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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `!`
Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
8 changes: 8 additions & 0 deletions internal/platform/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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},
Expand Down Expand Up @@ -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")),
Expand Down
60 changes: 43 additions & 17 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ✓"
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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)",
Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions internal/tui/model_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down
Loading