@xynogen/pix-ask 0.2.6 → 0.2.7

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.7",
4
4
  "description": "Pi tool — structured questionnaire UI (ask_user)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/ask.test.ts CHANGED
@@ -17,11 +17,7 @@ import {
17
17
 
18
18
  // ── Fixtures ──────────────────────────────────────────────────────────
19
19
 
20
- const opt = (
21
- label: string,
22
- description = "Test option",
23
- preview?: string,
24
- ): OptionData => ({
20
+ const opt = (label: string, description = "Test option", preview?: string): OptionData => ({
25
21
  label,
26
22
  description,
27
23
  ...(preview ? { preview } : {}),
@@ -30,10 +26,7 @@ const opt = (
30
26
  const qSingle: QuestionData = {
31
27
  question: "Which approach?",
32
28
  header: "Approach",
33
- options: [
34
- opt("REST", "Traditional REST API"),
35
- opt("GraphQL", "Query language for APIs"),
36
- ],
29
+ options: [opt("REST", "Traditional REST API"), opt("GraphQL", "Query language for APIs")],
37
30
  };
38
31
 
39
32
  const qMulti: QuestionData = {
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/frame.ts CHANGED
@@ -60,8 +60,7 @@ export function frameLines(opts: FrameOptions): string[] {
60
60
 
61
61
  const row = (content: string): string => {
62
62
  const pad = inner - visibleWidth(content);
63
- const padded =
64
- pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
63
+ const padded = pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
65
64
  return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
66
65
  };
67
66
 
@@ -4,36 +4,28 @@ import { checkboxGlyphs, selectionGlyph } from "./glyphs.js";
4
4
 
5
5
  describe("selectionGlyph", () => {
6
6
  test("multi checked → ▣ / success", () => {
7
- expect(
8
- selectionGlyph({ multi: true, selected: false, checked: true }),
9
- ).toEqual({
7
+ expect(selectionGlyph({ multi: true, selected: false, checked: true })).toEqual({
10
8
  glyph: "▣",
11
9
  color: "success",
12
10
  });
13
11
  });
14
12
 
15
13
  test("multi unchecked → ☐ / dim", () => {
16
- expect(
17
- selectionGlyph({ multi: true, selected: true, checked: false }),
18
- ).toEqual({
14
+ expect(selectionGlyph({ multi: true, selected: true, checked: false })).toEqual({
19
15
  glyph: "☐",
20
16
  color: "dim",
21
17
  });
22
18
  });
23
19
 
24
20
  test("radio selected → ◉ / accent", () => {
25
- expect(
26
- selectionGlyph({ multi: false, selected: true, checked: false }),
27
- ).toEqual({
21
+ expect(selectionGlyph({ multi: false, selected: true, checked: false })).toEqual({
28
22
  glyph: "◉",
29
23
  color: "accent",
30
24
  });
31
25
  });
32
26
 
33
27
  test("radio unselected → ○ / dim", () => {
34
- expect(
35
- selectionGlyph({ multi: false, selected: false, checked: false }),
36
- ).toEqual({
28
+ expect(selectionGlyph({ multi: false, selected: false, checked: false })).toEqual({
37
29
  glyph: "○",
38
30
  color: "dim",
39
31
  });
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
@@ -6,13 +6,7 @@ import { once } from "./once.ts";
6
6
  import { AskQuestionnaire } from "./questionnaire.js";
7
7
  import { rpcFallback } from "./rpc.js";
8
8
  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";
9
+ import { MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, ParamsSchema } from "./schema.js";
16
10
  import type { QuestionAnswer, QuestionnaireResult } from "./types.js";
17
11
 
18
12
  // ── Re-exports (consumed by tests and single-select-layout) ───────────
@@ -41,8 +35,6 @@ export default function registerAsk(pi: ExtensionAPI): void {
41
35
  promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
42
36
  promptGuidelines: [
43
37
  `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
38
  "Do not stack multiple ask calls back-to-back — group all clarifying questions into one invocation.",
47
39
  ],
48
40
  executionMode: "sequential",
@@ -60,9 +52,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
60
52
 
61
53
  if (!Array.isArray(typed.questions) || typed.questions.length === 0) {
62
54
  return {
63
- content: [
64
- { type: "text", text: "At least one question is required." },
65
- ],
55
+ content: [{ type: "text", text: "At least one question is required." }],
66
56
  isError: true,
67
57
  details: { answers: [], cancelled: true },
68
58
  };
@@ -79,11 +69,9 @@ export default function registerAsk(pi: ExtensionAPI): void {
79
69
  const result = await ctx.ui.custom<QuestionnaireResult | null>(
80
70
  (tui, theme, keybindings, done) => {
81
71
  if (signal) {
82
- signal.addEventListener(
83
- "abort",
84
- () => done({ answers: [], cancelled: true }),
85
- { once: true },
86
- );
72
+ signal.addEventListener("abort", () => done({ answers: [], cancelled: true }), {
73
+ once: true,
74
+ });
87
75
  }
88
76
  return new AskQuestionnaire(typed, tui, theme, keybindings, done);
89
77
  },
@@ -92,9 +80,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
92
80
 
93
81
  if (!result || result.cancelled) {
94
82
  return {
95
- content: [
96
- { type: "text", text: "User cancelled the questionnaire" },
97
- ],
83
+ content: [{ type: "text", text: "User cancelled the questionnaire" }],
98
84
  details: result ?? { answers: [], cancelled: true },
99
85
  };
100
86
  }
@@ -124,10 +110,7 @@ export default function registerAsk(pi: ExtensionAPI): void {
124
110
  return new Text(theme.fg("warning", "Cancelled"), 0, 0);
125
111
  }
126
112
  const texts = details.answers.map((a) => {
127
- const v =
128
- a.kind === "multi"
129
- ? (a.selected ?? []).join(", ")
130
- : (a.answer ?? "");
113
+ const v = a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? "");
131
114
  return `${a.questionIndex + 1}: ${v}`;
132
115
  });
133
116
  return new Text(theme.fg("success", `✓ ${texts.join(" • ")}`), 0, 0);
@@ -17,17 +17,8 @@ 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
 
@@ -84,8 +75,7 @@ export class AskQuestionnaire extends Container {
84
75
  label?: string;
85
76
  option?: OptionData;
86
77
  }> {
87
- const items: Array<{ kind: string; label?: string; option?: OptionData }> =
88
- [];
78
+ const items: Array<{ kind: string; label?: string; option?: OptionData }> = [];
89
79
  for (const o of this.filteredOptions) {
90
80
  items.push({ kind: "option", option: o });
91
81
  }
@@ -137,9 +127,7 @@ export class AskQuestionnaire extends Container {
137
127
  selected?: string[],
138
128
  preview?: string,
139
129
  ): void {
140
- this.answers = this.answers.filter(
141
- (a) => a.questionIndex !== this.currentIndex,
142
- );
130
+ this.answers = this.answers.filter((a) => a.questionIndex !== this.currentIndex);
143
131
  this.answers.push({
144
132
  questionIndex: this.currentIndex,
145
133
  question: this.currentQ.question,
@@ -158,12 +146,7 @@ export class AskQuestionnaire extends Container {
158
146
  }
159
147
 
160
148
  if (item.kind === "option" && item.option) {
161
- this.recordAnswer(
162
- "option",
163
- item.option.label,
164
- undefined,
165
- item.option.preview,
166
- );
149
+ this.recordAnswer("option", item.option.label, undefined, item.option.preview);
167
150
  this.nextQuestion();
168
151
  } else if (item.kind === "other") {
169
152
  this.inputMode = true;
@@ -204,9 +187,7 @@ export class AskQuestionnaire extends Container {
204
187
  }
205
188
 
206
189
  private restoreAnswerState(): void {
207
- const prev = this.answers.find(
208
- (a) => a.questionIndex === this.currentIndex,
209
- );
190
+ const prev = this.answers.find((a) => a.questionIndex === this.currentIndex);
210
191
  if (!prev) return;
211
192
  const q = this.currentQ;
212
193
  if (prev.kind === "multi") {
@@ -287,8 +268,7 @@ export class AskQuestionnaire extends Container {
287
268
  matchesKey(data, Key.ctrl("k"))
288
269
  ) {
289
270
  if (total > 0) {
290
- this.selectedOptionIndex =
291
- (this.selectedOptionIndex - 1 + total) % total;
271
+ this.selectedOptionIndex = (this.selectedOptionIndex - 1 + total) % total;
292
272
  this.refresh();
293
273
  }
294
274
  return;
@@ -423,10 +403,7 @@ export class AskQuestionnaire extends Container {
423
403
  const maxVisible = Math.min(total, 12);
424
404
  const start = Math.max(
425
405
  0,
426
- Math.min(
427
- this.selectedOptionIndex - Math.floor(maxVisible / 2),
428
- total - maxVisible,
429
- ),
406
+ Math.min(this.selectedOptionIndex - Math.floor(maxVisible / 2), total - maxVisible),
430
407
  );
431
408
  const end = Math.min(start + maxVisible, total);
432
409
 
@@ -462,24 +439,18 @@ export class AskQuestionnaire extends Container {
462
439
  const label = sel
463
440
  ? t.fg("accent", t.bold(SENTINEL_FREEFORM))
464
441
  : t.fg("text", t.bold(SENTINEL_FREEFORM));
465
- lines.push(
466
- truncateToWidth(`${ptr} ${t.fg("dim", "✎")} ${label}`, inner, ""),
467
- );
442
+ lines.push(truncateToWidth(`${ptr} ${t.fg("dim", "✎")} ${label}`, inner, ""));
468
443
  } else if (item.kind === "next") {
469
444
  const label = sel
470
445
  ? t.fg("accent", t.bold(SENTINEL_NEXT))
471
446
  : t.fg("text", t.bold(SENTINEL_NEXT));
472
- lines.push(
473
- truncateToWidth(`${ptr} ${t.fg("dim", "→")} ${label}`, inner, ""),
474
- );
447
+ lines.push(truncateToWidth(`${ptr} ${t.fg("dim", "→")} ${label}`, inner, ""));
475
448
  }
476
449
  }
477
450
 
478
451
  if (start > 0 || end < total) {
479
452
  const count =
480
- this.filteredOptions.length > 0
481
- ? `${this.selectedOptionIndex + 1}/${total}`
482
- : `${total}`;
453
+ this.filteredOptions.length > 0 ? `${this.selectedOptionIndex + 1}/${total}` : `${total}`;
483
454
  lines.push(t.fg("dim", truncateToWidth(` ${count}`, inner, "")));
484
455
  }
485
456
 
@@ -496,19 +467,12 @@ export class AskQuestionnaire extends Container {
496
467
  const mdWidth = Math.max(10, width);
497
468
 
498
469
  if (this.mdTheme) {
499
- const md = new Markdown(
500
- `## ${item.option.label}\n\n${mdText}`,
501
- 0,
502
- 0,
503
- this.mdTheme,
504
- );
470
+ const md = new Markdown(`## ${item.option.label}\n\n${mdText}`, 0, 0, this.mdTheme);
505
471
  return md.render(mdWidth);
506
472
  }
507
473
 
508
474
  const lines = wrapTextWithAnsi(mdText, mdWidth);
509
- return lines.map((l) =>
510
- truncateToWidth(this.theme.fg("muted", l), mdWidth, ""),
511
- );
475
+ return lines.map((l) => truncateToWidth(this.theme.fg("muted", l), mdWidth, ""));
512
476
  }
513
477
 
514
478
  override render(termWidth: number): string[] {
@@ -520,9 +484,7 @@ export class AskQuestionnaire extends Container {
520
484
  const t = this.theme;
521
485
  const isMulti = !!this.currentQ.multiSelect;
522
486
  const hasPreview =
523
- !isMulti &&
524
- this.selectedItem?.kind === "option" &&
525
- !!this.selectedItem?.option?.preview;
487
+ !isMulti && this.selectedItem?.kind === "option" && !!this.selectedItem?.option?.preview;
526
488
 
527
489
  const useSplit = hasPreview && width >= SPLIT_PANE_MIN_WIDTH;
528
490
  const leftWidth = useSplit ? Math.floor((width - 2) * 0.45) : inner;
@@ -530,8 +492,7 @@ export class AskQuestionnaire extends Container {
530
492
 
531
493
  const lines: string[] = [];
532
494
 
533
- const row = (content: string): string =>
534
- truncateToWidth(content, width, "");
495
+ const row = (content: string): string => truncateToWidth(content, width, "");
535
496
 
536
497
  // Tab bar — rendered as the framed top edge (frameLines `top`).
537
498
  let top: string | undefined;
@@ -554,10 +515,7 @@ export class AskQuestionnaire extends Container {
554
515
  lines.push(row(`${chip}${prog}`));
555
516
 
556
517
  // Question text
557
- for (const w of wrapTextWithAnsi(
558
- this.currentQ.question,
559
- Math.max(10, inner),
560
- )) {
518
+ for (const w of wrapTextWithAnsi(this.currentQ.question, Math.max(10, inner))) {
561
519
  lines.push(row(t.fg("text", t.bold(w))));
562
520
  }
563
521
 
@@ -601,28 +559,17 @@ export class AskQuestionnaire extends Container {
601
559
  }
602
560
 
603
561
  // Footer hints
604
- const navHint =
605
- this.params.questions.length > 1 ? "↑↓ nav • ←→ question" : "↑↓ nav";
562
+ const navHint = this.params.questions.length > 1 ? "↑↓ nav • ←→ question" : "↑↓ nav";
606
563
  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
- ];
564
+ ? [`${navHint} • space toggle • enter commit • esc clear`, "ctrl+c cancel"]
565
+ : [`${navHint} • type filter • enter select • esc clear`, "ctrl+c cancel"];
615
566
  lines.push(row(dim(t)(hintParts.join(" • "))));
616
567
 
617
568
  return this.frame(mw, lines, top);
618
569
  }
619
570
 
620
571
  /** 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[] {
572
+ private frame(outerWidth: number, lines: string[], top: string | undefined): string[] {
626
573
  const t = this.theme;
627
574
  return frameLines({
628
575
  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
  });