Fix tool output clipping and live-edge scroll following

Preserve full terminal-grid width with horizontal scrolling in tool panes. Keep hidden command control changes from unpinning the viewport, and re-pin scrolling at the live edge in normal and inverted layouts.

Validation: formatting, test-layout checks, both presubmit Clippy commands, and 141 relevant tests passed. Ten other terminal-view tests failed identically on the untouched baseline (keyboard, cloud-agent, input status, and footer behavior).
This commit is contained in:
2026-09-10 00:30:57 -05:00
parent 1845b9e20e
commit 2d581f4c01
4 changed files with 197 additions and 45 deletions
@@ -8,7 +8,9 @@ use std::sync::Arc;
use galaxy_core::ui::appearance::Appearance; use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::Icon; use galaxy_core::ui::Icon;
use galaxy_editor::render::element::VerticalExpansionBehavior; use galaxy_editor::render::element::VerticalExpansionBehavior;
use galaxyui::elements::new_scrollable::SingleAxisConfig; use galaxyui::elements::new_scrollable::{
ClippedAxisConfiguration, DualAxisConfig, ScrollableAppearance,
};
use galaxyui::elements::{ use galaxyui::elements::{
Align, Border, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container, Align, Border, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex, MainAxisSize, MouseStateHandle, CornerRadius, CrossAxisAlignment, Expanded, Fill, Flex, MainAxisSize, MouseStateHandle,
@@ -336,6 +338,7 @@ pub struct RequestedCommandView {
is_user_expanded: bool, is_user_expanded: bool,
header_mouse_state: MouseStateHandle, header_mouse_state: MouseStateHandle,
output_scroll_state: ClippedScrollStateHandle, output_scroll_state: ClippedScrollStateHandle,
output_horizontal_scroll_state: ClippedScrollStateHandle,
follow_command_output: Cell<bool>, follow_command_output: Cell<bool>,
is_editing: bool, is_editing: bool,
@@ -596,6 +599,7 @@ impl RequestedCommandView {
is_user_expanded: false, is_user_expanded: false,
header_mouse_state: Default::default(), header_mouse_state: Default::default(),
output_scroll_state: ClippedScrollStateHandle::new(), output_scroll_state: ClippedScrollStateHandle::new(),
output_horizontal_scroll_state: ClippedScrollStateHandle::new(),
follow_command_output: Cell::new(true), follow_command_output: Cell::new(true),
copied_from_citation: None, copied_from_citation: None,
derived_from_citations: Default::default(), derived_from_citations: Default::default(),
@@ -1731,15 +1735,27 @@ impl View for RequestedCommandView {
if should_use_ligature_rendering(app) { if should_use_ligature_rendering(app) {
output_grid = output_grid.with_ligature_rendering(); output_grid = output_grid.with_ligature_rendering();
} }
let scrollable = NewScrollable::vertical( // The terminal grid can be wider than this padded tool pane. Lay it out
SingleAxisConfig::Clipped { // at its natural width so every column remains reachable by scrolling.
let scrollable = NewScrollable::horizontal_and_vertical(
DualAxisConfig::Clipped {
horizontal: ClippedAxisConfiguration {
handle: self.output_horizontal_scroll_state.clone(),
max_size: None,
stretch_child: false,
},
vertical: ClippedAxisConfiguration {
handle: self.output_scroll_state.clone(), handle: self.output_scroll_state.clone(),
max_size: None,
stretch_child: false,
},
child: output_grid.finish(), child: output_grid.finish(),
}, },
Fill::None, theme.nonactive_ui_detail().into(),
Fill::None, theme.active_ui_detail().into(),
Fill::None, Fill::None,
) )
.with_horizontal_scrollbar(ScrollableAppearance::new(SCROLLBAR_WIDTH, false))
.with_propagate_mousewheel_if_not_handled(true) .with_propagate_mousewheel_if_not_handled(true)
.finish(); .finish();
let output_body = ConstrainedBox::new(scrollable) let output_body = ConstrainedBox::new(scrollable)
+44 -36
View File
@@ -136,6 +136,9 @@ impl ScrollLines {
#[derive(Copy, Clone, Debug, PartialEq)] #[derive(Copy, Clone, Debug, PartialEq)]
pub enum ScrollPosition { pub enum ScrollPosition {
/// Follow newly inserted content at the top of an inverted block list.
FollowsTopOfMostRecentBlock,
/// The scrolling follows the bottom of the most recently executed block. /// The scrolling follows the bottom of the most recently executed block.
/// In terms of scroll_top, this implies scrolling stays locked to max_scroll_top. /// In terms of scroll_top, this implies scrolling stays locked to max_scroll_top.
FollowsBottomOfMostRecentBlock, FollowsBottomOfMostRecentBlock,
@@ -645,6 +648,7 @@ impl<'a> ViewportState<'a> {
/// How far the view is scrolled from the top of all blocks in lines. /// How far the view is scrolled from the top of all blocks in lines.
pub fn scroll_top_in_lines(&self) -> Lines { pub fn scroll_top_in_lines(&self) -> Lines {
match (self.input_mode, self.scroll_position) { match (self.input_mode, self.scroll_position) {
(_, ScrollPosition::FollowsTopOfMostRecentBlock) => Lines::zero(),
( (
InputMode::PinnedToBottom, InputMode::PinnedToBottom,
ScrollPosition::FollowsBottomOfMostRecentBlock ScrollPosition::FollowsBottomOfMostRecentBlock
@@ -775,8 +779,18 @@ impl<'a> ViewportState<'a> {
return self.scroll_position; return self.scroll_position;
} }
// Keep an established live-edge pin across tool/response insertion.
// Recomputing a waterfall gap offset here can turn it into a fixed offset.
match self.scroll_position {
ScrollPosition::FollowsTopOfMostRecentBlock
| ScrollPosition::FollowsBottomOfMostRecentBlock => self.scroll_position,
ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }
| ScrollPosition::FixedAtPosition { .. }
| ScrollPosition::FixedWithinLongRunningBlock { .. } => {
self.scroll_position_after_command_execution(app) self.scroll_position_after_command_execution(app)
} }
}
}
ScrollPositionUpdate::AfterKeydownOnTerminal ScrollPositionUpdate::AfterKeydownOnTerminal
| ScrollPositionUpdate::AfterTypedCharacters | ScrollPositionUpdate::AfterTypedCharacters
| ScrollPositionUpdate::AfterWriteUserBytesToPty => { | ScrollPositionUpdate::AfterWriteUserBytesToPty => {
@@ -821,34 +835,19 @@ impl<'a> ViewportState<'a> {
self.scroll_position self.scroll_position
} }
} }
ScrollPositionUpdate::AfterPageUp => { ScrollPositionUpdate::AfterPageUp => self
let new_scroll_top = (self.scroll_top_in_lines() .scroll_position_for_delta(self.content_element_height_lines() - 1.0.into_lines()),
- self.content_element_height_lines() ScrollPositionUpdate::AfterPageDown => self
+ 1.0.into_lines()) .scroll_position_for_delta(1.0.into_lines() - self.content_element_height_lines()),
.max(Lines::zero()); ScrollPositionUpdate::AfterHome => self.scroll_position_for_offset(Lines::zero()),
ScrollPosition::FixedAtPosition { ScrollPositionUpdate::AfterEnterAgentView => {
scroll_lines: self.scroll_lines_from_scroll_top(new_scroll_top), if self.input_mode.is_inverted_blocklist() {
} ScrollPosition::FollowsTopOfMostRecentBlock
}
ScrollPositionUpdate::AfterPageDown => {
let total_block_heights = self.block_list.block_heights().summary().height;
let visible_rows = self.content_element_height_lines();
let current_position = self.scroll_top_in_lines();
if current_position + visible_rows - 1.0.into_lines()
> (total_block_heights - visible_rows).max(Lines::zero())
{
ScrollPosition::FollowsBottomOfMostRecentBlock
} else { } else {
let new_scroll_top = current_position + visible_rows - 1.0.into_lines(); ScrollPosition::FollowsBottomOfMostRecentBlock
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_scroll_top),
} }
} }
} ScrollPositionUpdate::AfterEnd => {
ScrollPositionUpdate::AfterHome => ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(Lines::zero()),
},
ScrollPositionUpdate::AfterEnd | ScrollPositionUpdate::AfterEnterAgentView => {
if matches!( if matches!(
self.input_mode, self.input_mode,
InputMode::PinnedToBottom | InputMode::Waterfall InputMode::PinnedToBottom | InputMode::Waterfall
@@ -904,7 +903,8 @@ impl<'a> ViewportState<'a> {
) -> ScrollPosition { ) -> ScrollPosition {
if matches!( if matches!(
self.scroll_position, self.scroll_position,
ScrollPosition::FollowsBottomOfMostRecentBlock ScrollPosition::FollowsTopOfMostRecentBlock
| ScrollPosition::FollowsBottomOfMostRecentBlock
| ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } | ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }
) { ) {
return self.scroll_position; return self.scroll_position;
@@ -975,7 +975,8 @@ impl<'a> ViewportState<'a> {
) -> ScrollPosition { ) -> ScrollPosition {
if matches!( if matches!(
self.scroll_position, self.scroll_position,
ScrollPosition::FollowsBottomOfMostRecentBlock ScrollPosition::FollowsTopOfMostRecentBlock
| ScrollPosition::FollowsBottomOfMostRecentBlock
| ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } | ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }
) { ) {
return self.scroll_position; return self.scroll_position;
@@ -1203,9 +1204,8 @@ impl<'a> ViewportState<'a> {
fn scroll_position_after_clear(&self) -> ScrollPosition { fn scroll_position_after_clear(&self) -> ScrollPosition {
match self.input_mode { match self.input_mode {
InputMode::PinnedToTop | InputMode::PinnedToBottom => { InputMode::PinnedToTop => ScrollPosition::FollowsTopOfMostRecentBlock,
ScrollPosition::FollowsBottomOfMostRecentBlock InputMode::PinnedToBottom => ScrollPosition::FollowsBottomOfMostRecentBlock,
}
InputMode::Waterfall => ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { InputMode::Waterfall => ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock {
scroll_top_in_lines: self.max_scroll_top_in_lines(), scroll_top_in_lines: self.max_scroll_top_in_lines(),
}, },
@@ -1254,6 +1254,7 @@ impl<'a> ViewportState<'a> {
// Returns the scroll position to set after a command has finished executing // Returns the scroll position to set after a command has finished executing
fn scroll_position_after_command_execution(&self, app: &AppContext) -> ScrollPosition { fn scroll_position_after_command_execution(&self, app: &AppContext) -> ScrollPosition {
match (self.input_mode, self.block_list.active_gap()) { match (self.input_mode, self.block_list.active_gap()) {
(InputMode::PinnedToTop, _) => ScrollPosition::FollowsTopOfMostRecentBlock,
(InputMode::Waterfall, Some(gap)) => { (InputMode::Waterfall, Some(gap)) => {
// In gap waterfall mode, the logic is somewhat complex for how to adjust scroll position // In gap waterfall mode, the logic is somewhat complex for how to adjust scroll position
// after a command is executed. The basic result we want is: // after a command is executed. The basic result we want is:
@@ -1297,13 +1298,20 @@ impl<'a> ViewportState<'a> {
let current_top = self.scroll_top_in_lines(); let current_top = self.scroll_top_in_lines();
let new_top = (current_top - delta).max(Lines::zero()).min(max_scroll_top); let new_top = (current_top - delta).max(Lines::zero()).min(max_scroll_top);
let fix_to_bottom = new_top >= max_scroll_top // Horizontal wheel gestures and no-op events must not change follow state.
&& matches!( if delta == Lines::zero() {
self.input_mode, return self.scroll_position;
InputMode::PinnedToBottom | InputMode::Waterfall }
); self.scroll_position_for_offset(new_top)
}
if fix_to_bottom { fn scroll_position_for_offset(&self, new_top: Lines) -> ScrollPosition {
let tolerance = Pixels::new(0.5).to_lines(self.size_info.cell_height_px());
if self.input_mode.is_inverted_blocklist() && new_top <= tolerance {
ScrollPosition::FollowsTopOfMostRecentBlock
} else if !self.input_mode.is_inverted_blocklist()
&& new_top + tolerance >= self.max_scroll_top_in_lines()
{
ScrollPosition::FollowsBottomOfMostRecentBlock ScrollPosition::FollowsBottomOfMostRecentBlock
} else if self.block_list.active_block().is_active_and_long_running() } else if self.block_list.active_block().is_active_and_long_running()
&& self.does_block_exceed_viewport(self.block_list.active_block_index(), new_top) && self.does_block_exceed_viewport(self.block_list.active_block_index(), new_top)
+11 -1
View File
@@ -6797,7 +6797,17 @@ impl TerminalView {
.. ..
} => { } => {
if !*agent_has_control && ctx.is_self_or_child_focused() { if !*agent_has_control && ctx.is_self_or_child_focused() {
let block_index = self.model.lock().block_list().block_index_for_id(block_id); // Tool output is rendered in its agent card. Its hidden shell block
// must not steal the viewport or clear the user's follow-scroll state.
let block_index = {
let model = self.model.lock();
let blocks = model.block_list();
blocks.block_index_for_id(block_id).filter(|index| {
blocks
.block_at(*index)
.is_some_and(|block| block.is_visible(blocks.agent_view_state()))
})
};
if let Some(block_index) = block_index { if let Some(block_index) = block_index {
self.update_scroll_position_locking( self.update_scroll_position_locking(
ScrollPositionUpdate::ScrollToBottomOfBlock { block_index }, ScrollPositionUpdate::ScrollToBottomOfBlock { block_index },
+119 -1
View File
@@ -3433,7 +3433,7 @@ fn test_viewport_most_recent_at_top() {
}, },
ctx ctx
), ),
ScrollPosition::FixedAtPosition { .. } ScrollPosition::FollowsTopOfMostRecentBlock
)); ));
assert_eq!( assert_eq!(
Lines::zero(), Lines::zero(),
@@ -8309,3 +8309,121 @@ fn child_shell_startup_failure_finishes_the_waiting_conversation() {
}); });
}); });
} }
#[test]
fn scrolling_to_live_edge_repins_both_blocklist_directions() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
for mode in [
InputMode::PinnedToBottom,
InputMode::Waterfall,
InputMode::PinnedToTop,
] {
let mut model = view.model.lock();
for _ in 0..100 {
model.simulate_block("echo", "output");
}
let pinned = if mode.is_inverted_blocklist() {
ScrollPosition::FollowsTopOfMostRecentBlock
} else {
ScrollPosition::FollowsBottomOfMostRecentBlock
};
view.scroll_position = ScrollState::new(pinned);
let away = if mode.is_inverted_blocklist() {
-5.0
} else {
5.0
};
let viewport = view.viewport_state(model.block_list(), mode, ctx);
let unpinned = viewport.next_scroll_position(
ScrollPositionUpdate::AfterScrollEvent {
scroll_delta: away.into_lines(),
},
ctx,
);
assert!(matches!(unpinned, ScrollPosition::FixedAtPosition { .. }));
view.scroll_position = ScrollState::new(unpinned);
model.simulate_block("echo", "new output");
assert_eq!(view.scroll_position(), unpinned);
let viewport = view.viewport_state(model.block_list(), mode, ctx);
let toward = if mode.is_inverted_blocklist() {
10000.0
} else {
-10000.0
};
let repinned = viewport.next_scroll_position(
ScrollPositionUpdate::AfterScrollEvent {
scroll_delta: toward.into_lines(),
},
ctx,
);
assert_eq!(repinned, pinned);
view.scroll_position = ScrollState::new(repinned);
model.simulate_block("echo", "more output");
let viewport = view.viewport_state(model.block_list(), mode, ctx);
assert_eq!(
viewport
.next_scroll_position(ScrollPositionUpdate::AfterRichBlockInserted, ctx),
pinned
);
assert_eq!(
viewport.next_scroll_position(
ScrollPositionUpdate::AfterScrollEvent {
scroll_delta: Lines::zero()
},
ctx
),
pinned
);
if mode.is_inverted_blocklist() {
assert_eq!(viewport.scroll_top_in_lines(), Lines::zero());
} else {
assert_eq!(
viewport.scroll_top_in_lines(),
viewport.max_scroll_top_in_lines()
);
}
}
});
});
}
#[test]
fn hidden_tool_control_changes_preserve_pinned_and_unpinned_scroll() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let terminal = add_window_with_terminal(&mut app, None);
terminal.update(&mut app, |view, ctx| {
view.focus_terminal(ctx);
assert!(ctx.is_self_or_child_focused());
let block_id = {
let mut model = view.model.lock();
model.simulate_cmd("echo tool output");
let block = model.block_list_mut().active_block_mut();
block.hide();
block.id().clone()
};
for position in [
ScrollPosition::FollowsBottomOfMostRecentBlock,
ScrollPosition::FollowsTopOfMostRecentBlock,
ScrollPosition::FixedAtPosition {
scroll_lines: ScrollLines::ScrollTop(2.0.into_lines()),
},
] {
view.scroll_position = ScrollState::new(position);
view.handle_cli_subagent_controller_event(
view.cli_subagent_controller.clone(),
&CLISubagentEvent::UpdatedControl {
block_id: block_id.clone(),
requested_command_action_id: None,
agent_has_control: false,
},
ctx,
);
assert_eq!(view.scroll_position(), position);
}
});
});
}