Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Configurable auto-refresh** — set `auto_refresh_seconds` (also in the settings panel) to tune the session-list poll interval, or set it to `0` to turn polling off and refresh only with `r` or reindex
- **Machine-readable doctor output** — `dispatch doctor --json` prints diagnostics as a single JSON object so scripts and CI can parse them instead of scraping text
- **Search query argument** — pass a search string on the command line (`dispatch auth` or `dispatch fix auth bug`) to launch the TUI with the search box pre-filled and the list already filtered
- **Open working directory** (`O`) — open the selected session's working directory in the system file manager (Explorer, Finder, or the Linux file manager)

## [v0.13.0] — 2026-06-30

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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
- **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
- **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 @@ -215,6 +216,7 @@ Add `--json` (`dispatch doctor --json`) to print the same checks as a single JSO
| `v` | View plan in preview pane |
| `o` | Toggle conversation sort order (oldest/newest first) |
| `c` | Copy session ID to clipboard |
| `O` | Open session working directory in file manager |
| `PgUp` / `PgDn` | Scroll preview |
| `r` | Refresh session store |
| `,` | Open settings panel |
Expand Down
43 changes: 33 additions & 10 deletions internal/platform/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,46 @@ package platform

import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
)

// OpenFile opens the given file path using the platform default application.
// On Windows it uses explorer.exe (avoids cmd.exe metacharacter injection),
// on macOS "open", and on Linux "xdg-open".
func OpenFile(path string) error {
ctx := context.Background()
var cmd *exec.Cmd
// openCommand builds the platform command used to open a path with the
// default handler: explorer.exe on Windows (which avoids cmd.exe
// metacharacter injection), "open" on macOS, and "xdg-open" elsewhere.
func openCommand(ctx context.Context, path string) *exec.Cmd {
switch runtime.GOOS {
case "windows":
cmd = exec.CommandContext(ctx, "explorer", path)
return exec.CommandContext(ctx, "explorer", path)
case "darwin":
cmd = exec.CommandContext(ctx, "open", path)
return exec.CommandContext(ctx, "open", path)
default:
cmd = exec.CommandContext(ctx, "xdg-open", path)
return exec.CommandContext(ctx, "xdg-open", path)
}
}

// OpenFile opens the given file path using the platform default application.
// On Windows it uses explorer.exe (avoids cmd.exe metacharacter injection),
// on macOS "open", and on Linux "xdg-open".
func OpenFile(path string) error {
return openCommand(context.Background(), path).Start()
}

// OpenDir opens the given directory in the platform file manager. It returns
// an error if the path is empty or is not an existing directory, so callers
// can surface a clear message instead of spawning against a bad path.
func OpenDir(path string) error {
if path == "" {
return fmt.Errorf("no directory to open")
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("directory not found: %s", path)
}
if !info.IsDir() {
return fmt.Errorf("not a directory: %s", path)
}
return cmd.Start()
return openCommand(context.Background(), path).Start()
}
53 changes: 53 additions & 0 deletions internal/platform/open_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package platform

import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
)

Expand All @@ -15,3 +19,52 @@ func TestOpenFile_NonExistentPath(t *testing.T) {
// We just verify no panic occurred.
_ = err
}

func TestOpenCommand_PerOS(t *testing.T) {
t.Parallel()
cmd := openCommand(context.Background(), "/some/path")
if len(cmd.Args) == 0 {
t.Fatal("expected command args")
}
var want string
switch runtime.GOOS {
case "windows":
want = "explorer"
case "darwin":
want = "open"
default:
want = "xdg-open"
}
if got := filepath.Base(cmd.Args[0]); got != want {
t.Errorf("openCommand on %s = %q, want %q", runtime.GOOS, got, want)
}
last := cmd.Args[len(cmd.Args)-1]
if last != "/some/path" {
t.Errorf("openCommand path arg = %q, want %q", last, "/some/path")
}
}

func TestOpenDir_EmptyPath(t *testing.T) {
t.Parallel()
if err := OpenDir(""); err == nil {
t.Error("expected error for empty path")
}
}

func TestOpenDir_MissingPath(t *testing.T) {
t.Parallel()
if err := OpenDir(filepath.Join(t.TempDir(), "does-not-exist")); err == nil {
t.Error("expected error for missing path")
}
}

