diff --git a/internal/plugins/workspace/agent_config_modal.go b/internal/plugins/workspace/agent_config_modal.go new file mode 100644 index 00000000..15a301c3 --- /dev/null +++ b/internal/plugins/workspace/agent_config_modal.go @@ -0,0 +1,228 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "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-" +) + +// 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 + p.agentConfigIsRestart = false + p.agentConfigAgentType = "" + p.agentConfigAgentIdx = 0 + p.agentConfigSkipPerms = false + p.agentConfigPromptIdx = -1 + p.agentConfigPrompts = nil + p.agentConfigModal = nil + p.agentConfigModalWidth = 0 +} + +// 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. +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), + )) + + // Set initial focus when modal is first built + p.agentConfigModal.SetFocus(agentConfigAgentListID) +} + +// 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 + } + + 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..1ccfa538 --- /dev/null +++ b/internal/plugins/workspace/agent_config_modal_test.go @@ -0,0 +1,169 @@ +package workspace + +import ( + "testing" + + "github.com/marcus/sidecar/internal/plugin" +) + +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") + } +} + +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) + } + }) + } +} + +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, + 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{ + ctx: &plugin.Context{}, + 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/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/keys.go b/internal/plugins/workspace/keys.go index 9e1a3a53..aa3e8293 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,94 @@ 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 + } + + 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.openPromptPicker(p.agentConfigPrompts, ViewModeAgentConfig) + 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) + + // 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 + } + + 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 @@ -295,13 +385,9 @@ 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 + p.openAgentConfigModal(wt, true) + return nil } // handleConfirmDeleteKeys handles keys in delete confirmation modal. @@ -803,8 +889,9 @@ 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 + p.openAgentConfigModal(wt, false) + return nil } // Agent exists - show choice modal (attach or restart) p.agentChoiceWorktree = wt @@ -1001,9 +1088,7 @@ func (p *Plugin) handleCreateKeys(msg tea.KeyMsg) tea.Cmd { return nil } if focusID == createPromptFieldID { - p.promptPicker = NewPromptPicker(p.createPrompts, p.width, p.height) - p.clearPromptPickerModal() - p.viewMode = ViewModePromptPicker + p.openPromptPicker(p.createPrompts, ViewModeCreate) return nil } if focusID == createSubmitID { @@ -1023,9 +1108,7 @@ func (p *Plugin) handleCreateKeys(msg tea.KeyMsg) tea.Cmd { return nil } if p.createFocus == 2 { - 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/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) diff --git a/internal/plugins/workspace/mouse.go b/internal/plugins/workspace/mouse.go index 39dfe18e..8b602b65 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) } @@ -118,9 +122,7 @@ func (p *Plugin) handleCreateModalMouse(msg tea.MouseMsg) tea.Cmd { case createPromptFieldID: p.createFocus = 2 p.syncCreateModalFocus() - 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 @@ -315,6 +317,38 @@ 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.openPromptPicker(p.agentConfigPrompts, ViewModeAgentConfig) + 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 +455,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 @@ -624,9 +661,7 @@ func (p *Plugin) handleMouseClick(action mouse.MouseAction) tea.Cmd { // If clicking prompt field, open the picker if focusIdx == 2 { - 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 0dda399c..aa8a8228 100644 --- a/internal/plugins/workspace/plugin.go +++ b/internal/plugins/workspace/plugin.go @@ -246,6 +246,20 @@ 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 + + // Prompt picker return routing + promptPickerReturnMode ViewMode + // Delete confirmation modal state deleteConfirmWorktree *Worktree // Worktree pending deletion deleteLocalBranchOpt bool // Checkbox: delete local branch @@ -444,6 +458,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/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. diff --git a/internal/plugins/workspace/update.go b/internal/plugins/workspace/update.go index 97ea6a0a..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 { @@ -971,6 +997,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 = "" 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: 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 {