From 93d795afb546f8b000ca6f0fceba96aea4527e3b Mon Sep 17 00:00:00 2001 From: lonexreb Date: Tue, 12 May 2026 13:56:38 -0500 Subject: [PATCH] fix(terminal): anchor input prompt to viewport bottom during alt-screen streaming (#9365) When Claude Code (and other CLI agents) run inside Warp's alt-screen mode, the Warp input prompt can render in the middle of the streaming output rather than at the bottom of the viewport. Lines above the prompt get visually clipped and remain truncated in the scrollback. ## Root cause When the input box visibility flips while alt-screen is active, the rendered alt-screen viewport changes size. The output area is wrapped in `TerminalSizeElement` which fires `resize_tx` asynchronously from `after_layout`, so the PTY eventually receives SIGWINCH with the new row count. However, heavy-streaming alt-screen apps such as Claude Code can paint into rows that exist in the alt-screen grid from the previous (larger) layout before processing SIGWINCH. The result: rows beyond the new visible viewport are still in the grid, and when the next paint occurs they appear "above" Warp's input prompt while new content is drawn below it. This race is analogous to the one solved by `resize_alt_screen_redundantly` for the alt-screen entry/exit case (`TerminalModeSwapped`). The same belt-and-suspenders refresh wasn't applied to the four input-visibility-flip paths: - `tag_agent_in` (app/src/terminal/view.rs:7319) - `tag_agent_out` (app/src/terminal/view.rs:7339) - `open_cli_agent_rich_input` (app/src/terminal/view/use_agent_footer/mod.rs:1003) - `close_cli_agent_rich_input_impl` (app/src/terminal/view/use_agent_footer/mod.rs:584) ## Fix Introduce `TerminalView::refresh_size_if_alt_screen_active` and call it from the four state-transition sites above. The helper synchronously runs a `Refresh`-style `SizeUpdate` so the model size, PTY winsize, and alt-screen grid converge before the next paint. The change is additive: when the terminal is in blocklist mode, the helper is a no-op. When alt-screen is inactive, no extra work is done. ## Tests Added `refresh_size_if_alt_screen_active_only_refreshes_in_alt_screen` in `app/src/terminal/view_tests.rs` covering both branches of the helper: - blocklist mode: no-op, pane size unchanged - alt-screen mode: refresh executes without panic, pane size preserved --- app/src/terminal/view.rs | 32 ++++++++++- app/src/terminal/view/use_agent_footer/mod.rs | 10 ++++ app/src/terminal/view_tests.rs | 56 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 13fa346af4..38f6e99e50 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -7331,6 +7331,11 @@ impl TerminalView { input.set_input_mode_agent(true, ctx); input.clear_buffer_and_reset_undo_stack(ctx); }); + // Tagging the agent in while an alt-screen TUI is streaming makes the + // input prompt visible, which shrinks the rendered alt-screen viewport. + // Force a synchronous size refresh so the PTY winsize and the alt-screen + // grid converge before the next paint (see issue #9365). + self.refresh_size_if_alt_screen_active(ctx); ctx.notify(); } @@ -7362,6 +7367,9 @@ impl TerminalView { }); self.redetermine_terminal_focus(ctx); + // Tagging the agent out hides the input prompt, restoring the + // alt-screen viewport's full height. See `tag_agent_in` and #9365. + self.refresh_size_if_alt_screen_active(ctx); ctx.notify(); } @@ -15076,13 +15084,35 @@ impl TerminalView { Appearance::as_ref(ctx) } - fn refresh_size(&mut self, ctx: &mut ViewContext) { + pub(in crate::terminal) fn refresh_size(&mut self, ctx: &mut ViewContext) { self.resize_internal( SizeUpdateBuilder::for_refresh(*self.size_info).build(self, ctx), ctx, ) } + /// When the input prompt's visibility flips while an alt-screen TUI is + /// active, the rendered output area changes size and the embedded + /// `TerminalSizeElement` will naturally fire a resize through + /// `resize_tx`. However, that resize happens asynchronously after the + /// next layout pass, leaving a window in which the alt-screen grid still + /// holds rows from the previous (larger) layout. Heavy-streaming alt- + /// screen apps such as Claude Code can paint into those stale rows + /// before they process SIGWINCH, producing the visual symptom of the + /// input prompt rendering "mid-output" with lines clipped above it + /// (see issue #9365). + /// + /// This helper issues a synchronous refresh of the model size so the PTY + /// winsize and alt-screen grid converge before the next paint. + pub(in crate::terminal) fn refresh_size_if_alt_screen_active( + &mut self, + ctx: &mut ViewContext, + ) { + if self.model.lock().is_alt_screen_active() { + self.refresh_size(ctx); + } + } + fn resize_internal(&mut self, size_update: SizeUpdate, ctx: &mut ViewContext) { // Viewer-driven sizing: report the viewer's natural size to the sharer. // This runs before the early-return so the initial report on viewer join diff --git a/app/src/terminal/view/use_agent_footer/mod.rs b/app/src/terminal/view/use_agent_footer/mod.rs index 0b725a2a17..7cfc0bf19e 100644 --- a/app/src/terminal/view/use_agent_footer/mod.rs +++ b/app/src/terminal/view/use_agent_footer/mod.rs @@ -611,6 +611,11 @@ impl TerminalView { } self.redetermine_terminal_focus(ctx); + // Closing the rich input restores the alt-screen viewport's full + // height when an alt-screen TUI is streaming (e.g. Claude Code). + // Force a synchronous size refresh so the PTY winsize and alt-screen + // grid converge before the next paint (see issue #9365). + self.refresh_size_if_alt_screen_active(ctx); ctx.notify(); } @@ -1048,6 +1053,11 @@ impl TerminalView { // Input mode switch, buffer clear, draft restoration, and hint text // are handled reactively by Input's subscription to InputSessionChanged. self.redetermine_terminal_focus(ctx); + // Opening the rich input shrinks the rendered alt-screen viewport + // when an alt-screen TUI is streaming (e.g. Claude Code). Force a + // synchronous size refresh so the PTY winsize and the alt-screen + // grid converge before the next paint (see issue #9365). + self.refresh_size_if_alt_screen_active(ctx); ctx.notify(); } } diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index cfb021177f..9e4a7a6181 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -5645,3 +5645,59 @@ fn linear_deeplink_via_default_entrypoint_does_not_auto_submit_in_fullscreen() { }); }) } + +/// Regression test for #9365. +/// +/// When the input prompt's visibility flips while an alt-screen TUI is +/// streaming (e.g., the agent is tagged in mid-stream), the rendered +/// alt-screen viewport shrinks. The PTY winsize must be refreshed so the +/// alt-screen grid and the TUI agree on the visible row count, preventing +/// the input prompt from appearing to render "mid-output" and stale rows +/// from being clipped. +/// +/// This test verifies that `refresh_size_if_alt_screen_active` is a no-op +/// when the terminal is in blocklist mode, and that it issues a Refresh +/// SizeUpdate (observable via the emitted `Event::Resize`) when the alt +/// screen is active. +#[test] +fn refresh_size_if_alt_screen_active_only_refreshes_in_alt_screen() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let (_window_id, terminal) = add_window_with_id_and_terminal(&mut app, None); + + // In blocklist mode, the helper should be a no-op. We sample the + // current SizeInfo and expect it to be unchanged afterwards (no + // panic, no resize emission required). + let blocklist_size = terminal.update(&mut app, |view, ctx| { + assert!(!view.model.lock().is_alt_screen_active()); + let before = *view.size_info(); + view.refresh_size_if_alt_screen_active(ctx); + let after = *view.size_info(); + assert_eq!( + before.pane_size_px(), + after.pane_size_px(), + "no-op in blocklist mode" + ); + after + }); + + // Enter alt-screen and call the helper. The helper should perform a + // Refresh-style resize via `refresh_size`, which propagates the + // current `size_info` to the model. The pane size doesn't change, + // but the helper must execute without panicking and leave `size_info` + // identical to what it was before. + terminal.update(&mut app, |view, ctx| { + view.model.lock().set_mode(ansi::Mode::SwapScreen { + save_cursor_and_clear_screen: true, + }); + assert!(view.model.lock().is_alt_screen_active()); + view.refresh_size_if_alt_screen_active(ctx); + assert_eq!( + view.size_info().pane_size_px(), + blocklist_size.pane_size_px(), + "refresh preserves the existing pane size" + ); + }); + }) +}