feat(tui): cross-platform wheel scroll and two-level expansion

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
This commit is contained in:
DaiChaoXiong 2026-08-09 22:30:04 +08:00
parent c6660e0b84
commit 4b5f4cf52b
4 changed files with 183 additions and 35 deletions

View File

@ -199,6 +199,9 @@ pub struct App {
/// Committed tool_result execution durations ((message idx, 0) → ms). /// Committed tool_result execution durations ((message idx, 0) → ms).
pub tool_durations: HashMap<(usize, usize), u64>, pub tool_durations: HashMap<(usize, usize), u64>,
pub expanded: HashSet<BlockId>, pub expanded: HashSet<BlockId>,
/// 二级展开(显示全部内容)的块。
/// Blocks expanded to the full level (show everything).
pub full: HashSet<BlockId>,
pub notes: Vec<String>, pub notes: Vec<String>,
pub status: String, pub status: String,
pub scroll: u16, pub scroll: u16,
@ -237,6 +240,7 @@ impl App {
thinking_durations: HashMap::new(), thinking_durations: HashMap::new(),
tool_durations: HashMap::new(), tool_durations: HashMap::new(),
expanded: HashSet::new(), expanded: HashSet::new(),
full: HashSet::new(),
notes: Vec::new(), notes: Vec::new(),
status: String::new(), status: String::new(),
scroll: 0, scroll: 0,
@ -552,6 +556,7 @@ impl App {
self.thinking_durations.clear(); self.thinking_durations.clear();
self.tool_durations.clear(); self.tool_durations.clear();
self.expanded.clear(); self.expanded.clear();
self.full.clear();
self.last_usage = None; self.last_usage = None;
self.notes.clear(); self.notes.clear();
self.push_note("新会话已开始。"); self.push_note("新会话已开始。");
@ -577,6 +582,7 @@ impl App {
self.thinking_durations.clear(); self.thinking_durations.clear();
self.tool_durations.clear(); self.tool_durations.clear();
self.expanded.clear(); self.expanded.clear();
self.full.clear();
self.notes.clear(); self.notes.clear();
self.push_note(format!( self.push_note(format!(
"已加载会话 {}{} 条消息)。", "已加载会话 {}{} 条消息)。",
@ -852,14 +858,23 @@ impl App {
.iter() .iter()
.find(|g| g.start <= content_row && content_row < g.end) .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), // crossterm 把 Linuxxterm 协议)与 WindowsConPTY的滚轮事件
MouseEventKind::ScrollDown => { // 都归一化为 ScrollUp/ScrollDown向上滚必须取消 follow
let max = self.max_scroll(); // 否则每次绘制会被吸回底部。
self.scroll = (self.scroll + 3).min(max); // 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()); .map(|g| g.id.clone());
if let Some(id) = id { if let Some(id) = id {
toggle_expanded(&mut self.expanded, &id); cycle_expansion(&mut self.expanded, &mut self.full, &id);
} }
} }

View File

@ -58,6 +58,10 @@ pub enum UiBlock {
duration_ms: Option<u64>, duration_ms: Option<u64>,
tokens: u64, tokens: u64,
expanded: bool, expanded: bool,
/// 二级展开true 表示显示全部内容false 时只显示部分)。
/// Second-level expansion: true shows everything (false shows a
/// partial preview).
full: bool,
}, },
/// assistant 发出的工具调用。 /// assistant 发出的工具调用。
/// A tool call issued by the assistant. /// A tool call issued by the assistant.
@ -77,6 +81,7 @@ pub enum UiBlock {
is_error: bool, is_error: bool,
duration_ms: Option<u64>, duration_ms: Option<u64>,
expanded: bool, 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).
@ -93,6 +98,7 @@ pub enum UiBlock {
result_text: String, result_text: String,
is_error: bool, is_error: bool,
expanded: bool, expanded: bool,
full: bool,
}, },
/// 系统提示(欢迎、帮助、状态说明等)。 /// 系统提示(欢迎、帮助、状态说明等)。
/// A system note (welcome, help, status). /// A system note (welcome, help, status).
@ -367,6 +373,7 @@ pub fn build_blocks(
tool_durations: &std::collections::HashMap<(usize, usize), u64>, tool_durations: &std::collections::HashMap<(usize, usize), u64>,
run_state: Option<&RunState>, run_state: Option<&RunState>,
expanded: &HashSet<BlockId>, expanded: &HashSet<BlockId>,
full: &HashSet<BlockId>,
) -> Vec<UiBlock> { ) -> Vec<UiBlock> {
let mut out = Vec::new(); let mut out = Vec::new();
for (mi, msg) in transcript.iter().enumerate() { for (mi, msg) in transcript.iter().enumerate() {
@ -392,6 +399,7 @@ pub fn build_blocks(
block, block,
thinking_durations.get(&(mi, bi)).copied(), thinking_durations.get(&(mi, bi)).copied(),
expanded, expanded,
full,
); );
} }
// 具体错误文本(如 provider 的 HTTP 400 详情)。 // 具体错误文本(如 provider 的 HTTP 400 详情)。
@ -404,13 +412,15 @@ pub fn build_blocks(
} }
} }
Message::ToolResult(t) => { Message::ToolResult(t) => {
let id = BlockId(format!("m{}-r", mi));
out.push(UiBlock::ToolResult { out.push(UiBlock::ToolResult {
id: BlockId(format!("m{}-r", mi)), id: id.clone(),
name: t.tool_name.clone(), name: t.tool_name.clone(),
text: join_text(&t.content), text: join_text(&t.content),
is_error: t.is_error, is_error: t.is_error,
duration_ms: tool_durations.get(&(mi, 0)).copied(), 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, block,
duration, duration,
expanded, expanded,
full,
); );
} }
if let Some(err) = &partial.error_message { if let Some(err) = &partial.error_message {
@ -452,8 +463,9 @@ pub fn build_blocks(
.enumerate() .enumerate()
.filter(|(_, e)| e.status == ExecStatus::Running) .filter(|(_, e)| e.status == ExecStatus::Running)
{ {
let id = BlockId(format!("exec-{}", ei));
out.push(UiBlock::ToolExec { out.push(UiBlock::ToolExec {
id: BlockId(format!("exec-{}", ei)), id: id.clone(),
name: exec.name.clone(), name: exec.name.clone(),
summary: exec.summary.clone(), summary: exec.summary.clone(),
status: exec.status, status: exec.status,
@ -461,7 +473,8 @@ pub fn build_blocks(
output: exec.output.clone(), output: exec.output.clone(),
result_text: exec.result_text.clone(), result_text: exec.result_text.clone(),
is_error: exec.is_error, 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, block: &ContentBlock,
thinking_duration: Option<u64>, thinking_duration: Option<u64>,
expanded: &HashSet<BlockId>, expanded: &HashSet<BlockId>,
full: &HashSet<BlockId>,
) { ) {
let is_expanded = expanded.contains(&id); let is_expanded = expanded.contains(&id);
let is_full = full.contains(&id);
match block { match block {
ContentBlock::Text(t) => out.push(UiBlock::Text { ContentBlock::Text(t) => out.push(UiBlock::Text {
id, id,
@ -490,6 +505,7 @@ fn push_content_block(
duration_ms: thinking_duration, duration_ms: thinking_duration,
tokens: focus_harness::estimate_tokens(&t.thinking), tokens: focus_harness::estimate_tokens(&t.thinking),
expanded: is_expanded, expanded: is_expanded,
full: is_full,
}), }),
ContentBlock::ToolCall(tc) => out.push(UiBlock::ToolCall { ContentBlock::ToolCall(tc) => out.push(UiBlock::ToolCall {
id, 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<BlockId>, full: &mut HashSet<BlockId>, 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<BlockId>, id: &BlockId) { pub fn toggle_expanded(expanded: &mut HashSet<BlockId>, id: &BlockId) {
if !expanded.remove(id) { if !expanded.remove(id) {
expanded.insert(id.clone()); expanded.insert(id.clone());

View File

@ -141,6 +141,7 @@ fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) {
&app.tool_durations, &app.tool_durations,
app.run_state.as_ref(), app.run_state.as_ref(),
&app.expanded, &app.expanded,
&app.full,
)); ));
let width = area.width.saturating_sub(1).max(1) as usize; let width = area.width.saturating_sub(1).max(1) as usize;
@ -247,6 +248,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
duration_ms, duration_ms,
tokens, tokens,
expanded, expanded,
full,
.. ..
} => { } => {
if *expanded { if *expanded {
@ -254,9 +256,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
"▾ 🧠 thinking", "▾ 🧠 thinking",
Style::new().fg(Color::Yellow).bold(), Style::new().fg(Color::Yellow).bold(),
))); )));
for line in wrap_text(text, width.saturating_sub(2)) { out.extend(preview_lines(text, *full, width, " ", dim));
out.push(Line::from(Span::styled(format!(" {}", line), dim)));
}
out.push(Line::from(Span::styled(" ──", dim))); out.push(Line::from(Span::styled(" ──", dim)));
} else { } else {
let dur = duration_ms let dur = duration_ms
@ -313,6 +313,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
is_error, is_error,
duration_ms, duration_ms,
expanded, expanded,
full,
.. ..
} => { } => {
let mark = if *is_error { "" } else { "" }; let mark = if *is_error { "" } else { "" };
@ -329,9 +330,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
Style::new().fg(Color::Green).bold() Style::new().fg(Color::Green).bold()
}, },
))); )));
for line in wrap_text(text, width.saturating_sub(2)) { out.extend(preview_lines(text, *full, width, " ", dim));
out.push(Line::from(Span::styled(format!(" {}", line), dim)));
}
} else { } else {
out.push(Line::from(Span::styled( out.push(Line::from(Span::styled(
format!("▸ 📦 {} {}{}(点击/Tab 展开)", mark, name, dur), format!("▸ 📦 {} {}{}(点击/Tab 展开)", mark, name, dur),
@ -351,6 +350,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
result_text, result_text,
is_error, is_error,
expanded, expanded,
full,
.. ..
} => { } => {
let running = *status == ExecStatus::Running; let running = *status == ExecStatus::Running;
@ -360,24 +360,21 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
format!("▾ 🔧 {} · {}", summary, state), format!("▾ 🔧 {} · {}", summary, state),
Style::new().fg(Color::Magenta).bold(), Style::new().fg(Color::Magenta).bold(),
))); )));
if running && !output.is_empty() { // 运行中:流式输出实时预览;结束后:结果文本(部分/全部)。
for line in wrap_text(output, width.saturating_sub(2)) { // Running: live streaming preview; done: result text
out.push(Line::from(Span::styled(format!(" {}", line), dim))); // (partial/full).
} let body = if running {
} output.clone()
if !running { } else {
if !result_text.is_empty() { result_text.clone()
for line in wrap_text(result_text, width.saturating_sub(2)) { };
out.push(Line::from(Span::styled(format!(" {}", line), dim))); out.extend(preview_lines(&body, *full, width, " ", dim));
} if !running && *is_error {
}
if *is_error {
out.push(Line::from(Span::styled( out.push(Line::from(Span::styled(
" ✗ 工具执行失败", " ✗ 工具执行失败",
Style::new().fg(Color::Red), Style::new().fg(Color::Red),
))); )));
} }
}
} else { } else {
let dur = duration_ms let dur = duration_ms
.map(format_duration) .map(format_duration)
@ -670,3 +667,45 @@ fn md_line_to_ratatui(md: &MdLine) -> Line<'static> {
.collect(); .collect();
Line::from(spans) 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<Line<'static>> {
/// 部分预览的最大行数。
/// 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<Line<'static>> = 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
}

View File

@ -157,6 +157,7 @@ fn blocks_are_collapsed_by_default_and_summarized() {
&HashMap::new(), &HashMap::new(),
None, None,
&expanded, &expanded,
&HashSet::new(),
); );
// 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。 // 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。
@ -197,6 +198,7 @@ fn blocks_are_collapsed_by_default_and_summarized() {
&HashMap::new(), &HashMap::new(),
None, None,
&expanded, &expanded,
&HashSet::new(),
); );
let mut expanded_count = 0; let mut expanded_count = 0;
for b in &blocks { for b in &blocks {
@ -230,6 +232,7 @@ fn build_blocks_includes_live_run() {
&HashMap::new(), &HashMap::new(),
Some(&rs), Some(&rs),
&HashSet::new(), &HashSet::new(),
&HashSet::new(),
); );
// live 头 + 思考 + 文本。 // live 头 + 思考 + 文本。
// live header + thinking + text. // live header + thinking + text.
@ -316,6 +319,7 @@ fn error_message_renders_as_error_block() {
&HashMap::new(), &HashMap::new(),
None, None,
&HashSet::new(), &HashSet::new(),
&HashSet::new(),
); );
let err = blocks let err = blocks
.iter() .iter()
@ -362,6 +366,7 @@ fn tool_duration_shows_in_tool_result_block() {
&durations, &durations,
None, None,
&HashSet::new(), &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::ToolResult { duration_ms, .. } => Some(*duration_ms),
@ -398,6 +403,7 @@ fn live_renders_only_running_execs() {
&HashMap::new(), &HashMap::new(),
Some(&rs), Some(&rs),
&HashSet::new(), &HashSet::new(),
&HashSet::new(),
); );
assert!( assert!(
!blocks.iter().any(|b| matches!(b, UiBlock::ToolExec { .. })), !blocks.iter().any(|b| matches!(b, UiBlock::ToolExec { .. })),
@ -419,6 +425,7 @@ fn live_renders_only_running_execs() {
&HashMap::new(), &HashMap::new(),
Some(&rs), Some(&rs),
&HashSet::new(), &HashSet::new(),
&HashSet::new(),
); );
assert!( assert!(
blocks.iter().any(|b| matches!( blocks.iter().any(|b| matches!(
@ -431,3 +438,62 @@ fn live_renders_only_running_execs() {
"running exec must render" "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");
}