feat(tui): markdown rendering, real-time transcript, scrolling, human args
1. scrolling: ↑/↓ scroll the messages when the input is empty (otherwise the caret moves), Ctrl+U scrolls up line-by-line, PageUp/PageDown page, wheel scrolls; follow-to-bottom restored on reaching the end 2. markdown: hand-rolled renderer (headers, bold/italic, inline code, fenced code blocks, lists, quotes, links, rules) applied to assistant/user text 3. ordering/refresh: messages are now committed into the transcript in real time at MessageEnd (assistant + tool results), so earlier turns never vanish mid-run and the latest content stays at the bottom; the live view shows only the streaming partial plus running tool executions (finished ones are presented by their tool_result messages, with duration) 4/5. expanded tool calls show human-readable arguments (command:/path:/edits as key-value lines) instead of raw JSON; tool results show duration new tests: markdown renderer, format_args_human, commit_partial, live running-only execs, tool-duration propagation
This commit is contained in:
parent
1d1f50d1b1
commit
c6660e0b84
|
|
@ -195,6 +195,9 @@ pub struct App {
|
||||||
/// 已提交 assistant 消息的思考耗时((消息索引, 内容索引) → 毫秒)。
|
/// 已提交 assistant 消息的思考耗时((消息索引, 内容索引) → 毫秒)。
|
||||||
/// Committed assistant thinking durations ((message idx, content idx) → ms).
|
/// Committed assistant thinking durations ((message idx, content idx) → ms).
|
||||||
pub thinking_durations: HashMap<(usize, usize), u64>,
|
pub thinking_durations: HashMap<(usize, usize), u64>,
|
||||||
|
/// 已提交 tool_result 消息的执行耗时((消息索引, 0) → 毫秒)。
|
||||||
|
/// Committed tool_result execution durations ((message idx, 0) → ms).
|
||||||
|
pub tool_durations: HashMap<(usize, usize), u64>,
|
||||||
pub expanded: HashSet<BlockId>,
|
pub expanded: HashSet<BlockId>,
|
||||||
pub notes: Vec<String>,
|
pub notes: Vec<String>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
|
@ -232,6 +235,7 @@ impl App {
|
||||||
run_join: None,
|
run_join: None,
|
||||||
run_state: None,
|
run_state: None,
|
||||||
thinking_durations: HashMap::new(),
|
thinking_durations: HashMap::new(),
|
||||||
|
tool_durations: HashMap::new(),
|
||||||
expanded: HashSet::new(),
|
expanded: HashSet::new(),
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
|
|
@ -422,16 +426,33 @@ impl App {
|
||||||
let tail_start = messages.len().saturating_sub(appended_count);
|
let tail_start = messages.len().saturating_sub(appended_count);
|
||||||
let mut turn = 0usize;
|
let mut turn = 0usize;
|
||||||
for (off, msg) in messages.iter().enumerate().skip(tail_start) {
|
for (off, msg) in messages.iter().enumerate().skip(tail_start) {
|
||||||
if let Message::Assistant(a) = msg {
|
match msg {
|
||||||
for (bi, block) in a.content.iter().enumerate() {
|
Message::Assistant(a) => {
|
||||||
if let ContentBlock::Thinking(_) = block {
|
for (bi, block) in a.content.iter().enumerate() {
|
||||||
if let Some(timing) = rs.thinking.get(&(turn, bi)) {
|
if let ContentBlock::Thinking(_) = block {
|
||||||
self.thinking_durations
|
if let Some(timing) = rs.thinking.get(&(turn, bi)) {
|
||||||
.insert((off, bi), timing.duration_ms());
|
self.thinking_durations
|
||||||
|
.insert((off, bi), timing.duration_ms());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
turn += 1;
|
||||||
|
}
|
||||||
|
Message::ToolResult(t) => {
|
||||||
|
// 把该轮的工具执行耗时映射到 tool_result 消息。
|
||||||
|
// Map the tool-execution duration onto the
|
||||||
|
// tool_result message.
|
||||||
|
if let Some(exec) = rs
|
||||||
|
.tool_execs
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.tool_call_id == t.tool_call_id)
|
||||||
|
{
|
||||||
|
if let Some(d) = exec.duration_ms() {
|
||||||
|
self.tool_durations.insert((off, 0), d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
turn += 1;
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -529,6 +550,7 @@ impl App {
|
||||||
self.session_id = None;
|
self.session_id = None;
|
||||||
self.last_entry_id = None;
|
self.last_entry_id = None;
|
||||||
self.thinking_durations.clear();
|
self.thinking_durations.clear();
|
||||||
|
self.tool_durations.clear();
|
||||||
self.expanded.clear();
|
self.expanded.clear();
|
||||||
self.last_usage = None;
|
self.last_usage = None;
|
||||||
self.notes.clear();
|
self.notes.clear();
|
||||||
|
|
@ -553,6 +575,7 @@ impl App {
|
||||||
self.session_id = Some(session_id.to_string());
|
self.session_id = Some(session_id.to_string());
|
||||||
self.last_entry_id = entries.last().map(|e| e.id.clone());
|
self.last_entry_id = entries.last().map(|e| e.id.clone());
|
||||||
self.thinking_durations.clear();
|
self.thinking_durations.clear();
|
||||||
|
self.tool_durations.clear();
|
||||||
self.expanded.clear();
|
self.expanded.clear();
|
||||||
self.notes.clear();
|
self.notes.clear();
|
||||||
self.push_note(format!(
|
self.push_note(format!(
|
||||||
|
|
@ -671,20 +694,48 @@ impl App {
|
||||||
KeyCode::Delete => self.delete(),
|
KeyCode::Delete => self.delete(),
|
||||||
KeyCode::Left => self.move_caret(-1),
|
KeyCode::Left => self.move_caret(-1),
|
||||||
KeyCode::Right => self.move_caret(1),
|
KeyCode::Right => self.move_caret(1),
|
||||||
KeyCode::Up => self.move_caret_line(-1),
|
|
||||||
KeyCode::Down => self.move_caret_line(1),
|
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
self.caret = self.line_start(self.caret);
|
self.caret = self.line_start(self.caret);
|
||||||
}
|
}
|
||||||
KeyCode::End => {
|
KeyCode::End => {
|
||||||
self.caret = self.line_end(self.caret);
|
self.caret = self.line_end(self.caret);
|
||||||
}
|
}
|
||||||
KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10),
|
KeyCode::PageUp => {
|
||||||
|
self.follow = false;
|
||||||
|
self.scroll = self.scroll.saturating_add(self.view_height.max(1));
|
||||||
|
self.scroll = self.scroll.min(self.max_scroll());
|
||||||
|
}
|
||||||
KeyCode::PageDown => {
|
KeyCode::PageDown => {
|
||||||
let max = self.max_scroll();
|
self.scroll = (self.scroll + self.view_height.max(1)).min(self.max_scroll());
|
||||||
self.scroll = (self.scroll + 10).min(max);
|
self.follow = self.scroll >= self.max_scroll();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if self.input.is_empty() {
|
||||||
|
// 输入为空时 ↑↓ 滚动消息区(否则用于移动光标)。
|
||||||
|
// Up/Down scroll the messages when the input is empty
|
||||||
|
// (otherwise they move the caret).
|
||||||
|
self.follow = false;
|
||||||
|
self.scroll = self.scroll.saturating_sub(1);
|
||||||
|
} else {
|
||||||
|
self.move_caret_line(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if self.input.is_empty() {
|
||||||
|
self.scroll = (self.scroll + 1).min(self.max_scroll());
|
||||||
|
self.follow = self.scroll >= self.max_scroll();
|
||||||
|
} else {
|
||||||
|
self.move_caret_line(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
|
// Ctrl+U 逐行向上滚动(vim 风格)。
|
||||||
|
// Ctrl+U scrolls up line-by-line (vim style).
|
||||||
|
if c == 'u' && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
self.follow = false;
|
||||||
|
self.scroll = self.scroll.saturating_sub(1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Ctrl+D 继续。
|
// Ctrl+D 继续。
|
||||||
// Ctrl+D continues.
|
// Ctrl+D continues.
|
||||||
if c == 'd' && key.modifiers.contains(KeyModifiers::CONTROL) {
|
if c == 'd' && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,42 @@ pub fn summarize_tool(name: &str, args: &JsonValue) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 把工具参数格式化为人类可读的多行文本(而非原始 JSON)。
|
||||||
|
/// Format tool arguments as human-readable lines (not raw JSON).
|
||||||
|
pub fn format_args_human(args: &JsonValue) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
match args {
|
||||||
|
JsonValue::Obj(entries) => {
|
||||||
|
for (k, v) in entries {
|
||||||
|
match v {
|
||||||
|
JsonValue::Str(s) => out.push(format!("{}: {}", k, s)),
|
||||||
|
JsonValue::Num(n) => out.push(format!("{}: {}", k, n)),
|
||||||
|
JsonValue::Bool(b) => out.push(format!("{}: {}", k, b)),
|
||||||
|
JsonValue::Arr(items) if k == "edits" => {
|
||||||
|
out.push(format!("{}: 共 {} 处修改", k, items.len()));
|
||||||
|
for (i, e) in items.iter().enumerate() {
|
||||||
|
let old_s = e
|
||||||
|
.get_str("oldString")
|
||||||
|
.map(|s| truncate(s, 60))
|
||||||
|
.unwrap_or_else(|| "?".into());
|
||||||
|
let new_s = e
|
||||||
|
.get_str("newString")
|
||||||
|
.map(|s| truncate(s, 60))
|
||||||
|
.unwrap_or_else(|| "?".into());
|
||||||
|
out.push(format!(" {}: {} → {}", i + 1, old_s, new_s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JsonValue::Arr(items) => out.push(format!("{}: [{} 项]", k, items.len())),
|
||||||
|
JsonValue::Null => out.push(format!("{}: (空)", k)),
|
||||||
|
_ => out.push(format!("{}: …", k)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => out.push(truncate(&focus_json::to_string(args), 200)),
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// 格式化耗时(毫秒 → 人类可读)。
|
/// 格式化耗时(毫秒 → 人类可读)。
|
||||||
/// Format a duration (ms → human readable).
|
/// Format a duration (ms → human readable).
|
||||||
pub fn format_duration(ms: u64) -> String {
|
pub fn format_duration(ms: u64) -> String {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@ pub mod format;
|
||||||
/// 后台任务:运行 agent、自动压缩。
|
/// 后台任务:运行 agent、自动压缩。
|
||||||
/// Background jobs: running the agent, auto-compaction.
|
/// Background jobs: running the agent, auto-compaction.
|
||||||
pub mod job;
|
pub mod job;
|
||||||
|
/// 极简 Markdown 渲染。
|
||||||
|
/// Minimal Markdown rendering.
|
||||||
|
pub mod markdown;
|
||||||
/// 纯 UI 状态机:从 Agent 事件构建可渲染的块(可独立测试)。
|
/// 纯 UI 状态机:从 Agent 事件构建可渲染的块(可独立测试)。
|
||||||
/// Pure UI state machine: builds renderable blocks from Agent events
|
/// Pure UI state machine: builds renderable blocks from Agent events
|
||||||
/// (testable without a terminal).
|
/// (testable without a terminal).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,384 @@
|
||||||
|
//! 极简 Markdown 渲染:把 LLM 输出的 markdown 转为带样式的行。
|
||||||
|
//! Minimal Markdown rendering: turns LLM markdown into styled lines.
|
||||||
|
//!
|
||||||
|
//! 手写实现(白名单内无 markdown crate):支持标题、粗体/斜体、行内代码、
|
||||||
|
//! 围栏代码块、列表、引用、链接、分隔线。不追求完整规范,覆盖常见输出即可。
|
||||||
|
//! Hand-rolled (no markdown crate in the whitelist): headers, bold/italic,
|
||||||
|
//! inline code, fenced code blocks, lists, quotes, links and rules. Not
|
||||||
|
//! spec-complete, just enough for typical LLM output.
|
||||||
|
|
||||||
|
/// 行级块样式。
|
||||||
|
/// Per-line block style.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MdBlockStyle {
|
||||||
|
/// 普通段落。
|
||||||
|
/// A plain paragraph.
|
||||||
|
Plain,
|
||||||
|
/// 标题(# / ## / ###,级别存于 `level`)。
|
||||||
|
/// A header (level in `level`).
|
||||||
|
Header,
|
||||||
|
/// 引用(>)。
|
||||||
|
/// A blockquote (>).
|
||||||
|
Quote,
|
||||||
|
/// 无序列表项。
|
||||||
|
/// An unordered list item.
|
||||||
|
Bullet,
|
||||||
|
/// 有序列表项。
|
||||||
|
/// An ordered list item.
|
||||||
|
Numbered,
|
||||||
|
/// 围栏代码块中的一行。
|
||||||
|
/// A line inside a fenced code block.
|
||||||
|
CodeBlock,
|
||||||
|
/// 分隔线(---)。
|
||||||
|
/// A horizontal rule (---).
|
||||||
|
Hr,
|
||||||
|
/// 空行。
|
||||||
|
/// A blank line.
|
||||||
|
Blank,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个带内联样式的文本片段。
|
||||||
|
/// One inline-styled text span.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MdSpan {
|
||||||
|
pub text: String,
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
/// 行内代码(`code`)。
|
||||||
|
/// Inline code (`code`).
|
||||||
|
pub code: bool,
|
||||||
|
/// 链接([text](url))。
|
||||||
|
/// A link ([text](url)).
|
||||||
|
pub link: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MdSpan {
|
||||||
|
fn plain(text: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.into(),
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
code: false,
|
||||||
|
link: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 渲染结果的一行。
|
||||||
|
/// One rendered line.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MdLine {
|
||||||
|
pub style: MdBlockStyle,
|
||||||
|
/// 标题级别(1-3,仅 Header 有意义)。
|
||||||
|
/// Header level (1-3, meaningful for Header only).
|
||||||
|
pub level: u8,
|
||||||
|
pub spans: Vec<MdSpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 markdown 渲染为带样式的行(按 `width` 换行)。
|
||||||
|
/// Render markdown into styled lines (wrapped at `width`).
|
||||||
|
pub fn render_markdown(text: &str, width: usize) -> Vec<MdLine> {
|
||||||
|
let mut out: Vec<MdLine> = Vec::new();
|
||||||
|
let mut in_code = false;
|
||||||
|
for raw in text.lines() {
|
||||||
|
if in_code {
|
||||||
|
if raw.trim().starts_with("```") {
|
||||||
|
in_code = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
push_code_line(&mut out, raw, width);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = raw.trim_start();
|
||||||
|
if trimmed.starts_with("```") {
|
||||||
|
in_code = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
out.push(MdLine {
|
||||||
|
style: MdBlockStyle::Blank,
|
||||||
|
level: 0,
|
||||||
|
spans: Vec::new(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(level) = header_level(trimmed) {
|
||||||
|
let body = trimmed[level as usize..].trim_start();
|
||||||
|
push_wrapped(
|
||||||
|
&mut out,
|
||||||
|
MdBlockStyle::Header,
|
||||||
|
level,
|
||||||
|
parse_inline(body),
|
||||||
|
width,
|
||||||
|
);
|
||||||
|
} else if let Some(q) = trimmed.strip_prefix(">") {
|
||||||
|
push_wrapped(
|
||||||
|
&mut out,
|
||||||
|
MdBlockStyle::Quote,
|
||||||
|
0,
|
||||||
|
parse_inline(q.trim_start()),
|
||||||
|
width,
|
||||||
|
);
|
||||||
|
} else if is_hr(trimmed) {
|
||||||
|
out.push(MdLine {
|
||||||
|
style: MdBlockStyle::Hr,
|
||||||
|
level: 0,
|
||||||
|
spans: vec![MdSpan::plain("─".repeat(width.max(3)))],
|
||||||
|
});
|
||||||
|
} else if let Some(num) = numbered_prefix(trimmed) {
|
||||||
|
let body = &trimmed[num.len()..];
|
||||||
|
let mut spans = vec![MdSpan::plain(num.to_string())];
|
||||||
|
spans.extend(parse_inline(body.trim_start()));
|
||||||
|
push_wrapped(&mut out, MdBlockStyle::Numbered, 0, spans, width);
|
||||||
|
} else if trimmed.starts_with("- ")
|
||||||
|
|| trimmed.starts_with("* ")
|
||||||
|
|| trimmed.starts_with("+ ")
|
||||||
|
{
|
||||||
|
let body = &trimmed[2..];
|
||||||
|
let mut spans = vec![MdSpan::plain("• ")];
|
||||||
|
spans.extend(parse_inline(body.trim_start()));
|
||||||
|
push_wrapped(&mut out, MdBlockStyle::Bullet, 0, spans, width);
|
||||||
|
} else {
|
||||||
|
push_wrapped(
|
||||||
|
&mut out,
|
||||||
|
MdBlockStyle::Plain,
|
||||||
|
0,
|
||||||
|
parse_inline(trimmed),
|
||||||
|
width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标题级别(返回 1-3,非标题返回 None)。
|
||||||
|
/// Header level (1-3, None if not a header).
|
||||||
|
fn header_level(s: &str) -> Option<u8> {
|
||||||
|
if s.starts_with("### ") {
|
||||||
|
Some(3)
|
||||||
|
} else if s.starts_with("## ") {
|
||||||
|
Some(2)
|
||||||
|
} else if s.starts_with("# ") {
|
||||||
|
Some(1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否为分隔线。
|
||||||
|
/// Whether the line is a horizontal rule.
|
||||||
|
fn is_hr(s: &str) -> bool {
|
||||||
|
let t = s.trim();
|
||||||
|
(t == "---" || t == "***" || t == "___") && t.len() >= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有序列表前缀(如 "1.");非有序列表返回 None。
|
||||||
|
/// An ordered-list prefix (e.g. "1."); None otherwise.
|
||||||
|
fn numbered_prefix(s: &str) -> Option<&str> {
|
||||||
|
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
|
if digits.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = &s[digits.len()..];
|
||||||
|
if rest.starts_with(". ") {
|
||||||
|
Some(&s[..digits.len() + 2])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 代码块行(整行作为一段文本)。
|
||||||
|
/// A code-block line (the whole line as one span).
|
||||||
|
fn push_code_line(out: &mut Vec<MdLine>, raw: &str, width: usize) {
|
||||||
|
for line in crate::format::wrap_text(raw, width) {
|
||||||
|
out.push(MdLine {
|
||||||
|
style: MdBlockStyle::CodeBlock,
|
||||||
|
level: 0,
|
||||||
|
spans: vec![MdSpan::plain(line)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按宽度换行推入一行(延续行复用同样的块样式与内联样式)。
|
||||||
|
/// Push one line wrapped at `width` (continuation lines keep the block style
|
||||||
|
/// and inline styles).
|
||||||
|
fn push_wrapped(
|
||||||
|
out: &mut Vec<MdLine>,
|
||||||
|
style: MdBlockStyle,
|
||||||
|
level: u8,
|
||||||
|
spans: Vec<MdSpan>,
|
||||||
|
width: usize,
|
||||||
|
) {
|
||||||
|
if spans.is_empty() {
|
||||||
|
out.push(MdLine {
|
||||||
|
style,
|
||||||
|
level,
|
||||||
|
spans,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 把 spans 拍平成 (字符, 样式) 流,按宽度切行,样式跟随字符。
|
||||||
|
// Flatten spans into a (char, style) stream and cut lines at `width`,
|
||||||
|
// carrying the style along with each char.
|
||||||
|
let width = width.max(1);
|
||||||
|
let mut current: Vec<MdSpan> = Vec::new();
|
||||||
|
let mut w = 0usize;
|
||||||
|
for span in spans {
|
||||||
|
for ch in span.text.chars() {
|
||||||
|
let cw = crate::format::display_width_char(ch);
|
||||||
|
if w + cw > width && w > 0 {
|
||||||
|
out.push(MdLine {
|
||||||
|
style,
|
||||||
|
level,
|
||||||
|
spans: std::mem::take(&mut current),
|
||||||
|
});
|
||||||
|
w = 0;
|
||||||
|
}
|
||||||
|
// 与上一个片段样式相同时合并(避免逐字符成片段)。
|
||||||
|
// Coalesce with the previous fragment when the style matches
|
||||||
|
// (avoid one span per char).
|
||||||
|
let same_style = current.last().map(|last| {
|
||||||
|
last.bold == span.bold
|
||||||
|
&& last.italic == span.italic
|
||||||
|
&& last.code == span.code
|
||||||
|
&& last.link == span.link
|
||||||
|
});
|
||||||
|
if same_style == Some(true) {
|
||||||
|
current.last_mut().expect("last span").text.push(ch);
|
||||||
|
} else {
|
||||||
|
current.push(MdSpan {
|
||||||
|
text: ch.to_string(),
|
||||||
|
bold: span.bold,
|
||||||
|
italic: span.italic,
|
||||||
|
code: span.code,
|
||||||
|
link: span.link,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
w += cw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
out.push(MdLine {
|
||||||
|
style,
|
||||||
|
level,
|
||||||
|
spans: current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行内解析:**粗体**、*斜体*、`行内代码`、[链接](url)。
|
||||||
|
/// Inline parsing: **bold**, *italic*, `inline code`, [link](url).
|
||||||
|
fn parse_inline(s: &str) -> Vec<MdSpan> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let chars: Vec<char> = s.chars().collect();
|
||||||
|
let mut i = 0usize;
|
||||||
|
let mut plain = String::new();
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if c == '`' {
|
||||||
|
// 行内代码:直到下一个反引号。
|
||||||
|
// Inline code: until the next backtick.
|
||||||
|
if let Some(end) = chars[i + 1..].iter().position(|&x| x == '`') {
|
||||||
|
let end = i + 1 + end;
|
||||||
|
flush_plain(&mut plain, &mut out);
|
||||||
|
let code: String = chars[i + 1..end].iter().collect();
|
||||||
|
out.push(MdSpan {
|
||||||
|
text: code,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
code: true,
|
||||||
|
link: false,
|
||||||
|
});
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
plain.push(c);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if c == '[' {
|
||||||
|
// 链接 [text](url)。
|
||||||
|
// A link [text](url).
|
||||||
|
if let Some(close) = chars[i + 1..].iter().position(|&x| x == ']') {
|
||||||
|
let close = i + 1 + close;
|
||||||
|
let after = close + 1;
|
||||||
|
if after < chars.len() && chars[after] == '(' {
|
||||||
|
if let Some(paren) = chars[after + 1..].iter().position(|&x| x == ')') {
|
||||||
|
let paren = after + 1 + paren;
|
||||||
|
flush_plain(&mut plain, &mut out);
|
||||||
|
let text: String = chars[i + 1..close].iter().collect();
|
||||||
|
out.push(MdSpan {
|
||||||
|
text,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
code: false,
|
||||||
|
link: true,
|
||||||
|
});
|
||||||
|
i = paren + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plain.push(c);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if c == '*' {
|
||||||
|
// **粗体** 或 *斜体*。
|
||||||
|
// **bold** or *italic*.
|
||||||
|
if i + 1 < chars.len() && chars[i + 1] == '*' {
|
||||||
|
if let Some(end) = chars[i + 2..]
|
||||||
|
.windows(2)
|
||||||
|
.position(|w| w[0] == '*' && w[1] == '*')
|
||||||
|
{
|
||||||
|
let end = i + 2 + end;
|
||||||
|
flush_plain(&mut plain, &mut out);
|
||||||
|
let inner: String = chars[i + 2..end].iter().collect();
|
||||||
|
out.push(MdSpan {
|
||||||
|
text: inner,
|
||||||
|
bold: true,
|
||||||
|
italic: false,
|
||||||
|
code: false,
|
||||||
|
link: false,
|
||||||
|
});
|
||||||
|
i = end + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(end) = chars[i + 1..].iter().position(|&x| x == '*') {
|
||||||
|
let end = i + 1 + end;
|
||||||
|
// 避免把 ** 误判为斜体:若该段以 * 结尾(如 **x** 的前半)则跳过。
|
||||||
|
// Avoid mistaking ** for italic: skip if the segment ends with
|
||||||
|
// a star (e.g. the first half of **x**).
|
||||||
|
if end > i + 1 && chars[end - 1] != '*' {
|
||||||
|
flush_plain(&mut plain, &mut out);
|
||||||
|
let inner: String = chars[i + 1..end].iter().collect();
|
||||||
|
out.push(MdSpan {
|
||||||
|
text: inner,
|
||||||
|
bold: false,
|
||||||
|
italic: true,
|
||||||
|
code: false,
|
||||||
|
link: false,
|
||||||
|
});
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plain.push(c);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
plain.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
flush_plain(&mut plain, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把累积的纯文本推入输出。
|
||||||
|
/// Flush accumulated plain text into the output.
|
||||||
|
fn flush_plain(plain: &mut String, out: &mut Vec<MdSpan>) {
|
||||||
|
if !plain.is_empty() {
|
||||||
|
out.push(MdSpan::plain(std::mem::take(plain)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,7 @@ pub enum UiBlock {
|
||||||
name: String,
|
name: String,
|
||||||
text: String,
|
text: String,
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
|
duration_ms: Option<u64>,
|
||||||
expanded: bool,
|
expanded: bool,
|
||||||
},
|
},
|
||||||
/// 运行错误(assistant 消息的 error_message,红色显示)。
|
/// 运行错误(assistant 消息的 error_message,红色显示)。
|
||||||
|
|
@ -324,6 +325,13 @@ impl RunState {
|
||||||
"working…".to_string()
|
"working…".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前 assistant 消息已提交进 transcript:清空 partial,避免重复显示。
|
||||||
|
/// The current assistant message was committed to the transcript: clear
|
||||||
|
/// the partial to avoid double-rendering.
|
||||||
|
pub fn commit_partial(&mut self) {
|
||||||
|
self.partial = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// 消息结束时收尾未结算的思考计时。
|
/// 消息结束时收尾未结算的思考计时。
|
||||||
/// Finalize unsettled thinking timings at message end.
|
/// Finalize unsettled thinking timings at message end.
|
||||||
pub fn finalize(&mut self) {
|
pub fn finalize(&mut self) {
|
||||||
|
|
@ -356,6 +364,7 @@ fn join_text(blocks: &[ContentBlock]) -> String {
|
||||||
pub fn build_blocks(
|
pub fn build_blocks(
|
||||||
transcript: &[Message],
|
transcript: &[Message],
|
||||||
thinking_durations: &std::collections::HashMap<(usize, usize), u64>,
|
thinking_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>,
|
||||||
) -> Vec<UiBlock> {
|
) -> Vec<UiBlock> {
|
||||||
|
|
@ -400,6 +409,7 @@ pub fn build_blocks(
|
||||||
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(),
|
||||||
expanded: expanded.contains(&BlockId(format!("m{}-r", mi))),
|
expanded: expanded.contains(&BlockId(format!("m{}-r", mi))),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -432,7 +442,16 @@ pub fn build_blocks(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (ei, exec) in rs.tool_execs.iter().enumerate() {
|
// 只显示仍在运行的工具执行(已结束的由 transcript 中的 tool_result
|
||||||
|
// 消息呈现,避免重复)。
|
||||||
|
// Only running tool executions are shown live; finished ones are
|
||||||
|
// presented by the transcript's tool_result messages (no duplicates).
|
||||||
|
for (ei, exec) in rs
|
||||||
|
.tool_execs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, e)| e.status == ExecStatus::Running)
|
||||||
|
{
|
||||||
out.push(UiBlock::ToolExec {
|
out.push(UiBlock::ToolExec {
|
||||||
id: BlockId(format!("exec-{}", ei)),
|
id: BlockId(format!("exec-{}", ei)),
|
||||||
name: exec.name.clone(),
|
name: exec.name.clone(),
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
//! Terminal rendering and the main loop (ratatui + crossterm).
|
//! Terminal rendering and the main loop (ratatui + crossterm).
|
||||||
|
|
||||||
use crate::app::{App, BlockGeometry, ConfigForm, FormField, Modal};
|
use crate::app::{App, BlockGeometry, ConfigForm, FormField, Modal};
|
||||||
use crate::format::{format_duration, format_tokens, truncate, wrap_text};
|
use crate::format::{format_args_human, format_duration, format_tokens, truncate, wrap_text};
|
||||||
|
use crate::markdown::{render_markdown, MdBlockStyle, MdLine};
|
||||||
use crate::state::{ExecStatus, UiBlock};
|
use crate::state::{ExecStatus, UiBlock};
|
||||||
use focus_harness::estimate_messages;
|
use focus_harness::estimate_messages;
|
||||||
use focus_providers::config::resolve_context_window;
|
use focus_providers::config::resolve_context_window;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Alignment, Constraint, Layout, Margin, Position, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Margin, Position, Rect};
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span, Text};
|
use ratatui::text::{Line, Span, Text};
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||||
use ratatui::{Frame, Terminal};
|
use ratatui::{Frame, Terminal};
|
||||||
|
|
@ -137,6 +138,7 @@ fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||||
blocks.extend(crate::state::build_blocks(
|
blocks.extend(crate::state::build_blocks(
|
||||||
&app.transcript,
|
&app.transcript,
|
||||||
&app.thinking_durations,
|
&app.thinking_durations,
|
||||||
|
&app.tool_durations,
|
||||||
app.run_state.as_ref(),
|
app.run_state.as_ref(),
|
||||||
&app.expanded,
|
&app.expanded,
|
||||||
));
|
));
|
||||||
|
|
@ -236,9 +238,9 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
|
||||||
out.push(Line::from(Span::styled(meta, dim)));
|
out.push(Line::from(Span::styled(meta, dim)));
|
||||||
}
|
}
|
||||||
UiBlock::Text { text, .. } => {
|
UiBlock::Text { text, .. } => {
|
||||||
for line in wrap_text(text, width) {
|
// LLM 输出多为 markdown:按块样式渲染。
|
||||||
out.push(Line::from(Span::raw(line)));
|
// LLM output is usually markdown: render it with block styles.
|
||||||
}
|
out.extend(markdown_lines(text, width));
|
||||||
}
|
}
|
||||||
UiBlock::Thinking {
|
UiBlock::Thinking {
|
||||||
text,
|
text,
|
||||||
|
|
@ -291,9 +293,12 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
|
||||||
format!("▾ 🔧 {}", name),
|
format!("▾ 🔧 {}", name),
|
||||||
Style::new().fg(Color::Magenta).bold(),
|
Style::new().fg(Color::Magenta).bold(),
|
||||||
)));
|
)));
|
||||||
let json = focus_json::to_string(args);
|
// 人类可读的参数(非原始 JSON)。
|
||||||
for line in wrap_text(&json, width.saturating_sub(2)) {
|
// Human-readable arguments (not raw JSON).
|
||||||
out.push(Line::from(Span::styled(format!(" {}", line), dim)));
|
for line in format_args_human(args) {
|
||||||
|
for wrapped in wrap_text(&line, width.saturating_sub(2)) {
|
||||||
|
out.push(Line::from(Span::styled(format!(" {}", wrapped), dim)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
out.push(Line::from(Span::styled(
|
out.push(Line::from(Span::styled(
|
||||||
|
|
@ -306,13 +311,18 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
|
||||||
name,
|
name,
|
||||||
text,
|
text,
|
||||||
is_error,
|
is_error,
|
||||||
|
duration_ms,
|
||||||
expanded,
|
expanded,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let mark = if *is_error { "✗" } else { "✓" };
|
let mark = if *is_error { "✗" } else { "✓" };
|
||||||
|
let dur = duration_ms
|
||||||
|
.map(format_duration)
|
||||||
|
.map(|d| format!(" · {}", d))
|
||||||
|
.unwrap_or_default();
|
||||||
if *expanded {
|
if *expanded {
|
||||||
out.push(Line::from(Span::styled(
|
out.push(Line::from(Span::styled(
|
||||||
format!("▾ 📦 {} {}", mark, name),
|
format!("▾ 📦 {} {}{}", mark, name, dur),
|
||||||
if *is_error {
|
if *is_error {
|
||||||
Style::new().fg(Color::Red).bold()
|
Style::new().fg(Color::Red).bold()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -324,7 +334,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec<Line<'static>> {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
out.push(Line::from(Span::styled(
|
out.push(Line::from(Span::styled(
|
||||||
format!("▸ 📦 {} {}(点击/Tab 展开)", mark, name),
|
format!("▸ 📦 {} {}{}(点击/Tab 展开)", mark, name, dur),
|
||||||
if *is_error {
|
if *is_error {
|
||||||
Style::new().fg(Color::Red)
|
Style::new().fg(Color::Red)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -524,7 +534,7 @@ focus — 终端编码 agent
|
||||||
中止:Esc(运行中);退出:Esc(空闲)/ Ctrl+C
|
中止:Esc(运行中);退出:Esc(空闲)/ Ctrl+C
|
||||||
继续:Ctrl+D(对最后一条 assistant 消息再追问)
|
继续:Ctrl+D(对最后一条 assistant 消息再追问)
|
||||||
展开/折叠:点击思考/工具块,或 Tab 切换最近一个
|
展开/折叠:点击思考/工具块,或 Tab 切换最近一个
|
||||||
滚动:PageUp / PageDown / 鼠标滚轮
|
滚动:↑↓(输入为空时)/ Ctrl+U / PageUp·PageDown / 鼠标滚轮
|
||||||
|
|
||||||
命令:
|
命令:
|
||||||
/new 新会话
|
/new 新会话
|
||||||
|
|
@ -612,3 +622,51 @@ fn draw_sessions_modal(frame: &mut Frame, area: Rect, form: &crate::app::Session
|
||||||
}
|
}
|
||||||
|
|
||||||
// emphasis.
|
// emphasis.
|
||||||
|
|
||||||
|
/// 把 markdown 渲染为 ratatui 行。
|
||||||
|
/// Render markdown into ratatui lines.
|
||||||
|
fn markdown_lines(text: &str, width: usize) -> Vec<Line<'static>> {
|
||||||
|
let lines = render_markdown(text, width);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for md in lines {
|
||||||
|
out.push(md_line_to_ratatui(&md));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把一条渲染后的 markdown 行映射为 ratatui 行。
|
||||||
|
/// Map one rendered markdown line to a ratatui line.
|
||||||
|
fn md_line_to_ratatui(md: &MdLine) -> Line<'static> {
|
||||||
|
let block_style: Style = match md.style {
|
||||||
|
MdBlockStyle::Header => match md.level {
|
||||||
|
1 => Style::new().fg(Color::Cyan).bold(),
|
||||||
|
2 => Style::new().fg(Color::Cyan),
|
||||||
|
_ => Style::new().fg(Color::Yellow),
|
||||||
|
},
|
||||||
|
MdBlockStyle::CodeBlock => Style::new().fg(Color::DarkGray),
|
||||||
|
MdBlockStyle::Quote => Style::new().fg(Color::DarkGray),
|
||||||
|
MdBlockStyle::Hr | MdBlockStyle::Blank => Style::new().fg(Color::DarkGray),
|
||||||
|
MdBlockStyle::Bullet | MdBlockStyle::Numbered | MdBlockStyle::Plain => Style::new(),
|
||||||
|
};
|
||||||
|
let spans: Vec<Span> = md
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|sp| {
|
||||||
|
let mut style = block_style;
|
||||||
|
if sp.bold {
|
||||||
|
style = style.add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
if sp.italic {
|
||||||
|
style = style.add_modifier(Modifier::ITALIC);
|
||||||
|
}
|
||||||
|
if sp.code {
|
||||||
|
style = style.fg(Color::Yellow);
|
||||||
|
}
|
||||||
|
if sp.link {
|
||||||
|
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
|
||||||
|
}
|
||||||
|
Span::styled(sp.text.clone(), style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,3 +108,34 @@ fn caret_positioning() {
|
||||||
let (row, col, total) = caret_position("aaaaaa", 6, 3);
|
let (row, col, total) = caret_position("aaaaaa", 6, 3);
|
||||||
assert_eq!((row, col, total), (1, 3, 2));
|
assert_eq!((row, col, total), (1, 3, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_human_format_no_raw_json() {
|
||||||
|
use focus_tui::format::format_args_human;
|
||||||
|
|
||||||
|
// shell:命令按原样。
|
||||||
|
// shell: the command verbatim.
|
||||||
|
let args = obj(&[("command", "ls -la".into())]);
|
||||||
|
assert_eq!(
|
||||||
|
format_args_human(&args),
|
||||||
|
vec!["command: ls -la".to_string()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// read:路径。
|
||||||
|
// read: the path.
|
||||||
|
let args = obj(&[("path", "/a/b.txt".into())]);
|
||||||
|
assert_eq!(format_args_human(&args), vec!["path: /a/b.txt".to_string()]);
|
||||||
|
|
||||||
|
// edit:多组修改以人类可读展示(非 JSON)。
|
||||||
|
// edit: multiple edits shown human-readably (not JSON).
|
||||||
|
let mut e1 = JsonValue::obj();
|
||||||
|
e1.insert("oldString", "foo".into()).ok();
|
||||||
|
e1.insert("newString", "bar".into()).ok();
|
||||||
|
let edits = JsonValue::Arr(vec![e1]);
|
||||||
|
let args = obj(&[("path", "f.txt".into()), ("edits", edits)]);
|
||||||
|
let lines = format_args_human(&args);
|
||||||
|
assert_eq!(lines[0], "path: f.txt");
|
||||||
|
assert_eq!(lines[1], "edits: 共 1 处修改");
|
||||||
|
assert!(lines[2].contains("foo"), "got {:?}", lines);
|
||||||
|
assert!(lines[2].contains("bar"), "got {:?}", lines);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
//! 极简 Markdown 渲染器的测试。
|
||||||
|
//! Tests for the minimal Markdown renderer.
|
||||||
|
|
||||||
|
use focus_tui::markdown::{render_markdown, MdBlockStyle, MdLine};
|
||||||
|
|
||||||
|
fn text_of(md: &MdLine) -> String {
|
||||||
|
md.spans.iter().map(|s| s.text.as_str()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(lines: &[MdLine]) -> Vec<String> {
|
||||||
|
lines.iter().map(text_of).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_headers() {
|
||||||
|
let out = render_markdown("# Title\n## Sub\n### Deep\nplain", 80);
|
||||||
|
assert_eq!(out[0].style, MdBlockStyle::Header);
|
||||||
|
assert_eq!(out[0].level, 1);
|
||||||
|
assert_eq!(text_of(&out[0]), "Title");
|
||||||
|
assert_eq!(out[1].level, 2);
|
||||||
|
assert_eq!(out[2].level, 3);
|
||||||
|
assert_eq!(out[3].style, MdBlockStyle::Plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_bold_italic_and_code() {
|
||||||
|
let out = render_markdown("**bold** and *italic* and `code`", 80);
|
||||||
|
let line = &out[0];
|
||||||
|
// 5 段:bold, " and ", italic, " and ", code。
|
||||||
|
// 5 spans: bold, " and ", italic, " and ", code.
|
||||||
|
assert_eq!(line.spans.len(), 5);
|
||||||
|
let bold = line.spans.iter().find(|s| s.bold).expect("bold span");
|
||||||
|
assert_eq!(bold.text, "bold");
|
||||||
|
let italic = line.spans.iter().find(|s| s.italic).expect("italic span");
|
||||||
|
assert_eq!(italic.text, "italic");
|
||||||
|
let code = line.spans.iter().find(|s| s.code).expect("code span");
|
||||||
|
assert_eq!(code.text, "code");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_fenced_code_blocks() {
|
||||||
|
let md = "```rust\nfn main() {}\n```\nafter";
|
||||||
|
let out = render_markdown(md, 80);
|
||||||
|
assert_eq!(out[0].style, MdBlockStyle::CodeBlock);
|
||||||
|
assert_eq!(text_of(&out[0]), "fn main() {}");
|
||||||
|
assert_eq!(out[1].style, MdBlockStyle::Plain);
|
||||||
|
assert_eq!(text_of(&out[1]), "after");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_lists_and_quotes() {
|
||||||
|
let md = "- item a\n- item b\n1. first\n2. second\n> quote text";
|
||||||
|
let out = render_markdown(md, 80);
|
||||||
|
let joined = join(&out);
|
||||||
|
assert_eq!(joined[0], "• item a");
|
||||||
|
assert_eq!(joined[1], "• item b");
|
||||||
|
assert_eq!(joined[2], "1. first");
|
||||||
|
assert_eq!(joined[3], "2. second");
|
||||||
|
assert_eq!(out[4].style, MdBlockStyle::Quote);
|
||||||
|
assert_eq!(text_of(&out[4]), "quote text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_links_and_hr() {
|
||||||
|
let out = render_markdown("see [docs](https://example.com)\n---", 80);
|
||||||
|
let link = out[0].spans.iter().find(|s| s.link).expect("link span");
|
||||||
|
assert_eq!(link.text, "docs");
|
||||||
|
assert_eq!(out[1].style, MdBlockStyle::Hr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wraps_at_width() {
|
||||||
|
let out = render_markdown("aaaaaa bbbbbb cccccc", 8);
|
||||||
|
let joined = join(&out);
|
||||||
|
assert!(joined.len() >= 2, "got {:?}", joined);
|
||||||
|
for line in &joined {
|
||||||
|
assert!(
|
||||||
|
focus_tui::format::display_width(line) <= 8,
|
||||||
|
"line too wide: {:?}",
|
||||||
|
line
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -151,7 +151,13 @@ fn blocks_are_collapsed_by_default_and_summarized() {
|
||||||
}),
|
}),
|
||||||
])),
|
])),
|
||||||
];
|
];
|
||||||
let blocks = build_blocks(&transcript, &HashMap::new(), None, &expanded);
|
let blocks = build_blocks(
|
||||||
|
&transcript,
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
None,
|
||||||
|
&expanded,
|
||||||
|
);
|
||||||
|
|
||||||
// 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。
|
// 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。
|
||||||
// Collapsed: thinking shows only its summary; tool calls show only their
|
// Collapsed: thinking shows only its summary; tool calls show only their
|
||||||
|
|
@ -185,7 +191,13 @@ fn blocks_are_collapsed_by_default_and_summarized() {
|
||||||
toggle_expanded(&mut expanded, id);
|
toggle_expanded(&mut expanded, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let blocks = build_blocks(&transcript, &HashMap::new(), None, &expanded);
|
let blocks = build_blocks(
|
||||||
|
&transcript,
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
None,
|
||||||
|
&expanded,
|
||||||
|
);
|
||||||
let mut expanded_count = 0;
|
let mut expanded_count = 0;
|
||||||
for b in &blocks {
|
for b in &blocks {
|
||||||
if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { expanded, .. } = b {
|
if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { expanded, .. } = b {
|
||||||
|
|
@ -212,7 +224,13 @@ fn build_blocks_includes_live_run() {
|
||||||
event_type: "text_start".into(),
|
event_type: "text_start".into(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let blocks = build_blocks(&[], &HashMap::new(), Some(&rs), &HashSet::new());
|
let blocks = build_blocks(
|
||||||
|
&[],
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
Some(&rs),
|
||||||
|
&HashSet::new(),
|
||||||
|
);
|
||||||
// live 头 + 思考 + 文本。
|
// live 头 + 思考 + 文本。
|
||||||
// live header + thinking + text.
|
// live header + thinking + text.
|
||||||
let thinking = blocks
|
let thinking = blocks
|
||||||
|
|
@ -292,7 +310,13 @@ fn error_message_renders_as_error_block() {
|
||||||
error_message: Some("http error 400: bad request detail".into()),
|
error_message: Some("http error 400: bad request detail".into()),
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
})];
|
})];
|
||||||
let blocks = build_blocks(&transcript, &HashMap::new(), None, &HashSet::new());
|
let blocks = build_blocks(
|
||||||
|
&transcript,
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
None,
|
||||||
|
&HashSet::new(),
|
||||||
|
);
|
||||||
let err = blocks
|
let err = blocks
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|b| match b {
|
.find_map(|b| match b {
|
||||||
|
|
@ -302,3 +326,108 @@ fn error_message_renders_as_error_block() {
|
||||||
.expect("error block");
|
.expect("error block");
|
||||||
assert_eq!(err, "http error 400: bad request detail");
|
assert_eq!(err, "http error 400: bad request detail");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// commit_partial 清空流式 partial(消息已提交进 transcript 后避免重复显示)。
|
||||||
|
/// commit_partial clears the streaming partial (avoids double-rendering once
|
||||||
|
/// the message is committed into the transcript).
|
||||||
|
#[test]
|
||||||
|
fn commit_partial_clears_live_message() {
|
||||||
|
let fc = FakeClock::new();
|
||||||
|
let mut rs = RunState::new(fc.clock());
|
||||||
|
rs.on_agent_event(&AgentEvent::MessageStart {
|
||||||
|
message: Message::Assistant(assistant(vec![ContentBlock::text("hi")])),
|
||||||
|
});
|
||||||
|
assert!(rs.partial.is_some());
|
||||||
|
rs.commit_partial();
|
||||||
|
assert!(rs.partial.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已提交 tool_result 消息的耗时进入摘要行。
|
||||||
|
/// A committed tool_result's duration reaches its summary.
|
||||||
|
#[test]
|
||||||
|
fn tool_duration_shows_in_tool_result_block() {
|
||||||
|
let transcript = vec![Message::ToolResult(ToolResultMessage {
|
||||||
|
tool_call_id: "c1".into(),
|
||||||
|
tool_name: "shell".into(),
|
||||||
|
content: vec![ContentBlock::text("ok")],
|
||||||
|
details: JsonValue::obj(),
|
||||||
|
is_error: false,
|
||||||
|
timestamp: 0,
|
||||||
|
})];
|
||||||
|
let mut durations = HashMap::new();
|
||||||
|
durations.insert((0usize, 0usize), 12345u64);
|
||||||
|
let blocks = build_blocks(
|
||||||
|
&transcript,
|
||||||
|
&HashMap::new(),
|
||||||
|
&durations,
|
||||||
|
None,
|
||||||
|
&HashSet::new(),
|
||||||
|
);
|
||||||
|
let tr = blocks.iter().find_map(|b| match b {
|
||||||
|
UiBlock::ToolResult { duration_ms, .. } => Some(*duration_ms),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
assert_eq!(tr, Some(Some(12_345)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式视图只显示运行中的工具执行(已结束的由 transcript 呈现)。
|
||||||
|
/// The live view only shows running tool executions (finished ones are
|
||||||
|
/// presented by the transcript).
|
||||||
|
#[test]
|
||||||
|
fn live_renders_only_running_execs() {
|
||||||
|
let fc = FakeClock::new();
|
||||||
|
let mut rs = RunState::new(fc.clock());
|
||||||
|
let mut args = JsonValue::obj();
|
||||||
|
args.insert("command", "cargo test".into()).ok();
|
||||||
|
rs.on_agent_event(&AgentEvent::ToolExecutionStart {
|
||||||
|
tool_call_id: "c1".into(),
|
||||||
|
tool_name: "shell".into(),
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
rs.on_agent_event(&AgentEvent::ToolExecutionEnd {
|
||||||
|
tool_call_id: "c1".into(),
|
||||||
|
tool_name: "shell".into(),
|
||||||
|
result: ToolResult::text("done"),
|
||||||
|
is_error: false,
|
||||||
|
});
|
||||||
|
// 全部结束 → 不渲染任何执行块。
|
||||||
|
// All finished → no execution blocks rendered.
|
||||||
|
let blocks = build_blocks(
|
||||||
|
&[],
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
Some(&rs),
|
||||||
|
&HashSet::new(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!blocks.iter().any(|b| matches!(b, UiBlock::ToolExec { .. })),
|
||||||
|
"finished execs must not render live"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 一个新的运行中工具 → 渲染。
|
||||||
|
// A new running tool → rendered.
|
||||||
|
let mut args = JsonValue::obj();
|
||||||
|
args.insert("command", "sleep 1".into()).ok();
|
||||||
|
rs.on_agent_event(&AgentEvent::ToolExecutionStart {
|
||||||
|
tool_call_id: "c2".into(),
|
||||||
|
tool_name: "shell".into(),
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
let blocks = build_blocks(
|
||||||
|
&[],
|
||||||
|
&HashMap::new(),
|
||||||
|
&HashMap::new(),
|
||||||
|
Some(&rs),
|
||||||
|
&HashSet::new(),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
blocks.iter().any(|b| matches!(
|
||||||
|
b,
|
||||||
|
UiBlock::ToolExec {
|
||||||
|
status: ExecStatus::Running,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
)),
|
||||||
|
"running exec must render"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue