From a03802af433e3d2faf04f6a0ad610398531484e2 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Fri, 3 Jul 2026 16:34:38 +0900 Subject: [PATCH 1/5] feat: add call_sig helpers for tool display strings --- crates/goat-tool/src/display.rs | 67 +++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/crates/goat-tool/src/display.rs b/crates/goat-tool/src/display.rs index 7e989d7..fc1abd9 100644 --- a/crates/goat-tool/src/display.rs +++ b/crates/goat-tool/src/display.rs @@ -9,6 +9,26 @@ pub fn flatten(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } +pub fn format_arg(s: &str) -> String { + let needs = + s.is_empty() || s.chars().any(char::is_whitespace) || s.contains('"') || s.contains('\''); + if needs { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") + } else { + s.to_owned() + } +} + +pub fn call_sig(name: &str, args: &[&str]) -> String { + if args.is_empty() { + format!("{name}()") + } else { + let inner: Vec = args.iter().map(|a| format_arg(a)).collect(); + format!("{name}({})", inner.join(", ")) + } +} + const PRIORITY_KEYS: [&str; 8] = [ "path", "file_path", @@ -21,6 +41,10 @@ const PRIORITY_KEYS: [&str; 8] = [ ]; pub fn generic(input: &str) -> ToolDisplay { + generic_named("", input) +} + +pub fn generic_named(tool_name: &str, input: &str) -> ToolDisplay { let Ok(Value::Object(map)) = serde_json::from_str::(input) else { return raw(input); }; @@ -43,15 +67,21 @@ pub fn generic(input: &str) -> ToolDisplay { parts.push(text); } } - let mut iter = parts.into_iter(); - let Some(primary) = iter.next() else { + if parts.is_empty() { return raw(input); - }; - let rest: Vec = iter.collect(); - if rest.is_empty() { - ToolDisplay::primary(primary) + } + let refs: Vec<&str> = parts.iter().map(String::as_str).collect(); + if tool_name.is_empty() { + let mut iter = parts.into_iter(); + let primary = iter.next().unwrap_or_default(); + let rest: Vec = iter.collect(); + if rest.is_empty() { + ToolDisplay::primary(primary) + } else { + ToolDisplay::with_detail(primary, rest.join(", ")) + } } else { - ToolDisplay::with_detail(primary, rest.join(" · ")) + ToolDisplay::primary(call_sig(tool_name, &refs)) } } @@ -68,7 +98,28 @@ fn scalar_text(value: &Value) -> Option { mod tests { use goat_protocol::ToolDisplay; - use super::generic; + use super::{call_sig, format_arg, generic, generic_named}; + + #[test] + fn generic_named_builds_call_sig() { + let got = generic_named("Glob", r#"{"pattern":"**/symbols*"}"#); + assert_eq!(got.primary, "Glob(**/symbols*)"); + } + + #[test] + fn format_arg_quotes_spaces() { + assert_eq!(format_arg("a b"), "\"a b\""); + assert_eq!(format_arg("path/to"), "path/to"); + } + + #[test] + fn call_sig_joins_args() { + assert_eq!(call_sig("Read", &["a.txt"]), "Read(a.txt)"); + assert_eq!( + call_sig("Read", &["/Users/jmo", "10", "20"]), + "Read(/Users/jmo, 10, 20)" + ); + } #[test] fn generic_prefers_priority_keys() { From a637384a8679fb4726980708c713bfcfc9f7bb64 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Fri, 3 Jul 2026 16:34:44 +0900 Subject: [PATCH 2/5] feat: use call_sig for tool transcript display inputs --- crates/goat-agent/src/ask.rs | 14 ++++++--- crates/goat-agent/src/delegate.rs | 8 +++-- crates/goat-agent/src/tools_exec.rs | 2 +- crates/goat-agent/src/websearch.rs | 7 +++-- crates/goat-tool-fs/src/tools/edit.rs | 5 ++- crates/goat-tool-fs/src/tools/read.rs | 9 ++++-- crates/goat-tool-fs/src/tools/write.rs | 5 ++- crates/goat-tool-search/src/glob.rs | 43 +++++++++++++++++++++++--- crates/goat-tool-search/src/grep.rs | 17 ++++------ crates/goat-tool-shell/src/bash.rs | 5 ++- crates/goat-tool-web/src/lib.rs | 5 ++- 11 files changed, 87 insertions(+), 33 deletions(-) diff --git a/crates/goat-agent/src/ask.rs b/crates/goat-agent/src/ask.rs index 78d95c7..544ff5f 100644 --- a/crates/goat-agent/src/ask.rs +++ b/crates/goat-agent/src/ask.rs @@ -54,16 +54,20 @@ pub(crate) fn ask_call_display(input: &str) -> ToolDisplay { questions: Vec, } let Ok(args) = serde_json::from_str::(input) else { - return goat_tool::display::generic(input); + return goat_tool::display::generic_named(ASK_TOOL_NAME, input); }; let Some(first) = args.questions.first() else { - return goat_tool::display::generic(input); + return goat_tool::display::generic_named(ASK_TOOL_NAME, input); }; - let primary = goat_tool::display::flatten(&first.question); + let q = goat_tool::display::flatten(&first.question); if args.questions.len() > 1 { - ToolDisplay::with_detail(primary, format!("+{} more", args.questions.len() - 1)) + let more = format!("+{} more", args.questions.len() - 1); + ToolDisplay::primary(goat_tool::display::call_sig( + ASK_TOOL_NAME, + &[q.as_str(), more.as_str()], + )) } else { - ToolDisplay::primary(primary) + ToolDisplay::primary(goat_tool::display::call_sig(ASK_TOOL_NAME, &[q.as_str()])) } } diff --git a/crates/goat-agent/src/delegate.rs b/crates/goat-agent/src/delegate.rs index e8285e7..3b3b590 100644 --- a/crates/goat-agent/src/delegate.rs +++ b/crates/goat-agent/src/delegate.rs @@ -54,9 +54,13 @@ pub(crate) fn agent_call_display(input: &str) -> ToolDisplay { } match serde_json::from_str::(input) { Ok(args) => { - ToolDisplay::with_detail(args.agent_type, goat_tool::display::flatten(&args.prompt)) + let prompt = goat_tool::display::flatten(&args.prompt); + ToolDisplay::primary(goat_tool::display::call_sig( + AGENT_TOOL_NAME, + &[args.agent_type.as_str(), prompt.as_str()], + )) } - Err(_) => goat_tool::display::generic(input), + Err(_) => goat_tool::display::generic_named(AGENT_TOOL_NAME, input), } } diff --git a/crates/goat-agent/src/tools_exec.rs b/crates/goat-agent/src/tools_exec.rs index 06eb1f9..ebdfbdc 100644 --- a/crates/goat-agent/src/tools_exec.rs +++ b/crates/goat-agent/src/tools_exec.rs @@ -66,7 +66,7 @@ pub(crate) fn call_display(tools: &ToolRegistry, name: &str, input: &str) -> Too ASK_TOOL_NAME => ask_call_display(input), WEB_SEARCH_TOOL_NAME => web_search_display(input), _ => tools.get(name).map_or_else( - || goat_tool::display::generic(input), + || goat_tool::display::generic_named(name, input), |tool| tool.display_input(input), ), } diff --git a/crates/goat-agent/src/websearch.rs b/crates/goat-agent/src/websearch.rs index 866ca5f..7946ecc 100644 --- a/crates/goat-agent/src/websearch.rs +++ b/crates/goat-agent/src/websearch.rs @@ -24,8 +24,11 @@ pub(crate) fn web_search_display(input: &str) -> ToolDisplay { query: String, } match serde_json::from_str::(input) { - Ok(args) => ToolDisplay::primary(goat_tool::display::flatten(&args.query)), - Err(_) => goat_tool::display::generic(input), + Ok(args) => ToolDisplay::primary(goat_tool::display::call_sig( + WEB_SEARCH_TOOL_NAME, + &[args.query.as_str()], + )), + Err(_) => goat_tool::display::generic_named(WEB_SEARCH_TOOL_NAME, input), } } diff --git a/crates/goat-tool-fs/src/tools/edit.rs b/crates/goat-tool-fs/src/tools/edit.rs index ab46384..e565722 100644 --- a/crates/goat-tool-fs/src/tools/edit.rs +++ b/crates/goat-tool-fs/src/tools/edit.rs @@ -39,7 +39,10 @@ impl Tool for EditTool { fn display_input(&self, input: &str) -> ToolDisplay { match serde_json::from_str::(input) { - Ok(args) => ToolDisplay::primary(display::flatten(&args.path)), + Ok(args) => ToolDisplay::primary(display::call_sig( + "Edit", + &[display::flatten(&args.path).as_str()], + )), Err(_) => display::generic(input), } } diff --git a/crates/goat-tool-fs/src/tools/read.rs b/crates/goat-tool-fs/src/tools/read.rs index fd8b1b0..5b73db2 100644 --- a/crates/goat-tool-fs/src/tools/read.rs +++ b/crates/goat-tool-fs/src/tools/read.rs @@ -36,7 +36,10 @@ impl Tool for ReadTool { fn display_input(&self, input: &str) -> ToolDisplay { match serde_json::from_str::(input) { - Ok(args) => ToolDisplay::primary(display::flatten(&args.path)), + Ok(args) => ToolDisplay::primary(display::call_sig( + "Read", + &[display::flatten(&args.path).as_str()], + )), Err(_) => display::generic(input), } } @@ -118,9 +121,9 @@ mod tests { } #[test] - fn display_shows_path_without_range() { + fn display_shows_call_signature() { let display = ReadTool.display_input(r#"{"path":"a.txt","offset":120,"limit":50}"#); - assert_eq!(display.primary, "a.txt"); + assert_eq!(display.primary, "Read(a.txt)"); assert_eq!(display.detail, None); } diff --git a/crates/goat-tool-fs/src/tools/write.rs b/crates/goat-tool-fs/src/tools/write.rs index 4968037..195204c 100644 --- a/crates/goat-tool-fs/src/tools/write.rs +++ b/crates/goat-tool-fs/src/tools/write.rs @@ -34,7 +34,10 @@ impl Tool for WriteTool { fn display_input(&self, input: &str) -> ToolDisplay { match serde_json::from_str::(input) { - Ok(args) => ToolDisplay::primary(display::flatten(&args.path)), + Ok(args) => ToolDisplay::primary(display::call_sig( + "Write", + &[display::flatten(&args.path).as_str()], + )), Err(_) => display::generic(input), } } diff --git a/crates/goat-tool-search/src/glob.rs b/crates/goat-tool-search/src/glob.rs index c279964..8c1abad 100644 --- a/crates/goat-tool-search/src/glob.rs +++ b/crates/goat-tool-search/src/glob.rs @@ -42,10 +42,11 @@ impl Tool for GlobTool { return goat_tool::display::generic(input); }; let pattern = goat_tool::display::flatten(&args.pattern); - match args.path.filter(|p| !p.is_empty() && p != ".") { - Some(path) => goat_protocol::ToolDisplay::with_detail(pattern, path), - None => goat_protocol::ToolDisplay::primary(pattern), - } + let sig = match args.path.filter(|p| !p.is_empty() && p != ".") { + Some(path) => goat_tool::display::call_sig("Glob", &[pattern.as_str(), path.as_str()]), + None => goat_tool::display::call_sig("Glob", &[pattern.as_str()]), + }; + goat_protocol::ToolDisplay::primary(sig) } fn run<'a>(&'a self, input: &'a str, ctx: &'a ToolContext) -> ToolFuture<'a> { @@ -85,6 +86,7 @@ fn walk( let mut found = Vec::new(); let mut builder = WalkBuilder::new(root); builder.require_git(false); + builder.hidden(false); let blocked_for_walk = blocked.to_vec(); builder.filter_entry(move |entry| !blocked_path(&blocked_for_walk, entry.path())); for entry in builder.build() { @@ -153,7 +155,7 @@ mod tests { fn display_omits_trivial_scope() { use goat_tool::Tool; let display = GlobTool.display_input(r#"{"pattern":"*.rs","path":"."}"#); - assert_eq!(display.primary, "*.rs"); + assert_eq!(display.primary, "Glob(*.rs)"); assert_eq!(display.detail, None); } @@ -197,4 +199,35 @@ mod tests { Err(goat_tool::ToolError::PathBlocked { .. }) )); } + + #[tokio::test] + async fn lists_dot_github_in_real_repo_layout() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".github/workflows")).unwrap(); + std::fs::write(dir.path().join(".github/workflows/ci.yml"), "").unwrap(); + let ctx = ctx(dir.path()); + for pat in ["**/.github/**", ".github/**/*", ".github/workflows/*.yml"] { + let out = GlobTool + .run(&format!(r#"{{"pattern":"{pat}"}}"#), &ctx) + .await + .unwrap_or_else(|e| panic!("{pat}: {e:?}")); + let text = out.as_text().unwrap(); + assert!( + text.contains("ci.yml") || text.contains(".github"), + "{pat} got: {text}" + ); + } + } + + #[test] + fn github_style_patterns_parse() { + use ignore::overrides::OverrideBuilder; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let root = root.canonicalize().unwrap(); + for pat in ["**/.github/**", ".github/**/*", "**/*depend*"] { + let mut o = OverrideBuilder::new(&root); + o.add(pat).unwrap_or_else(|e| panic!("add {pat}: {e:?}")); + o.build().unwrap_or_else(|e| panic!("build {pat}: {e:?}")); + } + } } diff --git a/crates/goat-tool-search/src/grep.rs b/crates/goat-tool-search/src/grep.rs index 54b9c74..13dcf4a 100644 --- a/crates/goat-tool-search/src/grep.rs +++ b/crates/goat-tool-search/src/grep.rs @@ -50,19 +50,13 @@ impl Tool for GrepTool { let Ok(args) = serde_json::from_str::(input) else { return goat_tool::display::generic(input); }; - let scope: Vec = [ - args.path.filter(|p| !p.is_empty() && p != "."), - args.glob.filter(|g| !g.is_empty() && g != "*"), - ] - .into_iter() - .flatten() - .collect(); let pattern = goat_tool::display::flatten(&args.pattern); - if scope.is_empty() { - goat_protocol::ToolDisplay::primary(pattern) - } else { - goat_protocol::ToolDisplay::with_detail(pattern, scope.join(" · ")) + let mut params = vec![pattern]; + if let Some(path) = args.path.filter(|p| !p.is_empty() && p != ".") { + params.push(path); } + let refs: Vec<&str> = params.iter().map(String::as_str).collect(); + goat_protocol::ToolDisplay::primary(goat_tool::display::call_sig("Grep", &refs)) } fn run<'a>(&'a self, input: &'a str, ctx: &'a ToolContext) -> ToolFuture<'a> { @@ -116,6 +110,7 @@ fn search( .build()?; let mut builder = WalkBuilder::new(root); builder.require_git(false); + builder.hidden(false); let blocked_for_walk = blocked.to_vec(); builder.filter_entry(move |entry| !blocked_path(&blocked_for_walk, entry.path())); let matcher = match glob { diff --git a/crates/goat-tool-shell/src/bash.rs b/crates/goat-tool-shell/src/bash.rs index d0d81a1..3899ac6 100644 --- a/crates/goat-tool-shell/src/bash.rs +++ b/crates/goat-tool-shell/src/bash.rs @@ -71,7 +71,10 @@ impl Tool for BashTool { fn display_input(&self, input: &str) -> ToolDisplay { match serde_json::from_str::(input) { - Ok(args) => ToolDisplay::primary(display::flatten(&args.command)), + Ok(args) => ToolDisplay::primary(display::call_sig( + "Bash", + &[display::flatten(&args.command).as_str()], + )), Err(_) => display::generic(input), } } diff --git a/crates/goat-tool-web/src/lib.rs b/crates/goat-tool-web/src/lib.rs index 24813ba..3260ee7 100644 --- a/crates/goat-tool-web/src/lib.rs +++ b/crates/goat-tool-web/src/lib.rs @@ -47,7 +47,10 @@ impl Tool for WebFetchTool { fn display_input(&self, input: &str) -> ToolDisplay { match serde_json::from_str::(input) { - Ok(args) => ToolDisplay::primary(display::flatten(&args.url)), + Ok(args) => ToolDisplay::primary(display::call_sig( + "WebFetch", + &[display::flatten(&args.url).as_str()], + )), Err(_) => display::generic(input), } } From ff4720ff3511b0b882e1543f37d0d49ec528f4a3 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Fri, 3 Jul 2026 16:35:00 +0900 Subject: [PATCH 3/5] refactor: split tui theme into palette module --- crates/goat-tui/src/highlight.rs | 3 +- crates/goat-tui/src/theme/mod.rs | 3 + .../src/{theme.rs => theme/palette.rs} | 179 +++++++++++------- 3 files changed, 112 insertions(+), 73 deletions(-) create mode 100644 crates/goat-tui/src/theme/mod.rs rename crates/goat-tui/src/{theme.rs => theme/palette.rs} (52%) diff --git a/crates/goat-tui/src/highlight.rs b/crates/goat-tui/src/highlight.rs index 8eae7db..a314497 100644 --- a/crates/goat-tui/src/highlight.rs +++ b/crates/goat-tui/src/highlight.rs @@ -57,7 +57,8 @@ impl SyntectHighlighter { { return built.clone(); } - let built = palette_to_syntect_theme(&theme.code, theme.fg_color(), theme.code.bg); + let code = theme.code(); + let built = palette_to_syntect_theme(&code, theme.fg_color(), code.bg); *guard = Some((id, built.clone())); built } diff --git a/crates/goat-tui/src/theme/mod.rs b/crates/goat-tui/src/theme/mod.rs new file mode 100644 index 0000000..e0cdeac --- /dev/null +++ b/crates/goat-tui/src/theme/mod.rs @@ -0,0 +1,3 @@ +mod palette; + +pub use palette::{CodePalette, Theme}; diff --git a/crates/goat-tui/src/theme.rs b/crates/goat-tui/src/theme/palette.rs similarity index 52% rename from crates/goat-tui/src/theme.rs rename to crates/goat-tui/src/theme/palette.rs index 59e84df..2951d0a 100644 --- a/crates/goat-tui/src/theme.rs +++ b/crates/goat-tui/src/theme/palette.rs @@ -1,4 +1,4 @@ -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::Color; use crate::layout::{METER_HIGH, METER_WARN}; @@ -18,38 +18,32 @@ pub struct CodePalette { } #[derive(Debug, Clone, Copy)] -pub struct Theme { - id: u8, - bg: Color, - fg: Color, - dark: bool, - user: Color, - user_panel: Color, - agent: Color, - tool: Color, - error: Color, - muted: Color, - accent: Color, - success: Color, - border: Color, - border_dim: Color, - panel: Color, - shell: Color, - shell_dim: Color, +pub struct Palette { + pub id: u8, + pub bg: Color, + pub fg: Color, + pub dark: bool, + pub user: Color, + pub user_panel: Color, + pub agent: Color, + pub tool: Color, + pub error: Color, + pub muted: Color, + pub accent: Color, + pub success: Color, + pub border: Color, + pub border_dim: Color, + pub panel: Color, + pub shell: Color, + pub shell_dim: Color, pub code: CodePalette, } -impl Default for Theme { - fn default() -> Self { - Self::dark() - } -} - -impl Theme { +impl Palette { pub const fn dark() -> Self { Self { id: 1, - bg: Color::Reset, + bg: Color::Rgb(0, 0, 0), dark: true, fg: Color::Rgb(0xd7, 0xda, 0xe0), user: Color::Rgb(0x7d, 0x9b, 0xd4), @@ -115,72 +109,108 @@ impl Theme { }, } } +} - pub fn base(self) -> Style { - Style::new().fg(self.fg).bg(self.bg) +#[derive(Debug, Clone, Copy)] +pub struct Theme { + palette: Palette, +} + +impl Default for Theme { + fn default() -> Self { + Self::dark() } +} - pub fn text(self) -> Style { - Style::new().fg(self.fg) +impl Theme { + pub const fn dark() -> Self { + Self { + palette: Palette::dark(), + } + } + + pub const fn light() -> Self { + Self { + palette: Palette::light(), + } + } + + pub fn base(self) -> ratatui::style::Style { + let p = self.palette; + ratatui::style::Style::new().fg(p.fg).bg(p.bg) + } + + pub fn text(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.fg) + } + + pub fn muted(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.muted) } - pub fn muted(self) -> Style { - Style::new().fg(self.muted) + pub fn key(self) -> ratatui::style::Style { + ratatui::style::Style::new() + .fg(self.palette.fg) + .add_modifier(ratatui::style::Modifier::BOLD) } - pub fn key(self) -> Style { - Style::new().fg(self.fg).add_modifier(Modifier::BOLD) + pub fn accent(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.accent) } - pub fn accent(self) -> Style { - Style::new().fg(self.accent) + pub fn border(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.border) } - pub fn border(self) -> Style { - Style::new().fg(self.border) + pub fn border_dim(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.border_dim) } - pub fn border_dim(self) -> Style { - Style::new().fg(self.border_dim) + pub fn shell(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.shell) } - pub fn shell(self) -> Style { - Style::new().fg(self.shell) + pub fn shell_dim(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.shell_dim) } - pub fn shell_dim(self) -> Style { - Style::new().fg(self.shell_dim) + pub fn role_user(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.user) } - pub fn role_user(self) -> Style { - Style::new().fg(self.user) + pub fn user_panel(self) -> ratatui::style::Style { + ratatui::style::Style::new().bg(self.palette.user_panel) } - pub fn user_panel(self) -> Style { - Style::new().bg(self.user_panel) + pub fn role_agent(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.agent) } - pub fn role_agent(self) -> Style { - Style::new().fg(self.agent) + pub fn role_tool(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.tool) } - pub fn role_tool(self) -> Style { - Style::new().fg(self.tool) + pub fn tool_fn(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.accent) } - pub fn error(self) -> Style { - Style::new().fg(self.error) + pub fn tool_arg_value(self) -> ratatui::style::Style { + self.muted() } - pub fn success(self) -> Style { - Style::new().fg(self.success) + pub fn error(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.error) } - pub fn error_body(self) -> Style { - Style::new().fg(self.error) + pub fn success(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.success) } - pub fn meter(self, pct: f32) -> Style { + pub fn error_body(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.error) + } + + pub fn meter(self, pct: f32) -> ratatui::style::Style { if pct >= METER_HIGH { self.error() } else if pct >= METER_WARN { @@ -190,35 +220,40 @@ impl Theme { } } - pub fn code_plain(self) -> Style { - Style::new().fg(self.fg) + pub fn code_plain(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.fg) } - pub fn inline_code(self) -> Style { - Style::new().fg(self.accent) + pub fn inline_code(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.accent) } pub fn fg_color(self) -> Color { - self.fg + self.palette.fg } pub fn accent_color(self) -> Color { - self.accent + self.palette.accent } - pub fn hint_key(self) -> Style { - Style::new().fg(self.fg) + pub fn hint_key(self) -> ratatui::style::Style { + ratatui::style::Style::new().fg(self.palette.fg) } - pub fn panel(self) -> Style { - Style::new().fg(self.fg).bg(self.panel) + pub fn panel(self) -> ratatui::style::Style { + let p = self.palette; + ratatui::style::Style::new().fg(p.fg).bg(p.panel) } pub fn is_dark(self) -> bool { - self.dark + self.palette.dark } pub fn id(self) -> u8 { - self.id + self.palette.id + } + + pub fn code(self) -> CodePalette { + self.palette.code } } From bef718c3dd1aedc65b813dfc83f8bc7e74fdb1ee Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Fri, 3 Jul 2026 16:35:04 +0900 Subject: [PATCH 4/5] feat: effort choices in slash command menu --- crates/goat-command-settings/src/effort.rs | 15 +-- crates/goat-commands/src/lib.rs | 2 +- crates/goat-tui/src/app/keys.rs | 14 ++- crates/goat-tui/src/command.rs | 107 +++++++++++++++++++-- crates/goat-tui/src/picker/effort.rs | 23 +++++ 5 files changed, 133 insertions(+), 28 deletions(-) diff --git a/crates/goat-command-settings/src/effort.rs b/crates/goat-command-settings/src/effort.rs index b105952..f071a9a 100644 --- a/crates/goat-command-settings/src/effort.rs +++ b/crates/goat-command-settings/src/effort.rs @@ -1,6 +1,5 @@ use goat_command::{ - ChoiceSpec, Command, CommandEffect, CommandInvocation, CommandShape, ParameterSpec, - ParameterValue, + Command, CommandEffect, CommandInvocation, CommandShape, ParameterSpec, ParameterValue, }; pub struct Effort; @@ -19,20 +18,12 @@ impl Command for Effort { name: "level".to_owned(), description: "reasoning effort level".to_owned(), required: false, - value: ParameterValue::Choice( - ["off", "low", "medium", "high", "xhigh", "max"] - .into_iter() - .map(|value| ChoiceSpec { - value: value.to_owned(), - description: None, - }) - .collect(), - ), + value: ParameterValue::Word, }]) } fn run(&self, invocation: CommandInvocation) -> CommandEffect { - if let Some(level) = invocation.choice("level") { + if let Some(level) = invocation.text("level") { CommandEffect::SelectEffort(level.to_ascii_lowercase()) } else { CommandEffect::OpenEffortPicker diff --git a/crates/goat-commands/src/lib.rs b/crates/goat-commands/src/lib.rs index 7a5b035..ea198b6 100644 --- a/crates/goat-commands/src/lib.rs +++ b/crates/goat-commands/src/lib.rs @@ -350,7 +350,7 @@ mod tests { let CommandShape::Parameters(parameters) = effort.shape else { panic!("expected parameters"); }; - assert!(matches!(parameters[0].value, ParameterValue::Choice(_))); + assert!(matches!(parameters[0].value, ParameterValue::Word)); } #[test] diff --git a/crates/goat-tui/src/app/keys.rs b/crates/goat-tui/src/app/keys.rs index 55a2099..06c7aff 100644 --- a/crates/goat-tui/src/app/keys.rs +++ b/crates/goat-tui/src/app/keys.rs @@ -347,11 +347,15 @@ impl App { } } KeyCode::Enter => { - if let Overlay::Effort(picker) = &self.overlay - && let EffortOutcome::Selected(effort) = picker.choose() - { - self.overlay = Overlay::None; - return self.apply_effort(effort); + if let Overlay::Effort(picker) = &self.overlay { + if picker.is_empty() { + self.overlay = Overlay::None; + return Vec::new(); + } + if let EffortOutcome::Selected(effort) = picker.choose() { + self.overlay = Overlay::None; + return self.apply_effort(effort); + } } } _ => {} diff --git a/crates/goat-tui/src/command.rs b/crates/goat-tui/src/command.rs index 0b260f9..72eb48a 100644 --- a/crates/goat-tui/src/command.rs +++ b/crates/goat-tui/src/command.rs @@ -2,6 +2,7 @@ use goat_commands::{ BranchSpec, ChoiceSpec, CommandRegistry, CommandShape, CommandSpec, ParameterSpec, ParameterValue, }; +use goat_protocol::Effort; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, @@ -67,6 +68,11 @@ enum Mode { Context(Vec), } +#[derive(Clone, Copy)] +pub struct CommandMenuContext<'a> { + pub effort_choices: Option<&'a [Effort]>, +} + pub struct CommandMenu { cursor: usize, mode: Mode, @@ -82,14 +88,14 @@ struct SlashParts<'a> { } impl CommandMenu { - pub fn new(registry: &CommandRegistry, input: &str) -> Self { + pub fn new(registry: &CommandRegistry, input: &str, ctx: &CommandMenuContext<'_>) -> Self { Self { cursor: 0, - mode: Self::compute_mode(registry, input), + mode: Self::compute_mode(registry, input, *ctx), } } - fn compute_mode(registry: &CommandRegistry, input: &str) -> Mode { + fn compute_mode(registry: &CommandRegistry, input: &str, ctx: CommandMenuContext<'_>) -> Mode { let Some(parts) = slash_parts(input) else { return Mode::Context(vec![hint("type a slash command")]); }; @@ -99,7 +105,7 @@ impl CommandMenu { let Some(spec) = registry.spec(parts.command) else { return Mode::Context(vec![hint(format!("unknown command: /{}", parts.command))]); }; - Mode::Context(context_rows(&spec, &parts)) + Mode::Context(context_rows(&spec, &parts, ctx)) } fn compute_command_matches(registry: &CommandRegistry, parts: &SlashParts<'_>) -> Vec { @@ -129,8 +135,13 @@ impl CommandMenu { .collect() } - pub fn update(&mut self, registry: &CommandRegistry, input: &str) { - self.mode = Self::compute_mode(registry, input); + pub fn update( + &mut self, + registry: &CommandRegistry, + input: &str, + ctx: &CommandMenuContext<'_>, + ) { + self.mode = Self::compute_mode(registry, input, *ctx); let len = self.rows().len(); if self.cursor >= len { self.cursor = len.saturating_sub(1); @@ -281,7 +292,14 @@ fn slash_parts(input: &str) -> Option> { }) } -fn context_rows(spec: &CommandSpec, parts: &SlashParts<'_>) -> Vec { +fn context_rows( + spec: &CommandSpec, + parts: &SlashParts<'_>, + ctx: CommandMenuContext<'_>, +) -> Vec { + if spec.name == "effort" { + return effort_context_rows(parts, ctx.effort_choices); + } match &spec.shape { CommandShape::Empty => vec![hint(spec.usage())], CommandShape::Parameters(parameters) => { @@ -342,6 +360,55 @@ fn parameter_rows(parameters: &[ParameterSpec], args: &str, absolute_start: usiz vec![hint("Enter to run")] } +fn effort_context_rows(parts: &SlashParts<'_>, choices: Option<&[Effort]>) -> Vec { + let Some(supported) = choices else { + return vec![hint("select a model first")]; + }; + if supported.is_empty() { + return vec![hint("this model does not support reasoning effort")]; + } + let specs: Vec = supported + .iter() + .map(|e| ChoiceSpec { + value: e.as_str().to_owned(), + description: None, + }) + .collect(); + let tokens = token_spans(parts.args); + let (query, start, end) = if let Some((s, e)) = tokens.first().copied() { + ( + &parts.args[s..e], + parts.args_start + s, + parts.args_start + e, + ) + } else { + ( + "", + parts.args_start + parts.args.len(), + parts.args_start + parts.args.len(), + ) + }; + let rows = choice_rows(&specs, query, start, end); + if rows.is_empty() && !query.is_empty() { + vec![hint(format!( + "no matching effort (supported: {})", + effort_list(supported) + ))] + } else if rows.is_empty() { + choice_rows(&specs, "", start, end) + } else { + rows + } +} + +fn effort_list(efforts: &[Effort]) -> String { + efforts + .iter() + .map(|e| e.as_str()) + .collect::>() + .join(", ") +} + fn branch_rows(branches: &[BranchSpec], query: &str, start: usize, end: usize) -> Vec { branches .iter() @@ -439,14 +506,21 @@ fn token_spans(args: &str) -> Vec<(usize, usize)> { mod tests { use unicode_width::UnicodeWidthStr; - use super::CommandMenu; + use super::{CommandMenu, CommandMenuContext}; use crate::overlay::truncate_to_width; use goat_commands::CommandRegistry; + use goat_protocol::Effort; + + fn empty_ctx() -> CommandMenuContext<'static> { + CommandMenuContext { + effort_choices: None, + } + } #[test] fn command_completion_replaces_prefix() { let registry = CommandRegistry::builtin(); - let menu = CommandMenu::new(®istry, "/eff"); + let menu = CommandMenu::new(®istry, "/eff", &empty_ctx()); let completion = menu.selected_completion().unwrap(); assert_eq!(completion.apply("/eff"), "/effort "); } @@ -454,7 +528,20 @@ mod tests { #[test] fn choice_completion_replaces_argument_token() { let registry = CommandRegistry::builtin(); - let menu = CommandMenu::new(®istry, "/effort h"); + let supported = [ + Effort::Low, + Effort::Medium, + Effort::High, + Effort::Xhigh, + Effort::Max, + ]; + let menu = CommandMenu::new( + ®istry, + "/effort h", + &CommandMenuContext { + effort_choices: Some(&supported), + }, + ); let completion = menu.selected_completion().unwrap(); assert_eq!(completion.apply("/effort h"), "/effort high"); } diff --git a/crates/goat-tui/src/picker/effort.rs b/crates/goat-tui/src/picker/effort.rs index 4721739..e7e95c2 100644 --- a/crates/goat-tui/src/picker/effort.rs +++ b/crates/goat-tui/src/picker/effort.rs @@ -23,10 +23,16 @@ pub struct EffortPicker { options: Vec, cursor: usize, scroll: usize, + empty_message: Option, } impl EffortPicker { pub fn new(label: String, options: Vec, current: Option) -> Self { + let empty_message = if options.is_empty() { + Some("This model does not support reasoning effort.".to_owned()) + } else { + None + }; let cursor = current .and_then(|cur| options.iter().position(|opt| *opt == cur)) .unwrap_or(0); @@ -35,9 +41,14 @@ impl EffortPicker { options, cursor, scroll: 0, + empty_message, } } + pub fn is_empty(&self) -> bool { + self.options.is_empty() + } + fn cap(&self) -> usize { self.options.len().min(LIST_MAX) } @@ -90,6 +101,18 @@ impl EffortPicker { context_area, ); + if let Some(msg) = &self.empty_message { + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {msg}"), theme.muted()))), + list_area, + ); + frame.render_widget( + Paragraph::new(hint_line(&[(symbols::key::ESC, "close")], theme)), + hint_area, + ); + return; + } + let width = usize::from(list_area.width); let rows = usize::from(list_area.height).max(1); let scroll = self.scroll.min(self.cursor); From e30ff3c5e81abd2da507dbec5fe1a0da09ce3f95 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Fri, 3 Jul 2026 16:35:08 +0900 Subject: [PATCH 5/5] feat: transcript tool gist lines and working row policy --- crates/goat-tui/src/app/mod.rs | 70 ++++-- crates/goat-tui/src/transcript/call_sig.rs | 124 ++++++++++ crates/goat-tui/src/transcript/gutter.rs | 64 +++++ crates/goat-tui/src/transcript/item.rs | 2 +- crates/goat-tui/src/transcript/mod.rs | 64 +++-- crates/goat-tui/src/transcript/render.rs | 200 ++++------------ crates/goat-tui/src/transcript/tool_gist.rs | 250 ++++++++++++++++++++ crates/goat-tui/src/transcript/tool_line.rs | 69 ++++++ crates/goat-tui/src/view.rs | 5 +- 9 files changed, 640 insertions(+), 208 deletions(-) create mode 100644 crates/goat-tui/src/transcript/call_sig.rs create mode 100644 crates/goat-tui/src/transcript/gutter.rs create mode 100644 crates/goat-tui/src/transcript/tool_gist.rs create mode 100644 crates/goat-tui/src/transcript/tool_line.rs diff --git a/crates/goat-tui/src/app/mod.rs b/crates/goat-tui/src/app/mod.rs index 1d02b9f..1f9a723 100644 --- a/crates/goat-tui/src/app/mod.rs +++ b/crates/goat-tui/src/app/mod.rs @@ -17,7 +17,7 @@ use tokio::sync::mpsc::{Receiver, Sender}; use crate::{ ask::AskPicker, - command::CommandMenu, + command::{CommandMenu, CommandMenuContext}, composer::Composer, config::{Config, ConfigOutcome}, files::FileMenu, @@ -359,18 +359,10 @@ impl App { CommandEffect::SelectModelNamed(query) => self.select_model_named(&query), CommandEffect::OpenEffortPicker => { let efforts = self.current_efforts(); - if efforts.is_empty() { - self.push_toast( - NotifyKind::Info, - "current model has no reasoning effort options".to_owned(), - ); - return Vec::new(); - } - let label = self - .model - .as_ref() - .map(|m| format!("{}/{}", m.provider, m.model)) - .unwrap_or_default(); + let label = self.model.as_ref().map_or_else( + || "no model selected".to_owned(), + |m| format!("{}/{}", m.provider, m.model), + ); let current = self.model.as_ref().and_then(|m| m.effort); self.overlay = Overlay::Effort(EffortPicker::new(label, efforts, current)); Vec::new() @@ -745,13 +737,18 @@ impl App { } let text = self.composer.text(); let trimmed = text.trim_start(); + let efforts = self.current_efforts(); + let cmd_ctx = CommandMenuContext { + effort_choices: self.model.as_ref().map(|_| efforts.as_slice()), + }; if trimmed.starts_with('/') && slash_command_name(trimmed).is_none_or(|name| !name.contains('/')) { match &mut self.overlay { - Overlay::Commands(menu) => menu.update(&self.commands, trimmed), + Overlay::Commands(menu) => menu.update(&self.commands, trimmed, &cmd_ctx), _ => { - self.overlay = Overlay::Commands(CommandMenu::new(&self.commands, trimmed)); + self.overlay = + Overlay::Commands(CommandMenu::new(&self.commands, trimmed, &cmd_ctx)); } } } else if matches!(self.overlay, Overlay::Commands(_)) { @@ -866,17 +863,39 @@ impl App { if self.turn.active_shell { return None; } - self.is_busy().then(|| crate::transcript::Working { + if !self.is_busy() { + return None; + } + let label = self + .retry_status() + .or_else(|| self.compacting_status()) + .or_else(|| self.agent_status()); + if label.is_none() && self.transcript_has_running_activity() { + return None; + } + Some(crate::transcript::Working { elapsed: self.elapsed_secs(), - label: self - .retry_status() - .or_else(|| self.compacting_status()) - .or_else(|| self.agent_status()), + label, thinking: self.turn.thinking, tokens: (self.usage.turn_tokens > 0).then_some(self.usage.turn_tokens), }) } + fn transcript_has_running_activity(&self) -> bool { + self.transcript.items.iter().any(|item| { + matches!( + item, + crate::transcript::Item::Tool { + status: crate::transcript::ToolStatus::Running, + .. + } | crate::transcript::Item::Shell { + status: crate::transcript::ShellStatus::Running, + .. + } + ) + }) + } + pub(crate) fn take_notification(&mut self) -> Option { self.notification_pending.take() } @@ -912,6 +931,7 @@ impl App { width, self.theme, &self.highlighter, + &self.cwd, self.working_state().as_ref(), &self.queued_labels(), ) @@ -1881,13 +1901,15 @@ mod tests { } #[test] - fn effort_without_model_shows_toast() { + fn effort_without_model_opens_empty_picker() { let mut app = App::new(Theme::dark()); let ops = app.dispatch_slash_command("/effort"); assert!(ops.is_empty()); - assert!(!matches!(app.overlay, Overlay::Effort(_))); - assert!(app.transcript.items.is_empty()); - assert_eq!(app.toasts.len(), 1); + match &app.overlay { + Overlay::Effort(p) => assert!(p.is_empty()), + _ => panic!("expected effort overlay"), + } + assert!(app.toasts.is_empty()); } #[test] diff --git a/crates/goat-tui/src/transcript/call_sig.rs b/crates/goat-tui/src/transcript/call_sig.rs new file mode 100644 index 0000000..c192a30 --- /dev/null +++ b/crates/goat-tui/src/transcript/call_sig.rs @@ -0,0 +1,124 @@ +pub(crate) fn normalize(tool_name: &str, display_primary: &str) -> String { + let trimmed = display_primary.trim(); + if trimmed.is_empty() { + return format!("{tool_name}()"); + } + if let Some(open) = trimmed.find('(') { + let head = trimmed[..open].trim(); + if head == tool_name { + return trimmed.to_owned(); + } + if let Some(args) = bare_args(trimmed) { + return format_with_refs(tool_name, &args); + } + } + format_with_refs(tool_name, std::slice::from_ref(&trimmed)) +} + +pub(crate) fn parse(tool_name: &str, sig: &str) -> (String, Vec) { + let Some(open) = sig.find('(') else { + return (tool_name.to_owned(), Vec::new()); + }; + let name = sig[..open].trim().to_owned(); + let tail = &sig[open..]; + if !tail.ends_with(')') || tail.len() < 2 { + return (name, vec![tail.to_owned()]); + } + let inner = &tail[1..tail.len() - 1]; + if inner.is_empty() { + return (name, Vec::new()); + } + (name, inner.split(", ").map(unquote_arg).collect::>()) +} + +pub(crate) fn format(tool_name: &str, args: &[String]) -> String { + if args.is_empty() { + format!("{tool_name}()") + } else { + let parts: Vec = args + .iter() + .map(|a| quote_arg_if_needed(a.as_str())) + .collect(); + format!("{tool_name}({})", parts.join(", ")) + } +} + +fn bare_args(sig: &str) -> Option> { + let open = sig.find('(')?; + let tail = &sig[open..]; + if !tail.ends_with(')') || tail.len() < 2 { + return None; + } + let inner = &tail[1..tail.len() - 1]; + if inner.is_empty() { + return Some(Vec::new()); + } + Some(inner.split(", ").collect()) +} + +fn format_with_refs(name: &str, args: &[&str]) -> String { + if args.is_empty() { + format!("{name}()") + } else { + format!("{name}({})", args.join(", ")) + } +} + +fn unquote_arg(s: &str) -> String { + let t = s.trim(); + if t.len() >= 2 + && ((t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\''))) + { + t[1..t.len() - 1] + .replace("\\\"", "\"") + .replace("\\\\", "\\") + } else { + t.to_owned() + } +} + +fn quote_arg_if_needed(s: &str) -> String { + let needs = s.is_empty() + || s.chars().any(char::is_whitespace) + || s.contains('"') + || s.contains('\'') + || s.contains(','); + if needs { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") + } else { + s.to_owned() + } +} + +#[cfg(test)] +mod tests { + use super::{format, normalize, parse}; + + #[test] + fn normalize_wraps_bare_pattern() { + assert_eq!(normalize("Glob", "**/symbols*"), "Glob(**/symbols*)"); + } + + #[test] + fn normalize_keeps_existing_sig() { + assert_eq!( + normalize("Grep", "Grep(marker|symbols::, /tmp)"), + "Grep(marker|symbols::, /tmp)" + ); + } + + #[test] + fn normalize_fixes_tool_prefix() { + assert_eq!(normalize("Glob", "Grep(foo)"), "Glob(foo)"); + } + + #[test] + fn parse_round_trip() { + let sig = "Read(crates/a.rs, 1)"; + let (name, args) = parse("Read", sig); + assert_eq!(name, "Read"); + assert_eq!(args, ["crates/a.rs", "1"]); + assert_eq!(format(&name, &args), sig); + } +} diff --git a/crates/goat-tui/src/transcript/gutter.rs b/crates/goat-tui/src/transcript/gutter.rs new file mode 100644 index 0000000..c767717 --- /dev/null +++ b/crates/goat-tui/src/transcript/gutter.rs @@ -0,0 +1,64 @@ +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthStr; + +use crate::{symbols, wrap}; + +fn leading_indent(line: &Line<'static>) -> Vec> { + let mut prefix: Vec> = Vec::new(); + let gutter_ch = symbols::ui::QUOTE_GUTTER.chars().next().unwrap_or('▎'); + for span in &line.spans { + let content = span.content.as_ref(); + let is_blank = !content.is_empty() && content.chars().all(|c| c == ' '); + let is_gutter = content.starts_with(gutter_ch); + if is_blank { + prefix.push(Span::styled(content.to_owned(), span.style)); + } else if is_gutter { + prefix.push(span.clone()); + break; + } else { + break; + } + } + prefix +} + +pub(crate) fn hang( + content: &[Line<'static>], + marker: Span<'static>, + width: u16, +) -> Vec> { + let inner = width.saturating_sub(2); + let mut first = Some(marker); + if content.is_empty() { + return vec![Line::from(vec![first.take().unwrap_or_default()])]; + } + let mut out: Vec> = Vec::new(); + for line in content { + if line.spans.len() == 1 && line.spans[0].content.as_ref() == symbols::ui::HRULE { + let style = line.spans[0].style; + let prefix = first.take().unwrap_or_else(|| Span::raw(" ")); + let prefix_w = UnicodeWidthStr::width(prefix.content.as_ref()); + let rule_w = usize::from(width).saturating_sub(prefix_w).max(1); + out.push(Line::from(vec![ + prefix, + Span::styled("─".repeat(rule_w), style), + ])); + continue; + } + let indent = leading_indent(line); + let mut wrapped = wrap::wrap_line(line, inner).into_iter(); + if let Some(mut first_row) = wrapped.next() { + let prefix = first.take().unwrap_or_else(|| Span::raw(" ")); + first_row.spans.insert(0, prefix); + out.push(first_row); + } + for mut row in wrapped { + for (i, span) in indent.iter().enumerate() { + row.spans.insert(i, span.clone()); + } + row.spans.insert(0, Span::raw(" ")); + out.push(row); + } + } + out +} diff --git a/crates/goat-tui/src/transcript/item.rs b/crates/goat-tui/src/transcript/item.rs index 4fb396e..a285984 100644 --- a/crates/goat-tui/src/transcript/item.rs +++ b/crates/goat-tui/src/transcript/item.rs @@ -42,7 +42,7 @@ pub(crate) enum Item { status: ShellStatus, }, Error(String), - Notice(String), + Interrupted, Compaction { tokens_before: u32, tokens_after: u32, diff --git a/crates/goat-tui/src/transcript/mod.rs b/crates/goat-tui/src/transcript/mod.rs index 2d8cc3f..e765884 100644 --- a/crates/goat-tui/src/transcript/mod.rs +++ b/crates/goat-tui/src/transcript/mod.rs @@ -1,5 +1,9 @@ +mod call_sig; +mod gutter; mod item; mod render; +mod tool_gist; +mod tool_line; use std::cell::RefCell; @@ -13,13 +17,15 @@ use ratatui::{ use crate::{highlight::Highlighter, markdown, symbols, theme::Theme}; +use gutter::hang; pub(crate) use item::{Item, ShellStatus, ToolStatus, UserMessage, Working}; -use render::{build_static_lines, hang, is_blank, queued_rows, stable_prefix_len, working_rows}; +use render::{build_static_lines, is_blank, queued_rows, stable_prefix_len, working_rows}; pub(crate) struct RenderCtx<'a> { pub theme: Theme, pub scroll: usize, pub left_pad: u16, + pub cwd: &'a str, pub spinner: &'static str, pub working: Option<&'a Working>, pub queued: &'a [String], @@ -235,17 +241,17 @@ impl Transcript { } if let Some(buffer) = self.streaming.take() { let text = if interrupted { - format!("{buffer} … interrupted") + format!("{buffer}\n\n(interrupted)") } else { buffer }; self.items.push(Item::Agent(text)); } else if interrupted && !matches!(self.items.last(), Some(Item::Error(_))) { - self.items.push(Item::Notice("interrupted".into())); + self.items.push(Item::Interrupted); } } - fn ensure_cache(&self, theme: Theme, width: u16, hl: &dyn Highlighter) { + fn ensure_cache(&self, theme: Theme, width: u16, hl: &dyn Highlighter, cwd: &str) { let valid = self .cache .borrow() @@ -260,7 +266,7 @@ impl Transcript { memo.entries.clear(); } let (lines, spinner_lines, images) = - build_static_lines(&self.items, theme, width, hl, &mut memo.entries); + build_static_lines(&self.items, theme, width, hl, cwd, &mut memo.entries); *self.cache.borrow_mut() = Some(RenderCache { width, version: self.version, @@ -351,10 +357,11 @@ impl Transcript { width: u16, theme: Theme, hl: &dyn Highlighter, + cwd: &str, working: Option<&Working>, queued: &[String], ) -> usize { - self.ensure_cache(theme, width, hl); + self.ensure_cache(theme, width, hl, cwd); let base = self.cache.borrow().as_ref().map_or(0, |c| c.lines.len()); base + self .tail_rows( @@ -371,7 +378,7 @@ impl Transcript { pub(crate) fn render(&self, frame: &mut Frame, area: Rect, ctx: &RenderCtx<'_>) { let body_width = area.width.saturating_sub(ctx.left_pad); - self.ensure_cache(ctx.theme, body_width, ctx.hl); + self.ensure_cache(ctx.theme, body_width, ctx.hl, ctx.cwd); let guard = self.cache.borrow(); let Some(cache) = guard.as_ref() else { return; @@ -482,7 +489,7 @@ mod tests { } fn height(t: &Transcript, width: u16) -> usize { - t.content_height(width, Theme::dark(), &PlainHighlighter, None, &[]) + t.content_height(width, Theme::dark(), &PlainHighlighter, "/", None, &[]) } #[test] @@ -491,9 +498,9 @@ mod tests { let light = Theme::light(); let items = vec![Item::Agent("plain body".to_owned())]; let (dark_lines, _, _) = - build_static_lines(&items, dark, 80, &PlainHighlighter, &mut Vec::new()); + build_static_lines(&items, dark, 80, &PlainHighlighter, "/", &mut Vec::new()); let (light_lines, _, _) = - build_static_lines(&items, light, 80, &PlainHighlighter, &mut Vec::new()); + build_static_lines(&items, light, 80, &PlainHighlighter, "/", &mut Vec::new()); let body_fg = |lines: &[Line<'static>]| { lines .iter() @@ -517,21 +524,21 @@ mod tests { Item::Tool { id: ToolCallId(1), name: "Read".to_owned(), - display: goat_protocol::ToolDisplay::primary("a.txt"), + display: goat_protocol::ToolDisplay::primary("Read(a.txt)"), status: ToolStatus::Running, image: None, }, ]; let mut memo = Vec::new(); - let _ = build_static_lines(&items, theme, 80, &PlainHighlighter, &mut memo); + let _ = build_static_lines(&items, theme, 80, &PlainHighlighter, "/", &mut memo); if let Item::Tool { status, .. } = &mut items[2] { *status = ToolStatus::Done(ok()); } items.push(Item::Agent("second answer".to_owned())); let (memo_lines, _, _) = - build_static_lines(&items, theme, 80, &PlainHighlighter, &mut memo); + build_static_lines(&items, theme, 80, &PlainHighlighter, "/", &mut memo); let (fresh_lines, _, _) = - build_static_lines(&items, theme, 80, &PlainHighlighter, &mut Vec::new()); + build_static_lines(&items, theme, 80, &PlainHighlighter, "/", &mut Vec::new()); let render = |lines: &[Line<'static>]| { lines .iter() @@ -617,8 +624,8 @@ mod tests { panic!("expected failed tool"); } assert!( - matches!(t.items.last(), Some(Item::Notice(_))), - "interrupt with no stream must append Notice" + matches!(t.items.last(), Some(Item::Interrupted)), + "interrupt with no stream must append Interrupted" ); } @@ -671,7 +678,14 @@ mod tests { thinking: false, tokens: None, }; - let busy = t.content_height(80, Theme::dark(), &PlainHighlighter, Some(&working), &[]); + let busy = t.content_height( + 80, + Theme::dark(), + &PlainHighlighter, + "/", + Some(&working), + &[], + ); assert!( busy > idle, "content_height must be larger when busy (working line)" @@ -683,8 +697,8 @@ mod tests { let mut t = Transcript::default(); t.complete(true); assert!( - matches!(t.items.last(), Some(Item::Notice(_))), - "interrupting with no stream must push a Notice item" + matches!(t.items.last(), Some(Item::Interrupted)), + "interrupting with no stream must push Interrupted" ); } @@ -697,8 +711,8 @@ mod tests { assert!(matches!(&t.items[1], Item::Error(_))); t.complete(true); assert!( - !matches!(t.items.last(), Some(Item::Notice(_))), - "interrupted notice must be suppressed right after an error row" + !matches!(t.items.last(), Some(Item::Interrupted)), + "interrupted row must be suppressed right after an error row" ); } @@ -729,6 +743,7 @@ mod tests { theme: Theme::dark(), scroll: h - 2, left_pad: 0, + cwd: "/", spinner: symbols::SPINNER[0], working: None, queued: &[], @@ -755,6 +770,7 @@ mod tests { theme: Theme::dark(), scroll: 0, left_pad: 0, + cwd: "/", spinner: symbols::SPINNER[3], working: None, queued: &[], @@ -798,6 +814,7 @@ mod tests { theme, scroll: 0, left_pad: 1, + cwd: "/", spinner: symbols::SPINNER[0], working: None, queued: &[], @@ -838,6 +855,7 @@ mod tests { theme, scroll: 0, left_pad: 1, + cwd: "/", spinner: symbols::SPINNER[0], working: None, queued: &[], @@ -866,8 +884,8 @@ mod tests { let mut t = Transcript::default(); commit(&mut t, "answer"); let queued: Vec = (0..5).map(|i| format!("queued {i}")).collect(); - let with_queue = t.content_height(80, Theme::dark(), &PlainHighlighter, None, &queued); - let without = t.content_height(80, Theme::dark(), &PlainHighlighter, None, &[]); + let with_queue = t.content_height(80, Theme::dark(), &PlainHighlighter, "/", None, &queued); + let without = t.content_height(80, Theme::dark(), &PlainHighlighter, "/", None, &[]); assert_eq!(with_queue - without, 5, "3 rows + overflow row + spacer"); } diff --git a/crates/goat-tui/src/transcript/render.rs b/crates/goat-tui/src/transcript/render.rs index 56f8f3f..19722c3 100644 --- a/crates/goat-tui/src/transcript/render.rs +++ b/crates/goat-tui/src/transcript/render.rs @@ -1,4 +1,3 @@ -use goat_protocol::ToolOutcome; use ratatui::{ style::Style, text::{Line, Span}, @@ -12,6 +11,8 @@ use crate::{ use super::ImagePlacement; use super::item::{Item, ShellStatus, ToolStatus, Working}; +use super::tool_gist::ToolLineCtx; +use super::tool_line::{ToolRowInput, tool_marker, tool_row}; pub(super) fn is_blank(line: &Line<'_>) -> bool { line.spans.iter().all(|s| s.content.is_empty()) @@ -38,65 +39,7 @@ pub(super) fn stable_prefix_len(buffer: &str) -> usize { split.min(buffer.len()) } -fn leading_indent(line: &Line<'static>) -> Vec> { - let mut prefix: Vec> = Vec::new(); - let gutter_ch = symbols::ui::QUOTE_GUTTER.chars().next().unwrap_or('▎'); - for span in &line.spans { - let content = span.content.as_ref(); - let is_blank = !content.is_empty() && content.chars().all(|c| c == ' '); - let is_gutter = content.starts_with(gutter_ch); - if is_blank { - prefix.push(Span::styled(content.to_owned(), span.style)); - } else if is_gutter { - prefix.push(span.clone()); - break; - } else { - break; - } - } - prefix -} - -pub(super) fn hang( - content: &[Line<'static>], - marker: Span<'static>, - width: u16, -) -> Vec> { - let inner = width.saturating_sub(2); - let mut first = Some(marker); - if content.is_empty() { - return vec![Line::from(vec![first.take().unwrap_or_default()])]; - } - let mut out: Vec> = Vec::new(); - for line in content { - if line.spans.len() == 1 && line.spans[0].content.as_ref() == symbols::ui::HRULE { - let style = line.spans[0].style; - let prefix = first.take().unwrap_or_else(|| Span::raw(" ")); - let prefix_w = UnicodeWidthStr::width(prefix.content.as_ref()); - let rule_w = usize::from(width).saturating_sub(prefix_w).max(1); - out.push(Line::from(vec![ - prefix, - Span::styled("─".repeat(rule_w), style), - ])); - continue; - } - let indent = leading_indent(line); - let mut wrapped = wrap::wrap_line(line, inner).into_iter(); - if let Some(mut first_row) = wrapped.next() { - let prefix = first.take().unwrap_or_else(|| Span::raw(" ")); - first_row.spans.insert(0, prefix); - out.push(first_row); - } - for mut row in wrapped { - for (i, span) in indent.iter().enumerate() { - row.spans.insert(i, span.clone()); - } - row.spans.insert(0, Span::raw(" ")); - out.push(row); - } - } - out -} +pub(super) use super::gutter::hang; pub(super) fn plain_lines(text: &str, theme: Theme) -> Vec> { text.split('\n') @@ -206,6 +149,7 @@ pub(super) fn build_static_lines( theme: Theme, width: u16, hl: &dyn Highlighter, + cwd: &str, memo: &mut Vec, ) -> (Vec>, Vec, Vec) { let mut lines: Vec> = Vec::new(); @@ -238,7 +182,7 @@ pub(super) fn build_static_lines( let rows = match memo.get(i) { Some(cached) if cached.sig == sig => cached.rows.clone(), _ => { - let rows = item_rows(item, theme, width, hl); + let rows = item_rows(item, theme, width, hl, cwd); let entry = ItemMemo { sig, rows: rows.clone(), @@ -306,9 +250,8 @@ pub(super) fn item_signature(item: &Item) -> u64 { 3u8.hash(&mut hasher); text.hash(&mut hasher); } - Item::Notice(text) => { - 4u8.hash(&mut hasher); - text.hash(&mut hasher); + Item::Interrupted => { + 7u8.hash(&mut hasher); } Item::Compaction { tokens_before, @@ -327,13 +270,11 @@ pub(super) fn item_signature(item: &Item) -> u64 { 6u8.hash(&mut hasher); name.hash(&mut hasher); display.primary.hash(&mut hasher); - display.detail.hash(&mut hasher); match status { ToolStatus::Running => 0u8.hash(&mut hasher), ToolStatus::Done(outcome) => { 1u8.hash(&mut hasher); outcome.ok.hash(&mut hasher); - outcome.summary.hash(&mut hasher); } } } @@ -346,6 +287,7 @@ pub(super) fn item_rows( theme: Theme, width: u16, hl: &dyn Highlighter, + cwd: &str, ) -> Vec> { match item { Item::User(message) => { @@ -364,13 +306,19 @@ pub(super) fn item_rows( user_panel_rows(rows, theme, width) } Item::Agent(text) => { - let rendered = markdown::render(text, theme, hl); - let end = rendered - .iter() - .rposition(|l| !is_blank(l)) - .map_or(0, |i| i + 1); + let interrupted_tail = text.contains("(interrupted)"); + let rendered = if interrupted_tail { + plain_lines_styled(text, theme.error_body()) + } else { + let rendered = markdown::render(text, theme, hl); + let end = rendered + .iter() + .rposition(|l| !is_blank(l)) + .map_or(0, |i| i + 1); + rendered[..end].to_vec() + }; hang( - &rendered[..end], + &rendered, Span::styled(symbols::marker::AGENT, theme.role_agent()), width, ) @@ -383,11 +331,17 @@ pub(super) fn item_rows( Span::styled(symbols::marker::ERROR, theme.error()), width, ), - Item::Notice(text) => hang( - &plain_lines(text, theme), - Span::styled(symbols::marker::NOTICE, theme.muted()), - width, - ), + Item::Interrupted => { + let body = "Turn interrupted."; + let inner = width.saturating_sub(2); + let line = Line::from(Span::styled(body.to_owned(), theme.error_body())); + let wrapped = wrap::wrap_line(&line, inner); + hang( + &wrapped, + Span::styled(symbols::marker::ERROR, theme.error()), + width, + ) + } Item::Compaction { tokens_before, tokens_after, @@ -415,57 +369,21 @@ pub(super) fn item_rows( status, .. } => { - let (marker, marker_style): (&str, _) = match status { - ToolStatus::Running => (symbols::SPINNER[0], theme.accent()), - ToolStatus::Done(ToolOutcome { ok: true, .. }) => { - (symbols::ui::CHECK, theme.success()) - } - ToolStatus::Done(ToolOutcome { ok: false, .. }) => { - (symbols::ui::CROSS, theme.error()) - } - }; - - let verb = name.to_lowercase(); - let verb_w = verb.width(); - let avail = usize::from(width) - .saturating_sub(2) - .saturating_sub(verb_w) - .saturating_sub(2); - - let primary = truncate_to_width(&display.primary, avail); - let detail_avail = avail.saturating_sub(primary.width()).saturating_sub(2); - let detail = display - .detail - .as_deref() - .filter(|_| detail_avail > 1) - .map(|d| truncate_to_width(d, detail_avail)); - - let mut spans = vec![ - Span::styled(marker, marker_style), - Span::raw(" "), - Span::styled(verb, theme.role_tool()), - Span::raw(" "), - Span::styled(primary, theme.base()), - ]; - if let Some(d) = detail { - spans.push(Span::raw(" ")); - spans.push(Span::styled(d, theme.muted())); - } - - let mut result = vec![Line::from(spans)]; - if let ToolStatus::Done(ToolOutcome { - summary: Some(summary), - .. - }) = status - { - result.extend(result_rows(summary, theme, width)); - } - result + let (marker, marker_style) = tool_marker(status, theme); + let failed = matches!(status, ToolStatus::Done(o) if !o.ok); + tool_row(&ToolRowInput { + name, + display_primary: &display.primary, + marker, + marker_style, + theme, + width, + line_ctx: ToolLineCtx { cwd, width, failed }, + }) } } } -const RESULT_BLOCK_CAP: usize = 6; pub(super) const SHELL_BLOCK_CAP: usize = 20; const SHELL_EXIT_PREFIX: &str = "exit code: "; const SHELL_NO_OUTPUT: &str = "(no output)"; @@ -597,37 +515,3 @@ pub(super) fn shell_rows( out.extend(rows); out } - -pub(super) fn result_rows(summary: &str, theme: Theme, width: u16) -> Vec> { - let src: Vec<&str> = summary.lines().collect(); - let inner = width.saturating_sub(2); - let mut out: Vec> = Vec::new(); - for line in src.iter().take(RESULT_BLOCK_CAP) { - let style = if line.starts_with("+ ") { - theme.role_agent() - } else if line.starts_with("- ") { - theme.error() - } else { - theme.muted() - }; - let content = Line::from(Span::styled(line.replace('\t', " "), style)); - for mut row in wrap::wrap_line(&content, inner) { - row.spans.insert(0, Span::raw(" ")); - out.push(row); - } - } - if src.len() > RESULT_BLOCK_CAP { - out.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - format!( - "{} {} more", - symbols::ui::ELLIPSIS, - src.len() - RESULT_BLOCK_CAP - ), - theme.muted(), - ), - ])); - } - out -} diff --git a/crates/goat-tui/src/transcript/tool_gist.rs b/crates/goat-tui/src/transcript/tool_gist.rs new file mode 100644 index 0000000..3942685 --- /dev/null +++ b/crates/goat-tui/src/transcript/tool_gist.rs @@ -0,0 +1,250 @@ +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::symbols; + +use super::call_sig; + +pub(crate) struct ToolLineCtx<'a> { + pub cwd: &'a str, + pub width: u16, + pub failed: bool, +} + +pub(crate) fn transcript_sig( + tool_name: &str, + display_primary: &str, + ctx: &ToolLineCtx<'_>, +) -> String { + let full = call_sig::normalize(tool_name, display_primary); + let (name, args) = call_sig::parse(tool_name, &full); + let args = shorten_args(tool_name, &args, ctx); + call_sig::format(&name, &args) +} + +fn shorten_args(tool_name: &str, args: &[String], ctx: &ToolLineCtx<'_>) -> Vec { + if args.is_empty() { + return Vec::new(); + } + match tool_name { + "Read" | "Write" | "Edit" => vec![shorten_path(&args[0], ctx)], + "Glob" => vec![shorten_text(&args[0], ctx, 64)], + "Grep" => shorten_grep(args, ctx), + "Bash" => vec![shorten_command(&args[0], ctx)], + "WebFetch" => vec![shorten_text( + &url_host(&args[0]).unwrap_or_else(|| args[0].clone()), + ctx, + 48, + )], + "WebSearch" => vec![shorten_text(&args[0], ctx, 48)], + "Skill" => vec![shorten_text(&args[0], ctx, 40)], + "Agent" => shorten_agent(args, ctx), + "Ask" => vec![shorten_text(&args[0], ctx, 56)], + _ => shorten_default(args, ctx), + } +} + +fn shorten_grep(args: &[String], ctx: &ToolLineCtx<'_>) -> Vec { + let mut out = vec![shorten_text(&args[0], ctx, 64)]; + if let Some(path) = args.get(1) + && !path.is_empty() + && path != "." + { + out.push(shorten_path(path, ctx)); + } + out +} + +fn shorten_agent(args: &[String], ctx: &ToolLineCtx<'_>) -> Vec { + if args.len() >= 2 { + vec![ + shorten_text(&args[0], ctx, 24), + shorten_text(&args[1], ctx, 40), + ] + } else { + vec![shorten_text(&args[0], ctx, 48)] + } +} + +fn shorten_default(args: &[String], ctx: &ToolLineCtx<'_>) -> Vec { + let mut out = vec![clip_to_budget(&args[0], arg_budget(ctx, 64))]; + if args.len() > 1 { + out.push("…".to_owned()); + } + out +} + +fn shorten_path(raw: &str, ctx: &ToolLineCtx<'_>) -> String { + let rel = path_under_cwd(raw, ctx.cwd); + ellipsize_path_middle(&rel, arg_budget(ctx, 56)) +} + +fn shorten_command(raw: &str, ctx: &ToolLineCtx<'_>) -> String { + let flat: String = raw.split_whitespace().collect::>().join(" "); + let cap = if ctx.failed { 72 } else { 56 }; + shorten_text(&flat, ctx, cap) +} + +fn shorten_text(s: &str, ctx: &ToolLineCtx<'_>, soft_max: usize) -> String { + let clipped = clip_to_budget(s, arg_budget(ctx, soft_max)); + let budget = arg_budget(ctx, soft_max); + if clipped.width() > budget { + clip_to_width(&clipped, budget) + } else { + clipped + } +} + +fn clip_to_budget(s: &str, budget: usize) -> String { + if s.width() <= budget { + return s.to_owned(); + } + clip_to_width(s, budget) +} + +fn arg_budget(ctx: &ToolLineCtx<'_>, base: usize) -> usize { + let w = usize::from(ctx.width.saturating_sub(2)).max(24); + let cap = w.saturating_sub(8); + let scaled = if ctx.failed { + cap.saturating_mul(5) / 4 + } else { + cap + }; + base.min(scaled.max(20)) +} + +fn path_under_cwd(raw: &str, cwd: &str) -> String { + let raw = raw.trim(); + if raw.is_empty() { + return raw.to_owned(); + } + let cwd = cwd.trim_end_matches('/'); + if cwd.is_empty() { + return home_relative(raw); + } + let prefix = format!("{cwd}/"); + if let Some(rest) = raw.strip_prefix(&prefix) { + return rest.to_owned(); + } + if raw == cwd { + return ".".to_owned(); + } + home_relative(raw) +} + +fn home_relative(raw: &str) -> String { + if let Some(home) = std::env::var("HOME").ok().filter(|h| !h.is_empty()) { + let home = home.trim_end_matches('/'); + if let Some(rest) = raw.strip_prefix(home) { + let rest = rest.strip_prefix('/').unwrap_or(rest); + return format!("~/{rest}"); + } + } + raw.to_owned() +} + +fn ellipsize_path_middle(path: &str, max: usize) -> String { + if path.width() <= max { + return path.to_owned(); + } + let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + if parts.is_empty() { + return clip_to_width(path, max); + } + let file = parts.last().copied().unwrap_or(""); + let parent = parts.get(parts.len().saturating_sub(2)).copied(); + if let Some(parent) = parent { + let candidate = format!("…/{parent}/{file}"); + if candidate.width() <= max { + return candidate; + } + } + let tail = format!("…/{file}"); + if tail.width() <= max { + return tail; + } + clip_to_width(file, max) +} + +fn url_host(url: &str) -> Option { + let t = url.trim(); + let after = t + .strip_prefix("https://") + .or_else(|| t.strip_prefix("http://"))?; + let host = after.split('/').next()?.split(':').next()?; + (!host.is_empty()).then(|| host.to_owned()) +} + +fn clip_to_width(s: &str, max: usize) -> String { + if max == 0 { + return String::new(); + } + let ell = symbols::ui::ELLIPSIS; + let ell_w = ell.width(); + if ell_w >= max { + return ell.to_owned(); + } + let mut w = 0usize; + let mut out = String::new(); + for ch in s.chars() { + let cw = ch.width().unwrap_or(0); + if w + cw + ell_w > max { + break; + } + w += cw; + out.push(ch); + } + out.push_str(ell); + out +} + +#[cfg(test)] +mod tests { + use super::{ToolLineCtx, ellipsize_path_middle, path_under_cwd, transcript_sig}; + + fn ctx(cwd: &str, width: u16) -> ToolLineCtx<'_> { + ToolLineCtx { + cwd, + width, + failed: false, + } + } + + #[test] + fn read_drops_extra_args() { + let sig = transcript_sig( + "Read", + "Read(/Users/jmo/proj/crates/foo/src/lib.rs, 10, 50)", + &ctx("/Users/jmo/proj", 100), + ); + assert!(sig.starts_with("Read(")); + assert!(sig.contains("crates/foo")); + assert!(!sig.contains(", 10")); + } + + #[test] + fn grep_omits_dot_scope() { + let sig = transcript_sig("Grep", "Grep(foo, .)", &ctx("/tmp", 80)); + assert_eq!(sig, "Grep(foo)"); + } + + #[test] + fn glob_keeps_pattern() { + let sig = transcript_sig("Glob", "Glob(**/symbols*)", &ctx("/x", 80)); + assert_eq!(sig, "Glob(**/symbols*)"); + } + + #[test] + fn path_under_cwd_strips_prefix() { + assert_eq!( + path_under_cwd("/Users/jmo/proj/crates/a.rs", "/Users/jmo/proj"), + "crates/a.rs" + ); + } + + #[test] + fn path_middle_ellipsis() { + let s = ellipsize_path_middle("crates/goat-tui/src/transcript/tool_gist.rs", 28); + assert!(s.contains("tool_gist.rs")); + assert!(s.contains('…')); + } +} diff --git a/crates/goat-tui/src/transcript/tool_line.rs b/crates/goat-tui/src/transcript/tool_line.rs new file mode 100644 index 0000000..bfe99a5 --- /dev/null +++ b/crates/goat-tui/src/transcript/tool_line.rs @@ -0,0 +1,69 @@ +use ratatui::{ + style::Style, + text::{Line, Span}, +}; + +use super::gutter::hang; +use super::tool_gist::{ToolLineCtx, transcript_sig}; +use crate::{symbols, theme::Theme}; + +pub(crate) struct ToolRowInput<'a> { + pub name: &'a str, + pub display_primary: &'a str, + pub marker: &'a str, + pub marker_style: Style, + pub theme: Theme, + pub width: u16, + pub line_ctx: ToolLineCtx<'a>, +} + +pub(crate) fn tool_row(input: &ToolRowInput<'_>) -> Vec> { + let sig = transcript_sig(input.name, input.display_primary, &input.line_ctx); + let gutter = Span::styled(format!("{} ", input.marker), input.marker_style); + hang( + &[Line::from(signature_spans(&sig, input.theme))], + gutter, + input.width, + ) +} + +pub(crate) fn tool_marker(status: &super::item::ToolStatus, theme: Theme) -> (&'static str, Style) { + use super::item::ToolStatus; + use goat_protocol::ToolOutcome; + match status { + ToolStatus::Running => (symbols::SPINNER[0], theme.accent()), + ToolStatus::Done(ToolOutcome { ok: true, .. }) => (symbols::ui::CHECK, theme.success()), + ToolStatus::Done(ToolOutcome { ok: false, .. }) => (symbols::ui::CROSS, theme.error()), + } +} + +fn signature_spans(sig: &str, theme: Theme) -> Vec> { + let Some(open) = sig.find('(') else { + return vec![Span::styled(sig.to_owned(), theme.tool_fn())]; + }; + let name = sig[..open].to_owned(); + let tail = &sig[open..]; + if !tail.ends_with(')') || tail.len() < 2 { + return vec![ + Span::styled(name, theme.tool_fn()), + Span::styled(tail.to_owned(), theme.muted()), + ]; + } + let inner = &tail[1..tail.len() - 1]; + let mut spans = vec![ + Span::styled(name, theme.tool_fn()), + Span::styled("(".to_owned(), theme.muted()), + ]; + if inner.is_empty() { + spans.push(Span::styled(")".to_owned(), theme.muted())); + return spans; + } + for (i, part) in inner.split(", ").enumerate() { + if i > 0 { + spans.push(Span::styled(", ".to_owned(), theme.muted())); + } + spans.push(Span::styled(part.to_owned(), theme.tool_arg_value())); + } + spans.push(Span::styled(")".to_owned(), theme.muted())); + spans +} diff --git a/crates/goat-tui/src/view.rs b/crates/goat-tui/src/view.rs index 94427db..9ffd6b8 100644 --- a/crates/goat-tui/src/view.rs +++ b/crates/goat-tui/src/view.rs @@ -243,6 +243,7 @@ fn render_transcript(frame: &mut Frame, area: Rect, app: &mut App, theme: Theme) theme, scroll: app.scroll(), left_pad: PAD_X, + cwd: app.cwd(), spinner: app.spinner_frame(), working: working.as_ref(), queued: &queued, @@ -414,7 +415,7 @@ fn format_ctx_status(used: u64, window: u32) -> (String, f32) { (used as f64 / f64::from(window) * 100.0).min(100.0) as f32 }; let label = format!( - "ctx {}/{}", + "{}/{}", format_tokens(used), format_tokens(u64::from(window)) ); @@ -617,7 +618,7 @@ mod tests { #[test] fn format_ctx_status_uses_token_fraction() { let (label, pct) = format_ctx_status(45_000, 128_000); - assert_eq!(label, "ctx 45k/128k"); + assert_eq!(label, "45k/128k"); assert!((pct - 35.15625).abs() < f32::EPSILON); }