fix(tui): merged tool request+result blocks; fix wheel-down bottom jump

1. wheel-down jumped to the bottom because max_scroll() derived the limit from
   geometry, which only covers expandable blocks — a text-only conversation
   left it at 0, so one down-notch clamped scroll to 0 with follow=true and
   the next draw snapped to the real bottom. The limit now uses
   content_lines (the full rendered line count, stored per draw).

2. tool request and result are now one block ('requested where it returns'):
   UiBlock::ToolCall/ToolResult/ToolExec are unified into UiBlock::Tool, built
   by merging each assistant tool call with its matching tool_result message
   (looked up by call_id). Collapsed shows the concrete summary + duration +
   status; expanded shows human-readable args, then '── 结果 ──', then the
   output (two-level: partial preview with remaining-line hint, then full).
   Live running executions reuse the same block. ToolResult messages are no
   longer rendered standalone.

tests updated for the merged model; wheel test now drives content_lines.
This commit is contained in:
DaiChaoXiong 2026-08-09 22:43:01 +08:00
parent acddf7be1b
commit 7d289580b5
5 changed files with 163 additions and 192 deletions

View File

@ -214,6 +214,10 @@ pub struct App {
/// 消息区视图高度(由 draw 填充)。 /// 消息区视图高度(由 draw 填充)。
/// Messages-area view height (filled by draw). /// Messages-area view height (filled by draw).
pub view_height: u16, pub view_height: u16,
/// 消息区总内容行数(由 draw 填充;滚动上限依据)。
/// Total content lines of the message area (filled by draw; the scroll
/// limit is based on it).
pub content_lines: u16,
/// 帧计数(供 spinner 动画)。 /// 帧计数(供 spinner 动画)。
/// Frame counter (drives the spinner animation). /// Frame counter (drives the spinner animation).
pub frame: u64, pub frame: u64,
@ -249,6 +253,7 @@ impl App {
last_usage: None, last_usage: None,
geometry: Vec::new(), geometry: Vec::new(),
view_height: 0, view_height: 0,
content_lines: 0,
frame: 0, frame: 0,
quit: false, quit: false,
} }
@ -892,26 +897,18 @@ impl App {
} }
} }
/// 最大滚动偏移(由最近渲染的几何决定)。 /// 最大滚动偏移(由最近渲染的总行数决定;几何只含可展开块,不能用作依据)。
/// Max scroll offset (derived from the last render's geometry). /// Max scroll offset (based on the last render's total line count; the
/// geometry only covers expandable blocks and is unusable for this).
fn max_scroll(&self) -> u16 { fn max_scroll(&self) -> u16 {
let total = self.geometry.last().map(|g| g.end).unwrap_or(0); self.content_lines.saturating_sub(self.view_height.max(1))
total.saturating_sub(self.view_height.max(1))
} }
/// 切换最近一个可折叠块。 /// 切换最近一个可折叠块(几何只含可展开块:思考与工具)。
/// Toggle the most recent collapsible block. /// Toggle the most recent collapsible block (geometry only holds
/// expandable blocks: thinking and tools).
fn toggle_latest_collapsible(&mut self) { fn toggle_latest_collapsible(&mut self) {
let id = self if let Some(id) = self.geometry.last().map(|g| g.id.clone()) {
.geometry
.iter()
.rev()
.find(|g| {
let id = &g.id;
id.0.starts_with("live-") || id.0.starts_with("exec-") || id.0.contains("-r")
})
.map(|g| g.id.clone());
if let Some(id) = id {
cycle_expansion(&mut self.expanded, &mut self.full, &id); cycle_expansion(&mut self.expanded, &mut self.full, &id);
} }
} }

View File

