@xynogen/pix-ask 0.2.6 → 0.2.9
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/package.json +5 -3
- package/src/components.ts +1 -4
- package/src/glyphs.ts +5 -7
- package/src/helpers.ts +4 -13
- package/src/index.ts +8 -26
- package/src/questionnaire.ts +44 -88
- package/src/rpc.ts +2 -5
- package/src/schema.ts +3 -6
- package/src/ask.test.ts +0 -243
- package/src/frame.test.ts +0 -39
- package/src/frame.ts +0 -73
- package/src/glyphs.test.ts +0 -48
- package/src/once.ts +0 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-ask",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "Pi tool — structured questionnaire UI (ask_user)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
|
+
"!src/**/*.test.ts",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
@@ -34,8 +35,9 @@
|
|
|
34
35
|
"access": "public"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
|
-
"
|
|
38
|
-
"@xynogen/pix-
|
|
38
|
+
"@xynogen/pix-pretty": "^1.7.9",
|
|
39
|
+
"@xynogen/pix-runtime": "^0.1.1",
|
|
40
|
+
"typebox": "^1.1.38"
|
|
39
41
|
},
|
|
40
42
|
"peerDependencies": {
|
|
41
43
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/components.ts
CHANGED
|
@@ -43,10 +43,7 @@ export class TabBar implements Component {
|
|
|
43
43
|
truncateToWidth(
|
|
44
44
|
t.fg("accent", "╭─") +
|
|
45
45
|
line +
|
|
46
|
-
t.fg(
|
|
47
|
-
"accent",
|
|
48
|
-
`${"─".repeat(Math.max(0, inner - line.length - 1))}╮`,
|
|
49
|
-
),
|
|
46
|
+
t.fg("accent", `${"─".repeat(Math.max(0, inner - line.length - 1))}╮`),
|
|
50
47
|
width,
|
|
51
48
|
"",
|
|
52
49
|
),
|
package/src/glyphs.ts
CHANGED
|
@@ -6,11 +6,10 @@ const RADIO_SELECTED = "◉";
|
|
|
6
6
|
const RADIO_UNSELECTED = "○";
|
|
7
7
|
|
|
8
8
|
/** Glyph + theme color token for a selection row. Caller applies theme.fg(color, glyph). */
|
|
9
|
-
export function selectionGlyph(opts: {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}): { glyph: string; color: string } {
|
|
9
|
+
export function selectionGlyph(opts: { multi: boolean; selected: boolean; checked: boolean }): {
|
|
10
|
+
glyph: string;
|
|
11
|
+
color: string;
|
|
12
|
+
} {
|
|
14
13
|
if (opts.multi) {
|
|
15
14
|
return opts.checked
|
|
16
15
|
? { glyph: CHECKBOX_CHECKED, color: "success" }
|
|
@@ -28,8 +27,7 @@ export function selectionGlyph(opts: {
|
|
|
28
27
|
*/
|
|
29
28
|
export function checkboxGlyphs(): { checked: string; unchecked: string } {
|
|
30
29
|
const unicodeSafe =
|
|
31
|
-
visibleWidth(CHECKBOX_CHECKED) === 1 &&
|
|
32
|
-
visibleWidth(CHECKBOX_UNCHECKED) === 1;
|
|
30
|
+
visibleWidth(CHECKBOX_CHECKED) === 1 && visibleWidth(CHECKBOX_UNCHECKED) === 1;
|
|
33
31
|
return unicodeSafe
|
|
34
32
|
? { checked: CHECKBOX_CHECKED, unchecked: CHECKBOX_UNCHECKED }
|
|
35
33
|
: { checked: "[x]", unchecked: "[ ]" };
|
package/src/helpers.ts
CHANGED
|
@@ -20,15 +20,11 @@ export function safeMarkdownTheme(): MarkdownTheme | undefined {
|
|
|
20
20
|
// ── Option / question helpers ──────────────────────────────────────────
|
|
21
21
|
|
|
22
22
|
export function hasAnyPreview(q: QuestionData): boolean {
|
|
23
|
-
return q.options.some(
|
|
24
|
-
(o) => typeof o.preview === "string" && o.preview.length > 0,
|
|
25
|
-
);
|
|
23
|
+
return q.options.some((o) => typeof o.preview === "string" && o.preview.length > 0);
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
/** Which sentinel rows are auto-appended for a question. */
|
|
29
|
-
export function sentinelsFor(
|
|
30
|
-
q: QuestionData,
|
|
31
|
-
): Array<{ kind: string; label: string }> {
|
|
27
|
+
export function sentinelsFor(q: QuestionData): Array<{ kind: string; label: string }> {
|
|
32
28
|
const out: Array<{ kind: string; label: string }> = [];
|
|
33
29
|
if (q.multiSelect) {
|
|
34
30
|
out.push({ kind: "next", label: SENTINEL_NEXT });
|
|
@@ -47,10 +43,7 @@ export function formatAnswerScalar(a: QuestionAnswer): string {
|
|
|
47
43
|
return a.answer ?? "(selected)";
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
export function buildResponseText(
|
|
51
|
-
answers: QuestionAnswer[],
|
|
52
|
-
questions: QuestionData[],
|
|
53
|
-
): string {
|
|
46
|
+
export function buildResponseText(answers: QuestionAnswer[], questions: QuestionData[]): string {
|
|
54
47
|
const segs: string[] = [];
|
|
55
48
|
for (const a of answers) {
|
|
56
49
|
const q = questions[a.questionIndex]?.question ?? `Q${a.questionIndex + 1}`;
|
|
@@ -58,9 +51,7 @@ export function buildResponseText(
|
|
|
58
51
|
if (a.preview) s += `. selected preview: ${a.preview}`;
|
|
59
52
|
segs.push(s);
|
|
60
53
|
}
|
|
61
|
-
return segs.length
|
|
62
|
-
? `User answered: ${segs.join(". ")}.`
|
|
63
|
-
: "User declined to answer questions.";
|
|
54
|
+
return segs.length ? `User answered: ${segs.join(". ")}.` : "User declined to answer questions.";
|
|
64
55
|
}
|
|
65
56
|
|
|
66
57
|
// ── Scroll indicator ───────────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
-
|
|
3
|
+
import { once } from "@xynogen/pix-runtime/once";
|
|
4
4
|
import { buildResponseText } from "./helpers.js";
|
|
5
|
-
import { once } from "./once.ts";
|
|
6
5
|
import { AskQuestionnaire } from "./questionnaire.js";
|
|
7
6
|
import { rpcFallback } from "./rpc.js";
|
|
8
7
|
import type { Params } from "./schema.js";
|
|
9
|
-
import {
|
|
10
|
-
MAX_OPTIONS,
|
|
11
|
-
MAX_QUESTIONS,
|
|
12
|
-
MIN_OPTIONS,
|
|
13
|
-
ParamsSchema,
|
|
14
|
-
SENTINEL_FREEFORM,
|
|
15
|
-
} from "./schema.js";
|
|
8
|
+
import { MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, ParamsSchema } from "./schema.js";
|
|
16
9
|
import type { QuestionAnswer, QuestionnaireResult } from "./types.js";
|
|
17
10
|
|
|
18
11
|
// ── Re-exports (consumed by tests and single-select-layout) ───────────
|
|
@@ -41,8 +34,6 @@ export default function registerAsk(pi: ExtensionAPI): void {
|
|
|
41
34
|
promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
|
|
42
35
|
promptGuidelines: [
|
|
43
36
|
`Use ask whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
|
|
44
|
-
`Each question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer ("${SENTINEL_FREEFORM}" row is appended automatically to single-select questions).`,
|
|
45
|
-
`Set multiSelect: true when multiple answers are valid; this suppresses the "${SENTINEL_FREEFORM}" row. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. NOTE: any non-empty preview on a single-select question ALSO suppresses the "${SENTINEL_FREEFORM}" row (no room in the side-by-side layout). If you recommend a specific option, make it the first option and append "(Recommended)" to its label.`,
|
|
46
37
|
"Do not stack multiple ask calls back-to-back — group all clarifying questions into one invocation.",
|
|
47
38
|
],
|
|
48
39
|
executionMode: "sequential",
|
|
@@ -60,9 +51,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
|
|
|
60
51
|
|
|
61
52
|
if (!Array.isArray(typed.questions) || typed.questions.length === 0) {
|
|
62
53
|
return {
|
|
63
|
-
content: [
|
|
64
|
-
{ type: "text", text: "At least one question is required." },
|
|
65
|
-
],
|
|
54
|
+
content: [{ type: "text", text: "At least one question is required." }],
|
|
66
55
|
isError: true,
|
|
67
56
|
details: { answers: [], cancelled: true },
|
|
68
57
|
};
|
|
@@ -79,11 +68,9 @@ export default function registerAsk(pi: ExtensionAPI): void {
|
|
|
79
68
|
const result = await ctx.ui.custom<QuestionnaireResult | null>(
|
|
80
69
|
(tui, theme, keybindings, done) => {
|
|
81
70
|
if (signal) {
|
|
82
|
-
signal.addEventListener(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{ once: true },
|
|
86
|
-
);
|
|
71
|
+
signal.addEventListener("abort", () => done({ answers: [], cancelled: true }), {
|
|
72
|
+
once: true,
|
|
73
|
+
});
|
|
87
74
|
}
|
|
88
75
|
return new AskQuestionnaire(typed, tui, theme, keybindings, done);
|
|
89
76
|
},
|
|
@@ -92,9 +79,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
|
|
|
92
79
|
|
|
93
80
|
if (!result || result.cancelled) {
|
|
94
81
|
return {
|
|
95
|
-
content: [
|
|
96
|
-
{ type: "text", text: "User cancelled the questionnaire" },
|
|
97
|
-
],
|
|
82
|
+
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
|
98
83
|
details: result ?? { answers: [], cancelled: true },
|
|
99
84
|
};
|
|
100
85
|
}
|
|
@@ -124,10 +109,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
|
|
|
124
109
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
125
110
|
}
|
|
126
111
|
const texts = details.answers.map((a) => {
|
|
127
|
-
const v =
|
|
128
|
-
a.kind === "multi"
|
|
129
|
-
? (a.selected ?? []).join(", ")
|
|
130
|
-
: (a.answer ?? "");
|
|
112
|
+
const v = a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? "");
|
|
131
113
|
return `${a.questionIndex + 1}: ${v}`;
|
|
132
114
|
});
|
|
133
115
|
return new Text(theme.fg("success", `✓ ${texts.join(" • ")}`), 0, 0);
|
package/src/questionnaire.ts
CHANGED
|
@@ -12,25 +12,32 @@ import {
|
|
|
12
12
|
truncateToWidth,
|
|
13
13
|
wrapTextWithAnsi,
|
|
14
14
|
} from "@earendil-works/pi-tui";
|
|
15
|
+
import { frameLines, modalWidth } from "@xynogen/pix-pretty/modal-frame";
|
|
15
16
|
import { dim } from "./components.js";
|
|
16
|
-
import { frameLines, modalWidth } from "./frame.js";
|
|
17
17
|
import { checkboxGlyphs, selectionGlyph } from "./glyphs.js";
|
|
18
18
|
import { safeMarkdownTheme, sentinelsFor } from "./helpers.js";
|
|
19
19
|
import type { OptionData, Params, QuestionData } from "./schema.js";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
SENTINEL_NEXT,
|
|
23
|
-
SEPARATOR,
|
|
24
|
-
SPLIT_PANE_MIN_WIDTH,
|
|
25
|
-
} from "./schema.js";
|
|
26
|
-
import type {
|
|
27
|
-
AnswerKind,
|
|
28
|
-
QuestionAnswer,
|
|
29
|
-
QuestionnaireResult,
|
|
30
|
-
} from "./types.js";
|
|
20
|
+
import { SENTINEL_FREEFORM, SENTINEL_NEXT, SEPARATOR, SPLIT_PANE_MIN_WIDTH } from "./schema.js";
|
|
21
|
+
import type { AnswerKind, QuestionAnswer, QuestionnaireResult } from "./types.js";
|
|
31
22
|
|
|
32
23
|
// ── AskQuestionnaire ───────────────────────────────────────────────────
|
|
33
24
|
|
|
25
|
+
function printableCharacter(data: string): string | undefined {
|
|
26
|
+
const decoded = decodeKittyPrintable(data) ?? data;
|
|
27
|
+
const characters = [...decoded];
|
|
28
|
+
if (characters.length !== 1) return undefined;
|
|
29
|
+
const codePoint = characters[0]?.codePointAt(0);
|
|
30
|
+
if (
|
|
31
|
+
codePoint === undefined ||
|
|
32
|
+
codePoint < 32 ||
|
|
33
|
+
codePoint === 127 ||
|
|
34
|
+
(codePoint >= 128 && codePoint <= 159)
|
|
35
|
+
) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return characters[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
export class AskQuestionnaire extends Container {
|
|
35
42
|
private params: Params;
|
|
36
43
|
private tui: TUI;
|
|
@@ -84,8 +91,7 @@ export class AskQuestionnaire extends Container {
|
|
|
84
91
|
label?: string;
|
|
85
92
|
option?: OptionData;
|
|
86
93
|
}> {
|
|
87
|
-
const items: Array<{ kind: string; label?: string; option?: OptionData }> =
|
|
88
|
-
[];
|
|
94
|
+
const items: Array<{ kind: string; label?: string; option?: OptionData }> = [];
|
|
89
95
|
for (const o of this.filteredOptions) {
|
|
90
96
|
items.push({ kind: "option", option: o });
|
|
91
97
|
}
|
|
@@ -137,9 +143,7 @@ export class AskQuestionnaire extends Container {
|
|
|
137
143
|
selected?: string[],
|
|
138
144
|
preview?: string,
|
|
139
145
|
): void {
|
|
140
|
-
this.answers = this.answers.filter(
|
|
141
|
-
(a) => a.questionIndex !== this.currentIndex,
|
|
142
|
-
);
|
|
146
|
+
this.answers = this.answers.filter((a) => a.questionIndex !== this.currentIndex);
|
|
143
147
|
this.answers.push({
|
|
144
148
|
questionIndex: this.currentIndex,
|
|
145
149
|
question: this.currentQ.question,
|
|
@@ -158,12 +162,7 @@ export class AskQuestionnaire extends Container {
|
|
|
158
162
|
}
|
|
159
163
|
|
|
160
164
|
if (item.kind === "option" && item.option) {
|
|
161
|
-
this.recordAnswer(
|
|
162
|
-
"option",
|
|
163
|
-
item.option.label,
|
|
164
|
-
undefined,
|
|
165
|
-
item.option.preview,
|
|
166
|
-
);
|
|
165
|
+
this.recordAnswer("option", item.option.label, undefined, item.option.preview);
|
|
167
166
|
this.nextQuestion();
|
|
168
167
|
} else if (item.kind === "other") {
|
|
169
168
|
this.inputMode = true;
|
|
@@ -204,9 +203,7 @@ export class AskQuestionnaire extends Container {
|
|
|
204
203
|
}
|
|
205
204
|
|
|
206
205
|
private restoreAnswerState(): void {
|
|
207
|
-
const prev = this.answers.find(
|
|
208
|
-
(a) => a.questionIndex === this.currentIndex,
|
|
209
|
-
);
|
|
206
|
+
const prev = this.answers.find((a) => a.questionIndex === this.currentIndex);
|
|
210
207
|
if (!prev) return;
|
|
211
208
|
const q = this.currentQ;
|
|
212
209
|
if (prev.kind === "multi") {
|
|
@@ -287,8 +284,7 @@ export class AskQuestionnaire extends Container {
|
|
|
287
284
|
matchesKey(data, Key.ctrl("k"))
|
|
288
285
|
) {
|
|
289
286
|
if (total > 0) {
|
|
290
|
-
this.selectedOptionIndex =
|
|
291
|
-
(this.selectedOptionIndex - 1 + total) % total;
|
|
287
|
+
this.selectedOptionIndex = (this.selectedOptionIndex - 1 + total) % total;
|
|
292
288
|
this.refresh();
|
|
293
289
|
}
|
|
294
290
|
return;
|
|
@@ -347,7 +343,10 @@ export class AskQuestionnaire extends Container {
|
|
|
347
343
|
return;
|
|
348
344
|
}
|
|
349
345
|
|
|
350
|
-
|
|
346
|
+
// Decode CSI-u first: under Kitty flag 1 digits arrive as escape
|
|
347
|
+
// sequences, and the raw regex would miss them (digit would fall
|
|
348
|
+
// through into the search query instead of selecting an option).
|
|
349
|
+
const numMatch = (decodeKittyPrintable(data) ?? data).match(/^[1-9]$/);
|
|
351
350
|
if (numMatch && this.filteredOptions.length > 0) {
|
|
352
351
|
const idx = Number(numMatch[0]) - 1;
|
|
353
352
|
if (idx >= 0 && idx < this.filteredOptions.length) {
|
|
@@ -371,23 +370,13 @@ export class AskQuestionnaire extends Container {
|
|
|
371
370
|
}
|
|
372
371
|
|
|
373
372
|
if (!isMulti) {
|
|
374
|
-
|
|
373
|
+
// Accept Unicode text under both legacy and Kitty encodings, while
|
|
374
|
+
// rejecting C0, DEL, and C1 controls that corrupt filter queries.
|
|
375
|
+
const printable = printableCharacter(data);
|
|
375
376
|
if (printable !== undefined) {
|
|
376
377
|
this.searchQuery += printable;
|
|
377
378
|
this.selectedOptionIndex = 0;
|
|
378
379
|
this.refresh();
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
const chars = [...data];
|
|
382
|
-
if (
|
|
383
|
-
chars.length === 1 &&
|
|
384
|
-
chars[0] &&
|
|
385
|
-
chars[0].charCodeAt(0) >= 32 &&
|
|
386
|
-
chars[0].charCodeAt(0) < 127
|
|
387
|
-
) {
|
|
388
|
-
this.searchQuery += chars[0];
|
|
389
|
-
this.selectedOptionIndex = 0;
|
|
390
|
-
this.refresh();
|
|
391
380
|
}
|
|
392
381
|
}
|
|
393
382
|
}
|
|
@@ -423,10 +412,7 @@ export class AskQuestionnaire extends Container {
|
|
|
423
412
|
const maxVisible = Math.min(total, 12);
|
|
424
413
|
const start = Math.max(
|
|
425
414
|
0,
|
|
426
|
-
Math.min(
|
|
427
|
-
this.selectedOptionIndex - Math.floor(maxVisible / 2),
|
|
428
|
-
total - maxVisible,
|
|
429
|
-
),
|
|
415
|
+
Math.min(this.selectedOptionIndex - Math.floor(maxVisible / 2), total - maxVisible),
|
|
430
416
|
);
|
|
431
417
|
const end = Math.min(start + maxVisible, total);
|
|
432
418
|
|
|
@@ -462,24 +448,18 @@ export class AskQuestionnaire extends Container {
|
|
|
462
448
|
const label = sel
|
|
463
449
|
? t.fg("accent", t.bold(SENTINEL_FREEFORM))
|
|
464
450
|
: t.fg("text", t.bold(SENTINEL_FREEFORM));
|
|
465
|
-
lines.push(
|
|
466
|
-
truncateToWidth(`${ptr} ${t.fg("dim", "✎")} ${label}`, inner, ""),
|
|
467
|
-
);
|
|
451
|
+
lines.push(truncateToWidth(`${ptr} ${t.fg("dim", "✎")} ${label}`, inner, ""));
|
|
468
452
|
} else if (item.kind === "next") {
|
|
469
453
|
const label = sel
|
|
470
454
|
? t.fg("accent", t.bold(SENTINEL_NEXT))
|
|
471
455
|
: t.fg("text", t.bold(SENTINEL_NEXT));
|
|
472
|
-
lines.push(
|
|
473
|
-
truncateToWidth(`${ptr} ${t.fg("dim", "→")} ${label}`, inner, ""),
|
|
474
|
-
);
|
|
456
|
+
lines.push(truncateToWidth(`${ptr} ${t.fg("dim", "→")} ${label}`, inner, ""));
|
|
475
457
|
}
|
|
476
458
|
}
|
|
477
459
|
|
|
478
460
|
if (start > 0 || end < total) {
|
|
479
461
|
const count =
|
|
480
|
-
this.filteredOptions.length > 0
|
|
481
|
-
? `${this.selectedOptionIndex + 1}/${total}`
|
|
482
|
-
: `${total}`;
|
|
462
|
+
this.filteredOptions.length > 0 ? `${this.selectedOptionIndex + 1}/${total}` : `${total}`;
|
|
483
463
|
lines.push(t.fg("dim", truncateToWidth(` ${count}`, inner, "")));
|
|
484
464
|
}
|
|
485
465
|
|
|
@@ -496,19 +476,12 @@ export class AskQuestionnaire extends Container {
|
|
|
496
476
|
const mdWidth = Math.max(10, width);
|
|
497
477
|
|
|
498
478
|
if (this.mdTheme) {
|
|
499
|
-
const md = new Markdown(
|
|
500
|
-
`## ${item.option.label}\n\n${mdText}`,
|
|
501
|
-
0,
|
|
502
|
-
0,
|
|
503
|
-
this.mdTheme,
|
|
504
|
-
);
|
|
479
|
+
const md = new Markdown(`## ${item.option.label}\n\n${mdText}`, 0, 0, this.mdTheme);
|
|
505
480
|
return md.render(mdWidth);
|
|
506
481
|
}
|
|
507
482
|
|
|
508
483
|
const lines = wrapTextWithAnsi(mdText, mdWidth);
|
|
509
|
-
return lines.map((l) =>
|
|
510
|
-
truncateToWidth(this.theme.fg("muted", l), mdWidth, ""),
|
|
511
|
-
);
|
|
484
|
+
return lines.map((l) => truncateToWidth(this.theme.fg("muted", l), mdWidth, ""));
|
|
512
485
|
}
|
|
513
486
|
|
|
514
487
|
override render(termWidth: number): string[] {
|
|
@@ -520,9 +493,7 @@ export class AskQuestionnaire extends Container {
|
|
|
520
493
|
const t = this.theme;
|
|
521
494
|
const isMulti = !!this.currentQ.multiSelect;
|
|
522
495
|
const hasPreview =
|
|
523
|
-
!isMulti &&
|
|
524
|
-
this.selectedItem?.kind === "option" &&
|
|
525
|
-
!!this.selectedItem?.option?.preview;
|
|
496
|
+
!isMulti && this.selectedItem?.kind === "option" && !!this.selectedItem?.option?.preview;
|
|
526
497
|
|
|
527
498
|
const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
|
|
528
499
|
const leftWidth = useSplit ? Math.floor((width - 2) * 0.45) : inner;
|
|
@@ -530,8 +501,7 @@ export class AskQuestionnaire extends Container {
|
|
|
530
501
|
|
|
531
502
|
const lines: string[] = [];
|
|
532
503
|
|
|
533
|
-
const row = (content: string): string =>
|
|
534
|
-
truncateToWidth(content, width, "");
|
|
504
|
+
const row = (content: string): string => truncateToWidth(content, width, "");
|
|
535
505
|
|
|
536
506
|
// Tab bar — rendered as the framed top edge (frameLines `top`).
|
|
537
507
|
let top: string | undefined;
|
|
@@ -554,10 +524,7 @@ export class AskQuestionnaire extends Container {
|
|
|
554
524
|
lines.push(row(`${chip}${prog}`));
|
|
555
525
|
|
|
556
526
|
// Question text
|
|
557
|
-
for (const w of wrapTextWithAnsi(
|
|
558
|
-
this.currentQ.question,
|
|
559
|
-
Math.max(10, inner),
|
|
560
|
-
)) {
|
|
527
|
+
for (const w of wrapTextWithAnsi(this.currentQ.question, Math.max(10, inner))) {
|
|
561
528
|
lines.push(row(t.fg("text", t.bold(w))));
|
|
562
529
|
}
|
|
563
530
|
|
|
@@ -601,28 +568,17 @@ export class AskQuestionnaire extends Container {
|
|
|
601
568
|
}
|
|
602
569
|
|
|
603
570
|
// Footer hints
|
|
604
|
-
const navHint =
|
|
605
|
-
this.params.questions.length > 1 ? "↑↓ nav • ←→ question" : "↑↓ nav";
|
|
571
|
+
const navHint = this.params.questions.length > 1 ? "↑↓ nav • ←→ question" : "↑↓ nav";
|
|
606
572
|
const hintParts = isMulti
|
|
607
|
-
? [
|
|
608
|
-
|
|
609
|
-
"ctrl+c cancel",
|
|
610
|
-
]
|
|
611
|
-
: [
|
|
612
|
-
`${navHint} • type filter • enter select • esc clear`,
|
|
613
|
-
"ctrl+c cancel",
|
|
614
|
-
];
|
|
573
|
+
? [`${navHint} • space toggle • enter commit • esc clear`, "ctrl+c cancel"]
|
|
574
|
+
: [`${navHint} • type filter • enter select • esc clear`, "ctrl+c cancel"];
|
|
615
575
|
lines.push(row(dim(t)(hintParts.join(" • "))));
|
|
616
576
|
|
|
617
577
|
return this.frame(mw, lines, top);
|
|
618
578
|
}
|
|
619
579
|
|
|
620
580
|
/** Wrap body lines in the rounded modal border at the given outer width. */
|
|
621
|
-
private frame(
|
|
622
|
-
outerWidth: number,
|
|
623
|
-
lines: string[],
|
|
624
|
-
top: string | undefined,
|
|
625
|
-
): string[] {
|
|
581
|
+
private frame(outerWidth: number, lines: string[], top: string | undefined): string[] {
|
|
626
582
|
const t = this.theme;
|
|
627
583
|
return frameLines({
|
|
628
584
|
width: outerWidth,
|
package/src/rpc.ts
CHANGED
|
@@ -21,9 +21,7 @@ export async function rpcFallback(
|
|
|
21
21
|
const header = q.header;
|
|
22
22
|
|
|
23
23
|
if (q.multiSelect) {
|
|
24
|
-
const lines = q.options.map(
|
|
25
|
-
(o, idx) => `${idx + 1}. ${o.label} — ${o.description}`,
|
|
26
|
-
);
|
|
24
|
+
const lines = q.options.map((o, idx) => `${idx + 1}. ${o.label} — ${o.description}`);
|
|
27
25
|
const raw = await ui.input(
|
|
28
26
|
`${header}: ${q.question}\n\n${lines.join("\n")}\n\nEnter numbers separated by commas:`,
|
|
29
27
|
"e.g. 1,3",
|
|
@@ -71,8 +69,7 @@ export async function rpcFallback(
|
|
|
71
69
|
});
|
|
72
70
|
} else {
|
|
73
71
|
const opt = q.options.find(
|
|
74
|
-
(o) =>
|
|
75
|
-
chosen === o.label || `${o.label} — ${o.description}` === chosen,
|
|
72
|
+
(o) => chosen === o.label || `${o.label} — ${o.description}` === chosen,
|
|
76
73
|
);
|
|
77
74
|
answers.push({
|
|
78
75
|
questionIndex: i,
|
package/src/schema.ts
CHANGED
|
@@ -27,8 +27,7 @@ export const OptionSchema = Type.Object({
|
|
|
27
27
|
}),
|
|
28
28
|
preview: Type.Optional(
|
|
29
29
|
Type.String({
|
|
30
|
-
description:
|
|
31
|
-
"Optional markdown preview for side-by-side layout (single-select only).",
|
|
30
|
+
description: "Optional markdown preview for side-by-side layout (single-select only).",
|
|
32
31
|
}),
|
|
33
32
|
),
|
|
34
33
|
});
|
|
@@ -44,14 +43,12 @@ export const QuestionSchema = Type.Object({
|
|
|
44
43
|
options: Type.Array(OptionSchema, {
|
|
45
44
|
minItems: MIN_OPTIONS,
|
|
46
45
|
maxItems: MAX_OPTIONS,
|
|
47
|
-
description:
|
|
48
|
-
"2-4 options. 'Type something.' is auto-appended for single-select.",
|
|
46
|
+
description: "2-4 options. 'Type something.' is auto-appended for single-select.",
|
|
49
47
|
}),
|
|
50
48
|
multiSelect: Type.Optional(
|
|
51
49
|
Type.Boolean({
|
|
52
50
|
default: false,
|
|
53
|
-
description:
|
|
54
|
-
"Allow multiple selections. Suppresses 'Type something.' row.",
|
|
51
|
+
description: "Allow multiple selections. Suppresses 'Type something.' row.",
|
|
55
52
|
}),
|
|
56
53
|
),
|
|
57
54
|
});
|
package/src/ask.test.ts
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ask.test.ts — tests for the ask questionnaire tool
|
|
3
|
-
*
|
|
4
|
-
* Tests cover pure functions (schema validation, sentinel logic, answer
|
|
5
|
-
* formatting). TUI components are not tested here.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import {
|
|
10
|
-
buildResponseText,
|
|
11
|
-
formatAnswerScalar,
|
|
12
|
-
hasAnyPreview,
|
|
13
|
-
type OptionData,
|
|
14
|
-
type QuestionData,
|
|
15
|
-
sentinelsFor,
|
|
16
|
-
} from "./index.ts";
|
|
17
|
-
|
|
18
|
-
// ── Fixtures ──────────────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
const opt = (
|
|
21
|
-
label: string,
|
|
22
|
-
description = "Test option",
|
|
23
|
-
preview?: string,
|
|
24
|
-
): OptionData => ({
|
|
25
|
-
label,
|
|
26
|
-
description,
|
|
27
|
-
...(preview ? { preview } : {}),
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const qSingle: QuestionData = {
|
|
31
|
-
question: "Which approach?",
|
|
32
|
-
header: "Approach",
|
|
33
|
-
options: [
|
|
34
|
-
opt("REST", "Traditional REST API"),
|
|
35
|
-
opt("GraphQL", "Query language for APIs"),
|
|
36
|
-
],
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const qMulti: QuestionData = {
|
|
40
|
-
question: "Which features?",
|
|
41
|
-
header: "Features",
|
|
42
|
-
options: [
|
|
43
|
-
opt("Auth", "User authentication"),
|
|
44
|
-
opt("Search", "Full text search"),
|
|
45
|
-
opt("Export", "Data export"),
|
|
46
|
-
],
|
|
47
|
-
multiSelect: true,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const qWithPreview: QuestionData = {
|
|
51
|
-
question: "Pick a component?",
|
|
52
|
-
header: "Component",
|
|
53
|
-
options: [
|
|
54
|
-
opt("Button", "Clickable button", "<Button>Primary</Button>"),
|
|
55
|
-
opt("Card", "Container card", "<Card><Content/></Card>"),
|
|
56
|
-
],
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const qSingleNoPreview: QuestionData = {
|
|
60
|
-
question: "Color?",
|
|
61
|
-
header: "Color",
|
|
62
|
-
options: [opt("Red", "Ruby red"), opt("Blue", "Ocean blue")],
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// ── hasAnyPreview ─────────────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
describe("hasAnyPreview", () => {
|
|
68
|
-
test("returns false when no option has preview", () => {
|
|
69
|
-
expect(hasAnyPreview(qSingle)).toBe(false);
|
|
70
|
-
expect(hasAnyPreview(qMulti)).toBe(false);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("returns true when at least one option has preview", () => {
|
|
74
|
-
expect(hasAnyPreview(qWithPreview)).toBe(true);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("returns false for empty options", () => {
|
|
78
|
-
const q: QuestionData = { question: "?", header: "X", options: [] };
|
|
79
|
-
expect(hasAnyPreview(q)).toBe(false);
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// ── sentinelsFor ──────────────────────────────────────────────────────
|
|
84
|
-
|
|
85
|
-
describe("sentinelsFor", () => {
|
|
86
|
-
test('single-select without preview appends "Type something."', () => {
|
|
87
|
-
const r = sentinelsFor(qSingleNoPreview);
|
|
88
|
-
expect(r).toHaveLength(1);
|
|
89
|
-
expect(r[0]?.kind).toBe("other");
|
|
90
|
-
expect(r[0]?.label).toBe("Type something.");
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('single-select with preview appends nothing (only "Chat about this" is separate)', () => {
|
|
94
|
-
const r = sentinelsFor(qWithPreview);
|
|
95
|
-
expect(r).toHaveLength(0);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('multi-select appends "Next"', () => {
|
|
99
|
-
const r = sentinelsFor(qMulti);
|
|
100
|
-
expect(r).toHaveLength(1);
|
|
101
|
-
expect(r[0]?.kind).toBe("next");
|
|
102
|
-
expect(r[0]?.label).toBe("Next");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("multi-select never appends Type something.", () => {
|
|
106
|
-
const r = sentinelsFor({ ...qMulti, multiSelect: true });
|
|
107
|
-
expect(r.every((s) => s.kind !== "other")).toBe(true);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("empty options still gets freeform sentinel (no preview = single-select)", () => {
|
|
111
|
-
const r = sentinelsFor({ question: "?", header: "X", options: [] });
|
|
112
|
-
expect(r).toHaveLength(1);
|
|
113
|
-
expect(r[0]?.kind).toBe("other");
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// ── formatAnswerScalar ────────────────────────────────────────────────
|
|
118
|
-
|
|
119
|
-
describe("formatAnswerScalar", () => {
|
|
120
|
-
test("option kind returns the answer string", () => {
|
|
121
|
-
const a = {
|
|
122
|
-
questionIndex: 0,
|
|
123
|
-
question: "Q",
|
|
124
|
-
kind: "option" as const,
|
|
125
|
-
answer: "REST",
|
|
126
|
-
};
|
|
127
|
-
expect(formatAnswerScalar(a)).toBe("REST");
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("multi kind joins selected with comma", () => {
|
|
131
|
-
const a = {
|
|
132
|
-
questionIndex: 0,
|
|
133
|
-
question: "Q",
|
|
134
|
-
kind: "multi" as const,
|
|
135
|
-
answer: null,
|
|
136
|
-
selected: ["Auth", "Search"],
|
|
137
|
-
};
|
|
138
|
-
expect(formatAnswerScalar(a)).toBe("Auth, Search");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("custom kind returns the typed text", () => {
|
|
142
|
-
const a = {
|
|
143
|
-
questionIndex: 0,
|
|
144
|
-
question: "Q",
|
|
145
|
-
kind: "custom" as const,
|
|
146
|
-
answer: "my custom answer",
|
|
147
|
-
};
|
|
148
|
-
expect(formatAnswerScalar(a)).toBe("my custom answer");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("chat kind returns (chat)", () => {
|
|
152
|
-
const a = {
|
|
153
|
-
questionIndex: 0,
|
|
154
|
-
question: "Q",
|
|
155
|
-
kind: "chat" as const,
|
|
156
|
-
answer: null,
|
|
157
|
-
};
|
|
158
|
-
expect(formatAnswerScalar(a)).toBe("(chat)");
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
// ── buildResponseText ─────────────────────────────────────────────────
|
|
163
|
-
|
|
164
|
-
describe("buildResponseText", () => {
|
|
165
|
-
test("formats single answer", () => {
|
|
166
|
-
const answers = [
|
|
167
|
-
{
|
|
168
|
-
questionIndex: 0,
|
|
169
|
-
question: "Which approach?",
|
|
170
|
-
kind: "option" as const,
|
|
171
|
-
answer: "REST",
|
|
172
|
-
},
|
|
173
|
-
];
|
|
174
|
-
const text = buildResponseText(answers, [qSingle]);
|
|
175
|
-
expect(text).toContain("REST");
|
|
176
|
-
expect(text).toContain("Which approach?");
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
test("formats multi-select answer", () => {
|
|
180
|
-
const answers = [
|
|
181
|
-
{
|
|
182
|
-
questionIndex: 0,
|
|
183
|
-
question: "Which features?",
|
|
184
|
-
kind: "multi" as const,
|
|
185
|
-
answer: null,
|
|
186
|
-
selected: ["Auth", "Search"],
|
|
187
|
-
},
|
|
188
|
-
];
|
|
189
|
-
const text = buildResponseText(answers, [qMulti]);
|
|
190
|
-
expect(text).toContain("Auth, Search");
|
|
191
|
-
expect(text).toContain("Which features?");
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
test("includes preview in response when present", () => {
|
|
195
|
-
const answers = [
|
|
196
|
-
{
|
|
197
|
-
questionIndex: 0,
|
|
198
|
-
question: "Pick a component?",
|
|
199
|
-
kind: "option" as const,
|
|
200
|
-
answer: "Button",
|
|
201
|
-
preview: "<Button>Primary</Button>",
|
|
202
|
-
},
|
|
203
|
-
];
|
|
204
|
-
const text = buildResponseText(answers, [qWithPreview]);
|
|
205
|
-
expect(text).toContain("preview: <Button>Primary</Button>");
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test("formats multiple answers", () => {
|
|
209
|
-
const qs = [qSingle, qMulti];
|
|
210
|
-
const answers = [
|
|
211
|
-
{
|
|
212
|
-
questionIndex: 0,
|
|
213
|
-
question: "Which approach?",
|
|
214
|
-
kind: "option" as const,
|
|
215
|
-
answer: "GraphQL",
|
|
216
|
-
},
|
|
217
|
-
{
|
|
218
|
-
questionIndex: 1,
|
|
219
|
-
question: "Which features?",
|
|
220
|
-
kind: "multi" as const,
|
|
221
|
-
answer: null,
|
|
222
|
-
selected: ["Export"],
|
|
223
|
-
},
|
|
224
|
-
];
|
|
225
|
-
const text = buildResponseText(answers, qs);
|
|
226
|
-
expect(text).toContain("GraphQL");
|
|
227
|
-
expect(text).toContain("Export");
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test("shows declined message when no answers", () => {
|
|
231
|
-
const text = buildResponseText([], [qSingle]);
|
|
232
|
-
expect(text).toContain("declined");
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
// ── Tool registration shape ─────────────────────────────────────────
|
|
237
|
-
|
|
238
|
-
describe("registerAsk", () => {
|
|
239
|
-
test("exports a default function", async () => {
|
|
240
|
-
const mod = await import("./index.ts");
|
|
241
|
-
expect(typeof mod.default).toBe("function");
|
|
242
|
-
});
|
|
243
|
-
});
|
package/src/frame.test.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { frameLines, modalWidth } from "./frame.js";
|
|
4
|
-
|
|
5
|
-
const noColor = (s: string) => s;
|
|
6
|
-
|
|
7
|
-
test("modalWidth clamps to [40, 96] with 4-col margin", () => {
|
|
8
|
-
expect(modalWidth(200)).toBe(96);
|
|
9
|
-
expect(modalWidth(50)).toBe(46);
|
|
10
|
-
expect(modalWidth(10)).toBe(40);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test("frameLines draws rounded border with uniform width", () => {
|
|
14
|
-
const out = frameLines({
|
|
15
|
-
width: 40,
|
|
16
|
-
lines: ["hello", "world"],
|
|
17
|
-
color: noColor,
|
|
18
|
-
});
|
|
19
|
-
const first = out[0] ?? "";
|
|
20
|
-
const last = out[out.length - 1] ?? "";
|
|
21
|
-
expect(first.startsWith("╭")).toBe(true);
|
|
22
|
-
expect(first.endsWith("╮")).toBe(true);
|
|
23
|
-
expect(last.startsWith("╰")).toBe(true);
|
|
24
|
-
expect(last.endsWith("╯")).toBe(true);
|
|
25
|
-
for (const line of out) {
|
|
26
|
-
expect(visibleWidth(line)).toBe(40);
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("frameLines pads ANSI-colored input by visible width", () => {
|
|
31
|
-
const out = frameLines({
|
|
32
|
-
width: 40,
|
|
33
|
-
lines: ["\x1b[31mhi\x1b[0m"],
|
|
34
|
-
color: noColor,
|
|
35
|
-
});
|
|
36
|
-
for (const line of out) {
|
|
37
|
-
expect(visibleWidth(line)).toBe(40);
|
|
38
|
-
}
|
|
39
|
-
});
|
package/src/frame.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Modal frame primitives — inlined from pix-pretty/modal-frame to avoid
|
|
3
|
-
* cross-package subpath imports that break under Node CJS require.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
-
|
|
8
|
-
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
const MIN_WIDTH = 40;
|
|
11
|
-
const MAX_WIDTH = 96;
|
|
12
|
-
const MARGIN = 4;
|
|
13
|
-
/** 2 border cols + 2 padding spaces */
|
|
14
|
-
const CHROME = 4;
|
|
15
|
-
|
|
16
|
-
// ── Width ─────────────────────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
/** Clamp terminal width to a sane modal width (40–96 cols). */
|
|
19
|
-
export function modalWidth(termWidth: number): number {
|
|
20
|
-
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, termWidth - MARGIN));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// ── Frame ─────────────────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
export interface FrameOptions {
|
|
26
|
-
width: number;
|
|
27
|
-
lines: string[];
|
|
28
|
-
/** Color function for border glyphs — e.g. `(s) => theme.fg("accent", s)` */
|
|
29
|
-
color: (s: string) => string;
|
|
30
|
-
/** Background fill function — e.g. `(s) => theme.bg("customMessageBg", s)` */
|
|
31
|
-
bg?: (s: string) => string;
|
|
32
|
-
/** Optional pre-styled string rendered as the first content row (tab bar etc.) */
|
|
33
|
-
top?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Render a rounded modal box.
|
|
38
|
-
*
|
|
39
|
-
* Returns an array of full-width ANSI strings:
|
|
40
|
-
* ╭──────────────────╮
|
|
41
|
-
* │ [top row] │ ← only if top is set
|
|
42
|
-
* │ content line 1 │
|
|
43
|
-
* │ content line 2 │
|
|
44
|
-
* ╰──────────────────╯
|
|
45
|
-
*/
|
|
46
|
-
export function frameLines(opts: FrameOptions): string[] {
|
|
47
|
-
const { width, lines, color, top } = opts;
|
|
48
|
-
const bg = opts.bg ?? ((s: string) => s);
|
|
49
|
-
const inner = Math.max(1, width - CHROME);
|
|
50
|
-
const dashes = "─".repeat(width - 2);
|
|
51
|
-
|
|
52
|
-
const SENTINEL = "\x00";
|
|
53
|
-
const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
|
|
54
|
-
const reassert = (s: string): string =>
|
|
55
|
-
bgOpen
|
|
56
|
-
? s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) =>
|
|
57
|
-
p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
|
|
58
|
-
)
|
|
59
|
-
: s;
|
|
60
|
-
|
|
61
|
-
const row = (content: string): string => {
|
|
62
|
-
const pad = inner - visibleWidth(content);
|
|
63
|
-
const padded =
|
|
64
|
-
pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
|
|
65
|
-
return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const out: string[] = [bg(color(`╭${dashes}╮`))];
|
|
69
|
-
if (top !== undefined) out.push(row(top));
|
|
70
|
-
for (const line of lines) out.push(row(line));
|
|
71
|
-
out.push(bg(color(`╰${dashes}╯`)));
|
|
72
|
-
return out;
|
|
73
|
-
}
|
package/src/glyphs.test.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { checkboxGlyphs, selectionGlyph } from "./glyphs.js";
|
|
4
|
-
|
|
5
|
-
describe("selectionGlyph", () => {
|
|
6
|
-
test("multi checked → ▣ / success", () => {
|
|
7
|
-
expect(
|
|
8
|
-
selectionGlyph({ multi: true, selected: false, checked: true }),
|
|
9
|
-
).toEqual({
|
|
10
|
-
glyph: "▣",
|
|
11
|
-
color: "success",
|
|
12
|
-
});
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
test("multi unchecked → ☐ / dim", () => {
|
|
16
|
-
expect(
|
|
17
|
-
selectionGlyph({ multi: true, selected: true, checked: false }),
|
|
18
|
-
).toEqual({
|
|
19
|
-
glyph: "☐",
|
|
20
|
-
color: "dim",
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("radio selected → ◉ / accent", () => {
|
|
25
|
-
expect(
|
|
26
|
-
selectionGlyph({ multi: false, selected: true, checked: false }),
|
|
27
|
-
).toEqual({
|
|
28
|
-
glyph: "◉",
|
|
29
|
-
color: "accent",
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("radio unselected → ○ / dim", () => {
|
|
34
|
-
expect(
|
|
35
|
-
selectionGlyph({ multi: false, selected: false, checked: false }),
|
|
36
|
-
).toEqual({
|
|
37
|
-
glyph: "○",
|
|
38
|
-
color: "dim",
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("checkboxGlyphs", () => {
|
|
44
|
-
test("checked and unchecked have equal display width", () => {
|
|
45
|
-
const { checked, unchecked } = checkboxGlyphs();
|
|
46
|
-
expect(visibleWidth(checked)).toBe(visibleWidth(unchecked));
|
|
47
|
-
});
|
|
48
|
-
});
|
package/src/once.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Per-instance idempotency guard for extension activation.
|
|
3
|
-
*
|
|
4
|
-
* pix-core (the meta-package) invokes this package's factory, and a standalone
|
|
5
|
-
* install makes Pi invoke it again — sometimes against the SAME `pi`. We must
|
|
6
|
-
* dedupe that. But Pi rebuilds the extension runtime on /new, /resume, /fork,
|
|
7
|
-
* and /reload, handing the factory a BRAND-NEW `pi`; that must re-register.
|
|
8
|
-
*
|
|
9
|
-
* Keying the registry on the `pi` instance satisfies both: same instance =>
|
|
10
|
-
* skip, new instance => run. The registry lives on globalThis because jiti
|
|
11
|
-
* (`moduleCache: false`) re-evaluates this module on every load pass, so a
|
|
12
|
-
* module-scoped WeakMap would not be shared between the aggregator pass and the
|
|
13
|
-
* standalone pass within a single session.
|
|
14
|
-
*/
|
|
15
|
-
export function once(pi: object, key: string, fn: () => void): void {
|
|
16
|
-
const g = globalThis as { __pixOnce?: WeakMap<object, Set<string>> };
|
|
17
|
-
if (!g.__pixOnce) g.__pixOnce = new WeakMap<object, Set<string>>();
|
|
18
|
-
const registry = g.__pixOnce;
|
|
19
|
-
let loaded = registry.get(pi);
|
|
20
|
-
if (!loaded) {
|
|
21
|
-
loaded = new Set<string>();
|
|
22
|
-
registry.set(pi, loaded);
|
|
23
|
-
}
|
|
24
|
-
if (loaded.has(key)) return;
|
|
25
|
-
loaded.add(key);
|
|
26
|
-
fn();
|
|
27
|
-
}
|