@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 +6 -0
- package/package.json +1 -1
- package/src/frame.test.ts +39 -0
- package/src/frame.ts +49 -0
- package/src/glyphs.test.ts +48 -0
- package/src/glyphs.ts +36 -0
- package/src/index.ts +1 -0
- package/src/questionnaire.ts +75 -45
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
|
@@ -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
package/src/questionnaire.ts
CHANGED
|
@@ -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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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(
|
|
492
|
-
|
|
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 -
|
|
502
|
-
const previewWidth = useSplit ? Math.max(20, width - leftWidth -
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
566
|
+
const editorLines = this.ensureEditor().render(width);
|
|
542
567
|
for (const el of editorLines) {
|
|
543
|
-
lines.push(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
576
|
-
|
|
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
|
|
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
|
}
|