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" + ); + }); + }) +}