pi-code 0.1.0 → 0.2.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.
@@ -14,7 +14,7 @@
14
14
 
15
15
  import type { AgentMessage } from '@earendil-works/pi-agent-core'
16
16
  import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
17
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
17
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent'
18
18
  import { Key } from '@earendil-works/pi-tui'
19
19
  import { Type } from 'typebox'
20
20
  import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
@@ -35,6 +35,23 @@ function getTextContent(message: AssistantMessage): string {
35
35
  .join('\n')
36
36
  }
37
37
 
38
+ // Last element matching the predicate (the lib target predates Array.prototype.findLast)
39
+ function findLast<T>(items: T[], match: (item: T) => boolean): T | undefined {
40
+ for (let i = items.length - 1; i >= 0; i--) {
41
+ if (match(items[i])) return items[i]
42
+ }
43
+ return undefined
44
+ }
45
+
46
+ // Index of the last plan-mode-execute entry, or -1 when the current run never started one
47
+ function findLastExecuteIndex(entries: SessionEntry[]): number {
48
+ for (let i = entries.length - 1; i >= 0; i--) {
49
+ const entry = entries[i] as { customType?: string }
50
+ if (entry.customType === 'plan-mode-execute') return i
51
+ }
52
+ return -1
53
+ }
54
+
38
55
  export default function planModeExtension(pi: ExtensionAPI): void {
39
56
  let planModeEnabled = false
40
57
  let executionMode = false
@@ -108,6 +125,62 @@ export default function planModeExtension(pi: ExtensionAPI): void {
108
125
  })
109
126
  }
110
127
 
