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