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
97 changes: 94 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ Dispatch reads your local Copilot CLI session store and presents every past sess
- **Full-text search** (`/`) — FTS5 full-text search with BM25 ranking when available, falling back to LIKE for older CLI versions. Two-tier: quick search (summaries, branches, repos, directories) returns results instantly; deep search (turns, checkpoints, files, refs) kicks in after 300ms. Searching a number (e.g. "42", "#42", "PR42") also matches session refs (PRs, issues, commits)
- **Directory filtering** (`f`) — hierarchical tree panel for toggling directory exclusion, persisted to config
- **Word filtering** (Settings panel) — comma-separated list of words to exclude sessions by content. Sessions whose name or conversation turns contain any excluded word (case-insensitive) are hidden from the list
- **Sorting** (`s` / `S`) — 5 fields (updated, folder, name, created, turns) with toggleable direction
- **Grouping (pivot) modes** (`Tab`) — flat, folder, repo, branch, date — displayed as collapsible trees with session counts
- **Sorting** (`s` / `S`) — 7 fields (updated, created, turns, name, folder, attention, frecency) with toggleable direction. Sort applies to both sessions and group ordering
- **Grouping modes** (`Tab`) — list, folder, repo, branch, date, host. 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`. Press `z` to view the preview fullscreen. 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
Expand Down Expand Up @@ -193,6 +193,20 @@ dispatch open <session-id> --yolo=false # force off even if config enables it

These flags apply to every launch mode and to `--print`. When omitted, the saved config values are used.

#### Scoped Resume

Resume the most recent session matching a repository, branch, or folder instead of specifying an ID:

```sh
dispatch open --repo jongio/dispatch
dispatch open --branch feature/auth
dispatch open --repo jongio/dispatch --branch main
dispatch open --folder ~/projects/api
dispatch open --current # auto-detect repo and branch from cwd
```

Scope flags cannot be combined with `--last`, `--stdin`, or a session ID.

### Start a New Session

Start a brand-new Copilot session from the command line without opening the TUI:
Expand Down Expand Up @@ -234,6 +248,7 @@ Run `dispatch stats` to print session totals and breakdowns by repository, branc
```sh
dispatch stats
dispatch stats --json
dispatch stats --csv
dispatch stats --calendar
dispatch stats --repo jongio/dispatch --since 2026-01-01
dispatch stats --top 5
Expand Down Expand Up @@ -299,6 +314,82 @@ dispatch export 0a1b2c3d --out ./exports

By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output or `--format html` for a self-contained web page you can open in a browser (styles are inlined, so there are no external files to manage). Use `--stdout` to print to the terminal instead of writing a file, `--out <dir>` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined.

#### Batch Export

Export all sessions matching a scope filter at once:

```sh
dispatch export --repo jongio/dispatch
dispatch export --repo jongio/dispatch --format json --out ./backup
dispatch export --branch main --since 2026-01-01 --until 2026-07-01
dispatch export --query "auth fix" --redact
```

Filter flags (`--query`, `--repo`, `--branch`, `--folder`, `--since`, `--until`) replace the session ID. Each matching session is exported to its own file in the output directory. `--stdout` is not supported in batch mode.

### Compare

Compare two sessions side by side with `dispatch compare`:

```sh
dispatch compare 0a1b2c3d 9f8e7d6c
dispatch compare 0a1b2c3d 9f8e7d6c --json
```

The output shows metadata differences (summary, branch, turn count), files that appear in only one session, ref differences, and checkpoint title lists. Use `--json` for machine-readable output. Session IDs accept the same prefix shorthand as `open` and `export`.

### Aliases

List every session alias with `dispatch aliases`:

```sh
dispatch aliases
dispatch aliases --json
```

Each entry shows the alias, the target session ID, summary, and repository. Aliases whose target session is no longer in the store are marked as orphaned. Use `--json` for scripting.

### Tag

Manage tags on a single session from the command line:

```sh
dispatch tag <id> # list current tags
dispatch tag <id> --add work,api # add tags
dispatch tag <id> --remove api # remove tags
dispatch tag <id> --set a,b # replace all tags
dispatch tag <id> --json # print result as JSON
```

A session alias can be used in place of the ID. Tags are deduplicated and stored in sorted order.

### Prune

Clean up config entries that reference sessions no longer in the store:

```sh
dispatch prune # dry run: report what would be removed
dispatch prune --apply # remove stale entries
dispatch prune --json # machine-readable summary
dispatch prune --apply --json # remove and print JSON summary
```

Scans aliases, tags, notes, favorites, hidden sessions, and launch stats. By default it only reports; `--apply` performs the cleanup.

### Watch

Monitor session attention state without opening the TUI:

```sh
dispatch watch --once # one-shot snapshot
dispatch watch --once --json # JSON snapshot
dispatch watch # stream transitions (Ctrl-C to stop)
dispatch watch --interval 10s # custom poll interval
dispatch watch --once --repo jongio/dispatch # filter by repo
```

The terminal bell rings when a session transitions to waiting or interrupted. Use `--repo`, `--branch`, or `--folder` to limit which sessions are monitored.

### Search (JSON)

