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)
This commit is contained in:
jrcjrcc
2026-06-04 05:24:20 +08:00
committed by Hunter B
parent 27db89c25d
commit 7b2a7e513d
3 changed files with 68 additions and 10 deletions
+47 -3
View File
@@ -43,6 +43,11 @@ pub(crate) struct ColorCompatBackend<W: Write> {
/// Forcing the expected size prevents ratatui's internal `autoresize` from
/// shrinking the viewport back to the stale dimension inside `draw()`.
forced_size: Option<Size>,
/// 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<Size>,
render_debug: Option<RenderDebugLog>,
}
@@ -59,6 +64,7 @@ impl<W: Write> ColorCompatBackend<W> {
// 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<W: Write> ColorCompatBackend<W> {
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<W: Write> Backend for ColorCompatBackend<W> {
}
fn size(&self) -> io::Result<Size> {
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<WindowSize> {
@@ -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));
}
}
+7 -5
View File
@@ -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 {
+14 -2
View File
@@ -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(())
}