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).
|
/// 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 把 Linux(xterm 协议)与 Windows(ConPTY)的滚轮事件
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue