diff --git a/Cargo.lock b/Cargo.lock index 0eb745b..369a3fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,7 @@ dependencies = [ "ignore", "include_dir", "nanoid", + "pulldown-cmark", "ratatui", "regex", "reqwest", @@ -172,7 +173,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tui-textarea", - "unicode-width", + "unicode-width 0.1.14", "wiremock", ] @@ -570,6 +571,15 @@ dependencies = [ "slab", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1214,6 +1224,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quinn" version = "0.11.9" @@ -1367,7 +1396,7 @@ dependencies = [ "strum_macros", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1965,9 +1994,15 @@ checksum = "29c07084342a575cea919eea996b9658a358c800b03d435df581c1d7c60e065a" dependencies = [ "crossterm", "ratatui", - "unicode-width", + "unicode-width 0.1.14", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1988,7 +2023,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -1997,6 +2032,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 3deb901..27554e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ include_dir = "0.7" nanoid = "0.4" ratatui = "0.28" regex = "1" +pulldown-cmark = "0.12" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/ROADMAP.md b/ROADMAP.md index bfa152d..12a3de0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # Cassady (Cass) Roadmap +## v0.2.1 — Message Rendering Polish + +### Transcript Rendering + +- [x] **Markdown message rendering and cleaner tool-call display.** Render assistant and user message blocks as Markdown, and improve tool call rendering/display so tool invocations and results are easier to scan in the transcript. + - Completed tool invocation/processing blocks are removed once the result arrives. + - Collapsed successful tool results render as a one-line summary. + - Successful `ls` results are hidden in collapsed mode to avoid transcript clutter; full tool view still shows them. + ## v0.2.0 — Control, Context, and Observability ✅ Completed This release focuses on making Cass easier to interrupt, easier to audit, and safer to run on real projects. Large provider expansions and broad protocol integrations are intentionally deferred. diff --git a/plans/V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md b/plans/V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md new file mode 100644 index 0000000..22a0bc9 --- /dev/null +++ b/plans/V0_2_1_COLLAPSED_TOOL_DENSITY_PLAN.md @@ -0,0 +1,63 @@ +# v0.2.1 Collapsed Tool Density Follow-up Plan + +## Context + +This is a follow-up to `plans/V0_2_1_MESSAGE_RENDERING_POLISH_PLAN.md`. + +The first pass improved tool rendering by removing completed processing/invocation blocks and replacing hidden tool output with compact summaries. In practice, repeated successful low-signal tools such as `ls` still clog the transcript because each collapsed result renders as a heading plus a summary body line. + +## Goal + +Make collapsed tool display much denser while preserving useful auditability. + +## Scope + +- Collapse non-live successful tool result summaries into the heading line. +- Omit successful `ls` result blocks entirely while tool output is hidden. +- Keep failed tool results visible. +- Keep live/running tool output visible. +- Keep full output behavior unchanged when full tools are enabled. + +## Design + +When `show_full_tools == false`: + +- `ls ✓ (...)` blocks are skipped entirely. +- Other completed successful tool blocks render as one line: + +```text +· read ✓ (abc123) · 202 lines · 10.3 KB +``` + +- Tool body content is omitted. +- Failed tool results continue to show content so errors are visible. +- Live tool blocks with streamed output continue to show body content. + +When `show_full_tools == true`: + +- Render all tool result blocks normally with full content. + +## Implementation Steps + +1. Update `src/ui/render.rs` transcript rendering. +2. Add helpers: + - `is_collapsed_successful_ls_result()` + - `collapsed_tool_heading_summary()` +3. Change collapsed successful tool behavior: + - Skip successful `ls` blocks entirely. + - Add output summary to the heading for other tools. + - Return empty body content for collapsed successful tools. +4. Leave errors and live output unchanged. +5. Add tests for: + - successful `ls` hidden while collapsed + - successful `ls` visible with full tools + - successful `read` collapsed to one line + - failed tool output still visible while collapsed + +## Acceptance Criteria + +- Repeated `ls` calls no longer clutter the default transcript view. +- Other successful tool calls occupy one line when collapsed. +- Expanding tools still shows complete tool outputs. +- Tool failures remain inspectable without expanding tools. +- `cargo fmt` and `cargo test` pass. diff --git a/plans/V0_2_1_MESSAGE_RENDERING_POLISH_PLAN.md b/plans/V0_2_1_MESSAGE_RENDERING_POLISH_PLAN.md new file mode 100644 index 0000000..6d215ab --- /dev/null +++ b/plans/V0_2_1_MESSAGE_RENDERING_POLISH_PLAN.md @@ -0,0 +1,348 @@ +# v0.2.1 Message Rendering Polish Implementation Plan + +## Goal + +Improve Cass transcript readability by rendering user/assistant messages as Markdown and making tool calls/results easier to scan. Keep the conversation record and provider payloads unchanged; this release is primarily a presentation-layer improvement. + +## Scope + +### In scope + +- Render `User` and `Assistant` transcript blocks as Markdown in the TUI. +- Preserve existing transcript wrapping, scrolling, and sanitization behavior. +- Improve tool invocation summaries with familiar developer-facing language. +- Improve collapsed tool result display so hidden output still gives useful context. +- Normalize tool titles/statuses for successful, failed, pending, cancelled, and approval blocks. +- Add focused rendering tests. + +### Out of scope + +- Changing stored conversation format. +- Changing provider message format. +- Rich Markdown support for tables, images, raw HTML, or nested block-level edge cases. +- Interactive expand/collapse per individual tool call. +- Syntax highlighting for code fences. + +## Current State + +Rendering is centralized in `src/ui/render.rs`: + +- `transcript_lines_from()` converts `TranscriptBlock`s into ratatui `Line`s. +- User and assistant content is displayed as sanitized plain text. +- Tool output is hidden when `show_full_tools == false`, except for live streamed tool output. +- `max_transcript_scroll()` depends on `transcript_lines_from()` output and wrapped row counting. + +Conversation-to-transcript conversion happens in `src/app.rs`: + +- `blocks_from_conversation()` turns records into `TranscriptBlock`s. +- Assistant tool calls currently show pretty-printed JSON arguments. +- Tool result blocks show full raw content when expanded. + +## Design Principles + +1. **Presentation-only where possible** + - Keep `TranscriptBlock` and conversation records stable unless a small field addition is clearly worth it. + - Prefer helper functions in rendering/conversion code over protocol/model changes. + +2. **Developer-native wording** + - Use common dev terms: `file`, `lines`, `command`, `query`, `matches`, `edits`, `diff`, `exit`, `duration`. + - Avoid implementation-specific or awkward terms like `replacements`, `operations`, `mutations`, or `modifications`. + +3. **Readable collapsed state** + - Collapsed tools should not vanish entirely. + - Show compact metadata such as line count and byte size. + +4. **Markdown subset first** + - Support common chat Markdown well. + - Do not attempt complete Markdown terminal fidelity in v0.2.1. + +## Implementation Steps + +## 1. Add Markdown parser dependency + +Update `Cargo.toml`: + +```toml +pulldown-cmark = "0.12" +``` + +Rationale: `pulldown-cmark` is mature, lightweight, and suitable for converting Markdown events into ratatui lines. + +## 2. Add Markdown rendering helpers + +File: `src/ui/render.rs` + +Add helper functions: + +```rust +fn render_markdown_content(content: &str, base_style: Style) -> Vec> +fn render_plain_content(content: &str) -> Vec> +fn indent_rendered_lines(lines: Vec>) -> Vec> +``` + +Use Markdown rendering for: + +- `TranscriptKind::User` +- `TranscriptKind::Assistant` + +Use plain rendering for: + +- `TranscriptKind::Tool` +- `TranscriptKind::Reasoning` +- `TranscriptKind::Status` +- `TranscriptKind::Error` + +### Markdown subset + +Support these elements: + +- Paragraphs +- Soft/hard breaks +- Headings +- Bullet lists +- Ordered lists +- Fenced/indented code blocks +- Inline code +- Emphasis +- Strong text +- Blockquotes +- Links as visible text, optionally followed by dim URL if useful + +Suggested visual treatment: + +- Heading: bold, maybe same role color +- Bullet: `• ` +- Ordered item: `1. `, `2. ` +- Code block: preserve text lines, dim or gray style, with indentation +- Inline code: contrasting style, e.g. yellow or gray +- Blockquote: prefix `│ `, dim style + +Important: continue sanitizing rendered text with `sanitize_line()` or equivalent character filtering. + +## 3. Integrate Markdown renderer into transcript rendering + +Current pattern in `transcript_lines_from()`: + +```rust +for line in content.lines() { + lines.push(Line::raw(format!(" {}", sanitize_line(line)))); +} +``` + +Replace with logic like: + +```rust +let rendered = match block.kind { + TranscriptKind::User | TranscriptKind::Assistant => { + render_markdown_content(&content, style_for(&block.kind)) + } + _ => render_plain_content(&content), +}; +lines.extend(indent_rendered_lines(rendered)); +``` + +Ensure empty content still produces no body lines. + +## 4. Improve collapsed tool result summaries + +File: `src/ui/render.rs` + +Change `display_content()` so completed tool blocks do not disappear when tools are collapsed. + +Add: + +```rust +fn collapsed_tool_summary(content: &str) -> String +``` + +Suggested output: + +```text +42 lines · 3.1 KB · tool output hidden +``` + +Rules: + +- Count lines with `content.lines().count()`. +- Count bytes with `content.len()`. +- Use human-readable byte formatting. +- If content is empty, show `no output`. +- Preserve current live streamed tool behavior: live output should remain visible even when full tools are hidden. + +Example behavior: + +Collapsed: + +```text +· read ✓ (abc123) + 120 lines · 8.4 KB · tool output hidden +``` + +Expanded: + +```text +· read ✓ (abc123) + +``` + +## 5. Add tool argument summarization + +File: `src/app.rs` + +Add: + +```rust +fn summarize_tool_arguments(name: &str, args: &serde_json::Value) -> String +``` + +Use this in `blocks_from_conversation()` for assistant tool-call blocks instead of always pretty-printing raw JSON. + +Fallback to pretty JSON if a tool is unknown or arguments do not match expected shape. + +### Recommended summaries + +#### `read` + +```text +file: src/ui/render.rs +lines: 1–120 +``` + +If no range: + +```text +file: src/ui/render.rs +``` + +#### `write` + +```text +file: src/lib.rs +bytes: 1.8 KB +``` + +If content length is unavailable, omit bytes. + +#### `edit` + +Use `edits`, not `replacements`. + +```text +file: src/ui/render.rs +edits: 2 +``` + +If only one edit: + +```text +file: src/ui/render.rs +edits: 1 +``` + +#### `shell` + +```text +command: cargo test +``` + +#### `grep` + +```text +query: transcript +path: src +``` + +If include/exclude globs exist, include them only if concise. + +#### `ls` + +```text +path: src/ui +``` + +#### Unknown tool fallback + +Pretty JSON: + +```rust +serde_json::to_string_pretty(args).unwrap_or_else(|_| args.to_string()) +``` + +## 6. Normalize tool titles + +File: `src/app.rs` + +Current loaded transcript titles are close to good: + +- Invocation: `{tool} … ({id})` +- Success: `{tool} ✓ ({id})` +- Failure: `{tool} ✗ ({id})` + +Keep these, but audit live event handling to ensure live-created blocks match loaded blocks after reload. + +Preferred title forms: + +```text +· read … (abc123) # pending/running invocation +· read ✓ (abc123) # successful result +! read ✗ (abc123) # failed result, via TranscriptKind::Error +· shell cancelled (abc123) +· approval required (abc123) +``` + +Do not include verbose implementation terms in titles. + +## 7. Tests + +Add or update tests in `src/ui/render.rs`: + +1. Assistant Markdown heading/list/code renders into multiple lines. +2. User Markdown uses Markdown rendering. +3. Tool output collapsed summary appears when `show_full_tools == false`. +4. Tool output is fully visible when `show_full_tools == true`. +5. Live streamed tool output remains visible even when tools are collapsed. +6. Scroll calculation still counts rendered Markdown lines. + +Add tests in `src/app.rs` for `summarize_tool_arguments()`: + +1. `edit` summary uses `edits`, not `replacements`. +2. `shell` summary uses `command`. +3. `read` summary uses `file` and `lines`. +4. Unknown tool falls back to pretty JSON. + +## 8. Manual QA checklist + +Run: + +```bash +cargo fmt +cargo test +``` + +Then manually verify in the TUI: + +- User Markdown renders cleanly. +- Assistant Markdown renders cleanly. +- Bullets and code blocks look acceptable in narrow terminals. +- Tool calls are scannable without expanding full output. +- `/tools` or equivalent full-tool toggle still shows complete output. +- Reloaded conversations and live conversations show consistent tool formatting. +- Error tool results are visually distinct. + +## Suggested Commit Breakdown + +1. Add Markdown rendering dependency and helpers. +2. Switch user/assistant transcript blocks to Markdown rendering. +3. Add collapsed tool result summaries. +4. Add tool argument summaries with developer-native wording. +5. Normalize live/reloaded tool titles. +6. Add tests and polish. + +## Acceptance Criteria + +- User and assistant messages render common Markdown elements in the transcript. +- Tool invocations no longer default to noisy JSON for known built-in tools. +- `edit` tool summaries say `edits`, not `replacements`. +- Collapsed tool outputs show useful metadata instead of disappearing. +- Existing tool expansion behavior remains available. +- `cargo fmt` and `cargo test` pass. diff --git a/src/app.rs b/src/app.rs index 0d059f4..0145dc3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use crate::ui::render::{self, TranscriptBlock, TranscriptKind}; use crate::ui::terminal; use anyhow::{Context, Result}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -909,8 +909,7 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu ctx.transcript.push(TranscriptBlock { kind: TranscriptKind::Tool, title: format!("{name} … ({})", short_call_id(&id)), - content: serde_json::to_string_pretty(&arguments) - .unwrap_or_else(|_| arguments.to_string()), + content: summarize_tool_arguments(&name, &arguments), }); ctx.active_tools.insert(id, ctx.transcript.len() - 1); update_bottom_scroll(ctx)?; @@ -950,7 +949,9 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu return Ok(()); } } - ctx.active_tools.remove(&id); + if let Some(idx) = ctx.active_tools.remove(&id) { + remove_transcript_block(ctx, idx); + } ctx.transcript.push(TranscriptBlock { kind: if ok { TranscriptKind::Tool @@ -975,8 +976,7 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu } => { *ctx.active_assistant = None; *ctx.active_reasoning = None; - let args = - serde_json::to_string_pretty(&arguments).unwrap_or_else(|_| arguments.to_string()); + let args = summarize_tool_arguments(&name, &arguments); let block_index = ctx.transcript.len(); *ctx.pending_approval = Some(PendingApproval { request_id: request_id.clone(), @@ -1065,11 +1065,29 @@ fn hide_pending_approval( transcript.remove(idx); adjust_index_after_remove(active_assistant, idx); adjust_index_after_remove(active_reasoning, idx); + adjust_active_tool_indices_after_remove(active_tools, idx); +} + +fn remove_transcript_block(ctx: &mut AgentEventContext<'_>, idx: usize) { + if idx >= ctx.transcript.len() { + return; + } + ctx.transcript.remove(idx); + adjust_index_after_remove(ctx.active_assistant, idx); + adjust_index_after_remove(ctx.active_reasoning, idx); + adjust_pending_approval_after_remove(ctx.pending_approval, idx); + adjust_active_tool_indices_after_remove(ctx.active_tools, idx); +} + +fn adjust_active_tool_indices_after_remove( + active_tools: &mut HashMap, + removed: usize, +) { active_tools.retain(|_, tool_idx| { - if *tool_idx == idx { + if *tool_idx == removed { false } else { - if *tool_idx > idx { + if *tool_idx > removed { *tool_idx -= 1; } true @@ -1077,6 +1095,19 @@ fn hide_pending_approval( }); } +fn adjust_pending_approval_after_remove( + pending_approval: &mut Option, + removed: usize, +) { + if let Some(pending) = pending_approval { + if pending.block_index == removed { + *pending_approval = None; + } else if pending.block_index > removed { + pending.block_index -= 1; + } + } +} + fn adjust_index_after_remove(index: &mut Option, removed: usize) { if let Some(value) = index { if *value == removed { @@ -1507,6 +1538,119 @@ fn transcript_from_loaded( blocks } +fn summarize_tool_arguments(name: &str, args: &serde_json::Value) -> String { + match name { + "read" => summarize_read_args(args), + "write" => summarize_write_args(args), + "edit" => summarize_edit_args(args), + "shell" => summarize_shell_args(args), + "grep" => summarize_grep_args(args), + "ls" => summarize_ls_args(args), + _ => pretty_json(args), + } +} + +fn summarize_read_args(args: &serde_json::Value) -> String { + let Some(files) = args.get("files").and_then(|value| value.as_array()) else { + return pretty_json(args); + }; + if files.len() == 1 { + let Some(file) = files.first() else { + return pretty_json(args); + }; + let Some(path) = file.get("path").and_then(|value| value.as_str()) else { + return pretty_json(args); + }; + let mut lines = vec![format!("file: {path}")]; + if let Some(range) = file.get("lines").and_then(|value| value.as_str()) { + lines.push(format!("lines: {}", range.replace('-', "–"))); + } + return lines.join("\n"); + } + let mut lines = vec![format!("files: {}", files.len())]; + for file in files.iter().take(4) { + if let Some(path) = file.get("path").and_then(|value| value.as_str()) { + lines.push(format!("- {path}")); + } + } + if files.len() > 4 { + lines.push(format!("… {} more", files.len() - 4)); + } + lines.join("\n") +} + +fn summarize_write_args(args: &serde_json::Value) -> String { + let Some(path) = args.get("path").and_then(|value| value.as_str()) else { + return pretty_json(args); + }; + let mut lines = vec![format!("file: {path}")]; + if let Some(content) = args.get("content").and_then(|value| value.as_str()) { + lines.push(format!("bytes: {}", human_bytes(content.len()))); + } + lines.join("\n") +} + +fn summarize_edit_args(args: &serde_json::Value) -> String { + let Some(path) = args.get("path").and_then(|value| value.as_str()) else { + return pretty_json(args); + }; + let Some(edits) = args.get("edits").and_then(|value| value.as_array()) else { + return pretty_json(args); + }; + format!("file: {path}\nedits: {}", edits.len()) +} + +fn summarize_shell_args(args: &serde_json::Value) -> String { + let Some(command) = args.get("command").and_then(|value| value.as_str()) else { + return pretty_json(args); + }; + format!("command: {command}") +} + +fn summarize_grep_args(args: &serde_json::Value) -> String { + let Some(query) = args.get("query").and_then(|value| value.as_str()) else { + return pretty_json(args); + }; + let mut lines = vec![format!("query: {query}")]; + if let Some(paths) = args.get("paths").and_then(|value| value.as_array()) { + if paths.len() == 1 { + if let Some(path) = paths.first().and_then(|value| value.as_str()) { + lines.push(format!("path: {path}")); + } + } else if !paths.is_empty() { + lines.push(format!("paths: {}", paths.len())); + } + } + if args.get("regex").and_then(|value| value.as_bool()) == Some(true) { + lines.push("regex: true".into()); + } + lines.join("\n") +} + +fn summarize_ls_args(args: &serde_json::Value) -> String { + let Some(path) = args.get("path").and_then(|value| value.as_str()) else { + return pretty_json(args); + }; + format!("path: {path}") +} + +fn pretty_json(value: &serde_json::Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()) +} + +fn human_bytes(bytes: usize) -> String { + const KB: f64 = 1024.0; + const MB: f64 = 1024.0 * 1024.0; + let bytes_f = bytes as f64; + if bytes < 1024 { + format!("{bytes} B") + } else if bytes_f < MB { + format!("{:.1} KB", bytes_f / KB) + } else { + format!("{:.1} MB", bytes_f / MB) + } +} + fn short_call_id(id: &str) -> String { if id.len() <= 12 { id.to_string() @@ -1554,6 +1698,14 @@ fn clamp_scroll( } fn blocks_from_conversation(conversation: &Conversation) -> Vec { + let completed_tool_calls: HashSet<&str> = conversation + .records + .iter() + .filter_map(|record| match record { + conversation::Record::Tool { tool_call_id, .. } => Some(tool_call_id.as_str()), + _ => None, + }) + .collect(); let mut blocks = Vec::new(); for r in &conversation.records { match r { @@ -1583,11 +1735,13 @@ fn blocks_from_conversation(conversation: &Conversation) -> Vec }); } for call in tool_calls { + if completed_tool_calls.contains(call.id.as_str()) { + continue; + } blocks.push(TranscriptBlock { kind: TranscriptKind::Tool, title: format!("{} … ({})", call.name, short_call_id(&call.id)), - content: serde_json::to_string_pretty(&call.arguments) - .unwrap_or_else(|_| call.arguments.to_string()), + content: summarize_tool_arguments(&call.name, &call.arguments), }); } } @@ -1632,6 +1786,123 @@ mod tests { (root, config) } + #[test] + fn summarize_edit_args_uses_edits() { + let summary = summarize_tool_arguments( + "edit", + &serde_json::json!({"path":"src/ui/render.rs","edits":[{"old_text":"a","new_text":"b"},{"old_text":"c","new_text":"d"}]}), + ); + + assert_eq!(summary, "file: src/ui/render.rs\nedits: 2"); + assert!(!summary.contains("replacements")); + } + + #[test] + fn summarize_shell_args_uses_command() { + let summary = + summarize_tool_arguments("shell", &serde_json::json!({"command":"cargo test"})); + + assert_eq!(summary, "command: cargo test"); + } + + #[test] + fn summarize_read_args_uses_file_and_lines() { + let summary = summarize_tool_arguments( + "read", + &serde_json::json!({"files":[{"path":"src/app.rs","lines":"1-20"}]}), + ); + + assert_eq!(summary, "file: src/app.rs\nlines: 1–20"); + } + + #[test] + fn summarize_unknown_tool_falls_back_to_json() { + let summary = summarize_tool_arguments("custom", &serde_json::json!({"alpha":1})); + + assert!(summary.contains("\"alpha\": 1")); + } + + #[test] + fn loaded_transcript_hides_completed_tool_call_invocations() { + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let config = Config { + root: root.path().to_path_buf(), + model: "test-model".into(), + ..Config::default() + }; + let mut conversation = Conversation::create( + &config.conversations_dir(), + &config.model, + cwd.path(), + "base prompt".into(), + ) + .unwrap(); + conversation + .append(Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![conversation::StoredToolCall { + id: "call_done".into(), + name: "read".into(), + arguments: serde_json::json!({"files":[{"path":"src/app.rs"}]}), + }], + ts: conversation::now_ts(), + }) + .unwrap(); + conversation + .append(Record::Tool { + tool_call_id: "call_done".into(), + name: "read".into(), + ok: true, + content: "ok".into(), + ts: conversation::now_ts(), + }) + .unwrap(); + + let blocks = blocks_from_conversation(&conversation); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].title, "read ✓ (call_done)"); + } + + #[test] + fn loaded_transcript_keeps_pending_tool_call_invocations() { + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let config = Config { + root: root.path().to_path_buf(), + model: "test-model".into(), + ..Config::default() + }; + let mut conversation = Conversation::create( + &config.conversations_dir(), + &config.model, + cwd.path(), + "base prompt".into(), + ) + .unwrap(); + conversation + .append(Record::Assistant { + content: String::new(), + reasoning: String::new(), + reasoning_field: None, + tool_calls: vec![conversation::StoredToolCall { + id: "call_pending".into(), + name: "shell".into(), + arguments: serde_json::json!({"command":"sleep 60"}), + }], + ts: conversation::now_ts(), + }) + .unwrap(); + + let blocks = blocks_from_conversation(&conversation); + + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].title, "shell … (call_pending)"); + } + #[test] fn command_autofill_lists_new_command_and_hides_exact_match() { let menu = command_autofill("/n", 0).unwrap(); diff --git a/src/ui/render.rs b/src/ui/render.rs index cd65ba1..da7fd1e 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -2,6 +2,7 @@ use crate::access::AccessMode; use crate::config::ReasoningEffort; use crate::ui::autofill::AutoFillMenu; use crate::ui::theme; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::prelude::*; use ratatui::widgets::{Paragraph, Wrap}; @@ -164,14 +165,22 @@ fn transcript_lines_from( continue; } + if should_hide_collapsed_tool_block(block, show_full_tools) { + continue; + } + let style = style_for(&block.kind); - lines.push(Line::styled(heading_for(block), style)); + lines.push(Line::styled(display_heading(block, show_full_tools), style)); let content = display_content(block, show_full_tools, show_reasoning); if !content.trim().is_empty() { - for line in content.lines() { - lines.push(Line::raw(format!(" {}", sanitize_line(line)))); - } + let rendered = match block.kind { + TranscriptKind::User | TranscriptKind::Assistant => { + render_markdown_content(&content, style) + } + _ => render_plain_content(&content), + }; + lines.extend(indent_rendered_lines(rendered)); } lines.push(Line::raw("")); } @@ -184,6 +193,239 @@ fn transcript_lines_from( lines } +fn render_plain_content(content: &str) -> Vec> { + content + .lines() + .map(|line| Line::raw(sanitize_line(line))) + .collect() +} + +fn render_markdown_content(content: &str, base_style: Style) -> Vec> { + let mut renderer = MarkdownRenderer::new(base_style); + for event in Parser::new(content) { + renderer.event(event); + } + renderer.finish() +} + +struct MarkdownRenderer { + lines: Vec>, + spans: Vec>, + style_stack: Vec