From 4b5f4cf52bfaa84a9ea634a04a657e61793484aa Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 22:30:04 +0800 Subject: [PATCH] feat(tui): cross-platform wheel scroll and two-level expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. wheel: scrolling up now clears follow (the real bug — with follow=true every draw snapped back to the bottom, so the wheel appeared dead on both Linux and Windows); crossterm already normalizes xterm (Linux) and ConPTY (Windows) wheel events to ScrollUp/ScrollDown, and ScrollLeft/ScrollRight are handled too 2. expansion becomes three-state (collapsed → partial → full → collapsed): the first expand shows the first 20 lines with a '… 还有 N 行(再次点击/Tab 展开全部)' hint; the second expand reveals everything. Applies to thinking, tool-execution output and tool-result content. ToolCall args stay single-level human-readable. tests: three-state cycle, full-flag propagation --- crates/focus-tui/src/app.rs | 27 +++++++-- crates/focus-tui/src/state.rs | 40 +++++++++++-- crates/focus-tui/src/ui.rs | 85 +++++++++++++++++++-------- crates/focus-tui/tests/state_tests.rs | 66 +++++++++++++++++++++ 4 files changed, 183 insertions(+), 35 deletions(-) diff --git a/crates/focus-tui/src/app.rs b/crates/focus-tui/src/app.rs index e840432..864f2c3 100644 --- a/crates/focus-tui/src/app.rs +++ b/crates/focus-tui/src/app.rs @@ -199,6 +199,9 @@ pub struct App { /// Committed tool_result execution durations ((message idx, 0) → ms). pub tool_durations: HashMap<(usize, usize), u64>, pub expanded: HashSet, + /// 二级展开(显示全部内容)的块。 + /// Blocks expanded to the full level (show everything). + pub full: HashSet, pub notes: Vec, pub status: String, pub scroll: u16, @@ -237,6 +240,7 @@ impl App { thinking_durations: HashMap::new(), tool_durations: HashMap::new(), expanded: HashSet::new(), + full: HashSet::new(), notes: Vec::new(), status: String::new(), scroll: 0, @@ -552,6 +556,7 @@ impl App { self.thinking_durations.clear(); self.tool_durations.clear(); self.expanded.clear(); + self.full.clear(); self.last_usage = None; self.notes.clear(); self.push_note("新会话已开始。"); @@ -577,6 +582,7 @@ impl App { self.thinking_durations.clear(); self.tool_durations.clear(); self.expanded.clear(); + self.full.clear(); self.notes.clear(); self.push_note(format!( "已加载会话 {}({} 条消息)。", @@ -852,14 +858,23 @@ impl App { .iter() .find(|g| g.start <= content_row && content_row < g.end) { - toggle_expanded(&mut self.expanded, &block.id); + cycle_expansion(&mut self.expanded, &mut self.full, &block.id); } } } - MouseEventKind::ScrollUp => self.scroll = self.scroll.saturating_add(3), - MouseEventKind::ScrollDown => { - let max = self.max_scroll(); - self.scroll = (self.scroll + 3).min(max); + // crossterm 把 Linux(xterm 协议)与 Windows(ConPTY)的滚轮事件 + // 都归一化为 ScrollUp/ScrollDown;向上滚必须取消 follow, + // 否则每次绘制会被吸回底部。 + // crossterm normalizes wheel events from both Linux (xterm) and + // Windows (ConPTY) to ScrollUp/ScrollDown; scrolling up must clear + // follow, otherwise every draw snaps back to the bottom. + MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => { + self.follow = false; + self.scroll = self.scroll.saturating_add(3); + } + MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => { + self.scroll = (self.scroll + 3).min(self.max_scroll()); + self.follow = self.scroll >= self.max_scroll(); } _ => {} } @@ -893,7 +908,7 @@ impl App { }) .map(|g| g.id.clone()); if let Some(id) = id { - toggle_expanded(&mut self.expanded, &id); + 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 baeda52..c84e616 100644 --- a/crates/focus-tui/src/state.rs +++ b/crates/focus-tui/src/state.rs @@ -58,6 +58,10 @@ pub enum UiBlock { duration_ms: Option, tokens: u64, expanded: bool, + /// 二级展开:true 表示显示全部内容(false 时只显示部分)。 + /// Second-level expansion: true shows everything (false shows a + /// partial preview). + full: bool, }, /// assistant 发出的工具调用。 /// A tool call issued by the assistant. @@ -77,6 +81,7 @@ pub enum UiBlock { 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). @@ -93,6 +98,7 @@ pub enum UiBlock { result_text: String, is_error: bool, expanded: bool, + full: bool, }, /// 系统提示(欢迎、帮助、状态说明等)。 /// A system note (welcome, help, status). @@ -367,6 +373,7 @@ pub fn build_blocks( tool_durations: &std::collections::HashMap<(usize, usize), u64>, run_state: Option<&RunState>, expanded: &HashSet, + full: &HashSet, ) -> Vec { let mut out = Vec::new(); for (mi, msg) in transcript.iter().enumerate() { @@ -392,6 +399,7 @@ pub fn build_blocks( block, thinking_durations.get(&(mi, bi)).copied(), expanded, + full, ); } // 具体错误文本(如 provider 的 HTTP 400 详情)。 @@ -404,13 +412,15 @@ pub fn build_blocks( } } Message::ToolResult(t) => { + let id = BlockId(format!("m{}-r", mi)); out.push(UiBlock::ToolResult { - id: BlockId(format!("m{}-r", mi)), + 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(&BlockId(format!("m{}-r", mi))), + expanded: expanded.contains(&id), + full: full.contains(&id), }); } } @@ -433,6 +443,7 @@ pub fn build_blocks( block, duration, expanded, + full, ); } if let Some(err) = &partial.error_message { @@ -452,8 +463,9 @@ pub fn build_blocks( .enumerate() .filter(|(_, e)| e.status == ExecStatus::Running) { + let id = BlockId(format!("exec-{}", ei)); out.push(UiBlock::ToolExec { - id: BlockId(format!("exec-{}", ei)), + id: id.clone(), name: exec.name.clone(), summary: exec.summary.clone(), status: exec.status, @@ -461,7 +473,8 @@ pub fn build_blocks( output: exec.output.clone(), result_text: exec.result_text.clone(), is_error: exec.is_error, - expanded: expanded.contains(&BlockId(format!("exec-{}", ei))), + expanded: expanded.contains(&id), + full: full.contains(&id), }); } } @@ -477,8 +490,10 @@ fn push_content_block( block: &ContentBlock, thinking_duration: Option, expanded: &HashSet, + full: &HashSet, ) { let is_expanded = expanded.contains(&id); + let is_full = full.contains(&id); match block { ContentBlock::Text(t) => out.push(UiBlock::Text { id, @@ -490,6 +505,7 @@ fn push_content_block( duration_ms: thinking_duration, tokens: focus_harness::estimate_tokens(&t.thinking), expanded: is_expanded, + full: is_full, }), ContentBlock::ToolCall(tc) => out.push(UiBlock::ToolCall { id, @@ -502,8 +518,20 @@ fn push_content_block( } } -/// 切换一个块的展开状态。 -/// Toggle a block's expansion state. +/// 三态展开循环:折叠 → 部分 → 全部 → 折叠。 +/// Three-state expansion cycle: collapsed → partial → full → collapsed. +pub fn cycle_expansion(expanded: &mut HashSet, full: &mut HashSet, id: &BlockId) { + if full.remove(id) { + expanded.remove(id); + } else if expanded.remove(id) { + full.insert(id.clone()); + } else { + expanded.insert(id.clone()); + } +} + +/// 切换一个块的展开状态(两态,供单级展开的块使用)。 +/// Toggle a block's expansion (two-state, for single-level blocks). pub fn toggle_expanded(expanded: &mut HashSet, id: &BlockId) { if !expanded.remove(id) { expanded.insert(id.clone()); diff --git a/crates/focus-tui/src/ui.rs b/crates/focus-tui/src/ui.rs index 10f7f59..f88c9ef 100644 --- a/crates/focus-tui/src/ui.rs +++ b/crates/focus-tui/src/ui.rs @@ -141,6 +141,7 @@ fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) { &app.tool_durations, app.run_state.as_ref(), &app.expanded, + &app.full, )); let width = area.width.saturating_sub(1).max(1) as usize; @@ -247,6 +248,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { duration_ms, tokens, expanded, + full, .. } => { if *expanded { @@ -254,9 +256,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { "▾ 🧠 thinking", Style::new().fg(Color::Yellow).bold(), ))); - for line in wrap_text(text, width.saturating_sub(2)) { - out.push(Line::from(Span::styled(format!(" {}", line), dim))); - } + out.extend(preview_lines(text, *full, width, " ", dim)); out.push(Line::from(Span::styled(" ──", dim))); } else { let dur = duration_ms @@ -313,6 +313,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { is_error, duration_ms, expanded, + full, .. } => { let mark = if *is_error { "✗" } else { "✓" }; @@ -329,9 +330,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { Style::new().fg(Color::Green).bold() }, ))); - for line in wrap_text(text, width.saturating_sub(2)) { - out.push(Line::from(Span::styled(format!(" {}", line), dim))); - } + out.extend(preview_lines(text, *full, width, " ", dim)); } else { out.push(Line::from(Span::styled( format!("▸ 📦 {} {}{}(点击/Tab 展开)", mark, name, dur), @@ -351,6 +350,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { result_text, is_error, expanded, + full, .. } => { let running = *status == ExecStatus::Running; @@ -360,23 +360,20 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { format!("▾ 🔧 {} · {}", summary, state), Style::new().fg(Color::Magenta).bold(), ))); - if running && !output.is_empty() { - for line in wrap_text(output, width.saturating_sub(2)) { - out.push(Line::from(Span::styled(format!(" {}", line), dim))); - } - } - if !running { - if !result_text.is_empty() { - for line in wrap_text(result_text, width.saturating_sub(2)) { - out.push(Line::from(Span::styled(format!(" {}", line), dim))); - } - } - if *is_error { - out.push(Line::from(Span::styled( - " ✗ 工具执行失败", - Style::new().fg(Color::Red), - ))); - } + // 运行中:流式输出实时预览;结束后:结果文本(部分/全部)。 + // Running: live streaming preview; done: result text + // (partial/full). + let body = if running { + output.clone() + } else { + result_text.clone() + }; + out.extend(preview_lines(&body, *full, width, " ", dim)); + if !running && *is_error { + out.push(Line::from(Span::styled( + " ✗ 工具执行失败", + Style::new().fg(Color::Red), + ))); } } else { let dur = duration_ms @@ -670,3 +667,45 @@ fn md_line_to_ratatui(md: &MdLine) -> Line<'static> { .collect(); Line::from(spans) } + +/// 展开时的内容预览:`full` 时显示全部,否则只显示前若干行并提示剩余行数。 +/// Content preview when expanded: `full` shows everything, otherwise the first +/// few lines plus a remaining-line-count hint. +fn preview_lines( + text: &str, + full: bool, + width: usize, + indent: &str, + style: Style, +) -> Vec> { + /// 部分预览的最大行数。 + /// Max lines shown in a partial preview. + const PARTIAL_MAX_LINES: usize = 20; + let total = text.lines().count(); + let lines: Vec<&str> = text.lines().collect(); + let shown: Vec<&str> = if full || total <= PARTIAL_MAX_LINES { + lines + } else { + lines.iter().take(PARTIAL_MAX_LINES).copied().collect() + }; + let mut out: Vec> = Vec::new(); + for line in shown { + for wrapped in wrap_text(line, width.saturating_sub(2)) { + out.push(Line::from(Span::styled( + format!("{}{}", indent, wrapped), + style, + ))); + } + } + if !full && total > PARTIAL_MAX_LINES { + out.push(Line::from(Span::styled( + format!( + "{}… 还有 {} 行(再次点击/Tab 展开全部)", + indent, + total - PARTIAL_MAX_LINES + ), + style, + ))); + } + out +} diff --git a/crates/focus-tui/tests/state_tests.rs b/crates/focus-tui/tests/state_tests.rs index 74d60e4..039b3c9 100644 --- a/crates/focus-tui/tests/state_tests.rs +++ b/crates/focus-tui/tests/state_tests.rs @@ -157,6 +157,7 @@ fn blocks_are_collapsed_by_default_and_summarized() { &HashMap::new(), None, &expanded, + &HashSet::new(), ); // 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。 @@ -197,6 +198,7 @@ fn blocks_are_collapsed_by_default_and_summarized() { &HashMap::new(), None, &expanded, + &HashSet::new(), ); let mut expanded_count = 0; for b in &blocks { @@ -230,6 +232,7 @@ fn build_blocks_includes_live_run() { &HashMap::new(), Some(&rs), &HashSet::new(), + &HashSet::new(), ); // live 头 + 思考 + 文本。 // live header + thinking + text. @@ -316,6 +319,7 @@ fn error_message_renders_as_error_block() { &HashMap::new(), None, &HashSet::new(), + &HashSet::new(), ); let err = blocks .iter() @@ -362,6 +366,7 @@ fn tool_duration_shows_in_tool_result_block() { &durations, None, &HashSet::new(), + &HashSet::new(), ); let tr = blocks.iter().find_map(|b| match b { UiBlock::ToolResult { duration_ms, .. } => Some(*duration_ms), @@ -398,6 +403,7 @@ fn live_renders_only_running_execs() { &HashMap::new(), Some(&rs), &HashSet::new(), + &HashSet::new(), ); assert!( !blocks.iter().any(|b| matches!(b, UiBlock::ToolExec { .. })), @@ -419,6 +425,7 @@ fn live_renders_only_running_execs() { &HashMap::new(), Some(&rs), &HashSet::new(), + &HashSet::new(), ); assert!( blocks.iter().any(|b| matches!( @@ -431,3 +438,62 @@ fn live_renders_only_running_execs() { "running exec must render" ); } + +/// 三态展开循环:折叠 → 部分 → 全部 → 折叠。 +/// Three-state expansion cycle: collapsed → partial → full → collapsed. +#[test] +fn expansion_cycles_three_states() { + let id = BlockId("live-0".into()); + let mut expanded = HashSet::new(); + let mut full = HashSet::new(); + + // 折叠 → 部分。 + // Collapsed → partial. + cycle_expansion(&mut expanded, &mut full, &id); + assert!(expanded.contains(&id)); + assert!(!full.contains(&id)); + + // 部分 → 全部。 + // Partial → full. + cycle_expansion(&mut expanded, &mut full, &id); + assert!(!expanded.contains(&id)); + assert!(full.contains(&id)); + + // 全部 → 折叠。 + // Full → collapsed. + cycle_expansion(&mut expanded, &mut full, &id); + assert!(!expanded.contains(&id)); + assert!(!full.contains(&id)); +} + +/// 二级展开标志进入 ToolResult 块。 +/// The full-level flag reaches ToolResult blocks. +#[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, + })]; + let mut full = HashSet::new(); + full.insert(BlockId("m0-r".into())); + let blocks = build_blocks( + &transcript, + &HashMap::new(), + &HashMap::new(), + None, + &HashSet::new(), + &full, + ); + let tr = blocks + .iter() + .find_map(|b| match b { + UiBlock::ToolResult { full, .. } => Some(*full), + _ => None, + }) + .expect("tool result block"); + assert!(tr, "full flag must propagate"); +}