From 788150cde8dea0ecb6aa6bb1bb1a2964eeb7e3fb Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:37:09 -0700 Subject: [PATCH] feat(tui): copy resume commands for all selected sessions When one or more sessions are marked with Space, Y now copies a resume command for each selected session, one per line, ordered to match the list. With no selection the single-session behavior is unchanged. Closes #211 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +-- internal/tui/model.go | 41 ++++++++++++++++++++++--------- internal/tui/model_update_test.go | 41 +++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7752edc..062b7b0 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ Dispatch reads your local Copilot CLI session store and presents every past sess - **Time range filtering** (`1`–`4`) — 1 hour, 1 day, 7 days, all - **Preview panel** (`p`) — metadata, chat-style conversation bubbles, checkpoints (up to 5), files (up to 5), refs (up to 5), scroll indicators. Toggle conversation sort order with `o`. Click the session ID row to copy it to clipboard - **Copy session ID** (`c`) — copy the selected session's ID to the system clipboard. Also available by clicking the ID row in the preview pane -- **Copy resume command** (`Y`) — copy the selected session's full resume command to the system clipboard +- **Copy resume command** (`Y`) — copy the selected session's full resume command to the system clipboard. With a multi-select active, copies one resume command per selected session, one per line - **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer on Windows, Finder on macOS, the default file manager on Linux) - **Four launch modes** (`Enter` / `t` / `w` / `e`) — in-place, new tab, new window, split pane (Windows Terminal) with per-session overrides -- **Multi-session open** (`Space` / `L` / `a` / `d`) — select multiple sessions with Space, launch all at once with L, select/deselect all with a/d. Shift+↑/↓ for range selection, Ctrl+click and Shift+click for mouse selection. With a selection active, `h` (hide) and `*` (favorite) apply to every selected session at once +- **Multi-session open** (`Space` / `L` / `a` / `d`) — select multiple sessions with Space, launch all at once with L, select/deselect all with a/d. Shift+↑/↓ for range selection, Ctrl+click and Shift+click for mouse selection. With a selection active, `h` (hide), `*` (favorite), and `Y` (copy resume command) apply to every selected session at once - **Attention indicators** — colored dots showing real-time session status: working (blue, executing tools), thinking (cyan, generating response), compacting (magenta, context compaction), waiting (purple), active (green), stale (yellow), interrupted (orange ⚡), idle (gray). Jump to next waiting session with `n`, resume interrupted sessions with `R`, filter by status with `!` - **Host type icons** — sessions display an icon indicating their origin: CLI (desktop), Cloud (cloud), or Actions (gear) - **Incremental auto-refresh** — the session list auto-refreshes within 2 seconds when the Copilot CLI writes new data (WAL file polling when focused). No manual reindex needed for normal use. Tune the interval or turn it off with `auto_refresh_seconds` diff --git a/internal/tui/model.go b/internal/tui/model.go index 9bd77ea..59abb85 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1701,24 +1701,43 @@ func (m Model) handleCopyPath() (tea.Model, tea.Cmd) { return m, clearStatusAfter(2 * time.Second) } -// handleCopyResumeCommand copies the selected session's resume command to the -// system clipboard. It mirrors the same launch options used by Dispatch when -// opening sessions so copied commands match the configured workflow. +// handleCopyResumeCommand copies the resume command(s) to the system clipboard. +// When multi-select is active, one resume command per selected session is +// copied, joined by newlines and ordered to match the list. Otherwise only the +// current session's command is copied. Copied commands mirror the same launch +// options Dispatch uses when opening sessions so they match the configured +// workflow. func (m Model) handleCopyResumeCommand() (tea.Model, tea.Cmd) { - sess, ok := m.sessionList.Selected() - if !ok { + var sessions []data.Session + if sel := m.sessionList.SelectedSessions(); len(sel) > 0 { + sessions = sel + } else if sess, ok := m.sessionList.Selected(); ok { + sessions = []data.Session{sess} + } + if len(sessions) == 0 { return m, nil } - cmd, err := platform.BuildResumeCommandString(sess.ID, m.resumeConfigForSession(sess.Cwd)) - if err != nil { - m.statusErr = "resume command: " + err.Error() - return m, clearStatusAfter(2 * time.Second) + + cmds := make([]string, 0, len(sessions)) + for _, sess := range sessions { + cmd, err := platform.BuildResumeCommandString(sess.ID, m.resumeConfigForSession(sess.Cwd)) + if err != nil { + m.statusErr = "resume command: " + err.Error() + return m, clearStatusAfter(2 * time.Second) + } + cmds = append(cmds, cmd) } - if err := clipboardWrite(cmd); err != nil { + + if err := clipboardWrite(strings.Join(cmds, "\n")); err != nil { m.statusErr = "clipboard: " + err.Error() return m, clearStatusAfter(2 * time.Second) } - m.statusInfo = statusCopiedResumeCommand + + if len(cmds) == 1 { + m.statusInfo = statusCopiedResumeCommand + } else { + m.statusInfo = fmt.Sprintf("Copied %d resume commands ✓", len(cmds)) + } return m, clearStatusAfter(2 * time.Second) } diff --git a/internal/tui/model_update_test.go b/internal/tui/model_update_test.go index 46509f0..ff92b49 100644 --- a/internal/tui/model_update_test.go +++ b/internal/tui/model_update_test.go @@ -2344,6 +2344,47 @@ func TestHandleKey_CopyResumeCommand_ClipboardError(t *testing.T) { } } +func TestHandleKey_CopyResumeCommand_MultiSelect(t *testing.T) { + var copied string + orig := clipboardWrite + clipboardWrite = func(text string) error { + copied = text + return nil + } + t.Cleanup(func() { clipboardWrite = orig }) + + m := newTestModel() + m.cfg.CustomCommand = "copilot --resume {sessionId}" + m.sessionList.SetSessions([]data.Session{ + {ID: "s1", Cwd: "/a"}, + {ID: "s2", Cwd: "/b"}, + {ID: "s3", Cwd: "/c"}, + }) + + // Mark the first and third sessions with Space. + m.sessionList.ToggleSelected() + m.sessionList.MoveDown() + m.sessionList.MoveDown() + m.sessionList.ToggleSelected() + + result, cmd := m.Update(runeKeyMsg('Y')) + rm := result.(Model) + + want := "copilot --resume s1\ncopilot --resume s3" + if copied != want { + t.Errorf("clipboard text = %q, want %q", copied, want) + } + if rm.statusInfo != "Copied 2 resume commands ✓" { + t.Errorf("statusInfo = %q, want %q", rm.statusInfo, "Copied 2 resume commands ✓") + } + if rm.statusErr != "" { + t.Errorf("statusErr = %q, want empty", rm.statusErr) + } + if cmd == nil { + t.Error("CopyResumeCommand multi-select should return clearStatusAfter cmd") + } +} + // --------------------------------------------------------------------------- // CopyPreview (y key) // ---------------------------------------------------------------------------