From 7d289580b568769d09704f04cc205126e1dd597a Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 22:43:01 +0800 Subject: [PATCH] fix(tui): merged tool request+result blocks; fix wheel-down bottom jump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/focus-tui/src/app.rs | 29 ++--- crates/focus-tui/src/state.rs | 131 ++++++++++---------- crates/focus-tui/src/ui.rs | 110 ++++++---------- crates/focus-tui/tests/app_session_tests.rs | 9 +- crates/focus-tui/tests/state_tests.rs | 76 +++++++----- 5 files changed, 163 insertions(+), 192 deletions(-) diff --git a/crates/focus-tui/src/app.rs b/crates/focus-tui/src/app.rs index ed9bd2e..5a349a6 100644 --- a/crates/focus-tui/src/app.rs +++ b/crates/focus-tui/src/app.rs @@ -214,6 +214,10 @@ pub struct App { /// 消息区视图高度(由 draw 填充)。 /// Messages-area view height (filled by draw). 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 动画)。 /// Frame counter (drives the spinner animation). pub frame: u64, @@ -249,6 +253,7 @@ impl App { last_usage: None, geometry: Vec::new(), view_height: 0, + content_lines: 0, frame: 0, 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 { - let total = self.geometry.last().map(|g| g.end).unwrap_or(0); - total.saturating_sub(self.view_height.max(1)) + self.content_lines.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) { - let id = self - .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 { + if let Some(id) = self.geometry.last().map(|g| g.id.clone()) { cycle_expansion(&mut self.expanded, &mut self.full, &id); } } diff --git a/crates/focus-tui/src/state.rs b/crates/focus-tui/src/state.rs index c84e616..17c6881 100644 --- a/crates/focus-tui/src/state.rs +++ b/crates/focus-tui/src/state.rs @@ -63,41 +63,30 @@ pub enum UiBlock { /// partial preview). 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, - expanded: bool, - full: bool, - }, /// 运行错误(assistant 消息的 error_message,红色显示)。 /// A run error (an assistant message's error_message, shown in red). Error { id: BlockId, text: String }, - /// 工具执行过程(运行中 / 完成,含耗时与输出)。 - /// A tool execution (running / done, with duration and output). - ToolExec { + /// 一次工具执行:请求(参数)与结果合并为一块——在那请求,在那返回。 + /// One tool execution: the request (arguments) and its result are merged + /// into a single block — requested where it returns. + Tool { id: BlockId, name: String, + /// 折叠摘要(具体内容,如 `read /path` / `shell: cmd`)。 + /// Collapsed summary (concrete content, e.g. `read /path`). summary: String, + /// 参数(展开时以人类可读键值对展示)。 + /// Arguments (shown human-readably when expanded). + args: JsonValue, status: ExecStatus, duration_ms: Option, + /// 结果文本(或运行中的流式输出)。 + /// Result text (or live streamed output while running). output: String, - result_text: String, is_error: bool, expanded: bool, + /// 二级展开:true 显示全部(false 只显示部分预览)。 + /// Second-level expansion: true shows everything. full: bool, }, /// 系统提示(欢迎、帮助、状态说明等)。 @@ -376,6 +365,16 @@ pub fn build_blocks( full: &HashSet, ) -> Vec { let mut out = Vec::new(); + // 预收集 tool_result:call_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() { match msg { Message::User(u) => { @@ -393,14 +392,43 @@ pub fn build_blocks( stop_reason: a.stop_reason, }); for (bi, block) in a.content.iter().enumerate() { - push_content_block( - &mut out, - BlockId(format!("m{}-{}", mi, bi)), - block, - thinking_durations.get(&(mi, bi)).copied(), - expanded, - full, - ); + 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, + BlockId(format!("m{}-{}", mi, bi)), + other, + thinking_durations.get(&(mi, bi)).copied(), + expanded, + full, + ), + } } // 具体错误文本(如 provider 的 HTTP 400 详情)。 // The concrete error text (e.g. a provider HTTP 400 detail). @@ -411,17 +439,9 @@ pub fn build_blocks( }); } } - Message::ToolResult(t) => { - let id = BlockId(format!("m{}-r", mi)); - out.push(UiBlock::ToolResult { - 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), - }); + Message::ToolResult(_) => { + // 已合并进对应的工具调用块,不再单独渲染。 + // Already merged into its tool-call block; not rendered alone. } } } @@ -464,14 +484,14 @@ pub fn build_blocks( .filter(|(_, e)| e.status == ExecStatus::Running) { let id = BlockId(format!("exec-{}", ei)); - out.push(UiBlock::ToolExec { + out.push(UiBlock::Tool { id: id.clone(), name: exec.name.clone(), summary: exec.summary.clone(), + args: JsonValue::obj(), status: exec.status, duration_ms: exec.duration_ms(), output: exec.output.clone(), - result_text: exec.result_text.clone(), is_error: exec.is_error, expanded: expanded.contains(&id), full: full.contains(&id), @@ -507,14 +527,7 @@ fn push_content_block( expanded: is_expanded, full: is_full, }), - ContentBlock::ToolCall(tc) => out.push(UiBlock::ToolCall { - id, - name: tc.name.clone(), - summary: crate::format::summarize_tool(&tc.name, &tc.arguments), - args: tc.arguments.clone(), - expanded: is_expanded, - }), - ContentBlock::Image(_) => {} + ContentBlock::Image(_) | ContentBlock::ToolCall(_) => {} } } @@ -547,9 +560,7 @@ impl UiBlock { | UiBlock::AssistantHeader { id, .. } | UiBlock::Text { id, .. } | UiBlock::Thinking { id, .. } - | UiBlock::ToolCall { id, .. } - | UiBlock::ToolResult { id, .. } - | UiBlock::ToolExec { id, .. } + | UiBlock::Tool { id, .. } | UiBlock::Error { id, .. } => Some(id), UiBlock::Note { .. } => None, } @@ -558,12 +569,6 @@ impl UiBlock { /// 是否可展开/折叠(点击或 Tab)。 /// Whether the block is expandable (click or Tab). pub fn is_expandable(&self) -> bool { - matches!( - self, - UiBlock::Thinking { .. } - | UiBlock::ToolCall { .. } - | UiBlock::ToolResult { .. } - | UiBlock::ToolExec { .. } - ) + matches!(self, UiBlock::Thinking { .. } | UiBlock::Tool { .. }) } } diff --git a/crates/focus-tui/src/ui.rs b/crates/focus-tui/src/ui.rs index f88c9ef..4e37475 100644 --- a/crates/focus-tui/src/ui.rs +++ b/crates/focus-tui/src/ui.rs @@ -164,6 +164,7 @@ fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) { } let total = lines.len() as u16; + app.content_lines = total; let max_scroll = total.saturating_sub(area.height.max(1)); if app.follow { app.scroll = max_scroll; @@ -281,94 +282,55 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { ))); } } - UiBlock::ToolCall { - name, + + UiBlock::Tool { + name: _, summary, 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, duration_ms, output, - result_text, is_error, expanded, full, .. } => { let running = *status == ExecStatus::Running; + let mark = if running { + "● 运行中".to_string() + } else if *is_error { + "✗".to_string() + } else { + "✓".to_string() + }; + let dur = duration_ms + .map(format_duration) + .map(|d| format!(" · {}", d)) + .unwrap_or_default(); if *expanded { - let state = if running { "● 运行中" } else { "✓" }; - out.push(Line::from(Span::styled( - format!("▾ 🔧 {} · {}", summary, state), - Style::new().fg(Color::Magenta).bold(), - ))); - // 运行中:流式输出实时预览;结束后:结果文本(部分/全部)。 - // Running: live streaming preview; done: result text - // (partial/full). - let body = if running { - output.clone() + let header = format!("▾ 🔧 {} · {} {}", summary, mark, dur); + let style = if *is_error { + Style::new().fg(Color::Red).bold() } else { - result_text.clone() + Style::new().fg(Color::Magenta).bold() }; - out.extend(preview_lines(&body, *full, width, " ", dim)); + 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 { out.push(Line::from(Span::styled( " ✗ 工具执行失败", diff --git a/crates/focus-tui/tests/app_session_tests.rs b/crates/focus-tui/tests/app_session_tests.rs index 38af816..db8680f 100644 --- a/crates/focus-tui/tests/app_session_tests.rs +++ b/crates/focus-tui/tests/app_session_tests.rs @@ -93,17 +93,10 @@ fn new_session_resets_state() { /// back, killing the wheel). #[test] 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")); // 模拟内容 100 行、视口 10 行的几何。 // Simulate geometry: 100 content lines, 10-line viewport. - app.geometry = vec![BlockGeometry { - id: BlockId("x".into()), - start: 0, - end: 100, - }]; + app.content_lines = 100; app.view_height = 10; // 贴底时向上滚:偏移减 1、取消 follow。 diff --git a/crates/focus-tui/tests/state_tests.rs b/crates/focus-tui/tests/state_tests.rs index 039b3c9..97d739f 100644 --- a/crates/focus-tui/tests/state_tests.rs +++ b/crates/focus-tui/tests/state_tests.rs @@ -175,7 +175,7 @@ fn blocks_are_collapsed_by_default_and_summarized() { assert_eq!(text, "secret reasoning"); assert_eq!(*duration_ms, None); } - UiBlock::ToolCall { + UiBlock::Tool { summary, expanded, .. } => { assert!(!expanded); @@ -202,7 +202,7 @@ fn blocks_are_collapsed_by_default_and_summarized() { ); let mut expanded_count = 0; for b in &blocks { - if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { expanded, .. } = b { + if let UiBlock::Thinking { expanded, .. } | UiBlock::Tool { expanded, .. } = b { assert!(*expanded); expanded_count += 1; } @@ -346,20 +346,27 @@ fn commit_partial_clears_live_message() { 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] -fn tool_duration_shows_in_tool_result_block() { - let transcript = vec![Message::ToolResult(ToolResultMessage { - tool_call_id: "c1".into(), - tool_name: "shell".into(), - content: vec![ContentBlock::text("ok")], - details: JsonValue::obj(), - is_error: false, - timestamp: 0, - })]; +fn tool_duration_shows_in_merged_tool_block() { + 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_name: "shell".into(), + content: vec![ContentBlock::text("ok")], + details: JsonValue::obj(), + is_error: false, + timestamp: 0, + }), + ]; 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( &transcript, &HashMap::new(), @@ -369,7 +376,7 @@ fn tool_duration_shows_in_tool_result_block() { &HashSet::new(), ); let tr = blocks.iter().find_map(|b| match b { - UiBlock::ToolResult { duration_ms, .. } => Some(*duration_ms), + UiBlock::Tool { duration_ms, .. } => Some(*duration_ms), _ => None, }); assert_eq!(tr, Some(Some(12_345))); @@ -406,7 +413,7 @@ fn live_renders_only_running_execs() { &HashSet::new(), ); assert!( - !blocks.iter().any(|b| matches!(b, UiBlock::ToolExec { .. })), + !blocks.iter().any(|b| matches!(b, UiBlock::Tool { .. })), "finished execs must not render live" ); @@ -430,7 +437,7 @@ fn live_renders_only_running_execs() { assert!( blocks.iter().any(|b| matches!( b, - UiBlock::ToolExec { + UiBlock::Tool { status: ExecStatus::Running, .. } @@ -466,20 +473,27 @@ fn expansion_cycles_three_states() { assert!(!full.contains(&id)); } -/// 二级展开标志进入 ToolResult 块。 -/// The full-level flag reaches ToolResult blocks. +/// 二级展开标志进入合并后的工具块。 +/// The full-level flag reaches the merged tool block. #[test] -fn full_flag_reaches_blocks() { - let transcript = vec![Message::ToolResult(ToolResultMessage { - tool_call_id: "c1".into(), - tool_name: "shell".into(), - content: vec![ContentBlock::text("out")], - details: JsonValue::obj(), - is_error: false, - timestamp: 0, - })]; +fn full_flag_reaches_merged_tool_block() { + 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_name: "shell".into(), + content: vec![ContentBlock::text("out")], + details: JsonValue::obj(), + is_error: false, + timestamp: 0, + }), + ]; let mut full = HashSet::new(); - full.insert(BlockId("m0-r".into())); + full.insert(BlockId("m0-0".into())); // 工具调用在 assistant 的索引 0 let blocks = build_blocks( &transcript, &HashMap::new(), @@ -491,9 +505,9 @@ fn full_flag_reaches_blocks() { let tr = blocks .iter() .find_map(|b| match b { - UiBlock::ToolResult { full, .. } => Some(*full), + UiBlock::Tool { full, .. } => Some(*full), _ => None, }) - .expect("tool result block"); + .expect("merged tool block"); assert!(tr, "full flag must propagate"); }