@mjasnikovs/pi-task 0.14.11 → 0.14.12
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/dist/remote/bridge.d.ts +9 -6
- package/dist/remote/bridge.js +15 -18
- package/dist/task/auto-orchestrator.js +23 -18
- package/dist/task/phases.js +17 -17
- package/dist/task/question-box.d.ts +85 -0
- package/dist/task/question-box.js +166 -0
- package/package.json +1 -1
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -20,6 +20,9 @@ export declare function answerPrompt(id: string, value: string | undefined): voi
|
|
|
20
20
|
export interface AskSpec {
|
|
21
21
|
/** Themed, possibly multi-line title for the local TUI input. */
|
|
22
22
|
localTitle: string;
|
|
23
|
+
/** Themed (markdown-rendered) question shown as the boxed picker header.
|
|
24
|
+
* Falls back to `question` when absent. */
|
|
25
|
+
displayQuestion?: string;
|
|
23
26
|
/** Plain question text for the browser card. */
|
|
24
27
|
question: string;
|
|
25
28
|
/** Primary recommended option, prefilled in the local TUI. */
|
|
@@ -52,12 +55,12 @@ export declare class SessionUI {
|
|
|
52
55
|
/** Race the local input against a remote answer; first to settle wins. */
|
|
53
56
|
ask(spec: AskSpec): Promise<string | undefined>;
|
|
54
57
|
/**
|
|
55
|
-
* The local-TUI half of ask(). With `spec.options` it renders
|
|
56
|
-
* picker (each
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* undefined.
|
|
58
|
+
* The local-TUI half of ask(). With `spec.options` it renders the boxed
|
|
59
|
+
* picker (each answer in its own bounding box, the first/recommended one
|
|
60
|
+
* tinted green) plus a trailing "type a different answer" entry that drops to
|
|
61
|
+
* a text input; the chosen entry's `value` is returned. Without options it
|
|
62
|
+
* falls back to a single text input. Cancelling either dialog (or an abort
|
|
63
|
+
* when the remote wins the race) resolves to undefined.
|
|
61
64
|
*/
|
|
62
65
|
private askLocal;
|
|
63
66
|
}
|
package/dist/remote/bridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { broadcast as wsBroadcast } from './broadcast.js';
|
|
2
2
|
import { pushNotify } from './push.js';
|
|
3
3
|
import { setPrompt, clearPrompt } from './session-state.js';
|
|
4
|
+
import { askQuestionBox } from '../task/question-box.js';
|
|
4
5
|
const g = globalThis;
|
|
5
6
|
export function getBridge() {
|
|
6
7
|
if (!g.__piBridge) {
|
|
@@ -25,9 +26,6 @@ export function answerPrompt(id, value) {
|
|
|
25
26
|
b.pending.delete(id);
|
|
26
27
|
settle(value);
|
|
27
28
|
}
|
|
28
|
-
/** Trailing picker entry that drops to a free-text input — the local mirror of
|
|
29
|
-
* the remote card's "✎ Manual answer" button. */
|
|
30
|
-
const TYPE_OWN_LABEL = 'Type a different answer…';
|
|
31
29
|
/** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
|
|
32
30
|
export class SessionUI {
|
|
33
31
|
ctx;
|
|
@@ -83,27 +81,26 @@ export class SessionUI {
|
|
|
83
81
|
}
|
|
84
82
|
}
|
|
85
83
|
/**
|
|
86
|
-
* The local-TUI half of ask(). With `spec.options` it renders
|
|
87
|
-
* picker (each
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* undefined.
|
|
84
|
+
* The local-TUI half of ask(). With `spec.options` it renders the boxed
|
|
85
|
+
* picker (each answer in its own bounding box, the first/recommended one
|
|
86
|
+
* tinted green) plus a trailing "type a different answer" entry that drops to
|
|
87
|
+
* a text input; the chosen entry's `value` is returned. Without options it
|
|
88
|
+
* falls back to a single text input. Cancelling either dialog (or an abort
|
|
89
|
+
* when the remote wins the race) resolves to undefined.
|
|
92
90
|
*/
|
|
93
91
|
async askLocal(spec, signal) {
|
|
94
92
|
const opts = spec.options;
|
|
95
93
|
if (opts && opts.length > 0) {
|
|
96
|
-
|
|
97
|
-
|
|
94
|
+
return askQuestionBox(this.ctx, {
|
|
95
|
+
question: spec.displayQuestion ?? spec.question,
|
|
96
|
+
inputTitle: spec.localTitle,
|
|
97
|
+
options: opts.map((o, i) => ({
|
|
98
|
+
label: o.label,
|
|
99
|
+
value: o.value,
|
|
100
|
+
recommended: i === 0
|
|
101
|
+
})),
|
|
98
102
|
signal
|
|
99
103
|
});
|
|
100
|
-
if (choice === undefined)
|
|
101
|
-
return undefined; // cancelled / aborted
|
|
102
|
-
if (choice === TYPE_OWN_LABEL) {
|
|
103
|
-
return this.ctx.ui.input(spec.localTitle, undefined, { signal });
|
|
104
|
-
}
|
|
105
|
-
const hit = opts.find(o => o.label === choice);
|
|
106
|
-
return hit ? hit.value : choice;
|
|
107
104
|
}
|
|
108
105
|
return this.ctx.ui.input(spec.localTitle, spec.recommended, { signal });
|
|
109
106
|
}
|
|
@@ -246,30 +246,30 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
246
246
|
askedQuestions.push(plainQ);
|
|
247
247
|
const plainSuggested = suggested === undefined ? undefined : stripInlineMarkdown(suggested);
|
|
248
248
|
const plainAlt = alt === undefined ? undefined : stripInlineMarkdown(alt);
|
|
249
|
-
// Identical to /task's grill dialog: a
|
|
250
|
-
// picker locally — each
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// "press Enter to accept" scaffolding.
|
|
249
|
+
// Identical to /task's grill dialog: a recommendation (or A/B fork)
|
|
250
|
+
// becomes the boxed picker locally — each answer in its own bounding box,
|
|
251
|
+
// the recommended one tinted green; an open question shows the bare text
|
|
252
|
+
// prompt. No verbose "Recommended:" / "press Enter to accept" scaffolding.
|
|
254
253
|
const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
254
|
+
const options = twoOption ?
|
|
255
|
+
[
|
|
256
|
+
{
|
|
257
|
+
label: `A: ${renderInlineMarkdown(suggested, theme)}`,
|
|
258
|
+
value: plainSuggested
|
|
259
|
+
},
|
|
260
|
+
{ label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
|
|
261
|
+
]
|
|
262
|
+
: plainSuggested !== undefined ?
|
|
263
|
+
[{ label: renderInlineMarkdown(suggested, theme), value: plainSuggested }]
|
|
264
|
+
: undefined;
|
|
258
265
|
const a = await ui.ask({
|
|
259
|
-
localTitle:
|
|
266
|
+
localTitle: shownQ,
|
|
267
|
+
displayQuestion: shownQ,
|
|
260
268
|
question: plainQ,
|
|
261
269
|
recommended: plainSuggested,
|
|
262
270
|
...(plainAlt !== undefined && { recommended2: plainAlt }),
|
|
263
271
|
allowSkip: plainSuggested === undefined && plainAlt === undefined,
|
|
264
|
-
...(
|
|
265
|
-
options: [
|
|
266
|
-
{
|
|
267
|
-
label: `A: ${renderInlineMarkdown(suggested, theme)}`,
|
|
268
|
-
value: plainSuggested
|
|
269
|
-
},
|
|
270
|
-
{ label: `B: ${renderInlineMarkdown(alt, theme)}`, value: plainAlt }
|
|
271
|
-
]
|
|
272
|
-
})
|
|
272
|
+
...(options && { options })
|
|
273
273
|
});
|
|
274
274
|
if (a === undefined) {
|
|
275
275
|
announceDone(ctx, '/task-auto cancelled.', 'warning');
|
|
@@ -292,6 +292,11 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
292
292
|
else if (twoOption && /^b[.)]?$/i.test(typed)) {
|
|
293
293
|
answer = plainAlt;
|
|
294
294
|
}
|
|
295
|
+
else if (!twoOption && plainSuggested !== undefined && typed === plainSuggested) {
|
|
296
|
+
// Single recommendation accepted by picking its (green) card in the
|
|
297
|
+
// boxed picker — same provenance as an empty-submit accept.
|
|
298
|
+
answer = `${plainSuggested} (accepted recommendation)`;
|
|
299
|
+
}
|
|
295
300
|
else {
|
|
296
301
|
answer = typed;
|
|
297
302
|
}
|
package/dist/task/phases.js
CHANGED
|
@@ -517,30 +517,30 @@ export async function phaseGrill(deps, ctx, widgetState, refined, research) {
|
|
|
517
517
|
else {
|
|
518
518
|
const plainSuggested = auto.suggested === undefined ? undefined : stripInlineMarkdown(auto.suggested);
|
|
519
519
|
const plainAlt = auto.alt === undefined ? undefined : stripInlineMarkdown(auto.alt);
|
|
520
|
-
// A
|
|
521
|
-
// each
|
|
522
|
-
//
|
|
523
|
-
// question shows the bare prompt.
|
|
520
|
+
// A recommendation (or suggested+alt fork) becomes the boxed picker
|
|
521
|
+
// locally — each answer in its own bounding box, the recommended one
|
|
522
|
+
// tinted green; an open question shows the bare text prompt.
|
|
524
523
|
const twoOption = plainSuggested !== undefined && plainAlt !== undefined;
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
524
|
+
const options = twoOption ?
|
|
525
|
+
[
|
|
526
|
+
{
|
|
527
|
+
label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
|
|
528
|
+
value: plainSuggested
|
|
529
|
+
},
|
|
530
|
+
{ label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
|
|
531
|
+
]
|
|
532
|
+
: plainSuggested !== undefined ?
|
|
533
|
+
[{ label: renderInlineMarkdown(auto.suggested, theme), value: plainSuggested }]
|
|
534
|
+
: undefined;
|
|
528
535
|
widgetState.lastLine = `awaiting Q${n + 1}`;
|
|
529
536
|
const a = await ui.ask({
|
|
530
|
-
localTitle,
|
|
537
|
+
localTitle: shownQ,
|
|
538
|
+
displayQuestion: shownQ,
|
|
531
539
|
question: plainQ,
|
|
532
540
|
recommended: plainSuggested,
|
|
533
541
|
recommended2: plainAlt,
|
|
534
542
|
allowSkip: plainSuggested === undefined && plainAlt === undefined,
|
|
535
|
-
...(
|
|
536
|
-
options: [
|
|
537
|
-
{
|
|
538
|
-
label: `A: ${renderInlineMarkdown(auto.suggested, theme)}`,
|
|
539
|
-
value: plainSuggested
|
|
540
|
-
},
|
|
541
|
-
{ label: `B: ${renderInlineMarkdown(auto.alt, theme)}`, value: plainAlt }
|
|
542
|
-
]
|
|
543
|
-
})
|
|
543
|
+
...(options && { options })
|
|
544
544
|
});
|
|
545
545
|
if (a === undefined)
|
|
546
546
|
throw new Error(USER_CANCELLED);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boxed question dialog for the clarify / grill phases.
|
|
3
|
+
*
|
|
4
|
+
* The built-in `ctx.ui.select` renders every option as a bare line under one
|
|
5
|
+
* outer frame — the question, the recommendation, and the choices all read as a
|
|
6
|
+
* single blob. The remote (browser) prompt card instead gives each response its
|
|
7
|
+
* own bounded panel, tints the recommended one green, and separates everything
|
|
8
|
+
* with margins. This module mirrors that remote style in the terminal: a custom
|
|
9
|
+
* `ctx.ui.custom` component that draws each answer in its own bounding box with a
|
|
10
|
+
* green RECOMMENDED tag, an accent-highlighted cursor box, and clear vertical
|
|
11
|
+
* spacing.
|
|
12
|
+
*
|
|
13
|
+
* `renderQuestionBox` is the pure layout — width in, lines out — so the visual
|
|
14
|
+
* structure (borders, tag, cursor arrow, padding) is unit-testable without a
|
|
15
|
+
* live TUI. The component and `askQuestionBox` wire it to keyboard input and the
|
|
16
|
+
* "type a different answer" free-text fallback.
|
|
17
|
+
*/
|
|
18
|
+
import type { Component } from '@earendil-works/pi-tui';
|
|
19
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
20
|
+
/** One answer card. `label` may carry ANSI (rendered markdown); width math is
|
|
21
|
+
* ANSI-aware so the box borders still line up. */
|
|
22
|
+
export interface BoxCard {
|
|
23
|
+
label: string;
|
|
24
|
+
/** Recommended answer → green chrome + RECOMMENDED tag. */
|
|
25
|
+
recommended?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Chrome colourers, injected so tests can pass identity fns and assert on the
|
|
28
|
+
* plain box structure. A real theme is adapted by {@link boxColors}. */
|
|
29
|
+
export interface BoxColors {
|
|
30
|
+
border: (s: string) => string;
|
|
31
|
+
borderRecommended: (s: string) => string;
|
|
32
|
+
borderSelected: (s: string) => string;
|
|
33
|
+
recommendedTag: (s: string) => string;
|
|
34
|
+
questionLabel: (s: string) => string;
|
|
35
|
+
hint: (s: string) => string;
|
|
36
|
+
arrow: (s: string) => string;
|
|
37
|
+
}
|
|
38
|
+
type ThemeLike = ExtensionCommandContext['ui']['theme'];
|
|
39
|
+
export declare const QUESTION_LABEL = "QUESTION";
|
|
40
|
+
export declare const HINT_TEXT = "\u2191\u2193 navigate \u00B7 enter select \u00B7 esc cancel";
|
|
41
|
+
export declare const MANUAL_CARD_LABEL = "\u270E Type a different answer\u2026";
|
|
42
|
+
/**
|
|
43
|
+
* Lay the whole dialog out for `width` columns: a QUESTION label + wrapped
|
|
44
|
+
* question, one bounding box per card (recommended tinted green, the cursor box
|
|
45
|
+
* highlighted with an accent border and "▸" arrow), and a key-hint footer.
|
|
46
|
+
*/
|
|
47
|
+
export declare function renderQuestionBox(width: number, question: string, cards: BoxCard[], selected: number, c: BoxColors, hintText?: string): string[];
|
|
48
|
+
export declare function boxColors(theme: ThemeLike): BoxColors;
|
|
49
|
+
/** Focusable picker component; navigation mirrors the built-in select dialog. */
|
|
50
|
+
export declare class QuestionBoxComponent implements Component {
|
|
51
|
+
private readonly question;
|
|
52
|
+
private readonly cards;
|
|
53
|
+
private readonly colors;
|
|
54
|
+
private readonly onChoose;
|
|
55
|
+
private readonly onCancel;
|
|
56
|
+
private selected;
|
|
57
|
+
/** Card labels in display order (recommended first, manual last). Read-only
|
|
58
|
+
* view for tests that drive the picker without parsing rendered output. */
|
|
59
|
+
get cardLabels(): string[];
|
|
60
|
+
/** The question header text. Read-only view for tests. */
|
|
61
|
+
get headerText(): string;
|
|
62
|
+
constructor(question: string, cards: BoxCard[], colors: BoxColors, onChoose: (index: number) => void, onCancel: () => void);
|
|
63
|
+
render(width: number): string[];
|
|
64
|
+
invalidate(): void;
|
|
65
|
+
handleInput(data: string): void;
|
|
66
|
+
}
|
|
67
|
+
export interface BoxOption {
|
|
68
|
+
label: string;
|
|
69
|
+
value: string;
|
|
70
|
+
recommended?: boolean;
|
|
71
|
+
}
|
|
72
|
+
export interface AskQuestionBoxSpec {
|
|
73
|
+
/** Display question for the box header (may carry ANSI). */
|
|
74
|
+
question: string;
|
|
75
|
+
/** Plain title for the free-text fallback input dialog. */
|
|
76
|
+
inputTitle: string;
|
|
77
|
+
options: BoxOption[];
|
|
78
|
+
signal: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Show the boxed picker and resolve to the chosen option's `value`, the text the
|
|
82
|
+
* user typed via "type a different answer", or undefined when cancelled/aborted.
|
|
83
|
+
*/
|
|
84
|
+
export declare function askQuestionBox(ctx: ExtensionCommandContext, spec: AskQuestionBoxSpec): Promise<string | undefined>;
|
|
85
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boxed question dialog for the clarify / grill phases.
|
|
3
|
+
*
|
|
4
|
+
* The built-in `ctx.ui.select` renders every option as a bare line under one
|
|
5
|
+
* outer frame — the question, the recommendation, and the choices all read as a
|
|
6
|
+
* single blob. The remote (browser) prompt card instead gives each response its
|
|
7
|
+
* own bounded panel, tints the recommended one green, and separates everything
|
|
8
|
+
* with margins. This module mirrors that remote style in the terminal: a custom
|
|
9
|
+
* `ctx.ui.custom` component that draws each answer in its own bounding box with a
|
|
10
|
+
* green RECOMMENDED tag, an accent-highlighted cursor box, and clear vertical
|
|
11
|
+
* spacing.
|
|
12
|
+
*
|
|
13
|
+
* `renderQuestionBox` is the pure layout — width in, lines out — so the visual
|
|
14
|
+
* structure (borders, tag, cursor arrow, padding) is unit-testable without a
|
|
15
|
+
* live TUI. The component and `askQuestionBox` wire it to keyboard input and the
|
|
16
|
+
* "type a different answer" free-text fallback.
|
|
17
|
+
*/
|
|
18
|
+
import { getKeybindings, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
19
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
20
|
+
/** Left gutter: two columns reserved for the "▸ " cursor so boxes stay aligned. */
|
|
21
|
+
const GUTTER = 2;
|
|
22
|
+
const RECOMMENDED_TAG = 'RECOMMENDED';
|
|
23
|
+
/** Smallest box that still fits "┌─ RECOMMENDED ─┐". Below this we drop the tag. */
|
|
24
|
+
const TAG_MIN_BOX_WIDTH = visibleWidth(`┌─ ${RECOMMENDED_TAG} ─┐`);
|
|
25
|
+
export const QUESTION_LABEL = 'QUESTION';
|
|
26
|
+
export const HINT_TEXT = '↑↓ navigate · enter select · esc cancel';
|
|
27
|
+
export const MANUAL_CARD_LABEL = '✎ Type a different answer…';
|
|
28
|
+
// ─── Pure renderer ─────────────────────────────────────────────────────────────
|
|
29
|
+
function padTo(line, width) {
|
|
30
|
+
const pad = width - visibleWidth(line);
|
|
31
|
+
return pad > 0 ? line + ' '.repeat(pad) : line;
|
|
32
|
+
}
|
|
33
|
+
function renderCard(card, selected, boxWidth, c) {
|
|
34
|
+
const borderFn = selected ? c.borderSelected
|
|
35
|
+
: card.recommended ? c.borderRecommended
|
|
36
|
+
: c.border;
|
|
37
|
+
const innerWidth = Math.max(1, boxWidth - 4); // "│ " + content + " │"
|
|
38
|
+
const dash = (n) => '─'.repeat(Math.max(0, n));
|
|
39
|
+
// Top border, with a RECOMMENDED tag woven in when it fits.
|
|
40
|
+
let top;
|
|
41
|
+
if (card.recommended && boxWidth >= TAG_MIN_BOX_WIDTH) {
|
|
42
|
+
// TAG_MIN_BOX_WIDTH is the width at one trailing dash, so add it back.
|
|
43
|
+
const dashes = boxWidth - TAG_MIN_BOX_WIDTH + 1;
|
|
44
|
+
top = borderFn('┌─ ') + c.recommendedTag(RECOMMENDED_TAG) + borderFn(` ${dash(dashes)}┐`);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
top = borderFn(`┌${dash(boxWidth - 2)}┐`);
|
|
48
|
+
}
|
|
49
|
+
const bodyLines = wrapTextWithAnsi(card.label, innerWidth);
|
|
50
|
+
const body = (bodyLines.length > 0 ? bodyLines : ['']).map(line => borderFn('│ ') + padTo(line, innerWidth) + borderFn(' │'));
|
|
51
|
+
const bottom = borderFn(`└${dash(boxWidth - 2)}┘`);
|
|
52
|
+
const arrow = selected ? c.arrow('▸') + ' ' : ' ';
|
|
53
|
+
const pad = ' ';
|
|
54
|
+
return [arrow + top, ...body.map(b => pad + b), pad + bottom];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Lay the whole dialog out for `width` columns: a QUESTION label + wrapped
|
|
58
|
+
* question, one bounding box per card (recommended tinted green, the cursor box
|
|
59
|
+
* highlighted with an accent border and "▸" arrow), and a key-hint footer.
|
|
60
|
+
*/
|
|
61
|
+
export function renderQuestionBox(width, question, cards, selected, c, hintText = HINT_TEXT) {
|
|
62
|
+
const W = Math.max(width, 12);
|
|
63
|
+
const boxWidth = Math.max(6, W - GUTTER);
|
|
64
|
+
const out = [''];
|
|
65
|
+
out.push(' ' + c.questionLabel(QUESTION_LABEL));
|
|
66
|
+
for (const line of wrapTextWithAnsi(question, W - GUTTER))
|
|
67
|
+
out.push(' ' + line);
|
|
68
|
+
out.push('');
|
|
69
|
+
for (let i = 0; i < cards.length; i++) {
|
|
70
|
+
out.push(...renderCard(cards[i], i === selected, boxWidth, c));
|
|
71
|
+
out.push('');
|
|
72
|
+
}
|
|
73
|
+
for (const line of wrapTextWithAnsi(hintText, W - GUTTER))
|
|
74
|
+
out.push(' ' + c.hint(line));
|
|
75
|
+
out.push('');
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
// ─── Theme adapter ─────────────────────────────────────────────────────────────
|
|
79
|
+
export function boxColors(theme) {
|
|
80
|
+
return {
|
|
81
|
+
border: s => theme.fg('borderMuted', s),
|
|
82
|
+
borderRecommended: s => theme.fg('success', s),
|
|
83
|
+
borderSelected: s => theme.fg('accent', s),
|
|
84
|
+
recommendedTag: s => theme.fg('success', theme.bold(s)),
|
|
85
|
+
questionLabel: s => theme.fg('accent', theme.bold(s)),
|
|
86
|
+
hint: s => theme.fg('muted', s),
|
|
87
|
+
arrow: s => theme.fg('accent', theme.bold(s))
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// ─── Component ─────────────────────────────────────────────────────────────────
|
|
91
|
+
/** Focusable picker component; navigation mirrors the built-in select dialog. */
|
|
92
|
+
export class QuestionBoxComponent {
|
|
93
|
+
question;
|
|
94
|
+
cards;
|
|
95
|
+
colors;
|
|
96
|
+
onChoose;
|
|
97
|
+
onCancel;
|
|
98
|
+
selected = 0;
|
|
99
|
+
/** Card labels in display order (recommended first, manual last). Read-only
|
|
100
|
+
* view for tests that drive the picker without parsing rendered output. */
|
|
101
|
+
get cardLabels() {
|
|
102
|
+
return this.cards.map(c => c.label);
|
|
103
|
+
}
|
|
104
|
+
/** The question header text. Read-only view for tests. */
|
|
105
|
+
get headerText() {
|
|
106
|
+
return this.question;
|
|
107
|
+
}
|
|
108
|
+
constructor(question, cards, colors, onChoose, onCancel) {
|
|
109
|
+
this.question = question;
|
|
110
|
+
this.cards = cards;
|
|
111
|
+
this.colors = colors;
|
|
112
|
+
this.onChoose = onChoose;
|
|
113
|
+
this.onCancel = onCancel;
|
|
114
|
+
}
|
|
115
|
+
render(width) {
|
|
116
|
+
return renderQuestionBox(width, this.question, this.cards, this.selected, this.colors);
|
|
117
|
+
}
|
|
118
|
+
invalidate() { }
|
|
119
|
+
handleInput(data) {
|
|
120
|
+
const kb = getKeybindings();
|
|
121
|
+
if (kb.matches(data, 'tui.select.up') || data === 'k') {
|
|
122
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
123
|
+
}
|
|
124
|
+
else if (kb.matches(data, 'tui.select.down') || data === 'j') {
|
|
125
|
+
this.selected = Math.min(this.cards.length - 1, this.selected + 1);
|
|
126
|
+
}
|
|
127
|
+
else if (kb.matches(data, 'tui.select.confirm') || data === '\n' || data === '\r') {
|
|
128
|
+
this.onChoose(this.selected);
|
|
129
|
+
}
|
|
130
|
+
else if (kb.matches(data, 'tui.select.cancel')) {
|
|
131
|
+
this.onCancel();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Show the boxed picker and resolve to the chosen option's `value`, the text the
|
|
137
|
+
* user typed via "type a different answer", or undefined when cancelled/aborted.
|
|
138
|
+
*/
|
|
139
|
+
export async function askQuestionBox(ctx, spec) {
|
|
140
|
+
const { question, options, inputTitle, signal } = spec;
|
|
141
|
+
const cards = [
|
|
142
|
+
...options.map(o => ({ label: o.label, recommended: o.recommended })),
|
|
143
|
+
{ label: MANUAL_CARD_LABEL }
|
|
144
|
+
];
|
|
145
|
+
const colors = boxColors(ctx.ui.theme);
|
|
146
|
+
const manualIndex = options.length;
|
|
147
|
+
const choice = await ctx.ui.custom((_tui, _theme, _kb, done) => {
|
|
148
|
+
if (signal.aborted) {
|
|
149
|
+
done(undefined);
|
|
150
|
+
return new QuestionBoxComponent(question, cards, colors, () => { }, () => { });
|
|
151
|
+
}
|
|
152
|
+
const onAbort = () => done(undefined);
|
|
153
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
154
|
+
const finish = (result) => {
|
|
155
|
+
signal.removeEventListener('abort', onAbort);
|
|
156
|
+
done(result);
|
|
157
|
+
};
|
|
158
|
+
return new QuestionBoxComponent(question, cards, colors, index => finish(index), () => finish(undefined));
|
|
159
|
+
});
|
|
160
|
+
if (choice === undefined)
|
|
161
|
+
return undefined;
|
|
162
|
+
if (choice === manualIndex) {
|
|
163
|
+
return ctx.ui.input(inputTitle, undefined, { signal });
|
|
164
|
+
}
|
|
165
|
+
return options[choice]?.value;
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.12",
|
|
4
4
|
"description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|