use imp_core::config::AnimationLevel;
use imp_llm::truncate_chars_with_suffix;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use serde_json::Value;
use crate::theme::Theme;
fn abbreviate_home_path(path: &str) -> String {
for prefix in ["/Users/", "/home/"] {
if let Some(rest) = path.strip_prefix(prefix) {
if let Some((_, suffix)) = rest.split_once('/') {
return format!("~/{suffix}");
}
return "~".to_string();
}
}
path.to_string()
}
fn abbreviate_path_list(items: &[Value]) -> String {
items
.iter()
.filter_map(|v| v.as_str())
.map(abbreviate_home_path)
.collect::<Vec<_>>()
.join(", ")
}
fn shell_summary(args: &Value) -> String {
let command = args
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim_start();
if command.is_empty() {
return String::new();
}
let label = if command.starts_with("rg ")
|| command.starts_with("grep ")
|| command.starts_with("fd ")
|| command.starts_with("find ")
|| command == "find"
|| command.starts_with("ls ")
|| command == "ls"
{
"search"
} else if command.contains("check")
|| command.contains("test")
|| command.contains("verify")
|| command.contains("lint")
{
"check"
} else {
"run"
};
label.to_string()
}
#[derive(Debug, Clone)]
pub struct DisplayToolCall {
pub id: String,
pub name: String,
pub args_summary: String,
pub output: Option<String>,
pub details: serde_json::Value,
pub is_error: bool,
pub expanded: bool,
pub streaming_lines: Vec<String>,
pub streaming_output: String,
}
impl DisplayToolCall {
pub fn header_line(&self, theme: &Theme) -> Line<'static> {
self.header_line_animated(theme, 0, AnimationLevel::Minimal)
}
pub fn header_line_animated(
&self,
theme: &Theme,
tick: u64,
animation_level: AnimationLevel,
) -> Line<'static> {
self.header_line_animated_focused(theme, tick, false, animation_level)
}
pub fn header_line_animated_focused(
&self,
theme: &Theme,
tick: u64,
focused: bool,
animation_level: AnimationLevel,
) -> Line<'static> {
let _ = tick;
let _ = animation_level;
let is_running = self.output.is_none() && !self.is_error;
let icon = if self.is_error { "✗" } else { "✓" };
let icon_style = if self.is_error {
theme.error_style()
} else if is_running {
Style::default().fg(theme.accent)
} else {
theme.success_style()
};
let focus_span = if focused {
Span::styled(
"â–¸",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
let mut spans = vec![focus_span];
if !is_running {
spans.push(Span::styled(format!(" {icon} "), icon_style));
}
spans.push(Span::styled(
self.name.clone(),
Style::default()
.fg(theme.tool_name)
.add_modifier(Modifier::BOLD),
));
if !self.args_summary.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(self.args_summary.clone(), theme.muted_style()));
}
if !self.expanded {
if let Some(ref output) = self.output {
if self.is_error {
spans.push(Span::styled(" error", theme.error_style()));
} else {
let line_count = output.lines().count();
spans.push(Span::styled(
format!(" {line_count} lines"),
theme.muted_style(),
));
}
}
}
Line::from(spans)
}
pub fn compact_spans(&self, theme: &Theme) -> Vec<Span<'static>> {
let icon_style = theme.success_style();
let args_short = short_args(&self.args_summary);
let mut spans = vec![
Span::styled("✓ ", icon_style),
Span::styled(
self.name.clone(),
Style::default()
.fg(theme.tool_name)
.add_modifier(Modifier::BOLD),
),
];
if !args_short.is_empty() {
spans.push(Span::styled(format!(" {args_short}"), theme.muted_style()));
}
spans
}
pub fn make_args_summary(name: &str, args: &serde_json::Value) -> String {
match name {
"read" => args
.get("path")
.and_then(|v| v.as_str())
.map(abbreviate_home_path)
.unwrap_or_default(),
"bash" => shell_summary(args),
"edit" | "write" => args
.get("path")
.and_then(|v| v.as_str())
.map(abbreviate_home_path)
.unwrap_or_default(),
"scan" => {
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
match action {
"extract" => args
.get("targets")
.or_else(|| args.get("files"))
.and_then(|v| v.as_array())
.map(|items| abbreviate_path_list(items))
.unwrap_or_else(|| "extract".to_string()),
"directory" | "scan" => args
.get("directory")
.and_then(|v| v.as_str())
.map(abbreviate_home_path)
.unwrap_or_default(),
_ => {
if action == name {
String::new()
} else {
action.to_string()
}
}
}
}
"mana" => format_mana_args(args),
_ => summarize_json_object(args),
}
}
}
fn format_mana_args(args: &Value) -> String {
let action = args.get("action").and_then(Value::as_str).unwrap_or("?");
let mut fields = Vec::new();
match action {
"create" => {
push_field(
&mut fields,
"title",
args.get("title")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"priority",
args.get("priority").and_then(value_to_short_string),
);
push_field(
&mut fields,
"parent",
args.get("parent")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"verify",
args.get("verify")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"deps",
args.get("deps").and_then(Value::as_str).map(str::to_string),
);
}
"update" => {
push_field(
&mut fields,
"id",
args.get("id").and_then(Value::as_str).map(str::to_string),
);
push_field(
&mut fields,
"status",
args.get("status")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"title",
args.get("title")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"priority",
args.get("priority").and_then(value_to_short_string),
);
push_field(
&mut fields,
"notes",
args.get("notes")
.and_then(Value::as_str)
.map(str::to_string),
);
}
"run" => {
push_field(
&mut fields,
"id",
args.get("id").and_then(Value::as_str).map(str::to_string),
);
push_field(
&mut fields,
"jobs",
args.get("jobs").and_then(value_to_short_string),
);
push_field(
&mut fields,
"background",
args.get("background").and_then(value_to_short_string),
);
push_field(
&mut fields,
"dry_run",
args.get("dry_run").and_then(value_to_short_string),
);
push_field(
&mut fields,
"review",
args.get("review").and_then(value_to_short_string),
);
}
"show" | "close" | "claim" | "release" | "logs" | "tree" => {
push_field(
&mut fields,
"id",
args.get("id").and_then(Value::as_str).map(str::to_string),
);
push_field(
&mut fields,
"run_id",
args.get("run_id")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"reason",
args.get("reason")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"by",
args.get("by").and_then(Value::as_str).map(str::to_string),
);
}
"list" => {
push_field(
&mut fields,
"status",
args.get("status")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"parent",
args.get("parent")
.and_then(Value::as_str)
.map(str::to_string),
);
push_field(
&mut fields,
"priority",
args.get("priority").and_then(value_to_short_string),
);
push_field(
&mut fields,
"all",
args.get("all").and_then(value_to_short_string),
);
}
"next" => {
push_field(
&mut fields,
"count",
args.get("count").and_then(value_to_short_string),
);
}
"status" | "agents" | "run_state" | "evaluate" => {
push_field(
&mut fields,
"run_id",
args.get("run_id")
.and_then(Value::as_str)
.map(str::to_string),
);
}
_ => {
for key in [
"id", "title", "status", "priority", "run_id", "reason", "count",
] {
push_field(
&mut fields,
key,
args.get(key).and_then(value_to_short_string),
);
}
}
}
if fields.is_empty() {
action.to_string()
} else {
format!("{action} {}", fields.join(" "))
}
}
fn summarize_json_object(args: &Value) -> String {
let Some(obj) = args.as_object() else {
let json = serde_json::to_string(args).unwrap_or_default();
return truncate_chars_with_suffix(&json, 80, "…");
};
let mut fields = Vec::new();
for (key, value) in obj {
if let Some(short) = value_to_short_string(value) {
fields.push(format!("{key} {short}"));
}
}
if fields.is_empty() {
"{}".to_string()
} else {
fields.join(" ")
}
}
fn push_field(fields: &mut Vec<String>, key: &str, value: Option<String>) {
if let Some(value) = value {
if !value.is_empty() {
fields.push(format!("{key} {value}"));
}
}
}
fn value_to_short_string(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(truncate_chars_with_suffix(
&abbreviate_home_path(s),
32,
"…",
)),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
Value::Array(items) => {
let joined = items
.iter()
.filter_map(value_to_short_string)
.collect::<Vec<_>>()
.join(",");
if joined.is_empty() {
None
} else {
Some(truncate_chars_with_suffix(&joined, 32, "…"))
}
}
Value::Object(_) => Some("{…}".to_string()),
}
}
pub struct ToolCallView<'a> {
tool_call: &'a DisplayToolCall,
theme: &'a Theme,
}
impl<'a> ToolCallView<'a> {
pub fn new(tool_call: &'a DisplayToolCall, theme: &'a Theme) -> Self {
Self { tool_call, theme }
}
}
impl Widget for ToolCallView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let header = self.tool_call.header_line(self.theme);
buf.set_line(area.x, area.y, &header, area.width);
if self.tool_call.expanded {
if let Some(ref output) = self.tool_call.output {
let output_style = if self.tool_call.is_error {
self.theme.error_style()
} else {
self.theme.muted_style()
};
for (i, line_str) in output.lines().enumerate() {
let y = area.y + 1 + i as u16;
if y >= area.y + area.height {
break;
}
let line = Line::from(Span::styled(format!(" {line_str}"), output_style));
buf.set_line(area.x, y, &line, area.width);
}
}
}
}
}
pub fn tool_call_height(tc: &DisplayToolCall) -> u16 {
let mut h: u16 = 1; if tc.expanded {
if let Some(ref output) = tc.output {
h += output.lines().count().min(50) as u16; }
}
h
}
pub fn is_compactable(tc: &DisplayToolCall) -> bool {
tc.output.is_some() && !tc.is_error && !tc.expanded
}
pub fn tool_calls_compact_height(tcs: &[DisplayToolCall], width: u16) -> u16 {
let mut h: u16 = 0;
let mut i = 0;
while i < tcs.len() {
let tc = &tcs[i];
if is_compactable(tc) {
let group_start = i;
while i < tcs.len() && is_compactable(&tcs[i]) {
i += 1;
}
h += compact_group_line_count(&tcs[group_start..i], width);
} else {
h += tool_call_height(tc);
i += 1;
}
}
h
}
fn compact_group_line_count(tcs: &[DisplayToolCall], width: u16) -> u16 {
if tcs.is_empty() {
return 0;
}
let usable = (width as usize).saturating_sub(4); if usable == 0 {
return tcs.len() as u16;
}
let mut lines: u16 = 1;
let mut col: usize = 0;
for tc in tcs {
let span_len = compact_span_width(tc);
if col > 0 && col + 2 + span_len > usable {
lines += 1;
col = span_len;
} else if col > 0 {
col += 2 + span_len; } else {
col = span_len;
}
}
lines
}
fn compact_span_width(tc: &DisplayToolCall) -> usize {
let args_short = short_args(&tc.args_summary);
let w = 2 + tc.name.len(); if args_short.is_empty() {
w
} else {
w + 1 + args_short.len()
}
}
fn short_args(args: &str) -> String {
if args.is_empty() {
return String::new();
}
if args.contains('/') {
if let Some(name) = args.rsplit('/').next() {
if !name.is_empty() {
return name.to_string();
}
}
}
if let Some(cmd) = args.strip_prefix("$ ") {
let short = if cmd.len() > 20 {
format!("$ {}", truncate_chars_with_suffix(cmd, 17, "…"))
} else {
format!("$ {cmd}")
};
return short;
}
if args.len() <= 24 {
return args.to_string();
}
truncate_chars_with_suffix(args, 21, "…")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tc(name: &str, args: &str, output: Option<&str>, is_error: bool) -> DisplayToolCall {
DisplayToolCall {
id: "test".into(),
name: name.into(),
args_summary: args.into(),
output: output.map(String::from),
details: serde_json::Value::Null,
is_error,
expanded: false,
streaming_lines: Vec::new(),
streaming_output: String::new(),
}
}
#[test]
fn make_args_summary_formats_mana_compactly() {
let summary = DisplayToolCall::make_args_summary(
"mana",
&serde_json::json!({
"action": "create",
"title": "Fix hotkeys",
"priority": 1,
"verify": "cargo check -p imp-tui",
"deps": "1.2,1.3"
}),
);
assert!(summary.starts_with("create "));
assert!(summary.contains("title Fix hotkeys"));
assert!(summary.contains("priority 1"));
assert!(summary.contains("verify cargo check -p imp-tui"));
assert!(summary.contains("deps 1.2,1.3"));
}
#[test]
fn compactable_completed_success() {
let tc = make_tc("read", "file.rs", Some("contents"), false);
assert!(is_compactable(&tc));
}
#[test]
fn not_compactable_running() {
let tc = make_tc("read", "file.rs", None, false);
assert!(!is_compactable(&tc));
}
#[test]
fn not_compactable_error() {
let tc = make_tc("read", "file.rs", Some("err"), true);
assert!(!is_compactable(&tc));
}
#[test]
fn not_compactable_expanded() {
let mut tc = make_tc("read", "file.rs", Some("data"), false);
tc.expanded = true;
assert!(!is_compactable(&tc));
}
#[test]
fn short_args_path() {
assert_eq!(short_args("src/views/tools.rs"), "tools.rs");
}
#[test]
fn short_args_bash() {
assert_eq!(short_args("check"), "check");
}
#[test]
fn short_args_bash_short() {
assert_eq!(short_args("run"), "run");
}
#[test]
fn short_args_empty() {
assert_eq!(short_args(""), "");
}
#[test]
fn short_args_short_text() {
assert_eq!(short_args("pattern"), "pattern");
}
#[test]
fn abbreviates_user_home_paths() {
assert_eq!(
abbreviate_home_path("/Users/test/src/main.rs"),
"~/src/main.rs"
);
assert_eq!(
abbreviate_home_path("/home/test/src/main.rs"),
"~/src/main.rs"
);
}
#[test]
fn make_args_summary_hides_bash_command_text() {
let summary = DisplayToolCall::make_args_summary(
"bash",
&serde_json::json!({"command": "cargo test -p imp-tui"}),
);
assert_eq!(summary, "check");
}
#[test]
fn make_args_summary_abbreviates_scan_directory() {
let summary = DisplayToolCall::make_args_summary(
"scan",
&serde_json::json!({"action": "scan", "directory": "/Users/test/project"}),
);
assert_eq!(summary, "~/project");
}
#[test]
fn compact_group_fits_one_line() {
let tcs = vec![
make_tc("read", "file.rs", Some("ok"), false),
make_tc("bash", "$ grep foo .", Some("ok"), false),
];
assert_eq!(compact_group_line_count(&tcs, 80), 1);
}
#[test]
fn compact_group_wraps() {
let tcs: Vec<_> = (0..10)
.map(|i| {
make_tc(
"read",
&format!("long/path/to/file_{i}.rs"),
Some("ok"),
false,
)
})
.collect();
let lines = compact_group_line_count(&tcs, 80);
assert!(lines > 1);
assert!(lines < 10);
}
#[test]
fn compact_height_mixed() {
let tcs = vec![
make_tc("read", "a.rs", Some("ok"), false),
make_tc("read", "b.rs", Some("ok"), false),
make_tc("bash", "$ cmd", None, false), make_tc("read", "c.rs", Some("ok"), false),
];
let h = tool_calls_compact_height(&tcs, 80);
assert_eq!(h, 3);
}
#[test]
fn compact_height_all_compactable() {
let tcs = vec![
make_tc("read", "a.rs", Some("ok"), false),
make_tc("bash", "$ grep foo .", Some("ok"), false),
make_tc("edit", "b.rs", Some("ok"), false),
];
let h = tool_calls_compact_height(&tcs, 80);
assert_eq!(h, 1);
}
}