Skip to content

Commit e4f5340

Browse files
jacolaCopilot
andcommitted
feat: add agentFilter config to limit visible agents in selection UIs
Add a new config option `agentFilter` under `plugins.workspace` that allows users to specify which AI agents appear in agent selection UIs. When configured with an array of agent IDs (e.g., ["claude", "codex", "copilot"]), only those agents plus "None (attach only)" will be shown. If the config is empty or not set, all agents are shown (backward compatible). The filter applies to: - Create Worktree modal - Create Shell modal (type selector) - Agent Config modal (start/restart agent) - Conversations Resume modal Example config: ```json { "plugins": { "workspace": { "agentFilter": ["claude", "codex", "copilot"] } } } ``` Changes: - Add AgentFilter field to WorkspacePluginConfig - Add filteredAgentOrder() and filteredShellAgentOrder() helpers - Update all agent selection UIs to use filtered lists - Add comprehensive tests for filter functionality - Document the new config option in README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fbec49a commit e4f5340

14 files changed

Lines changed: 560 additions & 59 deletions

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,10 @@ Config file: `~/.config/sidecar/config.json`
249249
"td-monitor": { "enabled": true, "refreshInterval": "2s" },
250250
"conversations": { "enabled": true },
251251
"file-browser": { "enabled": true },
252-
"workspaces": { "enabled": true }
252+
"workspace": {
253+
"defaultAgentType": "claude",
254+
"agentFilter": ["claude", "codex", "copilot"]
255+
}
253256
},
254257
"ui": {
255258
"showClock": true,
@@ -261,6 +264,15 @@ Config file: `~/.config/sidecar/config.json`
261264
}
262265
```
263266

267+
### Workspace Plugin Options
268+
269+
| Option | Type | Default | Description |
270+
|--------|------|---------|-------------|
271+
| `defaultAgentType` | string | `"claude"` | Default agent selected when creating workspaces. Valid values: `claude`, `codex`, `copilot`, `gemini`, `cursor`, `opencode`, `pi`, `amp` |
272+
| `agentFilter` | string[] | `[]` (all agents) | Limits which agents appear in selection UIs. If empty or omitted, all agents are shown. "None (attach only)" is always available. |
273+
| `dirPrefix` | bool | `true` | Prefix workspace directories with repo name |
274+
| `agentStart` | object | `{}` | Map of agent type to custom startup command (e.g., `{"claude": "claude --profile fast"}`) |
275+
264276
## Contributing
265277

266278
- **Bug reports**: [Open an issue](https://github.com/marcus/sidecar/issues)

internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ type WorkspacePluginConfig struct {
9696
InteractivePasteKey string `json:"interactivePasteKey,omitempty"`
9797
// SidebarDisplay controls what information is shown in the workspace sidebar entries.
9898
SidebarDisplay SidebarDisplayConfig `json:"sidebarDisplay"`
99+
// AgentFilter limits which agents appear in selection UIs.
100+
// If empty or not set, all agents are shown.
101+
// Values are agent type IDs: "claude", "codex", "copilot", "gemini", "cursor", "opencode", "pi", "amp".
102+
// "none" is always available regardless of this setting.
103+
AgentFilter []string `json:"agentFilter,omitempty"`
99104
}
100105

101106
// SidebarDisplayConfig controls visibility of workspace sidebar entry elements.

internal/config/loader.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,14 @@ type rawWorkspaceConfig struct {
8383
DirPrefix *bool `json:"dirPrefix"`
8484
DefaultAgentType string `json:"defaultAgentType"`
8585
LegacyDefaultAgent string `json:"defaultAgent"` // Backward compatibility
86-
AgentStart json.RawMessage `json:"agentStart"`
86+
AgentStart json.RawMessage `json:"agentStart"`
8787
TmuxCaptureMaxBytes *int `json:"tmuxCaptureMaxBytes"`
8888
InteractiveExitKey string `json:"interactiveExitKey"`
8989
InteractiveAttachKey string `json:"interactiveAttachKey"`
9090
InteractiveCopyKey string `json:"interactiveCopyKey"`
9191
InteractivePasteKey string `json:"interactivePasteKey"`
9292
SidebarDisplay *rawSidebarDisplayConfig `json:"sidebarDisplay"`
93+
AgentFilter []string `json:"agentFilter"`
9394
}
9495

9596
type rawSidebarDisplayConfig struct {
@@ -251,6 +252,9 @@ func mergeConfig(cfg *Config, raw *rawConfig) {
251252
if agentStart, ok := parseAgentStartOverrides(raw.Plugins.Workspace.AgentStart); ok {
252253
cfg.Plugins.Workspace.AgentStart = agentStart
253254
}
255+
if len(raw.Plugins.Workspace.AgentFilter) > 0 {
256+
cfg.Plugins.Workspace.AgentFilter = raw.Plugins.Workspace.AgentFilter
257+
}
254258
if raw.Plugins.Workspace.InteractiveExitKey != "" {
255259
cfg.Plugins.Workspace.InteractiveExitKey = raw.Plugins.Workspace.InteractiveExitKey
256260
}

internal/config/loader_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,3 +408,58 @@ func TestLoadFrom_FileBrowserDisabled(t *testing.T) {
408408
t.Error("git-status should still be enabled (default)")
409409
}
410410
}
411+
412+
func TestLoadFrom_WorkspaceAgentFilter(t *testing.T) {
413+
dir := t.TempDir()
414+
path := filepath.Join(dir, "config.json")
415+
content := []byte(`{
416+
"plugins": {
417+
"workspace": {
418+
"agentFilter": ["claude", "codex", "copilot"]
419+
}
420+
}
421+
}`)
422+
if err := os.WriteFile(path, content, 0644); err != nil {
423+
t.Fatal(err)
424+
}
425+
426+
cfg, err := LoadFrom(path)
427+
if err != nil {
428+
t.Fatalf("LoadFrom failed: %v", err)
429+
}
430+
431+
if len(cfg.Plugins.Workspace.AgentFilter) != 3 {
432+
t.Errorf("AgentFilter length = %d, want 3", len(cfg.Plugins.Workspace.AgentFilter))
433+
}
434+
expected := []string{"claude", "codex", "copilot"}
435+
for i, want := range expected {
436+
if cfg.Plugins.Workspace.AgentFilter[i] != want {
437+
t.Errorf("AgentFilter[%d] = %q, want %q", i, cfg.Plugins.Workspace.AgentFilter[i], want)
438+
}
439+
}
440+
}
441+
442+
func TestLoadFrom_WorkspaceAgentFilter_Empty(t *testing.T) {
443+
dir := t.TempDir()
444+
path := filepath.Join(dir, "config.json")
445+
content := []byte(`{
446+
"plugins": {
447+
"workspace": {
448+
"agentFilter": []
449+
}
450+
}
451+
}`)
452+
if err := os.WriteFile(path, content, 0644); err != nil {
453+
t.Fatal(err)
454+
}
455+
456+
cfg, err := LoadFrom(path)
457+
if err != nil {
458+
t.Fatalf("LoadFrom failed: %v", err)
459+
}
460+
461+
// Empty array should not override (stays nil/empty)
462+
if len(cfg.Plugins.Workspace.AgentFilter) != 0 {
463+
t.Errorf("AgentFilter length = %d, want 0", len(cfg.Plugins.Workspace.AgentFilter))
464+
}
465+
}

internal/plugins/conversations/plugin.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/marcus/sidecar/internal/modal"
1919
"github.com/marcus/sidecar/internal/mouse"
2020
"github.com/marcus/sidecar/internal/plugin"
21+
"github.com/marcus/sidecar/internal/plugins/workspace"
2122
"github.com/marcus/sidecar/internal/state"
2223
"github.com/marcus/sidecar/internal/ui"
2324
)
@@ -240,6 +241,7 @@ type Plugin struct {
240241
resumeNameInput textinput.Model
241242
resumeBaseBranchInput textinput.Model
242243
resumeAgentIdx int
244+
resumeAgentOrder []workspace.AgentType // Filtered agent order
243245
resumeSkipPermissions bool
244246
resumeFocus int
245247
resumeSession *adapter.Session

internal/plugins/conversations/resume_modal.go

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,12 @@ func (p *Plugin) ensureResumeModal() {
6666
}
6767

6868
// Build agent selection list
69-
agentItems := make([]modal.ListItem, len(workspace.AgentTypeOrder))
70-
for i, at := range workspace.AgentTypeOrder {
69+
agentOrder := p.resumeAgentOrder
70+
if len(agentOrder) == 0 {
71+
agentOrder = workspace.AgentTypeOrder
72+
}
73+
agentItems := make([]modal.ListItem, len(agentOrder))
74+
for i, at := range agentOrder {
7175
agentItems[i] = modal.ListItem{
7276
ID: fmt.Sprintf("%s%d", resumeAgentItemPrefix, i),
7377
Label: workspace.AgentDisplayNames[at],
@@ -139,10 +143,14 @@ func (p *Plugin) shouldShowResumeSkipPerms() bool {
139143
if !p.isResumeWorktreeMode() {
140144
return false
141145
}
142-
if p.resumeAgentIdx < 0 || p.resumeAgentIdx >= len(workspace.AgentTypeOrder) {
146+
agentOrder := p.resumeAgentOrder
147+
if len(agentOrder) == 0 {
148+
agentOrder = workspace.AgentTypeOrder
149+
}
150+
if p.resumeAgentIdx < 0 || p.resumeAgentIdx >= len(agentOrder) {
143151
return false
144152
}
145-
agentType := workspace.AgentTypeOrder[p.resumeAgentIdx]
153+
agentType := agentOrder[p.resumeAgentIdx]
146154
if agentType == workspace.AgentNone {
147155
return false
148156
}
@@ -180,7 +188,11 @@ func (p *Plugin) handleResumeModalKeys(msg tea.KeyMsg) tea.Cmd {
180188
if strings.HasPrefix(action, resumeAgentItemPrefix) {
181189
var idx int
182190
_, _ = fmt.Sscanf(action, resumeAgentItemPrefix+"%d", &idx)
183-
if idx >= 0 && idx < len(workspace.AgentTypeOrder) {
191+
agentOrder := p.resumeAgentOrder
192+
if len(agentOrder) == 0 {
193+
agentOrder = workspace.AgentTypeOrder
194+
}
195+
if idx >= 0 && idx < len(agentOrder) {
184196
p.resumeAgentIdx = idx
185197
}
186198
}
@@ -217,7 +229,11 @@ func (p *Plugin) handleResumeModalMouse(msg tea.MouseMsg) tea.Cmd {
217229
if strings.HasPrefix(action, resumeAgentItemPrefix) {
218230
var idx int
219231
_, _ = fmt.Sscanf(action, resumeAgentItemPrefix+"%d", &idx)
220-
if idx >= 0 && idx < len(workspace.AgentTypeOrder) {
232+
agentOrder := p.resumeAgentOrder
233+
if len(agentOrder) == 0 {
234+
agentOrder = workspace.AgentTypeOrder
235+
}
236+
if idx >= 0 && idx < len(agentOrder) {
221237
p.resumeAgentIdx = idx
222238
}
223239
}
@@ -276,7 +292,8 @@ func (p *Plugin) openResumeModal() tea.Cmd {
276292
p.resumeBaseBranchInput.CharLimit = 100
277293

278294
// Set default agent based on adapter
279-
p.resumeAgentIdx = defaultAgentIdxForAdapter(session.AdapterID)
295+
p.resumeAgentOrder = p.filteredAgentOrder()
296+
p.resumeAgentIdx = p.defaultAgentIdxInOrder(session.AdapterID)
280297
p.resumeSkipPermissions = false
281298

282299
// Clear cached modal to rebuild with new session
@@ -295,9 +312,47 @@ func (p *Plugin) resetResumeModal() {
295312
p.resumeType = resumeTypeShell
296313
p.resumeFocus = 0
297314
p.resumeAgentIdx = 0
315+
p.resumeAgentOrder = nil
298316
p.resumeSkipPermissions = false
299317
}
300318

319+
// filteredAgentOrder returns the agent order filtered by workspace config.
320+
// If agentFilter is empty, returns the full AgentTypeOrder.
321+
// AgentNone is always included at the end regardless of filter.
322+
func (p *Plugin) filteredAgentOrder() []workspace.AgentType {
323+
if p.ctx == nil || p.ctx.Config == nil {
324+
return workspace.AgentTypeOrder
325+
}
326+
filter := p.ctx.Config.Plugins.Workspace.AgentFilter
327+
if len(filter) == 0 {
328+
return workspace.AgentTypeOrder
329+
}
330+
331+
// Build set of allowed agents from config
332+
allowed := make(map[workspace.AgentType]bool, len(filter))
333+
for _, id := range filter {
334+
at := workspace.AgentType(id)
335+
if _, ok := workspace.AgentDisplayNames[at]; ok && at != workspace.AgentNone {
336+
allowed[at] = true
337+
}
338+
}
339+
340+
// Filter AgentTypeOrder to preserve ordering
341+
result := make([]workspace.AgentType, 0, len(allowed)+1)
342+
for _, at := range workspace.AgentTypeOrder {
343+
if at == workspace.AgentNone {
344+
continue // Add None at the end
345+
}
346+
if allowed[at] {
347+
result = append(result, at)
348+
}
349+
}
350+
351+
// Always include None at the end
352+
result = append(result, workspace.AgentNone)
353+
return result
354+
}
355+
301356
// executeResume sends the resume message to workspace plugin.
302357
func (p *Plugin) executeResume() tea.Cmd {
303358
session := p.resumeSession
@@ -329,8 +384,12 @@ func (p *Plugin) executeResume() tea.Cmd {
329384
if msg.BaseBranch == "" {
330385
msg.BaseBranch = "HEAD"
331386
}
332-
if p.resumeAgentIdx >= 0 && p.resumeAgentIdx < len(workspace.AgentTypeOrder) {
333-
msg.AgentType = workspace.AgentTypeOrder[p.resumeAgentIdx]
387+
agentOrder := p.resumeAgentOrder
388+
if len(agentOrder) == 0 {
389+
agentOrder = workspace.AgentTypeOrder
390+
}
391+
if p.resumeAgentIdx >= 0 && p.resumeAgentIdx < len(agentOrder) {
392+
msg.AgentType = agentOrder[p.resumeAgentIdx]
334393
}
335394
msg.SkipPerms = p.resumeSkipPermissions
336395
}
@@ -390,6 +449,38 @@ func defaultAgentIdxForAdapter(adapterID string) int {
390449
return 0
391450
}
392451

452+
// defaultAgentIdxInOrder returns the index in resumeAgentOrder for the given adapter.
453+
func (p *Plugin) defaultAgentIdxInOrder(adapterID string) int {
454+
var agentType workspace.AgentType
455+
switch adapterID {
456+
case "claude-code":
457+
agentType = workspace.AgentClaude
458+
case "codex":
459+
agentType = workspace.AgentCodex
460+
case "gemini-cli":
461+
agentType = workspace.AgentGemini
462+
case "cursor-cli":
463+
agentType = workspace.AgentCursor
464+
case "opencode":
465+
agentType = workspace.AgentOpenCode
466+
case "pi-agent", "pi":
467+
agentType = workspace.AgentPi
468+
default:
469+
return 0 // Default to first agent
470+
}
471+
472+
agentOrder := p.resumeAgentOrder
473+
if len(agentOrder) == 0 {
474+
agentOrder = workspace.AgentTypeOrder
475+
}
476+
for i, at := range agentOrder {
477+
if at == agentType {
478+
return i
479+
}
480+
}
481+
return 0
482+
}
483+
393484
// sanitizeBranchName converts a string to a valid git branch name.
394485
var branchNameInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9-]`)
395486

