Polish message and tool rendering
This commit is contained in:
Generated
+45
-4
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user