diff --git a/README.md b/README.md index 062b7b0..371aa80 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess - **Work status detection** — analyzes `plan.md` files to identify sessions with incomplete planned work. Colored dots show completion status in the session list and preview panel. Press `R` to explicitly scan work status. Filter by work completion via the `!` status picker. Supports AI-powered analysis via Copilot SDK `analyze_completion` tool - **Session hiding** (`h` / `H`) — hide sessions from the list, toggle visibility of hidden sessions, persistent state - **Session favorites** (`*`) — star sessions as favorites. Filter to show only favorites via the `!` status picker -- **Settings panel** (`,`) — 12 fields: Yolo Mode, Agent, Model, Launch Mode, Pane Direction, Terminal, Shell, Custom Command, Theme, Crash Recovery, Preview Position, Excluded Words +- **Settings panel** (`,`) — 15 fields: Yolo Mode, Agent, Model, Launch Mode, Pane Direction, Terminal, Shell, Custom Command, Theme, Crash Recovery, Preview Position, Redact Secrets, Excluded Words, Auto Refresh, Notify On Waiting - **Shell picker** — auto-detects installed shells, modal picker when multiple available - **5 built-in themes** — Dispatch Dark, Dispatch Light, Campbell, One Half Dark, One Half Light + custom via Windows Terminal JSON - **Help overlay** (`?`) — two-column grouped keyboard shortcuts @@ -303,6 +303,7 @@ Configuration is stored in the platform-specific config directory: | `excluded_dirs` | array | `[]` | Directory paths to hide from session list | | `excluded_words` | array | `[]` | Comma-separated words; sessions containing any word are hidden | | `attention_threshold` | string | `"15m"` | Duration after which an inactive running session is marked stale | +| `notify_on_waiting` | bool | `false` | Ring the terminal bell and show a footer message when a session enters the waiting state | | `auto_refresh_seconds` | int | *(unset)* | Session-list poll interval in seconds. Unset uses the default (2s); `0` disables polling; a positive value sets the interval (minimum 1s). Applies on next launch | | `theme` | string | `"auto"` | Color scheme: `auto` or a named scheme | | `workspace_recovery` | bool | `true` | Detect sessions interrupted by crash/reboot | @@ -345,6 +346,7 @@ When `launch_mode` is `"pane"`, the `pane_direction` value maps to Windows Termi "excluded_dirs": [], "theme": "auto", "workspace_recovery": true, + "notify_on_waiting": false, "ai_search": false, "hiddenSessions": [], "favoriteSessions": [] diff --git a/internal/config/config.go b/internal/config/config.go index 0a6dd4d..e1ebfb1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -212,6 +212,12 @@ type Config struct { // instead of "waiting" or "active". Default is "15m". AttentionThreshold string `json:"attention_threshold,omitempty"` + // NotifyOnWaiting rings the terminal bell and shows a footer message when + // a session enters the waiting state (the AI has responded and is waiting + // for user input). The bell fires once per transition into waiting, not on + // every scan and not for sessions already waiting when dispatch starts. + NotifyOnWaiting bool `json:"notify_on_waiting,omitempty"` + // Theme is the active color scheme name. "auto" (or empty) means // detect from the terminal; any other value is looked up in Schemes // and then the built-in scheme list. diff --git a/internal/tui/cmdpalette.go b/internal/tui/cmdpalette.go index e60692c..ac8b1c0 100644 --- a/internal/tui/cmdpalette.go +++ b/internal/tui/cmdpalette.go @@ -151,6 +151,7 @@ func (m Model) handleCmdPaletteAction(msg cmdPaletteActionMsg) (tea.Model, tea.C RedactSecrets: m.cfg.RedactPreviewSecrets, ExcludedWords: joinExcludedWords(m.cfg.ExcludedWords), AutoRefresh: autoRefreshFieldValue(m.cfg.AutoRefreshSeconds), + NotifyOnWaiting: m.cfg.NotifyOnWaiting, }) m.state = stateConfigPanel return m, nil diff --git a/internal/tui/components/configpanel.go b/internal/tui/components/configpanel.go index e527f36..2cf36bc 100644 --- a/internal/tui/components/configpanel.go +++ b/internal/tui/components/configpanel.go @@ -34,6 +34,7 @@ const ( cfgRedactSecrets cfgExcludedWords cfgAutoRefresh + cfgNotifyOnWaiting cfgFieldCount ) @@ -58,6 +59,7 @@ type ConfigPanel struct { redactSecrets bool excludedWords string // comma-separated list of filter words autoRefresh string // session-list auto-refresh seconds ("" default, "0" off) + notifyOnWaiting bool // Available options for cycling. terminals []string @@ -104,6 +106,7 @@ type ConfigValues struct { RedactSecrets bool ExcludedWords string // comma-separated filter words AutoRefresh string // auto-refresh seconds ("" default, "0" off) + NotifyOnWaiting bool } // SetValues loads the config panel state from external values. @@ -122,6 +125,7 @@ func (c *ConfigPanel) SetValues(v ConfigValues) { c.redactSecrets = v.RedactSecrets c.excludedWords = v.ExcludedWords c.autoRefresh = v.AutoRefresh + c.notifyOnWaiting = v.NotifyOnWaiting } // Values returns the current state of all editable fields. @@ -141,6 +145,7 @@ func (c *ConfigPanel) Values() ConfigValues { RedactSecrets: c.redactSecrets, ExcludedWords: c.excludedWords, AutoRefresh: c.autoRefresh, + NotifyOnWaiting: c.notifyOnWaiting, } } @@ -243,6 +248,8 @@ func (c *ConfigPanel) HandleEnter() tea.Cmd { c.previewPosition = cyclePreviewPosition(c.previewPosition) case cfgRedactSecrets: c.redactSecrets = !c.redactSecrets + case cfgNotifyOnWaiting: + c.notifyOnWaiting = !c.notifyOnWaiting default: // cfgFieldCount is a sentinel; no action needed. } @@ -322,6 +329,7 @@ func (c ConfigPanel) View() string { {"Redact Secrets", boolDisplay(c.redactSecrets), false}, {"Excluded Words", stringDisplay(c.excludedWords), false}, {"Auto Refresh", autoRefreshDisplay(c.autoRefresh), false}, + {"Notify On Waiting", boolDisplay(c.notifyOnWaiting), false}, } var body strings.Builder diff --git a/internal/tui/components/configpanel_test.go b/internal/tui/components/configpanel_test.go index 5c19116..33f3550 100644 --- a/internal/tui/components/configpanel_test.go +++ b/internal/tui/components/configpanel_test.go @@ -161,6 +161,21 @@ func TestConfigPanel_HandleEnter_WorkspaceRecoveryToggle(t *testing.T) { } } +func TestConfigPanel_HandleEnter_NotifyOnWaitingToggle(t *testing.T) { + t.Parallel() + cp := NewConfigPanel() + cp.SetValues(ConfigValues{NotifyOnWaiting: true}) + cp.cursor = cfgNotifyOnWaiting + cp.HandleEnter() + if cp.Values().NotifyOnWaiting { + t.Error("HandleEnter on notifyOnWaiting should toggle to false") + } + cp.HandleEnter() + if !cp.Values().NotifyOnWaiting { + t.Error("second HandleEnter on notifyOnWaiting should toggle back to true") + } +} + func TestConfigPanel_HandleEnter_PreviewPositionCycle(t *testing.T) { t.Parallel() cp := NewConfigPanel() diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go index 71c0c6c..efc342e 100644 --- a/internal/tui/handlers.go +++ b/internal/tui/handlers.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "maps" + "os" "time" "charm.land/bubbles/v2/spinner" @@ -284,6 +285,20 @@ func (m Model) handleDataError(msg dataErrorMsg) (Model, tea.Cmd) { //nolint:unp return m, nil } +// bellFn writes the terminal bell (BEL) character. It is a package variable so +// tests can swap it out to observe that the bell fired without touching stdout. +var bellFn = func() { + fmt.Fprint(os.Stdout, "\a") +} + +// bellCmd returns a command that rings the terminal bell. +func bellCmd() tea.Cmd { + return func() tea.Msg { + bellFn() + return nil + } +} + // ----- Attention scanning -------------------------------------------------- func (m Model) handleAttentionQuickScanned(msg attentionQuickScannedMsg) (Model, tea.Cmd) { @@ -308,9 +323,68 @@ func (m Model) handleAttentionScanned(msg attentionScannedMsg) (Model, tea.Cmd) if len(m.attentionFilter) > 0 { cmds = append(cmds, m.loadSessionsCmd()) } + // Ring the bell when a session newly enters the waiting state. + if bell := m.notifyWaiting(msg.statuses); bell != nil { + cmds = append(cmds, bell) + } return m, tea.Batch(cmds...) } +// notifyWaiting detects sessions that transitioned into the waiting state +// since the previous scan and, when the notify_on_waiting setting is enabled, +// rings the terminal bell once and sets a short footer message. The first scan +// after startup only records a baseline so sessions already waiting when +// dispatch launches do not trigger the bell. It returns a tea.Cmd that rings +// the bell (and clears the footer), or nil when nothing should be signalled. +func (m *Model) notifyWaiting(statuses map[string]data.AttentionStatus) tea.Cmd { + newly := m.recordWaitingTransitions(statuses) + + // The first scan just establishes the baseline; never notify on it. + if !m.attentionScanned { + m.attentionScanned = true + return nil + } + if newly == 0 || !m.cfg.NotifyOnWaiting { + return nil + } + + waiting := len(m.waitingNotified) + if waiting == 1 { + m.statusInfo = "1 session is waiting" + } else { + m.statusInfo = fmt.Sprintf("%d sessions are waiting", waiting) + } + return tea.Batch(bellCmd(), clearStatusAfter(4*time.Second)) +} + +// recordWaitingTransitions updates the set of sessions that have already +// triggered a waiting notification and returns how many sessions newly entered +// the waiting state. Sessions that leave the waiting state (or disappear) are +// dropped so a later re-entry notifies again. +func (m *Model) recordWaitingTransitions(statuses map[string]data.AttentionStatus) int { + if m.waitingNotified == nil { + m.waitingNotified = make(map[string]struct{}) + } + newly := 0 + for id, st := range statuses { + if st == data.AttentionWaiting { + if _, seen := m.waitingNotified[id]; !seen { + m.waitingNotified[id] = struct{}{} + newly++ + } + } else { + delete(m.waitingNotified, id) + } + } + // Forget sessions that are no longer reported at all. + for id := range m.waitingNotified { + if _, ok := statuses[id]; !ok { + delete(m.waitingNotified, id) + } + } + return newly +} + func (m Model) handleAttentionTick() (Model, tea.Cmd) { return m, m.scanAttentionCmd() } diff --git a/internal/tui/model.go b/internal/tui/model.go index 59abb85..15f595e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -370,6 +370,14 @@ type Model struct { attentionMap map[string]data.AttentionStatus attentionFilter map[data.AttentionStatus]struct{} // when non-empty, only show sessions with matching status + // Waiting notification tracking. waitingNotified holds the IDs of + // sessions currently in the waiting state that have already triggered a + // bell, so re-entering waiting notifies again but a steady waiting state + // does not. attentionScanned becomes true after the first scan so the + // initial population never rings the bell. + waitingNotified map[string]struct{} + attentionScanned bool + // Plan status tracking — scanned from session-state directories. planMap map[string]bool filterPlans bool // when true, only show sessions with a plan.md file @@ -412,6 +420,7 @@ func NewModel() Model { RedactSecrets: cfg.RedactPreviewSecrets, ExcludedWords: strings.Join(cfg.ExcludedWords, ", "), AutoRefresh: autoRefreshFieldValue(cfg.AutoRefreshSeconds), + NotifyOnWaiting: cfg.NotifyOnWaiting, }) // Build the list of available theme names for the config panel. @@ -469,6 +478,7 @@ func NewModel() Model { compareView: components.NewCompareView(), cmdPalette: components.NewCmdPalette(), attentionFilter: make(map[data.AttentionStatus]struct{}), + waitingNotified: make(map[string]struct{}), dbWatchCh: make(chan struct{}, 1), } @@ -1174,6 +1184,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { RedactSecrets: m.cfg.RedactPreviewSecrets, ExcludedWords: strings.Join(m.cfg.ExcludedWords, ", "), AutoRefresh: autoRefreshFieldValue(m.cfg.AutoRefreshSeconds), + NotifyOnWaiting: m.cfg.NotifyOnWaiting, }) m.state = stateConfigPanel return m, nil @@ -1956,6 +1967,7 @@ func (m *Model) saveConfigFromPanel() { m.cfg.ExcludedWords = parseExcludedWords(v.ExcludedWords) m.filter.ExcludedWords = m.cfg.ExcludedWords m.cfg.AutoRefreshSeconds = parseAutoRefresh(v.AutoRefresh) + m.cfg.NotifyOnWaiting = v.NotifyOnWaiting resolveTheme(m.cfg) // If the user switched back to "auto", re-apply with the detected // terminal brightness so colours adapt immediately. diff --git a/internal/tui/model_notify_waiting_test.go b/internal/tui/model_notify_waiting_test.go new file mode 100644 index 0000000..e4227f3 --- /dev/null +++ b/internal/tui/model_notify_waiting_test.go @@ -0,0 +1,176 @@ +package tui + +import ( + "testing" + + "github.com/jongio/dispatch/internal/data" +) + +// TestNotifyWaiting_FirstScanNoBell verifies the initial scan only records a +// baseline and never rings the bell, even when a session is already waiting. +func TestNotifyWaiting_FirstScanNoBell(t *testing.T) { + m := newTestModel() + m.cfg.NotifyOnWaiting = true + + cmd := m.notifyWaiting(map[string]data.AttentionStatus{"s1": data.AttentionWaiting}) + + if cmd != nil { + t.Error("first scan should not ring the bell") + } + if !m.attentionScanned { + t.Error("first scan should mark attentionScanned") + } + if m.statusInfo != "" { + t.Errorf("statusInfo = %q, want empty on first scan", m.statusInfo) + } + if _, ok := m.waitingNotified["s1"]; !ok { + t.Error("first scan should record already-waiting sessions as baseline") + } +} + +// TestNotifyWaiting_TransitionRingsBell verifies a session entering waiting +// after the baseline rings the bell and sets a footer message. +func TestNotifyWaiting_TransitionRingsBell(t *testing.T) { + m := newTestModel() + m.cfg.NotifyOnWaiting = true + m.attentionScanned = true + + cmd := m.notifyWaiting(map[string]data.AttentionStatus{"s1": data.AttentionWaiting}) + + if cmd == nil { + t.Fatal("transition into waiting should ring the bell") + } + if m.statusInfo != "1 session is waiting" { + t.Errorf("statusInfo = %q, want %q", m.statusInfo, "1 session is waiting") + } +} + +// TestNotifyWaiting_Disabled verifies no bell fires when the setting is off. +func TestNotifyWaiting_Disabled(t *testing.T) { + m := newTestModel() + m.cfg.NotifyOnWaiting = false + m.attentionScanned = true + + cmd := m.notifyWaiting(map[string]data.AttentionStatus{"s1": data.AttentionWaiting}) + + if cmd != nil { + t.Error("bell should not ring when notify_on_waiting is disabled") + } + if m.statusInfo != "" { + t.Errorf("statusInfo = %q, want empty when disabled", m.statusInfo) + } +} + +// TestNotifyWaiting_SteadyStateNoSecondBell verifies a session that stays in +// the waiting state only rings the bell once. +func TestNotifyWaiting_SteadyStateNoSecondBell(t *testing.T) { + m := newTestModel() + m.cfg.NotifyOnWaiting = true + m.attentionScanned = true + + statuses := map[string]data.AttentionStatus{"s1": data.AttentionWaiting} + if cmd := m.notifyWaiting(statuses); cmd == nil { + t.Fatal("first transition should ring the bell") + } + m.statusInfo = "" + if cmd := m.notifyWaiting(statuses); cmd != nil { + t.Error("steady waiting state should not ring the bell again") + } +} + +// TestNotifyWaiting_ReentryRingsAgain verifies leaving and re-entering the +// waiting state rings the bell a second time. +func TestNotifyWaiting_ReentryRingsAgain(t *testing.T) { + m := newTestModel() + m.cfg.NotifyOnWaiting = true + m.attentionScanned = true + + if cmd := m.notifyWaiting(map[string]data.AttentionStatus{"s1": data.AttentionWaiting}); cmd == nil { + t.Fatal("first transition should ring the bell") + } + // Leave the waiting state. + if cmd := m.notifyWaiting(map[string]data.AttentionStatus{"s1": data.AttentionWorking}); cmd != nil { + t.Error("leaving waiting should not ring the bell") + } + if _, ok := m.waitingNotified["s1"]; ok { + t.Error("session should be dropped from waitingNotified after leaving waiting") + } + // Re-enter waiting. + if cmd := m.notifyWaiting(map[string]data.AttentionStatus{"s1": data.AttentionWaiting}); cmd == nil { + t.Error("re-entering waiting should ring the bell again") + } +} + +// TestNotifyWaiting_MultipleSessionsSingleBell verifies several sessions +// entering waiting in one scan ring the bell once with a pluralized message. +func TestNotifyWaiting_MultipleSessionsSingleBell(t *testing.T) { + m := newTestModel() + m.cfg.NotifyOnWaiting = true + m.attentionScanned = true + + cmd := m.notifyWaiting(map[string]data.AttentionStatus{ + "s1": data.AttentionWaiting, + "s2": data.AttentionWaiting, + }) + + if cmd == nil { + t.Fatal("multiple sessions entering waiting should ring the bell") + } + if m.statusInfo != "2 sessions are waiting" { + t.Errorf("statusInfo = %q, want %q", m.statusInfo, "2 sessions are waiting") + } +} + +// TestNotifyWaiting_ForgetsDisappearedSessions verifies sessions that vanish +// from the scan are dropped from the notified set. +func TestNotifyWaiting_ForgetsDisappearedSessions(t *testing.T) { + m := newTestModel() + m.cfg.NotifyOnWaiting = true + m.attentionScanned = true + + m.notifyWaiting(map[string]data.AttentionStatus{"s1": data.AttentionWaiting}) + m.notifyWaiting(map[string]data.AttentionStatus{}) + if _, ok := m.waitingNotified["s1"]; ok { + t.Error("disappeared session should be forgotten") + } +} + +// TestBellCmd_RingsBell verifies bellCmd invokes the bell function and returns +// a nil message. +func TestBellCmd_RingsBell(t *testing.T) { + called := 0 + orig := bellFn + bellFn = func() { called++ } + t.Cleanup(func() { bellFn = orig }) + + msg := bellCmd()() + if called != 1 { + t.Errorf("bellFn called %d times, want 1", called) + } + if msg != nil { + t.Errorf("bellCmd message = %v, want nil", msg) + } +} + +// TestHandleAttentionScanned_NotifiesOnTransition verifies the handler wiring: +// the first scan is a baseline and a later transition sets the footer message. +func TestHandleAttentionScanned_NotifiesOnTransition(t *testing.T) { + m := newTestModelWithSize(120, 30) + m.cfg.NotifyOnWaiting = true + + // First scan: baseline, no notification. + m1, _ := m.handleAttentionScanned(attentionScannedMsg{ + statuses: map[string]data.AttentionStatus{"s1": data.AttentionActive}, + }) + if m1.statusInfo != "" { + t.Errorf("statusInfo = %q, want empty after baseline scan", m1.statusInfo) + } + + // Second scan: s1 enters waiting. + m2, _ := m1.handleAttentionScanned(attentionScannedMsg{ + statuses: map[string]data.AttentionStatus{"s1": data.AttentionWaiting}, + }) + if m2.statusInfo != "1 session is waiting" { + t.Errorf("statusInfo = %q, want %q", m2.statusInfo, "1 session is waiting") + } +}