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) call_started: Option<std::time::Instant>,
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<usize>,
/// Reference to agent for state sync
agent: std::sync::Arc<tokio::sync::Mutex<crate::agent::Agent>>,
}
@ -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.

View file

@ -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;