@xynogen/pix-ask 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -12,6 +12,12 @@ Registers the `ask_user` tool in Pi. When the agent needs to resolve ambiguous r
12
12
  pi install npm:@xynogen/pix-ask
13
13
  ```
14
14
 
15
+ > Also included in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core):
16
+ >
17
+ > ```bash
18
+ > pi install npm:@xynogen/pix-core
19
+ > ```
20
+
15
21
  ## Full distro
16
22
 
17
23
  Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ask",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
@@ -88,6 +88,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
88
88
  }
89
89
  return new AskQuestionnaire(typed, tui, theme, keybindings, done);
90
90
  },
91
+ { overlay: true },
91
92
  );
92
93
 
93
94
  if (!result || result.cancelled) {
@@ -13,6 +13,8 @@ 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 {
@@ -251,11 +253,9 @@ export class AskQuestionnaire extends Container {
251
253
  // ── Input handling ─────────────────────────────────────────────────
252
254
 
253
255
  handleInput(data: string): void {
254
- if (this.keybindings.matches(data, "tui.select.cancel")) {
255
- this.cancel();
256
- return;
257
- }
258
-
256
+ // Input mode owns esc (back to options) — handle it BEFORE the global
257
+ // cancel guard, which is also bound to esc and would otherwise close the
258
+ // whole questionnaire instead of stepping back.
259
259
  if (this.inputMode) {
260
260
  if (matchesKey(data, Key.escape)) {
261
261
  this.inputMode = false;
@@ -263,11 +263,20 @@ export class AskQuestionnaire extends Container {
263
263
  this.refresh();
264
264
  return;
265
265
  }
266
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
267
+ this.cancel();
268
+ return;
269
+ }
266
270
  this.ensureEditor().handleInput(data);
267
271
  this.tui.requestRender();
268
272
  return;
269
273
  }
270
274
 
275
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
276
+ this.cancel();
277
+ return;
278
+ }
279
+
271
280
  const isMulti = !!this.currentQ.multiSelect;
272
281
  const total = this.totalItems;
273
282
 
@@ -389,12 +398,23 @@ export class AskQuestionnaire extends Container {
389
398
  const isMulti = !!this.currentQ.multiSelect;
390
399
  const items = this.mainListItems;
391
400
  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
- : "";
401
+ const box = checkboxGlyphs();
402
+ // Glyph per option: checkbox (▣/☐) for multi, radio (◉/○) for single.
403
+ // `sel` = cursor row; for radio the cursor row IS the chosen one.
404
+ const glyphFor = (optIdx: number, sel: boolean): string => {
405
+ const g = selectionGlyph({
406
+ multi: isMulti,
407
+ selected: sel,
408
+ checked: this.multiChecked.has(optIdx),
409
+ });
410
+ // width-safety fallback only affects the checkbox pair
411
+ const glyph = isMulti
412
+ ? this.multiChecked.has(optIdx)
413
+ ? box.checked
414
+ : box.unchecked
415
+ : g.glyph;
416
+ return t.fg(g.color as Parameters<typeof t.fg>[0], glyph);
417
+ };
398
418
 
399
419
  if (total === 0) return [t.fg("warning", "No options")];
400
420
 
@@ -409,7 +429,10 @@ export class AskQuestionnaire extends Container {
409
429
  const end = Math.min(start + maxVisible, total);
410
430
 
411
431
  const lines: string[] = [];
412
- const pad = " ";
432
+ // Hang-indent descriptions under the LABEL column, not the pointer.
433
+ // Prefix is `→ G ` = ptr(1)+sp(1)+glyph(1)+sp(1) = 4 cols.
434
+ const LABEL_COL = 4;
435
+ const pad = " ".repeat(LABEL_COL);
413
436
 
414
437
  for (let i = start; i < end; i++) {
415
438
  const item = items[i]!;
@@ -418,18 +441,15 @@ export class AskQuestionnaire extends Container {
418
441
 
419
442
  if (item.kind === "option" && item.option) {
420
443
  const optIdx = this.filteredOptions.indexOf(item.option);
421
- const checkbox = isMulti ? ` ${chk(optIdx)}` : "";
422
- const num = t.fg("dim", `${optIdx + 1}.`);
444
+ const glyph = glyphFor(optIdx, sel);
423
445
  const label = sel
424
446
  ? t.fg("accent", t.bold(item.option.label))
425
447
  : t.fg("text", t.bold(item.option.label));
426
- lines.push(
427
- truncateToWidth(`${ptr} ${num}${checkbox} ${label}`, inner, ""),
428
- );
448
+ lines.push(truncateToWidth(`${ptr} ${glyph} ${label}`, inner, ""));
429
449
  if (item.option.description) {
430
450
  const wrapped = wrapTextWithAnsi(
431
451
  item.option.description,
432
- Math.max(10, inner - 6),
452
+ Math.max(10, inner - LABEL_COL),
433
453
  );
434
454
  for (const w of wrapped) {
435
455
  lines.push(truncateToWidth(`${pad}${t.fg("muted", w)}`, inner, ""));
@@ -488,8 +508,12 @@ export class AskQuestionnaire extends Container {
488
508
  );
489
509
  }
490
510
 
491
- override render(width: number): string[] {
492
- const inner = Math.max(20, width - 4);
511
+ override render(termWidth: number): string[] {
512
+ // Cap to a fixed-width floating modal; render content at the inner width
513
+ // and frame it with a rounded border (see frameLines).
514
+ const mw = modalWidth(termWidth);
515
+ const width = mw - 4; // border (2) + padding (2)
516
+ const inner = Math.max(20, width);
493
517
  const t = this.theme;
494
518
  const isMulti = !!this.currentQ.multiSelect;
495
519
  const hasPreview =
@@ -498,15 +522,16 @@ export class AskQuestionnaire extends Container {
498
522
  !!this.selectedItem?.option?.preview;
499
523
 
500
524
  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;
525
+ const leftWidth = useSplit ? Math.floor((width - 2) * 0.45) : inner;
526
+ const previewWidth = useSplit ? Math.max(20, width - leftWidth - 3) : 0;
503
527
 
504
528
  const lines: string[] = [];
505
529
 
506
530
  const row = (content: string): string =>
507
- ` ${truncateToWidth(content, Math.max(0, width - 1), "")}`;
531
+ truncateToWidth(content, width, "");
508
532
 
509
- // Tab bar
533
+ // Tab bar — rendered as the framed top edge (frameLines `top`).
534
+ let top: string | undefined;
510
535
  if (this.params.questions.length > 1) {
511
536
  const tabParts: string[] = [];
512
537
  for (let i = 0; i < this.params.questions.length; i++) {
@@ -514,7 +539,7 @@ export class AskQuestionnaire extends Container {
514
539
  const tag = `${i + 1}.${this.params.questions[i]?.header}`;
515
540
  tabParts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
516
541
  }
517
- lines.push(row(tabParts.join(t.fg("dim", " "))));
542
+ top = truncateToWidth(tabParts.join(t.fg("dim", " ")), width, "");
518
543
  }
519
544
 
520
545
  // Header chip
@@ -538,14 +563,13 @@ export class AskQuestionnaire extends Container {
538
563
  lines.push("");
539
564
  lines.push(row(t.fg("accent", t.bold("Type your response:"))));
540
565
  lines.push("");
541
- const editorLines = this.ensureEditor().render(Math.max(0, width - 1));
566
+ const editorLines = this.ensureEditor().render(width);
542
567
  for (const el of editorLines) {
543
- lines.push(` ${truncateToWidth(el, Math.max(0, width - 1), "")}`);
568
+ lines.push(truncateToWidth(el, width, ""));
544
569
  }
545
570
  lines.push("");
546
571
  lines.push(row(dim(t)("enter submit • esc back • ctrl+c cancel")));
547
- lines.push("");
548
- return lines.map((l) => truncateToWidth(l, width, ""));
572
+ return this.frame(mw, lines, top);
549
573
  }
550
574
 
551
575
  // Search bar
@@ -564,26 +588,17 @@ export class AskQuestionnaire extends Container {
564
588
  lines.push(row(` ${t.fg("dim", "💬")} ${chatLabel}`));
565
589
 
566
590
  // Options (with optional split-pane preview)
567
- const optionLines = this.renderOptions(useSplit ? leftWidth : width - 4);
591
+ const optionLines = this.renderOptions(useSplit ? leftWidth : width);
568
592
  const previewLines = useSplit ? this.renderPreview(previewWidth) : [];
569
593
  const maxOptLines = Math.max(optionLines.length, previewLines.length);
570
594
 
571
595
  if (useSplit) {
572
596
  const sep = t.fg("dim", SEPARATOR);
573
597
  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), "")}`);
