mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
allow arguments for aliases
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user