feat: compactのプログレス表示

This commit is contained in:
Keisuke Hirata 2026-05-09 03:11:53 +09:00
parent ec1eccd10d
commit 8ebdd47fbb
4 changed files with 177 additions and 16 deletions

View File

@ -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(),

View File

@ -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<u64>,
},
/// Compaction ended with `CompactFailed`.
Failed {
error: String,
elapsed_secs: Option<u64>,
},
/// The TUI stopped observing events before a terminal compact event.
Incomplete { elapsed_secs: Option<u64> },
}
pub struct ToolCallBlock {

View File

@ -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");
}
}

View File

@ -1011,21 +1011,45 @@ fn fmt_elapsed(secs: u64) -> String {
fn render_compact(lines: &mut Vec<Line<'static>>, 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::<String>();
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<Line<'static>>, evt: &CompactEvent, width: u16
}
}
fn elapsed_suffix(elapsed_secs: Option<u64>) -> 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(