From 71d83d23ed9b3c2b9bb42343e2a9c377a3a29dd4 Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 22:59:50 +0800 Subject: [PATCH] fix(tui): show pending tool calls while streaming; pause-follow hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. tool calls inside a streaming assistant message were dropped entirely (push_content_block dropped the ToolCall arm after the merged-block refactor), so the call info only appeared once the whole turn finished. They now render as pending UiBlock::Tool blocks (ExecStatus::Pending, no status marker) as soon as the model issues them, then transition to the running execution blocks, then the merged request+result block. 2. with two thinkings, the first was pushed out of view by auto-follow when the second streamed; scrolling up already pauses follow, and the status bar now hints '↑跟随已暂停(滚到最底恢复)' while a run is active and the user is reviewing history. regression test: a streaming partial with a tool call renders a pending Tool block with the concrete summary. --- crates/focus-tui/src/state.rs | 24 ++++++++++++++- crates/focus-tui/src/ui.rs | 26 +++++++++++----- crates/focus-tui/tests/state_tests.rs | 43 +++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/crates/focus-tui/src/state.rs b/crates/focus-tui/src/state.rs index 17c6881..2f080b0 100644 --- a/crates/focus-tui/src/state.rs +++ b/crates/focus-tui/src/state.rs @@ -98,6 +98,9 @@ pub enum UiBlock { /// Tool execution status. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExecStatus { + /// 模型已发出调用、尚未执行。 + /// The model issued the call; not executed yet. + Pending, /// 正在运行。 /// Running. Running, @@ -527,7 +530,26 @@ fn push_content_block( expanded: is_expanded, full: is_full, }), - ContentBlock::Image(_) | ContentBlock::ToolCall(_) => {} + ContentBlock::ToolCall(tc) => { + // 流式消息中的工具调用:以「待执行」状态渲染,让用户在模型发出调用 + // 时就能看到,而不是等整个回合输出完(回归)。 + // Tool calls inside a streaming message render as pending blocks, + // visible as soon as the model issues them rather than after the + // whole turn finishes (regression). + out.push(UiBlock::Tool { + id, + name: tc.name.clone(), + summary: crate::format::summarize_tool(&tc.name, &tc.arguments), + args: tc.arguments.clone(), + status: ExecStatus::Pending, + duration_ms: None, + output: String::new(), + is_error: false, + expanded: is_expanded, + full: is_full, + }); + } + ContentBlock::Image(_) => {} } } diff --git a/crates/focus-tui/src/ui.rs b/crates/focus-tui/src/ui.rs index 80fdecd..ecd578b 100644 --- a/crates/focus-tui/src/ui.rs +++ b/crates/focus-tui/src/ui.rs @@ -296,12 +296,13 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { .. } => { let running = *status == ExecStatus::Running; - let mark = if running { - "● 运行中".to_string() - } else if *is_error { - "✗".to_string() - } else { - "✓".to_string() + let mark = match status { + ExecStatus::Running => "● 运行中".to_string(), + ExecStatus::Done if *is_error => "✗".to_string(), + ExecStatus::Done => "✓".to_string(), + // 待执行:不显示状态标记。 + // Pending: no status marker. + ExecStatus::Pending => String::new(), }; let dur = duration_ms .map(format_duration) @@ -430,14 +431,23 @@ fn draw_status(frame: &mut Frame, area: Rect, app: &App) { ratio, usage ); + let follow_hint = if app.is_running() && !app.follow { + // 用户上滚查看历史时已暂停自动跟随,提示如何恢复。 + // Auto-follow is paused while the user reviews history; hint how to + // resume. + " ↑跟随已暂停(滚到最底恢复)" + } else { + "" + }; let right = if app.is_running() { // 运行中:spinner + 阶段(或状态说明,如 "compacting…")。 // Running: spinner + phase (or a status note like "compacting…"). - if !app.status.is_empty() { + let base = if !app.status.is_empty() { format!("{} {}", spinner(app.frame), app.status) } else { format!("{} {}", spinner(app.frame), working_phase(app)) - } + }; + format!("{}{}", base, follow_hint) } else if !app.status.is_empty() { app.status.clone() } else { diff --git a/crates/focus-tui/tests/state_tests.rs b/crates/focus-tui/tests/state_tests.rs index 97d739f..3d84b18 100644 --- a/crates/focus-tui/tests/state_tests.rs +++ b/crates/focus-tui/tests/state_tests.rs @@ -511,3 +511,46 @@ fn full_flag_reaches_merged_tool_block() { .expect("merged tool block"); assert!(tr, "full flag must propagate"); } + +/// 流式消息中的工具调用必须以「待执行」状态渲染(回归:此前被丢弃, +/// 工具信息要等整个回合输出完才出现)。 +/// Tool calls inside a streaming message must render as pending blocks +/// (regression: they used to be dropped, appearing only after the turn). +#[test] +fn live_partial_renders_pending_tool_calls() { + let fc = FakeClock::new(); + let mut rs = RunState::new(fc.clock()); + rs.on_agent_event(&AgentEvent::MessageStart { + message: Message::Assistant(assistant(vec![ + thinking_block("why"), + ContentBlock::ToolCall(ToolCall { + id: "call_0".into(), + name: "read".into(), + arguments: { + let mut a = JsonValue::obj(); + a.insert("path", "src/main.rs".into()).ok(); + a + }, + }), + ])), + }); + let blocks = build_blocks( + &[], + &HashMap::new(), + &HashMap::new(), + Some(&rs), + &HashSet::new(), + &HashSet::new(), + ); + let tool = blocks + .iter() + .find_map(|b| match b { + UiBlock::Tool { + status, summary, .. + } => Some((*status, summary.clone())), + _ => None, + }) + .expect("live tool block"); + assert_eq!(tool.0, ExecStatus::Pending); + assert_eq!(tool.1, "read src/main.rs"); +}