diff --git a/crates/focus-tui/src/app.rs b/crates/focus-tui/src/app.rs index f1132ac..e840432 100644 --- a/crates/focus-tui/src/app.rs +++ b/crates/focus-tui/src/app.rs @@ -195,6 +195,9 @@ pub struct App { /// 已提交 assistant 消息的思考耗时((消息索引, 内容索引) → 毫秒)。 /// Committed assistant thinking durations ((message idx, content idx) → ms). 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, pub notes: Vec, pub status: String, @@ -232,6 +235,7 @@ impl App { run_join: None, run_state: None, thinking_durations: HashMap::new(), + tool_durations: HashMap::new(), expanded: HashSet::new(), notes: Vec::new(), status: String::new(), @@ -422,16 +426,33 @@ impl App { let tail_start = messages.len().saturating_sub(appended_count); let mut turn = 0usize; for (off, msg) in messages.iter().enumerate().skip(tail_start) { - if let Message::Assistant(a) = msg { - for (bi, block) in a.content.iter().enumerate() { - if let ContentBlock::Thinking(_) = block { - if let Some(timing) = rs.thinking.get(&(turn, bi)) { - self.thinking_durations - .insert((off, bi), timing.duration_ms()); + match msg { + Message::Assistant(a) => { + for (bi, block) in a.content.iter().enumerate() { + if let ContentBlock::Thinking(_) = block { + if let Some(timing) = rs.thinking.get(&(turn, bi)) { + 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.last_entry_id = None; self.thinking_durations.clear(); + self.tool_durations.clear(); self.expanded.clear(); self.last_usage = None; self.notes.clear(); @@ -553,6 +575,7 @@ impl App { self.session_id = Some(session_id.to_string()); self.last_entry_id = entries.last().map(|e| e.id.clone()); self.thinking_durations.clear(); + self.tool_durations.clear(); self.expanded.clear(); self.notes.clear(); self.push_note(format!( @@ -671,20 +694,48 @@ impl App { KeyCode::Delete => self.delete(), KeyCode::Left => 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 => { self.caret = self.line_start(self.caret); } KeyCode::End => { 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 => { - let max = self.max_scroll(); - self.scroll = (self.scroll + 10).min(max); + self.scroll = (self.scroll + self.view_height.max(1)).min(self.max_scroll()); + 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) => { + // 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 continues. if c == 'd' && key.modifiers.contains(KeyModifiers::CONTROL) { diff --git a/crates/focus-tui/src/format.rs b/crates/focus-tui/src/format.rs index 25987af..b9ee13b 100644 --- a/crates/focus-tui/src/format.rs +++ b/crates/focus-tui/src/format.rs @@ -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 { + 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). pub fn format_duration(ms: u64) -> String { diff --git a/crates/focus-tui/src/lib.rs b/crates/focus-tui/src/lib.rs index dc07409..44b902b 100644 --- a/crates/focus-tui/src/lib.rs +++ b/crates/focus-tui/src/lib.rs @@ -25,6 +25,9 @@ pub mod format; /// 后台任务:运行 agent、自动压缩。 /// Background jobs: running the agent, auto-compaction. pub mod job; +/// 极简 Markdown 渲染。 +/// Minimal Markdown rendering. +pub mod markdown; /// 纯 UI 状态机:从 Agent 事件构建可渲染的块(可独立测试)。 /// Pure UI state machine: builds renderable blocks from Agent events /// (testable without a terminal). diff --git a/crates/focus-tui/src/markdown.rs b/crates/focus-tui/src/markdown.rs new file mode 100644 index 0000000..37dadb2 --- /dev/null +++ b/crates/focus-tui/src/markdown.rs @@ -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) -> 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, +} + +/// 把 markdown 渲染为带样式的行(按 `width` 换行)。 +/// Render markdown into styled lines (wrapped at `width`). +pub fn render_markdown(text: &str, width: usize) -> Vec { + let mut out: Vec = 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 { + 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, 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, + style: MdBlockStyle, + level: u8, + spans: Vec, + 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 = 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 { + let mut out = Vec::new(); + let chars: Vec = 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) { + if !plain.is_empty() { + out.push(MdSpan::plain(std::mem::take(plain))); + } +} diff --git a/crates/focus-tui/src/state.rs b/crates/focus-tui/src/state.rs index b980b37..baeda52 100644 --- a/crates/focus-tui/src/state.rs +++ b/crates/focus-tui/src/state.rs @@ -75,6 +75,7 @@ pub enum UiBlock { name: String, text: String, is_error: bool, + duration_ms: Option, expanded: bool, }, /// 运行错误(assistant 消息的 error_message,红色显示)。 @@ -324,6 +325,13 @@ impl RunState { "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. pub fn finalize(&mut self) { @@ -356,6 +364,7 @@ fn join_text(blocks: &[ContentBlock]) -> String { pub fn build_blocks( transcript: &[Message], thinking_durations: &std::collections::HashMap<(usize, usize), u64>, + tool_durations: &std::collections::HashMap<(usize, usize), u64>, run_state: Option<&RunState>, expanded: &HashSet, ) -> Vec { @@ -400,6 +409,7 @@ pub fn build_blocks( 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))), }); } @@ -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 { id: BlockId(format!("exec-{}", ei)), name: exec.name.clone(), diff --git a/crates/focus-tui/src/ui.rs b/crates/focus-tui/src/ui.rs index 93462d2..10f7f59 100644 --- a/crates/focus-tui/src/ui.rs +++ b/crates/focus-tui/src/ui.rs @@ -2,13 +2,14 @@ //! Terminal rendering and the main loop (ratatui + crossterm). 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 focus_harness::estimate_messages; use focus_providers::config::resolve_context_window; use ratatui::backend::CrosstermBackend; 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::widgets::{Block, Borders, Paragraph, Wrap}; 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( &app.transcript, &app.thinking_durations, + &app.tool_durations, app.run_state.as_ref(), &app.expanded, )); @@ -236,9 +238,9 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { out.push(Line::from(Span::styled(meta, dim))); } UiBlock::Text { text, .. } => { - for line in wrap_text(text, width) { - out.push(Line::from(Span::raw(line))); - } + // LLM 输出多为 markdown:按块样式渲染。 + // LLM output is usually markdown: render it with block styles. + out.extend(markdown_lines(text, width)); } UiBlock::Thinking { text, @@ -291,9 +293,12 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { format!("▾ 🔧 {}", name), Style::new().fg(Color::Magenta).bold(), ))); - let json = focus_json::to_string(args); - for line in wrap_text(&json, width.saturating_sub(2)) { - out.push(Line::from(Span::styled(format!(" {}", line), dim))); + // 人类可读的参数(非原始 JSON)。 + // Human-readable arguments (not raw JSON). + 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 { out.push(Line::from(Span::styled( @@ -306,13 +311,18 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { name, text, is_error, + duration_ms, expanded, .. } => { let mark = if *is_error { "✗" } else { "✓" }; + let dur = duration_ms + .map(format_duration) + .map(|d| format!(" · {}", d)) + .unwrap_or_default(); if *expanded { out.push(Line::from(Span::styled( - format!("▾ 📦 {} {}", mark, name), + format!("▾ 📦 {} {}{}", mark, name, dur), if *is_error { Style::new().fg(Color::Red).bold() } else { @@ -324,7 +334,7 @@ fn block_lines(block: &UiBlock, width: usize) -> Vec> { } } else { out.push(Line::from(Span::styled( - format!("▸ 📦 {} {}(点击/Tab 展开)", mark, name), + format!("▸ 📦 {} {}{}(点击/Tab 展开)", mark, name, dur), if *is_error { Style::new().fg(Color::Red) } else { @@ -524,7 +534,7 @@ focus — 终端编码 agent 中止:Esc(运行中);退出:Esc(空闲)/ Ctrl+C 继续:Ctrl+D(对最后一条 assistant 消息再追问) 展开/折叠:点击思考/工具块,或 Tab 切换最近一个 -滚动:PageUp / PageDown / 鼠标滚轮 +滚动:↑↓(输入为空时)/ Ctrl+U / PageUp·PageDown / 鼠标滚轮 命令: /new 新会话 @@ -612,3 +622,51 @@ fn draw_sessions_modal(frame: &mut Frame, area: Rect, form: &crate::app::Session } // emphasis. + +/// 把 markdown 渲染为 ratatui 行。 +/// Render markdown into ratatui lines. +fn markdown_lines(text: &str, width: usize) -> Vec> { + 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 = 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) +} diff --git a/crates/focus-tui/tests/format_tests.rs b/crates/focus-tui/tests/format_tests.rs index 7d3bf6f..b681d4f 100644 --- a/crates/focus-tui/tests/format_tests.rs +++ b/crates/focus-tui/tests/format_tests.rs @@ -108,3 +108,34 @@ fn caret_positioning() { let (row, col, total) = caret_position("aaaaaa", 6, 3); 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); +} diff --git a/crates/focus-tui/tests/markdown_tests.rs b/crates/focus-tui/tests/markdown_tests.rs new file mode 100644 index 0000000..4c44395 --- /dev/null +++ b/crates/focus-tui/tests/markdown_tests.rs @@ -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 { + 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 + ); + } +} diff --git a/crates/focus-tui/tests/state_tests.rs b/crates/focus-tui/tests/state_tests.rs index f7caee8..74d60e4 100644 --- a/crates/focus-tui/tests/state_tests.rs +++ b/crates/focus-tui/tests/state_tests.rs @@ -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 @@ -185,7 +191,13 @@ fn blocks_are_collapsed_by_default_and_summarized() { 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; for b in &blocks { if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { expanded, .. } = b { @@ -212,7 +224,13 @@ fn build_blocks_includes_live_run() { 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 header + thinking + text. let thinking = blocks @@ -292,7 +310,13 @@ fn error_message_renders_as_error_block() { error_message: Some("http error 400: bad request detail".into()), 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 .iter() .find_map(|b| match b { @@ -302,3 +326,108 @@ fn error_message_renders_as_error_block() { .expect("error block"); 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" + ); +}