From 851bb6cc779d9da7aca3e3ba49e724623cf3b0db Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:22:38 -0700 Subject: [PATCH] feat(info): add `dispatch info ` for a concise session summary `export` dumps a whole session; there was no quick way to see just its shape. `info ` prints a one-screen summary: ID, summary, repository, branch, directory, host, created/updated/last-active timestamps, and counts for turns, files, checkpoints, and references grouped into commits, PRs, and issues. Empty optional fields are omitted; counts always print. `--json` emits the same data for scripting. The detail loader is shared with export, so info adds no new store code. Wired into handleArgs, the usage banner, and all four shell completion lists. Also drains the stdout pipe on a goroutine in TestPrintUsage_Output so the grown usage banner cannot deadlock on the Windows pipe buffer. Closes #312 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/dispatch/cli.go | 15 ++- cmd/dispatch/info.go | 181 ++++++++++++++++++++++++++++ cmd/dispatch/info_test.go | 245 ++++++++++++++++++++++++++++++++++++++ cmd/dispatch/main.go | 1 + cmd/dispatch/main_test.go | 15 ++- 5 files changed, 449 insertions(+), 8 deletions(-) create mode 100644 cmd/dispatch/info.go create mode 100644 cmd/dispatch/info_test.go diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 813dfa9..37e4aaf 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -141,6 +141,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "info": + if iErr := runInfo(os.Stdout, args); iErr != nil { + fmt.Fprintf(os.Stderr, "info: %v\n", iErr) + return true, cleanup, startupOptions{}, iErr + } + return true, cleanup, startupOptions{}, nil + case "--demo": c, demoErr := setupDemo() if demoErr != nil { @@ -288,7 +295,7 @@ func runCompletion(w io.Writer, shell string) error { const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" - local commands="help version open new doctor update completion stats search tags config export" + local commands="help version open new doctor update completion stats search tags config export info" local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query" if [[ "${COMP_CWORD}" -eq 1 ]]; then @@ -312,7 +319,7 @@ complete -F _dispatch_completion dispatch disp const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { local -a commands shells flags configsubs - commands=(help version open new doctor update completion stats search tags config export) + commands=(help version open new doctor update completion stats search tags config export info) shells=(bash zsh fish powershell) configsubs=(list get set edit path) flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query) @@ -348,14 +355,14 @@ end for bin in dispatch disp complete -c $bin -f - complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export' + complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export info' complete -c $bin -n '__dispatch_needs_command' -a '-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query' complete -c $bin -n '__dispatch_using_completion' -a 'bash zsh fish powershell' end ` const powershellCompletionScript = `# PowerShell completion for dispatch -$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export') +$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export', 'info') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query') $script:DispatchShells = @('bash', 'zsh', 'fish', 'powershell') $script:DispatchConfigSubcommands = @('list', 'get', 'set', 'edit', 'path') diff --git a/cmd/dispatch/info.go b/cmd/dispatch/info.go new file mode 100644 index 0000000..5a7cadc --- /dev/null +++ b/cmd/dispatch/info.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/jongio/dispatch/internal/data" +) + +// infoGetDetailFn loads a session's aggregated detail by ID. It is a seam so +// tests can substitute the store lookup. It reuses the export loader, which +// returns (nil, nil) when no session with the given ID exists. +var infoGetDetailFn = defaultExportGetDetail + +// sessionInfo is the concise, count-oriented view of a session emitted by the +// info command. Unlike export, it summarizes the conversation with counts +// rather than including every turn. +type sessionInfo struct { + ID string `json:"id"` + Summary string `json:"summary,omitempty"` + Repository string `json:"repository,omitempty"` + Branch string `json:"branch,omitempty"` + Directory string `json:"directory,omitempty"` + HostType string `json:"host_type,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + LastActiveAt string `json:"last_active_at,omitempty"` + Turns int `json:"turns"` + Files int `json:"files"` + Checkpoints int `json:"checkpoints"` + Commits int `json:"commits"` + PRs int `json:"prs"` + Issues int `json:"issues"` +} + +// runInfo prints a concise summary of a single session as text, or as JSON +// with --json. args is the full argument slice with args[0] == "info". +func runInfo(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + id, asJSON, err := parseInfoArgs(args) + if err != nil { + return err + } + + detail, err := infoGetDetailFn(id) + if err != nil { + return err + } + if detail == nil { + return fmt.Errorf("session %q not found", id) + } + + info := buildSessionInfo(detail) + if asJSON { + return writeInfoJSON(w, info) + } + return writeInfoText(w, info) +} + +// parseInfoArgs extracts the session ID and the --json flag from the info +// subcommand arguments. args[0] is expected to be "info". +func parseInfoArgs(args []string) (id string, asJSON bool, err error) { + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop the "info" token + } + + var positionals []string + for _, arg := range rest { + switch { + case arg == "--json": + asJSON = true + case strings.HasPrefix(arg, "-"): + return "", false, fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + + switch len(positionals) { + case 0: + return "", false, errors.New("info requires a session ID") + case 1: + return positionals[0], asJSON, nil + default: + return "", false, fmt.Errorf("info accepts a single session ID, got %d arguments", len(positionals)) + } +} + +// buildSessionInfo reduces a full SessionDetail to the concise info view, +// counting external references by type. +func buildSessionInfo(detail *data.SessionDetail) sessionInfo { + s := detail.Session + info := sessionInfo{ + ID: s.ID, + Summary: s.Summary, + Repository: s.Repository, + Branch: s.Branch, + Directory: s.Cwd, + HostType: s.HostType, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + LastActiveAt: s.LastActiveAt, + Turns: s.TurnCount, + Files: s.FileCount, + Checkpoints: len(detail.Checkpoints), + } + for _, ref := range detail.Refs { + switch strings.ToLower(ref.RefType) { + case "commit": + info.Commits++ + case "pr": + info.PRs++ + case "issue": + info.Issues++ + } + } + return info +} + +// writeInfoJSON encodes info as indented JSON. +func writeInfoJSON(w io.Writer, info sessionInfo) error { + b, err := json.MarshalIndent(info, "", " ") + if err != nil { + return fmt.Errorf("encoding session info as JSON: %w", err) + } + _, err = fmt.Fprintln(w, string(b)) + return err +} + +// writeInfoText prints a human-readable summary, one field per line. Optional +// string fields are omitted when empty; counts always print. +func writeInfoText(w io.Writer, info sessionInfo) error { + var b strings.Builder + fmt.Fprintf(&b, "Session %s\n", info.ID) + + writeField := func(label, value string) { + if value != "" { + fmt.Fprintf(&b, " %-12s %s\n", label, value) + } + } + writeField("Summary:", info.Summary) + writeField("Repository:", info.Repository) + writeField("Branch:", info.Branch) + writeField("Directory:", info.Directory) + writeField("Host:", info.HostType) + writeField("Created:", info.CreatedAt) + writeField("Updated:", info.UpdatedAt) + writeField("Last active:", info.LastActiveAt) + + fmt.Fprintf(&b, " %-12s %d\n", "Turns:", info.Turns) + fmt.Fprintf(&b, " %-12s %d\n", "Files:", info.Files) + fmt.Fprintf(&b, " %-12s %d\n", "Checkpoints:", info.Checkpoints) + fmt.Fprintf(&b, " %-12s %s\n", "Refs:", formatRefCounts(info)) + + _, err := io.WriteString(w, b.String()) + return err +} + +// formatRefCounts renders the reference breakdown as a compact summary such as +// "3 commits, 1 pr, 0 issues", using singular labels when a count is one. +func formatRefCounts(info sessionInfo) string { + return fmt.Sprintf("%s, %s, %s", + pluralize(info.Commits, "commit", "commits"), + pluralize(info.PRs, "pr", "prs"), + pluralize(info.Issues, "issue", "issues")) +} + +// pluralize renders n with a singular label when n is exactly one. +func pluralize(n int, singular, plural string) string { + if n == 1 { + return "1 " + singular + } + return fmt.Sprintf("%d %s", n, plural) +} diff --git a/cmd/dispatch/info_test.go b/cmd/dispatch/info_test.go new file mode 100644 index 0000000..b540aaa --- /dev/null +++ b/cmd/dispatch/info_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/update" +) + +// withInfoDetail swaps the info detail loader seam for the duration of a test. +func withInfoDetail(t *testing.T, fn func(string) (*data.SessionDetail, error)) { + t.Helper() + prev := infoGetDetailFn + infoGetDetailFn = fn + t.Cleanup(func() { infoGetDetailFn = prev }) +} + +func infoSampleDetail() *data.SessionDetail { + return &data.SessionDetail{ + Session: data.Session{ + ID: "ses-info-1", + Summary: "Fix the widget", + Cwd: "/tmp/project", + Repository: "jongio/dispatch", + Branch: "main", + HostType: "github", + CreatedAt: "2026-01-05T10:00:00Z", + UpdatedAt: "2026-01-06T11:00:00Z", + LastActiveAt: "2026-01-06T11:30:00Z", + TurnCount: 5, + FileCount: 3, + }, + Checkpoints: []data.Checkpoint{{CheckpointNumber: 1}, {CheckpointNumber: 2}}, + Refs: []data.SessionRef{ + {RefType: "commit", RefValue: "abc123"}, + {RefType: "commit", RefValue: "def456"}, + {RefType: "pr", RefValue: "42"}, + {RefType: "issue", RefValue: "7"}, + {RefType: "PR", RefValue: "43"}, // case-insensitive + }, + } +} + +func TestParseInfoArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantID string + wantJSON bool + wantErr bool + }{ + {name: "id only", args: []string{"info", "abc"}, wantID: "abc"}, + {name: "id with json", args: []string{"info", "abc", "--json"}, wantID: "abc", wantJSON: true}, + {name: "json before id", args: []string{"info", "--json", "abc"}, wantID: "abc", wantJSON: true}, + {name: "missing id", args: []string{"info"}, wantErr: true}, + {name: "two ids", args: []string{"info", "a", "b"}, wantErr: true}, + {name: "unknown flag", args: []string{"info", "abc", "--nope"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, asJSON, err := parseInfoArgs(tt.args) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != tt.wantID { + t.Errorf("id = %q, want %q", id, tt.wantID) + } + if asJSON != tt.wantJSON { + t.Errorf("asJSON = %v, want %v", asJSON, tt.wantJSON) + } + }) + } +} + +func TestBuildSessionInfo_CountsRefsByType(t *testing.T) { + info := buildSessionInfo(infoSampleDetail()) + + if info.Turns != 5 || info.Files != 3 || info.Checkpoints != 2 { + t.Errorf("counts = turns %d files %d checkpoints %d, want 5/3/2", + info.Turns, info.Files, info.Checkpoints) + } + if info.Commits != 2 { + t.Errorf("commits = %d, want 2", info.Commits) + } + if info.PRs != 2 { + t.Errorf("prs = %d, want 2 (case-insensitive)", info.PRs) + } + if info.Issues != 1 { + t.Errorf("issues = %d, want 1", info.Issues) + } +} + +func TestRunInfo_Text(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return infoSampleDetail(), nil + }) + + var buf bytes.Buffer + if err := runInfo(&buf, []string{"info", "ses-info-1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "Session ses-info-1", + "Fix the widget", + "jongio/dispatch", + "github", + "Turns:", + "Checkpoints: 2", + "2 commits, 2 prs, 1 issue", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q, got:\n%s", want, out) + } + } +} + +func TestRunInfo_TextOmitsEmptyFields(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return &data.SessionDetail{Session: data.Session{ID: "bare"}}, nil + }) + + var buf bytes.Buffer + if err := runInfo(&buf, []string{"info", "bare"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + if strings.Contains(out, "Repository:") || strings.Contains(out, "Branch:") { + t.Errorf("empty optional fields should be omitted, got:\n%s", out) + } + // Counts always print, even at zero, and use singular for one. + if !strings.Contains(out, "Turns:") || !strings.Contains(out, "0 commits, 0 prs, 0 issues") { + t.Errorf("counts should always print, got:\n%s", out) + } +} + +func TestRunInfo_JSON(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return infoSampleDetail(), nil + }) + + var buf bytes.Buffer + if err := runInfo(&buf, []string{"info", "ses-info-1", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got sessionInfo + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String()) + } + if got.ID != "ses-info-1" || got.Turns != 5 || got.Commits != 2 || got.PRs != 2 || got.Issues != 1 { + t.Errorf("decoded info = %+v", got) + } +} + +func TestRunInfo_NotFound(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return nil, nil // loader returns (nil, nil) when the ID is unknown + }) + + if err := runInfo(&bytes.Buffer{}, []string{"info", "missing"}); err == nil { + t.Fatal("expected a not-found error") + } +} + +func TestRunInfo_LoaderError(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return nil, errors.New("store boom") + }) + + if err := runInfo(&bytes.Buffer{}, []string{"info", "x"}); err == nil { + t.Fatal("expected the loader error to propagate") + } +} + +func TestRunInfo_MissingID(t *testing.T) { + if err := runInfo(&bytes.Buffer{}, []string{"info"}); err == nil { + t.Fatal("expected an error when no session ID is given") + } +} + +func TestPluralize(t *testing.T) { + cases := []struct { + n int + want string + }{ + {0, "0 prs"}, + {1, "1 pr"}, + {2, "2 prs"}, + } + for _, c := range cases { + if got := pluralize(c.n, "pr", "prs"); got != c.want { + t.Errorf("pluralize(%d) = %q, want %q", c.n, got, c.want) + } + } +} + +func TestHandleArgs_Info(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return infoSampleDetail(), nil + }) + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, cleanup, _, err := handleArgs([]string{"info", "ses-info-1"}, io.Discard, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true for info") + } + if cleanup != nil { + t.Error("expected cleanup=nil for info") + } +} + +func TestHandleArgs_InfoError(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return nil, nil // unknown ID + }) + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, _, _, err := handleArgs([]string{"info", "missing"}, io.Discard, ch) + if !done { + t.Error("expected done=true for info") + } + if err == nil { + t.Error("expected an error for an unknown session ID") + } +} diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index e6833e7..2ee3f9e 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -127,6 +127,7 @@ Commands: config [get|set|list|edit|path] Read or change preferences (see Config commands) export [flags] Export a session as Markdown or JSON + info [--json] Print a concise session summary (--json for machine-readable output) update Update dispatch to the latest release Stats flags: diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 08e19a2..56e6b55 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -457,15 +457,22 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + // Drain the pipe concurrently. The usage banner exceeds the OS pipe + // buffer (~4KB on Windows), so printUsage() would block on write if we + // only read after it returns. Reading on a goroutine avoids the deadlock. + done := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + printUsage() w.Close() os.Stdout = origStdout - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - - output := buf.String() + output := <-done for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} { if !strings.Contains(output, want) { t.Errorf("printUsage() should mention %q, got:\n%s", want, output)