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:
parent
c6660e0b84
commit
4b5f4cf52b
|
|
@ -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 把 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,24 +360,21 @@ 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 {
|
||||
// 运行中:流式输出实时预览;结束后:结果文本(部分/全部)。
|
||||
// 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
|
||||
.map(format_duration)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue