From 16bd8e3a88f9e84a6961d9d3a9f6d01b5b7c282e Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 9 May 2026 03:11:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20compact=E3=81=AE=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=83=AC=E3=82=B9=E8=A1=A8=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/app.rs | 134 +++++++++++++++++++++++++++++++++++++--- crates/tui/src/block.rs | 18 +++++- crates/tui/src/main.rs | 1 + crates/tui/src/ui.rs | 40 ++++++++++-- 4 files changed, 177 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 38e59b4f..f84bc8b8 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -568,9 +568,7 @@ impl App { } Event::ToolCallDone { id, arguments, .. } => { self.current_tool = None; - let name = self - .find_tool_call_mut(&id) - .map(|b| b.name.clone()); + let name = self.find_tool_call_mut(&id).map(|b| b.name.clone()); if let Some(name) = name.as_deref() { self.task_store.apply_tool_call(name, &arguments); } @@ -679,15 +677,47 @@ impl App { self.assistant_streaming = false; } Event::CompactStart => { - self.blocks.push(Block::Compact(CompactEvent::Start)); + self.blocks.push(Block::Compact(CompactEvent::Streaming { + started_at: Instant::now(), + })); } Event::CompactDone { new_session_id } => { - self.blocks - .push(Block::Compact(CompactEvent::Done { new_session_id })); + if let Some(evt) = self.last_streaming_compact_mut() { + let elapsed_secs = match evt { + CompactEvent::Streaming { started_at } => { + Some(started_at.elapsed().as_secs()) + } + _ => None, + }; + *evt = CompactEvent::Done { + new_session_id, + elapsed_secs, + }; + } else { + self.blocks.push(Block::Compact(CompactEvent::Done { + new_session_id, + elapsed_secs: None, + })); + } } Event::CompactFailed { error } => { - self.blocks - .push(Block::Compact(CompactEvent::Failed { error })); + if let Some(evt) = self.last_streaming_compact_mut() { + let elapsed_secs = match evt { + CompactEvent::Streaming { started_at } => { + Some(started_at.elapsed().as_secs()) + } + _ => None, + }; + *evt = CompactEvent::Failed { + error, + elapsed_secs, + }; + } else { + self.blocks.push(Block::Compact(CompactEvent::Failed { + error, + elapsed_secs: None, + })); + } } Event::Alert(alert) => { self.blocks.push(Block::Alert { @@ -719,6 +749,7 @@ impl App { } } Event::Shutdown => { + self.mark_orphan_compacts_incomplete(); self.quit = true; } } @@ -775,6 +806,33 @@ impl App { } } + fn last_streaming_compact_mut(&mut self) -> Option<&mut CompactEvent> { + for b in self.blocks.iter_mut().rev() { + match b { + Block::Compact(evt) if matches!(evt, CompactEvent::Streaming { .. }) => { + return Some(evt); + } + Block::Compact(_) => return None, + _ => continue, + } + } + None + } + + pub(crate) fn mark_orphan_compacts_incomplete(&mut self) { + for b in self.blocks.iter_mut().rev() { + if let Block::Compact(evt) = b { + if let CompactEvent::Streaming { started_at } = evt { + *evt = CompactEvent::Incomplete { + elapsed_secs: Some(started_at.elapsed().as_secs()), + }; + } else { + break; + } + } + } + } + fn find_tool_call_mut(&mut self, id: &str) -> Option<&mut ToolCallBlock> { for b in self.blocks.iter_mut().rev() { if let Block::ToolCall(tc) = b @@ -1310,6 +1368,66 @@ mod completion_flow_tests { )); } + #[test] + fn compact_done_replaces_live_block() { + let mut app = App::new("test".into()); + let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap(); + + app.handle_pod_event(Event::CompactStart); + app.handle_pod_event(Event::CompactDone { new_session_id: id }); + + assert_eq!(compact_block_count(&app), 1); + assert!(matches!( + app.blocks.as_slice(), + [Block::Compact(CompactEvent::Done { + new_session_id, + elapsed_secs: Some(_), + })] if *new_session_id == id + )); + } + + #[test] + fn compact_failed_replaces_live_block() { + let mut app = App::new("test".into()); + + app.handle_pod_event(Event::CompactStart); + app.handle_pod_event(Event::CompactFailed { + error: "provider 429".into(), + }); + + assert_eq!(compact_block_count(&app), 1); + assert!(matches!( + app.blocks.as_slice(), + [Block::Compact(CompactEvent::Failed { + error, + elapsed_secs: Some(_), + })] if error == "provider 429" + )); + } + + #[test] + fn shutdown_marks_live_compact_incomplete() { + let mut app = App::new("test".into()); + + app.handle_pod_event(Event::CompactStart); + app.handle_pod_event(Event::Shutdown); + + assert!(app.quit); + assert!(matches!( + app.blocks.as_slice(), + [Block::Compact(CompactEvent::Incomplete { + elapsed_secs: Some(_), + })] + )); + } + + fn compact_block_count(app: &App) -> usize { + app.blocks + .iter() + .filter(|block| matches!(block, Block::Compact(_))) + .count() + } + fn test_greeting() -> protocol::Greeting { protocol::Greeting { pod_name: "test".into(), diff --git a/crates/tui/src/block.rs b/crates/tui/src/block.rs index 11556d3f..f3a5faa2 100644 --- a/crates/tui/src/block.rs +++ b/crates/tui/src/block.rs @@ -78,9 +78,21 @@ pub enum ThinkingState { } pub enum CompactEvent { - Start, - Done { new_session_id: uuid::Uuid }, - Failed { error: String }, + /// Live block: compaction worker is running. `started_at` powers the + /// `Compacting... (Xs)` live timer. + Streaming { started_at: Instant }, + /// Compaction ended cleanly with `CompactDone`. + Done { + new_session_id: uuid::Uuid, + elapsed_secs: Option, + }, + /// Compaction ended with `CompactFailed`. + Failed { + error: String, + elapsed_secs: Option, + }, + /// The TUI stopped observing events before a terminal compact event. + Incomplete { elapsed_secs: Option }, } pub struct ToolCallBlock { diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 26b0f640..bb9cd4a2 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -330,6 +330,7 @@ async fn run_loop( Some(ev) => app.handle_pod_event(ev), None => { app.connected = false; + app.mark_orphan_compacts_incomplete(); app.push_error("Connection lost"); } } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 95647aa0..884caac8 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1011,21 +1011,45 @@ fn fmt_elapsed(secs: u64) -> String { fn render_compact(lines: &mut Vec>, evt: &CompactEvent, width: u16, mode: Mode) { let (text, kind) = match evt { - CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn), - CompactEvent::Done { new_session_id } => { + CompactEvent::Streaming { started_at } => { + let secs = started_at.elapsed().as_secs(); + ( + format!("Compacting... ({})", fmt_elapsed(secs)), + MessageKind::NoticeWarn, + ) + } + CompactEvent::Done { + new_session_id, + elapsed_secs, + } => { let short = new_session_id .to_string() .chars() .take(8) .collect::(); + let elapsed = elapsed_suffix(*elapsed_secs); ( - format!("[compact] done (new session {short})"), + format!("[compact] done (new session {short}){elapsed}"), MessageKind::NoticeWarn, ) } - CompactEvent::Failed { error } => { - (format!("[compact error] {error}"), MessageKind::NoticeError) + CompactEvent::Failed { + error, + elapsed_secs, + } => { + let elapsed = elapsed_suffix(*elapsed_secs); + ( + format!("[compact error] {error}{elapsed}"), + MessageKind::NoticeError, + ) } + CompactEvent::Incomplete { elapsed_secs } => match elapsed_secs { + Some(s) => ( + format!("[compact] interrupted ({})", fmt_elapsed(*s)), + MessageKind::NoticeError, + ), + None => ("[compact] interrupted".to_owned(), MessageKind::NoticeError), + }, }; match mode { Mode::Overview => push_overview_line(lines, &text, width, kind, ""), @@ -1033,6 +1057,12 @@ fn render_compact(lines: &mut Vec>, evt: &CompactEvent, width: u16 } } +fn elapsed_suffix(elapsed_secs: Option) -> String { + elapsed_secs + .map(|s| format!(" ({})", fmt_elapsed(s))) + .unwrap_or_default() +} + fn draw_separator(frame: &mut Frame, area: Rect) { let line = "─".repeat(area.width as usize); frame.render_widget(