internal/plugins/workspace/agent_config_modal.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ func (p *Plugin) openAgentConfigModal(wt *Worktree, isRestart bool) {
2626
configDir := filepath.Join(home, ".config", "sidecar")
2727
p.agentConfigWorktree = wt
2828
p.agentConfigIsRestart = isRestart
29+
p.agentConfigAgentOrder = p.filteredAgentOrder()
2930
p.agentConfigAgentType = p.resolveWorktreeAgentType(wt)
30-
p.agentConfigAgentIdx = p.agentTypeIndex(p.agentConfigAgentType)
31+
p.agentConfigAgentIdx = p.agentConfigTypeIndex(p.agentConfigAgentType)
3132
p.agentConfigSkipPerms = false
3233
p.agentConfigPromptIdx = -1
3334
p.agentConfigPrompts = LoadPrompts(configDir, p.ctx.ProjectRoot)
@@ -36,12 +37,27 @@ func (p *Plugin) openAgentConfigModal(wt *Worktree, isRestart bool) {
3637
p.viewMode = ViewModeAgentConfig
3738
}
3839

40+
// agentConfigTypeIndex returns the index of the agent type in agentConfigAgentOrder.
41+
func (p *Plugin) agentConfigTypeIndex(agentType AgentType) int {
42+
agentOrder := p.agentConfigAgentOrder
43+
if len(agentOrder) == 0 {
44+
agentOrder = AgentTypeOrder
45+
}
46+
for i, at := range agentOrder {
47+
if at == agentType {
48+
return i
49+
}
50+
}
51+
return 0
52+
}
53+
3954
// clearAgentConfigModal resets all agent config modal state.
4055
func (p *Plugin) clearAgentConfigModal() {
4156
p.agentConfigWorktree = nil
4257
p.agentConfigIsRestart = false
4358
p.agentConfigAgentType = ""
4459
p.agentConfigAgentIdx = 0
60+
p.agentConfigAgentOrder = nil
4561
p.agentConfigSkipPerms = false
4662
p.agentConfigPromptIdx = -1
4763
p.agentConfigPrompts = nil
@@ -95,8 +111,12 @@ func (p *Plugin) ensureAgentConfigModal() {
95111
}
96112
p.agentConfigModalWidth = modalW
97113

98-
items := make([]modal.ListItem, len(AgentTypeOrder))
99-
for i, at := range AgentTypeOrder {
114+
agentOrder := p.agentConfigAgentOrder
115+
if len(agentOrder) == 0 {
116+
agentOrder = AgentTypeOrder
117+
}
118+
items := make([]modal.ListItem, len(agentOrder))
119+
for i, at := range agentOrder {
100120
items[i] = modal.ListItem{
101121
ID: fmt.Sprintf("%s%d", agentConfigAgentItemPrefix, i),
102122
Label: AgentDisplayNames[at],

internal/plugins/workspace/agent_config_modal_test.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,14 @@ func TestGetAgentConfigPrompt(t *testing.T) {
4545

4646
func TestClearAgentConfigModal(t *testing.T) {
4747
p := &Plugin{
48-
agentConfigWorktree: &Worktree{Name: "test"},
49-
agentConfigIsRestart: true,
50-
agentConfigAgentType: AgentClaude,
51-
agentConfigAgentIdx: 3,
52-
agentConfigSkipPerms: true,
53-
agentConfigPromptIdx: 2,
54-
agentConfigPrompts: []Prompt{{Name: "x"}},
48+
agentConfigWorktree: &Worktree{Name: "test"},
49+
agentConfigIsRestart: true,
50+
agentConfigAgentType: AgentClaude,
51+
agentConfigAgentIdx: 3,
52+
agentConfigAgentOrder: []AgentType{AgentClaude, AgentCodex},
53+
agentConfigSkipPerms: true,
54+
agentConfigPromptIdx: 2,
55+
agentConfigPrompts: []Prompt{{Name: "x"}},
5556
}
5657
p.clearAgentConfigModal()
5758

@@ -67,6 +68,9 @@ func TestClearAgentConfigModal(t *testing.T) {
6768
if p.agentConfigAgentIdx != 0 {
6869
t.Error("agentIdx not cleared")
6970
}
71+
if p.agentConfigAgentOrder != nil {
72+
t.Error("agentOrder not cleared")
73+
}
7074
if p.agentConfigSkipPerms {
7175
t.Error("skipPerms not cleared")
7276
}

0 commit comments

Comments
 (0)