@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.
Files changed (69) hide show
  1. package/.prettierrc +7 -0
  2. package/AGENTS.md +1 -0
  3. package/README.md +85 -0
  4. package/README.zh-CN.md +85 -0
  5. package/agents/explorer.md +10 -0
  6. package/agents/planner.md +16 -0
  7. package/agents/researcher.md +22 -0
  8. package/agents/reviewer.md +10 -0
  9. package/eslint.config.mjs +14 -0
  10. package/example-prompts/coordinator.md +21 -0
  11. package/package.json +48 -0
  12. package/pm-subagents-prompts/coordinator.md +16 -0
  13. package/pnpm-workspace.yaml +5 -0
  14. package/src/bash-readonly.test.ts +331 -0
  15. package/src/bash-readonly.ts +205 -0
  16. package/src/coordinator/coordinator.test.ts +28 -0
  17. package/src/coordinator/coordinator.ts +275 -0
  18. package/src/custom-select.test.ts +91 -0
  19. package/src/custom-select.ts +209 -0
  20. package/src/index.ts +87 -0
  21. package/src/models-config/models-config.test.ts +88 -0
  22. package/src/models-config/models-config.ts +205 -0
  23. package/src/models-config/scoped-models-editor.test.ts +189 -0
  24. package/src/models-config/scoped-models-editor.ts +412 -0
  25. package/src/models-config/subagent-model-constants.ts +2 -0
  26. package/src/models-config/subagent-model-cycle.ts +53 -0
  27. package/src/models-config/subagent-model-utils.test.ts +250 -0
  28. package/src/models-config/subagent-model-utils.ts +52 -0
  29. package/src/pm-mode.test.ts +324 -0
  30. package/src/pm-mode.ts +142 -0
  31. package/src/prompts/mode.test.ts +289 -0
  32. package/src/prompts/mode.ts +31 -0
  33. package/src/prompts/roles.test.ts +724 -0
  34. package/src/prompts/roles.ts +119 -0
  35. package/src/subagent/activity.test.ts +230 -0
  36. package/src/subagent/activity.ts +60 -0
  37. package/src/subagent/batcher.test.ts +198 -0
  38. package/src/subagent/batcher.ts +51 -0
  39. package/src/subagent/consts.ts +1 -0
  40. package/src/subagent/demo.ts +773 -0
  41. package/src/subagent/fleet.test.ts +1758 -0
  42. package/src/subagent/fleet.ts +376 -0
  43. package/src/subagent/identity.test.ts +31 -0
  44. package/src/subagent/identity.ts +16 -0
  45. package/src/subagent/manager.test.ts +392 -0
  46. package/src/subagent/manager.ts +277 -0
  47. package/src/subagent/tools.ts +314 -0
  48. package/src/subagent/viewer.ts +305 -0
  49. package/src/types.ts +15 -0
  50. package/src/ui/border-view.ts +50 -0
  51. package/src/ui/review-pager.ts +146 -0
  52. package/src/ui/scroll-view.test.ts +190 -0
  53. package/src/ui/scroll-view.ts +155 -0
  54. package/src/utils/format.test.ts +76 -0
  55. package/src/utils/format.ts +67 -0
  56. package/src/utils/fs.ts +9 -0
  57. package/src/utils/markdown.test.ts +442 -0
  58. package/src/utils/markdown.ts +79 -0
  59. package/src/utils/messages.test.ts +436 -0
  60. package/src/utils/messages.ts +131 -0
  61. package/src/utils/model-ref.test.ts +42 -0
  62. package/src/utils/model-ref.ts +44 -0
  63. package/src/utils/state.test.ts +98 -0
  64. package/src/utils/state.ts +45 -0
  65. package/src/utils/tools.ts +48 -0
  66. package/src/utils/truncate.test.ts +41 -0
  67. package/src/utils/truncate.ts +59 -0
  68. package/tsconfig.json +24 -0
  69. 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,2 @@
1
+ export const MODEL_DEFAULT = 'DEFAULT'
2
+ export const MODEL_DEFAULT_LABEL = 'DEFAULT (use agent default)'
@@ -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
+ }