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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ dispatch search --branch main --since 2026-01-01 --limit 20
dispatch search --deep refactor --json
dispatch search auth --format ids
dispatch search auth --ids
dispatch search auth --table
```

The query can be passed as a positional argument or with `--query`. Filters mirror the interactive search and the `stats` command:
Expand All @@ -273,7 +274,7 @@ The query can be passed as a positional argument or with `--query`. Filters mirr
- `--since` / `--until` accept `YYYY-MM-DD` or full RFC3339 timestamps.
- `--deep` also searches turns, checkpoints, touched files, and refs.
- `--limit <n>` caps the result count (default 50, `0` for no limit).
- `--format json|ids` chooses JSON output or one session ID per line. `--ids` is a shortcut for `--format ids`.
- `--format json|ids|table` chooses JSON output, one session ID per line, or a readable table. `--ids` and `--table` are shortcuts.

Each JSON result includes `id`, `summary`, `cwd`, `repository`, `branch`, `created_at`, `updated_at`, `turn_count`, and `file_count`. No JSON matches prints `[]`; no ID matches prints nothing. Both exit 0. Invalid flags or an unreadable store exit non-zero with a message on stderr.

Expand Down
5 changes: 3 additions & 2 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,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 (no TUI)
search [query] [flags] Print matching sessions as JSON, IDs, or a table
tags [--json] List tags in use with per-tag session counts
config [get|set|list|edit|path]
Read or change preferences (see Config commands)
Expand All @@ -141,7 +141,8 @@ Stats flags:
Search flags:
--json Print results as JSON (default)
--ids Print one session ID per line
--format json|ids Choose the output format
--table Print a readable table
--format json|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
--repo <name> Only include sessions for a repository
Expand Down
11 changes: 8 additions & 3 deletions cmd/dispatch/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,13 +457,18 @@ func TestPrintUsage_Output(t *testing.T) {
origStdout := os.Stdout
os.Stdout = w

var buf bytes.Buffer
readDone := make(chan struct{})
go func() {
_, _ = io.Copy(&buf, r)
close(readDone)
}()

printUsage()

w.Close()
os.Stdout = origStdout

var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
<-readDone

output := buf.String()
for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} {
Expand Down
62 changes: 59 additions & 3 deletions cmd/dispatch/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"strconv"
"strings"
"text/tabwriter"

"github.com/jongio/dispatch/internal/data"
)
Expand All @@ -27,8 +28,9 @@ const searchAllLimit = 100_000
type searchOutputFormat string

const (
searchFormatJSON searchOutputFormat = "json"
searchFormatIDs searchOutputFormat = "ids"
searchFormatJSON searchOutputFormat = "json"
searchFormatIDs searchOutputFormat = "ids"
searchFormatTable searchOutputFormat = "table"
)

// searchOptions holds the parsed flags for the search command.
Expand Down Expand Up @@ -78,6 +80,9 @@ func runSearch(w io.Writer, args []string) error {
if opts.format == searchFormatIDs {
return writeSearchIDs(w, sessions)
}
if opts.format == searchFormatTable {
return writeSearchTable(w, sessions)
}

results := make([]searchSession, 0, len(sessions))
for _, s := range sessions {
Expand Down Expand Up @@ -108,6 +113,53 @@ func writeSearchIDs(w io.Writer, sessions []data.Session) error {
return nil
}

func writeSearchTable(w io.Writer, sessions []data.Session) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "ID\tLAST ACTIVE\tREPO\tBRANCH\tTURNS\tFILES\tSUMMARY"); err != nil {
return err
}
for _, s := range sessions {
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%d\t%s\n",
shortSearchID(s.ID),
searchTableTime(s),
searchTableCell(s.Repository),
searchTableCell(s.Branch),
s.TurnCount,
s.FileCount,
searchTableCell(s.Summary),
); err != nil {
return err
}
}
return tw.Flush()
}

func shortSearchID(id string) string {
if len(id) <= 12 {
return id
}
return id[:12]
}

func searchTableTime(s data.Session) string {
v := s.LastActiveAt
if v == "" {
v = s.UpdatedAt
}
if len(v) >= len("2006-01-02") {
return v[:len("2006-01-02")]
}
return searchTableCell(v)
}

func searchTableCell(v string) string {
v = strings.Join(strings.Fields(v), " ")
if v == "" {
return "-"
}
return v
}

// parseSearchArgs reads the search subcommand flags. args[0] is expected to be
// "search". A single leading token that does not start with "-" is treated as
// the search query, matching how the TUI seeds its search box.
Expand Down Expand Up @@ -139,6 +191,8 @@ func parseSearchArgs(args []string) (searchOptions, error) {
opts.format = searchFormatJSON
case name == "--ids":
opts.format = searchFormatIDs
case name == "--table":
opts.format = searchFormatTable
case name == "--format":
v, ni, err := takeValue(i, "--format", inlineOrEmpty(inline, hasInline))
if err != nil {
Expand Down Expand Up @@ -240,8 +294,10 @@ func parseSearchFormat(v string) (searchOutputFormat, error) {
return searchFormatJSON, nil
case string(searchFormatIDs):
return searchFormatIDs, nil
case string(searchFormatTable):
return searchFormatTable, nil
default:
return "", fmt.Errorf("invalid --format value %q (want json or ids)", v)
return "", fmt.Errorf("invalid --format value %q (want json, ids, or table)", v)
}
}

Expand Down
65 changes: 62 additions & 3 deletions cmd/dispatch/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,22 @@ func TestParseSearchArgsIDFormats(t *testing.T) {
{name: "ids shortcut", args: []string{"search", "--ids"}},
{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"}},
{name: "format table separate", args: []string{"search", "--format", "table"}},
{name: "format table inline", args: []string{"search", "--format=table"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
opts, err := parseSearchArgs(tc.args)
if err != nil {
t.Fatalf("parseSearchArgs returned error: %v", err)
}
if opts.format != searchFormatIDs {
t.Errorf("format = %q, want ids", opts.format)
want := searchFormatIDs
if strings.Contains(strings.Join(tc.args, " "), "table") {
want = searchFormatTable
}
if opts.format != want {
t.Errorf("format = %q, want %s", opts.format, want)
}
})
}
Expand All @@ -113,7 +120,7 @@ func TestParseSearchArgsErrors(t *testing.T) {
{"search", "--limit", "abc"},
{"search", "--repo"},
{"search", "--format"},
{"search", "--format", "table"},
{"search", "--format", "yaml"},
}
for _, args := range cases {
if _, err := parseSearchArgs(args); err == nil {
Expand Down Expand Up @@ -201,6 +208,58 @@ func TestRunSearchIDsNoMatchesIsEmpty(t *testing.T) {
}
}

func TestRunSearchTableOutput(t *testing.T) {
sessions := []data.Session{
{
ID: "0123456789abcdef",
Summary: "fix auth bug",
Repository: "jongio/dispatch",
Branch: "main",
LastActiveAt: "2026-01-06T10:00:00Z",
TurnCount: 5,
FileCount: 3,
},
{
ID: "short",
Summary: " ",
UpdatedAt: "2026-01-05T09:00:00Z",
},
}
withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) {
return sessions, nil
})

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

got := buf.String()
for _, want := range []string{
"ID", "LAST ACTIVE", "REPO", "BRANCH", "TURNS", "FILES", "SUMMARY",
"0123456789ab", "2026-01-06", "jongio/dispatch", "main", "fix auth bug",
"short", "2026-01-05",
} {
if !strings.Contains(got, want) {
t.Errorf("table output missing %q:\n%s", want, got)
}
}
}

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

var buf bytes.Buffer
if err := runSearch(&buf, []string{"search", "--format", "table"}); err != nil {
t.Fatalf("runSearch returned error: %v", err)
}
if got := buf.String(); !strings.Contains(got, "ID") || strings.Contains(got, "session-a") {
t.Errorf("unexpected table output:\n%s", got)
}
}

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