128
+ // Announce completion and reset once every step is done
129
+ function finalizeCompletedExecution(ctx: ExtensionContext): void {
130
+ if (!todoItems.every((t) => t.completed)) return
131
+ const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
132
+ pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
133
+ executionMode = false
134
+ todoItems = []
135
+ restoreTools()
136
+ updateStatus(ctx)
137
+ persistState() // Save cleared state so resume doesn't restore old execution mode
138
+ }
139
+
140
+ // Fall back to extracting a plan from the last assistant message's prose
141
+ function deriveTodosFromProse(messages: AgentMessage[]): void {
142
+ const lastAssistant = [...messages].reverse().find(isAssistantMessage)
143
+ if (!lastAssistant) return
144
+ const extracted = extractTodoItems(getTextContent(lastAssistant))
145
+ if (extracted.length > 0) {
146
+ todoItems = extracted
147
+ }
148
+ }
149
+
150
+ // Ask the user how to proceed after a plan is ready
151
+ async function promptPlanNextAction(ctx: ExtensionContext): Promise<void> {
152
+ const choice = await ctx.ui.select('Plan mode - what next?', [todoItems.length > 0 ? 'Execute the plan (track progress)' : 'Execute the plan', 'Stay in plan mode', 'Refine the plan'])
153
+
154
+ if (choice?.startsWith('Execute')) {
155
+ planModeEnabled = false
156
+ executionMode = todoItems.length > 0
157
+ planFromTool = false
158
+ restoreTools()
159
+ updateStatus(ctx)
160
+
161
+ const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
162
+ pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
163
+ } else if (choice === 'Refine the plan') {
164
+ const refinement = await ctx.ui.editor('Refine the plan:', '')
165
+ if (refinement?.trim()) {
166
+ pi.sendUserMessage(refinement.trim())
167
+ }
168
+ }
169
+ }
170
+
171
+ // Rebuild completion state from assistant messages after the last execute marker
172
+ function rescanCompletion(entries: SessionEntry[]): void {
173
+ const executeIndex = findLastExecuteIndex(entries)
174
+ const messages: AssistantMessage[] = []
175
+ for (let i = executeIndex + 1; i < entries.length; i++) {
176
+ const entry = entries[i]
177
+ if (entry.type === 'message' && 'message' in entry && isAssistantMessage(entry.message as AgentMessage)) {
178
+ messages.push(entry.message as AssistantMessage)
179
+ }
180
+ }
181
+ markCompletedSteps(messages.map(getTextContent).join('\n'), todoItems)
182
+ }
183
+
111
184
  pi.registerCommand('plan', {
112
185
  description: 'Toggle plan mode (read-only exploration)',
113
186
  handler: async (_args, ctx) => togglePlanMode(ctx),
@@ -193,7 +266,8 @@ You are in plan mode - a read-only exploration mode for safe code analysis.
193
266
  Restrictions:
194
267
  - You can only use: read, bash, grep, find, ls, question
195
268
  - You CANNOT use: edit, write (file modifications are disabled)
196
- - Bash is restricted to an allowlist of read-only commands
269
+ - Bash is limited to an allowlist of read-only commands, checked per subcommand. Treat it
270
+ as a reminder of intent, not a sandbox: do not look for ways around it
197
271
 
198
272
  Ask clarifying questions using the question tool.
199
273
 
@@ -244,15 +318,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
244
318
  pi.on('agent_end', async (event, ctx) => {
245
319
  // Check if execution is complete
246
320
  if (executionMode && todoItems.length > 0) {
247
- if (todoItems.every((t) => t.completed)) {
248
- const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
249
- pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
250
- executionMode = false
251
- todoItems = []
252
- restoreTools()
253
- updateStatus(ctx)
254
- persistState() // Save cleared state so resume doesn't restore old execution mode
255
- }
321
+ finalizeCompletedExecution(ctx)
256
322
  return
257
323
  }
258
324
 
@@ -260,13 +326,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
260
326
 
261
327
  // Prefer an explicitly submitted plan; fall back to extracting from prose
262
328
  if (!planFromTool) {
263
- const lastAssistant = [...event.messages].reverse().find(isAssistantMessage)
264
- if (lastAssistant) {
265
- const extracted = extractTodoItems(getTextContent(lastAssistant))
266
- if (extracted.length > 0) {
267
- todoItems = extracted
268
- }
269
- }
329
+ deriveTodosFromProse(event.messages)
270
330
  }
271
331
 
272
332
  // Show plan steps and prompt for next action
@@ -282,23 +342,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
282
342
  )
283
343
  }
284
344
 
285
- const choice = await ctx.ui.select('Plan mode - what next?', [todoItems.length > 0 ? 'Execute the plan (track progress)' : 'Execute the plan', 'Stay in plan mode', 'Refine the plan'])
286
-
287
- if (choice?.startsWith('Execute')) {
288
- planModeEnabled = false
289
- executionMode = todoItems.length > 0
290
- planFromTool = false
291
- restoreTools()
292
- updateStatus(ctx)
293
-
294
- const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
295
- pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
296
- } else if (choice === 'Refine the plan') {
297
- const refinement = await ctx.ui.editor('Refine the plan:', '')
298
- if (refinement?.trim()) {
299
- pi.sendUserMessage(refinement.trim())
300
- }
301
- }
345
+ await promptPlanNextAction(ctx)
302
346
  })
303
347
 
304
348
  // Restore state on session start/resume
@@ -310,7 +354,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
310
354
  const entries = ctx.sessionManager.getEntries()
311
355
 
312
356
  // Restore persisted state
