chat: double-buffered sync with content-length diffing

Store content lengths of rendered entries. On each tick:
- Generation changed → full pane reset
- Entries removed → pop from tail
- Last entry content length changed → pop and re-render (streaming)
- New entries → route and push

PaneState gains pop_line() for removing the last rendered entry.
This handles streaming (last entry growing), compaction (generation
bump), and normal appends.

Co-Authored-By: Kent Overstreet <kent.overstreet@linux.dev>
This commit is contained in:
Kent Overstreet 2026-04-05 19:35:19 -04:00
parent 563771e979
commit ca9f2b2b9a
2 changed files with 57 additions and 29 deletions

View file

@ -78,9 +78,10 @@ pub(crate) struct InteractScreen {
pub(crate) turn_started: Option<std::time::Instant>, pub(crate) turn_started: Option<std::time::Instant>,
pub(crate) call_started: Option<std::time::Instant>, pub(crate) call_started: Option<std::time::Instant>,
pub(crate) call_timeout_secs: u64, pub(crate) call_timeout_secs: u64,
// State sync with agent // State sync with agent — double buffer
last_generation: u64, last_generation: u64,
last_entry_count: usize, /// Content lengths of rendered entries — for detecting changes
last_entry_lengths: Vec<usize>,
/// Reference to agent for state sync /// Reference to agent for state sync
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>, agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
} }
@ -101,7 +102,7 @@ impl InteractScreen {
call_started: None, call_started: None,
call_timeout_secs: 60, call_timeout_secs: 60,
last_generation: 0, last_generation: 0,
last_entry_count: 0, last_entry_lengths: Vec::new(),
agent, agent,
} }
} }
@ -110,41 +111,63 @@ impl InteractScreen {
fn sync_from_agent(&mut self) { fn sync_from_agent(&mut self) {
let agent = self.agent.blocking_lock(); let agent = self.agent.blocking_lock();
let gen = agent.generation; let gen = agent.generation;
let count = agent.entries().len(); let entries = agent.entries();
// Phase 1: detect desync — pop entries from panes to match // Phase 1: detect desync and pop
if gen != self.last_generation || count < self.last_entry_count { if gen != self.last_generation {
// Compaction or mutation happened — full reset
// (could be smarter and pop from front, but reset is safe)
self.conversation = PaneState::new(true); self.conversation = PaneState::new(true);
self.autonomous = PaneState::new(true); self.autonomous = PaneState::new(true);
self.tools = PaneState::new(false); self.tools = PaneState::new(false);
self.last_entry_count = 0; self.last_entry_lengths.clear();
} } else {
// Pop entries from the tail that were removed or changed
// Phase 2: route new entries to panes while self.last_entry_lengths.len() > entries.len() {
for entry in agent.entries().iter().skip(self.last_entry_count) { self.last_entry_lengths.pop();
match route_entry(entry) { // TODO: pop from correct pane
Some((PaneTarget::Conversation, text, marker)) => { }
self.conversation.push_line_with_marker(text, Color::Cyan, marker); // Check if last entry changed (streaming)
if let (Some(&last_len), Some(entry)) = (
self.last_entry_lengths.last(),
entries.get(self.last_entry_lengths.len() - 1),
) {
let cur_len = entry.message().content_text().len();
if cur_len != last_len {
// Last entry changed — pop and re-render
self.last_entry_lengths.pop();
self.conversation.pop_line();
} }
Some((PaneTarget::ConversationAssistant, text, marker)) => {
self.conversation.push_line_with_marker(text, Color::Reset, marker);
}
Some((PaneTarget::Tools, text, _)) => {
self.tools.push_line(text, Color::Yellow);
}
Some((PaneTarget::ToolResult, text, _)) => {
for line in text.lines().take(20) {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
}
None => {} // skip (memory, system, system-reminder)
} }
} }
// Phase 2: push new/changed entries
let start = self.last_entry_lengths.len();
for entry in entries.iter().skip(start) {
let msg = entry.message();
let text_len = msg.content_text().len();
if let Some((target, text, marker)) = route_entry(entry) {
match target {
PaneTarget::Conversation => {
self.conversation.push_line_with_marker(text, Color::Cyan, marker);
}
PaneTarget::ConversationAssistant => {
self.conversation.push_line_with_marker(text, Color::Reset, marker);
}
PaneTarget::Tools => {
self.tools.push_line(text, Color::Yellow);
}
PaneTarget::ToolResult => {
for line in text.lines().take(20) {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
}
}
}
self.last_entry_lengths.push(text_len);
}
self.last_generation = gen; self.last_generation = gen;
self.last_entry_count = count;
} }
/// Process a UiMessage — update pane state. /// Process a UiMessage — update pane state.

View file

@ -190,6 +190,11 @@ impl PaneState {
self.evict(); self.evict();
} }
pub(crate) fn pop_line(&mut self) {
self.lines.pop();
self.markers.pop();
}
fn scroll_up(&mut self, n: u16) { fn scroll_up(&mut self, n: u16) {
self.scroll = self.scroll.saturating_sub(n); self.scroll = self.scroll.saturating_sub(n);
self.pinned = true; self.pinned = true;