From 8d7bc65696a94265bf7c1e1f0ce60a8eb6452a8b Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:13:20 -0700 Subject: [PATCH 1/2] feat(open,new): add per-launch agent, model, and yolo overrides Add --agent, --model, and --yolo flags to the open and new subcommands so a single launch can override the saved config values without editing config first. The overrides apply to every launch mode and to --print, and are never written back to the config file. Also fix a latent deadlock in TestPrintUsage_Output. It wrote printUsage output to an os.Pipe and only drained it after the call returned, which blocks once the text passes the Windows pipe buffer size. The new usage lines pushed it over that limit, so the test now drains the pipe from a goroutine. Closes #294 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 14 +- cmd/dispatch/cli.go | 37 +++++- cmd/dispatch/launch_overrides.go | 91 +++++++++++++ cmd/dispatch/launch_overrides_test.go | 178 ++++++++++++++++++++++++++ cmd/dispatch/main.go | 8 ++ cmd/dispatch/main_test.go | 16 ++- cmd/dispatch/new.go | 29 +++-- cmd/dispatch/new_test.go | 2 +- cmd/dispatch/open.go | 30 +++-- cmd/dispatch/open_test.go | 33 ++++- 10 files changed, 409 insertions(+), 29 deletions(-) create mode 100644 cmd/dispatch/launch_overrides.go create mode 100644 cmd/dispatch/launch_overrides_test.go diff --git a/README.md b/README.md index eaec257..23ca150 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,16 @@ Use `--print` to write the resume command to stdout instead of launching it. Thi dispatch open --print ``` +Override the agent, model, or yolo mode for a single resume without changing your saved config with `--agent`, `--model`, and `--yolo`: + +```sh +dispatch open --agent coder --model gpt-5 +dispatch open --last --yolo +dispatch open --yolo=false # force off even if config enables it +``` + +These flags apply to every launch mode and to `--print`. When omitted, the saved config values are used. + ### Start a New Session Start a brand-new Copilot session from the command line without opening the TUI: @@ -191,9 +201,11 @@ Start a brand-new Copilot session from the command line without opening the TUI: dispatch new # start in the current directory dispatch new ~/code/app # start in a specific directory dispatch new --mode tab # override the launch mode (inplace, tab, window, pane) +dispatch new --agent coder --model gpt-5 # override agent/model for this session +dispatch new --yolo # start with yolo mode on for this session only ``` -The session uses the same agent, model, and launch settings as the TUI. When no directory is given, the current working directory is used. +The session uses the same agent, model, and launch settings as the TUI unless you pass `--agent`, `--model`, or `--yolo` to override them for that one launch. When no directory is given, the current working directory is used. ### Shell Completion diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 813dfa9..290e783 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -305,16 +305,28 @@ _dispatch_completion() { COMPREPLY=( $(compgen -W "list get set edit path" -- "${cur}") ) return 0 fi + + if [[ "${COMP_WORDS[1]}" == "open" ]]; then + COMPREPLY=( $(compgen -W "--mode --last --print --agent --model --yolo" -- "${cur}") ) + return 0 + fi + + if [[ "${COMP_WORDS[1]}" == "new" ]]; then + COMPREPLY=( $(compgen -W "--mode --agent --model --yolo" -- "${cur}") ) + return 0 + fi } complete -F _dispatch_completion dispatch disp ` const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { - local -a commands shells flags configsubs + local -a commands shells flags configsubs openflags newflags commands=(help version open new doctor update completion stats search tags config export) shells=(bash zsh fish powershell) configsubs=(list get set edit path) + openflags=(--mode --last --print --agent --model --yolo) + newflags=(--mode --agent --model --yolo) flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query) if (( CURRENT == 2 )); then @@ -331,6 +343,16 @@ _dispatch_completion() { _describe -t configsubs 'config subcommand' configsubs return fi + + if [[ ${words[2]} == open ]]; then + _describe -t openflags 'open flag' openflags + return + fi + + if [[ ${words[2]} == new ]]; then + _describe -t newflags 'new flag' newflags + return + fi } _dispatch_completion "$@" ` @@ -346,11 +368,18 @@ function __dispatch_using_completion test (count $cmd) -ge 2; and test $cmd[2] = completion end +function __dispatch_using_subcommand + set -l cmd (commandline -opc) + test (count $cmd) -ge 2; and test $cmd[2] = $argv[1] +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_using_subcommand open' -a '--mode --last --print --agent --model --yolo' + complete -c $bin -n '__dispatch_using_subcommand new' -a '--mode --agent --model --yolo' end ` @@ -359,6 +388,8 @@ $script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update $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') +$script:DispatchOpenFlags = @('--mode', '--last', '--print', '--agent', '--model', '--yolo') +$script:DispatchNewFlags = @('--mode', '--agent', '--model', '--yolo') Register-ArgumentCompleter -Native -CommandName dispatch, disp -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) @@ -367,6 +398,10 @@ Register-ArgumentCompleter -Native -CommandName dispatch, disp -ScriptBlock { $script:DispatchShells } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'config') { $script:DispatchConfigSubcommands + } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'open') { + $script:DispatchOpenFlags + } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'new') { + $script:DispatchNewFlags } else { $script:DispatchCommands + $script:DispatchFlags } diff --git a/cmd/dispatch/launch_overrides.go b/cmd/dispatch/launch_overrides.go new file mode 100644 index 0000000..ab15c8f --- /dev/null +++ b/cmd/dispatch/launch_overrides.go @@ -0,0 +1,91 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/jongio/dispatch/internal/config" +) + +// launchOverrides holds per-invocation overrides for the agent, model, and +// yolo settings parsed from the open and new subcommand flags. A nil pointer +// means the flag was not given, so the saved config value is used for that +// setting. These overrides are never written back to the config file. +type launchOverrides struct { + agent *string + model *string + yolo *bool +} + +// apply copies any set overrides onto cfg. cfg is the freshly loaded config for +// a single launch and is never persisted, so this only affects the current +// invocation. Callers apply overrides before building a platform.ResumeConfig +// so every launch mode and --print see the same values. +func (o launchOverrides) apply(cfg *config.Config) { + if o.agent != nil { + cfg.Agent = *o.agent + } + if o.model != nil { + cfg.Model = *o.model + } + if o.yolo != nil { + cfg.YoloMode = *o.yolo + } +} + +// matchLaunchOverride interprets rest[i] as one of the shared --agent, --model, +// or --yolo override flags used by both the open and new subcommands. It +// returns matched=true when the token is one of those flags, the index the +// caller should continue from (next), and an error for a missing or invalid +// value. When matched is false the caller handles the token itself. Both +// "--flag value" and "--flag=value" forms are accepted; --yolo also accepts a +// bare form (implies true) and an explicit --yolo=true/false value. +func matchLaunchOverride(rest []string, i int, ov *launchOverrides) (matched bool, next int, err error) { + name, inline, hasInline := splitFlag(rest[i]) + + switch name { + case "--agent", "--model": + value, ni, vErr := takeOverrideValue(rest, i, name, inline, hasInline) + if vErr != nil { + return true, i, vErr + } + if name == "--agent" { + ov.agent = &value + } else { + ov.model = &value + } + return true, ni, nil + case "--yolo": + if !hasInline { + enabled := true + ov.yolo = &enabled + return true, i, nil + } + enabled, pErr := strconv.ParseBool(strings.TrimSpace(inline)) + if pErr != nil { + return true, i, fmt.Errorf("invalid value %q for --yolo (want true or false)", inline) + } + ov.yolo = &enabled + return true, i, nil + default: + return false, i, nil + } +} + +// takeOverrideValue resolves the value for a value-taking override flag, +// accepting both "--flag value" and "--flag=value" forms. It returns the index +// the caller should continue from: unchanged for the inline form, advanced past +// the consumed value for the space-separated form. +func takeOverrideValue(rest []string, i int, name, inline string, hasInline bool) (value string, next int, err error) { + if hasInline { + if inline == "" { + return "", i, fmt.Errorf("%s requires a value", name) + } + return inline, i, nil + } + if i+1 >= len(rest) { + return "", i, fmt.Errorf("%s requires a value", name) + } + return rest[i+1], i + 1, nil +} diff --git a/cmd/dispatch/launch_overrides_test.go b/cmd/dispatch/launch_overrides_test.go new file mode 100644 index 0000000..d95ba6f --- /dev/null +++ b/cmd/dispatch/launch_overrides_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "testing" + + "github.com/jongio/dispatch/internal/config" +) + +func TestParseOpenArgs_Overrides(t *testing.T) { + tests := []struct { + name string + args []string + wantID string + wantMode string + wantAgent *string + wantModel *string + wantYolo *bool + wantErr bool + }{ + {name: "agent space", args: []string{"open", "abc", "--agent", "coder"}, wantID: "abc", wantAgent: ptr("coder")}, + {name: "agent equals", args: []string{"open", "abc", "--agent=coder"}, wantID: "abc", wantAgent: ptr("coder")}, + {name: "model space", args: []string{"open", "abc", "--model", "gpt-5"}, wantID: "abc", wantModel: ptr("gpt-5")}, + {name: "model equals", args: []string{"open", "abc", "--model=gpt-5"}, wantID: "abc", wantModel: ptr("gpt-5")}, + {name: "yolo bare", args: []string{"open", "abc", "--yolo"}, wantID: "abc", wantYolo: ptr(true)}, + {name: "yolo true", args: []string{"open", "abc", "--yolo=true"}, wantID: "abc", wantYolo: ptr(true)}, + {name: "yolo false", args: []string{"open", "abc", "--yolo=false"}, wantID: "abc", wantYolo: ptr(false)}, + { + name: "all overrides before id", + args: []string{"open", "--agent", "coder", "--model", "gpt-5", "--yolo", "abc"}, + wantID: "abc", + wantAgent: ptr("coder"), + wantModel: ptr("gpt-5"), + wantYolo: ptr(true), + }, + {name: "override with mode", args: []string{"open", "abc", "--agent", "coder", "--mode", "tab"}, wantID: "abc", wantMode: "tab", wantAgent: ptr("coder")}, + {name: "agent missing value", args: []string{"open", "abc", "--agent"}, wantErr: true}, + {name: "model missing value at end", args: []string{"open", "abc", "--model"}, wantErr: true}, + {name: "agent empty inline", args: []string{"open", "abc", "--agent="}, wantErr: true}, + {name: "yolo invalid", args: []string{"open", "abc", "--yolo=maybe"}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + id, mode, _, _, ov, err := parseOpenArgs(tc.args) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got id=%q mode=%q", id, mode) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != tc.wantID { + t.Errorf("id = %q, want %q", id, tc.wantID) + } + if mode != tc.wantMode { + t.Errorf("mode = %q, want %q", mode, tc.wantMode) + } + assertStrPtr(t, "agent", ov.agent, tc.wantAgent) + assertStrPtr(t, "model", ov.model, tc.wantModel) + assertBoolPtr(t, "yolo", ov.yolo, tc.wantYolo) + }) + } +} + +func TestParseNewArgs_Overrides(t *testing.T) { + tests := []struct { + name string + args []string + wantDir string + wantMode string + wantAgent *string + wantModel *string + wantYolo *bool + wantErr bool + }{ + {name: "agent only", args: []string{"new", "--agent", "coder"}, wantAgent: ptr("coder")}, + {name: "dir plus overrides", args: []string{"new", "/tmp/proj", "--model=gpt-5", "--yolo"}, wantDir: "/tmp/proj", wantModel: ptr("gpt-5"), wantYolo: ptr(true)}, + {name: "override with mode", args: []string{"new", "--mode", "window", "--agent", "coder"}, wantMode: "window", wantAgent: ptr("coder")}, + {name: "model missing value", args: []string{"new", "--model"}, wantErr: true}, + {name: "yolo invalid", args: []string{"new", "--yolo=nope"}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir, mode, ov, err := parseNewArgs(tc.args) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got dir=%q mode=%q", dir, mode) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dir != tc.wantDir { + t.Errorf("dir = %q, want %q", dir, tc.wantDir) + } + if mode != tc.wantMode { + t.Errorf("mode = %q, want %q", mode, tc.wantMode) + } + assertStrPtr(t, "agent", ov.agent, tc.wantAgent) + assertStrPtr(t, "model", ov.model, tc.wantModel) + assertBoolPtr(t, "yolo", ov.yolo, tc.wantYolo) + }) + } +} + +func TestLaunchOverridesApply(t *testing.T) { + t.Run("empty overrides leave config unchanged", func(t *testing.T) { + cfg := &config.Config{Agent: "base", Model: "base-model", YoloMode: true} + launchOverrides{}.apply(cfg) + if cfg.Agent != "base" || cfg.Model != "base-model" || !cfg.YoloMode { + t.Fatalf("empty overrides mutated config: %+v", cfg) + } + }) + + t.Run("set overrides replace config values", func(t *testing.T) { + cfg := &config.Config{Agent: "base", Model: "base-model", YoloMode: false} + launchOverrides{agent: ptr("coder"), model: ptr("gpt-5"), yolo: ptr(true)}.apply(cfg) + if cfg.Agent != "coder" { + t.Errorf("agent = %q, want %q", cfg.Agent, "coder") + } + if cfg.Model != "gpt-5" { + t.Errorf("model = %q, want %q", cfg.Model, "gpt-5") + } + if !cfg.YoloMode { + t.Error("yolo = false, want true") + } + }) + + t.Run("yolo false override disables enabled config", func(t *testing.T) { + cfg := &config.Config{YoloMode: true} + launchOverrides{yolo: ptr(false)}.apply(cfg) + if cfg.YoloMode { + t.Error("yolo = true, want false") + } + }) +} + +func TestMatchLaunchOverride_NonOverrideFlag(t *testing.T) { + var ov launchOverrides + matched, next, err := matchLaunchOverride([]string{"--mode", "tab"}, 0, &ov) + if matched { + t.Fatal("expected --mode to not match a launch override") + } + if next != 0 || err != nil { + t.Fatalf("unexpected next=%d err=%v", next, err) + } + if ov.agent != nil || ov.model != nil || ov.yolo != nil { + t.Errorf("overrides mutated on non-match: %+v", ov) + } +} + +func ptr[T any](v T) *T { return &v } + +func assertStrPtr(t *testing.T, field string, got, want *string) { + t.Helper() + switch { + case want == nil && got != nil: + t.Errorf("%s = %q, want unset", field, *got) + case want != nil && got == nil: + t.Errorf("%s unset, want %q", field, *want) + case want != nil && got != nil && *got != *want: + t.Errorf("%s = %q, want %q", field, *got, *want) + } +} + +func assertBoolPtr(t *testing.T, field string, got, want *bool) { + t.Helper() + switch { + case want == nil && got != nil: + t.Errorf("%s = %v, want unset", field, *got) + case want != nil && got == nil: + t.Errorf("%s unset, want %v", field, *want) + case want != nil && got != nil && *got != *want: + t.Errorf("%s = %v, want %v", field, *got, *want) + } +} diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index e6833e7..86de4c4 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -129,6 +129,14 @@ Commands: export [flags] Export a session as Markdown or JSON update Update dispatch to the latest release +Open and new flags: + --mode Launch mode for this run (inplace, tab, window, pane) + --agent Override the configured agent for this run only + --model Override the configured model for this run only + --yolo[=true|false] Override yolo mode for this run only (bare form implies true) + --last (open only) Resume the most recently active session + --print (open only) Print the resume command instead of launching + Stats flags: --json Print the summary as JSON --calendar Add a per-day activity heatmap diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 08e19a2..0790dcb 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -457,15 +457,23 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + // Drain the pipe from a separate goroutine so printUsage never blocks on a + // full pipe buffer. Windows pipes buffer only a few KB, which is smaller + // than the usage text, so a synchronous read after printUsage would + // deadlock. + captured := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + captured <- buf.String() + }() + printUsage() w.Close() os.Stdout = origStdout - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - - output := buf.String() + output := <-captured for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} { if !strings.Contains(output, want) { t.Errorf("printUsage() should mention %q, got:\n%s", want, output) diff --git a/cmd/dispatch/new.go b/cmd/dispatch/new.go index cd5217c..4b5da38 100644 --- a/cmd/dispatch/new.go +++ b/cmd/dispatch/new.go @@ -27,7 +27,7 @@ func runNew(w io.Writer, args []string) error { w = io.Discard } - dir, modeFlag, err := parseNewArgs(args) + dir, modeFlag, ov, err := parseNewArgs(args) if err != nil { return err } @@ -36,6 +36,7 @@ func runNew(w io.Writer, args []string) error { if err != nil { return fmt.Errorf("loading config: %w", err) } + ov.apply(cfg) mode := resolveOpenMode(modeFlag, cfg) @@ -47,10 +48,11 @@ func runNew(w io.Writer, args []string) error { return newLaunchFn(w, cfg, resolvedDir, mode) } -// parseNewArgs extracts the optional directory and launch mode from the "new" -// subcommand arguments. args[0] is expected to be "new". A missing directory -// means the current working directory. -func parseNewArgs(args []string) (dir, mode string, err error) { +// parseNewArgs extracts the optional directory, launch mode, and any per-launch +// agent/model/yolo overrides from the "new" subcommand arguments. args[0] is +// expected to be "new". A missing directory means the current working +// directory. +func parseNewArgs(args []string) (dir, mode string, ov launchOverrides, err error) { rest := args if len(rest) > 0 { rest = rest[1:] // drop the "new" token @@ -59,17 +61,24 @@ func parseNewArgs(args []string) (dir, mode string, err error) { var positionals []string for i := 0; i < len(rest); i++ { arg := rest[i] + if matched, next, mErr := matchLaunchOverride(rest, i, &ov); matched { + if mErr != nil { + return "", "", launchOverrides{}, mErr + } + i = next + continue + } switch { case arg == "--mode" || arg == "-m": if i+1 >= len(rest) { - return "", "", errors.New("--mode requires a value: inplace, tab, window, or pane") + return "", "", launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane") } mode = rest[i+1] i++ case strings.HasPrefix(arg, "--mode="): mode = strings.TrimPrefix(arg, "--mode=") case strings.HasPrefix(arg, "-"): - return "", "", fmt.Errorf("unknown flag: %s", arg) + return "", "", launchOverrides{}, fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } @@ -81,15 +90,15 @@ func parseNewArgs(args []string) (dir, mode string, err error) { case 1: dir = positionals[0] default: - return "", "", fmt.Errorf("new accepts a single directory, got %d arguments", len(positionals)) + return "", "", launchOverrides{}, fmt.Errorf("new accepts a single directory, got %d arguments", len(positionals)) } if mode != "" { if _, mErr := normalizeLaunchMode(mode); mErr != nil { - return "", "", mErr + return "", "", launchOverrides{}, mErr } } - return dir, mode, nil + return dir, mode, ov, nil } // resolveNewDir validates the target directory and returns an absolute path. diff --git a/cmd/dispatch/new_test.go b/cmd/dispatch/new_test.go index 95629c1..bb9c318 100644 --- a/cmd/dispatch/new_test.go +++ b/cmd/dispatch/new_test.go @@ -31,7 +31,7 @@ func TestParseNewArgs(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - dir, mode, err := parseNewArgs(tc.args) + dir, mode, _, err := parseNewArgs(tc.args) if tc.wantErr { if err == nil { t.Fatalf("expected error, got dir=%q mode=%q", dir, mode) diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index 7611339..acff0d0 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -33,7 +33,7 @@ func runOpen(w io.Writer, args []string) error { w = io.Discard } - id, modeFlag, last, printCmd, err := parseOpenArgs(args) + id, modeFlag, last, printCmd, ov, err := parseOpenArgs(args) if err != nil { return err } @@ -42,6 +42,7 @@ func runOpen(w io.Writer, args []string) error { if err != nil { return fmt.Errorf("loading config: %w", err) } + ov.apply(cfg) var sess *data.Session if last { @@ -94,9 +95,9 @@ func openResumeConfig(cfg *config.Config, sess *data.Session) platform.ResumeCon } // parseOpenArgs extracts the session ID, optional launch mode, the --last -// flag, and the --print flag from the "open" subcommand arguments. args[0] is -// expected to be "open". -func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, err error) { +// flag, the --print flag, and any per-launch agent/model/yolo overrides from +// the "open" subcommand arguments. args[0] is expected to be "open". +func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, ov launchOverrides, err error) { rest := args if len(rest) > 0 { rest = rest[1:] // drop the "open" token @@ -105,12 +106,19 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, err err var positionals []string for i := 0; i < len(rest); i++ { arg := rest[i] + if matched, next, mErr := matchLaunchOverride(rest, i, &ov); matched { + if mErr != nil { + return "", "", false, false, launchOverrides{}, mErr + } + i = next + continue + } switch { case arg == "--last" || arg == "-l": last = true case arg == "--mode" || arg == "-m": if i+1 >= len(rest) { - return "", "", false, false, errors.New("--mode requires a value: inplace, tab, window, or pane") + return "", "", false, false, launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane") } mode = rest[i+1] i++ @@ -119,7 +127,7 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, err err case arg == "--print": printCmd = true case strings.HasPrefix(arg, "-"): - return "", "", false, false, fmt.Errorf("unknown flag: %s", arg) + return "", "", false, false, launchOverrides{}, fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } @@ -127,25 +135,25 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, err err if last { if len(positionals) > 0 { - return "", "", false, false, errors.New("open --last does not take a session ID") + return "", "", false, false, launchOverrides{}, errors.New("open --last does not take a session ID") } } else { switch len(positionals) { case 0: - return "", "", false, false, errors.New("open requires a session ID (or use --last)") + return "", "", false, false, launchOverrides{}, errors.New("open requires a session ID (or use --last)") case 1: id = positionals[0] default: - return "", "", false, false, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals)) + return "", "", false, false, launchOverrides{}, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals)) } } if mode != "" { if _, mErr := normalizeLaunchMode(mode); mErr != nil { - return "", "", false, false, mErr + return "", "", false, false, launchOverrides{}, mErr } } - return id, mode, last, printCmd, nil + return id, mode, last, printCmd, ov, nil } // normalizeLaunchMode maps a user-facing mode string to a config launch mode. diff --git a/cmd/dispatch/open_test.go b/cmd/dispatch/open_test.go index cc2aa37..8dc215a 100644 --- a/cmd/dispatch/open_test.go +++ b/cmd/dispatch/open_test.go @@ -44,7 +44,7 @@ func TestParseOpenArgs(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - id, mode, last, printCmd, err := parseOpenArgs(tc.args) + id, mode, last, printCmd, _, err := parseOpenArgs(tc.args) if tc.wantErr { if err == nil { t.Fatalf("expected error, got id=%q mode=%q last=%v", id, mode, last) @@ -235,6 +235,37 @@ func TestRunOpen_PrintError(t *testing.T) { } } +func TestRunOpen_OverridesFlowToResumeConfig(t *testing.T) { + cfg := config.Default() + cfg.Agent = "base-agent" + cfg.Model = "base-model" + cfg.YoloMode = false + sess := &data.Session{ID: "sess-1", Cwd: "/tmp/project"} + withOpenStubs(t, cfg, sess, nil) + + var gotRC platform.ResumeConfig + origResume := openResumeCmdFn + openResumeCmdFn = func(id string, rc platform.ResumeConfig) (string, error) { + gotRC = rc + return "copilot --resume " + id, nil + } + t.Cleanup(func() { openResumeCmdFn = origResume }) + + args := []string{"open", "sess-1", "--print", "--agent", "coder", "--model", "gpt-5", "--yolo"} + if err := runOpen(io.Discard, args); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotRC.Agent != "coder" { + t.Errorf("agent = %q, want %q", gotRC.Agent, "coder") + } + if gotRC.Model != "gpt-5" { + t.Errorf("model = %q, want %q", gotRC.Model, "gpt-5") + } + if !gotRC.YoloMode { + t.Error("yolo = false, want true") + } +} + func TestRunOpen_NotFound(t *testing.T) { withOpenStubs(t, config.Default(), nil, nil) err := runOpen(io.Discard, []string{"open", "missing"}) From 77835b1bf3ba9ad8534b4ea64c972a4836e570e3 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Tue, 14 Jul 2026 06:06:10 -0700 Subject: [PATCH 2/2] Merge remote-tracking branch 'origin/main' into idea/open-new-launch-overrides --- README.md | 45 +++++++++- cmd/dispatch/cli.go | 174 ++++++++++++++++++++++++++++-------- cmd/dispatch/main.go | 31 ++++++- cmd/dispatch/main_test.go | 21 ++--- cmd/dispatch/open.go | 165 ++++++++++++++++++++++++++++++---- cmd/dispatch/open_test.go | 183 +++++++++++++++++++++++++++++++++++++- 6 files changed, 547 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 23ca150..1c3f5ad 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,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. @@ -234,6 +236,7 @@ dispatch stats dispatch stats --json dispatch stats --calendar dispatch stats --repo jongio/dispatch --since 2026-01-01 +dispatch stats --top 5 ``` Flags: @@ -241,6 +244,7 @@ Flags: - `--json` prints the summary as a single JSON object. - `--calendar` adds a GitHub-style activity heatmap of sessions per day, with an intensity legend. It honors the `--repo`, `--branch`, `--since`, and `--until` filters. - `--repo`, `--branch`, `--folder`, `--since`, and `--until` narrow which sessions are counted. +- `--top ` caps each repository, branch, and host breakdown to the first N entries. ### Tags @@ -253,6 +257,33 @@ dispatch tags --json Tags come from sessions you have tagged in the TUI. Counts are taken against the current session store, so tags left on sessions that no longer exist are not counted. Use `--json` for scripting. +### Notes + +Manage session notes from the command line without editing `config.json` directly: + +```sh +dispatch notes +dispatch notes --json +dispatch notes get 0a1b2c3d +dispatch notes set 0a1b2c3d "follow up after review" +dispatch notes clear 0a1b2c3d +``` + +`dispatch notes` lists notes for sessions that still exist in the session store. `set`, `get`, and `clear` operate on one session ID and use the same notes shown in the TUI preview. + +### Named Views + +List named views and switch the active view from scripts: + +```sh +dispatch views +dispatch views --json +dispatch views use Work +dispatch views use default +``` + +Named views come from the `views` array in `config.json`. `dispatch views use ` sets `active_view`, and `dispatch views use default` clears it so the TUI opens with the normal filters. + ### Export Save a full session (metadata and the complete conversation) to a file with `dispatch export `: @@ -260,11 +291,13 @@ Save a full session (metadata and the complete conversation) to a file with `dis ```sh dispatch export 0a1b2c3d dispatch export 0a1b2c3d --format json +dispatch export 0a1b2c3d --format html dispatch export 0a1b2c3d --stdout +dispatch export 0a1b2c3d --redact --stdout dispatch export 0a1b2c3d --out ./exports ``` -By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output, `--stdout` to print to the terminal instead of writing a file, and `--out ` to choose the destination directory. `--stdout` and `--out` cannot be combined. +By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output or `--format html` for a self-contained web page you can open in a browser (styles are inlined, so there are no external files to manage). Use `--stdout` to print to the terminal instead of writing a file, `--out ` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined. ### Search (JSON) @@ -441,6 +474,8 @@ Configuration is stored in the platform-specific config directory: - **macOS**: `~/Library/Application Support/dispatch/config.json` - **Windows**: `%APPDATA%\dispatch\config.json` +Set `DISPATCH_CONFIG` to an absolute file path to use a different config file, for example to keep separate work and personal profiles. `config path`, `config get`/`set`/`edit`, and `doctor` all follow the override. A relative or UNC value is ignored and the default location is used. + ### From the command line Read and change settings without opening the TUI or editing JSON by hand: @@ -450,11 +485,12 @@ dispatch config list # print every setting and its value dispatch config list --json # same, as a single JSON object dispatch config get launch_mode # print one value dispatch config set launch_mode window +dispatch config unset launch_mode # reset one setting to its default dispatch config edit # open the config file in your editor dispatch config path # print the config file path ``` -`set` validates the value and writes through the same save path the TUI uses, so migrations and checks still run. Unknown keys and invalid values exit non-zero with a clear message. The keys match the option names in the table below. Set `auto_refresh_seconds` to `default` to clear it back to unset. `edit` opens the file in `$VISUAL` or `$EDITOR` (falling back to a platform default) and re-checks it after you save, which is handy for list and map settings that `set` does not cover. +`set` validates the value and writes through the same save path the TUI uses, so migrations and checks still run. `unset` resets one key to its default through that same save path. Unknown keys and invalid values exit non-zero with a clear message. The keys match the option names in the table below. Set `auto_refresh_seconds` to `default` to clear it back to unset. `edit` opens the file in `$VISUAL` or `$EDITOR` (falling back to a platform default) and re-checks it after you save, which is handy for list and map settings that `set` does not cover. ### Options @@ -490,6 +526,8 @@ dispatch config path # print the config file path | `keybindings` | object | `{}` | Remap keyboard shortcuts. Keys are action names, values are comma-separated key lists (see [Customizing Keybindings](#customizing-keybindings)) | | `sessionTags` | object | `{}` | Map of session ID to a list of user-defined tags | | `sessionAliases` | object | `{}` | Map of session ID to a unique short alias for `dispatch open ` | +| `views` | array | `[]` | Named search, sort, pivot, and filter presets | +| `active_view` | string | `""` | Name of the named view to apply on startup. Empty means default filters | | `hidden_columns` | array | `[]` | Optional session-list columns to hide (`repo`, `folder`, `turns`, `host`); empty shows all | #### Pane Direction Semantics @@ -618,7 +656,7 @@ Add custom color schemes using Windows Terminal JSON format in the `schemes` arr | Flag | Description | |---|---| | `--help`, `-h`, `help` | Show usage information | -| `--version`, `-v`, `version` | Print the version and exit | +| `--version`, `-v`, `version` | Print the version and exit. Add `--json` for script output | | `update` | Update dispatch to the latest release | | `--demo` | Load a demo database with synthetic sessions | | `--reindex` | Full chronicle reindex via Copilot CLI (falls back to FTS5 rebuild) | @@ -637,6 +675,7 @@ Unknown flags print an error message with usage help and exit with code 1. | Variable | Description | |---|---| +| `DISPATCH_CONFIG` | Override the path to the config file. Must be an absolute, non-UNC path; a relative or UNC value is ignored and the default location is used | | `DISPATCH_DB` | Override the path to the Copilot CLI session store database | | `DISPATCH_LOG` | Path to a log file (enables debug logging) | | `DISPATCH_NO_UPDATE_CHECK` | Skip the background release check when set to `1`, `true`, `yes`, or `on` | diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 290e783..1004baf 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -46,6 +46,39 @@ var ( doctorSessionCountFn = defaultSessionCount ) +type versionOutput struct { + Version string `json:"version"` +} + +func runVersion(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + jsonOut := false + rest := args + if len(rest) > 0 { + rest = rest[1:] + } + for _, arg := range rest { + switch arg { + case "--json": + jsonOut = true + default: + if strings.HasPrefix(arg, "-") { + return fmt.Errorf("unknown flag: %s", arg) + } + return fmt.Errorf("version does not take positional arguments, got %q", arg) + } + } + + if jsonOut { + return json.NewEncoder(w).Encode(versionOutput{Version: version.Version}) + } + _, err := fmt.Fprintln(w, version.Version) + return err +} + func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.UpdateInfo) (done bool, cleanup func(), startup startupOptions, err error) { var flags startupFlags for i := 0; i < len(args); i++ { @@ -57,7 +90,10 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd return true, cleanup, startupOptions{}, nil case "--version", "-v", "version": - fmt.Println(version.Version) + if vErr := runVersion(os.Stdout, args[i:]); vErr != nil { + fmt.Fprintf(os.Stderr, "version: %v\n", vErr) + return true, cleanup, startupOptions{}, vErr + } showUpdateNotification(origStderr, updateCh) return true, cleanup, startupOptions{}, nil @@ -127,6 +163,20 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "notes": + if nErr := runNotes(os.Stdout, args); nErr != nil { + fmt.Fprintf(os.Stderr, "notes: %v\n", nErr) + return true, cleanup, startupOptions{}, nErr + } + return true, cleanup, startupOptions{}, nil + + case "views": + if vErr := runViews(os.Stdout, args); vErr != nil { + fmt.Fprintf(os.Stderr, "views: %v\n", vErr) + return true, cleanup, startupOptions{}, vErr + } + return true, cleanup, startupOptions{}, nil + case "config": if cErr := runConfig(os.Stdout, args); cErr != nil { fmt.Fprintf(os.Stderr, "config: %v\n", cErr) @@ -141,6 +191,25 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "info": + if iErr := runInfo(os.Stdout, args); iErr != nil { + fmt.Fprintf(os.Stderr, "info: %v\n", iErr) + return true, cleanup, startupOptions{}, iErr + } + return true, cleanup, startupOptions{}, nil + + case "man": + if mErr := runMan(os.Stdout); mErr != nil { + fmt.Fprintf(os.Stderr, "man: %v\n", mErr) + return true, cleanup, startupOptions{}, mErr + } + 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,7 +357,8 @@ func runCompletion(w io.Writer, shell string) error { const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" - local commands="help version open new doctor update completion stats search tags config export" + local bin="${COMP_WORDS[0]}" + local commands="help version open new doctor update completion stats search tags config export info man" local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query" if [[ "${COMP_CWORD}" -eq 1 ]]; then @@ -296,35 +366,38 @@ _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 - - if [[ "${COMP_WORDS[1]}" == "open" ]]; then - COMPREPLY=( $(compgen -W "--mode --last --print --agent --model --yolo" -- "${cur}") ) - return 0 - fi - - if [[ "${COMP_WORDS[1]}" == "new" ]]; then - COMPREPLY=( $(compgen -W "--mode --agent --model --yolo" -- "${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) --mode --last --print --agent --model --yolo" -- "${cur}") ) + return 0 + ;; + new) + COMPREPLY=( $(compgen -W "--mode --agent --model --yolo" -- "${cur}") ) + return 0 + ;; + config) + if [[ "${COMP_CWORD}" -eq 2 ]]; then + COMPREPLY=( $(compgen -W "list get set unset 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 openflags newflags - commands=(help version open new doctor update completion stats search tags config export) - shells=(bash zsh fish powershell) - configsubs=(list get set edit path) + local -a commands flags configsubs shells aliases configkeys openflags newflags + local bin=${words[1]} + commands=(help version open new doctor update completion stats search tags config export info man) + configsubs=(list get set unset edit path) openflags=(--mode --last --print --agent --model --yolo) newflags=(--mode --agent --model --yolo) flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query) @@ -335,12 +408,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 @@ -363,9 +448,14 @@ 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] = completion + 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 3; and test $cmd[2] = config; and contains -- $cmd[3] get set unset end function __dispatch_using_subcommand @@ -375,31 +465,37 @@ 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 'help version open new doctor update completion stats search tags config export info man' 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_using_subcommand open' -a '--mode --last --print --agent --model --yolo' - complete -c $bin -n '__dispatch_using_subcommand new' -a '--mode --agent --model --yolo' + 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_after open' -a '--mode --last --print --agent --model --yolo' + complete -c $bin -n '__dispatch_after new' -a '--mode --agent --model --yolo' + 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:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export', 'info', 'man') $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') +$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path') $script:DispatchOpenFlags = @('--mode', '--last', '--print', '--agent', '--model', '--yolo') $script:DispatchNewFlags = @('--mode', '--agent', '--model', '--yolo') 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 - } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'config') { - $script:DispatchConfigSubcommands + & $bin __complete shells } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'open') { - $script:DispatchOpenFlags + (& $bin __complete aliases) + $script:DispatchOpenFlags + } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'config') { + if ($tokens.Count -ge 3 -and @('get', 'set', 'unset') -contains $tokens[2]) { + & $bin __complete config-keys + } else { + $script:DispatchConfigSubcommands + } } elseif ($tokens.Count -ge 2 -and $tokens[1] -eq 'new') { $script:DispatchNewFlags } else { diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 86de4c4..ec37736 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -114,19 +114,25 @@ Usage: Commands: help Show this help message - version Print the version - open [--mode M] Resume a session by ID (M: inplace, tab, window, pane) + version [--json] Print the version + open [--mode M] Resume a session by ID or prefix (M: inplace, tab, window, pane) --print writes the resume command instead of launching open --last [--mode M] Resume the most recently active session + open --stdin [--mode M] Resume every session ID read from standard input + (one per line; pairs with search --ids) new [dir] [--mode M] Start a new session in a directory (default: current) completion Print shell completion (bash, zsh, fish, powershell) doctor [--json] Print environment diagnostics (--json for machine-readable output) stats [flags] Print session totals and breakdowns search [query] [flags] Print matching sessions as JSON (no TUI) tags [--json] List tags in use with per-tag session counts + notes [command] List, get, set, or clear session notes + views [command] List named views or set the active view config [get|set|list|edit|path] Read or change preferences (see Config commands) export [flags] Export a session as Markdown or JSON + info [--json] Print a concise session summary (--json for machine-readable output) + man Write the man page (roff) to standard output update Update dispatch to the latest release Open and new flags: @@ -137,6 +143,11 @@ Open and new flags: --last (open only) Resume the most recently active session --print (open only) Print the resume command instead of launching +Session IDs: + Commands that take (open, export) accept a full session ID or any + unique prefix of one, like a short git SHA. An ambiguous prefix lists the + matching sessions so you can add more characters. + Stats flags: --json Print the summary as JSON --calendar Add a per-day activity heatmap @@ -145,6 +156,7 @@ Stats flags: --folder Only count sessions under a folder --since Only count sessions created on or after a date --until Only count sessions created on or before a date + --top Limit each breakdown to the top N entries Search flags: --json Print results as JSON (default) @@ -160,17 +172,30 @@ Search flags: --until Only include sessions active on or before a date --limit Cap the number of results (default 50, 0 for no limit) +Views commands: + views [list] [--json] List configured named views + views use Set the active named view + views use default Clear the active named view + Config commands: config list [--json] Print every setting and its value config get Print one setting value config set Validate and save one setting + config unset Reset one setting to its default config edit Open the config file in your editor config path Print the config file path +Notes commands: + notes [list] [--json] List notes attached to current sessions + notes get Print one session note + notes set Set one session note + notes clear Clear one session note + Export flags: - --format md|json Output format (default md) + --format md|json|html Output format (default md) --out Write to a directory instead of the exports folder --stdout Print to stdout instead of writing a file + --redact Mask common secret patterns in the export Flags: -h, --help Show this help message diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 0790dcb..6b8add5 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -448,7 +448,10 @@ func TestShiftDemoTimestamps_AlreadyRecent(t *testing.T) { // --------------------------------------------------------------------------- func TestPrintUsage_Output(t *testing.T) { - // Capture stdout. + // Capture stdout. The pipe must be drained from a separate goroutine while + // printUsage writes: on Windows the pipe buffer is only a few KB and the + // usage text is larger, so draining after printUsage returned would block + // the write and deadlock. r, w, err := os.Pipe() if err != nil { t.Fatal(err) @@ -456,24 +459,22 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + defer func() { os.Stdout = origStdout }() - // Drain the pipe from a separate goroutine so printUsage never blocks on a - // full pipe buffer. Windows pipes buffer only a few KB, which is smaller - // than the usage text, so a synchronous read after printUsage would - // deadlock. - captured := make(chan string, 1) + var buf bytes.Buffer + readDone := make(chan struct{}) go func() { - var buf bytes.Buffer _, _ = io.Copy(&buf, r) - captured <- buf.String() + close(readDone) }() printUsage() - w.Close() + _ = w.Close() os.Stdout = origStdout + <-readDone - output := <-captured + output := buf.String() for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} { if !strings.Contains(output, want) { t.Errorf("printUsage() should mention %q, got:\n%s", want, output) diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index acff0d0..210c10f 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "database/sql" "errors" @@ -22,18 +23,24 @@ var ( openGetLastSessionFn = defaultOpenGetLastSession openLaunchFn = defaultOpenLaunch openResumeCmdFn = platform.BuildResumeCommandString + + // openStdin is the reader used by --stdin batch resume. It is a package + // variable so tests can feed it a fixed list of IDs. + openStdin io.Reader = os.Stdin ) // runOpen resumes a session using the same launch path the TUI uses. It // resumes the session named by ID, or the most recently active session when // --last is passed. With --print it writes the resolved resume command to w -// and does not launch. args is the full argument slice with args[0] == "open". +// and does not launch. With --stdin it reads session IDs from standard input +// (one per line) and resumes each, which composes with `dispatch search --ids`. +// args is the full argument slice with args[0] == "open". func runOpen(w io.Writer, args []string) error { if w == nil { w = io.Discard } - id, modeFlag, last, printCmd, ov, err := parseOpenArgs(args) + id, modeFlag, last, printCmd, stdin, ov, err := parseOpenArgs(args) if err != nil { return err } @@ -44,6 +51,10 @@ func runOpen(w io.Writer, args []string) error { } ov.apply(cfg) + if stdin { + return runOpenBatch(w, cfg, modeFlag, printCmd) + } + var sess *data.Session if last { sess, err = openGetLastSessionFn() @@ -81,6 +92,109 @@ func runOpen(w io.Writer, args []string) error { return openLaunchFn(w, cfg, sess, mode) } +// runOpenBatch resumes every session whose ID is read from standard input, +// one ID per line. Blank lines and lines beginning with '#' are ignored, and +// each line's first whitespace-separated field is used as the ID, so annotated +// lists still work. It composes with `dispatch search --ids`: +// +// dispatch search --ids feat | dispatch open --stdin --mode tab +// +// Resolution and launch reuse the single-session path. A missing session or a +// launch failure for one ID does not abort the batch; failures are collected +// and returned together so the exit code is non-zero while the rest still run. +func runOpenBatch(w io.Writer, cfg *config.Config, modeFlag string, printCmd bool) error { + ids, err := readSessionIDs(openStdin) + if err != nil { + return fmt.Errorf("reading session IDs: %w", err) + } + if len(ids) == 0 { + return errors.New("open --stdin: no session IDs on standard input") + } + + mode := resolveOpenMode(modeFlag, cfg) + if !printCmd && mode == config.LaunchModeInPlace { + return errors.New("open --stdin cannot launch in inplace mode; pass --mode tab, window, or pane") + } + + var failures []error + resumed := 0 + for _, rawID := range ids { + target := rawID + if resolved := cfg.SessionIDForAlias(target); resolved != "" { + target = resolved + } + + sess, gErr := openGetSessionFn(target) + if gErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", rawID, gErr)) + continue + } + if sess == nil { + failures = append(failures, fmt.Errorf("%s: session not found", rawID)) + continue + } + + if printCmd { + cmdStr, cErr := openResumeCmdFn(sess.ID, openResumeConfig(cfg, sess)) + if cErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", rawID, cErr)) + continue + } + fmt.Fprintln(w, cmdStr) + resumed++ + continue + } + + if lErr := openLaunchFn(w, cfg, sess, mode); lErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", rawID, lErr)) + continue + } + resumed++ + } + + if len(failures) > 0 { + fmt.Fprintf(w, "resumed %d of %d sessions\n", resumed, len(ids)) + return fmt.Errorf("open --stdin: %d of %d failed: %w", + len(failures), len(ids), errors.Join(failures...)) + } + return nil +} + +// readSessionIDs reads session IDs from r, one per line. It trims surrounding +// whitespace, skips blank lines and lines starting with '#', takes the first +// whitespace-separated field of each remaining line, and drops duplicates while +// preserving first-seen order. +func readSessionIDs(r io.Reader) ([]string, error) { + if r == nil { + return nil, nil + } + + var ids []string + seen := make(map[string]struct{}) + + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) // tolerate long lines + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if i := strings.IndexAny(line, " \t"); i >= 0 { + line = line[:i] + } + if _, dup := seen[line]; dup { + continue + } + seen[line] = struct{}{} + ids = append(ids, line) + } + if scErr := sc.Err(); scErr != nil { + return nil, scErr + } + return ids, nil +} + // openResumeConfig builds the resume config used to launch or print a // session's resume command. Terminal, launch style, and pane direction are // omitted because they affect terminal placement, not the copilot invocation. @@ -95,9 +209,9 @@ func openResumeConfig(cfg *config.Config, sess *data.Session) platform.ResumeCon } // parseOpenArgs extracts the session ID, optional launch mode, the --last -// flag, the --print flag, and any per-launch agent/model/yolo overrides from -// the "open" subcommand arguments. args[0] is expected to be "open". -func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, ov launchOverrides, err error) { +// flag, the --print flag, the --stdin flag, and any per-launch agent/model/yolo +// overrides from the "open" subcommand arguments. args[0] is expected to be "open". +func parseOpenArgs(args []string) (id, mode string, last, printCmd, stdin bool, ov launchOverrides, err error) { rest := args if len(rest) > 0 { rest = rest[1:] // drop the "open" token @@ -108,7 +222,7 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, ov laun arg := rest[i] if matched, next, mErr := matchLaunchOverride(rest, i, &ov); matched { if mErr != nil { - return "", "", false, false, launchOverrides{}, mErr + return "", "", false, false, false, launchOverrides{}, mErr } i = next continue @@ -116,9 +230,11 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, ov laun switch { case arg == "--last" || arg == "-l": last = true + case arg == "--stdin": + stdin = true case arg == "--mode" || arg == "-m": if i+1 >= len(rest) { - return "", "", false, false, launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane") + return "", "", false, false, false, launchOverrides{}, errors.New("--mode requires a value: inplace, tab, window, or pane") } mode = rest[i+1] i++ @@ -127,33 +243,41 @@ func parseOpenArgs(args []string) (id, mode string, last, printCmd bool, ov laun case arg == "--print": printCmd = true case strings.HasPrefix(arg, "-"): - return "", "", false, false, launchOverrides{}, fmt.Errorf("unknown flag: %s", arg) + return "", "", false, false, false, launchOverrides{}, fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } } - if last { + switch { + case stdin: + if last { + return "", "", false, false, false, launchOverrides{}, errors.New("open --stdin cannot be combined with --last") + } if len(positionals) > 0 { - return "", "", false, false, launchOverrides{}, errors.New("open --last does not take a session ID") + return "", "", false, false, false, launchOverrides{}, errors.New("open --stdin reads session IDs from standard input; do not also pass an ID") } - } else { + case last: + if len(positionals) > 0 { + return "", "", false, false, false, launchOverrides{}, errors.New("open --last does not take a session ID") + } + default: switch len(positionals) { case 0: - return "", "", false, false, launchOverrides{}, errors.New("open requires a session ID (or use --last)") + return "", "", false, false, false, launchOverrides{}, errors.New("open requires a session ID (or use --last or --stdin)") case 1: id = positionals[0] default: - return "", "", false, false, launchOverrides{}, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals)) + return "", "", false, false, false, launchOverrides{}, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals)) } } if mode != "" { if _, mErr := normalizeLaunchMode(mode); mErr != nil { - return "", "", false, false, launchOverrides{}, mErr + return "", "", false, false, false, launchOverrides{}, mErr } } - return id, mode, last, printCmd, ov, nil + return id, mode, last, printCmd, stdin, ov, nil } // normalizeLaunchMode maps a user-facing mode string to a config launch mode. @@ -191,7 +315,16 @@ func defaultOpenGetSession(id string) (*data.Session, error) { } defer store.Close() //nolint:errcheck // read-only, best-effort close - detail, err := store.GetSession(context.Background(), id) + ctx := context.Background() + fullID, err := store.ResolveIDPrefix(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + + detail, err := store.GetSession(ctx, fullID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil diff --git a/cmd/dispatch/open_test.go b/cmd/dispatch/open_test.go index 8dc215a..6d23153 100644 --- a/cmd/dispatch/open_test.go +++ b/cmd/dispatch/open_test.go @@ -21,6 +21,7 @@ func TestParseOpenArgs(t *testing.T) { wantMode string wantLast bool wantPrint bool + wantStdin bool wantErr bool }{ {name: "id only", args: []string{"open", "abc123"}, wantID: "abc123"}, @@ -34,17 +35,22 @@ func TestParseOpenArgs(t *testing.T) { {name: "print flag", args: []string{"open", "abc", "--print"}, wantID: "abc", wantPrint: true}, {name: "print before id", args: []string{"open", "--print", "abc"}, wantID: "abc", wantPrint: true}, {name: "print with mode", args: []string{"open", "abc", "--print", "--mode", "tab"}, wantID: "abc", wantMode: "tab", wantPrint: true}, + {name: "stdin", args: []string{"open", "--stdin"}, wantStdin: true}, + {name: "stdin with mode", args: []string{"open", "--stdin", "--mode", "tab"}, wantStdin: true, wantMode: "tab"}, + {name: "stdin with print", args: []string{"open", "--stdin", "--print"}, wantStdin: true, wantPrint: true}, {name: "missing id", args: []string{"open"}, wantErr: true}, {name: "missing id with mode", args: []string{"open", "--mode", "tab"}, wantErr: true}, {name: "two ids", args: []string{"open", "a", "b"}, wantErr: true}, {name: "last with id", args: []string{"open", "--last", "abc"}, wantErr: true}, + {name: "stdin with id", args: []string{"open", "--stdin", "abc"}, wantErr: true}, + {name: "stdin with last", args: []string{"open", "--stdin", "--last"}, wantErr: true}, {name: "unknown flag", args: []string{"open", "--nope", "a"}, wantErr: true}, {name: "mode without value", args: []string{"open", "a", "--mode"}, wantErr: true}, {name: "invalid mode", args: []string{"open", "a", "--mode", "sideways"}, wantErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - id, mode, last, printCmd, _, err := parseOpenArgs(tc.args) + id, mode, last, printCmd, stdin, _, err := parseOpenArgs(tc.args) if tc.wantErr { if err == nil { t.Fatalf("expected error, got id=%q mode=%q last=%v", id, mode, last) @@ -66,6 +72,9 @@ func TestParseOpenArgs(t *testing.T) { if printCmd != tc.wantPrint { t.Errorf("print = %v, want %v", printCmd, tc.wantPrint) } + if stdin != tc.wantStdin { + t.Errorf("stdin = %v, want %v", stdin, tc.wantStdin) + } }) } } @@ -341,3 +350,175 @@ func TestHandleArgs_OpenMissingID(t *testing.T) { t.Error("expected error for open with no session ID") } } + +// withOpenBatchStubs installs config, per-ID session lookup, and launch seams +// for --stdin batch tests. sessions maps a session ID to the session returned +// for it; an ID absent from the map resolves to "not found" (nil, nil). The +// returned slice records the IDs launched, in order. +func withOpenBatchStubs(t *testing.T, cfg *config.Config, sessions map[string]*data.Session) *[]string { + t.Helper() + launched := &[]string{} + origCfg, origGet, origLaunch := openLoadConfigFn, openGetSessionFn, openLaunchFn + openLoadConfigFn = func() (*config.Config, error) { return cfg, nil } + openGetSessionFn = func(id string) (*data.Session, error) { + return sessions[id], nil + } + openLaunchFn = func(_ io.Writer, _ *config.Config, s *data.Session, _ string) error { + *launched = append(*launched, s.ID) + return nil + } + t.Cleanup(func() { + openLoadConfigFn, openGetSessionFn, openLaunchFn = origCfg, origGet, origLaunch + }) + return launched +} + +// withStdin points the openStdin seam at s for the duration of the test. +func withStdin(t *testing.T, s string) { + t.Helper() + orig := openStdin + openStdin = strings.NewReader(s) + t.Cleanup(func() { openStdin = orig }) +} + +func TestReadSessionIDs(t *testing.T) { + in := strings.Join([]string{ + "sess-1", + " sess-2 ", // trimmed + "", // blank skipped + "# a comment", // comment skipped + "sess-1", // duplicate skipped + "sess-3 extra", // first field only + "\tsess-4\t", // tabs trimmed + }, "\n") + + got, err := readSessionIDs(strings.NewReader(in)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"sess-1", "sess-2", "sess-3", "sess-4"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("id[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestReadSessionIDs_NilReader(t *testing.T) { + got, err := readSessionIDs(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Errorf("got %v, want nil", got) + } +} + +func TestRunOpen_StdinBatch(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeInPlace // default is inplace; --mode overrides + sessions := map[string]*data.Session{ + "sess-1": {ID: "sess-1", Cwd: "/tmp/a"}, + "sess-2": {ID: "sess-2", Cwd: "/tmp/b"}, + } + launched := withOpenBatchStubs(t, cfg, sessions) + withStdin(t, "sess-1\nsess-2\n") + + if err := runOpen(io.Discard, []string{"open", "--stdin", "--mode", "tab"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*launched) != 2 || (*launched)[0] != "sess-1" || (*launched)[1] != "sess-2" { + t.Errorf("launched = %v, want [sess-1 sess-2]", *launched) + } +} + +func TestRunOpen_StdinResolvesAlias(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeTab + cfg.SessionAliases = map[string]string{"sess-1": "authfix"} + sessions := map[string]*data.Session{"sess-1": {ID: "sess-1", Cwd: "/tmp/a"}} + launched := withOpenBatchStubs(t, cfg, sessions) + withStdin(t, "authfix\n") + + if err := runOpen(io.Discard, []string{"open", "--stdin"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*launched) != 1 || (*launched)[0] != "sess-1" { + t.Errorf("launched = %v, want [sess-1]", *launched) + } +} + +func TestRunOpen_StdinInplaceRejected(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeInPlace + withOpenBatchStubs(t, cfg, map[string]*data.Session{"s": {ID: "s"}}) + withStdin(t, "s\n") + + // No explicit mode, so the inplace default applies and batch must reject it. + if err := runOpen(io.Discard, []string{"open", "--stdin"}); err == nil { + t.Fatal("expected error rejecting inplace mode for batch resume") + } +} + +func TestRunOpen_StdinEmpty(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeTab + withOpenBatchStubs(t, cfg, nil) + withStdin(t, "\n#only a comment\n") + + if err := runOpen(io.Discard, []string{"open", "--stdin"}); err == nil { + t.Fatal("expected error when standard input has no session IDs") + } +} + +func TestRunOpen_StdinPartialFailure(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeTab + sessions := map[string]*data.Session{"sess-1": {ID: "sess-1", Cwd: "/tmp/a"}} + launched := withOpenBatchStubs(t, cfg, sessions) + withStdin(t, "sess-1\nmissing\n") + + var buf bytes.Buffer + err := runOpen(&buf, []string{"open", "--stdin"}) + if err == nil { + t.Fatal("expected an aggregated error for the missing session") + } + if len(*launched) != 1 || (*launched)[0] != "sess-1" { + t.Errorf("launched = %v, want [sess-1] (found session still resumes)", *launched) + } + if !strings.Contains(buf.String(), "resumed 1 of 2") { + t.Errorf("summary = %q, want it to mention resumed 1 of 2", buf.String()) + } +} + +func TestRunOpen_StdinPrint(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeInPlace // print ignores mode entirely + sessions := map[string]*data.Session{ + "sess-1": {ID: "sess-1", Cwd: "/tmp/a"}, + "sess-2": {ID: "sess-2", Cwd: "/tmp/b"}, + } + launched := withOpenBatchStubs(t, cfg, sessions) + withStdin(t, "sess-1\nsess-2\n") + + origResume := openResumeCmdFn + openResumeCmdFn = func(id string, _ platform.ResumeConfig) (string, error) { + return "copilot --resume " + id, nil + } + t.Cleanup(func() { openResumeCmdFn = origResume }) + + var buf bytes.Buffer + if err := runOpen(&buf, []string{"open", "--stdin", "--print"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*launched) != 0 { + t.Errorf("expected --print to skip launching, launched = %v", *launched) + } + out := buf.String() + if !strings.Contains(out, "copilot --resume sess-1") || !strings.Contains(out, "copilot --resume sess-2") { + t.Errorf("output = %q, want resume commands for both sessions", out) + } +}