@xynogen/pix-ask 0.1.5 → 0.2.0

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.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Pi tool — structured questionnaire UI (ask_user)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,39 @@
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 ADDED
@@ -0,0 +1,49 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ const MIN_WIDTH = 40;
4
+ const MAX_WIDTH = 96;
5
+ const MARGIN = 4;
6
+ // 2 border cols + 2 padding spaces
7
+ const CHROME = 4;
8
+
9
+ export function modalWidth(termWidth: number): number {
10
+ return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, termWidth - MARGIN));
11
+ }
12
+
13
+ export function frameLines(opts: {
14
+ width: number;
15
+ lines: string[];
16
+ color: (s: string) => string;
17
+ bg?: (s: string) => string;
18
+ top?: string;
19
+ }): string[] {
20
+ const { width, lines, color, top } = opts;
21
+ const bg = opts.bg ?? ((s: string) => s);
22
+ const inner = Math.max(1, width - CHROME);
23
+ const dashes = "─".repeat(width - 2);
24
+
25
+ // Derive the bg OPEN sequence so we can re-assert it after any full reset
26
+ // (\x1b[0m) or bg reset (\x1b[49m) embedded in content — theme fg/bold spans
27
+ // emit \x1b[0m, which would otherwise punch transparent holes in the fill.
28
+ const SENTINEL = "\x00";
29
+ const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
30
+ const reassert = (s: string): string =>
31
+ bgOpen
32
+ ? s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) =>
33
+ p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
34
+ )
35
+ : s;
36
+
37
+ const row = (content: string): string => {
38
+ const pad = inner - visibleWidth(content);
39
+ const padded =
40
+ pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
41
+ return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
42
+ };
43
+
44
+ const out: string[] = [bg(color(`╭${dashes}╮`))];
45
+ if (top !== undefined) out.push(row(top));
46
+ for (const line of lines) out.push(row(line));
47
+ out.push(bg(color(`╰${dashes}╯`)));
48
+ return out;
49
+ }
@@ -0,0 +1,48 @@
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/glyphs.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ const CHECKBOX_CHECKED = "▣";
4
+ const CHECKBOX_UNCHECKED = "☐";
5
+ const RADIO_SELECTED = "◉";
6
+ const RADIO_UNSELECTED = "○";
7
+
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 } {
14
+ if (opts.multi) {
15
+ return opts.checked
16
+ ? { glyph: CHECKBOX_CHECKED, color: "success" }
17
+ : { glyph: CHECKBOX_UNCHECKED, color: "dim" };
18
+ }
19
+ return opts.selected
20
+ ? { glyph: RADIO_SELECTED, color: "accent" }
21
+ : { glyph: RADIO_UNSELECTED, color: "dim" };
22
+ }
23
+
24
+ /**
25
+ * Checkbox glyph pair, falling back to ASCII [x]/[ ] when the unicode squares
26
+ * do not measure as a single display cell (some terminals render
27
+ * geometric-shape codepoints as width-2, breaking column alignment).
28
+ */
29
+ export function checkboxGlyphs(): { checked: string; unchecked: string } {
30
+ const unicodeSafe =
31
+ visibleWidth(CHECKBOX_CHECKED) === 1 &&
32
+ visibleWidth(CHECKBOX_UNCHECKED) === 1;
33
+ return unicodeSafe
34
+ ? { checked: CHECKBOX_CHECKED, unchecked: CHECKBOX_UNCHECKED }
35
+ : { checked: "[x]", unchecked: "[ ]" };
36
+ }
package/src/index.ts CHANGED
@@ -11,7 +11,6 @@ import {
11
11
  MAX_QUESTIONS,
12
12
  MIN_OPTIONS,
13
13
  ParamsSchema,
14
- SENTINEL_CHAT,
15
14
  SENTINEL_FREEFORM,
16
15
  } from "./schema.js";
17
16
  import type { QuestionAnswer, QuestionnaireResult } from "./types.js";
