From c5af5c302293dc4524fd13c6327d969a287c3e46 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 12:51:39 +0100 Subject: [PATCH 01/16] docs: add agent config modal design spec Design for a new modal that appears when starting or restarting an agent on a worktree, allowing users to choose agent type, toggle skip-permissions, and select a prompt template. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-15-agent-config-modal-design.md | 419 ++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-agent-config-modal-design.md diff --git a/docs/superpowers/specs/2026-03-15-agent-config-modal-design.md b/docs/superpowers/specs/2026-03-15-agent-config-modal-design.md new file mode 100644 index 00000000..227c5caa --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-agent-config-modal-design.md @@ -0,0 +1,419 @@ +# Agent Config Modal Design + +## Problem + +When a tmux session is killed/stopped and the user presses 's' to start a new agent, there is no opportunity to choose an agent type, toggle skip-permissions, or select a prompt. The agent starts immediately with the previously-saved type and no options. Similarly, restarting a running agent offers no configuration. + +## Solution + +A new standalone "Agent Config Modal" that presents agent type selection, skip-permissions toggle, and prompt picker before starting or restarting an agent on an existing worktree. + +## Entry Points + +1. **'s' with no agent running** — opens the agent config modal directly (replaces the current immediate `StartAgent` call) +2. **'s' with agent running → "Restart"** — after selecting restart in the existing attach/restart choice modal, transitions to the agent config modal (replaces the current immediate restart) + +The existing attach/restart choice modal is unchanged. "Attach" still attaches immediately. Only "Restart" now routes through the new modal. + +## Modal Layout + +``` +┌─── Start Agent: ───┐ +│ │ +│ Prompt: │ +│ ┌─(none)─────────────────────────┐ │ +│ Press Enter to select a prompt │ +│ │ +│ Agent: │ +│ > Claude │ +│ Codex │ +│ Copilot │ +│ Gemini │ +│ Cursor │ +│ OpenCode │ +│ Pi │ +│ Amp │ +│ None │ +│ │ +│ ☐ Auto-approve all actions │ +│ (Adds --dangerously-skip-...) │ +│ │ +│ [ Start ] [ Cancel ] │ +└─────────────────────────────────────┘ +``` + +Modal width: 60 (narrower than create modal's 70, since fewer fields). + +Sections from top to bottom: +1. **Prompt selector** — clickable field, opens the existing `ViewModePromptPicker` overlay on Enter. Displays selected prompt name or "(none)". +2. **Agent type list** — scrollable list of `AgentTypeOrder` entries. Pre-selected to the worktree's saved agent type. +3. **Skip permissions checkbox** — conditionally shown when the selected agent has a non-empty `SkipPermissionsFlags` entry. Shows the actual flag as a hint below. +4. **Buttons** — "Start" (primary action) and "Cancel". + +## New State Fields on Plugin + +```go +// Agent config modal state +agentConfigWorktree *Worktree // Target worktree +agentConfigIsRestart bool // true = stop first, false = fresh start +agentConfigAgentType AgentType // Selected agent type +agentConfigAgentIdx int // List selection index +agentConfigSkipPerms bool // Skip permissions toggle +agentConfigPromptIdx int // Selected prompt index (-1 = none) +agentConfigPrompts []Prompt // Loaded prompts for this modal (independent of createPrompts) +agentConfigModal *modal.Modal // Cached modal instance +agentConfigModalWidth int // For rebuild detection + +// Prompt picker return routing +promptPickerReturnMode ViewMode // Which view mode to return to after prompt picker +``` + +## New View Mode + +```go +ViewModeAgentConfig // Agent configuration modal +``` + +## New File: `agent_config_modal.go` + +Contains: +- `ensureAgentConfigModal()` — builds the modal declaratively using the `modal` package +- `syncAgentConfigModalFocus()` — syncs focus state +- `renderAgentConfigModal()` — renders modal over dimmed background +- `clearAgentConfigModal()` — resets all state fields (see explicit list below) +- `getAgentConfigPrompt()` — resolves `agentConfigPromptIdx` to `*Prompt` (see definition below) +- Custom section builders for prompt display, agent label, skip permissions spacer/hint +- `shouldShowAgentConfigSkipPerms()` — returns true if selected agent has a skip-permissions flag + +Section builders follow the same patterns as `create_modal.go` but reference `agentConfig*` state fields instead of `create*` fields. + +### Element IDs + +```go +const ( + agentConfigPromptFieldID = "agent-config-prompt" + agentConfigAgentListID = "agent-config-agent-list" + agentConfigSkipPermissionsID = "agent-config-skip-permissions" + agentConfigSubmitID = "agent-config-submit" + agentConfigCancelID = "agent-config-cancel" + agentConfigAgentItemPrefix = "agent-config-agent-" +) +``` + +### `clearAgentConfigModal()` — explicit field reset + +```go +func (p *Plugin) clearAgentConfigModal() { + p.agentConfigWorktree = nil + p.agentConfigIsRestart = false + p.agentConfigAgentType = "" + p.agentConfigAgentIdx = 0 + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = nil + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 +} +``` + +### `getAgentConfigPrompt()` — prompt resolution + +```go +func (p *Plugin) getAgentConfigPrompt() *Prompt { + if p.agentConfigPromptIdx < 0 || p.agentConfigPromptIdx >= len(p.agentConfigPrompts) { + return nil + } + prompt := p.agentConfigPrompts[p.agentConfigPromptIdx] + return &prompt +} +``` + +## Key Handler: `handleAgentConfigKeys()` + +Located in `keys.go`. Follows the same pattern as other modal key handlers: + +```go +func (p *Plugin) handleAgentConfigKeys(msg tea.KeyMsg) tea.Cmd { + p.ensureAgentConfigModal() + if p.agentConfigModal == nil { + return nil + } + + prevAgentIdx := p.agentConfigAgentIdx + action, cmd := p.agentConfigModal.HandleKey(msg) + + // Sync agent type when selection changes + if p.agentConfigAgentIdx != prevAgentIdx { + if p.agentConfigAgentIdx >= 0 && p.agentConfigAgentIdx < len(AgentTypeOrder) { + p.agentConfigAgentType = AgentTypeOrder[p.agentConfigAgentIdx] + } + } + + switch action { + case "cancel", agentConfigCancelID: + p.viewMode = ViewModeList + p.clearAgentConfigModal() + return nil + case agentConfigPromptFieldID: + // Open prompt picker, set return mode to agent config + p.promptPickerReturnMode = ViewModeAgentConfig + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + p.viewMode = ViewModePromptPicker + return nil + case agentConfigSubmitID: + return p.executeAgentConfig() + } + + return cmd +} +``` + +## Execution: `executeAgentConfig()` + +```go +func (p *Plugin) executeAgentConfig() tea.Cmd { + wt := p.agentConfigWorktree + agentType := p.agentConfigAgentType + skipPerms := p.agentConfigSkipPerms + prompt := p.getAgentConfigPrompt() + isRestart := p.agentConfigIsRestart + + p.viewMode = ViewModeList + p.clearAgentConfigModal() + + if wt == nil { + return nil + } + + if isRestart { + return tea.Sequence( + p.StopAgent(wt), + func() tea.Msg { + return restartAgentWithOptionsMsg{ + worktree: wt, + agentType: agentType, + skipPerms: skipPerms, + prompt: prompt, + } + }, + ) + } + return p.StartAgentWithOptions(wt, agentType, skipPerms, prompt) +} +``` + +## New Message Type + +```go +// restartAgentWithOptionsMsg signals that an agent should be restarted with specific options. +type restartAgentWithOptionsMsg struct { + worktree *Worktree + agentType AgentType + skipPerms bool + prompt *Prompt +} +``` + +Handled in `update.go` alongside the existing `restartAgentMsg`: + +```go +case restartAgentWithOptionsMsg: + if msg.worktree != nil { + return p, p.StartAgentWithOptions(msg.worktree, msg.agentType, msg.skipPerms, msg.prompt) + } + return p, nil +``` + +## Changes to Existing Files + +### `keys.go` + +1. **Line ~807** (`case "s"`, no agent branch): Replace `return p.StartAgent(wt, p.resolveWorktreeAgentType(wt))` with opening the agent config modal: + ```go + p.agentConfigWorktree = wt + p.agentConfigIsRestart = false + p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) + p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.viewMode = ViewModeAgentConfig + return nil + ``` + + Where `configDir` is obtained from `os.UserHomeDir() + "/.config/sidecar"` (same pattern as `initCreateModalBase()`). + +2. **`executeAgentChoice()`** (line ~298, restart branch): Replace immediate restart with transitioning to agent config modal: + ```go + // Restart agent: open config modal instead of restarting immediately + p.agentConfigWorktree = wt + p.agentConfigIsRestart = true + p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) + p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + home, _ := os.UserHomeDir() + configDir := filepath.Join(home, ".config", "sidecar") + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.viewMode = ViewModeAgentConfig + return nil + ``` + +3. **`handleKeys()` switch**: Add `case ViewModeAgentConfig: return p.handleAgentConfigKeys(msg)` + +### `view_list.go` + +Add rendering case: +```go +case ViewModeAgentConfig: + return p.renderAgentConfigModal(width, height) +``` + +### `commands.go` + +1. Add `case ViewModeAgentConfig:` to `Commands()` — return Confirm/Cancel commands. +2. Add `case ViewModeAgentConfig:` to `KeyBindings()` — return keybinding context `"workspace-agent-config"`. +3. `ConsumesTextInput()` — no change needed. The agent config modal has no text input fields. When the prompt picker is opened from it, `ViewModePromptPicker` handles text consumption. + +### `mouse.go` + +Add `case ViewModeAgentConfig:` mouse handler delegating to `p.agentConfigModal.HandleMouse()`. Follow the same pattern as `ViewModeAgentChoice` mouse handling. + +Note: `isModalViewMode()` uses a default `true` return for unknown view modes, so `ViewModeAgentConfig` is automatically treated as a modal. No change needed there. + +### `plugin.go` + +1. Add state fields listed above to the `Plugin` struct. +2. Add `promptPickerReturnMode ViewMode` field. +3. `outputVisibleForUnfocused()` — no change needed. It already returns `false` for all non-list/non-interactive view modes, which is correct (suppress polling while modal is open). + +### `update.go` + +1. Add `case restartAgentWithOptionsMsg:` handler (see above). + +2. **Modify `PromptSelectedMsg` handler** to use `promptPickerReturnMode`: + ```go + case PromptSelectedMsg: + returnMode := p.promptPickerReturnMode + p.promptPicker = nil + p.clearPromptPickerModal() + + if returnMode == ViewModeAgentConfig { + p.viewMode = ViewModeAgentConfig + if msg.Prompt != nil { + for i, pr := range p.agentConfigPrompts { + if pr.Name == msg.Prompt.Name { + p.agentConfigPromptIdx = i + break + } + } + } else { + p.agentConfigPromptIdx = -1 + } + } else { + // Existing create modal logic (unchanged) + p.viewMode = ViewModeCreate + if msg.Prompt != nil { + for i, pr := range p.createPrompts { + if pr.Name == msg.Prompt.Name { + p.createPromptIdx = i + break + } + } + if msg.Prompt.TicketMode == TicketNone { + p.createFocus = 4 + } else { + p.createFocus = 3 + } + } else { + p.createPromptIdx = -1 + p.createFocus = 3 + } + } + ``` + +3. **Modify `PromptCancelledMsg` handler** to use `promptPickerReturnMode`: + ```go + case PromptCancelledMsg: + returnMode := p.promptPickerReturnMode + p.promptPicker = nil + p.clearPromptPickerModal() + if returnMode == ViewModeAgentConfig { + p.viewMode = ViewModeAgentConfig + } else { + p.viewMode = ViewModeCreate + } + ``` + +4. **Set `promptPickerReturnMode` in existing create modal flow** — in `keys.go` where the create modal opens the prompt picker (around line ~1004 and ~1026), add: + ```go + p.promptPickerReturnMode = ViewModeCreate + ``` + +5. **Modify `PromptInstallDefaultsMsg` handler** to respect `promptPickerReturnMode`. Currently it always reloads into `p.createPrompts`. When the picker was opened from agent config, it should update `agentConfigPrompts` instead: + ```go + case PromptInstallDefaultsMsg: + // ... existing home dir / WriteDefaultPromptsToConfig logic ... + if p.promptPickerReturnMode == ViewModeAgentConfig { + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + } else { + p.createPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) + } + ``` + +### `messages.go` + +Add `restartAgentWithOptionsMsg` struct. + +### `types.go` + +Add `ViewModeAgentConfig` constant. + +### `plugin.go` `Init()` + +Add keymap registration for the new modal context: + +```go +// Agent config modal context +ctx.Keymap.RegisterPluginBinding("esc", "cancel", "workspace-agent-config") +ctx.Keymap.RegisterPluginBinding("enter", "confirm", "workspace-agent-config") +ctx.Keymap.RegisterPluginBinding("tab", "next-field", "workspace-agent-config") +ctx.Keymap.RegisterPluginBinding("shift+tab", "prev-field", "workspace-agent-config") +``` + +## Prompt Picker Integration + +When the user activates the prompt field in the agent config modal: +1. `promptPickerReturnMode` is set to `ViewModeAgentConfig` +2. `promptPicker` is created with `p.agentConfigPrompts` (the modal's own prompt list) +3. `viewMode` switches to `ViewModePromptPicker` +4. The existing prompt picker overlay renders on top +5. On `PromptSelectedMsg`: handler checks `promptPickerReturnMode`, returns to `ViewModeAgentConfig`, updates `agentConfigPromptIdx` by matching `msg.Prompt.Name` against `agentConfigPrompts` +6. On `PromptCancelledMsg`: handler checks `promptPickerReturnMode`, returns to `ViewModeAgentConfig` with no changes + +The existing create modal prompt picker flow is updated to set `promptPickerReturnMode = ViewModeCreate` before opening the picker, maintaining backward compatibility. + +## Initialization + +When opening the modal (both entry points): +- `agentConfigPrompts` is loaded via `LoadPrompts(configDir, p.ctx.ProjectRoot)` — independent of `createPrompts` +- `agentConfigAgentType` is pre-selected from `resolveWorktreeAgentType(wt)` (the worktree's saved agent type) +- `agentConfigAgentIdx` is set via `agentTypeIndex()` to match +- `agentConfigSkipPerms` defaults to `false` +- `agentConfigPromptIdx` defaults to `-1` (none) + +## Edge Cases + +- **Worktree deleted while modal is open**: `executeAgentConfig()` checks `wt == nil` and returns nil. This is consistent with the existing `restartAgentMsg` handler's behavior. Stale pointer risk is pre-existing and not addressed here. +- **Agent starts from another source while modal is open**: The modal operates on captured state. If the agent starts externally, the user will see the updated status after closing the modal. No special handling needed — same as existing modals. + +## Testing + +- Unit test for `handleAgentConfigKeys`: confirm, cancel, agent selection changes +- Unit test for `executeAgentConfig`: fresh start vs restart paths +- Unit test for `getAgentConfigPrompt`: valid index, invalid index, nil prompts +- Unit test for prompt picker return routing: `PromptSelectedMsg` and `PromptCancelledMsg` with both `ViewModeAgentConfig` and `ViewModeCreate` return modes +- Unit test for modal rendering (snapshot or content check) +- Integration: 's' key on stopped worktree opens modal, confirm starts agent with selected options From 5885feb4cb5d18a7b610099fda1babc3ae120a60 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:03:07 +0100 Subject: [PATCH 02/16] docs: add agent config modal implementation plan 13 tasks across 6 chunks covering types, messages, state fields, modal builder, key/mouse/command handlers, entry point rewiring, and prompt picker return routing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-agent-config-modal.md | 1201 +++++++++++++++++ 1 file changed, 1201 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-agent-config-modal.md diff --git a/docs/superpowers/plans/2026-03-15-agent-config-modal.md b/docs/superpowers/plans/2026-03-15-agent-config-modal.md new file mode 100644 index 00000000..ba7868cb --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-agent-config-modal.md @@ -0,0 +1,1201 @@ +# Agent Config Modal Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a modal that lets users choose agent type, skip-permissions, and prompt when starting or restarting an agent on an existing worktree. + +**Architecture:** New `agent_config_modal.go` file containing the modal builder, renderer, clear/helper functions. Existing files are modified to add: a new `ViewModeAgentConfig` constant, new Plugin state fields, key/mouse/command handlers, prompt picker return routing via `promptPickerReturnMode`, and a `restartAgentWithOptionsMsg` message type. + +**Tech Stack:** Go, Bubbletea (TUI framework), Lipgloss (styling), internal `modal` package + +**Spec:** `docs/superpowers/specs/2026-03-15-agent-config-modal-design.md` + +--- + +## Chunk 1: Foundation — Types, Messages, State Fields + +### Task 1: Add ViewModeAgentConfig constant + +**Files:** +- Modify: `internal/plugins/workspace/types.go:25-41` + +- [ ] **Step 1: Add the new view mode constant** + +In `internal/plugins/workspace/types.go`, add `ViewModeAgentConfig` to the `ViewMode` iota block, after `ViewModeFetchPR`: + +```go +ViewModeFetchPR // Fetch remote PR modal +ViewModeAgentConfig // Agent config modal (start/restart with options) +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success (new constant is unused but that's fine for iota) + +- [ ] **Step 3: Commit** + +```bash +git add internal/plugins/workspace/types.go +git commit -m "feat: add ViewModeAgentConfig constant" +``` + +### Task 2: Add restartAgentWithOptionsMsg message type + +**Files:** +- Modify: `internal/plugins/workspace/messages.go:186-189` + +- [ ] **Step 1: Add the new message struct** + +In `internal/plugins/workspace/messages.go`, after the existing `restartAgentMsg` struct (line 189), add: + +```go +// restartAgentWithOptionsMsg signals that an agent should be restarted with specific options. +type restartAgentWithOptionsMsg struct { + worktree *Worktree + agentType AgentType + skipPerms bool + prompt *Prompt +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 3: Commit** + +```bash +git add internal/plugins/workspace/messages.go +git commit -m "feat: add restartAgentWithOptionsMsg message type" +``` + +### Task 3: Add agent config state fields to Plugin struct + +**Files:** +- Modify: `internal/plugins/workspace/plugin.go:243-247` + +- [ ] **Step 1: Add state fields after the existing agent choice modal state block** + +In `internal/plugins/workspace/plugin.go`, after the `agentChoiceModalWidth` field (line 247), add: + +```go + // Agent config modal state (start/restart with options) + agentConfigWorktree *Worktree + agentConfigIsRestart bool + agentConfigAgentType AgentType + agentConfigAgentIdx int + agentConfigSkipPerms bool + agentConfigPromptIdx int + agentConfigPrompts []Prompt + agentConfigModal *modal.Modal + agentConfigModalWidth int + agentConfigFocusSet bool + + // Prompt picker return routing + promptPickerReturnMode ViewMode +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 3: Commit** + +```bash +git add internal/plugins/workspace/plugin.go +git commit -m "feat: add agent config modal state fields to Plugin" +``` + +### Task 4: Add restartAgentWithOptionsMsg handler in update.go + +**Files:** +- Modify: `internal/plugins/workspace/update.go:966-972` + +- [ ] **Step 1: Add the handler after the existing restartAgentMsg handler** + +In `internal/plugins/workspace/update.go`, after the `restartAgentMsg` case (line 972), add: + +```go + case restartAgentWithOptionsMsg: + // Start new agent after stop completed, with user-selected options + if msg.worktree != nil { + return p, p.StartAgentWithOptions(msg.worktree, msg.agentType, msg.skipPerms, msg.prompt) + } + return p, nil +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 3: Commit** + +```bash +git add internal/plugins/workspace/update.go +git commit -m "feat: handle restartAgentWithOptionsMsg in update loop" +``` + +--- + +## Chunk 2: Agent Config Modal — Core File + +### Task 5: Create agent_config_modal.go with constants, clear, and helpers + +**Files:** +- Create: `internal/plugins/workspace/agent_config_modal.go` + +- [ ] **Step 1: Write the test file first** + +Create `internal/plugins/workspace/agent_config_modal_test.go`: + +```go +package workspace + +import "testing" + +func TestGetAgentConfigPrompt(t *testing.T) { + tests := []struct { + name string + prompts []Prompt + idx int + wantNil bool + wantName string + }{ + {"negative index", []Prompt{{Name: "a"}}, -1, true, ""}, + {"out of bounds", []Prompt{{Name: "a"}}, 5, true, ""}, + {"nil prompts", nil, 0, true, ""}, + {"valid index", []Prompt{{Name: "first"}, {Name: "second"}}, 1, false, "second"}, + {"first index", []Prompt{{Name: "only"}}, 0, false, "only"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{ + agentConfigPrompts: tt.prompts, + agentConfigPromptIdx: tt.idx, + } + got := p.getAgentConfigPrompt() + if tt.wantNil { + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + return + } + if got == nil { + t.Fatal("expected non-nil prompt") + } + if got.Name != tt.wantName { + t.Errorf("expected name %q, got %q", tt.wantName, got.Name) + } + }) + } +} + +func TestClearAgentConfigModal(t *testing.T) { + p := &Plugin{ + agentConfigWorktree: &Worktree{Name: "test"}, + agentConfigIsRestart: true, + agentConfigAgentType: AgentClaude, + agentConfigAgentIdx: 3, + agentConfigSkipPerms: true, + agentConfigPromptIdx: 2, + agentConfigPrompts: []Prompt{{Name: "x"}}, + } + p.clearAgentConfigModal() + + if p.agentConfigWorktree != nil { + t.Error("worktree not cleared") + } + if p.agentConfigIsRestart { + t.Error("isRestart not cleared") + } + if p.agentConfigAgentType != "" { + t.Error("agentType not cleared") + } + if p.agentConfigAgentIdx != 0 { + t.Error("agentIdx not cleared") + } + if p.agentConfigSkipPerms { + t.Error("skipPerms not cleared") + } + if p.agentConfigPromptIdx != -1 { + t.Error("promptIdx not cleared") + } + if p.agentConfigPrompts != nil { + t.Error("prompts not cleared") + } + if p.agentConfigModal != nil { + t.Error("modal not cleared") + } + if p.agentConfigModalWidth != 0 { + t.Error("modalWidth not cleared") + } + if p.agentConfigFocusSet { + t.Error("focusSet not cleared") + } +} + +func TestShouldShowAgentConfigSkipPerms(t *testing.T) { + tests := []struct { + name string + agentType AgentType + want bool + }{ + {"claude has flag", AgentClaude, true}, + {"codex has flag", AgentCodex, true}, + {"none has no flag", AgentNone, false}, + {"opencode has no flag", AgentOpenCode, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{agentConfigAgentType: tt.agentType} + if got := p.shouldShowAgentConfigSkipPerms(); got != tt.want { + t.Errorf("shouldShowAgentConfigSkipPerms() = %v, want %v", got, tt.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run tests — they should fail (functions don't exist yet)** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -run TestGetAgentConfigPrompt -v 2>&1 | head -5` +Expected: Compilation error — functions not defined + +- [ ] **Step 3: Create agent_config_modal.go with constants, helpers, and clear function** + +Create `internal/plugins/workspace/agent_config_modal.go`: + +```go +package workspace + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/marcus/sidecar/internal/modal" + "github.com/marcus/sidecar/internal/styles" + ui "github.com/marcus/sidecar/internal/ui" +) + +const ( + agentConfigPromptFieldID = "agent-config-prompt" + agentConfigAgentListID = "agent-config-agent-list" + agentConfigSkipPermissionsID = "agent-config-skip-permissions" + agentConfigSubmitID = "agent-config-submit" + agentConfigCancelID = "agent-config-cancel" + agentConfigAgentItemPrefix = "agent-config-agent-" +) + +// clearAgentConfigModal resets all agent config modal state. +func (p *Plugin) clearAgentConfigModal() { + p.agentConfigWorktree = nil + p.agentConfigIsRestart = false + p.agentConfigAgentType = "" + p.agentConfigAgentIdx = 0 + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = nil + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 + p.agentConfigFocusSet = false +} + +// getAgentConfigPrompt resolves the selected prompt index to a *Prompt. +func (p *Plugin) getAgentConfigPrompt() *Prompt { + if p.agentConfigPromptIdx < 0 || p.agentConfigPromptIdx >= len(p.agentConfigPrompts) { + return nil + } + prompt := p.agentConfigPrompts[p.agentConfigPromptIdx] + return &prompt +} + +// shouldShowAgentConfigSkipPerms returns true if the selected agent supports skip permissions. +func (p *Plugin) shouldShowAgentConfigSkipPerms() bool { + if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { + return false + } + flag, ok := SkipPermissionsFlags[p.agentConfigAgentType] + return ok && flag != "" +} +``` + +Note: The imports for `fmt`, `strings`, `lipgloss`, `ansi`, `styles`, and `ui` will be used by later functions in this file. If the compiler complains about unused imports at this stage, temporarily remove them and add them back in Task 6 when the section builders are added. + +- [ ] **Step 4: Run tests — they should pass** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -run "TestGetAgentConfigPrompt|TestClearAgentConfigModal|TestShouldShowAgentConfigSkipPerms" -v` +Expected: All 3 test functions pass + +- [ ] **Step 5: Commit** + +```bash +git add internal/plugins/workspace/agent_config_modal.go internal/plugins/workspace/agent_config_modal_test.go +git commit -m "feat: add agent config modal constants, helpers, and tests" +``` + +### Task 6: Add ensureAgentConfigModal — modal builder with sections + +**Files:** +- Modify: `internal/plugins/workspace/agent_config_modal.go` + +- [ ] **Step 1: Add section builder functions and ensureAgentConfigModal** + +Append to `internal/plugins/workspace/agent_config_modal.go`: + +```go +// ensureAgentConfigModal builds or rebuilds the agent config modal. +func (p *Plugin) ensureAgentConfigModal() { + if p.agentConfigWorktree == nil { + return + } + + modalW := 60 + maxW := p.width - 4 + if maxW < 1 { + maxW = 1 + } + if modalW > maxW { + modalW = maxW + } + + if p.agentConfigModal != nil && p.agentConfigModalWidth == modalW { + return + } + p.agentConfigModalWidth = modalW + + items := make([]modal.ListItem, len(AgentTypeOrder)) + for i, at := range AgentTypeOrder { + items[i] = modal.ListItem{ + ID: fmt.Sprintf("%s%d", agentConfigAgentItemPrefix, i), + Label: AgentDisplayNames[at], + } + } + + title := fmt.Sprintf("Start Agent: %s", p.agentConfigWorktree.Name) + if p.agentConfigIsRestart { + title = fmt.Sprintf("Restart Agent: %s", p.agentConfigWorktree.Name) + } + + p.agentConfigModal = modal.New(title, + modal.WithWidth(modalW), + modal.WithPrimaryAction(agentConfigSubmitID), + modal.WithHints(false), + ). + AddSection(p.agentConfigPromptSection()). + AddSection(modal.Spacer()). + AddSection(p.agentConfigAgentLabelSection()). + AddSection(modal.List(agentConfigAgentListID, items, &p.agentConfigAgentIdx, modal.WithMaxVisible(len(items)))). + AddSection(p.agentConfigSkipPermissionsSpacerSection()). + AddSection(modal.When(p.shouldShowAgentConfigSkipPerms, modal.Checkbox(agentConfigSkipPermissionsID, "Auto-approve all actions", &p.agentConfigSkipPerms))). + AddSection(p.agentConfigSkipPermissionsHintSection()). + AddSection(modal.Spacer()). + AddSection(modal.Buttons( + modal.Btn(" Start ", agentConfigSubmitID), + modal.Btn(" Cancel ", agentConfigCancelID), + )) +} + +// syncAgentConfigModalFocus sets initial focus if not already set. +// Called from renderAgentConfigModal; only sets focus once to avoid +// overriding user navigation (tab/arrow keys). +func (p *Plugin) syncAgentConfigModalFocus() { + if p.agentConfigModal == nil { + return + } + // Only set initial focus — the modal tracks focus after first set + if !p.agentConfigFocusSet { + p.agentConfigModal.SetFocus(agentConfigAgentListID) + p.agentConfigFocusSet = true + } +} + +// agentConfigPromptSection renders the prompt selector field. +func (p *Plugin) agentConfigPromptSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + lines := make([]string, 0, 4) + focusables := make([]modal.FocusableInfo, 0, 1) + + lines = append(lines, "Prompt:") + + selectedPrompt := p.getAgentConfigPrompt() + displayText := "(none)" + if len(p.agentConfigPrompts) == 0 { + displayText = "No prompts configured" + } else if selectedPrompt != nil { + scopeIndicator := "[G] global" + if selectedPrompt.Source == "project" { + scopeIndicator = "[P] project" + } + displayText = fmt.Sprintf("%s %s", selectedPrompt.Name, dimText(scopeIndicator)) + } + + promptStyle := inputStyle() + if focusID == agentConfigPromptFieldID { + promptStyle = inputFocusedStyle() + } + rendered := promptStyle.Render(displayText) + renderedLines := strings.Split(rendered, "\n") + displayStartY := len(lines) + lines = append(lines, renderedLines...) + + focusables = append(focusables, modal.FocusableInfo{ + ID: agentConfigPromptFieldID, + OffsetX: 0, + OffsetY: displayStartY, + Width: ansi.StringWidth(rendered), + Height: len(renderedLines), + }) + + if len(p.agentConfigPrompts) == 0 { + lines = append(lines, dimText(" See: .claude/skills/create-prompt/SKILL.md")) + } else if selectedPrompt == nil { + lines = append(lines, dimText(" Press Enter to select a prompt template")) + } else { + preview := strings.ReplaceAll(selectedPrompt.Body, "\n", " ") + if runes := []rune(preview); len(runes) > 60 { + preview = string(runes[:57]) + "..." + } + lines = append(lines, dimText(fmt.Sprintf(" Preview: %s", preview))) + } + + return modal.RenderedSection{Content: strings.Join(lines, "\n"), Focusables: focusables} + }, nil) +} + +// agentConfigAgentLabelSection renders the "Agent:" label. +func (p *Plugin) agentConfigAgentLabelSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + return modal.RenderedSection{Content: "Agent:"} + }, nil) +} + +// agentConfigSkipPermissionsSpacerSection renders a spacer before the checkbox (hidden when agent is None). +func (p *Plugin) agentConfigSkipPermissionsSpacerSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { + return modal.RenderedSection{} + } + return modal.RenderedSection{Content: " "} + }, nil) +} + +// agentConfigSkipPermissionsHintSection renders the hint showing the actual flag. +func (p *Plugin) agentConfigSkipPermissionsHintSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { + return modal.RenderedSection{} + } + if p.shouldShowAgentConfigSkipPerms() { + flag := SkipPermissionsFlags[p.agentConfigAgentType] + return modal.RenderedSection{Content: dimText(fmt.Sprintf(" (Adds %s)", flag))} + } + return modal.RenderedSection{Content: dimText(" Skip permissions not available for this agent")} + }, nil) +} + +// renderAgentConfigModal renders the agent config modal over a dimmed background. +func (p *Plugin) renderAgentConfigModal(width, height int) string { + background := p.renderListView(width, height) + + p.ensureAgentConfigModal() + if p.agentConfigModal == nil { + return background + } + + p.syncAgentConfigModalFocus() + modalContent := p.agentConfigModal.Render(width, height, p.mouseHandler) + return ui.OverlayModal(background, modalContent, width, height) +} +``` + +Make sure the import block at the top of the file includes all needed imports. The final import block should be: + +```go +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/marcus/sidecar/internal/modal" + "github.com/marcus/sidecar/internal/styles" + ui "github.com/marcus/sidecar/internal/ui" +) +``` + +Remove any imports that the compiler says are unused. The `lipgloss` and `styles` imports are used by `inputStyle()`/`inputFocusedStyle()`/`dimText()` which are defined in `create_modal.go` (same package). If these helper functions reference `lipgloss` and `styles` internally and the new file doesn't directly use them, remove those imports. + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 3: Commit** + +```bash +git add internal/plugins/workspace/agent_config_modal.go +git commit -m "feat: add ensureAgentConfigModal builder and section renderers" +``` + +--- + +## Chunk 3: Key, Mouse, Command Handlers & View Routing + +### Task 7: Add key handler and executeAgentConfig + +**Files:** +- Modify: `internal/plugins/workspace/keys.go:27-28` (handleKeys switch) +- Modify: `internal/plugins/workspace/keys.go:264-305` (handleAgentConfigKeys + executeAgentConfig) + +- [ ] **Step 1: Write test for executeAgentConfig** + +Add to `internal/plugins/workspace/agent_config_modal_test.go`: + +```go +func TestExecuteAgentConfig_FreshStart(t *testing.T) { + wt := &Worktree{Name: "test-wt", Path: "/tmp/test"} + p := &Plugin{ + agentConfigWorktree: wt, + agentConfigIsRestart: false, + agentConfigAgentType: AgentClaude, + agentConfigSkipPerms: true, + agentConfigPromptIdx: -1, + viewMode: ViewModeAgentConfig, + } + + cmd := p.executeAgentConfig() + + // After execute, modal state should be cleared + if p.viewMode != ViewModeList { + t.Errorf("expected ViewModeList, got %v", p.viewMode) + } + if p.agentConfigWorktree != nil { + t.Error("worktree should be cleared") + } + // cmd should be non-nil (StartAgentWithOptions returns a tea.Cmd) + if cmd == nil { + t.Error("expected non-nil cmd for fresh start") + } +} + +func TestExecuteAgentConfig_Restart(t *testing.T) { + wt := &Worktree{Name: "test-wt", Path: "/tmp/test"} + p := &Plugin{ + agentConfigWorktree: wt, + agentConfigIsRestart: true, + agentConfigAgentType: AgentCodex, + agentConfigSkipPerms: false, + agentConfigPromptIdx: -1, + viewMode: ViewModeAgentConfig, + } + + cmd := p.executeAgentConfig() + + if p.viewMode != ViewModeList { + t.Errorf("expected ViewModeList, got %v", p.viewMode) + } + // cmd should be non-nil (tea.Sequence for stop + restart) + if cmd == nil { + t.Error("expected non-nil cmd for restart") + } +} + +func TestExecuteAgentConfig_NilWorktree(t *testing.T) { + p := &Plugin{ + agentConfigWorktree: nil, + viewMode: ViewModeAgentConfig, + } + + cmd := p.executeAgentConfig() + + if p.viewMode != ViewModeList { + t.Errorf("expected ViewModeList, got %v", p.viewMode) + } + if cmd != nil { + t.Error("expected nil cmd for nil worktree") + } +} +``` + +- [ ] **Step 2: Run tests to see them fail** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -run "TestExecuteAgentConfig" -v 2>&1 | head -5` +Expected: Compilation error — `executeAgentConfig` not defined + +- [ ] **Step 3: Add handleAgentConfigKeys to the handleKeys switch** + +In `internal/plugins/workspace/keys.go`, in the `handleKeys()` switch (around line 27), add before the `case ViewModeAgentChoice:` line: + +```go + case ViewModeAgentConfig: + return p.handleAgentConfigKeys(msg) +``` + +- [ ] **Step 4: Add handleAgentConfigKeys and executeAgentConfig functions** + +In `internal/plugins/workspace/keys.go`, after the `handleAgentChoiceKeys` function (after line 283), add: + +```go +// handleAgentConfigKeys handles keys in agent config modal. +func (p *Plugin) handleAgentConfigKeys(msg tea.KeyMsg) tea.Cmd { + p.ensureAgentConfigModal() + if p.agentConfigModal == nil { + return nil + } + + prevAgentIdx := p.agentConfigAgentIdx + action, cmd := p.agentConfigModal.HandleKey(msg) + + // Sync agent type when selection changes + if p.agentConfigAgentIdx != prevAgentIdx { + if p.agentConfigAgentIdx >= 0 && p.agentConfigAgentIdx < len(AgentTypeOrder) { + p.agentConfigAgentType = AgentTypeOrder[p.agentConfigAgentIdx] + } + } + + switch action { + case "cancel", agentConfigCancelID: + p.viewMode = ViewModeList + p.clearAgentConfigModal() + return nil + case agentConfigPromptFieldID: + // Open prompt picker, set return mode to agent config + p.promptPickerReturnMode = ViewModeAgentConfig + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + p.clearPromptPickerModal() + p.viewMode = ViewModePromptPicker + return nil + case agentConfigSubmitID: + return p.executeAgentConfig() + } + + return cmd +} + +// executeAgentConfig executes the agent config modal action (start or restart). +func (p *Plugin) executeAgentConfig() tea.Cmd { + wt := p.agentConfigWorktree + agentType := p.agentConfigAgentType + skipPerms := p.agentConfigSkipPerms + prompt := p.getAgentConfigPrompt() + isRestart := p.agentConfigIsRestart + + p.viewMode = ViewModeList + p.clearAgentConfigModal() + + if wt == nil { + return nil + } + + if isRestart { + return tea.Sequence( + p.StopAgent(wt), + func() tea.Msg { + return restartAgentWithOptionsMsg{ + worktree: wt, + agentType: agentType, + skipPerms: skipPerms, + prompt: prompt, + } + }, + ) + } + return p.StartAgentWithOptions(wt, agentType, skipPerms, prompt) +} +``` + +- [ ] **Step 5: Run tests** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -run "TestExecuteAgentConfig" -v` +Expected: All 3 tests pass + +- [ ] **Step 6: Commit** + +```bash +git add internal/plugins/workspace/keys.go internal/plugins/workspace/agent_config_modal_test.go +git commit -m "feat: add handleAgentConfigKeys and executeAgentConfig" +``` + +### Task 8: Add view routing, commands, and mouse handler + +**Files:** +- Modify: `internal/plugins/workspace/view_list.go:66-67` +- Modify: `internal/plugins/workspace/commands.go:48-52` (Commands()) +- Modify: `internal/plugins/workspace/commands.go:236-250` (FocusContext()) +- Modify: `internal/plugins/workspace/mouse.go:67-69` +- Modify: `internal/plugins/workspace/mouse.go:424-426` + +- [ ] **Step 1: Add rendering case in view_list.go** + +In `internal/plugins/workspace/view_list.go`, in the `View()` switch (around line 66), add before the `case ViewModeAgentChoice:` line: + +```go + case ViewModeAgentConfig: + return p.renderAgentConfigModal(width, height) +``` + +- [ ] **Step 2: Add commands in commands.go** + +In `internal/plugins/workspace/commands.go`, in the `Commands()` switch, add before the `case ViewModeAgentChoice:` (around line 48): + +```go + case ViewModeAgentConfig: + return []plugin.Command{ + {ID: "cancel", Name: "Cancel", Description: "Cancel agent config", Context: "workspace-agent-config", Priority: 1}, + {ID: "confirm", Name: "Start", Description: "Start agent with config", Context: "workspace-agent-config", Priority: 2}, + } +``` + +In the `FocusContext()` switch (around line 249), add before the `case ViewModeAgentChoice:` line: + +```go + case ViewModeAgentConfig: + return "workspace-agent-config" +``` + +- [ ] **Step 3: Add mouse handling in mouse.go** + +In `internal/plugins/workspace/mouse.go`, add the click handler function (after `handleAgentChoiceModalMouse`, around line 316): + +```go +func (p *Plugin) handleAgentConfigModalMouse(msg tea.MouseMsg) tea.Cmd { + p.ensureAgentConfigModal() + if p.agentConfigModal == nil { + return nil + } + + prevAgentIdx := p.agentConfigAgentIdx + action := p.agentConfigModal.HandleMouse(msg, p.mouseHandler) + + // Sync agent type when list selection changes via mouse + if p.agentConfigAgentIdx != prevAgentIdx { + if p.agentConfigAgentIdx >= 0 && p.agentConfigAgentIdx < len(AgentTypeOrder) { + p.agentConfigAgentType = AgentTypeOrder[p.agentConfigAgentIdx] + } + } + + switch action { + case "": + return nil + case "cancel", agentConfigCancelID: + p.viewMode = ViewModeList + p.clearAgentConfigModal() + return nil + case agentConfigPromptFieldID: + p.promptPickerReturnMode = ViewModeAgentConfig + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + p.clearPromptPickerModal() + p.viewMode = ViewModePromptPicker + return nil + case agentConfigSubmitID: + return p.executeAgentConfig() + } + return nil +} +``` + +In the `handleMouse` function (around line 67), add before the `if p.viewMode == ViewModeAgentChoice {` line: + +```go + if p.viewMode == ViewModeAgentConfig { + return p.handleAgentConfigModalMouse(msg) + } +``` + +In the `handleMouseHover` function's switch (around line 424), add before the `case ViewModeAgentChoice:` line: + +```go + case ViewModeAgentConfig: + // Modal library handles hover state internally + return nil +``` + +- [ ] **Step 4: Add keymap registration in plugin.go Init()** + +In `internal/plugins/workspace/plugin.go`, in `Init()` after the agent choice modal context keybindings (after line 445), add: + +```go + // Agent config modal context + ctx.Keymap.RegisterPluginBinding("esc", "cancel", "workspace-agent-config") + ctx.Keymap.RegisterPluginBinding("enter", "confirm", "workspace-agent-config") + ctx.Keymap.RegisterPluginBinding("tab", "next-field", "workspace-agent-config") + ctx.Keymap.RegisterPluginBinding("shift+tab", "prev-field", "workspace-agent-config") +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 6: Run all tests** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -v -count=1 2>&1 | tail -5` +Expected: All tests pass + +- [ ] **Step 7: Commit** + +```bash +git add internal/plugins/workspace/view_list.go internal/plugins/workspace/commands.go internal/plugins/workspace/mouse.go internal/plugins/workspace/plugin.go +git commit -m "feat: wire agent config modal into view, commands, mouse, and keymap" +``` + +--- + +## Chunk 4: Entry Points — 's' Key & Restart Flow + +### Task 9: Modify 's' key handler to open agent config modal + +**Files:** +- Modify: `internal/plugins/workspace/keys.go:799-813` + +- [ ] **Step 1: Replace the direct StartAgent call with modal opening** + +In `internal/plugins/workspace/keys.go`, replace the `case "s":` block (lines 799-813). The current code: + +```go + case "s": + // Start agent on selected worktree + wt := p.selectedWorktree() + if wt == nil { + return nil + } + if wt.Agent == nil { + // No agent running - start new one + return p.StartAgent(wt, p.resolveWorktreeAgentType(wt)) + } + // Agent exists - show choice modal (attach or restart) + p.agentChoiceWorktree = wt + p.agentChoiceIdx = 0 // Default to attach + p.viewMode = ViewModeAgentChoice + return nil +``` + +Replace with: + +```go + case "s": + // Start agent on selected worktree + wt := p.selectedWorktree() + if wt == nil { + return nil + } + if wt.Agent == nil { + // No agent running - open agent config modal + home, _ := os.UserHomeDir() + configDir := filepath.Join(home, ".config", "sidecar") + p.agentConfigWorktree = wt + p.agentConfigIsRestart = false + p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) + p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 + p.viewMode = ViewModeAgentConfig + return nil + } + // Agent exists - show choice modal (attach or restart) + p.agentChoiceWorktree = wt + p.agentChoiceIdx = 0 // Default to attach + p.viewMode = ViewModeAgentChoice + return nil +``` + +**Important:** `keys.go` does NOT currently import `os` or `path/filepath`. Add them to the import block: + +```go +import ( + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + appmsg "github.com/marcus/sidecar/internal/msg" + "github.com/marcus/sidecar/internal/state" +) +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 3: Commit** + +```bash +git add internal/plugins/workspace/keys.go +git commit -m "feat: 's' key opens agent config modal instead of starting directly" +``` + +### Task 10: Modify executeAgentChoice to transition to agent config on restart + +**Files:** +- Modify: `internal/plugins/workspace/keys.go:286-305` + +- [ ] **Step 1: Replace the restart branch in executeAgentChoice** + +In `internal/plugins/workspace/keys.go`, in `executeAgentChoice()`, replace the restart branch (starting at the comment `// Restart agent: stop first, then start`). The current code: + +```go + // Restart agent: stop first, then start + return tea.Sequence( + p.StopAgent(wt), + func() tea.Msg { + return restartAgentMsg{worktree: wt} + }, + ) +``` + +Replace with: + +```go + // Restart agent: open config modal to choose options + home, _ := os.UserHomeDir() + configDir := filepath.Join(home, ".config", "sidecar") + p.agentConfigWorktree = wt + p.agentConfigIsRestart = true + p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) + p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 + p.viewMode = ViewModeAgentConfig + return nil +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 3: Commit** + +```bash +git add internal/plugins/workspace/keys.go +git commit -m "feat: restart from agent choice modal opens agent config instead" +``` + +--- + +## Chunk 5: Prompt Picker Return Routing + +### Task 11: Add promptPickerReturnMode to existing create modal flow + +**Files:** +- Modify: `internal/plugins/workspace/keys.go:1003-1007` and `keys.go:1025-1029` + +- [ ] **Step 1: Write test for prompt picker return routing** + +Add to `internal/plugins/workspace/agent_config_modal_test.go`: + +```go +func TestPromptPickerReturnMode_AgentConfig(t *testing.T) { + p := &Plugin{ + promptPickerReturnMode: ViewModeAgentConfig, + agentConfigPrompts: []Prompt{{Name: "test-prompt", Body: "do stuff"}}, + agentConfigPromptIdx: -1, + } + + // Simulate PromptSelectedMsg being handled — the logic we're testing + // is in update.go, so this test verifies the state after handling. + // The actual handler integration is tested by the update.go modification. + + // Verify initial state + if p.agentConfigPromptIdx != -1 { + t.Error("expected initial promptIdx to be -1") + } + if p.promptPickerReturnMode != ViewModeAgentConfig { + t.Error("expected return mode to be ViewModeAgentConfig") + } +} +``` + +- [ ] **Step 2: Set promptPickerReturnMode = ViewModeCreate in existing prompt picker openers** + +In `internal/plugins/workspace/keys.go`, at line 1003 (where create modal opens prompt picker via `focusID == createPromptFieldID`), add before the `p.promptPicker = ...` line: + +```go + p.promptPickerReturnMode = ViewModeCreate +``` + +At line 1025 (where create modal opens prompt picker via `p.createFocus == 2`), add before the `p.promptPicker = ...` line: + +```go + p.promptPickerReturnMode = ViewModeCreate +``` + +Also update `mouse.go` for mouse-based prompt picker opening. There are **two** places: + +1. In `handleCreateModalMouse()` around line 118-123 (the `case createPromptFieldID:` branch), add before `p.promptPicker = ...`: + ```go + p.promptPickerReturnMode = ViewModeCreate + ``` + +2. In `handleMouseClick()` around line 626-629 (the `if focusIdx == 2 {` block inside `case regionCreateInput:`), add before `p.promptPicker = ...`: + ```go + p.promptPickerReturnMode = ViewModeCreate + ``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 4: Commit** + +```bash +git add internal/plugins/workspace/keys.go internal/plugins/workspace/mouse.go internal/plugins/workspace/agent_config_modal_test.go +git commit -m "feat: set promptPickerReturnMode in existing create modal flow" +``` + +### Task 12: Modify PromptSelectedMsg and PromptCancelledMsg handlers + +**Files:** +- Modify: `internal/plugins/workspace/update.go:218-246` + +- [ ] **Step 1: Replace PromptSelectedMsg handler** + +In `internal/plugins/workspace/update.go`, replace the `case PromptSelectedMsg:` handler (lines 218-240) with: + +```go + case PromptSelectedMsg: + // Prompt selected from picker + returnMode := p.promptPickerReturnMode + p.promptPicker = nil + p.clearPromptPickerModal() + + if returnMode == ViewModeAgentConfig { + p.viewMode = ViewModeAgentConfig + if msg.Prompt != nil { + for i, pr := range p.agentConfigPrompts { + if pr.Name == msg.Prompt.Name { + p.agentConfigPromptIdx = i + break + } + } + } else { + p.agentConfigPromptIdx = -1 + } + } else { + p.viewMode = ViewModeCreate + if msg.Prompt != nil { + // Find index of selected prompt + for i, pr := range p.createPrompts { + if pr.Name == msg.Prompt.Name { + p.createPromptIdx = i + break + } + } + // If ticketMode is none, skip task field and jump to agent + if msg.Prompt.TicketMode == TicketNone { + p.createFocus = 4 // agent field + } else { + p.createFocus = 3 // task field + } + } else { + p.createPromptIdx = -1 + p.createFocus = 3 // task field + } + } +``` + +- [ ] **Step 2: Replace PromptCancelledMsg handler** + +In `internal/plugins/workspace/update.go`, replace the `case PromptCancelledMsg:` handler (lines 242-246) with: + +```go + case PromptCancelledMsg: + // Picker cancelled, return to originating modal + returnMode := p.promptPickerReturnMode + p.promptPicker = nil + p.clearPromptPickerModal() + if returnMode == ViewModeAgentConfig { + p.viewMode = ViewModeAgentConfig + } else { + p.viewMode = ViewModeCreate + } +``` + +- [ ] **Step 3: Modify PromptInstallDefaultsMsg handler** + +In `internal/plugins/workspace/update.go`, in the `case PromptInstallDefaultsMsg:` handler (lines 248-265), replace the successful path (lines 257-260): + +```go + if WriteDefaultPromptsToConfig(configDir) { + p.createPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) + p.clearPromptPickerModal() +``` + +with: + +```go + if WriteDefaultPromptsToConfig(configDir) { + if p.promptPickerReturnMode == ViewModeAgentConfig { + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + } else { + p.createPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) + } + p.clearPromptPickerModal() +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` +Expected: Success + +- [ ] **Step 5: Run all tests** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -v -count=1 2>&1 | tail -10` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add internal/plugins/workspace/update.go +git commit -m "feat: route prompt picker return to agent config or create modal" +``` + +--- + +## Chunk 6: Final Integration Test & Cleanup + +### Task 13: Run full test suite and verify build + +**Files:** +- No new files + +- [ ] **Step 1: Run full test suite** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./... 2>&1 | tail -20` +Expected: All tests pass + +- [ ] **Step 2: Build the binary** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./cmd/sidecar` +Expected: Success + +- [ ] **Step 3: Verify no compilation warnings or vet issues** + +Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go vet ./internal/plugins/workspace/...` +Expected: No issues + +- [ ] **Step 4: Final commit if any cleanup was needed** + +Only commit if changes were made during cleanup. Otherwise skip. From 41ad7efdf499687a936a2bd44da961f2eb25a2d8 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:17:13 +0100 Subject: [PATCH 03/16] feat: add ViewModeAgentConfig constant Co-Authored-By: Claude Sonnet 4.6 --- internal/plugins/workspace/types.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/plugins/workspace/types.go b/internal/plugins/workspace/types.go index 2e599a28..3bd89a40 100644 --- a/internal/plugins/workspace/types.go +++ b/internal/plugins/workspace/types.go @@ -38,6 +38,7 @@ const ( ViewModeFilePicker // Diff file picker modal ViewModeInteractive // Interactive mode (tmux input passthrough) ViewModeFetchPR // Fetch remote PR modal + ViewModeAgentConfig // Agent config modal (start/restart with options) ) // FocusPane represents which pane is active in the split view. From 6d919797f05af0515df9d97cbd4a691353f30fbe Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:17:16 +0100 Subject: [PATCH 04/16] feat: add restartAgentWithOptionsMsg message type Co-Authored-By: Claude Sonnet 4.6 --- internal/plugins/workspace/messages.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/plugins/workspace/messages.go b/internal/plugins/workspace/messages.go index 7b42e771..846864ca 100644 --- a/internal/plugins/workspace/messages.go +++ b/internal/plugins/workspace/messages.go @@ -188,6 +188,14 @@ type restartAgentMsg struct { worktree *Worktree } +// restartAgentWithOptionsMsg signals that an agent should be restarted with specific options. +type restartAgentWithOptionsMsg struct { + worktree *Worktree + agentType AgentType + skipPerms bool + prompt *Prompt +} + // CommitStatusLoadedMsg delivers commit status info for the diff view header. type CommitStatusLoadedMsg struct { Epoch uint64 // Epoch when request was issued (for stale detection) From 66a8ea8399809d79cddf6c221d4b1a4f18bc09d0 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:17:19 +0100 Subject: [PATCH 05/16] feat: add agent config modal state fields to Plugin Co-Authored-By: Claude Sonnet 4.6 --- internal/plugins/workspace/plugin.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/plugins/workspace/plugin.go b/internal/plugins/workspace/plugin.go index 0dda399c..27b37047 100644 --- a/internal/plugins/workspace/plugin.go +++ b/internal/plugins/workspace/plugin.go @@ -246,6 +246,21 @@ type Plugin struct { agentChoiceModal *modal.Modal // Modal instance agentChoiceModalWidth int // Cached width for rebuild detection + // Agent config modal state (start/restart with options) + agentConfigWorktree *Worktree + agentConfigIsRestart bool + agentConfigAgentType AgentType + agentConfigAgentIdx int + agentConfigSkipPerms bool + agentConfigPromptIdx int + agentConfigPrompts []Prompt + agentConfigModal *modal.Modal + agentConfigModalWidth int + agentConfigFocusSet bool + + // Prompt picker return routing + promptPickerReturnMode ViewMode + // Delete confirmation modal state deleteConfirmWorktree *Worktree // Worktree pending deletion deleteLocalBranchOpt bool // Checkbox: delete local branch From a8523ed49aa520aba464769ebd769d7431ba8b44 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:17:22 +0100 Subject: [PATCH 06/16] feat: handle restartAgentWithOptionsMsg in update loop Co-Authored-By: Claude Sonnet 4.6 --- internal/plugins/workspace/update.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/plugins/workspace/update.go b/internal/plugins/workspace/update.go index 97ea6a0a..de09cb06 100644 --- a/internal/plugins/workspace/update.go +++ b/internal/plugins/workspace/update.go @@ -971,6 +971,13 @@ func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) { } return p, nil + case restartAgentWithOptionsMsg: + // Start new agent after stop completed, with user-selected options + if msg.worktree != nil { + return p, p.StartAgentWithOptions(msg.worktree, msg.agentType, msg.skipPerms, msg.prompt) + } + return p, nil + case TmuxAttachFinishedMsg: // Clear attached state p.attachedSession = "" From 17eecab70c6470d7a8331b5b9b6e0af6a7df5237 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:20:06 +0100 Subject: [PATCH 07/16] feat: add agent config modal constants, helpers, and tests --- .../plugins/workspace/agent_config_modal.go | 212 ++++++++++++++++++ .../workspace/agent_config_modal_test.go | 105 +++++++++ 2 files changed, 317 insertions(+) create mode 100644 internal/plugins/workspace/agent_config_modal.go create mode 100644 internal/plugins/workspace/agent_config_modal_test.go diff --git a/internal/plugins/workspace/agent_config_modal.go b/internal/plugins/workspace/agent_config_modal.go new file mode 100644 index 00000000..1767b757 --- /dev/null +++ b/internal/plugins/workspace/agent_config_modal.go @@ -0,0 +1,212 @@ +package workspace + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/x/ansi" + "github.com/marcus/sidecar/internal/modal" + ui "github.com/marcus/sidecar/internal/ui" +) + +const ( + agentConfigPromptFieldID = "agent-config-prompt" + agentConfigAgentListID = "agent-config-agent-list" + agentConfigSkipPermissionsID = "agent-config-skip-permissions" + agentConfigSubmitID = "agent-config-submit" + agentConfigCancelID = "agent-config-cancel" + agentConfigAgentItemPrefix = "agent-config-agent-" +) + +// clearAgentConfigModal resets all agent config modal state. +func (p *Plugin) clearAgentConfigModal() { + p.agentConfigWorktree = nil + p.agentConfigIsRestart = false + p.agentConfigAgentType = "" + p.agentConfigAgentIdx = 0 + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = nil + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 + p.agentConfigFocusSet = false +} + +// getAgentConfigPrompt resolves the selected prompt index to a *Prompt. +func (p *Plugin) getAgentConfigPrompt() *Prompt { + if p.agentConfigPromptIdx < 0 || p.agentConfigPromptIdx >= len(p.agentConfigPrompts) { + return nil + } + prompt := p.agentConfigPrompts[p.agentConfigPromptIdx] + return &prompt +} + +// shouldShowAgentConfigSkipPerms returns true if the selected agent supports skip permissions. +func (p *Plugin) shouldShowAgentConfigSkipPerms() bool { + if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { + return false + } + flag, ok := SkipPermissionsFlags[p.agentConfigAgentType] + return ok && flag != "" +} + +// ensureAgentConfigModal builds or rebuilds the agent config modal. +func (p *Plugin) ensureAgentConfigModal() { + if p.agentConfigWorktree == nil { + return + } + + modalW := 60 + maxW := p.width - 4 + if maxW < 1 { + maxW = 1 + } + if modalW > maxW { + modalW = maxW + } + + if p.agentConfigModal != nil && p.agentConfigModalWidth == modalW { + return + } + p.agentConfigModalWidth = modalW + + items := make([]modal.ListItem, len(AgentTypeOrder)) + for i, at := range AgentTypeOrder { + items[i] = modal.ListItem{ + ID: fmt.Sprintf("%s%d", agentConfigAgentItemPrefix, i), + Label: AgentDisplayNames[at], + } + } + + title := fmt.Sprintf("Start Agent: %s", p.agentConfigWorktree.Name) + if p.agentConfigIsRestart { + title = fmt.Sprintf("Restart Agent: %s", p.agentConfigWorktree.Name) + } + + p.agentConfigModal = modal.New(title, + modal.WithWidth(modalW), + modal.WithPrimaryAction(agentConfigSubmitID), + modal.WithHints(false), + ). + AddSection(p.agentConfigPromptSection()). + AddSection(modal.Spacer()). + AddSection(p.agentConfigAgentLabelSection()). + AddSection(modal.List(agentConfigAgentListID, items, &p.agentConfigAgentIdx, modal.WithMaxVisible(len(items)))). + AddSection(p.agentConfigSkipPermissionsSpacerSection()). + AddSection(modal.When(p.shouldShowAgentConfigSkipPerms, modal.Checkbox(agentConfigSkipPermissionsID, "Auto-approve all actions", &p.agentConfigSkipPerms))). + AddSection(p.agentConfigSkipPermissionsHintSection()). + AddSection(modal.Spacer()). + AddSection(modal.Buttons( + modal.Btn(" Start ", agentConfigSubmitID), + modal.Btn(" Cancel ", agentConfigCancelID), + )) +} + +// syncAgentConfigModalFocus sets initial focus if not already set. +func (p *Plugin) syncAgentConfigModalFocus() { + if p.agentConfigModal == nil { + return + } + if !p.agentConfigFocusSet { + p.agentConfigModal.SetFocus(agentConfigAgentListID) + p.agentConfigFocusSet = true + } +} + +// agentConfigPromptSection renders the prompt selector field. +func (p *Plugin) agentConfigPromptSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + lines := make([]string, 0, 4) + focusables := make([]modal.FocusableInfo, 0, 1) + + lines = append(lines, "Prompt:") + + selectedPrompt := p.getAgentConfigPrompt() + displayText := "(none)" + if len(p.agentConfigPrompts) == 0 { + displayText = "No prompts configured" + } else if selectedPrompt != nil { + scopeIndicator := "[G] global" + if selectedPrompt.Source == "project" { + scopeIndicator = "[P] project" + } + displayText = fmt.Sprintf("%s %s", selectedPrompt.Name, dimText(scopeIndicator)) + } + + promptStyle := inputStyle() + if focusID == agentConfigPromptFieldID { + promptStyle = inputFocusedStyle() + } + rendered := promptStyle.Render(displayText) + renderedLines := strings.Split(rendered, "\n") + displayStartY := len(lines) + lines = append(lines, renderedLines...) + + focusables = append(focusables, modal.FocusableInfo{ + ID: agentConfigPromptFieldID, + OffsetX: 0, + OffsetY: displayStartY, + Width: ansi.StringWidth(rendered), + Height: len(renderedLines), + }) + + if len(p.agentConfigPrompts) == 0 { + lines = append(lines, dimText(" See: .claude/skills/create-prompt/SKILL.md")) + } else if selectedPrompt == nil { + lines = append(lines, dimText(" Press Enter to select a prompt template")) + } else { + preview := strings.ReplaceAll(selectedPrompt.Body, "\n", " ") + if runes := []rune(preview); len(runes) > 60 { + preview = string(runes[:57]) + "..." + } + lines = append(lines, dimText(fmt.Sprintf(" Preview: %s", preview))) + } + + return modal.RenderedSection{Content: strings.Join(lines, "\n"), Focusables: focusables} + }, nil) +} + +// agentConfigAgentLabelSection renders the "Agent:" label. +func (p *Plugin) agentConfigAgentLabelSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + return modal.RenderedSection{Content: "Agent:"} + }, nil) +} + +// agentConfigSkipPermissionsSpacerSection renders a spacer before the checkbox (hidden when agent is None). +func (p *Plugin) agentConfigSkipPermissionsSpacerSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { + return modal.RenderedSection{} + } + return modal.RenderedSection{Content: " "} + }, nil) +} + +// agentConfigSkipPermissionsHintSection renders the hint showing the actual flag. +func (p *Plugin) agentConfigSkipPermissionsHintSection() modal.Section { + return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { + if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { + return modal.RenderedSection{} + } + if p.shouldShowAgentConfigSkipPerms() { + flag := SkipPermissionsFlags[p.agentConfigAgentType] + return modal.RenderedSection{Content: dimText(fmt.Sprintf(" (Adds %s)", flag))} + } + return modal.RenderedSection{Content: dimText(" Skip permissions not available for this agent")} + }, nil) +} + +// renderAgentConfigModal renders the agent config modal over a dimmed background. +func (p *Plugin) renderAgentConfigModal(width, height int) string { + background := p.renderListView(width, height) + + p.ensureAgentConfigModal() + if p.agentConfigModal == nil { + return background + } + + p.syncAgentConfigModalFocus() + modalContent := p.agentConfigModal.Render(width, height, p.mouseHandler) + return ui.OverlayModal(background, modalContent, width, height) +} diff --git a/internal/plugins/workspace/agent_config_modal_test.go b/internal/plugins/workspace/agent_config_modal_test.go new file mode 100644 index 00000000..e18007a8 --- /dev/null +++ b/internal/plugins/workspace/agent_config_modal_test.go @@ -0,0 +1,105 @@ +package workspace + +import "testing" + +func TestGetAgentConfigPrompt(t *testing.T) { + tests := []struct { + name string + prompts []Prompt + idx int + wantNil bool + wantName string + }{ + {"negative index", []Prompt{{Name: "a"}}, -1, true, ""}, + {"out of bounds", []Prompt{{Name: "a"}}, 5, true, ""}, + {"nil prompts", nil, 0, true, ""}, + {"valid index", []Prompt{{Name: "first"}, {Name: "second"}}, 1, false, "second"}, + {"first index", []Prompt{{Name: "only"}}, 0, false, "only"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{ + agentConfigPrompts: tt.prompts, + agentConfigPromptIdx: tt.idx, + } + got := p.getAgentConfigPrompt() + if tt.wantNil { + if got != nil { + t.Errorf("expected nil, got %+v", got) + } + return + } + if got == nil { + t.Fatal("expected non-nil prompt") + } + if got.Name != tt.wantName { + t.Errorf("expected name %q, got %q", tt.wantName, got.Name) + } + }) + } +} + +func TestClearAgentConfigModal(t *testing.T) { + p := &Plugin{ + agentConfigWorktree: &Worktree{Name: "test"}, + agentConfigIsRestart: true, + agentConfigAgentType: AgentClaude, + agentConfigAgentIdx: 3, + agentConfigSkipPerms: true, + agentConfigPromptIdx: 2, + agentConfigPrompts: []Prompt{{Name: "x"}}, + } + p.clearAgentConfigModal() + + if p.agentConfigWorktree != nil { + t.Error("worktree not cleared") + } + if p.agentConfigIsRestart { + t.Error("isRestart not cleared") + } + if p.agentConfigAgentType != "" { + t.Error("agentType not cleared") + } + if p.agentConfigAgentIdx != 0 { + t.Error("agentIdx not cleared") + } + if p.agentConfigSkipPerms { + t.Error("skipPerms not cleared") + } + if p.agentConfigPromptIdx != -1 { + t.Error("promptIdx not cleared") + } + if p.agentConfigPrompts != nil { + t.Error("prompts not cleared") + } + if p.agentConfigModal != nil { + t.Error("modal not cleared") + } + if p.agentConfigModalWidth != 0 { + t.Error("modalWidth not cleared") + } + if p.agentConfigFocusSet { + t.Error("focusSet not cleared") + } +} + +func TestShouldShowAgentConfigSkipPerms(t *testing.T) { + tests := []struct { + name string + agentType AgentType + want bool + }{ + {"claude has flag", AgentClaude, true}, + {"codex has flag", AgentCodex, true}, + {"none has no flag", AgentNone, false}, + {"opencode has no flag", AgentOpenCode, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Plugin{agentConfigAgentType: tt.agentType} + if got := p.shouldShowAgentConfigSkipPerms(); got != tt.want { + t.Errorf("shouldShowAgentConfigSkipPerms() = %v, want %v", got, tt.want) + } + }) + } +} From 705716d7e770d9141f5ef076924bf0387a94c6f8 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:23:08 +0100 Subject: [PATCH 08/16] feat: add handleAgentConfigKeys and executeAgentConfig Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/plugins/workspace/agent.go | 5 +- .../workspace/agent_config_modal_test.go | 61 ++++++++++++++++ internal/plugins/workspace/keys.go | 69 +++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/internal/plugins/workspace/agent.go b/internal/plugins/workspace/agent.go index ae0bb89e..e2dd1aa6 100644 --- a/internal/plugins/workspace/agent.go +++ b/internal/plugins/workspace/agent.go @@ -657,7 +657,10 @@ func (p *Plugin) getAgentCommandWithContext(agentType AgentType, wt *Worktree) s // StartAgentWithOptions creates a tmux session and starts an agent with options. // If a session already exists, it reconnects to it instead of failing. func (p *Plugin) StartAgentWithOptions(wt *Worktree, agentType AgentType, skipPerms bool, prompt *Prompt) tea.Cmd { - epoch := p.ctx.Epoch // Capture epoch for stale detection + var epoch uint64 + if p.ctx != nil { + epoch = p.ctx.Epoch // Capture epoch for stale detection + } return func() tea.Msg { sessionName := tmuxSessionPrefix + sanitizeName(wt.Name) diff --git a/internal/plugins/workspace/agent_config_modal_test.go b/internal/plugins/workspace/agent_config_modal_test.go index e18007a8..c918fb80 100644 --- a/internal/plugins/workspace/agent_config_modal_test.go +++ b/internal/plugins/workspace/agent_config_modal_test.go @@ -103,3 +103,64 @@ func TestShouldShowAgentConfigSkipPerms(t *testing.T) { }) } } + +func TestExecuteAgentConfig_FreshStart(t *testing.T) { + wt := &Worktree{Name: "test-wt", Path: "/tmp/test"} + p := &Plugin{ + agentConfigWorktree: wt, + agentConfigIsRestart: false, + agentConfigAgentType: AgentClaude, + agentConfigSkipPerms: true, + agentConfigPromptIdx: -1, + viewMode: ViewModeAgentConfig, + } + + cmd := p.executeAgentConfig() + + if p.viewMode != ViewModeList { + t.Errorf("expected ViewModeList, got %v", p.viewMode) + } + if p.agentConfigWorktree != nil { + t.Error("worktree should be cleared") + } + if cmd == nil { + t.Error("expected non-nil cmd for fresh start") + } +} + +func TestExecuteAgentConfig_Restart(t *testing.T) { + wt := &Worktree{Name: "test-wt", Path: "/tmp/test"} + p := &Plugin{ + agentConfigWorktree: wt, + agentConfigIsRestart: true, + agentConfigAgentType: AgentCodex, + agentConfigSkipPerms: false, + agentConfigPromptIdx: -1, + viewMode: ViewModeAgentConfig, + } + + cmd := p.executeAgentConfig() + + if p.viewMode != ViewModeList { + t.Errorf("expected ViewModeList, got %v", p.viewMode) + } + if cmd == nil { + t.Error("expected non-nil cmd for restart") + } +} + +func TestExecuteAgentConfig_NilWorktree(t *testing.T) { + p := &Plugin{ + agentConfigWorktree: nil, + viewMode: ViewModeAgentConfig, + } + + cmd := p.executeAgentConfig() + + if p.viewMode != ViewModeList { + t.Errorf("expected ViewModeList, got %v", p.viewMode) + } + if cmd != nil { + t.Error("expected nil cmd for nil worktree") + } +} diff --git a/internal/plugins/workspace/keys.go b/internal/plugins/workspace/keys.go index 9e1a3a53..25c95c8d 100644 --- a/internal/plugins/workspace/keys.go +++ b/internal/plugins/workspace/keys.go @@ -24,6 +24,8 @@ func (p *Plugin) handleKeyPress(msg tea.KeyMsg) tea.Cmd { return p.handleTaskLinkKeys(msg) case ViewModeMerge: return p.handleMergeKeys(msg) + case ViewModeAgentConfig: + return p.handleAgentConfigKeys(msg) case ViewModeAgentChoice: return p.handleAgentChoiceKeys(msg) case ViewModeConfirmDelete: @@ -282,6 +284,73 @@ func (p *Plugin) handleAgentChoiceKeys(msg tea.KeyMsg) tea.Cmd { return cmd } +// handleAgentConfigKeys handles keys in agent config modal. +func (p *Plugin) handleAgentConfigKeys(msg tea.KeyMsg) tea.Cmd { + p.ensureAgentConfigModal() + if p.agentConfigModal == nil { + return nil + } + + prevAgentIdx := p.agentConfigAgentIdx + action, cmd := p.agentConfigModal.HandleKey(msg) + + // Sync agent type when selection changes + if p.agentConfigAgentIdx != prevAgentIdx { + if p.agentConfigAgentIdx >= 0 && p.agentConfigAgentIdx < len(AgentTypeOrder) { + p.agentConfigAgentType = AgentTypeOrder[p.agentConfigAgentIdx] + } + } + + switch action { + case "cancel", agentConfigCancelID: + p.viewMode = ViewModeList + p.clearAgentConfigModal() + return nil + case agentConfigPromptFieldID: + // Open prompt picker, set return mode to agent config + p.promptPickerReturnMode = ViewModeAgentConfig + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + p.clearPromptPickerModal() + p.viewMode = ViewModePromptPicker + return nil + case agentConfigSubmitID: + return p.executeAgentConfig() + } + + return cmd +} + +// executeAgentConfig executes the agent config modal action (start or restart). +func (p *Plugin) executeAgentConfig() tea.Cmd { + wt := p.agentConfigWorktree + agentType := p.agentConfigAgentType + skipPerms := p.agentConfigSkipPerms + prompt := p.getAgentConfigPrompt() + isRestart := p.agentConfigIsRestart + + p.viewMode = ViewModeList + p.clearAgentConfigModal() + + if wt == nil { + return nil + } + + if isRestart { + return tea.Sequence( + p.StopAgent(wt), + func() tea.Msg { + return restartAgentWithOptionsMsg{ + worktree: wt, + agentType: agentType, + skipPerms: skipPerms, + prompt: prompt, + } + }, + ) + } + return p.StartAgentWithOptions(wt, agentType, skipPerms, prompt) +} + // executeAgentChoice executes the selected agent choice action. func (p *Plugin) executeAgentChoice() tea.Cmd { wt := p.agentChoiceWorktree From 76629bf92b4a5f614cba2be2607c2c156e4d532e Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:24:03 +0100 Subject: [PATCH 09/16] feat: wire agent config modal into view, commands, mouse, and keymap Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/plugins/workspace/commands.go | 7 +++++ internal/plugins/workspace/mouse.go | 42 +++++++++++++++++++++++++ internal/plugins/workspace/plugin.go | 6 ++++ internal/plugins/workspace/view_list.go | 2 ++ 4 files changed, 57 insertions(+) diff --git a/internal/plugins/workspace/commands.go b/internal/plugins/workspace/commands.go index 13acc62c..26bd00fd 100644 --- a/internal/plugins/workspace/commands.go +++ b/internal/plugins/workspace/commands.go @@ -45,6 +45,11 @@ func (p *Plugin) Commands() []plugin.Command { } } return cmds + case ViewModeAgentConfig: + return []plugin.Command{ + {ID: "cancel", Name: "Cancel", Description: "Cancel agent config", Context: "workspace-agent-config", Priority: 1}, + {ID: "confirm", Name: "Start", Description: "Start agent with config", Context: "workspace-agent-config", Priority: 2}, + } case ViewModeAgentChoice: return []plugin.Command{ {ID: "cancel", Name: "Cancel", Description: "Cancel agent choice", Context: "workspace-agent-choice", Priority: 1}, @@ -246,6 +251,8 @@ func (p *Plugin) FocusContext() string { return "workspace-merge-error" } return "workspace-merge" + case ViewModeAgentConfig: + return "workspace-agent-config" case ViewModeAgentChoice: return "workspace-agent-choice" case ViewModeConfirmDelete: diff --git a/internal/plugins/workspace/mouse.go b/internal/plugins/workspace/mouse.go index 39dfe18e..60a64e28 100644 --- a/internal/plugins/workspace/mouse.go +++ b/internal/plugins/workspace/mouse.go @@ -64,6 +64,10 @@ func (p *Plugin) handleMouse(msg tea.MouseMsg) tea.Cmd { return p.handleTypeSelectorModalMouse(msg) } + if p.viewMode == ViewModeAgentConfig { + return p.handleAgentConfigModalMouse(msg) + } + if p.viewMode == ViewModeAgentChoice { return p.handleAgentChoiceModalMouse(msg) } @@ -315,6 +319,41 @@ func (p *Plugin) handleAgentChoiceModalMouse(msg tea.MouseMsg) tea.Cmd { return nil } +func (p *Plugin) handleAgentConfigModalMouse(msg tea.MouseMsg) tea.Cmd { + p.ensureAgentConfigModal() + if p.agentConfigModal == nil { + return nil + } + + prevAgentIdx := p.agentConfigAgentIdx + action := p.agentConfigModal.HandleMouse(msg, p.mouseHandler) + + // Sync agent type when list selection changes via mouse + if p.agentConfigAgentIdx != prevAgentIdx { + if p.agentConfigAgentIdx >= 0 && p.agentConfigAgentIdx < len(AgentTypeOrder) { + p.agentConfigAgentType = AgentTypeOrder[p.agentConfigAgentIdx] + } + } + + switch action { + case "": + return nil + case "cancel", agentConfigCancelID: + p.viewMode = ViewModeList + p.clearAgentConfigModal() + return nil + case agentConfigPromptFieldID: + p.promptPickerReturnMode = ViewModeAgentConfig + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + p.clearPromptPickerModal() + p.viewMode = ViewModePromptPicker + return nil + case agentConfigSubmitID: + return p.executeAgentConfig() + } + return nil +} + func (p *Plugin) handleFetchPRModalMouse(msg tea.MouseMsg) tea.Cmd { p.ensureFetchPRModal() if p.fetchPRModal == nil { @@ -421,6 +460,9 @@ func (p *Plugin) handleMouseHover(action mouse.MouseAction) tea.Cmd { default: p.createButtonHover = 0 } + case ViewModeAgentConfig: + // Modal library handles hover state internally + return nil case ViewModeAgentChoice: // Modal library handles hover state internally return nil diff --git a/internal/plugins/workspace/plugin.go b/internal/plugins/workspace/plugin.go index 27b37047..39b907d9 100644 --- a/internal/plugins/workspace/plugin.go +++ b/internal/plugins/workspace/plugin.go @@ -459,6 +459,12 @@ func (p *Plugin) Init(ctx *plugin.Context) error { ctx.Keymap.RegisterPluginBinding("down", "cursor-down", "workspace-agent-choice") ctx.Keymap.RegisterPluginBinding("up", "cursor-up", "workspace-agent-choice") + // Agent config modal context + ctx.Keymap.RegisterPluginBinding("esc", "cancel", "workspace-agent-config") + ctx.Keymap.RegisterPluginBinding("enter", "confirm", "workspace-agent-config") + ctx.Keymap.RegisterPluginBinding("tab", "next-field", "workspace-agent-config") + ctx.Keymap.RegisterPluginBinding("shift+tab", "prev-field", "workspace-agent-config") + // Interactive mode context - uses configured keys (td-18098d) ctx.Keymap.RegisterPluginBinding(p.getInteractiveExitKey(), "exit-interactive", "workspace-interactive") ctx.Keymap.RegisterPluginBinding(p.getInteractiveCopyKey(), "copy", "workspace-interactive") diff --git a/internal/plugins/workspace/view_list.go b/internal/plugins/workspace/view_list.go index 97cdca3d..e4ef2f5c 100644 --- a/internal/plugins/workspace/view_list.go +++ b/internal/plugins/workspace/view_list.go @@ -63,6 +63,8 @@ func (p *Plugin) View(width, height int) string { return p.renderTaskLinkModal(width, height) case ViewModeMerge: return p.renderMergeModal(width, height) + case ViewModeAgentConfig: + return p.renderAgentConfigModal(width, height) case ViewModeAgentChoice: return p.renderAgentChoiceModal(width, height) case ViewModeConfirmDelete: From ca77c2f6e071835ec4aa868f55809b42e8d818fb Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:25:13 +0100 Subject: [PATCH 10/16] feat: 's' key opens agent config modal instead of starting directly --- internal/plugins/workspace/keys.go | 39 +++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/internal/plugins/workspace/keys.go b/internal/plugins/workspace/keys.go index 25c95c8d..de07273b 100644 --- a/internal/plugins/workspace/keys.go +++ b/internal/plugins/workspace/keys.go @@ -3,7 +3,9 @@ package workspace import ( "fmt" "math" + "os" "os/exec" + "path/filepath" "strings" "time" @@ -364,13 +366,20 @@ func (p *Plugin) executeAgentChoice() tea.Cmd { // Attach to existing session return p.AttachToSession(wt) } - // Restart agent: stop first, then start - return tea.Sequence( - p.StopAgent(wt), - func() tea.Msg { - return restartAgentMsg{worktree: wt} - }, - ) + // Restart agent: open config modal to choose options + home, _ := os.UserHomeDir() + configDir := filepath.Join(home, ".config", "sidecar") + p.agentConfigWorktree = wt + p.agentConfigIsRestart = true + p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) + p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 + p.viewMode = ViewModeAgentConfig + return nil } // handleConfirmDeleteKeys handles keys in delete confirmation modal. @@ -872,8 +881,20 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { return nil } if wt.Agent == nil { - // No agent running - start new one - return p.StartAgent(wt, p.resolveWorktreeAgentType(wt)) + // No agent running - open agent config modal + home, _ := os.UserHomeDir() + configDir := filepath.Join(home, ".config", "sidecar") + p.agentConfigWorktree = wt + p.agentConfigIsRestart = false + p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) + p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 + p.viewMode = ViewModeAgentConfig + return nil } // Agent exists - show choice modal (attach or restart) p.agentChoiceWorktree = wt From 7226505e0587841937644159321abe786374d80b Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:26:44 +0100 Subject: [PATCH 11/16] feat: set promptPickerReturnMode in existing create modal flow Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/plugins/workspace/keys.go | 2 ++ internal/plugins/workspace/mouse.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/plugins/workspace/keys.go b/internal/plugins/workspace/keys.go index de07273b..b8436acb 100644 --- a/internal/plugins/workspace/keys.go +++ b/internal/plugins/workspace/keys.go @@ -1091,6 +1091,7 @@ func (p *Plugin) handleCreateKeys(msg tea.KeyMsg) tea.Cmd { return nil } if focusID == createPromptFieldID { + p.promptPickerReturnMode = ViewModeCreate p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) p.clearPromptPickerModal() p.viewMode = ViewModePromptPicker @@ -1113,6 +1114,7 @@ func (p *Plugin) handleCreateKeys(msg tea.KeyMsg) tea.Cmd { return nil } if p.createFocus == 2 { + p.promptPickerReturnMode = ViewModeCreate p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) p.clearPromptPickerModal() p.viewMode = ViewModePromptPicker diff --git a/internal/plugins/workspace/mouse.go b/internal/plugins/workspace/mouse.go index 60a64e28..2161e08e 100644 --- a/internal/plugins/workspace/mouse.go +++ b/internal/plugins/workspace/mouse.go @@ -122,6 +122,7 @@ func (p *Plugin) handleCreateModalMouse(msg tea.MouseMsg) tea.Cmd { case createPromptFieldID: p.createFocus = 2 p.syncCreateModalFocus() + p.promptPickerReturnMode = ViewModeCreate p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) p.clearPromptPickerModal() p.viewMode = ViewModePromptPicker @@ -666,6 +667,7 @@ func (p *Plugin) handleMouseClick(action mouse.MouseAction) tea.Cmd { // If clicking prompt field, open the picker if focusIdx == 2 { + p.promptPickerReturnMode = ViewModeCreate p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) p.clearPromptPickerModal() p.viewMode = ViewModePromptPicker From f9fe96f33f18edaebe8cb0cd87cd8ff64e9db084 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:27:20 +0100 Subject: [PATCH 12/16] feat: route prompt picker return to agent config or create modal Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/plugins/workspace/update.go | 60 ++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/internal/plugins/workspace/update.go b/internal/plugins/workspace/update.go index de09cb06..e9438e17 100644 --- a/internal/plugins/workspace/update.go +++ b/internal/plugins/workspace/update.go @@ -217,33 +217,54 @@ func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) { case PromptSelectedMsg: // Prompt selected from picker - p.viewMode = ViewModeCreate + returnMode := p.promptPickerReturnMode p.promptPicker = nil p.clearPromptPickerModal() - if msg.Prompt != nil { - // Find index of selected prompt - for i, pr := range p.createPrompts { - if pr.Name == msg.Prompt.Name { - p.createPromptIdx = i - break + + if returnMode == ViewModeAgentConfig { + p.viewMode = ViewModeAgentConfig + if msg.Prompt != nil { + for i, pr := range p.agentConfigPrompts { + if pr.Name == msg.Prompt.Name { + p.agentConfigPromptIdx = i + break + } } + } else { + p.agentConfigPromptIdx = -1 } - // If ticketMode is none, skip task field and jump to agent - if msg.Prompt.TicketMode == TicketNone { - p.createFocus = 4 // agent field + } else { + p.viewMode = ViewModeCreate + if msg.Prompt != nil { + // Find index of selected prompt + for i, pr := range p.createPrompts { + if pr.Name == msg.Prompt.Name { + p.createPromptIdx = i + break + } + } + // If ticketMode is none, skip task field and jump to agent + if msg.Prompt.TicketMode == TicketNone { + p.createFocus = 4 // agent field + } else { + p.createFocus = 3 // task field + } } else { + p.createPromptIdx = -1 p.createFocus = 3 // task field } - } else { - p.createPromptIdx = -1 - p.createFocus = 3 // task field } case PromptCancelledMsg: - // Picker cancelled, return to create modal - p.viewMode = ViewModeCreate + // Picker cancelled, return to originating modal + returnMode := p.promptPickerReturnMode p.promptPicker = nil p.clearPromptPickerModal() + if returnMode == ViewModeAgentConfig { + p.viewMode = ViewModeAgentConfig + } else { + p.viewMode = ViewModeCreate + } case PromptInstallDefaultsMsg: // User pressed 'd' to install default prompts @@ -255,8 +276,13 @@ func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) { } configDir := filepath.Join(home, ".config", "sidecar") if WriteDefaultPromptsToConfig(configDir) { - p.createPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) + if p.promptPickerReturnMode == ViewModeAgentConfig { + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + } else { + p.createPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) + } p.clearPromptPickerModal() } else { return p, func() tea.Msg { From f059681bff95ae8e7f186df5c790dbc4b4f2cf5c Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:42:37 +0100 Subject: [PATCH 13/16] fix: use correct background when prompt picker opens from agent config renderPromptPickerModal always used renderCreateModal as its background. When opened from the agent config modal, the create modal's text inputs were uninitialized (nil cursor), causing a nil pointer panic. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/plugins/workspace/view_modals.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/plugins/workspace/view_modals.go b/internal/plugins/workspace/view_modals.go index 186d7cd8..cc7ec0c2 100644 --- a/internal/plugins/workspace/view_modals.go +++ b/internal/plugins/workspace/view_modals.go @@ -476,8 +476,13 @@ func (p *Plugin) renderRenameShellModal(width, height int) string { // renderPromptPickerModal renders the prompt picker modal. func (p *Plugin) renderPromptPickerModal(width, height int) string { - // Render the background (create modal behind it) - background := p.renderCreateModal(width, height) + // Render the appropriate background based on which modal opened the picker + var background string + if p.promptPickerReturnMode == ViewModeAgentConfig { + background = p.renderAgentConfigModal(width, height) + } else { + background = p.renderCreateModal(width, height) + } p.ensurePromptPickerModal() if p.promptPickerModal == nil { From 79726951c47bec63b8f3503f5643d01d1ef47857 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 13:49:05 +0100 Subject: [PATCH 14/16] fix: handle Enter per-element in agent config modal The modal's HandleKey falls through to primaryAction (submit) when the focused section doesn't consume Enter. This caused Enter on the prompt selector or agent list to immediately submit instead of activating the focused element. Now Enter is handled manually by checking FocusedID, matching the pattern used by handleCreateKeys. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/plugins/workspace/keys.go | 42 +++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/internal/plugins/workspace/keys.go b/internal/plugins/workspace/keys.go index b8436acb..87aac03a 100644 --- a/internal/plugins/workspace/keys.go +++ b/internal/plugins/workspace/keys.go @@ -293,6 +293,39 @@ func (p *Plugin) handleAgentConfigKeys(msg tea.KeyMsg) tea.Cmd { return nil } + key := msg.String() + + // Handle Enter manually to respect focused element — the modal's HandleKey + // falls through to primaryAction (submit) when the focused section doesn't + // consume Enter, which incorrectly submits when prompt or agent list is focused. + if key == "enter" { + focusID := p.agentConfigModal.FocusedID() + switch { + case focusID == agentConfigPromptFieldID: + // Open prompt picker + p.promptPickerReturnMode = ViewModeAgentConfig + p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) + p.clearPromptPickerModal() + p.viewMode = ViewModePromptPicker + return nil + case focusID == agentConfigSubmitID: + return p.executeAgentConfig() + case focusID == agentConfigCancelID: + p.viewMode = ViewModeList + p.clearAgentConfigModal() + return nil + case strings.HasPrefix(focusID, agentConfigAgentItemPrefix): + // Enter on agent list item — just absorb (selection already tracked by index) + return nil + case focusID == agentConfigSkipPermissionsID: + // Toggle checkbox + p.agentConfigSkipPerms = !p.agentConfigSkipPerms + return nil + } + return nil + } + + // Delegate all other keys to the modal prevAgentIdx := p.agentConfigAgentIdx action, cmd := p.agentConfigModal.HandleKey(msg) @@ -308,15 +341,6 @@ func (p *Plugin) handleAgentConfigKeys(msg tea.KeyMsg) tea.Cmd { p.viewMode = ViewModeList p.clearAgentConfigModal() return nil - case agentConfigPromptFieldID: - // Open prompt picker, set return mode to agent config - p.promptPickerReturnMode = ViewModeAgentConfig - p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker - return nil - case agentConfigSubmitID: - return p.executeAgentConfig() } return cmd From e5c757a5c63e59f2123e4daabba35386e51a151c Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 14:19:10 +0100 Subject: [PATCH 15/16] chore: remove design spec and implementation plan docs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-agent-config-modal.md | 1201 ----------------- .../2026-03-15-agent-config-modal-design.md | 419 ------ 2 files changed, 1620 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-15-agent-config-modal.md delete mode 100644 docs/superpowers/specs/2026-03-15-agent-config-modal-design.md diff --git a/docs/superpowers/plans/2026-03-15-agent-config-modal.md b/docs/superpowers/plans/2026-03-15-agent-config-modal.md deleted file mode 100644 index ba7868cb..00000000 --- a/docs/superpowers/plans/2026-03-15-agent-config-modal.md +++ /dev/null @@ -1,1201 +0,0 @@ -# Agent Config Modal Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a modal that lets users choose agent type, skip-permissions, and prompt when starting or restarting an agent on an existing worktree. - -**Architecture:** New `agent_config_modal.go` file containing the modal builder, renderer, clear/helper functions. Existing files are modified to add: a new `ViewModeAgentConfig` constant, new Plugin state fields, key/mouse/command handlers, prompt picker return routing via `promptPickerReturnMode`, and a `restartAgentWithOptionsMsg` message type. - -**Tech Stack:** Go, Bubbletea (TUI framework), Lipgloss (styling), internal `modal` package - -**Spec:** `docs/superpowers/specs/2026-03-15-agent-config-modal-design.md` - ---- - -## Chunk 1: Foundation — Types, Messages, State Fields - -### Task 1: Add ViewModeAgentConfig constant - -**Files:** -- Modify: `internal/plugins/workspace/types.go:25-41` - -- [ ] **Step 1: Add the new view mode constant** - -In `internal/plugins/workspace/types.go`, add `ViewModeAgentConfig` to the `ViewMode` iota block, after `ViewModeFetchPR`: - -```go -ViewModeFetchPR // Fetch remote PR modal -ViewModeAgentConfig // Agent config modal (start/restart with options) -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success (new constant is unused but that's fine for iota) - -- [ ] **Step 3: Commit** - -```bash -git add internal/plugins/workspace/types.go -git commit -m "feat: add ViewModeAgentConfig constant" -``` - -### Task 2: Add restartAgentWithOptionsMsg message type - -**Files:** -- Modify: `internal/plugins/workspace/messages.go:186-189` - -- [ ] **Step 1: Add the new message struct** - -In `internal/plugins/workspace/messages.go`, after the existing `restartAgentMsg` struct (line 189), add: - -```go -// restartAgentWithOptionsMsg signals that an agent should be restarted with specific options. -type restartAgentWithOptionsMsg struct { - worktree *Worktree - agentType AgentType - skipPerms bool - prompt *Prompt -} -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 3: Commit** - -```bash -git add internal/plugins/workspace/messages.go -git commit -m "feat: add restartAgentWithOptionsMsg message type" -``` - -### Task 3: Add agent config state fields to Plugin struct - -**Files:** -- Modify: `internal/plugins/workspace/plugin.go:243-247` - -- [ ] **Step 1: Add state fields after the existing agent choice modal state block** - -In `internal/plugins/workspace/plugin.go`, after the `agentChoiceModalWidth` field (line 247), add: - -```go - // Agent config modal state (start/restart with options) - agentConfigWorktree *Worktree - agentConfigIsRestart bool - agentConfigAgentType AgentType - agentConfigAgentIdx int - agentConfigSkipPerms bool - agentConfigPromptIdx int - agentConfigPrompts []Prompt - agentConfigModal *modal.Modal - agentConfigModalWidth int - agentConfigFocusSet bool - - // Prompt picker return routing - promptPickerReturnMode ViewMode -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 3: Commit** - -```bash -git add internal/plugins/workspace/plugin.go -git commit -m "feat: add agent config modal state fields to Plugin" -``` - -### Task 4: Add restartAgentWithOptionsMsg handler in update.go - -**Files:** -- Modify: `internal/plugins/workspace/update.go:966-972` - -- [ ] **Step 1: Add the handler after the existing restartAgentMsg handler** - -In `internal/plugins/workspace/update.go`, after the `restartAgentMsg` case (line 972), add: - -```go - case restartAgentWithOptionsMsg: - // Start new agent after stop completed, with user-selected options - if msg.worktree != nil { - return p, p.StartAgentWithOptions(msg.worktree, msg.agentType, msg.skipPerms, msg.prompt) - } - return p, nil -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 3: Commit** - -```bash -git add internal/plugins/workspace/update.go -git commit -m "feat: handle restartAgentWithOptionsMsg in update loop" -``` - ---- - -## Chunk 2: Agent Config Modal — Core File - -### Task 5: Create agent_config_modal.go with constants, clear, and helpers - -**Files:** -- Create: `internal/plugins/workspace/agent_config_modal.go` - -- [ ] **Step 1: Write the test file first** - -Create `internal/plugins/workspace/agent_config_modal_test.go`: - -```go -package workspace - -import "testing" - -func TestGetAgentConfigPrompt(t *testing.T) { - tests := []struct { - name string - prompts []Prompt - idx int - wantNil bool - wantName string - }{ - {"negative index", []Prompt{{Name: "a"}}, -1, true, ""}, - {"out of bounds", []Prompt{{Name: "a"}}, 5, true, ""}, - {"nil prompts", nil, 0, true, ""}, - {"valid index", []Prompt{{Name: "first"}, {Name: "second"}}, 1, false, "second"}, - {"first index", []Prompt{{Name: "only"}}, 0, false, "only"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := &Plugin{ - agentConfigPrompts: tt.prompts, - agentConfigPromptIdx: tt.idx, - } - got := p.getAgentConfigPrompt() - if tt.wantNil { - if got != nil { - t.Errorf("expected nil, got %+v", got) - } - return - } - if got == nil { - t.Fatal("expected non-nil prompt") - } - if got.Name != tt.wantName { - t.Errorf("expected name %q, got %q", tt.wantName, got.Name) - } - }) - } -} - -func TestClearAgentConfigModal(t *testing.T) { - p := &Plugin{ - agentConfigWorktree: &Worktree{Name: "test"}, - agentConfigIsRestart: true, - agentConfigAgentType: AgentClaude, - agentConfigAgentIdx: 3, - agentConfigSkipPerms: true, - agentConfigPromptIdx: 2, - agentConfigPrompts: []Prompt{{Name: "x"}}, - } - p.clearAgentConfigModal() - - if p.agentConfigWorktree != nil { - t.Error("worktree not cleared") - } - if p.agentConfigIsRestart { - t.Error("isRestart not cleared") - } - if p.agentConfigAgentType != "" { - t.Error("agentType not cleared") - } - if p.agentConfigAgentIdx != 0 { - t.Error("agentIdx not cleared") - } - if p.agentConfigSkipPerms { - t.Error("skipPerms not cleared") - } - if p.agentConfigPromptIdx != -1 { - t.Error("promptIdx not cleared") - } - if p.agentConfigPrompts != nil { - t.Error("prompts not cleared") - } - if p.agentConfigModal != nil { - t.Error("modal not cleared") - } - if p.agentConfigModalWidth != 0 { - t.Error("modalWidth not cleared") - } - if p.agentConfigFocusSet { - t.Error("focusSet not cleared") - } -} - -func TestShouldShowAgentConfigSkipPerms(t *testing.T) { - tests := []struct { - name string - agentType AgentType - want bool - }{ - {"claude has flag", AgentClaude, true}, - {"codex has flag", AgentCodex, true}, - {"none has no flag", AgentNone, false}, - {"opencode has no flag", AgentOpenCode, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - p := &Plugin{agentConfigAgentType: tt.agentType} - if got := p.shouldShowAgentConfigSkipPerms(); got != tt.want { - t.Errorf("shouldShowAgentConfigSkipPerms() = %v, want %v", got, tt.want) - } - }) - } -} -``` - -- [ ] **Step 2: Run tests — they should fail (functions don't exist yet)** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -run TestGetAgentConfigPrompt -v 2>&1 | head -5` -Expected: Compilation error — functions not defined - -- [ ] **Step 3: Create agent_config_modal.go with constants, helpers, and clear function** - -Create `internal/plugins/workspace/agent_config_modal.go`: - -```go -package workspace - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/marcus/sidecar/internal/modal" - "github.com/marcus/sidecar/internal/styles" - ui "github.com/marcus/sidecar/internal/ui" -) - -const ( - agentConfigPromptFieldID = "agent-config-prompt" - agentConfigAgentListID = "agent-config-agent-list" - agentConfigSkipPermissionsID = "agent-config-skip-permissions" - agentConfigSubmitID = "agent-config-submit" - agentConfigCancelID = "agent-config-cancel" - agentConfigAgentItemPrefix = "agent-config-agent-" -) - -// clearAgentConfigModal resets all agent config modal state. -func (p *Plugin) clearAgentConfigModal() { - p.agentConfigWorktree = nil - p.agentConfigIsRestart = false - p.agentConfigAgentType = "" - p.agentConfigAgentIdx = 0 - p.agentConfigSkipPerms = false - p.agentConfigPromptIdx = -1 - p.agentConfigPrompts = nil - p.agentConfigModal = nil - p.agentConfigModalWidth = 0 - p.agentConfigFocusSet = false -} - -// getAgentConfigPrompt resolves the selected prompt index to a *Prompt. -func (p *Plugin) getAgentConfigPrompt() *Prompt { - if p.agentConfigPromptIdx < 0 || p.agentConfigPromptIdx >= len(p.agentConfigPrompts) { - return nil - } - prompt := p.agentConfigPrompts[p.agentConfigPromptIdx] - return &prompt -} - -// shouldShowAgentConfigSkipPerms returns true if the selected agent supports skip permissions. -func (p *Plugin) shouldShowAgentConfigSkipPerms() bool { - if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { - return false - } - flag, ok := SkipPermissionsFlags[p.agentConfigAgentType] - return ok && flag != "" -} -``` - -Note: The imports for `fmt`, `strings`, `lipgloss`, `ansi`, `styles`, and `ui` will be used by later functions in this file. If the compiler complains about unused imports at this stage, temporarily remove them and add them back in Task 6 when the section builders are added. - -- [ ] **Step 4: Run tests — they should pass** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -run "TestGetAgentConfigPrompt|TestClearAgentConfigModal|TestShouldShowAgentConfigSkipPerms" -v` -Expected: All 3 test functions pass - -- [ ] **Step 5: Commit** - -```bash -git add internal/plugins/workspace/agent_config_modal.go internal/plugins/workspace/agent_config_modal_test.go -git commit -m "feat: add agent config modal constants, helpers, and tests" -``` - -### Task 6: Add ensureAgentConfigModal — modal builder with sections - -**Files:** -- Modify: `internal/plugins/workspace/agent_config_modal.go` - -- [ ] **Step 1: Add section builder functions and ensureAgentConfigModal** - -Append to `internal/plugins/workspace/agent_config_modal.go`: - -```go -// ensureAgentConfigModal builds or rebuilds the agent config modal. -func (p *Plugin) ensureAgentConfigModal() { - if p.agentConfigWorktree == nil { - return - } - - modalW := 60 - maxW := p.width - 4 - if maxW < 1 { - maxW = 1 - } - if modalW > maxW { - modalW = maxW - } - - if p.agentConfigModal != nil && p.agentConfigModalWidth == modalW { - return - } - p.agentConfigModalWidth = modalW - - items := make([]modal.ListItem, len(AgentTypeOrder)) - for i, at := range AgentTypeOrder { - items[i] = modal.ListItem{ - ID: fmt.Sprintf("%s%d", agentConfigAgentItemPrefix, i), - Label: AgentDisplayNames[at], - } - } - - title := fmt.Sprintf("Start Agent: %s", p.agentConfigWorktree.Name) - if p.agentConfigIsRestart { - title = fmt.Sprintf("Restart Agent: %s", p.agentConfigWorktree.Name) - } - - p.agentConfigModal = modal.New(title, - modal.WithWidth(modalW), - modal.WithPrimaryAction(agentConfigSubmitID), - modal.WithHints(false), - ). - AddSection(p.agentConfigPromptSection()). - AddSection(modal.Spacer()). - AddSection(p.agentConfigAgentLabelSection()). - AddSection(modal.List(agentConfigAgentListID, items, &p.agentConfigAgentIdx, modal.WithMaxVisible(len(items)))). - AddSection(p.agentConfigSkipPermissionsSpacerSection()). - AddSection(modal.When(p.shouldShowAgentConfigSkipPerms, modal.Checkbox(agentConfigSkipPermissionsID, "Auto-approve all actions", &p.agentConfigSkipPerms))). - AddSection(p.agentConfigSkipPermissionsHintSection()). - AddSection(modal.Spacer()). - AddSection(modal.Buttons( - modal.Btn(" Start ", agentConfigSubmitID), - modal.Btn(" Cancel ", agentConfigCancelID), - )) -} - -// syncAgentConfigModalFocus sets initial focus if not already set. -// Called from renderAgentConfigModal; only sets focus once to avoid -// overriding user navigation (tab/arrow keys). -func (p *Plugin) syncAgentConfigModalFocus() { - if p.agentConfigModal == nil { - return - } - // Only set initial focus — the modal tracks focus after first set - if !p.agentConfigFocusSet { - p.agentConfigModal.SetFocus(agentConfigAgentListID) - p.agentConfigFocusSet = true - } -} - -// agentConfigPromptSection renders the prompt selector field. -func (p *Plugin) agentConfigPromptSection() modal.Section { - return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { - lines := make([]string, 0, 4) - focusables := make([]modal.FocusableInfo, 0, 1) - - lines = append(lines, "Prompt:") - - selectedPrompt := p.getAgentConfigPrompt() - displayText := "(none)" - if len(p.agentConfigPrompts) == 0 { - displayText = "No prompts configured" - } else if selectedPrompt != nil { - scopeIndicator := "[G] global" - if selectedPrompt.Source == "project" { - scopeIndicator = "[P] project" - } - displayText = fmt.Sprintf("%s %s", selectedPrompt.Name, dimText(scopeIndicator)) - } - - promptStyle := inputStyle() - if focusID == agentConfigPromptFieldID { - promptStyle = inputFocusedStyle() - } - rendered := promptStyle.Render(displayText) - renderedLines := strings.Split(rendered, "\n") - displayStartY := len(lines) - lines = append(lines, renderedLines...) - - focusables = append(focusables, modal.FocusableInfo{ - ID: agentConfigPromptFieldID, - OffsetX: 0, - OffsetY: displayStartY, - Width: ansi.StringWidth(rendered), - Height: len(renderedLines), - }) - - if len(p.agentConfigPrompts) == 0 { - lines = append(lines, dimText(" See: .claude/skills/create-prompt/SKILL.md")) - } else if selectedPrompt == nil { - lines = append(lines, dimText(" Press Enter to select a prompt template")) - } else { - preview := strings.ReplaceAll(selectedPrompt.Body, "\n", " ") - if runes := []rune(preview); len(runes) > 60 { - preview = string(runes[:57]) + "..." - } - lines = append(lines, dimText(fmt.Sprintf(" Preview: %s", preview))) - } - - return modal.RenderedSection{Content: strings.Join(lines, "\n"), Focusables: focusables} - }, nil) -} - -// agentConfigAgentLabelSection renders the "Agent:" label. -func (p *Plugin) agentConfigAgentLabelSection() modal.Section { - return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { - return modal.RenderedSection{Content: "Agent:"} - }, nil) -} - -// agentConfigSkipPermissionsSpacerSection renders a spacer before the checkbox (hidden when agent is None). -func (p *Plugin) agentConfigSkipPermissionsSpacerSection() modal.Section { - return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { - if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { - return modal.RenderedSection{} - } - return modal.RenderedSection{Content: " "} - }, nil) -} - -// agentConfigSkipPermissionsHintSection renders the hint showing the actual flag. -func (p *Plugin) agentConfigSkipPermissionsHintSection() modal.Section { - return modal.Custom(func(contentWidth int, focusID, hoverID string) modal.RenderedSection { - if p.agentConfigAgentType == AgentNone || p.agentConfigAgentType == "" { - return modal.RenderedSection{} - } - if p.shouldShowAgentConfigSkipPerms() { - flag := SkipPermissionsFlags[p.agentConfigAgentType] - return modal.RenderedSection{Content: dimText(fmt.Sprintf(" (Adds %s)", flag))} - } - return modal.RenderedSection{Content: dimText(" Skip permissions not available for this agent")} - }, nil) -} - -// renderAgentConfigModal renders the agent config modal over a dimmed background. -func (p *Plugin) renderAgentConfigModal(width, height int) string { - background := p.renderListView(width, height) - - p.ensureAgentConfigModal() - if p.agentConfigModal == nil { - return background - } - - p.syncAgentConfigModalFocus() - modalContent := p.agentConfigModal.Render(width, height, p.mouseHandler) - return ui.OverlayModal(background, modalContent, width, height) -} -``` - -Make sure the import block at the top of the file includes all needed imports. The final import block should be: - -```go -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/marcus/sidecar/internal/modal" - "github.com/marcus/sidecar/internal/styles" - ui "github.com/marcus/sidecar/internal/ui" -) -``` - -Remove any imports that the compiler says are unused. The `lipgloss` and `styles` imports are used by `inputStyle()`/`inputFocusedStyle()`/`dimText()` which are defined in `create_modal.go` (same package). If these helper functions reference `lipgloss` and `styles` internally and the new file doesn't directly use them, remove those imports. - -- [ ] **Step 2: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 3: Commit** - -```bash -git add internal/plugins/workspace/agent_config_modal.go -git commit -m "feat: add ensureAgentConfigModal builder and section renderers" -``` - ---- - -## Chunk 3: Key, Mouse, Command Handlers & View Routing - -### Task 7: Add key handler and executeAgentConfig - -**Files:** -- Modify: `internal/plugins/workspace/keys.go:27-28` (handleKeys switch) -- Modify: `internal/plugins/workspace/keys.go:264-305` (handleAgentConfigKeys + executeAgentConfig) - -- [ ] **Step 1: Write test for executeAgentConfig** - -Add to `internal/plugins/workspace/agent_config_modal_test.go`: - -```go -func TestExecuteAgentConfig_FreshStart(t *testing.T) { - wt := &Worktree{Name: "test-wt", Path: "/tmp/test"} - p := &Plugin{ - agentConfigWorktree: wt, - agentConfigIsRestart: false, - agentConfigAgentType: AgentClaude, - agentConfigSkipPerms: true, - agentConfigPromptIdx: -1, - viewMode: ViewModeAgentConfig, - } - - cmd := p.executeAgentConfig() - - // After execute, modal state should be cleared - if p.viewMode != ViewModeList { - t.Errorf("expected ViewModeList, got %v", p.viewMode) - } - if p.agentConfigWorktree != nil { - t.Error("worktree should be cleared") - } - // cmd should be non-nil (StartAgentWithOptions returns a tea.Cmd) - if cmd == nil { - t.Error("expected non-nil cmd for fresh start") - } -} - -func TestExecuteAgentConfig_Restart(t *testing.T) { - wt := &Worktree{Name: "test-wt", Path: "/tmp/test"} - p := &Plugin{ - agentConfigWorktree: wt, - agentConfigIsRestart: true, - agentConfigAgentType: AgentCodex, - agentConfigSkipPerms: false, - agentConfigPromptIdx: -1, - viewMode: ViewModeAgentConfig, - } - - cmd := p.executeAgentConfig() - - if p.viewMode != ViewModeList { - t.Errorf("expected ViewModeList, got %v", p.viewMode) - } - // cmd should be non-nil (tea.Sequence for stop + restart) - if cmd == nil { - t.Error("expected non-nil cmd for restart") - } -} - -func TestExecuteAgentConfig_NilWorktree(t *testing.T) { - p := &Plugin{ - agentConfigWorktree: nil, - viewMode: ViewModeAgentConfig, - } - - cmd := p.executeAgentConfig() - - if p.viewMode != ViewModeList { - t.Errorf("expected ViewModeList, got %v", p.viewMode) - } - if cmd != nil { - t.Error("expected nil cmd for nil worktree") - } -} -``` - -- [ ] **Step 2: Run tests to see them fail** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -run "TestExecuteAgentConfig" -v 2>&1 | head -5` -Expected: Compilation error — `executeAgentConfig` not defined - -- [ ] **Step 3: Add handleAgentConfigKeys to the handleKeys switch** - -In `internal/plugins/workspace/keys.go`, in the `handleKeys()` switch (around line 27), add before the `case ViewModeAgentChoice:` line: - -```go - case ViewModeAgentConfig: - return p.handleAgentConfigKeys(msg) -``` - -- [ ] **Step 4: Add handleAgentConfigKeys and executeAgentConfig functions** - -In `internal/plugins/workspace/keys.go`, after the `handleAgentChoiceKeys` function (after line 283), add: - -```go -// handleAgentConfigKeys handles keys in agent config modal. -func (p *Plugin) handleAgentConfigKeys(msg tea.KeyMsg) tea.Cmd { - p.ensureAgentConfigModal() - if p.agentConfigModal == nil { - return nil - } - - prevAgentIdx := p.agentConfigAgentIdx - action, cmd := p.agentConfigModal.HandleKey(msg) - - // Sync agent type when selection changes - if p.agentConfigAgentIdx != prevAgentIdx { - if p.agentConfigAgentIdx >= 0 && p.agentConfigAgentIdx < len(AgentTypeOrder) { - p.agentConfigAgentType = AgentTypeOrder[p.agentConfigAgentIdx] - } - } - - switch action { - case "cancel", agentConfigCancelID: - p.viewMode = ViewModeList - p.clearAgentConfigModal() - return nil - case agentConfigPromptFieldID: - // Open prompt picker, set return mode to agent config - p.promptPickerReturnMode = ViewModeAgentConfig - p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker - return nil - case agentConfigSubmitID: - return p.executeAgentConfig() - } - - return cmd -} - -// executeAgentConfig executes the agent config modal action (start or restart). -func (p *Plugin) executeAgentConfig() tea.Cmd { - wt := p.agentConfigWorktree - agentType := p.agentConfigAgentType - skipPerms := p.agentConfigSkipPerms - prompt := p.getAgentConfigPrompt() - isRestart := p.agentConfigIsRestart - - p.viewMode = ViewModeList - p.clearAgentConfigModal() - - if wt == nil { - return nil - } - - if isRestart { - return tea.Sequence( - p.StopAgent(wt), - func() tea.Msg { - return restartAgentWithOptionsMsg{ - worktree: wt, - agentType: agentType, - skipPerms: skipPerms, - prompt: prompt, - } - }, - ) - } - return p.StartAgentWithOptions(wt, agentType, skipPerms, prompt) -} -``` - -- [ ] **Step 5: Run tests** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -run "TestExecuteAgentConfig" -v` -Expected: All 3 tests pass - -- [ ] **Step 6: Commit** - -```bash -git add internal/plugins/workspace/keys.go internal/plugins/workspace/agent_config_modal_test.go -git commit -m "feat: add handleAgentConfigKeys and executeAgentConfig" -``` - -### Task 8: Add view routing, commands, and mouse handler - -**Files:** -- Modify: `internal/plugins/workspace/view_list.go:66-67` -- Modify: `internal/plugins/workspace/commands.go:48-52` (Commands()) -- Modify: `internal/plugins/workspace/commands.go:236-250` (FocusContext()) -- Modify: `internal/plugins/workspace/mouse.go:67-69` -- Modify: `internal/plugins/workspace/mouse.go:424-426` - -- [ ] **Step 1: Add rendering case in view_list.go** - -In `internal/plugins/workspace/view_list.go`, in the `View()` switch (around line 66), add before the `case ViewModeAgentChoice:` line: - -```go - case ViewModeAgentConfig: - return p.renderAgentConfigModal(width, height) -``` - -- [ ] **Step 2: Add commands in commands.go** - -In `internal/plugins/workspace/commands.go`, in the `Commands()` switch, add before the `case ViewModeAgentChoice:` (around line 48): - -```go - case ViewModeAgentConfig: - return []plugin.Command{ - {ID: "cancel", Name: "Cancel", Description: "Cancel agent config", Context: "workspace-agent-config", Priority: 1}, - {ID: "confirm", Name: "Start", Description: "Start agent with config", Context: "workspace-agent-config", Priority: 2}, - } -``` - -In the `FocusContext()` switch (around line 249), add before the `case ViewModeAgentChoice:` line: - -```go - case ViewModeAgentConfig: - return "workspace-agent-config" -``` - -- [ ] **Step 3: Add mouse handling in mouse.go** - -In `internal/plugins/workspace/mouse.go`, add the click handler function (after `handleAgentChoiceModalMouse`, around line 316): - -```go -func (p *Plugin) handleAgentConfigModalMouse(msg tea.MouseMsg) tea.Cmd { - p.ensureAgentConfigModal() - if p.agentConfigModal == nil { - return nil - } - - prevAgentIdx := p.agentConfigAgentIdx - action := p.agentConfigModal.HandleMouse(msg, p.mouseHandler) - - // Sync agent type when list selection changes via mouse - if p.agentConfigAgentIdx != prevAgentIdx { - if p.agentConfigAgentIdx >= 0 && p.agentConfigAgentIdx < len(AgentTypeOrder) { - p.agentConfigAgentType = AgentTypeOrder[p.agentConfigAgentIdx] - } - } - - switch action { - case "": - return nil - case "cancel", agentConfigCancelID: - p.viewMode = ViewModeList - p.clearAgentConfigModal() - return nil - case agentConfigPromptFieldID: - p.promptPickerReturnMode = ViewModeAgentConfig - p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker - return nil - case agentConfigSubmitID: - return p.executeAgentConfig() - } - return nil -} -``` - -In the `handleMouse` function (around line 67), add before the `if p.viewMode == ViewModeAgentChoice {` line: - -```go - if p.viewMode == ViewModeAgentConfig { - return p.handleAgentConfigModalMouse(msg) - } -``` - -In the `handleMouseHover` function's switch (around line 424), add before the `case ViewModeAgentChoice:` line: - -```go - case ViewModeAgentConfig: - // Modal library handles hover state internally - return nil -``` - -- [ ] **Step 4: Add keymap registration in plugin.go Init()** - -In `internal/plugins/workspace/plugin.go`, in `Init()` after the agent choice modal context keybindings (after line 445), add: - -```go - // Agent config modal context - ctx.Keymap.RegisterPluginBinding("esc", "cancel", "workspace-agent-config") - ctx.Keymap.RegisterPluginBinding("enter", "confirm", "workspace-agent-config") - ctx.Keymap.RegisterPluginBinding("tab", "next-field", "workspace-agent-config") - ctx.Keymap.RegisterPluginBinding("shift+tab", "prev-field", "workspace-agent-config") -``` - -- [ ] **Step 5: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 6: Run all tests** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -v -count=1 2>&1 | tail -5` -Expected: All tests pass - -- [ ] **Step 7: Commit** - -```bash -git add internal/plugins/workspace/view_list.go internal/plugins/workspace/commands.go internal/plugins/workspace/mouse.go internal/plugins/workspace/plugin.go -git commit -m "feat: wire agent config modal into view, commands, mouse, and keymap" -``` - ---- - -## Chunk 4: Entry Points — 's' Key & Restart Flow - -### Task 9: Modify 's' key handler to open agent config modal - -**Files:** -- Modify: `internal/plugins/workspace/keys.go:799-813` - -- [ ] **Step 1: Replace the direct StartAgent call with modal opening** - -In `internal/plugins/workspace/keys.go`, replace the `case "s":` block (lines 799-813). The current code: - -```go - case "s": - // Start agent on selected worktree - wt := p.selectedWorktree() - if wt == nil { - return nil - } - if wt.Agent == nil { - // No agent running - start new one - return p.StartAgent(wt, p.resolveWorktreeAgentType(wt)) - } - // Agent exists - show choice modal (attach or restart) - p.agentChoiceWorktree = wt - p.agentChoiceIdx = 0 // Default to attach - p.viewMode = ViewModeAgentChoice - return nil -``` - -Replace with: - -```go - case "s": - // Start agent on selected worktree - wt := p.selectedWorktree() - if wt == nil { - return nil - } - if wt.Agent == nil { - // No agent running - open agent config modal - home, _ := os.UserHomeDir() - configDir := filepath.Join(home, ".config", "sidecar") - p.agentConfigWorktree = wt - p.agentConfigIsRestart = false - p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) - p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) - p.agentConfigSkipPerms = false - p.agentConfigPromptIdx = -1 - p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.agentConfigModal = nil - p.agentConfigModalWidth = 0 - p.viewMode = ViewModeAgentConfig - return nil - } - // Agent exists - show choice modal (attach or restart) - p.agentChoiceWorktree = wt - p.agentChoiceIdx = 0 // Default to attach - p.viewMode = ViewModeAgentChoice - return nil -``` - -**Important:** `keys.go` does NOT currently import `os` or `path/filepath`. Add them to the import block: - -```go -import ( - "fmt" - "math" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - appmsg "github.com/marcus/sidecar/internal/msg" - "github.com/marcus/sidecar/internal/state" -) -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 3: Commit** - -```bash -git add internal/plugins/workspace/keys.go -git commit -m "feat: 's' key opens agent config modal instead of starting directly" -``` - -### Task 10: Modify executeAgentChoice to transition to agent config on restart - -**Files:** -- Modify: `internal/plugins/workspace/keys.go:286-305` - -- [ ] **Step 1: Replace the restart branch in executeAgentChoice** - -In `internal/plugins/workspace/keys.go`, in `executeAgentChoice()`, replace the restart branch (starting at the comment `// Restart agent: stop first, then start`). The current code: - -```go - // Restart agent: stop first, then start - return tea.Sequence( - p.StopAgent(wt), - func() tea.Msg { - return restartAgentMsg{worktree: wt} - }, - ) -``` - -Replace with: - -```go - // Restart agent: open config modal to choose options - home, _ := os.UserHomeDir() - configDir := filepath.Join(home, ".config", "sidecar") - p.agentConfigWorktree = wt - p.agentConfigIsRestart = true - p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) - p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) - p.agentConfigSkipPerms = false - p.agentConfigPromptIdx = -1 - p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.agentConfigModal = nil - p.agentConfigModalWidth = 0 - p.viewMode = ViewModeAgentConfig - return nil -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 3: Commit** - -```bash -git add internal/plugins/workspace/keys.go -git commit -m "feat: restart from agent choice modal opens agent config instead" -``` - ---- - -## Chunk 5: Prompt Picker Return Routing - -### Task 11: Add promptPickerReturnMode to existing create modal flow - -**Files:** -- Modify: `internal/plugins/workspace/keys.go:1003-1007` and `keys.go:1025-1029` - -- [ ] **Step 1: Write test for prompt picker return routing** - -Add to `internal/plugins/workspace/agent_config_modal_test.go`: - -```go -func TestPromptPickerReturnMode_AgentConfig(t *testing.T) { - p := &Plugin{ - promptPickerReturnMode: ViewModeAgentConfig, - agentConfigPrompts: []Prompt{{Name: "test-prompt", Body: "do stuff"}}, - agentConfigPromptIdx: -1, - } - - // Simulate PromptSelectedMsg being handled — the logic we're testing - // is in update.go, so this test verifies the state after handling. - // The actual handler integration is tested by the update.go modification. - - // Verify initial state - if p.agentConfigPromptIdx != -1 { - t.Error("expected initial promptIdx to be -1") - } - if p.promptPickerReturnMode != ViewModeAgentConfig { - t.Error("expected return mode to be ViewModeAgentConfig") - } -} -``` - -- [ ] **Step 2: Set promptPickerReturnMode = ViewModeCreate in existing prompt picker openers** - -In `internal/plugins/workspace/keys.go`, at line 1003 (where create modal opens prompt picker via `focusID == createPromptFieldID`), add before the `p.promptPicker = ...` line: - -```go - p.promptPickerReturnMode = ViewModeCreate -``` - -At line 1025 (where create modal opens prompt picker via `p.createFocus == 2`), add before the `p.promptPicker = ...` line: - -```go - p.promptPickerReturnMode = ViewModeCreate -``` - -Also update `mouse.go` for mouse-based prompt picker opening. There are **two** places: - -1. In `handleCreateModalMouse()` around line 118-123 (the `case createPromptFieldID:` branch), add before `p.promptPicker = ...`: - ```go - p.promptPickerReturnMode = ViewModeCreate - ``` - -2. In `handleMouseClick()` around line 626-629 (the `if focusIdx == 2 {` block inside `case regionCreateInput:`), add before `p.promptPicker = ...`: - ```go - p.promptPickerReturnMode = ViewModeCreate - ``` - -- [ ] **Step 3: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 4: Commit** - -```bash -git add internal/plugins/workspace/keys.go internal/plugins/workspace/mouse.go internal/plugins/workspace/agent_config_modal_test.go -git commit -m "feat: set promptPickerReturnMode in existing create modal flow" -``` - -### Task 12: Modify PromptSelectedMsg and PromptCancelledMsg handlers - -**Files:** -- Modify: `internal/plugins/workspace/update.go:218-246` - -- [ ] **Step 1: Replace PromptSelectedMsg handler** - -In `internal/plugins/workspace/update.go`, replace the `case PromptSelectedMsg:` handler (lines 218-240) with: - -```go - case PromptSelectedMsg: - // Prompt selected from picker - returnMode := p.promptPickerReturnMode - p.promptPicker = nil - p.clearPromptPickerModal() - - if returnMode == ViewModeAgentConfig { - p.viewMode = ViewModeAgentConfig - if msg.Prompt != nil { - for i, pr := range p.agentConfigPrompts { - if pr.Name == msg.Prompt.Name { - p.agentConfigPromptIdx = i - break - } - } - } else { - p.agentConfigPromptIdx = -1 - } - } else { - p.viewMode = ViewModeCreate - if msg.Prompt != nil { - // Find index of selected prompt - for i, pr := range p.createPrompts { - if pr.Name == msg.Prompt.Name { - p.createPromptIdx = i - break - } - } - // If ticketMode is none, skip task field and jump to agent - if msg.Prompt.TicketMode == TicketNone { - p.createFocus = 4 // agent field - } else { - p.createFocus = 3 // task field - } - } else { - p.createPromptIdx = -1 - p.createFocus = 3 // task field - } - } -``` - -- [ ] **Step 2: Replace PromptCancelledMsg handler** - -In `internal/plugins/workspace/update.go`, replace the `case PromptCancelledMsg:` handler (lines 242-246) with: - -```go - case PromptCancelledMsg: - // Picker cancelled, return to originating modal - returnMode := p.promptPickerReturnMode - p.promptPicker = nil - p.clearPromptPickerModal() - if returnMode == ViewModeAgentConfig { - p.viewMode = ViewModeAgentConfig - } else { - p.viewMode = ViewModeCreate - } -``` - -- [ ] **Step 3: Modify PromptInstallDefaultsMsg handler** - -In `internal/plugins/workspace/update.go`, in the `case PromptInstallDefaultsMsg:` handler (lines 248-265), replace the successful path (lines 257-260): - -```go - if WriteDefaultPromptsToConfig(configDir) { - p.createPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) - p.clearPromptPickerModal() -``` - -with: - -```go - if WriteDefaultPromptsToConfig(configDir) { - if p.promptPickerReturnMode == ViewModeAgentConfig { - p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) - } else { - p.createPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) - } - p.clearPromptPickerModal() -``` - -- [ ] **Step 4: Verify it compiles** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./internal/plugins/workspace/...` -Expected: Success - -- [ ] **Step 5: Run all tests** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./internal/plugins/workspace/ -v -count=1 2>&1 | tail -10` -Expected: All tests pass - -- [ ] **Step 6: Commit** - -```bash -git add internal/plugins/workspace/update.go -git commit -m "feat: route prompt picker return to agent config or create modal" -``` - ---- - -## Chunk 6: Final Integration Test & Cleanup - -### Task 13: Run full test suite and verify build - -**Files:** -- No new files - -- [ ] **Step 1: Run full test suite** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go test ./... 2>&1 | tail -20` -Expected: All tests pass - -- [ ] **Step 2: Build the binary** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go build ./cmd/sidecar` -Expected: Success - -- [ ] **Step 3: Verify no compilation warnings or vet issues** - -Run: `cd /Users/eugeneosipenko/Documents/Projects/Personal/sidecar-new-agent-config-modal && go vet ./internal/plugins/workspace/...` -Expected: No issues - -- [ ] **Step 4: Final commit if any cleanup was needed** - -Only commit if changes were made during cleanup. Otherwise skip. diff --git a/docs/superpowers/specs/2026-03-15-agent-config-modal-design.md b/docs/superpowers/specs/2026-03-15-agent-config-modal-design.md deleted file mode 100644 index 227c5caa..00000000 --- a/docs/superpowers/specs/2026-03-15-agent-config-modal-design.md +++ /dev/null @@ -1,419 +0,0 @@ -# Agent Config Modal Design - -## Problem - -When a tmux session is killed/stopped and the user presses 's' to start a new agent, there is no opportunity to choose an agent type, toggle skip-permissions, or select a prompt. The agent starts immediately with the previously-saved type and no options. Similarly, restarting a running agent offers no configuration. - -## Solution - -A new standalone "Agent Config Modal" that presents agent type selection, skip-permissions toggle, and prompt picker before starting or restarting an agent on an existing worktree. - -## Entry Points - -1. **'s' with no agent running** — opens the agent config modal directly (replaces the current immediate `StartAgent` call) -2. **'s' with agent running → "Restart"** — after selecting restart in the existing attach/restart choice modal, transitions to the agent config modal (replaces the current immediate restart) - -The existing attach/restart choice modal is unchanged. "Attach" still attaches immediately. Only "Restart" now routes through the new modal. - -## Modal Layout - -``` -┌─── Start Agent: ───┐ -│ │ -│ Prompt: │ -│ ┌─(none)─────────────────────────┐ │ -│ Press Enter to select a prompt │ -│ │ -│ Agent: │ -│ > Claude │ -│ Codex │ -│ Copilot │ -│ Gemini │ -│ Cursor │ -│ OpenCode │ -│ Pi │ -│ Amp │ -│ None │ -│ │ -│ ☐ Auto-approve all actions │ -│ (Adds --dangerously-skip-...) │ -│ │ -│ [ Start ] [ Cancel ] │ -└─────────────────────────────────────┘ -``` - -Modal width: 60 (narrower than create modal's 70, since fewer fields). - -Sections from top to bottom: -1. **Prompt selector** — clickable field, opens the existing `ViewModePromptPicker` overlay on Enter. Displays selected prompt name or "(none)". -2. **Agent type list** — scrollable list of `AgentTypeOrder` entries. Pre-selected to the worktree's saved agent type. -3. **Skip permissions checkbox** — conditionally shown when the selected agent has a non-empty `SkipPermissionsFlags` entry. Shows the actual flag as a hint below. -4. **Buttons** — "Start" (primary action) and "Cancel". - -## New State Fields on Plugin - -```go -// Agent config modal state -agentConfigWorktree *Worktree // Target worktree -agentConfigIsRestart bool // true = stop first, false = fresh start -agentConfigAgentType AgentType // Selected agent type -agentConfigAgentIdx int // List selection index -agentConfigSkipPerms bool // Skip permissions toggle -agentConfigPromptIdx int // Selected prompt index (-1 = none) -agentConfigPrompts []Prompt // Loaded prompts for this modal (independent of createPrompts) -agentConfigModal *modal.Modal // Cached modal instance -agentConfigModalWidth int // For rebuild detection - -// Prompt picker return routing -promptPickerReturnMode ViewMode // Which view mode to return to after prompt picker -``` - -## New View Mode - -```go -ViewModeAgentConfig // Agent configuration modal -``` - -## New File: `agent_config_modal.go` - -Contains: -- `ensureAgentConfigModal()` — builds the modal declaratively using the `modal` package -- `syncAgentConfigModalFocus()` — syncs focus state -- `renderAgentConfigModal()` — renders modal over dimmed background -- `clearAgentConfigModal()` — resets all state fields (see explicit list below) -- `getAgentConfigPrompt()` — resolves `agentConfigPromptIdx` to `*Prompt` (see definition below) -- Custom section builders for prompt display, agent label, skip permissions spacer/hint -- `shouldShowAgentConfigSkipPerms()` — returns true if selected agent has a skip-permissions flag - -Section builders follow the same patterns as `create_modal.go` but reference `agentConfig*` state fields instead of `create*` fields. - -### Element IDs - -```go -const ( - agentConfigPromptFieldID = "agent-config-prompt" - agentConfigAgentListID = "agent-config-agent-list" - agentConfigSkipPermissionsID = "agent-config-skip-permissions" - agentConfigSubmitID = "agent-config-submit" - agentConfigCancelID = "agent-config-cancel" - agentConfigAgentItemPrefix = "agent-config-agent-" -) -``` - -### `clearAgentConfigModal()` — explicit field reset - -```go -func (p *Plugin) clearAgentConfigModal() { - p.agentConfigWorktree = nil - p.agentConfigIsRestart = false - p.agentConfigAgentType = "" - p.agentConfigAgentIdx = 0 - p.agentConfigSkipPerms = false - p.agentConfigPromptIdx = -1 - p.agentConfigPrompts = nil - p.agentConfigModal = nil - p.agentConfigModalWidth = 0 -} -``` - -### `getAgentConfigPrompt()` — prompt resolution - -```go -func (p *Plugin) getAgentConfigPrompt() *Prompt { - if p.agentConfigPromptIdx < 0 || p.agentConfigPromptIdx >= len(p.agentConfigPrompts) { - return nil - } - prompt := p.agentConfigPrompts[p.agentConfigPromptIdx] - return &prompt -} -``` - -## Key Handler: `handleAgentConfigKeys()` - -Located in `keys.go`. Follows the same pattern as other modal key handlers: - -```go -func (p *Plugin) handleAgentConfigKeys(msg tea.KeyMsg) tea.Cmd { - p.ensureAgentConfigModal() - if p.agentConfigModal == nil { - return nil - } - - prevAgentIdx := p.agentConfigAgentIdx - action, cmd := p.agentConfigModal.HandleKey(msg) - - // Sync agent type when selection changes - if p.agentConfigAgentIdx != prevAgentIdx { - if p.agentConfigAgentIdx >= 0 && p.agentConfigAgentIdx < len(AgentTypeOrder) { - p.agentConfigAgentType = AgentTypeOrder[p.agentConfigAgentIdx] - } - } - - switch action { - case "cancel", agentConfigCancelID: - p.viewMode = ViewModeList - p.clearAgentConfigModal() - return nil - case agentConfigPromptFieldID: - // Open prompt picker, set return mode to agent config - p.promptPickerReturnMode = ViewModeAgentConfig - p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) - p.viewMode = ViewModePromptPicker - return nil - case agentConfigSubmitID: - return p.executeAgentConfig() - } - - return cmd -} -``` - -## Execution: `executeAgentConfig()` - -```go -func (p *Plugin) executeAgentConfig() tea.Cmd { - wt := p.agentConfigWorktree - agentType := p.agentConfigAgentType - skipPerms := p.agentConfigSkipPerms - prompt := p.getAgentConfigPrompt() - isRestart := p.agentConfigIsRestart - - p.viewMode = ViewModeList - p.clearAgentConfigModal() - - if wt == nil { - return nil - } - - if isRestart { - return tea.Sequence( - p.StopAgent(wt), - func() tea.Msg { - return restartAgentWithOptionsMsg{ - worktree: wt, - agentType: agentType, - skipPerms: skipPerms, - prompt: prompt, - } - }, - ) - } - return p.StartAgentWithOptions(wt, agentType, skipPerms, prompt) -} -``` - -## New Message Type - -```go -// restartAgentWithOptionsMsg signals that an agent should be restarted with specific options. -type restartAgentWithOptionsMsg struct { - worktree *Worktree - agentType AgentType - skipPerms bool - prompt *Prompt -} -``` - -Handled in `update.go` alongside the existing `restartAgentMsg`: - -```go -case restartAgentWithOptionsMsg: - if msg.worktree != nil { - return p, p.StartAgentWithOptions(msg.worktree, msg.agentType, msg.skipPerms, msg.prompt) - } - return p, nil -``` - -## Changes to Existing Files - -### `keys.go` - -1. **Line ~807** (`case "s"`, no agent branch): Replace `return p.StartAgent(wt, p.resolveWorktreeAgentType(wt))` with opening the agent config modal: - ```go - p.agentConfigWorktree = wt - p.agentConfigIsRestart = false - p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) - p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) - p.agentConfigSkipPerms = false - p.agentConfigPromptIdx = -1 - p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.viewMode = ViewModeAgentConfig - return nil - ``` - - Where `configDir` is obtained from `os.UserHomeDir() + "/.config/sidecar"` (same pattern as `initCreateModalBase()`). - -2. **`executeAgentChoice()`** (line ~298, restart branch): Replace immediate restart with transitioning to agent config modal: - ```go - // Restart agent: open config modal instead of restarting immediately - p.agentConfigWorktree = wt - p.agentConfigIsRestart = true - p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) - p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) - p.agentConfigSkipPerms = false - p.agentConfigPromptIdx = -1 - home, _ := os.UserHomeDir() - configDir := filepath.Join(home, ".config", "sidecar") - p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.viewMode = ViewModeAgentConfig - return nil - ``` - -3. **`handleKeys()` switch**: Add `case ViewModeAgentConfig: return p.handleAgentConfigKeys(msg)` - -### `view_list.go` - -Add rendering case: -```go -case ViewModeAgentConfig: - return p.renderAgentConfigModal(width, height) -``` - -### `commands.go` - -1. Add `case ViewModeAgentConfig:` to `Commands()` — return Confirm/Cancel commands. -2. Add `case ViewModeAgentConfig:` to `KeyBindings()` — return keybinding context `"workspace-agent-config"`. -3. `ConsumesTextInput()` — no change needed. The agent config modal has no text input fields. When the prompt picker is opened from it, `ViewModePromptPicker` handles text consumption. - -### `mouse.go` - -Add `case ViewModeAgentConfig:` mouse handler delegating to `p.agentConfigModal.HandleMouse()`. Follow the same pattern as `ViewModeAgentChoice` mouse handling. - -Note: `isModalViewMode()` uses a default `true` return for unknown view modes, so `ViewModeAgentConfig` is automatically treated as a modal. No change needed there. - -### `plugin.go` - -1. Add state fields listed above to the `Plugin` struct. -2. Add `promptPickerReturnMode ViewMode` field. -3. `outputVisibleForUnfocused()` — no change needed. It already returns `false` for all non-list/non-interactive view modes, which is correct (suppress polling while modal is open). - -### `update.go` - -1. Add `case restartAgentWithOptionsMsg:` handler (see above). - -2. **Modify `PromptSelectedMsg` handler** to use `promptPickerReturnMode`: - ```go - case PromptSelectedMsg: - returnMode := p.promptPickerReturnMode - p.promptPicker = nil - p.clearPromptPickerModal() - - if returnMode == ViewModeAgentConfig { - p.viewMode = ViewModeAgentConfig - if msg.Prompt != nil { - for i, pr := range p.agentConfigPrompts { - if pr.Name == msg.Prompt.Name { - p.agentConfigPromptIdx = i - break - } - } - } else { - p.agentConfigPromptIdx = -1 - } - } else { - // Existing create modal logic (unchanged) - p.viewMode = ViewModeCreate - if msg.Prompt != nil { - for i, pr := range p.createPrompts { - if pr.Name == msg.Prompt.Name { - p.createPromptIdx = i - break - } - } - if msg.Prompt.TicketMode == TicketNone { - p.createFocus = 4 - } else { - p.createFocus = 3 - } - } else { - p.createPromptIdx = -1 - p.createFocus = 3 - } - } - ``` - -3. **Modify `PromptCancelledMsg` handler** to use `promptPickerReturnMode`: - ```go - case PromptCancelledMsg: - returnMode := p.promptPickerReturnMode - p.promptPicker = nil - p.clearPromptPickerModal() - if returnMode == ViewModeAgentConfig { - p.viewMode = ViewModeAgentConfig - } else { - p.viewMode = ViewModeCreate - } - ``` - -4. **Set `promptPickerReturnMode` in existing create modal flow** — in `keys.go` where the create modal opens the prompt picker (around line ~1004 and ~1026), add: - ```go - p.promptPickerReturnMode = ViewModeCreate - ``` - -5. **Modify `PromptInstallDefaultsMsg` handler** to respect `promptPickerReturnMode`. Currently it always reloads into `p.createPrompts`. When the picker was opened from agent config, it should update `agentConfigPrompts` instead: - ```go - case PromptInstallDefaultsMsg: - // ... existing home dir / WriteDefaultPromptsToConfig logic ... - if p.promptPickerReturnMode == ViewModeAgentConfig { - p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) - } else { - p.createPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) - } - ``` - -### `messages.go` - -Add `restartAgentWithOptionsMsg` struct. - -### `types.go` - -Add `ViewModeAgentConfig` constant. - -### `plugin.go` `Init()` - -Add keymap registration for the new modal context: - -```go -// Agent config modal context -ctx.Keymap.RegisterPluginBinding("esc", "cancel", "workspace-agent-config") -ctx.Keymap.RegisterPluginBinding("enter", "confirm", "workspace-agent-config") -ctx.Keymap.RegisterPluginBinding("tab", "next-field", "workspace-agent-config") -ctx.Keymap.RegisterPluginBinding("shift+tab", "prev-field", "workspace-agent-config") -``` - -## Prompt Picker Integration - -When the user activates the prompt field in the agent config modal: -1. `promptPickerReturnMode` is set to `ViewModeAgentConfig` -2. `promptPicker` is created with `p.agentConfigPrompts` (the modal's own prompt list) -3. `viewMode` switches to `ViewModePromptPicker` -4. The existing prompt picker overlay renders on top -5. On `PromptSelectedMsg`: handler checks `promptPickerReturnMode`, returns to `ViewModeAgentConfig`, updates `agentConfigPromptIdx` by matching `msg.Prompt.Name` against `agentConfigPrompts` -6. On `PromptCancelledMsg`: handler checks `promptPickerReturnMode`, returns to `ViewModeAgentConfig` with no changes - -The existing create modal prompt picker flow is updated to set `promptPickerReturnMode = ViewModeCreate` before opening the picker, maintaining backward compatibility. - -## Initialization - -When opening the modal (both entry points): -- `agentConfigPrompts` is loaded via `LoadPrompts(configDir, p.ctx.ProjectRoot)` — independent of `createPrompts` -- `agentConfigAgentType` is pre-selected from `resolveWorktreeAgentType(wt)` (the worktree's saved agent type) -- `agentConfigAgentIdx` is set via `agentTypeIndex()` to match -- `agentConfigSkipPerms` defaults to `false` -- `agentConfigPromptIdx` defaults to `-1` (none) - -## Edge Cases - -- **Worktree deleted while modal is open**: `executeAgentConfig()` checks `wt == nil` and returns nil. This is consistent with the existing `restartAgentMsg` handler's behavior. Stale pointer risk is pre-existing and not addressed here. -- **Agent starts from another source while modal is open**: The modal operates on captured state. If the agent starts externally, the user will see the updated status after closing the modal. No special handling needed — same as existing modals. - -## Testing - -- Unit test for `handleAgentConfigKeys`: confirm, cancel, agent selection changes -- Unit test for `executeAgentConfig`: fresh start vs restart paths -- Unit test for `getAgentConfigPrompt`: valid index, invalid index, nil prompts -- Unit test for prompt picker return routing: `PromptSelectedMsg` and `PromptCancelledMsg` with both `ViewModeAgentConfig` and `ViewModeCreate` return modes -- Unit test for modal rendering (snapshot or content check) -- Integration: 's' key on stopped worktree opens modal, confirm starts agent with selected options From 409b605fe1042c554825a0a2938bfb8130661c62 Mon Sep 17 00:00:00 2001 From: Eugene Osipenko Date: Sun, 15 Mar 2026 14:30:01 +0100 Subject: [PATCH 16/16] refactor: extract helpers, remove redundant state, fix test leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract openAgentConfigModal(wt, isRestart) eliminating duplicate 12-line init blocks in two places - Extract openPromptPicker(prompts, returnMode) eliminating duplicate 4-line blocks in 6 places across keys.go and mouse.go - Remove agentConfigFocusSet field — move initial SetFocus into ensureAgentConfigModal when modal is first built, avoiding the bug where focus flag wasn't reset on re-open - Revert p.ctx nil guard in StartAgentWithOptions (test-only concern); fix tests to provide &plugin.Context{} instead - Remove os/filepath imports from keys.go (no longer needed) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/plugins/workspace/agent.go | 5 +-- .../plugins/workspace/agent_config_modal.go | 40 +++++++++++------ .../workspace/agent_config_modal_test.go | 11 +++-- internal/plugins/workspace/keys.go | 43 +++---------------- internal/plugins/workspace/mouse.go | 15 ++----- internal/plugins/workspace/plugin.go | 1 - 6 files changed, 44 insertions(+), 71 deletions(-) diff --git a/internal/plugins/workspace/agent.go b/internal/plugins/workspace/agent.go index e2dd1aa6..ae0bb89e 100644 --- a/internal/plugins/workspace/agent.go +++ b/internal/plugins/workspace/agent.go @@ -657,10 +657,7 @@ func (p *Plugin) getAgentCommandWithContext(agentType AgentType, wt *Worktree) s // StartAgentWithOptions creates a tmux session and starts an agent with options. // If a session already exists, it reconnects to it instead of failing. func (p *Plugin) StartAgentWithOptions(wt *Worktree, agentType AgentType, skipPerms bool, prompt *Prompt) tea.Cmd { - var epoch uint64 - if p.ctx != nil { - epoch = p.ctx.Epoch // Capture epoch for stale detection - } + epoch := p.ctx.Epoch // Capture epoch for stale detection return func() tea.Msg { sessionName := tmuxSessionPrefix + sanitizeName(wt.Name) diff --git a/internal/plugins/workspace/agent_config_modal.go b/internal/plugins/workspace/agent_config_modal.go index 1767b757..15a301c3 100644 --- a/internal/plugins/workspace/agent_config_modal.go +++ b/internal/plugins/workspace/agent_config_modal.go @@ -2,6 +2,8 @@ package workspace import ( "fmt" + "os" + "path/filepath" "strings" "github.com/charmbracelet/x/ansi" @@ -18,6 +20,22 @@ const ( agentConfigAgentItemPrefix = "agent-config-agent-" ) +// openAgentConfigModal initializes and opens the agent config modal for a worktree. +func (p *Plugin) openAgentConfigModal(wt *Worktree, isRestart bool) { + home, _ := os.UserHomeDir() + configDir := filepath.Join(home, ".config", "sidecar") + p.agentConfigWorktree = wt + p.agentConfigIsRestart = isRestart + p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) + p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 + p.viewMode = ViewModeAgentConfig +} + // clearAgentConfigModal resets all agent config modal state. func (p *Plugin) clearAgentConfigModal() { p.agentConfigWorktree = nil @@ -29,7 +47,14 @@ func (p *Plugin) clearAgentConfigModal() { p.agentConfigPrompts = nil p.agentConfigModal = nil p.agentConfigModalWidth = 0 - p.agentConfigFocusSet = false +} + +// openPromptPicker opens the prompt picker overlay, routing return to the given mode. +func (p *Plugin) openPromptPicker(prompts []Prompt, returnMode ViewMode) { + p.promptPickerReturnMode = returnMode + p.promptPicker = NewPromptPicker(prompts, p.width, p.height) + p.clearPromptPickerModal() + p.viewMode = ViewModePromptPicker } // getAgentConfigPrompt resolves the selected prompt index to a *Prompt. @@ -100,17 +125,9 @@ func (p *Plugin) ensureAgentConfigModal() { modal.Btn(" Start ", agentConfigSubmitID), modal.Btn(" Cancel ", agentConfigCancelID), )) -} -// syncAgentConfigModalFocus sets initial focus if not already set. -func (p *Plugin) syncAgentConfigModalFocus() { - if p.agentConfigModal == nil { - return - } - if !p.agentConfigFocusSet { - p.agentConfigModal.SetFocus(agentConfigAgentListID) - p.agentConfigFocusSet = true - } + // Set initial focus when modal is first built + p.agentConfigModal.SetFocus(agentConfigAgentListID) } // agentConfigPromptSection renders the prompt selector field. @@ -206,7 +223,6 @@ func (p *Plugin) renderAgentConfigModal(width, height int) string { return background } - p.syncAgentConfigModalFocus() modalContent := p.agentConfigModal.Render(width, height, p.mouseHandler) return ui.OverlayModal(background, modalContent, width, height) } diff --git a/internal/plugins/workspace/agent_config_modal_test.go b/internal/plugins/workspace/agent_config_modal_test.go index c918fb80..1ccfa538 100644 --- a/internal/plugins/workspace/agent_config_modal_test.go +++ b/internal/plugins/workspace/agent_config_modal_test.go @@ -1,6 +1,10 @@ package workspace -import "testing" +import ( + "testing" + + "github.com/marcus/sidecar/internal/plugin" +) func TestGetAgentConfigPrompt(t *testing.T) { tests := []struct { @@ -78,9 +82,6 @@ func TestClearAgentConfigModal(t *testing.T) { if p.agentConfigModalWidth != 0 { t.Error("modalWidth not cleared") } - if p.agentConfigFocusSet { - t.Error("focusSet not cleared") - } } func TestShouldShowAgentConfigSkipPerms(t *testing.T) { @@ -107,6 +108,7 @@ func TestShouldShowAgentConfigSkipPerms(t *testing.T) { func TestExecuteAgentConfig_FreshStart(t *testing.T) { wt := &Worktree{Name: "test-wt", Path: "/tmp/test"} p := &Plugin{ + ctx: &plugin.Context{}, agentConfigWorktree: wt, agentConfigIsRestart: false, agentConfigAgentType: AgentClaude, @@ -131,6 +133,7 @@ func TestExecuteAgentConfig_FreshStart(t *testing.T) { func TestExecuteAgentConfig_Restart(t *testing.T) { wt := &Worktree{Name: "test-wt", Path: "/tmp/test"} p := &Plugin{ + ctx: &plugin.Context{}, agentConfigWorktree: wt, agentConfigIsRestart: true, agentConfigAgentType: AgentCodex, diff --git a/internal/plugins/workspace/keys.go b/internal/plugins/workspace/keys.go index 87aac03a..aa3e8293 100644 --- a/internal/plugins/workspace/keys.go +++ b/internal/plugins/workspace/keys.go @@ -3,9 +3,7 @@ package workspace import ( "fmt" "math" - "os" "os/exec" - "path/filepath" "strings" "time" @@ -303,10 +301,7 @@ func (p *Plugin) handleAgentConfigKeys(msg tea.KeyMsg) tea.Cmd { switch { case focusID == agentConfigPromptFieldID: // Open prompt picker - p.promptPickerReturnMode = ViewModeAgentConfig - p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker + p.openPromptPicker(p.agentConfigPrompts, ViewModeAgentConfig) return nil case focusID == agentConfigSubmitID: return p.executeAgentConfig() @@ -391,18 +386,7 @@ func (p *Plugin) executeAgentChoice() tea.Cmd { return p.AttachToSession(wt) } // Restart agent: open config modal to choose options - home, _ := os.UserHomeDir() - configDir := filepath.Join(home, ".config", "sidecar") - p.agentConfigWorktree = wt - p.agentConfigIsRestart = true - p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) - p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) - p.agentConfigSkipPerms = false - p.agentConfigPromptIdx = -1 - p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.agentConfigModal = nil - p.agentConfigModalWidth = 0 - p.viewMode = ViewModeAgentConfig + p.openAgentConfigModal(wt, true) return nil } @@ -906,18 +890,7 @@ func (p *Plugin) handleListKeys(msg tea.KeyMsg) tea.Cmd { } if wt.Agent == nil { // No agent running - open agent config modal - home, _ := os.UserHomeDir() - configDir := filepath.Join(home, ".config", "sidecar") - p.agentConfigWorktree = wt - p.agentConfigIsRestart = false - p.agentConfigAgentType = p.resolveWorktreeAgentType(wt) - p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType) - p.agentConfigSkipPerms = false - p.agentConfigPromptIdx = -1 - p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot) - p.agentConfigModal = nil - p.agentConfigModalWidth = 0 - p.viewMode = ViewModeAgentConfig + p.openAgentConfigModal(wt, false) return nil } // Agent exists - show choice modal (attach or restart) @@ -1115,10 +1088,7 @@ func (p *Plugin) handleCreateKeys(msg tea.KeyMsg) tea.Cmd { return nil } if focusID == createPromptFieldID { - p.promptPickerReturnMode = ViewModeCreate - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker + p.openPromptPicker(p.createPrompts, ViewModeCreate) return nil } if focusID == createSubmitID { @@ -1138,10 +1108,7 @@ func (p *Plugin) handleCreateKeys(msg tea.KeyMsg) tea.Cmd { return nil } if p.createFocus == 2 { - p.promptPickerReturnMode = ViewModeCreate - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker + p.openPromptPicker(p.createPrompts, ViewModeCreate) return nil } if p.createFocus == 3 && len(p.taskSearchFiltered) > 0 { diff --git a/internal/plugins/workspace/mouse.go b/internal/plugins/workspace/mouse.go index 2161e08e..8b602b65 100644 --- a/internal/plugins/workspace/mouse.go +++ b/internal/plugins/workspace/mouse.go @@ -122,10 +122,7 @@ func (p *Plugin) handleCreateModalMouse(msg tea.MouseMsg) tea.Cmd { case createPromptFieldID: p.createFocus = 2 p.syncCreateModalFocus() - p.promptPickerReturnMode = ViewModeCreate - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker + p.openPromptPicker(p.createPrompts, ViewModeCreate) return nil case createNameFieldID: p.createFocus = 0 @@ -344,10 +341,7 @@ func (p *Plugin) handleAgentConfigModalMouse(msg tea.MouseMsg) tea.Cmd { p.clearAgentConfigModal() return nil case agentConfigPromptFieldID: - p.promptPickerReturnMode = ViewModeAgentConfig - p.promptPicker = NewPromptPicker(p.agentConfigPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker + p.openPromptPicker(p.agentConfigPrompts, ViewModeAgentConfig) return nil case agentConfigSubmitID: return p.executeAgentConfig() @@ -667,10 +661,7 @@ func (p *Plugin) handleMouseClick(action mouse.MouseAction) tea.Cmd { // If clicking prompt field, open the picker if focusIdx == 2 { - p.promptPickerReturnMode = ViewModeCreate - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker + p.openPromptPicker(p.createPrompts, ViewModeCreate) } } case regionCreateDropdown: diff --git a/internal/plugins/workspace/plugin.go b/internal/plugins/workspace/plugin.go index 39b907d9..aa8a8228 100644 --- a/internal/plugins/workspace/plugin.go +++ b/internal/plugins/workspace/plugin.go @@ -256,7 +256,6 @@ type Plugin struct { agentConfigPrompts []Prompt agentConfigModal *modal.Modal agentConfigModalWidth int - agentConfigFocusSet bool // Prompt picker return routing promptPickerReturnMode ViewMode