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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ dispatch fix auth bug
7. Press `s` to cycle sort fields, `S` to flip direction
8. Press `,` to open settings — change theme, launch mode, model, and more

### Start a New Session

Start a brand-new Copilot session from the command line without opening the TUI:

```sh
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)
```

The session uses the same agent, model, and launch settings as the TUI. When no directory is given, the current working directory is used.

### Shell Completion

Print completion scripts for supported shells:
Expand Down
13 changes: 10 additions & 3 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
}
return true, cleanup, "", nil

case "new":
if nErr := runNew(os.Stdout, args); nErr != nil {
fmt.Fprintf(os.Stderr, "new: %v\n", nErr)
return true, cleanup, "", nErr
}
return true, cleanup, "", nil

case "stats":
if sErr := runStats(os.Stdout, args); sErr != nil {
fmt.Fprintf(os.Stderr, "stats: %v\n", sErr)
Expand Down Expand Up @@ -173,7 +180,7 @@ 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 doctor update completion stats"
local commands="help version open new doctor update completion stats"
local flags="-h --help -v --version --demo --clear-cache --reindex"

if [[ "${COMP_CWORD}" -eq 1 ]]; then
Expand All @@ -192,7 +199,7 @@ complete -F _dispatch_completion dispatch disp
const zshCompletionScript = `#compdef dispatch disp
_dispatch_completion() {
local -a commands shells flags
commands=(help version open doctor update completion stats)
commands=(help version open new doctor update completion stats)
shells=(bash zsh powershell)
flags=(-h --help -v --version --demo --clear-cache --reindex)

Expand All @@ -210,7 +217,7 @@ _dispatch_completion "$@"
`

const powershellCompletionScript = `# PowerShell completion for dispatch
$script:DispatchCommands = @('help', 'version', 'open', 'doctor', 'update', 'completion', 'stats')
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats')
$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex')
$script:DispatchShells = @('bash', 'zsh', 'powershell')

Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Commands:
help Show this help message
version Print the version
open <id> [--mode M] Resume a session by ID (M: inplace, tab, window, pane)
new [dir] [--mode M] Start a new session in a directory (default: current)
completion <shell> Print shell completion (bash, zsh, powershell)
doctor [--json] Print environment diagnostics (--json for machine-readable output)
stats [flags] Print session totals and breakdowns
Expand Down
165 changes: 165 additions & 0 deletions cmd/dispatch/new.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package main

import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/jongio/dispatch/internal/config"
"github.com/jongio/dispatch/internal/platform"
)

// Function variables allow test substitution of external calls, matching the
// pattern used elsewhere in this package (see cli.go and open.go).
var (
newLoadConfigFn = config.Load
newLaunchFn = defaultNewLaunch
)

// runNew starts a brand-new Copilot session in a directory using the same
// launch path the TUI uses. args is the full argument slice with
// args[0] == "new".
func runNew(w io.Writer, args []string) error {
if w == nil {
w = io.Discard
}

dir, modeFlag, err := parseNewArgs(args)
if err != nil {
return err
}

cfg, err := newLoadConfigFn()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

mode := resolveOpenMode(modeFlag, cfg)

resolvedDir, err := resolveNewDir(dir)
if err != nil {
return err
}

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) {
rest := args
if len(rest) > 0 {
rest = rest[1:] // drop the "new" token
}

var positionals []string
for i := 0; i < len(rest); i++ {
arg := rest[i]
switch {
case arg == "--mode" || arg == "-m":
if i+1 >= len(rest) {
return "", "", 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)
default:
positionals = append(positionals, arg)
}
}

switch len(positionals) {
case 0:
dir = "" // current directory
case 1:
dir = positionals[0]
default:
return "", "", fmt.Errorf("new accepts a single directory, got %d arguments", len(positionals))
}

if mode != "" {
if _, mErr := normalizeLaunchMode(mode); mErr != nil {
return "", "", mErr
}
}
return dir, mode, nil
}

// resolveNewDir validates the target directory and returns an absolute path.
// An empty dir resolves to the current working directory.
func resolveNewDir(dir string) (string, error) {
if strings.TrimSpace(dir) == "" {
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("resolving current directory: %w", err)
}
return wd, nil
}

info, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("directory %q does not exist", dir)
}
return "", fmt.Errorf("checking directory %q: %w", dir, err)
}
if !info.IsDir() {
return "", fmt.Errorf("%q is not a directory", dir)
}

abs, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("resolving path %q: %w", dir, err)
}
return abs, nil
}

// defaultNewLaunch starts a new session using the resolved launch mode. It
// mirrors defaultOpenLaunch but passes an empty session ID, which the platform
// resume builders treat as a new session (no --resume flag).
func defaultNewLaunch(w io.Writer, cfg *config.Config, dir string, mode string) error {
if mode == config.LaunchModeInPlace {
rc := platform.ResumeConfig{
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
CustomCommand: cfg.CustomCommand,
Cwd: dir,
}
cmd, err := platform.NewResumeCmd("", rc)
if err != nil {
return err
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

shell := resolveOpenShell(cfg)
if shell.Path == "" {
return errors.New("no shell detected on this system")
}
rc := platform.ResumeConfig{
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
Terminal: cfg.DefaultTerminal,
CustomCommand: cfg.CustomCommand,
Cwd: dir,
LaunchStyle: launchStyleForOpenMode(mode),
PaneDirection: cfg.EffectivePaneDirection(),
}
if err := platform.LaunchSession(shell, "", rc); err != nil {
return err
}
fmt.Fprintf(w, "Started a new session in %s\n", dir)
return nil
}
Loading