func TestOpenDir_FileNotDir(t *testing.T) {
t.Parallel()
f := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(f, []byte("x"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
if err := OpenDir(f); err == nil {
t.Error("expected error when path is a file, not a directory")
}
}
11 changes: 11 additions & 0 deletions internal/tui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ func (m Model) handleFileOpened(msg fileOpenedMsg) (Model, tea.Cmd) {
return m, clearStatusAfter(2 * time.Second)
}

// ----- Directory opened result ---------------------------------------------

func (m Model) handleDirOpened(msg dirOpenedMsg) (Model, tea.Cmd) {
if msg.err != nil {
m.statusErr = msg.err.Error()
return m, clearStatusAfter(2 * time.Second)
}
m.statusInfo = "Opened " + msg.path
return m, clearStatusAfter(2 * time.Second)
}

// ----- Pending click fire (single-click debounce) --------------------------

func (m Model) handlePendingClickFire(msg pendingClickFireMsg) (Model, tea.Cmd) {
Expand Down
37 changes: 37 additions & 0 deletions internal/tui/handlers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"errors"
"testing"

tea "charm.land/bubbletea/v2"
Expand All @@ -9,6 +10,42 @@ import (
"github.com/jongio/dispatch/internal/tui/components"
)

var errTestOpenDir = errors.New("directory not found: /tmp/work")

// ---------------------------------------------------------------------------
// handleDirOpened
// ---------------------------------------------------------------------------

func TestHandleDirOpened_Success(t *testing.T) {
m := newTestModelWithSize(120, 30)
m2, cmd := m.handleDirOpened(dirOpenedMsg{path: "/tmp/work"})

if m2.statusErr != "" {
t.Errorf("statusErr should be empty on success, got %q", m2.statusErr)
}
if m2.statusInfo != "Opened /tmp/work" {
t.Errorf("statusInfo = %q, want %q", m2.statusInfo, "Opened /tmp/work")
}
if cmd == nil {
t.Error("expected a clear-status command")
}
}

func TestHandleDirOpened_Error(t *testing.T) {
m := newTestModelWithSize(120, 30)
m2, cmd := m.handleDirOpened(dirOpenedMsg{path: "/tmp/work", err: errTestOpenDir})

if m2.statusInfo != "" {
t.Errorf("statusInfo should be empty on error, got %q", m2.statusInfo)
}
if m2.statusErr != errTestOpenDir.Error() {
t.Errorf("statusErr = %q, want %q", m2.statusErr, errTestOpenDir.Error())
}
if cmd == nil {
t.Error("expected a clear-status command")
}
}

// ---------------------------------------------------------------------------
// handleBackgroundColor
// ---------------------------------------------------------------------------
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,15 @@ type keyMap struct {
ShiftDown key.Binding
ViewSwitch key.Binding
OpenFile key.Binding
OpenDir key.Binding
Timeline key.Binding
Compare key.Binding
CmdPalette key.Binding
}

// 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.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.CopyID, k.CopyPath, k.CopyPreview, k.Export, k.OpenFile, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, 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.Timeline, k.Compare, k.Hide, k.Star, k.Note, k.CopyID, k.CopyPath, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit}
}

// FullHelp returns grouped key bindings for the expanded help view.
Expand All @@ -73,7 +74,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.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyPreview, k.Export, k.OpenFile, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config},
{k.Preview, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.CopyID, k.CopyPath, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config},
{k.Hide, k.ToggleHidden, k.Star, k.Note, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted},
{k.TimeRange1, k.TimeRange2, k.TimeRange3, k.TimeRange4},
{k.Help, k.CmdPalette, k.Quit},
Expand Down Expand Up @@ -132,6 +133,7 @@ var keys = keyMap{
ShiftDown: key.NewBinding(key.WithKeys("shift+down"), key.WithHelp("shift+\u2193", "extend select down")),
ViewSwitch: key.NewBinding(key.WithKeys("V"), key.WithHelp("V", "switch view")),
OpenFile: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "open file")),
OpenDir: key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "open directory")),
Timeline: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "activity timeline")),
Compare: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "compare selected")),
CmdPalette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "command palette")),
Expand Down
6 changes: 6 additions & 0 deletions internal/tui/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ type fileOpenedMsg struct {
err error
}

// dirOpenedMsg reports the result of opening a directory in the file manager.
type dirOpenedMsg struct {
path string
err error
}

// compareDetailMsg delivers two session details for side-by-side comparison.
type compareDetailMsg struct {
left *data.SessionDetail
Expand Down
20 changes: 20 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case fileOpenedMsg:
return m.handleFileOpened(msg)

case dirOpenedMsg:
return m.handleDirOpened(msg)

case compareDetailMsg:
return m.handleCompareDetail(msg)

Expand Down Expand Up @@ -1438,6 +1441,14 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
return m, nil

case key.Matches(msg, keys.OpenDir):
cwd := m.selectedSessionCwd()
if cwd == "" {
m.statusErr = "No working directory for this session"
return m, clearStatusAfter(2 * time.Second)
}
return m, m.openDirCmd(cwd)

case key.Matches(msg, keys.Space):
m.sessionList.ToggleSelected()
m.updateSelectionStatus()
Expand Down Expand Up @@ -4067,6 +4078,15 @@ func (m Model) openFileCmd(path string) tea.Cmd {
}
}

// openDirCmd opens a directory in the platform file manager. Validation of the
// path lives in platform.OpenDir so the failure message is consistent.
func (m Model) openDirCmd(path string) tea.Cmd {
return func() tea.Msg {
err := platform.OpenDir(path)
return dirOpenedMsg{path: path, err: err}
}
}

// ---------------------------------------------------------------------------
// Group sorting helpers
// ---------------------------------------------------------------------------
Expand Down