From ca9f2b2b9afa1243c1390e89765873c7740569c6 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sun, 5 Apr 2026 19:35:19 -0400 Subject: [PATCH] chat: double-buffered sync with content-length diffing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/user/chat.rs | 81 +++++++++++++++++++++++++++++++----------------- src/user/mod.rs | 5 +++ 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/src/user/chat.rs b/src/user/chat.rs index d6c866a..8130295 100644 --- a/src/user/chat.rs +++ b/src/user/chat.rs @@ -78,9 +78,10 @@ pub(crate) struct InteractScreen { pub(crate) turn_started: Option, pub(crate) call_started: Option, pub(crate) call_timeout_secs: u64, - // State sync with agent + // State sync with agent — double buffer last_generation: u64, - last_entry_count: usize, + /// Content lengths of rendered entries — for detecting changes + last_entry_lengths: Vec, /// Reference to agent for state sync agent: std::sync::Arc>, } @@ -101,7 +102,7 @@ impl InteractScreen { call_started: None, call_timeout_secs: 60, last_generation: 0, - last_entry_count: 0, + last_entry_lengths: Vec::new(), agent, } } @@ -110,41 +111,63 @@ impl InteractScreen { fn sync_from_agent(&mut self) { let agent = self.agent.blocking_lock(); let gen = agent.generation; - let count = agent.entries().len(); + let entries = agent.entries(); - // Phase 1: detect desync — pop entries from panes to match - if gen != self.last_generation || count < self.last_entry_count { - // Compaction or mutation happened — full reset - // (could be smarter and pop from front, but reset is safe) + // Phase 1: detect desync and pop + if gen != self.last_generation { self.conversation = PaneState::new(true); self.autonomous = PaneState::new(true); self.tools = PaneState::new(false); - self.last_entry_count = 0; - } - - // Phase 2: route new entries to panes - for entry in agent.entries().iter().skip(self.last_entry_count) { - match route_entry(entry) { - Some((PaneTarget::Conversation, text, marker)) => { - self.conversation.push_line_with_marker(text, Color::Cyan, marker); + self.last_entry_lengths.clear(); + } else { + // Pop entries from the tail that were removed or changed + while self.last_entry_lengths.len() > entries.len() { + self.last_entry_lengths.pop(); + // TODO: pop from correct pane + } + // 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_entry_count = count; } /// Process a UiMessage — update pane state. diff --git a/src/user/mod.rs b/src/user/mod.rs index fd28caf..0a1967f 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -190,6 +190,11 @@ impl PaneState { self.evict(); } + pub(crate) fn pop_line(&mut self) { + self.lines.pop(); + self.markers.pop(); + } + fn scroll_up(&mut self, n: u16) { self.scroll = self.scroll.saturating_sub(n); self.pinned = true;