add tool_renderers.rs (missing from cherry-pick)
This commit is contained in:
parent
39b39193b3
commit
e12a540389
1 changed files with 789 additions and 0 deletions
789
src/ui/chat/tool_renderers.rs
Normal file
789
src/ui/chat/tool_renderers.rs
Normal file
|
|
@ -0,0 +1,789 @@
|
||||||
|
//! Per-tool-type rendering for tool cards.
|
||||||
|
//!
|
||||||
|
//! Pure functions dispatched by tool name so compact args, result previews,
|
||||||
|
//! and full card bodies are semantically meaningful rather than generic
|
||||||
|
//! key:value dumps.
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::ChatPalette;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) fn clip(s: &str, max: usize) -> String {
|
||||||
|
if s.chars().count() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||||
|
out.push('…');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_str<'a>(map: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a str> {
|
||||||
|
map.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_bool(map: &serde_json::Map<String, Value>, key: &str) -> Option<bool> {
|
||||||
|
map.get(key).and_then(|v| v.as_bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_u64(map: &serde_json::Map<String, Value>, key: &str) -> Option<u64> {
|
||||||
|
map.get(key).and_then(|v| v.as_u64())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tier 1: Per-tool argument summarization ──────────────────────────
|
||||||
|
|
||||||
|
/// Format tool arguments into a short human-readable string for compact cards.
|
||||||
|
pub fn summarize_tool_args(name: &str, arguments: &str) -> String {
|
||||||
|
let parsed: Result<Value, _> = serde_json::from_str(arguments);
|
||||||
|
let map = match parsed {
|
||||||
|
Ok(Value::Object(m)) => m,
|
||||||
|
Ok(other) => return clip(&other.to_string(), 120),
|
||||||
|
Err(_) => return clip(arguments, 120),
|
||||||
|
};
|
||||||
|
match name {
|
||||||
|
"bash" => summarize_bash(&map),
|
||||||
|
"read" => summarize_read(&map),
|
||||||
|
"write" => summarize_write(&map),
|
||||||
|
"edit" => summarize_edit(&map),
|
||||||
|
"grep" => summarize_grep(&map),
|
||||||
|
"glob" => summarize_glob(&map),
|
||||||
|
"list_dir" => summarize_list_dir(&map),
|
||||||
|
"todo" => summarize_todo(&map),
|
||||||
|
"subagent" => summarize_subagent(&map),
|
||||||
|
"atmosphere" => summarize_atmosphere(&map),
|
||||||
|
"outfit" => summarize_outfit(&map),
|
||||||
|
"itinerary" => summarize_itinerary(&map),
|
||||||
|
"schedule" => summarize_schedule(&map),
|
||||||
|
"memory" => summarize_memory(&map),
|
||||||
|
"nickname" => summarize_nickname(&map),
|
||||||
|
"reach" | "consult" => summarize_summon(&map),
|
||||||
|
_ => summarize_generic(&map),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_bash(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
match json_str(map, "command") {
|
||||||
|
Some(cmd) => {
|
||||||
|
let cmd = clip(cmd, 80);
|
||||||
|
let extra = if json_bool(map, "run_in_background").unwrap_or(false) {
|
||||||
|
" [bg]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("$ {}{}", cmd, extra)
|
||||||
|
}
|
||||||
|
None => summarize_generic(map),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_read(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
match json_str(map, "path") {
|
||||||
|
Some(p) => {
|
||||||
|
let p = clip(p, 60);
|
||||||
|
let extra = if json_bool(map, "force").unwrap_or(false) {
|
||||||
|
" [force]"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("{}{}", p, extra)
|
||||||
|
}
|
||||||
|
None => summarize_generic(map),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_write(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
match json_str(map, "path") {
|
||||||
|
Some(p) => {
|
||||||
|
let p = clip(p, 50);
|
||||||
|
let mode = json_str(map, "mode").unwrap_or("write");
|
||||||
|
// Estimate content length from the content field
|
||||||
|
let content_len = map
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.len());
|
||||||
|
let label = if mode == "append" { "append" } else { "write" };
|
||||||
|
match content_len {
|
||||||
|
Some(n) if n > 0 => format!("{} → {} ({}B)", label, p, n),
|
||||||
|
_ => format!("{} → {}", label, p),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => summarize_generic(map),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_edit(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let path = json_str(map, "path").map(|p| clip(p, 40)).unwrap_or_default();
|
||||||
|
let old = json_str(map, "old_string").map(|s| clip(s, 30)).unwrap_or_default();
|
||||||
|
let all = json_bool(map, "replace_all").unwrap_or(false);
|
||||||
|
if path.is_empty() {
|
||||||
|
return summarize_generic(map);
|
||||||
|
}
|
||||||
|
if !old.is_empty() {
|
||||||
|
if all {
|
||||||
|
format!("{}: replace_all \"{}\"", path, old)
|
||||||
|
} else {
|
||||||
|
format!("{}: \"{}\"", path, old)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_grep(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let pattern = json_str(map, "pattern").map(|p| clip(p, 40)).unwrap_or_default();
|
||||||
|
let path = json_str(map, "path").filter(|p| *p != ".");
|
||||||
|
let context = json_u64(map, "context").unwrap_or(0);
|
||||||
|
let mut out = if pattern.is_empty() {
|
||||||
|
return summarize_generic(map);
|
||||||
|
} else {
|
||||||
|
format!("\"{}\"", pattern)
|
||||||
|
};
|
||||||
|
if let Some(p) = path {
|
||||||
|
out.push_str(&format!(" {}", clip(p, 30)));
|
||||||
|
}
|
||||||
|
if context > 0 {
|
||||||
|
out.push_str(&format!(" -C {}", context));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_glob(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let pattern = json_str(map, "pattern").map(|p| clip(p, 50)).unwrap_or_default();
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return summarize_generic(map);
|
||||||
|
}
|
||||||
|
let base = json_str(map, "base");
|
||||||
|
match base {
|
||||||
|
Some(b) if b != "." => format!("{} (in {})", pattern, clip(b, 30)),
|
||||||
|
_ => pattern,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_list_dir(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
json_str(map, "path")
|
||||||
|
.map(|p| clip(p, 50))
|
||||||
|
.unwrap_or_else(|| ".".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_todo(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let action = json_str(map, "action").unwrap_or("list");
|
||||||
|
if action == "list" {
|
||||||
|
return "list".to_string();
|
||||||
|
}
|
||||||
|
let text = json_str(map, "text")
|
||||||
|
.or_else(|| json_str(map, "id"))
|
||||||
|
.map(|s| clip(s, 50));
|
||||||
|
match text {
|
||||||
|
Some(t) => format!("{}: {}", action, t),
|
||||||
|
None => action.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_subagent(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let prompt = json_str(map, "prompt").map(|s| clip(s, 50)).unwrap_or_default();
|
||||||
|
let sub_type = json_str(map, "subagent_type").filter(|t| *t != "general-purpose");
|
||||||
|
let bg = json_bool(map, "run_in_background").unwrap_or(false);
|
||||||
|
let mut out = String::new();
|
||||||
|
if bg {
|
||||||
|
out.push_str("[bg] ");
|
||||||
|
}
|
||||||
|
if let Some(t) = sub_type {
|
||||||
|
out.push_str(&format!("[{}] ", t));
|
||||||
|
}
|
||||||
|
if !prompt.is_empty() {
|
||||||
|
out.push_str(&prompt);
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return summarize_generic(map);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_atmosphere(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
json_str(map, "name")
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.unwrap_or_else(|| summarize_generic(map))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_outfit(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
json_str(map, "name")
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.unwrap_or_else(|| summarize_generic(map))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_itinerary(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let action = json_str(map, "action").unwrap_or("describe");
|
||||||
|
let title = json_str(map, "title")
|
||||||
|
.or_else(|| {
|
||||||
|
// Try first stop from `stops` array
|
||||||
|
map.get("stops")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.and_then(|a| a.first())
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
})
|
||||||
|
.map(|s| clip(s, 40));
|
||||||
|
match title {
|
||||||
|
Some(t) => format!("{}: {}", action, t),
|
||||||
|
None => action.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_schedule(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let action = json_str(map, "action").unwrap_or("list");
|
||||||
|
let name = json_str(map, "name").map(|n| clip(n, 30));
|
||||||
|
match name {
|
||||||
|
Some(n) => format!("{}: {}", action, n),
|
||||||
|
None => action.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_memory(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let cmd = json_str(map, "command").unwrap_or("list");
|
||||||
|
let path = json_str(map, "path").map(|p| clip(p, 40));
|
||||||
|
match path {
|
||||||
|
Some(p) => format!("{} {}", cmd, p),
|
||||||
|
None => cmd.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_nickname(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let action = json_str(map, "action").unwrap_or("get");
|
||||||
|
let name = json_str(map, "name").map(|n| clip(n, 30));
|
||||||
|
match name {
|
||||||
|
Some(n) => format!("{}: {}", action, n),
|
||||||
|
None => action.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_summon(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let target = json_str(map, "target").map(|t| clip(t, 40)).unwrap_or_default();
|
||||||
|
if target.is_empty() {
|
||||||
|
return summarize_generic(map);
|
||||||
|
}
|
||||||
|
target
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_generic(map: &serde_json::Map<String, Value>) -> String {
|
||||||
|
let parts: Vec<String> = map
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
let s = match v {
|
||||||
|
Value::String(s) => clip(s, 60),
|
||||||
|
other => clip(&other.to_string(), 60),
|
||||||
|
};
|
||||||
|
format!("{}: {}", k, s)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tier 2: Compact result details ───────────────────────────────────
|
||||||
|
|
||||||
|
/// Return an optional second-line detail for the compact card.
|
||||||
|
/// For completed tools this can show a one-line output preview (e.g. the
|
||||||
|
/// last line of a bash run, the match count for grep). For errors it is
|
||||||
|
/// always the first error line. `None` means no detail line.
|
||||||
|
pub fn compact_result_details(name: &str, output: &str, is_error: bool) -> Option<String> {
|
||||||
|
if is_error {
|
||||||
|
return output.lines().next().map(|l| clip(l.trim(), 80));
|
||||||
|
}
|
||||||
|
match name {
|
||||||
|
"bash" => {
|
||||||
|
// Last non-empty line of output
|
||||||
|
output
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|l| !l.trim().is_empty())
|
||||||
|
.map(|l| clip(l.trim(), 80))
|
||||||
|
}
|
||||||
|
"grep" => {
|
||||||
|
// First line often says "N matches for pattern:"
|
||||||
|
output.lines().next().map(|l| clip(l.trim(), 80))
|
||||||
|
}
|
||||||
|
"glob" => {
|
||||||
|
output.lines().next().map(|l| clip(l.trim(), 80))
|
||||||
|
}
|
||||||
|
"edit" => {
|
||||||
|
// Edit output is a prose summary
|
||||||
|
output.lines().next().map(|l| clip(l.trim(), 80))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tier 3: Full card body rendering ─────────────────────────────────
|
||||||
|
|
||||||
|
/// Render the body of a full tool card.
|
||||||
|
///
|
||||||
|
/// Returns `(args_lines, body_lines)` — the argument summary and the
|
||||||
|
/// output rendering, both ready to be inserted into the bubble layout.
|
||||||
|
pub fn render_card_body(
|
||||||
|
name: &str,
|
||||||
|
arguments: &str,
|
||||||
|
output: Option<&str>,
|
||||||
|
is_error: bool,
|
||||||
|
inner_width: usize,
|
||||||
|
palette: &ChatPalette,
|
||||||
|
) -> (Vec<Line<'static>>, Vec<Line<'static>>) {
|
||||||
|
let dim_color = if is_error {
|
||||||
|
palette.compaction
|
||||||
|
} else {
|
||||||
|
palette.tool_dim
|
||||||
|
};
|
||||||
|
let fg_color = if is_error {
|
||||||
|
palette.compaction
|
||||||
|
} else {
|
||||||
|
palette.agent_primary
|
||||||
|
};
|
||||||
|
let mdpal = crate::ui::markdown::MarkdownPalette::from_chat_palette(palette);
|
||||||
|
|
||||||
|
// Argument line(s)
|
||||||
|
let args_summary = summarize_tool_args(name, arguments);
|
||||||
|
let args_line = Line::from(vec![Span::styled(args_summary, Style::default().fg(dim_color))]);
|
||||||
|
let args_lines = crate::ui::markdown::wrap_line(args_line, inner_width);
|
||||||
|
|
||||||
|
// Body — per-tool output rendering
|
||||||
|
let body_lines = match (name, output) {
|
||||||
|
("bash", Some(out)) if !out.is_empty() => render_bash_body(out, inner_width, palette),
|
||||||
|
("read", Some(out)) if !out.is_empty() => render_read_body(out, arguments, inner_width, palette, &mdpal),
|
||||||
|
("grep", Some(out)) if !out.is_empty() => render_grep_body(out, inner_width, palette, &mdpal),
|
||||||
|
("glob", Some(out)) if !out.is_empty() => render_glob_body(out, inner_width, palette, &mdpal),
|
||||||
|
("write", Some(out)) if !out.is_empty() => render_write_body(out, arguments, inner_width, palette),
|
||||||
|
("edit", Some(out)) if !out.is_empty() => render_edit_body(out, arguments, inner_width, palette),
|
||||||
|
("memory", Some(out)) => render_memory_body(out, inner_width, &mdpal, palette),
|
||||||
|
("list_dir", Some(out)) if !out.is_empty() => render_list_dir_body(out, inner_width, palette),
|
||||||
|
_ => {
|
||||||
|
// Fallback: markdown-rendered preview (existing behavior)
|
||||||
|
if let Some(out) = output {
|
||||||
|
let preview = preview_lines(out, 12);
|
||||||
|
let rendered = crate::ui::markdown::render_with_width(
|
||||||
|
&preview,
|
||||||
|
fg_color,
|
||||||
|
Some(inner_width),
|
||||||
|
&mdpal,
|
||||||
|
);
|
||||||
|
rendered
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(args_lines, body_lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_bash_body(
|
||||||
|
output: &str,
|
||||||
|
inner_width: usize,
|
||||||
|
palette: &ChatPalette,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let dim = palette.tool_dim;
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
|
// ┌─ header
|
||||||
|
let top = format!("┌─ bash output ");
|
||||||
|
let top_dashes = inner_width.saturating_sub(top.chars().count()).max(0);
|
||||||
|
let top_line = Line::from(Span::styled(
|
||||||
|
format!("{}{}", top, "─".repeat(top_dashes)),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
));
|
||||||
|
lines.push(top_line);
|
||||||
|
|
||||||
|
// Content — up to 30 lines, monospace-style (plain spans)
|
||||||
|
let max_lines = 30usize;
|
||||||
|
let displayed: Vec<&str> = output.lines().take(max_lines).collect();
|
||||||
|
for line_text in &displayed {
|
||||||
|
let clipped = clip(line_text, inner_width);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {} ", clipped),
|
||||||
|
Style::default().fg(palette.tool_accent),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let total = output.lines().count();
|
||||||
|
if total > max_lines {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" … ({} more lines)", total - max_lines),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// └─ footer
|
||||||
|
let bottom = format!("└{}", "─".repeat(inner_width.saturating_sub(1)));
|
||||||
|
lines.push(Line::from(Span::styled(bottom, Style::default().fg(dim).add_modifier(Modifier::DIM))));
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_read_body(
|
||||||
|
output: &str,
|
||||||
|
arguments: &str,
|
||||||
|
_inner_width: usize,
|
||||||
|
_palette: &ChatPalette,
|
||||||
|
mdpal: &crate::ui::markdown::MarkdownPalette,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
// For .md files, render as markdown. For everything else, render as
|
||||||
|
// a code block with guessed language from arguments.
|
||||||
|
let path = arguments_to_path(arguments);
|
||||||
|
let ext = path.rsplit('.').next().map(|s| s.to_lowercase());
|
||||||
|
let is_markdown = matches!(ext.as_deref(), Some("md" | "mdx"));
|
||||||
|
let lang_label = match ext.as_deref() {
|
||||||
|
Some("rs") => "rust",
|
||||||
|
Some("py") => "python",
|
||||||
|
Some("js") | Some("ts") | Some("tsx") => ext.as_deref().unwrap_or("code"),
|
||||||
|
Some("toml") => "toml",
|
||||||
|
Some("json") => "json",
|
||||||
|
Some("yaml") | Some("yml") => "yaml",
|
||||||
|
Some("md") | Some("mdx") => "markdown",
|
||||||
|
Some(other) => other,
|
||||||
|
None => "code",
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_markdown {
|
||||||
|
// Full markdown render for prose files
|
||||||
|
let fg = mdpal.code_fg;
|
||||||
|
let rendered = crate::ui::markdown::render_with_width(output, fg, None, mdpal);
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code block with language label
|
||||||
|
let fg = mdpal.code_fg;
|
||||||
|
let bg = mdpal.code_bg;
|
||||||
|
let code_style = Style::default().fg(fg).bg(bg);
|
||||||
|
let dim_style = Style::default().fg(mdpal.code_fg).add_modifier(Modifier::DIM);
|
||||||
|
|
||||||
|
// This is a simplified code-block render that doesn't depend on the
|
||||||
|
// markdown renderer's internal ┌─ / └─ borders, since we want a
|
||||||
|
// monospace look in the tool card.
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
let header = format!("┌─ {} ", lang_label);
|
||||||
|
let dashes = _inner_width.saturating_sub(header.chars().count()).max(0);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("{}{}", header, "─".repeat(dashes)),
|
||||||
|
dim_style,
|
||||||
|
)));
|
||||||
|
|
||||||
|
let max_lines = 30usize;
|
||||||
|
let displayed: Vec<&str> = output.lines().take(max_lines).collect();
|
||||||
|
for line_text in &displayed {
|
||||||
|
let clipped = clip(line_text, _inner_width.saturating_sub(2).max(10));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {}", clipped),
|
||||||
|
code_style,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let total = output.lines().count();
|
||||||
|
if total > max_lines {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" … ({} more lines)", total - max_lines),
|
||||||
|
dim_style.add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("└{}", "─".repeat(_inner_width.saturating_sub(1))),
|
||||||
|
dim_style,
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_grep_body(
|
||||||
|
output: &str,
|
||||||
|
inner_width: usize,
|
||||||
|
palette: &ChatPalette,
|
||||||
|
mdpal: &crate::ui::markdown::MarkdownPalette,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let dim = palette.tool_dim;
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
|
// First line is the match count header
|
||||||
|
if let Some(first) = output.lines().next() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
clip(first.trim(), inner_width),
|
||||||
|
Style::default().fg(palette.tool_accent).add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"─".repeat(inner_width.min(40)),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match lines — cap at 20
|
||||||
|
let max_lines = 20usize;
|
||||||
|
for line_text in output.lines().skip(1).take(max_lines) {
|
||||||
|
let clipped = clip(line_text.trim(), inner_width);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
clipped,
|
||||||
|
Style::default().fg(dim),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let total = output.lines().count().saturating_sub(1);
|
||||||
|
if total > max_lines {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" … ({} more matches)", total - max_lines),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_glob_body(
|
||||||
|
output: &str,
|
||||||
|
inner_width: usize,
|
||||||
|
palette: &ChatPalette,
|
||||||
|
_mdpal: &crate::ui::markdown::MarkdownPalette,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let dim = palette.tool_dim;
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(first) = output.lines().next() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
clip(first.trim(), inner_width),
|
||||||
|
Style::default().fg(palette.tool_accent).add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"─".repeat(inner_width.min(40)),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_lines = 30usize;
|
||||||
|
for line_text in output.lines().skip(1).take(max_lines) {
|
||||||
|
let clipped = clip(line_text.trim(), inner_width);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
clipped,
|
||||||
|
Style::default().fg(dim),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let total = output.lines().count().saturating_sub(1);
|
||||||
|
if total > max_lines {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" … ({} more files)", total - max_lines),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_list_dir_body(
|
||||||
|
output: &str,
|
||||||
|
inner_width: usize,
|
||||||
|
palette: &ChatPalette,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let dim = palette.tool_dim;
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
|
let max_lines = 30usize;
|
||||||
|
for line_text in output.lines().take(max_lines) {
|
||||||
|
let clipped = clip(line_text.trim(), inner_width);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
clipped,
|
||||||
|
Style::default().fg(dim),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let total = output.lines().count();
|
||||||
|
if total > max_lines {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" … ({} more entries)", total - max_lines),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_write_body(
|
||||||
|
output: &str,
|
||||||
|
arguments: &str,
|
||||||
|
inner_width: usize,
|
||||||
|
palette: &ChatPalette,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let dim = palette.tool_dim;
|
||||||
|
let fg = palette.tool_accent;
|
||||||
|
let path = arguments_to_path(arguments);
|
||||||
|
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
// Header with path and output summary
|
||||||
|
let header = format!("┌─ {} ", if path.is_empty() { "write" } else { &path });
|
||||||
|
let dashes = inner_width.saturating_sub(header.chars().count()).max(0);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("{}{}", header, "─".repeat(dashes)),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
// Output summary line — write returns prose like "Appended N chars to path"
|
||||||
|
let clipped = clip(output.trim(), inner_width.saturating_sub(2).max(10));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {}", clipped),
|
||||||
|
Style::default().fg(fg),
|
||||||
|
)));
|
||||||
|
// Footer
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("└{}", "─".repeat(inner_width.saturating_sub(1))),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_edit_body(
|
||||||
|
output: &str,
|
||||||
|
arguments: &str,
|
||||||
|
inner_width: usize,
|
||||||
|
palette: &ChatPalette,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let dim = palette.tool_dim;
|
||||||
|
let fg = palette.tool_accent;
|
||||||
|
let path = arguments_to_path(arguments);
|
||||||
|
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
// Header with path
|
||||||
|
let header = format!("┌─ edit{} ", if path.is_empty() { "" } else { ":" });
|
||||||
|
let label = format!("{}{}", header, &path);
|
||||||
|
let dashes = inner_width.saturating_sub(label.chars().count()).max(0);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("{}{}", label, "─".repeat(dashes)),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
// Edit returns prose like "Edited file.rs. +42 chars, 10 -> 12 lines"
|
||||||
|
if let Some(first) = output.lines().next() {
|
||||||
|
let clipped = clip(first.trim(), inner_width.saturating_sub(2).max(10));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {}", clipped),
|
||||||
|
Style::default().fg(fg),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Footer
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("└{}", "─".repeat(inner_width.saturating_sub(1))),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_memory_body(
|
||||||
|
output: &str,
|
||||||
|
inner_width: usize,
|
||||||
|
mdpal: &crate::ui::markdown::MarkdownPalette,
|
||||||
|
palette: &ChatPalette,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let dim = palette.tool_dim;
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let header = format!("┌─ memory ");
|
||||||
|
let dashes = inner_width.saturating_sub(header.chars().count()).max(0);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("{}{}", header, "─".repeat(dashes)),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
|
||||||
|
if output.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" (empty)",
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("└{}", "─".repeat(inner_width.saturating_sub(1))),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to split frontmatter from body — frontmatter lives between --- lines
|
||||||
|
let body = if output.starts_with("---") {
|
||||||
|
if let Some(end) = output[3..].find("\n---") {
|
||||||
|
let frontmatter = &output[..end + 6]; // include closing ---
|
||||||
|
let body = output[end + 6..].trim();
|
||||||
|
// Render frontmatter as folded single-line summary
|
||||||
|
let fm_summary = summarize_frontmatter(frontmatter);
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {} ", fm_summary),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
body
|
||||||
|
} else {
|
||||||
|
output
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render body as markdown (up to 30 lines)
|
||||||
|
if !body.is_empty() {
|
||||||
|
let max_lines = 30usize;
|
||||||
|
let preview: String = body.lines().take(max_lines).collect::<Vec<&str>>().join("\n");
|
||||||
|
let rendered = crate::ui::markdown::render_with_width(
|
||||||
|
&preview,
|
||||||
|
mdpal.code_fg,
|
||||||
|
Some(inner_width.saturating_sub(2).max(8)),
|
||||||
|
mdpal,
|
||||||
|
);
|
||||||
|
lines.extend(rendered);
|
||||||
|
|
||||||
|
let total = body.lines().count();
|
||||||
|
if total > max_lines {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" … ({} more lines)", total - max_lines),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!("└{}", "─".repeat(inner_width.saturating_sub(1))),
|
||||||
|
Style::default().fg(dim).add_modifier(Modifier::DIM),
|
||||||
|
)));
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a one-line summary from YAML frontmatter: `description · tags · limit`
|
||||||
|
fn summarize_frontmatter(frontmatter: &str) -> String {
|
||||||
|
let mut desc = String::new();
|
||||||
|
let mut tags = String::new();
|
||||||
|
let mut limit = String::new();
|
||||||
|
|
||||||
|
for line in frontmatter.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if let Some(val) = line.strip_prefix("description:") {
|
||||||
|
desc = val.trim().to_string();
|
||||||
|
} else if let Some(val) = line.strip_prefix("tags:") {
|
||||||
|
tags = val.trim().to_string();
|
||||||
|
} else if let Some(val) = line.strip_prefix("limit:") {
|
||||||
|
limit = val.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts: Vec<&str> = Vec::new();
|
||||||
|
if !desc.is_empty() { parts.push(&desc); }
|
||||||
|
if !tags.is_empty() { parts.push(&tags); }
|
||||||
|
if !limit.is_empty() { parts.push(&limit); }
|
||||||
|
if parts.is_empty() {
|
||||||
|
return "📄 frontmatter".to_string();
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Extract just the path from an arguments JSON for read-body heuristics.
|
||||||
|
fn arguments_to_path(arguments: &str) -> String {
|
||||||
|
let parsed: Result<Value, _> = serde_json::from_str(arguments);
|
||||||
|
match parsed {
|
||||||
|
Ok(Value::Object(map)) => {
|
||||||
|
json_str(&map, "path").map(|s| s.to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the first N lines of a string.
|
||||||
|
fn preview_lines(s: &str, n: usize) -> String {
|
||||||
|
let lines: Vec<&str> = s.lines().take(n).collect();
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue