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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ Query the session store from scripts without opening the TUI. `dispatch search`
dispatch search auth
dispatch search --query "fix login" --repo jongio/dispatch
dispatch search --branch main --since 2026-01-01 --limit 20
dispatch search --sort turns --order desc --limit 10
dispatch search --deep refactor --json
dispatch search auth --format ids
dispatch search auth --ids
Expand All @@ -421,6 +422,7 @@ The query can be passed as a positional argument or with `--query`. Filters mirr
- `--repo`, `--branch`, `--folder`, `--host` narrow by session metadata.
- `--since` / `--until` accept `YYYY-MM-DD` or full RFC3339 timestamps.
- `--deep` also searches turns, checkpoints, touched files, and refs.
- `--sort updated|created|turns|name|folder` and `--order asc|desc` control result order.
- `--limit <n>` caps the result count (default 50, `0` for no limit).
- `--format json|ids|table` chooses JSON output, one session ID per line, or a readable table. `--ids` and `--table` are shortcuts.

Expand Down
71 changes: 66 additions & 5 deletions cmd/dispatch/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const (
// searchOptions holds the parsed flags for the search command.
type searchOptions struct {
filter data.FilterOptions
sort data.SortOptions
limit int
format searchOutputFormat
}
Expand Down Expand Up @@ -72,7 +73,7 @@ func runSearch(w io.Writer, args []string) error {
limit = searchAllLimit
}

sessions, err := searchListSessionsFn(opts.filter, limit)
sessions, err := searchListSessionsFn(opts.filter, opts.sort, limit)
if err != nil {
return err
}
Expand Down Expand Up @@ -165,7 +166,11 @@ func searchTableCell(v string) string {
// "search". A single leading token that does not start with "-" is treated as
// the search query, matching how the TUI seeds its search box.
func parseSearchArgs(args []string) (searchOptions, error) {
opts := searchOptions{limit: searchDefaultLimit, format: searchFormatJSON}
opts := searchOptions{
sort: defaultSearchSort(),
limit: searchDefaultLimit,
format: searchFormatJSON,
}

rest := args
if len(rest) > 0 {
Expand Down Expand Up @@ -264,6 +269,28 @@ func parseSearchArgs(args []string) (searchOptions, error) {
}
opts.filter.Until = &t
i = ni
case name == "--sort":
v, ni, err := takeValue(i, "--sort", inlineOrEmpty(inline, hasInline))
if err != nil {
return searchOptions{}, err
}
field, err := parseSearchSortField(v)
if err != nil {
return searchOptions{}, err
}
opts.sort.Field = field
i = ni
case name == "--order":
v, ni, err := takeValue(i, "--order", inlineOrEmpty(inline, hasInline))
if err != nil {
return searchOptions{}, err
}
order, err := parseSearchSortOrder(v)
if err != nil {
return searchOptions{}, err
}
opts.sort.Order = order
i = ni
case name == "--limit" || name == "-n":
v, ni, err := takeValue(i, "--limit", inlineOrEmpty(inline, hasInline))
if err != nil {
Expand All @@ -289,6 +316,38 @@ func parseSearchArgs(args []string) (searchOptions, error) {
return opts, nil
}

func defaultSearchSort() data.SortOptions {
return data.SortOptions{Field: data.SortByUpdated, Order: data.Descending}
}

func parseSearchSortField(v string) (data.SortField, error) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "updated":
return data.SortByUpdated, nil
case "created":
return data.SortByCreated, nil
case "turns":
return data.SortByTurns, nil
case "name":
return data.SortByName, nil
case "folder":
return data.SortByFolder, nil
default:
return "", fmt.Errorf("invalid --sort value %q (want updated, created, turns, name, or folder)", v)
}
}

func parseSearchSortOrder(v string) (data.SortOrder, error) {
switch strings.ToLower(strings.TrimSpace(v)) {
case "asc":
return data.Ascending, nil
case "desc":
return data.Descending, nil
default:
return "", fmt.Errorf("invalid --order value %q (want asc or desc)", v)
}
}

func parseSearchFormat(v string) (searchOutputFormat, error) {
switch strings.ToLower(strings.TrimSpace(v)) {
case string(searchFormatJSON):
Expand All @@ -313,15 +372,17 @@ func parseSearchLimit(v string) (int, error) {
}

// defaultSearchListSessions loads sessions matching the filter from the default
// session store, ordered by most recent activity first.
func defaultSearchListSessions(filter data.FilterOptions, limit int) ([]data.Session, error) {
// session store.
func defaultSearchListSessions(filter data.FilterOptions, sortOpts data.SortOptions, limit int) ([]data.Session, error) {
store, err := data.Open()
if err != nil {
return nil, fmt.Errorf("opening session store: %w", err)
}
defer store.Close() //nolint:errcheck // read-only, best-effort close

sortOpts := data.SortOptions{Field: data.SortByUpdated, Order: data.Descending}
if sortOpts.Field == "" {
sortOpts = defaultSearchSort()
}
sessions, err := store.ListSessions(context.Background(), filter, sortOpts, limit)
if err != nil {
return nil, fmt.Errorf("listing sessions: %w", err)
Expand Down
39 changes: 29 additions & 10 deletions cmd/dispatch/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

// withSearchList swaps the search command's session loader for a test double
// and restores it afterward, matching the seam helper used by the stats tests.
func withSearchList(t *testing.T, fn func(data.FilterOptions, int) ([]data.Session, error)) {
func withSearchList(t *testing.T, fn func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error)) {
t.Helper()
prev := searchListSessionsFn
searchListSessionsFn = fn
Expand All @@ -29,6 +29,8 @@ func TestParseSearchArgsQueryAndFilters(t *testing.T) {
"--host", "cli",
"--deep",
"--limit", "10",
"--sort", "turns",
"--order=asc",
"--since", "2026-01-01",
"--until", "2026-12-31",
})
Expand Down Expand Up @@ -56,6 +58,12 @@ func TestParseSearchArgsQueryAndFilters(t *testing.T) {
if opts.limit != 10 {
t.Errorf("limit = %d, want 10", opts.limit)
}
if opts.sort.Field != data.SortByTurns {
t.Errorf("sort field = %q, want %q", opts.sort.Field, data.SortByTurns)
}
if opts.sort.Order != data.Ascending {
t.Errorf("sort order = %q, want %q", opts.sort.Order, data.Ascending)
}
if opts.format != searchFormatJSON {
t.Errorf("format = %q, want json", opts.format)
}
Expand All @@ -75,6 +83,9 @@ func TestParseSearchArgsDefaultLimit(t *testing.T) {
if opts.limit != searchDefaultLimit {
t.Errorf("limit = %d, want default %d", opts.limit, searchDefaultLimit)
}
if opts.sort != defaultSearchSort() {
t.Errorf("sort = %+v, want %+v", opts.sort, defaultSearchSort())
}
if opts.filter.Query != "" {
t.Errorf("Query = %q, want empty", opts.filter.Query)
}
Expand Down Expand Up @@ -121,6 +132,9 @@ func TestParseSearchArgsErrors(t *testing.T) {
{"search", "--repo"},
{"search", "--format"},
{"search", "--format", "yaml"},
{"search", "--sort"},
{"search", "--sort", "attention"},
{"search", "--order", "sideways"},
}
for _, args := range cases {
if _, err := parseSearchArgs(args); err == nil {
Expand All @@ -144,15 +158,17 @@ func TestRunSearchJSONOutput(t *testing.T) {
},
}
var gotFilter data.FilterOptions
var gotSort data.SortOptions
var gotLimit int
withSearchList(t, func(f data.FilterOptions, limit int) ([]data.Session, error) {
withSearchList(t, func(f data.FilterOptions, sort data.SortOptions, limit int) ([]data.Session, error) {
gotFilter = f
gotSort = sort
gotLimit = limit
return sessions, nil
})

var buf bytes.Buffer
if err := runSearch(&buf, []string{"search", "auth", "--limit", "5"}); err != nil {
if err := runSearch(&buf, []string{"search", "auth", "--limit", "5", "--sort", "name", "--order", "asc"}); err != nil {
t.Fatalf("runSearch returned error: %v", err)
}

Expand All @@ -162,6 +178,9 @@ func TestRunSearchJSONOutput(t *testing.T) {
if gotLimit != 5 {
t.Errorf("limit = %d, want 5", gotLimit)
}
if gotSort.Field != data.SortByName || gotSort.Order != data.Ascending {
t.Errorf("sort = %+v, want name asc", gotSort)
}

var out []searchSession
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
Expand All @@ -180,7 +199,7 @@ func TestRunSearchIDsOutput(t *testing.T) {
{ID: "session-a"},
{ID: "session-b"},
}
withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) {
withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) {
return sessions, nil
})

Expand All @@ -195,7 +214,7 @@ func TestRunSearchIDsOutput(t *testing.T) {
}

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

Expand Down Expand Up @@ -225,7 +244,7 @@ func TestRunSearchTableOutput(t *testing.T) {
UpdatedAt: "2026-01-05T09:00:00Z",
},
}
withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) {
withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) {
return sessions, nil
})

Expand All @@ -247,7 +266,7 @@ func TestRunSearchTableOutput(t *testing.T) {
}

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

Expand All @@ -261,7 +280,7 @@ func TestRunSearchTableEmptyPrintsHeader(t *testing.T) {
}

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

Expand All @@ -276,7 +295,7 @@ func TestRunSearchEmptyIsEmptyArray(t *testing.T) {

func TestRunSearchNoLimitUsesCeiling(t *testing.T) {
var gotLimit int
withSearchList(t, func(_ data.FilterOptions, limit int) ([]data.Session, error) {
withSearchList(t, func(_ data.FilterOptions, _ data.SortOptions, limit int) ([]data.Session, error) {
gotLimit = limit
return nil, nil
})
Expand All @@ -291,7 +310,7 @@ func TestRunSearchNoLimitUsesCeiling(t *testing.T) {
}

func TestRunSearchPropagatesStoreError(t *testing.T) {
withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) {
withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) {
return nil, errors.New("store boom")
})

Expand Down