Skip to content
58 changes: 44 additions & 14 deletions app/src/terminal/alt_screen/alt_screen_element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@ impl AltScreenElement {
}));
} else {
ctx.dispatch_typed_action(TerminalAction::MaybeClearAltSelect);
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
let visible_point = Point::new(point.row.saturating_sub(self.grid_history_offset()), point.col);
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(visible_point)));
}
true
}
Expand All @@ -307,7 +308,8 @@ impl AltScreenElement {
position: local_position,
});
} else {
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
let visible_point = Point::new(point.row.saturating_sub(self.grid_history_offset()), point.col);
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(visible_point)));
}
true
}
Expand Down Expand Up @@ -359,7 +361,8 @@ impl AltScreenElement {
// see Linear issue at https://linear.app/warpdotdev/issue/CORE-1039/combine-the-mousebutton-and-mouseaction-enums-to-avoid-impossible.
let mouse_state =
MouseState::new(MouseButton::Move, MouseAction::Pressed, Default::default());
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
let visible_point = Point::new(point.row.saturating_sub(self.grid_history_offset()), point.col);
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(visible_point)));
}

// Allow the event to continue propagating.
Expand Down Expand Up @@ -401,7 +404,8 @@ impl AltScreenElement {
}

if !should_intercept_mouse(&self.model.lock(), mouse_state.modifiers().shift, app) {
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
let visible_point = Point::new(point.row.saturating_sub(self.grid_history_offset()), point.col);
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(visible_point)));
}

true
Expand Down Expand Up @@ -436,7 +440,8 @@ impl AltScreenElement {
is_mouse_dragged = true;
}
if !should_intercept_mouse(&self.model.lock(), mouse_state.modifiers().shift, app) {
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(point)));
let visible_point = Point::new(point.row.saturating_sub(self.grid_history_offset()), point.col);
ctx.dispatch_typed_action(TerminalAction::AltMouseAction(mouse_state.set_point(visible_point)));
}
is_mouse_dragged
}
Expand Down Expand Up @@ -478,14 +483,15 @@ impl AltScreenElement {
ctx.dispatch_typed_action(TerminalAction::AltScroll { delta });
} else {
let point = self.coord_to_point(local_position);
let visible_point = Point::new(point.row.saturating_sub(self.grid_history_offset()), point.col);

ctx.dispatch_typed_action(TerminalAction::AltMouseAction(
MouseState::new(
MouseButton::Wheel,
MouseAction::Scrolled { delta },
Default::default(),
)
.set_point(point),
.set_point(visible_point),
));
}
true
Expand All @@ -499,20 +505,34 @@ impl AltScreenElement {
)
}

/// Returns the grid's scrollback size (history rows above the visible viewport).
fn grid_history_offset(&self) -> usize {
self.model.lock().alt_screen().grid_handler().history_size()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] GridHandler::history_size() still reports flat-storage rows, but alt-screen scrollback is now stored in GridStorage, so this returns 0 and the render/hit-test offsets never account for preserved history. Use grid_storage().history_size() here or update the GridHandler dimension implementation for alt-screen grids.

}

/// Converts a pixel coordinate to a point in the `AltScreen` coordinate space.
fn coord_to_point(&self, coord: Vector2F) -> Point {
let model = self.model.lock();
let grid = model.alt_screen().grid_handler();
let total_height = grid.total_rows();
let size = self.grid_render_params.size_info;

let column = ((coord.x() - size.padding_x_px.as_f32()) / size.cell_width_px().as_f32())
.max(0.)
.min(grid.columns() as f32 - 1.) as usize;

let row = (coord.y() / size.cell_height_px().as_f32())
.max(0.)
.min(total_height as f32 - 1.) as usize;
let history_offset = grid.history_size();
let visible_height = self
.visible_lines
.expect("should be set after layout")
.as_f64() as usize;
// coord.y already includes scroll_top via to_local(), so the clamp
// upper bound must account for it.
let max_row = (self.scroll_top.as_f64() as usize) + visible_height.saturating_sub(1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] scroll_top can be fractional for precise shared-session scrolling, but casting it to usize truncates the visible upper bound; the bottom partially visible row can map to the row above. Compute the clamp using the fractional scroll offset, such as ceil(scroll_top + visible_height) - 1, before converting to an integer.

let row = (history_offset
+ (coord.y() / size.cell_height_px().as_f32())
.max(0.)
.min(max_row as f32) as usize)
.min(grid.total_rows().saturating_sub(1));
Point::new(row, column)
}