598
+ const left = truncateToWidth(optionLines[i] ?? "", leftWidth, "", true);
599
+ const right = truncateToWidth(previewLines[i] ?? "", previewWidth, "");
600
+ const body = `${left || " ".repeat(leftWidth)}${sep}${right || " ".repeat(previewWidth)}`;
601
+ lines.push(truncateToWidth(body, width, ""));
587
602
  }
588
603
  } else {
589
604
  for (const line of optionLines) lines.push(row(line));
@@ -602,8 +617,23 @@ export class AskQuestionnaire extends Container {
602
617
  "ctrl+c cancel",
603
618
  ];
604
619
  lines.push(row(dim(t)(hintParts.join(" • "))));
605
- lines.push("");
606
620
 
607
- return lines.map((l) => truncateToWidth(l, width, ""));
621
+ return this.frame(mw, lines, top);
622
+ }
623
+
624
+ /** Wrap body lines in the rounded modal border at the given outer width. */
625
+ private frame(
626
+ outerWidth: number,
627
+ lines: string[],
628
+ top: string | undefined,
629
+ ): string[] {
630
+ const t = this.theme;
631
+ return frameLines({
632
+ width: outerWidth,
633
+ lines,
634
+ top,
635
+ color: (s) => t.fg("accent", s),
636
+ bg: (s) => t.bg("customMessageBg", s),
637
+ });
608
638
  }
609
639
  }