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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ 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

### Resume a session

Resume a session without opening the TUI:

```sh
dispatch open <session-id> # resume a specific session by ID
dispatch open --last # resume the most recently active session
```

Both accept `--mode` to override the launch mode for that one resume (`inplace`, `tab`, `window`, `pane`):

```sh
dispatch open --last --mode window
```

### Shell Completion

Print completion scripts for supported shells:
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)
open --last [--mode M] Resume the most recently active session
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
95 changes: 69 additions & 26 deletions cmd/dispatch/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@ import (
// Function variables allow test substitution of external calls, matching the
// pattern used elsewhere in this package (see cli.go).
var (
openLoadConfigFn = config.Load
openGetSessionFn = defaultOpenGetSession
openLaunchFn = defaultOpenLaunch
openLoadConfigFn = config.Load
openGetSessionFn = defaultOpenGetSession
openGetLastSessionFn = defaultOpenGetLastSession
openLaunchFn = defaultOpenLaunch
)

// runOpen resumes the session with the given ID using the same launch path
// the TUI uses. args is the full argument slice with args[0] == "open".
// 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. 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, err := parseOpenArgs(args)
id, modeFlag, last, err := parseOpenArgs(args)
if err != nil {
return err
}
Expand All @@ -41,20 +43,31 @@ func runOpen(w io.Writer, args []string) error {

mode := resolveOpenMode(modeFlag, cfg)

sess, err := openGetSessionFn(id)
if err != nil {
return err
}
if sess == nil {
return fmt.Errorf("session %q not found", id)
var sess *data.Session
if last {
sess, err = openGetLastSessionFn()
if err != nil {
return err
}
if sess == nil {
return errors.New("no sessions to resume")
}
} else {
sess, err = openGetSessionFn(id)
if err != nil {
return err
}
if sess == nil {
return fmt.Errorf("session %q not found", id)
}
}

return openLaunchFn(w, cfg, sess, mode)
}

// parseOpenArgs extracts the session ID and optional launch mode from the
// "open" subcommand arguments. args[0] is expected to be "open".
func parseOpenArgs(args []string) (id, mode string, err error) {
// parseOpenArgs extracts the session ID, optional launch mode, and the --last
// flag from the "open" subcommand arguments. args[0] is expected to be "open".
func parseOpenArgs(args []string) (id, mode string, last bool, err error) {
rest := args
if len(rest) > 0 {
rest = rest[1:] // drop the "open" token
Expand All @@ -64,36 +77,44 @@ func parseOpenArgs(args []string) (id, mode string, err error) {
for i := 0; i < len(rest); i++ {
arg := rest[i]
switch {
case arg == "--last" || arg == "-l":
last = true
case arg == "--mode" || arg == "-m":
if i+1 >= len(rest) {
return "", "", errors.New("--mode requires a value: inplace, tab, window, or pane")
return "", "", false, 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 "", "", false, fmt.Errorf("unknown flag: %s", arg)
default:
positionals = append(positionals, arg)
}
}

switch len(positionals) {
case 0:
return "", "", errors.New("open requires a session ID")
case 1:
id = positionals[0]
default:
return "", "", fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals))
if last {
if len(positionals) > 0 {
return "", "", false, errors.New("open --last does not take a session ID")
}
} else {
switch len(positionals) {
case 0:
return "", "", false, errors.New("open requires a session ID (or use --last)")
case 1:
id = positionals[0]
default:
return "", "", false, fmt.Errorf("open accepts a single session ID, got %d arguments", len(positionals))
}
}

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

// normalizeLaunchMode maps a user-facing mode string to a config launch mode.
Expand Down Expand Up @@ -144,6 +165,28 @@ func defaultOpenGetSession(id string) (*data.Session, error) {
return &detail.Session, nil
}

// defaultOpenGetLastSession loads the most recently active session from the
// default session store, ordering by last active time (the same ordering the
// TUI uses for its default "updated" sort). It returns (nil, nil) when the
// store holds no sessions.
func defaultOpenGetLastSession() (*data.Session, error) {
store, err := data.Open()
if err != nil {
return nil, fmt.Errorf("opening session store: %w", err)
}
defer store.Close() //nolint:errcheck // read-only, best-effort close

sortOpts := data.SortOptions{Field: data.SortByUpdated, Order: data.Descending}
sessions, err := store.ListSessions(context.Background(), data.FilterOptions{}, sortOpts, 1)
if err != nil {
return nil, fmt.Errorf("listing sessions: %w", err)
}
if len(sessions) == 0 {
return nil, nil
}
return &sessions[0], nil
}

// defaultOpenLaunch resumes the session using the resolved launch mode. For
// in-place mode it runs the Copilot CLI in the current terminal and waits for
// it to exit. For tab, window, and pane modes it delegates to the platform
Expand Down
54 changes: 52 additions & 2 deletions cmd/dispatch/open_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,31 @@ func TestParseOpenArgs(t *testing.T) {
args []string
wantID string
wantMode string
wantLast bool
wantErr bool
}{
{name: "id only", args: []string{"open", "abc123"}, wantID: "abc123"},
{name: "mode space", args: []string{"open", "abc", "--mode", "window"}, wantID: "abc", wantMode: "window"},
{name: "mode equals", args: []string{"open", "abc", "--mode=pane"}, wantID: "abc", wantMode: "pane"},
{name: "short mode", args: []string{"open", "-m", "tab", "abc"}, wantID: "abc", wantMode: "tab"},
{name: "mode before id", args: []string{"open", "--mode", "inplace", "xyz"}, wantID: "xyz", wantMode: "inplace"},
{name: "last", args: []string{"open", "--last"}, wantLast: true},
{name: "last short", args: []string{"open", "-l"}, wantLast: true},
{name: "last with mode", args: []string{"open", "--last", "--mode", "window"}, wantLast: true, wantMode: "window"},
{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: "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, err := parseOpenArgs(tc.args)
id, mode, last, err := parseOpenArgs(tc.args)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got id=%q mode=%q", id, mode)
t.Fatalf("expected error, got id=%q mode=%q last=%v", id, mode, last)
}
return
}
Expand All @@ -48,6 +53,9 @@ func TestParseOpenArgs(t *testing.T) {
if mode != tc.wantMode {
t.Errorf("mode = %q, want %q", mode, tc.wantMode)
}
if last != tc.wantLast {
t.Errorf("last = %v, want %v", last, tc.wantLast)
}
})
}
}
Expand Down Expand Up @@ -170,6 +178,48 @@ func TestRunOpen_BadArgs(t *testing.T) {
}
}

// withOpenLastStub swaps openGetLastSessionFn (and the config/launch seams via
// withOpenStubs) so --last paths can be exercised without a real store.
func withOpenLastStub(t *testing.T, cfg *config.Config, sess *data.Session, getErr error) *openCapture {
t.Helper()
capture := withOpenStubs(t, cfg, sess, nil)
origLast := openGetLastSessionFn
openGetLastSessionFn = func() (*data.Session, error) { return sess, getErr }
t.Cleanup(func() { openGetLastSessionFn = origLast })
return capture
}

func TestRunOpen_Last(t *testing.T) {
cfg := config.Default()
cfg.LaunchMode = config.LaunchModeTab
sess := &data.Session{ID: "recent-1", Cwd: "/tmp/project"}
capture := withOpenLastStub(t, cfg, sess, nil)

if err := runOpen(io.Discard, []string{"open", "--last"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !capture.launched {
t.Fatal("expected launch to be invoked")
}
if capture.session == nil || capture.session.ID != "recent-1" {
t.Errorf("launched session = %+v, want recent-1", capture.session)
}
}

func TestRunOpen_LastEmptyStore(t *testing.T) {
withOpenLastStub(t, config.Default(), nil, nil)
if err := runOpen(io.Discard, []string{"open", "--last"}); err == nil {
t.Fatal("expected error when no sessions to resume")
}
}

func TestRunOpen_LastLookupError(t *testing.T) {
withOpenLastStub(t, config.Default(), nil, errors.New("boom"))
if err := runOpen(io.Discard, []string{"open", "--last"}); err == nil {
t.Fatal("expected lookup error to propagate")
}
}

func TestHandleArgs_OpenMissingID(t *testing.T) {
ch := make(chan *update.UpdateInfo, 1)
ch <- nil
Expand Down