From dfe5d59debf80313cee04024b0ad14b079fe3a87 Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Thu, 1 Oct 2026 09:42:04 +0200 Subject: [PATCH 1/5] Render assistant output as markdown and separate it from user input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dependency-free, line-buffered markdown renderer for the terminal. Model replies are streamed token-by-token, so full re-rendering is impossible; instead each line is styled as soon as its newline arrives: - fenced code blocks (markers hidden, language tag shown, content dimmed) - headings, bullet/numbered lists, blockquotes and horizontal rules - inline bold, code spans and [text](url) links - unmatched markers are emitted verbatim — text is never dropped - raw model-emitted ANSI is contained per line so styling cannot leak MarkdownStream powers live streaming; render_markdown re-prints saved session history so a reloaded session looks identical to the original run. Assistant prose now uses the terminal's default foreground instead of an ambient orange color, which the spinner/thinking RESET could tear down mid-stream. The agent loop also prints an "Assistant" divider once per user turn and the input prompt gains a bold "You >" label, giving clear visual separation between user and model output in both live output and reloaded session history. --- src/agent/display.rs | 111 ++++- src/agent/mod.rs | 49 +-- tinyharness-ui/src/style.rs | 12 +- tinyharness-ui/src/ui/markdown.rs | 670 ++++++++++++++++++++++++++++++ tinyharness-ui/src/ui/mod.rs | 1 + 5 files changed, 809 insertions(+), 34 deletions(-) create mode 100644 tinyharness-ui/src/ui/markdown.rs diff --git a/src/agent/display.rs b/src/agent/display.rs index 5f721ff..167a12a 100644 --- a/src/agent/display.rs +++ b/src/agent/display.rs @@ -96,6 +96,16 @@ pub fn print_context_load_warning( Ok(()) } +/// Separate assistant output from user input without coloring the prose. +pub fn write_assistant_header(stdout: &mut W) -> std::io::Result<()> { + writeln!( + stdout, + "\n{RESET}{DIM}──{RESET} {BOLD}{CYAN}Assistant{RESET} {DIM}{}{RESET}\n", + "─".repeat(28) + )?; + stdout.flush() +} + /// Print the conversation history from loaded messages so the user can see /// what was discussed in the resumed session. pub fn print_conversation_history( @@ -110,13 +120,21 @@ pub fn print_conversation_history( match msg.role { Role::System => {} Role::User => { - writeln!(stdout, "{}> {}{}", BLUE, msg.content, RESET)?; + writeln!(stdout, "{BOLD}{BLUE}You >{RESET} {}", msg.content)?; } Role::Assistant => { + if !msg.content.is_empty() || !msg.tool_calls.is_empty() { + write_assistant_header(stdout)?; + } if !msg.content.is_empty() { - write!(stdout, "{}", ORANGE)?; - stdout.write_all(msg.content.as_bytes())?; - writeln!(stdout, "{}", RESET)?; + // Render prose with the same markdown styling as live + // streaming so a reloaded session looks identical to the + // original run. + write!(stdout, "{ASSISTANT_TEXT}")?; + let rendered = tinyharness_ui::ui::markdown::render_markdown(&msg.content); + stdout + .write_all(rendered.strip_suffix('\n').unwrap_or(&rendered).as_bytes())?; + writeln!(stdout, "{RESET}")?; } if !msg.tool_calls.is_empty() { for tc in &msg.tool_calls { @@ -536,4 +554,89 @@ mod tests { // Should have dim gray color for the token part assert!(result.contains(GRAY)); } + + #[test] + fn test_history_labels_separate_user_and_assistant() { + let messages = vec![ + Message::simple(Role::User, "question"), + Message::simple(Role::Assistant, "answer"), + ]; + let mut out = Vec::new(); + print_conversation_history(&messages, &mut out).unwrap(); + let out = String::from_utf8(out).unwrap(); + assert!(out.contains(&format!("You >{RESET} question\n"))); + assert!(out.contains("Assistant")); + assert!(out.find("question").unwrap() < out.find("Assistant").unwrap()); + assert!(out.find("Assistant").unwrap() < out.find("answer").unwrap()); + assert!(out.ends_with(&format!("answer{RESET}\n\n"))); + } + + #[test] + fn test_history_assistant_message_exact_newlines_without_trailing() { + // Content WITHOUT a trailing newline: the markdown renderer adds one, + // and the final writeln! adds the message-terminating newline. The + // result must be exactly one blank line after the message — never two. + let msgs = vec![Message::simple(Role::Assistant, "hello world")]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + let mut header = Vec::new(); + write_assistant_header(&mut header).unwrap(); + let header = String::from_utf8(header).unwrap(); + assert_eq!( + out, + format!("{header}{ASSISTANT_TEXT}hello world{RESET}\n\n") + ); + } + + #[test] + fn test_history_assistant_message_exact_newlines_with_trailing() { + // Content WITH a trailing newline must yield the same output as + // without it — no doubled blank line between history messages. + let msgs = vec![Message::simple(Role::Assistant, "hello world\n")]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + let mut header = Vec::new(); + write_assistant_header(&mut header).unwrap(); + let header = String::from_utf8(header).unwrap(); + assert_eq!( + out, + format!("{header}{ASSISTANT_TEXT}hello world{RESET}\n\n") + ); + } + + #[test] + fn test_history_multi_paragraph_no_doubled_blanks() { + // Two assistant messages in a row: exactly one blank line separates + // each message block. + let msgs = vec![ + Message::simple(Role::Assistant, "first"), + Message::simple(Role::Assistant, "second"), + ]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + let mut header = Vec::new(); + write_assistant_header(&mut header).unwrap(); + let header = String::from_utf8(header).unwrap(); + assert_eq!( + out, + format!( + "{header}{ASSISTANT_TEXT}first{RESET}\n\n{header}{ASSISTANT_TEXT}second{RESET}\n\n" + ) + ); + } + + #[test] + fn test_history_multiline_content_rendered_as_markdown() { + // Headings get markdown-styled on reload, matching live streaming. + let msgs = vec![Message::simple(Role::Assistant, "# Title\nbody")]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains(BOLD), "heading should be bold: {out:?}"); + assert!(out.contains("body")); + assert!(out.ends_with(&format!("{RESET}\n\n"))); + } } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 4c6f6b6..2c5e21d 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -58,29 +58,11 @@ pub async fn run_agent_loop( let mut stdout = Output::stdout(); stdout.write_all( format!( - "{}╔════════════════════════════════════════════════════════╗{}\n", - BOX_COLOR, RESET - ) - .as_bytes(), - )?; - stdout.write_all( - format!( - "{}║{} {}TinyHarness AI Assistant{} {}║{}\n", - BOX_COLOR, RESET, BOLD, TITLE_COLOR, BOX_COLOR, RESET - ) - .as_bytes(), - )?; - stdout.write_all( - format!( - "{}╚════════════════════════════════════════════════════════╝{}\n\n", - BOX_COLOR, RESET - ) - .as_bytes(), - )?; - stdout.write_all( - format!( - "{}Tip:{} Type {} to see available commands\n\n", - GRAY, RESET, "/help" + "{BOLD}{TITLE_COLOR}TinyHarness{RESET}{BOLD} AI Assistant{RESET} {DIM}v{}{RESET}\n\ + {DIM}{}{RESET}\n\ + {DIM}Tip:{RESET} {CYAN}/help{RESET} {DIM}shows all commands{RESET}\n\n", + env!("CARGO_PKG_VERSION"), + "─".repeat(44) ) .as_bytes(), )?; @@ -199,7 +181,7 @@ pub async fn run_agent_loop( }; let prompt = format!( - "{}{}{}\n{}[{}]{}{}> {}{}", + "{}{}{}\n{}[{}]{}{} {BOLD}{BLUE}You >{RESET} {}{}", status_line, session_suffix, RESET, @@ -322,6 +304,9 @@ pub async fn run_agent_loop( // auto_accept persists across all agent iterations within this user turn, let mut auto_accept = false; + // One visible boundary per user turn; tool iterations remain within it. + display::write_assistant_header(&mut stdout)?; + loop { // Filter tools based on current mode and settings let (_, _, merged) = load_merged_settings(); @@ -366,7 +351,12 @@ pub async fn run_agent_loop( // debug metric fires about once per second instead of per chunk. let mut backpressure_log_ticks: u32 = 0; - stdout.write_all(ORANGE.as_bytes())?; + // Line-buffered markdown renderer for the assistant's reply: emits + // each line (styled) as soon as its newline arrives, and always + // starts from a clean ANSI state — the previous code relied on an + // ambient orange color that the spinner/thinking RESET could tear + // down mid-stream ("random orange" bug). + let mut md_stream = tinyharness_ui::ui::markdown::MarkdownStream::new(); loop { tokio::select! { @@ -438,7 +428,7 @@ pub async fn run_agent_loop( } response_content.push_str(&msg.message.content); - stdout.write_all(msg.message.content.as_bytes())?; + md_stream.push(&mut stdout, &msg.message.content)?; stdout.flush()?; } @@ -501,6 +491,9 @@ pub async fn run_agent_loop( stdout.flush()?; } + // Flush the markdown renderer's trailing partial line. + md_stream.finish(&mut stdout)?; + // Close thinking styling if stream ended while still in thinking mode if thinking_header_shown { writeln!(stdout, "{RESET}")?; @@ -632,8 +625,8 @@ pub async fn run_agent_loop( break; } - // Blank line after agent response for visual separation - writeln!(stdout)?; + // End a partial output line and leave space before the next prompt. + writeln!(stdout, "{RESET}\n")?; } // Save history on exit diff --git a/tinyharness-ui/src/style.rs b/tinyharness-ui/src/style.rs index 2c31901..7a10dd0 100644 --- a/tinyharness-ui/src/style.rs +++ b/tinyharness-ui/src/style.rs @@ -18,10 +18,18 @@ pub const WHITE: &str = "\x1b[37m"; // Bright / extended colors pub const GRAY: &str = "\x1b[90m"; -pub const ORANGE: &str = "\x1b[38;5;208m"; +pub const ORANGE: &str = "\x1b[38;5;208m"; // Warning / notice accent (matches `Output::warning`) pub const BRIGHT_YELLOW: &str = "\x1b[93m"; pub const BRIGHT_CYAN: &str = "\x1b[96m"; +// Assistant prose: rendered in the terminal's *default* foreground so it stays +// legible against any user theme (the modern CLI convention). Emitted once, +// before the first streamed content chunk, to clear any residual styling left +// behind by the spinner, the thinking block, or ANSI sequences that happened to +// be present in the model's own output. This is the single hook for theming +// assistant text later. +pub const ASSISTANT_TEXT: &str = RESET; + // Thinking/reasoning chain colors pub const THINK_COLOR: &str = "\x1b[35m"; // Magenta for thinking text pub const THINK_COLOR_DIM: &str = "\x1b[38;5;97m"; // Dimmer magenta @@ -37,7 +45,7 @@ pub const FILL_EOL: &str = "\x1b[K"; // UI styling presets pub const TITLE_COLOR: &str = CYAN; // For titles and headers pub const BOX_COLOR: &str = BLUE; // For box borders and frames -pub const WARNING_COLOR: &str = YELLOW; // For warnings and alerts +pub const WARNING_COLOR: &str = ORANGE; // For warnings and alerts (matches `Output::warning`) pub const ACCENT_COLOR: &str = MAGENTA; // For highlights and emphasis // Special escape sequences diff --git a/tinyharness-ui/src/ui/markdown.rs b/tinyharness-ui/src/ui/markdown.rs new file mode 100644 index 0000000..12fdbf0 --- /dev/null +++ b/tinyharness-ui/src/ui/markdown.rs @@ -0,0 +1,670 @@ +//! A conservative, dependency-free markdown renderer for terminal output. +//! +//! Model responses are streamed token-by-token, which makes full re-rendering +//! impossible — styling markers (`` ` ``, `**`, fences) arrive split across +//! chunks. This module solves that by buffering **lines**: text is emitted as +//! soon as a newline finalizes it, so output still feels live while constructs +//! like fenced code blocks, headings, and lists are styled correctly. +//! +//! Design rules: +//! - **Never lose text.** If a construct can't be completed (unmatched `**`, +//! unterminated link, etc.) the raw characters are emitted untouched. +//! - **Styling can't leak.** If the model emits raw ANSI sequences in its +//! prose, each affected line is closed with `RESET` so stray styling never +//! bleeds into subsequent lines or UI output. +//! - Single-`*` italics and `_underscores_` are deliberately *not* styled — +//! they appear too often in code/glob/math contexts to detect safely. +//! - ANSI is only emitted for constructs that are actually detected, so plain +//! prose streams through with zero escape-sequence noise. +//! +//! [`MarkdownStream`] is the incremental (live) API; [`render_markdown`] is the +//! one-shot API used for re-printing saved history. + +use std::io::{self, Write}; + +use crate::style::*; + +/// Incremental markdown renderer for streamed content. +/// +/// Feed deltas with [`push`](Self::push); call [`finish`](Self::finish) when +/// the generation ends (including on interrupt/error) to flush the trailing +/// partial line. +#[derive(Debug, Default)] +pub struct MarkdownStream { + core: MdCore, + /// Whether the initial [`ASSISTANT_TEXT`] style has been emitted. + started: bool, +} + +impl MarkdownStream { + pub fn new() -> Self { + Self::default() + } + + /// Consume a streamed content delta, emitting finished lines. + pub fn push(&mut self, w: &mut W, delta: &str) -> io::Result<()> { + if delta.is_empty() { + return Ok(()); + } + if !self.started { + // Reset any styling left over from the spinner / thinking block / + // model-emitted escapes before the first visible content. + w.write_all(ASSISTANT_TEXT.as_bytes())?; + self.started = true; + } + for ch in delta.chars() { + if ch == '\n' { + let line = std::mem::take(&mut self.core.line_buf); + let styled = self.core.finalize_line(&line); + w.write_all(styled.as_bytes())?; + w.write_all(b"\n")?; + } else if ch != '\r' { + self.core.line_buf.push(ch); + } + } + Ok(()) + } + + /// Flush the trailing partial line (if any) without adding a newline. + /// + /// Note: the instance is *not* reusable after `finish` in practice — + /// `started` stays `true`, so no redundant second reset is emitted. Use a + /// fresh [`MarkdownStream`] per generation. + pub fn finish(&mut self, w: &mut W) -> io::Result<()> { + if !self.core.line_buf.is_empty() { + let line = std::mem::take(&mut self.core.line_buf); + let styled = self.core.finalize_line(&line); + w.write_all(styled.as_bytes())?; + } + self.core.fence = None; + Ok(()) + } +} + +/// One-shot renderer for complete texts (e.g. re-printing a saved session). +pub fn render_markdown(text: &str) -> String { + let mut core = MdCore::default(); + let mut out = String::with_capacity(text.len()); + for line in text.split('\n') { + out.push_str(&core.finalize_line(line.trim_end_matches('\r'))); + out.push('\n'); + } + // `split` on a trailing '\n' yields a final empty fragment that must not + // become an extra blank line. + if text.ends_with('\n') && out.ends_with("\n\n") { + out.truncate(out.len() - 1); + } + out +} + +#[derive(Debug, Default)] +struct MdCore { + line_buf: String, + /// Opening fence marker of an open code block (``` or ~~~). + fence: Option, +} + +impl MdCore { + /// Style one complete line. May return an empty string (consumed lines, + /// e.g. code-fence delimiters). + /// + /// Wraps [`Self::style_line`] with ANSI containment: if the model emitted raw + /// escape sequences in its prose, they must not leak styling into subsequent + /// lines. Paragraph and list-item tails don't otherwise end with `RESET`, so + /// append one when the raw line contained ESC and the styled output doesn't + /// already end in a reset (idempotent, so this is always safe). + fn finalize_line(&mut self, line: &str) -> String { + let styled = self.style_line(line); + if line.contains('\x1b') && !styled.ends_with(RESET) { + styled + RESET + } else { + styled + } + } + + /// Core line styler — see [`Self::finalize_line`]. + fn style_line(&mut self, line: &str) -> String { + let trimmed = line.trim(); + + // ── Inside a fenced code block ── + if let Some(marker) = &self.fence { + if trimmed.starts_with(marker.as_str()) { + // Closing fence: hide it. + self.fence = None; + return String::new(); + } + let mut out = String::with_capacity(line.len() + 16); + out.push_str(GRAY); + out.push_str(" "); + out.push_str(line.trim_start()); + out.push_str(RESET); + return out; + } + if let Some(lang) = fence_opening(trimmed) { + self.fence = Some(lang.0); + let mut out = String::new(); + if !lang.1.is_empty() { + out.push_str(DIM); + out.push_str(ITALIC); + out.push_str(&lang.1); + out.push_str(RESET); + } + return out; + } + + // ── Block constructs (matched on trimmed text) ── + if let Some((level, text)) = heading(trimmed) { + let color = if level <= 2 { TITLE_COLOR } else { GRAY }; + return format!("{BOLD}{color}{}{RESET}", inline(text)); + } + if is_rule(trimmed) { + return format!("{DIM}{}{RESET}", "─".repeat(40)); + } + if let Some(rest) = trimmed.strip_prefix("> ") { + return format!("{DIM}│ {RESET}{GRAY}{}{RESET}", inline(rest)); + } + if trimmed == ">" { + return format!("{DIM}│{RESET}"); + } + if let Some((indent, marker, rest)) = list_item(line) { + let pad = " ".repeat(indent); + return format!("{pad}{GRAY}{marker}{RESET} {}", inline(rest)); + } + + // ── Plain paragraph line ── + inline(line) + } +} + +/// Returns `Some((marker, language))` if the line opens a code fence. +fn fence_opening(trimmed: &str) -> Option<(String, String)> { + for marker in ["```", "~~~"] { + if let Some(rest) = trimmed.strip_prefix(marker) { + return Some((marker.to_string(), rest.trim().to_string())); + } + } + None +} + +/// `# Heading` .. `###### Heading` → `(level, text)`. +fn heading(trimmed: &str) -> Option<(u8, &str)> { + let hashes = trimmed.chars().take_while(|&c| c == '#').count(); + if (1..=6).contains(&hashes) + && let Some(rest) = trimmed[hashes..].strip_prefix(' ') + { + let text = rest.trim_end(); + if !text.is_empty() { + return Some((hashes as u8, text)); + } + } + None +} + +/// `---`, `***`, `___` (3+) → horizontal rule. Allocation-free: bails out at +/// the first character that can't belong to a rule. +fn is_rule(trimmed: &str) -> bool { + let mut first: Option = None; + let mut count = 0usize; + for c in trimmed.chars() { + match first { + None => { + if !matches!(c, '-' | '*' | '_') { + return false; + } + first = Some(c); + count = 1; + } + Some(f) if c == f => count += 1, + Some(_) if c != ' ' => return false, + _ => {} + } + } + count >= 3 +} + +/// Detect `- item`, `* item`, `+ item` or `1. item`. Returns +/// `(indent width, display marker, item text)`. `-`/`*` render as `•`. +fn list_item(line: &str) -> Option<(usize, String, &str)> { + let indent = line.len() - line.trim_start().len(); + let rest = line.trim_start(); + if let Some(after) = rest.strip_prefix(['-', '+']) + && let Some(text) = after.strip_prefix(' ') + { + let text = text.trim_start(); + if !text.is_empty() { + return Some((indent, "•".to_string(), text)); + } + } + if let Some(after) = rest.strip_prefix('*') + && let Some(text) = after.strip_prefix(' ') + { + let text = text.trim_start(); + // Guard: `** bold **`-style lines are not lists. + if !text.is_empty() && !text.starts_with('*') { + return Some((indent, "•".to_string(), text)); + } + } + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if !digits.is_empty() + && let Some(after) = rest[digits.len()..].strip_prefix(". ") + { + let text = after.trim_start(); + if !text.is_empty() { + return Some((indent, format!("{digits}."), text)); + } + } + None +} + +/// Inline styling: `**bold**`, `` `code` ``, `[text](url)`. +/// +/// Unmatched markers are emitted verbatim — text is never dropped. +fn inline(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut i = 0usize; + while i < s.len() { + let c = s[i..].chars().next().unwrap(); + match c { + '*' => { + if let Some(inner) = s[i..].strip_prefix("**") { + match inner.find("**") { + Some(end) if end > 0 => { + out.push_str(BOLD); + out.push_str(&inner[..end]); + out.push_str(RESET); + // 2 (opening **) + content + 2 (closing **) + i += end + 4; + continue; + } + _ => {} + } + } + out.push('*'); + i += 1; + } + '`' => { + if let Some(end) = s[i + 1..].find('`') + && end > 0 + { + out.push_str(GRAY); + out.push('`'); + out.push_str(&s[i + 1..i + 1 + end]); + out.push('`'); + out.push_str(RESET); + i += end + 2; + continue; + } + out.push('`'); + i += 1; + } + '[' | '!' => { + // [text](url) or ![alt](url) + let is_image = c == '!'; + let open = if is_image { i + 1 } else { i }; + if s.get(open..).and_then(|r| r.strip_prefix('[')).is_some() { + let after_bracket = &s[open + 1..]; + if let Some(text_end) = after_bracket.find("](") + && text_end > 0 + { + let tail = &after_bracket[text_end + 2..]; + if let Some(url_end) = tail.find(')') { + let text = &after_bracket[..text_end]; + let url = &tail[..url_end]; + if is_image { + out.push('!'); + } + out.push_str(CYAN); + out.push_str(text); + out.push_str(RESET); + out.push_str(DIM); + out.push('('); + out.push_str(url); + out.push(')'); + out.push_str(RESET); + i = open + 1 + text_end + 2 + url_end + 1; + continue; + } + } + } + out.push(c); + i += c.len_utf8(); + } + _ => { + out.push(c); + i += c.len_utf8(); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap(); + re.replace_all(s, "").to_string() + } + + // ── Block constructs ── + + #[test] + fn heading_is_bold_and_styled() { + let out = render_markdown("# Title\n"); + assert!(out.contains(BOLD)); + assert!(out.contains("Title")); + assert_eq!(strip_ansi(&out).trim_end(), "Title"); + assert!(!strip_ansi(&out).contains('#')); + } + + #[test] + fn h3_heading_uses_dim_color_variant() { + let out = render_markdown("### Sub\n"); + assert!(out.contains(GRAY)); + assert_eq!(strip_ansi(&out).trim_end(), "Sub"); + } + + #[test] + fn heading_without_space_not_styled() { + // `#hashtag` is not a heading — must pass through untouched. + let out = render_markdown("#hashtag\n"); + assert_eq!(out, "#hashtag\n"); + } + + #[test] + fn bullet_points_rendered() { + let out = render_markdown("- one\n* two\n"); + assert!(out.contains('•')); + assert_eq!(strip_ansi(&out), "• one\n• two\n"); + } + + #[test] + fn nested_list_keeps_indent() { + let out = render_markdown(" - child\n"); + assert!(out.starts_with(" ")); + assert_eq!(strip_ansi(&out), " • child\n"); + } + + #[test] + fn numbered_list_keeps_number() { + let out = render_markdown("3. third\n"); + assert_eq!(strip_ansi(&out), "3. third\n"); + } + + #[test] + fn bold_stars_line_is_not_a_list() { + let out = render_markdown("**Important** note\n"); + assert!(out.contains(BOLD)); + assert_eq!(strip_ansi(&out), "Important note\n"); + } + + #[test] + fn code_fence_block_styled_and_markers_hidden() { + let out = render_markdown("```rust\nfn main() {}\n```\n"); + let plain = strip_ansi(&out); + assert!(!plain.contains("```")); + assert!(plain.contains(" fn main() {}")); + assert!(out.contains(GRAY)); + } + + #[test] + fn code_fence_shows_language_tag_dim() { + let out = render_markdown("```rust\nx\n```\n"); + assert!(out.contains(ITALIC)); + assert!(out.contains("rust")); + } + + #[test] + fn code_fence_content_never_styled() { + // Markdown inside a code block must not be interpreted. + let out = render_markdown("```\n# not a heading\n**not bold**\n```\n"); + let plain = strip_ansi(&out); + assert!(plain.contains("# not a heading")); + assert!(plain.contains("**not bold**")); + assert!(!out.contains(BOLD)); + } + + #[test] + fn unclosed_fence_streams_content_as_code() { + let mut core = MdCore::default(); + assert_eq!(core.finalize_line("```"), ""); + assert!(core.fence.is_some()); + let line = core.finalize_line("code here"); + assert!(line.contains(GRAY)); + assert!(line.contains("code here")); + } + + #[test] + fn blockquote_renders_bar() { + let out = render_markdown("> quoted text\n"); + assert!(out.contains('│')); + assert_eq!(strip_ansi(&out), "│ quoted text\n"); + } + + #[test] + fn horizontal_rule_converted() { + let out = render_markdown("---\n"); + assert!(out.contains("─")); + assert!(!strip_ansi(&out).contains("---")); + } + + #[test] + fn dashes_inside_text_untouched() { + let out = render_markdown("a - - b\n"); + assert_eq!(out, "a - - b\n"); + } + + // ── Inline constructs ── + + #[test] + fn inline_bold() { + let out = render_markdown("this is **bold** text\n"); + assert!(out.contains(BOLD)); + assert_eq!(strip_ansi(&out), "this is bold text\n"); + } + + #[test] + fn unmatched_bold_marker_preserved() { + let out = render_markdown("2 * 3 ** 4\n"); + assert_eq!(strip_ansi(&out), "2 * 3 ** 4\n"); + } + + #[test] + fn inline_code_span() { + let out = render_markdown("call `foo()` now\n"); + assert!(out.contains(GRAY)); + assert_eq!(strip_ansi(&out), "call `foo()` now\n"); + } + + #[test] + fn unmatched_backtick_preserved() { + let out = render_markdown("one ` tick\n"); + assert_eq!(strip_ansi(&out), "one ` tick\n"); + } + + #[test] + fn link_shows_text_and_url() { + let out = render_markdown("see [docs](https://x.dev) please\n"); + assert!(out.contains(CYAN)); + assert!(out.contains("docs")); + assert!(out.contains("https://x.dev")); + } + + #[test] + fn broken_link_left_raw() { + let out = render_markdown("a [b c\n"); + assert_eq!(strip_ansi(&out), "a [b c\n"); + } + + #[test] + fn plain_text_gets_no_ansi() { + let out = render_markdown("just a normal sentence\n"); + assert_eq!(out, "just a normal sentence\n"); + } + + #[test] + fn unicode_safe() { + let out = render_markdown("żółw **gruby** 🐢\n"); + assert_eq!(strip_ansi(&out), "żółw gruby 🐢\n"); + } + + // ── Raw ANSI containment (model-emitted escapes) ── + + #[test] + fn model_emitted_ansi_is_contained_on_plain_line() { + let red = "\x1b[31m"; + let out = render_markdown(&format!("hello {red}red world\n")); + // Styling is neutralized before the next line begins. + assert!(out.ends_with(&format!("{RESET}\n"))); + assert!( + out.contains(red), + "raw sequence must pass through, not be dropped" + ); + assert_eq!(strip_ansi(&out), "hello red world\n"); + } + + #[test] + fn model_emitted_ansi_does_not_leak_to_next_line() { + let red = "\x1b[31m"; + let out = render_markdown(&format!("line one {red}colored\nline two\n")); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 2); + // The second line carries no residual color: it is plain text with + // zero escape sequences of its own. + assert_eq!(lines[1], "line two"); + } + + #[test] + fn styled_line_ending_in_reset_is_not_double_reset() { + // A construct-styled line already ends with RESET — containment must + // be idempotent (no doubled escape). + let out = render_markdown("already **styled**\n"); + let suffix = format!("{BOLD}styled{RESET}"); + assert!(out.contains(&suffix)); + // Exactly one RESET at end-of-line. + let after_suffix = &out[out.find(&suffix).unwrap() + suffix.len()..]; + assert!(!after_suffix.starts_with(RESET)); + } + + #[test] + fn fence_content_with_ansi_is_contained() { + let red = "\x1b[31m"; + let out = render_markdown(&format!("```\n{red}colored code\n```\n")); + // Fence delimiters are hidden (empty lines); only the content line is + // non-empty. Fence styling already ends with RESET, so containment is + // idempotent and no raw escape can leak past it. + let non_empty: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(non_empty.len(), 1); + assert!(non_empty[0].contains("colored code")); + assert!(non_empty[0].ends_with(RESET)); + } + + #[test] + fn stream_containment_matches_oneshot() { + // The streaming path goes through the same finalize_line wrapper, so + // its containment behavior must match the one-shot renderer. + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "a \x1b[32mb\n").unwrap(); + md.finish(&mut buf).unwrap(); + let streamed = String::from_utf8(buf).unwrap(); + let oneshot = render_markdown("a \x1b[32mb\n"); + let streamed_body = streamed.strip_prefix(ASSISTANT_TEXT).unwrap_or(&streamed); + assert_eq!(streamed_body, oneshot); + } + + // ── Horizontal-rule edge cases ── + + #[test] + fn two_dashes_is_not_a_rule() { + let out = render_markdown("--\n"); + assert_eq!(strip_ansi(&out), "--\n"); + } + + #[test] + fn mixed_rule_chars_not_a_rule() { + let out = render_markdown("-*- \n"); + assert_eq!(strip_ansi(&out), "-*- \n"); + } + + #[test] + fn spaced_rule_is_a_rule() { + let out = render_markdown("- - -\n"); + assert!(strip_ansi(&out).contains('─')); + } + + #[test] + fn long_underscore_rule_converted() { + let out = render_markdown("_____\n"); + assert!(strip_ansi(&out).contains('─')); + } + + // ── Streaming behavior ── + + #[test] + fn stream_emits_line_only_on_newline() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "# Hel").unwrap(); + // No newline yet: nothing visible except the initial reset. + assert_eq!(String::from_utf8_lossy(&buf), ASSISTANT_TEXT); + md.push(&mut buf, "lo world\nnext\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("Hello world")); + assert!(s.contains("next")); + } + + #[test] + fn stream_marker_split_across_deltas_is_styled() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "**bo").unwrap(); + md.push(&mut buf, "ld**\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains(BOLD)); + assert_eq!(strip_ansi(&s[ASSISTANT_TEXT.len()..]), "bold\n"); + } + + #[test] + fn stream_fence_spans_multiple_chunks() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "```\n").unwrap(); + md.push(&mut buf, "let x = 1;\n").unwrap(); + md.push(&mut buf, "```\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + let plain = strip_ansi(&s); + assert!(!plain.contains("```")); + assert!(plain.contains("let x = 1;")); + } + + #[test] + fn finish_flushes_partial_line_without_newline() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "complete\npartial tail").unwrap(); + md.finish(&mut buf).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.ends_with("partial tail")); + assert!(!s.ends_with("partial tail\n")); + } + + #[test] + fn crlf_input_is_normalized() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "line one\r\nline two\r\n").unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert_eq!(s, format!("{ASSISTANT_TEXT}line one\nline two\n")); + } + + #[test] + fn empty_push_is_noop() { + let mut md = MarkdownStream::new(); + let mut buf = Vec::new(); + md.push(&mut buf, "").unwrap(); + md.finish(&mut buf).unwrap(); + assert!(buf.is_empty()); + } +} diff --git a/tinyharness-ui/src/ui/mod.rs b/tinyharness-ui/src/ui/mod.rs index 2f0f51d..f892827 100644 --- a/tinyharness-ui/src/ui/mod.rs +++ b/tinyharness-ui/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod confirm; pub mod diff; pub mod input; +pub mod markdown; pub mod wrap; From e5c45f43a71ea20477e4d606a6b4696b925f0661 Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Thu, 1 Oct 2026 12:54:14 +0200 Subject: [PATCH 2/5] Render tool results through a shared capped, styled renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tinyharness-ui/src/ui/tool_result.rs, a structural renderer for tool execution output shared by the live agent loop and session-history replay. Tool results are plain text, not markdown — raw command output, listings and API responses — so instead of the markdown formatter this module applies the same design rules (never lose the summary, honest truncation footers, no styling leaks) with per-tool treatment: - ls/grep/glob: one-line count summary ("5 entries — a, b, c ...") - read: "(N lines)" meta line plus a 3-line preview - web_search: parsed into a link-styled list (cyan titles, dim URLs) - run and default tools: output capped at 10 lines, "┊ +N more lines" - errors: single red line; denied calls: single orange line - signal tools (question/switch_mode/invoke_skill/auto_compact): conversational meta-results collapse to one dim line The live path in handle_tool_calls drops its per-tool display match and calls write_tool_result; format_duration and summarize_listing_result moved into the shared module. History replay previously extracted tool names by splitting on "'", matching an obsolete result format, so reloaded sessions labeled every tool "tool" and dumped uncapped bodies. Tool names now come from the "### {name}" prefix of stored results, falling back to a tool_call_id -> name map built from the assistant's tool_calls. --- src/agent/display.rs | 231 +++++------ src/agent/mod.rs | 2 +- src/agent/tools.rs | 112 +----- tinyharness-ui/src/ui/mod.rs | 1 + tinyharness-ui/src/ui/tool_result.rs | 554 +++++++++++++++++++++++++++ 5 files changed, 667 insertions(+), 233 deletions(-) create mode 100644 tinyharness-ui/src/ui/tool_result.rs diff --git a/src/agent/display.rs b/src/agent/display.rs index 167a12a..c5f85cf 100644 --- a/src/agent/display.rs +++ b/src/agent/display.rs @@ -116,6 +116,11 @@ pub fn print_conversation_history( return Ok(()); } + // tool_call_id → tool name, populated from Assistant messages so Tool + // result messages can be rendered with the right tool-specific body. + let mut tool_names: std::collections::HashMap = + std::collections::HashMap::new(); + for msg in messages { match msg.role { Role::System => {} @@ -138,6 +143,11 @@ pub fn print_conversation_history( } if !msg.tool_calls.is_empty() { for tc in &msg.tool_calls { + // Remember tool names so the following Tool messages + // can render with the correct tool-specific body. + if let Some(id) = &tc.id { + tool_names.insert(id.clone(), tc.function.name.clone()); + } writeln!( stdout, "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM}{FILL_EOL}{RESET}", @@ -148,33 +158,15 @@ pub fn print_conversation_history( writeln!(stdout)?; } Role::Tool => { - let tool_name = msg.content.split('\'').nth(1).unwrap_or("tool"); - let result_body = extract_tool_result_body(&msg.content, tool_name); - - if tool_name == "read" { - let summary = result_body.lines().next().unwrap_or("(empty result)"); - writeln!( - stdout, - "{BG_DIM} {DIM}{summary}{FILL_EOL}{RESET}", - summary = summary - )?; - } else if matches!(tool_name, "ls" | "grep" | "glob") { - let summary = summarize_listing_result(result_body, tool_name); - writeln!( - stdout, - "{BG_DIM} {DIM}{summary}{FILL_EOL}{RESET}", - summary = summary - )?; - } else { - tinyharness_ui::ui::wrap::write_wrapped_lines( - stdout, - result_body, - &format!("{BG_DIM} "), - &format!(" {BG_DIM}{DIM}"), - tinyharness_ui::ui::wrap::MAX_LINE_WIDTH, - true, - )?; - } + let (tool_name, result_body) = + split_tool_result(&msg.content, &tool_names, &msg.tool_call_id); + tinyharness_ui::ui::tool_result::write_tool_result( + stdout, + &tool_name, + result_body, + result_body.starts_with("Error:"), + None, + )?; writeln!(stdout, "{RESET}")?; } } @@ -304,43 +296,29 @@ pub fn display_context_status( Ok(()) } -/// Extract the result body from a tool result message. -fn extract_tool_result_body<'a>(content: &'a str, tool_name: &str) -> &'a str { - let prefix = format!("Tool '{tool_name}' result:\n"); - content - .strip_prefix(&prefix) - .or_else(|| content.strip_prefix(&prefix)) - .unwrap_or(content) -} - -/// Produce a one-line summary for listing tools (ls, grep, glob). -pub fn summarize_listing_result(result: &str, tool_name: &str) -> String { - if result.starts_with("Error:") || result.starts_with("No ") || result == "Directory is empty" { - return result.to_string(); - } - - let lines: Vec<&str> = result.lines().collect(); - let count = lines.len(); - let label = match tool_name { - "ls" => "entries", - "grep" => "matches", - "glob" => "files", - _ => "results", - }; - - const PREVIEW: usize = 3; - if count <= PREVIEW { - format!("{} {} — {}", count, label, result) - } else { - let preview: Vec<&str> = lines.iter().take(PREVIEW).copied().collect(); - format!( - "{} {} — {} ... ({} more)", - count, - label, - preview.join(", "), - count - PREVIEW - ) - } +/// Split a stored tool result into `(tool_name, result_body)`. +/// +/// Tool results are persisted as `### {tool}\n\n{result}`. When the tool name +/// can't be recovered from the prefix, falls back to the assistant's +/// `tool_calls` map (id → name) or the message's own `tool_call_id`, so older +/// sessions with different prefixes still resolve the tool name. +fn split_tool_result<'a>( + content: &'a str, + tool_names: &std::collections::HashMap, + tool_call_id: &Option, +) -> (String, &'a str) { + if let Some(rest) = content.strip_prefix("### ") + && let Some((name, body)) = rest.split_once("\n\n") + { + return (name.trim().to_string(), body); + } + // Fallbacks: the originating call's name, looked up by id. + let fallback = tool_call_id + .as_deref() + .and_then(|id| tool_names.get(id)) + .cloned() + .unwrap_or_else(|| "tool".to_string()); + (fallback, content) } /// Format tool call arguments as a compact single-line summary. @@ -377,73 +355,9 @@ fn format_args_summary_impl(arguments: &serde_json::Value, truncate: bool) -> St #[cfg(test)] mod tests { - use super::*; + use std::collections::HashMap; - #[test] - fn test_summarize_ls_error_passthrough() { - let result = summarize_listing_result("Error: Path '/nope' does not exist", "ls"); - assert_eq!(result, "Error: Path '/nope' does not exist"); - } - - #[test] - fn test_summarize_ls_empty_dir() { - let result = summarize_listing_result("Directory is empty", "ls"); - assert_eq!(result, "Directory is empty"); - } - - #[test] - fn test_summarize_ls_no_matches() { - let result = summarize_listing_result("No matches found for pattern 'xyz'", "grep"); - assert_eq!(result, "No matches found for pattern 'xyz'"); - } - - #[test] - fn test_summarize_ls_few_entries() { - let result = summarize_listing_result("Cargo.toml\nCargo.lock\nsrc", "ls"); - assert_eq!(result, "3 entries — Cargo.toml\nCargo.lock\nsrc"); - } - - #[test] - fn test_summarize_ls_many_entries() { - let entries: Vec = (0..10).map(|i| format!("file{i}")).collect(); - let input = entries.join("\n"); - let result = summarize_listing_result(&input, "ls"); - assert_eq!(result, "10 entries — file0, file1, file2 ... (7 more)"); - } - - #[test] - fn test_summarize_grep_many_matches() { - let matches: Vec = (1..=5).map(|i| format!("src/main.rs:{}:foo", i)).collect(); - let input = matches.join("\n"); - let result = summarize_listing_result(&input, "grep"); - assert_eq!( - result, - "5 matches — src/main.rs:1:foo, src/main.rs:2:foo, src/main.rs:3:foo ... (2 more)" - ); - } - - #[test] - fn test_summarize_glob_no_files() { - let result = summarize_listing_result("No files found matching pattern '*.xyz'", "glob"); - assert_eq!(result, "No files found matching pattern '*.xyz'"); - } - - #[test] - fn test_summarize_glob_many_files() { - let files: Vec = (0..6).map(|i| format!("src/file{i}.rs")).collect(); - let input = files.join("\n"); - let result = summarize_listing_result(&input, "glob"); - assert_eq!( - result, - "6 files — src/file0.rs, src/file1.rs, src/file2.rs ... (3 more)" - ); - } - - #[test] - fn test_summarize_single_entry() { - let result = summarize_listing_result("Cargo.toml", "ls"); - assert_eq!(result, "1 entries — Cargo.toml"); - } + use super::*; #[test] fn test_format_args_summary_short_string() { @@ -639,4 +553,61 @@ mod tests { assert!(out.contains("body")); assert!(out.ends_with(&format!("{RESET}\n\n"))); } + + #[test] + fn test_split_tool_result_stored_prefix() { + let names = HashMap::new(); + let content = "### read\n\n(2 lines)\nfoo\nbar"; + let (name, body) = split_tool_result(content, &names, &None); + assert_eq!(name, "read"); + assert_eq!(body, "(2 lines)\nfoo\nbar"); + } + + #[test] + fn test_split_tool_result_falls_back_to_call_id() { + let mut names = HashMap::new(); + names.insert("call_0".to_string(), "run".to_string()); + let content = "plain output without prefix"; + let (name, body) = split_tool_result(content, &names, &Some("call_0".to_string())); + assert_eq!(name, "run"); + assert_eq!(body, "plain output without prefix"); + } + + #[test] + fn test_history_tool_result_uses_call_name_and_caps_body() { + use tinyharness_lib::provider::{ToolCall, ToolCallFunction}; + let assistant = Message { + role: Role::Assistant, + content: String::new(), + tool_calls: vec![ToolCall { + id: Some("call_0".to_string()), + function: ToolCallFunction { + name: "read".to_string(), + arguments: serde_json::json!({}), + thought_signature: None, + }, + }], + tool_call_id: None, + images: vec![], + thinking: None, + }; + let mut tool = Message::simple(Role::Tool, "### read\n\n(5 lines)\nl1\nl2\nl3\nl4\nl5"); + tool.tool_call_id = Some("call_0".to_string()); + let msgs = vec![assistant, tool]; + let mut buf = Vec::new(); + print_conversation_history(&msgs, &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains("read"), "tool name should render: {out:?}"); + assert!(out.contains("(5 lines)"), "meta line should render"); + assert!(out.contains("┊ +2 more lines"), "body should cap at 3"); + assert!(!out.contains("l4"), "hidden lines must not render"); + } + + #[test] + fn test_split_tool_result_unknown_defaults_to_tool() { + let names = HashMap::new(); + let (name, body) = split_tool_result("hello", &names, &None); + assert_eq!(name, "tool"); + assert_eq!(body, "hello"); + } } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 2c5e21d..a44a2c4 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -37,7 +37,7 @@ use tinyharness_ui::ui::input::CommandHelper; pub use display::{ format_args_summary, format_context_status, print_context_load_warning, - print_conversation_history, summarize_listing_result, + print_conversation_history, }; pub use input::read_multiline_input; pub use safety::{is_safe_command, strip_safe_descriptor_redirections}; diff --git a/src/agent/tools.rs b/src/agent/tools.rs index 58b3fae..a043c87 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -387,20 +387,6 @@ fn render_signal_result_cli( Ok(()) } -/// Format a duration in milliseconds as a human-readable string. -/// Under 1 second: "42ms", 1-59 seconds: "1.2s", 60+ seconds: "1m 23s" -fn format_duration(ms: u64) -> String { - if ms < 1000 { - format!("{}ms ", ms) - } else if ms < 60_000 { - format!("{:.1}s ", ms as f64 / 1000.0) - } else { - let mins = ms / 60_000; - let secs = (ms % 60_000) / 1000; - format!("{}m {}s ", mins, secs) - } -} - async fn execute_generic_tool( call: &ToolCall, tool_manager: &ToolManager, @@ -492,94 +478,16 @@ async fn execute_generic_tool( let duration_ms = start_time.elapsed().as_millis() as u64; - // For tools that return potentially large listings, show only a summary - match call.function.name.as_str() { - "read" => { - let is_error = result.starts_with("Error:"); - let summary = result.lines().next().unwrap_or("(empty result)"); - let indicator = if is_error { RED } else { GREEN }; - let icon = if is_error { "✗" } else { "✓" }; - let summary_color = if is_error { RED } else { DIM }; - writeln!( - stdout, - "{BG_DIM} {indicator}{icon}{RESET}{BG_DIM} {DIM}{name}{RESET}{BG_DIM} {duration}{summary_color}{summary}{FILL_EOL}{RESET}", - indicator = indicator, - icon = icon, - name = call.function.name, - duration = format_duration(duration_ms), - summary_color = summary_color, - summary = summary - ) - .unwrap(); - } - "ls" | "grep" | "glob" => { - let is_error = result.starts_with("Error:"); - let summary = super::display::summarize_listing_result(&result, &call.function.name); - let indicator = if is_error { RED } else { GREEN }; - let icon = if is_error { "✗" } else { "✓" }; - let summary_color = if is_error { RED } else { DIM }; - writeln!( - stdout, - "{BG_DIM} {indicator}{icon}{RESET}{BG_DIM} {DIM}{name}{RESET}{BG_DIM} {duration}{summary_color}{summary}{FILL_EOL}{RESET}", - indicator = indicator, - icon = icon, - name = call.function.name, - duration = format_duration(duration_ms), - summary_color = summary_color, - summary = summary - ) - .unwrap(); - } - _ => { - let is_error = result.starts_with("Error:"); - let indicator = if is_error { RED } else { GREEN }; - let icon = if is_error { "✗" } else { "✓" }; - - if is_error { - // Compact single-line error: truncate to fit one line - let error_msg = result.lines().next().unwrap_or("Error"); - // Truncate at 80 chars to keep the line compact - let max_err_len = 80; - let truncated = if error_msg.len() > max_err_len { - let cut = error_msg.floor_char_boundary(max_err_len - 1); - format!("{}…", &error_msg[..cut]) - } else { - error_msg.to_string() - }; - writeln!( - stdout, - "{BG_DIM} {indicator}{icon}{RESET}{BG_DIM} {DIM}{name}{RESET}{BG_DIM} {duration}{RED}{truncated}{FILL_EOL}{RESET}", - indicator = indicator, - icon = icon, - name = call.function.name, - duration = format_duration(duration_ms), - truncated = truncated, - ) - .unwrap(); - } else { - writeln!( - stdout, - "{BG_DIM} {indicator}{icon}{RESET}{BG_DIM} {DIM}{name}{RESET}{BG_DIM} {duration}", - indicator = indicator, - icon = icon, - name = call.function.name, - duration = format_duration(duration_ms), - ) - .unwrap(); - tinyharness_ui::ui::wrap::write_wrapped_lines( - stdout, - &result, - &format!("{BG_DIM} "), - &format!(" {BG_DIM}{DIM}"), - tinyharness_ui::ui::wrap::MAX_LINE_WIDTH, - true, // fill background to end of line - ) - .unwrap(); - } - } - } - writeln!(stdout, "{RESET}").unwrap(); - stdout.flush().unwrap(); + // Render the result through the shared tool-result renderer (same styling + // as session-history replay). + tinyharness_ui::ui::tool_result::write_tool_result( + stdout, + &call.function.name, + &result, + result.starts_with("Error:"), + Some(duration_ms), + ) + .unwrap(); // Capture audit-relevant info before returning let (audit_tool_name, audit_detail) = audit_info_for_tool(call); diff --git a/tinyharness-ui/src/ui/mod.rs b/tinyharness-ui/src/ui/mod.rs index f892827..182eb3c 100644 --- a/tinyharness-ui/src/ui/mod.rs +++ b/tinyharness-ui/src/ui/mod.rs @@ -2,4 +2,5 @@ pub mod confirm; pub mod diff; pub mod input; pub mod markdown; +pub mod tool_result; pub mod wrap; diff --git a/tinyharness-ui/src/ui/tool_result.rs b/tinyharness-ui/src/ui/tool_result.rs new file mode 100644 index 0000000..d1ca419 --- /dev/null +++ b/tinyharness-ui/src/ui/tool_result.rs @@ -0,0 +1,554 @@ +//! Compact, styled rendering of tool execution results. +//! +//! Shared by the live agent loop (after a tool finishes executing) and the +//! session-history replay (when a saved session is printed), so both paths +//! render identically. +//! +//! Tool results are *plain text*, not markdown — they are raw command output, +//! file listings and API responses emitted verbatim by the model. Interpreting +//! them as markdown would corrupt `**` in test output, `-` in diffs and `#` in +//! shell comments, so this module uses a small structural renderer instead, +//! following the same design rules as [`super::markdown`]: +//! +//! - **Never lose the summary.** Long output is capped, but the footer states +//! exactly how many lines were hidden (`┊ +N more lines`). +//! - **Errors are always visible in full prominence**, on a single red line. +//! - Styling is band-based (`BG_DIM` + `FILL_EOL`) so blocks read as one unit. + +use std::io::{self, Write}; + +use super::wrap::{MAX_LINE_WIDTH, write_wrapped_lines}; +use crate::style::*; + +/// Maximum body lines shown for free-form output (`run`, tool dumps). +const MAX_OUTPUT_LINES: usize = 10; +/// Maximum content lines shown after a `read` meta line. +const MAX_READ_LINES: usize = 3; +/// Maximum entries shown for parsed `web_search` output. +const MAX_SEARCH_RESULTS: usize = 5; +/// Maximum characters for single-line summaries before truncation. +const MAX_SUMMARY_CHARS: usize = 120; + +/// Signal tools produce conversational meta-results ("User answered …"), +/// not raw output — always render as a single dim line. +const SIGNAL_TOOLS: [&str; 4] = ["question", "switch_mode", "invoke_skill", "auto_compact"]; + +/// Format a duration in milliseconds as a human-readable string. +/// +/// Under 1 second: `42ms` — 1–59 seconds: `1.2s` — 60+: `1m 23s`. +pub fn format_duration(ms: u64) -> String { + if ms < 1000 { + format!("{ms}ms") + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + let mins = ms / 60_000; + let secs = (ms % 60_000) / 1000; + format!("{mins}m {secs}s") + } +} + +/// Truncate `s` to at most `max` characters (char-boundary safe), appending +/// `…` when truncation occurred. +fn truncate_ellipsis(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let cut = s.floor_char_boundary(max.saturating_sub(1)); + format!("{}…", &s[..cut]) +} + +/// Write one body line inside the `BG_DIM` band with a 6-space indent. +fn band_line(w: &mut W, text: &str, color: &str) -> io::Result<()> { + writeln!(w, "{BG_DIM} {color}{text}{FILL_EOL}{RESET}") +} + +/// Render one tool result block: a status header line plus a capped, +/// tool-specific body. +/// +/// - `tool`: tool name (e.g. `read`, `run`, `web_search`). +/// - `body`: the raw tool result, without any `### {tool}` prefix. +/// - `is_error`: whether the tool failed (colors the header `✗` red and +/// collapses the body to a single red line). +/// - `duration_ms`: execution duration, when known (`None` for history +/// replay, where durations are not persisted). +pub fn write_tool_result( + w: &mut W, + tool: &str, + body: &str, + is_error: bool, + duration_ms: Option, +) -> io::Result<()> { + let (icon, icon_color) = if is_error { + ("✗", RED) + } else { + ("✓", GREEN) + }; + let duration = duration_ms + .map(format_duration) + .map(|d| format!(" {DIM}· {d}{RESET}{BG_DIM}")) + .unwrap_or_default(); + writeln!( + w, + "{BG_DIM} {icon_color}{icon}{RESET}{BG_DIM} {DIM}{tool}{RESET}{BG_DIM}{duration}{FILL_EOL}{RESET}" + )?; + + if body.trim().is_empty() { + return Ok(()); + } + + if is_error { + let msg = body.lines().next().unwrap_or("Error"); + return band_line(w, &truncate_ellipsis(msg, MAX_SUMMARY_CHARS), RED); + } + + if body.starts_with("[Tool denied]") { + let msg = body.lines().next().unwrap_or(""); + return band_line(w, &truncate_ellipsis(msg, MAX_SUMMARY_CHARS), ORANGE); + } + + match tool { + // Listings render as a single summary line ("3 entries — a, b, c"). + "ls" | "grep" | "glob" => band_line( + w, + &truncate_ellipsis(&summarize_listing_result(body, tool), MAX_SUMMARY_CHARS), + DIM, + ), + // Structured search results render as a link-styled list. + "web_search" => match parse_web_search(body) { + Some(entries) => write_search_entries(w, &entries), + None => write_capped_lines(w, body, MAX_OUTPUT_LINES), + }, + // File reads show the line-count meta line, then a small preview. + "read" => write_read_body(w, body), + // Conversational meta-results collapse to one dim line. + t if SIGNAL_TOOLS.contains(&t) => { + let first = body.lines().next().unwrap_or(body); + band_line(w, &truncate_ellipsis(first, MAX_SUMMARY_CHARS), DIM) + } + // Everything else: capped raw output. + _ => write_capped_lines(w, body, MAX_OUTPUT_LINES), + } +} + +/// Render up to `max` lines of `body` (word-wrapped), with a footer stating +/// how many lines were hidden. +fn write_capped_lines(w: &mut W, body: &str, max: usize) -> io::Result<()> { + let total = body.lines().count(); + let shown: Vec<&str> = body.lines().take(max).collect(); + write_wrapped_lines( + w, + &shown.join("\n"), + &format!("{BG_DIM} {DIM}"), + &format!(" {BG_DIM}{DIM}"), + MAX_LINE_WIDTH, + true, + ) + .map_err(|_| io::Error::other("wrap failed"))?; + if total > shown.len() { + writeln!( + w, + "{BG_DIM} {DIM}┊ +{} more lines{FILL_EOL}{RESET}", + total - shown.len() + )?; + } + Ok(()) +} + +/// Render a `read` result: `(N lines)` meta line, then up to +/// [`MAX_READ_LINES`] content lines and a hidden-lines footer. +fn write_read_body(w: &mut W, body: &str) -> io::Result<()> { + let mut lines = body.lines(); + let first = lines.next().unwrap_or(""); + let is_meta = first.starts_with('(') && first.contains(" lines") && first.ends_with(')'); + + let (meta, content) = if is_meta { + (Some(first), lines.collect::>()) + } else { + ( + None, + std::iter::once(first).chain(lines).collect::>(), + ) + }; + + if let Some(meta) = meta { + band_line(w, meta, DIM)?; + } + + let total = content.len(); + let shown: Vec<&str> = content.iter().take(MAX_READ_LINES).copied().collect(); + for line in &shown { + band_line(w, &truncate_ellipsis(line, MAX_LINE_WIDTH), DIM)?; + } + if total > shown.len() { + writeln!( + w, + "{BG_DIM} {DIM}┊ +{} more lines{FILL_EOL}{RESET}", + total - shown.len() + )?; + } + Ok(()) +} + +/// One parsed `web_search` entry. +struct SearchEntry { + title: String, + url: String, + content: String, +} + +/// Parse the `web_search` tool's textual output format: +/// +/// ```text +/// [1] Title +/// URL: https://… +/// content… +/// ``` +/// +/// Returns `None` when the body doesn't match the format at all, so callers +/// can fall back to raw capped output. +fn parse_web_search(body: &str) -> Option> { + let mut entries: Vec = Vec::new(); + + for line in body.lines() { + if let Some(rest) = line.strip_prefix(" URL: ") { + // URL line completes the most recently started entry. + let last = entries.last_mut()?; + last.url = rest.trim().to_string(); + } else if line.starts_with('[') + && let Some(close) = line.find("] ") + && close > 1 + && line[1..close].chars().all(|c| c.is_ascii_digit()) + { + entries.push(SearchEntry { + title: line[close + 2..].trim().to_string(), + url: String::new(), + content: String::new(), + }); + } else if let Some(last) = entries.last_mut() { + // Continuation (snippet) lines accumulate into the open entry. + if !last.content.is_empty() { + last.content.push(' '); + } + last.content.push_str(line.trim()); + } + } + + if entries.is_empty() || entries.iter().any(|e| e.url.is_empty()) { + return None; + } + Some(entries) +} + +/// Render parsed search entries as a link-styled list (matching the markdown +/// renderer's link treatment: cyan text, dim URL). +fn write_search_entries(w: &mut W, entries: &[SearchEntry]) -> io::Result<()> { + let total = entries.len(); + for entry in entries.iter().take(MAX_SEARCH_RESULTS) { + writeln!( + w, + "{BG_DIM} {GRAY}•{RESET}{BG_DIM} {CYAN}{}{RESET}{BG_DIM} {DIM}({}){FILL_EOL}{RESET}", + entry.title, entry.url + )?; + let snippet = entry.content.lines().next().unwrap_or(""); + if !snippet.is_empty() { + band_line(w, &format!(" {}", truncate_ellipsis(snippet, 100)), DIM)?; + } + } + if total > MAX_SEARCH_RESULTS { + writeln!( + w, + "{BG_DIM} {DIM}┊ +{} more results{FILL_EOL}{RESET}", + total - MAX_SEARCH_RESULTS + )?; + } + Ok(()) +} + +/// Produce a one-line summary for listing tools (ls, grep, glob). +pub fn summarize_listing_result(result: &str, tool_name: &str) -> String { + if result.starts_with("Error:") || result.starts_with("No ") || result == "Directory is empty" { + return result.to_string(); + } + + let lines: Vec<&str> = result.lines().collect(); + let count = lines.len(); + let label = match tool_name { + "ls" => "entries", + "grep" => "matches", + "glob" => "files", + _ => "results", + }; + + const PREVIEW: usize = 3; + if count <= PREVIEW { + format!("{count} {label} — {result}") + } else { + let preview: Vec<&str> = lines.iter().take(PREVIEW).copied().collect(); + format!( + "{count} {label} — {} ... ({} more)", + preview.join(", "), + count - PREVIEW + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap(); + re.replace_all(s, "").to_string() + } + + fn render(tool: &str, body: &str, is_error: bool, ms: Option) -> String { + let mut buf = Vec::new(); + write_tool_result(&mut buf, tool, body, is_error, ms).unwrap(); + String::from_utf8(buf).unwrap() + } + + // ── Header ── + + #[test] + fn success_header_shows_check_icon_and_duration() { + let out = render("read", "(1 lines)\nhi", false, Some(42)); + assert!(out.contains('✓')); + assert!(out.contains("read")); + assert!(out.contains("42ms")); + assert!(!out.contains('✗')); + } + + #[test] + fn error_header_shows_cross_icon() { + let out = render("run", "Error: boom", true, Some(1500)); + assert!(out.contains('✗')); + assert!(out.contains(RED)); + assert!(out.contains("1.5s")); + } + + #[test] + fn history_omits_duration() { + let out = render("ls", "a\nb", false, None); + assert!(!out.contains('·'), "no duration separator: {out:?}"); + assert!(out.contains("ls")); + } + + #[test] + fn empty_body_is_header_only() { + let out = render("run", "", false, Some(5)); + assert_eq!(out.lines().count(), 1); + } + + // ── Error / denied bodies ── + + #[test] + fn error_body_is_single_red_line_truncated() { + let long = format!("Error: {}", "x".repeat(300)); + let out = render("run", &long, true, None); + assert_eq!(out.lines().count(), 2); // header + one body line + assert!(out.contains(RED)); + assert!(out.contains('…')); + assert!(!strip_ansi(&out).contains(&"x".repeat(200))); + } + + #[test] + fn denied_tool_renders_orange_single_line() { + let out = render( + "run", + "[Tool denied] The user denied the 'run' tool call with arguments: cmd=\"rm\"", + false, + None, + ); + assert!(out.contains(ORANGE)); + assert_eq!(out.lines().count(), 2); + } + + // ── Listing tools ── + + #[test] + fn listing_summary_line() { + let out = render("ls", "Cargo.toml\nsrc\ntarget\nx\ny", false, None); + let plain = strip_ansi(&out); + assert!(plain.contains("5 entries — Cargo.toml, src, target ... (2 more)")); + assert_eq!(plain.lines().count(), 2); + } + + #[test] + fn listing_error_passes_through() { + let out = render("grep", "Error: bad pattern", true, None); + assert!(strip_ansi(&out).contains("Error: bad pattern")); + } + + // ── read ── + + #[test] + fn read_shows_meta_then_capped_preview() { + let content: Vec = (1..=20).map(|i| format!("line{i}")).collect(); + let body = format!("(20 lines)\n{}", content.join("\n")); + let out = render("read", &body, false, None); + let plain = strip_ansi(&out); + assert!(plain.contains("(20 lines)")); + assert!(plain.contains("line1")); + assert!(plain.contains("line3")); + assert!(!plain.contains("line4")); + assert!(plain.contains("┊ +17 more lines")); + } + + #[test] + fn read_without_meta_line_renders_all_as_content() { + let out = render("read", "just one line", false, None); + let plain = strip_ansi(&out); + assert!(plain.contains("just one line")); + assert!(!plain.contains("┊")); + } + + // ── run / default capping ── + + #[test] + fn run_output_capped_with_footer() { + let content: Vec = (1..=50).map(|i| format!("out{i}")).collect(); + let out = render("run", &content.join("\n"), false, Some(2300)); + let plain = strip_ansi(&out); + assert!(plain.contains("out1")); + assert!(plain.contains("out10")); + assert!(!plain.contains("out11")); + assert!(plain.contains("┊ +40 more lines")); + assert!(out.contains("2.3s")); + } + + #[test] + fn short_output_not_capped() { + let out = render("run", "one\ntwo", false, None); + assert!(!out.contains("┊")); + let plain = strip_ansi(&out); + assert!(plain.contains("one")); + assert!(plain.contains("two")); + } + + // ── web_search ── + + fn search_body(n: usize) -> String { + (1..=n) + .map(|i| format!("[{i}] Title {i}\n URL: https://x.dev/{i}\n Snippet {i} text\n")) + .collect::>() + .join("\n") + } + + #[test] + fn web_search_renders_link_styled_list() { + let out = render("web_search", &search_body(3), false, None); + let plain = strip_ansi(&out); + assert!(plain.contains("• Title 1")); + assert!(plain.contains("(https://x.dev/1)")); + assert!(plain.contains("Snippet 1 text")); + assert!(out.contains(CYAN), "titles should be link-colored"); + assert!(!out.contains("┊"), "3 entries fit under the cap"); + } + + #[test] + fn web_search_caps_results_with_footer() { + let out = render("web_search", &search_body(8), false, None); + let plain = strip_ansi(&out); + assert!(plain.contains("Title 1")); + assert!(plain.contains("Title 5")); + assert!(!plain.contains("Title 6")); + assert!(plain.contains("┊ +3 more results")); + } + + #[test] + fn web_search_unparseable_falls_back_to_raw() { + let out = render("web_search", "No results found.", false, None); + let plain = strip_ansi(&out); + assert!(plain.contains("No results found.")); + } + + #[test] + fn web_search_prose_starting_with_bracket_falls_back() { + // `[1] x` without a following URL line is prose, not a result list. + let out = render( + "web_search", + "[1] see section one of the manual", + false, + None, + ); + assert!(strip_ansi(&out).contains("see section one")); + } + + // ── signal tools ── + + #[test] + fn signal_tool_result_is_single_dim_line() { + let out = render( + "question", + "User answered the question 'Deploy?' with: 'yes'.", + false, + None, + ); + assert_eq!(out.lines().count(), 2); + assert!(strip_ansi(&out).contains("User answered the question")); + } + + #[test] + fn debug_web_search_parse() { + let body = "[1] Title 1\n URL: https://x.dev/1\n Snippet 1 text\n"; + match super::parse_web_search(body) { + Some(entries) => { + for e in &entries { + println!( + "ENTRY title={:?} url={:?} content={:?}", + e.title, e.url, e.content + ); + } + } + None => println!("PARSE RETURNED NONE"), + } + } + + #[test] + fn debug_web_search_full_render() { + let body: String = (1..=3) + .map(|i| format!("[{i}] Title {i}\n URL: https://x.dev/{i}\n Snippet {i} text\n")) + .collect::>() + .join("\n"); + println!("BODY: {body:?}"); + let mut buf = Vec::new(); + super::write_tool_result(&mut buf, "web_search", &body, false, None).unwrap(); + for l in String::from_utf8(buf).unwrap().lines() { + println!("OUT: {l:?}"); + } + } + + // ── format_duration ── + + #[test] + fn duration_formatting() { + assert_eq!(format_duration(42), "42ms"); + assert_eq!(format_duration(1200), "1.2s"); + assert_eq!(format_duration(83_000), "1m 23s"); + } + + // ── summarize_listing_result ── + + #[test] + fn summarize_labels_per_tool() { + assert_eq!( + summarize_listing_result("a\nb\nc", "grep"), + "3 matches — a\nb\nc" + ); + assert_eq!(summarize_listing_result("a", "ls"), "1 entries — a"); + } + + #[test] + fn summarize_error_passthrough() { + assert_eq!(summarize_listing_result("Error: nope", "ls"), "Error: nope"); + } + + #[test] + fn truncate_ellipsis_is_char_boundary_safe() { + let s = "żółw".repeat(50); + let t = truncate_ellipsis(&s, 10); + assert!(t.ends_with('…')); + assert!(t.is_char_boundary(0)); + } +} From 883592c07a6ae2b040cf295affad1b3b6576a90b Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Thu, 1 Oct 2026 13:23:22 +0200 Subject: [PATCH 3/5] Update package versions --- Cargo.lock | 718 ++++++++++++++++++++++++++--------------------------- 1 file changed, 351 insertions(+), 367 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f761d7c..67197c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,9 +24,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -109,7 +109,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -126,9 +126,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -136,14 +136,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -152,6 +153,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -169,9 +176,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -208,15 +215,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -226,15 +233,26 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] [[package]] name = "chrono" @@ -251,9 +269,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -261,9 +279,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -273,21 +291,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "clipboard-win" @@ -321,9 +339,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -361,6 +379,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -372,9 +396,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -420,9 +444,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "digest" @@ -460,13 +484,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -483,11 +507,16 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -514,21 +543,21 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fnv" @@ -568,53 +597,53 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -656,11 +685,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -670,15 +697,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "h2" @@ -731,9 +761,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -741,9 +771,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -751,9 +781,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -770,18 +800,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -800,9 +830,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -831,16 +861,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -880,9 +911,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -894,9 +925,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -907,9 +938,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -921,16 +952,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -941,15 +973,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -983,9 +1015,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -993,9 +1025,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -1036,7 +1068,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1055,24 +1087,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1081,15 +1113,15 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -1099,9 +1131,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1114,27 +1146,27 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "md5" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" +checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1144,15 +1176,21 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "native-tls" version = "0.2.18" @@ -1226,9 +1264,9 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "ollama-rs" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22839a9841aacb7bcd70a3f73575945b89ffaa7a5532c8a85cf2fee85d919b8c" +checksum = "fed8739c37da731774bd5b39209bbd9b770f0c1d9a000c4b859c824badac3e8e" dependencies = [ "async-stream", "log", @@ -1277,7 +1315,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1335,15 +1373,15 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1359,9 +1397,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1376,7 +1414,7 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -1393,9 +1431,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -1413,15 +1451,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.3", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -1435,23 +1474,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1480,12 +1519,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1495,7 +1545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1507,13 +1557,28 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1527,29 +1592,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1559,9 +1624,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1580,7 +1645,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", @@ -1615,11 +1680,11 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "encoding_rs", "futures-core", @@ -1672,9 +1737,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -1687,9 +1752,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1700,9 +1765,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -1726,9 +1791,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -1736,9 +1801,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -1757,15 +1822,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -1775,9 +1840,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -1793,9 +1858,9 @@ dependencies = [ [[package]] name = "rustyline" -version = "18.0.0" +version = "18.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" dependencies = [ "bitflags", "cfg-if", @@ -1821,7 +1886,7 @@ checksum = "64e5587417a3c4e16a4415e8d7d07f80998ed835ade621d19dfbe9fbe3205b0f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1850,9 +1915,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -1863,14 +1928,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -1910,9 +1975,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1920,40 +1985,40 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", @@ -1977,9 +2042,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -1993,7 +2058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -2024,9 +2089,9 @@ dependencies = [ [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -2046,15 +2111,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2086,9 +2151,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -2097,9 +2162,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -2117,13 +2182,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2162,22 +2227,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2200,7 +2265,7 @@ dependencies = [ "md5", "ollama-rs", "regex", - "reqwest 0.13.4", + "reqwest 0.13.5", "schemars", "serde", "serde_json", @@ -2226,9 +2291,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2236,24 +2301,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -2268,13 +2324,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2289,9 +2345,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -2299,9 +2355,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -2322,13 +2378,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2397,7 +2454,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2452,7 +2509,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.9.5", "sha1", "thiserror", ] @@ -2471,9 +2528,9 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -2519,9 +2576,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -2591,9 +2648,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2604,19 +2661,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2624,22 +2682,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -2672,9 +2730,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2692,9 +2750,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -2729,7 +2787,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2740,7 +2798,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2784,16 +2842,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2811,31 +2860,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2844,96 +2876,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -2942,9 +2926,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" @@ -2959,34 +2943,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3000,13 +2984,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "synstructure", ] @@ -3018,9 +3002,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3029,9 +3013,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3040,17 +3024,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From 32edea568a2a2414fb661c098f3001c001fc8fc8 Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Thu, 1 Oct 2026 13:27:14 +0200 Subject: [PATCH 4/5] Fix typo error --- tinyharness-ui/src/ui/markdown.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinyharness-ui/src/ui/markdown.rs b/tinyharness-ui/src/ui/markdown.rs index 12fdbf0..3cbd40a 100644 --- a/tinyharness-ui/src/ui/markdown.rs +++ b/tinyharness-ui/src/ui/markdown.rs @@ -606,10 +606,10 @@ mod tests { fn stream_emits_line_only_on_newline() { let mut md = MarkdownStream::new(); let mut buf = Vec::new(); - md.push(&mut buf, "# Hel").unwrap(); + md.push(&mut buf, "# He").unwrap(); // No newline yet: nothing visible except the initial reset. assert_eq!(String::from_utf8_lossy(&buf), ASSISTANT_TEXT); - md.push(&mut buf, "lo world\nnext\n").unwrap(); + md.push(&mut buf, "llo world\nnext\n").unwrap(); let s = String::from_utf8(buf).unwrap(); assert!(s.contains("Hello world")); assert!(s.contains("next")); From fc0c6b860867725578c56c817377c72980a4f4c1 Mon Sep 17 00:00:00 2001 From: PTFOPlayer Date: Thu, 1 Oct 2026 15:49:50 +0200 Subject: [PATCH 5/5] Unify tool I/O under one framed visual language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool output, diffs and confirmation prompts each drew their own boxes, so a turn read as unrelated blocks rather than as one transcript. Extract the shared layout into `ui::frame` (filled title band, outlined gutter, closing `╰` elbow) and rebuild `tool_result`, `diff`, `confirm` and `wrap` on it. - style: add the frame palette (BG_BAND/BG_PROMPT, FG_OK/ERR/WARN/ACCENT…) picked from 256-color ramps that survive light and dark themes; deprecate BG_DIM/BG_TOOL/BG_WARN in favour of the renamed constants. - cards: title band names the tool and its subject with timing (`● read · path · 42ms`); status moves into the glyph so body rows stay muted and an error line is the only loud thing. - confirmations: title band + `$ command`/diff body + inline choices; a declined call now renders a `⊘ … · denied` card so the transcript records the refusal instead of dropping it. - replay: an assistant turn's tool calls and their stored results collapse into single cards via `tool_subject`, instead of a bare `▶ name` line. Docs and README updated to describe the new blocks. --- README.md | 23 +- docs/safety.md | 13 +- src/agent/display.rs | 100 +++- src/agent/tools.rs | 211 ++++--- tinyharness-ui/src/style.rs | 34 +- tinyharness-ui/src/ui/confirm.rs | 216 +++---- tinyharness-ui/src/ui/diff.rs | 588 +++++++++---------- tinyharness-ui/src/ui/frame.rs | 197 +++++++ tinyharness-ui/src/ui/mod.rs | 1 + tinyharness-ui/src/ui/tool_result.rs | 815 +++++++++++++++++++-------- tinyharness-ui/src/ui/wrap.rs | 150 ++++- 11 files changed, 1541 insertions(+), 807 deletions(-) create mode 100644 tinyharness-ui/src/ui/frame.rs diff --git a/README.md b/README.md index 516781b..6d17b98 100644 --- a/README.md +++ b/README.md @@ -384,15 +384,36 @@ Terminal UI abstractions — reusable output formatting, diff display, and confi tinyharness-ui/src/ ├── lib.rs Module declarations ├── output.rs Structured output writer (stdout/stderr abstraction) -├── style.rs ANSI color constants (BOLD, CYAN, RED, BG_TOOL, SPINNER_FRAMES, etc.) +├── style.rs ANSI color constants (BOLD, CYAN, the tool-frame palette, SPINNER_FRAMES) └── ui/ ├── mod.rs Module declarations ├── confirm.rs Tool call confirmation prompts (Yes/No/Auto-accept) ├── diff.rs Unified diff display + ├── frame.rs Shared layout primitives for the tool-frame family ├── input.rs CommandHelper for rustyline tab-completion + ├── markdown.rs Incremental markdown renderer for streamed assistant output + ├── tool_result.rs Tool execution cards (status band + capped body) └── wrap.rs Word-wrapped output with ANSI-aware line filling ``` +Tool I/O shares one visual language: a filled **title band** names each call +(`● run · cargo test --workspace · 2.3s`), and an outlined **gutter** carries +its body, closing with an elbow so every block reads as one unit. + +``` + ● read · tinyharness-ui/src/ui/frame.rs · 42ms + │ (180 lines) + │ use std::io::{self, Write}; + ╰ +176 more lines + + ✕ run · cargo clippy --workspace · 1.5s + ╰ error[E0308]: mismatched types + + ▲ Allow? · run — execute a shell command + │ $ rm -rf ./target + ▲ [y]es [n]o [a]uto-accept the rest of this turn › +``` + ### Binary crate (`src/`) CLI application — argument parsing, agent loop, slash commands, tool dispatch, safety checking. diff --git a/docs/safety.md b/docs/safety.md index af22f1b..631a1ec 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -168,14 +168,19 @@ Mixed chains work: ### Destructive Tools -`write`, `edit`, and `run` always show a confirmation prompt: +`write`, `edit`, and `run` always show a confirmation prompt — a title band +naming the tool and its effect, the command or diff it applies to, then the +choices: ``` - Write to /path/to/file.rs (4194 bytes) - -Confirm? (Y)es / (N)o / (A)uto-accept future + ▲ Allow? · run — execute a shell command + │ $ cargo clippy --workspace -- -D warnings + ▲ [y]es [n]o [a]uto-accept the rest of this turn › ``` +Declining renders a `⊘ … · denied` card, so the transcript records what was +refused rather than nothing at all. + ### Auto-Accept Mode Toggle with `/autoaccept off`, `/autoaccept safe`, or `/autoaccept all`. diff --git a/src/agent/display.rs b/src/agent/display.rs index c5f85cf..0d1b2f9 100644 --- a/src/agent/display.rs +++ b/src/agent/display.rs @@ -7,6 +7,7 @@ use tinyharness_lib::{ }; use tinyharness_ui::style::*; +use tinyharness_ui::ui::tool_result::{ToolStatus, tool_subject}; /// Print a warning if the loaded session's conversation has many messages. /// @@ -120,6 +121,10 @@ pub fn print_conversation_history( // result messages can be rendered with the right tool-specific body. let mut tool_names: std::collections::HashMap = std::collections::HashMap::new(); + // tool_call_id → (tool name, subject), so the result card can name what + // the call acted on — arguments are not persisted on the Tool message. + let mut pending_cards: std::collections::HashMap)> = + std::collections::HashMap::new(); for msg in messages { match msg.role { @@ -140,6 +145,10 @@ pub fn print_conversation_history( stdout .write_all(rendered.strip_suffix('\n').unwrap_or(&rendered).as_bytes())?; writeln!(stdout, "{RESET}")?; + // Blank line after prose. Tool-only turns skip it: the + // assistant header already ends on one, and a doubled gap + // would separate the header from its own cards. + writeln!(stdout)?; } if !msg.tool_calls.is_empty() { for tc in &msg.tool_calls { @@ -148,26 +157,43 @@ pub fn print_conversation_history( if let Some(id) = &tc.id { tool_names.insert(id.clone(), tc.function.name.clone()); } - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM}{FILL_EOL}{RESET}", - name = tc.function.name - )?; + // Replay shows the call and its result as one card: + // the stored Tool message carries the outcome, so the + // header is emitted there rather than as a separate + // "▶ name" line. + pending_cards.insert( + tc.id.clone().unwrap_or_default(), + ( + tc.function.name.clone(), + tool_subject(&tc.function.arguments), + ), + ); } } - writeln!(stdout)?; } Role::Tool => { let (tool_name, result_body) = split_tool_result(&msg.content, &tool_names, &msg.tool_call_id); - tinyharness_ui::ui::tool_result::write_tool_result( + let subject = msg + .tool_call_id + .as_deref() + .and_then(|id| pending_cards.get(id)) + .and_then(|(_, subject)| subject.clone()); + let denied = result_body.starts_with("[Tool denied]"); + tinyharness_ui::ui::tool_result::write_tool_card( stdout, &tool_name, - result_body, - result_body.starts_with("Error:"), + subject.as_deref(), + if denied { + ToolStatus::Denied + } else if result_body.starts_with("Error:") { + ToolStatus::Error + } else { + ToolStatus::Ok + }, None, + result_body, )?; - writeln!(stdout, "{RESET}")?; } } } @@ -359,6 +385,12 @@ mod tests { use super::*; + /// Strip ANSI escape sequences so assertions can match rendered text. + fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*[mK]").unwrap(); + re.replace_all(s, "").to_string() + } + #[test] fn test_format_args_summary_short_string() { let args = serde_json::json!({"path": "/tmp/test.rs", "content": "hello"}); @@ -583,7 +615,7 @@ mod tests { id: Some("call_0".to_string()), function: ToolCallFunction { name: "read".to_string(), - arguments: serde_json::json!({}), + arguments: serde_json::json!({"path": "src/lib.rs"}), thought_signature: None, }, }], @@ -598,9 +630,51 @@ mod tests { print_conversation_history(&msgs, &mut buf).unwrap(); let out = String::from_utf8(buf).unwrap(); assert!(out.contains("read"), "tool name should render: {out:?}"); + assert!( + out.contains("src/lib.rs"), + "the call's subject should carry over from the assistant message: {out:?}" + ); assert!(out.contains("(5 lines)"), "meta line should render"); - assert!(out.contains("┊ +2 more lines"), "body should cap at 3"); - assert!(!out.contains("l4"), "hidden lines must not render"); + let plain = strip_ansi(&out); + assert!( + plain.contains("╰ +2 more lines"), + "body should cap at 3: {plain:?}" + ); + assert!(!plain.contains("l4"), "hidden lines must not render"); + } + + #[test] + fn test_history_denied_call_renders_as_denied_card() { + use tinyharness_lib::provider::{ToolCall, ToolCallFunction}; + let assistant = Message { + role: Role::Assistant, + content: String::new(), + tool_calls: vec![ToolCall { + id: Some("call_0".to_string()), + function: ToolCallFunction { + name: "run".to_string(), + arguments: serde_json::json!({"command": "rm -rf /"}), + thought_signature: None, + }, + }], + tool_call_id: None, + images: vec![], + thinking: None, + }; + let mut tool = Message::simple( + Role::Tool, + "### run\n\n[Tool denied] The user denied the 'run' tool call", + ); + tool.tool_call_id = Some("call_0".to_string()); + let mut buf = Vec::new(); + print_conversation_history(&[assistant, tool], &mut buf).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!( + out.contains('⊘'), + "denied calls use the deny glyph: {out:?}" + ); + assert!(out.contains("rm -rf /"), "subject should render"); + assert!(!out.contains('●'), "a denial is not a success"); } #[test] diff --git a/src/agent/tools.rs b/src/agent/tools.rs index a043c87..121289a 100644 --- a/src/agent/tools.rs +++ b/src/agent/tools.rs @@ -14,6 +14,8 @@ use tinyharness_lib::{ use crate::commands::CommandContext; use tinyharness_ui::style::*; use tinyharness_ui::ui::confirm::Confirmation; +use tinyharness_ui::ui::frame::status_line; +use tinyharness_ui::ui::tool_result::ToolStatus; use super::confirm::ConfirmationDecision; use super::signal::{self, SignalResult}; @@ -55,11 +57,10 @@ pub async fn handle_tool_calls( let tool_count = tool_calls.len(); // Track cumulative tool calls for session stats. session.add_tool_calls(tool_count as u64); - writeln!( - stdout, - "\n{BG_TOOL} {WHITE}{count} tool call(s){FILL_EOL}{RESET}", - count = tool_count - )?; + // Blank line separating the assistant's prose from the first card of the + // batch. Cards within the batch stay tight together — they read as one + // group. + writeln!(stdout)?; messages.push(Message { role: Role::Assistant, @@ -140,18 +141,24 @@ pub async fn handle_tool_calls( ConfirmationDecision::NeedsConfirmation => { match tinyharness_ui::ui::confirm::prompt_tool_confirmation(stdout, &call)? { Confirmation::No => { - stdout.write_all( - format!(" {}Skipped{}{}\n", ORANGE, RESET, BOLD).as_bytes(), + // Denials get a card of their own so the transcript + // shows what was refused, rather than a bare word. + tinyharness_ui::ui::tool_result::write_tool_card( + stdout, + &call.function.name, + tinyharness_ui::ui::tool_result::tool_subject(&call.function.arguments) + .as_deref(), + ToolStatus::Denied, + None, + "", )?; - stdout.flush()?; (false, false) } Confirmation::AutoAccept => { *auto_accept = true; writeln!( stdout, - " {}Auto-accept enabled for the rest of this turn{}", - GREEN, RESET + " {FG_OK}●{RESET} {DIM}auto-accepting the rest of this turn{RESET}" )?; (true, true) } @@ -178,7 +185,7 @@ pub async fn handle_tool_calls( } // Generic tool execution — collect result for batching - let result = execute_generic_tool(&call, tool_manager, stdout, auto_accepted).await; + let result = execute_generic_tool(&call, tool_manager, stdout).await; // Log to audit if this was an auditable tool (run/write/edit) log_tool_audit( @@ -220,39 +227,36 @@ fn handle_question_cli( return Ok(()); } - // Display the question and options + // Display the question and options inside the same frame language as the + // tool cards: a band for the ask, a gutter for the options. + writeln!(stdout)?; + tinyharness_ui::ui::frame::write_band( + stdout, + &[("?", FG_ACCENT), (" ", FG_FAINT), ("Question", FG_MUTED)], + )?; writeln!( stdout, - "\n{} ┌─── {}❓ Question {}─────{}", - BOLD, CYAN, BOLD, RESET + " {FG_FAINT}│{RESET} {BOLD}{question}{RESET}", + question = question )?; - writeln!(stdout, " │ {}{}{}", BOLD, question, RESET)?; - writeln!(stdout, " │")?; for (i, answer) in answers.iter().enumerate() { writeln!( stdout, - " │ {}{}.{}) {} {}{}", - GREEN, - i + 1, - RESET, - BOLD, - answer, - RESET + " {FG_FAINT}│{RESET} {FG_OK}{n}{RESET}{FG_FAINT}){RESET} {BOLD}{answer}{RESET}", + n = i + 1, + answer = answer )?; } - writeln!(stdout, " │")?; writeln!( stdout, - " │ {}Enter anything else to skip with a custom answer{}", - DIM, RESET + " {FG_FAINT}╰{RESET} {FG_FAINT}anything else skips with a custom answer{RESET}" )?; - writeln!(stdout, " └{}──────────────────────────────{}", BOLD, RESET)?; let answer_count = answers.len(); write!( stdout, - " {}Your choice (1-{} or type to skip): {}", - BOLD, answer_count, RESET + " {BOLD}Your choice{RESET} {FG_FAINT}(1-{answer_count} or type to skip){RESET}{FG_FAINT} ›{RESET} ", + answer_count = answer_count )?; stdout.flush()?; @@ -282,14 +286,14 @@ fn handle_question_cli( if is_skip { writeln!( stdout, - " {}⊘{} Skipped with: {}{}{}", - ORANGE, RESET, BOLD, selected_answer, RESET + " {FG_WARN}⊘{RESET} {FG_FAINT}skipped with{RESET} {BOLD}{answer}{RESET}", + answer = selected_answer )?; } else { writeln!( stdout, - " {}✓{} Selected: {}{}{}", - GREEN, RESET, BOLD, selected_answer, RESET + " {FG_OK}●{RESET} {FG_FAINT}answered{RESET} {BOLD}{answer}{RESET}", + answer = selected_answer )?; } stdout.flush()?; @@ -316,16 +320,22 @@ fn render_signal_result_cli( new_mode, already_in, } => { + // One-line outcomes use the card glyphs so the whole transcript + // speaks the same visual language: `●` something happened, + // `⊘` nothing did, `✕` it failed. if *already_in { - writeln!( + status_line( stdout, - " {ORANGE}Already in '{new_mode}' mode. No change was made.{RESET}", + "⊘", + FG_WARN, + &format!("already in '{new_mode}' mode — no change"), )?; } else { - writeln!( + status_line( stdout, - "\n{}{}🔄 Mode switched: {} → {}{}", - BOLD, BLUE, old_mode, new_mode, RESET + "●", + FG_ACCENT, + &format!("mode switched: {old_mode} → {new_mode}"), )?; } stdout.flush()?; @@ -336,13 +346,9 @@ fn render_signal_result_cli( error, } => { if *success { - writeln!( - stdout, - "\n{} {}▶ auto_compact{} Compacting conversation history...", - DIM, CYAN, RESET - )?; + status_line(stdout, "●", FG_ACCENT, "compacting conversation history…")?; } else if let Some(e) = error { - writeln!(stdout, "\n{}⚠ Auto-compact failed: {}{}", RED, e, RESET)?; + status_line(stdout, "✕", FG_ERR, &format!("auto-compact failed: {e}"))?; } stdout.flush()?; } @@ -353,22 +359,25 @@ fn render_signal_result_cli( found, } => { if *already_active { - writeln!( + status_line( stdout, - "\n{}⚠ Skill '{}' is already active.{}", - ORANGE, name, RESET + "⊘", + FG_WARN, + &format!("skill '{name}' already active"), )?; } else if *found { - writeln!( + status_line( stdout, - "\n{}{}⚡ Skill activated: {}{}{} — {}{}", - BOLD, CYAN, BOLD, name, RESET, description, RESET + "●", + FG_ACCENT, + &format!("skill activated: {name} — {description}"), )?; } else { - writeln!( + status_line( stdout, - "\n{}⚠ Skill '{}' not found — it may have been removed.{}", - RED, name, RESET + "✕", + FG_ERR, + &format!("skill '{name}' not found — it may have been removed"), )?; } stdout.flush()?; @@ -377,10 +386,11 @@ fn render_signal_result_cli( // Question is handled separately via handle_question_cli } SignalResult::ParseError { tool_name } => { - writeln!( + status_line( stdout, - "\n{}⚠ Could not parse arguments for signal tool '{}'.{}", - RED, tool_name, RESET + "✕", + FG_ERR, + &format!("could not parse arguments for signal tool '{tool_name}'"), )?; } } @@ -391,56 +401,14 @@ async fn execute_generic_tool( call: &ToolCall, tool_manager: &ToolManager, stdout: &mut W, - auto_accepted: bool, ) -> GenericToolResult { - // Show the "Executing..." header line - if auto_accepted { - if call.function.name == "run" { - if let Some(cmd) = call - .function - .arguments - .get("command") - .and_then(|v| v.as_str()) - { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM} (auto-accepted){FILL_EOL}{RESET}", - name = call.function.name - ) - .unwrap(); - writeln!( - stdout, - "{BG_DIM} {BRIGHT_CYAN}{cmd}{FILL_EOL}{RESET}", - cmd = cmd - ) - .unwrap(); - } else { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM} (auto-accepted){FILL_EOL}{RESET}", - name = call.function.name - ) - .unwrap(); - } - } else { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM} (auto-accepted){FILL_EOL}{RESET}", - name = call.function.name - ) - .unwrap(); - } - } else { - writeln!( - stdout, - "{BG_DIM} {DIM}▶ {WHITE}{name}{DIM} Executing...{FILL_EOL}{RESET}", - name = call.function.name - ) - .unwrap(); - } - stdout.flush().unwrap(); + use tinyharness_ui::ui::tool_result::{ToolStatus, tool_subject, write_tool_card}; - // Spinner state for tool execution animation + let subject = tool_subject(&call.function.arguments); + // The progress line is drawn lazily: instant calls never show it, so the + // common case stays one card per call with no flicker. It is erased once + // the real card is printed, which is why the tool/subject are not repeated + // there. let mut spinner_idx: usize = 0; let mut has_shown_spinner = false; @@ -454,7 +422,7 @@ async fn execute_generic_tool( let result = loop { tokio::select! { result = &mut tool_fut => { - // Tool finished — clear spinner line if we showed one + // Tool finished — clear the progress line if we showed one. if has_shown_spinner { write!(stdout, "\r{CLEAR_LINE}{RESET}").unwrap(); stdout.flush().unwrap(); @@ -462,36 +430,51 @@ async fn execute_generic_tool( break result; } _ = tokio::time::sleep(std::time::Duration::from_millis(80)) => { - // Animate spinner + // Animate: redraw the whole progress line each tick so a + // shrinking frame never leaves stale text behind. let frame = SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.len()]; spinner_idx += 1; if has_shown_spinner { - write!(stdout, "\r{DIM}{frame} {RESET}").unwrap(); + write!(stdout, "\r{CLEAR_LINE}").unwrap(); } else { - write!(stdout, "{DIM}{frame} {RESET}").unwrap(); has_shown_spinner = true; } + write!( + stdout, + " {FG_ACCENT}{frame}{RESET} {FG_MUTED}{name}{RESET}{rest}", + name = call.function.name, + rest = subject + .as_deref() + .map(|s| format!(" {FG_FAINT}·{RESET} {FG_FAINT}{s}{RESET}")) + .unwrap_or_default(), + ) + .unwrap(); stdout.flush().unwrap(); } } }; let duration_ms = start_time.elapsed().as_millis() as u64; + let is_error = result.starts_with("Error:"); - // Render the result through the shared tool-result renderer (same styling - // as session-history replay). - tinyharness_ui::ui::tool_result::write_tool_result( + // Render the card through the shared renderer (same styling as + // session-history replay), so live and replayed transcripts match. + write_tool_card( stdout, &call.function.name, - &result, - result.starts_with("Error:"), + subject.as_deref(), + if is_error { + ToolStatus::Error + } else { + ToolStatus::Ok + }, Some(duration_ms), + &result, ) .unwrap(); // Capture audit-relevant info before returning let (audit_tool_name, audit_detail) = audit_info_for_tool(call); - let is_error = result.starts_with("Error:"); // For read tool on image files, load the image data for the model to view. // The read tool prefixes image results with "[IMAGE] path" so we can detect them. diff --git a/tinyharness-ui/src/style.rs b/tinyharness-ui/src/style.rs index 7a10dd0..cc362b0 100644 --- a/tinyharness-ui/src/style.rs +++ b/tinyharness-ui/src/style.rs @@ -22,6 +22,31 @@ pub const ORANGE: &str = "\x1b[38;5;208m"; // Warning / notice accent (matches ` pub const BRIGHT_YELLOW: &str = "\x1b[93m"; pub const BRIGHT_CYAN: &str = "\x1b[96m"; +// ── Tool frame palette ───────────────────────────────────────────────────── +// +// The tool-call family (execution cards, result blocks, diffs and the +// confirmation prompt) shares one visual language: a single filled *title +// band* per block, an outlined gutter body, and status conveyed by a colored +// glyph rather than by tinting whole lines. +// +// 256-color SGR is the common denominator for the terminals this CLI targets +// (the UI crate deliberately has no terminal-capability dependency), and the +// indices below are chosen from the neutral/flat ramps that survive both +// light and dark themes: they are either background shades or saturated +// mid-lightness hues, never the pure primary 1..4 which theme authors remap +// aggressively. +pub const BG_BAND: &str = "\x1b[48;5;236m"; // Card title band (neutral charcoal) +pub const BG_PROMPT: &str = "\x1b[48;5;237m"; // Confirmation prompt band (one step lighter) + +pub const FG_FAINT: &str = "\x1b[38;5;240m"; // Gutters, timestamps, "more lines" footers +pub const FG_MUTED: &str = "\x1b[38;5;245m"; // Secondary text (argument values, snippets) +pub const FG_BAND: &str = "\x1b[38;5;252m"; // Tool name on a band (needs contrast vs BG_BAND) +pub const FG_ACCENT: &str = "\x1b[38;5;39m"; // Tool identity, paths, result titles +pub const FG_CMD: &str = "\x1b[38;5;45m"; // Shell command text +pub const FG_OK: &str = "\x1b[38;5;41m"; // Success status / additions +pub const FG_ERR: &str = "\x1b[38;5;197m"; // Failure status / removals +pub const FG_WARN: &str = "\x1b[38;5;214m"; // Denied calls, warnings, the approval prompt + // Assistant prose: rendered in the terminal's *default* foreground so it stays // legible against any user theme (the modern CLI convention). Emitted once, // before the first streamed content chunk, to clear any residual styling left @@ -35,9 +60,12 @@ pub const THINK_COLOR: &str = "\x1b[35m"; // Magenta for thinking text pub const THINK_COLOR_DIM: &str = "\x1b[38;5;97m"; // Dimmer magenta // Background colors (subtle, for tool call highlighting) -pub const BG_DIM: &str = "\x1b[48;5;236m"; // Dark gray bg — tool call results -pub const BG_TOOL: &str = "\x1b[48;5;237m"; // Slightly lighter gray bg — tool headers -pub const BG_WARN: &str = "\x1b[48;5;17m"; // Dark navy bg — confirmation/warning headers +#[deprecated(note = "renamed to BG_BAND — the tool card title band")] +pub const BG_DIM: &str = "\x1b[48;5;236m"; +#[deprecated(note = "tool batch headers use the outlined frame in ui::frame")] +pub const BG_TOOL: &str = "\x1b[48;5;237m"; +#[deprecated(note = "renamed to BG_PROMPT — the confirmation prompt band")] +pub const BG_WARN: &str = "\x1b[48;5;17m"; // Line fill: clears from cursor to end of line, filling with current background color pub const FILL_EOL: &str = "\x1b[K"; diff --git a/tinyharness-ui/src/ui/confirm.rs b/tinyharness-ui/src/ui/confirm.rs index 76dc785..0954f49 100644 --- a/tinyharness-ui/src/ui/confirm.rs +++ b/tinyharness-ui/src/ui/confirm.rs @@ -1,72 +1,65 @@ +//! The approval prompt: the one place the CLI interrupts the user. +//! +//! Rendered in the same visual language as the tool cards (see +//! [`super::frame`]): a filled band that states what is being asked and why, +//! the arguments/preview underneath, then a single-line choice list. The band +//! is deliberately *lighter* than a card band ([`BG_PROMPT`] vs [`BG_BAND`]) +//! so a pending question never reads as a completed action. + use std::{ error::Error, io::{self, Write}, }; use super::diff::{show_edit_diff, show_write_preview}; -use super::wrap::MAX_LINE_WIDTH; +use super::frame::{ + GUTTER, GUTTER_WIDTH, gutter_line, title_case, truncate_ellipsis, write_band_on, +}; +use super::wrap::{MAX_LINE_WIDTH, wrap_line}; use tinyharness_lib::provider::ToolCall; use crate::style::*; -/// Maximum width available for the command text on the first line, -/// after the ` ` prefix (4 chars) and `$ ` (2 chars). -const CMD_FIRST_AVAIL: usize = MAX_LINE_WIDTH - 6; -/// Maximum width available for continuation lines, -/// after the ` ` prefix (4 chars) and `> ` (2 chars). -const CMD_CONT_AVAIL: usize = MAX_LINE_WIDTH - 6; +/// What the prompt is asking the user to bless, phrased as a sentence. +/// +/// Naming the *effect* (not just the tool identifier) is what makes an +/// approval prompt useful: `run` means nothing on its own, "execute a shell +/// command" tells you what could happen. +fn describe(name: &str) -> &'static str { + match name { + "run" => "execute a shell command", + "write" => "create or overwrite a file", + "edit" => "modify a file in place", + "read" => "read a file", + "ls" => "list a directory", + "grep" => "search file contents", + "glob" => "match files by pattern", + "web_search" => "search the web", + "web_fetch" => "fetch a web page", + "screenshot" => "capture a screenshot", + _ => "", + } +} + +/// Columns left for command text after the gutter and `$ ` prefix. +const CMD_PREFIX_WIDTH: usize = GUTTER_WIDTH + 2; /// Display a shell command, splitting it across multiple lines at word /// boundaries when it exceeds the available terminal width. /// -/// Each line has BG_WARN background filling the full terminal width. +/// Drawn inside the frame's gutter with a `$` prompt prefix, so a wrapped +/// multi-line command still reads as one quoted statement. Rows stay open +/// ([`GUTTER`], never an elbow): the prompt's closing `▲` line ends the +/// block, and nested diff cards close themselves. fn write_command_lines(stdout: &mut W, cmd: &str) -> Result<(), Box> { - // BG_WARN starts at column 0, no RESET until end of line. - let prefix_first = format!("{BG_WARN} {BOLD}{WHITE}$ "); - let prefix_cont = format!("{BG_WARN} {DIM}>{WHITE} "); - - // Split at spaces for word-wrapping - let mut remaining = cmd; - let mut first = true; - while !remaining.is_empty() { - let (prefix, avail) = if first { - first = false; - (prefix_first.as_str(), CMD_FIRST_AVAIL) - } else { - (prefix_cont.as_str(), CMD_CONT_AVAIL) - }; - - if remaining.len() <= avail { - writeln!( - stdout, - "{prefix}{BRIGHT_CYAN}{remaining}{FILL_EOL}{RESET}", - remaining = remaining - )?; - break; - } - - // Find the last space within `avail` characters - let chunk_end = remaining.floor_char_boundary(avail); - let chunk = &remaining[..chunk_end]; - let split_at = match chunk.rfind(' ') { - Some(pos) if pos > 0 => pos, - _ => { - // No space found — hard-break at width limit - writeln!( - stdout, - "{prefix}{BRIGHT_CYAN}{chunk}{FILL_EOL}{RESET}", - chunk = &remaining[..chunk_end] - )?; - remaining = remaining[chunk_end..].trim_start(); - continue; - } - }; - writeln!( + let avail = MAX_LINE_WIDTH - CMD_PREFIX_WIDTH; + for (i, row) in wrap_line(cmd, avail, avail).into_iter().enumerate() { + let prefix = if i == 0 { "$ " } else { "> " }; + gutter_line( stdout, - "{prefix}{BRIGHT_CYAN}{chunk}{FILL_EOL}{RESET}", - chunk = &chunk[..split_at] + GUTTER, + &format!("{FG_FAINT}{prefix}{RESET}{FG_CMD}{row}{RESET}", row = row), )?; - remaining = remaining[chunk[..split_at].len()..].trim_start(); } Ok(()) } @@ -81,10 +74,10 @@ pub enum Confirmation { AutoAccept, } -/// Display a tool confirmation header and prompt the user. +/// Display a tool confirmation prompt and ask the user to confirm. /// -/// Shows a bordered box with the tool name, relevant arguments, and optional -/// diff/preview content, then asks the user to confirm. +/// Shows a band naming the tool and its target, the remaining arguments and +/// any diff/command preview, then a one-line choice list. pub fn prompt_tool_confirmation( stdout: &mut W, call: &ToolCall, @@ -92,12 +85,27 @@ pub fn prompt_tool_confirmation( let name = &call.function.name; let args = &call.function.arguments; - // ── Header ── - writeln!( - stdout, - "\n{BG_WARN} {WHITE}─── {BRIGHT_YELLOW}⚠ {WHITE}{name}{WHITE} ───{FILL_EOL}{RESET}", - name = name - )?; + // ── Title band ── + // Glyph + question + tool + effect, so the reason for the interruption is + // on the same line as the thing being interrupted. + let effect = describe(name); + let mut segments: Vec<(&str, &str)> = vec![ + ("▲", FG_WARN), + (" ", FG_FAINT), + ("Allow?", FG_WARN), + (" · ", FG_FAINT), + ]; + // Known tools get their effect spelled out; custom tools fall back to a + // humanized form of their identifier rather than the raw wire name. + let label = title_case(name); + if effect.is_empty() { + segments.push((label.as_str(), FG_BAND)); + } else { + segments.push((name.as_str(), FG_BAND)); + segments.push((" — ", FG_FAINT)); + segments.push((effect, FG_MUTED)); + } + write_band_on(stdout, BG_PROMPT, &segments)?; // ── Arguments (skip large fields already shown in diff/preview) ── let skip_keys: &[&str] = match name.as_str() { @@ -113,20 +121,20 @@ pub fn prompt_tool_confirmation( continue; } let val_str = match val { - serde_json::Value::String(s) => { - if s.len() > 100 { - format!("{}... ({} chars)", &s[..97], s.len()) - } else { - s.clone() - } - } + serde_json::Value::String(s) => truncate_ellipsis( + s.lines().next().unwrap_or_default(), + MAX_LINE_WIDTH - GUTTER_WIDTH - key.len() - 2, + ), other => other.to_string(), }; - writeln!( + gutter_line( stdout, - "{BG_WARN} {BRIGHT_CYAN}{key}: {WHITE}{val}{FILL_EOL}{RESET}", - key = key, - val = val_str + GUTTER, + &format!( + "{FG_MUTED}{key}:{RESET} {FG_ACCENT}{val}{RESET}", + key = key, + val = val_str + ), )?; } } @@ -151,7 +159,7 @@ pub fn prompt_tool_confirmation( } } - // ── Diff / preview for write (shown after the box) ── + // ── Diff / preview for write (shown after the band) ── if let serde_json::Value::Object(map) = args { let path = map.get("path").and_then(|v| v.as_str()).unwrap_or(""); if !path.trim().is_empty() && name == "write" { @@ -162,12 +170,16 @@ pub fn prompt_tool_confirmation( } } - // ── Footer with prompt ── - writeln!( + // ── Choice list ── + // Part of the same frame as the band above: the question was asked there, + // the details are between, so this line only offers the answers. Brackets + // mark the accepted keystroke, and every option is spelled out so the + // user never has to remember what `a` meant three prompts ago. + write!( stdout, - "{BG_WARN} {DIM}───────────────────────────────{FILL_EOL}{RESET}" + " {FG_WARN}▲{RESET} {BOLD}[y]{RESET}{FG_FAINT}es{RESET} {BOLD}[n]{RESET}{FG_FAINT}o{RESET} \ + {BOLD}[a]{RESET}{FG_FAINT}uto-accept the rest of this turn{RESET} {FG_FAINT}›{RESET} " )?; - write!(stdout, " {BOLD}Allow? {GREEN}y{BOLD}/n/a{RESET}: ")?; stdout.flush()?; let mut input = String::new(); @@ -194,57 +206,57 @@ mod tests { re.replace_all(s, "").to_string() } + // ── command rendering ── + #[test] fn test_short_command_single_line() { let mut buf = Vec::new(); write_command_lines(&mut buf, "ls -la").unwrap(); let output = strip_ansi(&String::from_utf8(buf).unwrap()); - assert!( - output.contains("$ ls -la\n"), - "short command should be on a single line, got:\n{output}" - ); - assert!( - !output.contains(">"), - "short command should not have continuation lines, got:\n{output}" - ); + assert_eq!(output, " │ $ ls -la\n", "rows stay open until the ▲ line"); } #[test] fn test_long_command_wraps() { - // Build a command that exceeds MAX_LINE_WIDTH - 4 chars - let long_cmd: Vec = (0..50).map(|i| format!("arg{i}")).collect(); + // Build a command that exceeds the line budget + let long_cmd: Vec = (0..60).map(|i| format!("argument_number_{i}")).collect(); let cmd = long_cmd.join(" "); - assert!( - cmd.len() > CMD_FIRST_AVAIL, - "test command must exceed first-line limit" - ); let mut buf = Vec::new(); write_command_lines(&mut buf, &cmd).unwrap(); let output = strip_ansi(&String::from_utf8(buf).unwrap()); let lines: Vec<&str> = output.lines().collect(); + assert!(lines.len() > 1, "long command should wrap: {output}"); + assert!(lines[0].starts_with(" │ $ "), "first line has $ prompt"); assert!( - lines.len() > 1, - "long command should wrap to multiple lines, got:\n{output}" - ); - assert!(lines[0].contains("$ "), "first line should have $ prompt"); - assert!( - lines[1].contains("> "), - "continuation lines should have > prompt" + lines[1..].iter().all(|l| l.starts_with(" │ > ")), + "continuation lines use > and stay in the gutter" ); + for line in &lines { + assert!(line.chars().count() <= MAX_LINE_WIDTH, "overflow: {line}"); + } } #[test] fn test_command_no_spaces_hard_breaks() { // A very long string with no spaces should hard-break - let cmd = "a".repeat(CMD_FIRST_AVAIL + 50); + let cmd = "a".repeat(MAX_LINE_WIDTH); let mut buf = Vec::new(); write_command_lines(&mut buf, &cmd).unwrap(); let output = strip_ansi(&String::from_utf8(buf).unwrap()); - let lines: Vec<&str> = output.lines().collect(); assert!( - lines.len() > 1, - "no-space command should hard-break, got:\n{output}" + output.lines().count() > 1, + "no-space command should hard-break" ); } + + // ── description ── + + #[test] + fn known_tools_describe_their_effect() { + assert_eq!(describe("run"), "execute a shell command"); + assert_eq!(describe("write"), "create or overwrite a file"); + // Unknown/custom tools get no invented description. + assert!(describe("my_tool").is_empty()); + } } diff --git a/tinyharness-ui/src/ui/diff.rs b/tinyharness-ui/src/ui/diff.rs index b1b1e6c..799657e 100644 --- a/tinyharness-ui/src/ui/diff.rs +++ b/tinyharness-ui/src/ui/diff.rs @@ -1,5 +1,10 @@ -use std::{error::Error, io::Write}; +use std::{ + error::Error, + io::{self, Write}, +}; +use super::frame::{FOOT, GAP, GUTTER, GUTTER_WIDTH, gutter_line, truncate_ellipsis, write_band}; +use super::wrap::MAX_LINE_WIDTH; use crate::style::*; // ── Diff computation (LCS-based algorithm) ───────────────────────────────── @@ -86,157 +91,217 @@ pub fn compute_diff<'a>(old: &'a [&str], new: &'a [&str]) -> Vec> { /// Context lines to show around each change in unified diff output. const DIFF_CONTEXT_LINES: usize = 3; -/// Render a [`DiffLine`] sequence into unified-diff-style output with line -/// numbers, `+`/`-`/` ` prefixes, and configurable context around changes. -#[allow(unused_assignments)] -fn render_diff_lines( - stdout: &mut W, - diff: &[DiffLine], - old_lines: &[&str], - new_lines: &[&str], - show_line_numbers: bool, -) -> Result<(), Box> { - if diff.is_empty() { - return Ok(()); +// ── Public API ───────────────────────────────────────────────────────────── + +/// Diff-card glyph — "not equal", marking the block as a change preview +/// rather than a completed tool result. +const DIFF_GLYPH: &str = "≠"; + +/// Which side of the diff a rendered row belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Keep, + Remove, + Add, +} + +impl Kind { + /// One-character change indicator, in the column before the line number. + fn sign(self) -> char { + match self { + Kind::Keep => ' ', + Kind::Remove => '-', + Kind::Add => '+', + } } - // Compute line number width - let max_line = old_lines.len().max(new_lines.len()).max(1); - let num_width = if show_line_numbers { - max_line.to_string().len().max(2) - } else { - 0 - }; + fn color(self) -> &'static str { + match self { + Kind::Keep => FG_FAINT, + Kind::Remove => FG_ERR, + Kind::Add => FG_OK, + } + } +} - // Find hunks: groups of changes with context around them. - let change_indices: Vec = diff +/// One row of a diff card. +enum DiffRow { + /// A source line, with the number of the side it belongs to. + Line { + kind: Kind, + num: usize, + text: String, + }, + /// Hunk boundary: how many unchanged rows were skipped. + Gap(usize), +} + +/// Number every row of a computed diff with the line number of its own side, +/// so a reader can jump straight to the location in their editor. +fn numbered(diff: &[DiffLine]) -> Vec<(Kind, usize, String)> { + let (mut old, mut new) = (0usize, 0usize); + diff.iter() + .map(|item| match item { + DiffLine::Keep(t) => { + old += 1; + new += 1; + (Kind::Keep, old, (*t).to_string()) + } + DiffLine::Remove(t) => { + old += 1; + (Kind::Remove, old, (*t).to_string()) + } + DiffLine::Add(t) => { + new += 1; + (Kind::Add, new, (*t).to_string()) + } + }) + .collect() +} + +/// Collapse numbered rows into hunks around the changes, keeping `context` +/// unchanged lines on each side and replacing everything else with a +/// [`DiffRow::Gap`] that says how much was hidden. +/// +/// Never hides a change — only context — so the preview always shows every +/// line the tool would touch. +fn hunked(rows: &[(Kind, usize, String)], context: usize) -> Vec { + let changed: Vec = rows .iter() .enumerate() - .filter_map(|(i, l)| matches!(l, DiffLine::Remove(_) | DiffLine::Add(_)).then_some(i)) + .filter(|(_, (k, ..))| *k != Kind::Keep) + .map(|(i, _)| i) .collect(); - - if change_indices.is_empty() { - return Ok(()); + if changed.is_empty() { + return vec![]; } - // Merge overlapping/adjacent hunks - let mut hunk_ranges: Vec<(usize, usize)> = Vec::new(); - let mut hunk_start = change_indices[0].saturating_sub(DIFF_CONTEXT_LINES); - let mut hunk_end = (change_indices[0] + DIFF_CONTEXT_LINES + 1).min(diff.len()); - - for &idx in &change_indices[1..] { - let new_start = idx.saturating_sub(DIFF_CONTEXT_LINES); - let new_end = (idx + DIFF_CONTEXT_LINES + 1).min(diff.len()); - - if new_start <= hunk_end { - hunk_end = hunk_end.max(new_end); + // Merge overlapping windows into hunk ranges. + let mut ranges: Vec<(usize, usize)> = Vec::new(); + let mut start = changed[0].saturating_sub(context); + let mut end = (changed[0] + context + 1).min(rows.len()); + for &i in &changed[1..] { + let lo = i.saturating_sub(context); + let hi = (i + context + 1).min(rows.len()); + if lo <= end { + end = end.max(hi); } else { - hunk_ranges.push((hunk_start, hunk_end)); - hunk_start = new_start; - hunk_end = new_end; + ranges.push((start, end)); + start = lo; + end = hi; } } - hunk_ranges.push((hunk_start, hunk_end)); + ranges.push((start, end)); - // Render each hunk - for (hunk_idx, &(start, end)) in hunk_ranges.iter().enumerate() { - // Add separator between hunks - if hunk_idx > 0 { - let sep_line = format!( - "{} {}{}", - DIM, - BOX_COLOR, - "·".repeat(if show_line_numbers { - num_width + 1 + 3 + 60 - } else { - 3 + 60 - }) - ); - writeln!(stdout, "{}{}", sep_line, RESET)?; + let mut out: Vec = Vec::new(); + let mut cursor = 0usize; + for (lo, hi) in ranges { + if lo > cursor { + out.push(DiffRow::Gap(lo - cursor)); } - - // Count line numbers up to the start of this hunk - let mut old_num: usize = 0; - #[allow(unused_variables)] - let mut new_num: usize = 0; - for item in diff.iter().take(start) { - match item { - DiffLine::Keep(_) => { - old_num += 1; - new_num += 1; - } - DiffLine::Remove(_) => { - old_num += 1; - } - DiffLine::Add(_) => { - new_num += 1; - } - } + for (kind, num, text) in &rows[lo..hi] { + out.push(DiffRow::Line { + kind: *kind, + num: *num, + text: text.clone(), + }); } + cursor = hi; + } + out +} - for i in start..end { - if i >= diff.len() { - break; +/// Draw a diff card: a `≠` title band naming the operation and target, then +/// gutter-outlined rows carrying line numbers and `-`/`+` signs. +/// +/// `stat` is a pre-styled trailing segment for the band (change counts, or a +/// note like `new file`); pass an empty string to omit it. When `rows` is +/// empty the card is just the band — the "nothing to change" case. +fn write_diff_card( + w: &mut W, + op: &str, + path: &str, + stat: &str, + rows: &[DiffRow], +) -> io::Result<()> { + // `stat` may carry its own inline colors (e.g. `-3 +7`), so it is passed + // through as an unstyled segment. + let mut segments: Vec<(&str, &str)> = vec![ + (DIFF_GLYPH, FG_ACCENT), + (" ", FG_FAINT), + (op, FG_BAND), + (" · ", FG_FAINT), + (path, FG_ACCENT), + ]; + if !stat.is_empty() { + segments.push((" · ", FG_FAINT)); + segments.push((stat, "")); + } + write_band(w, &segments)?; + if rows.is_empty() { + return Ok(()); + } + + let num_width = rows + .iter() + .map(|r| match r { + DiffRow::Line { num, .. } => *num, + DiffRow::Gap(_) => 0, + }) + .max() + .unwrap_or(1) + .to_string() + .len() + .max(2); + // Columns left for source text after the gutter, sign, number and space. + let text_width = MAX_LINE_WIDTH.saturating_sub(GUTTER_WIDTH + num_width + 3); + let last = rows.len() - 1; + + for (i, row) in rows.iter().enumerate() { + match row { + DiffRow::Line { kind, num, text } => { + let glyph = if i == last { FOOT } else { GUTTER }; + let color = kind.color(); + gutter_line( + w, + glyph, + &format!( + "{color}{sign}{RESET}{FG_FAINT}{num:>width$}{RESET} {color}{text}{RESET}", + sign = kind.sign(), + num = num, + width = num_width, + text = truncate_ellipsis(text, text_width) + ), + )?; } - match &diff[i] { - DiffLine::Keep(line) => { - old_num += 1; - new_num += 1; - if show_line_numbers { - writeln!( - stdout, - " {:>width$} {} {}", - old_num, - DIM, - line, - width = num_width, - )?; - } else { - writeln!(stdout, " {} {}{}", DIM, line, RESET)?; - } - } - DiffLine::Remove(line) => { - old_num += 1; - if show_line_numbers { - writeln!( - stdout, - "{} {: { - new_num += 1; - if show_line_numbers { - writeln!( - stdout, - "{} {: { + gutter_line( + w, + GAP, + &format!( + "{FG_FAINT}{skipped} unchanged lines{RESET}", + skipped = skipped + ), + )?; } } } - Ok(()) } -// ── Public API ───────────────────────────────────────────────────────────── +/// Count removed/added rows and format them as a band stat (`-3 +7`). +fn stat_of(diff: &[DiffLine]) -> String { + let removals = diff + .iter() + .filter(|l| matches!(l, DiffLine::Remove(_))) + .count(); + let additions = diff + .iter() + .filter(|l| matches!(l, DiffLine::Add(_))) + .count(); + format!("{FG_ERR}-{removals}{RESET} {FG_OK}+{additions}{RESET}") +} /// Show a unified-diff-style view of a write operation (full file). /// If the file already exists, reads it and shows a line-by-line diff @@ -247,94 +312,37 @@ pub fn show_write_preview( path: &str, new_content: &str, ) -> Result<(), Box> { - let existing = std::fs::read_to_string(path); + let new_lines: Vec<&str> = new_content.lines().collect(); - let old_lines: Vec<&str> = match &existing { - Ok(content) => content.lines().collect(), - Err(_) => { - // File doesn't exist — show a green preview - writeln!( - stdout, - "\n{} ── {}New file: {}{} ──{}", - BOLD, BOX_COLOR, path, BOLD, RESET - )?; - writeln!( - stdout, - "{} {}{} {}", - DIM, - BOX_COLOR, - "┄".repeat(60), - RESET - )?; - for line in new_content.lines() { - writeln!(stdout, "{} + {}{}", GREEN, RESET, line)?; - } - writeln!(stdout, "{} {} {}", DIM, "┄".repeat(60), RESET)?; - return Ok(()); - } + let Ok(existing) = std::fs::read_to_string(path) else { + // File doesn't exist — every line is an addition. + let rows: Vec = new_lines + .iter() + .enumerate() + .map(|(i, t)| DiffRow::Line { + kind: Kind::Add, + num: i + 1, + text: (*t).to_string(), + }) + .collect(); + let stat = format!( + "{FG_OK}new file{RESET} {FG_FAINT}({} lines)", + new_lines.len() + ); + write_diff_card(stdout, "write", path, &stat, &rows)?; + return Ok(()); }; - let new_lines: Vec<&str> = new_content.lines().collect(); - - // Compute the diff + let old_lines: Vec<&str> = existing.lines().collect(); let diff = compute_diff(&old_lines, &new_lines); - - // If the file is identical, show a note - let has_changes = diff.iter().any(|l| !matches!(l, DiffLine::Keep(_))); - - if !has_changes { - writeln!( - stdout, - "\n{} ── {}No changes in {}{} ──{}", - BOLD, BOX_COLOR, path, BOLD, RESET - )?; + if !diff.iter().any(|l| !matches!(l, DiffLine::Keep(_))) { + write_diff_card(stdout, "write", path, "no changes", &[])?; return Ok(()); } - // Count additions and removals for the header - let removals = diff - .iter() - .filter(|l| matches!(l, DiffLine::Remove(_))) - .count(); - let additions = diff - .iter() - .filter(|l| matches!(l, DiffLine::Add(_))) - .count(); - - writeln!( - stdout, - "\n{} ── {}Diff for {}{} ── {}{}-{}{}+{}{}", - BOLD, BOX_COLOR, path, BOLD, RED, removals, GREEN, additions, RESET, RESET, - )?; - - let max_line = old_lines.len().max(new_lines.len()).max(1); - let num_width = max_line.to_string().len().max(2); - - // Separator line - writeln!( - stdout, - "{} {}{} {} {}", - DIM, - BOX_COLOR, - " ".repeat(num_width), - "┄".repeat(60), - RESET - )?; - - // Render the diff with line numbers - render_diff_lines(stdout, &diff, &old_lines, &new_lines, true)?; - - // Ending separator - writeln!( - stdout, - "{} {}{} {} {}", - DIM, - BOX_COLOR, - " ".repeat(num_width), - "┄".repeat(60), - RESET - )?; - + let rows = hunked(&numbered(&diff), DIFF_CONTEXT_LINES); + let stat = stat_of(&diff); + write_diff_card(stdout, "write", path, &stat, &rows)?; Ok(()) } @@ -347,120 +355,84 @@ pub fn show_edit_diff( old_str: &str, new_str: &str, ) -> Result<(), Box> { - let content = - std::fs::read_to_string(path).map_err(|e| format!("Failed to read '{}': {}", path, e))?; + let content = std::fs::read_to_string(path) + .map_err(|e| -> Box { format!("Failed to read '{path}': {e}").into() })?; // Find the byte offset of old_str in the content - let offset = match content.find(old_str) { - Some(o) => o, - None => { - writeln!( - stdout, - " {}[diff error: 'old_str' not found in file]{}", - RED, RESET - )?; - return Ok(()); - } + let Some(offset) = content.find(old_str) else { + let rows = vec![DiffRow::Line { + kind: Kind::Remove, + num: 0, + text: "'old_str' not found in file — the edit would fail".to_string(), + }]; + return write_diff_card(stdout, "edit", path, "match failed", &rows).map_err(Into::into); }; // Count newlines before the match to get the line number (0-based) let line_number = content[..offset].matches('\n').count(); let lines: Vec<&str> = content.lines().collect(); - - // Split old_str and new_str into lines let old_lines: Vec<&str> = old_str.lines().collect(); let new_lines: Vec<&str> = new_str.lines().collect(); - // Determine context window (show up to 2 lines before and after) + // Show a couple of lines of context around the replacement so the change + // has a location, not just a content. let before_ctx = 2usize; let after_ctx = 2usize; let start_line = line_number.saturating_sub(before_ctx); - let end_line = (line_number + old_lines.len() + after_ctx).min(lines.len()); - let line_num_width = (end_line + 1).to_string().len().max(2); - - writeln!( - stdout, - "\n{} ── {}Diff for {}{} ──{}", - BOLD, BOX_COLOR, path, BOLD, RESET - )?; - - // Separator line - writeln!( - stdout, - "{} {}{} {} {}", - DIM, - BOX_COLOR, - " ".repeat(line_num_width), - "┄".repeat(60), - RESET - )?; - - // Lines before the change - for (i, line) in lines.iter().enumerate().take(line_number).skip(start_line) { - writeln!( - stdout, - " {:>width$} {} {}", - i + 1, - DIM, - line, - width = line_num_width, - )?; - } - // Removed lines (old_str) – shown in red with '-' - for line in old_lines.iter() { - writeln!( - stdout, - "{} {: = Vec::new(); + if start_line > 0 { + rows.push(DiffRow::Gap(start_line)); } - - // Added lines (new_str) – shown in green with '+' - for line in new_lines.iter() { - writeln!( - stdout, - "{} {:width$} {} {}", - i + 1, - DIM, - lines[i], - width = line_num_width, - )?; - } - } - - // Ending separator - writeln!( - stdout, - "{} {}{} {} {}", - DIM, - BOX_COLOR, - " ".repeat(line_num_width), - "┄".repeat(60), - RESET - )?; - + let end_line = (after_start + after_ctx).min(lines.len()); + for (i, line) in lines.iter().enumerate().take(end_line).skip(after_start) { + rows.push(DiffRow::Line { + kind: Kind::Keep, + num: i + 1, + text: line.to_string(), + }); + } + let tail = lines.len().saturating_sub(end_line); + if tail > 0 { + rows.push(DiffRow::Gap(tail)); + } + + let stat = format!( + "{FG_ERR}-{n}{RESET} {FG_OK}+{m}{RESET}", + n = old_lines.len(), + m = new_lines.len() + ); + write_diff_card(stdout, "edit", path, &stat, &rows)?; Ok(()) } diff --git a/tinyharness-ui/src/ui/frame.rs b/tinyharness-ui/src/ui/frame.rs new file mode 100644 index 0000000..3914ce9 --- /dev/null +++ b/tinyharness-ui/src/ui/frame.rs @@ -0,0 +1,197 @@ +//! Shared visual primitives for the *tool frame* family. +//! +//! Every piece of tool I/O — the execution card, the diff previews and the +//! confirmation prompt — is drawn from these parts so all three read as one +//! language: +//! +//! ```text +//! ● run · cargo test --all · 1.2s ← filled title band (`write_band`) +//! │ running 199 tests ← outlined gutter (`gutter_line`) +//! ╰ +40 more lines ← closing elbow marks the last row +//! ``` +//! +//! Rules the family follows: +//! - **One filled band per block, and only for the title.** Whole-line +//! background fills on body rows are loud and make output hard to scan; +//! the band anchors the eye, the gutter carries the structure. +//! - **The gutter always closes with `╰`.** The elbow tells you where a block +//! ends without needing a blank line or a rule. +//! - **Status lives in the glyph, not the body.** `●`/`✕`/`⊘` in +//! [`FG_OK`]/[`FG_ERR`]/[`FG_WARN`] on the title band; body rows stay muted +//! so the exception (an error line) is the only thing that can be loud. + +use std::io::{self, Write}; + +use crate::style::*; + +/// Left margin of every tool frame line. Two columns, matching the assistant +/// prose indent, so tool output nests visually under the message that asked +/// for it. +pub const INDENT: &str = " "; +/// Gutter glyph for body rows. +pub const GUTTER: &str = "│"; +/// Gutter glyph marking a gap between two hunks of the same block (skipped +/// context lines in a diff, hidden middle rows). +pub const GAP: &str = "┆"; +/// Gutter glyph closing the last row of a block. +pub const FOOT: &str = "╰"; + +/// Columns consumed by `INDENT + GUTTER + " "` — subtract from the line budget +/// to get the space available for body text. +pub const GUTTER_WIDTH: usize = 4; + +/// Truncate `s` to at most `max` *characters*, appending `…` when truncation +/// occurred. +/// +/// Character-based (not byte-based) so a multibyte string is cut to the number +/// of columns it will actually occupy. +pub fn truncate_ellipsis(s: &str, max: usize) -> String { + if s.char_indices().nth(max).is_none() { + return s.to_string(); + } + let kept: String = s.chars().take(max.saturating_sub(1)).collect(); + kept + "…" +} + +/// Write one filled title band from pre-composed styled content, on a +/// specific background. +/// +/// `styled` may contain its own escape sequences; any embedded [`RESET`] +/// clears the background mid-line, so the background is re-opened before the +/// fill to guarantee the band always reaches the margin. +fn band_line_on(w: &mut W, bg: &str, styled: &str) -> io::Result<()> { + writeln!(w, "{bg}{INDENT}{styled}{bg}{FILL_EOL}{RESET}") +} + +/// Write a filled title band from `(text, style)` segments, on a specific +/// background. +/// +/// The background is restored between segments so the band is always +/// continuous — an embedded [`RESET`] clears the background as well as the +/// color, which is the easy thing to get wrong when hand-building bands. +pub fn write_band_on(w: &mut W, bg: &str, segments: &[(&str, &str)]) -> io::Result<()> { + let mut styled = String::new(); + for (text, style) in segments { + styled.push_str(&format!("{style}{text}{RESET}{bg}")); + } + band_line_on(w, bg, &styled) +} + +/// Write a filled title band on the standard card background. +pub fn write_band(w: &mut W, segments: &[(&str, &str)]) -> io::Result<()> { + write_band_on(w, BG_BAND, segments) +} + +/// Write one gutter row of a tool frame. +/// +/// Pass [`GUTTER`] for every row except the last, and [`FOOT`] for the last, +/// so the block reads as a closed shape. `styled` is the row content, already +/// carrying whatever inline styling it needs. +pub fn gutter_line(w: &mut W, glyph: &str, styled: &str) -> io::Result<()> { + writeln!(w, "{INDENT}{FG_FAINT}{glyph}{RESET} {styled}{RESET}") +} + +/// Write a single status line (no card body), for signal tools and other +/// one-line outcomes that share the card glyph vocabulary. +pub fn status_line(w: &mut W, glyph: &str, color: &str, text: &str) -> io::Result<()> { + writeln!(w, "{INDENT}{color}{glyph}{RESET} {FG_MUTED}{text}{RESET}") +} + +/// `deploy_to_staging` → `Deploy to staging`. +/// +/// Human-facing labels (the confirmation prompt's description of an unknown +/// tool) should read like a sentence, not like a wire-format identifier: +/// words separated, only the first letter capitalized. +pub fn title_case(name: &str) -> String { + let sentence = name + .split(['_', '-']) + .filter(|w| !w.is_empty()) + .map(str::to_lowercase) + .collect::>() + .join(" "); + let mut chars = sentence.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strip_ansi(s: &str) -> String { + let re = regex::Regex::new(r"\x1b\[[0-9;]*[mK]").unwrap(); + re.replace_all(s, "").to_string() + } + + fn render_gutter(glyph: &str, text: &str) -> String { + let mut buf = Vec::new(); + gutter_line(&mut buf, glyph, &format!("{FG_FAINT}{text}{RESET}")).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn gutter_row_is_indented_and_reset() { + let out = render_gutter(GUTTER, "hello"); + assert_eq!(strip_ansi(&out), " │ hello\n"); + assert!(out.ends_with(&format!("{RESET}\n"))); + } + + #[test] + fn gutter_width_matches_the_rendered_prefix() { + let out = render_gutter(GUTTER, ""); + let prefix = strip_ansi(&out); + assert_eq!(prefix.chars().count(), GUTTER_WIDTH + 1); + } + + #[test] + fn band_fills_to_end_of_line() { + let mut buf = Vec::new(); + write_band(&mut buf, &[("title", FG_BAND)]).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(strip_ansi(&out), " title\n"); + assert!(out.contains(BG_BAND)); + assert!(out.contains(FILL_EOL)); + } + + #[test] + fn segmented_band_keeps_the_background_continuous() { + let mut buf = Vec::new(); + write_band(&mut buf, &[("●", FG_OK), (" run", FG_MUTED)]).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(strip_ansi(&out), " ● run\n"); + // Every RESET that ends a segment must be followed by the background + // again (or the line-ending fill), or the band would show holes. The + // final RESET legitimately ends the line, so it is not inspected. + let chunks: Vec<&str> = out.split("\x1b[0m").collect(); + for chunk in &chunks[1..chunks.len() - 1] { + assert!( + chunk.starts_with(BG_BAND) || chunk.starts_with(FILL_EOL), + "background must reopen after each segment, got {chunk:?}" + ); + } + } + + #[test] + fn title_case_humanizes_identifiers() { + assert_eq!(title_case("deploy_to_staging"), "Deploy to staging"); + assert_eq!(title_case("run"), "Run"); + assert_eq!(title_case("my-tool"), "My tool"); + assert_eq!(title_case("__"), ""); + } + + #[test] + fn truncate_ellipsis_is_char_boundary_safe() { + let s = "żółw".repeat(50); + let t = truncate_ellipsis(&s, 10); + assert!(t.ends_with('…')); + assert_eq!(t.chars().count(), 10); + } + + #[test] + fn truncate_ellipsis_leaves_short_text_alone() { + assert_eq!(truncate_ellipsis("short", 10), "short"); + assert_eq!(truncate_ellipsis("exact", 5), "exact"); + } +} diff --git a/tinyharness-ui/src/ui/mod.rs b/tinyharness-ui/src/ui/mod.rs index 182eb3c..97228f9 100644 --- a/tinyharness-ui/src/ui/mod.rs +++ b/tinyharness-ui/src/ui/mod.rs @@ -1,5 +1,6 @@ pub mod confirm; pub mod diff; +pub mod frame; pub mod input; pub mod markdown; pub mod tool_result; diff --git a/tinyharness-ui/src/ui/tool_result.rs b/tinyharness-ui/src/ui/tool_result.rs index d1ca419..4cd1e1c 100644 --- a/tinyharness-ui/src/ui/tool_result.rs +++ b/tinyharness-ui/src/ui/tool_result.rs @@ -1,23 +1,38 @@ -//! Compact, styled rendering of tool execution results. +//! Compact, styled rendering of tool execution cards. //! //! Shared by the live agent loop (after a tool finishes executing) and the //! session-history replay (when a saved session is printed), so both paths //! render identically. //! +//! A card is one filled title band naming the call — status glyph, tool, +//! subject, duration — followed by a gutter-outlined body: +//! +//! ```text +//! ● run · cargo test --all · 1.2s +//! │ running 199 tests +//! ╰ +40 more lines +//! ``` +//! //! Tool results are *plain text*, not markdown — they are raw command output, //! file listings and API responses emitted verbatim by the model. Interpreting //! them as markdown would corrupt `**` in test output, `-` in diffs and `#` in //! shell comments, so this module uses a small structural renderer instead, -//! following the same design rules as [`super::markdown`]: +//! following the same design rules as [`super::markdown`] and the layout +//! primitives in [`super::frame`]: //! //! - **Never lose the summary.** Long output is capped, but the footer states -//! exactly how many lines were hidden (`┊ +N more lines`). +//! exactly how many lines were hidden (`╰ +N more lines`). +//! - **The title band always says what was acted on**, so a reader scrolling +//! back learns which file or command a card refers to without opening the +//! body. //! - **Errors are always visible in full prominence**, on a single red line. -//! - Styling is band-based (`BG_DIM` + `FILL_EOL`) so blocks read as one unit. +//! - **Only the status glyph carries strong color** in the header; body rows +//! stay muted, so an error line is the one thing allowed to be loud. use std::io::{self, Write}; -use super::wrap::{MAX_LINE_WIDTH, write_wrapped_lines}; +use super::frame::{FOOT, GUTTER, GUTTER_WIDTH, gutter_line, truncate_ellipsis, write_band}; +use super::wrap::{MAX_LINE_WIDTH, wrap_line}; use crate::style::*; /// Maximum body lines shown for free-form output (`run`, tool dumps). @@ -26,13 +41,68 @@ const MAX_OUTPUT_LINES: usize = 10; const MAX_READ_LINES: usize = 3; /// Maximum entries shown for parsed `web_search` output. const MAX_SEARCH_RESULTS: usize = 5; -/// Maximum characters for single-line summaries before truncation. -const MAX_SUMMARY_CHARS: usize = 120; +/// Columns left for body text inside a gutter row. +const BODY_WIDTH: usize = MAX_LINE_WIDTH - GUTTER_WIDTH; +/// Maximum characters for the one-line subject on the title band. The band +/// fills the terminal, so it is not wrapped and only needs a sanity cap. +const MAX_SUBJECT_CHARS: usize = 120; /// Signal tools produce conversational meta-results ("User answered …"), /// not raw output — always render as a single dim line. const SIGNAL_TOOLS: [&str; 4] = ["question", "switch_mode", "invoke_skill", "auto_compact"]; +/// Prefix of the denial message the agent loop records for a refused call +/// (see `src/agent/tools.rs`). Its content is fully implied by the card's +/// title band, so it is not repeated in the body. +const DENIED_BOILERPLATE: &str = "[Tool denied] The user denied the"; + +/// Where a tool call is in its lifecycle, and how it ended. +/// +/// The status decides the title band's glyph and color — the only strongly +/// colored element on the line — so a user scanning a scrolled-back transcript +/// can tell success from failure from refusal at a glance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolStatus { + /// Completed without error. + Ok, + /// Returned an error. + Error, + /// The user refused it — nothing ran. + Denied, +} + +impl ToolStatus { + /// Status glyph drawn on the title band. + /// + /// The braille spinner frames are deliberately not reused here: the live + /// spinner is drawn on this same line while the call runs and would fight + /// a static glyph for the same cell. + fn glyph(self) -> &'static str { + match self { + ToolStatus::Ok => "●", + ToolStatus::Error => "✕", + ToolStatus::Denied => "⊘", + } + } + + fn color(self) -> &'static str { + match self { + ToolStatus::Ok => FG_OK, + ToolStatus::Error => FG_ERR, + ToolStatus::Denied => FG_WARN, + } + } + + /// Trailing label for terminal states that have no duration to show. + fn label(self) -> &'static str { + match self { + ToolStatus::Error => "failed", + ToolStatus::Denied => "denied", + _ => "", + } + } +} + /// Format a duration in milliseconds as a human-readable string. /// /// Under 1 second: `42ms` — 1–59 seconds: `1.2s` — 60+: `1m 23s`. @@ -48,146 +118,250 @@ pub fn format_duration(ms: u64) -> String { } } -/// Truncate `s` to at most `max` characters (char-boundary safe), appending -/// `…` when truncation occurred. -fn truncate_ellipsis(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); - } - let cut = s.floor_char_boundary(max.saturating_sub(1)); - format!("{}…", &s[..cut]) -} - -/// Write one body line inside the `BG_DIM` band with a 6-space indent. -fn band_line(w: &mut W, text: &str, color: &str) -> io::Result<()> { - writeln!(w, "{BG_DIM} {color}{text}{FILL_EOL}{RESET}") -} - -/// Render one tool result block: a status header line plus a capped, -/// tool-specific body. +/// Render one tool card: a status title band plus a capped, tool-specific +/// gutter body. /// /// - `tool`: tool name (e.g. `read`, `run`, `web_search`). -/// - `body`: the raw tool result, without any `### {tool}` prefix. -/// - `is_error`: whether the tool failed (colors the header `✗` red and -/// collapses the body to a single red line). +/// - `subject`: what the call acted on — the command, the path, the query. +/// Pass `None` when it can't be determined (history replay, argument-less +/// tools) and the band degrades to just the tool name. +/// - `status`: see [`ToolStatus`]. /// - `duration_ms`: execution duration, when known (`None` for history /// replay, where durations are not persisted). -pub fn write_tool_result( +/// - `body`: the raw tool result, without any `### {tool}` prefix. +pub fn write_tool_card( w: &mut W, tool: &str, + subject: Option<&str>, + status: ToolStatus, + duration_ms: Option, body: &str, - is_error: bool, +) -> io::Result<()> { + write_card_header(w, tool, subject, status, duration_ms)?; + if body.trim().is_empty() { + return Ok(()); + } + write_body_lines(w, &card_lines(tool, body, status)) +} + +/// The call's primary target — command, path or query — for a card's title +/// band. +/// +/// Recognized argument names are checked in order of specificity, then any +/// first string value, so custom tools still say what they were pointed at. +/// Returns `None` when the call has no string arguments. +pub fn tool_subject(arguments: &serde_json::Value) -> Option { + let args = arguments.as_object()?; + for key in ["command", "path", "file_path", "query", "url", "pattern"] { + if let Some(value) = args.get(key).and_then(|v| v.as_str()) + && !value.trim().is_empty() + { + return Some(value.lines().next().unwrap_or_default().to_string()); + } + } + args.values() + .find_map(|v| v.as_str()) + .map(|v| v.lines().next().unwrap_or_default().to_string()) + .filter(|v| !v.trim().is_empty()) +} + +/// Write a card's title band: status glyph, tool name, subject, duration. +fn write_card_header( + w: &mut W, + tool: &str, + subject: Option<&str>, + status: ToolStatus, duration_ms: Option, ) -> io::Result<()> { - let (icon, icon_color) = if is_error { - ("✗", RED) - } else { - ("✓", GREEN) + // Compose the optional trailing pieces before assembling the band, so the + // segments can all borrow from values that outlive the call. + let subject_text = subject + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| truncate_ellipsis(s.lines().next().unwrap_or_default(), MAX_SUBJECT_CHARS)); + + let tail = match (status, duration_ms) { + (_, Some(ms)) => Some((format!(" · {}", format_duration(ms)), FG_FAINT)), + // Without a duration (history replay) the terminal states still need + // to say how they ended. + (ToolStatus::Error | ToolStatus::Denied, None) => { + Some((format!(" · {}", status.label()), status.color())) + } + (ToolStatus::Ok, None) => None, }; - let duration = duration_ms - .map(format_duration) - .map(|d| format!(" {DIM}· {d}{RESET}{BG_DIM}")) - .unwrap_or_default(); - writeln!( - w, - "{BG_DIM} {icon_color}{icon}{RESET}{BG_DIM} {DIM}{tool}{RESET}{BG_DIM}{duration}{FILL_EOL}{RESET}" - )?; - if body.trim().is_empty() { - return Ok(()); + let mut segments: Vec<(&str, &str)> = vec![(status.glyph(), status.color()), (" ", FG_FAINT)]; + segments.push((tool, FG_BAND)); + if let Some(subject) = &subject_text { + segments.push((" · ", FG_FAINT)); + segments.push((subject.as_str(), FG_ACCENT)); + } + if let Some((text, color)) = &tail { + segments.push((text.as_str(), *color)); + } + write_band(w, &segments) +} + +/// One physical line of a card body, before framing. +enum BodyLine { + /// Plain text, painted in a single color. + Text(String, &'static str), + /// Pre-styled content with its own inline colors (search entries). + Rich(String), +} + +/// Frame and write body lines: every line but the last uses the [`GUTTER`] +/// bar, the last closes the card with the [`FOOT`] elbow. +fn write_body_lines(w: &mut W, lines: &[BodyLine]) -> io::Result<()> { + let last = lines.len().saturating_sub(1); + for (i, line) in lines.iter().enumerate() { + let glyph = if i == last { FOOT } else { GUTTER }; + match line { + BodyLine::Text(text, color) => gutter_line(w, glyph, &format!("{color}{text}{RESET}"))?, + BodyLine::Rich(styled) => gutter_line(w, glyph, styled)?, + } + } + Ok(()) +} + +/// Append `text` to `lines` as however many physical rows it needs at the +/// body width. +/// +/// Embedded newlines (a summary joining several entries, say) become separate +/// rows rather than one row that would silently break the gutter framing. +fn push_wrapped(lines: &mut Vec, text: &str, color: &'static str) { + for segment in text.split('\n') { + for row in wrap_line(segment, BODY_WIDTH, BODY_WIDTH) { + lines.push(BodyLine::Text(row, color)); + } + } +} + +/// Append the hidden-rows footer. +fn push_more(lines: &mut Vec, hidden: usize, noun: &'static str) { + if hidden > 0 { + let noun = if hidden == 1 { singular(noun) } else { noun }; + lines.push(BodyLine::Text(format!("+{hidden} more {noun}"), FG_FAINT)); } +} - if is_error { - let msg = body.lines().next().unwrap_or("Error"); - return band_line(w, &truncate_ellipsis(msg, MAX_SUMMARY_CHARS), RED); +/// `"lines"` → `"line"`, `"results"` → `"result"`. +fn singular(noun: &str) -> &'static str { + match noun { + "lines" => "line", + "results" => "result", + _ => "line", } +} + +/// Build the body lines of a card for `tool`/`body`. +fn card_lines(tool: &str, body: &str, status: ToolStatus) -> Vec { + let mut lines: Vec = Vec::new(); + if status == ToolStatus::Error { + // The header already says `failed`; the body carries the message. + push_wrapped( + &mut lines, + &truncate_ellipsis(body.lines().next().unwrap_or("Error"), BODY_WIDTH), + FG_ERR, + ); + return lines; + } if body.starts_with("[Tool denied]") { - let msg = body.lines().next().unwrap_or(""); - return band_line(w, &truncate_ellipsis(msg, MAX_SUMMARY_CHARS), ORANGE); + // The canonical denial template only restates what the band already + // says (tool, subject, `denied`), so it renders as a band alone. Any + // other message is shown with the marker stripped. + if body.starts_with(DENIED_BOILERPLATE) { + return lines; + } + let msg = body.trim_start_matches("[Tool denied]").trim(); + push_wrapped(&mut lines, &truncate_ellipsis(msg, BODY_WIDTH), FG_WARN); + return lines; } match tool { - // Listings render as a single summary line ("3 entries — a, b, c"). - "ls" | "grep" | "glob" => band_line( - w, - &truncate_ellipsis(&summarize_listing_result(body, tool), MAX_SUMMARY_CHARS), - DIM, + // Listings collapse to a single summary line. + "ls" | "grep" | "glob" => push_wrapped( + &mut lines, + &truncate_ellipsis(&summarize_listing_result(body, tool), BODY_WIDTH), + FG_MUTED, ), // Structured search results render as a link-styled list. "web_search" => match parse_web_search(body) { - Some(entries) => write_search_entries(w, &entries), - None => write_capped_lines(w, body, MAX_OUTPUT_LINES), + Some(entries) => search_lines(&mut lines, &entries), + None => capped_lines(&mut lines, body.lines(), MAX_OUTPUT_LINES), }, // File reads show the line-count meta line, then a small preview. - "read" => write_read_body(w, body), + "read" => read_lines(&mut lines, body), // Conversational meta-results collapse to one dim line. - t if SIGNAL_TOOLS.contains(&t) => { - let first = body.lines().next().unwrap_or(body); - band_line(w, &truncate_ellipsis(first, MAX_SUMMARY_CHARS), DIM) - } + t if SIGNAL_TOOLS.contains(&t) => push_wrapped( + &mut lines, + &truncate_ellipsis(body.lines().next().unwrap_or(body), BODY_WIDTH), + FG_MUTED, + ), // Everything else: capped raw output. - _ => write_capped_lines(w, body, MAX_OUTPUT_LINES), + _ => capped_lines(&mut lines, body.lines(), MAX_OUTPUT_LINES), } + lines } -/// Render up to `max` lines of `body` (word-wrapped), with a footer stating -/// how many lines were hidden. -fn write_capped_lines(w: &mut W, body: &str, max: usize) -> io::Result<()> { - let total = body.lines().count(); - let shown: Vec<&str> = body.lines().take(max).collect(); - write_wrapped_lines( - w, - &shown.join("\n"), - &format!("{BG_DIM} {DIM}"), - &format!(" {BG_DIM}{DIM}"), - MAX_LINE_WIDTH, - true, - ) - .map_err(|_| io::Error::other("wrap failed"))?; - if total > shown.len() { - writeln!( - w, - "{BG_DIM} {DIM}┊ +{} more lines{FILL_EOL}{RESET}", - total - shown.len() - )?; - } - Ok(()) +/// Show up to `max` of `body_lines`, noting how many were hidden. +fn capped_lines<'a>( + out: &mut Vec, + body_lines: impl Iterator, + max: usize, +) { + let all: Vec<&str> = body_lines.collect(); + for line in all.iter().take(max) { + push_wrapped(out, line, FG_MUTED); + } + push_more(out, all.len().saturating_sub(max), "lines"); } -/// Render a `read` result: `(N lines)` meta line, then up to -/// [`MAX_READ_LINES`] content lines and a hidden-lines footer. -fn write_read_body(w: &mut W, body: &str) -> io::Result<()> { +/// Meta line, then up to [`MAX_READ_LINES`] content lines and a footer. +fn read_lines(out: &mut Vec, body: &str) { let mut lines = body.lines(); let first = lines.next().unwrap_or(""); let is_meta = first.starts_with('(') && first.contains(" lines") && first.ends_with(')'); - let (meta, content) = if is_meta { - (Some(first), lines.collect::>()) + let (meta, content): (Option<&str>, Vec<&str>) = if is_meta { + (Some(first), lines.collect()) } else { - ( - None, - std::iter::once(first).chain(lines).collect::>(), - ) + (None, std::iter::once(first).chain(lines).collect()) }; if let Some(meta) = meta { - band_line(w, meta, DIM)?; + out.push(BodyLine::Text(meta.to_string(), FG_FAINT)); } - - let total = content.len(); - let shown: Vec<&str> = content.iter().take(MAX_READ_LINES).copied().collect(); - for line in &shown { - band_line(w, &truncate_ellipsis(line, MAX_LINE_WIDTH), DIM)?; + for line in content.iter().take(MAX_READ_LINES) { + push_wrapped(out, &truncate_ellipsis(line, BODY_WIDTH), FG_MUTED); } - if total > shown.len() { - writeln!( - w, - "{BG_DIM} {DIM}┊ +{} more lines{FILL_EOL}{RESET}", - total - shown.len() - )?; + push_more(out, content.len().saturating_sub(MAX_READ_LINES), "lines"); +} + +/// Render parsed search entries as a link-styled list (matching the markdown +/// renderer's link treatment: accent title, faint URL). +fn search_lines(out: &mut Vec, entries: &[SearchEntry]) { + for entry in entries.iter().take(MAX_SEARCH_RESULTS) { + out.push(BodyLine::Rich(format!( + "{FG_FAINT}•{RESET} {FG_ACCENT}{}{RESET} {FG_FAINT}{}{RESET}", + truncate_ellipsis(&entry.title, 60), + truncate_ellipsis(&format!("({})", entry.url), 60), + ))); + let snippet = entry.content.lines().next().unwrap_or(""); + if !snippet.is_empty() { + push_wrapped( + out, + &format!(" {}", truncate_ellipsis(snippet, 100)), + FG_FAINT, + ); + } } - Ok(()) + push_more( + out, + entries.len().saturating_sub(MAX_SEARCH_RESULTS), + "results", + ); } /// One parsed `web_search` entry. @@ -227,10 +401,16 @@ fn parse_web_search(body: &str) -> Option> { }); } else if let Some(last) = entries.last_mut() { // Continuation (snippet) lines accumulate into the open entry. + // Blank separator lines between entries are skipped — otherwise + // they leave a trailing space on the previous snippet. + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } if !last.content.is_empty() { last.content.push(' '); } - last.content.push_str(line.trim()); + last.content.push_str(trimmed); } } @@ -240,32 +420,11 @@ fn parse_web_search(body: &str) -> Option> { Some(entries) } -/// Render parsed search entries as a link-styled list (matching the markdown -/// renderer's link treatment: cyan text, dim URL). -fn write_search_entries(w: &mut W, entries: &[SearchEntry]) -> io::Result<()> { - let total = entries.len(); - for entry in entries.iter().take(MAX_SEARCH_RESULTS) { - writeln!( - w, - "{BG_DIM} {GRAY}•{RESET}{BG_DIM} {CYAN}{}{RESET}{BG_DIM} {DIM}({}){FILL_EOL}{RESET}", - entry.title, entry.url - )?; - let snippet = entry.content.lines().next().unwrap_or(""); - if !snippet.is_empty() { - band_line(w, &format!(" {}", truncate_ellipsis(snippet, 100)), DIM)?; - } - } - if total > MAX_SEARCH_RESULTS { - writeln!( - w, - "{BG_DIM} {DIM}┊ +{} more results{FILL_EOL}{RESET}", - total - MAX_SEARCH_RESULTS - )?; - } - Ok(()) -} - /// Produce a one-line summary for listing tools (ls, grep, glob). +/// +/// `5 entries — Cargo.toml, src, target ... (2 more)`. Always a single line: +/// the whole point is to collapse a listing that would otherwise bury the +/// cards around it. pub fn summarize_listing_result(result: &str, tool_name: &str) -> String { if result.starts_with("Error:") || result.starts_with("No ") || result == "Directory is empty" { return result.to_string(); @@ -273,64 +432,168 @@ pub fn summarize_listing_result(result: &str, tool_name: &str) -> String { let lines: Vec<&str> = result.lines().collect(); let count = lines.len(); - let label = match tool_name { - "ls" => "entries", - "grep" => "matches", - "glob" => "files", - _ => "results", + let label = match (tool_name, count == 1) { + ("ls", true) => "entry", + ("ls", false) => "entries", + ("grep", true) => "match", + ("grep", false) => "matches", + ("glob", true) => "file", + ("glob", false) => "files", + (_, true) => "result", + (_, false) => "results", }; const PREVIEW: usize = 3; + let preview = lines + .iter() + .take(PREVIEW) + .copied() + .collect::>() + .join(", "); if count <= PREVIEW { - format!("{count} {label} — {result}") + format!("{count} {label} — {preview}") } else { - let preview: Vec<&str> = lines.iter().take(PREVIEW).copied().collect(); - format!( - "{count} {label} — {} ... ({} more)", - preview.join(", "), - count - PREVIEW - ) + format!("{count} {label} — {preview} ... ({} more)", count - PREVIEW) } } #[cfg(test)] mod tests { use super::*; + use serde_json::json; fn strip_ansi(s: &str) -> String { - let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap(); + let re = regex::Regex::new(r"\x1b\[[0-9;]*[mK]").unwrap(); re.replace_all(s, "").to_string() } + /// Render a finished card (the common case in tests). fn render(tool: &str, body: &str, is_error: bool, ms: Option) -> String { let mut buf = Vec::new(); - write_tool_result(&mut buf, tool, body, is_error, ms).unwrap(); + write_tool_card( + &mut buf, + tool, + None, + if is_error { + ToolStatus::Error + } else { + ToolStatus::Ok + }, + ms, + body, + ) + .unwrap(); String::from_utf8(buf).unwrap() } + fn plain(s: &str) -> String { + strip_ansi(s) + } + + fn body_rows(out: &str) -> Vec { + plain(out).lines().skip(1).map(str::to_string).collect() + } + // ── Header ── #[test] - fn success_header_shows_check_icon_and_duration() { + fn success_header_shows_status_glyph_and_duration() { let out = render("read", "(1 lines)\nhi", false, Some(42)); - assert!(out.contains('✓')); + assert!(out.contains('●')); assert!(out.contains("read")); assert!(out.contains("42ms")); - assert!(!out.contains('✗')); + assert!(!out.contains('✕')); + assert!(out.contains(FG_OK), "status color must be applied"); + } + + #[test] + fn error_header_shows_cross_and_failure_label() { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "run", + None, + ToolStatus::Error, + None, + "Error: boom", + ) + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains('✕')); + assert!(out.contains(FG_ERR)); + assert!(plain(&out).contains("failed"), "no duration ⇒ label"); } #[test] - fn error_header_shows_cross_icon() { + fn error_with_duration_omits_the_label() { let out = render("run", "Error: boom", true, Some(1500)); - assert!(out.contains('✗')); - assert!(out.contains(RED)); + assert!(out.contains('✕')); assert!(out.contains("1.5s")); + assert!(!plain(&out).contains("failed")); + } + + #[test] + fn denied_header_shows_slash_circle_and_denied_label() { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "run", + Some("rm -rf /"), + ToolStatus::Denied, + None, + "", + ) + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert!(out.contains('⊘')); + assert!(plain(&out).contains("denied")); + assert!(plain(&out).contains("rm -rf /")); + } + + #[test] + fn subject_appears_on_the_title_band() { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "read", + Some("src/main.rs"), + ToolStatus::Ok, + None, + "", + ) + .unwrap(); + assert!(plain(&String::from_utf8(buf).unwrap()).contains("src/main.rs")); + } + + #[test] + fn multiline_subject_collapses_to_its_first_line() { + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "run", + Some("echo one\necho two"), + ToolStatus::Ok, + None, + "", + ) + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(out.lines().count(), 1, "band must stay a single line"); + assert!(plain(&out).contains("echo one")); + assert!(!plain(&out).contains("echo two")); + } + + #[test] + fn blank_subject_is_omitted() { + let mut buf = Vec::new(); + write_tool_card(&mut buf, "ls", Some(" "), ToolStatus::Ok, None, "").unwrap(); + assert_eq!(plain(&String::from_utf8(buf).unwrap()).trim(), "● ls"); } #[test] fn history_omits_duration() { let out = render("ls", "a\nb", false, None); - assert!(!out.contains('·'), "no duration separator: {out:?}"); + assert!(!plain(&out).contains('·'), "no separator: {out:?}"); assert!(out.contains("ls")); } @@ -340,28 +603,106 @@ mod tests { assert_eq!(out.lines().count(), 1); } + #[test] + fn whitespace_only_body_is_header_only() { + let out = render("run", " \n ", false, None); + assert_eq!(out.lines().count(), 1); + } + + // ── Body framing ── + + #[test] + fn body_rows_are_gutter_outlined_and_close_with_an_elbow() { + let out = render("run", "one\ntwo", false, None); + let rows = body_rows(&out); + assert_eq!(rows.len(), 2, "band + 2 body rows"); + assert!(rows[0].starts_with(" │ "), "body rows use the gutter"); + assert!(rows[1].starts_with(" ╰ "), "last row closes the card"); + assert!(rows[0].contains("one")); + assert!(rows[1].contains("two")); + } + + #[test] + fn single_row_body_closes_with_the_elbow() { + let out = render("ls", "a\nb\nc", false, None); + let rows = body_rows(&out); + assert_eq!(rows.len(), 1); + assert!(rows[0].starts_with(" ╰ ")); + } + + #[test] + fn elbow_lands_on_the_final_physical_row_after_wrapping() { + let line = "word ".repeat(80); + let out = render("run", line.trim(), false, None); + let rows = body_rows(&out); + assert!(rows.len() > 2, "long line must wrap onto several rows"); + assert!(rows.last().unwrap().starts_with(" ╰ ")); + for row in &rows[..rows.len() - 1] { + assert!(row.starts_with(" │ "), "no early elbow: {row:?}"); + } + } + + #[test] + fn no_body_row_exceeds_the_line_budget() { + let out = render("run", &"word ".repeat(60), false, None); + for row in body_rows(&out) { + assert!(row.chars().count() <= MAX_LINE_WIDTH, "overflow: {row:?}"); + } + } + // ── Error / denied bodies ── #[test] - fn error_body_is_single_red_line_truncated() { + fn error_body_is_a_single_red_line_truncated() { let long = format!("Error: {}", "x".repeat(300)); let out = render("run", &long, true, None); - assert_eq!(out.lines().count(), 2); // header + one body line - assert!(out.contains(RED)); - assert!(out.contains('…')); - assert!(!strip_ansi(&out).contains(&"x".repeat(200))); + let rows = body_rows(&out); + assert_eq!(rows.len(), 1, "error collapses to one row: {rows:?}"); + assert!(out.contains(FG_ERR)); + assert!(plain(&rows[0]).ends_with('…')); + assert!(!plain(&out).contains(&"x".repeat(200))); + } + + #[test] + fn error_body_shows_only_the_first_line() { + let out = render("run", "Error: boom\nstack trace\nmore", true, None); + let p = plain(&out); + assert!(p.contains("Error: boom")); + assert!(!p.contains("stack trace"), "trace stays hidden: {p:?}"); } #[test] - fn denied_tool_renders_orange_single_line() { + fn canonical_denial_renders_band_only() { + // The boilerplate message only restates the band (tool, subject, + // `denied`), so repeating it in the body would be pure noise. + let mut buf = Vec::new(); + write_tool_card( + &mut buf, + "run", + Some("rm -rf /"), + ToolStatus::Denied, + None, + "[Tool denied] The user denied the 'run' tool call with arguments: command=\"rm -rf /\"", + ) + .unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(out.lines().count(), 1, "no body row: {out:?}"); + assert!(out.contains('⊘')); + assert!(out.contains(FG_WARN)); + } + + #[test] + fn non_boilerplate_denial_shows_the_message_without_the_marker() { let out = render( "run", - "[Tool denied] The user denied the 'run' tool call with arguments: cmd=\"rm\"", + "[Tool denied] sandbox policy refuses writes outside the workspace", false, None, ); - assert!(out.contains(ORANGE)); - assert_eq!(out.lines().count(), 2); + let p = plain(&out); + assert!(out.contains(FG_WARN)); + assert!(p.contains("sandbox policy"), "{p:?}"); + assert!(!p.contains("[Tool denied]"), "marker is redundant: {p:?}"); } // ── Listing tools ── @@ -369,15 +710,15 @@ mod tests { #[test] fn listing_summary_line() { let out = render("ls", "Cargo.toml\nsrc\ntarget\nx\ny", false, None); - let plain = strip_ansi(&out); - assert!(plain.contains("5 entries — Cargo.toml, src, target ... (2 more)")); - assert_eq!(plain.lines().count(), 2); + let p = plain(&out); + assert!(p.contains("5 entries — Cargo.toml, src, target ... (2 more)")); + assert_eq!(body_rows(&out).len(), 1); } #[test] fn listing_error_passes_through() { let out = render("grep", "Error: bad pattern", true, None); - assert!(strip_ansi(&out).contains("Error: bad pattern")); + assert!(plain(&out).contains("Error: bad pattern")); } // ── read ── @@ -387,20 +728,21 @@ mod tests { let content: Vec = (1..=20).map(|i| format!("line{i}")).collect(); let body = format!("(20 lines)\n{}", content.join("\n")); let out = render("read", &body, false, None); - let plain = strip_ansi(&out); - assert!(plain.contains("(20 lines)")); - assert!(plain.contains("line1")); - assert!(plain.contains("line3")); - assert!(!plain.contains("line4")); - assert!(plain.contains("┊ +17 more lines")); + let p = plain(&out); + assert!(p.contains("(20 lines)")); + assert!(p.contains("line1")); + assert!(p.contains("line3")); + assert!(!p.contains("line4")); + assert!(p.contains("╰ +17 more lines")); } #[test] fn read_without_meta_line_renders_all_as_content() { let out = render("read", "just one line", false, None); - let plain = strip_ansi(&out); - assert!(plain.contains("just one line")); - assert!(!plain.contains("┊")); + let p = plain(&out); + assert!(p.contains("just one line")); + assert!(!p.contains("more lines")); + assert_eq!(body_rows(&out).len(), 1); } // ── run / default capping ── @@ -409,21 +751,18 @@ mod tests { fn run_output_capped_with_footer() { let content: Vec = (1..=50).map(|i| format!("out{i}")).collect(); let out = render("run", &content.join("\n"), false, Some(2300)); - let plain = strip_ansi(&out); - assert!(plain.contains("out1")); - assert!(plain.contains("out10")); - assert!(!plain.contains("out11")); - assert!(plain.contains("┊ +40 more lines")); + let p = plain(&out); + assert!(p.contains("out1")); + assert!(p.contains("out10")); + assert!(!p.contains("out11")); + assert!(p.contains("╰ +40 more lines")); assert!(out.contains("2.3s")); } #[test] fn short_output_not_capped() { let out = render("run", "one\ntwo", false, None); - assert!(!out.contains("┊")); - let plain = strip_ansi(&out); - assert!(plain.contains("one")); - assert!(plain.contains("two")); + assert!(!plain(&out).contains("more lines")); } // ── web_search ── @@ -438,29 +777,28 @@ mod tests { #[test] fn web_search_renders_link_styled_list() { let out = render("web_search", &search_body(3), false, None); - let plain = strip_ansi(&out); - assert!(plain.contains("• Title 1")); - assert!(plain.contains("(https://x.dev/1)")); - assert!(plain.contains("Snippet 1 text")); - assert!(out.contains(CYAN), "titles should be link-colored"); - assert!(!out.contains("┊"), "3 entries fit under the cap"); + let p = plain(&out); + assert!(p.contains("• Title 1")); + assert!(p.contains("(https://x.dev/1)")); + assert!(p.contains("Snippet 1 text")); + assert!(out.contains(FG_ACCENT), "titles should be link-colored"); + assert!(!p.contains("more results"), "3 entries fit under the cap"); } #[test] fn web_search_caps_results_with_footer() { let out = render("web_search", &search_body(8), false, None); - let plain = strip_ansi(&out); - assert!(plain.contains("Title 1")); - assert!(plain.contains("Title 5")); - assert!(!plain.contains("Title 6")); - assert!(plain.contains("┊ +3 more results")); + let p = plain(&out); + assert!(p.contains("Title 1")); + assert!(p.contains("Title 5")); + assert!(!p.contains("Title 6")); + assert!(p.contains("╰ +3 more results")); } #[test] fn web_search_unparseable_falls_back_to_raw() { let out = render("web_search", "No results found.", false, None); - let plain = strip_ansi(&out); - assert!(plain.contains("No results found.")); + assert!(plain(&out).contains("No results found.")); } #[test] @@ -472,7 +810,7 @@ mod tests { false, None, ); - assert!(strip_ansi(&out).contains("see section one")); + assert!(plain(&out).contains("see section one")); } // ── signal tools ── @@ -485,38 +823,49 @@ mod tests { false, None, ); - assert_eq!(out.lines().count(), 2); - assert!(strip_ansi(&out).contains("User answered the question")); + assert_eq!(body_rows(&out).len(), 1); + assert!(plain(&out).contains("User answered the question")); } + // ── tool_subject ── + #[test] - fn debug_web_search_parse() { - let body = "[1] Title 1\n URL: https://x.dev/1\n Snippet 1 text\n"; - match super::parse_web_search(body) { - Some(entries) => { - for e in &entries { - println!( - "ENTRY title={:?} url={:?} content={:?}", - e.title, e.url, e.content - ); - } - } - None => println!("PARSE RETURNED NONE"), - } + fn subject_prefers_the_command_over_other_arguments() { + assert_eq!( + tool_subject(&json!({"command": "cargo build", "timeout": 30})).as_deref(), + Some("cargo build") + ); } #[test] - fn debug_web_search_full_render() { - let body: String = (1..=3) - .map(|i| format!("[{i}] Title {i}\n URL: https://x.dev/{i}\n Snippet {i} text\n")) - .collect::>() - .join("\n"); - println!("BODY: {body:?}"); - let mut buf = Vec::new(); - super::write_tool_result(&mut buf, "web_search", &body, false, None).unwrap(); - for l in String::from_utf8(buf).unwrap().lines() { - println!("OUT: {l:?}"); - } + fn subject_prefers_the_path_for_file_tools() { + assert_eq!( + tool_subject(&json!({"path": "src/main.rs", "old_str": "a"})).as_deref(), + Some("src/main.rs") + ); + } + + #[test] + fn subject_falls_back_to_the_first_string_argument() { + assert_eq!( + tool_subject(&json!({"count": 3, "target": "prod"})).as_deref(), + Some("prod") + ); + } + + #[test] + fn subject_collapses_multiline_arguments_to_one_line() { + assert_eq!( + tool_subject(&json!({"command": "echo one\necho two"})).as_deref(), + Some("echo one") + ); + } + + #[test] + fn subject_is_none_without_string_arguments() { + assert_eq!(tool_subject(&json!({})), None); + assert_eq!(tool_subject(&json!({"n": 1})), None); + assert_eq!(tool_subject(&json!([])), None); } // ── format_duration ── @@ -534,21 +883,23 @@ mod tests { fn summarize_labels_per_tool() { assert_eq!( summarize_listing_result("a\nb\nc", "grep"), - "3 matches — a\nb\nc" + "3 matches — a, b, c" ); - assert_eq!(summarize_listing_result("a", "ls"), "1 entries — a"); + assert_eq!(summarize_listing_result("a", "ls"), "1 entry — a"); + assert_eq!(summarize_listing_result("a\nb", "glob"), "2 files — a, b"); } #[test] - fn summarize_error_passthrough() { - assert_eq!(summarize_listing_result("Error: nope", "ls"), "Error: nope"); + fn summarize_is_always_a_single_line() { + // A multi-entry summary containing newlines would break the gutter + // framing of the card body, so the join is always ", ". + let out = summarize_listing_result("a\nb\nc\nd\ne", "ls"); + assert!(!out.contains('\n'), "{out:?}"); + assert_eq!(out, "5 entries — a, b, c ... (2 more)"); } #[test] - fn truncate_ellipsis_is_char_boundary_safe() { - let s = "żółw".repeat(50); - let t = truncate_ellipsis(&s, 10); - assert!(t.ends_with('…')); - assert!(t.is_char_boundary(0)); + fn summarize_error_passthrough() { + assert_eq!(summarize_listing_result("Error: nope", "ls"), "Error: nope"); } } diff --git a/tinyharness-ui/src/ui/wrap.rs b/tinyharness-ui/src/ui/wrap.rs index abdd747..7956b7b 100644 --- a/tinyharness-ui/src/ui/wrap.rs +++ b/tinyharness-ui/src/ui/wrap.rs @@ -5,6 +5,50 @@ use crate::style::FILL_EOL; /// Maximum line width for word-wrapped output. pub const MAX_LINE_WIDTH: usize = 120; +/// Break a single logical `line` into display rows that fit the available +/// width, splitting at word boundaries where possible and hard-breaking only +/// when a single word exceeds the budget. +/// +/// `first_avail` / `rest_avail` are the columns left for text on the first row +/// and on continuation rows (i.e. `max_width - indent.len()`), which lets +/// hanging-indent layouts wrap without the continuation rows overflowing. +/// +/// Always returns at least one row for a non-empty line, and exactly one empty +/// row for an empty line, so callers can round-trip blank lines. +pub fn wrap_line(line: &str, first_avail: usize, rest_avail: usize) -> Vec { + if line.is_empty() { + return vec![String::new()]; + } + if line.chars().count() <= first_avail { + return vec![line.to_string()]; + } + + let mut rows = Vec::new(); + let mut remaining = line; + let mut avail = first_avail; + loop { + if remaining.chars().count() <= avail { + rows.push(remaining.to_string()); + break; + } + let chunk_end = remaining.floor_char_boundary(avail); + let chunk = &remaining[..chunk_end]; + match chunk.rfind(' ') { + Some(pos) if pos > 0 => { + rows.push(chunk[..pos].to_string()); + remaining = remaining[pos..].trim_start(); + } + // No break point inside the budget — hard-break at the limit. + _ => { + rows.push(chunk.to_string()); + remaining = remaining[chunk_end..].trim_start(); + } + } + avail = rest_avail; + } + rows +} + /// Write lines to stdout with word-wrapping at `max_width` characters. /// Lines shorter than `max_width - indent.len()` are written as-is. /// Empty lines produce just a newline. @@ -19,38 +63,84 @@ pub fn write_wrapped_lines( fill_bg: bool, ) -> Result<(), Box> { let suffix = if fill_bg { FILL_EOL } else { "" }; + let first_avail = max_width.saturating_sub(base_indent.chars().count()); + let rest_avail = max_width.saturating_sub(cont_indent.chars().count()); for line in content.lines() { - if line.is_empty() { - writeln!(stdout, "{}", suffix)?; - continue; - } - if line.len() <= max_width - base_indent.len() { - writeln!(stdout, "{}{}{}", base_indent, line, suffix)?; - continue; - } - // Word-wrap long lines - let mut remaining = line; - let mut first = true; - while !remaining.is_empty() { - let indent = if first { base_indent } else { cont_indent }; - let avail = max_width - indent.len(); - if remaining.len() <= avail { - writeln!(stdout, "{}{}{}", indent, remaining, suffix)?; - break; - } - let chunk_end = remaining.floor_char_boundary(avail); - let chunk = &remaining[..chunk_end]; - let split_at = chunk.rfind(' ').unwrap_or(chunk_end); - if split_at == 0 { - // No space found at all — just break at the width limit - writeln!(stdout, "{}{}{}", indent, &remaining[..chunk_end], suffix)?; - remaining = remaining[chunk_end..].trim_start(); - } else { - writeln!(stdout, "{}{}{}", indent, &chunk[..split_at], suffix)?; - remaining = remaining[chunk[..split_at].len()..].trim_start(); - } - first = false; + for (i, row) in wrap_line(line, first_avail, rest_avail) + .into_iter() + .enumerate() + { + let indent = if i == 0 { base_indent } else { cont_indent }; + writeln!(stdout, "{indent}{row}{suffix}")?; } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(line: &str, avail: usize) -> Vec { + wrap_line(line, avail, avail) + } + + #[test] + fn short_line_is_one_row() { + assert_eq!(rows("hello", 10), vec!["hello"]); + } + + #[test] + fn empty_line_yields_one_empty_row() { + assert_eq!(rows("", 10), vec![String::new()]); + } + + #[test] + fn wraps_at_word_boundaries() { + assert_eq!(rows("alpha beta gamma", 12), vec!["alpha beta", "gamma"]); + } + + #[test] + fn hard_breaks_when_a_word_exceeds_the_budget() { + let out = rows(&"x".repeat(25), 10); + assert_eq!(out.len(), 3); + assert_eq!(out[0], "x".repeat(10)); + assert_eq!(out.concat(), "x".repeat(25)); + } + + #[test] + fn continuation_budget_is_honoured() { + // First row is narrow, continuations wide: nothing may exceed its own + // budget, and no text may be lost. + let line = "aaa bbb ccc ddd eee"; + let out = wrap_line(line, 7, 12); + assert!(out[0].len() <= 7); + for rest in &out[1..] { + assert!(rest.len() <= 12); + } + assert_eq!(out.join(" ").replace(' ', ""), line.replace(' ', "")); + } + + #[test] + fn multibyte_input_never_panics_and_loses_nothing() { + let line = "🎉 ".repeat(10); + let out = wrap_line(&line, 6, 6); + assert_eq!(out.join(" ").replace(' ', ""), line.replace(' ', "")); + } + + #[test] + fn writer_pads_every_row_when_requested() { + let mut buf = Vec::new(); + write_wrapped_lines(&mut buf, "one two three", " ", " ", 9, true).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!(out.matches(FILL_EOL).count(), out.lines().count()); + assert!(out.lines().all(|l| l.len() <= 12), "{out:?}"); + } + + #[test] + fn writer_keeps_blank_lines_blank() { + let mut buf = Vec::new(); + write_wrapped_lines(&mut buf, "a\n\nb", "", "", 80, false).unwrap(); + assert_eq!(String::from_utf8(buf).unwrap(), "a\n\nb\n"); + } +}