From 918009e14f11084d5b8194a12d2cfdec5de8a908 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:55:32 -0700 Subject: [PATCH] feat(completion): complete session aliases and config keys Shell completion only offered static tokens before: command names, flags, shell names, and config subcommands. The values you actually reach for, session aliases for `dispatch open` and config keys for `dispatch config get`/`set`/`unset`, still had to be typed by hand. Add a hidden `dispatch __complete ` command that prints newline-separated candidates for a kind (aliases, config-keys, shells) and wire the bash, zsh, fish, and PowerShell scripts to call it at the right argument positions. Aliases read local config and config keys come from the settable field list, so each lookup stays fast. Unknown kinds print nothing and exit zero so a completer never breaks, and the command is left out of help and usage. Closes #298 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + cmd/dispatch/cli.go | 77 +++++++++++++----- cmd/dispatch/complete.go | 70 ++++++++++++++++ cmd/dispatch/complete_test.go | 147 ++++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 cmd/dispatch/complete.go create mode 100644 cmd/dispatch/complete_test.go diff --git a/README.md b/README.md index eaec257..15352d5 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ dispatch completion fish dispatch completion powershell ``` +Once the script is sourced, completion covers dynamic values too: `dispatch open ` completes your configured session aliases, `dispatch config get ` (and `set`/`unset`) completes config keys, and `dispatch completion ` completes the supported shells. These come from your local config and static lists, so completion stays fast. + ### Diagnostics Run `dispatch doctor` to print setup checks for the config file, session store, session-state directory, and Copilot CLI binary. It also reports the detected Copilot CLI version and the number of stored sessions. diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 813dfa9..a1eaef5 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -141,6 +141,12 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "__complete": + // Hidden helper used by the shell completion scripts to fetch + // dynamic candidates. Deliberately omitted from help and usage. + runComplete(os.Stdout, args) + return true, cleanup, startupOptions{}, nil + case "--demo": c, demoErr := setupDemo() if demoErr != nil { @@ -288,6 +294,7 @@ func runCompletion(w io.Writer, shell string) error { const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" + local bin="${COMP_WORDS[0]}" local commands="help version open new doctor update completion stats search tags config export" local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query" @@ -296,24 +303,33 @@ _dispatch_completion() { return 0 fi - if [[ "${COMP_WORDS[1]}" == "completion" ]]; then - COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "${cur}") ) - return 0 - fi - - if [[ "${COMP_WORDS[1]}" == "config" ]]; then - COMPREPLY=( $(compgen -W "list get set edit path" -- "${cur}") ) - return 0 - fi + case "${COMP_WORDS[1]}" in + completion) + COMPREPLY=( $(compgen -W "$("${bin}" __complete shells)" -- "${cur}") ) + return 0 + ;; + open) + COMPREPLY=( $(compgen -W "$("${bin}" __complete aliases)" -- "${cur}") ) + return 0 + ;; + config) + if [[ "${COMP_CWORD}" -eq 2 ]]; then + COMPREPLY=( $(compgen -W "list get set edit path" -- "${cur}") ) + elif [[ "${COMP_WORDS[2]}" == "get" || "${COMP_WORDS[2]}" == "set" || "${COMP_WORDS[2]}" == "unset" ]]; then + COMPREPLY=( $(compgen -W "$("${bin}" __complete config-keys)" -- "${cur}") ) + fi + return 0 + ;; + esac } complete -F _dispatch_completion dispatch disp ` const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { - local -a commands shells flags configsubs + local -a commands flags configsubs shells aliases configkeys + local bin=${words[1]} commands=(help version open new doctor update completion stats search tags config export) - shells=(bash zsh fish powershell) configsubs=(list get set edit path) flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query) @@ -323,12 +339,24 @@ _dispatch_completion() { fi if [[ ${words[2]} == completion ]]; then + shells=(${(f)"$($bin __complete shells)"}) _describe -t shells 'shell' shells return fi + if [[ ${words[2]} == open ]]; then + aliases=(${(f)"$($bin __complete aliases)"}) + _describe -t aliases 'session alias' aliases + return + fi + if [[ ${words[2]} == config ]]; then - _describe -t configsubs 'config subcommand' configsubs + if (( CURRENT == 3 )); then + _describe -t configsubs 'config subcommand' configsubs + elif [[ ${words[3]} == get || ${words[3]} == set || ${words[3]} == unset ]]; then + configkeys=(${(f)"$($bin __complete config-keys)"}) + _describe -t configkeys 'config key' configkeys + fi return fi } @@ -341,32 +369,45 @@ function __dispatch_needs_command test (count $cmd) -eq 1 end -function __dispatch_using_completion +function __dispatch_after + set -l cmd (commandline -opc) + test (count $cmd) -ge 2; and test $cmd[2] = $argv[1] +end + +function __dispatch_config_key set -l cmd (commandline -opc) - test (count $cmd) -ge 2; and test $cmd[2] = completion + test (count $cmd) -ge 3; and test $cmd[2] = config; and contains -- $cmd[3] get set unset end for bin in dispatch disp complete -c $bin -f complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export' complete -c $bin -n '__dispatch_needs_command' -a '-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query' - complete -c $bin -n '__dispatch_using_completion' -a 'bash zsh fish powershell' + complete -c $bin -n '__dispatch_after completion' -a "($bin __complete shells)" + complete -c $bin -n '__dispatch_after open' -a "($bin __complete aliases)" + complete -c $bin -n '__dispatch_config_key' -a "($bin __complete config-keys)" end ` const powershellCompletionScript = `# PowerShell completion for dispatch $script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query') -$script:DispatchShells = @('bash', 'zsh', 'fish', 'powershell') $script:DispatchConfigSubcommands = @('list', 'get', 'set', 'edit', 'path') Register-ArgumentCompleter -Native -CommandName dispatch, disp -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) $tokens = @($commandAst.CommandElements | ForEach-Object { $_.ToString() }) + $bin = $tokens[0] $values = if ($tokens.Count -ge 2 -and $tokens[1] -eq 'completion') { - $script:DispatchShells + & $bin __complete shells + } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'open') { + & $bin __complete aliases } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'config') { - $script:DispatchConfigSubcommands + if ($tokens.Count -ge 3 -and @('get', 'set', 'unset') -contains $tokens[2]) { + & $bin __complete config-keys + } else { + $script:DispatchConfigSubcommands + } } else { $script:DispatchCommands + $script:DispatchFlags } diff --git a/cmd/dispatch/complete.go b/cmd/dispatch/complete.go new file mode 100644 index 0000000..c6bc7a1 --- /dev/null +++ b/cmd/dispatch/complete.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "io" + "sort" +) + +// completionShells lists the shells the completion scripts support. It is the +// canonical source for the "shells" completion kind. +var completionShells = []string{"bash", "zsh", "fish", "powershell"} + +// runComplete backs the hidden "__complete " command. It prints +// newline-separated completion candidates for the requested kind so the shell +// completion scripts can offer dynamic values (session aliases, config keys) +// that cannot be hardcoded. Unknown kinds print nothing so a completer never +// breaks. The command is intentionally absent from help and usage output. +func runComplete(w io.Writer, args []string) { + if w == nil { + w = io.Discard + } + if len(args) < 2 { + return + } + + var candidates []string + switch args[1] { + case "aliases": + candidates = completeAliases() + case "config-keys": + candidates = completeConfigKeys() + case "shells": + candidates = completionShells + default: + return + } + + for _, c := range candidates { + fmt.Fprintln(w, c) + } +} + +// completeAliases returns the configured session aliases in sorted order. A +// config load failure yields no candidates rather than an error so completion +// stays non-fatal. +func completeAliases() []string { + cfg, err := configLoadFn() + if err != nil { + return nil + } + aliases := make([]string, 0, len(cfg.SessionAliases)) + for _, alias := range cfg.SessionAliases { + if alias != "" { + aliases = append(aliases, alias) + } + } + sort.Strings(aliases) + return aliases +} + +// completeConfigKeys returns the settable config key names in their stable +// display order. +func completeConfigKeys() []string { + fields := configFields() + keys := make([]string, 0, len(fields)) + for _, f := range fields { + keys = append(keys, f.name) + } + return keys +} diff --git a/cmd/dispatch/complete_test.go b/cmd/dispatch/complete_test.go new file mode 100644 index 0000000..9ab5608 --- /dev/null +++ b/cmd/dispatch/complete_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/update" +) + +func TestRunComplete_ConfigKeys(t *testing.T) { + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "config-keys"}) + lines := splitNonEmptyLines(buf.String()) + + want := len(configFields()) + if len(lines) != want { + t.Fatalf("got %d keys, want %d:\n%s", len(lines), want, buf.String()) + } + for _, key := range []string{"agent", "model", "yoloMode", "theme"} { + if !containsLine(lines, key) { + t.Errorf("config-keys output missing %q", key) + } + } +} + +func TestRunComplete_Aliases(t *testing.T) { + cfg := config.Default() + cfg.SessionAliases = map[string]string{"s2": "bugfix", "s1": "authfix"} + withConfigSeams(t, cfg) + + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "aliases"}) + // Aliases are sorted regardless of map iteration order. + if got := buf.String(); got != "authfix\nbugfix\n" { + t.Errorf("aliases output = %q, want sorted list", got) + } +} + +func TestRunComplete_AliasesEmpty(t *testing.T) { + withConfigSeams(t, config.Default()) + + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "aliases"}) + if got := buf.String(); got != "" { + t.Errorf("aliases output = %q, want empty", got) + } +} + +func TestRunComplete_AliasesLoadErrorIsSilent(t *testing.T) { + prev := configLoadFn + configLoadFn = func() (*config.Config, error) { return nil, errors.New("boom") } + t.Cleanup(func() { configLoadFn = prev }) + + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "aliases"}) + if got := buf.String(); got != "" { + t.Errorf("aliases output = %q, want empty on load error", got) + } +} + +func TestRunComplete_Shells(t *testing.T) { + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "shells"}) + if got := buf.String(); got != "bash\nzsh\nfish\npowershell\n" { + t.Errorf("shells output = %q", got) + } +} + +func TestRunComplete_UnknownKindIsSilent(t *testing.T) { + var buf bytes.Buffer + runComplete(&buf, []string{"__complete", "bogus"}) + if got := buf.String(); got != "" { + t.Errorf("unknown kind output = %q, want empty", got) + } +} + +func TestRunComplete_NoKindIsSilent(t *testing.T) { + var buf bytes.Buffer + runComplete(&buf, []string{"__complete"}) + if got := buf.String(); got != "" { + t.Errorf("no-kind output = %q, want empty", got) + } +} + +func TestHandleArgs_Complete(t *testing.T) { + ch := make(chan *update.UpdateInfo, 1) + + done, cleanup, _, err := handleArgs([]string{"__complete", "shells"}, io.Discard, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true for __complete") + } + if cleanup != nil { + t.Error("expected cleanup=nil for __complete") + } +} + +func TestPrintUsage_HidesCompleteCommand(t *testing.T) { + var buf bytes.Buffer + prev := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + done := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + + printUsage() + + w.Close() + os.Stdout = prev + <-done + + if strings.Contains(buf.String(), "__complete") { + t.Errorf("printUsage must not advertise the hidden __complete command:\n%s", buf.String()) + } +} + +func splitNonEmptyLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) != "" { + out = append(out, line) + } + } + return out +} + +func containsLine(lines []string, want string) bool { + for _, l := range lines { + if l == want { + return true + } + } + return false +}