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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,10 @@ Print a concise summary of one session with `dispatch info <id>`:
```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

Expand Down
88 changes: 65 additions & 23 deletions cmd/dispatch/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -76,20 +86,22 @@ 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)
}
}

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))
}
}

Expand Down Expand Up @@ -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, "", " ")
Expand Down Expand Up @@ -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
Expand All @@ -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, ", ")
}
58 changes: 57 additions & 1 deletion cmd/dispatch/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,22 @@ 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},
}

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")
Expand All @@ -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)
}
})
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ Commands:
Read or change preferences (see Config commands)
export <id> [flags] Export a session as Markdown, JSON, or HTML
export --repo R [flags] Export all sessions matching a scope filter (batch mode)
info <id> [--json] Print a concise session summary (--json for machine-readable output)
info <id> [--json] [--refs]
Print a concise session summary
compare <a> <b> [--json]
Compare two sessions side by side
tag <id> [flags] Add, remove, set, or list tags on a session
Expand Down