@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,205 @@
1
+ import {
2
+ type BashOperations,
3
+ createBashToolDefinition,
4
+ createLocalBashOperations,
5
+ defineTool,
6
+ type ExtensionAPI,
7
+ } from '@earendil-works/pi-coding-agent'
8
+ import { parse as parseShell } from 'shell-quote'
9
+
10
+ import { registerOptionalTools } from './utils/tools.js'
11
+
12
+ export type BashSafetyIssue =
13
+ { allowed: true } | { allowed: false; subCommand: string }
14
+
15
+ const FORWARD_PREFIX = ['rtk'] as const
16
+
17
+ const DESTRUCTIVE_BASH_PATTERNS = [
18
+ /^\s*(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|tee|truncate|dd|shred)\b/i,
19
+ /(^|[^<])>(?!>)/,
20
+ />>/,
21
+ /^\s*(npm|yarn|pnpm|bun)\s+(install|uninstall|add|remove|update|ci|link|publish)\b/i,
22
+ /^\s*pip\s+(install|uninstall)\b/i,
23
+ /^\s*apt(-get)?\s+(install|remove|purge|update|upgrade)\b/i,
24
+ /^\s*brew\s+(install|uninstall|upgrade)\b/i,
25
+ /^\s*git\s+(add|commit|push|pull|merge|rebase|reset|checkout|cherry-pick|revert|tag|init|clone)\b/i,
26
+ /^\s*git\s+stash\s+(push|pop|apply|drop|clear|save|create|branch|store|import|export)\b/i,
27
+ /^\s*sudo\b/i,
28
+ /^\s*(kill|pkill|killall|reboot|shutdown)\b/i,
29
+ /^\s*(vim?|nano|emacs|code|subl)\b/i,
30
+ ] as const
31
+
32
+ const BASH_READONLY_PATTERNS = [
33
+ /^\s*(cat|head|tail|less|more|grep|find|ls|cd|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|uptime|ps|free)\b/,
34
+ /^\s*git\s+(status|log|diff|show|branch|remote|ls-)/i,
35
+ /^\s*git\s+stash\s+(list|show)\b/i,
36
+ /^\s*(npm|yarn|pnpm)\s+(list|ls|view|info|outdated|audit)\b/i,
37
+ /^\s*(npm|yarn|pnpm)\s+(run\s+)?(test|test:.*)\b/i,
38
+ /^\s*(node|python|python3)\s+--version/i,
39
+ /^\s*curl\s/,
40
+ /^\s*jq\b/,
41
+ /^\s*sed\s+-n/i,
42
+ /^\s*awk\b/,
43
+ /^\s*rg\b/,
44
+ /^\s*fd\b/,
45
+ /^\s*bat\b/,
46
+ /^\s*eza\b/,
47
+ ] as const
48
+
49
+ const HARMLESS_SUB_COMMAND = /^\s*export\s+[\w]+\s*=\s*\S+\s*$/
50
+
51
+ const SUB_COMMAND_OPS = new Set(['|', '||', '|&', '&&', ';'])
52
+
53
+ const isFd = (token: unknown, n: number): token is string => token === `${n}`
54
+
55
+ const REDIRECT_OPS = new Set(['>', '>>', '<', '<>', '>&', '<&', '>|', '&>>'])
56
+
57
+ type SubCommand = { tokens: string[]; hasStdoutRedirect: boolean }
58
+
59
+ function splitShellSubCommands(command: string): SubCommand[] {
60
+ let parsed: ReturnType<typeof parseShell>
61
+ try {
62
+ parsed = parseShell(command)
63
+ } catch {
64
+ return []
65
+ }
66
+
67
+ const subCommands: SubCommand[] = []
68
+ let current: string[] = []
69
+ let hasStdoutRedirect = false
70
+
71
+ for (let i = 0; i < parsed.length; i++) {
72
+ const token = parsed[i]
73
+ const nextToken = parsed[i + 1]
74
+ const prevToken = i > 0 ? parsed[i - 1] : null
75
+
76
+ if (typeof token === 'object' && 'op' in token) {
77
+ const op = token.op as string
78
+ if (SUB_COMMAND_OPS.has(op)) {
79
+ subCommands.push({ tokens: current, hasStdoutRedirect })
80
+ current = []
81
+ hasStdoutRedirect = false
82
+ } else if (op === '>&' && isFd(prevToken, 2) && isFd(nextToken, 1)) {
83
+ const tokenAfterNext = parsed[i + 2]
84
+ const hasPipeAfter =
85
+ typeof tokenAfterNext === 'object' &&
86
+ 'op' in tokenAfterNext &&
87
+ tokenAfterNext.op === '|'
88
+ if (!hasPipeAfter) hasStdoutRedirect = true
89
+ } else if (
90
+ (op === '>' || op === '>>' || op === '>|') &&
91
+ !isFd(prevToken, 1) &&
92
+ !isFd(prevToken, 2)
93
+ ) {
94
+ hasStdoutRedirect = true
95
+ }
96
+ } else if (typeof token === 'string') {
97
+ const isRedirectTarget =
98
+ prevToken &&
99
+ typeof prevToken === 'object' &&
100
+ 'op' in prevToken &&
101
+ REDIRECT_OPS.has(prevToken.op as string)
102
+
103
+ const isRedirectFd =
104
+ /^\d+$/.test(token) &&
105
+ nextToken &&
106
+ typeof nextToken === 'object' &&
107
+ 'op' in nextToken &&
108
+ REDIRECT_OPS.has(nextToken.op)
109
+
110
+ if (!isRedirectTarget && !isRedirectFd) {
111
+ current.push(token)
112
+ }
113
+ }
114
+ }
115
+ if (current.length > 0 || subCommands.length > 0)
116
+ subCommands.push({ tokens: current, hasStdoutRedirect })
117
+
118
+ return subCommands
119
+ }
120
+
121
+ function checkSubCommandSafety(
122
+ subCommand: SubCommand,
123
+ ): { ok: true } | { ok: false; subCommand: string } {
124
+ const text = subCommand.tokens.join(' ').trim()
125
+
126
+ if (HARMLESS_SUB_COMMAND.test(text)) return { ok: true }
127
+
128
+ let normalizedCommand = text
129
+ for (const prefix of FORWARD_PREFIX) {
130
+ const pattern = new RegExp(`^\\s*${prefix}\\s+`, 'i')
131
+ const match = normalizedCommand.match(pattern)
132
+ if (match) {
133
+ normalizedCommand = normalizedCommand.slice(match[0].length).trim()
134
+ break
135
+ }
136
+ }
137
+
138
+ if (subCommand.hasStdoutRedirect)
139
+ return { ok: false, subCommand: normalizedCommand }
140
+
141
+ const destructive = DESTRUCTIVE_BASH_PATTERNS.some((p) =>
142
+ p.test(normalizedCommand),
143
+ )
144
+ const readonly = BASH_READONLY_PATTERNS.some((p) => p.test(normalizedCommand))
145
+
146
+ if (destructive) return { ok: false, subCommand: normalizedCommand }
147
+ if (!readonly) return { ok: false, subCommand: normalizedCommand }
148
+ return { ok: true }
149
+ }
150
+
151
+ export function checkBashSafety(command: string): BashSafetyIssue {
152
+ const subCommands = splitShellSubCommands(command)
153
+ if (subCommands.length === 0) {
154
+ return { allowed: false, subCommand: command }
155
+ }
156
+
157
+ for (const subCommand of subCommands) {
158
+ if (subCommand.tokens.length === 0) {
159
+ return { allowed: false, subCommand: '(empty)' }
160
+ }
161
+ const result = checkSubCommandSafety(subCommand)
162
+ if (!result.ok) {
163
+ return { allowed: false, subCommand: result.subCommand }
164
+ }
165
+ }
166
+
167
+ return { allowed: true }
168
+ }
169
+
170
+ export function isBashReadonlyCommand(command: string): boolean {
171
+ return checkBashSafety(command).allowed
172
+ }
173
+
174
+ export const BASH_READONLY_TOOL_NAME = 'bash_readonly'
175
+
176
+ export function setupBashReadonlyTool(pi: ExtensionAPI): void {
177
+ const localOps = createLocalBashOperations()
178
+ const safeOps: BashOperations = {
179
+ exec: async (command, cwd, options) => {
180
+ const safety = checkBashSafety(command)
181
+ if (!safety.allowed) {
182
+ throw new Error(
183
+ `${BASH_READONLY_TOOL_NAME}: not allowed: "${safety.subCommand}"`,
184
+ )
185
+ }
186
+ return localOps.exec(command, cwd, options)
187
+ },
188
+ }
189
+ const def = createBashToolDefinition(process.cwd(), { operations: safeOps })
190
+ registerOptionalTools(
191
+ pi,
192
+ [
193
+ defineTool({
194
+ ...def,
195
+ name: BASH_READONLY_TOOL_NAME,
196
+ label: 'Read-Only Bash',
197
+ description:
198
+ 'Execute a bash command. Only commands classified as read-only are allowed; write operations are rejected. Use the regular bash tool outside of read-only modes.',
199
+ promptSnippet: undefined,
200
+ promptGuidelines: undefined,
201
+ }),
202
+ ],
203
+ false,
204
+ )
205
+ }
@@ -0,0 +1,28 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import { loadMarkdown } from '../utils/markdown.js'
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
9
+ const repoRoot = join(__dirname, '../..')
10
+
11
+ describe('coordinator mode prompt', () => {
12
+ it('has correct removeTools in coordinator.md', async () => {
13
+ const coordinatorPath = join(
14
+ repoRoot,
15
+ 'pm-subagents-prompts',
16
+ 'coordinator.md',
17
+ )
18
+ const result = await loadMarkdown(coordinatorPath)
19
+ expect(result).toBeDefined()
20
+ expect(result?.fm.removeTools).toEqual([
21
+ 'find',
22
+ 'grep',
23
+ 'bash',
24
+ 'web_search',
25
+ 'web_fetch',
26
+ ])
27
+ })
28
+ })
@@ -0,0 +1,275 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from '@earendil-works/pi-coding-agent'
5
+
6
+ import { getPmSubagentsConfig } from '../models-config/models-config.js'
7
+ import { formatSubagentModelLabel } from '../models-config/subagent-model-utils.js'
8
+ import { applyModeFor, assertModeIdle, exitModeFor } from '../pm-mode.js'
9
+ import { loadRoles } from '../prompts/roles.js'
10
+ import { ActivityReporter } from '../subagent/activity.js'
11
+ import { MessageBatcher } from '../subagent/batcher.js'
12
+ import { SubagentManagerDemo } from '../subagent/demo.js'
13
+ import { FleetList } from '../subagent/fleet.js'
14
+ import {
15
+ type LiveSubagent,
16
+ SubagentManager,
17
+ type SubagentStatus,
18
+ } from '../subagent/manager.js'
19
+ import { registerSubagentTools, SUBAGENT_TOOLS } from '../subagent/tools.js'
20
+ import { openSubagentViewer } from '../subagent/viewer.js'
21
+ import type { PmSubagentState } from '../types.js'
22
+ import {
23
+ mergePromptDefinitions,
24
+ type PromptDefinition,
25
+ } from '../utils/markdown.js'
26
+ import { notifyAgentMessage } from '../utils/messages.ts'
27
+
28
+ const COORDINATOR_MODE_WIDGET_KEY = 'pi-pm-subagents:coordinator-mode'
29
+
30
+ /** job event for pi-notify */
31
+ const JOB_START_EVENT = 'pi-notify:job:start'
32
+ const JOB_END_EVENT = 'pi-notify:job:end'
33
+
34
+ export type { LiveSubagent, SubagentStatus }
35
+
36
+ interface CoordinatorRuntime {
37
+ manager: SubagentManager
38
+ activityReporter: ActivityReporter
39
+ demoSubagentManager: SubagentManagerDemo | undefined
40
+ fleet: FleetList
41
+ batcher: MessageBatcher
42
+ }
43
+
44
+ let runtime: CoordinatorRuntime | undefined
45
+
46
+ function requiredRuntime(): CoordinatorRuntime {
47
+ if (!runtime) {
48
+ throw new Error('Coordinator mode is not initialized.')
49
+ }
50
+ return runtime
51
+ }
52
+
53
+ export async function enterCoordinatorMode(
54
+ pi: ExtensionAPI,
55
+ state: PmSubagentState,
56
+ prompt: string | undefined,
57
+ ctx: ExtensionContext,
58
+ def: PromptDefinition,
59
+ ): Promise<void> {
60
+ assertModeIdle(state, 'coordinator')
61
+ state.mode = 'coordinator'
62
+ await applyCoordinatorMode(pi, state, ctx, def)
63
+ if (prompt) notifyAgentMessage(pi, prompt)
64
+ }
65
+
66
+ export function renderCoordinatorModeWidget(
67
+ ctx: ExtensionContext,
68
+ state?: PmSubagentState,
69
+ ): void {
70
+ const config = getPmSubagentsConfig()
71
+ const scope = config.subagentModelScoped ?? []
72
+ const currentRef = state?.sessionSubagentModel ?? config.subagentModel
73
+ const label = formatSubagentModelLabel(scope, currentRef)
74
+ ctx.ui.setWidget(COORDINATOR_MODE_WIDGET_KEY, [
75
+ ctx.ui.theme.fg(
76
+ 'accent',
77
+ `${ctx.ui.theme.bold('👥 COORDINATOR MODE')} - subagent: ${label}`,
78
+ ),
79
+ ])
80
+ }
81
+
82
+ export async function applyCoordinatorMode(
83
+ pi: ExtensionAPI,
84
+ state: PmSubagentState,
85
+ ctx: ExtensionContext,
86
+ def: PromptDefinition,
87
+ ): Promise<void> {
88
+ const { fleet, activityReporter } = requiredRuntime()
89
+ fleet.setContext(ctx)
90
+
91
+ await applyModeFor(pi, state, 'coordinator', ctx, {
92
+ promptDefinition: mergePromptDefinitions(def, {
93
+ fm: {
94
+ extraTools: Object.values(SUBAGENT_TOOLS),
95
+ },
96
+ }),
97
+ color: 'accent',
98
+ })
99
+
100
+ renderCoordinatorModeWidget(ctx, state)
101
+
102
+ fleet.update()
103
+ activityReporter.start()
104
+ }
105
+
106
+ export async function exitCoordinatorMode(
107
+ pi: ExtensionAPI,
108
+ state: PmSubagentState,
109
+ ctx: ExtensionContext,
110
+ ): Promise<void> {
111
+ const { manager, fleet, batcher, activityReporter } = requiredRuntime()
112
+ batcher.clear()
113
+ activityReporter.stop()
114
+ state.mode = undefined
115
+ fleet.dispose()
116
+ manager.disposeAll()
117
+ ctx.ui.setWidget(COORDINATOR_MODE_WIDGET_KEY, undefined)
118
+ await exitModeFor(pi, state, ctx, 'coordinator')
119
+ }
120
+
121
+ export async function setupCoordinator(
122
+ pi: ExtensionAPI,
123
+ state: PmSubagentState,
124
+ {
125
+ demoEnabled,
126
+ coordinatorDefinition,
127
+ }: {
128
+ demoEnabled: boolean
129
+ coordinatorDefinition: PromptDefinition
130
+ },
131
+ ): Promise<void> {
132
+ const batcher = new MessageBatcher((messages: readonly string[]) => {
133
+ if (state.mode !== 'coordinator') return
134
+ notifyAgentMessage(pi, messages.join('\n\n'))
135
+ })
136
+ const manager = new SubagentManager({
137
+ onStatusChange: () => runtime?.fleet.update(),
138
+ onEachStart: (subagent) => {
139
+ pi.events.emit(JOB_START_EVENT, {
140
+ id: `pi-pm-subagents:session:${subagent.id}`,
141
+ })
142
+ },
143
+ onEachEnd: (subagent) => {
144
+ pi.events.emit(JOB_END_EVENT, {
145
+ id: `pi-pm-subagents:session:${subagent.id}`,
146
+ })
147
+ },
148
+ })
149
+ const activityReporter = new ActivityReporter({
150
+ list: () => manager.list(),
151
+ onActivity: (subagent, report) => {
152
+ batcher.add(subagent, 'activity', report)
153
+ },
154
+ })
155
+
156
+ const fleet = new FleetList({
157
+ list: () => runtime?.demoSubagentManager?.list() ?? manager.list(),
158
+ onOpen: async (ctx, id) => {
159
+ const activeManager = runtime?.demoSubagentManager ?? manager
160
+ return openSubagentViewer(ctx, activeManager, id)
161
+ },
162
+ })
163
+
164
+ await loadRoles(process.cwd(), {
165
+ skipPluginAgents: getPmSubagentsConfig().skipPluginAgents,
166
+ })
167
+
168
+ runtime = {
169
+ manager,
170
+ activityReporter,
171
+ demoSubagentManager: undefined,
172
+ fleet,
173
+ batcher,
174
+ }
175
+
176
+ registerSubagentTools(pi, state, manager, fleet, batcher)
177
+
178
+ const coordinatorPrompt = coordinatorDefinition.systemPrompt
179
+
180
+ pi.on('before_agent_start', async (event) => {
181
+ if (state.mode !== 'coordinator') return
182
+ return { systemPrompt: `${event.systemPrompt}\n\n${coordinatorPrompt}` }
183
+ })
184
+
185
+ const runCoordinatorCommand = async (args: string, ctx: ExtensionContext) => {
186
+ const request = args.trim()
187
+ if (state.mode === 'coordinator') {
188
+ await exitCoordinatorMode(pi, state, ctx)
189
+ ctx.ui.notify('Coordinator mode off.', 'info')
190
+ return
191
+ }
192
+ await enterCoordinatorMode(pi, state, undefined, ctx, coordinatorDefinition)
193
+ if (request) notifyAgentMessage(pi, request)
194
+ }
195
+
196
+ pi.registerCommand('coordinator', {
197
+ description:
198
+ 'Coordinator mode. Usage: /coordinator [request] — toggles, or enters with a task',
199
+ handler: runCoordinatorCommand,
200
+ })
201
+
202
+ pi.registerCommand('pm', {
203
+ description:
204
+ 'Coordinator mode. Usage: /pm [request] — toggles, or enters with a task',
205
+ handler: runCoordinatorCommand,
206
+ })
207
+
208
+ pi.registerCommand('plan', {
209
+ description: 'Delegate a planner subagent. Usage: /plan <request>',
210
+ handler: async (args, ctx) => {
211
+ if (state.mode !== 'coordinator') {
212
+ await enterCoordinatorMode(
213
+ pi,
214
+ state,
215
+ undefined,
216
+ ctx,
217
+ coordinatorDefinition,
218
+ )
219
+ }
220
+ let request = args.trim()
221
+ if (!request && ctx.hasUI) {
222
+ request = (await ctx.ui.editor('Enter the request to plan:', '')) ?? ''
223
+ }
224
+ if (!request.trim()) return
225
+ notifyAgentMessage(
226
+ pi,
227
+ [
228
+ `<planRequest>${request}</planRequest>`,
229
+ "Use role 'planner' to create a plan. then other subagents to implements",
230
+ ].join('\n'),
231
+ )
232
+ },
233
+ })
234
+
235
+ if (demoEnabled) {
236
+ pi.registerCommand('subagent-demo', {
237
+ description: 'Browse a subagent live demo (coordinator mode)',
238
+ handler: async (args, ctx) => {
239
+ const argList = args.split(/\s+/)
240
+ if (!ctx.hasUI) return
241
+ if (state.mode !== 'coordinator') {
242
+ ctx.ui.notify(
243
+ 'Subagents browser is only available in coordinator mode.',
244
+ 'info',
245
+ )
246
+ return
247
+ }
248
+
249
+ const runtime = requiredRuntime()
250
+ const { fleet } = runtime
251
+
252
+ if (argList[0] === 'add') {
253
+ runtime.demoSubagentManager ??= new SubagentManagerDemo()
254
+ runtime.demoSubagentManager.add(argList.slice(1).join(' '))
255
+ fleet.update()
256
+ ctx.ui.notify(
257
+ 'Added a demo subagent. Use /subagent demo to exit demo mode.',
258
+ 'info',
259
+ )
260
+ } else if (runtime.demoSubagentManager) {
261
+ runtime.demoSubagentManager = undefined
262
+ fleet.update()
263
+ ctx.ui.notify('Demo mode exited.', 'info')
264
+ } else {
265
+ runtime.demoSubagentManager = new SubagentManagerDemo()
266
+ fleet.update()
267
+ ctx.ui.notify(
268
+ 'Demo mode active: fake subagents loaded. Use /subagent demo to exit.',
269
+ 'info',
270
+ )
271
+ }
272
+ },
273
+ })
274
+ }
275
+ }
@@ -0,0 +1,91 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent'
2
+ import type { TUI } from '@earendil-works/pi-tui'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { CustomSelectComponent } from './custom-select.js'
6
+
7
+ const DOWN = '\x1b[B'
8
+
9
+ const makeTui = (): TUI =>
10
+ ({
11
+ terminal: { rows: 30, columns: 80 },
12
+ requestRender: () => {},
13
+ }) as unknown as TUI
14
+
15
+ const makeTheme = (): Theme =>
16
+ ({
17
+ fg: (_color: string, text: string) => text,
18
+ bold: (text: string) => text,
19
+ }) as unknown as Theme
20
+
21
+ describe('CustomSelectComponent', () => {
22
+ it('renders without throwing on small lists', () => {
23
+ const picker = new CustomSelectComponent(
24
+ makeTui(),
25
+ makeTheme(),
26
+ {
27
+ items: [
28
+ { key: 'a', text: 'apple' },
29
+ { key: 'b', text: 'banana' },
30
+ ],
31
+ title: 'Fruit',
32
+ },
33
+ () => {},
34
+ () => {},
35
+ )
36
+ expect(picker.render(40)).toBeDefined()
37
+ })
38
+
39
+ it('renders without throwing on large lists and caps the viewport', () => {
40
+ const items = Array.from({ length: 100 }, (_, i) => ({
41
+ key: `k${i}`,
42
+ text: `option-${i}`,
43
+ }))
44
+ const picker = new CustomSelectComponent(
45
+ makeTui(),
46
+ makeTheme(),
47
+ { items, title: 'Pick', maxVisible: 10 },
48
+ () => {},
49
+ () => {},
50
+ )
51
+ const lines = picker.render(40)
52
+ expect(lines.length).toBeGreaterThan(0)
53
+ })
54
+
55
+ it('reports the selected key via onSelect', () => {
56
+ let picked: string | undefined
57
+ const picker = new CustomSelectComponent(
58
+ makeTui(),
59
+ makeTheme(),
60
+ {
61
+ items: [
62
+ { key: 'a', text: 'apple' },
63
+ { key: 'b', text: 'banana' },
64
+ ],
65
+ title: 'Fruit',
66
+ },
67
+ (key) => {
68
+ picked = key
69
+ },
70
+ () => {},
71
+ )
72
+ picker.handleInput(DOWN) // move down
73
+ picker.handleInput('\n') // confirm
74
+ expect(picked).toBe('b')
75
+ })
76
+
77
+ it('invokes onCancel on escape', () => {
78
+ let cancelled = false
79
+ const picker = new CustomSelectComponent(
80
+ makeTui(),
81
+ makeTheme(),
82
+ { items: [{ key: 'a', text: 'apple' }], title: 'Fruit' },
83
+ () => {},
84
+ () => {
85
+ cancelled = true
86
+ },
87
+ )
88
+ picker.handleInput('\x1b')
89
+ expect(cancelled).toBe(true)
90
+ })
91
+ })