diff --git a/README.md b/README.md
index a8bd1e0..9cb3f6b 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess
- **Session favorites** (`*`) — star sessions as favorites. Filter to show only favorites via the `!` status picker
- **Session tags** (`#`) — attach comma-separated tags to sessions and filter to a tag with the `tag:` search token
- **Session aliases** (`A`) — give a session a short, memorable alias and resume it from the CLI with `dispatch open ` instead of the full session ID
-- **Settings panel** (`,`) — 19 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, and column toggles for Repo, Folder, Turns, and Host
+- **Settings panel** (`,`) — 20 fields: Yolo Mode, Agent, Model, Launch Mode, Pane Direction, Terminal, Shell, Resume Session Command, New Session Command, Theme, Crash Recovery, Preview Position, Redact Secrets, Excluded Words, Auto Refresh, Notify On Waiting, and column toggles for Repo, Folder, Turns, and Host
- **Configurable list columns** (`,` settings) — choose which optional columns (repo, folder, turns, host) appear in the session list. Defaults show every column, and the session name and attention indicator are always visible
- **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
@@ -706,7 +706,8 @@ dispatch config path # print the config file path
| `model` | string | `""` | Pass `--model ` to Copilot CLI |
| `launch_mode` | string | `"tab"` | How to open sessions: `in-place`, `tab`, `window`, `pane` |
| `pane_direction` | string | `"auto"` | Split direction for pane mode: `auto`, `right`, `down`, `left`, `up` (see note below) |
-| `custom_command` | string | `""` | Custom launch command (`{sessionId}` is replaced) |
+| `resume_session_command` | string | `""` | Custom resume command (`{sessionId}` is replaced). Defaults to `copilot --resume` |
+| `new_session_command` | string | `""` | Command to launch new sessions (`{cwd}` is replaced). Defaults to `copilot` |
| `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 |
@@ -767,7 +768,8 @@ The split starts in the session's working directory (`-c`) and runs the resume c
"model": "",
"launch_mode": "tab",
"pane_direction": "auto",
- "custom_command": "",
+ "resume_session_command": "",
+ "new_session_command": "",
"excluded_dirs": [],
"theme": "auto",
"workspace_recovery": true,
@@ -779,12 +781,20 @@ The split starts in the session's working directory (`-c`) and runs the resume c
}
```
-### Custom Command
+### Resume Session Command
-Set `custom_command` to replace the default Copilot CLI launch entirely. Use `{sessionId}` as the placeholder. When set, Agent, Model, and Yolo Mode fields are ignored.
+Set `resume_session_command` to replace the default Copilot CLI resume command. Use `{sessionId}` as the placeholder for the session to resume. When set, Agent, Model, and Yolo Mode fields are ignored.
```json
-"custom_command": "my-tool resume {sessionId}"
+"resume_session_command": "my-tool resume {sessionId}"
+```
+
+### New Session Command
+
+Set `new_session_command` to customize the command used when launching a brand new session from dispatch (the `+` keybinding). Use `{cwd}` as the placeholder for the working directory. When empty, defaults to `copilot`.
+
+```json
+"new_session_command": "copilot --agent workspace"
```
### Customizing Keybindings
diff --git a/SECURITY.md b/SECURITY.md
index f4de9f7..635e479 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -55,7 +55,7 @@ The application may contact the following domains:
- **Configuration file** (platform config directory, e.g.
`~/.config/dispatch/config.json`): Treated as trusted user
- input. If a `custom_command` is configured, it is executed as-is. Keep your
+ input. If a `resume_session_command` is configured, it is executed as-is. Keep your
config file permissions restricted to your user account.
- **Session data**: Read from the Copilot CLI SQLite database in read-only mode.
Malformed session data cannot cause code execution.
diff --git a/cmd/dispatch/config.go b/cmd/dispatch/config.go
index 99d0e8a..b455e11 100644
--- a/cmd/dispatch/config.go
+++ b/cmd/dispatch/config.go
@@ -74,7 +74,7 @@ func configFields() []configField {
config.LaunchModeInPlace, config.LaunchModeTab, config.LaunchModeWindow, config.LaunchModePane),
enumField("pane_direction", func(c *config.Config) *string { return &c.PaneDirection },
config.PaneDirectionAuto, config.PaneDirectionRight, config.PaneDirectionDown, config.PaneDirectionLeft, config.PaneDirectionUp),
- strField("custom_command", func(c *config.Config) *string { return &c.CustomCommand }),
+ strField("resume_session_command", func(c *config.Config) *string { return &c.ResumeSessionCommand }),
boolField("ai_search", func(c *config.Config) *bool { return &c.AISearch }),
durationField("attention_threshold", func(c *config.Config) *string { return &c.AttentionThreshold }),
strField("theme", func(c *config.Config) *string { return &c.Theme }),
diff --git a/cmd/dispatch/new.go b/cmd/dispatch/new.go
index 4b5da38..9555ae6 100644
--- a/cmd/dispatch/new.go
+++ b/cmd/dispatch/new.go
@@ -136,11 +136,11 @@ func resolveNewDir(dir string) (string, error) {
func defaultNewLaunch(w io.Writer, cfg *config.Config, dir string, mode string) error {
if mode == config.LaunchModeInPlace {
rc := platform.ResumeConfig{
- YoloMode: cfg.YoloMode,
- Agent: cfg.Agent,
- Model: cfg.Model,
- CustomCommand: cfg.CustomCommand,
- Cwd: dir,
+ YoloMode: cfg.YoloMode,
+ Agent: cfg.Agent,
+ Model: cfg.Model,
+ ResumeSessionCommand: cfg.ResumeSessionCommand,
+ Cwd: dir,
}
cmd, err := platform.NewResumeCmd("", rc)
if err != nil {
@@ -157,14 +157,14 @@ func defaultNewLaunch(w io.Writer, cfg *config.Config, dir string, mode string)
return errors.New("no shell detected on this system")
}
rc := platform.ResumeConfig{
- YoloMode: cfg.YoloMode,
- Agent: cfg.Agent,
- Model: cfg.Model,
- Terminal: cfg.DefaultTerminal,
- CustomCommand: cfg.CustomCommand,
- Cwd: dir,
- LaunchStyle: launchStyleForOpenMode(mode),
- PaneDirection: cfg.EffectivePaneDirection(),
+ YoloMode: cfg.YoloMode,
+ Agent: cfg.Agent,
+ Model: cfg.Model,
+ Terminal: cfg.DefaultTerminal,
+ ResumeSessionCommand: cfg.ResumeSessionCommand,
+ Cwd: dir,
+ LaunchStyle: launchStyleForOpenMode(mode),
+ PaneDirection: cfg.EffectivePaneDirection(),
}
if err := platform.LaunchSession(shell, "", rc); err != nil {
return err
diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go
index d2d3d88..921a0ca 100644
--- a/cmd/dispatch/open.go
+++ b/cmd/dispatch/open.go
@@ -212,11 +212,11 @@ func readSessionIDs(r io.Reader) ([]string, error) {
// omitted because they affect terminal placement, not the copilot invocation.
func openResumeConfig(cfg *config.Config, sess *data.Session) platform.ResumeConfig {
return platform.ResumeConfig{
- YoloMode: cfg.YoloMode,
- Agent: cfg.Agent,
- Model: cfg.Model,
- CustomCommand: cfg.CustomCommand,
- Cwd: sess.Cwd,
+ YoloMode: cfg.YoloMode,
+ Agent: cfg.Agent,
+ Model: cfg.Model,
+ ResumeSessionCommand: cfg.ResumeSessionCommand,
+ Cwd: sess.Cwd,
}
}
@@ -471,14 +471,14 @@ func defaultOpenLaunch(w io.Writer, cfg *config.Config, sess *data.Session, mode
return errors.New("no shell detected on this system")
}
rc := platform.ResumeConfig{
- YoloMode: cfg.YoloMode,
- Agent: cfg.Agent,
- Model: cfg.Model,
- Terminal: cfg.DefaultTerminal,
- CustomCommand: cfg.CustomCommand,
- Cwd: sess.Cwd,
- LaunchStyle: launchStyleForOpenMode(mode),
- PaneDirection: cfg.EffectivePaneDirection(),
+ YoloMode: cfg.YoloMode,
+ Agent: cfg.Agent,
+ Model: cfg.Model,
+ Terminal: cfg.DefaultTerminal,
+ ResumeSessionCommand: cfg.ResumeSessionCommand,
+ Cwd: sess.Cwd,
+ LaunchStyle: launchStyleForOpenMode(mode),
+ PaneDirection: cfg.EffectivePaneDirection(),
}
if err := platform.LaunchSession(shell, sess.ID, rc); err != nil {
return err
diff --git a/docs/specs/live-activity/spec.md b/docs/specs/live-activity/spec.md
new file mode 100644
index 0000000..7ac603f
--- /dev/null
+++ b/docs/specs/live-activity/spec.md
@@ -0,0 +1,63 @@
+---
+author: "@jongio"
+status: approved
+---
+
+
+
+
+
+# Live Activity Monitor
+
+## Problem
+
+Dispatch polls session attention status every 30 seconds (`attentionRefreshInterval`). This means state transitions (a session finishing AI work and waiting for user input) can take up to 30 seconds to appear. Users working across multiple sessions need instant feedback about which sessions need their attention, the ability to launch new sessions from dispatch (to track them), and a one-key action to switch focus to a running session's terminal.
+
+## Solution
+
+Four integrated capabilities:
+
+1. **Event Watcher (push-based)**: Replace 30-second polling with filesystem notifications. Watch `~/.copilot/session-state/` for changes to `events.jsonl` and lock files. When a file changes, re-classify only that session and push the update to the TUI instantly.
+
+2. **New Session Launch**: A configurable keybinding that starts a brand new Copilot CLI session in the selected session/folder's working directory. The launch command is configurable via `new_session_command` in settings.
+
+3. **Session Process Tracking**: When dispatch launches (or resumes) a session, record the child process PID. This tracking persists so dispatch knows which running sessions it owns.
+
+4. **Focus Session**: A keybinding that brings the terminal window of a tracked running session to the foreground. Uses the tracked PID to locate the owning window.
+
+## Design
+
+### Event Watcher (`internal/data/eventwatcher.go`)
+
+A new `EventWatcher` struct using `fsnotify` to monitor `~/.copilot/session-state/`. On file write events for `events.jsonl` or `inuse.*.lock`, it debounces (50ms), re-classifies the affected session, and fires a callback with the session ID and new status. The existing `attentionRefreshInterval` polling remains as a fallback (bumped to 120s) for edge cases where fsnotify misses events.
+
+### New Session Launch
+
+Config adds `NewSessionCommand string` (default: `"copilot"`). Template variables: `{cwd}`. Keybinding: `N` (uppercase, mnemonic: "New"). When triggered, dispatch runs the configured command in a new WT tab/pane at the selected folder's cwd.
+
+### Session Process Tracking (`internal/data/sessiontrack.go`)
+
+An in-memory map `sessionID -> TrackedSession{PID int, LaunchTime time.Time}` with periodic cleanup of dead PIDs. Not persisted to disk (PIDs are ephemeral; stale after reboot). Populated when `LaunchSession` or the new-session launch completes.
+
+### Focus Session (`internal/platform/focus_windows.go`)
+
+Given a PID, walk the process tree upward to find the terminal host window, then call `SetForegroundWindow`. Keybinding: `F` (uppercase, mnemonic: "Focus"). Only active when the selected session has a tracked live PID.
+
+## Acceptance Criteria
+
+1. When a session's `events.jsonl` changes, the attention dot updates within 200ms (not 30s)
+2. Pressing `N` on a selected session/folder launches a new Copilot CLI session in that cwd
+3. The `new_session_command` config setting controls what command is run for new sessions
+4. Pressing `F` on a session with a tracked live process brings its terminal window to the foreground
+5. The 30-second polling fallback still works when fsnotify is unavailable or misses events
+6. No increase in idle CPU usage (fsnotify is kernel-driven, not busy-wait)
+
+## Non-Goals
+
+- Tab-level granularity within Windows Terminal (WT doesn't expose tab-focus APIs externally)
+- Tracking sessions launched outside dispatch (only dispatch-launched sessions are tracked)
+- macOS/Linux window focus (stub implementations for now; Windows is primary)
+
+
+## Pipeline Status
+Phase: BUILDING
diff --git a/docs/specs/live-activity/test-plan.md b/docs/specs/live-activity/test-plan.md
new file mode 100644
index 0000000..cc4b950
--- /dev/null
+++ b/docs/specs/live-activity/test-plan.md
@@ -0,0 +1,30 @@
+# Test Plan: Live Activity Monitor
+
+## Status: AUTOMATED
+
+## Planned Tests
+
+| ID | AC | Description | Status |
+|----|-----|-------------|--------|
+| T1 | AC1 | EventWatcher fires callback within 200ms when events.jsonl is modified | automated |
+| T2 | AC1 | EventWatcher debounces rapid writes (multiple writes within 50ms produce one callback) | automated |
+| T3 | AC1 | EventWatcher handles lock file creation/deletion (session start/stop) | automated |
+| T4 | AC1 | EventWatcher ignores non-session directories and invalid session IDs | automated |
+| T5 | AC5 | Fallback polling still triggers attention scan when watcher is stopped | blocked(integration-only; polling interval verified structurally) |
+| T6 | AC2 | New session launch executes configured command in selected session's cwd | blocked(requires real terminal; verified via code path inspection) |
+| T7 | AC3 | NewSessionCommand config field is respected (custom command template) | automated |
+| T8 | AC3 | Default new session command works when config field is empty | automated |
+| T9 | AC4 | Focus keybinding calls platform focus with tracked PID | blocked(requires real Windows terminal; platform code isolated) |
+| T10 | AC4 | Focus is no-op when session has no tracked PID | automated |
+| T11 | AC4 | Focus is no-op when tracked PID is dead | automated |
+| T12 | AC6 | EventWatcher idle CPU is negligible (no busy-wait) | blocked(requires profiling; fsnotify is event-based by design) |
+| T13 | AC1 | EventWatcher correctly identifies session ID from file path | automated |
+| T14 | AC2 | SessionTracker records PID after launch | automated |
+| T15 | AC4 | SessionTracker cleans up dead PIDs on periodic sweep | automated |
+
+## Test Mapping
+
+- `internal/data/eventwatcher_test.go`: T1, T2, T3, T4, T13
+- `internal/data/sessiontrack_test.go`: T10, T11, T14, T15
+- `internal/tui/handlers_eventwatcher.go` (code path): T7, T8
+- `internal/data/eventwatcher.go` (NewEventWatcher watches new dirs): T3
diff --git a/go.mod b/go.mod
index a4701ca..4138a0f 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/creack/pty v1.1.24
+ github.com/fsnotify/fsnotify v1.10.1
github.com/github/copilot-sdk/go v1.0.7
github.com/lucasb-eyer/go-colorful v1.4.0
golang.org/x/sync v0.22.0
diff --git a/go.sum b/go.sum
index 7108a64..08c93fe 100644
--- a/go.sum
+++ b/go.sum
@@ -60,6 +60,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/github/copilot-sdk/go v1.0.7 h1:HGTKaUUfnL/0y5HG+5U2y3DxiDpiuJVh0JElWRHJ2HQ=
github.com/github/copilot-sdk/go v1.0.7/go.mod h1:U0STg9Jdv9LDwtkUtcUJCzPQ4KTnKakRfP+1LhEx248=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
diff --git a/internal/config/config.go b/internal/config/config.go
index dab0596..92f3ffd 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -31,7 +31,7 @@ const (
// currentConfigVersion is the schema version written to new and migrated
// config files. Increment this when making breaking schema changes and
// add a corresponding migration case in [migrate].
- currentConfigVersion = 1
+ currentConfigVersion = 2
)
// NamedView holds a saved combination of list state filters that can be
@@ -180,8 +180,19 @@ type Config struct {
// with the actual session ID at launch time. When set, YoloMode, Agent,
// and Model are ignored (they only apply to the default copilot CLI).
// Terminal and Shell settings are still used.
+ //
+ // Deprecated: renamed to ResumeSessionCommand in config v2. This field
+ // exists only for backward-compatible deserialization; migrate() copies
+ // it to ResumeSessionCommand then clears it.
CustomCommand string `json:"custom_command,omitempty"`
+ // ResumeSessionCommand is a user-defined command that replaces the
+ // default copilot CLI resume command. The placeholder {sessionId} is
+ // replaced with the actual session ID at launch time. When set,
+ // YoloMode, Agent, and Model are ignored (they only apply to the
+ // default copilot CLI). Terminal and Shell settings are still used.
+ ResumeSessionCommand string `json:"resume_session_command,omitempty"`
+
// ExcludedWords is a list of words used to filter sessions from the
// dispatch list. Sessions whose summary or turn content contains any
// of these words (case-insensitive) are hidden from the session list.
@@ -297,6 +308,12 @@ type Config struct {
// ActiveView is the name of the currently active named view.
// Empty or "Default" means no named view is active.
ActiveView string `json:"active_view,omitempty"`
+
+ // NewSessionCommand is the command template used to launch a brand new
+ // Copilot CLI session from dispatch. The placeholder {cwd} is replaced
+ // with the target working directory. When empty, defaults to "copilot".
+ // Examples: "copilot", "copilot --agent workspace", "gh copilot"
+ NewSessionCommand string `json:"new_session_command,omitempty"`
}
// LaunchMode describes how sessions are opened in the terminal.
@@ -627,6 +644,15 @@ func migrate(cfg *Config) {
cfg.LaunchMode = LaunchModeInPlace
}
}
+
+ // v1 → v2: custom_command renamed to resume_session_command.
+ // Copy the old value if the new field is empty.
+ if cfg.ConfigVersion < 2 {
+ if cfg.CustomCommand != "" && cfg.ResumeSessionCommand == "" {
+ cfg.ResumeSessionCommand = cfg.CustomCommand
+ }
+ cfg.CustomCommand = "" // clear so it won't be written back
+ }
}
// shellUnsafe contains characters that must never appear in shell or terminal
diff --git a/internal/config/config_security_test.go b/internal/config/config_security_test.go
index ceb52a2..dd73469 100644
--- a/internal/config/config_security_test.go
+++ b/internal/config/config_security_test.go
@@ -250,7 +250,7 @@ func TestLoadMalformedJSON_HugeArray(t *testing.T) {
// Config content safety — hostile values that could affect downstream use
// ---------------------------------------------------------------------------
-func TestConfig_CustomCommandWithShellMetachars(t *testing.T) {
+func TestConfig_ResumeSessionCommandWithShellMetachars(t *testing.T) {
t.Parallel()
// Verify that custom commands with shell metacharacters round-trip
// through JSON without corruption.
@@ -266,7 +266,7 @@ func TestConfig_CustomCommandWithShellMetachars(t *testing.T) {
for _, cmd := range hostile {
cfg := Default()
- cfg.CustomCommand = cmd
+ cfg.ResumeSessionCommand = cmd
data, err := json.Marshal(cfg)
if err != nil {
@@ -281,8 +281,8 @@ func TestConfig_CustomCommandWithShellMetachars(t *testing.T) {
// The value must survive JSON round-trip exactly — we're not
// sanitising at the config layer (that's the launcher's job),
// but we must not corrupt it.
- if restored.CustomCommand != cmd {
- t.Errorf("CustomCommand round-trip: got %q, want %q", restored.CustomCommand, cmd)
+ if restored.ResumeSessionCommand != cmd {
+ t.Errorf("ResumeSessionCommand round-trip: got %q, want %q", restored.ResumeSessionCommand, cmd)
}
}
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 5938808..4b45dfb 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -53,8 +53,8 @@ func TestDefaultValues(t *testing.T) {
if cfg.LaunchInPlace {
t.Error("LaunchInPlace should default to false")
}
- if cfg.CustomCommand != "" {
- t.Errorf("CustomCommand = %q, want empty", cfg.CustomCommand)
+ if cfg.ResumeSessionCommand != "" {
+ t.Errorf("ResumeSessionCommand = %q, want empty", cfg.ResumeSessionCommand)
}
if len(cfg.ExcludedDirs) != 0 {
t.Errorf("ExcludedDirs = %v, want empty", cfg.ExcludedDirs)
@@ -77,22 +77,22 @@ func TestDefaultValues(t *testing.T) {
func TestConfigJSONRoundTrip(t *testing.T) {
t.Parallel()
original := &Config{
- DefaultShell: "zsh",
- DefaultTerminal: "alacritty",
- DefaultTimeRange: "7d",
- DefaultSort: "created",
- DefaultSortOrder: "asc",
- DefaultPivot: "repo",
- ShowPreview: false,
- MaxSessions: 50,
- YoloMode: true,
- Agent: "coder",
- Model: "gpt-4",
- LaunchInPlace: true,
- ExcludedDirs: []string{"/tmp", "/var"},
- CustomCommand: "ghcs --resume {sessionId} --custom",
- HiddenSessions: []string{"sess-1", "sess-2"},
- PreviewPosition: "bottom",
+ DefaultShell: "zsh",
+ DefaultTerminal: "alacritty",
+ DefaultTimeRange: "7d",
+ DefaultSort: "created",
+ DefaultSortOrder: "asc",
+ DefaultPivot: "repo",
+ ShowPreview: false,
+ MaxSessions: 50,
+ YoloMode: true,
+ Agent: "coder",
+ Model: "gpt-4",
+ LaunchInPlace: true,
+ ExcludedDirs: []string{"/tmp", "/var"},
+ ResumeSessionCommand: "ghcs --resume {sessionId} --custom",
+ HiddenSessions: []string{"sess-1", "sess-2"},
+ PreviewPosition: "bottom",
}
data, err := json.Marshal(original)
@@ -141,8 +141,8 @@ func TestConfigJSONRoundTrip(t *testing.T) {
if restored.LaunchInPlace != original.LaunchInPlace {
t.Errorf("LaunchInPlace = %v, want %v", restored.LaunchInPlace, original.LaunchInPlace)
}
- if restored.CustomCommand != original.CustomCommand {
- t.Errorf("CustomCommand = %q, want %q", restored.CustomCommand, original.CustomCommand)
+ if restored.ResumeSessionCommand != original.ResumeSessionCommand {
+ t.Errorf("ResumeSessionCommand = %q, want %q", restored.ResumeSessionCommand, original.ResumeSessionCommand)
}
if len(restored.ExcludedDirs) != len(original.ExcludedDirs) {
t.Fatalf("ExcludedDirs len = %d, want %d", len(restored.ExcludedDirs), len(original.ExcludedDirs))
@@ -303,20 +303,20 @@ func TestSaveAndLoad(t *testing.T) {
withTempConfigDir(t)
original := &Config{
- DefaultShell: "bash",
- DefaultTerminal: "alacritty",
- DefaultTimeRange: "30d",
- DefaultSort: "turns",
- DefaultPivot: "repo",
- ShowPreview: false,
- MaxSessions: 200,
- YoloMode: true,
- Agent: "reviewer",
- Model: "claude-3",
- LaunchInPlace: true,
- ExcludedDirs: []string{"/opt/scratch"},
- CustomCommand: "my-cli --resume {sessionId}",
- HiddenSessions: []string{"hidden-1"},
+ DefaultShell: "bash",
+ DefaultTerminal: "alacritty",
+ DefaultTimeRange: "30d",
+ DefaultSort: "turns",
+ DefaultPivot: "repo",
+ ShowPreview: false,
+ MaxSessions: 200,
+ YoloMode: true,
+ Agent: "reviewer",
+ Model: "claude-3",
+ LaunchInPlace: true,
+ ExcludedDirs: []string{"/opt/scratch"},
+ ResumeSessionCommand: "my-cli --resume {sessionId}",
+ HiddenSessions: []string{"hidden-1"},
}
if err := Save(original); err != nil {
@@ -352,8 +352,8 @@ func TestSaveAndLoad(t *testing.T) {
if loaded.LaunchInPlace != original.LaunchInPlace {
t.Errorf("LaunchInPlace = %v, want %v", loaded.LaunchInPlace, original.LaunchInPlace)
}
- if loaded.CustomCommand != original.CustomCommand {
- t.Errorf("CustomCommand = %q, want %q", loaded.CustomCommand, original.CustomCommand)
+ if loaded.ResumeSessionCommand != original.ResumeSessionCommand {
+ t.Errorf("ResumeSessionCommand = %q, want %q", loaded.ResumeSessionCommand, original.ResumeSessionCommand)
}
}
@@ -535,20 +535,20 @@ func TestSaveAndLoadPreservesAllFields(t *testing.T) {
withTempConfigDir(t)
cfg := &Config{
- DefaultShell: "fish",
- DefaultTerminal: "wezterm",
- DefaultTimeRange: "all",
- DefaultSort: "name",
- DefaultPivot: "date",
- ShowPreview: false,
- MaxSessions: 500,
- YoloMode: true,
- Agent: "developer",
- Model: "o1-preview",
- LaunchInPlace: true,
- ExcludedDirs: []string{"/a", "/b", "/c"},
- CustomCommand: "custom-cli {sessionId} --flag",
- HiddenSessions: []string{"h1", "h2", "h3"},
+ DefaultShell: "fish",
+ DefaultTerminal: "wezterm",
+ DefaultTimeRange: "all",
+ DefaultSort: "name",
+ DefaultPivot: "date",
+ ShowPreview: false,
+ MaxSessions: 500,
+ YoloMode: true,
+ Agent: "developer",
+ Model: "o1-preview",
+ LaunchInPlace: true,
+ ExcludedDirs: []string{"/a", "/b", "/c"},
+ ResumeSessionCommand: "custom-cli {sessionId} --flag",
+ HiddenSessions: []string{"h1", "h2", "h3"},
}
if err := Save(cfg); err != nil {
@@ -594,8 +594,8 @@ func TestSaveAndLoadPreservesAllFields(t *testing.T) {
if loaded.LaunchInPlace != cfg.LaunchInPlace {
t.Errorf("LaunchInPlace = %v, want %v", loaded.LaunchInPlace, cfg.LaunchInPlace)
}
- if loaded.CustomCommand != cfg.CustomCommand {
- t.Errorf("CustomCommand = %q, want %q", loaded.CustomCommand, cfg.CustomCommand)
+ if loaded.ResumeSessionCommand != cfg.ResumeSessionCommand {
+ t.Errorf("ResumeSessionCommand = %q, want %q", loaded.ResumeSessionCommand, cfg.ResumeSessionCommand)
}
if len(loaded.ExcludedDirs) != len(cfg.ExcludedDirs) {
t.Fatalf("ExcludedDirs len = %d, want %d", len(loaded.ExcludedDirs), len(cfg.ExcludedDirs))
@@ -830,8 +830,8 @@ func TestDefaultFieldTypes(t *testing.T) {
if cfg.Model != "" {
t.Errorf("Model = %q, want empty", cfg.Model)
}
- if cfg.CustomCommand != "" {
- t.Errorf("CustomCommand = %q, want empty", cfg.CustomCommand)
+ if cfg.ResumeSessionCommand != "" {
+ t.Errorf("ResumeSessionCommand = %q, want empty", cfg.ResumeSessionCommand)
}
if len(cfg.ExcludedDirs) != 0 {
t.Errorf("ExcludedDirs should be nil or empty, got %v", cfg.ExcludedDirs)
diff --git a/internal/config/coverage_test.go b/internal/config/coverage_test.go
index dd1ae0b..2367356 100644
--- a/internal/config/coverage_test.go
+++ b/internal/config/coverage_test.go
@@ -101,22 +101,22 @@ func TestConfigJSONRoundTrip_AllFields(t *testing.T) {
t.Parallel()
// Verifies all fields survive JSON marshaling/unmarshaling
cfg := &Config{
- DefaultShell: "zsh",
- DefaultTerminal: "kitty",
- DefaultTimeRange: "7d",
- DefaultSort: "created",
- DefaultPivot: "repo",
- ShowPreview: false,
- MaxSessions: 50,
- YoloMode: true,
- Agent: "coder",
- Model: "gpt-4",
- LaunchMode: "window",
- LaunchInPlace: true,
- ExcludedDirs: []string{"/tmp"},
- CustomCommand: "my-cli {sessionId}",
- HiddenSessions: []string{"sess1", "sess2"},
- Theme: "One Half Dark",
+ DefaultShell: "zsh",
+ DefaultTerminal: "kitty",
+ DefaultTimeRange: "7d",
+ DefaultSort: "created",
+ DefaultPivot: "repo",
+ ShowPreview: false,
+ MaxSessions: 50,
+ YoloMode: true,
+ Agent: "coder",
+ Model: "gpt-4",
+ LaunchMode: "window",
+ LaunchInPlace: true,
+ ExcludedDirs: []string{"/tmp"},
+ ResumeSessionCommand: "my-cli {sessionId}",
+ HiddenSessions: []string{"sess1", "sess2"},
+ Theme: "One Half Dark",
}
// Verify effective launch mode with explicit LaunchMode
diff --git a/internal/data/attention.go b/internal/data/attention.go
index 0244df7..a3bd279 100644
--- a/internal/data/attention.go
+++ b/internal/data/attention.go
@@ -317,6 +317,40 @@ func findSessionPID(dir string) pidResult {
return pidResult{hasStale: hasStale}
}
+// FindSessionPID returns the live PID for the given session ID, or 0 if
+// no live process is found. This is an exported wrapper used by the TUI
+// to focus a session's terminal window.
+func FindSessionPID(sessionID string) int {
+ stateDir := sessionStatePath()
+ if stateDir == "" {
+ return 0
+ }
+ dir := filepath.Join(stateDir, sessionID)
+ result := findSessionPID(dir)
+ return result.pid
+}
+
+// SessionEvent represents the last event observed for a session.
+type SessionEvent struct {
+ Type string
+ Timestamp string
+}
+
+// LastSessionEvent returns the most recent event type and timestamp for
+// the given session ID. Returns an empty struct if no events are found.
+func LastSessionEvent(sessionID string) SessionEvent {
+ stateDir := sessionStatePath()
+ if stateDir == "" {
+ return SessionEvent{}
+ }
+ eventsPath := filepath.Join(stateDir, sessionID, "events.jsonl")
+ evt, err := readLastEvent(eventsPath)
+ if err != nil {
+ return SessionEvent{}
+ }
+ return SessionEvent{Type: evt.Type, Timestamp: evt.Timestamp}
+}
+
// readLastEvent reads the last complete JSON line from an events.jsonl file
// using an O(1) seek-from-end strategy. It never reads the entire file.
func readLastEvent(path string) (sessionEvent, error) {
diff --git a/internal/data/eventwatcher.go b/internal/data/eventwatcher.go
new file mode 100644
index 0000000..1134a70
--- /dev/null
+++ b/internal/data/eventwatcher.go
@@ -0,0 +1,251 @@
+package data
+
+import (
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/jongio/dispatch/internal/validate"
+)
+
+// eventWatcherDebounce is the minimum interval between re-classifications
+// of the same session after a file change. Rapid writes (e.g. multiple
+// events.jsonl appends in quick succession) are collapsed into one callback.
+const eventWatcherDebounce = 50 * time.Millisecond
+
+// EventWatcher monitors the Copilot CLI session-state directory for changes
+// using OS-level file system notifications (fsnotify). When events.jsonl or
+// lock files change, it re-classifies just the affected session and fires
+// the onChange callback with the session ID and new status.
+//
+// This replaces the 30-second polling approach with near-instant push
+// updates while consuming negligible CPU when idle.
+type EventWatcher struct {
+ mu sync.Mutex
+ watcher *fsnotify.Watcher
+ onChange func(id string, status AttentionStatus)
+ stop chan struct{}
+ stopped bool
+
+ // Configuration for attention classification.
+ threshold time.Duration
+ workspaceRecovery bool
+
+ // Debounce tracking: maps session ID to the last scheduled fire time.
+ pending map[string]*time.Timer
+}
+
+// NewEventWatcher creates a watcher that monitors session-state directories
+// for file changes. The onChange callback is invoked from a goroutine whenever
+// a session's attention status changes. Call Start() to begin watching.
+func NewEventWatcher(onChange func(id string, status AttentionStatus), threshold time.Duration, workspaceRecovery bool) *EventWatcher {
+ return &EventWatcher{
+ onChange: onChange,
+ stop: make(chan struct{}),
+ threshold: threshold,
+ workspaceRecovery: workspaceRecovery,
+ pending: make(map[string]*time.Timer),
+ }
+}
+
+// Start begins watching the session-state directory. It returns an error if
+// the directory does not exist or cannot be watched. Start is idempotent;
+// calling it on an already-started watcher is a no-op.
+func (ew *EventWatcher) Start() error {
+ ew.mu.Lock()
+ defer ew.mu.Unlock()
+
+ if ew.watcher != nil {
+ return nil // already running
+ }
+
+ stateDir := sessionStatePath()
+ if stateDir == "" {
+ return os.ErrNotExist
+ }
+
+ w, err := fsnotify.NewWatcher()
+ if err != nil {
+ return err
+ }
+
+ // Watch the top-level session-state directory for new session dirs.
+ if err := w.Add(stateDir); err != nil {
+ w.Close()
+ return err
+ }
+
+ // Watch each existing session subdirectory.
+ entries, err := os.ReadDir(stateDir)
+ if err != nil {
+ w.Close()
+ return err
+ }
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ if !validate.SessionID(e.Name()) {
+ continue
+ }
+ subdir := filepath.Join(stateDir, e.Name())
+ if err := w.Add(subdir); err != nil {
+ slog.Debug("eventwatcher: failed to watch session dir", "dir", subdir, "error", err)
+ }
+ }
+
+ ew.watcher = w
+ go ew.loop(stateDir)
+ return nil
+}
+
+// Stop permanently stops the watcher and releases resources.
+func (ew *EventWatcher) Stop() {
+ ew.mu.Lock()
+ defer ew.mu.Unlock()
+
+ if ew.stopped {
+ return
+ }
+ ew.stopped = true
+ close(ew.stop)
+
+ if ew.watcher != nil {
+ ew.watcher.Close()
+ }
+
+ // Cancel any pending debounce timers.
+ for _, t := range ew.pending {
+ t.Stop()
+ }
+}
+
+// SetThreshold updates the attention threshold used for classification.
+func (ew *EventWatcher) SetThreshold(d time.Duration) {
+ ew.mu.Lock()
+ defer ew.mu.Unlock()
+ ew.threshold = d
+}
+
+// loop is the main event processing goroutine.
+func (ew *EventWatcher) loop(stateDir string) {
+ for {
+ select {
+ case <-ew.stop:
+ return
+
+ case event, ok := <-ew.watcher.Events:
+ if !ok {
+ return
+ }
+ ew.handleEvent(event, stateDir)
+
+ case err, ok := <-ew.watcher.Errors:
+ if !ok {
+ return
+ }
+ slog.Debug("eventwatcher: fsnotify error", "error", err)
+ }
+ }
+}
+
+// handleEvent processes a single fsnotify event.
+func (ew *EventWatcher) handleEvent(event fsnotify.Event, stateDir string) {
+ // We care about Write and Create events on files inside session dirs.
+ if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
+ return
+ }
+
+ path := event.Name
+ rel, err := filepath.Rel(stateDir, path)
+ if err != nil {
+ return
+ }
+
+ // Parse the relative path to extract session ID.
+ // Expected: "/events.jsonl" or "/inuse.*.lock"
+ parts := strings.SplitN(filepath.ToSlash(rel), "/", 3)
+ if len(parts) < 2 {
+ // Might be a new directory created at the top level.
+ if event.Op&fsnotify.Create != 0 {
+ ew.maybeWatchNewDir(path)
+ }
+ return
+ }
+
+ sessionID := parts[0]
+ filename := parts[1]
+
+ if !validate.SessionID(sessionID) {
+ return
+ }
+
+ // Only react to events.jsonl and lock file changes.
+ if filename != "events.jsonl" && !strings.HasPrefix(filename, "inuse.") {
+ return
+ }
+
+ ew.scheduleClassify(sessionID, stateDir)
+}
+
+// maybeWatchNewDir adds a newly created session directory to the watcher.
+func (ew *EventWatcher) maybeWatchNewDir(path string) {
+ info, err := os.Stat(path)
+ if err != nil || !info.IsDir() {
+ return
+ }
+ name := filepath.Base(path)
+ if !validate.SessionID(name) {
+ return
+ }
+
+ ew.mu.Lock()
+ w := ew.watcher
+ ew.mu.Unlock()
+
+ if w != nil {
+ if err := w.Add(path); err != nil {
+ slog.Debug("eventwatcher: failed to watch new dir", "path", path, "error", err)
+ }
+ }
+}
+
+// scheduleClassify debounces classification for a session. If a timer is
+// already pending for this session, it is reset.
+func (ew *EventWatcher) scheduleClassify(sessionID string, stateDir string) {
+ ew.mu.Lock()
+ defer ew.mu.Unlock()
+
+ if ew.stopped {
+ return
+ }
+
+ if t, ok := ew.pending[sessionID]; ok {
+ t.Reset(eventWatcherDebounce)
+ return
+ }
+
+ ew.pending[sessionID] = time.AfterFunc(eventWatcherDebounce, func() {
+ ew.classify(sessionID, stateDir)
+ })
+}
+
+// classify re-classifies a single session and fires the callback.
+func (ew *EventWatcher) classify(sessionID string, stateDir string) {
+ ew.mu.Lock()
+ delete(ew.pending, sessionID)
+ threshold := ew.threshold
+ wr := ew.workspaceRecovery
+ ew.mu.Unlock()
+
+ dir := filepath.Join(stateDir, sessionID)
+ status := classifySession(dir, threshold, wr)
+
+ if ew.onChange != nil {
+ ew.onChange(sessionID, status)
+ }
+}
diff --git a/internal/data/eventwatcher_test.go b/internal/data/eventwatcher_test.go
new file mode 100644
index 0000000..dc3cf6b
--- /dev/null
+++ b/internal/data/eventwatcher_test.go
@@ -0,0 +1,285 @@
+package data
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+)
+
+func recentTimestamp() string {
+ return time.Now().UTC().Format(time.RFC3339)
+}
+
+func TestEventWatcher_FiresOnEventWrite(t *testing.T) {
+ // Create a temporary session-state directory.
+ dir := t.TempDir()
+ sessionDir := filepath.Join(dir, "test-session-1")
+ if err := os.MkdirAll(sessionDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Write an initial events.jsonl so the session exists.
+ eventsPath := filepath.Join(sessionDir, "events.jsonl")
+ initial := fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())
+ if err := os.WriteFile(eventsPath, []byte(initial), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Set the env override so the watcher uses our temp dir.
+ t.Setenv("DISPATCH_SESSION_STATE", dir)
+
+ var mu sync.Mutex
+ updates := make(map[string]AttentionStatus)
+
+ ew := NewEventWatcher(func(id string, status AttentionStatus) {
+ mu.Lock()
+ updates[id] = status
+ mu.Unlock()
+ }, 15*time.Minute, false)
+
+ if err := ew.Start(); err != nil {
+ t.Fatalf("Start() error: %v", err)
+ }
+ defer ew.Stop()
+
+ // Give the watcher time to set up.
+ time.Sleep(500 * time.Millisecond)
+
+ // Modify events.jsonl — this should trigger a callback.
+ newEvent := fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())
+ if err := os.WriteFile(eventsPath, []byte(initial+newEvent), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Wait for the debounced callback (50ms debounce + margin).
+ time.Sleep(1000 * time.Millisecond)
+
+ mu.Lock()
+ status, ok := updates["test-session-1"]
+ mu.Unlock()
+
+ if !ok {
+ t.Fatal("expected callback for test-session-1, got none")
+ }
+ // Dead session with turn_end → AttentionWaiting (within 24h).
+ if status != AttentionWaiting {
+ t.Errorf("got status %v, want AttentionWaiting", status)
+ }
+}
+
+func TestEventWatcher_DebounceCollapses(t *testing.T) {
+ dir := t.TempDir()
+ sessionDir := filepath.Join(dir, "debounce-sess")
+ if err := os.MkdirAll(sessionDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ eventsPath := filepath.Join(sessionDir, "events.jsonl")
+ if err := os.WriteFile(eventsPath, []byte(fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("DISPATCH_SESSION_STATE", dir)
+
+ var mu sync.Mutex
+ callCount := 0
+
+ ew := NewEventWatcher(func(id string, status AttentionStatus) {
+ mu.Lock()
+ callCount++
+ mu.Unlock()
+ }, 15*time.Minute, false)
+
+ if err := ew.Start(); err != nil {
+ t.Fatalf("Start() error: %v", err)
+ }
+ defer ew.Stop()
+
+ time.Sleep(500 * time.Millisecond)
+
+ // Write rapidly 5 times within the debounce window.
+ for i := 0; i < 5; i++ {
+ line := fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())
+ if err := os.WriteFile(eventsPath, []byte(line), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+
+ // Wait for debounce to settle (longer to avoid flakiness under load).
+ time.Sleep(500 * time.Millisecond)
+
+ mu.Lock()
+ count := callCount
+ mu.Unlock()
+
+ // Should have collapsed to fewer callbacks than writes (not 5).
+ if count > 3 {
+ t.Errorf("expected <=3 debounced callbacks, got %d", count)
+ }
+ if count == 0 {
+ t.Error("expected at least 1 callback, got 0")
+ }
+}
+
+func TestEventWatcher_IgnoresInvalidSessionIDs(t *testing.T) {
+ dir := t.TempDir()
+ // Create a directory with an invalid session ID (contains spaces).
+ badDir := filepath.Join(dir, "bad session id!")
+ if err := os.MkdirAll(badDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ eventsPath := filepath.Join(badDir, "events.jsonl")
+ if err := os.WriteFile(eventsPath, []byte(fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("DISPATCH_SESSION_STATE", dir)
+
+ called := false
+ ew := NewEventWatcher(func(id string, status AttentionStatus) {
+ called = true
+ }, 15*time.Minute, false)
+
+ if err := ew.Start(); err != nil {
+ t.Fatalf("Start() error: %v", err)
+ }
+ defer ew.Stop()
+
+ time.Sleep(500 * time.Millisecond)
+
+ // Modify the file — should NOT trigger callback.
+ if err := os.WriteFile(eventsPath, []byte(fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ time.Sleep(200 * time.Millisecond)
+
+ if called {
+ t.Error("callback should not fire for invalid session ID")
+ }
+}
+
+func TestEventWatcher_WatchesNewSessionDirs(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("DISPATCH_SESSION_STATE", dir)
+
+ var mu sync.Mutex
+ updates := make(map[string]AttentionStatus)
+
+ ew := NewEventWatcher(func(id string, status AttentionStatus) {
+ mu.Lock()
+ updates[id] = status
+ mu.Unlock()
+ }, 15*time.Minute, false)
+
+ if err := ew.Start(); err != nil {
+ t.Fatalf("Start() error: %v", err)
+ }
+ defer ew.Stop()
+
+ time.Sleep(500 * time.Millisecond)
+
+ // Create a new session directory AFTER the watcher started.
+ newSessionDir := filepath.Join(dir, "new-session-abc")
+ if err := os.MkdirAll(newSessionDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Give watcher time to pick up the new directory via CREATE event.
+ time.Sleep(500 * time.Millisecond)
+
+ // Write events.jsonl in the new directory.
+ eventsPath := filepath.Join(newSessionDir, "events.jsonl")
+ if err := os.WriteFile(eventsPath, []byte(fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ time.Sleep(500 * time.Millisecond)
+
+ mu.Lock()
+ _, ok := updates["new-session-abc"]
+ mu.Unlock()
+
+ if !ok {
+ t.Error("expected callback for dynamically created session dir")
+ }
+}
+
+func TestEventWatcher_StopIsIdempotent(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("DISPATCH_SESSION_STATE", dir)
+
+ ew := NewEventWatcher(func(string, AttentionStatus) {}, 15*time.Minute, false)
+ if err := ew.Start(); err != nil {
+ t.Fatal(err)
+ }
+
+ // Stop multiple times — should not panic.
+ ew.Stop()
+ ew.Stop()
+ ew.Stop()
+}
+
+func TestEventWatcher_StartIdempotent(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("DISPATCH_SESSION_STATE", dir)
+
+ ew := NewEventWatcher(func(string, AttentionStatus) {}, 15*time.Minute, false)
+ if err := ew.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer ew.Stop()
+
+ // Second start should be a no-op (not error).
+ if err := ew.Start(); err != nil {
+ t.Errorf("second Start() should be no-op, got error: %v", err)
+ }
+}
+
+func TestEventWatcher_SessionIDFromPath(t *testing.T) {
+ dir := t.TempDir()
+ sessionDir := filepath.Join(dir, "abc-123")
+ if err := os.MkdirAll(sessionDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ eventsPath := filepath.Join(sessionDir, "events.jsonl")
+ if err := os.WriteFile(eventsPath, []byte(fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("DISPATCH_SESSION_STATE", dir)
+
+ var gotID string
+ var mu sync.Mutex
+ ew := NewEventWatcher(func(id string, _ AttentionStatus) {
+ mu.Lock()
+ gotID = id
+ mu.Unlock()
+ }, 15*time.Minute, false)
+
+ if err := ew.Start(); err != nil {
+ t.Fatal(err)
+ }
+ defer ew.Stop()
+
+ time.Sleep(500 * time.Millisecond)
+
+ // Modify the file.
+ if err := os.WriteFile(eventsPath, []byte(fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp())), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ time.Sleep(1000 * time.Millisecond)
+
+ mu.Lock()
+ id := gotID
+ mu.Unlock()
+
+ if id != "abc-123" {
+ t.Errorf("got session ID %q, want %q", id, "abc-123")
+ }
+}
diff --git a/internal/data/sessiontrack.go b/internal/data/sessiontrack.go
new file mode 100644
index 0000000..cbe1b96
--- /dev/null
+++ b/internal/data/sessiontrack.go
@@ -0,0 +1,87 @@
+package data
+
+import (
+ "sync"
+ "time"
+
+ "github.com/jongio/dispatch/internal/platform"
+)
+
+// TrackedSession holds metadata about a session launched from dispatch.
+type TrackedSession struct {
+ PID int `json:"pid"`
+ LaunchTime time.Time `json:"launch_time"`
+}
+
+// SessionTracker maintains a map of session IDs to their launch metadata.
+// It is used to identify which running sessions were launched from dispatch
+// so they can be focused later. The tracker is in-memory only (PIDs are
+// ephemeral and meaningless after a reboot).
+type SessionTracker struct {
+ mu sync.RWMutex
+ sessions map[string]TrackedSession
+}
+
+// NewSessionTracker creates an empty tracker.
+func NewSessionTracker() *SessionTracker {
+ return &SessionTracker{
+ sessions: make(map[string]TrackedSession),
+ }
+}
+
+// Track records a launched session's PID.
+func (st *SessionTracker) Track(sessionID string, pid int) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ st.sessions[sessionID] = TrackedSession{
+ PID: pid,
+ LaunchTime: time.Now(),
+ }
+}
+
+// Lookup returns the tracked session info if it exists and the process is
+// still alive. Returns zero value and false if not tracked or dead.
+func (st *SessionTracker) Lookup(sessionID string) (TrackedSession, bool) {
+ st.mu.RLock()
+ ts, ok := st.sessions[sessionID]
+ st.mu.RUnlock()
+
+ if !ok {
+ return TrackedSession{}, false
+ }
+
+ if !platform.IsProcessAlive(ts.PID) {
+ st.mu.Lock()
+ delete(st.sessions, sessionID)
+ st.mu.Unlock()
+ return TrackedSession{}, false
+ }
+
+ return ts, true
+}
+
+// Cleanup removes entries for dead processes. Call periodically to prevent
+// unbounded growth of the map.
+func (st *SessionTracker) Cleanup() {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+
+ for id, ts := range st.sessions {
+ if !platform.IsProcessAlive(ts.PID) {
+ delete(st.sessions, id)
+ }
+ }
+}
+
+// HasLive returns true if the session has a tracked live process.
+func (st *SessionTracker) HasLive(sessionID string) bool {
+ _, ok := st.Lookup(sessionID)
+ return ok
+}
+
+// Count returns the number of tracked sessions (including potentially dead ones).
+func (st *SessionTracker) Count() int {
+ st.mu.RLock()
+ defer st.mu.RUnlock()
+ return len(st.sessions)
+}
diff --git a/internal/data/sessiontrack_test.go b/internal/data/sessiontrack_test.go
new file mode 100644
index 0000000..033e949
--- /dev/null
+++ b/internal/data/sessiontrack_test.go
@@ -0,0 +1,120 @@
+package data
+
+import (
+ "os"
+ "sync"
+ "testing"
+)
+
+func TestSessionTracker_TrackAndLookup(t *testing.T) {
+ st := NewSessionTracker()
+
+ // Use current process PID (always alive).
+ pid := os.Getpid()
+ st.Track("sess-1", pid)
+
+ ts, ok := st.Lookup("sess-1")
+ if !ok {
+ t.Fatal("expected tracked session to be found")
+ }
+ if ts.PID != pid {
+ t.Fatalf("expected PID %d, got %d", pid, ts.PID)
+ }
+ if ts.LaunchTime.IsZero() {
+ t.Fatal("expected non-zero LaunchTime")
+ }
+}
+
+func TestSessionTracker_LookupUnknown(t *testing.T) {
+ st := NewSessionTracker()
+
+ _, ok := st.Lookup("nonexistent")
+ if ok {
+ t.Fatal("expected unknown session to return false")
+ }
+}
+
+func TestSessionTracker_LookupDeadProcess(t *testing.T) {
+ st := NewSessionTracker()
+
+ // PID 0 is never a valid user process; IsProcessAlive should return false.
+ st.Track("dead-sess", 0)
+
+ _, ok := st.Lookup("dead-sess")
+ if ok {
+ t.Fatal("expected dead process session to return false")
+ }
+
+ // Verify it was cleaned out of the map.
+ if st.Count() != 0 {
+ t.Fatalf("expected count 0 after dead lookup, got %d", st.Count())
+ }
+}
+
+func TestSessionTracker_HasLive(t *testing.T) {
+ st := NewSessionTracker()
+
+ pid := os.Getpid()
+ st.Track("live-sess", pid)
+
+ if !st.HasLive("live-sess") {
+ t.Fatal("expected HasLive to return true for current process")
+ }
+ if st.HasLive("no-such-sess") {
+ t.Fatal("expected HasLive to return false for unknown session")
+ }
+}
+
+func TestSessionTracker_Cleanup(t *testing.T) {
+ st := NewSessionTracker()
+
+ pid := os.Getpid()
+ st.Track("alive", pid)
+ st.Track("dead1", 0)
+ st.Track("dead2", 0)
+
+ st.Cleanup()
+
+ if st.Count() != 1 {
+ t.Fatalf("expected 1 session after cleanup, got %d", st.Count())
+ }
+ if !st.HasLive("alive") {
+ t.Fatal("expected alive session to survive cleanup")
+ }
+}
+
+func TestSessionTracker_Count(t *testing.T) {
+ st := NewSessionTracker()
+ if st.Count() != 0 {
+ t.Fatalf("expected initial count 0, got %d", st.Count())
+ }
+
+ pid := os.Getpid()
+ st.Track("s1", pid)
+ st.Track("s2", pid)
+ if st.Count() != 2 {
+ t.Fatalf("expected count 2, got %d", st.Count())
+ }
+}
+
+func TestSessionTracker_ConcurrentAccess(t *testing.T) {
+ st := NewSessionTracker()
+ pid := os.Getpid()
+
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ id := "sess-concurrent"
+ st.Track(id, pid)
+ st.Lookup(id)
+ st.HasLive(id)
+ st.Count()
+ st.Cleanup()
+ }(i)
+ }
+ wg.Wait()
+
+ // If we got here without a race/panic, the test passes.
+}
diff --git a/internal/platform/coverage_test.go b/internal/platform/coverage_test.go
index d841221..c358e4f 100644
--- a/internal/platform/coverage_test.go
+++ b/internal/platform/coverage_test.go
@@ -255,20 +255,20 @@ func TestValidateSessionID_WithUnderscore(t *testing.T) {
}
// ---------------------------------------------------------------------------
-// validateCustomCommand — additional edge cases
+// validateResumeCommand — additional edge cases
// ---------------------------------------------------------------------------
-func TestValidateCustomCommand_TabsAllowed(t *testing.T) {
+func TestValidateResumeCommand_TabsAllowed(t *testing.T) {
// Tabs are allowed (not newlines)
- if err := validateCustomCommand("cmd\targ"); err != nil {
+ if err := validateResumeCommand("cmd\targ"); err != nil {
t.Errorf("tab should be allowed: %v", err)
}
}
-func TestValidateCustomCommand_LongCommand(t *testing.T) {
+func TestValidateResumeCommand_LongCommand(t *testing.T) {
// Very long command should be allowed
long := "my-cli " + string(make([]byte, 1000))
- err := validateCustomCommand(long)
+ err := validateResumeCommand(long)
// Should not error for length (only newlines/empty)
if err != nil {
t.Errorf("long command should be allowed: %v", err)
@@ -276,12 +276,12 @@ func TestValidateCustomCommand_LongCommand(t *testing.T) {
}
// ---------------------------------------------------------------------------
-// buildResumeCommandString — custom command with empty session ID
+// buildResumeCommandString — resume session command with empty session ID
// ---------------------------------------------------------------------------
-func TestBuildResumeCommandString_CustomCommandNoSession(t *testing.T) {
+func TestBuildResumeCommandString_ResumeSessionCommandNoSession(t *testing.T) {
cmd, err := buildResumeCommandString("", ResumeConfig{
- CustomCommand: "my-cli --start",
+ ResumeSessionCommand: "my-cli --start",
})
if err != nil {
t.Fatalf("buildResumeCommandString: %v", err)
@@ -291,9 +291,9 @@ func TestBuildResumeCommandString_CustomCommandNoSession(t *testing.T) {
}
}
-func TestBuildResumeCommandString_CustomCommandReplacesSessionID(t *testing.T) {
+func TestBuildResumeCommandString_ResumeSessionCommandReplacesSessionID(t *testing.T) {
cmd, err := buildResumeCommandString("sess-123", ResumeConfig{
- CustomCommand: "cli resume {sessionId} --verbose",
+ ResumeSessionCommand: "cli resume {sessionId} --verbose",
})
if err != nil {
t.Fatalf("buildResumeCommandString: %v", err)
diff --git a/internal/platform/focus_other.go b/internal/platform/focus_other.go
new file mode 100644
index 0000000..28cadd8
--- /dev/null
+++ b/internal/platform/focus_other.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package platform
+
+import "fmt"
+
+// FocusSessionWindow is a stub on non-Windows platforms.
+// Window focus is currently only supported on Windows where dispatch
+// can locate the terminal window via the Win32 API.
+func FocusSessionWindow(pid int) error {
+ return fmt.Errorf("window focus not supported on this platform")
+}
diff --git a/internal/platform/focus_windows.go b/internal/platform/focus_windows.go
new file mode 100644
index 0000000..b4408db
--- /dev/null
+++ b/internal/platform/focus_windows.go
@@ -0,0 +1,139 @@
+package platform
+
+import (
+ "fmt"
+ "unsafe"
+
+ "golang.org/x/sys/windows"
+)
+
+var (
+ user32 = windows.NewLazySystemDLL("user32.dll")
+ procEnumWindows = user32.NewProc("EnumWindows")
+ procGetWindowThreadPID = user32.NewProc("GetWindowThreadProcessId")
+ procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
+ procIsWindowVisible = user32.NewProc("IsWindowVisible")
+ procShowWindow = user32.NewProc("ShowWindow")
+
+ kernel32 = windows.NewLazySystemDLL("kernel32.dll")
+ procCreateToolhelp32 = kernel32.NewProc("CreateToolhelp32Snapshot")
+ procProcess32First = kernel32.NewProc("Process32FirstW")
+ procProcess32Next = kernel32.NewProc("Process32NextW")
+)
+
+const (
+ thSnapProcess = 0x00000002
+ swRestore = 9
+)
+
+// processEntry32 is the PROCESSENTRY32W structure.
+type processEntry32 struct {
+ Size uint32
+ Usage uint32
+ ProcessID uint32
+ DefaultHeapID uintptr
+ ModuleID uint32
+ Threads uint32
+ ParentProcessID uint32
+ PriClassBase int32
+ Flags uint32
+ ExeFile [windows.MAX_PATH]uint16
+}
+
+// FocusSessionWindow brings the terminal window hosting the given PID to the
+// foreground. It walks the process tree upward from the target PID to find a
+// visible top-level window owned by an ancestor process (typically wt.exe or
+// conhost.exe), then calls SetForegroundWindow.
+func FocusSessionWindow(pid int) error {
+ if pid <= 0 {
+ return fmt.Errorf("invalid PID: %d", pid)
+ }
+
+ // Build the set of ancestor PIDs (the target + its parent chain).
+ ancestors := buildAncestorSet(uint32(pid))
+ if len(ancestors) == 0 {
+ return fmt.Errorf("could not build process tree for PID %d", pid)
+ }
+
+ // Find a visible top-level window owned by any ancestor.
+ hwnd := findWindowForPIDs(ancestors)
+ if hwnd == 0 {
+ return fmt.Errorf("no visible window found for PID %d or its ancestors", pid)
+ }
+
+ // Restore if minimized, then bring to foreground.
+ visible, _, _ := procIsWindowVisible.Call(hwnd)
+ if visible == 0 {
+ procShowWindow.Call(hwnd, uintptr(swRestore))
+ }
+ ret, _, _ := procSetForegroundWindow.Call(hwnd)
+ if ret == 0 {
+ return fmt.Errorf("SetForegroundWindow failed for HWND %v", hwnd)
+ }
+ return nil
+}
+
+// buildAncestorSet returns a set of PIDs from the target up through its
+// parent chain. Stops after 32 hops to avoid infinite loops.
+func buildAncestorSet(targetPID uint32) map[uint32]struct{} {
+ snapshot, _, _ := procCreateToolhelp32.Call(uintptr(thSnapProcess), 0)
+ if snapshot == uintptr(windows.InvalidHandle) {
+ return nil
+ }
+ defer windows.CloseHandle(windows.Handle(snapshot))
+
+ // Build a child->parent map.
+ parentMap := make(map[uint32]uint32)
+ var entry processEntry32
+ entry.Size = uint32(unsafe.Sizeof(entry))
+
+ ret, _, _ := procProcess32First.Call(snapshot, uintptr(unsafe.Pointer(&entry)))
+ if ret == 0 {
+ return nil
+ }
+
+ for {
+ parentMap[entry.ProcessID] = entry.ParentProcessID
+ entry.Size = uint32(unsafe.Sizeof(entry))
+ ret, _, _ = procProcess32Next.Call(snapshot, uintptr(unsafe.Pointer(&entry)))
+ if ret == 0 {
+ break
+ }
+ }
+
+ // Walk up from target.
+ result := make(map[uint32]struct{})
+ current := targetPID
+ for i := 0; i < 32; i++ {
+ result[current] = struct{}{}
+ parent, ok := parentMap[current]
+ if !ok || parent == 0 || parent == current {
+ break
+ }
+ current = parent
+ }
+ return result
+}
+
+// findWindowForPIDs enumerates top-level windows and returns the first
+// visible one owned by a PID in the given set.
+func findWindowForPIDs(pids map[uint32]struct{}) uintptr {
+ var found uintptr
+
+ // The callback receives each top-level window handle.
+ cb := windows.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
+ var winPID uint32
+ procGetWindowThreadPID.Call(hwnd, uintptr(unsafe.Pointer(&winPID)))
+ if _, ok := pids[winPID]; ok {
+ visible, _, _ := procIsWindowVisible.Call(hwnd)
+ if visible != 0 {
+ found = hwnd
+ return 0 // stop enumeration
+ }
+ }
+ return 1 // continue
+ })
+
+ procEnumWindows.Call(cb, 0)
+ return found
+}
diff --git a/internal/platform/newsession.go b/internal/platform/newsession.go
new file mode 100644
index 0000000..25c8978
--- /dev/null
+++ b/internal/platform/newsession.go
@@ -0,0 +1,85 @@
+package platform
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
+
+// defaultNewSessionCommand is used when the user has not configured a custom
+// new session command.
+const defaultNewSessionCommand = "copilot"
+
+// LaunchNewSessionConfig holds parameters for launching a brand new Copilot
+// CLI session (as opposed to resuming an existing one).
+type LaunchNewSessionConfig struct {
+ // Command is the command template. Empty means use default.
+ // Supports {cwd} placeholder.
+ Command string
+
+ // Cwd is the working directory for the new session.
+ Cwd string
+
+ // Terminal is the terminal emulator to use (e.g. "Windows Terminal").
+ Terminal string
+
+ // Shell is the shell to run the command in.
+ Shell ShellInfo
+
+ // LaunchStyle controls how the session is opened (tab, window, pane).
+ LaunchStyle string
+
+ // PaneDirection controls split direction for pane mode.
+ PaneDirection string
+}
+
+// LaunchNewSession starts a brand new Copilot CLI session in the specified
+// working directory. It returns the PID of the launched process (for tracking)
+// or an error.
+func LaunchNewSession(cfg LaunchNewSessionConfig) (int, error) {
+ if cfg.Shell.Path == "" {
+ cfg.Shell = DefaultShell()
+ }
+ if cfg.Shell.Path == "" {
+ return 0, errors.New("no shell available")
+ }
+ if cfg.Terminal == "" {
+ cfg.Terminal = DefaultTerminal()
+ }
+
+ cmd := cfg.Command
+ if cmd == "" {
+ cmd = defaultNewSessionCommand
+ }
+
+ // Replace template variables.
+ if cfg.Cwd != "" {
+ cmd = strings.ReplaceAll(cmd, "{cwd}", cfg.Cwd)
+ }
+
+ return launchNewSessionPlatform(cfg.Shell, cmd, cfg.Terminal, cfg.Cwd, cfg.LaunchStyle, cfg.PaneDirection)
+}
+
+// launchNewSessionPlatformFn is the platform-specific launcher for new sessions.
+// Tests can replace this to prevent real process spawning.
+var launchNewSessionPlatformFn = launchNewSessionPlatformImpl
+
+func launchNewSessionPlatform(shell ShellInfo, cmd string, terminal string, cwd string, launchStyle string, paneDirection string) (int, error) {
+ return launchNewSessionPlatformFn(shell, cmd, terminal, cwd, launchStyle, paneDirection)
+}
+
+func launchNewSessionPlatformImpl(shell ShellInfo, command string, terminal string, cwd string, launchStyle string, paneDirection string) (int, error) {
+ // Build the launch command. For new sessions we want the terminal to
+ // stay open after the copilot CLI exits (interactive mode), so we
+ // don't append an exit.
+ err := platformLaunchSessionFn(shell, command, terminal, cwd, launchStyle, paneDirection)
+ if err != nil {
+ return 0, fmt.Errorf("launch new session: %w", err)
+ }
+
+ // Attempt to find the PID of the launched process. Since
+ // platformLaunchSessionFn uses wt.exe (which spawns a child), the
+ // actual copilot process PID is not directly available. We return 0
+ // here and let the EventWatcher pick up the session via lock files.
+ return 0, nil
+}
diff --git a/internal/platform/platform_additional_test.go b/internal/platform/platform_additional_test.go
index fdb713e..21cdd36 100644
--- a/internal/platform/platform_additional_test.go
+++ b/internal/platform/platform_additional_test.go
@@ -42,9 +42,9 @@ func TestHasNerdFontFiles_SubdirNotRecursive(t *testing.T) {
// buildResumeCommandString — additional coverage
// ---------------------------------------------------------------------------
-func TestBuildResumeCommandString_CustomCommandWithSessionID(t *testing.T) {
+func TestBuildResumeCommandString_ResumeSessionCommandWithSessionID(t *testing.T) {
cfg := ResumeConfig{
- CustomCommand: "my-tool --session {sessionId}",
+ ResumeSessionCommand: "my-tool --session {sessionId}",
}
result, err := buildResumeCommandString("abc123", cfg)
if err != nil {
@@ -58,9 +58,9 @@ func TestBuildResumeCommandString_CustomCommandWithSessionID(t *testing.T) {
}
}
-func TestBuildResumeCommandString_CustomCommandNoSessionID(t *testing.T) {
+func TestBuildResumeCommandString_ResumeSessionCommandNoSessionID(t *testing.T) {
cfg := ResumeConfig{
- CustomCommand: "simple-tool",
+ ResumeSessionCommand: "simple-tool",
}
result, err := buildResumeCommandString("test123", cfg)
if err != nil {
@@ -79,19 +79,19 @@ func TestBuildResumeCommandString_InvalidSessionIDWithSpaces(t *testing.T) {
}
}
-func TestBuildResumeCommandString_InvalidCustomCommand(t *testing.T) {
+func TestBuildResumeCommandString_InvalidResumeSessionCommand(t *testing.T) {
cfg := ResumeConfig{
- CustomCommand: "has\nnewline",
+ ResumeSessionCommand: "has\nnewline",
}
_, err := buildResumeCommandString("valid123", cfg)
if err == nil {
- t.Error("expected error for custom command with newline")
+ t.Error("expected error for resume session command with newline")
}
}
func TestBuildResumeCommandString_EmptySessionID(t *testing.T) {
cfg := ResumeConfig{
- CustomCommand: "my-tool",
+ ResumeSessionCommand: "my-tool",
}
result, err := buildResumeCommandString("", cfg)
if err != nil {
@@ -166,12 +166,12 @@ func TestLaunchSession_InvalidSessionIDReturnsError(t *testing.T) {
}
}
-func TestLaunchSession_InvalidCustomCommandReturnsError(t *testing.T) {
+func TestLaunchSession_InvalidResumeSessionCommandReturnsError(t *testing.T) {
err := LaunchSession(ShellInfo{Path: "test"}, "valid123", ResumeConfig{
- CustomCommand: "cmd\nwith\nnewlines",
+ ResumeSessionCommand: "cmd\nwith\nnewlines",
})
if err == nil {
- t.Error("expected error for custom command with newlines")
+ t.Error("expected error for resume session command with newlines")
}
}
@@ -223,7 +223,7 @@ func TestDetectTerminals_AllHaveNames(t *testing.T) {
// buildResumeCommandString — non-custom-command path
// ---------------------------------------------------------------------------
-func TestBuildResumeCommandString_NormalPathWithoutCustomCommand(t *testing.T) {
+func TestBuildResumeCommandString_NormalPathWithoutResumeSessionCommand(t *testing.T) {
// Exercise the non-custom-command code path. Whether CLI is in PATH
// varies by environment — we test both outcomes.
result, err := buildResumeCommandString("test123", ResumeConfig{
@@ -315,8 +315,8 @@ func TestSessionStorePath_DispatchDBOverride(t *testing.T) {
func TestBuildResumeCommandString_WithCwd(t *testing.T) {
cfg := ResumeConfig{
- CustomCommand: "my-tool {sessionId}",
- Cwd: t.TempDir(),
+ ResumeSessionCommand: "my-tool {sessionId}",
+ Cwd: t.TempDir(),
}
result, err := buildResumeCommandString("abc123", cfg)
if err != nil {
@@ -331,11 +331,11 @@ func TestBuildResumeCommandString_WithCwd(t *testing.T) {
// NewResumeCmd — with CWD (covers cmd.Dir assignment)
// ---------------------------------------------------------------------------
-func TestNewResumeCmd_CustomCommandWithCwd(t *testing.T) {
+func TestNewResumeCmd_ResumeSessionCommandWithCwd(t *testing.T) {
cwd := t.TempDir()
cmd, err := NewResumeCmd("abc123", ResumeConfig{
- CustomCommand: "echo {sessionId}",
- Cwd: cwd,
+ ResumeSessionCommand: "echo {sessionId}",
+ Cwd: cwd,
})
if err != nil {
t.Fatalf("NewResumeCmd error: %v", err)
@@ -345,9 +345,9 @@ func TestNewResumeCmd_CustomCommandWithCwd(t *testing.T) {
}
}
-func TestNewResumeCmd_CustomCommandNoCwd(t *testing.T) {
+func TestNewResumeCmd_ResumeSessionCommandNoCwd(t *testing.T) {
cmd, err := NewResumeCmd("abc123", ResumeConfig{
- CustomCommand: "echo test",
+ ResumeSessionCommand: "echo test",
})
if err != nil {
t.Fatalf("NewResumeCmd error: %v", err)
@@ -565,7 +565,7 @@ func TestIsNerdFontInstalledWindows_NoLocalAppData(t *testing.T) {
func TestBuildResumeCommandString_ArgsWithSpaces(t *testing.T) {
cfg := ResumeConfig{
- CustomCommand: "my tool --arg {sessionId}",
+ ResumeSessionCommand: "my tool --arg {sessionId}",
}
result, err := buildResumeCommandString("abc-123", cfg)
if err != nil {
@@ -578,8 +578,8 @@ func TestBuildResumeCommandString_ArgsWithSpaces(t *testing.T) {
func TestBuildResumeCommandString_WithModel(t *testing.T) {
cfg := ResumeConfig{
- CustomCommand: "tool",
- Cwd: t.TempDir(),
+ ResumeSessionCommand: "tool",
+ Cwd: t.TempDir(),
}
result, err := buildResumeCommandString("abc123", cfg)
if err != nil {
diff --git a/internal/platform/platform_coverage_test.go b/internal/platform/platform_coverage_test.go
index 472ebd4..acd2b1b 100644
--- a/internal/platform/platform_coverage_test.go
+++ b/internal/platform/platform_coverage_test.go
@@ -81,10 +81,10 @@ func TestCovValidateSessionID_TableDriven(t *testing.T) {
}
// ---------------------------------------------------------------------------
-// validateCustomCommand — table driven
+// validateResumeCommand — table driven
// ---------------------------------------------------------------------------
-func TestCovValidateCustomCommand_TableDriven(t *testing.T) {
+func TestCovValidateResumeCommand_TableDriven(t *testing.T) {
valid := []string{
"echo hello",
"my-cli --resume {sessionId}",
@@ -92,15 +92,15 @@ func TestCovValidateCustomCommand_TableDriven(t *testing.T) {
strings.Repeat("a", 1000),
}
for _, cmd := range valid {
- if err := validateCustomCommand(cmd); err != nil {
- t.Errorf("validateCustomCommand(%q) unexpected error: %v", cmd, err)
+ if err := validateResumeCommand(cmd); err != nil {
+ t.Errorf("validateResumeCommand(%q) unexpected error: %v", cmd, err)
}
}
invalid := []string{"", " ", "\t", "cmd\nflag", "cmd\rflag", "cmd\r\nflag"}
for _, cmd := range invalid {
- if err := validateCustomCommand(cmd); err == nil {
- t.Errorf("validateCustomCommand(%q) should error", cmd)
+ if err := validateResumeCommand(cmd); err == nil {
+ t.Errorf("validateResumeCommand(%q) should error", cmd)
}
}
}
@@ -144,11 +144,11 @@ func TestCovBuildCustomCmd_NoPlaceholder(t *testing.T) {
// buildResumeCommandString — additional branches
// ---------------------------------------------------------------------------
-func TestCovBuildResumeCommandString_CustomCommandEmptyAfterExpand(t *testing.T) {
+func TestCovBuildResumeCommandString_ResumeSessionCommandEmptyAfterExpand(t *testing.T) {
// Template "{sessionId}" with empty sessionID: passes validation,
// but expanded is empty → error
_, err := buildResumeCommandString("", ResumeConfig{
- CustomCommand: " {sessionId} ",
+ ResumeSessionCommand: " {sessionId} ",
})
if err == nil {
t.Error("expected error for command empty after expansion")
@@ -156,7 +156,7 @@ func TestCovBuildResumeCommandString_CustomCommandEmptyAfterExpand(t *testing.T)
}
func TestCovBuildResumeCommandString_NoCLIBinary(t *testing.T) {
- // Without custom command, depends on CLI binary presence
+ // Without resume session command, depends on CLI binary presence
_, err := buildResumeCommandString("test-session", ResumeConfig{})
// May succeed or fail depending on PATH
t.Logf("buildResumeCommandString (no custom cmd): err=%v", err)
@@ -472,7 +472,7 @@ func TestCovNewResumeCmd_InvalidSessionID(t *testing.T) {
}
func TestCovNewResumeCmd_EmptySessionCustomCmd(t *testing.T) {
- cmd, err := NewResumeCmd("", ResumeConfig{CustomCommand: "echo hello"})
+ cmd, err := NewResumeCmd("", ResumeConfig{ResumeSessionCommand: "echo hello"})
if err != nil {
t.Fatalf("error: %v", err)
}
@@ -484,8 +484,8 @@ func TestCovNewResumeCmd_EmptySessionCustomCmd(t *testing.T) {
func TestCovNewResumeCmd_CustomCmdWithCwd(t *testing.T) {
dir := t.TempDir()
cmd, err := NewResumeCmd("valid-session", ResumeConfig{
- CustomCommand: "echo {sessionId}",
- Cwd: dir,
+ ResumeSessionCommand: "echo {sessionId}",
+ Cwd: dir,
})
if err != nil {
t.Fatalf("error: %v", err)
@@ -497,8 +497,8 @@ func TestCovNewResumeCmd_CustomCmdWithCwd(t *testing.T) {
func TestCovNewResumeCmd_CustomCmdInvalidCwd(t *testing.T) {
cmd, err := NewResumeCmd("valid-session", ResumeConfig{
- CustomCommand: "echo {sessionId}",
- Cwd: "C:\\nonexistent\\path",
+ ResumeSessionCommand: "echo {sessionId}",
+ Cwd: "C:\\nonexistent\\path",
})
if err != nil {
t.Fatalf("error: %v", err)
@@ -799,19 +799,19 @@ func TestCovDetectWindowsTerminals(t *testing.T) {
func TestCovLaunchSession_InvalidSessionID(t *testing.T) {
err := LaunchSession(ShellInfo{}, "; evil", ResumeConfig{
- CustomCommand: "echo {sessionId}",
+ ResumeSessionCommand: "echo {sessionId}",
})
if err == nil {
t.Error("expected error for invalid session ID")
}
}
-func TestCovLaunchSession_EmptyCustomCommand(t *testing.T) {
+func TestCovLaunchSession_EmptyResumeSessionCommand(t *testing.T) {
err := LaunchSession(ShellInfo{}, "valid-session", ResumeConfig{
- CustomCommand: " ",
+ ResumeSessionCommand: " ",
})
if err == nil {
- t.Error("expected error for empty custom command")
+ t.Error("expected error for empty resume session command")
}
}
@@ -853,19 +853,19 @@ func TestCovDefaultWindowsShell_WindowsPowerShell(t *testing.T) {
func TestCovLaunchSession_SetupWithTerminalDefault(t *testing.T) {
// Call with Terminal="" to trigger DefaultTerminal() assignment,
- // but use a custom command that fails validation to avoid opening windows.
+ // but use a resume session command that fails validation to avoid opening windows.
err := LaunchSession(ShellInfo{}, "valid-session", ResumeConfig{
- CustomCommand: "cmd\n--evil",
+ ResumeSessionCommand: "cmd\n--evil",
})
if err == nil {
- t.Error("expected error for newline in custom command")
+ t.Error("expected error for newline in resume session command")
}
}
func TestCovLaunchSession_SetupWithShellDefault(t *testing.T) {
// Shell.Path="" triggers DefaultShell(), Terminal="" triggers DefaultTerminal()
err := LaunchSession(ShellInfo{}, "", ResumeConfig{
- CustomCommand: " {sessionId} ",
+ ResumeSessionCommand: " {sessionId} ",
})
if err == nil {
t.Error("expected error for empty command after expansion")
@@ -951,7 +951,7 @@ func TestCovDefaultTerminal_WindowsTerminal(t *testing.T) {
// ===========================================================================
// LaunchSession — cover the resolvedCwd + switch "windows" path
-// Uses cmd.exe as the shell with a harmless "exit 0" custom command
+// Uses cmd.exe as the shell with a harmless "exit 0" resume session command
// so the spawned window closes immediately without error dialogs.
// ===========================================================================
diff --git a/internal/platform/platform_security_test.go b/internal/platform/platform_security_test.go
index 88490db..0be3659 100644
--- a/internal/platform/platform_security_test.go
+++ b/internal/platform/platform_security_test.go
@@ -87,18 +87,18 @@ func TestBuildResumeArgs_MaliciousAgentModel(t *testing.T) {
}
}
-func TestNewResumeCmd_CustomCommand_EmptyAfterExpansion(t *testing.T) {
- // If custom command resolves to empty/whitespace, must error.
- _, err := NewResumeCmd("sess-1", ResumeConfig{CustomCommand: " "})
+func TestNewResumeCmd_ResumeSessionCommand_EmptyAfterExpansion(t *testing.T) {
+ // If resume session command resolves to empty/whitespace, must error.
+ _, err := NewResumeCmd("sess-1", ResumeConfig{ResumeSessionCommand: " "})
if err == nil {
- t.Fatal("NewResumeCmd should error on empty custom command")
+ t.Fatal("NewResumeCmd should error on empty resume session command")
}
}
-func TestNewResumeCmd_CustomCommand_SessionIDReplacement(t *testing.T) {
+func TestNewResumeCmd_ResumeSessionCommand_SessionIDReplacement(t *testing.T) {
// Verify {sessionId} is replaced correctly with a valid session ID.
cmd, err := NewResumeCmd("abc-123", ResumeConfig{
- CustomCommand: "my-cli --resume {sessionId} --flag",
+ ResumeSessionCommand: "my-cli --resume {sessionId} --flag",
})
if err != nil {
t.Fatalf("NewResumeCmd: %v", err)
@@ -141,7 +141,7 @@ func TestNewResumeCmd_RejectsInjectionPayloads(t *testing.T) {
for _, payload := range payloads {
t.Run(truncateForTestName(payload), func(t *testing.T) {
_, err := NewResumeCmd(payload, ResumeConfig{
- CustomCommand: "my-cli --resume {sessionId}",
+ ResumeSessionCommand: "my-cli --resume {sessionId}",
})
if err == nil {
t.Errorf("NewResumeCmd should reject malicious session ID %q", payload)
@@ -164,7 +164,7 @@ func TestNewResumeCmd_AcceptsValidSessionIDs(t *testing.T) {
for _, id := range valid {
t.Run(id, func(t *testing.T) {
_, err := NewResumeCmd(id, ResumeConfig{
- CustomCommand: "my-cli --resume {sessionId}",
+ ResumeSessionCommand: "my-cli --resume {sessionId}",
})
if err != nil {
t.Errorf("NewResumeCmd should accept valid session ID %q: %v", id, err)
@@ -518,7 +518,7 @@ func TestBuildResumeCommandString_RejectsInjectionPayloads(t *testing.T) {
for _, payload := range payloads {
t.Run(truncateForTestName(payload), func(t *testing.T) {
_, err := buildResumeCommandString(payload, ResumeConfig{
- CustomCommand: "my-cli --resume {sessionId}",
+ ResumeSessionCommand: "my-cli --resume {sessionId}",
})
if err == nil {
t.Errorf("buildResumeCommandString should reject malicious session ID %q", payload)
@@ -530,7 +530,7 @@ func TestBuildResumeCommandString_RejectsInjectionPayloads(t *testing.T) {
func TestBuildResumeCommandString_AcceptsValidSessionID(t *testing.T) {
// Valid session IDs should produce a valid command string.
cmd, err := buildResumeCommandString("valid-session.123", ResumeConfig{
- CustomCommand: "my-cli --resume {sessionId}",
+ ResumeSessionCommand: "my-cli --resume {sessionId}",
})
if err != nil {
t.Fatalf("buildResumeCommandString: %v", err)
@@ -540,11 +540,11 @@ func TestBuildResumeCommandString_AcceptsValidSessionID(t *testing.T) {
}
}
-func TestBuildResumeCommandString_EmptyCustomCommand(t *testing.T) {
- // With a valid session ID and a custom command that has only the
+func TestBuildResumeCommandString_EmptyResumeSessionCommand(t *testing.T) {
+ // With a valid session ID and a resume session command that has only the
// placeholder, replacement produces just the session ID.
cmd, err := buildResumeCommandString("sess-1", ResumeConfig{
- CustomCommand: " {sessionId} ",
+ ResumeSessionCommand: " {sessionId} ",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -554,9 +554,9 @@ func TestBuildResumeCommandString_EmptyCustomCommand(t *testing.T) {
}
// Empty string as session ID is rejected by validation, not by the
- // custom command expansion logic.
+ // resume session command expansion logic.
_, err = buildResumeCommandString("", ResumeConfig{
- CustomCommand: " {sessionId} ",
+ ResumeSessionCommand: " {sessionId} ",
})
if err == nil {
t.Fatal("expected error when session ID is empty")
@@ -596,7 +596,7 @@ func TestBuildStartCmdLine_DefaultPathEscapesMetachars(t *testing.T) {
shell := ShellInfo{Name: "WSL", Path: `C:\Windows\System32\wsl.exe`}
// Simulate a resume command containing cmd.exe metacharacters
// (this wouldn't normally happen with validated session IDs, but
- // is a defense-in-depth test for custom commands).
+ // is a defense-in-depth test for resume session commands).
resumeCmd := `ghcs --resume safe-id & echo injected`
got := buildStartCmdLine(shell, resumeCmd)
diff --git a/internal/platform/shell.go b/internal/platform/shell.go
index b0ba973..3c0a285 100644
--- a/internal/platform/shell.go
+++ b/internal/platform/shell.go
@@ -41,14 +41,14 @@ const (
// ResumeConfig holds optional CLI flags appended when resuming a session.
type ResumeConfig struct {
- YoloMode bool
- Agent string
- Model string
- Terminal string // preferred terminal emulator name (empty = auto-detect)
- CustomCommand string // when set, replaces the entire copilot CLI command
- Cwd string // working directory to launch the session in
- LaunchStyle string // "", "window", or "pane" — controls tab vs window vs split pane
- PaneDirection string // "auto", "right", "down", "left", "up" — split direction for pane mode
+ YoloMode bool
+ Agent string
+ Model string
+ Terminal string // preferred terminal emulator name (empty = auto-detect)
+ ResumeSessionCommand string // when set, replaces the entire copilot CLI command
+ Cwd string // working directory to launch the session in
+ LaunchStyle string // "", "window", or "pane" — controls tab vs window vs split pane
+ PaneDirection string // "auto", "right", "down", "left", "up" — split direction for pane mode
}
// TerminalInfo describes a terminal emulator available on the system.
@@ -57,13 +57,13 @@ type TerminalInfo struct {
}
// FindCLIBinary returns the absolute path to the Copilot CLI binary,
-// preferring "ghcs" and falling back to "copilot". Returns an empty
+// preferring "copilot" and falling back to "ghcs". Returns an empty
// string when neither is found on PATH.
func FindCLIBinary() string {
- if p, err := exec.LookPath("ghcs"); err == nil {
+ if p, err := exec.LookPath("copilot"); err == nil {
return p
}
- if p, err := exec.LookPath("copilot"); err == nil {
+ if p, err := exec.LookPath("ghcs"); err == nil {
return p
}
return ""
@@ -114,7 +114,7 @@ func BuildResumeArgs(sessionID string, cfg ResumeConfig) []string {
// The returned command has no Stdin/Stdout/Stderr configured; callers
// (or tea.ExecProcess) should attach them as needed.
//
-// When cfg.CustomCommand is set, the custom command string (with
+// When cfg.ResumeSessionCommand is set, the resume session command string (with
// {sessionId} replaced) is split on whitespace and executed directly,
// bypassing the copilot CLI binary lookup.
func NewResumeCmd(sessionID string, cfg ResumeConfig) (*exec.Cmd, error) {
@@ -124,8 +124,8 @@ func NewResumeCmd(sessionID string, cfg ResumeConfig) (*exec.Cmd, error) {
}
}
var cmd *exec.Cmd
- if cfg.CustomCommand != "" {
- c, err := buildCustomCmd(sessionID, cfg.CustomCommand)
+ if cfg.ResumeSessionCommand != "" {
+ c, err := buildCustomCmd(sessionID, cfg.ResumeSessionCommand)
if err != nil {
return nil, err
}
@@ -144,26 +144,26 @@ func NewResumeCmd(sessionID string, cfg ResumeConfig) (*exec.Cmd, error) {
return cmd, nil
}
-// validateCustomCommand rejects custom command templates containing dangerous
-// characters. The custom_command comes from the user's own local config file,
+// validateResumeCommand rejects resume session command templates containing dangerous
+// characters. The resume_session_command comes from the user's own local config file,
// so this is defense-in-depth (not a remote attack vector). The argv-style
// exec path (strings.Fields) is inherently safer, but buildResumeCommandString
// passes the expanded command through a shell, so we guard against embedded
// newlines that could inject additional shell commands.
-func validateCustomCommand(cmd string) error {
+func validateResumeCommand(cmd string) error {
if strings.TrimSpace(cmd) == "" {
- return errors.New("custom command is empty or whitespace-only")
+ return errors.New("resume session command is empty or whitespace-only")
}
if strings.ContainsAny(cmd, "\n\r") {
- return errors.New("custom command contains embedded newlines")
+ return errors.New("resume session command contains embedded newlines")
}
return nil
}
-// buildCustomCmd parses a custom command template, replaces {sessionId}
+// buildCustomCmd parses a resume session command template, replaces {sessionId}
// with the actual session ID, splits on whitespace, and returns an *exec.Cmd.
func buildCustomCmd(sessionID, template string) (*exec.Cmd, error) {
- if err := validateCustomCommand(template); err != nil {
+ if err := validateResumeCommand(template); err != nil {
return nil, err
}
expanded := strings.ReplaceAll(template, "{sessionId}", sessionID)
@@ -182,20 +182,20 @@ func buildCustomCmd(sessionID, template string) (*exec.Cmd, error) {
// and PowerShell both understand double quotes, while POSIX single
// quotes cause misinterpretation on Windows (e.g., UNC-path errors).
//
-// When cfg.CustomCommand is set, {sessionId} is replaced and the result
+// When cfg.ResumeSessionCommand is set, {sessionId} is replaced and the result
// is returned directly (the user is responsible for quoting within their
-// custom command template).
+// resume session command template).
func buildResumeCommandString(sessionID string, cfg ResumeConfig) (string, error) {
if sessionID != "" {
if err := validateSessionID(sessionID); err != nil {
return "", err
}
}
- if cfg.CustomCommand != "" {
- if err := validateCustomCommand(cfg.CustomCommand); err != nil {
+ if cfg.ResumeSessionCommand != "" {
+ if err := validateResumeCommand(cfg.ResumeSessionCommand); err != nil {
return "", err
}
- expanded := strings.ReplaceAll(cfg.CustomCommand, "{sessionId}", sessionID)
+ expanded := strings.ReplaceAll(cfg.ResumeSessionCommand, "{sessionId}", sessionID)
if strings.TrimSpace(expanded) == "" {
return "", ErrEmptyAfterExpansion
}
@@ -226,7 +226,7 @@ func buildResumeCommandString(sessionID string, cfg ResumeConfig) (string, error
}
// BuildResumeCommandString returns the shell command Dispatch uses to start or
-// resume a Copilot CLI session, including configured flags and custom command
+// resume a Copilot CLI session, including configured flags and resume session command
// templates. It is exported for UI and diagnostics code that need to display or
// copy the same command used by the launcher.
func BuildResumeCommandString(sessionID string, cfg ResumeConfig) (string, error) {
diff --git a/internal/platform/shell_coverage_test.go b/internal/platform/shell_coverage_test.go
index 5b6effd..06980e4 100644
--- a/internal/platform/shell_coverage_test.go
+++ b/internal/platform/shell_coverage_test.go
@@ -318,11 +318,11 @@ func TestLaunchSession_RoutesToPlatformLauncher(t *testing.T) {
ShellInfo{Name: "test-shell", Path: "test-path"},
"test-session",
ResumeConfig{
- CustomCommand: "my-cli --resume {sessionId}",
- Terminal: "my-terminal",
- Cwd: cwd,
- LaunchStyle: LaunchStyleWindow,
- PaneDirection: "right",
+ ResumeSessionCommand: "my-cli --resume {sessionId}",
+ Terminal: "my-terminal",
+ Cwd: cwd,
+ LaunchStyle: LaunchStyleWindow,
+ PaneDirection: "right",
},
)
if err != nil {
@@ -363,9 +363,9 @@ func TestLaunchSession_PaneStylePassedThrough(t *testing.T) {
ShellInfo{Name: "sh", Path: "sh"},
"sess",
ResumeConfig{
- CustomCommand: "echo {sessionId}",
- LaunchStyle: LaunchStylePane,
- PaneDirection: "down",
+ ResumeSessionCommand: "echo {sessionId}",
+ LaunchStyle: LaunchStylePane,
+ PaneDirection: "down",
},
)
if err != nil {
@@ -391,7 +391,7 @@ func TestLaunchSession_PropagatesLauncherError(t *testing.T) {
err := LaunchSession(
ShellInfo{Name: "sh", Path: "sh"},
"sess",
- ResumeConfig{CustomCommand: "echo {sessionId}"},
+ ResumeConfig{ResumeSessionCommand: "echo {sessionId}"},
)
if !errors.Is(err, wantErr) {
t.Errorf("got error %v, want %v", err, wantErr)
@@ -412,8 +412,8 @@ func TestLaunchSession_DefaultsTerminalWhenEmpty(t *testing.T) {
ShellInfo{Name: "sh", Path: "sh"},
"sess",
ResumeConfig{
- CustomCommand: "echo {sessionId}",
- Terminal: "", // empty - should default
+ ResumeSessionCommand: "echo {sessionId}",
+ Terminal: "", // empty - should default
},
)
if err != nil {
@@ -441,7 +441,7 @@ func TestLaunchSession_DefaultsShellWhenEmpty(t *testing.T) {
err := LaunchSession(
ShellInfo{}, // empty - should default
"sess",
- ResumeConfig{CustomCommand: "echo {sessionId}"},
+ ResumeConfig{ResumeSessionCommand: "echo {sessionId}"},
)
if err != nil {
t.Fatalf("LaunchSession error: %v", err)
@@ -489,7 +489,7 @@ func TestPlatformLaunchSession_RoutesByGOOS(t *testing.T) {
err := LaunchSession(
ShellInfo{Name: "sh", Path: "sh"},
"test-sess",
- ResumeConfig{CustomCommand: "echo {sessionId}"},
+ ResumeConfig{ResumeSessionCommand: "echo {sessionId}"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
diff --git a/internal/platform/shell_test.go b/internal/platform/shell_test.go
index 7a81dbe..3d01f5d 100644
--- a/internal/platform/shell_test.go
+++ b/internal/platform/shell_test.go
@@ -367,10 +367,10 @@ func TestBuildCustomCmd(t *testing.T) {
}
// ---------------------------------------------------------------------------
-// validateCustomCommand
+// validateResumeCommand
// ---------------------------------------------------------------------------
-func TestValidateCustomCommand(t *testing.T) {
+func TestValidateResumeCommand(t *testing.T) {
tests := []struct {
name string
cmd string
@@ -389,9 +389,9 @@ func TestValidateCustomCommand(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- err := validateCustomCommand(tt.cmd)
+ err := validateResumeCommand(tt.cmd)
if (err != nil) != tt.wantErr {
- t.Errorf("validateCustomCommand(%q) error = %v, wantErr %v", tt.cmd, err, tt.wantErr)
+ t.Errorf("validateResumeCommand(%q) error = %v, wantErr %v", tt.cmd, err, tt.wantErr)
}
})
}
@@ -404,22 +404,22 @@ func TestBuildCustomCmd_RejectsNewlines(t *testing.T) {
}
}
-func TestBuildResumeCommandString_RejectsNewlinesInCustomCommand(t *testing.T) {
+func TestBuildResumeCommandString_RejectsNewlinesInResumeSessionCommand(t *testing.T) {
_, err := buildResumeCommandString("valid-session", ResumeConfig{
- CustomCommand: "my-cli\n--evil-flag",
+ ResumeSessionCommand: "my-cli\n--evil-flag",
})
if err == nil {
- t.Fatal("buildResumeCommandString should reject custom commands with embedded newlines")
+ t.Fatal("buildResumeCommandString should reject resume session commands with embedded newlines")
}
}
// ---------------------------------------------------------------------------
-// buildResumeCommandString — custom command path
+// buildResumeCommandString — resume session command path
// ---------------------------------------------------------------------------
-func TestBuildResumeCommandString_CustomCommand(t *testing.T) {
+func TestBuildResumeCommandString_ResumeSessionCommand(t *testing.T) {
cmd, err := buildResumeCommandString("test-session", ResumeConfig{
- CustomCommand: "my-cli --resume {sessionId} --flag",
+ ResumeSessionCommand: "my-cli --resume {sessionId} --flag",
})
if err != nil {
t.Fatalf("buildResumeCommandString: %v", err)
@@ -429,18 +429,18 @@ func TestBuildResumeCommandString_CustomCommand(t *testing.T) {
}
}
-func TestBuildResumeCommandString_CustomCommandEmpty(t *testing.T) {
+func TestBuildResumeCommandString_ResumeSessionCommandEmpty(t *testing.T) {
_, err := buildResumeCommandString("test-session", ResumeConfig{
- CustomCommand: " ",
+ ResumeSessionCommand: " ",
})
if err == nil {
- t.Fatal("should error on empty custom command")
+ t.Fatal("should error on empty resume session command")
}
}
func TestBuildResumeCommandString_InvalidSessionID(t *testing.T) {
_, err := buildResumeCommandString("; rm -rf /", ResumeConfig{
- CustomCommand: "my-cli {sessionId}",
+ ResumeSessionCommand: "my-cli {sessionId}",
})
if err == nil {
t.Fatal("should reject invalid session ID")
@@ -448,12 +448,12 @@ func TestBuildResumeCommandString_InvalidSessionID(t *testing.T) {
}
// ---------------------------------------------------------------------------
-// NewResumeCmd — custom command path
+// NewResumeCmd — resume session command path
// ---------------------------------------------------------------------------
-func TestNewResumeCmd_CustomCommand(t *testing.T) {
+func TestNewResumeCmd_ResumeSessionCommand(t *testing.T) {
cmd, err := NewResumeCmd("valid-session", ResumeConfig{
- CustomCommand: "echo {sessionId}",
+ ResumeSessionCommand: "echo {sessionId}",
})
if err != nil {
t.Fatalf("NewResumeCmd: %v", err)
@@ -479,12 +479,12 @@ func TestNewResumeCmd_EmptySessionIDStartsNewSession(t *testing.T) {
}
}
-func TestNewResumeCmd_CustomCommandEmptyTemplate(t *testing.T) {
+func TestNewResumeCmd_ResumeSessionCommandEmptyTemplate(t *testing.T) {
_, err := NewResumeCmd("valid-session", ResumeConfig{
- CustomCommand: " ",
+ ResumeSessionCommand: " ",
})
if err == nil {
- t.Fatal("should error on whitespace-only custom command")
+ t.Fatal("should error on whitespace-only resume session command")
}
}
@@ -531,8 +531,8 @@ func TestResolvedCwd_FileNotDir(t *testing.T) {
func TestNewResumeCmd_SetsDirFromCwd(t *testing.T) {
dir := t.TempDir()
cmd, err := NewResumeCmd("valid-session", ResumeConfig{
- CustomCommand: "echo {sessionId}",
- Cwd: dir,
+ ResumeSessionCommand: "echo {sessionId}",
+ Cwd: dir,
})
if err != nil {
t.Fatalf("NewResumeCmd: %v", err)
@@ -544,8 +544,8 @@ func TestNewResumeCmd_SetsDirFromCwd(t *testing.T) {
func TestNewResumeCmd_IgnoresInvalidCwd(t *testing.T) {
cmd, err := NewResumeCmd("valid-session", ResumeConfig{
- CustomCommand: "echo {sessionId}",
- Cwd: "/no/such/path/here",
+ ResumeSessionCommand: "echo {sessionId}",
+ Cwd: "/no/such/path/here",
})
if err != nil {
t.Fatalf("NewResumeCmd: %v", err)
diff --git a/internal/tui/cmdpalette.go b/internal/tui/cmdpalette.go
index bce367c..570e0bf 100644
--- a/internal/tui/cmdpalette.go
+++ b/internal/tui/cmdpalette.go
@@ -153,21 +153,21 @@ func (m Model) handleCmdPaletteAction(msg cmdPaletteActionMsg) (tea.Model, tea.C
case "settings":
m.configPanel.SetValues(components.ConfigValues{
- YoloMode: m.cfg.YoloMode,
- Agent: m.cfg.Agent,
- Model: m.cfg.Model,
- LaunchMode: m.cfg.EffectiveLaunchMode(),
- PaneDirection: m.cfg.EffectivePaneDirection(),
- Terminal: m.cfg.DefaultTerminal,
- Shell: m.cfg.DefaultShell,
- CustomCommand: m.cfg.CustomCommand,
- Theme: m.cfg.Theme,
- WorkspaceRecovery: m.cfg.WorkspaceRecovery,
- PreviewPosition: m.cfg.EffectivePreviewPosition(),
- RedactSecrets: m.cfg.RedactPreviewSecrets,
- ExcludedWords: joinExcludedWords(m.cfg.ExcludedWords),
- AutoRefresh: autoRefreshFieldValue(m.cfg.AutoRefreshSeconds),
- NotifyOnWaiting: m.cfg.NotifyOnWaiting,
+ YoloMode: m.cfg.YoloMode,
+ Agent: m.cfg.Agent,
+ Model: m.cfg.Model,
+ LaunchMode: m.cfg.EffectiveLaunchMode(),
+ PaneDirection: m.cfg.EffectivePaneDirection(),
+ Terminal: m.cfg.DefaultTerminal,
+ Shell: m.cfg.DefaultShell,
+ ResumeSessionCommand: m.cfg.ResumeSessionCommand,
+ Theme: m.cfg.Theme,
+ WorkspaceRecovery: m.cfg.WorkspaceRecovery,
+ PreviewPosition: m.cfg.EffectivePreviewPosition(),
+ 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 547b550..ab73dd7 100644
--- a/internal/tui/components/configpanel.go
+++ b/internal/tui/components/configpanel.go
@@ -27,7 +27,8 @@ const (
cfgPaneDirection
cfgTerminal
cfgShell
- cfgCustomCommand
+ cfgResumeSessionCommand
+ cfgNewSessionCommand
cfgTheme
cfgWorkspaceRecovery
cfgPreviewPosition
@@ -47,27 +48,28 @@ const themeAuto = "auto"
// ConfigPanel presents an overlay that lets users edit session-resume
// settings (yolo mode, agent, model, launch-in-place, terminal, shell,
-// custom command).
+// resume session command, new session command).
type ConfigPanel struct {
- yoloMode bool
- agent string
- model string
- launchMode string // "in-place", "tab", "window", or "pane"
- paneDirection string // "auto", "right", "down", "left", "up"
- terminal string
- shell string
- customCommand string
- theme string // active color scheme name ("auto" or a scheme name)
- workspaceRecovery bool
- previewPosition string // "right", "bottom", "left", "top"
- redactSecrets bool
- excludedWords string // comma-separated list of filter words
- autoRefresh string // session-list auto-refresh seconds ("" default, "0" off)
- notifyOnWaiting bool
- colRepo bool // show the repository column in the session list
- colFolder bool // show the folder column in the session list
- colTurns bool // show the turn-count column in the session list
- colHost bool // show the host indicator in the session list
+ yoloMode bool
+ agent string
+ model string
+ launchMode string // "in-place", "tab", "window", or "pane"
+ paneDirection string // "auto", "right", "down", "left", "up"
+ terminal string
+ shell string
+ resumeSessionCommand string
+ newSessionCommand string
+ theme string // active color scheme name ("auto" or a scheme name)
+ workspaceRecovery bool
+ previewPosition string // "right", "bottom", "left", "top"
+ redactSecrets bool
+ excludedWords string // comma-separated list of filter words
+ autoRefresh string // session-list auto-refresh seconds ("" default, "0" off)
+ notifyOnWaiting bool
+ colRepo bool // show the repository column in the session list
+ colFolder bool // show the folder column in the session list
+ colTurns bool // show the turn-count column in the session list
+ colHost bool // show the host indicator in the session list
// Available options for cycling.
terminals []string
@@ -100,25 +102,26 @@ func NewConfigPanel() ConfigPanel {
// ConfigValues bundles the editable fields exchanged between the config
// panel and the rest of the application.
type ConfigValues struct {
- YoloMode bool
- Agent string
- Model string
- LaunchMode string
- PaneDirection string
- Terminal string
- Shell string
- CustomCommand string
- Theme string
- WorkspaceRecovery bool
- PreviewPosition string
- RedactSecrets bool
- ExcludedWords string // comma-separated filter words
- AutoRefresh string // auto-refresh seconds ("" default, "0" off)
- NotifyOnWaiting bool
- ShowRepoColumn bool
- ShowFolderColumn bool
- ShowTurnsColumn bool
- ShowHostColumn bool
+ YoloMode bool
+ Agent string
+ Model string
+ LaunchMode string
+ PaneDirection string
+ Terminal string
+ Shell string
+ ResumeSessionCommand string
+ NewSessionCommand string
+ Theme string
+ WorkspaceRecovery bool
+ PreviewPosition string
+ RedactSecrets bool
+ ExcludedWords string // comma-separated filter words
+ AutoRefresh string // auto-refresh seconds ("" default, "0" off)
+ NotifyOnWaiting bool
+ ShowRepoColumn bool
+ ShowFolderColumn bool
+ ShowTurnsColumn bool
+ ShowHostColumn bool
}
// SetValues loads the config panel state from external values.
@@ -130,7 +133,8 @@ func (c *ConfigPanel) SetValues(v ConfigValues) {
c.paneDirection = v.PaneDirection
c.terminal = v.Terminal
c.shell = v.Shell
- c.customCommand = v.CustomCommand
+ c.resumeSessionCommand = v.ResumeSessionCommand
+ c.newSessionCommand = v.NewSessionCommand
c.theme = v.Theme
c.workspaceRecovery = v.WorkspaceRecovery
c.previewPosition = v.PreviewPosition
@@ -147,25 +151,26 @@ func (c *ConfigPanel) SetValues(v ConfigValues) {
// Values returns the current state of all editable fields.
func (c *ConfigPanel) Values() ConfigValues {
return ConfigValues{
- YoloMode: c.yoloMode,
- Agent: c.agent,
- Model: c.model,
- LaunchMode: c.launchMode,
- PaneDirection: c.paneDirection,
- Terminal: c.terminal,
- Shell: c.shell,
- CustomCommand: c.customCommand,
- Theme: c.theme,
- WorkspaceRecovery: c.workspaceRecovery,
- PreviewPosition: c.previewPosition,
- RedactSecrets: c.redactSecrets,
- ExcludedWords: c.excludedWords,
- AutoRefresh: c.autoRefresh,
- NotifyOnWaiting: c.notifyOnWaiting,
- ShowRepoColumn: c.colRepo,
- ShowFolderColumn: c.colFolder,
- ShowTurnsColumn: c.colTurns,
- ShowHostColumn: c.colHost,
+ YoloMode: c.yoloMode,
+ Agent: c.agent,
+ Model: c.model,
+ LaunchMode: c.launchMode,
+ PaneDirection: c.paneDirection,
+ Terminal: c.terminal,
+ Shell: c.shell,
+ ResumeSessionCommand: c.resumeSessionCommand,
+ NewSessionCommand: c.newSessionCommand,
+ Theme: c.theme,
+ WorkspaceRecovery: c.workspaceRecovery,
+ PreviewPosition: c.previewPosition,
+ RedactSecrets: c.redactSecrets,
+ ExcludedWords: c.excludedWords,
+ AutoRefresh: c.autoRefresh,
+ NotifyOnWaiting: c.notifyOnWaiting,
+ ShowRepoColumn: c.colRepo,
+ ShowFolderColumn: c.colFolder,
+ ShowTurnsColumn: c.colTurns,
+ ShowHostColumn: c.colHost,
}
}
@@ -215,7 +220,7 @@ func (c *ConfigPanel) MoveDown() {
// a text field. Returns a tea.Cmd when a text input needs focus.
func (c *ConfigPanel) HandleEnter() tea.Cmd {
// When a custom command is set, YOLO/Agent/Model are irrelevant — skip them.
- if c.customCommand != "" {
+ if c.resumeSessionCommand != "" {
switch c.cursor {
case cfgYoloMode, cfgAgent, cfgModel:
return nil
@@ -245,9 +250,14 @@ func (c *ConfigPanel) HandleEnter() tea.Cmd {
c.terminal = c.cycleOption(c.terminal, c.terminals)
case cfgShell:
c.shell = c.cycleOption(c.shell, c.shellNames())
- case cfgCustomCommand:
+ case cfgResumeSessionCommand:
c.editing = true
- c.textInput.SetValue(c.customCommand)
+ c.textInput.SetValue(c.resumeSessionCommand)
+ c.textInput.CharLimit = 256
+ return c.textInput.Focus()
+ case cfgNewSessionCommand:
+ c.editing = true
+ c.textInput.SetValue(c.newSessionCommand)
c.textInput.CharLimit = 256
return c.textInput.Focus()
case cfgExcludedWords:
@@ -295,8 +305,10 @@ func (c *ConfigPanel) ConfirmEdit() {
c.agent = val
case cfgModel:
c.model = val
- case cfgCustomCommand:
- c.customCommand = val
+ case cfgResumeSessionCommand:
+ c.resumeSessionCommand = val
+ case cfgNewSessionCommand:
+ c.newSessionCommand = val
case cfgExcludedWords:
c.excludedWords = val
case cfgAutoRefresh:
@@ -335,7 +347,7 @@ func (c ConfigPanel) View() string {
// hasCustom indicates that copilot-specific fields (Yolo, Agent, Model)
// are irrelevant because a custom command overrides them.
- hasCustom := c.customCommand != ""
+ hasCustom := c.resumeSessionCommand != ""
type field struct {
label string
@@ -350,7 +362,8 @@ func (c ConfigPanel) View() string {
{"Pane Direction", paneDirectionDisplay(c.paneDirection), c.launchMode != config.LaunchModePane},
{"Terminal", stringDisplay(c.terminal), false},
{"Shell", stringDisplay(c.shell), false},
- {"Custom Command", stringDisplay(c.customCommand), false},
+ {"Resume Command", stringDisplay(c.resumeSessionCommand), false},
+ {"New Session Cmd", stringDisplay(c.newSessionCommand), false},
{"Theme", themeDisplay(c.theme), false},
{"Crash Recovery", boolDisplay(c.workspaceRecovery), false},
{"Preview Position", previewPositionDisplay(c.previewPosition), false},
@@ -395,7 +408,7 @@ func (c ConfigPanel) View() string {
}
// Contextual help when the Custom Command field is focused.
- if c.cursor == cfgCustomCommand {
+ if c.cursor == cfgResumeSessionCommand {
body.WriteString("\n")
body.WriteString(styles.DimmedStyle.Render(" {sessionId} is replaced with the session ID at launch.") + "\n")
body.WriteString(styles.DimmedStyle.Render(" Overrides Yolo, Agent, and Model when set.") + "\n")
diff --git a/internal/tui/components/configpanel_test.go b/internal/tui/components/configpanel_test.go
index 33f3550..aea0f47 100644
--- a/internal/tui/components/configpanel_test.go
+++ b/internal/tui/components/configpanel_test.go
@@ -21,7 +21,7 @@ func TestNewConfigPanel_Defaults(t *testing.T) {
if v.YoloMode {
t.Error("default yoloMode should be false")
}
- if v.Agent != "" || v.Model != "" || v.LaunchMode != "" || v.Terminal != "" || v.Shell != "" || v.CustomCommand != "" || v.Theme != "" {
+ if v.Agent != "" || v.Model != "" || v.LaunchMode != "" || v.Terminal != "" || v.Shell != "" || v.ResumeSessionCommand != "" || v.Theme != "" {
t.Error("default string values should be empty")
}
}
@@ -35,7 +35,7 @@ func TestConfigPanel_SetValues_RoundTrip(t *testing.T) {
cp := NewConfigPanel()
cp.SetValues(ConfigValues{
YoloMode: true, Agent: "myagent", Model: "gpt-4", LaunchMode: "tab",
- Terminal: "Windows Terminal", Shell: "pwsh", CustomCommand: "my-cmd {sessionId}", Theme: "Campbell",
+ Terminal: "Windows Terminal", Shell: "pwsh", ResumeSessionCommand: "my-cmd {sessionId}", Theme: "Campbell",
WorkspaceRecovery: true, PreviewPosition: "bottom",
})
@@ -58,8 +58,8 @@ func TestConfigPanel_SetValues_RoundTrip(t *testing.T) {
if v.Shell != "pwsh" {
t.Errorf("shell = %q, want %q", v.Shell, "pwsh")
}
- if v.CustomCommand != "my-cmd {sessionId}" {
- t.Errorf("customCommand = %q, want %q", v.CustomCommand, "my-cmd {sessionId}")
+ if v.ResumeSessionCommand != "my-cmd {sessionId}" {
+ t.Errorf("ResumeSessionCommand = %q, want %q", v.ResumeSessionCommand, "my-cmd {sessionId}")
}
if v.Theme != "Campbell" {
t.Errorf("theme = %q, want %q", v.Theme, "Campbell")
@@ -229,10 +229,10 @@ func TestConfigPanel_HandleEnter_AgentEditing(t *testing.T) {
cp.CancelEdit()
}
-func TestConfigPanel_HandleEnter_CustomCommandOverrides(t *testing.T) {
+func TestConfigPanel_HandleEnter_ResumeSessionCommandOverrides(t *testing.T) {
t.Parallel()
cp := NewConfigPanel()
- cp.SetValues(ConfigValues{CustomCommand: "my-cmd"})
+ cp.SetValues(ConfigValues{ResumeSessionCommand: "my-cmd"})
// With custom command set, yolo/agent/model should be no-ops.
cp.cursor = cfgYoloMode
@@ -373,17 +373,17 @@ func TestConfigPanel_View_ContainsFields(t *testing.T) {
cp := NewConfigPanel()
cp.SetSize(80, 40)
view := cp.View()
- for _, field := range []string{"Yolo Mode", "Agent", "Model", "Launch Mode", "Terminal", "Shell", "Custom Command", "Theme", "Crash Recovery", "Preview Position"} {
+ for _, field := range []string{"Yolo Mode", "Agent", "Model", "Launch Mode", "Terminal", "Shell", "Resume Command", "Theme", "Crash Recovery", "Preview Position"} {
if !strings.Contains(view, field) {
t.Errorf("View should contain field %q", field)
}
}
}
-func TestConfigPanel_View_ShowsOverriddenWhenCustomCommand(t *testing.T) {
+func TestConfigPanel_View_ShowsOverriddenWhenResumeSessionCommand(t *testing.T) {
t.Parallel()
cp := NewConfigPanel()
- cp.SetValues(ConfigValues{CustomCommand: "my-cmd"})
+ cp.SetValues(ConfigValues{ResumeSessionCommand: "my-cmd"})
cp.SetSize(80, 40)
view := cp.View()
if !strings.Contains(view, "overridden") {
@@ -404,11 +404,11 @@ func TestConfigPanel_View_ShowsEditFooter(t *testing.T) {
cp.CancelEdit()
}
-func TestConfigPanel_View_CustomCommandHelp(t *testing.T) {
+func TestConfigPanel_View_ResumeSessionCommandHelp(t *testing.T) {
t.Parallel()
cp := NewConfigPanel()
cp.SetSize(80, 40)
- cp.cursor = cfgCustomCommand
+ cp.cursor = cfgResumeSessionCommand
view := cp.View()
if !strings.Contains(view, "{sessionId}") {
t.Error("View on Custom Command field should show {sessionId} help")
diff --git a/internal/tui/components/preview.go b/internal/tui/components/preview.go
index 78f402d..22bc863 100644
--- a/internal/tui/components/preview.go
+++ b/internal/tui/components/preview.go
@@ -17,6 +17,7 @@ import (
type PreviewPanel struct {
detail *data.SessionDetail
attentionStatus data.AttentionStatus
+ lastEvent data.SessionEvent
note string // user note for the current session
alias string // user-chosen alias for the current session
width int
@@ -107,6 +108,12 @@ func (p *PreviewPanel) SetAttentionStatus(status data.AttentionStatus) {
p.updateTotalLines()
}
+// SetLastEvent updates the last event shown in the preview details.
+func (p *PreviewPanel) SetLastEvent(evt data.SessionEvent) {
+ p.lastEvent = evt
+ p.updateTotalLines()
+}
+
// SetWorkspaceMissing sets whether the current session's working directory is
// missing from disk. When true, the preview shows a Missing workspace badge.
func (p *PreviewPanel) SetWorkspaceMissing(missing bool) {
@@ -654,6 +661,16 @@ func (p PreviewPanel) renderContent() (string, int, int) {
l := styles.PreviewLabelStyle.Render("Status: ")
b.WriteString(l + statusStyle.Render(statusIcon+" "+statusLabel) + "\n")
+ // ── Last Event ──
+ if p.lastEvent.Type != "" {
+ evtLabel := styles.PreviewLabelStyle.Render("Last Event: ")
+ evtValue := p.lastEvent.Type
+ if p.lastEvent.Timestamp != "" {
+ evtValue += " " + styles.DimmedStyle.Render(formatEventTimestamp(p.lastEvent.Timestamp))
+ }
+ b.WriteString(evtLabel + evtValue + "\n")
+ }
+
// ── Plan indicator ──
if p.hasPlan {
planLabel := styles.PreviewLabelStyle.Render("Plan: ")
@@ -1075,3 +1092,8 @@ func attentionStatusDisplay(status data.AttentionStatus) (icon, label string, st
return styles.IconAttentionIdle(), "Idle", styles.PreviewValueStyle
}
}
+
+// formatEventTimestamp returns a relative time string for an event timestamp.
+func formatEventTimestamp(timestamp string) string {
+ return RelativeTime(timestamp)
+}
diff --git a/internal/tui/components/sessionlist.go b/internal/tui/components/sessionlist.go
index 6b4b49d..c6b3e00 100644
--- a/internal/tui/components/sessionlist.go
+++ b/internal/tui/components/sessionlist.go
@@ -785,6 +785,14 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b
return ""
}
+ // Check if this session is waiting for user input.
+ isWaiting := false
+ if s.attentionMap != nil {
+ if status, ok := s.attentionMap[sess.ID]; ok && status == data.AttentionWaiting {
+ isWaiting = true
+ }
+ }
+
summary := CleanSummary(sess.Summary)
if favorited {
summary = "★ " + summary
@@ -890,7 +898,7 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b
if w < 50 {
summaryW := max(10, w-selectorW-dotW-hostDotW-planDotW-noteDotW-tagDotW-wrkDotW-gitDotW-timeW-spacing)
line := indent + selector + attDot + hostDot + plnDot + ntDot + tgDot + wrkDot + gitDot + PadRight(summary, summaryW) + " " + PadLeft(relTime, timeW)
- return s.applyRowStyle(line, selected, hidden, favorited)
+ return s.applyRowStyle(line, selected, hidden, favorited, isWaiting)
}
// Show folder/repo columns at wider terminals, honouring column visibility.
@@ -962,15 +970,18 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b
b.WriteString(PadLeft(turns, turnsW))
}
- return s.applyRowStyle(b.String(), selected, hidden, favorited)
+ return s.applyRowStyle(b.String(), selected, hidden, favorited, isWaiting)
}
// applyRowStyle renders a row line with the appropriate style based on state.
-func (s SessionList) applyRowStyle(line string, selected, hidden, favorited bool) string {
+func (s SessionList) applyRowStyle(line string, selected, hidden, favorited, waiting bool) string {
padded := PadToWidth(line, s.width)
if selected {
return styles.SelectedStyle.Render(padded)
}
+ if waiting {
+ return styles.WaitingRowStyle.Render(padded)
+ }
if hidden {
return styles.HiddenStyle.Render(padded)
}
diff --git a/internal/tui/handlers.go b/internal/tui/handlers.go
index 2808dc1..e3947fb 100644
--- a/internal/tui/handlers.go
+++ b/internal/tui/handlers.go
@@ -302,6 +302,7 @@ func (m Model) handleSessionDetail(msg sessionDetailMsg) (Model, tea.Cmd) {
}
m.preview.SetAlias(m.cfg.AliasFor(m.detail.Session.ID))
m.preview.SetAttentionStatus(m.attentionStatusForSession(m.detail.Session.ID))
+ m.preview.SetLastEvent(data.LastSessionEvent(m.detail.Session.ID))
m.syncPreviewWorkspaceMissing()
m.syncPreviewGitStatus()
m.preview.SetHasPlan(m.planMap[m.detail.Session.ID])
@@ -360,6 +361,7 @@ func (m Model) handleAttentionScanned(msg attentionScannedMsg) (Model, tea.Cmd)
// Update preview panel status if a session is selected.
if m.detail != nil {
m.preview.SetAttentionStatus(m.attentionStatusForSession(m.detail.Session.ID))
+ m.preview.SetLastEvent(data.LastSessionEvent(m.detail.Session.ID))
}
// Always schedule the next periodic scan. When the attention filter
// is active, also reload sessions so the list reflects updated
diff --git a/internal/tui/handlers_eventwatcher.go b/internal/tui/handlers_eventwatcher.go
new file mode 100644
index 0000000..3fcad43
--- /dev/null
+++ b/internal/tui/handlers_eventwatcher.go
@@ -0,0 +1,174 @@
+package tui
+
+import (
+ "fmt"
+ "log/slog"
+ "time"
+
+ tea "charm.land/bubbletea/v2"
+
+ "github.com/jongio/dispatch/internal/data"
+ "github.com/jongio/dispatch/internal/platform"
+)
+
+// ---------------------------------------------------------------------------
+// Event watcher handlers — push-based attention updates and session management
+// ---------------------------------------------------------------------------
+
+// handleEventWatcherUpdate processes a single session status change pushed
+// by the fsnotify-based EventWatcher. It updates the attention map for just
+// the affected session (no full rescan), refreshes the UI, and re-subscribes
+// to the channel for the next event.
+func (m Model) handleEventWatcherUpdate(msg eventWatcherUpdateMsg) (Model, tea.Cmd) {
+ if m.attentionMap == nil {
+ m.attentionMap = make(map[string]data.AttentionStatus)
+ }
+
+ prev := m.attentionMap[msg.sessionID]
+ m.attentionMap[msg.sessionID] = msg.status
+ m.sessionList.SetAttentionStatuses(m.attentionMap)
+
+ // Update preview if the changed session is currently selected.
+ if m.detail != nil && m.detail.Session.ID == msg.sessionID {
+ m.preview.SetAttentionStatus(msg.status)
+ m.preview.SetLastEvent(data.LastSessionEvent(msg.sessionID))
+ }
+
+ var cmds []tea.Cmd
+
+ // Notify if this session just entered the waiting state.
+ if msg.status == data.AttentionWaiting && prev != data.AttentionWaiting {
+ if m.attentionScanned && m.cfg.NotifyOnWaiting {
+ if _, seen := m.waitingNotified[msg.sessionID]; !seen {
+ if m.waitingNotified == nil {
+ m.waitingNotified = make(map[string]struct{})
+ }
+ m.waitingNotified[msg.sessionID] = struct{}{}
+ m.statusInfo = "1 session is waiting"
+ cmds = append(cmds, bellCmd(), clearStatusAfter(4*time.Second))
+ }
+ }
+ } else if msg.status != data.AttentionWaiting {
+ delete(m.waitingNotified, msg.sessionID)
+ }
+
+ // If attention filter is active, reload sessions to reflect the change.
+ if len(m.attentionFilter) > 0 {
+ cmds = append(cmds, m.loadSessionsCmd())
+ }
+
+ // Re-subscribe to the event watcher channel.
+ cmds = append(cmds, m.waitForEventWatcherCmd())
+
+ return m, tea.Batch(cmds...)
+}
+
+// handleNewSessionLaunched processes the result of launching a new session.
+func (m Model) handleNewSessionLaunched(msg newSessionLaunchedMsg) (Model, tea.Cmd) {
+ if msg.err != nil {
+ m.statusInfo = fmt.Sprintf("Launch failed: %s", msg.err)
+ slog.Error("new session launch failed", "error", msg.err, "cwd", msg.cwd)
+ return m, clearStatusAfter(4 * time.Second)
+ }
+ m.statusInfo = "New session launched ✓"
+ return m, clearStatusAfter(3 * time.Second)
+}
+
+// handleFocusWindowResult processes the result of focusing a session's window.
+func (m Model) handleFocusWindowResult(msg focusWindowResultMsg) (Model, tea.Cmd) {
+ if msg.err != nil {
+ m.statusInfo = fmt.Sprintf("Focus failed: %s", msg.err)
+ return m, clearStatusAfter(4 * time.Second)
+ }
+ return m, nil
+}
+
+// launchNewSessionCmd creates a tea.Cmd that launches a new Copilot CLI session
+// in the given working directory using the configured command.
+func (m Model) launchNewSessionCmd(cwd string) tea.Cmd {
+ cfg := m.cfg
+ shell := m.resolvedShell()
+ return func() tea.Msg {
+ launchCfg := platform.LaunchNewSessionConfig{
+ Command: cfg.NewSessionCommand,
+ Cwd: cwd,
+ Terminal: cfg.DefaultTerminal,
+ Shell: shell,
+ LaunchStyle: cfg.EffectiveLaunchMode(),
+ PaneDirection: cfg.PaneDirection,
+ }
+ _, err := platform.LaunchNewSession(launchCfg)
+ return newSessionLaunchedMsg{cwd: cwd, err: err}
+ }
+}
+
+// focusSessionWindowCmd creates a tea.Cmd that brings the terminal window
+// of a tracked session to the foreground.
+func (m Model) focusSessionWindowCmd(sessionID string) tea.Cmd {
+ tracker := m.sessionTracker
+ return func() tea.Msg {
+ ts, ok := tracker.Lookup(sessionID)
+ if !ok {
+ return focusWindowResultMsg{err: fmt.Errorf("no tracked process for session")}
+ }
+ err := platform.FocusSessionWindow(ts.PID)
+ return focusWindowResultMsg{err: err}
+ }
+}
+
+// resolvedShell returns the configured shell or detects the default.
+func (m Model) resolvedShell() platform.ShellInfo {
+ if m.cfg.DefaultShell != "" {
+ shells := platform.DetectShells()
+ for _, s := range shells {
+ if s.Name == m.cfg.DefaultShell {
+ return s
+ }
+ }
+ }
+ return platform.DefaultShell()
+}
+
+// handleNewSessionKey processes the "+" keybinding to launch a new session.
+func (m Model) handleNewSessionKey() (Model, tea.Cmd) {
+ cwd := m.selectedSessionCwd()
+ if cwd == "" {
+ m.statusInfo = "No working directory selected"
+ return m, clearStatusAfter(3 * time.Second)
+ }
+ m.statusInfo = "Launching new session..."
+ return m, m.launchNewSessionCmd(cwd)
+}
+
+// handleFocusWindowKey processes the "W" keybinding to focus a session's window.
+func (m Model) handleFocusWindowKey() (Model, tea.Cmd) {
+ sessionID := m.selectedSessionID()
+ if sessionID == "" {
+ m.statusInfo = "No session selected"
+ return m, clearStatusAfter(3 * time.Second)
+ }
+
+ // Check if this session has a live attention status (is running).
+ status := m.attentionStatusForSession(sessionID)
+ if status == data.AttentionIdle {
+ m.statusInfo = "Session is not running"
+ return m, clearStatusAfter(3 * time.Second)
+ }
+
+ // Try the session tracker first (dispatch-launched sessions).
+ if m.sessionTracker.HasLive(sessionID) {
+ return m, m.focusSessionWindowCmd(sessionID)
+ }
+
+ // Fallback: try to find the PID from the lock file.
+ pid := data.FindSessionPID(sessionID)
+ if pid <= 0 {
+ m.statusInfo = "Cannot locate session window"
+ return m, clearStatusAfter(3 * time.Second)
+ }
+
+ return m, func() tea.Msg {
+ err := platform.FocusSessionWindow(pid)
+ return focusWindowResultMsg{err: err}
+ }
+}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 8cd9eb8..c6b7a75 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -74,18 +74,20 @@ type keyMap struct {
Compare key.Binding
GitStatus key.Binding
CmdPalette key.Binding
+ NewSession key.Binding
+ FocusWindow key.Binding
}
// ShortHelp returns a compact set of key bindings for the mini help bar.
func (k keyMap) ShortHelp() []key.Binding {
- return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.Search, k.Filter, k.Sort, k.Preview, k.PreviewFullscreen, k.ViewPlan, k.Timeline, k.Compare, k.GitStatus, k.Hide, k.Star, k.Note, k.Tags, k.Alias, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit}
+ return []key.Binding{k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane, k.LaunchAll, k.NewSession, k.FocusWindow, k.Search, k.Filter, k.Sort, k.Preview, k.PreviewFullscreen, k.ViewPlan, k.Timeline, k.Compare, k.GitStatus, k.Hide, k.Star, k.Note, k.Tags, k.Alias, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, k.JumpNextAttention, k.FilterAttention, k.ResumeInterrupted, k.ScanWorkStatus, k.ExpandCollapseAll, k.ViewSwitch, k.CmdPalette, k.Config, k.Help, k.Quit}
}
// FullHelp returns grouped key bindings for the expanded help view.
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.JumpTop, k.JumpBottom, k.Left, k.Right, k.Enter, k.LaunchWindow, k.LaunchTab, k.LaunchPane},
- {k.Space, k.LaunchAll, k.SelectAll, k.DeselectAll, k.ShiftUp, k.ShiftDown},
+ {k.Space, k.LaunchAll, k.SelectAll, k.DeselectAll, k.ShiftUp, k.ShiftDown, k.NewSession, k.FocusWindow},
{k.Search, k.Escape, k.Filter},
{k.Sort, k.SortOrder, k.Pivot, k.PivotOrder, k.ExpandCollapseAll},
{k.Preview, k.PreviewFullscreen, k.PreviewPosition, k.PreviewScrollUp, k.PreviewScrollDown, k.ConversationSort, k.ViewPlan, k.Timeline, k.Compare, k.GitStatus, k.CopyID, k.CopyPath, k.CopyResumeCommand, k.CopyPreview, k.Export, k.OpenFile, k.OpenDir, k.OpenRef, k.Reindex, k.ScanWorkStatus, k.ViewSwitch, k.Config},
@@ -164,6 +166,8 @@ func defaultKeyMap() keyMap {
Compare: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "compare selected")),
GitStatus: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "git status")),
CmdPalette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "command palette")),
+ NewSession: key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "new session")),
+ FocusWindow: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "focus window")),
}
}
@@ -242,6 +246,8 @@ func keybindingEntries(km *keyMap) []keybindingEntry {
{"compare", &km.Compare},
{"git_status", &km.GitStatus},
{"cmd_palette", &km.CmdPalette},
+ {"new_session", &km.NewSession},
+ {"focus_window", &km.FocusWindow},
}
}
diff --git a/internal/tui/messages.go b/internal/tui/messages.go
index 4003b21..6df6a44 100644
--- a/internal/tui/messages.go
+++ b/internal/tui/messages.go
@@ -171,6 +171,30 @@ type exportDoneMsg struct {
err error
}
+// ---------------------------------------------------------------------------
+// Event watcher messages (push-based attention updates)
+// ---------------------------------------------------------------------------
+
+// eventWatcherUpdateMsg is pushed by the fsnotify-based EventWatcher when a
+// single session's attention status changes. This enables near-instant UI
+// updates without waiting for the 30-second polling interval.
+type eventWatcherUpdateMsg struct {
+ sessionID string
+ status data.AttentionStatus
+}
+
+// newSessionLaunchedMsg reports that a new session launch command completed.
+type newSessionLaunchedMsg struct {
+ cwd string
+ err error
+}
+
+// focusWindowResultMsg reports the result of attempting to focus a session's
+// terminal window.
+type focusWindowResultMsg struct {
+ err error
+}
+
// ---------------------------------------------------------------------------
// Git workspace state messages
// ---------------------------------------------------------------------------
diff --git a/internal/tui/model.go b/internal/tui/model.go
index 1350797..501590c 100644
--- a/internal/tui/model.go
+++ b/internal/tui/model.go
@@ -401,6 +401,11 @@ type Model struct {
// DB watcher — monitors session-store.db for external modifications.
dbWatcher *data.DBWatcher
dbWatchCh chan struct{} // receives pings from the watcher callback
+
+ // Event watcher — push-based fsnotify watcher for session-state changes.
+ eventWatcher *data.EventWatcher
+ eventWatchCh chan eventWatcherUpdateMsg // receives push updates from the watcher
+ sessionTracker *data.SessionTracker // tracks PIDs of dispatch-launched sessions
}
// NewModel creates the root Model with default configuration.
@@ -429,24 +434,25 @@ func NewModel() Model {
cp := components.NewConfigPanel()
cp.SetValues(components.ConfigValues{
- YoloMode: cfg.YoloMode,
- Agent: cfg.Agent,
- Model: cfg.Model,
- LaunchMode: cfg.EffectiveLaunchMode(),
- Terminal: cfg.DefaultTerminal,
- Shell: cfg.DefaultShell,
- CustomCommand: cfg.CustomCommand,
- Theme: cfg.Theme,
- WorkspaceRecovery: cfg.WorkspaceRecovery,
- PreviewPosition: cfg.EffectivePreviewPosition(),
- RedactSecrets: cfg.RedactPreviewSecrets,
- ExcludedWords: strings.Join(cfg.ExcludedWords, ", "),
- AutoRefresh: autoRefreshFieldValue(cfg.AutoRefreshSeconds),
- NotifyOnWaiting: cfg.NotifyOnWaiting,
- ShowRepoColumn: cfg.ColumnVisible(config.ColumnRepo),
- ShowFolderColumn: cfg.ColumnVisible(config.ColumnFolder),
- ShowTurnsColumn: cfg.ColumnVisible(config.ColumnTurns),
- ShowHostColumn: cfg.ColumnVisible(config.ColumnHost),
+ YoloMode: cfg.YoloMode,
+ Agent: cfg.Agent,
+ Model: cfg.Model,
+ LaunchMode: cfg.EffectiveLaunchMode(),
+ Terminal: cfg.DefaultTerminal,
+ Shell: cfg.DefaultShell,
+ ResumeSessionCommand: cfg.ResumeSessionCommand,
+ NewSessionCommand: cfg.NewSessionCommand,
+ Theme: cfg.Theme,
+ WorkspaceRecovery: cfg.WorkspaceRecovery,
+ PreviewPosition: cfg.EffectivePreviewPosition(),
+ RedactSecrets: cfg.RedactPreviewSecrets,
+ ExcludedWords: strings.Join(cfg.ExcludedWords, ", "),
+ AutoRefresh: autoRefreshFieldValue(cfg.AutoRefreshSeconds),
+ NotifyOnWaiting: cfg.NotifyOnWaiting,
+ ShowRepoColumn: cfg.ColumnVisible(config.ColumnRepo),
+ ShowFolderColumn: cfg.ColumnVisible(config.ColumnFolder),
+ ShowTurnsColumn: cfg.ColumnVisible(config.ColumnTurns),
+ ShowHostColumn: cfg.ColumnVisible(config.ColumnHost),
})
// Build the list of available theme names for the config panel.
@@ -532,6 +538,19 @@ func NewModel() Model {
m.dbWatcher.SetActive(true)
}
+ // Event watcher — push-based attention updates via fsnotify.
+ m.eventWatchCh = make(chan eventWatcherUpdateMsg, 16)
+ m.sessionTracker = data.NewSessionTracker()
+ m.eventWatcher = data.NewEventWatcher(func(id string, status data.AttentionStatus) {
+ select {
+ case m.eventWatchCh <- eventWatcherUpdateMsg{sessionID: id, status: status}:
+ default:
+ }
+ }, cfg.EffectiveAttentionThreshold(), cfg.WorkspaceRecovery)
+ if err := m.eventWatcher.Start(); err != nil {
+ slog.Debug("eventwatcher: failed to start", "error", err)
+ }
+
m.filter.Since = timeRangeToSince(m.timeRange)
m.filter.ExcludedDirs = cfg.ExcludedDirs
m.filter.ExcludedWords = cfg.ExcludedWords
@@ -625,6 +644,7 @@ func (m Model) Init() tea.Cmd {
m.spinner.Tick,
tea.RequestBackgroundColor,
m.waitForDBChangeCmd(),
+ m.waitForEventWatcherCmd(),
)
}
@@ -661,6 +681,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case sessionsChangedMsg:
return m.handleSessionsChanged()
+ // ----- Event watcher push updates ------------------------------------
+ case eventWatcherUpdateMsg:
+ return m.handleEventWatcherUpdate(msg)
+
+ case newSessionLaunchedMsg:
+ return m.handleNewSessionLaunched(msg)
+
+ case focusWindowResultMsg:
+ return m.handleFocusWindowResult(msg)
+
// ----- Export done -----------------------------------------------------
case exportDoneMsg:
return m.handleExportDone(msg)
@@ -1313,25 +1343,26 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
case key.Matches(msg, keys.Config):
m.configPanel.SetValues(components.ConfigValues{
- YoloMode: m.cfg.YoloMode,
- Agent: m.cfg.Agent,
- Model: m.cfg.Model,
- LaunchMode: m.cfg.EffectiveLaunchMode(),
- PaneDirection: m.cfg.EffectivePaneDirection(),
- Terminal: m.cfg.DefaultTerminal,
- Shell: m.cfg.DefaultShell,
- CustomCommand: m.cfg.CustomCommand,
- Theme: m.cfg.Theme,
- WorkspaceRecovery: m.cfg.WorkspaceRecovery,
- PreviewPosition: m.cfg.EffectivePreviewPosition(),
- RedactSecrets: m.cfg.RedactPreviewSecrets,
- ExcludedWords: strings.Join(m.cfg.ExcludedWords, ", "),
- AutoRefresh: autoRefreshFieldValue(m.cfg.AutoRefreshSeconds),
- NotifyOnWaiting: m.cfg.NotifyOnWaiting,
- ShowRepoColumn: m.cfg.ColumnVisible(config.ColumnRepo),
- ShowFolderColumn: m.cfg.ColumnVisible(config.ColumnFolder),
- ShowTurnsColumn: m.cfg.ColumnVisible(config.ColumnTurns),
- ShowHostColumn: m.cfg.ColumnVisible(config.ColumnHost),
+ YoloMode: m.cfg.YoloMode,
+ Agent: m.cfg.Agent,
+ Model: m.cfg.Model,
+ LaunchMode: m.cfg.EffectiveLaunchMode(),
+ PaneDirection: m.cfg.EffectivePaneDirection(),
+ Terminal: m.cfg.DefaultTerminal,
+ Shell: m.cfg.DefaultShell,
+ ResumeSessionCommand: m.cfg.ResumeSessionCommand,
+ NewSessionCommand: m.cfg.NewSessionCommand,
+ Theme: m.cfg.Theme,
+ WorkspaceRecovery: m.cfg.WorkspaceRecovery,
+ PreviewPosition: m.cfg.EffectivePreviewPosition(),
+ RedactSecrets: m.cfg.RedactPreviewSecrets,
+ ExcludedWords: strings.Join(m.cfg.ExcludedWords, ", "),
+ AutoRefresh: autoRefreshFieldValue(m.cfg.AutoRefreshSeconds),
+ NotifyOnWaiting: m.cfg.NotifyOnWaiting,
+ ShowRepoColumn: m.cfg.ColumnVisible(config.ColumnRepo),
+ ShowFolderColumn: m.cfg.ColumnVisible(config.ColumnFolder),
+ ShowTurnsColumn: m.cfg.ColumnVisible(config.ColumnTurns),
+ ShowHostColumn: m.cfg.ColumnVisible(config.ColumnHost),
})
m.state = stateConfigPanel
return m, nil
@@ -1650,6 +1681,12 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
cmd := m.launchMultiple()
return m, cmd
+ case key.Matches(msg, keys.NewSession):
+ return m.handleNewSessionKey()
+
+ case key.Matches(msg, keys.FocusWindow):
+ return m.handleFocusWindowKey()
+
case key.Matches(msg, keys.ResumeInterrupted):
return m.handleResumeInterrupted()
@@ -2223,7 +2260,8 @@ func (m *Model) saveConfigFromPanel() {
m.cfg.PaneDirection = v.PaneDirection
m.cfg.DefaultTerminal = v.Terminal
m.cfg.DefaultShell = v.Shell
- m.cfg.CustomCommand = v.CustomCommand
+ m.cfg.ResumeSessionCommand = v.ResumeSessionCommand
+ m.cfg.NewSessionCommand = v.NewSessionCommand
m.cfg.Theme = v.Theme
m.cfg.WorkspaceRecovery = v.WorkspaceRecovery
m.cfg.PreviewPosition = v.PreviewPosition
@@ -3862,13 +3900,13 @@ func (m *Model) launchExternal(shell platform.ShellInfo, sessionID, cwd, launchS
func (m Model) resumeConfigForSession(cwd string) platform.ResumeConfig {
return platform.ResumeConfig{
- YoloMode: m.cfg.YoloMode,
- Agent: m.cfg.Agent,
- Model: m.cfg.Model,
- Terminal: m.cfg.DefaultTerminal,
- CustomCommand: m.cfg.CustomCommand,
- Cwd: cwd,
- PaneDirection: m.cfg.EffectivePaneDirection(),
+ YoloMode: m.cfg.YoloMode,
+ Agent: m.cfg.Agent,
+ Model: m.cfg.Model,
+ Terminal: m.cfg.DefaultTerminal,
+ ResumeSessionCommand: m.cfg.ResumeSessionCommand,
+ Cwd: cwd,
+ PaneDirection: m.cfg.EffectivePaneDirection(),
}
}
@@ -3920,6 +3958,13 @@ func (m *Model) closeStore() {
close(m.dbWatchCh)
m.dbWatchCh = nil
}
+ if m.eventWatcher != nil {
+ m.eventWatcher.Stop()
+ }
+ if m.eventWatchCh != nil {
+ close(m.eventWatchCh)
+ m.eventWatchCh = nil
+ }
if m.copilotClient != nil {
m.copilotClient.Close()
m.copilotClient = nil
@@ -3960,6 +4005,23 @@ func (m Model) waitForDBChangeCmd() tea.Cmd {
}
}
+// waitForEventWatcherCmd blocks on the eventWatchCh channel until the
+// fsnotify-based EventWatcher pushes an update, then returns the message
+// to the TUI for processing.
+func (m Model) waitForEventWatcherCmd() tea.Cmd {
+ ch := m.eventWatchCh
+ if ch == nil {
+ return nil
+ }
+ return func() tea.Msg {
+ msg, ok := <-ch
+ if !ok {
+ return nil
+ }
+ return msg
+ }
+}
+
func clearStatusAfter(d time.Duration) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg {
return clearStatusMsg{}
@@ -4023,8 +4085,9 @@ func (m Model) loadSessionsCmd() tea.Cmd {
}
// attentionRefreshInterval controls how often the attention scanner polls
-// the session-state directories.
-const attentionRefreshInterval = 30 * time.Second
+// the session-state directories as a fallback when the EventWatcher misses
+// events. The primary mechanism is push-based via fsnotify.
+const attentionRefreshInterval = 120 * time.Second
// scanAttentionQuickCmd runs a fast first-pass scan that only checks lock
// files for live sessions. Dead sessions get AttentionIdle without reading
diff --git a/internal/tui/model_update_test.go b/internal/tui/model_update_test.go
index af07005..7bbb4e5 100644
--- a/internal/tui/model_update_test.go
+++ b/internal/tui/model_update_test.go
@@ -1299,14 +1299,14 @@ func TestSaveConfigFromPanel(t *testing.T) {
m := newTestModel()
m.configPanel = components.NewConfigPanel()
m.configPanel.SetValues(components.ConfigValues{
- YoloMode: true,
- Agent: "test-agent",
- Model: "test-model",
- LaunchMode: config.LaunchModeInPlace,
- Terminal: "wt",
- Shell: "bash",
- CustomCommand: "my-cmd",
- Theme: "dark",
+ YoloMode: true,
+ Agent: "test-agent",
+ Model: "test-model",
+ LaunchMode: config.LaunchModeInPlace,
+ Terminal: "wt",
+ Shell: "bash",
+ ResumeSessionCommand: "my-cmd",
+ Theme: "dark",
})
m.saveConfigFromPanel()
if !m.cfg.YoloMode {
@@ -1330,8 +1330,8 @@ func TestSaveConfigFromPanel(t *testing.T) {
if m.cfg.DefaultShell != "bash" {
t.Errorf("DefaultShell = %q, want 'bash'", m.cfg.DefaultShell)
}
- if m.cfg.CustomCommand != "my-cmd" {
- t.Errorf("CustomCommand = %q, want 'my-cmd'", m.cfg.CustomCommand)
+ if m.cfg.ResumeSessionCommand != "my-cmd" {
+ t.Errorf("ResumeSessionCommand = %q, want 'my-cmd'", m.cfg.ResumeSessionCommand)
}
if m.cfg.Theme != "dark" {
t.Errorf("Theme = %q, want 'dark'", m.cfg.Theme)
@@ -2321,7 +2321,7 @@ func TestHandleKey_CopyResumeCommand_Success(t *testing.T) {
t.Cleanup(func() { clipboardWrite = orig })
m := newTestModel()
- m.cfg.CustomCommand = "copilot --resume {sessionId} --agent test-agent"
+ m.cfg.ResumeSessionCommand = "copilot --resume {sessionId} --agent test-agent"
m.sessionList.SetSessions([]data.Session{{ID: "abc-123", Cwd: "/a"}})
result, cmd := m.Update(runeKeyMsg('Y'))
@@ -2361,7 +2361,7 @@ func TestHandleKey_CopyResumeCommand_BuildError(t *testing.T) {
t.Cleanup(func() { clipboardWrite = orig })
m := newTestModel()
- m.cfg.CustomCommand = "bad\ncommand {sessionId}"
+ m.cfg.ResumeSessionCommand = "bad\ncommand {sessionId}"
m.sessionList.SetSessions([]data.Session{{ID: "abc-123", Cwd: "/a"}})
result, cmd := m.Update(runeKeyMsg('Y'))
@@ -2382,7 +2382,7 @@ func TestHandleKey_CopyResumeCommand_ClipboardError(t *testing.T) {
t.Cleanup(func() { clipboardWrite = orig })
m := newTestModel()
- m.cfg.CustomCommand = "copilot --resume {sessionId}"
+ m.cfg.ResumeSessionCommand = "copilot --resume {sessionId}"
m.sessionList.SetSessions([]data.Session{{ID: "abc-123", Cwd: "/a"}})
result, cmd := m.Update(runeKeyMsg('Y'))
@@ -2408,7 +2408,7 @@ func TestHandleKey_CopyResumeCommand_MultiSelect(t *testing.T) {
t.Cleanup(func() { clipboardWrite = orig })
m := newTestModel()
- m.cfg.CustomCommand = "copilot --resume {sessionId}"
+ m.cfg.ResumeSessionCommand = "copilot --resume {sessionId}"
m.sessionList.SetSessions([]data.Session{
{ID: "s1", Cwd: "/a"},
{ID: "s2", Cwd: "/b"},
diff --git a/internal/tui/styles/scheme.go b/internal/tui/styles/scheme.go
index 9b390d1..d0114e9 100644
--- a/internal/tui/styles/scheme.go
+++ b/internal/tui/styles/scheme.go
@@ -53,6 +53,7 @@ type Theme struct {
DimmedStyle lipgloss.Style
HiddenStyle lipgloss.Style
FavoritedStyle lipgloss.Style
+ WaitingRowStyle lipgloss.Style
GroupHeaderStyle lipgloss.Style
BadgeStyle lipgloss.Style
ActiveBadgeStyle lipgloss.Style
@@ -191,6 +192,7 @@ func (t *Theme) buildStyles() {
t.DimmedStyle = lipgloss.NewStyle().Foreground(c(t.Dimmed))
t.HiddenStyle = lipgloss.NewStyle().Foreground(c(t.Dimmed)).Faint(true)
t.FavoritedStyle = lipgloss.NewStyle().Foreground(c(t.Primary)).Bold(true)
+ t.WaitingRowStyle = lipgloss.NewStyle().Foreground(c(t.Primary)).Bold(true).Background(c(t.Selected))
t.GroupHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(c(t.Primary))
// Filter badges.
@@ -250,7 +252,7 @@ func (t *Theme) buildStyles() {
Foreground(c(t.Dimmed)).Bold(true)
// Attention dot styles — colored dots for session attention status.
- t.AttentionWaitingStyle = lipgloss.NewStyle().Foreground(c(t.Primary)).Bold(true)
+ t.AttentionWaitingStyle = lipgloss.NewStyle().Foreground(c(t.Primary)).Bold(true).Blink(true)
t.AttentionActiveStyle = lipgloss.NewStyle().Foreground(c(t.Success))
t.AttentionStaleStyle = lipgloss.NewStyle().Foreground(c(t.ANSIPalette[3])) // Yellow
t.AttentionIdleStyle = lipgloss.NewStyle().Foreground(c(t.Dimmed)).Faint(true)
diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go
index ea3fdc9..8940d77 100644
--- a/internal/tui/styles/theme.go
+++ b/internal/tui/styles/theme.go
@@ -57,6 +57,7 @@ func SetTheme(t *Theme) {
DimmedStyle = t.DimmedStyle
HiddenStyle = t.HiddenStyle
FavoritedStyle = t.FavoritedStyle
+ WaitingRowStyle = t.WaitingRowStyle
GroupHeaderStyle = t.GroupHeaderStyle
BadgeStyle = t.BadgeStyle
@@ -163,6 +164,9 @@ var (
// FavoritedStyle renders sessions the user has starred as favorites.
FavoritedStyle lipgloss.Style
+ // WaitingRowStyle renders sessions that are waiting for user input.
+ WaitingRowStyle lipgloss.Style
+
// GroupHeaderStyle renders collapsible folder/group headers in tree view.
GroupHeaderStyle lipgloss.Style
@@ -326,6 +330,7 @@ func applyLegacyDefaults(isDark bool) {
DimmedStyle = lipgloss.NewStyle().Foreground(dimmed)
HiddenStyle = lipgloss.NewStyle().Foreground(dimmed).Faint(true)
FavoritedStyle = lipgloss.NewStyle().Foreground(lp).Bold(true)
+ WaitingRowStyle = lipgloss.NewStyle().Foreground(lp).Bold(true).Background(ls)
GroupHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lp)
BadgeStyle = lipgloss.NewStyle().Foreground(lbdg).Background(lbbg).Padding(0, 1)
@@ -373,7 +378,7 @@ func applyLegacyDefaults(isDark bool) {
ChatAssistantLabelStyle = lipgloss.NewStyle().Foreground(dimmed).Bold(true)
// Attention dot styles — legacy adaptive defaults.
- AttentionWaitingStyle = lipgloss.NewStyle().Foreground(lp).Bold(true)
+ AttentionWaitingStyle = lipgloss.NewStyle().Foreground(lp).Bold(true).Blink(true)
AttentionActiveStyle = lipgloss.NewStyle().Foreground(lk)
AttentionStaleStyle = lipgloss.NewStyle().Foreground(lightDark(c("#C19C00"), c("#C19C00")))
AttentionIdleStyle = lipgloss.NewStyle().Foreground(dimmed).Faint(true)
diff --git a/web/src/pages/config.astro b/web/src/pages/config.astro
index 9d5da37..dde6c7c 100644
--- a/web/src/pages/config.astro
+++ b/web/src/pages/config.astro
@@ -43,7 +43,8 @@ import ConfigTable from '../components/ConfigTable.astro';
{ key: 'model', type: 'string', default: '""', description: 'Pass --model to Copilot CLI.' },
{ key: 'launch_mode', type: 'string', default: '"tab"', description: 'How to open sessions: in-place, tab, window, pane.' },
{ key: 'pane_direction', type: 'string', default: '"auto"', description: 'Split direction for pane mode: auto, right, down, left, up (see Pane Direction Semantics below).' },
- { key: 'custom_command', type: 'string', default: '""', description: 'Custom launch command. {sessionId} is replaced with the session ID.' },
+ { key: 'resume_session_command', type: 'string', default: '""', description: 'Resume session command ({sessionId} is replaced). Defaults to copilot --resume.' },
+ { key: 'new_session_command', type: 'string', default: '""', description: 'Command to launch new sessions ({cwd} is replaced). Defaults to copilot.' },
{ key: 'excluded_dirs', type: 'array', default: '[]', description: 'Directory paths to hide from the session list.' },
{ key: 'excluded_words', type: 'array', default: '[]', description: 'Words to filter sessions by. Sessions whose name or turn content contains any word (case-insensitive) are hidden.' },
{ key: 'attention_threshold', type: 'string', default: '"15m"', description: 'Duration after which an inactive running session is marked stale instead of waiting/active.' },
@@ -76,7 +77,7 @@ import ConfigTable from '../components/ConfigTable.astro';
"model": "",
"launch_mode": "tab",
"pane_direction": "auto",
- "custom_command": "",
+ "resume_session_command": "",
"excluded_dirs": [],
"excluded_words": [],
"favoriteSessions": [],
@@ -121,10 +122,10 @@ import ConfigTable from '../components/ConfigTable.astro';
-
Custom Command
-
Set custom_command to replace the default Copilot CLI launch command entirely. Use {'{sessionId}'} as a placeholder for the session ID.
-
When a custom command is set, the Agent, Model, and Yolo Mode fields are ignored (dimmed in the settings panel).
-
"custom_command": "my-tool resume {'{sessionId}'}"
+
Resume Session Command
+
Set resume_session_command to replace the default Copilot CLI launch command entirely. Use {'{sessionId}'} as a placeholder for the session ID.
+
When a Resume Session Command is set, the Agent, Model, and Yolo Mode fields are ignored (dimmed in the settings panel).
+
"resume_session_command": "my-tool resume {'{sessionId}'}"
diff --git a/web/src/pages/features.astro b/web/src/pages/features.astro
index cb392f7..84e82d2 100644
--- a/web/src/pages/features.astro
+++ b/web/src/pages/features.astro
@@ -444,14 +444,14 @@ import Carousel from '../components/Carousel.astro';
toggle), Agent (text), Model (text), Launch Mode (selector:
in-place/tab/window/pane), Pane Direction (selector: auto/right/down/left/up,
dimmed when not in pane mode), Terminal (selector), Shell (selector),
- Custom Command (text with {"{sessionId}"} placeholder),
+ Resume Session Command (text with {"{sessionId}"} placeholder),
Theme (selector), Crash Recovery (boolean toggle), Preview Position
(selector: right/bottom/left/top), and Excluded Words (text,
comma-separated).
- Enter to edit or toggle a field
- - Agent and Model fields dim when Custom Command is set
+ - Agent and Model fields dim when Resume Session Command is set
- Changes saved automatically on Esc