@ -63,41 +63,30 @@ pub enum UiBlock {
/// partial preview). /// partial preview).
full: bool, full: bool,
}, },
/// assistant 发出的工具调用。
/// A tool call issued by the assistant.
ToolCall {
id: BlockId,
name: String,
summary: String,
args: JsonValue,
expanded: bool,
},
/// 工具执行结果消息(来自已提交的 transcript
/// A tool-result message (from the committed transcript).
ToolResult {
id: BlockId,
name: String,
text: String,
is_error: bool,
duration_ms: Option<u64>,
expanded: bool,
full: bool,
},
/// 运行错误assistant 消息的 error_message红色显示 /// 运行错误assistant 消息的 error_message红色显示
/// A run error (an assistant message's error_message, shown in red). /// A run error (an assistant message's error_message, shown in red).
Error { id: BlockId, text: String }, Error { id: BlockId, text: String },
/// 工具执行过程(运行中 / 完成,含耗时与输出)。 /// 一次工具执行:请求(参数)与结果合并为一块——在那请求,在那返回。
/// A tool execution (running / done, with duration and output). /// One tool execution: the request (arguments) and its result are merged
ToolExec { /// into a single block — requested where it returns.
Tool {
id: BlockId, id: BlockId,
name: String, name: String,
/// 折叠摘要(具体内容,如 `read /path` / `shell: cmd`)。
/// Collapsed summary (concrete content, e.g. `read /path`).
summary: String, summary: String,
/// 参数(展开时以人类可读键值对展示)。
/// Arguments (shown human-readably when expanded).
args: JsonValue,
status: ExecStatus, status: ExecStatus,
duration_ms: Option<u64>, duration_ms: Option<u64>,
/// 结果文本(或运行中的流式输出)。
/// Result text (or live streamed output while running).
output: String, output: String,
result_text: String,
is_error: bool, is_error: bool,
expanded: bool, expanded: bool,
/// 二级展开true 显示全部false 只显示部分预览)。
/// Second-level expansion: true shows everything.
full: bool, full: bool,
}, },
/// 系统提示(欢迎、帮助、状态说明等)。 /// 系统提示(欢迎、帮助、状态说明等)。
@ -376,6 +365,16 @@ pub fn build_blocks(
full: &HashSet<BlockId>, full: &HashSet<BlockId>,
) -> Vec<UiBlock> { ) -> Vec<UiBlock> {
let mut out = Vec::new(); let mut out = Vec::new();
// 预收集 tool_resultcall_id → (结果消息, 消息索引),用于把结果合并到
// 对应的工具调用块里(在那请求,在那返回)。
// Pre-collect tool_results: call_id → (result message, index), so each
// result merges into its tool-call block (requested where it returns).
let mut results: HashMap<&str, (&ToolResultMessage, usize)> = HashMap::new();
for (mi, msg) in transcript.iter().enumerate() {
if let Message::ToolResult(t) = msg {
results.entry(t.tool_call_id.as_str()).or_insert((t, mi));
}
}
for (mi, msg) in transcript.iter().enumerate() { for (mi, msg) in transcript.iter().enumerate() {
match msg { match msg {
Message::User(u) => { Message::User(u) => {
@ -393,14 +392,43 @@ pub fn build_blocks(
stop_reason: a.stop_reason, stop_reason: a.stop_reason,
}); });
for (bi, block) in a.content.iter().enumerate() { for (bi, block) in a.content.iter().enumerate() {
push_content_block( match block {
ContentBlock::ToolCall(tc) => {
// 工具调用与其结果合并为一块。
// The tool call merges with its result.
let id = BlockId(format!("m{}-{}", mi, bi));
let result = results.get(tc.id.as_str()).copied();
let (status, duration, output, is_error) = match result {
Some((r, r_idx)) => (
ExecStatus::Done,
tool_durations.get(&(r_idx, 0)).copied(),
join_text(&r.content),
r.is_error,
),
None => (ExecStatus::Done, None, String::new(), false),
};
out.push(UiBlock::Tool {
id,
name: tc.name.clone(),
summary: crate::format::summarize_tool(&tc.name, &tc.arguments),
args: tc.arguments.clone(),
status,
duration_ms: duration,
output,
is_error,
expanded: expanded.contains(&BlockId(format!("m{}-{}", mi, bi))),
full: full.contains(&BlockId(format!("m{}-{}", mi, bi))),
});
}
other => push_content_block(
&mut out, &mut out,
BlockId(format!("m{}-{}", mi, bi)), BlockId(format!("m{}-{}", mi, bi)),
block, other,
thinking_durations.get(&(mi, bi)).copied(), thinking_durations.get(&(mi, bi)).copied(),
expanded, expanded,
full, full,
); ),
}
} }
// 具体错误文本(如 provider 的 HTTP 400 详情)。 // 具体错误文本(如 provider 的 HTTP 400 详情)。
// The concrete error text (e.g. a provider HTTP 400 detail). // The concrete error text (e.g. a provider HTTP 400 detail).
@ -411,17 +439,9 @@ pub fn build_blocks(
}); });
} }
} }
Message::ToolResult(t) => { Message::ToolResult(_) => {
let id = BlockId(format!("m{}-r", mi)); // 已合并进对应的工具调用块,不再单独渲染。
out.push(UiBlock::ToolResult { // Already merged into its tool-call block; not rendered alone.
id: id.clone(),
name: t.tool_name.clone(),
text: join_text(&t.content),
is_error: t.is_error,
duration_ms: tool_durations.get(&(mi, 0)).copied(),
expanded: expanded.contains(&id),
full: full.contains(&id),
});
} }
} }
} }
@ -464,14 +484,14 @@ pub fn build_blocks(
.filter(|(_, e)| e.status == ExecStatus::Running) .filter(|(_, e)| e.status == ExecStatus::Running)
{ {
let id = BlockId(format!("exec-{}", ei)); let id = BlockId(format!("exec-{}", ei));
out.push(UiBlock::ToolExec { out.push(UiBlock::Tool {
id: id.clone(), id: id.clone(),
name: exec.name.clone(), name: exec.name.clone(),
summary: exec.summary.clone(), summary: exec.summary.clone(),
args: JsonValue::obj(),
status: exec.status, status: exec.status,
duration_ms: exec.duration_ms(), duration_ms: exec.duration_ms(),
output: exec.output.clone(), output: exec.output.clone(),
result_text: exec.result_text.clone(),
is_error: exec.is_error, is_error: exec.is_error,
expanded: expanded.contains(&id), expanded: expanded.contains(&id),
full: full.contains(&id), full: full.contains(&id),
@ -507,14 +527,7 @@ fn push_content_block(
expanded: is_expanded, expanded: is_expanded,
full: is_full, full: is_full,
}), }),
ContentBlock::ToolCall(tc) => out.push(UiBlock::ToolCall { ContentBlock::Image(_) | ContentBlock::ToolCall(_) => {}
id,
name: tc.name.clone(),
summary: crate::format::summarize_tool(&tc.name, &tc.arguments),
args: tc.arguments.clone(),
expanded: is_expanded,
}),
ContentBlock::Image(_) => {}
} }
} }
@ -547,9 +560,7 @@ impl UiBlock {
| UiBlock::AssistantHeader { id, .. } | UiBlock::AssistantHeader { id, .. }
| UiBlock::Text { id, .. } | UiBlock::Text { id, .. }
| UiBlock::Thinking { id, .. } | UiBlock::Thinking { id, .. }
| UiBlock::ToolCall { id, .. } | UiBlock::Tool { id, .. }
| UiBlock::ToolResult { id, .. }
| UiBlock::ToolExec { id, .. }
| UiBlock::Error { id, .. } => Some(id), | UiBlock::Error { id, .. } => Some(id),
UiBlock::Note { .. } => None, UiBlock::Note { .. } => None,
} }
@ -558,12 +569,6 @@ impl UiBlock {
/// 是否可展开/折叠(点击或 Tab /// 是否可展开/折叠(点击或 Tab
/// Whether the block is expandable (click or Tab). /// Whether the block is expandable (click or Tab).
pub fn is_expandable(&self) -> bool { pub fn is_expandable(&self) -> bool {
matches!( matches!(self, UiBlock::Thinking { .. } | UiBlock::Tool { .. })
self,
UiBlock::Thinking { .. }
| UiBlock::ToolCall { .. }
| UiBlock::ToolResult { .. }
| UiBlock::ToolExec { .. }
)
} }
} }

