From c40dc2f4fb318dad90cc24b02e87963d1573db1d Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Mon, 2 Mar 2026 15:37:52 +0300 Subject: [PATCH] feat: add argument tab-completion for extension slash commands Extensions can now provide a Complete function on CommandDef that supplies argument suggestions. When the user types a command name followed by a space, the input popup switches to argument-completion mode, calling Complete with the partial text and displaying matching suggestions. --- cmd/root.go | 10 +++- examples/extensions/bookmark.go | 22 +++++++++ internal/extensions/api.go | 4 ++ internal/ui/commands.go | 4 +- internal/ui/input.go | 87 ++++++++++++++++++++++++++++++--- internal/ui/model.go | 1 + 6 files changed, 118 insertions(+), 10 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 2689263d..e24cad3e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -296,13 +296,19 @@ func extensionCommandsForUI(k *kit.Kit) []ui.ExtensionCommand { if len(name) > 0 && name[0] != '/' { name = "/" + name } - cmds = append(cmds, ui.ExtensionCommand{ + ec := ui.ExtensionCommand{ Name: name, Description: d.Description, Execute: func(args string) (string, error) { return d.Execute(args, k.GetExtensionContext()) }, - }) + } + if d.Complete != nil { + ec.Complete = func(prefix string) []string { + return d.Complete(prefix, k.GetExtensionContext()) + } + } + cmds = append(cmds, ec) } return cmds } diff --git a/examples/extensions/bookmark.go b/examples/extensions/bookmark.go index ef4e50a2..762925d0 100644 --- a/examples/extensions/bookmark.go +++ b/examples/extensions/bookmark.go @@ -48,6 +48,28 @@ func Init(api ext.API) { ctx.PrintInfo(fmt.Sprintf("Bookmarked: %s (at message %d)", label, len(msgs))) return "", nil }, + Complete: func(prefix string, ctx ext.Context) []string { + // Suggest existing bookmark labels so the user can quickly + // re-bookmark at the same label. + entries := ctx.GetEntries("bookmark") + var labels []string + seen := map[string]bool{} + for _, e := range entries { + var data map[string]any + if err := json.Unmarshal([]byte(e.Data), &data); err != nil { + continue + } + label, _ := data["label"].(string) + if label == "" || seen[label] { + continue + } + if prefix == "" || strings.HasPrefix(strings.ToLower(label), strings.ToLower(prefix)) { + labels = append(labels, label) + seen[label] = true + } + } + return labels + }, }) api.RegisterCommand(ext.CommandDef{ diff --git a/internal/extensions/api.go b/internal/extensions/api.go index 5ba8bd0e..17707a60 100644 --- a/internal/extensions/api.go +++ b/internal/extensions/api.go @@ -1004,6 +1004,10 @@ type CommandDef struct { Name string Description string Execute func(args string, ctx Context) (string, error) + // Complete provides argument tab-completion for this command. + // Called with the partial argument text typed so far; returns + // candidate completions. Nil means no argument completion. + Complete func(prefix string, ctx Context) []string } // --------------------------------------------------------------------------- diff --git a/internal/ui/commands.go b/internal/ui/commands.go index a0869710..09d8923b 100644 --- a/internal/ui/commands.go +++ b/internal/ui/commands.go @@ -9,7 +9,8 @@ type SlashCommand struct { Name string Description string Aliases []string - Category string // e.g., "Navigation", "System", "Info" + Category string // e.g., "Navigation", "System", "Info" + Complete func(prefix string) []string // optional argument tab-completion } // SlashCommands provides the global registry of all available slash commands @@ -136,6 +137,7 @@ type ExtensionCommand struct { Name string Description string Execute func(args string) (string, error) + Complete func(prefix string) []string // optional argument tab-completion } // FindExtensionCommand looks up an extension command by name from the given diff --git a/internal/ui/input.go b/internal/ui/input.go index 119aafd3..f108e51f 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -36,6 +36,13 @@ type InputComponent struct { title string submitNext bool // defer submit one tick so popup dismisses cleanly + // Argument completion state. When the user types "/cmd " followed by + // a partial argument and the command has a Complete function, the popup + // switches to argument-completion mode showing suggestions from Complete. + argMode bool // true when showing arg completions + argCommand string // command prefix for arg mode (e.g. "/bookmark") + argSynthCmds []SlashCommand // backing storage for synthetic arg entries + // appCtrl is used for slash commands that mutate app state. // May be nil in tests; nil-safe. appCtrl AppController @@ -141,7 +148,11 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): if s.selected < len(s.filtered) { - s.textarea.SetValue(s.filtered[s.selected].Command.Name) + if s.argMode { + s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name) + } else { + s.textarea.SetValue(s.filtered[s.selected].Command.Name) + } s.showPopup = false s.selected = 0 s.textarea.CursorEnd() @@ -150,8 +161,12 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): if s.selected < len(s.filtered) { - // Populate textarea with selected command and submit on next tick. - s.textarea.SetValue(s.filtered[s.selected].Command.Name) + // Populate textarea with selected item and submit on next tick. + if s.argMode { + s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name) + } else { + s.textarea.SetValue(s.filtered[s.selected].Command.Name) + } s.textarea.CursorEnd() s.showPopup = false s.selected = 0 @@ -175,12 +190,26 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if value != s.lastValue { s.lastValue = value lines := strings.Split(value, "\n") - if len(lines) == 1 && strings.HasPrefix(lines[0], "/") && !strings.Contains(lines[0], " ") { - s.showPopup = true - s.filtered = FuzzyMatchCommands(lines[0], s.commands) - s.selected = 0 + if len(lines) == 1 && strings.HasPrefix(lines[0], "/") { + if !strings.Contains(lines[0], " ") { + // Command name completion. + s.showPopup = true + s.argMode = false + s.filtered = FuzzyMatchCommands(lines[0], s.commands) + s.selected = 0 + } else if suggestions := s.completeArgs(lines[0]); len(suggestions) > 0 { + // Argument completion for a command with a Complete function. + s.showPopup = true + // s.argMode, s.argCommand, s.argSynthCmds, s.filtered + // are set by completeArgs. + s.selected = 0 + } else { + s.showPopup = false + s.argMode = false + } } else { s.showPopup = false + s.argMode = false } } return s, cmd @@ -331,3 +360,47 @@ func (s *InputComponent) renderPopup() string { return popupStyle.Render(content + "\n\n" + footer) } + +// completeArgs checks whether the input line matches a command with a Complete +// function, calls it, and populates the arg-mode state on success. Returns the +// list of suggestions (empty means no completions available). +func (s *InputComponent) completeArgs(line string) []FuzzyMatch { + parts := strings.SplitN(line, " ", 2) + cmdName := parts[0] + argPrefix := "" + if len(parts) > 1 { + argPrefix = parts[1] + } + + cmd := s.findCommandWithComplete(cmdName) + if cmd == nil { + return nil + } + + suggestions := cmd.Complete(argPrefix) + if len(suggestions) == 0 { + s.argMode = false + return nil + } + + s.argMode = true + s.argCommand = cmdName + s.argSynthCmds = make([]SlashCommand, len(suggestions)) + s.filtered = make([]FuzzyMatch, len(suggestions)) + for i, sug := range suggestions { + s.argSynthCmds[i] = SlashCommand{Name: sug} + s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]} + } + return s.filtered +} + +// findCommandWithComplete looks up a command by name that has a non-nil +// Complete function. +func (s *InputComponent) findCommandWithComplete(name string) *SlashCommand { + for i := range s.commands { + if s.commands[i].Name == name && s.commands[i].Complete != nil { + return &s.commands[i] + } + } + return nil +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 846623e0..f03348a5 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -517,6 +517,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { Name: ec.Name, Description: ec.Description, Category: "Extensions", + Complete: ec.Complete, }) } }