@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 +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 +3 -3
- package/src/questionnaire.ts +75 -53
- package/src/schema.ts +1 -1
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
|
@@ -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)
|
|
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)
|
|
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) {
|
package/src/questionnaire.ts
CHANGED
|
@@ -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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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(
|
|
492
|
-
|
|
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 -
|
|
502
|
-
const previewWidth = useSplit ? Math.max(20, width - leftWidth -
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
565
|
+
const editorLines = this.ensureEditor().render(width);
|
|
542
566
|
for (const el of editorLines) {
|
|
543
|
-
lines.push(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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), "")}`);
|
|
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
|
|
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.'
|
|
48
|
+
"2-4 options. 'Type something.' is auto-appended for single-select.",
|
|
49
49
|
}),
|
|
50
50
|
multiSelect: Type.Optional(
|
|
51
51
|
Type.Boolean({
|