Expand Down Expand Up @@ -699,6 +719,8 @@ impl Element for AltScreenElement {

let grid = model.alt_screen().grid_handler();

let history_offset = grid.history_size();

let cell_size = Vector2F::new(
self.grid_render_params.size_info.cell_width_px().as_f32(),
self.grid_render_params.size_info.cell_height_px().as_f32(),
Expand All @@ -724,16 +746,24 @@ impl Element for AltScreenElement {
let mut sampler = model.alt_screen().bg_color_sampler.lock();
sampler.reset();

// Render grid cells. Since the alt screen has no scrollback we can always start at index 0.
// Render grid cells. The alt screen preserves scrollback for apps that
// rely on cursor-up overshoot and full-buffer redraws (e.g. Claude Code).
// Start rendering from the history offset so visible content anchors to
// the bottom of the buffer rather than the top.
record_trace_event!("alt_screen_element:paint:preparing_to_render_grid");
let start_row = self.scroll_top.as_f64();
let start_row = history_offset as f64 + self.scroll_top.as_f64();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [CRITICAL] render_grid still reads rows through GridHandler::row, whose total_rows() ignores GridStorage history for alt-screen, so adding history_offset here makes the renderer request out-of-bounds rows once scrollback exists; the bottom row is skipped immediately and the alt-screen renders blank once history_offset >= visible_rows.

let end_row = (start_row
+ self
.visible_lines
.expect("should be set after layout")
.as_f64())
.min(grid.visible_rows() as f64);
let adjusted_grid_origin = origin - self.vertical_scroll_pixels();
.min(grid.total_rows() as f64);
// Offset the paint origin upward by the scrollback height so that
// render_grid's absolute offset_row positioning maps cells to the
// correct screen positions (the first visible row lands at origin.y).
let adjusted_grid_origin = origin
- self.vertical_scroll_pixels()
- vec2f(0., history_offset as f32 * cell_size.y());
let cursor_visible = model.alt_screen().is_mode_set(TermMode::SHOW_CURSOR);
grid_renderer::render_grid(
grid,
Expand Down
25 changes: 16 additions & 9 deletions app/src/terminal/model/grid/grid_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,12 +368,10 @@ impl GridHandler {
obfuscate_secrets: ObfuscateSecrets,
perform_reset_grid_checks: PerformResetGridChecks,
) -> Self {
// We set the maximum scrollback for grid storage to zero, as the
// scrollback is stored in flat storage _instead_. `GridHandler`
// is responsible for moving lines from grid storage to flat storage
// when they are about to be scrolled up out of the active region of
// the grid.
let grid_max_scroll_limit = 0;
// Non-alt grids store scrollback in flat storage. Alt-screen grids do
// not push rows into flat storage, so they keep their bounded
// scrollback in GridStorage itself.
let grid_max_scroll_limit = if is_alt_screen { max_scroll_limit } else { 0 };

let grid = GridStorage::new(
size_info.rows(),
Expand All @@ -392,7 +390,11 @@ impl GridHandler {

GridHandler {
grid,
flat_storage: FlatStorage::new(size_info.columns(), Some(max_scroll_limit), None),
flat_storage: FlatStorage::new(
size_info.columns(),
Some(if is_alt_screen { 0 } else { max_scroll_limit }),
None,
),
finished: false,
ansi_handler_state,
displayed_output: None,
Expand Down Expand Up @@ -1632,6 +1634,11 @@ impl GridHandler {
/// grid's maximum scrollback limit.
pub fn num_lines_truncated(&self) -> u64 {
self.flat_storage.num_truncated_rows()
+ if self.ansi_handler_state.is_alt_screen {
self.grid.num_lines_truncated
} else {
0
}
}

/// Finishes the grid.
Expand Down Expand Up @@ -2649,7 +2656,7 @@ impl Iterator for RegexIter<'_> {
impl Dimensions for GridHandler {
#[inline]
fn total_rows(&self) -> usize {
self.visible_rows() + self.history_size()
self.flat_storage.total_rows() + self.grid.total_rows()
}

#[inline]
Expand All @@ -2659,7 +2666,7 @@ impl Dimensions for GridHandler {

#[inline]
fn history_size(&self) -> usize {
self.flat_storage.total_rows()
self.flat_storage.total_rows() + self.grid.history_size()
}

#[inline]
Expand Down
34 changes: 34 additions & 0 deletions app/src/terminal/model/grid/grid_handler_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,40 @@ fn test_empty_grid_bounds_to_string() {
);
}

#[test]
fn test_alt_screen_scrollback_is_stored_in_grid_storage() {
let size = SizeInfo::new_without_font_metrics(3, 1);
let mut grid_handler = GridHandler::new(
size,
2,
ChannelEventListener::new_for_test(),
true,
ObfuscateSecrets::No,
PerformResetGridChecks::No,
);

grid_handler.input('a');
grid_handler.linefeed();
grid_handler.carriage_return();
grid_handler.input('b');
grid_handler.linefeed();
grid_handler.carriage_return();
grid_handler.input('c');
grid_handler.linefeed();
grid_handler.carriage_return();
grid_handler.input('d');

assert_eq!(grid_handler.flat_storage.total_rows(), 0);
assert_eq!(grid_handler.grid_storage().history_size(), 1);
assert_eq!(grid_handler.history_size(), 1);
assert_eq!(grid_handler.total_rows(), 4);
assert_eq!(grid_handler.cursor_point(), Point::new(3, 0));
assert_eq!(grid_handler.row(0).expect("row should exist")[0].c, 'a');
assert_eq!(grid_handler.row(1).expect("row should exist")[0].c, 'b');
assert_eq!(grid_handler.row(2).expect("row should exist")[0].c, 'c');
assert_eq!(grid_handler.row(3).expect("row should exist")[0].c, 'd');
}

#[test]
fn test_semantic_search() {
let blockgrid =
Expand Down
4 changes: 3 additions & 1 deletion app/src/terminal/model/terminal_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1107,7 +1107,9 @@ impl TerminalModel {
) -> Self {
let alt_screen = AltScreen::new(
sizes.size,
0, /* max_scroll_limit */
1000, /* max_scroll_limit — preserve alt-screen scrollback to prevent content loss
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] This only grows GridStorage history, but GridHandler still reports history via flat_storage (which alt-screen keeps empty), so history_offset stays 0 and row(0..visible) reads hidden GridStorage history instead of the visible rows once scrolling occurs. Expose GridStorage history through GridHandler for alt-screen, or render/index via GridStorage absolute rows before enabling this.

* during heavy streaming from apps like Claude Code that rely on cursor-up
* overshoot and full-buffer redraws (see warpdotdev/Warp#7200, #8089) */
event_proxy.clone(),
obfuscate_secrets,
);
Expand Down