313
- const planModeEntry = entries.filter((e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode').pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
357
+ const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
314
358
 
315
359
  if (planModeEntry?.data) {
316
360
  planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled
@@ -318,30 +362,11 @@ After completing a step, include a [DONE:n] tag in your response.`,
318
362
  executionMode = planModeEntry.data.executing ?? executionMode
319
363
  }
320
364
 
321
- // On resume: re-scan messages to rebuild completion state
322
- // Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
365
+ // On resume: re-scan messages after the last "plan-mode-execute" to rebuild
366
+ // completion state without picking up [DONE:n] from previous plans
323
367
  const isResume = planModeEntry !== undefined
324
368
  if (isResume && executionMode && todoItems.length > 0) {
325
- // Find the index of the last plan-mode-execute entry (marks when current execution started)
326
- let executeIndex = -1
327
- for (let i = entries.length - 1; i >= 0; i--) {
328
- const entry = entries[i] as { type: string; customType?: string }
329
- if (entry.customType === 'plan-mode-execute') {
330
- executeIndex = i
331
- break
332
- }
333
- }
334
-
335
- // Only scan messages after the execute marker
336
- const messages: AssistantMessage[] = []
337
- for (let i = executeIndex + 1; i < entries.length; i++) {
338
- const entry = entries[i]
339
- if (entry.type === 'message' && 'message' in entry && isAssistantMessage(entry.message as AgentMessage)) {
340
- messages.push(entry.message as AssistantMessage)
341
- }
342
- }
343
- const allText = messages.map(getTextContent).join('\n')
344
- markCompletedSteps(allText, todoItems)
369
+ rescanCompletion(entries)
345
370
  }
346
371
 
347
372
  if (planModeEnabled) {
@@ -27,7 +27,7 @@ const DESTRUCTIVE_PATTERNS = [
27
27
  /\bpip\s+(install|uninstall)/i,
28
28
  /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
29
29
  /\bbrew\s+(install|uninstall|upgrade)/i,
30
- /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
30
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-d|stash|cherry-pick|revert|tag|init|clone)/i,
31
31
  /\bsudo\b/i,
32
32
  /\bsu\b/i,
33
33
  /\bkill\b/i,
@@ -40,7 +40,9 @@ const DESTRUCTIVE_PATTERNS = [
40
40
  /\b(vim?|nano|emacs|code|subl)\b/i,
41
41
  ]
42
42
 
43
- // Safe read-only commands allowed in plan mode
43
+ // Safe read-only commands allowed in plan mode. Deliberately excludes env/printenv
44
+ // (secret disclosure, and env is an exec wrapper), curl/wget (fetch plus -o writes),
45
+ // awk (system()) and sed (w/W/e write even under -n).
44
46
  const SAFE_PATTERNS = [
45
47
  /^\s*cat\b/,
46
48
  /^\s*head\b/,
@@ -65,8 +67,6 @@ const SAFE_PATTERNS = [
65
67
  /^\s*which\b/,
66
68
  /^\s*whereis\b/,
67
69
  /^\s*type\b/,
68
- /^\s*env\b/,
69
- /^\s*printenv\b/,
70
70
  /^\s*uname\b/,
71
71
  /^\s*whoami\b/,
72
72
  /^\s*id\b/,
@@ -83,21 +83,86 @@ const SAFE_PATTERNS = [
83
83
  /^\s*yarn\s+(list|info|why|audit)/i,
84
84
  /^\s*node\s+--version/i,
85
85
  /^\s*python\s+--version/i,
86
- /^\s*curl\s/i,
87
- /^\s*wget\s+-O\s*-/i,
88
86
  /^\s*jq\b/,
89
- /^\s*sed\s+-n/i,
90
- /^\s*awk\b/,
91
87
  /^\s*rg\b/,
92
88
  /^\s*fd\b/,
93
89
  /^\s*bat\b/,
94
90
  /^\s*eza\b/,
95
91
  ]
96
92
 
93
+ // The shell can hide an arbitrary command inside any of these, so they are refused
94
+ // outright rather than parsed.
95
+ const SUBSTITUTION = /\$\(|`|<\(|>\(/
96
+
97
+ /**
98
+ * Split on the shell separators Claude Code documents (`&&`, `||`, `;`, `|`, `|&`, `&`,
99
+ * newline) so every subcommand is checked on its own, ignoring separators inside quotes:
100
+ * `grep 'a|b'` is one read, not a pipe. Returns nothing on an unbalanced quote, which
101
+ * fails the caller closed rather than guessing at the intended split.
102
+ *
103
+ * A shell AST would be exact; this is the honest approximation for a quoting-only concern.
104
+ */
105
+ function splitSegments(command: string): string[] {
106
+ const segments: string[] = []
107
+ let current = ''
108
+ let quote: "'" | '"' | undefined
109
+
110
+ for (let i = 0; i < command.length; i++) {
111
+ const ch = command[i]
112
+ if (quote !== undefined) {
113
+ current += ch
114
+ if (ch === quote) quote = undefined
115
+ continue
116
+ }
117
+ if (ch === "'" || ch === '"') {
118
+ quote = ch
119
+ current += ch
120
+ continue
121
+ }
122
+ if (ch === '\\' && i + 1 < command.length) {
123
+ current += ch + command[++i]
124
+ continue
125
+ }
126
+ const pair = command.slice(i, i + 2)
127
+ if (pair === '&&' || pair === '||' || pair === '|&') {
128
+ segments.push(current)
129
+ current = ''
130
+ i++
131
+ continue
132
+ }
133
+ if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
134
+ segments.push(current)
135
+ current = ''
136
+ continue
137
+ }
138
+ current += ch
139
+ }
140
+
141
+ if (quote !== undefined) return []
142
+ segments.push(current)
143
+ return segments.map((segment) => segment.trim()).filter(Boolean)
144
+ }
145
+
146
+ // find is allowlisted for traversal only; these actions run commands or delete.
147
+ const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
148
+
149
+ function isSafeSegment(segment: string): boolean {
150
+ if (DESTRUCTIVE_PATTERNS.some((p) => p.test(segment))) return false
151
+ if (!SAFE_PATTERNS.some((p) => p.test(segment))) return false
152
+ return !(/^\s*find\b/.test(segment) && FIND_ACTIONS.test(segment))
153
+ }
154
+
155
+ /**
156
+ * Whether plan mode should let this bash command run.
157
+ *
158
+ * Model steering, not a sandbox: an allowlisted interpreter can still read and write
159
+ * whatever the user can, so this narrows the blast radius of a wrong turn rather than
160
+ * containing a determined one. Only OS-level isolation would be a boundary.
161
+ */
97
162
  export function isSafeCommand(command: string): boolean {
98
- const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command))
99
- const isSafe = SAFE_PATTERNS.some((p) => p.test(command))
100
- return !isDestructive && isSafe
163
+ if (SUBSTITUTION.test(command)) return false
164
+ const segments = splitSegments(command)
165
+ return segments.length > 0 && segments.every(isSafeSegment)
101
166
  }
102
167
 
103
168
  export interface TodoItem {
@@ -123,16 +188,53 @@ export function cleanStepText(text: string): string {
123
188
  return cleaned
124
189
  }
125
190
 
191
+ // Horizontal whitespace before the newline: \s would include \n itself and overlap
192
+ // the following \n, which is what backtracks super-linearly.
193
+ const PLAN_HEADER = /\*{0,2}Plan:\*{0,2}[^\S\n]*\n/i
194
+
195
+ const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === ''
196
+
197
+ /**
198
+ * Text of a `1. step` / `2) step` line, stopping at an inline `*`, or undefined when
199
+ * the line is not a numbered step. Scanned rather than matched: the equivalent
200
+ * pattern needs adjacent quantifiers over overlapping classes, which backtracks
201
+ * super-linearly on a long line that turns out not to be a step.
202
+ */
203
+ function numberedStepText(line: string): string | undefined {
204
+ let i = 0
205
+ while (isBlank(line[i])) i++
206
+
207
+ const digitsStart = i
208
+ while (line[i] >= '0' && line[i] <= '9') i++
209
+ if (i === digitsStart) return undefined
210
+
211
+ if (line[i] !== '.' && line[i] !== ')') return undefined
212
+ i++
213
+
214
+ const spaceStart = i
215
+ while (isBlank(line[i])) i++
216
+ if (i === spaceStart) return undefined // the marker must be followed by whitespace
217
+
218
+ for (let stars = 0; stars < 2 && line[i] === '*'; stars++) i++
219
+ const first = line[i]
220
+ if (first === undefined || first === '*' || isBlank(first)) return undefined
221
+
222
+ const rest = line.slice(i)
223
+ const star = rest.indexOf('*')
224
+ return star === -1 ? rest : rest.slice(0, star)
225
+ }
226
+
126
227
  export function extractTodoItems(message: string): TodoItem[] {
127
228
  const items: TodoItem[] = []
128
- const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i)
229
+ const headerMatch = PLAN_HEADER.exec(message)
129
230
  if (!headerMatch) return items
130
231
 
131
232
  const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length)
132
- const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm
133
233
 
134
- for (const match of planSection.matchAll(numberedPattern)) {
135
- const text = match[2]
234
+ for (const line of planSection.split('\n')) {
235
+ const captured = numberedStepText(line)
236
+ if (captured === undefined) continue
237
+ const text = captured
136
238
  .trim()
137
239
  .replace(/\*{1,2}$/, '')
138
240
  .trim()
@@ -170,6 +272,6 @@ export function markCompletedSteps(text: string, items: TodoItem[]): number {
170
272
  * the tool input is already known to be the plan itself.
171
273
  */
172
274
  export function planToTodos(plan: string): TodoItem[] {
173
- const withHeader = /\*{0,2}Plan:\*{0,2}\s*\n/i.test(plan) ? plan : `Plan:\n${plan}`
275
+ const withHeader = PLAN_HEADER.test(plan) ? plan : `Plan:\n${plan}`
174
276
  return extractTodoItems(withHeader)
175
277
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Project Approval
3
+ *
4
+ * `ctx.isProjectTrusted()` is not sufficient on its own. pi decides whether to ask for
5
+ * trust in `hasTrustRequiringProjectResources`, which looks only under `cwd/.pi` and for
6
+ * `.agents/skills`. A repository shipping just `.claude/` and `.mcp.json` matches neither,
7
+ * so `resolveProjectTrusted` short-circuits to `true` before it ever emits `project_trust`:
8
+ *
9
+ * if (!hasTrustRequiringProjectResources(cwd)) return true
10
+ * if (extensionsResult) { ...emitProjectTrustEvent... }
11
+ *
12
+ * A `project_trust` handler therefore cannot cover this case; the event only fires for
13
+ * projects pi was already going to prompt about. The decision has to be made where the
14
+ * project config is consumed instead, which is what this module does.
15
+ *
16
+ * Answers are stored in pi's own trust store, so approving here also satisfies pi if the
17
+ * project later grows `.pi` resources, and a decision recorded on a parent directory applies.
18
+ */
19
+
20
+ import * as fs from 'node:fs'
21
+ import * as path from 'node:path'
22
+ import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
23
+
24
+ /** Project files pi-code acts on that pi's own trust check does not look for. */
25
+ const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
26
+
27
+ export function hasClaudeShapedConfig(cwd: string): boolean {
28
+ return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
29
+ }
30
+
31
+ export interface ApprovalContext {
32
+ cwd: string
33
+ hasUI: boolean
34
+ isProjectTrusted?: () => boolean
35
+ ui: { confirm: (title: string, body: string) => Promise<boolean> }
36
+ }
37
+
38
+ export interface ApprovalDeps {
39
+ hasClaudeShaped: (cwd: string) => boolean
40
+ piWouldAsk: (cwd: string) => boolean
41
+ savedDecision: (cwd: string) => boolean | null
42
+ remember: (cwd: string, trusted: boolean) => void
43
+ }
44
+
45
+ const defaultDeps: ApprovalDeps = {
46
+ hasClaudeShaped: hasClaudeShapedConfig,
47
+ piWouldAsk: hasTrustRequiringProjectResources,
48
+ savedDecision: (cwd) => new ProjectTrustStore(getAgentDir()).get(cwd),
49
+ remember: (cwd, trusted) => new ProjectTrustStore(getAgentDir()).set(cwd, trusted),
50
+ }
51
+
52
+ const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MCP servers, hooks and agents can run commands from this repository.'
53
+
54
+ /**
55
+ * Whether project-controlled config may be acted on.
56
+ *
57
+ * Refuses without a UI rather than deferring: pi reached this point without consulting
58
+ * `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
59
+ * that cannot ask has not been approved.
60
+ */
61
+ export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
62
+ if (ctx.isProjectTrusted?.() !== true) return false // pi already declined, or never trusted
63
+ if (!deps.hasClaudeShaped(ctx.cwd)) return true // nothing here pi's own check would miss
64
+ if (deps.piWouldAsk(ctx.cwd)) return true // pi genuinely prompted for this project
65
+
66
+ const stored = deps.savedDecision(ctx.cwd)
67
+ if (stored !== null) return stored
68
+ if (!ctx.hasUI) return false
69
+
70
+ const approved = await ctx.ui.confirm('Trust this project?', `${ctx.cwd}\n\n${APPROVAL_BODY}`)
71
+ deps.remember(ctx.cwd, approved)
72
+ return approved
73
+ }
@@ -4,7 +4,7 @@
4
4
  * Escape in editor returns to options, Escape in options cancels
5
5
  */
6
6
 
7
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
7
+ import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
8
8
  import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
9
9
  import { Type } from 'typebox'
10
10
 
@@ -33,6 +33,60 @@ const QuestionParams = Type.Object({
33
33
  options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
34
34
  })
35
35
 
36
+ function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, theme: Theme): string {
37
+ const label = `${index + 1}. ${opt.label}`
38
+ const prefix = selected ? theme.fg('accent', '> ') : ' '
39
+ if (opt.isOther === true && editMode) {
40
+ return prefix + theme.fg('accent', `${label} ✎`)
41
+ }
42
+ if (selected) {
43
+ return prefix + theme.fg('accent', label)
44
+ }
45
+ return ` ${theme.fg('text', label)}`
46
+ }
47
+
48
+ interface QuestionView {
49
+ width: number
50
+ question: string
51
+ options: DisplayOption[]
52
+ optionIndex: number
53
+ editMode: boolean
54
+ editor: Editor
55
+ theme: Theme
56
+ }
57
+
58
+ function buildQuestionLines(view: QuestionView): string[] {
59
+ const { width, question, options, optionIndex, editMode, editor, theme } = view
60
+ const lines: string[] = []
61
+ const add = (s: string) => lines.push(truncateToWidth(s, width))
62
+
63
+ add(theme.fg('accent', '─'.repeat(width)))
64
+ add(theme.fg('text', ` ${question}`))
65
+ lines.push('')
66
+
67
+ for (let i = 0; i < options.length; i++) {
68
+ const opt = options[i]
69
+ add(optionLine(opt, i, i === optionIndex, editMode, theme))
70
+ if (opt.description) {
71
+ add(` ${theme.fg('muted', opt.description)}`)
72
+ }
73
+ }
74
+
75
+ if (editMode) {
76
+ lines.push('')
77
+ add(theme.fg('muted', ' Your answer:'))
78
+ for (const line of editor.render(width - 2)) {
79
+ add(` ${line}`)
80
+ }
81
+ }
82
+
83
+ lines.push('')
84
+ add(theme.fg('dim', editMode ? ' Enter to submit • Esc to go back' : ' ↑↓ navigate • Enter to select • Esc to cancel'))
85
+ add(theme.fg('accent', '─'.repeat(width)))
86
+
87
+ return lines
88
+ }
89
+
36
90
  export default function question(pi: ExtensionAPI) {
37
91
  pi.registerTool({
38
92
  name: 'question',
@@ -65,6 +119,7 @@ export default function question(pi: ExtensionAPI) {
65
119
  let optionIndex = 0
66
120
  let editMode = false
67
121
  let cachedLines: string[] | undefined
122
+ let cachedWidth: number | undefined
68
123
 
69
124
  const editorTheme: EditorTheme = {
70
125
  borderColor: (s) => theme.fg('accent', s),
@@ -135,58 +190,16 @@ export default function question(pi: ExtensionAPI) {
135
190
  }
136
191
 
137
192
  function render(width: number): string[] {
138
- if (cachedLines) return cachedLines
139
-
140
- const lines: string[] = []
141
- const add = (s: string) => lines.push(truncateToWidth(s, width))
142
-
143
- add(theme.fg('accent', '─'.repeat(width)))
144
- add(theme.fg('text', ` ${params.question}`))
145
- lines.push('')
146
-
147
- for (let i = 0; i < allOptions.length; i++) {
148
- const opt = allOptions[i]
149
- const selected = i === optionIndex
150
- const isOther = opt.isOther === true
151
- const prefix = selected ? theme.fg('accent', '> ') : ' '
152
-
153
- if (isOther && editMode) {
154
- add(prefix + theme.fg('accent', `${i + 1}. ${opt.label} ✎`))
155
- } else if (selected) {
156
- add(prefix + theme.fg('accent', `${i + 1}. ${opt.label}`))
157
- } else {
158
- add(` ${theme.fg('text', `${i + 1}. ${opt.label}`)}`)
159
- }
160
-
161
- // Show description if present
162
- if (opt.description) {
163
- add(` ${theme.fg('muted', opt.description)}`)
164
- }
165
- }
166
-
167
- if (editMode) {
168
- lines.push('')
169
- add(theme.fg('muted', ' Your answer:'))
170
- for (const line of editor.render(width - 2)) {
171
- add(` ${line}`)
172
- }
173
- }
174
-
175
- lines.push('')
176
- if (editMode) {
177
- add(theme.fg('dim', ' Enter to submit • Esc to go back'))
178
- } else {
179
- add(theme.fg('dim', ' ↑↓ navigate • Enter to select • Esc to cancel'))
180
- }
181
- add(theme.fg('accent', '─'.repeat(width)))
182
-
183
- cachedLines = lines
184
- return lines
193
+ if (cachedLines && cachedWidth === width) return cachedLines
194
+ cachedWidth = width
195
+ cachedLines = buildQuestionLines({ width, question: params.question, options: allOptions, optionIndex, editMode, editor, theme })
196
+ return cachedLines
185
197
  }
186
198
 
187
199
  return {
188
200
  render,
189
201
  invalidate: () => {
202
+ cachedWidth = undefined
190
203
  cachedLines = undefined
191
204
  },
192
205
  handleInput,
@@ -231,7 +244,8 @@ export default function question(pi: ExtensionAPI) {
231
244
  if (opts.length) {
232
245
  const labels = opts.map((o: OptionWithDesc) => o.label)
233
246
  const numbered = [...labels, 'Type something.'].map((o, i) => `${i + 1}. ${o}`)
234
- text += `\n${theme.fg('dim', ` Options: ${numbered.join(', ')}`)}`
247
+ const optionsLine = ` Options: ${numbered.join(', ')}`
248
+ text += `\n${theme.fg('dim', optionsLine)}`
235
249
  }
236
250
  return new Text(text, 0, 0)
237
251
  },
@@ -111,6 +111,17 @@ function findNearestDir(cwd: string, relative: string): string | null {
111
111
  }
112
112
  }
113
113
 
114
+ function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[], scope: AgentScope): Map<string, AgentConfig> {
115
+ const agentMap = new Map<string, AgentConfig>()
116
+ const register = (agents: AgentConfig[]): void => {
117
+ for (const agent of agents) agentMap.set(agent.name, agent)
118
+ }
119
+ // user agents first so project agents win on name conflicts
120
+ if (scope !== 'project') register(userAgents)
121
+ if (scope !== 'user') register(projectAgents)
122
+ return agentMap
123
+ }
124
+
114
125
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
115
126
  const userDir = path.join(getAgentDir(), 'agents')
116
127
  const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
@@ -122,17 +133,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
122
133
  // project .claude/agents loads first so project .pi/agents wins on name conflicts
123
134
  const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
124
135
 
125
- const agentMap = new Map<string, AgentConfig>()
126
-
127
- if (scope === 'both') {
128
- for (const agent of userAgents) agentMap.set(agent.name, agent)
129
- for (const agent of projectAgents) agentMap.set(agent.name, agent)
130
- } else if (scope === 'user') {
131
- for (const agent of userAgents) agentMap.set(agent.name, agent)
132
- } else {
133
- for (const agent of projectAgents) agentMap.set(agent.name, agent)
134
- }
135
-
136
+ const agentMap = buildAgentMap(userAgents, projectAgents, scope)
136
137
  return { agents: Array.from(agentMap.values()), projectAgentsDir: projectPiDir }
137
138
  }
138
139