Query the session store from scripts without opening the TUI. `dispatch search` prints matching sessions as a JSON array by default:
Expand Down Expand Up @@ -617,7 +708,7 @@ named keys (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `space`,
Available action names:

`up`, `down`, `left`, `right`, `enter`, `space`, `quit`, `force_quit`,
`search`, `escape`, `filter`, `sort`, `sort_order`, `pivot`, `pivot_order`,
`search`, `escape`, `filter`, `sort`, `sort_order`, `pivot`,
`preview`, `reindex`, `help`, `config`, `time_range_1`, `time_range_2`,
`time_range_3`, `time_range_4`, `hide`, `toggle_hidden`, `star`,
`launch_window`, `launch_tab`, `launch_pane`, `preview_scroll_up`,
Expand Down
173 changes: 173 additions & 0 deletions cmd/dispatch/aliases.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package main

import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"

"github.com/jongio/dispatch/internal/data"
)

// aliasesListSessionsFn loads sessions for the aliases command. It is a
// package variable so tests can substitute a fixed set of sessions.
var aliasesListSessionsFn = defaultStatsListSessions

// aliasEntry describes one alias mapping.
type aliasEntry struct {
Alias string `json:"alias"`
ID string `json:"id"`
Summary string `json:"summary,omitempty"`
Repo string `json:"repo,omitempty"`
Orphaned bool `json:"orphaned"`
}

// aliasesReport is the aggregate output for the aliases command.
type aliasesReport struct {
TotalAliases int `json:"total_aliases"`
Orphaned int `json:"orphaned"`
Aliases []aliasEntry `json:"aliases"`
}

// runAliases lists every session alias with its target session. args[0] is
// expected to be "aliases".
func runAliases(w io.Writer, args []string) error {
if w == nil {
w = io.Discard
}

opts, err := parseAliasesArgs(args)
if err != nil {
return err
}

cfg, err := configLoadFn()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

sessions, err := aliasesListSessionsFn(data.FilterOptions{})
if err != nil {
return err
}

report := buildAliasesReport(cfg.SessionAliases, sessions)
if opts.json {
return writeAliasesJSON(w, report)
}
writeAliasesText(w, report)
return nil
}

// aliasesOptions holds parsed flags for the aliases command.
type aliasesOptions struct {
json bool
}

// parseAliasesArgs reads the aliases subcommand flags.
func parseAliasesArgs(args []string) (aliasesOptions, error) {
var opts aliasesOptions

rest := args
if len(rest) > 0 {
rest = rest[1:]
}

for _, arg := range rest {
switch {
case arg == "--json":
opts.json = true
case strings.HasPrefix(arg, "-"):
return aliasesOptions{}, fmt.Errorf("unknown flag: %s", arg)
default:
return aliasesOptions{}, fmt.Errorf("aliases does not take positional arguments, got %q", arg)
}
}

return opts, nil
}

// buildAliasesReport builds the alias listing. SessionAliases maps session
// ID to alias string.
func buildAliasesReport(aliases map[string]string, sessions []data.Session) aliasesReport {
report := aliasesReport{Aliases: []aliasEntry{}}

liveIDs := make(map[string]*data.Session, len(sessions))
for i := range sessions {
liveIDs[sessions[i].ID] = &sessions[i]
}

for id, alias := range aliases {
if alias == "" {
continue
}
entry := aliasEntry{Alias: alias, ID: id}
if s, ok := liveIDs[id]; ok {
entry.Summary = s.Summary
entry.Repo = s.Repository
} else {
entry.Orphaned = true
report.Orphaned++
}
report.Aliases = append(report.Aliases, entry)
}

sort.Slice(report.Aliases, func(i, j int) bool {
return report.Aliases[i].Alias < report.Aliases[j].Alias
})
report.TotalAliases = len(report.Aliases)
return report
}

// writeAliasesJSON prints the report as a single JSON object.
func writeAliasesJSON(w io.Writer, report aliasesReport) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(report)
}

// writeAliasesText prints the report in a plain, human-readable layout.
func writeAliasesText(w io.Writer, report aliasesReport) {
fmt.Fprintln(w, "Dispatch aliases")
fmt.Fprintln(w)

if report.TotalAliases == 0 {
fmt.Fprintln(w, "No aliases configured.")
return
}

fmt.Fprintf(w, "Aliases: %d", report.TotalAliases)
if report.Orphaned > 0 {
fmt.Fprintf(w, " (%d orphaned)", report.Orphaned)
}
fmt.Fprintln(w)
fmt.Fprintln(w)

aliasWidth := 0
for _, e := range report.Aliases {
if len(e.Alias) > aliasWidth {
aliasWidth = len(e.Alias)
}
}

for _, e := range report.Aliases {
if e.Orphaned {
fmt.Fprintf(w, " %-*s %s [orphaned]\n", aliasWidth, e.Alias, shortID(e.ID))
} else {
label := e.Summary
if e.Repo != "" {
label = e.Repo + ": " + label
}
fmt.Fprintf(w, " %-*s %s %s\n", aliasWidth, e.Alias, shortID(e.ID), label)
}
}
}

// shortID returns a truncated session ID for display.
func shortID(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}
Loading