From 1ec54a29c44abc3282ba6f82eac2e02f0452415d Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:31:10 -0700 Subject: [PATCH] feat(cli): add open --last to resume the most recent session Adds a --last flag to the open command so users can resume their most recently active session without looking up its ID. Reuses the existing launch path and honors --mode. Errors clearly when --last is combined with an explicit ID and when the store has no sessions. Closes #208 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 15 ++++++ cmd/dispatch/main.go | 1 + cmd/dispatch/open.go | 106 ++++++++++++++++++++++++++------------ cmd/dispatch/open_test.go | 54 ++++++++++++++++++- 4 files changed, 142 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1e71a96..676d77f 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,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 # 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: diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 1f5d3c1..6eb4ae0 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -92,6 +92,7 @@ Commands: help Show this help message version Print the version open [--mode M] Resume a session by ID (M: inplace, tab, window, pane) + open --last [--mode M] Resume the most recently active session completion Print shell completion (bash, zsh, powershell) doctor [--json] Print environment diagnostics (--json for machine-readable output) stats [flags] Print session totals and breakdowns diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index 51f93fa..d130ab9 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -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 } @@ -41,26 +43,36 @@ func runOpen(w io.Writer, args []string) error { mode := resolveOpenMode(modeFlag, cfg) - // Resolve the argument as an alias first; fall back to treating it as a - // session ID when no alias matches. - if resolved := cfg.SessionIDForAlias(id); resolved != "" { - id = resolved - } - - 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 { + // Resolve the argument as an alias first; fall back to treating it as a + // session ID when no alias matches. + if resolved := cfg.SessionIDForAlias(id); resolved != "" { + id = resolved + } + 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 @@ -70,36 +82,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. @@ -150,6 +170,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 diff --git a/cmd/dispatch/open_test.go b/cmd/dispatch/open_test.go index bdde6bc..9544cf6 100644 --- a/cmd/dispatch/open_test.go +++ b/cmd/dispatch/open_test.go @@ -16,6 +16,7 @@ func TestParseOpenArgs(t *testing.T) { args []string wantID string wantMode string + wantLast bool wantErr bool }{ {name: "id only", args: []string{"open", "abc123"}, wantID: "abc123"}, @@ -23,19 +24,23 @@ func TestParseOpenArgs(t *testing.T) { {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 } @@ -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) + } }) } } @@ -201,6 +209,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