@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ask",
3
- "version": "0.2.7",
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
- "typebox": "^1.1.38",
38
- "@xynogen/pix-pretty": "^1.7.9"
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";
@@ -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
- const numMatch = data.match(/^[1-9]$/);
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
- const printable = decodeKittyPrintable(data);
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
- }
@@ -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
- }