pi-code 0.3.2 → 0.4.0
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/README.md +3 -3
- package/extensions/hooks.ts +71 -4
- package/extensions/mcp.ts +44 -14
- package/extensions/question.ts +70 -28
- package/extensions/subagent/background.ts +4 -3
- package/extensions/subagent/index.ts +5 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,10 +38,10 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
38
38
|
| Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
|
|
39
39
|
| Custom slash commands | `.claude/commands/*.md` → pi prompt templates | `commands.ts` |
|
|
40
40
|
| Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
|
|
41
|
-
| Hooks | `.claude/settings.json` hooks
|
|
41
|
+
| Hooks | `.claude/settings.json` hooks: PreToolUse, PostToolUse, SessionStart, UserPromptSubmit (blocks and injects context), Stop, PreCompact, SessionEnd | `hooks.ts` |
|
|
42
42
|
| Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
|
|
43
43
|
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
|
|
44
|
-
| MCP servers | user `~/.claude.json
|
|
44
|
+
| MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
|
|
45
45
|
| Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles) that pi would otherwise trust silently | `internal/project-approval.ts` |
|
|
46
46
|
| Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
|
|
47
47
|
| Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
|
|
@@ -49,7 +49,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
49
49
|
| Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later | `git-checkpoint.ts` |
|
|
50
50
|
| Persistent memory | per-project memories, index injected each session | `memory.ts` |
|
|
51
51
|
| WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
|
|
52
|
-
| AskUserQuestion |
|
|
52
|
+
| AskUserQuestion | one question with `header`, single- or `multiSelect` options, plus free-text; no multi-question batching | `question.ts` |
|
|
53
53
|
| Statusline | turn state + session cost | `status-line.ts` |
|
|
54
54
|
| Notifications | vendored example | `notify.ts` |
|
|
55
55
|
|
package/extensions/hooks.ts
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
|
|
5
5
|
* a project's existing hooks work under pi:
|
|
6
|
-
* - PreToolUse
|
|
7
|
-
* - PostToolUse
|
|
8
|
-
* - SessionStart-> pi `session_start` (fire-and-forget)
|
|
6
|
+
* - PreToolUse -> pi `tool_call` (can block the tool)
|
|
7
|
+
* - PostToolUse -> pi `tool_execution_end` (fire-and-forget)
|
|
8
|
+
* - SessionStart -> pi `session_start` (fire-and-forget)
|
|
9
|
+
* - UserPromptSubmit-> pi `input` (can block the prompt via `handled`, or inject
|
|
10
|
+
* additional context by transforming the submitted text)
|
|
11
|
+
* - Stop -> pi `agent_end` (fire-and-forget; cannot prevent stopping)
|
|
12
|
+
* - PreCompact -> pi `session_before_compact` (fire-and-forget)
|
|
13
|
+
* - SessionEnd -> pi `session_shutdown` (fire-and-forget)
|
|
14
|
+
*
|
|
15
|
+
* Claude's SubagentStop has no pi lifecycle seam (the subagent tool spawns child pi
|
|
16
|
+
* processes, and pi emits no subagent-completion event), so it is not bridged.
|
|
9
17
|
*
|
|
10
18
|
* Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
|
|
11
19
|
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
@@ -102,7 +110,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, name: stri
|
|
|
102
110
|
return result
|
|
103
111
|
}
|
|
104
112
|
|
|
105
|
-
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string }; decision?: string; reason?: string; continue?: boolean; stopReason?: string } | undefined {
|
|
113
|
+
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string }; decision?: string; reason?: string; continue?: boolean; stopReason?: string } | undefined {
|
|
106
114
|
try {
|
|
107
115
|
return JSON.parse(text)
|
|
108
116
|
} catch {
|
|
@@ -217,6 +225,35 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
|
|
|
217
225
|
await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
|
|
218
226
|
}
|
|
219
227
|
|
|
228
|
+
export interface PromptDecision {
|
|
229
|
+
block: boolean
|
|
230
|
+
reason?: string
|
|
231
|
+
context: string
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Additional context a UserPromptSubmit hook contributes: an explicit
|
|
235
|
+
* hookSpecificOutput.additionalContext, or the raw stdout of a plain exit-0 hook. */
|
|
236
|
+
function promptContext(stdout: string): string {
|
|
237
|
+
const parsed = tryParseJson(stdout)
|
|
238
|
+
if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
|
|
239
|
+
return stdout.trim()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
|
|
243
|
+
* additional context is concatenated for injection ahead of the prompt. */
|
|
244
|
+
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner): Promise<PromptDecision> {
|
|
245
|
+
const contexts: string[] = []
|
|
246
|
+
for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
|
|
247
|
+
const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
|
|
248
|
+
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
|
|
249
|
+
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
250
|
+
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
251
|
+
const context = promptContext(result.stdout)
|
|
252
|
+
if (context) contexts.push(context)
|
|
253
|
+
}
|
|
254
|
+
return { block: false, context: contexts.join('\n') }
|
|
255
|
+
}
|
|
256
|
+
|
|
220
257
|
/** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
|
|
221
258
|
const MAX_PENDING_INPUTS = 100
|
|
222
259
|
|
|
@@ -258,4 +295,34 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
258
295
|
if (event.isError) return
|
|
259
296
|
await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
|
|
260
297
|
})
|
|
298
|
+
|
|
299
|
+
pi.on('input', async (event, ctx) => {
|
|
300
|
+
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
301
|
+
// prompts the user submitted.
|
|
302
|
+
if (event.source === 'extension') return { action: 'continue' }
|
|
303
|
+
const decision = await runUserPromptSubmit(config, event.text, runner)
|
|
304
|
+
if (decision.block) {
|
|
305
|
+
// pi's input result has no reason channel, so surface why before consuming it.
|
|
306
|
+
ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
|
|
307
|
+
return { action: 'handled' }
|
|
308
|
+
}
|
|
309
|
+
// Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
|
|
310
|
+
// pi's seam for rewriting the submitted text.
|
|
311
|
+
if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
|
|
312
|
+
return { action: 'continue' }
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Notify-style Claude events with a matching pi lifecycle seam. None can block: pi's
|
|
316
|
+
// agent_end, session_before_compact and session_shutdown are fire-and-forget here.
|
|
317
|
+
pi.on('agent_end', async () => {
|
|
318
|
+
await runNotifyHooks(matchingCommands(config.Stop, 'Stop'), { hook_event_name: 'Stop' }, runner)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
pi.on('session_before_compact', async (event) => {
|
|
322
|
+
await runNotifyHooks(matchingCommands(config.PreCompact, event.reason), { hook_event_name: 'PreCompact', trigger: event.reason }, runner)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
pi.on('session_shutdown', async (event) => {
|
|
326
|
+
await runNotifyHooks(matchingCommands(config.SessionEnd, event.reason), { hook_event_name: 'SessionEnd', reason: event.reason }, runner)
|
|
327
|
+
})
|
|
261
328
|
}
|
package/extensions/mcp.ts
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
* failures skip with a notice; stdio and HTTP (streamable with SSE fallback)
|
|
8
8
|
* transports; /mcp shows status.
|
|
9
9
|
*
|
|
10
|
-
* Reads Claude Code's MCP config too. User config (~/.claude.json
|
|
11
|
-
* ~/.pi/agent/mcp.json) is the
|
|
12
|
-
* config (.mcp.json, .pi/mcp.json)
|
|
13
|
-
* loads only once the project is approved
|
|
14
|
-
* are loaded separately, not merged; user config
|
|
15
|
-
* server cannot take the name of a user server that connected.
|
|
16
|
-
* Values support ${VAR} interpolation, and
|
|
17
|
-
*
|
|
10
|
+
* Reads Claude Code's MCP config too. User config (~/.claude.json top-level plus its
|
|
11
|
+
* per-project `projects[cwd].mcpServers` local scope, and ~/.pi/agent/mcp.json) is the
|
|
12
|
+
* user's own and loads on the first session. Project config (.mcp.json, .pi/mcp.json)
|
|
13
|
+
* can run arbitrary commands on connect, so it loads only once the project is approved
|
|
14
|
+
* (see project-approval). The two scopes are loaded separately, not merged; user config
|
|
15
|
+
* connects first, so a project server cannot take the name of a user server that connected.
|
|
16
|
+
* Values support ${VAR} / ${VAR:-default} interpolation, connect and per-call timeouts
|
|
17
|
+
* honor MCP_TIMEOUT / MCP_TOOL_TIMEOUT, and a stdio server receives only the SDK's default
|
|
18
|
+
* environment plus its own `env` block, not the whole process environment.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import * as fs from 'node:fs'
|
|
@@ -31,8 +32,20 @@ import { Type } from 'typebox'
|
|
|
31
32
|
import { capForContext } from './internal/output-guard.js'
|
|
32
33
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
33
34
|
|
|
34
|
-
const
|
|
35
|
-
const
|
|
35
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
36
|
+
const DEFAULT_CALL_TIMEOUT_MS = 120_000
|
|
37
|
+
|
|
38
|
+
/** A positive-integer env override, or the default when unset or unparseable. */
|
|
39
|
+
function envTimeout(name: string, fallback: number): number {
|
|
40
|
+
const raw = process.env[name]
|
|
41
|
+
if (raw === undefined) return fallback
|
|
42
|
+
const value = Number.parseInt(raw, 10)
|
|
43
|
+
return Number.isInteger(value) && value > 0 ? value : fallback
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
|
|
47
|
+
const connectTimeoutMs = (): number => envTimeout('MCP_TIMEOUT', DEFAULT_CONNECT_TIMEOUT_MS)
|
|
48
|
+
const callTimeoutMs = (): number => envTimeout('MCP_TOOL_TIMEOUT', DEFAULT_CALL_TIMEOUT_MS)
|
|
36
49
|
// Tool names an MCP server must never take over. formatToolName always emits
|
|
37
50
|
// `<server>_<tool>`, so only names containing an underscore are actually reachable:
|
|
38
51
|
// pi's own built-ins (read, bash, edit, ...) cannot be produced and are not listed.
|
|
@@ -91,6 +104,23 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
|
|
|
91
104
|
return servers
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
/**
|
|
108
|
+
* All user-owned servers for this session: the global user servers plus Claude's "local"
|
|
109
|
+
* scope, the per-project user servers under `projects[cwd].mcpServers` in ~/.claude.json.
|
|
110
|
+
* Both are the user's own config, so neither needs project trust; local wins on a name
|
|
111
|
+
* clash (Claude's precedence is local over user).
|
|
112
|
+
*/
|
|
113
|
+
export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
|
|
114
|
+
const servers = loadConfigFrom(userConfigPaths(home))
|
|
115
|
+
try {
|
|
116
|
+
const claudeJson = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf-8'))
|
|
117
|
+
Object.assign(servers, claudeJson.projects?.[cwd]?.mcpServers ?? {})
|
|
118
|
+
} catch {
|
|
119
|
+
// missing or invalid ~/.claude.json: the top-level user servers already loaded
|
|
120
|
+
}
|
|
121
|
+
return servers
|
|
122
|
+
}
|
|
123
|
+
|
|
94
124
|
export function formatToolName(server: string, tool: string): string {
|
|
95
125
|
return `${server}_${tool}`.replaceAll('-', '_')
|
|
96
126
|
}
|
|
@@ -204,7 +234,7 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
|
|
|
204
234
|
async function connectWithTimeout(client: Client, transport: Parameters<Client['connect']>[0], label: string): Promise<void> {
|
|
205
235
|
const connecting = client.connect(transport)
|
|
206
236
|
try {
|
|
207
|
-
await withTimeout(connecting,
|
|
237
|
+
await withTimeout(connecting, connectTimeoutMs(), label)
|
|
208
238
|
} catch (error) {
|
|
209
239
|
// Only a timeout can orphan a still-opening transport; a connect rejection means the
|
|
210
240
|
// SDK already tore it down, so closing again would be redundant.
|
|
@@ -244,7 +274,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
244
274
|
try {
|
|
245
275
|
const client = await connect(name, config)
|
|
246
276
|
clients.set(name, client)
|
|
247
|
-
const tools = await withTimeout(listAllTools(client),
|
|
277
|
+
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
248
278
|
let count = 0
|
|
249
279
|
for (const tool of tools) {
|
|
250
280
|
const toolName = formatToolName(name, tool.name)
|
|
@@ -262,7 +292,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
262
292
|
async execute(_id, params) {
|
|
263
293
|
// Pass the timeout to the SDK too: its own default request timeout is 60s and
|
|
264
294
|
// would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
|
|
265
|
-
const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout:
|
|
295
|
+
const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: callTimeoutMs() }), callTimeoutMs(), toolName)
|
|
266
296
|
const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
|
|
267
297
|
const details: { error?: string } = {}
|
|
268
298
|
if (result.isError) {
|
|
@@ -289,7 +319,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
289
319
|
// the factory: pi runs the factory for invocations that never start a session.
|
|
290
320
|
if (!userConnected) {
|
|
291
321
|
userConnected = true
|
|
292
|
-
await connectServers(
|
|
322
|
+
await connectServers(loadUserScope(os.homedir(), ctx.cwd))
|
|
293
323
|
}
|
|
294
324
|
// A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
|
|
295
325
|
// isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
|
package/extensions/question.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Question Tool -
|
|
3
|
-
* Full custom UI: options list + inline editor for "Type something..."
|
|
4
|
-
*
|
|
2
|
+
* Question Tool - a question with options, single- or multi-select.
|
|
3
|
+
* Full custom UI: options list + inline editor for "Type something..." (single-select
|
|
4
|
+
* only), or space-toggled checkboxes when `multiSelect` is set. An optional `header`
|
|
5
|
+
* labels the question. Escape in the editor returns to options; Escape in options cancels.
|
|
6
|
+
* Multiple questions per call are not batched; ask sequentially.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
|
|
@@ -17,9 +19,11 @@ type DisplayOption = OptionWithDesc & { isOther?: boolean }
|
|
|
17
19
|
|
|
18
20
|
interface QuestionDetails {
|
|
19
21
|
question: string
|
|
22
|
+
header?: string
|
|
20
23
|
options: string[]
|
|
21
24
|
answer: string | null
|
|
22
25
|
wasCustom?: boolean
|
|
26
|
+
multiSelect?: boolean
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
// Options with labels and optional descriptions
|
|
@@ -30,11 +34,18 @@ const OptionSchema = Type.Object({
|
|
|
30
34
|
|
|
31
35
|
const QuestionParams = Type.Object({
|
|
32
36
|
question: Type.String({ description: 'The question to ask the user' }),
|
|
37
|
+
header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it' })),
|
|
33
38
|
options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
|
|
39
|
+
multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })),
|
|
34
40
|
})
|
|
35
41
|
|
|
36
|
-
function
|
|
37
|
-
|
|
42
|
+
function checkbox(checked: boolean | undefined): string {
|
|
43
|
+
if (checked === undefined) return ''
|
|
44
|
+
return checked ? '[x] ' : '[ ] '
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, checked: boolean | undefined, theme: Theme): string {
|
|
48
|
+
const label = `${index + 1}. ${checkbox(checked)}${opt.label}`
|
|
38
49
|
const prefix = selected ? theme.fg('accent', '> ') : ' '
|
|
39
50
|
if (opt.isOther === true && editMode) {
|
|
40
51
|
return prefix + theme.fg('accent', `${label} ✎`)
|
|
@@ -48,25 +59,30 @@ function optionLine(opt: DisplayOption, index: number, selected: boolean, editMo
|
|
|
48
59
|
interface QuestionView {
|
|
49
60
|
width: number
|
|
50
61
|
question: string
|
|
62
|
+
header?: string
|
|
51
63
|
options: DisplayOption[]
|
|
52
64
|
optionIndex: number
|
|
53
65
|
editMode: boolean
|
|
66
|
+
multiSelect: boolean
|
|
67
|
+
checked: boolean[]
|
|
54
68
|
editor: Editor
|
|
55
69
|
theme: Theme
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
function buildQuestionLines(view: QuestionView): string[] {
|
|
59
|
-
const { width, question, options, optionIndex, editMode, editor, theme } = view
|
|
73
|
+
const { width, question, header, options, optionIndex, editMode, multiSelect, checked, editor, theme } = view
|
|
60
74
|
const lines: string[] = []
|
|
61
75
|
const add = (s: string) => lines.push(truncateToWidth(s, width))
|
|
62
76
|
|
|
63
77
|
add(theme.fg('accent', '─'.repeat(width)))
|
|
78
|
+
if (header) add(theme.fg('muted', ` [${header}]`))
|
|
64
79
|
add(theme.fg('text', ` ${question}`))
|
|
65
80
|
lines.push('')
|
|
66
81
|
|
|
67
82
|
for (let i = 0; i < options.length; i++) {
|
|
68
83
|
const opt = options[i]
|
|
69
|
-
|
|
84
|
+
const box = multiSelect && opt.isOther !== true ? checked[i] : undefined
|
|
85
|
+
add(optionLine(opt, i, i === optionIndex, editMode, box, theme))
|
|
70
86
|
if (opt.description) {
|
|
71
87
|
add(` ${theme.fg('muted', opt.description)}`)
|
|
72
88
|
}
|
|
@@ -81,12 +97,26 @@ function buildQuestionLines(view: QuestionView): string[] {
|
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
lines.push('')
|
|
84
|
-
add(theme.fg('dim', editMode
|
|
100
|
+
add(theme.fg('dim', navHint(editMode, multiSelect)))
|
|
85
101
|
add(theme.fg('accent', '─'.repeat(width)))
|
|
86
102
|
|
|
87
103
|
return lines
|
|
88
104
|
}
|
|
89
105
|
|
|
106
|
+
function navHint(editMode: boolean, multiSelect: boolean): string {
|
|
107
|
+
if (editMode) return ' Enter to submit • Esc to go back'
|
|
108
|
+
if (multiSelect) return ' ↑↓ navigate • Space to toggle • Enter to confirm • Esc to cancel'
|
|
109
|
+
return ' ↑↓ navigate • Enter to select • Esc to cancel'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The comma-joined labels of the checked options, in order. */
|
|
113
|
+
function selectedLabels(options: DisplayOption[], checked: boolean[]): string {
|
|
114
|
+
return options
|
|
115
|
+
.filter((_, i) => checked[i])
|
|
116
|
+
.map((o) => o.label)
|
|
117
|
+
.join(', ')
|
|
118
|
+
}
|
|
119
|
+
|
|
90
120
|
export default function question(pi: ExtensionAPI) {
|
|
91
121
|
pi.registerTool({
|
|
92
122
|
name: 'question',
|
|
@@ -113,11 +143,14 @@ export default function question(pi: ExtensionAPI) {
|
|
|
113
143
|
}
|
|
114
144
|
}
|
|
115
145
|
|
|
116
|
-
const
|
|
146
|
+
const multiSelect = params.multiSelect === true
|
|
147
|
+
// The free-text option does not compose with checkbox selection, so it is single-select only.
|
|
148
|
+
const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
|
|
117
149
|
|
|
118
150
|
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui, theme, _kb, done) => {
|
|
119
151
|
let optionIndex = 0
|
|
120
152
|
let editMode = false
|
|
153
|
+
const checked: boolean[] = allOptions.map(() => false)
|
|
121
154
|
let cachedLines: string[] | undefined
|
|
122
155
|
let cachedWidth: number | undefined
|
|
123
156
|
|
|
@@ -173,7 +206,17 @@ export default function question(pi: ExtensionAPI) {
|
|
|
173
206
|
return
|
|
174
207
|
}
|
|
175
208
|
|
|
209
|
+
if (multiSelect && data === ' ') {
|
|
210
|
+
checked[optionIndex] = !checked[optionIndex]
|
|
211
|
+
refresh()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
176
215
|
if (matchesKey(data, Key.enter)) {
|
|
216
|
+
if (multiSelect) {
|
|
217
|
+
done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
|
|
218
|
+
return
|
|
219
|
+
}
|
|
177
220
|
const selected = allOptions[optionIndex]
|
|
178
221
|
if (selected.isOther) {
|
|
179
222
|
editMode = true
|
|
@@ -192,7 +235,7 @@ export default function question(pi: ExtensionAPI) {
|
|
|
192
235
|
function render(width: number): string[] {
|
|
193
236
|
if (cachedLines && cachedWidth === width) return cachedLines
|
|
194
237
|
cachedWidth = width
|
|
195
|
-
cachedLines = buildQuestionLines({ width, question: params.question, options: allOptions, optionIndex, editMode, editor, theme })
|
|
238
|
+
cachedLines = buildQuestionLines({ width, question: params.question, header: params.header, options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
|
|
196
239
|
return cachedLines
|
|
197
240
|
}
|
|
198
241
|
|
|
@@ -206,45 +249,41 @@ export default function question(pi: ExtensionAPI) {
|
|
|
206
249
|
}
|
|
207
250
|
})
|
|
208
251
|
|
|
209
|
-
// Build simple options list for details
|
|
252
|
+
// Build simple options list for details; header/multiSelect appear only when set,
|
|
253
|
+
// so single-select details are unchanged.
|
|
210
254
|
const simpleOptions = params.options.map((o) => o.label)
|
|
255
|
+
const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: params.header } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
|
|
211
256
|
|
|
212
257
|
if (!result) {
|
|
213
258
|
return {
|
|
214
259
|
content: [{ type: 'text', text: 'User cancelled the selection' }],
|
|
215
|
-
details: {
|
|
260
|
+
details: { ...base, answer: null } as QuestionDetails,
|
|
216
261
|
}
|
|
217
262
|
}
|
|
218
263
|
|
|
219
264
|
if (result.wasCustom) {
|
|
220
265
|
return {
|
|
221
266
|
content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
|
|
222
|
-
details: {
|
|
223
|
-
question: params.question,
|
|
224
|
-
options: simpleOptions,
|
|
225
|
-
answer: result.answer,
|
|
226
|
-
wasCustom: true,
|
|
227
|
-
} as QuestionDetails,
|
|
267
|
+
details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
|
|
228
268
|
}
|
|
229
269
|
}
|
|
270
|
+
const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
|
|
230
271
|
return {
|
|
231
|
-
content: [{ type: 'text', text:
|
|
232
|
-
details: {
|
|
233
|
-
question: params.question,
|
|
234
|
-
options: simpleOptions,
|
|
235
|
-
answer: result.answer,
|
|
236
|
-
wasCustom: false,
|
|
237
|
-
} as QuestionDetails,
|
|
272
|
+
content: [{ type: 'text', text: selectionText }],
|
|
273
|
+
details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
|
|
238
274
|
}
|
|
239
275
|
},
|
|
240
276
|
|
|
241
277
|
renderCall(args, theme, _context) {
|
|
242
|
-
|
|
278
|
+
const multi = args.multiSelect === true
|
|
279
|
+
const heading = args.header ? `[${args.header}] ` : ''
|
|
280
|
+
let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? ''))
|
|
243
281
|
const opts = Array.isArray(args.options) ? args.options : []
|
|
244
282
|
if (opts.length) {
|
|
245
283
|
const labels = opts.map((o: OptionWithDesc) => o.label)
|
|
246
|
-
const
|
|
247
|
-
const
|
|
284
|
+
const shown = multi ? labels : [...labels, 'Type something.']
|
|
285
|
+
const numbered = shown.map((o, i) => `${i + 1}. ${o}`)
|
|
286
|
+
const optionsLine = ` Options${multi ? ' (multi)' : ''}: ${numbered.join(', ')}`
|
|
248
287
|
text += `\n${theme.fg('dim', optionsLine)}`
|
|
249
288
|
}
|
|
250
289
|
return new Text(text, 0, 0)
|
|
@@ -264,6 +303,9 @@ export default function question(pi: ExtensionAPI) {
|
|
|
264
303
|
if (details.wasCustom) {
|
|
265
304
|
return new Text(theme.fg('success', '✓ ') + theme.fg('muted', '(wrote) ') + theme.fg('accent', details.answer), 0, 0)
|
|
266
305
|
}
|
|
306
|
+
if (details.multiSelect) {
|
|
307
|
+
return new Text(theme.fg('success', '✓ ') + theme.fg('accent', details.answer || '(none)'), 0, 0)
|
|
308
|
+
}
|
|
267
309
|
const idx = details.options.indexOf(details.answer) + 1
|
|
268
310
|
const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer
|
|
269
311
|
return new Text(theme.fg('success', '✓ ') + theme.fg('accent', display), 0, 0)
|
|
@@ -47,9 +47,10 @@ export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns:
|
|
|
47
47
|
}
|
|
48
48
|
if (event.type !== 'message_end' || event.message?.role !== 'assistant') continue
|
|
49
49
|
turns++
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
// The complete text of the last assistant message, matching getFinalOutput on the
|
|
51
|
+
// foreground path so a multi-part message reads the same in both.
|
|
52
|
+
const parts = (event.message.content ?? []).filter((p) => p.type === 'text' && p.text).map((p) => p.text as string)
|
|
53
|
+
if (parts.length > 0) text = parts.join('\n')
|
|
53
54
|
}
|
|
54
55
|
return { text, turns }
|
|
55
56
|
}
|
|
@@ -160,9 +160,10 @@ export function getFinalOutput(messages: Message[]): string {
|
|
|
160
160
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
161
161
|
const msg = messages[i]
|
|
162
162
|
if (msg.role === 'assistant') {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
// The complete text of the last assistant message: a message can carry more than one
|
|
164
|
+
// text part, and taking only the first diverged from the background parser.
|
|
165
|
+
const parts = msg.content.filter((part) => part.type === 'text').map((part) => part.text)
|
|
166
|
+
if (parts.length > 0) return parts.join('\n')
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
169
|
return ''
|
|
@@ -638,7 +639,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
|
|
|
638
639
|
details: makeDetails('chain')(results),
|
|
639
640
|
}
|
|
640
641
|
}
|
|
641
|
-
previousOutput = getFinalOutput(result.messages)
|
|
642
|
+
previousOutput = capForContext(getFinalOutput(result.messages))
|
|
642
643
|
}
|
|
643
644
|
return {
|
|
644
645
|
content: [{ type: 'text', text: capForContext(getFinalOutput(results.at(-1)?.messages ?? [])) || '(no output)' }],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|