From 6ba53d85d5b1154aec5d19efacaa7dcab46d058a Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Sat, 29 Nov 2025 09:43:03 -0500 Subject: [PATCH] allow arguments for aliases --- crates/goose-tui/src/components/input.rs | 120 +++++++++---- .../src/components/popups/builder.rs | 160 +++++++++++++----- crates/goose-tui/src/state/reducer.rs | 1 - 3 files changed, 207 insertions(+), 74 deletions(-) diff --git a/crates/goose-tui/src/components/input.rs b/crates/goose-tui/src/components/input.rs index e9846bebb9..73f3daeaee 100644 --- a/crates/goose-tui/src/components/input.rs +++ b/crates/goose-tui/src/components/input.rs @@ -63,8 +63,13 @@ impl<'a> InputComponent<'a> { } fn handle_slash_command(&self, cmd_line: &str, state: &AppState) -> Option { - let parts: Vec<&str> = cmd_line.split_whitespace().collect(); - let cmd = parts[0]; + // Split into command and trailing arguments + let trimmed = cmd_line.trim(); + let (cmd, trailing_args) = match trimmed.split_once(' ') { + Some((c, rest)) => (c, rest.trim()), + None => (trimmed, ""), + }; + match cmd { "/exit" | "/quit" => Some(Action::Quit), "/help" => Some(Action::ToggleHelp), @@ -80,8 +85,8 @@ impl<'a> InputComponent<'a> { Some(Action::SendMessage(message)) } "/theme" => { - if let Some(theme_name) = parts.get(1) { - Some(Action::ChangeTheme(theme_name.to_string())) + if !trailing_args.is_empty() { + Some(Action::ChangeTheme(trailing_args.to_string())) } else { Some(Action::OpenThemePicker) } @@ -95,9 +100,10 @@ impl<'a> InputComponent<'a> { .iter() .find(|c| c.name == cmd_name) { + let processed_args = replace_input_placeholder(&custom.args, trailing_args); let message_text = format!( "Please run the tool '{}' with these arguments: {}", - custom.tool, custom.args + custom.tool, processed_args ); let message = goose::conversation::message::Message::user().with_text(message_text); @@ -110,6 +116,25 @@ impl<'a> InputComponent<'a> { } } +fn replace_input_placeholder(args: &serde_json::Value, input: &str) -> serde_json::Value { + match args { + serde_json::Value::String(s) => serde_json::Value::String(s.replace("{input}", input)), + serde_json::Value::Object(obj) => { + let new_obj: serde_json::Map = obj + .iter() + .map(|(k, v)| (k.clone(), replace_input_placeholder(v, input))) + .collect(); + serde_json::Value::Object(new_obj) + } + serde_json::Value::Array(arr) => serde_json::Value::Array( + arr.iter() + .map(|v| replace_input_placeholder(v, input)) + .collect(), + ), + other => other.clone(), + } +} + impl<'a> Component for InputComponent<'a> { #[allow(clippy::too_many_lines)] fn handle_event(&mut self, event: &Event, state: &AppState) -> Result> { @@ -264,35 +289,42 @@ impl<'a> Component for InputComponent<'a> { if state.input_mode == InputMode::Editing { if let Some(first_line) = self.textarea.lines().first() { - if first_line.starts_with('/') { - let mut commands = vec![ - "/exit", - "/quit", - "/help", - "/todos", - "/config", - "/session", - "/alias", - "/clear", - "/compact", - "/theme", - "/copy", - "/copymode", + let first_word = first_line.split_whitespace().next().unwrap_or(first_line); + if first_word.starts_with('/') { + let builtin_commands = vec![ + ("/exit", false), + ("/quit", false), + ("/help", false), + ("/todos", false), + ("/config", false), + ("/session", false), + ("/alias", false), + ("/clear", false), + ("/compact", false), + ("/theme", true), + ("/copy", false), + ("/copymode", false), ]; - let custom: Vec = state + + let custom: Vec<(String, bool)> = state .config .custom_commands .iter() - .map(|c| format!("/{}", c.name)) + .map(|c| { + let has_input = args_has_input_placeholder(&c.args); + (format!("/{}", c.name), has_input) + }) .collect(); - let custom_refs: Vec<&str> = custom.iter().map(|s| s.as_str()).collect(); - commands.extend(custom_refs); - commands.sort(); - let filtered: Vec<&str> = commands - .iter() - .filter(|c| c.starts_with(first_line)) - .cloned() + let mut all_commands: Vec<(&str, bool)> = builtin_commands; + let custom_refs: Vec<(&str, bool)> = + custom.iter().map(|(s, b)| (s.as_str(), *b)).collect(); + all_commands.extend(custom_refs); + all_commands.sort_by(|a, b| a.0.cmp(b.0)); + + let filtered: Vec<(&str, bool)> = all_commands + .into_iter() + .filter(|(c, _)| c.starts_with(first_word)) .collect(); if !filtered.is_empty() { @@ -308,11 +340,24 @@ impl<'a> Component for InputComponent<'a> { let items: Vec = filtered .iter() - .map(|c| { - ListItem::new(Span::styled( - *c, - Style::default().fg(theme.base.foreground), - )) + .map(|(c, accepts_args)| { + if *accepts_args { + ListItem::new(ratatui::text::Line::from(vec![ + Span::styled( + (*c).to_string(), + Style::default().fg(theme.base.foreground), + ), + Span::styled( + " ".to_string(), + Style::default().fg(theme.status.warning), + ), + ])) + } else { + ListItem::new(Span::styled( + (*c).to_string(), + Style::default().fg(theme.base.foreground), + )) + } }) .collect(); @@ -332,3 +377,12 @@ impl<'a> Component for InputComponent<'a> { } } } + +fn args_has_input_placeholder(args: &serde_json::Value) -> bool { + match args { + serde_json::Value::String(s) => s.contains("{input}"), + serde_json::Value::Object(obj) => obj.values().any(args_has_input_placeholder), + serde_json::Value::Array(arr) => arr.iter().any(args_has_input_placeholder), + _ => false, + } +} diff --git a/crates/goose-tui/src/components/popups/builder.rs b/crates/goose-tui/src/components/popups/builder.rs index de750a4943..cc0a2f8fec 100644 --- a/crates/goose-tui/src/components/popups/builder.rs +++ b/crates/goose-tui/src/components/popups/builder.rs @@ -292,14 +292,15 @@ impl BuilderPopup<'_> { KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => self.navigate(1, state), KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => self.navigate(-1, state), KeyCode::Char('d') | KeyCode::Delete => { - if let Some(cmd) = self - .list_state - .selected() - .and_then(|i| state.config.custom_commands.get(i)) - { - let name = cmd.name.clone(); - self.reset(); - return Ok(Some(Action::DeleteCustomCommand(name))); + if let Some(selected) = self.list_state.selected() { + if let Some(cmd) = state.config.custom_commands.get(selected) { + let name = cmd.name.clone(); + let new_len = state.config.custom_commands.len().saturating_sub(1); + if new_len > 0 && selected >= new_len { + self.list_state.select(Some(new_len - 1)); + } + return Ok(Some(Action::DeleteCustomCommand(name))); + } } } KeyCode::Enter | KeyCode::Char('e') => { @@ -329,18 +330,15 @@ impl BuilderPopup<'_> { key: &crossterm::event::KeyEvent, state: &AppState, ) -> Result> { - let is_last_field = self.focused_field == self.param_inputs.len(); - let ctrl_held = key.modifiers.contains(KeyModifiers::CONTROL); - match key.code { KeyCode::Esc => { self.view = View::ToolSelect; self.search.clear(); self.list_state.select(Some(0)); } - KeyCode::Tab => self.focus_next(), - KeyCode::BackTab => self.focus_prev(), - KeyCode::Enter if ctrl_held || is_last_field => { + KeyCode::Tab | KeyCode::Down => self.focus_next(), + KeyCode::BackTab | KeyCode::Up => self.focus_prev(), + KeyCode::Enter => { if let Some(cmd) = self.build_command(&state.available_tools) { let msg = if self.editing_alias.is_some() { format!("✓ Updated /{}", cmd.name) @@ -361,6 +359,12 @@ impl BuilderPopup<'_> { } Ok(None) } + + fn has_input_placeholder(&self) -> bool { + self.param_inputs + .iter() + .any(|(_, ta)| ta.lines().join("").contains("{input}")) + } } // View-specific renderers @@ -461,19 +465,24 @@ impl BuilderPopup<'_> { " New Alias " }; + let has_input = self.has_input_placeholder(); + + // Calculate constraints: alias name + params + hint (if no {input}) + preview + spacer + hints let mut constraints: Vec = vec![Constraint::Length(3)]; constraints.extend(std::iter::repeat_n( Constraint::Length(3), self.param_inputs.len(), )); + if !has_input && !self.param_inputs.is_empty() { + constraints.push(Constraint::Length(1)); // hint about {input} + } constraints.extend([ - Constraint::Length(2), - Constraint::Min(0), - Constraint::Length(1), + Constraint::Length(2), // preview + Constraint::Min(0), // spacer + Constraint::Length(1), // hints ]); let chunks = Layout::vertical(constraints).margin(1).split(area); - let field_count = self.param_inputs.len() + 1; f.render_widget(popup_block(title, theme), area); @@ -486,22 +495,36 @@ impl BuilderPopup<'_> { f.render_widget(&*ta, chunks[i + 1]); } + let mut next_chunk = self.param_inputs.len() + 1; + + // Show hint about {input} if not already used + if !has_input && !self.param_inputs.is_empty() { + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("Tip: ", Style::default().fg(theme.base.border)), + Span::styled( + "Use {input} in a parameter to accept trailing arguments", + Style::default().fg(theme.base.border), + ), + ])), + chunks[next_chunk], + ); + next_chunk += 1; + } + + // Preview with {input} highlighting + let preview = self.preview_text(&state.available_tools); + let preview_spans = build_preview_spans(&preview, has_input, theme); f.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled("Preview: ", Style::default().fg(theme.base.border)), - Span::styled( - self.preview_text(&state.available_tools), - Style::default().fg(theme.status.success), - ), - ])), - chunks[field_count], + Paragraph::new(Line::from(preview_spans)), + chunks[next_chunk], ); render_hints( f, - chunks[field_count + 2], + chunks[next_chunk + 2], theme, - &[("Tab", "next"), ("Ctrl+Enter", "save"), ("Esc", "cancel")], + &[("↑↓/Tab", "nav"), ("Enter", "save"), ("Esc", "cancel")], ); } } @@ -679,19 +702,30 @@ fn tool_list_item_with_prefix(tool: &ToolInfo, theme: &Theme) -> ListItem<'stati fn alias_list_item(cmd: &CustomCommand, theme: &Theme) -> ListItem<'static> { let short_tool = cmd.tool.split("__").last().unwrap_or(&cmd.tool); let args_preview = preview_args(&cmd.args); + let has_input = args_has_input_placeholder(&cmd.args); + + let mut first_line = vec![ + Span::styled( + format!("/{}", cmd.name), + Style::default() + .fg(theme.status.success) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" → {short_tool}"), + Style::default().fg(theme.status.info), + ), + ]; + + if has_input { + first_line.push(Span::styled( + " ".to_string(), + Style::default().fg(theme.status.warning), + )); + } + ListItem::new(vec![ - Line::from(vec![ - Span::styled( - format!("/{}", cmd.name), - Style::default() - .fg(theme.status.success) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" → {short_tool}"), - Style::default().fg(theme.status.info), - ), - ]), + Line::from(first_line), Line::from(Span::styled( format!(" {args_preview}"), Style::default().fg(theme.base.border), @@ -699,6 +733,15 @@ fn alias_list_item(cmd: &CustomCommand, theme: &Theme) -> ListItem<'static> { ]) } +fn args_has_input_placeholder(args: &serde_json::Value) -> bool { + match args { + serde_json::Value::String(s) => s.contains("{input}"), + serde_json::Value::Object(obj) => obj.values().any(args_has_input_placeholder), + serde_json::Value::Array(arr) => arr.iter().any(args_has_input_placeholder), + _ => false, + } +} + fn truncate(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() @@ -726,3 +769,40 @@ fn preview_args(args: &serde_json::Value) -> String { }) .unwrap_or_default() } + +fn build_preview_spans<'a>(preview: &str, has_input: bool, theme: &Theme) -> Vec> { + let mut spans = vec![Span::styled( + "Preview: ".to_string(), + Style::default().fg(theme.base.border), + )]; + + if has_input { + // Split on {input} and highlight it + let parts: Vec<&str> = preview.split("{input}").collect(); + for (i, part) in parts.iter().enumerate() { + if !part.is_empty() { + spans.push(Span::styled( + (*part).to_string(), + Style::default().fg(theme.status.success), + )); + } + if i < parts.len() - 1 { + spans.push(Span::styled( + "{input}".to_string(), + Style::default().fg(theme.status.warning), + )); + } + } + spans.push(Span::styled( + " (accepts args)".to_string(), + Style::default().fg(theme.status.warning), + )); + } else { + spans.push(Span::styled( + preview.to_string(), + Style::default().fg(theme.status.success), + )); + } + + spans +} diff --git a/crates/goose-tui/src/state/reducer.rs b/crates/goose-tui/src/state/reducer.rs index 241c91346b..9c50401559 100644 --- a/crates/goose-tui/src/state/reducer.rs +++ b/crates/goose-tui/src/state/reducer.rs @@ -201,7 +201,6 @@ fn handle_misc(state: &mut AppState, action: &Action) -> bool { Action::DeleteCustomCommand(name) => { state.config.custom_commands.retain(|c| c.name != *name); let _ = state.config.save(); - state.showing_command_builder = false; state.flash_message = Some(( format!("✓ Deleted /{name}"), Instant::now() + Duration::from_secs(3),