@@ -42,8 +41,8 @@ export default function registerAsk(pi: ExtensionAPI): void {
42
41
  promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
43
42
  promptGuidelines: [
44
43
  `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.`,
45
- `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) or pick "${SENTINEL_CHAT}" to abandon the questionnaire.`,
46
- `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); "${SENTINEL_CHAT}" remains the escape hatch. If you recommend a specific option, make it the first option and append "(Recommended)" to its label.`,
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.`,
47
46
  "Do not stack multiple ask calls back-to-back — group all clarifying questions into one invocation.",
48
47
  ],
49
48
  executionMode: "sequential",
@@ -88,6 +87,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
88
87
  }
89
88
  return new AskQuestionnaire(typed, tui, theme, keybindings, done);
90
89
  },
90
+ { overlay: true },
91
91
  );
92
92
 
93
93
  if (!result || result.cancelled) {
@@ -13,10 +13,11 @@ import {
13
13
  wrapTextWithAnsi,
14
14
  } from "@earendil-works/pi-tui";
15
15
  import { dim } from "./components.js";
16
+ import { frameLines, modalWidth } from "./frame.js";
17
+ import { checkboxGlyphs, selectionGlyph } from "./glyphs.js";
16
18
  import { safeMarkdownTheme, sentinelsFor } from "./helpers.js";
17
19
  import type { OptionData, Params, QuestionData } from "./schema.js";
18
20
  import {
19
- SENTINEL_CHAT,
20
21
  SENTINEL_FREEFORM,
21
22
  SENTINEL_NEXT,
22
23
  SEPARATOR,
@@ -251,11 +252,9 @@ export class AskQuestionnaire extends Container {
251
252
  // ── Input handling ─────────────────────────────────────────────────
252
253
 
253
254
  handleInput(data: string): void {
254
- if (this.keybindings.matches(data, "tui.select.cancel")) {
255
- this.cancel();
256
- return;
257
- }
258
-
255
+ // Input mode owns esc (back to options) — handle it BEFORE the global
256
+ // cancel guard, which is also bound to esc and would otherwise close the
257
+ // whole questionnaire instead of stepping back.
259
258
  if (this.inputMode) {
260
259
  if (matchesKey(data, Key.escape)) {
261
260
  this.inputMode = false;
@@ -263,11 +262,20 @@ export class AskQuestionnaire extends Container {
263
262
  this.refresh();
264
263
  return;
265
264
  }
265
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
266
+ this.cancel();
267
+ return;
268
+ }
266
269
  this.ensureEditor().handleInput(data);
267
270
  this.tui.requestRender();
268
271
  return;
269
272
  }
270
273
 
274
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
275
+ this.cancel();
276
+ return;
277
+ }
278
+
271
279
  const isMulti = !!this.currentQ.multiSelect;
272
280
  const total = this.totalItems;
273
281
 
@@ -389,12 +397,23 @@ export class AskQuestionnaire extends Container {
389
397
  const isMulti = !!this.currentQ.multiSelect;
390
398
  const items = this.mainListItems;
391
399
  const total = items.length;
392
- const chk = (i: number) =>
393
- isMulti
394
- ? this.multiChecked.has(i)
395
- ? t.fg("success", "✓")
396
- : t.fg("dim", "○")
397
- : "";
400
+ const box = checkboxGlyphs();
401
+ // Glyph per option: checkbox (▣/☐) for multi, radio (◉/○) for single.
402
+ // `sel` = cursor row; for radio the cursor row IS the chosen one.
403
+ const glyphFor = (optIdx: number, sel: boolean): string => {
404
+ const g = selectionGlyph({
405
+ multi: isMulti,
406
+ selected: sel,
407
+ checked: this.multiChecked.has(optIdx),
408
+ });
409
+ // width-safety fallback only affects the checkbox pair
410
+ const glyph = isMulti
411
+ ? this.multiChecked.has(optIdx)
412
+ ? box.checked
413
+ : box.unchecked
414
+ : g.glyph;
415
+ return t.fg(g.color as Parameters<typeof t.fg>[0], glyph);
416
+ };
398
417
 
399
418
  if (total === 0) return [t.fg("warning", "No options")];
400
419
 
@@ -409,7 +428,10 @@ export class AskQuestionnaire extends Container {
409
428
  const end = Math.min(start + maxVisible, total);
410
429
 
411
430
  const lines: string[] = [];
412
- const pad = " ";
431
+ // Hang-indent descriptions under the LABEL column, not the pointer.
432
+ // Prefix is `→ G ` = ptr(1)+sp(1)+glyph(1)+sp(1) = 4 cols.
433
+ const LABEL_COL = 4;
434
+ const pad = " ".repeat(LABEL_COL);
413
435
 
414
436
  for (let i = start; i < end; i++) {
415
437
  const item = items[i]!;
@@ -418,18 +440,15 @@ export class AskQuestionnaire extends Container {
418
440
 
419
441
  if (item.kind === "option" && item.option) {
420
442
  const optIdx = this.filteredOptions.indexOf(item.option);
421
- const checkbox = isMulti ? ` ${chk(optIdx)}` : "";
422
- const num = t.fg("dim", `${optIdx + 1}.`);
443
+ const glyph = glyphFor(optIdx, sel);
423
444
  const label = sel
424
445
  ? t.fg("accent", t.bold(item.option.label))
425
446
  : t.fg("text", t.bold(item.option.label));
426
- lines.push(
427
- truncateToWidth(`${ptr} ${num}${checkbox} ${label}`, inner, ""),
428
- );
447
+ lines.push(truncateToWidth(`${ptr} ${glyph} ${label}`, inner, ""));
429
448
  if (item.option.description) {
430
449
  const wrapped = wrapTextWithAnsi(
431
450
  item.option.description,
432
- Math.max(10, inner - 6),
451
+ Math.max(10, inner - LABEL_COL),
433
452
  );
434
453
  for (const w of wrapped) {
435
454
  lines.push(truncateToWidth(`${pad}${t.fg("muted", w)}`, inner, ""));
@@ -488,8 +507,12 @@ export class AskQuestionnaire extends Container {
488
507
  );
489
508
  }
490
509
 
491
- override render(width: number): string[] {
492
- const inner = Math.max(20, width - 4);
510
+ override render(termWidth: number): string[] {
511
+ // Cap to a fixed-width floating modal; render content at the inner width
512
+ // and frame it with a rounded border (see frameLines).
513
+ const mw = modalWidth(termWidth);
514
+ const width = mw - 4; // border (2) + padding (2)
515
+ const inner = Math.max(20, width);
493
516
  const t = this.theme;
494
517
  const isMulti = !!this.currentQ.multiSelect;
495
518
  const hasPreview =
@@ -498,15 +521,16 @@ export class AskQuestionnaire extends Container {
498
521
  !!this.selectedItem?.option?.preview;
499
522
 
500
523
  const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
501
- const leftWidth = useSplit ? Math.floor((width - 6) * 0.45) : inner;
502
- const previewWidth = useSplit ? Math.max(20, width - leftWidth - 10) : 0;
524
+ const leftWidth = useSplit ? Math.floor((width - 2) * 0.45) : inner;
525
+ const previewWidth = useSplit ? Math.max(20, width - leftWidth - 3) : 0;
503
526
 
504
527
  const lines: string[] = [];
505
528
 
506
529
  const row = (content: string): string =>
507
- ` ${truncateToWidth(content, Math.max(0, width - 1), "")}`;
530
+ truncateToWidth(content, width, "");
508
531
 
509
- // Tab bar
532
+ // Tab bar — rendered as the framed top edge (frameLines `top`).
533
+ let top: string | undefined;
510
534
  if (this.params.questions.length > 1) {
511
535
  const tabParts: string[] = [];
512
536
  for (let i = 0; i < this.params.questions.length; i++) {
@@ -514,7 +538,7 @@ export class AskQuestionnaire extends Container {
514
538
  const tag = `${i + 1}.${this.params.questions[i]?.header}`;
515
539
  tabParts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
516
540
  }
517
- lines.push(row(tabParts.join(t.fg("dim", " "))));
541
+ top = truncateToWidth(tabParts.join(t.fg("dim", " ")), width, "");
518
542
  }
519
543
 
520
544
  // Header chip
@@ -538,14 +562,13 @@ export class AskQuestionnaire extends Container {
538
562
  lines.push("");
539
563
  lines.push(row(t.fg("accent", t.bold("Type your response:"))));
540
564
  lines.push("");
541
- const editorLines = this.ensureEditor().render(Math.max(0, width - 1));
565
+ const editorLines = this.ensureEditor().render(width);
542
566
  for (const el of editorLines) {
543
- lines.push(` ${truncateToWidth(el, Math.max(0, width - 1), "")}`);
567
+ lines.push(truncateToWidth(el, width, ""));
544
568
  }
545
569
  lines.push("");
546
570
  lines.push(row(dim(t)("enter submit • esc back • ctrl+c cancel")));
547
- lines.push("");
548
- return lines.map((l) => truncateToWidth(l, width, ""));
571
+ return this.frame(mw, lines, top);
549
572
  }
550
573
 
551
574
  // Search bar
@@ -556,34 +579,18 @@ export class AskQuestionnaire extends Container {
556
579
  lines.push(row(`${t.fg("accent", "Filter:")} ${searchVal}`));
557
580
  }
558
581
 
559
- // Chat sentinel
560
- const chatLabel =
561
- this.selectedOptionIndex === -999
562
- ? t.fg("accent", t.bold(SENTINEL_CHAT))
563
- : t.fg("dim", SENTINEL_CHAT);
564
- lines.push(row(` ${t.fg("dim", "💬")} ${chatLabel}`));
565
-
566
582
  // Options (with optional split-pane preview)
567
- const optionLines = this.renderOptions(useSplit ? leftWidth : width - 4);
583
+ const optionLines = this.renderOptions(useSplit ? leftWidth : width);
568
584
  const previewLines = useSplit ? this.renderPreview(previewWidth) : [];
569
585
  const maxOptLines = Math.max(optionLines.length, previewLines.length);
570
586
 
571
587
  if (useSplit) {
572
588
  const sep = t.fg("dim", SEPARATOR);
573
589
  for (let i = 0; i < maxOptLines; i++) {
574
- const left = truncateToWidth(
575
- optionLines[i] ?? "",
576
- leftWidth - 1,
577
- "",
578
- true,
579
- );
580
- const right = truncateToWidth(
581
- previewLines[i] ?? "",
582
- previewWidth - 2,
583
- "",
584
- );
585
- const body = `${left || " ".repeat(leftWidth - 1)}${sep}${right || " ".repeat(previewWidth - 2)}`;
586
- lines.push(` ${truncateToWidth(body, Math.max(0, width - 1), "")}`);
590
+ const left = truncateToWidth(optionLines[i] ?? "", leftWidth, "", true);
591
+ const right = truncateToWidth(previewLines[i] ?? "", previewWidth, "");
592
+ const body = `${left || " ".repeat(leftWidth)}${sep}${right || " ".repeat(previewWidth)}`;
593
+ lines.push(truncateToWidth(body, width, ""));
587
594
  }
588
595
  } else {
589
596
  for (const line of optionLines) lines.push(row(line));
@@ -602,8 +609,23 @@ export class AskQuestionnaire extends Container {
602
609
  "ctrl+c cancel",
603
610
  ];
604
611
  lines.push(row(dim(t)(hintParts.join(" • "))));
605
- lines.push("");
606
612
 
607
- return lines.map((l) => truncateToWidth(l, width, ""));
613
+ return this.frame(mw, lines, top);
614
+ }
615
+
616
+ /** Wrap body lines in the rounded modal border at the given outer width. */
617
+ private frame(
618
+ outerWidth: number,
619
+ lines: string[],
620
+ top: string | undefined,
621
+ ): string[] {
622
+ const t = this.theme;
623
+ return frameLines({
624
+ width: outerWidth,
625
+ lines,
626
+ top,
627
+ color: (s) => t.fg("accent", s),
628
+ bg: (s) => t.bg("customMessageBg", s),
629
+ });
608
630
  }
609
631
  }
package/src/schema.ts CHANGED
@@ -45,7 +45,7 @@ export const QuestionSchema = Type.Object({
45
45
  minItems: MIN_OPTIONS,
46
46
  maxItems: MAX_OPTIONS,
47
47
  description:
48
- "2-4 options. 'Type something.' and 'Chat about this' are auto-appended.",
48
+ "2-4 options. 'Type something.' is auto-appended for single-select.",
49
49
  }),
50
50
  multiSelect: Type.Optional(
51
51
  Type.Boolean({