Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions crates/infinity-agent-cli/src/inline_viewport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,19 +439,38 @@ impl<T: TermOut> InlineViewport<T> {
Ok(())
}

/// Re-derive the anchor after a resize, with a single cursor query.
/// Re-derive the anchor after a resize, with a cursor query.
///
/// See the type-level docs for the strategy. Ends with the live cursor
/// parked on the (re-saved) anchor.
///
/// The cursor query is a full terminal round-trip, and during a
/// continuous window drag (e.g. resizing a pane in Zed) the terminal
/// keeps reflowing while the query is in flight. A reply describing a
/// geometry that is already gone must not be acted upon: re-saving the
/// anchor and clearing below it at stale coordinates destroys scrollback
/// rows that have since moved (a growing reflow pulls content down, so a
/// stale anchor sits *above* the true one and the clear eats the tail of
/// the output). The size is therefore re-checked after every round-trip
/// and the query retried until the geometry is stable.
fn re_anchor(&mut self) -> io::Result<()> {
let (cols, rows) = self.terminal_size;
if cols == 0 || rows == 0 {
self.anchor_stale = false;
return Ok(());
}
let (live_col, live_row) = loop {
let (cols, rows) = self.terminal_size;
if cols == 0 || rows == 0 {
self.anchor_stale = false;
return Ok(());
}

self.term.flush()?;
let (live_col, live_row) = self.term.cursor_position()?;
self.term.flush()?;
let pos = self.term.cursor_position()?;
if self.term.size()? == self.terminal_size {
break pos;
}
// The terminal resized again while the query was in flight; the
// reply is stale. Absorb the new geometry and re-query.
self.handle_resize()?;
};
let (cols, rows) = self.terminal_size;
let pre_resize_row_lens = self.pre_resize_row_lens.take();

// Where the live cursor would be if the terminal moved nothing
Expand Down Expand Up @@ -606,6 +625,14 @@ impl<T: TermOut> InlineViewport<T> {
where
F: FnOnce(&mut ViewportFrame),
{
// A resize since the last draw invalidated the anchor; re-derive it
// (the only cursor query in the entire viewport) first. This may
// absorb further coalesced/raced resizes, so it must run before any
// geometry below is computed from `terminal_size`.
if self.anchor_stale {
self.re_anchor()?;
}

// Keep one row above the viewport free for the anchor, even on tiny
// terminals.
let desired_lines = desired_lines
Expand All @@ -630,12 +657,6 @@ impl<T: TermOut> InlineViewport<T> {
cursor_position = frame.cursor_position;
}

// A resize since the last draw invalidated the anchor; re-derive it
// (the only cursor query in the entire viewport) before positioning.
if self.anchor_stale {
self.re_anchor()?;
}

let previous = &self.buffers[1 - self.current];
let current = &self.buffers[self.current];

Expand Down
14 changes: 13 additions & 1 deletion crates/infinity-agent-cli/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,20 @@ impl TuiHarness {
let term = VirtualTerm {
emu: Arc::clone(&emu),
};

let (event_tx, event_rx) = mpsc::unbounded_channel();
Self::spawn_with_term(term, emu, event_tx, event_rx, opts).await
}

/// Spawn the TUI against a caller-provided [`TermOut`] (e.g. one that
/// interposes on cursor queries to model races), with the emulator and
/// event channel also provided by the caller.
pub async fn spawn_with_term<T: TermOut + Send + 'static>(
term: T,
emu: SharedEmulator,
event_tx: mpsc::UnboundedSender<Event>,
event_rx: mpsc::UnboundedReceiver<Event>,
opts: HarnessOptions,
) -> Self {
let events = ScriptedEvents {
rx: event_rx,
pending: None,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
source: crates/infinity-agent-cli/tests/tui_resize_race.rs
expression: after
---
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~
~ Infinity Agent CLI
~ Type your messages below. /help for commands. Ctrl+C to exit.
~
~ > tell me things
~ assistant line 01
~ assistant line 02
~ assistant line 03
~ assistant line 04
~ assistant line 05
~ assistant line 06
~ assistant line 07
┌────────────────────────────────────────────────────────────────────────────────┐
│assistant line 08 │
│assistant line 09 │
│assistant line 10 │
│assistant line 11 │
│assistant line 12 │
│assistant line 13 │
│assistant line 14 │
│assistant line 15 │
│assistant line 16 │
│assistant line 17 │
│assistant line 18 │
│assistant line 19 │
│assistant line 20 │
│assistant line 21 │
│assistant line 22 │
│assistant line 23 │
│assistant line 24 │
│assistant line 25 │
│assistant line 26 │
│assistant line 27 │
│assistant line 28 │
│assistant line 29 │
│assistant line 30 │
│ │
│ │
│────────────────────────────────────────────────────────────────────────────────│
│ │
│ │
│ │
│/help for commands mock: mock-model · 0% context│
└────────────────────────────────────────────────────────────────────────────────┘
cursor: row=27 col=1
141 changes: 141 additions & 0 deletions crates/infinity-agent-cli/tests/tui_resize_race.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//! Regression test for the resize race that eats scrollback output.
//!
//! When the terminal window is resized *continuously* (e.g. dragging a pane
//! divider in Zed, whose terminal reflows on every drag frame), the terminal
//! keeps reflowing while the viewport's re-anchor cursor query (`CSI 6n`) is
//! in flight. The reply then describes a geometry that is already gone: a
//! reflowing terminal pulls more scrollback rows onto the screen as it grows,
//! moving all content (and the true anchor) further down. Re-saving the
//! anchor at the stale coordinates and clearing from it downwards erases the
//! rows between the stale and the true anchor — the tail of the output.
//!
//! The test models the race by interposing on `cursor_position()`: right
//! after the emulator answers the query (i.e. the moment the reply left the
//! terminal), the emulator is resized again, so all subsequent bytes land on
//! the newer grid — exactly what happens to a real TUI mid-drag.

mod common;

use common::{
AlacrittyEmulator, Emulator, HarnessOptions, SharedEmulator, TuiHarness, VirtualTerm,
};
use infinity_agent_cli::term_io::TermOut;
use infinity_agent_core::batch_processor::DisplayEvent;
use ratatui::crossterm::event::Event;
use rig_mock::MockStreamingResponse;
use std::io::{self, Write};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;

type Evt = DisplayEvent<MockStreamingResponse>;

/// A [`TermOut`] that resizes the emulator (and queues the matching Resize
/// event) immediately after answering a cursor-position query, modeling a
/// window drag that continues while the query round-trip is in flight.
struct MidQueryResizeTerm {
inner: VirtualTerm,
emu: SharedEmulator,
event_tx: mpsc::UnboundedSender<Event>,
resize_after_query: Arc<Mutex<Option<(u16, u16)>>>,
}

impl Write for MidQueryResizeTerm {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.inner.write(buf)
}

fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}

impl TermOut for MidQueryResizeTerm {
fn size(&mut self) -> io::Result<(u16, u16)> {
self.inner.size()
}

fn cursor_position(&mut self) -> io::Result<(u16, u16)> {
let pos = self.inner.cursor_position()?;
let pending = self
.resize_after_query
.lock()
.expect("bug: resize_after_query lock poisoned")
.take();
if let Some((cols, rows)) = pending {
self.emu
.lock()
.expect("bug: emulator lock poisoned")
.resize(cols, rows);
self.event_tx
.send(Event::Resize(cols, rows))
.expect("bug: UI task dropped event channel");
}
Ok(pos)
}

fn enable_raw_mode(&mut self) -> io::Result<()> {
Ok(())
}

fn disable_raw_mode(&mut self) -> io::Result<()> {
Ok(())
}
}

/// Stable TUI with deep scrollback; a vertical grow whose cursor query races
/// a further grow must not eat any of the finished assistant output.
#[tokio::test(start_paused = true)]
async fn drag_grow_races_cursor_query() {
let emu: SharedEmulator = Arc::new(Mutex::new(
Box::new(AlacrittyEmulator::new(80, 20)) as Box<dyn Emulator>
));
let (event_tx, event_rx) = mpsc::unbounded_channel();
let resize_after_query = Arc::new(Mutex::new(None));
let term = MidQueryResizeTerm {
inner: VirtualTerm::new(Arc::clone(&emu)),
emu: Arc::clone(&emu),
event_tx: event_tx.clone(),
resize_after_query: Arc::clone(&resize_after_query),
};
let h = TuiHarness::spawn_with_term(
term,
Arc::clone(&emu),
event_tx,
event_rx,
HarnessOptions {
backend: common::Backend::Alacritty,
cols: 80,
rows: 20,
..HarnessOptions::default()
},
)
.await;

// A finished multi-line response, deep enough to fill scrollback.
h.display(Evt::UserInput("tell me things".to_owned()));
h.display(Evt::StartOutput);
let text: String = (1..=30)
.map(|i| format!("assistant line {i:02}"))
.collect::<Vec<_>>()
.join("\n");
h.display(Evt::TextChunk { chunk: text });
h.display(Evt::ResponseDone(None));
h.settle().await;

// Grow to 24 rows; while the TUI's re-anchor cursor query is in flight,
// the drag continues to 30 rows (pulling 6 more scrollback rows down).
*resize_after_query
.lock()
.expect("bug: resize_after_query lock poisoned") = Some((80, 30));
h.resize(80, 24);
h.settle().await;

let after = h.screen_with_scrollback();
for i in 1..=30 {
assert!(
after.contains(&format!("assistant line {i:02}")),
"assistant line {i:02} was eaten by the raced resize\n{after}"
);
}
insta::assert_snapshot!("drag_grow_raced_query", after);
}
Loading