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:
DaiChaoXiong 2026-08-09 22:21:30 +08:00
parent 1d1f50d1b1
commit c6660e0b84
9 changed files with 822 additions and 28 deletions

View File

@ -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<BlockId>,
pub notes: Vec<String>,
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,7 +426,8 @@ 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 {
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)) {
@ -433,6 +438,22 @@ impl App {
}
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);
}
}
}
_ => {}
}
}
}
@ -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) {

View File

@ -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).
pub fn format_duration(ms: u64) -> String {

View File

@ -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).

View File

@ -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)));
}
}

View File

@ -75,6 +75,7 @@ pub enum UiBlock {
name: String,
text: String,
is_error: bool,
duration_ms: Option<u64>,
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<BlockId>,
) -> Vec<UiBlock> {
@ -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(),

View File

@ -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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
}
} 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<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)
}

View File

@ -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);
}

View File

@ -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
);
}
}

View File

@ -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"
);
}