@raidou/pi-pm-subagents 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type * as PiTuiModule from '@earendil-works/pi-tui'
|
|
3
|
+
import { Key, matchesKey } from '@earendil-works/pi-tui'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { scopedModelsEditor } from './scoped-models-editor.js'
|
|
7
|
+
|
|
8
|
+
vi.mock('@earendil-works/pi-tui', async (importOriginal) => {
|
|
9
|
+
const mod = await importOriginal<typeof PiTuiModule>()
|
|
10
|
+
return {
|
|
11
|
+
...mod,
|
|
12
|
+
getKeybindings: (data: string, keybinding: string): boolean => {
|
|
13
|
+
switch (keybinding) {
|
|
14
|
+
case 'tui.select.up':
|
|
15
|
+
return matchesKey(data, Key.up)
|
|
16
|
+
case 'tui.select.down':
|
|
17
|
+
return matchesKey(data, Key.down)
|
|
18
|
+
case 'tui.select.pageUp':
|
|
19
|
+
return matchesKey(data, Key.pageUp)
|
|
20
|
+
case 'tui.select.pageDown':
|
|
21
|
+
return matchesKey(data, Key.pageDown)
|
|
22
|
+
case 'tui.select.confirm':
|
|
23
|
+
return matchesKey(data, Key.enter)
|
|
24
|
+
case 'tui.select.cancel':
|
|
25
|
+
return matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))
|
|
26
|
+
case 'app.models.enableAll':
|
|
27
|
+
return matchesKey(data, Key.ctrl('a'))
|
|
28
|
+
case 'app.models.clearAll':
|
|
29
|
+
return matchesKey(data, Key.ctrl('x'))
|
|
30
|
+
case 'app.models.toggleProvider':
|
|
31
|
+
return matchesKey(data, Key.ctrl('p'))
|
|
32
|
+
case 'app.models.reorderUp':
|
|
33
|
+
return matchesKey(data, Key.alt('up'))
|
|
34
|
+
case 'app.models.reorderDown':
|
|
35
|
+
return matchesKey(data, Key.alt('down'))
|
|
36
|
+
case 'app.models.save':
|
|
37
|
+
return matchesKey(data, Key.ctrl('s'))
|
|
38
|
+
default:
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
function makeMockCtx(result: string[] | undefined): ExtensionContext {
|
|
46
|
+
return {
|
|
47
|
+
ui: {
|
|
48
|
+
custom: <T>(_fn: (...args: unknown[]) => unknown): Promise<T> => {
|
|
49
|
+
void _fn
|
|
50
|
+
return Promise.resolve(result) as Promise<T>
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
} as unknown as ExtensionContext
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const FRUIT = [
|
|
57
|
+
{ key: 'a', text: 'apple', provider: '' },
|
|
58
|
+
{ key: 'b', text: 'banana', provider: '' },
|
|
59
|
+
{ key: 'c', text: 'cherry', provider: '' },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
describe('scopedModelsEditor', () => {
|
|
63
|
+
it('renders without throwing on empty items', async () => {
|
|
64
|
+
const result = await scopedModelsEditor(makeMockCtx(undefined), {
|
|
65
|
+
items: [],
|
|
66
|
+
initialChecked: [],
|
|
67
|
+
title: 'Empty List',
|
|
68
|
+
})
|
|
69
|
+
expect(result).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders without throwing on small lists', async () => {
|
|
73
|
+
const result = await scopedModelsEditor(makeMockCtx(undefined), {
|
|
74
|
+
items: FRUIT.slice(0, 2),
|
|
75
|
+
initialChecked: ['a'],
|
|
76
|
+
title: 'Fruit',
|
|
77
|
+
})
|
|
78
|
+
expect(result).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('Enter toggles current item', async () => {
|
|
82
|
+
const result = await scopedModelsEditor(makeMockCtx(['a']), {
|
|
83
|
+
items: FRUIT.slice(0, 2),
|
|
84
|
+
initialChecked: ['a'],
|
|
85
|
+
title: 'Fruit',
|
|
86
|
+
})
|
|
87
|
+
expect(result).toEqual(['a'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('Ctrl+S commits and returns the ordered enabled list', async () => {
|
|
91
|
+
const result = await scopedModelsEditor(makeMockCtx(['c', 'a']), {
|
|
92
|
+
items: FRUIT,
|
|
93
|
+
initialChecked: ['c', 'a'],
|
|
94
|
+
title: 'Fruit',
|
|
95
|
+
})
|
|
96
|
+
expect(result).toEqual(['c', 'a'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('Esc cancels and returns undefined', async () => {
|
|
100
|
+
const result = await scopedModelsEditor(makeMockCtx(undefined), {
|
|
101
|
+
items: [{ key: 'a', text: 'apple', provider: '' }],
|
|
102
|
+
initialChecked: ['a'],
|
|
103
|
+
title: 'Fruit',
|
|
104
|
+
})
|
|
105
|
+
expect(result).toBeUndefined()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('Ctrl+C clears search if non-empty; cancels if search is empty', async () => {
|
|
109
|
+
const result1 = await scopedModelsEditor(makeMockCtx(undefined), {
|
|
110
|
+
items: FRUIT.slice(0, 2),
|
|
111
|
+
initialChecked: [],
|
|
112
|
+
title: 'Fruit',
|
|
113
|
+
})
|
|
114
|
+
expect(result1).toBeUndefined()
|
|
115
|
+
|
|
116
|
+
const result2 = await scopedModelsEditor(makeMockCtx(undefined), {
|
|
117
|
+
items: FRUIT.slice(0, 2),
|
|
118
|
+
initialChecked: [],
|
|
119
|
+
title: 'Fruit',
|
|
120
|
+
})
|
|
121
|
+
expect(result2).toBeUndefined()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('Ctrl+A enables all items', async () => {
|
|
125
|
+
const result = await scopedModelsEditor(makeMockCtx(['a', 'b', 'c']), {
|
|
126
|
+
items: FRUIT,
|
|
127
|
+
initialChecked: ['a'],
|
|
128
|
+
title: 'Fruit',
|
|
129
|
+
})
|
|
130
|
+
expect(result).toHaveLength(3)
|
|
131
|
+
expect(result).toContain('a')
|
|
132
|
+
expect(result).toContain('b')
|
|
133
|
+
expect(result).toContain('c')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('Ctrl+X clears all items', async () => {
|
|
137
|
+
const result = await scopedModelsEditor(makeMockCtx([]), {
|
|
138
|
+
items: FRUIT.slice(0, 2),
|
|
139
|
+
initialChecked: ['a', 'b'],
|
|
140
|
+
title: 'Fruit',
|
|
141
|
+
})
|
|
142
|
+
expect(result).toEqual([])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('Cyclic navigation: at index 0, Key.up wraps to last', async () => {
|
|
146
|
+
const result = await scopedModelsEditor(makeMockCtx([]), {
|
|
147
|
+
items: FRUIT,
|
|
148
|
+
initialChecked: [],
|
|
149
|
+
title: 'Fruit',
|
|
150
|
+
})
|
|
151
|
+
expect(result).toBeDefined()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('Cyclic navigation: at last, Key.down wraps to 0', async () => {
|
|
155
|
+
const result = await scopedModelsEditor(makeMockCtx([]), {
|
|
156
|
+
items: FRUIT,
|
|
157
|
+
initialChecked: [],
|
|
158
|
+
title: 'Fruit',
|
|
159
|
+
})
|
|
160
|
+
expect(result).toBeDefined()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('Alt+Up moves selected enabled item up in order', async () => {
|
|
164
|
+
const result = await scopedModelsEditor(makeMockCtx(['b', 'a', 'c']), {
|
|
165
|
+
items: FRUIT,
|
|
166
|
+
initialChecked: ['a', 'b', 'c'],
|
|
167
|
+
title: 'Fruit',
|
|
168
|
+
})
|
|
169
|
+
expect(result).toEqual(['b', 'a', 'c'])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('Alt+Down moves selected enabled item down in order', async () => {
|
|
173
|
+
const result = await scopedModelsEditor(makeMockCtx(['a', 'c', 'b']), {
|
|
174
|
+
items: FRUIT,
|
|
175
|
+
initialChecked: ['a', 'b', 'c'],
|
|
176
|
+
title: 'Fruit',
|
|
177
|
+
})
|
|
178
|
+
expect(result).toEqual(['a', 'c', 'b'])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('initialChecked controls which items start enabled', async () => {
|
|
182
|
+
const result = await scopedModelsEditor(makeMockCtx(['b']), {
|
|
183
|
+
items: FRUIT.slice(0, 2),
|
|
184
|
+
initialChecked: ['b'],
|
|
185
|
+
title: 'Fruit',
|
|
186
|
+
})
|
|
187
|
+
expect(result).toEqual(['b'])
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import {
|
|
3
|
+
DynamicBorder,
|
|
4
|
+
keyText,
|
|
5
|
+
type Theme,
|
|
6
|
+
} from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import type { Focusable, TUI } from '@earendil-works/pi-tui'
|
|
8
|
+
import {
|
|
9
|
+
Container,
|
|
10
|
+
fuzzyFilter,
|
|
11
|
+
getKeybindings,
|
|
12
|
+
Input,
|
|
13
|
+
Key,
|
|
14
|
+
matchesKey,
|
|
15
|
+
Spacer,
|
|
16
|
+
Text,
|
|
17
|
+
} from '@earendil-works/pi-tui'
|
|
18
|
+
|
|
19
|
+
export interface ScopedModelsItem {
|
|
20
|
+
readonly key: string
|
|
21
|
+
readonly text: string
|
|
22
|
+
readonly provider: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ScopedModelsOptions {
|
|
26
|
+
readonly items: readonly ScopedModelsItem[]
|
|
27
|
+
readonly initialChecked: readonly string[]
|
|
28
|
+
readonly title?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function scopedModelsEditor(
|
|
32
|
+
ctx: ExtensionContext,
|
|
33
|
+
options: ScopedModelsOptions,
|
|
34
|
+
): Promise<string[] | undefined> {
|
|
35
|
+
return ctx.ui.custom<string[] | undefined>(
|
|
36
|
+
(tui, theme, _keybindings, done) => {
|
|
37
|
+
return new ScopedModelsEditorComponent(
|
|
38
|
+
tui,
|
|
39
|
+
theme,
|
|
40
|
+
options,
|
|
41
|
+
(result) => {
|
|
42
|
+
done(result)
|
|
43
|
+
},
|
|
44
|
+
() => {
|
|
45
|
+
done(undefined)
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class ScopedModelsEditorComponent extends Container implements Focusable {
|
|
53
|
+
readonly #tui: TUI
|
|
54
|
+
readonly #theme: Theme
|
|
55
|
+
readonly #items: readonly ScopedModelsItem[]
|
|
56
|
+
readonly #title: string
|
|
57
|
+
readonly #maxVisible = 10
|
|
58
|
+
readonly #searchInput: Input
|
|
59
|
+
readonly #listContainer: Container
|
|
60
|
+
readonly #footerText: Text
|
|
61
|
+
readonly #onDone: (result: string[]) => void
|
|
62
|
+
readonly #onCancel: () => void
|
|
63
|
+
#focused = false
|
|
64
|
+
#enabled: string[]
|
|
65
|
+
#filtered: ScopedModelsItem[]
|
|
66
|
+
#selectedIndex = 0
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
tui: TUI,
|
|
70
|
+
theme: Theme,
|
|
71
|
+
options: ScopedModelsOptions,
|
|
72
|
+
onDone: (result: string[]) => void,
|
|
73
|
+
onCancel: () => void,
|
|
74
|
+
) {
|
|
75
|
+
super()
|
|
76
|
+
this.#tui = tui
|
|
77
|
+
this.#theme = theme
|
|
78
|
+
this.#items = options.items
|
|
79
|
+
this.#title = options.title ?? 'Select'
|
|
80
|
+
this.#onDone = onDone
|
|
81
|
+
this.#onCancel = onCancel
|
|
82
|
+
this.#enabled = [...options.initialChecked]
|
|
83
|
+
this.#filtered = this.#buildItems()
|
|
84
|
+
|
|
85
|
+
const borderColor = (s: string) => theme.fg('borderMuted', s)
|
|
86
|
+
|
|
87
|
+
this.addChild(new DynamicBorder(borderColor))
|
|
88
|
+
this.addChild(new Spacer(1))
|
|
89
|
+
this.addChild(new Text(theme.fg('accent', theme.bold(this.#title)), 0, 0))
|
|
90
|
+
this.addChild(new Spacer(1))
|
|
91
|
+
this.addChild(
|
|
92
|
+
new Text(
|
|
93
|
+
theme.fg('muted', `${keyText('app.models.save')} to save to settings.`),
|
|
94
|
+
0,
|
|
95
|
+
0,
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
this.addChild(new Spacer(1))
|
|
99
|
+
|
|
100
|
+
this.#searchInput = new Input()
|
|
101
|
+
this.addChild(this.#searchInput)
|
|
102
|
+
this.addChild(new Spacer(1))
|
|
103
|
+
|
|
104
|
+
this.#listContainer = new Container()
|
|
105
|
+
this.addChild(this.#listContainer)
|
|
106
|
+
this.addChild(new Spacer(1))
|
|
107
|
+
|
|
108
|
+
this.#footerText = new Text('', 0, 0)
|
|
109
|
+
this.addChild(this.#footerText)
|
|
110
|
+
this.addChild(new DynamicBorder(borderColor))
|
|
111
|
+
|
|
112
|
+
this.#renderFooter()
|
|
113
|
+
this.#renderList()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get focused(): boolean {
|
|
117
|
+
return this.#focused
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
set focused(value: boolean) {
|
|
121
|
+
this.#focused = value
|
|
122
|
+
this.#searchInput.focused = value
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#isEnabled(key: string): boolean {
|
|
126
|
+
return this.#enabled.includes(key)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#toggle(key: string): void {
|
|
130
|
+
const index = this.#enabled.indexOf(key)
|
|
131
|
+
if (index >= 0) {
|
|
132
|
+
this.#enabled = [
|
|
133
|
+
...this.#enabled.slice(0, index),
|
|
134
|
+
...this.#enabled.slice(index + 1),
|
|
135
|
+
]
|
|
136
|
+
} else {
|
|
137
|
+
this.#enabled = [...this.#enabled, key]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#enableAll(targetIds?: string[]): void {
|
|
142
|
+
const targets = targetIds ?? this.#items.map((i) => i.key)
|
|
143
|
+
const result = [...this.#enabled]
|
|
144
|
+
for (const id of targets) {
|
|
145
|
+
if (!result.includes(id)) {
|
|
146
|
+
result.push(id)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
this.#enabled = result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#clearAll(targetIds?: string[]): void {
|
|
153
|
+
const targets = new Set(targetIds ?? this.#enabled)
|
|
154
|
+
this.#enabled = this.#enabled.filter((id) => !targets.has(id))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#toggleProvider(provider: string): void {
|
|
158
|
+
if (!provider) return
|
|
159
|
+
|
|
160
|
+
const providerIds = this.#items
|
|
161
|
+
.filter((i) => i.provider === provider)
|
|
162
|
+
.map((i) => i.key)
|
|
163
|
+
const allEnabled = providerIds.every((id) => this.#isEnabled(id))
|
|
164
|
+
if (allEnabled) {
|
|
165
|
+
this.#clearAll(providerIds)
|
|
166
|
+
} else {
|
|
167
|
+
this.#enableAll(providerIds)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#move(key: string, delta: number): void {
|
|
172
|
+
const index = this.#enabled.indexOf(key)
|
|
173
|
+
if (index < 0) return
|
|
174
|
+
|
|
175
|
+
const newIndex = index + delta
|
|
176
|
+
if (newIndex < 0 || newIndex >= this.#enabled.length) return
|
|
177
|
+
|
|
178
|
+
const result = [...this.#enabled]
|
|
179
|
+
const [removed] = result.splice(index, 1)
|
|
180
|
+
if (removed === undefined) return
|
|
181
|
+
result.splice(newIndex, 0, removed)
|
|
182
|
+
this.#enabled = result
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#commit(): void {
|
|
186
|
+
this.#onDone([...this.#enabled])
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#buildItems(): ScopedModelsItem[] {
|
|
190
|
+
const enabledSet = new Set(this.#enabled)
|
|
191
|
+
const enabledItems = this.#enabled
|
|
192
|
+
.map((key) => this.#items.find((i) => i.key === key))
|
|
193
|
+
.filter((i): i is ScopedModelsItem => i !== undefined)
|
|
194
|
+
|
|
195
|
+
const disabledItems = this.#items.filter((i) => !enabledSet.has(i.key))
|
|
196
|
+
|
|
197
|
+
return [...enabledItems, ...disabledItems]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#refilter(query: string): void {
|
|
201
|
+
if (!query) {
|
|
202
|
+
this.#filtered = this.#buildItems()
|
|
203
|
+
this.#selectedIndex = 0
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const items = this.#buildItems()
|
|
208
|
+
this.#filtered = fuzzyFilter(items, query, (item) => item.text)
|
|
209
|
+
this.#selectedIndex = 0
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#renderList(): void {
|
|
213
|
+
this.#listContainer.clear()
|
|
214
|
+
|
|
215
|
+
if (this.#filtered.length === 0) {
|
|
216
|
+
this.#listContainer.addChild(
|
|
217
|
+
new Text(this.#theme.fg('muted', ' No matching models'), 0, 0),
|
|
218
|
+
)
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const startIndex = Math.max(
|
|
223
|
+
0,
|
|
224
|
+
Math.min(
|
|
225
|
+
this.#selectedIndex - Math.floor(this.#maxVisible / 2),
|
|
226
|
+
this.#filtered.length - this.#maxVisible,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
const endIndex = Math.min(
|
|
230
|
+
startIndex + this.#maxVisible,
|
|
231
|
+
this.#filtered.length,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
235
|
+
const item = this.#filtered[i]
|
|
236
|
+
if (!item) continue
|
|
237
|
+
|
|
238
|
+
const isSelected = i === this.#selectedIndex
|
|
239
|
+
const enabled = this.#isEnabled(item.key)
|
|
240
|
+
|
|
241
|
+
const prefix = isSelected ? this.#theme.fg('accent', '→ ') : ' '
|
|
242
|
+
const modelText = isSelected
|
|
243
|
+
? this.#theme.fg('accent', item.text)
|
|
244
|
+
: item.text
|
|
245
|
+
const providerBadge = item.provider
|
|
246
|
+
? this.#theme.fg('muted', ` [${item.provider}]`)
|
|
247
|
+
: ''
|
|
248
|
+
const status = enabled
|
|
249
|
+
? this.#theme.fg('success', ' ✓')
|
|
250
|
+
: this.#theme.fg('dim', ' ✗')
|
|
251
|
+
|
|
252
|
+
this.#listContainer.addChild(
|
|
253
|
+
new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0),
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (startIndex > 0 || endIndex < this.#filtered.length) {
|
|
258
|
+
this.#listContainer.addChild(
|
|
259
|
+
new Text(
|
|
260
|
+
this.#theme.fg(
|
|
261
|
+
'muted',
|
|
262
|
+
` (${this.#selectedIndex + 1}/${this.#filtered.length})`,
|
|
263
|
+
),
|
|
264
|
+
0,
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#renderFooter(): void {
|
|
271
|
+
const enabledCount = this.#enabled.length
|
|
272
|
+
const countText = `${enabledCount}/${this.#items.length} enabled`
|
|
273
|
+
|
|
274
|
+
const parts = [
|
|
275
|
+
`${keyText('tui.select.confirm')} toggle`,
|
|
276
|
+
`${keyText('app.models.enableAll')} all`,
|
|
277
|
+
`${keyText('app.models.clearAll')} clear`,
|
|
278
|
+
`${keyText('app.models.toggleProvider')} provider`,
|
|
279
|
+
`${keyText('app.models.reorderUp')}/${keyText('app.models.reorderDown')} reorder`,
|
|
280
|
+
`${keyText('app.models.save')} save`,
|
|
281
|
+
countText,
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
this.#footerText.setText(this.#theme.fg('dim', ` ${parts.join(' · ')}`))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#notify(): void {
|
|
288
|
+
this.#tui.requestRender()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
handleInput(data: string): void {
|
|
292
|
+
const kb = getKeybindings()
|
|
293
|
+
|
|
294
|
+
if (kb.matches(data, 'tui.select.up')) {
|
|
295
|
+
if (this.#filtered.length === 0) return
|
|
296
|
+
this.#selectedIndex =
|
|
297
|
+
this.#selectedIndex === 0
|
|
298
|
+
? this.#filtered.length - 1
|
|
299
|
+
: this.#selectedIndex - 1
|
|
300
|
+
this.#renderList()
|
|
301
|
+
this.#notify()
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (kb.matches(data, 'tui.select.down')) {
|
|
306
|
+
if (this.#filtered.length === 0) return
|
|
307
|
+
this.#selectedIndex =
|
|
308
|
+
this.#selectedIndex === this.#filtered.length - 1
|
|
309
|
+
? 0
|
|
310
|
+
: this.#selectedIndex + 1
|
|
311
|
+
this.#renderList()
|
|
312
|
+
this.#notify()
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const reorderUp = kb.matches(data, 'app.models.reorderUp')
|
|
317
|
+
const reorderDown = kb.matches(data, 'app.models.reorderDown')
|
|
318
|
+
|
|
319
|
+
if (reorderUp || reorderDown) {
|
|
320
|
+
const item = this.#filtered[this.#selectedIndex]
|
|
321
|
+
if (item && this.#isEnabled(item.key)) {
|
|
322
|
+
const delta = reorderUp ? -1 : 1
|
|
323
|
+
const currentIndex = this.#enabled.indexOf(item.key)
|
|
324
|
+
const newIndex = currentIndex + delta
|
|
325
|
+
|
|
326
|
+
if (newIndex >= 0 && newIndex < this.#enabled.length) {
|
|
327
|
+
this.#move(item.key, delta)
|
|
328
|
+
this.#selectedIndex += delta
|
|
329
|
+
this.#refilter(this.#searchInput.getValue())
|
|
330
|
+
this.#renderList()
|
|
331
|
+
this.#renderFooter()
|
|
332
|
+
this.#notify()
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (kb.matches(data, 'tui.select.confirm')) {
|
|
339
|
+
const item = this.#filtered[this.#selectedIndex]
|
|
340
|
+
if (item) {
|
|
341
|
+
this.#toggle(item.key)
|
|
342
|
+
this.#refilter(this.#searchInput.getValue())
|
|
343
|
+
this.#renderList()
|
|
344
|
+
this.#renderFooter()
|
|
345
|
+
this.#notify()
|
|
346
|
+
}
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (kb.matches(data, 'app.models.enableAll')) {
|
|
351
|
+
const query = this.#searchInput.getValue()
|
|
352
|
+
const targetIds = query ? this.#filtered.map((i) => i.key) : undefined
|
|
353
|
+
this.#enableAll(targetIds)
|
|
354
|
+
this.#refilter(query)
|
|
355
|
+
this.#renderList()
|
|
356
|
+
this.#renderFooter()
|
|
357
|
+
this.#notify()
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (kb.matches(data, 'app.models.clearAll')) {
|
|
362
|
+
const query = this.#searchInput.getValue()
|
|
363
|
+
const targetIds = query ? this.#filtered.map((i) => i.key) : undefined
|
|
364
|
+
this.#clearAll(targetIds)
|
|
365
|
+
this.#refilter(query)
|
|
366
|
+
this.#renderList()
|
|
367
|
+
this.#renderFooter()
|
|
368
|
+
this.#notify()
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (kb.matches(data, 'app.models.toggleProvider')) {
|
|
373
|
+
const item = this.#filtered[this.#selectedIndex]
|
|
374
|
+
if (item?.provider) {
|
|
375
|
+
this.#toggleProvider(item.provider)
|
|
376
|
+
this.#refilter(this.#searchInput.getValue())
|
|
377
|
+
this.#renderList()
|
|
378
|
+
this.#renderFooter()
|
|
379
|
+
this.#notify()
|
|
380
|
+
}
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (kb.matches(data, 'app.models.save')) {
|
|
385
|
+
this.#commit()
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (matchesKey(data, Key.ctrl('c'))) {
|
|
390
|
+
if (this.#searchInput.getValue()) {
|
|
391
|
+
this.#searchInput.setValue('')
|
|
392
|
+
this.#refilter('')
|
|
393
|
+
this.#renderList()
|
|
394
|
+
this.#notify()
|
|
395
|
+
} else {
|
|
396
|
+
this.#onCancel()
|
|
397
|
+
}
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (matchesKey(data, Key.escape)) {
|
|
402
|
+
this.#onCancel()
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
this.#searchInput.handleInput(data)
|
|
407
|
+
this.#refilter(this.#searchInput.getValue())
|
|
408
|
+
this.#renderList()
|
|
409
|
+
this.#renderFooter()
|
|
410
|
+
this.#notify()
|
|
411
|
+
}
|
|
412
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Key } from '@earendil-works/pi-tui'
|
|
6
|
+
|
|
7
|
+
import { renderCoordinatorModeWidget } from '../coordinator/coordinator.js'
|
|
8
|
+
import type { PmSubagentState } from '../types.js'
|
|
9
|
+
import { getPmSubagentsConfig } from './models-config.js'
|
|
10
|
+
import { MODEL_DEFAULT } from './subagent-model-constants.js'
|
|
11
|
+
import { cycleSubagentModel } from './subagent-model-utils.js'
|
|
12
|
+
|
|
13
|
+
function cycleAndApply(
|
|
14
|
+
ctx: ExtensionContext,
|
|
15
|
+
state: PmSubagentState,
|
|
16
|
+
direction: 1 | -1,
|
|
17
|
+
): void {
|
|
18
|
+
const config = getPmSubagentsConfig()
|
|
19
|
+
const scoped = config.subagentModelScoped ?? []
|
|
20
|
+
|
|
21
|
+
if (scoped.length === 0) {
|
|
22
|
+
ctx.ui.notify(
|
|
23
|
+
'Subagent scope is empty. Use /pm-subagent-scoped to add models.',
|
|
24
|
+
'warning',
|
|
25
|
+
)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const currentRef = state.sessionSubagentModel
|
|
30
|
+
const result = cycleSubagentModel(scoped, currentRef, direction)
|
|
31
|
+
state.sessionSubagentModel =
|
|
32
|
+
result.ref === MODEL_DEFAULT ? undefined : result.ref
|
|
33
|
+
ctx.ui.notify(
|
|
34
|
+
`Subagent model: ${result.ref} [session] (${result.index + 1}/${result.poolSize})`,
|
|
35
|
+
'info',
|
|
36
|
+
)
|
|
37
|
+
if (state.mode === 'coordinator') renderCoordinatorModeWidget(ctx, state)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setupSubagentModelCycle(
|
|
41
|
+
pi: ExtensionAPI,
|
|
42
|
+
state: PmSubagentState,
|
|
43
|
+
): void {
|
|
44
|
+
const config = getPmSubagentsConfig()
|
|
45
|
+
state.sessionSubagentModel ??= config.subagentModel
|
|
46
|
+
|
|
47
|
+
pi.registerShortcut(Key.alt('n'), {
|
|
48
|
+
description: 'Cycle subagent model forward',
|
|
49
|
+
handler: (ctx) => {
|
|
50
|
+
cycleAndApply(ctx, state, 1)
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
}
|