From 42092022f1e3412c391c3f4329fb9392910500a3 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 25 Feb 2026 17:58:22 +0100 Subject: [PATCH] feat: support skill arguments in command palette and inline completion Add an ArgsDescription field to skills (via 'args' YAML frontmatter) so skills can declare that they accept arguments. When present: - Command palette and inline / completion show the args hint in the skill description (e.g., '(args: )'). - Tab auto-completes the skill name into the editor without submitting, leaving the cursor for the user to type arguments before pressing Enter. - Enter continues to execute the command immediately. Split the combined enter/tab key binding in the completion component into separate bindings so each key can trigger distinct behavior. Preserve tab-to-select in file picker, model picker, and theme picker dialogs by matching both keys in their handlers. Assisted-By: cagent --- pkg/skills/skills.go | 25 ++++++++++------ pkg/tui/commands/commands.go | 6 +++- pkg/tui/components/completion/completion.go | 24 +++++++++++++-- pkg/tui/components/editor/editor.go | 9 ++++++ pkg/tui/dialog/command_palette.go | 33 +++++++++++++++++---- pkg/tui/dialog/file_picker.go | 2 +- pkg/tui/dialog/model_picker.go | 2 +- pkg/tui/dialog/theme_picker.go | 2 +- pkg/tui/tui.go | 8 +++++ 9 files changed, 91 insertions(+), 20 deletions(-) diff --git a/pkg/skills/skills.go b/pkg/skills/skills.go index 7dc903065..5ced7bf0d 100644 --- a/pkg/skills/skills.go +++ b/pkg/skills/skills.go @@ -16,15 +16,22 @@ const skillFile = "SKILL.md" // Skill represents a loaded skill with its metadata and content location. type Skill struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - FilePath string `yaml:"-"` - BaseDir string `yaml:"-"` - Files []string `yaml:"-"` - License string `yaml:"license"` - Compatibility string `yaml:"compatibility"` - Metadata map[string]string `yaml:"metadata"` - AllowedTools []string `yaml:"allowed-tools"` + Name string `yaml:"name"` + Description string `yaml:"description"` + ArgsDescription string `yaml:"args"` + FilePath string `yaml:"-"` + BaseDir string `yaml:"-"` + Files []string `yaml:"-"` + License string `yaml:"license"` + Compatibility string `yaml:"compatibility"` + Metadata map[string]string `yaml:"metadata"` + AllowedTools []string `yaml:"allowed-tools"` +} + +// HasArgs returns true if the skill declares an args description, +// indicating it accepts arguments when invoked as a slash command. +func (s Skill) HasArgs() bool { + return s.ArgsDescription != "" } // Load discovers and loads skills from the given sources. diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index 05c3d2abc..4a716e5fb 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -404,7 +404,11 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor skillCommands := make([]Item, 0, len(skillsList)) for _, skill := range skillsList { skillName := skill.Name - description := toolcommon.TruncateText(skill.Description, 55) + description := skill.Description + if skill.HasArgs() { + description += " (args: " + skill.ArgsDescription + ")" + } + description = toolcommon.TruncateText(description, 55) skillCommands = append(skillCommands, Item{ ID: "skill." + skillName, diff --git a/pkg/tui/components/completion/completion.go b/pkg/tui/components/completion/completion.go index 909fa0f8a..02957b4b9 100644 --- a/pkg/tui/components/completion/completion.go +++ b/pkg/tui/components/completion/completion.go @@ -54,6 +54,7 @@ type QueryMsg struct { type SelectedMsg struct { Value string Execute func() tea.Cmd + Tab bool // Tab selects without submitting, allowing the user to continue typing. } // SelectionChangedMsg is sent when the selected item changes (for preview in editor) @@ -88,6 +89,7 @@ type completionKeyMap struct { Up key.Binding Down key.Binding Enter key.Binding + Tab key.Binding Escape key.Binding } @@ -103,8 +105,12 @@ func defaultCompletionKeyMap() completionKeyMap { key.WithHelp("↓", "down"), ), Enter: key.NewBinding( - key.WithKeys("enter", "tab"), - key.WithHelp("enter/tab", "select"), + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "complete"), ), Escape: key.NewBinding( key.WithKeys("esc"), @@ -260,6 +266,20 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { }), core.CmdHandler(ClosedMsg{}), ) + + case key.Matches(msg, c.keyMap.Tab): + c.visible = false + if len(c.filteredItems) == 0 || c.selected >= len(c.filteredItems) { + return c, core.CmdHandler(ClosedMsg{}) + } + selectedItem := c.filteredItems[c.selected] + return c, tea.Sequence( + core.CmdHandler(SelectedMsg{ + Value: selectedItem.Value, + Tab: true, + }), + core.CmdHandler(ClosedMsg{}), + ) case key.Matches(msg, c.keyMap.Escape): c.visible = false return c, core.CmdHandler(ClosedMsg{}) diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index d7c2a1298..adce5c6a2 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -655,6 +655,15 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return e, msg.Execute() } if e.currentCompletion.AutoSubmit() { + if msg.Tab { + // Tab inserts the value without submitting, allowing the user + // to continue typing (e.g., adding arguments to a skill). + e.textarea.SetValue(msg.Value + " ") + e.textarea.MoveToEnd() + e.userTyped = true + e.clearSuggestion() + return e, nil + } // For auto-submit completions (like commands), use the selected // command value (e.g., "/exit") instead of what the user typed // (e.g., "/e"). Append any extra text after the trigger word diff --git a/pkg/tui/dialog/command_palette.go b/pkg/tui/dialog/command_palette.go index c77129c77..b6af6a8bf 100644 --- a/pkg/tui/dialog/command_palette.go +++ b/pkg/tui/dialog/command_palette.go @@ -22,6 +22,12 @@ type CommandExecuteMsg struct { Command commands.Item } +// CommandAutoCompleteMsg is sent when a command is auto-completed via tab. +// The dialog closes and the slash command is inserted into the editor for further typing. +type CommandAutoCompleteMsg struct { + Command commands.Item +} + // commandPaletteDialog implements Dialog for the command palette type commandPaletteDialog struct { BaseDialog @@ -40,6 +46,7 @@ type commandPaletteKeyMap struct { Up key.Binding Down key.Binding Enter key.Binding + Tab key.Binding Escape key.Binding } @@ -58,6 +65,10 @@ func defaultCommandPaletteKeyMap() commandPaletteKeyMap { key.WithKeys("enter"), key.WithHelp("enter", "execute"), ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "complete"), + ), Escape: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "close"), @@ -120,7 +131,7 @@ func (d *commandPaletteDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { if cmdIdx == d.lastClickIndex && now.Sub(d.lastClickTime) < styles.DoubleClickThreshold { d.selected = cmdIdx d.lastClickTime = time.Time{} - cmd := d.executeSelected() + cmd := d.selectCommand(false) return d, cmd } d.selected = cmdIdx @@ -154,7 +165,11 @@ func (d *commandPaletteDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return d, nil case key.Matches(msg, d.keyMap.Enter): - cmd := d.executeSelected() + cmd := d.selectCommand(false) + return d, cmd + + case key.Matches(msg, d.keyMap.Tab): + cmd := d.selectCommand(true) return d, cmd default: @@ -168,12 +183,20 @@ func (d *commandPaletteDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return d, nil } -// executeSelected executes the currently selected command and closes the dialog. -func (d *commandPaletteDialog) executeSelected() tea.Cmd { +// selectCommand handles both enter (execute) and tab (auto-complete) on the +// selected command. When autoComplete is true, the slash command is inserted +// into the editor for further typing instead of being executed. +func (d *commandPaletteDialog) selectCommand(autoComplete bool) tea.Cmd { if d.selected < 0 || d.selected >= len(d.filtered) { return nil } selectedCmd := d.filtered[d.selected] + if autoComplete { + return tea.Sequence( + core.CmdHandler(CloseDialogMsg{}), + core.CmdHandler(CommandAutoCompleteMsg{Command: selectedCmd}), + ) + } cmds := []tea.Cmd{core.CmdHandler(CloseDialogMsg{})} if selectedCmd.Execute != nil { cmds = append(cmds, selectedCmd.Execute("")) @@ -338,7 +361,7 @@ func (d *commandPaletteDialog) View() string { AddSeparator(). AddContent(scrollableContent). AddSpace(). - AddHelpKeys("↑/↓", "navigate", "enter", "execute", "esc", "close"). + AddHelpKeys("↑/↓", "navigate", "enter", "execute", "tab", "complete", "esc", "close"). Build() return styles.DialogStyle.Width(dialogWidth).Render(content) diff --git a/pkg/tui/dialog/file_picker.go b/pkg/tui/dialog/file_picker.go index c29f0587f..972d69308 100644 --- a/pkg/tui/dialog/file_picker.go +++ b/pkg/tui/dialog/file_picker.go @@ -214,7 +214,7 @@ func (d *filePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { } return d, nil - case key.Matches(msg, d.keyMap.Enter): + case key.Matches(msg, d.keyMap.Enter) || key.Matches(msg, d.keyMap.Tab): if d.selected >= 0 && d.selected < len(d.filtered) { entry := d.filtered[d.selected] if entry.isDir { diff --git a/pkg/tui/dialog/model_picker.go b/pkg/tui/dialog/model_picker.go index 1b76991d9..da0fb9f6d 100644 --- a/pkg/tui/dialog/model_picker.go +++ b/pkg/tui/dialog/model_picker.go @@ -156,7 +156,7 @@ func (d *modelPickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { } return d, nil - case key.Matches(msg, d.keyMap.Enter): + case key.Matches(msg, d.keyMap.Enter) || key.Matches(msg, d.keyMap.Tab): cmd := d.handleSelection() return d, cmd diff --git a/pkg/tui/dialog/theme_picker.go b/pkg/tui/dialog/theme_picker.go index 61ecbe490..46ecb4329 100644 --- a/pkg/tui/dialog/theme_picker.go +++ b/pkg/tui/dialog/theme_picker.go @@ -198,7 +198,7 @@ func (d *themePickerDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { } return d, nil - case key.Matches(msg, d.keyMap.Enter): + case key.Matches(msg, d.keyMap.Enter) || key.Matches(msg, d.keyMap.Tab): cmd := d.handleSelection() return d, cmd diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 1576ec242..176c9c7a6 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -581,6 +581,14 @@ func (m *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cleanupAll() return m, tea.Quit + case dialog.CommandAutoCompleteMsg: + // Tab was pressed in the command palette: insert the slash command into the editor + // so the user can continue typing (e.g., adding arguments). + if msg.Command.SlashCommand != "" { + m.editor.SetValue(msg.Command.SlashCommand + " ") + } + return m, m.editor.Focus() + case dialog.RuntimeResumeMsg: m.application.Resume(msg.Request) return m, nil