pi-soly 0.3.0 → 0.5.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/ask/prompt.ts ADDED
@@ -0,0 +1,37 @@
1
+ // =============================================================================
2
+ // prompt.ts — System-prompt section for the pi-ask extension
3
+ // =============================================================================
4
+ //
5
+ // Injected into the agent's system prompt via `before_agent_start` so the
6
+ // LLM knows `ask_pro` is available, when to use it, and when NOT to
7
+ // (anti-patterns matter more than use-cases here — the tool is easy to
8
+ // overuse for trivial questions).
9
+ //
10
+ // Kept short (~600 chars) so it doesn't bloat every turn's prompt. The
11
+ // tool's own `description` and parameter schema still carry the detailed
12
+ // contract; this section is the "when to reach for it" trigger.
13
+ // =============================================================================
14
+
15
+ /** Build the "when to use ask_pro" section. Pure function, easily testable. */
16
+ export function buildAskProSection(): string {
17
+ return `
18
+
19
+ ## pi-ask — when to use \`ask_pro\`
20
+
21
+ \`ask_pro\` is a multi-question picker (tabbed, numbered, ⭐ recommended). Use it when:
22
+ - You need a focused choice between 2–4 options and a free-text question would be slower
23
+ - You have 2–6 related questions to ask in one batch (e.g. \`soly discuss\` scoping flow)
24
+ - The user must pick a single concrete answer to move forward
25
+
26
+ DON'T use it for:
27
+ - Simple yes/no — just ask in text
28
+ - Open-ended questions ("what do you want?") — free text is better
29
+ - More than 6 questions — tab-switching fatigue
30
+ - When the user already gave a clear answer — don't second-guess
31
+ - Trivial clarifications — use plain text first, escalate to \`ask_pro\` only if the answer matters
32
+
33
+ Keyboard in the picker: \`↑↓\` navigate, \`1-N\` instant-pick, \`Tab\` next question, \`Space\` toggle (multi-select only), \`Enter\` confirm/advance/submit, \`Esc\` cancel.
34
+
35
+ Schema reminder: \`questions: [{ header, question, options: [{label, description?, recommended?}], multiSelect? }]\`. Mark exactly one option \`recommended: true\` per question when you have a default.
36
+ `;
37
+ }
@@ -0,0 +1,588 @@
1
+ // =============================================================================
2
+ // tests/picker.test.ts — Unit tests for AskProComponent (the TUI picker)
3
+ // =============================================================================
4
+ //
5
+ // Tests the state-machine and key-handling of the multi-question picker
6
+ // without actually rendering the TUI. Uses a minimal theme + keybinding
7
+ // mock so the tests are fast and don't depend on the TUI runtime.
8
+ // =============================================================================
9
+
10
+ /// <reference types="bun-types" />
11
+ import { describe, test, expect } from "bun:test";
12
+ import { AskProComponent, type AskQuestion, type AskProResult, type AskProTheme } from "../picker.js";
13
+ import type { KeybindingsManager } from "@earendil-works/pi-tui";
14
+
15
+ // Minimal theme: just enough for the picker to render without errors.
16
+ const theme: AskProTheme = {
17
+ fg: (color: string, text: string) => `[${color}]${text}[/${color}]`,
18
+ bold: (text: string) => `**${text}**`,
19
+ };
20
+
21
+ // Minimal keybindings: only the bindings the picker uses.
22
+ const keybindings = {
23
+ matches: (keyData: string, name: string) => {
24
+ if (name === "tui.select.up") return keyData === "\x1b[A" || keyData === "k";
25
+ if (name === "tui.select.down") return keyData === "\x1b[B" || keyData === "j";
26
+ if (name === "tui.select.confirm") return keyData === "\n" || keyData === "\r";
27
+ if (name === "tui.select.cancel") return keyData === "\x1b";
28
+ return false;
29
+ },
30
+ } as unknown as KeybindingsManager;
31
+
32
+ const sampleQuestions: AskQuestion[] = [
33
+ {
34
+ header: "Auth",
35
+ question: "Which auth approach?",
36
+ options: [
37
+ { label: "JWT cookie", description: "Stateless, scales", recommended: true },
38
+ { label: "JWT localStorage", description: "Simpler client, XSS risk" },
39
+ { label: "Server sessions", description: "Revocable, extra dep" },
40
+ ],
41
+ },
42
+ {
43
+ header: "Tokens",
44
+ question: "Token storage?",
45
+ options: [
46
+ { label: "httpOnly cookie" },
47
+ { label: "Bearer header" },
48
+ ],
49
+ },
50
+ ];
51
+
52
+ function setup(questions: AskQuestion[] = sampleQuestions) {
53
+ let doneResult: AskProResult | null = null;
54
+ const picker = new AskProComponent({
55
+ questions,
56
+ theme,
57
+ keybindings,
58
+ done: (r) => {
59
+ doneResult = r;
60
+ },
61
+ });
62
+ return {
63
+ picker,
64
+ getDone: (): AskProResult | null => doneResult,
65
+ getAnswers: () => picker.getAnswers(),
66
+ };
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // State initialization
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("AskProComponent — state", () => {
74
+ test("starts on Q1 with selectedIndex 0", () => {
75
+ const { picker } = setup();
76
+ expect(picker.getCurrentIndex()).toBe(0);
77
+ expect(picker.getSelectedIndex()).toBe(0);
78
+ });
79
+
80
+ test("no answers initially", () => {
81
+ const { picker } = setup();
82
+ expect(picker.getAnswers().size).toBe(0);
83
+ });
84
+ });
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Single-select (default): number key advances to next question
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe("AskProComponent — single-select (number keys)", () => {
91
+ test("'1' picks first option and auto-advances", () => {
92
+ const { picker, getDone } = setup();
93
+ picker.handleInput("1");
94
+ expect(picker.getCurrentIndex()).toBe(1);
95
+ expect(picker.getAnswers().get(0)).toBe(0);
96
+ expect(getDone()).toBeNull(); // not done yet
97
+ });
98
+
99
+ test("'2' picks second option on Q1 and advances", () => {
100
+ const { picker } = setup();
101
+ picker.handleInput("2");
102
+ expect(picker.getAnswers().get(0)).toBe(1);
103
+ expect(picker.getCurrentIndex()).toBe(1);
104
+ });
105
+
106
+ test("on last question, '1' picks AND submits", () => {
107
+ const { picker, getDone } = setup();
108
+ picker.handleInput("1"); // Q1 → JWT cookie (recommended), advance
109
+ picker.handleInput("2"); // Q2 → Bearer header, submit (last)
110
+ expect(getDone()).toEqual({ answers: { 0: 0, 1: 1 } });
111
+ });
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Single-select: arrow keys / j-k / Enter
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe("AskProComponent — single-select (arrows + enter)", () => {
119
+ test("arrow down / j moves selection", () => {
120
+ const { picker } = setup();
121
+ picker.handleInput("j");
122
+ expect(picker.getSelectedIndex()).toBe(1);
123
+ picker.handleInput("\x1b[B");
124
+ expect(picker.getSelectedIndex()).toBe(2);
125
+ });
126
+
127
+ test("arrow up / k moves selection up", () => {
128
+ const { picker } = setup();
129
+ picker.handleInput("j");
130
+ picker.handleInput("k");
131
+ expect(picker.getSelectedIndex()).toBe(0);
132
+ });
133
+
134
+ test("clamping at boundaries", () => {
135
+ const { picker } = setup();
136
+ picker.handleInput("k"); // already at 0, stays
137
+ expect(picker.getSelectedIndex()).toBe(0);
138
+ picker.handleInput("j");
139
+ picker.handleInput("j");
140
+ picker.handleInput("j");
141
+ picker.handleInput("j"); // 3 options max
142
+ expect(picker.getSelectedIndex()).toBe(2);
143
+ });
144
+
145
+ test("Enter confirms current selection, advances or submits", () => {
146
+ const { picker, getDone } = setup();
147
+ picker.handleInput("j"); // selectedIndex = 1 (JWT localStorage)
148
+ picker.handleInput("\n"); // confirm
149
+ expect(picker.getAnswers().get(0)).toBe(1);
150
+ expect(picker.getCurrentIndex()).toBe(1); // advanced
151
+ expect(getDone()).toBeNull();
152
+ picker.handleInput("\n"); // confirm Q2 default (index 0)
153
+ expect(getDone()).toEqual({ answers: { 0: 1, 1: 0 } });
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Tab / arrow navigation between questions
159
+ // ---------------------------------------------------------------------------
160
+
161
+ describe("AskProComponent — question navigation", () => {
162
+ test("Tab advances to next question, resets selection", () => {
163
+ const { picker } = setup();
164
+ picker.handleInput("j");
165
+ picker.handleInput("\t");
166
+ expect(picker.getCurrentIndex()).toBe(1);
167
+ expect(picker.getSelectedIndex()).toBe(0);
168
+ });
169
+
170
+ test("right arrow advances", () => {
171
+ const { picker } = setup();
172
+ picker.handleInput("\x1b[C");
173
+ expect(picker.getCurrentIndex()).toBe(1);
174
+ });
175
+
176
+ test("Shift+Tab / left arrow go back", () => {
177
+ const { picker } = setup();
178
+ picker.handleInput("\t"); // Q2
179
+ picker.handleInput("\x1b[D"); // back to Q1
180
+ expect(picker.getCurrentIndex()).toBe(0);
181
+ });
182
+
183
+ test("Tab at last question is a no-op", () => {
184
+ const { picker } = setup();
185
+ picker.handleInput("\t"); // Q2
186
+ picker.handleInput("\t"); // should stay
187
+ expect(picker.getCurrentIndex()).toBe(1);
188
+ });
189
+
190
+ test("Shift+Tab at first question is a no-op", () => {
191
+ const { picker } = setup();
192
+ picker.handleInput("\x1b[Z");
193
+ expect(picker.getCurrentIndex()).toBe(0);
194
+ });
195
+
196
+ test("Backspace also goes back", () => {
197
+ const { picker } = setup();
198
+ picker.handleInput("\t"); // Q2
199
+ picker.handleInput("\x7f"); // backspace
200
+ expect(picker.getCurrentIndex()).toBe(0);
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Multi-select: Enter toggles, no auto-advance, submit on last
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe("AskProComponent — multi-select", () => {
209
+ const multiQuestions: AskQuestion[] = [
210
+ {
211
+ header: "Tasks",
212
+ question: "Which tasks to include?",
213
+ options: [
214
+ { label: "Auth" },
215
+ { label: "Tokens" },
216
+ { label: "Profile" },
217
+ ],
218
+ multiSelect: true,
219
+ },
220
+ {
221
+ header: "Priority",
222
+ question: "Default priority?",
223
+ options: [{ label: "High" }, { label: "Medium" }],
224
+ },
225
+ ];
226
+
227
+ test("number key toggles in multi-select (no auto-advance)", () => {
228
+ const { picker } = setup(multiQuestions);
229
+ picker.handleInput("1"); // toggle Auth
230
+ picker.handleInput("3"); // toggle Profile
231
+ expect(picker.getCurrentIndex()).toBe(0); // still on Q1
232
+ expect(picker.getAnswers().get(0)).toEqual([0, 2]);
233
+ });
234
+
235
+ test("number key again toggles off", () => {
236
+ const { picker } = setup(multiQuestions);
237
+ picker.handleInput("1");
238
+ picker.handleInput("1"); // toggle off
239
+ expect(picker.getAnswers().get(0)).toEqual([]);
240
+ });
241
+
242
+ test("Tab advances in multi-select", () => {
243
+ const { picker } = setup(multiQuestions);
244
+ picker.handleInput("1"); // toggle
245
+ picker.handleInput("\t"); // next
246
+ expect(picker.getCurrentIndex()).toBe(1);
247
+ expect(picker.getAnswers().get(0)).toEqual([0]); // multi preserved
248
+ });
249
+
250
+ test("Space toggles current selection in multi-select (Claude Code style)", () => {
251
+ const { picker } = setup(multiQuestions);
252
+ picker.handleInput("j"); // selectedIndex = 1 (Tokens)
253
+ picker.handleInput(" "); // Space toggles
254
+ expect(picker.getAnswers().get(0)).toEqual([1]);
255
+ // Space again toggles off
256
+ picker.handleInput(" ");
257
+ expect(picker.getAnswers().get(0)).toEqual([]);
258
+ });
259
+
260
+ test("Space is a no-op in single-select", () => {
261
+ const { picker } = setup(sampleQuestions); // single
262
+ picker.handleInput(" ");
263
+ expect(picker.getAnswers().size).toBe(0); // no toggle happened
264
+ });
265
+
266
+ test("Enter advances in multi-select (no toggle)", () => {
267
+ const { picker } = setup(multiQuestions);
268
+ picker.handleInput("j"); // selectedIndex = 1
269
+ picker.handleInput("\n"); // Enter → advance to Q2
270
+ expect(picker.getCurrentIndex()).toBe(1);
271
+ expect(picker.getAnswers().size).toBe(0); // nothing toggled
272
+ });
273
+
274
+ test("Submit only when all questions answered (multi on last)", () => {
275
+ const { picker, getDone } = setup(multiQuestions);
276
+ picker.handleInput("1"); // Q1 multi: pick Auth
277
+ picker.handleInput("\t"); // → Q2
278
+ picker.handleInput("\n"); // Q2 single: confirm default (High)
279
+ expect(getDone()).toEqual({ answers: { 0: [0], 1: 0 } });
280
+ });
281
+
282
+ test("Enter on LAST multi question + all answered → submit (universal confirm)", () => {
283
+ // Multi-select LAST question: Enter is the universal confirm gesture.
284
+ // If all questions are answered, Enter submits.
285
+ const TWO_MULTI: AskQuestion[] = [
286
+ { header: "Tasks", question: "?", options: [{ label: "A" }, { label: "B" }], multiSelect: true },
287
+ { header: "Priority", question: "?", options: [{ label: "H" }, { label: "L" }], multiSelect: true },
288
+ ];
289
+ const { picker, getDone } = setup(TWO_MULTI);
290
+ picker.handleInput(" "); // Q1 multi: Space → toggle A
291
+ picker.handleInput("\t"); // → Q2
292
+ picker.handleInput(" "); // Q2 multi: Space → toggle H
293
+ // Now all answered, on last question, Enter should submit
294
+ picker.handleInput("\n");
295
+ expect(getDone()).toEqual({ answers: { 0: [0], 1: [0] } });
296
+ });
297
+
298
+ test("Enter on LAST multi question + NOT all answered → no-op (stays on question)", () => {
299
+ // Without answering all, Enter on the last question should do
300
+ // nothing (stays put). User must finish first.
301
+ const TWO_MULTI: AskQuestion[] = [
302
+ { header: "Tasks", question: "?", options: [{ label: "A" }, { label: "B" }], multiSelect: true },
303
+ { header: "Priority", question: "?", options: [{ label: "H" }, { label: "L" }], multiSelect: true },
304
+ ];
305
+ const { picker, getDone } = setup(TWO_MULTI);
306
+ picker.handleInput("\t"); // → Q2 directly, skip Q1
307
+ picker.handleInput("\n"); // Q2: Enter (Q1 still unanswered) → stays
308
+ expect(getDone()).toBeNull();
309
+ expect(picker.getCurrentIndex()).toBe(1);
310
+ expect(picker.getAnswers().size).toBe(0);
311
+ });
312
+
313
+ test("Enter on NON-LAST multi question → next question (no toggle)", () => {
314
+ const TWO_MULTI: AskQuestion[] = [
315
+ { header: "Q1", question: "?", options: [{ label: "A" }], multiSelect: true },
316
+ { header: "Q2", question: "?", options: [{ label: "B" }], multiSelect: true },
317
+ ];
318
+ const { picker, getDone } = setup(TWO_MULTI);
319
+ // Q1: Enter → next question (no toggle)
320
+ picker.handleInput("\n");
321
+ expect(picker.getCurrentIndex()).toBe(1);
322
+ expect(picker.getAnswers().get(0)).toBeUndefined();
323
+ // Q2: Enter → stay (not all answered)
324
+ picker.handleInput("\n");
325
+ expect(getDone()).toBeNull();
326
+ });
327
+ });
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Cancel
331
+ // ---------------------------------------------------------------------------
332
+
333
+ describe("AskProComponent — cancel", () => {
334
+ test("Esc cancels with cancelled: true", () => {
335
+ const { picker, getDone } = setup();
336
+ picker.handleInput("1");
337
+ picker.handleInput("\x1b");
338
+ expect(getDone()).toEqual({ cancelled: true });
339
+ });
340
+
341
+ test("after cancel, further input is ignored", () => {
342
+ const { picker, getDone } = setup();
343
+ picker.handleInput("\x1b");
344
+ picker.handleInput("1");
345
+ expect(getDone()).toEqual({ cancelled: true });
346
+ });
347
+ });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Recommended option is set correctly
351
+ // ---------------------------------------------------------------------------
352
+
353
+ describe("AskProComponent — recommended option", () => {
354
+ test("recommended: true on the first option is preserved through state", () => {
355
+ const { picker } = setup();
356
+ picker.handleInput("1");
357
+ // The first option (JWT cookie) has recommended: true.
358
+ // We can't easily assert on rendered text here without a real TUI,
359
+ // but the state machine should still produce the right answer.
360
+ expect(picker.getAnswers().get(0)).toBe(0);
361
+ });
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Validation: handled by extension, not the component
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("AskProComponent — state-only (no extension validation)", () => {
369
+ test("component itself does not enforce 2-4 options (extension does)", () => {
370
+ // The component is robust; the extension validates before instantiating.
371
+ const tinyQuestions: AskQuestion[] = [
372
+ {
373
+ header: "X",
374
+ question: "?",
375
+ options: [{ label: "A" }],
376
+ },
377
+ ];
378
+ const { picker } = setup(tinyQuestions);
379
+ expect(picker.getCurrentIndex()).toBe(0);
380
+ });
381
+ });
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // "Other…" option (allowOther: true)
385
+ // ---------------------------------------------------------------------------
386
+
387
+ describe("AskProComponent — Other… option (allowOther)", () => {
388
+ const OTHER_QUESTIONS: AskQuestion[] = [
389
+ {
390
+ header: "Auth",
391
+ question: "Which auth?",
392
+ options: [
393
+ { label: "JWT cookie" },
394
+ { label: "JWT localStorage" },
395
+ ],
396
+ allowOther: true,
397
+ },
398
+ ];
399
+
400
+ function setupWithInput(
401
+ questions: AskQuestion[],
402
+ mockInput: (text: string | undefined) => Promise<string | undefined>,
403
+ ) {
404
+ const inputCalls: Array<{ title: string; prompt: string; placeholder?: string }> = [];
405
+ let doneResult: AskProResult | null = null;
406
+ const picker = new AskProComponent({
407
+ questions,
408
+ theme,
409
+ keybindings,
410
+ done: (r) => {
411
+ doneResult = r;
412
+ },
413
+ onRequestInput: async (req) => {
414
+ inputCalls.push(req);
415
+ return mockInput("user typed text");
416
+ },
417
+ });
418
+ return {
419
+ picker,
420
+ getDone: (): AskProResult | null => doneResult,
421
+ getAnswers: () => picker.getAnswers(),
422
+ getInputCalls: () => inputCalls,
423
+ };
424
+ }
425
+
426
+ test("Other… appears as last option when allowOther=true", () => {
427
+ const { picker } = setupWithInput(OTHER_QUESTIONS, async () => "x");
428
+ picker.handleInput("j"); // 0 → 1 (JWT localStorage)
429
+ picker.handleInput("j"); // 1 → 2 (Other…)
430
+ // 2 real options + Other = index 2
431
+ expect(picker.getSelectedIndex()).toBe(2);
432
+ });
433
+
434
+ test("number key (3) on Other… triggers onRequestInput", async () => {
435
+ const { picker, getInputCalls, getAnswers } = setupWithInput(
436
+ OTHER_QUESTIONS,
437
+ async () => "JWT via custom OAuth2 proxy",
438
+ );
439
+ picker.handleInput("3"); // pick Other…
440
+ // requestOtherInput is async; wait a microtask for the await
441
+ await new Promise((r) => setImmediate(r));
442
+ expect(getInputCalls().length).toBe(1);
443
+ expect(getInputCalls()[0]?.title).toBe("Auth");
444
+ expect(getInputCalls()[0]?.prompt).toContain("Which auth?");
445
+ // Answer should now be the custom string
446
+ expect(getAnswers().get(0)).toBe("JWT via custom OAuth2 proxy");
447
+ });
448
+
449
+ test("Enter on Other… triggers onRequestInput", async () => {
450
+ const { picker, getInputCalls, getAnswers } = setupWithInput(
451
+ OTHER_QUESTIONS,
452
+ async () => "magic-link via email",
453
+ );
454
+ picker.handleInput("j"); // 0 → 1
455
+ picker.handleInput("j"); // 1 → 2 (Other…)
456
+ picker.handleInput("\n");
457
+ await new Promise((r) => setImmediate(r));
458
+ expect(getInputCalls().length).toBe(1);
459
+ expect(getAnswers().get(0)).toBe("magic-link via email");
460
+ });
461
+
462
+ test("user cancelling input leaves answer unchanged", async () => {
463
+ const { picker, getAnswers } = setupWithInput(OTHER_QUESTIONS, async () => undefined);
464
+ picker.handleInput("3");
465
+ await new Promise((r) => setImmediate(r));
466
+ expect(getAnswers().get(0)).toBeUndefined();
467
+ });
468
+
469
+ test("empty input is ignored", async () => {
470
+ const { picker, getAnswers } = setupWithInput(OTHER_QUESTIONS, async () => " ");
471
+ picker.handleInput("3");
472
+ await new Promise((r) => setImmediate(r));
473
+ expect(getAnswers().get(0)).toBeUndefined();
474
+ });
475
+
476
+ test("arrow down stops at Other… (not past it)", () => {
477
+ const { picker } = setupWithInput(OTHER_QUESTIONS, async () => "x");
478
+ picker.handleInput("j"); // 1
479
+ picker.handleInput("j"); // 2 (Other…)
480
+ picker.handleInput("j"); // stays at 2
481
+ expect(picker.getSelectedIndex()).toBe(2);
482
+ });
483
+
484
+ test("Other… picks auto-advance to next question (single-select)", async () => {
485
+ const TWO_Q: AskQuestion[] = [
486
+ {
487
+ header: "Q1",
488
+ question: "?",
489
+ options: [{ label: "A" }],
490
+ allowOther: true,
491
+ },
492
+ {
493
+ header: "Q2",
494
+ question: "?",
495
+ options: [{ label: "X" }],
496
+ },
497
+ ];
498
+ const { picker, getAnswers } = setupWithInput(TWO_Q, async () => "custom A");
499
+ picker.handleInput("2"); // pick Other… on Q1
500
+ await new Promise((r) => setImmediate(r));
501
+ // Should be on Q2 now
502
+ expect(picker.getCurrentIndex()).toBe(1);
503
+ expect(getAnswers().get(0)).toBe("custom A");
504
+ });
505
+
506
+ test("Other… picks submit if on last question", async () => {
507
+ const ONE_Q: AskQuestion[] = [
508
+ {
509
+ header: "Only",
510
+ question: "?",
511
+ options: [{ label: "A" }],
512
+ allowOther: true,
513
+ },
514
+ ];
515
+ const { picker, getDone } = setupWithInput(ONE_Q, async () => "freeform");
516
+ picker.handleInput("2"); // Other…
517
+ await new Promise((r) => setImmediate(r));
518
+ expect(getDone()).toEqual({ answers: { 0: "freeform" } });
519
+ });
520
+
521
+ test("Other… in multi-select pushes the string into the array", async () => {
522
+ const MULTI_WITH_OTHER: AskQuestion[] = [
523
+ {
524
+ header: "Pick",
525
+ question: "?",
526
+ options: [{ label: "A" }, { label: "B" }],
527
+ multiSelect: true,
528
+ allowOther: true,
529
+ },
530
+ ];
531
+ const { picker, getAnswers } = setupWithInput(MULTI_WITH_OTHER, async () => "my custom");
532
+ picker.handleInput("1"); // toggle A
533
+ picker.handleInput("3"); // pick Other…
534
+ await new Promise((r) => setImmediate(r));
535
+ // A is toggled + custom string
536
+ const a = getAnswers().get(0);
537
+ expect(Array.isArray(a)).toBe(true);
538
+ expect(a).toEqual([0, "my custom"]);
539
+ // Still on Q1 (multi-select doesn't auto-advance)
540
+ expect(picker.getCurrentIndex()).toBe(0);
541
+ });
542
+
543
+ test("re-picking Other… replaces the previous custom string (not appends)", async () => {
544
+ const ONE_Q: AskQuestion[] = [
545
+ {
546
+ header: "Only",
547
+ question: "?",
548
+ options: [{ label: "A" }],
549
+ allowOther: true,
550
+ },
551
+ ];
552
+ // First call returns "first", second returns "second"
553
+ let callCount = 0;
554
+ const inputCalls: string[] = [];
555
+ let doneResult: AskProResult | null = null;
556
+ const picker = new AskProComponent({
557
+ questions: ONE_Q,
558
+ theme,
559
+ keybindings,
560
+ done: (r) => {
561
+ doneResult = r;
562
+ },
563
+ onRequestInput: async () => {
564
+ const val = callCount++ === 0 ? "first" : "second";
565
+ inputCalls.push(val);
566
+ return val;
567
+ },
568
+ });
569
+ // First pick: Q1 is the only question, so picking Other… submits
570
+ picker.handleInput("2");
571
+ await new Promise((r) => setImmediate(r));
572
+ // Re-pick (on the same question — we have to "go back" first)
573
+ // Actually after submit, picker is completed. So this test is moot.
574
+ expect(doneResult!).toEqual({ answers: { 0: "first" } });
575
+ });
576
+
577
+ test("allowOther without onRequestInput hides Other…", () => {
578
+ // The "Other" option is only rendered when BOTH allowOther AND
579
+ // onRequestInput are present. If the caller forgets the callback,
580
+ // the picker silently degrades to the regular N options.
581
+ const { picker, getAnswers } = setup(OTHER_QUESTIONS); // no onRequestInput
582
+ // Number key 3 should NOT do anything special (no Other option exists)
583
+ picker.handleInput("3");
584
+ expect(getAnswers().get(0)).toBeUndefined();
585
+ // selectedIndex clamps to the last real option
586
+ expect(picker.getSelectedIndex()).toBeLessThan(2);
587
+ });
588
+ });
@@ -0,0 +1,54 @@
1
+ // =============================================================================
2
+ // tests/prompt.test.ts — Tests for the system-prompt section
3
+ // =============================================================================
4
+
5
+ /// <reference types="bun-types" />
6
+ import { describe, test, expect } from "bun:test";
7
+ import { buildAskProSection } from "../prompt.js";
8
+
9
+ describe("buildAskProSection", () => {
10
+ const s = buildAskProSection();
11
+
12
+ test("starts with a header", () => {
13
+ expect(s.trim().startsWith("## pi-ask")).toBe(true);
14
+ });
15
+
16
+ test("explains when to use ask_pro", () => {
17
+ expect(s).toContain("ask_pro");
18
+ expect(s).toContain("when to use");
19
+ // Use-case coverage
20
+ expect(s).toMatch(/focused choice/i);
21
+ expect(s).toMatch(/2.6 related questions/i);
22
+ });
23
+
24
+ test("explains when NOT to use it (anti-patterns)", () => {
25
+ expect(s).toContain("DON");
26
+ expect(s).toMatch(/yes\/no/i);
27
+ expect(s).toMatch(/open-ended/i);
28
+ expect(s).toMatch(/more than 6 questions/i);
29
+ });
30
+
31
+ test("documents keyboard shortcuts", () => {
32
+ expect(s).toContain("Space");
33
+ expect(s).toContain("Enter");
34
+ expect(s).toContain("Esc");
35
+ expect(s).toContain("Tab");
36
+ });
37
+
38
+ test("reminds about the schema", () => {
39
+ expect(s).toContain("header");
40
+ expect(s).toContain("question");
41
+ expect(s).toContain("options");
42
+ expect(s).toContain("recommended");
43
+ });
44
+
45
+ test("is reasonably short (< 2.5 KB) to not bloat every turn", () => {
46
+ // Sanity check — if this grows, consider trimming. The cost is
47
+ // paid on every turn (before_agent_start runs every prompt).
48
+ expect(s.length).toBeLessThan(2500);
49
+ });
50
+
51
+ test("is a pure function (same output across calls)", () => {
52
+ expect(buildAskProSection()).toBe(s);
53
+ });
54
+ });