From 7b2a7e513d7377dab259a8a1256271642df7d342 Mon Sep 17 00:00:00 2001 From: jrcjrcc <192965070+jrcjrcc@users.noreply.github.com> Date: Thu, 4 Jun 2026 05:24:20 +0800 Subject: [PATCH] fix: Windows sub-agent completion halves TUI render width Root cause: AgentComplete unconditionally calls resume_terminal() even when the terminal was never paused, causing a secondary EnterAlternateScreen on Windows that creates a new buffer whose width may differ from the window width. Additionally, ColorCompatBackend had no terminal_size cache, so size() fell through to crossterm::terminal::size() which on Windows returns the WinAPI buffer width rather than the window width. Changes: - AgentComplete: add event_broker.is_paused() guard - resume_terminal(): cache real terminal size before reset_viewport - Resize handler: also set terminal_size alongside forced_size - subagent_routing: 3x mark_history_updated -> bump_history_cell(idx) - color_compat: add terminal_size field, set_terminal_size(), fix size() fallback priority (forced_size > terminal_size) - tests: 3 unit tests for size() fallback chain Review feedback addressed: - forced_size now takes priority over terminal_size (gemini-code-assist) - Redundant map lookups removed in subagent_routing (both bots) - set_terminal_size moved before reset_terminal_viewport (greptile-apps) (cherry picked from commit 4463c46644a6e485e7e20dc2b19c29c2e8eb3c5c) --- crates/tui/src/tui/color_compat.rs | 50 ++++++++++++++++++++++++-- crates/tui/src/tui/subagent_routing.rs | 12 ++++--- crates/tui/src/tui/ui.rs | 16 +++++++-- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index cedaea0b..0a8107ec 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -43,6 +43,11 @@ pub(crate) struct ColorCompatBackend { /// Forcing the expected size prevents ratatui's internal `autoresize` from /// shrinking the viewport back to the stale dimension inside `draw()`. forced_size: Option, + /// Cached terminal size from `crossterm::terminal::size()`, set after + /// re-entering alt-screen to avoid stale buffer dimensions on Windows. + /// Used as the primary fallback in `size()` before falling through to + /// the live crossterm query. + terminal_size: Option, render_debug: Option, } @@ -59,6 +64,7 @@ impl ColorCompatBackend { // to a community preset. active_ui_theme: UiTheme::detect(), forced_size: None, + terminal_size: None, render_debug: RenderDebugLog::from_env(), } } @@ -71,6 +77,10 @@ impl ColorCompatBackend { self.forced_size = None; } + pub(crate) fn set_terminal_size(&mut self, size: Size) { + self.terminal_size = Some(size); + } + pub(crate) fn set_palette_mode(&mut self, palette_mode: PaletteMode) { self.palette_mode = palette_mode; } @@ -152,10 +162,14 @@ impl Backend for ColorCompatBackend { } fn size(&self) -> io::Result { - match self.forced_size { - Some(size) => Ok(size), - None => self.inner.size(), + // forced_size takes priority: it is set during resize events to prevent + // ratatui's autoresize from shrinking the viewport back to a stale + // dimension. terminal_size is the cached real terminal size used as a + // fallback after alt-screen re-entry (Windows buffer width workaround). + if let Some(size) = self.forced_size.or(self.terminal_size) { + return Ok(size); } + self.inner.size() } fn window_size(&mut self) -> io::Result { @@ -496,4 +510,34 @@ mod tests { assert!(body.contains("diff_cells=1"), "{body}"); assert!(body.contains("sample=3:4"), "{body}"); } + + #[test] + fn size_returns_terminal_size_when_set() { + let writer = SharedWriter::default(); + let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark); + + backend.set_terminal_size(Size::new(120, 40)); + assert_eq!(backend.size().unwrap(), Size::new(120, 40)); + } + + #[test] + fn forced_size_takes_priority_over_terminal_size() { + let writer = SharedWriter::default(); + let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark); + + // forced_size is set during resize events to temporarily override the + // cached terminal_size — it must win to prevent viewport shrinking. + backend.set_terminal_size(Size::new(120, 40)); + backend.force_size(Size::new(80, 25)); + assert_eq!(backend.size().unwrap(), Size::new(80, 25)); + } + + #[test] + fn size_falls_back_to_forced_size_when_terminal_size_unset() { + let writer = SharedWriter::default(); + let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark); + + backend.force_size(Size::new(80, 25)); + assert_eq!(backend.size().unwrap(), Size::new(80, 25)); + } } diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index afe48361..318203b2 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -113,7 +113,7 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox { apply_to_fanout(card, message); app.subagent_card_index.insert(agent_id, idx); - app.mark_history_updated(); + app.bump_history_cell(idx); return; } @@ -129,7 +129,9 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox _ => false, }; if updated { - app.mark_history_updated(); + // idx is already in scope from the outer + // `if let Some(&idx) = app.subagent_card_index.get(&agent_id)`. + app.bump_history_cell(idx); } return; } @@ -168,13 +170,13 @@ pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &Mailbox let card = DelegateCard::new(agent_id.clone(), agent_type.clone()); app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card))); let idx = app.history.len().saturating_sub(1); - app.subagent_card_index.insert(agent_id, idx); + app.subagent_card_index.insert(agent_id.clone(), idx); // Single delegate consumes the pending dispatch label so a follow-on // tool call doesn't accidentally inherit it. app.pending_subagent_dispatch = None; + // idx was just inserted on the line above — no need to re-query. + app.bump_history_cell(idx); } - - app.mark_history_updated(); } pub(super) fn task_mode_label(mode: AppMode) -> &'static str { diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b23f4fad..b22779e4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2161,7 +2161,7 @@ async fn run_event_loop( subagent_elapsed, ); } - if should_recapture_terminal { + if should_recapture_terminal && event_broker.is_paused() { resume_terminal( terminal, app.use_alt_screen, @@ -2694,7 +2694,9 @@ async fn run_event_loop( // this single draw so the buffer matches the real viewport. { let backend = terminal.backend_mut(); - backend.force_size(Size::new(final_w, final_h)); + let new_size = Size::new(final_w, final_h); + backend.force_size(new_size); + backend.set_terminal_size(new_size); } draw_app_frame_inner(terminal, app, true)?; draws_since_last_full_repaint = 0; @@ -7935,6 +7937,16 @@ fn resume_terminal( use_mouse_capture, use_bracketed_paste, ); + // Cache the real terminal size *before* resetting the viewport, so that + // reset_terminal_viewport → terminal.clear() → autoresize() → backend.size() + // picks up the cached size instead of falling through to + // crossterm::terminal::size() which may return stale buffer metadata + // (especially on Windows after a secondary EnterAlternateScreen). + if let Ok((cols, rows)) = crossterm::terminal::size() { + terminal + .backend_mut() + .set_terminal_size(Size::new(cols, rows)); + } reset_terminal_viewport(terminal, sync_output_enabled)?; Ok(()) }