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).
pub tool_durations: HashMap<(usize, usize), u64>,
pub expanded: HashSet<BlockId>,
/// 二级展开(显示全部内容)的块。
/// Blocks expanded to the full level (show everything).
pub full: HashSet<BlockId>,
pub notes: Vec<String>,
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 把 Linuxxterm 协议)与 WindowsConPTY的滚轮事件
// 都归一化为 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);
}
}

View File

@ -58,6 +58,10 @@ pub enum UiBlock {
duration_ms: Option<u64>,
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<u64>,
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<BlockId>,
full: &HashSet<BlockId>,
) -> Vec<UiBlock> {
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<u64>,
expanded: &HashSet<BlockId>,
full: &HashSet<BlockId>,
) {
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<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) {
if !expanded.remove(id) {
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.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<Line<'static>> {
duration_ms,
tokens,
expanded,
full,
..
} => {
if *expanded {
@ -254,9 +256,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
"▾ 🧠 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<Line<'static>> {
is_error,
duration_ms,
expanded,
full,
..
} => {
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()
},
)));
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<Line<'static>> {
result_text,
is_error,
expanded,
full,
..
} => {
let running = *status == ExecStatus::Running;
@ -360,23 +360,20 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
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<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(),
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");
}