diff --git a/README.md b/README.md index 1c3f5ad..d80b508 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,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: @@ -318,7 +319,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 ` 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. diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index ec37736..3216c78 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -124,7 +124,7 @@ Commands: completion 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 notes [command] List, get, set, or clear session notes views [command] List named views or set the active view @@ -161,7 +161,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 to match (also accepted as a positional argument) --deep Search turns, checkpoints, files, and refs too --repo Only include sessions for a repository diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 6b8add5..9e57ec7 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -468,6 +468,13 @@ func TestPrintUsage_Output(t *testing.T) { close(readDone) }() + var buf bytes.Buffer + readDone := make(chan struct{}) + go func() { + _, _ = io.Copy(&buf, r) + close(readDone) + }() + printUsage() _ = w.Close() diff --git a/cmd/dispatch/search.go b/cmd/dispatch/search.go index a7e8322..5bb39d5 100644 --- a/cmd/dispatch/search.go +++ b/cmd/dispatch/search.go @@ -7,6 +7,7 @@ import ( "io" "strconv" "strings" + "text/tabwriter" "github.com/jongio/dispatch/internal/data" ) @@ -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. @@ -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 { @@ -108,6 +113,54 @@ 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. @@ -139,6 +192,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 { @@ -240,8 +295,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) } } diff --git a/cmd/dispatch/search_test.go b/cmd/dispatch/search_test.go index 5ca1730..f3dc491 100644 --- a/cmd/dispatch/search_test.go +++ b/cmd/dispatch/search_test.go @@ -91,6 +91,9 @@ 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) { @@ -98,8 +101,12 @@ func TestParseSearchArgsIDFormats(t *testing.T) { 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) } }) } @@ -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 { @@ -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