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
+45 -37
View File
@@ -136,6 +136,9 @@ impl ScrollLines {
#[derive(Copy, Clone, Debug, PartialEq)]
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.
/// In terms of scroll_top, this implies scrolling stays locked to max_scroll_top.
FollowsBottomOfMostRecentBlock,
@@ -645,6 +648,7 @@ impl<'a> ViewportState<'a> {
/// How far the view is scrolled from the top of all blocks in lines.
pub fn scroll_top_in_lines(&self) -> Lines {
match (self.input_mode, self.scroll_position) {
(_, ScrollPosition::FollowsTopOfMostRecentBlock) => Lines::zero(),
(
InputMode::PinnedToBottom,
ScrollPosition::FollowsBottomOfMostRecentBlock
@@ -775,7 +779,17 @@ impl<'a> ViewportState<'a> {
return self.scroll_position;
}
self.scroll_position_after_command_execution(app)
// 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)
}
}
}
ScrollPositionUpdate::AfterKeydownOnTerminal
| ScrollPositionUpdate::AfterTypedCharacters
@@ -821,34 +835,19 @@ impl<'a> ViewportState<'a> {
self.scroll_position
}
}
ScrollPositionUpdate::AfterPageUp => {
let new_scroll_top = (self.scroll_top_in_lines()
- self.content_element_height_lines()
+ 1.0.into_lines())
.max(Lines::zero());
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_scroll_top),
}
}
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
ScrollPositionUpdate::AfterPageUp => self
.scroll_position_for_delta(self.content_element_height_lines() - 1.0.into_lines()),
ScrollPositionUpdate::AfterPageDown => self
.scroll_position_for_delta(1.0.into_lines() - self.content_element_height_lines()),
ScrollPositionUpdate::AfterHome => self.scroll_position_for_offset(Lines::zero()),
ScrollPositionUpdate::AfterEnterAgentView => {
if self.input_mode.is_inverted_blocklist() {
ScrollPosition::FollowsTopOfMostRecentBlock
} else {
let new_scroll_top = current_position + visible_rows - 1.0.into_lines();
ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(new_scroll_top),
}
ScrollPosition::FollowsBottomOfMostRecentBlock
}
}
ScrollPositionUpdate::AfterHome => ScrollPosition::FixedAtPosition {
scroll_lines: self.scroll_lines_from_scroll_top(Lines::zero()),
},
ScrollPositionUpdate::AfterEnd | ScrollPositionUpdate::AfterEnterAgentView => {
ScrollPositionUpdate::AfterEnd => {
if matches!(
self.input_mode,
InputMode::PinnedToBottom | InputMode::Waterfall
@@ -904,7 +903,8 @@ impl<'a> ViewportState<'a> {
) -> ScrollPosition {
if matches!(
self.scroll_position,
ScrollPosition::FollowsBottomOfMostRecentBlock
ScrollPosition::FollowsTopOfMostRecentBlock
| ScrollPosition::FollowsBottomOfMostRecentBlock
| ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }
) {
return self.scroll_position;
@@ -975,7 +975,8 @@ impl<'a> ViewportState<'a> {
) -> ScrollPosition {
if matches!(
self.scroll_position,
ScrollPosition::FollowsBottomOfMostRecentBlock
ScrollPosition::FollowsTopOfMostRecentBlock
| ScrollPosition::FollowsBottomOfMostRecentBlock
| ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }
) {
return self.scroll_position;
@@ -1203,9 +1204,8 @@ impl<'a> ViewportState<'a> {
fn scroll_position_after_clear(&self) -> ScrollPosition {
match self.input_mode {
InputMode::PinnedToTop | InputMode::PinnedToBottom => {
ScrollPosition::FollowsBottomOfMostRecentBlock
}
InputMode::PinnedToTop => ScrollPosition::FollowsTopOfMostRecentBlock,
InputMode::PinnedToBottom => ScrollPosition::FollowsBottomOfMostRecentBlock,
InputMode::Waterfall => ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock {
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
fn scroll_position_after_command_execution(&self, app: &AppContext) -> ScrollPosition {
match (self.input_mode, self.block_list.active_gap()) {
(InputMode::PinnedToTop, _) => ScrollPosition::FollowsTopOfMostRecentBlock,
(InputMode::Waterfall, Some(gap)) => {
// 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:
@@ -1297,13 +1298,20 @@ impl<'a> ViewportState<'a> {
let current_top = self.scroll_top_in_lines();
let new_top = (current_top - delta).max(Lines::zero()).min(max_scroll_top);
let fix_to_bottom = new_top >= max_scroll_top
&& matches!(
self.input_mode,
InputMode::PinnedToBottom | InputMode::Waterfall
);
// Horizontal wheel gestures and no-op events must not change follow state.
if delta == Lines::zero() {
return self.scroll_position;
}
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
} 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)