Polish message and tool rendering

This commit is contained in:
2026-06-23 21:56:30 -05:00
parent 7fbb8b9776
commit 707d05c352
7 changed files with 1185 additions and 20 deletions
Generated
+45 -4
View File
@@ -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"
+1
View File
@@ -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"
+9
View File
@@ -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.
@@ -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.
@@ -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<Line<'static>>
fn render_plain_content(content: &str) -> Vec<Line<'static>>
fn indent_rendered_lines(lines: Vec<Line<'static>>) -> Vec<Line<'static>>
```
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)
<full content>
```
## 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.
+281 -10
View File
@@ -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<String, usize>,
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<PendingApproval>,
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<usize>, 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<TranscriptBlock> {
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<TranscriptBlock>
});
}
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();
+438 -6
View File
@@ -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<Line<'static>> {
content
.lines()
.map(|line| Line::raw(sanitize_line(line)))
.collect()
}
fn render_markdown_content(content: &str, base_style: Style) -> Vec<Line<'static>> {
let mut renderer = MarkdownRenderer::new(base_style);
for event in Parser::new(content) {
renderer.event(event);
}
renderer.finish()
}
struct MarkdownRenderer {
lines: Vec<Line<'static>>,
spans: Vec<Span<'static>>,
style_stack: Vec<Style>,
base_style: Style,
pending_prefix: Option<String>,
list_stack: Vec<ListState>,
in_code_block: bool,
in_heading: bool,
in_blockquote: bool,
}
#[derive(Debug, Clone, Copy)]
struct ListState {
next: Option<u64>,
}
impl MarkdownRenderer {
fn new(base_style: Style) -> Self {
Self {
lines: Vec::new(),
spans: Vec::new(),
style_stack: vec![base_style],
base_style,
pending_prefix: None,
list_stack: Vec::new(),
in_code_block: false,
in_heading: false,
in_blockquote: false,
}
}
fn event(&mut self, event: Event<'_>) {
match event {
Event::Start(tag) => self.start(tag),
Event::End(tag) => self.end(tag),
Event::Text(text) => self.push_text(&text),
Event::Code(code) => self.push_styled(&code, self.current_style().fg(Color::Yellow)),
Event::SoftBreak | Event::HardBreak => self.flush_line(),
Event::Rule => {
self.flush_line();
self.lines.push(Line::styled(
"────────",
self.base_style.fg(Color::DarkGray),
));
}
Event::Html(html) | Event::InlineHtml(html) => self.push_text(&html),
Event::FootnoteReference(reference) => self.push_text(&format!("[{reference}]")),
Event::TaskListMarker(checked) => self.push_text(if checked { "[x] " } else { "[ ] " }),
_ => {}
}
}
fn start(&mut self, tag: Tag<'_>) {
match tag {
Tag::Paragraph => {}
Tag::Heading { level, .. } => {
self.flush_line();
self.in_heading = true;
let marker = match level {
HeadingLevel::H1 => "# ",
HeadingLevel::H2 => "## ",
HeadingLevel::H3 => "### ",
_ => "#### ",
};
self.pending_prefix = Some(marker.into());
self.push_style(self.current_style().add_modifier(Modifier::BOLD));
}
Tag::BlockQuote(_) => {
self.flush_line();
self.in_blockquote = true;
self.pending_prefix = Some("│ ".into());
self.push_style(self.current_style().fg(Color::DarkGray));
}
Tag::CodeBlock(kind) => {
self.flush_line();
self.in_code_block = true;
let label = match kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(format!("```{lang}")),
_ => Some("```".to_string()),
};
if let Some(label) = label {
self.lines
.push(Line::styled(label, self.base_style.fg(Color::DarkGray)));
}
self.push_style(self.base_style.fg(Color::Gray));
}
Tag::List(start) => {
self.flush_line();
self.list_stack.push(ListState { next: start });
}
Tag::Item => {
self.flush_line();
let prefix = if let Some(list) = self.list_stack.last_mut() {
if let Some(n) = list.next {
list.next = Some(n + 1);
format!("{n}. ")
} else {
"• ".to_string()
}
} else {
"• ".to_string()
};
self.pending_prefix = Some(prefix);
}
Tag::Emphasis => self.push_style(self.current_style().add_modifier(Modifier::ITALIC)),
Tag::Strong => self.push_style(self.current_style().add_modifier(Modifier::BOLD)),
Tag::Strikethrough => {
self.push_style(self.current_style().add_modifier(Modifier::CROSSED_OUT))
}
Tag::Link { .. } => self.push_style(self.current_style().underlined()),
_ => {}
}
}
fn end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => self.flush_line(),
TagEnd::Heading(_) => {
self.flush_line();
self.in_heading = false;
self.pop_style();
}
TagEnd::BlockQuote(_) => {
self.flush_line();
self.in_blockquote = false;
self.pop_style();
}
TagEnd::CodeBlock => {
self.flush_line();
self.lines
.push(Line::styled("```", self.base_style.fg(Color::DarkGray)));
self.in_code_block = false;
self.pop_style();
}
TagEnd::List(_) => {
self.flush_line();
self.list_stack.pop();
}
TagEnd::Item => self.flush_line(),
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => {
self.pop_style();
}
_ => self.flush_line(),
}
}
fn finish(mut self) -> Vec<Line<'static>> {
self.flush_line();
if self.lines.is_empty() {
render_plain_content("")
} else {
self.lines
}
}
fn push_style(&mut self, style: Style) {
self.style_stack.push(style);
}
fn pop_style(&mut self) {
if self.style_stack.len() > 1 {
self.style_stack.pop();
}
}
fn current_style(&self) -> Style {
*self.style_stack.last().unwrap_or(&self.base_style)
}
fn push_text(&mut self, text: &str) {
self.push_styled(text, self.current_style());
}
fn push_styled(&mut self, text: &str, style: Style) {
for (idx, part) in text.split('\n').enumerate() {
if idx > 0 {
self.flush_line();
}
if part.is_empty() {
continue;
}
self.ensure_prefix();
self.spans.push(Span::styled(sanitize_line(part), style));
}
}
fn ensure_prefix(&mut self) {
if let Some(prefix) = self.pending_prefix.take() {
self.spans.push(Span::styled(prefix, self.current_style()));
}
}
fn flush_line(&mut self) {
if self.spans.is_empty() {
if let Some(prefix) = self.pending_prefix.take() {
self.lines.push(Line::styled(prefix, self.current_style()));
}
return;
}
self.lines.push(Line::from(std::mem::take(&mut self.spans)));
if self.in_blockquote {
self.pending_prefix = Some("│ ".into());
}
}
}
fn indent_rendered_lines(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| {
let mut spans = vec![Span::raw(" ")];
spans.extend(line.spans);
Line::from(spans)
})
.collect()
}
fn input_lines(input: &str, busy: bool) -> Vec<Line<'static>> {
let prefix = if busy { "… " } else { "› " };
if input.is_empty() {
@@ -312,6 +554,15 @@ fn style_for(kind: &TranscriptKind) -> Style {
}
}
fn display_heading(block: &TranscriptBlock, show_full_tools: bool) -> String {
let heading = heading_for(block);
if is_collapsed_successful_tool_result(block, show_full_tools) {
format!("{heading} · {}", collapsed_tool_summary(&block.content))
} else {
heading
}
}
fn heading_for(block: &TranscriptBlock) -> String {
match block.kind {
TranscriptKind::User => "› you".into(),
@@ -330,9 +581,13 @@ fn heading_for(block: &TranscriptBlock) -> String {
}
fn display_content(block: &TranscriptBlock, show_full_tools: bool, show_reasoning: bool) -> String {
if matches!(block.kind, TranscriptKind::Tool) && !show_full_tools && !is_live_tool_output(block)
{
if is_collapsed_successful_tool_result(block, show_full_tools) {
String::new()
} else if matches!(block.kind, TranscriptKind::Tool)
&& !show_full_tools
&& !is_live_tool_output(block)
{
collapsed_tool_summary(&block.content)
} else if matches!(block.kind, TranscriptKind::Reasoning) && !show_reasoning {
String::new()
} else {
@@ -340,6 +595,53 @@ fn display_content(block: &TranscriptBlock, show_full_tools: bool, show_reasonin
}
}
fn should_hide_collapsed_tool_block(block: &TranscriptBlock, show_full_tools: bool) -> bool {
!show_full_tools
&& matches!(block.kind, TranscriptKind::Tool)
&& !is_live_tool_output(block)
&& block.title.starts_with("ls ✓")
}
fn is_collapsed_successful_tool_result(block: &TranscriptBlock, show_full_tools: bool) -> bool {
!show_full_tools
&& matches!(block.kind, TranscriptKind::Tool)
&& !is_live_tool_output(block)
&& block.title.contains('✓')
}
fn collapsed_tool_summary(content: &str) -> String {
if content.is_empty() {
return "no output".into();
}
let lines = content.lines().count();
format!(
"{} · {} · tool output hidden",
pluralize(lines, "line"),
human_bytes(content.len())
)
}
fn pluralize(count: usize, unit: &str) -> String {
if count == 1 {
format!("1 {unit}")
} else {
format!("{count} {unit}s")
}
}
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 is_live_tool_output(block: &TranscriptBlock) -> bool {
block.title.contains('…') && block.content.contains("streamed output:\n")
}
@@ -473,6 +775,136 @@ fn input_height(input: &str, available_width: u16) -> u16 {
mod tests {
use super::*;
fn rendered_text(lines: &[Line<'static>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn assistant_markdown_renders_common_blocks() {
let transcript = vec![TranscriptBlock {
kind: TranscriptKind::Assistant,
title: "response".into(),
content: "## Plan\n\n- Read files\n- Apply `edits`\n\n```rust\nlet ok = true;\n```"
.into(),
}];
let text = rendered_text(&transcript_lines_from(&transcript, false, false));
assert!(text.contains("## Plan"));
assert!(text.contains("• Read files"));
assert!(text.contains("Apply edits"));
assert!(text.contains("```rust"));
assert!(text.contains("let ok = true;"));
}
#[test]
fn user_markdown_is_rendered() {
let transcript = vec![TranscriptBlock {
kind: TranscriptKind::User,
title: "message".into(),
content: "Please do **this**:\n1. Test".into(),
}];
let text = rendered_text(&transcript_lines_from(&transcript, false, false));
assert!(text.contains("Please do this:"));
assert!(text.contains("1. Test"));
}
#[test]
fn collapsed_tool_output_shows_one_line_summary() {
let transcript = vec![TranscriptBlock {
kind: TranscriptKind::Tool,
title: "read ✓ (call_1)".into(),
content: "one\ntwo\nthree".into(),
}];
let rendered = transcript_lines_from(&transcript, false, false);
let text = rendered_text(&rendered);
assert_eq!(rendered.len(), 2); // heading plus spacer
assert!(text.contains("read ✓ (call_1) · 3 lines"));
assert!(!text.contains("one\ntwo\nthree"));
}
#[test]
fn successful_ls_is_hidden_when_tools_are_collapsed() {
let transcript = vec![TranscriptBlock {
kind: TranscriptKind::Tool,
title: "ls ✓ (call_1)".into(),
content: "file1\nfile2".into(),
}];
let text = rendered_text(&transcript_lines_from(&transcript, false, false));
assert!(!text.contains("ls ✓"));
assert!(text.contains("Ask a question"));
}
#[test]
fn successful_ls_is_visible_when_tools_are_expanded() {
let transcript = vec![TranscriptBlock {
kind: TranscriptKind::Tool,
title: "ls ✓ (call_1)".into(),
content: "file1\nfile2".into(),
}];
let text = rendered_text(&transcript_lines_from(&transcript, true, false));
assert!(text.contains("ls ✓ (call_1)"));
assert!(text.contains("file1"));
}
#[test]
fn failed_tool_output_stays_visible_when_collapsed() {
let transcript = vec![TranscriptBlock {
kind: TranscriptKind::Error,
title: "ls ✗ (call_1)".into(),
content: "permission denied".into(),
}];
let text = rendered_text(&transcript_lines_from(&transcript, false, false));
assert!(text.contains("ls ✗ (call_1)"));
assert!(text.contains("permission denied"));
}
#[test]
fn expanded_tool_output_shows_full_content() {
let transcript = vec![TranscriptBlock {
kind: TranscriptKind::Tool,
title: "read ✓ (call_1)".into(),
content: "one\ntwo".into(),
}];
let text = rendered_text(&transcript_lines_from(&transcript, true, false));
assert!(text.contains("one\n two"));
}
#[test]
fn live_tool_output_stays_visible_when_collapsed() {
let transcript = vec![TranscriptBlock {
kind: TranscriptKind::Tool,
title: "shell … (call_1)".into(),
content: "streamed output:\nhello".into(),
}];
let text = rendered_text(&transcript_lines_from(&transcript, false, false));
assert!(text.contains("streamed output"));
assert!(text.contains("hello"));
}
#[test]
fn max_scroll_counts_wrapped_transcript_rows() {
let transcript = vec![TranscriptBlock {