diff --git a/README.md b/README.md index 10bf6e9..eb949f9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess - **Grouping (pivot) modes** (`Tab`) — flat, folder, repo, branch, date — displayed as collapsible trees with session counts - **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 session helpers** (`c` / `C`) — copy the selected session's ID or full resume command to the system clipboard. Copying the ID is also available by clicking the ID row in the preview pane - **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 - **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 `!` @@ -192,6 +192,7 @@ dispatch | `v` | View plan in preview pane | | `o` | Toggle conversation sort order (oldest/newest first) | | `c` | Copy session ID to clipboard | +| `C` | Copy resume command to clipboard | | `PgUp` / `PgDn` | Scroll preview | | `r` | Refresh session store | | `,` | Open settings panel | diff --git a/docs/keybindings.md b/docs/keybindings.md index 73bd915..97ceb92 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -190,6 +190,13 @@ - Behavior: Cycles preview pane position: right → bottom → left → top → right. Persisted in config. - Condition: In session list view +20e. **C** (Shift+C) → Copy Resume Command + - File: internal\tui\keys.go + - Code: key.NewBinding(key.WithKeys("C"), key.WithHelp("C", "copy resume cmd")) + - Handler: internal\tui\model.go + - Behavior: Copies the selected session's resume command to the system clipboard using the same launch settings as Dispatch. + - Condition: Only when a session is selected + 21. **PgUp (Page Up)** → Preview Panel Scroll Up - File: internal\tui\keys.go (line 85) - Code: key.NewBinding(key.WithKeys("pgup")) diff --git a/internal/platform/shell.go b/internal/platform/shell.go index 37aeb10..50226db 100644 --- a/internal/platform/shell.go +++ b/internal/platform/shell.go @@ -225,6 +225,14 @@ func buildResumeCommandString(sessionID string, cfg ResumeConfig) (string, error return quote(binary) + " " + strings.Join(quoted, " "), nil } +// BuildResumeCommandString returns the shell command Dispatch uses to start or +// resume a Copilot CLI session, including configured flags and custom command +// templates. It is exported for UI and diagnostics code that need to display or +// copy the same command used by the launcher. +func BuildResumeCommandString(sessionID string, cfg ResumeConfig) (string, error) { + return buildResumeCommandString(sessionID, cfg) +} + // shellQuote wraps s in POSIX single quotes if it contains whitespace or // shell metacharacters. Single quotes suppress all shell interpretation; // embedded single quotes are escaped with the POSIX end-escape-reopen idiom (end quote, diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 204e35c..118d460 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -46,6 +46,7 @@ type keyMap struct { ResumeInterrupted key.Binding ViewPlan key.Binding CopyID key.Binding + CopyResumeCommand key.Binding CopyPreview key.Binding ExpandCollapseAll key.Binding ScanWorkStatus key.Binding @@ -55,7 +56,7 @@ type keyMap struct { // ShortHelp returns a compact set of key bindings for the mini help bar. func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.ViewPlan, k.Hide, k.Star, k.CopyID, k.CopyPreview, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.Config, k.Help, k.Quit} + return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.ViewPlan, k.Hide, k.Star, k.CopyID, k.CopyResumeCommand, k.CopyPreview, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.Config, k.Help, k.Quit} } // FullHelp returns grouped key bindings for the expanded help view. @@ -65,7 +66,7 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.Space, k.LaunchAll, k.SelectAll, k.DeselectAll, k.ShiftUp, k.ShiftDown}, {k.Search, k.Escape, k.Filter}, {k.Sort, k.SortOrder, k.Pivot, k.PivotOrder, k.ExpandCollapseAll}, - {k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.CopyID, k.CopyPreview, k.Reindex, k.ScanWorkStatus, k.Config}, + {k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.CopyID, k.CopyResumeCommand, k.CopyPreview, k.Reindex, k.ScanWorkStatus, k.Config}, {k.Hide, k.ToggleHidden, k.Star, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted}, {k.TimeRange1, k.TimeRange2, k.TimeRange3, k.TimeRange4}, {k.Help, k.Quit}, @@ -114,6 +115,7 @@ var keys = keyMap{ ResumeInterrupted: key.NewBinding(key.WithKeys("N"), key.WithHelp("N", "resume interrupted")), ViewPlan: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view plan")), CopyID: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy session ID")), + CopyResumeCommand: key.NewBinding(key.WithKeys("C"), key.WithHelp("C", "copy resume cmd")), CopyPreview: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy preview")), ExpandCollapseAll: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "expand/collapse all")), ScanWorkStatus: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "scan work status")), diff --git a/internal/tui/model.go b/internal/tui/model.go index d24e51f..cb91858 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -53,6 +53,10 @@ const ( // copied to the clipboard. statusCopiedID = "Copied session ID ✓" + // statusCopiedResumeCommand is the status message shown when a session's + // resume command is copied to the clipboard. + statusCopiedResumeCommand = "Copied resume command ✓" + // statusCopiedPreview is the status message shown when preview content // is copied to the clipboard via the y key. statusCopiedPreview = "Copied to clipboard ✓" @@ -1025,6 +1029,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.CopyPreview): return m.handleCopyPreview() + case key.Matches(msg, keys.CopyResumeCommand): + return m.handleCopyResumeCommand() + case key.Matches(msg, keys.JumpNextAttention): return m.handleJumpNextAttention() @@ -1155,6 +1162,27 @@ func (m Model) handleCopyID() (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. +func (m Model) handleCopyResumeCommand() (tea.Model, tea.Cmd) { + sess, ok := m.sessionList.Selected() + if !ok { + 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) + } + if err := clipboardWrite(cmd); err != nil { + m.statusErr = "clipboard: " + err.Error() + return m, clearStatusAfter(2 * time.Second) + } + m.statusInfo = statusCopiedResumeCommand + return m, clearStatusAfter(2 * time.Second) +} + // handleCopyPreview copies the preview pane content to the system clipboard. // When there is an active mouse text selection, only the selected text is // copied; otherwise the full preview/plan content is copied. @@ -2640,13 +2668,7 @@ func (m *Model) resolveShellAndLaunchDirect(sessionID, cwd, mode string) tea.Cmd // runs the Copilot CLI session resume in the current terminal, and quits // the TUI when the session ends. func (m *Model) launchInPlace(sessionID, cwd string) tea.Cmd { - cfg := platform.ResumeConfig{ - YoloMode: m.cfg.YoloMode, - Agent: m.cfg.Agent, - Model: m.cfg.Model, - CustomCommand: m.cfg.CustomCommand, - Cwd: cwd, - } + cfg := m.resumeConfigForSession(cwd) cmd, err := platform.NewResumeCmd(sessionID, cfg) if err != nil { m.statusErr = err.Error() @@ -2671,16 +2693,8 @@ func launchStyleForMode(mode string) string { // launchExternal opens the session in a new tab, window, or pane depending on launchStyle. func (m *Model) launchExternal(shell platform.ShellInfo, sessionID, cwd, launchStyle string) tea.Cmd { - cfg := platform.ResumeConfig{ - YoloMode: m.cfg.YoloMode, - Agent: m.cfg.Agent, - Model: m.cfg.Model, - Terminal: m.cfg.DefaultTerminal, - CustomCommand: m.cfg.CustomCommand, - Cwd: cwd, - LaunchStyle: launchStyle, - PaneDirection: m.cfg.EffectivePaneDirection(), - } + cfg := m.resumeConfigForSession(cwd) + cfg.LaunchStyle = launchStyle return func() tea.Msg { if err := platform.LaunchSession(shell, sessionID, cfg); err != nil { detail := fmt.Sprintf("launch failed: %v (shell=%s, terminal=%s)", @@ -2691,6 +2705,18 @@ func (m *Model) launchExternal(shell platform.ShellInfo, sessionID, cwd, launchS } } +func (m Model) resumeConfigForSession(cwd string) platform.ResumeConfig { + return platform.ResumeConfig{ + YoloMode: m.cfg.YoloMode, + Agent: m.cfg.Agent, + Model: m.cfg.Model, + Terminal: m.cfg.DefaultTerminal, + CustomCommand: m.cfg.CustomCommand, + Cwd: cwd, + PaneDirection: m.cfg.EffectivePaneDirection(), + } +} + func (m Model) selectedSessionID() string { if sess, ok := m.sessionList.Selected(); ok { return sess.ID diff --git a/internal/tui/model_update_test.go b/internal/tui/model_update_test.go index 65a2bbe..14b8420 100644 --- a/internal/tui/model_update_test.go +++ b/internal/tui/model_update_test.go @@ -2127,6 +2127,93 @@ func TestHandleKey_CopyID_Error(t *testing.T) { } } +func TestHandleKey_CopyResumeCommand_Success(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} --agent test-agent" + m.sessionList.SetSessions([]data.Session{{ID: "abc-123", Cwd: "/a"}}) + + result, cmd := m.Update(runeKeyMsg('C')) + rm := result.(Model) + if copied != "copilot --resume abc-123 --agent test-agent" { + t.Errorf("clipboard text = %q", copied) + } + if rm.statusInfo != statusCopiedResumeCommand { + t.Errorf("statusInfo = %q, want %q", rm.statusInfo, statusCopiedResumeCommand) + } + if rm.statusErr != "" { + t.Errorf("statusErr = %q, want empty", rm.statusErr) + } + if cmd == nil { + t.Error("CopyResumeCommand success should return clearStatusAfter cmd") + } +} + +func TestHandleKey_CopyResumeCommand_NoSession(t *testing.T) { + m := newTestModel() + result, cmd := m.Update(runeKeyMsg('C')) + rm := result.(Model) + if rm.statusInfo != "" || rm.statusErr != "" { + t.Errorf("status = (%q, %q), want empty", rm.statusInfo, rm.statusErr) + } + if cmd != nil { + t.Error("CopyResumeCommand with no session should return nil cmd") + } +} + +func TestHandleKey_CopyResumeCommand_BuildError(t *testing.T) { + orig := clipboardWrite + clipboardWrite = func(string) error { + t.Fatal("clipboard should not be called when command build fails") + return nil + } + t.Cleanup(func() { clipboardWrite = orig }) + + m := newTestModel() + m.cfg.CustomCommand = "bad\ncommand {sessionId}" + m.sessionList.SetSessions([]data.Session{{ID: "abc-123", Cwd: "/a"}}) + + result, cmd := m.Update(runeKeyMsg('C')) + rm := result.(Model) + if !strings.Contains(rm.statusErr, "resume command:") { + t.Errorf("statusErr = %q, want resume command error", rm.statusErr) + } + if cmd == nil { + t.Error("CopyResumeCommand build error should return clearStatusAfter cmd") + } +} + +func TestHandleKey_CopyResumeCommand_ClipboardError(t *testing.T) { + orig := clipboardWrite + clipboardWrite = func(string) error { + return errors.New("no display") + } + t.Cleanup(func() { clipboardWrite = orig }) + + m := newTestModel() + m.cfg.CustomCommand = "copilot --resume {sessionId}" + m.sessionList.SetSessions([]data.Session{{ID: "abc-123", Cwd: "/a"}}) + + result, cmd := m.Update(runeKeyMsg('C')) + rm := result.(Model) + if !strings.Contains(rm.statusErr, "clipboard:") { + t.Errorf("statusErr = %q, want clipboard error", rm.statusErr) + } + if rm.statusInfo != "" { + t.Errorf("statusInfo = %q, want empty", rm.statusInfo) + } + if cmd == nil { + t.Error("CopyResumeCommand clipboard error should return clearStatusAfter cmd") + } +} + // --------------------------------------------------------------------------- // CopyPreview (y key) // ---------------------------------------------------------------------------