View File

@ -164,6 +164,7 @@ fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) {
} }
let total = lines.len() as u16; let total = lines.len() as u16;
app.content_lines = total;
let max_scroll = total.saturating_sub(area.height.max(1)); let max_scroll = total.saturating_sub(area.height.max(1));
if app.follow { if app.follow {
app.scroll = max_scroll; app.scroll = max_scroll;
@ -281,94 +282,55 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
))); )));
} }
} }
UiBlock::ToolCall {
name, UiBlock::Tool {
name: _,
summary, summary,
args, args,
expanded,
..
} => {
if *expanded {
out.push(Line::from(Span::styled(
format!("▾ 🔧 {}", name),
Style::new().fg(Color::Magenta).bold(),
)));
// 人类可读的参数(非原始 JSON
// Human-readable arguments (not raw JSON).
for line in format_args_human(args) {
for wrapped in wrap_text(&line, width.saturating_sub(2)) {
out.push(Line::from(Span::styled(format!(" {}", wrapped), dim)));
}
}
} else {
out.push(Line::from(Span::styled(
format!("▸ 🔧 {}(点击/Tab 展开)", summary),
Style::new().fg(Color::Magenta),
)));
}
}
UiBlock::ToolResult {
name,
text,
is_error,
duration_ms,
expanded,
full,
..
} => {
let mark = if *is_error { "" } else { "" };
let dur = duration_ms
.map(format_duration)
.map(|d| format!(" · {}", d))
.unwrap_or_default();
if *expanded {
out.push(Line::from(Span::styled(
format!("▾ 📦 {} {}{}", mark, name, dur),
if *is_error {
Style::new().fg(Color::Red).bold()
} else {
Style::new().fg(Color::Green).bold()
},
)));
out.extend(preview_lines(text, *full, width, " ", dim));
} else {
out.push(Line::from(Span::styled(
format!("▸ 📦 {} {}{}(点击/Tab 展开)", mark, name, dur),
if *is_error {
Style::new().fg(Color::Red)
} else {
Style::new().fg(Color::Green)
},
)));
}
}
UiBlock::ToolExec {
summary,
status, status,
duration_ms, duration_ms,
output, output,
result_text,
is_error, is_error,
expanded, expanded,
full, full,
.. ..
} => { } => {
let running = *status == ExecStatus::Running; let running = *status == ExecStatus::Running;
if *expanded { let mark = if running {
let state = if running { "● 运行中" } else { "" }; "● 运行中".to_string()
out.push(Line::from(Span::styled( } else if *is_error {
format!("▾ 🔧 {} · {}", summary, state), "".to_string()
Style::new().fg(Color::Magenta).bold(),
)));
// 运行中:流式输出实时预览;结束后:结果文本(部分/全部)。
// Running: live streaming preview; done: result text
// (partial/full).
let body = if running {
output.clone()
} else { } else {
result_text.clone() "".to_string()
}; };
out.extend(preview_lines(&body, *full, width, " ", dim)); let dur = duration_ms
.map(format_duration)
.map(|d| format!(" · {}", d))
.unwrap_or_default();
if *expanded {
let header = format!("▾ 🔧 {} · {} {}", summary, mark, dur);
let style = if *is_error {
Style::new().fg(Color::Red).bold()
} else {
Style::new().fg(Color::Magenta).bold()
};
out.push(Line::from(Span::styled(header, style)));
// 参数:人类可读键值对(在那请求)。
// Arguments: human-readable key-value lines (the request).
for line in format_args_human(args) {
for wrapped in wrap_text(&line, width.saturating_sub(2)) {
out.push(Line::from(Span::styled(format!(" {}", wrapped), dim)));
}
}
if !output.is_empty() {
out.push(Line::from(Span::styled(
" ── 结果 ──",
Style::new().fg(Color::DarkGray),
)));
// 结果:部分/全部(在那返回)。
// Result: partial/full (the return).
out.extend(preview_lines(output, *full, width, " ", dim));
}
if !running && *is_error { if !running && *is_error {
out.push(Line::from(Span::styled( out.push(Line::from(Span::styled(
" ✗ 工具执行失败", " ✗ 工具执行失败",

View File

@ -93,17 +93,10 @@ fn new_session_resets_state() {
/// back, killing the wheel). /// back, killing the wheel).
#[test] #[test]
fn wheel_scrolls_line_by_line_in_correct_direction() { fn wheel_scrolls_line_by_line_in_correct_direction() {
use focus_tui::app::BlockGeometry;
use focus_tui::state::BlockId;
let mut app = app_on(&temp_data_dir("wheel")); let mut app = app_on(&temp_data_dir("wheel"));
// 模拟内容 100 行、视口 10 行的几何。 // 模拟内容 100 行、视口 10 行的几何。
// Simulate geometry: 100 content lines, 10-line viewport. // Simulate geometry: 100 content lines, 10-line viewport.
app.geometry = vec![BlockGeometry { app.content_lines = 100;
id: BlockId("x".into()),
start: 0,
end: 100,
}];
app.view_height = 10; app.view_height = 10;
// 贴底时向上滚:偏移减 1、取消 follow。 // 贴底时向上滚:偏移减 1、取消 follow。

View File

@ -175,7 +175,7 @@ fn blocks_are_collapsed_by_default_and_summarized() {
assert_eq!(text, "secret reasoning"); assert_eq!(text, "secret reasoning");
assert_eq!(*duration_ms, None); assert_eq!(*duration_ms, None);
} }
UiBlock::ToolCall { UiBlock::Tool {
summary, expanded, .. summary, expanded, ..
} => { } => {
assert!(!expanded); assert!(!expanded);
@ -202,7 +202,7 @@ fn blocks_are_collapsed_by_default_and_summarized() {
); );
let mut expanded_count = 0; let mut expanded_count = 0;
for b in &blocks { for b in &blocks {
if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { expanded, .. } = b { if let UiBlock::Thinking { expanded, .. } | UiBlock::Tool { expanded, .. } = b {
assert!(*expanded); assert!(*expanded);
expanded_count += 1; expanded_count += 1;
} }
@ -346,20 +346,27 @@ fn commit_partial_clears_live_message() {
assert!(rs.partial.is_none()); assert!(rs.partial.is_none());
} }
/// 已提交 tool_result 消息的耗时进入摘要行 /// 工具结果与调用合并为一块时,耗时进入该块
/// A committed tool_result's duration reaches its summary. /// When a tool result merges with its call, the duration reaches the block.
#[test] #[test]
fn tool_duration_shows_in_tool_result_block() { fn tool_duration_shows_in_merged_tool_block() {
let transcript = vec![Message::ToolResult(ToolResultMessage { let transcript = vec![
Message::Assistant(assistant(vec![ContentBlock::ToolCall(ToolCall {
id: "c1".into(),
name: "shell".into(),
arguments: JsonValue::obj(),
})])),
Message::ToolResult(ToolResultMessage {
tool_call_id: "c1".into(), tool_call_id: "c1".into(),
tool_name: "shell".into(), tool_name: "shell".into(),
content: vec![ContentBlock::text("ok")], content: vec![ContentBlock::text("ok")],
details: JsonValue::obj(), details: JsonValue::obj(),
is_error: false, is_error: false,
timestamp: 0, timestamp: 0,
})]; }),
];
let mut durations = HashMap::new(); let mut durations = HashMap::new();
durations.insert((0usize, 0usize), 12345u64); durations.insert((1usize, 0usize), 12345u64); // tool_result 在索引 1 / at index 1
let blocks = build_blocks( let blocks = build_blocks(
&transcript, &transcript,
&HashMap::new(), &HashMap::new(),
@ -369,7 +376,7 @@ fn tool_duration_shows_in_tool_result_block() {
&HashSet::new(), &HashSet::new(),
); );
let tr = blocks.iter().find_map(|b| match b { let tr = blocks.iter().find_map(|b| match b {
UiBlock::ToolResult { duration_ms, .. } => Some(*duration_ms), UiBlock::Tool { duration_ms, .. } => Some(*duration_ms),
_ => None, _ => None,
}); });
assert_eq!(tr, Some(Some(12_345))); assert_eq!(tr, Some(Some(12_345)));
@ -406,7 +413,7 @@ fn live_renders_only_running_execs() {
&HashSet::new(), &HashSet::new(),
); );
assert!( assert!(
!blocks.iter().any(|b| matches!(b, UiBlock::ToolExec { .. })), !blocks.iter().any(|b| matches!(b, UiBlock::Tool { .. })),
"finished execs must not render live" "finished execs must not render live"
); );
@ -430,7 +437,7 @@ fn live_renders_only_running_execs() {
assert!( assert!(
blocks.iter().any(|b| matches!( blocks.iter().any(|b| matches!(
b, b,
UiBlock::ToolExec { UiBlock::Tool {
status: ExecStatus::Running, status: ExecStatus::Running,
.. ..
} }
@ -466,20 +473,27 @@ fn expansion_cycles_three_states() {
assert!(!full.contains(&id)); assert!(!full.contains(&id));
} }
/// 二级展开标志进入 ToolResult 块。 /// 二级展开标志进入合并后的工具块。
/// The full-level flag reaches ToolResult blocks. /// The full-level flag reaches the merged tool block.
#[test] #[test]
fn full_flag_reaches_blocks() { fn full_flag_reaches_merged_tool_block() {
let transcript = vec![Message::ToolResult(ToolResultMessage { let transcript = vec![
Message::Assistant(assistant(vec![ContentBlock::ToolCall(ToolCall {
id: "c1".into(),
name: "shell".into(),
arguments: JsonValue::obj(),
})])),
Message::ToolResult(ToolResultMessage {
tool_call_id: "c1".into(), tool_call_id: "c1".into(),
tool_name: "shell".into(), tool_name: "shell".into(),
content: vec![ContentBlock::text("out")], content: vec![ContentBlock::text("out")],
details: JsonValue::obj(), details: JsonValue::obj(),
is_error: false, is_error: false,
timestamp: 0, timestamp: 0,
})]; }),
];
let mut full = HashSet::new(); let mut full = HashSet::new();
full.insert(BlockId("m0-r".into())); full.insert(BlockId("m0-0".into())); // 工具调用在 assistant 的索引 0
let blocks = build_blocks( let blocks = build_blocks(
&transcript, &transcript,
&HashMap::new(), &HashMap::new(),
@ -491,9 +505,9 @@ fn full_flag_reaches_blocks() {
let tr = blocks let tr = blocks
.iter() .iter()
.find_map(|b| match b { .find_map(|b| match b {
UiBlock::ToolResult { full, .. } => Some(*full), UiBlock::Tool { full, .. } => Some(*full),
_ => None, _ => None,
}) })
.expect("tool result block"); .expect("merged tool block");
assert!(tr, "full flag must propagate"); assert!(tr, "full flag must propagate");
} }