From b532a1bc2d590b03eb04e6e082d111bc43d08bb0 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:02:13 -0700 Subject: [PATCH] feat(tui): notify when a session enters the waiting state Add a notify_on_waiting setting that rings the terminal bell and shows a footer message when a session transitions into 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 sessions already waiting when dispatch starts do not trigger it. Leaving and re-entering waiting notifies again. The setting is off by default and can be toggled from the Settings panel (,) or set in config.json. Closes #209 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +- internal/config/config.go | 6 + internal/tui/cmdpalette.go | 1 + internal/tui/components/configpanel.go | 8 + internal/tui/components/configpanel_test.go | 15 ++ internal/tui/handlers.go | 74 ++++++++ internal/tui/model.go | 12 ++ internal/tui/model_notify_waiting_test.go | 176 ++++++++++++++++++++ 8 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 internal/tui/model_notify_waiting_test.go 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") + } +}