From 7c3b71c0781282557c8872705f31120e10985e8e Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:07:19 -0700 Subject: [PATCH] feat(cli): show linked refs in info Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +- cmd/dispatch/info.go | 88 +++++++++++++++++++++++++++++---------- cmd/dispatch/info_test.go | 58 +++++++++++++++++++++++++- cmd/dispatch/main.go | 3 +- 4 files changed, 126 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index bf2de97..41f4b01 100644 --- a/README.md +++ b/README.md @@ -346,9 +346,10 @@ Print a concise summary of one session with `dispatch info `: ```sh dispatch info 0a1b2c3d dispatch info 0a1b2c3d --json +dispatch info 0a1b2c3d --refs ``` -The summary covers the session's repository, branch, working directory, host type, turn and file counts, timestamps, tags, alias, and any linked refs. Use `--json` for scripting. The session ID accepts the same prefix shorthand as `open`. +The summary covers the session's repository, branch, working directory, host type, turn and file counts, timestamps, and linked ref counts. Use `--refs` to include linked commit, PR, and issue values, or `--json` for scripting. The session ID accepts the same prefix shorthand as `open`. ### Aliases diff --git a/cmd/dispatch/info.go b/cmd/dispatch/info.go index 5a7cadc..857b79a 100644 --- a/cmd/dispatch/info.go +++ b/cmd/dispatch/info.go @@ -19,31 +19,38 @@ var infoGetDetailFn = defaultExportGetDetail // 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"` + 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"` + Refs *infoRefs `json:"refs,omitempty"` +} + +type infoRefs struct { + Commits []string `json:"commits"` + PRs []string `json:"prs"` + Issues []string `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". +// with --json. The --refs flag adds linked reference values. func runInfo(w io.Writer, args []string) error { if w == nil { w = io.Discard } - id, asJSON, err := parseInfoArgs(args) + id, asJSON, includeRefs, err := parseInfoArgs(args) if err != nil { return err } @@ -57,15 +64,18 @@ func runInfo(w io.Writer, args []string) error { } info := buildSessionInfo(detail) + if includeRefs { + addInfoRefs(&info, detail.Refs) + } if asJSON { return writeInfoJSON(w, info) } return writeInfoText(w, info) } -// parseInfoArgs extracts the session ID and the --json flag from the info +// parseInfoArgs extracts the session ID and flags from the info // subcommand arguments. args[0] is expected to be "info". -func parseInfoArgs(args []string) (id string, asJSON bool, err error) { +func parseInfoArgs(args []string) (id string, asJSON, includeRefs bool, err error) { rest := args if len(rest) > 0 { rest = rest[1:] // drop the "info" token @@ -76,8 +86,10 @@ func parseInfoArgs(args []string) (id string, asJSON bool, err error) { switch { case arg == "--json": asJSON = true + case arg == "--refs": + includeRefs = true case strings.HasPrefix(arg, "-"): - return "", false, fmt.Errorf("unknown flag: %s", arg) + return "", false, false, fmt.Errorf("unknown flag: %s", arg) default: positionals = append(positionals, arg) } @@ -85,11 +97,11 @@ func parseInfoArgs(args []string) (id string, asJSON bool, err error) { switch len(positionals) { case 0: - return "", false, errors.New("info requires a session ID") + return "", false, false, errors.New("info requires a session ID") case 1: - return positionals[0], asJSON, nil + return positionals[0], asJSON, includeRefs, nil default: - return "", false, fmt.Errorf("info accepts a single session ID, got %d arguments", len(positionals)) + return "", false, false, fmt.Errorf("info accepts a single session ID, got %d arguments", len(positionals)) } } @@ -124,6 +136,24 @@ func buildSessionInfo(detail *data.SessionDetail) sessionInfo { return info } +func addInfoRefs(info *sessionInfo, refs []data.SessionRef) { + info.Refs = &infoRefs{ + Commits: []string{}, + PRs: []string{}, + Issues: []string{}, + } + for _, ref := range refs { + switch strings.ToLower(ref.RefType) { + case "commit": + info.Refs.Commits = append(info.Refs.Commits, ref.RefValue) + case "pr": + info.Refs.PRs = append(info.Refs.PRs, ref.RefValue) + case "issue": + info.Refs.Issues = append(info.Refs.Issues, ref.RefValue) + } + } +} + // writeInfoJSON encodes info as indented JSON. func writeInfoJSON(w io.Writer, info sessionInfo) error { b, err := json.MarshalIndent(info, "", " ") @@ -158,6 +188,11 @@ func writeInfoText(w io.Writer, info sessionInfo) error { 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)) + if info.Refs != nil { + fmt.Fprintf(&b, " %-12s %s\n", "Commits:", formatRefList(info.Refs.Commits)) + fmt.Fprintf(&b, " %-12s %s\n", "PRs:", formatRefList(info.Refs.PRs)) + fmt.Fprintf(&b, " %-12s %s\n", "Issues:", formatRefList(info.Refs.Issues)) + } _, err := io.WriteString(w, b.String()) return err @@ -179,3 +214,10 @@ func pluralize(n int, singular, plural string) string { } return fmt.Sprintf("%d %s", n, plural) } + +func formatRefList(values []string) string { + if len(values) == 0 { + return "-" + } + return strings.Join(values, ", ") +} diff --git a/cmd/dispatch/info_test.go b/cmd/dispatch/info_test.go index b540aaa..83f80c4 100644 --- a/cmd/dispatch/info_test.go +++ b/cmd/dispatch/info_test.go @@ -52,11 +52,14 @@ func TestParseInfoArgs(t *testing.T) { args []string wantID string wantJSON bool + wantRefs 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: "id with refs", args: []string{"info", "abc", "--refs"}, wantID: "abc", wantRefs: true}, + {name: "json with refs", args: []string{"info", "--json", "--refs", "abc"}, wantID: "abc", wantJSON: true, wantRefs: 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}, @@ -64,7 +67,7 @@ func TestParseInfoArgs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - id, asJSON, err := parseInfoArgs(tt.args) + id, asJSON, includeRefs, err := parseInfoArgs(tt.args) if tt.wantErr { if err == nil { t.Fatal("expected an error") @@ -80,6 +83,9 @@ func TestParseInfoArgs(t *testing.T) { if asJSON != tt.wantJSON { t.Errorf("asJSON = %v, want %v", asJSON, tt.wantJSON) } + if includeRefs != tt.wantRefs { + t.Errorf("includeRefs = %v, want %v", includeRefs, tt.wantRefs) + } }) } } @@ -128,6 +134,28 @@ func TestRunInfo_Text(t *testing.T) { } } +func TestRunInfo_TextWithRefs(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", "--refs"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "Commits: abc123, def456", + "PRs: 42, 43", + "Issues: 7", + } { + 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 @@ -167,6 +195,34 @@ func TestRunInfo_JSON(t *testing.T) { } } +func TestRunInfo_JSONWithRefs(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", "--refs"}); 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.Refs == nil { + t.Fatal("refs = nil, want ref arrays") + } + if strings.Join(got.Refs.Commits, ",") != "abc123,def456" { + t.Errorf("commits = %+v", got.Refs.Commits) + } + if strings.Join(got.Refs.PRs, ",") != "42,43" { + t.Errorf("prs = %+v", got.Refs.PRs) + } + if strings.Join(got.Refs.Issues, ",") != "7" { + t.Errorf("issues = %+v", got.Refs.Issues) + } +} + 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 diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 8931a16..ca885f2 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -135,7 +135,8 @@ Commands: Read or change preferences (see Config commands) export [flags] Export a session as Markdown, JSON, or HTML export --repo R [flags] Export all sessions matching a scope filter (batch mode) - info [--json] Print a concise session summary (--json for machine-readable output) + info [--json] [--refs] + Print a concise session summary compare [--json] Compare two sessions side by side tag [flags] Add, remove, set, or list tags on a session