Skip to content
Open
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
5 changes: 3 additions & 2 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Commands:
completion <shell> Print shell completion (bash, zsh, fish, powershell)
doctor [--json] Print environment diagnostics (--json for machine-readable output)
stats [flags] Print session totals and breakdowns
search [query] [flags] Print matching sessions as JSON, CSV, IDs, or a table
search [query] [flags] Print matching sessions as JSON, JSONL, CSV, IDs, or a table
tags [--json] List tags in use with per-tag session counts
aliases [--json] List session aliases with orphan detection
alias <id> <name> Set, reassign, clear (--clear), or remove (--remove) a session alias
Expand Down Expand Up @@ -179,9 +179,10 @@ Stats flags:

Search flags:
--json Print results as JSON (default)
--jsonl Print one JSON object per line
--ids Print one session ID per line
--table Print a readable table
--format json|csv|ids|table
--format json|jsonl|csv|ids|table
Choose the output format
--query <text> Text to match (also accepted as a positional argument)
--deep Search turns, checkpoints, files, and refs too
Expand Down
46 changes: 34 additions & 12 deletions cmd/dispatch/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type searchOutputFormat string

const (
searchFormatJSON searchOutputFormat = "json"
searchFormatJSONL searchOutputFormat = "jsonl"
searchFormatIDs searchOutputFormat = "ids"
searchFormatTable searchOutputFormat = "table"
searchFormatCSV searchOutputFormat = "csv"
Expand Down Expand Up @@ -94,27 +95,44 @@ func runSearch(w io.Writer, args []string) error {
if opts.format == searchFormatCSV {
return writeSearchCSV(w, sessions)
}
if opts.format == searchFormatJSONL {
return writeSearchJSONL(w, sessions)
}

results := make([]searchSession, 0, len(sessions))
for _, s := range sessions {
results = append(results, searchSession{
ID: s.ID,
Summary: s.Summary,
Cwd: s.Cwd,
Repository: s.Repository,
Branch: s.Branch,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
TurnCount: s.TurnCount,
FileCount: s.FileCount,
})
results = append(results, newSearchSession(s))
}

enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(results)
}

func writeSearchJSONL(w io.Writer, sessions []data.Session) error {
enc := json.NewEncoder(w)
for _, s := range sessions {
if err := enc.Encode(newSearchSession(s)); err != nil {
return err
}
}
return nil
}

func newSearchSession(s data.Session) searchSession {
return searchSession{
ID: s.ID,
Summary: s.Summary,
Cwd: s.Cwd,
Repository: s.Repository,
Branch: s.Branch,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
TurnCount: s.TurnCount,
FileCount: s.FileCount,
}
}

func writeSearchIDs(w io.Writer, sessions []data.Session) error {
for _, s := range sessions {
if _, err := fmt.Fprintln(w, s.ID); err != nil {
Expand Down Expand Up @@ -229,6 +247,8 @@ func parseSearchArgs(args []string) (searchOptions, error) {
switch {
case name == "--json":
opts.format = searchFormatJSON
case name == "--jsonl":
opts.format = searchFormatJSONL
case name == "--ids":
opts.format = searchFormatIDs
case name == "--table":
Expand Down Expand Up @@ -399,14 +419,16 @@ func parseSearchFormat(v string) (searchOutputFormat, error) {
switch strings.ToLower(strings.TrimSpace(v)) {
case string(searchFormatJSON):
return searchFormatJSON, nil
case string(searchFormatJSONL):
return searchFormatJSONL, nil
case string(searchFormatIDs):
return searchFormatIDs, nil
case string(searchFormatTable):
return searchFormatTable, nil
case string(searchFormatCSV):
return searchFormatCSV, nil
default:
return "", fmt.Errorf("invalid --format value %q (want json, ids, table, or csv)", v)
return "", fmt.Errorf("invalid --format value %q (want json, jsonl, ids, table, or csv)", v)
}
}

Expand Down
64 changes: 64 additions & 0 deletions cmd/dispatch/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ func TestParseSearchArgsIDFormats(t *testing.T) {
args []string
}{
{name: "ids shortcut", args: []string{"search", "--ids"}},
{name: "jsonl shortcut", args: []string{"search", "--jsonl"}},
{name: "format jsonl separate", args: []string{"search", "--format", "jsonl"}},
{name: "format jsonl inline", args: []string{"search", "--format=jsonl"}},
{name: "format ids separate", args: []string{"search", "--format", "ids"}},
{name: "format ids inline", args: []string{"search", "--format=ids"}},
{name: "table shortcut", args: []string{"search", "--table"}},
Expand All @@ -121,6 +124,9 @@ func TestParseSearchArgsIDFormats(t *testing.T) {
t.Fatalf("parseSearchArgs returned error: %v", err)
}
want := searchFormatIDs
if strings.Contains(strings.Join(tc.args, " "), "jsonl") {
want = searchFormatJSONL
}
if strings.Contains(strings.Join(tc.args, " "), "table") {
want = searchFormatTable
}
Expand Down Expand Up @@ -360,6 +366,64 @@ func TestRunSearchCSVEmptyPrintsHeader(t *testing.T) {
}
}

func TestRunSearchJSONLOutput(t *testing.T) {
sessions := []data.Session{
{
ID: "session-a",
Summary: "fix auth bug",
Cwd: "/code/app",
Repository: "jongio/dispatch",
Branch: "main",
CreatedAt: "2026-01-05T10:00:00Z",
UpdatedAt: "2026-01-06T10:00:00Z",
TurnCount: 5,
FileCount: 3,
},
{ID: "session-b"},
}
withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) {
return sessions, nil
})

var buf bytes.Buffer
if err := runSearch(&buf, []string{"search", "--jsonl"}); err != nil {
t.Fatalf("runSearch returned error: %v", err)
}

lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 2 {
t.Fatalf("got %d JSONL lines, want 2: %q", len(lines), buf.String())
}
var first searchSession
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatalf("first line is not valid JSON: %v\n%s", err, lines[0])
}
if first.ID != "session-a" || first.Repository != "jongio/dispatch" || first.TurnCount != 5 {
t.Errorf("first line = %+v", first)
}
var second searchSession
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
t.Fatalf("second line is not valid JSON: %v\n%s", err, lines[1])
}
if second.ID != "session-b" {
t.Errorf("second line ID = %q, want session-b", second.ID)
}
}

func TestRunSearchJSONLEmptyIsEmpty(t *testing.T) {
withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) {
return nil, nil
})

var buf bytes.Buffer
if err := runSearch(&buf, []string{"search", "--format", "jsonl"}); err != nil {
t.Fatalf("runSearch returned error: %v", err)
}
if got := buf.String(); got != "" {
t.Errorf("output = %q, want empty", got)
}
}

func TestRunSearchEmptyIsEmptyArray(t *testing.T) {
withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) {
return nil, nil
Expand Down