allow arguments for aliases

This commit is contained in:
Tyler Longwell
2025-11-29 09:43:03 -05:00
parent 256b56bb2a
commit 6ba53d85d5
3 changed files with 207 additions and 74 deletions
+87 -33
View File
@@ -63,8 +63,13 @@ impl<'a> InputComponent<'a> {
}
fn handle_slash_command(&self, cmd_line: &str, state: &AppState) -> Option<Action> {
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<String, serde_json::Value> = 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<Option<Action>> {
@@ -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<String> = 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<ListItem> = 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(
" <args>".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,
}
}
+120 -40
View File
@@ -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<Option<Action>> {
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<Constraint> = 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(
" <args>".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<Span<'a>> {
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
}
-1
View File
@@ -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),