Skip to content
Open
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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": []
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/tui/cmdpalette.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions internal/tui/components/configpanel.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
cfgRedactSecrets
cfgExcludedWords
cfgAutoRefresh
cfgNotifyOnWaiting
cfgFieldCount
)

Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -141,6 +145,7 @@ func (c *ConfigPanel) Values() ConfigValues {
RedactSecrets: c.redactSecrets,
ExcludedWords: c.excludedWords,
AutoRefresh: c.autoRefresh,
NotifyOnWaiting: c.notifyOnWaiting,
}
}

Expand Down Expand Up @@ -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.
}
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions internal/tui/components/configpanel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
74 changes: 74 additions & 0 deletions internal/tui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"maps"
"os"
"time"

"charm.land/bubbles/v2/spinner"
Expand Down Expand Up @@ -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) {
Expand All @@ -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()
}
Expand Down
12 changes: 12 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1937,6 +1948,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.
Expand Down
Loading