From 29e0f29b72624d7a89b371bb1049f7bc5166e025 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Mon, 20 Jul 2026 12:30:01 -0700 Subject: [PATCH 1/2] feat: live activity push watcher, new session command, resume rename - Add fsnotify-based EventWatcher for push session updates (replaces polling) - Add SessionTracker for PID tracking of launched sessions - Add focus window support (Win32 SetForegroundWindow) - Add new session launcher with + keybinding - Add W keybinding to focus selected session terminal window - Rename custom_command to resume_session_command with v1->v2 migration - Add new_session_command config field and settings panel entry - Add blinking dot and highlighted row for waiting sessions - Show last event type and timestamp in session detail pane - Update all docs (README, SECURITY, config.astro, features.astro) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 37403417-09bc-4600-8056-fbb5a2fb18bb --- README.md | 22 +- SECURITY.md | 2 +- cmd/dispatch/config.go | 2 +- cmd/dispatch/new.go | 4 +- cmd/dispatch/open.go | 4 +- docs/specs/live-activity/spec.md | 63 ++++ docs/specs/live-activity/test-plan.md | 30 ++ go.mod | 1 + go.sum | 2 + internal/config/config.go | 28 +- internal/config/config_security_test.go | 8 +- internal/config/config_test.go | 26 +- internal/config/coverage_test.go | 2 +- internal/data/attention.go | 34 +++ internal/data/eventwatcher.go | 251 +++++++++++++++ internal/data/eventwatcher_test.go | 285 ++++++++++++++++++ internal/data/sessiontrack.go | 87 ++++++ internal/data/sessiontrack_test.go | 120 ++++++++ internal/platform/coverage_test.go | 20 +- internal/platform/focus_other.go | 12 + internal/platform/focus_windows.go | 139 +++++++++ internal/platform/newsession.go | 85 ++++++ internal/platform/platform_additional_test.go | 38 +-- internal/platform/platform_coverage_test.go | 42 +-- internal/platform/platform_security_test.go | 32 +- internal/platform/shell.go | 48 +-- internal/platform/shell_coverage_test.go | 12 +- internal/platform/shell_test.go | 44 +-- internal/tui/cmdpalette.go | 2 +- internal/tui/components/configpanel.go | 77 +++-- internal/tui/components/configpanel_test.go | 22 +- internal/tui/components/preview.go | 22 ++ internal/tui/components/sessionlist.go | 17 +- internal/tui/handlers.go | 2 + internal/tui/handlers_eventwatcher.go | 174 +++++++++++ internal/tui/keys.go | 10 +- internal/tui/messages.go | 24 ++ internal/tui/model.go | 75 ++++- internal/tui/model_update_test.go | 14 +- internal/tui/styles/scheme.go | 4 +- internal/tui/styles/theme.go | 7 +- web/src/pages/config.astro | 12 +- web/src/pages/features.astro | 4 +- 43 files changed, 1688 insertions(+), 221 deletions(-) create mode 100644 docs/specs/live-activity/spec.md create mode 100644 docs/specs/live-activity/test-plan.md create mode 100644 internal/data/eventwatcher.go create mode 100644 internal/data/eventwatcher_test.go create mode 100644 internal/data/sessiontrack.go create mode 100644 internal/data/sessiontrack_test.go create mode 100644 internal/platform/focus_other.go create mode 100644 internal/platform/focus_windows.go create mode 100644 internal/platform/newsession.go create mode 100644 internal/tui/handlers_eventwatcher.go diff --git a/README.md b/README.md index a8bd1e06..14803189 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) | +| `new_session_command` | string | `""` | Command to launch new sessions (`{cwd}` is replaced). Defaults to `gh 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 `gh copilot`. + +```json +"new_session_command": "gh copilot --agent workspace" ``` ### Customizing Keybindings diff --git a/SECURITY.md b/SECURITY.md index f4de9f7b..635e4798 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 99d0e8a1..b455e117 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 4b5da383..b61a02a6 100644 --- a/cmd/dispatch/new.go +++ b/cmd/dispatch/new.go @@ -139,7 +139,7 @@ func defaultNewLaunch(w io.Writer, cfg *config.Config, dir string, mode string) YoloMode: cfg.YoloMode, Agent: cfg.Agent, Model: cfg.Model, - CustomCommand: cfg.CustomCommand, + ResumeSessionCommand: cfg.ResumeSessionCommand, Cwd: dir, } cmd, err := platform.NewResumeCmd("", rc) @@ -161,7 +161,7 @@ func defaultNewLaunch(w io.Writer, cfg *config.Config, dir string, mode string) Agent: cfg.Agent, Model: cfg.Model, Terminal: cfg.DefaultTerminal, - CustomCommand: cfg.CustomCommand, + ResumeSessionCommand: cfg.ResumeSessionCommand, Cwd: dir, LaunchStyle: launchStyleForOpenMode(mode), PaneDirection: cfg.EffectivePaneDirection(), diff --git a/cmd/dispatch/open.go b/cmd/dispatch/open.go index d2d3d88d..017242c6 100644 --- a/cmd/dispatch/open.go +++ b/cmd/dispatch/open.go @@ -215,7 +215,7 @@ func openResumeConfig(cfg *config.Config, sess *data.Session) platform.ResumeCon YoloMode: cfg.YoloMode, Agent: cfg.Agent, Model: cfg.Model, - CustomCommand: cfg.CustomCommand, + ResumeSessionCommand: cfg.ResumeSessionCommand, Cwd: sess.Cwd, } } @@ -475,7 +475,7 @@ func defaultOpenLaunch(w io.Writer, cfg *config.Config, sess *data.Session, mode Agent: cfg.Agent, Model: cfg.Model, Terminal: cfg.DefaultTerminal, - CustomCommand: cfg.CustomCommand, + ResumeSessionCommand: cfg.ResumeSessionCommand, Cwd: sess.Cwd, LaunchStyle: launchStyleForOpenMode(mode), PaneDirection: cfg.EffectivePaneDirection(), diff --git a/docs/specs/live-activity/spec.md b/docs/specs/live-activity/spec.md new file mode 100644 index 00000000..d7ab0f11 --- /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: `"gh copilot"` or whatever `copilot` resolves to). 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 00000000..cc4b950b --- /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 a4701ca0..4138a0f3 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 7108a64e..08c93fea 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 dab05963..37248ea4 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 "gh copilot". + // Examples: "gh copilot", "copilot", "gh cs new" + 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 ceb52a2f..dd734692 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 59388085..89332760 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) @@ -90,7 +90,7 @@ func TestConfigJSONRoundTrip(t *testing.T) { Model: "gpt-4", LaunchInPlace: true, ExcludedDirs: []string{"/tmp", "/var"}, - CustomCommand: "ghcs --resume {sessionId} --custom", + ResumeSessionCommand: "ghcs --resume {sessionId} --custom", HiddenSessions: []string{"sess-1", "sess-2"}, PreviewPosition: "bottom", } @@ -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)) @@ -315,7 +315,7 @@ func TestSaveAndLoad(t *testing.T) { Model: "claude-3", LaunchInPlace: true, ExcludedDirs: []string{"/opt/scratch"}, - CustomCommand: "my-cli --resume {sessionId}", + ResumeSessionCommand: "my-cli --resume {sessionId}", HiddenSessions: []string{"hidden-1"}, } @@ -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) } } @@ -547,7 +547,7 @@ func TestSaveAndLoadPreservesAllFields(t *testing.T) { Model: "o1-preview", LaunchInPlace: true, ExcludedDirs: []string{"/a", "/b", "/c"}, - CustomCommand: "custom-cli {sessionId} --flag", + ResumeSessionCommand: "custom-cli {sessionId} --flag", HiddenSessions: []string{"h1", "h2", "h3"}, } @@ -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 dd1ae0b4..51efd82a 100644 --- a/internal/config/coverage_test.go +++ b/internal/config/coverage_test.go @@ -114,7 +114,7 @@ func TestConfigJSONRoundTrip_AllFields(t *testing.T) { LaunchMode: "window", LaunchInPlace: true, ExcludedDirs: []string{"/tmp"}, - CustomCommand: "my-cli {sessionId}", + ResumeSessionCommand: "my-cli {sessionId}", HiddenSessions: []string{"sess1", "sess2"}, Theme: "One Half Dark", } diff --git a/internal/data/attention.go b/internal/data/attention.go index 0244df7c..a3bd279a 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 00000000..8cc661e8 --- /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 00000000..195cfc26 --- /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(100 * 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(300 * 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(100 * 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(100 * 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(100 * 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(100 * 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(300 * 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 00000000..cbe1b961 --- /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 00000000..033e9495 --- /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 d8412215..c358e4f9 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 00000000..28cadd8c --- /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 00000000..12257f91 --- /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 00000000..0e752394 --- /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 = "gh 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 fdb713e5..2175b54f 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,7 +315,7 @@ func TestSessionStorePath_DispatchDBOverride(t *testing.T) { func TestBuildResumeCommandString_WithCwd(t *testing.T) { cfg := ResumeConfig{ - CustomCommand: "my-tool {sessionId}", + ResumeSessionCommand: "my-tool {sessionId}", Cwd: t.TempDir(), } result, err := buildResumeCommandString("abc123", cfg) @@ -331,10 +331,10 @@ 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}", + ResumeSessionCommand: "echo {sessionId}", Cwd: cwd, }) if err != nil { @@ -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,7 +578,7 @@ func TestBuildResumeCommandString_ArgsWithSpaces(t *testing.T) { func TestBuildResumeCommandString_WithModel(t *testing.T) { cfg := ResumeConfig{ - CustomCommand: "tool", + ResumeSessionCommand: "tool", Cwd: t.TempDir(), } result, err := buildResumeCommandString("abc123", cfg) diff --git a/internal/platform/platform_coverage_test.go b/internal/platform/platform_coverage_test.go index 472ebd4a..58cdc90d 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,7 +484,7 @@ func TestCovNewResumeCmd_EmptySessionCustomCmd(t *testing.T) { func TestCovNewResumeCmd_CustomCmdWithCwd(t *testing.T) { dir := t.TempDir() cmd, err := NewResumeCmd("valid-session", ResumeConfig{ - CustomCommand: "echo {sessionId}", + ResumeSessionCommand: "echo {sessionId}", Cwd: dir, }) if err != nil { @@ -497,7 +497,7 @@ func TestCovNewResumeCmd_CustomCmdWithCwd(t *testing.T) { func TestCovNewResumeCmd_CustomCmdInvalidCwd(t *testing.T) { cmd, err := NewResumeCmd("valid-session", ResumeConfig{ - CustomCommand: "echo {sessionId}", + ResumeSessionCommand: "echo {sessionId}", Cwd: "C:\\nonexistent\\path", }) if err != nil { @@ -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 88490db9..0be36595 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 b0ba973b..c6bd66a6 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. @@ -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 5b6effd7..f24accfa 100644 --- a/internal/platform/shell_coverage_test.go +++ b/internal/platform/shell_coverage_test.go @@ -318,7 +318,7 @@ func TestLaunchSession_RoutesToPlatformLauncher(t *testing.T) { ShellInfo{Name: "test-shell", Path: "test-path"}, "test-session", ResumeConfig{ - CustomCommand: "my-cli --resume {sessionId}", + ResumeSessionCommand: "my-cli --resume {sessionId}", Terminal: "my-terminal", Cwd: cwd, LaunchStyle: LaunchStyleWindow, @@ -363,7 +363,7 @@ func TestLaunchSession_PaneStylePassedThrough(t *testing.T) { ShellInfo{Name: "sh", Path: "sh"}, "sess", ResumeConfig{ - CustomCommand: "echo {sessionId}", + ResumeSessionCommand: "echo {sessionId}", LaunchStyle: LaunchStylePane, PaneDirection: "down", }, @@ -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,7 +412,7 @@ func TestLaunchSession_DefaultsTerminalWhenEmpty(t *testing.T) { ShellInfo{Name: "sh", Path: "sh"}, "sess", ResumeConfig{ - CustomCommand: "echo {sessionId}", + ResumeSessionCommand: "echo {sessionId}", Terminal: "", // empty - should default }, ) @@ -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 7a81dbee..77b66f41 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,7 +531,7 @@ func TestResolvedCwd_FileNotDir(t *testing.T) { func TestNewResumeCmd_SetsDirFromCwd(t *testing.T) { dir := t.TempDir() cmd, err := NewResumeCmd("valid-session", ResumeConfig{ - CustomCommand: "echo {sessionId}", + ResumeSessionCommand: "echo {sessionId}", Cwd: dir, }) if err != nil { @@ -544,7 +544,7 @@ func TestNewResumeCmd_SetsDirFromCwd(t *testing.T) { func TestNewResumeCmd_IgnoresInvalidCwd(t *testing.T) { cmd, err := NewResumeCmd("valid-session", ResumeConfig{ - CustomCommand: "echo {sessionId}", + ResumeSessionCommand: "echo {sessionId}", Cwd: "/no/such/path/here", }) if err != nil { diff --git a/internal/tui/cmdpalette.go b/internal/tui/cmdpalette.go index bce367cb..7900966e 100644 --- a/internal/tui/cmdpalette.go +++ b/internal/tui/cmdpalette.go @@ -160,7 +160,7 @@ func (m Model) handleCmdPaletteAction(msg cmdPaletteActionMsg) (tea.Model, tea.C PaneDirection: m.cfg.EffectivePaneDirection(), Terminal: m.cfg.DefaultTerminal, Shell: m.cfg.DefaultShell, - CustomCommand: m.cfg.CustomCommand, + ResumeSessionCommand: m.cfg.ResumeSessionCommand, Theme: m.cfg.Theme, WorkspaceRecovery: m.cfg.WorkspaceRecovery, PreviewPosition: m.cfg.EffectivePreviewPosition(), diff --git a/internal/tui/components/configpanel.go b/internal/tui/components/configpanel.go index 547b5501..2e142782 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 @@ -107,7 +109,8 @@ type ConfigValues struct { PaneDirection string Terminal string Shell string - CustomCommand string + ResumeSessionCommand string + NewSessionCommand string Theme string WorkspaceRecovery bool PreviewPosition string @@ -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 @@ -154,7 +158,8 @@ func (c *ConfigPanel) Values() ConfigValues { PaneDirection: c.paneDirection, Terminal: c.terminal, Shell: c.shell, - CustomCommand: c.customCommand, + ResumeSessionCommand: c.resumeSessionCommand, + NewSessionCommand: c.newSessionCommand, Theme: c.theme, WorkspaceRecovery: c.workspaceRecovery, PreviewPosition: c.previewPosition, @@ -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 33f35504..aea0f475 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 78f402d8..22bc8633 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 6b4b49db..c6b3e005 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 2808dc16..e3947fb2 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 00000000..3fcad434 --- /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 8cd9eb85..c6b7a758 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 4003b217..6df6a444 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 1350797b..ca15fd39 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. @@ -435,7 +440,8 @@ func NewModel() Model { LaunchMode: cfg.EffectiveLaunchMode(), Terminal: cfg.DefaultTerminal, Shell: cfg.DefaultShell, - CustomCommand: cfg.CustomCommand, + ResumeSessionCommand: cfg.ResumeSessionCommand, + NewSessionCommand: cfg.NewSessionCommand, Theme: cfg.Theme, WorkspaceRecovery: cfg.WorkspaceRecovery, PreviewPosition: cfg.EffectivePreviewPosition(), @@ -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) @@ -1320,7 +1350,8 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { PaneDirection: m.cfg.EffectivePaneDirection(), Terminal: m.cfg.DefaultTerminal, Shell: m.cfg.DefaultShell, - CustomCommand: m.cfg.CustomCommand, + ResumeSessionCommand: m.cfg.ResumeSessionCommand, + NewSessionCommand: m.cfg.NewSessionCommand, Theme: m.cfg.Theme, WorkspaceRecovery: m.cfg.WorkspaceRecovery, PreviewPosition: m.cfg.EffectivePreviewPosition(), @@ -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 @@ -3866,7 +3904,7 @@ func (m Model) resumeConfigForSession(cwd string) platform.ResumeConfig { Agent: m.cfg.Agent, Model: m.cfg.Model, Terminal: m.cfg.DefaultTerminal, - CustomCommand: m.cfg.CustomCommand, + 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 af070055..2bacdb68 100644 --- a/internal/tui/model_update_test.go +++ b/internal/tui/model_update_test.go @@ -1305,7 +1305,7 @@ func TestSaveConfigFromPanel(t *testing.T) { LaunchMode: config.LaunchModeInPlace, Terminal: "wt", Shell: "bash", - CustomCommand: "my-cmd", + ResumeSessionCommand: "my-cmd", Theme: "dark", }) m.saveConfigFromPanel() @@ -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 9b390d11..d0114e92 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 ea3fdc98..8940d775 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 9d5da37a..5875cd5d 100644 --- a/web/src/pages/config.astro +++ b/web/src/pages/config.astro @@ -43,7 +43,7 @@ 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 with the session ID.' }, { 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 +76,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 +121,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 cb392f7a..84e82d2b 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
From 751846427ee3fc19833addf7ff29eacac96d3799 Mon Sep 17 00:00:00 2001 From: Jon Gallant Date: Mon, 20 Jul 2026 12:51:42 -0700 Subject: [PATCH 2/2] feat: default new_session_command to 'copilot' and resume to 'copilot --resume' - Change defaultNewSessionCommand from 'gh copilot' to 'copilot' - Flip FindCLIBinary() to prefer 'copilot' over 'ghcs' - Update README, spec doc, web config page with new defaults - Add new_session_command to web config.astro options table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 37403417-09bc-4600-8056-fbb5a2fb18bb --- README.md | 8 +- cmd/dispatch/new.go | 22 ++--- cmd/dispatch/open.go | 22 ++--- docs/specs/live-activity/spec.md | 2 +- internal/config/config.go | 4 +- internal/config/config_test.go | 88 ++++++++--------- internal/config/coverage_test.go | 32 +++---- internal/data/eventwatcher.go | 2 +- internal/data/eventwatcher_test.go | 14 +-- internal/platform/focus_windows.go | 8 +- internal/platform/newsession.go | 2 +- internal/platform/platform_additional_test.go | 6 +- internal/platform/platform_coverage_test.go | 4 +- internal/platform/shell.go | 6 +- internal/platform/shell_coverage_test.go | 14 +-- internal/platform/shell_test.go | 4 +- internal/tui/cmdpalette.go | 30 +++--- internal/tui/components/configpanel.go | 80 ++++++++-------- internal/tui/model.go | 96 +++++++++---------- internal/tui/model_update_test.go | 14 +-- web/src/pages/config.astro | 3 +- 21 files changed, 231 insertions(+), 230 deletions(-) diff --git a/README.md b/README.md index 14803189..9cb3f6bb 100644 --- a/README.md +++ b/README.md @@ -706,8 +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) | -| `resume_session_command` | string | `""` | Custom resume command (`{sessionId}` is replaced) | -| `new_session_command` | string | `""` | Command to launch new sessions (`{cwd}` is replaced). Defaults to `gh copilot` | +| `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 | @@ -791,10 +791,10 @@ Set `resume_session_command` to replace the default Copilot CLI resume command. ### 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 `gh copilot`. +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": "gh copilot --agent workspace" +"new_session_command": "copilot --agent workspace" ``` ### Customizing Keybindings diff --git a/cmd/dispatch/new.go b/cmd/dispatch/new.go index b61a02a6..9555ae60 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, + YoloMode: cfg.YoloMode, + Agent: cfg.Agent, + Model: cfg.Model, ResumeSessionCommand: cfg.ResumeSessionCommand, - Cwd: dir, + 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, + YoloMode: cfg.YoloMode, + Agent: cfg.Agent, + Model: cfg.Model, + Terminal: cfg.DefaultTerminal, ResumeSessionCommand: cfg.ResumeSessionCommand, - Cwd: dir, - LaunchStyle: launchStyleForOpenMode(mode), - PaneDirection: cfg.EffectivePaneDirection(), + 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 017242c6..921a0ca1 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, + YoloMode: cfg.YoloMode, + Agent: cfg.Agent, + Model: cfg.Model, ResumeSessionCommand: cfg.ResumeSessionCommand, - Cwd: sess.Cwd, + 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, + YoloMode: cfg.YoloMode, + Agent: cfg.Agent, + Model: cfg.Model, + Terminal: cfg.DefaultTerminal, ResumeSessionCommand: cfg.ResumeSessionCommand, - Cwd: sess.Cwd, - LaunchStyle: launchStyleForOpenMode(mode), - PaneDirection: cfg.EffectivePaneDirection(), + 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 index d7ab0f11..7ac603f7 100644 --- a/docs/specs/live-activity/spec.md +++ b/docs/specs/live-activity/spec.md @@ -33,7 +33,7 @@ A new `EventWatcher` struct using `fsnotify` to monitor `~/.copilot/session-stat ### New Session Launch -Config adds `NewSessionCommand string` (default: `"gh copilot"` or whatever `copilot` resolves to). 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. +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`) diff --git a/internal/config/config.go b/internal/config/config.go index 37248ea4..92f3ffd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -311,8 +311,8 @@ type Config struct { // 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 "gh copilot". - // Examples: "gh copilot", "copilot", "gh cs new" + // with the target working directory. When empty, defaults to "copilot". + // Examples: "copilot", "copilot --agent workspace", "gh copilot" NewSessionCommand string `json:"new_session_command,omitempty"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 89332760..4b45dfb3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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"}, - ResumeSessionCommand: "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) @@ -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"}, - ResumeSessionCommand: "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 { @@ -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"}, - ResumeSessionCommand: "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 { diff --git a/internal/config/coverage_test.go b/internal/config/coverage_test.go index 51efd82a..23673565 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"}, - ResumeSessionCommand: "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/eventwatcher.go b/internal/data/eventwatcher.go index 8cc661e8..1134a707 100644 --- a/internal/data/eventwatcher.go +++ b/internal/data/eventwatcher.go @@ -44,7 +44,7 @@ type EventWatcher struct { // 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, + onChange: onChange, stop: make(chan struct{}), threshold: threshold, workspaceRecovery: workspaceRecovery, diff --git a/internal/data/eventwatcher_test.go b/internal/data/eventwatcher_test.go index 195cfc26..dc3cf6b1 100644 --- a/internal/data/eventwatcher_test.go +++ b/internal/data/eventwatcher_test.go @@ -46,7 +46,7 @@ func TestEventWatcher_FiresOnEventWrite(t *testing.T) { defer ew.Stop() // Give the watcher time to set up. - time.Sleep(100 * time.Millisecond) + time.Sleep(500 * time.Millisecond) // Modify events.jsonl — this should trigger a callback. newEvent := fmt.Sprintf(`{"type":"assistant.turn_end","timestamp":"%s"}`+"\n", recentTimestamp()) @@ -55,7 +55,7 @@ func TestEventWatcher_FiresOnEventWrite(t *testing.T) { } // Wait for the debounced callback (50ms debounce + margin). - time.Sleep(300 * time.Millisecond) + time.Sleep(1000 * time.Millisecond) mu.Lock() status, ok := updates["test-session-1"] @@ -98,7 +98,7 @@ func TestEventWatcher_DebounceCollapses(t *testing.T) { } defer ew.Stop() - time.Sleep(100 * time.Millisecond) + time.Sleep(500 * time.Millisecond) // Write rapidly 5 times within the debounce window. for i := 0; i < 5; i++ { @@ -149,7 +149,7 @@ func TestEventWatcher_IgnoresInvalidSessionIDs(t *testing.T) { } defer ew.Stop() - time.Sleep(100 * time.Millisecond) + 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 { @@ -181,7 +181,7 @@ func TestEventWatcher_WatchesNewSessionDirs(t *testing.T) { } defer ew.Stop() - time.Sleep(100 * time.Millisecond) + time.Sleep(500 * time.Millisecond) // Create a new session directory AFTER the watcher started. newSessionDir := filepath.Join(dir, "new-session-abc") @@ -266,14 +266,14 @@ func TestEventWatcher_SessionIDFromPath(t *testing.T) { } defer ew.Stop() - time.Sleep(100 * time.Millisecond) + 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(300 * time.Millisecond) + time.Sleep(1000 * time.Millisecond) mu.Lock() id := gotID diff --git a/internal/platform/focus_windows.go b/internal/platform/focus_windows.go index 12257f91..b4408dba 100644 --- a/internal/platform/focus_windows.go +++ b/internal/platform/focus_windows.go @@ -15,10 +15,10 @@ var ( 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") + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procCreateToolhelp32 = kernel32.NewProc("CreateToolhelp32Snapshot") + procProcess32First = kernel32.NewProc("Process32FirstW") + procProcess32Next = kernel32.NewProc("Process32NextW") ) const ( diff --git a/internal/platform/newsession.go b/internal/platform/newsession.go index 0e752394..25c89787 100644 --- a/internal/platform/newsession.go +++ b/internal/platform/newsession.go @@ -8,7 +8,7 @@ import ( // defaultNewSessionCommand is used when the user has not configured a custom // new session command. -const defaultNewSessionCommand = "gh copilot" +const defaultNewSessionCommand = "copilot" // LaunchNewSessionConfig holds parameters for launching a brand new Copilot // CLI session (as opposed to resuming an existing one). diff --git a/internal/platform/platform_additional_test.go b/internal/platform/platform_additional_test.go index 2175b54f..21cdd36f 100644 --- a/internal/platform/platform_additional_test.go +++ b/internal/platform/platform_additional_test.go @@ -316,7 +316,7 @@ func TestSessionStorePath_DispatchDBOverride(t *testing.T) { func TestBuildResumeCommandString_WithCwd(t *testing.T) { cfg := ResumeConfig{ ResumeSessionCommand: "my-tool {sessionId}", - Cwd: t.TempDir(), + Cwd: t.TempDir(), } result, err := buildResumeCommandString("abc123", cfg) if err != nil { @@ -335,7 +335,7 @@ func TestNewResumeCmd_ResumeSessionCommandWithCwd(t *testing.T) { cwd := t.TempDir() cmd, err := NewResumeCmd("abc123", ResumeConfig{ ResumeSessionCommand: "echo {sessionId}", - Cwd: cwd, + Cwd: cwd, }) if err != nil { t.Fatalf("NewResumeCmd error: %v", err) @@ -579,7 +579,7 @@ func TestBuildResumeCommandString_ArgsWithSpaces(t *testing.T) { func TestBuildResumeCommandString_WithModel(t *testing.T) { cfg := ResumeConfig{ ResumeSessionCommand: "tool", - Cwd: t.TempDir(), + 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 58cdc90d..acd2b1b9 100644 --- a/internal/platform/platform_coverage_test.go +++ b/internal/platform/platform_coverage_test.go @@ -485,7 +485,7 @@ func TestCovNewResumeCmd_CustomCmdWithCwd(t *testing.T) { dir := t.TempDir() cmd, err := NewResumeCmd("valid-session", ResumeConfig{ ResumeSessionCommand: "echo {sessionId}", - Cwd: dir, + Cwd: dir, }) if err != nil { t.Fatalf("error: %v", err) @@ -498,7 +498,7 @@ func TestCovNewResumeCmd_CustomCmdWithCwd(t *testing.T) { func TestCovNewResumeCmd_CustomCmdInvalidCwd(t *testing.T) { cmd, err := NewResumeCmd("valid-session", ResumeConfig{ ResumeSessionCommand: "echo {sessionId}", - Cwd: "C:\\nonexistent\\path", + Cwd: "C:\\nonexistent\\path", }) if err != nil { t.Fatalf("error: %v", err) diff --git a/internal/platform/shell.go b/internal/platform/shell.go index c6bd66a6..3c0a2850 100644 --- a/internal/platform/shell.go +++ b/internal/platform/shell.go @@ -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 "" diff --git a/internal/platform/shell_coverage_test.go b/internal/platform/shell_coverage_test.go index f24accfa..06980e4e 100644 --- a/internal/platform/shell_coverage_test.go +++ b/internal/platform/shell_coverage_test.go @@ -319,10 +319,10 @@ func TestLaunchSession_RoutesToPlatformLauncher(t *testing.T) { "test-session", ResumeConfig{ ResumeSessionCommand: "my-cli --resume {sessionId}", - Terminal: "my-terminal", - Cwd: cwd, - LaunchStyle: LaunchStyleWindow, - PaneDirection: "right", + Terminal: "my-terminal", + Cwd: cwd, + LaunchStyle: LaunchStyleWindow, + PaneDirection: "right", }, ) if err != nil { @@ -364,8 +364,8 @@ func TestLaunchSession_PaneStylePassedThrough(t *testing.T) { "sess", ResumeConfig{ ResumeSessionCommand: "echo {sessionId}", - LaunchStyle: LaunchStylePane, - PaneDirection: "down", + LaunchStyle: LaunchStylePane, + PaneDirection: "down", }, ) if err != nil { @@ -413,7 +413,7 @@ func TestLaunchSession_DefaultsTerminalWhenEmpty(t *testing.T) { "sess", ResumeConfig{ ResumeSessionCommand: "echo {sessionId}", - Terminal: "", // empty - should default + Terminal: "", // empty - should default }, ) if err != nil { diff --git a/internal/platform/shell_test.go b/internal/platform/shell_test.go index 77b66f41..3d01f5d6 100644 --- a/internal/platform/shell_test.go +++ b/internal/platform/shell_test.go @@ -532,7 +532,7 @@ func TestNewResumeCmd_SetsDirFromCwd(t *testing.T) { dir := t.TempDir() cmd, err := NewResumeCmd("valid-session", ResumeConfig{ ResumeSessionCommand: "echo {sessionId}", - Cwd: dir, + Cwd: dir, }) if err != nil { t.Fatalf("NewResumeCmd: %v", err) @@ -545,7 +545,7 @@ func TestNewResumeCmd_SetsDirFromCwd(t *testing.T) { func TestNewResumeCmd_IgnoresInvalidCwd(t *testing.T) { cmd, err := NewResumeCmd("valid-session", ResumeConfig{ ResumeSessionCommand: "echo {sessionId}", - Cwd: "/no/such/path/here", + 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 7900966e..570e0bf8 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, - 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, + 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 2e142782..ab73dd75 100644 --- a/internal/tui/components/configpanel.go +++ b/internal/tui/components/configpanel.go @@ -102,26 +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 - 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 + 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. @@ -151,26 +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, - 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, + 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, } } diff --git a/internal/tui/model.go b/internal/tui/model.go index ca15fd39..501590c2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -403,9 +403,9 @@ type Model struct { 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 + 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. @@ -434,25 +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, - 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), + 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. @@ -1343,26 +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, - 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), + 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 @@ -3900,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, + 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(), + Cwd: cwd, + PaneDirection: m.cfg.EffectivePaneDirection(), } } diff --git a/internal/tui/model_update_test.go b/internal/tui/model_update_test.go index 2bacdb68..7bbb4e5b 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", + YoloMode: true, + Agent: "test-agent", + Model: "test-model", + LaunchMode: config.LaunchModeInPlace, + Terminal: "wt", + Shell: "bash", ResumeSessionCommand: "my-cmd", - Theme: "dark", + Theme: "dark", }) m.saveConfigFromPanel() if !m.cfg.YoloMode { diff --git a/web/src/pages/config.astro b/web/src/pages/config.astro index 5875cd5d..dde6c7ca 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: 'resume_session_command', type: 'string', default: '""', description: 'Resume session 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.' },