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/README.md +135 -0
- package/ask/index.ts +218 -0
- package/ask/package.json +51 -0
- package/ask/picker.ts +686 -0
- package/ask/prompt.ts +37 -0
- package/ask/tests/picker.test.ts +588 -0
- package/ask/tests/prompt.test.ts +54 -0
- package/ask/tsconfig.json +28 -0
- package/index.ts +8 -0
- package/package.json +6 -2
- package/switch/README.md +107 -0
- package/switch/core.ts +202 -0
- package/switch/index.ts +300 -0
- package/switch/package.json +52 -0
- package/switch/prompt.ts +134 -0
- package/switch/tests/core.test.ts +188 -0
- package/switch/tests/index.test.ts +47 -0
- package/switch/tests/prompt.test.ts +106 -0
- package/switch/tsconfig.json +28 -0
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
|
+
});
|