fix(tui): show pending tool calls while streaming; pause-follow hint

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.
This commit is contained in:
DaiChaoXiong 2026-08-09 22:59:50 +08:00
parent 9a2d50ee58
commit 71d83d23ed
3 changed files with 84 additions and 9 deletions

View File

@ -98,6 +98,9 @@ pub enum UiBlock {
/// Tool execution status. /// Tool execution status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecStatus { pub enum ExecStatus {
/// 模型已发出调用、尚未执行。
/// The model issued the call; not executed yet.
Pending,
/// 正在运行。 /// 正在运行。
/// Running. /// Running.
Running, Running,
@ -527,7 +530,26 @@ fn push_content_block(
expanded: is_expanded, expanded: is_expanded,
full: is_full, 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(_) => {}
} }
} }

View File

@ -296,12 +296,13 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
.. ..
} => { } => {
let running = *status == ExecStatus::Running; let running = *status == ExecStatus::Running;
let mark = if running { let mark = match status {
"● 运行中".to_string() ExecStatus::Running => "● 运行中".to_string(),
} else if *is_error { ExecStatus::Done if *is_error => "".to_string(),
"".to_string() ExecStatus::Done => "".to_string(),
} else { // 待执行:不显示状态标记。
"".to_string() // Pending: no status marker.
ExecStatus::Pending => String::new(),
}; };
let dur = duration_ms let dur = duration_ms
.map(format_duration) .map(format_duration)
@ -430,14 +431,23 @@ fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
ratio, ratio,
usage 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() { let right = if app.is_running() {
// 运行中spinner + 阶段(或状态说明,如 "compacting…")。 // 运行中spinner + 阶段(或状态说明,如 "compacting…")。
// Running: spinner + phase (or a status note like "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) format!("{} {}", spinner(app.frame), app.status)
} else { } else {
format!("{} {}", spinner(app.frame), working_phase(app)) format!("{} {}", spinner(app.frame), working_phase(app))
} };
format!("{}{}", base, follow_hint)
} else if !app.status.is_empty() { } else if !app.status.is_empty() {
app.status.clone() app.status.clone()
} else { } else {

View File

@ -511,3 +511,46 @@ fn full_flag_reaches_merged_tool_block() {
.expect("merged tool block"); .expect("merged tool block");
assert!(tr, "full flag must propagate"); 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");
}