Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions internal/plugins/workspace/agent_config_modal.go
Original file line number Diff line number Diff line change
@@ -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)
}
169 changes: 169 additions & 0 deletions internal/plugins/workspace/agent_config_modal_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading