@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ask",
3
- "version": "0.2.6",
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/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
- multi: boolean;
11
- selected: boolean;
12
- checked: boolean;
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
- "abort",
84
- () => done({ answers: [], cancelled: true }),
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);
@@ -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
- SENTINEL_FREEFORM,
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
- 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]$/);
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
- 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);
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
- `${navHint} • space toggle • enter commit • esc clear`,
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
- }
@@ -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
- }