From eb2c8bab1790a1065dbd5d0c1a39f90064440e9f Mon Sep 17 00:00:00 2001 From: Daniel Fadehan Date: Fri, 1 May 2026 23:00:19 +0100 Subject: [PATCH 1/2] Fix interactive terminal prompt scroll locking Keep non-alt-screen interactive long-running prompts visible by following the live output cursor instead of the block bottom. Clamp interactive fixed-scroll states and scrollbar range to that cursor boundary so scrolling back up/down does not expose blank backing-grid space. --- app/src/terminal/block_list_element.rs | 21 +- app/src/terminal/block_list_viewport.rs | 212 +++++++++++++-- app/src/terminal/view.rs | 48 +++- app/src/terminal/view_test.rs | 330 ++++++++++++++++++++++++ 4 files changed, 573 insertions(+), 38 deletions(-) diff --git a/app/src/terminal/block_list_element.rs b/app/src/terminal/block_list_element.rs index 373ea7b86..81b4f1a1c 100644 --- a/app/src/terminal/block_list_element.rs +++ b/app/src/terminal/block_list_element.rs @@ -429,7 +429,12 @@ impl SnackbarHeader { ) -> bool { // A user has scrolled iff they are fixed at a pixel position. Otherwise, they are fixed to // the bottom of a block. - let has_scrolled = matches!(scroll_position, ScrollPosition::FixedAtPosition { .. }); + let has_scrolled = matches!( + scroll_position, + ScrollPosition::FixedAtPosition { .. } + | ScrollPosition::FixedAtInteractivePosition { .. } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } + ); let block_output_taller_than_content_area = block.output_grid_displayed_height().into_lines() @@ -4725,15 +4730,13 @@ impl NewScrollableElement for BlockListElement { impl ScrollableElement for BlockListElement { fn scroll_data(&self, _app: &AppContext) -> Option { let line_height = self.line_height?; - let total_size = self - .model - .lock() - .block_list() - .block_heights() - .summary() - .height - .to_pixels(line_height); let mut visible_px = self.size?.y().into_pixels(); + let total_size = { + let model = self.model.lock(); + self.viewport_state_after_layout(model.block_list()) + .effective_scrollable_height_in_lines() + .to_pixels(line_height) + }; // If the number of visible_lines is within a rounding error of total // lines, just set them to be exactly equal so the scrollable element diff --git a/app/src/terminal/block_list_viewport.rs b/app/src/terminal/block_list_viewport.rs index dd87f58b1..39b374145 100644 --- a/app/src/terminal/block_list_viewport.rs +++ b/app/src/terminal/block_list_viewport.rs @@ -145,6 +145,11 @@ pub enum ScrollPosition { /// In terms of scroll_top, this implies scrolling stays locked to max_scroll_top. FollowsBottomOfMostRecentBlock, + /// The scrolling follows the active cursor row of an interactive long-running block. + /// This keeps the current input line visible with a small buffer beneath it until the + /// user manually scrolls away. + FollowsInteractiveCursor, + /// The scrolling follows the bottom of the most recently executed block, /// similar to FollowsBottomOfMostRecentBlock, but because there can be a gap /// below the most recent block, the scroll_top is not necessarily max_scroll_top. @@ -155,6 +160,10 @@ pub enum ScrollPosition { /// of the block list) FixedAtPosition { scroll_lines: ScrollLines }, + /// Like [`ScrollPosition::FixedAtPosition`], but the effective bottom remains + /// the live cursor row of an interactive prompt rather than the bottom of the backing grid. + FixedAtInteractivePosition { scroll_lines: ScrollLines }, + /// The scrolling follows an offset within a long-running block and /// adjusts for output grid truncation. /// @@ -170,6 +179,18 @@ pub enum ScrollPosition { /// at the time that the scroll position was set. num_output_lines_truncated: u64, }, + + /// Like [`ScrollPosition::FixedWithinLongRunningBlock`], but the effective bottom remains + /// the live cursor row of an interactive prompt rather than the bottom of the backing grid. + FixedWithinInteractiveLongRunningBlock { + /// The absolute scroll offset. + /// This is equivalent to [`ScrollPosition::FixedAtPosition::scroll_lines`]. + scroll_lines: ScrollLines, + + /// The number of lines truncated from the output grid + /// at the time that the scroll position was set. + num_output_lines_truncated: u64, + }, } /// Represents the location of a find match to be used for calculating scroll position. @@ -199,6 +220,7 @@ pub enum ScrollPositionUpdate { AfterKeydownOnTerminal, AfterTypedCharacters, AfterWriteUserBytesToPty, + AfterInteractiveLongRunningPtyInput, AfterScrollEvent { scroll_delta: Lines, }, @@ -655,9 +677,15 @@ impl<'a> ViewportState<'a> { ScrollPosition::FollowsBottomOfMostRecentBlock | ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. }, ) => self.max_scroll_top_in_lines(), + (InputMode::PinnedToBottom, ScrollPosition::FollowsInteractiveCursor) => { + self.interactive_cursor_scroll_top_in_lines() + } (InputMode::Waterfall, ScrollPosition::FollowsBottomOfMostRecentBlock) => { self.max_scroll_top_in_lines() } + (InputMode::Waterfall, ScrollPosition::FollowsInteractiveCursor) => { + self.interactive_cursor_scroll_top_in_lines() + } ( InputMode::Waterfall, ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { @@ -710,14 +738,23 @@ impl<'a> ViewportState<'a> { .map(|height| height - self.content_element_height_lines()) .unwrap_or(Lines::zero()) } - (_, ScrollPosition::FixedAtPosition { scroll_lines }) => { - scroll_lines.scroll_top(self.block_list, self.content_element_height_lines()) + (_, ScrollPosition::FollowsInteractiveCursor) => { + self.interactive_cursor_scroll_top_in_lines() } + ( + _, + ScrollPosition::FixedAtPosition { scroll_lines } + | ScrollPosition::FixedAtInteractivePosition { scroll_lines }, + ) => scroll_lines.scroll_top(self.block_list, self.content_element_height_lines()), ( _, ScrollPosition::FixedWithinLongRunningBlock { scroll_lines, num_output_lines_truncated, + } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { + scroll_lines, + num_output_lines_truncated, }, ) => { // Adjust the scroll-top by the number of lines @@ -740,7 +777,7 @@ impl<'a> ViewportState<'a> { } } .max(Lines::zero()) - .min(self.max_scroll_top_in_lines()) + .min(self.effective_max_scroll_top_in_lines()) } /// How far the view is scrolled from the top of all blocks in pixels @@ -774,7 +811,9 @@ impl<'a> ViewportState<'a> { && matches!( self.scroll_position, ScrollPosition::FixedAtPosition { .. } + | ScrollPosition::FixedAtInteractivePosition { .. } | ScrollPosition::FixedWithinLongRunningBlock { .. } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } ) { return self.scroll_position; @@ -789,21 +828,27 @@ impl<'a> ViewportState<'a> { // as executing a command. self.scroll_position_after_command_execution(app) } + ScrollPositionUpdate::AfterInteractiveLongRunningPtyInput => { + self.scroll_position_for_interactive_long_running_input() + } ScrollPositionUpdate::AfterScrollEvent { scroll_delta } => { self.scroll_position_for_delta(scroll_delta) } ScrollPositionUpdate::AfterResize => { - let max_scroll_top = self.max_scroll_top_in_lines(); + let max_scroll_top = self.effective_max_scroll_top_in_lines(); // When resizing, the number of rows might "shrink" as the wrapped-around lines // are rendered in one line. This changes the value of maximum scroll top and could // make the previous scroll position invalid. Thus we add an additional check here // to change the scroll position to stick to the bottom if previous scroll top is invalid. - if let ScrollPosition::FixedAtPosition { scroll_lines } = self.scroll_position { + if let ScrollPosition::FixedAtPosition { scroll_lines } + | ScrollPosition::FixedAtInteractivePosition { scroll_lines } = + self.scroll_position + { if scroll_lines.scroll_top(self.block_list, self.content_element_height_lines()) > max_scroll_top { - return ScrollPosition::FollowsBottomOfMostRecentBlock; + return self.bottom_follow_position(); } } self.scroll_position @@ -836,13 +881,13 @@ impl<'a> ViewportState<'a> { } } ScrollPositionUpdate::AfterPageDown => { - let total_block_heights = self.block_list.block_heights().summary().height; + let total_block_heights = self.effective_scrollable_height_in_lines(); 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 + self.bottom_follow_position() } else { let new_scroll_top = current_position + visible_rows - 1.0.into_lines(); ScrollPosition::FixedAtPosition { @@ -858,7 +903,7 @@ impl<'a> ViewportState<'a> { self.input_mode, InputMode::PinnedToBottom | InputMode::Waterfall ) { - ScrollPosition::FollowsBottomOfMostRecentBlock + self.bottom_follow_position() } else { ScrollPosition::FixedAtPosition { scroll_lines: self @@ -910,6 +955,7 @@ impl<'a> ViewportState<'a> { if matches!( self.scroll_position, ScrollPosition::FollowsBottomOfMostRecentBlock + | ScrollPosition::FollowsInteractiveCursor | ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } ) { return self.scroll_position; @@ -981,6 +1027,7 @@ impl<'a> ViewportState<'a> { if matches!( self.scroll_position, ScrollPosition::FollowsBottomOfMostRecentBlock + | ScrollPosition::FollowsInteractiveCursor | ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } ) { return self.scroll_position; @@ -1217,6 +1264,58 @@ impl<'a> ViewportState<'a> { } } + fn interactive_cursor_row_in_lines(&self) -> Option { + let active_block_index = self.block_list.active_block_index(); + let active_block = self.block_list.active_block(); + let cursor_display_point = active_block.output_grid().cursor_display_point()?; + let cursor_row = match cursor_display_point { + crate::terminal::model::blockgrid::CursorDisplayPoint::Visible(point) + | crate::terminal::model::blockgrid::CursorDisplayPoint::HiddenCache(point) => { + point.row + } + }; + + Some( + self.top_of_block_in_lines(active_block_index) + + active_block.output_grid_offset() + + (cursor_row as f32).into_lines(), + ) + } + + fn interactive_cursor_bottom_buffer_lines(&self) -> Lines { + if self.content_element_height_lines() > 1.0.into_lines() { + 1.0.into_lines() + } else { + Lines::zero() + } + } + + fn interactive_cursor_scroll_top_in_lines(&self) -> Lines { + let Some(cursor_row) = self.interactive_cursor_row_in_lines() else { + return self.max_scroll_top_in_lines(); + }; + + let visible_rows = self.content_element_height_lines(); + let bottom_buffer = self.interactive_cursor_bottom_buffer_lines(); + let preferred_scroll_top = cursor_row - (visible_rows - 1.0.into_lines() - bottom_buffer); + + preferred_scroll_top + .max(Lines::zero()) + .min(self.max_scroll_top_in_lines()) + } + + fn scroll_position_for_interactive_long_running_input(&self) -> ScrollPosition { + match self.scroll_position { + ScrollPosition::FollowsBottomOfMostRecentBlock + | ScrollPosition::FollowsInteractiveCursor => ScrollPosition::FollowsInteractiveCursor, + ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } + | ScrollPosition::FixedAtPosition { .. } + | ScrollPosition::FixedAtInteractivePosition { .. } + | ScrollPosition::FixedWithinLongRunningBlock { .. } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } => self.scroll_position, + } + } + // Returns whether the input is rendered exactly at the bottom of its pane. fn is_input_rendered_at_bottom_of_pane(&self, app: &AppContext) -> bool { match self.input_mode { @@ -1296,9 +1395,34 @@ impl<'a> ViewportState<'a> { } } + fn bottom_follow_position(&self) -> ScrollPosition { + if matches!( + self.scroll_position, + ScrollPosition::FollowsInteractiveCursor + | ScrollPosition::FixedAtInteractivePosition { .. } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } + ) { + ScrollPosition::FollowsInteractiveCursor + } else { + ScrollPosition::FollowsBottomOfMostRecentBlock + } + } + + fn has_interactive_cursor_boundary(&self) -> bool { + matches!( + self.input_mode, + InputMode::PinnedToBottom | InputMode::Waterfall + ) && matches!( + self.scroll_position, + ScrollPosition::FollowsInteractiveCursor + | ScrollPosition::FixedAtInteractivePosition { .. } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } + ) + } + /// Calculates the next scroll position for the given viewport state and scroll delta. fn scroll_position_for_delta(&self, delta: Lines) -> ScrollPosition { - let max_scroll_top = self.max_scroll_top_in_lines(); + let max_scroll_top = self.effective_max_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); @@ -1309,7 +1433,7 @@ impl<'a> ViewportState<'a> { ); if fix_to_bottom { - ScrollPosition::FollowsBottomOfMostRecentBlock + self.bottom_follow_position() } 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) { @@ -1317,18 +1441,30 @@ impl<'a> ViewportState<'a> { // the viewport. If there are other block items in the viewport, we don't want // truncation to affect the scroll position because the user might want to have // their scroll position fixed between different blocks. - ScrollPosition::FixedWithinLongRunningBlock { - scroll_lines: self.scroll_lines_from_scroll_top(new_top), - num_output_lines_truncated: self - .block_list - .active_block() - .output_grid() - .grid_handler() - .num_lines_truncated(), + let scroll_lines = self.scroll_lines_from_scroll_top(new_top); + let num_output_lines_truncated = self + .block_list + .active_block() + .output_grid() + .grid_handler() + .num_lines_truncated(); + if self.has_interactive_cursor_boundary() { + ScrollPosition::FixedWithinInteractiveLongRunningBlock { + scroll_lines, + num_output_lines_truncated, + } + } else { + ScrollPosition::FixedWithinLongRunningBlock { + scroll_lines, + num_output_lines_truncated, + } } } else { - ScrollPosition::FixedAtPosition { - scroll_lines: self.scroll_lines_from_scroll_top(new_top), + let scroll_lines = self.scroll_lines_from_scroll_top(new_top); + if self.has_interactive_cursor_boundary() { + ScrollPosition::FixedAtInteractivePosition { scroll_lines } + } else { + ScrollPosition::FixedAtPosition { scroll_lines } } } } @@ -1410,10 +1546,42 @@ impl<'a> ViewportState<'a> { } pub fn max_scroll_top_px(&self) -> Pixels { - self.max_scroll_top_in_lines() + self.effective_max_scroll_top_in_lines() .to_pixels(self.size_info.cell_height_px) } + pub fn effective_scrollable_height_in_lines(&self) -> Lines { + self.effective_max_scroll_top_in_lines() + self.content_element_height_lines() + } + + fn effective_max_scroll_top_in_lines(&self) -> Lines { + match (self.input_mode, self.scroll_position) { + ( + InputMode::PinnedToBottom | InputMode::Waterfall, + ScrollPosition::FollowsInteractiveCursor + | ScrollPosition::FixedAtInteractivePosition { .. } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. }, + ) => self.interactive_cursor_scroll_top_in_lines(), + ( + InputMode::PinnedToTop, + ScrollPosition::FollowsInteractiveCursor + | ScrollPosition::FollowsBottomOfMostRecentBlock + | ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } + | ScrollPosition::FixedAtPosition { .. } + | ScrollPosition::FixedAtInteractivePosition { .. } + | ScrollPosition::FixedWithinLongRunningBlock { .. } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. }, + ) + | ( + InputMode::PinnedToBottom | InputMode::Waterfall, + ScrollPosition::FollowsBottomOfMostRecentBlock + | ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } + | ScrollPosition::FixedAtPosition { .. } + | ScrollPosition::FixedWithinLongRunningBlock { .. }, + ) => self.max_scroll_top_in_lines(), + } + } + /// Returns the max possible value in lines for scroll_top (how far from the top of the /// blocklist it's possible to scroll down) pub fn max_scroll_top_in_lines(&self) -> Lines { diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 48bc67ca0..487fd16bd 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -7640,6 +7640,18 @@ impl TerminalView { self.write_user_bytes_to_pty(bytes, ctx); } + fn scroll_update_for_pty_input(model: &TerminalModel) -> ScrollPositionUpdate { + let active_block = model.block_list().active_block(); + if !model.is_alt_screen_active() + && active_block.is_active_and_long_running() + && active_block.is_visible(model.block_list().agent_view_state()) + { + ScrollPositionUpdate::AfterInteractiveLongRunningPtyInput + } else { + ScrollPositionUpdate::AfterWriteUserBytesToPty + } + } + /// Ends the current line before writing 1000 byte chunks to the pty with a small delay in /// between to work around a macos pty bug. fn clear_line_editor_and_write_to_pty_with_mac_workaround_hack>>( @@ -7691,7 +7703,7 @@ impl TerminalView { data: B, ctx: &mut ViewContext, ) { - { + let scroll_update = { let mut terminal_model = self.model.lock(); let active_block = terminal_model.block_list().active_block(); if active_block.is_agent_in_control() { @@ -7704,12 +7716,13 @@ impl TerminalView { .active_block_mut() .mark_received_user_input(); } - } + Self::scroll_update_for_pty_input(&terminal_model) + }; let bytes = data.into(); let bytes_vec = bytes.to_vec(); self.clear_selected_blocks(ctx); - self.update_scroll_position_locking(ScrollPositionUpdate::AfterWriteUserBytesToPty, ctx); + self.update_scroll_position_locking(scroll_update, ctx); self.write_to_pty(bytes, ctx); self.emit_non_editor_typed_event(bytes_vec, ctx); } @@ -7730,11 +7743,12 @@ impl TerminalView { // the bootstrap script, otherwise the user could accidentally interfere // with bootstrap script execution. if was_bootstrap_script_echoed && self.is_long_running() { + let scroll_update = { + let terminal_model = self.model.lock(); + Self::scroll_update_for_pty_input(&terminal_model) + }; self.clear_selected_blocks(ctx); - self.update_scroll_position_locking( - ScrollPositionUpdate::AfterWriteUserBytesToPty, - ctx, - ); + self.update_scroll_position_locking(scroll_update, ctx); self.write_to_pty(characters, ctx); } } @@ -7958,6 +7972,26 @@ impl TerminalView { model.block_list_mut().update_background_block_height(); model.block_list_mut().update_active_block_height(); } + + let should_follow_interactive_cursor = { + let model = self.model.lock(); + !model.is_alt_screen_active() + && model + .block_list() + .active_block() + .is_active_and_long_running() + && model + .block_list() + .active_block() + .is_visible(model.block_list().agent_view_state()) + }; + if should_follow_interactive_cursor { + self.update_scroll_position_locking( + ScrollPositionUpdate::AfterInteractiveLongRunningPtyInput, + ctx, + ); + } + self.maybe_emit_terminal_view_state_changed_for_long_running_block(ctx); self.use_agent_footer.update(ctx, |footer, ctx| { footer.notify_and_notify_children(ctx); diff --git a/app/src/terminal/view_test.rs b/app/src/terminal/view_test.rs index 73047e3d6..4444a2e35 100644 --- a/app/src/terminal/view_test.rs +++ b/app/src/terminal/view_test.rs @@ -51,6 +51,7 @@ use crate::view_components::find::FindWithinBlockState; use crate::terminal::model::ansi::{self, InitShellValue}; use crate::terminal::model::ansi::{BootstrappedValue, PreexecValue}; +use crate::terminal::model::blockgrid::CursorDisplayPoint; use crate::terminal::model::blocks::{insert_block, TotalIndex}; use crate::terminal::model::terminal_model::WithinBlock; @@ -649,6 +650,54 @@ impl TerminalView { viewport.scroll_top_in_lines() } + fn active_output_cursor_row_in_lines( + &self, + model: &TerminalModel, + input_mode: InputMode, + app: &AppContext, + ) -> Option { + let active_block = model.block_list().active_block(); + let cursor_display_point = active_block.output_grid().cursor_display_point()?; + let cursor_row = match cursor_display_point { + CursorDisplayPoint::Visible(point) | CursorDisplayPoint::HiddenCache(point) => { + point.row + } + }; + let viewport = self.viewport_state(model.block_list(), input_mode, app); + + Some( + viewport.top_of_block_in_lines(model.block_list().active_block_index()) + + active_block.output_grid_offset() + + (cursor_row as f32).into_lines(), + ) + } + + fn active_output_cursor_has_bottom_buffer( + &self, + model: &TerminalModel, + input_mode: InputMode, + app: &AppContext, + ) -> bool { + let Some(cursor_row) = self.active_output_cursor_row_in_lines(model, input_mode, app) + else { + return false; + }; + + let scroll_top = self.scroll_top_in_lines(model, input_mode, app); + let visible_rows = self.content_element_height_lines(app); + cursor_row >= scroll_top && cursor_row < scroll_top + visible_rows - 1.0.into_lines() + } + + fn effective_scrollable_height_in_lines( + &self, + model: &TerminalModel, + input_mode: InputMode, + app: &AppContext, + ) -> Lines { + self.viewport_state(model.block_list(), input_mode, app) + .effective_scrollable_height_in_lines() + } + fn is_vertically_scrollable(&self, app: &AppContext) -> bool { let total_block_heights = self .model @@ -1557,6 +1606,287 @@ fn test_stable_scrolling_during_grid_truncation() { }) } +fn run_interactive_long_running_scroll_follow_test(input_mode: InputMode) { + 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| { + InputModeSettings::handle(ctx).update(ctx, |input_mode_settings, ctx| { + let _ = input_mode_settings.input_mode.set_value(input_mode, ctx); + }); + + { + let mut model = view.model.lock(); + model.simulate_block("ls", "foo"); + model.simulate_long_running_block("aws configure sso", ""); + for _ in 0..100 { + model.process_bytes("\n"); + } + model.process_bytes("\x1b[1;1HSSO session name"); + } + + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsBottomOfMostRecentBlock + ); + + view.write_user_bytes_to_pty(vec![b'a'], ctx); + + let model = view.model.lock(); + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsInteractiveCursor + ); + assert!(view.active_output_cursor_has_bottom_buffer(&model, input_mode, ctx)); + }); + }); +} + +#[test] +fn test_interactive_long_running_input_keeps_cursor_visible_pinned_to_bottom() { + run_interactive_long_running_scroll_follow_test(InputMode::PinnedToBottom); +} + +#[test] +fn test_interactive_long_running_input_keeps_cursor_visible_pinned_to_top() { + run_interactive_long_running_scroll_follow_test(InputMode::PinnedToTop); +} + +#[test] +fn test_long_running_prompt_output_switches_to_interactive_follow_before_typing() { + App::test((), |mut app| async move { + const INPUT_MODE: InputMode = InputMode::PinnedToBottom; + + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + terminal.update(&mut app, |view, ctx| { + { + let mut model = view.model.lock(); + model.simulate_block("ls", "foo"); + model.simulate_long_running_block("aws configure sso", ""); + for _ in 0..100 { + model.process_bytes("\n"); + } + model.process_bytes("\x1b[1;1HSSO session name"); + } + + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsBottomOfMostRecentBlock + ); + + view.handle_terminal_wakeup((), ctx); + + let model = view.model.lock(); + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsInteractiveCursor + ); + assert!(view.active_output_cursor_has_bottom_buffer(&model, INPUT_MODE, ctx)); + }); + }) +} + +#[test] +fn test_interactive_long_running_prompt_does_not_expose_blank_scroll_area() { + App::test((), |mut app| async move { + const INPUT_MODE: InputMode = InputMode::PinnedToBottom; + + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + terminal.update(&mut app, |view, ctx| { + { + let mut model = view.model.lock(); + model.simulate_block("ls", "foo"); + model.simulate_long_running_block("aws configure sso", ""); + for _ in 0..100 { + model.process_bytes("\n"); + } + model.process_bytes("\x1b[1;1HSSO session name"); + } + + view.handle_terminal_wakeup((), ctx); + + let scroll_top_before_scroll_down = { + let model = view.model.lock(); + let full_blocklist_height = model.block_list().block_heights().summary().height; + let effective_scrollable_height = + view.effective_scrollable_height_in_lines(&model, INPUT_MODE, ctx); + + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsInteractiveCursor + ); + assert!(effective_scrollable_height < full_blocklist_height); + view.scroll_top_in_lines(&model, INPUT_MODE, ctx) + }; + + view.scroll(Lines::zero() - 100.0.into_lines(), ctx); + + { + let model = view.model.lock(); + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsInteractiveCursor + ); + assert_eq!( + view.scroll_top_in_lines(&model, INPUT_MODE, ctx), + scroll_top_before_scroll_down + ); + + let viewport = view.viewport_state(model.block_list(), INPUT_MODE, ctx); + assert_eq!( + viewport.next_scroll_position(ScrollPositionUpdate::AfterPageDown, ctx), + ScrollPosition::FollowsInteractiveCursor + ); + assert_eq!( + viewport.next_scroll_position(ScrollPositionUpdate::AfterEnd, ctx), + ScrollPosition::FollowsInteractiveCursor + ); + } + + view.scroll(1.0.into_lines(), ctx); + assert!(matches!( + view.scroll_position(), + ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } + )); + + { + let model = view.model.lock(); + let full_blocklist_height = model.block_list().block_heights().summary().height; + let effective_scrollable_height = + view.effective_scrollable_height_in_lines(&model, INPUT_MODE, ctx); + assert!(effective_scrollable_height < full_blocklist_height); + } + + view.scroll(Lines::zero() - 100.0.into_lines(), ctx); + + let model = view.model.lock(); + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsInteractiveCursor + ); + assert_eq!( + view.scroll_top_in_lines(&model, INPUT_MODE, ctx), + scroll_top_before_scroll_down + ); + }); + }) +} + +#[test] +fn test_interactive_long_running_prompt_keeps_boundary_after_small_scroll_up() { + App::test((), |mut app| async move { + const INPUT_MODE: InputMode = InputMode::PinnedToBottom; + + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + terminal.update(&mut app, |view, ctx| { + { + let mut model = view.model.lock(); + for _ in 0..100 { + model.simulate_block("history", "previous output"); + } + model.simulate_long_running_block("aws configure sso", ""); + for _ in 0..100 { + model.process_bytes("\n"); + } + model.process_bytes("\x1b[1;1HSSO session name"); + } + + view.handle_terminal_wakeup((), ctx); + + let scroll_top_at_prompt = { + let model = view.model.lock(); + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsInteractiveCursor + ); + view.scroll_top_in_lines(&model, INPUT_MODE, ctx) + }; + + view.scroll(1.0.into_lines(), ctx); + assert!(matches!( + view.scroll_position(), + ScrollPosition::FixedAtInteractivePosition { .. } + | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } + )); + + { + let model = view.model.lock(); + let full_blocklist_height = model.block_list().block_heights().summary().height; + let effective_scrollable_height = + view.effective_scrollable_height_in_lines(&model, INPUT_MODE, ctx); + assert!(effective_scrollable_height < full_blocklist_height); + } + + view.scroll(Lines::zero() - 100.0.into_lines(), ctx); + + let model = view.model.lock(); + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsInteractiveCursor + ); + assert_eq!( + view.scroll_top_in_lines(&model, INPUT_MODE, ctx), + scroll_top_at_prompt + ); + }); + }) +} + +#[test] +fn test_manual_scroll_away_is_respected_during_interactive_long_running_input() { + App::test((), |mut app| async move { + const INPUT_MODE: InputMode = InputMode::PinnedToBottom; + + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + terminal.update(&mut app, |view, ctx| { + { + let mut model = view.model.lock(); + model.simulate_block("ls", "foo"); + model.simulate_long_running_block("aws configure sso", ""); + for _ in 0..100 { + model.process_bytes("\n"); + } + } + + view.write_user_bytes_to_pty(vec![b'a'], ctx); + assert_eq!( + view.scroll_position(), + ScrollPosition::FollowsInteractiveCursor + ); + + view.scroll(1.0.into_lines(), ctx); + let scroll_position_after_manual_scroll = view.scroll_position(); + assert!(matches!( + scroll_position_after_manual_scroll, + ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } + )); + + let scroll_top_after_manual_scroll = { + let model = view.model.lock(); + view.scroll_top_in_lines(&model, INPUT_MODE, ctx) + }; + + view.write_to_pty_for_syncing_long_running_commands(vec![b'b'], ctx); + + let model = view.model.lock(); + assert_eq!(view.scroll_position(), scroll_position_after_manual_scroll); + assert_eq!( + view.scroll_top_in_lines(&model, INPUT_MODE, ctx), + scroll_top_after_manual_scroll + ); + }); + }) +} + #[test] fn test_clear_buffer() { App::test((), |mut app| async move { From 3b0f814a75ab7677290f6738293240d4a860e878 Mon Sep 17 00:00:00 2001 From: Daniel Fadehan Date: Sat, 2 May 2026 08:40:24 +0100 Subject: [PATCH 2/2] chore(fix): address clamp issue and update test --- app/src/terminal/block_list_viewport.rs | 24 +++++++++++++++++++++--- app/src/terminal/view_test.rs | 1 + 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/app/src/terminal/block_list_viewport.rs b/app/src/terminal/block_list_viewport.rs index 39b374145..82c80ec90 100644 --- a/app/src/terminal/block_list_viewport.rs +++ b/app/src/terminal/block_list_viewport.rs @@ -1304,12 +1304,30 @@ impl<'a> ViewportState<'a> { .min(self.max_scroll_top_in_lines()) } + fn interactive_cursor_needs_scroll_follow(&self) -> bool { + heights_approx_lt( + self.interactive_cursor_scroll_top_in_lines(), + self.max_scroll_top_in_lines(), + ) + } + fn scroll_position_for_interactive_long_running_input(&self) -> ScrollPosition { match self.scroll_position { ScrollPosition::FollowsBottomOfMostRecentBlock - | ScrollPosition::FollowsInteractiveCursor => ScrollPosition::FollowsInteractiveCursor, - ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } - | ScrollPosition::FixedAtPosition { .. } + | ScrollPosition::FollowsInteractiveCursor + | ScrollPosition::WaterfallGapFollowsBottomOfMostRecentBlock { .. } => { + if self.interactive_cursor_needs_scroll_follow() + || matches!( + self.scroll_position, + ScrollPosition::FollowsInteractiveCursor + ) + { + ScrollPosition::FollowsInteractiveCursor + } else { + self.scroll_position + } + } + ScrollPosition::FixedAtPosition { .. } | ScrollPosition::FixedAtInteractivePosition { .. } | ScrollPosition::FixedWithinLongRunningBlock { .. } | ScrollPosition::FixedWithinInteractiveLongRunningBlock { .. } => self.scroll_position, diff --git a/app/src/terminal/view_test.rs b/app/src/terminal/view_test.rs index 4444a2e35..ddcfad441 100644 --- a/app/src/terminal/view_test.rs +++ b/app/src/terminal/view_test.rs @@ -1855,6 +1855,7 @@ fn test_manual_scroll_away_is_respected_during_interactive_long_running_input() for _ in 0..100 { model.process_bytes("\n"); } + model.process_bytes("\x1b[1;1HSSO session name"); } view.write_user_bytes_to_pty(vec![b'a'], ctx);