@xynogen/pix-ask 0.2.7 → 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/index.ts +1 -2
- package/src/questionnaire.ts +24 -15
- package/src/ask.test.ts +0 -236
- package/src/frame.test.ts +0 -39
- package/src/frame.ts +0 -72
- package/src/glyphs.test.ts +0 -40
- 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/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
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";
|
package/src/questionnaire.ts
CHANGED
|
@@ -12,8 +12,8 @@ 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";
|
|
@@ -22,6 +22,22 @@ import type { AnswerKind, QuestionAnswer, QuestionnaireResult } from "./types.js
|
|
|
22
22
|
|
|
23
23
|
// ── AskQuestionnaire ───────────────────────────────────────────────────
|
|
24
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
|
+
|
|
25
41
|
export class AskQuestionnaire extends Container {
|
|
26
42
|
private params: Params;
|
|
27
43
|
private tui: TUI;
|
|
@@ -327,7 +343,10 @@ export class AskQuestionnaire extends Container {
|
|
|
327
343
|
return;
|
|
328
344
|
}
|
|
329
345
|
|
|
330
|
-
|
|
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]$/);
|
|
331
350
|
if (numMatch && this.filteredOptions.length > 0) {
|
|
332
351
|
const idx = Number(numMatch[0]) - 1;
|
|
333
352
|
if (idx >= 0 && idx < this.filteredOptions.length) {
|
|
@@ -351,23 +370,13 @@ export class AskQuestionnaire extends Container {
|
|
|
351
370
|
}
|
|
352
371
|
|
|
353
372
|
if (!isMulti) {
|
|
354
|
-
|
|
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);
|
|
355
376
|
if (printable !== undefined) {
|
|
356
377
|
this.searchQuery += printable;
|
|
357
378
|
this.selectedOptionIndex = 0;
|
|
358
379
|
this.refresh();
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
const chars = [...data];
|
|
362
|
-
if (
|
|
363
|
-
chars.length === 1 &&
|
|
364
|
-
chars[0] &&
|
|
365
|
-
chars[0].charCodeAt(0) >= 32 &&
|
|
366
|
-
chars[0].charCodeAt(0) < 127
|
|
367
|
-
) {
|
|
368
|
-
this.searchQuery += chars[0];
|
|
369
|
-
this.selectedOptionIndex = 0;
|
|
370
|
-
this.refresh();
|
|
371
380
|
}
|
|
372
381
|
}
|
|
373
382
|
}
|
package/src/ask.test.ts
DELETED
|
@@ -1,236 +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 = (label: string, description = "Test option", preview?: string): OptionData => ({
|
|
21
|
-
label,
|
|
22
|
-
description,
|
|
23
|
-
...(preview ? { preview } : {}),
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
const qSingle: QuestionData = {
|
|
27
|
-
question: "Which approach?",
|
|
28
|
-
header: "Approach",
|
|
29
|
-
options: [opt("REST", "Traditional REST API"), opt("GraphQL", "Query language for APIs")],
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const qMulti: QuestionData = {
|
|
33
|
-
question: "Which features?",
|
|
34
|
-
header: "Features",
|
|
35
|
-
options: [
|
|
36
|
-
opt("Auth", "User authentication"),
|
|
37
|
-
opt("Search", "Full text search"),
|
|
38
|
-
opt("Export", "Data export"),
|
|
39
|
-
],
|
|
40
|
-
multiSelect: true,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const qWithPreview: QuestionData = {
|
|
44
|
-
question: "Pick a component?",
|
|
45
|
-
header: "Component",
|
|
46
|
-
options: [
|
|
47
|
-
opt("Button", "Clickable button", "<Button>Primary</Button>"),
|
|
48
|
-
opt("Card", "Container card", "<Card><Content/></Card>"),
|
|
49
|
-
],
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const qSingleNoPreview: QuestionData = {
|
|
53
|
-
question: "Color?",
|
|
54
|
-
header: "Color",
|
|
55
|
-
options: [opt("Red", "Ruby red"), opt("Blue", "Ocean blue")],
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
// ── hasAnyPreview ─────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
describe("hasAnyPreview", () => {
|
|
61
|
-
test("returns false when no option has preview", () => {
|
|
62
|
-
expect(hasAnyPreview(qSingle)).toBe(false);
|
|
63
|
-
expect(hasAnyPreview(qMulti)).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("returns true when at least one option has preview", () => {
|
|
67
|
-
expect(hasAnyPreview(qWithPreview)).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("returns false for empty options", () => {
|
|
71
|
-
const q: QuestionData = { question: "?", header: "X", options: [] };
|
|
72
|
-
expect(hasAnyPreview(q)).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// ── sentinelsFor ──────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
describe("sentinelsFor", () => {
|
|
79
|
-
test('single-select without preview appends "Type something."', () => {
|
|
80
|
-
const r = sentinelsFor(qSingleNoPreview);
|
|
81
|
-
expect(r).toHaveLength(1);
|
|
82
|
-
expect(r[0]?.kind).toBe("other");
|
|
83
|
-
expect(r[0]?.label).toBe("Type something.");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('single-select with preview appends nothing (only "Chat about this" is separate)', () => {
|
|
87
|
-
const r = sentinelsFor(qWithPreview);
|
|
88
|
-
expect(r).toHaveLength(0);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('multi-select appends "Next"', () => {
|
|
92
|
-
const r = sentinelsFor(qMulti);
|
|
93
|
-
expect(r).toHaveLength(1);
|
|
94
|
-
expect(r[0]?.kind).toBe("next");
|
|
95
|
-
expect(r[0]?.label).toBe("Next");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("multi-select never appends Type something.", () => {
|
|
99
|
-
const r = sentinelsFor({ ...qMulti, multiSelect: true });
|
|
100
|
-
expect(r.every((s) => s.kind !== "other")).toBe(true);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("empty options still gets freeform sentinel (no preview = single-select)", () => {
|
|
104
|
-
const r = sentinelsFor({ question: "?", header: "X", options: [] });
|
|
105
|
-
expect(r).toHaveLength(1);
|
|
106
|
-
expect(r[0]?.kind).toBe("other");
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
// ── formatAnswerScalar ────────────────────────────────────────────────
|
|
111
|
-
|
|
112
|
-
describe("formatAnswerScalar", () => {
|
|
113
|
-
test("option kind returns the answer string", () => {
|
|
114
|
-
const a = {
|
|
115
|
-
questionIndex: 0,
|
|
116
|
-
question: "Q",
|
|
117
|
-
kind: "option" as const,
|
|
118
|
-
answer: "REST",
|
|
119
|
-
};
|
|
120
|
-
expect(formatAnswerScalar(a)).toBe("REST");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("multi kind joins selected with comma", () => {
|
|
124
|
-
const a = {
|
|
125
|
-
questionIndex: 0,
|
|
126
|
-
question: "Q",
|
|
127
|
-
kind: "multi" as const,
|
|
128
|
-
answer: null,
|
|
129
|
-
selected: ["Auth", "Search"],
|
|
130
|
-
};
|
|
131
|
-
expect(formatAnswerScalar(a)).toBe("Auth, Search");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("custom kind returns the typed text", () => {
|
|
135
|
-
const a = {
|
|
136
|
-
questionIndex: 0,
|
|
137
|
-
question: "Q",
|
|
138
|
-
kind: "custom" as const,
|
|
139
|
-
answer: "my custom answer",
|
|
140
|
-
};
|
|
141
|
-
expect(formatAnswerScalar(a)).toBe("my custom answer");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("chat kind returns (chat)", () => {
|
|
145
|
-
const a = {
|
|
146
|
-
questionIndex: 0,
|
|
147
|
-
question: "Q",
|
|
148
|
-
kind: "chat" as const,
|
|
149
|
-
answer: null,
|
|
150
|
-
};
|
|
151
|
-
expect(formatAnswerScalar(a)).toBe("(chat)");
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// ── buildResponseText ─────────────────────────────────────────────────
|
|
156
|
-
|
|
157
|
-
describe("buildResponseText", () => {
|
|
158
|
-
test("formats single answer", () => {
|
|
159
|
-
const answers = [
|
|
160
|
-
{
|
|
161
|
-
questionIndex: 0,
|
|
162
|
-
question: "Which approach?",
|
|
163
|
-
kind: "option" as const,
|
|
164
|
-
answer: "REST",
|
|
165
|
-
},
|
|
166
|
-
];
|
|
167
|
-
const text = buildResponseText(answers, [qSingle]);
|
|
168
|
-
expect(text).toContain("REST");
|
|
169
|
-
expect(text).toContain("Which approach?");
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("formats multi-select answer", () => {
|
|
173
|
-
const answers = [
|
|
174
|
-
{
|
|
175
|
-
questionIndex: 0,
|
|
176
|
-
question: "Which features?",
|
|
177
|
-
kind: "multi" as const,
|
|
178
|
-
answer: null,
|
|
179
|
-
selected: ["Auth", "Search"],
|
|
180
|
-
},
|
|
181
|
-
];
|
|
182
|
-
const text = buildResponseText(answers, [qMulti]);
|
|
183
|
-
expect(text).toContain("Auth, Search");
|
|
184
|
-
expect(text).toContain("Which features?");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("includes preview in response when present", () => {
|
|
188
|
-
const answers = [
|
|
189
|
-
{
|
|
190
|
-
questionIndex: 0,
|
|
191
|
-
question: "Pick a component?",
|
|
192
|
-
kind: "option" as const,
|
|
193
|
-
answer: "Button",
|
|
194
|
-
preview: "<Button>Primary</Button>",
|
|
195
|
-
},
|
|
196
|
-
];
|
|
197
|
-
const text = buildResponseText(answers, [qWithPreview]);
|
|
198
|
-
expect(text).toContain("preview: <Button>Primary</Button>");
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("formats multiple answers", () => {
|
|
202
|
-
const qs = [qSingle, qMulti];
|
|
203
|
-
const answers = [
|
|
204
|
-
{
|
|
205
|
-
questionIndex: 0,
|
|
206
|
-
question: "Which approach?",
|
|
207
|
-
kind: "option" as const,
|
|
208
|
-
answer: "GraphQL",
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
questionIndex: 1,
|
|
212
|
-
question: "Which features?",
|
|
213
|
-
kind: "multi" as const,
|
|
214
|
-
answer: null,
|
|
215
|
-
selected: ["Export"],
|
|
216
|
-
},
|
|
217
|
-
];
|
|
218
|
-
const text = buildResponseText(answers, qs);
|
|
219
|
-
expect(text).toContain("GraphQL");
|
|
220
|
-
expect(text).toContain("Export");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test("shows declined message when no answers", () => {
|
|
224
|
-
const text = buildResponseText([], [qSingle]);
|
|
225
|
-
expect(text).toContain("declined");
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
// ── Tool registration shape ─────────────────────────────────────────
|
|
230
|
-
|
|
231
|
-
describe("registerAsk", () => {
|
|
232
|
-
test("exports a default function", async () => {
|
|
233
|
-
const mod = await import("./index.ts");
|
|
234
|
-
expect(typeof mod.default).toBe("function");
|
|
235
|
-
});
|
|
236
|
-
});
|
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,72 +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 = pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
|
|
64
|
-
return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const out: string[] = [bg(color(`╭${dashes}╮`))];
|
|
68
|
-
if (top !== undefined) out.push(row(top));
|
|
69
|
-
for (const line of lines) out.push(row(line));
|
|
70
|
-
out.push(bg(color(`╰${dashes}╯`)));
|
|
71
|
-
return out;
|
|
72
|
-
}
|
package/src/glyphs.test.ts
DELETED
|
@@ -1,40 +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(selectionGlyph({ multi: true, selected: false, checked: true })).toEqual({
|
|
8
|
-
glyph: "▣",
|
|
9
|
-
color: "success",
|
|
10
|
-
});
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
test("multi unchecked → ☐ / dim", () => {
|
|
14
|
-
expect(selectionGlyph({ multi: true, selected: true, checked: false })).toEqual({
|
|
15
|
-
glyph: "☐",
|
|
16
|
-
color: "dim",
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("radio selected → ◉ / accent", () => {
|
|
21
|
-
expect(selectionGlyph({ multi: false, selected: true, checked: false })).toEqual({
|
|
22
|
-
glyph: "◉",
|
|
23
|
-
color: "accent",
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("radio unselected → ○ / dim", () => {
|
|
28
|
-
expect(selectionGlyph({ multi: false, selected: false, checked: false })).toEqual({
|
|
29
|
-
glyph: "○",
|
|
30
|
-
color: "dim",
|
|
31
|
-
});
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe("checkboxGlyphs", () => {
|
|
36
|
-
test("checked and unchecked have equal display width", () => {
|
|
37
|
-
const { checked, unchecked } = checkboxGlyphs();
|
|
38
|
-
expect(visibleWidth(checked)).toBe(visibleWidth(unchecked));
|
|
39
|
-
});
|
|
40
|
-
});
|
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
|
-
}
|