diff --git a/README.md b/README.md index cd1e2a2..771c02b 100644 --- a/README.md +++ b/README.md @@ -404,10 +404,11 @@ dispatch watch --once --json # JSON snapshot dispatch watch # stream transitions (Ctrl-C to stop) dispatch watch --interval 10s # custom poll interval dispatch watch --once --repo jongio/dispatch # filter by repo +dispatch watch --once --status waiting # filter by attention status dispatch watch --exec 'notify-send "$DISPATCH_SESSION_STATE"' # run a hook on each transition ``` -The terminal bell rings when a session transitions to waiting or interrupted. Use `--repo`, `--branch`, or `--folder` to limit which sessions are monitored. +The terminal bell rings when a session transitions to waiting or interrupted. Use `--repo`, `--branch`, `--folder`, or `--status waiting|working|thinking|active|stale|compacting|interrupted|idle` to limit which sessions are monitored. #### Transition hooks diff --git a/cmd/dispatch/watch.go b/cmd/dispatch/watch.go index a93fbf0..3313d98 100644 --- a/cmd/dispatch/watch.go +++ b/cmd/dispatch/watch.go @@ -90,13 +90,15 @@ func fireWatchHook(command, id, state, prevState string, meta data.Session) { // watchOptions holds parsed flags for the watch command. type watchOptions struct { - once bool - json bool - interval time.Duration - repo string - branch string - folder string - exec string + once bool + json bool + interval time.Duration + repo string + branch string + folder string + exec string + hasStatus bool + status data.AttentionStatus } // watchSnapshot is the JSON representation of attention state at a point in time. @@ -193,6 +195,17 @@ func parseWatchArgs(args []string) (watchOptions, error) { return watchOptions{}, fmt.Errorf("--exec requires a command") } opts.exec = rest[i] + case arg == "--status": + i++ + if i >= len(rest) { + return watchOptions{}, fmt.Errorf("--status requires a value") + } + status, sErr := parseWatchStatus(rest[i]) + if sErr != nil { + return watchOptions{}, sErr + } + opts.status = status + opts.hasStatus = true case strings.HasPrefix(arg, "-"): return watchOptions{}, fmt.Errorf("unknown flag: %s", arg) default: @@ -208,6 +221,29 @@ func parseWatchArgs(args []string) (watchOptions, error) { return opts, nil } +func parseWatchStatus(value string) (data.AttentionStatus, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "waiting": + return data.AttentionWaiting, nil + case "working": + return data.AttentionWorking, nil + case "thinking": + return data.AttentionThinking, nil + case "active": + return data.AttentionActive, nil + case "stale": + return data.AttentionStale, nil + case "compacting": + return data.AttentionCompacting, nil + case "interrupted": + return data.AttentionInterrupted, nil + case "idle": + return data.AttentionIdle, nil + default: + return data.AttentionIdle, fmt.Errorf("invalid --status value %q (want waiting, working, thinking, active, stale, compacting, interrupted, or idle)", value) + } +} + // runWatchOnce prints a single attention snapshot and exits. func runWatchOnce(w io.Writer, opts watchOptions) error { snap, err := buildWatchSnapshot(opts) @@ -290,21 +326,23 @@ func runWatchStream(w io.Writer, opts watchOptions) error { } } // Detect sessions that disappeared. - for id := range prev { - if _, ok := current[id]; !ok { - ts := time.Now().Format("15:04:05") - if opts.json { - enc := json.NewEncoder(w) - _ = enc.Encode(map[string]string{ - "time": ts, - "id": id, - "status": "gone", - }) - } else { - fmt.Fprintf(w, "[%s] %s gone\n", ts, shortID(id)) - } - if opts.exec != "" { - fireWatchHook(opts.exec, id, "gone", prev[id].String(), meta[id]) + if !opts.hasStatus { + for id := range prev { + if _, ok := current[id]; !ok { + ts := time.Now().Format("15:04:05") + if opts.json { + enc := json.NewEncoder(w) + _ = enc.Encode(map[string]string{ + "time": ts, + "id": id, + "status": "gone", + }) + } else { + fmt.Fprintf(w, "[%s] %s gone\n", ts, shortID(id)) + } + if opts.exec != "" { + fireWatchHook(opts.exec, id, "gone", prev[id].String(), meta[id]) + } } } } @@ -319,7 +357,7 @@ func scanFiltered(opts watchOptions) map[string]data.AttentionStatus { attention := watchScanAttentionFn(threshold) if opts.repo == "" && opts.branch == "" && opts.folder == "" { - return attention + return filterAttentionByStatus(attention, opts) } sessions, err := watchListSessionsFn(data.FilterOptions{ @@ -328,7 +366,7 @@ func scanFiltered(opts watchOptions) map[string]data.AttentionStatus { Folder: opts.folder, }) if err != nil { - return attention + return filterAttentionByStatus(attention, opts) } allowed := make(map[string]bool, len(sessions)) @@ -342,6 +380,19 @@ func scanFiltered(opts watchOptions) map[string]data.AttentionStatus { filtered[id] = status } } + return filterAttentionByStatus(filtered, opts) +} + +func filterAttentionByStatus(attention map[string]data.AttentionStatus, opts watchOptions) map[string]data.AttentionStatus { + if !opts.hasStatus { + return attention + } + filtered := make(map[string]data.AttentionStatus, len(attention)) + for id, status := range attention { + if status == opts.status { + filtered[id] = status + } + } return filtered } @@ -408,7 +459,8 @@ func buildWatchSnapshot(opts watchOptions) (watchSnapshot, error) { snap.Idle++ } - if status == data.AttentionWaiting || status == data.AttentionInterrupted || + if opts.hasStatus || + status == data.AttentionWaiting || status == data.AttentionInterrupted || status == data.AttentionWorking || status == data.AttentionThinking || status == data.AttentionActive { entries = append(entries, watchSessionEntry{ diff --git a/cmd/dispatch/watch_test.go b/cmd/dispatch/watch_test.go index 9179758..ea535ec 100644 --- a/cmd/dispatch/watch_test.go +++ b/cmd/dispatch/watch_test.go @@ -153,6 +153,35 @@ func TestRunWatch_FilteredByRepo(t *testing.T) { } } +func TestRunWatch_FilteredByStatus(t *testing.T) { + attention := map[string]data.AttentionStatus{ + "ses-1": data.AttentionWaiting, + "ses-2": data.AttentionIdle, + "ses-3": data.AttentionWaiting, + } + sessions := []data.Session{ + {ID: "ses-1", Summary: "Auth"}, + {ID: "ses-2", Summary: "Quiet"}, + {ID: "ses-3", Summary: "Review"}, + } + withWatchSeams(t, attention, sessions) + + var buf bytes.Buffer + if err := runWatch(&buf, []string{"watch", "--once", "--json", "--status", "waiting"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got watchSnapshot + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got.Total != 2 || got.Waiting != 2 || got.Idle != 0 { + t.Fatalf("snapshot = %+v, want two waiting sessions only", got) + } + if len(got.Sessions) != 2 { + t.Fatalf("sessions len = %d, want 2", len(got.Sessions)) + } +} + func TestParseWatchArgs_Interval(t *testing.T) { opts, err := parseWatchArgs([]string{"watch", "--interval", "10s"}) if err != nil { @@ -173,6 +202,16 @@ func TestParseWatchArgs_IntervalClamped(t *testing.T) { } } +func TestParseWatchArgs_Status(t *testing.T) { + opts, err := parseWatchArgs([]string{"watch", "--status", "Interrupted"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !opts.hasStatus || opts.status != data.AttentionInterrupted { + t.Fatalf("status filter = %v %v, want interrupted", opts.hasStatus, opts.status) + } +} + func TestParseWatchArgs_UnknownFlag(t *testing.T) { _, err := parseWatchArgs([]string{"watch", "--nope"}) if err == nil { @@ -187,6 +226,17 @@ func TestParseWatchArgs_MissingIntervalValue(t *testing.T) { } } +func TestParseWatchArgs_StatusErrors(t *testing.T) { + for _, args := range [][]string{ + {"watch", "--status"}, + {"watch", "--status", "blocked"}, + } { + if _, err := parseWatchArgs(args); err == nil { + t.Fatalf("parseWatchArgs(%v) returned nil error", args) + } + } +} + func TestRunWatch_ListSessionsError(t *testing.T) { prevScan := watchScanAttentionFn watchScanAttentionFn = func(time.Duration) map[string]data.AttentionStatus {