pi-code 1.0.77 → 1.1.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/extensions/question.ts +161 -11
- package/package.json +1 -1
package/extensions/question.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* only), or space-toggled checkboxes when `multiSelect` is set. An optional `header`
|
|
5
5
|
* labels the question. Escape in the editor returns to options; Escape in options cancels.
|
|
6
6
|
* Multiple questions per call are not batched; ask sequentially.
|
|
7
|
+
* On the TUI each question is also offered to a remote responder over pi.events
|
|
8
|
+
* (REMOTE_QUESTION_CHANNEL); the first answer, local or remote, wins.
|
|
7
9
|
*/
|
|
8
10
|
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
9
12
|
import * as os from 'node:os'
|
|
10
13
|
import * as path from 'node:path'
|
|
11
14
|
import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
@@ -64,6 +67,26 @@ export interface QuestionSpec {
|
|
|
64
67
|
multiSelect?: boolean
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/** In-process, question-owned offer; arbitrary custom TUI components are not sent. */
|
|
71
|
+
export const REMOTE_QUESTION_CHANNEL = 'pi-code:question:v1'
|
|
72
|
+
export type RemoteQuestionOutcome = { action: 'answer'; indices: number[] } | { action: 'text'; text: string } | { action: 'cancel' } | { action: 'pass' }
|
|
73
|
+
|
|
74
|
+
export interface RemoteQuestionOffer {
|
|
75
|
+
version: 1
|
|
76
|
+
requestId: string
|
|
77
|
+
sessionId: string
|
|
78
|
+
question: string
|
|
79
|
+
header?: string
|
|
80
|
+
options: OptionWithDesc[]
|
|
81
|
+
multiSelect: boolean
|
|
82
|
+
allowFreeText: boolean
|
|
83
|
+
signal: AbortSignal
|
|
84
|
+
/** Must be called during event emission; only one listener receives a settle handle. */
|
|
85
|
+
claim: () => ((outcome: RemoteQuestionOutcome) => boolean) | undefined
|
|
86
|
+
/** The claimed responder reports input activity to reset a configured idle timer. */
|
|
87
|
+
touch: () => boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
67
90
|
/** Normalize either accepted shape into the list of questions to ask. */
|
|
68
91
|
function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec[] }): QuestionSpec[] {
|
|
69
92
|
if (params.questions && params.questions.length > 0) return params.questions
|
|
@@ -194,19 +217,19 @@ export default function question(pi: ExtensionAPI) {
|
|
|
194
217
|
// parallel leave the first unanswerable and the run unable to finish.
|
|
195
218
|
executionMode: 'sequential',
|
|
196
219
|
|
|
197
|
-
async execute(_toolCallId, rawParams,
|
|
220
|
+
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
198
221
|
const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
|
|
199
222
|
if (specs.length === 0) {
|
|
200
223
|
return { content: [{ type: 'text', text: 'Error: No question provided' }], details: { question: '', options: [], answer: null } as QuestionDetails }
|
|
201
224
|
}
|
|
202
|
-
if (specs.length === 1) return await askOne(specs[0], ctx)
|
|
225
|
+
if (specs.length === 1) return await askOne(specs[0], ctx, pi.events, signal)
|
|
203
226
|
|
|
204
227
|
// Several questions are asked in sequence; a cancel ends the run, since the
|
|
205
228
|
// remaining answers would be guesses about a flow the user just declined.
|
|
206
229
|
const texts: string[] = []
|
|
207
230
|
const collected: QuestionDetails[] = []
|
|
208
231
|
for (const spec of specs) {
|
|
209
|
-
const result = await askOne(spec, ctx)
|
|
232
|
+
const result = await askOne(spec, ctx, pi.events, signal)
|
|
210
233
|
const detail = result.details as QuestionDetails
|
|
211
234
|
collected.push(detail)
|
|
212
235
|
texts.push(`${spec.question}\n${result.content[0].text}`)
|
|
@@ -258,7 +281,135 @@ export default function question(pi: ExtensionAPI) {
|
|
|
258
281
|
})
|
|
259
282
|
}
|
|
260
283
|
|
|
261
|
-
|
|
284
|
+
type QuestionAnswer = Awaited<ReturnType<typeof askViaOverlay>>
|
|
285
|
+
|
|
286
|
+
/** A claimed offer raced against the open overlay: whichever answers first wins. */
|
|
287
|
+
interface RemoteQuestion {
|
|
288
|
+
/** Routes remote answers into the overlay and remote activity into its idle timer. */
|
|
289
|
+
attach: (answer: (value: QuestionAnswer) => void, touch: () => void) => void
|
|
290
|
+
/** Ends the offer; the responder's settle handle and touch are rejected from then on. */
|
|
291
|
+
close: () => void
|
|
292
|
+
}
|
|
293
|
+
type RemoteOffer = { kind: 'settled'; value: QuestionAnswer } | { kind: 'claimed'; remote: RemoteQuestion }
|
|
294
|
+
|
|
295
|
+
function validIndices(indices: unknown, count: number, multiSelect: boolean): indices is number[] {
|
|
296
|
+
if (!Array.isArray(indices) || (!multiSelect && indices.length !== 1)) return false
|
|
297
|
+
return new Set(indices).size === indices.length && indices.every((index) => Number.isInteger(index) && index >= 1 && index <= count)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A remote outcome as the answer it stands for: null is a cancel, undefined is a
|
|
301
|
+
* pass or anything this question cannot accept, which must never become an answer. */
|
|
302
|
+
function remoteAnswer(outcome: RemoteQuestionOutcome, options: DisplayOption[], multiSelect: boolean): QuestionAnswer | undefined {
|
|
303
|
+
if (outcome === null || typeof outcome !== 'object') return undefined
|
|
304
|
+
if (outcome.action === 'cancel') return null
|
|
305
|
+
if (outcome.action === 'text') {
|
|
306
|
+
const text = typeof outcome.text === 'string' ? outcome.text.trim() : ''
|
|
307
|
+
return !multiSelect && text ? { answer: text, wasCustom: true } : undefined
|
|
308
|
+
}
|
|
309
|
+
if (outcome.action !== 'answer') return undefined
|
|
310
|
+
// Read once and copied, so a getter or Proxy cannot answer with other indices
|
|
311
|
+
// than the ones that passed validation.
|
|
312
|
+
const raw: unknown = outcome.indices
|
|
313
|
+
const indices: unknown = Array.isArray(raw) ? [...raw] : undefined
|
|
314
|
+
if (!validIndices(indices, options.length, multiSelect)) return undefined
|
|
315
|
+
const checked = options.map((_, index) => indices.includes(index + 1))
|
|
316
|
+
return { answer: selectedLabels(options, checked), wasCustom: false, ...(multiSelect ? {} : { index: indices[0] }) }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Undefined when nobody claims synchronously, so the overlay opens with no async gap. */
|
|
320
|
+
function offerRemoteQuestion(params: QuestionSpec, allOptions: DisplayOption[], ctx: ExtensionContext, events: ExtensionAPI['events'] | undefined, signal: AbortSignal | undefined): RemoteOffer | undefined {
|
|
321
|
+
if (!events) return undefined
|
|
322
|
+
if (signal?.aborted) return { kind: 'settled', value: null }
|
|
323
|
+
|
|
324
|
+
const multiSelect = params.multiSelect === true
|
|
325
|
+
const controller = new AbortController()
|
|
326
|
+
let claimed = false
|
|
327
|
+
let early: { value: QuestionAnswer } | undefined
|
|
328
|
+
let deliver: ((value: QuestionAnswer) => void) | undefined
|
|
329
|
+
let touchOverlay: (() => void) | undefined
|
|
330
|
+
const close = (): void => {
|
|
331
|
+
controller.abort()
|
|
332
|
+
signal?.removeEventListener('abort', abort)
|
|
333
|
+
}
|
|
334
|
+
const settle = (value: QuestionAnswer): void => {
|
|
335
|
+
close()
|
|
336
|
+
if (deliver) deliver(value)
|
|
337
|
+
else early = { value }
|
|
338
|
+
}
|
|
339
|
+
// Pi aborts the turn before a session replacement invalidates ctx, so this is
|
|
340
|
+
// also the session-replacement path; ctx is never read after emission.
|
|
341
|
+
const abort = (): void => settle(null)
|
|
342
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
343
|
+
|
|
344
|
+
const offer: RemoteQuestionOffer = {
|
|
345
|
+
version: 1,
|
|
346
|
+
requestId: randomUUID(),
|
|
347
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
348
|
+
question: params.question,
|
|
349
|
+
header: shortHeader(params.header),
|
|
350
|
+
options: params.options.map(({ label, description }) => ({ label, ...(description === undefined ? {} : { description }) })),
|
|
351
|
+
multiSelect,
|
|
352
|
+
allowFreeText: allOptions.some((option) => option.isOther === true),
|
|
353
|
+
signal: controller.signal,
|
|
354
|
+
claim: () => {
|
|
355
|
+
if (claimed || controller.signal.aborted) return undefined
|
|
356
|
+
claimed = true
|
|
357
|
+
return (outcome) => {
|
|
358
|
+
if (controller.signal.aborted) return false
|
|
359
|
+
const value = remoteAnswer(outcome, params.options, multiSelect)
|
|
360
|
+
if (value !== undefined) {
|
|
361
|
+
settle(value)
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
364
|
+
// A pass or invalid reply only withdraws the remote side; the overlay stays
|
|
365
|
+
// open, and a turn abort must still cancel it.
|
|
366
|
+
controller.abort()
|
|
367
|
+
return outcome?.action === 'pass'
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
touch: () => {
|
|
371
|
+
if (!claimed || controller.signal.aborted) return false
|
|
372
|
+
touchOverlay?.()
|
|
373
|
+
return true
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
events.emit(REMOTE_QUESTION_CHANNEL, offer)
|
|
378
|
+
if (early) return { kind: 'settled', value: early.value }
|
|
379
|
+
if (!claimed || controller.signal.aborted) {
|
|
380
|
+
close()
|
|
381
|
+
return undefined
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
kind: 'claimed',
|
|
385
|
+
remote: {
|
|
386
|
+
attach: (answer, touch) => {
|
|
387
|
+
deliver = answer
|
|
388
|
+
touchOverlay = touch
|
|
389
|
+
},
|
|
390
|
+
close,
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** ui.custom() is terminal-only: with a UI but no terminal (RPC mode) it resolves
|
|
396
|
+
* undefined immediately, which would read as a cancel without ever asking. Ask
|
|
397
|
+
* through the dialog primitives there instead. askUserQuestionTimeout is a TUI
|
|
398
|
+
* concept (a countdown, a keypress resetting it): the dialog-primitive fallback
|
|
399
|
+
* has no keyboard or visible countdown to drive it, so it is not applied there. */
|
|
400
|
+
async function collectAnswer(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<QuestionAnswer> {
|
|
401
|
+
if (ctx.mode !== 'tui') return askViaDialogs(params, ctx, allOptions, multiSelect)
|
|
402
|
+
const offer = offerRemoteQuestion(params, allOptions, ctx, events, signal)
|
|
403
|
+
if (offer?.kind === 'settled') return offer.value
|
|
404
|
+
const remote = offer?.remote
|
|
405
|
+
try {
|
|
406
|
+
return await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs(), remote)
|
|
407
|
+
} finally {
|
|
408
|
+
remote?.close()
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function askOne(params: QuestionSpec, ctx: ExtensionContext, events?: ExtensionAPI['events'], signal?: AbortSignal): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> {
|
|
262
413
|
if (!ctx.hasUI) {
|
|
263
414
|
return {
|
|
264
415
|
content: [{ type: 'text', text: 'Error: UI not available (running in non-interactive mode)' }],
|
|
@@ -281,12 +432,7 @@ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ co
|
|
|
281
432
|
// The free-text option does not compose with checkbox selection, so it is single-select only.
|
|
282
433
|
const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
|
|
283
434
|
|
|
284
|
-
|
|
285
|
-
// undefined immediately, which would read as a cancel without ever asking. Ask
|
|
286
|
-
// through the dialog primitives there instead. askUserQuestionTimeout is a TUI
|
|
287
|
-
// concept (a countdown, a keypress resetting it): the dialog-primitive fallback
|
|
288
|
-
// has no keyboard or visible countdown to drive it, so it is not applied there.
|
|
289
|
-
const result = ctx.mode === 'tui' ? await askViaOverlay(params, ctx, allOptions, multiSelect, askUserQuestionTimeoutMs()) : await askViaDialogs(params, ctx, allOptions, multiSelect)
|
|
435
|
+
const result = await collectAnswer(params, ctx, allOptions, multiSelect, events, signal)
|
|
290
436
|
|
|
291
437
|
// Build simple options list for details; header/multiSelect appear only when set,
|
|
292
438
|
// so single-select details are unchanged.
|
|
@@ -345,7 +491,7 @@ const IDLE_TICK_MS = 250
|
|
|
345
491
|
* mechanics are testable directly, independent of where timeoutMs itself is read
|
|
346
492
|
* from (askUserQuestionTimeoutMs, tested separately).
|
|
347
493
|
*/
|
|
348
|
-
export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
|
|
494
|
+
export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOptions: DisplayOption[], multiSelect: boolean, timeoutMs?: number, remote?: RemoteQuestion): Promise<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null> {
|
|
349
495
|
return ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null) => void) => {
|
|
350
496
|
let optionIndex = 0
|
|
351
497
|
let editMode = false
|
|
@@ -368,6 +514,8 @@ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOp
|
|
|
368
514
|
* here, so the interval can never outlive the overlay it belongs to. */
|
|
369
515
|
function finish(value: { answer: string; wasCustom: boolean; index?: number; timedOut?: boolean } | null): void {
|
|
370
516
|
stopIdleTimer()
|
|
517
|
+
// Closed before done, so a remote reply racing a local answer is rejected.
|
|
518
|
+
remote?.close()
|
|
371
519
|
done(value)
|
|
372
520
|
}
|
|
373
521
|
|
|
@@ -376,6 +524,8 @@ export function askViaOverlay(params: QuestionSpec, ctx: ExtensionContext, allOp
|
|
|
376
524
|
deadline = Date.now() + timeoutMs
|
|
377
525
|
}
|
|
378
526
|
|
|
527
|
+
remote?.attach(finish, resetIdleTimer)
|
|
528
|
+
|
|
379
529
|
function fireTimeout(): void {
|
|
380
530
|
// Claude: "submits any options you'd already selected". Single-select has
|
|
381
531
|
// nothing pre-committed (a selection only exists once Enter confirms it), so
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|