Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ Use `--print` to write the resume command to stdout instead of launching it. Thi
dispatch open <session-id> --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 <session-id> --agent coder --model gpt-5
dispatch open --last --yolo
dispatch open <session-id> --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:
Expand All @@ -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
Expand Down
37 changes: 36 additions & 1 deletion cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 "$@"
`
Expand All @@ -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
`

Expand All @@ -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)
Expand All @@ -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
}
Expand Down
91 changes: 91 additions & 0 deletions cmd/dispatch/launch_overrides.go
Original file line number Diff line number Diff line change
@@ -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
}
178 changes: 178 additions & 0 deletions cmd/dispatch/launch_overrides_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading