@ryan_nookpi/pi-extension-ask-user-question 0.1.1 → 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/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # @ryan_nookpi/pi-extension-ask-user-question
2
2
 
3
- This extension lets pi ask the user questions and wait for an answer during a task.
3
+ Interactive multi-question form tool for pi.
4
4
 
5
- It is useful when the request is ambiguous, when the user needs to choose between options, or when you need input before continuing.
5
+ After installation, pi can use the `ask_user_question` tool to collect structured input from the user with:
6
+
7
+ - `radio` questions for single choice
8
+ - `checkbox` questions for multiple choice
9
+ - `text` questions for free-form answers
10
+ - optional `Other...` inputs for custom answers
6
11
 
7
12
  ## Install
8
13
 
@@ -10,17 +15,29 @@ It is useful when the request is ambiguous, when the user needs to choose betwee
10
15
  pi install npm:@ryan_nookpi/pi-extension-ask-user-question
11
16
  ```
12
17
 
13
- ## Great for
14
-
15
- - clarifying vague requirements before implementation
16
- - letting the user choose between options
17
- - collecting multiple selections from a checklist
18
- - asking for missing information in the middle of a task
19
-
20
- ## Example prompts
21
-
22
- - "Ask which design option they want."
23
- - "Show deployment choices and let the user pick one."
24
- - "Ask the user to select multiple items from a checklist."
25
-
26
- After installation, pi can use the `AskUserQuestion` tool for interactive decision-making.
18
+ ## Example
19
+
20
+ ```json
21
+ {
22
+ "title": "Deployment settings",
23
+ "description": "Need a few decisions before continuing.",
24
+ "questions": [
25
+ {
26
+ "id": "env",
27
+ "type": "radio",
28
+ "prompt": "Which environment should I use?",
29
+ "options": [
30
+ { "value": "staging", "label": "Staging" },
31
+ { "value": "prod", "label": "Production" }
32
+ ]
33
+ },
34
+ {
35
+ "id": "notes",
36
+ "type": "text",
37
+ "prompt": "Anything else I should know?",
38
+ "required": false,
39
+ "placeholder": "Optional notes"
40
+ }
41
+ ]
42
+ }
43
+ ```
package/constants.ts ADDED
@@ -0,0 +1,10 @@
1
+ export const SYM = {
2
+ radioOn: "◉",
3
+ radioOff: "○",
4
+ checkOn: "☑",
5
+ checkOff: "☐",
6
+ pointer: "❯",
7
+ dot: "·",
8
+ check: "✓",
9
+ submit: "✓",
10
+ } as const;
@@ -0,0 +1,281 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createFormController } from "./controller.ts";
4
+ import { createAnswerState, normalizeQuestions } from "./state.ts";
5
+ import type { EditorAdapter, FormResult, RenderTheme } from "./types.ts";
6
+
7
+ class FakeEditor implements EditorAdapter {
8
+ text = "";
9
+ onSubmit?: (value: string) => void;
10
+ handledInputs: string[] = [];
11
+
12
+ getText(): string {
13
+ return this.text;
14
+ }
15
+
16
+ setText(text: string): void {
17
+ this.text = text;
18
+ }
19
+
20
+ handleInput(data: string): void {
21
+ this.handledInputs.push(data);
22
+ if (data.length === 1 && data !== "\t") {
23
+ this.text += data;
24
+ }
25
+ }
26
+
27
+ render(): string[] {
28
+ return this.text ? [this.text] : [];
29
+ }
30
+ }
31
+
32
+ const theme: RenderTheme = {
33
+ fg: (_color, text) => text,
34
+ bg: (_color, text) => `[${text}]`,
35
+ bold: (text) => text,
36
+ };
37
+
38
+ function createController(questionInput: Parameters<typeof normalizeQuestions>[0]) {
39
+ const questions = normalizeQuestions(questionInput);
40
+ const answerState = createAnswerState(questions);
41
+ const editor = new FakeEditor();
42
+ const requestRender = vi.fn();
43
+ const done = vi.fn<(result: FormResult) => void>();
44
+ const controller = createFormController({
45
+ title: "Title",
46
+ description: "Description",
47
+ questions,
48
+ answerState,
49
+ editor,
50
+ theme,
51
+ requestRender,
52
+ done,
53
+ });
54
+ return { controller, questions, answerState, editor, requestRender, done };
55
+ }
56
+
57
+ describe("ask-user-question/controller", () => {
58
+ it("renders with caching and supports empty question sets", () => {
59
+ const empty = createController([]);
60
+ const firstRender = empty.controller.render(40);
61
+ const secondRender = empty.controller.render(40);
62
+ expect(firstRender).toBe(secondRender);
63
+ empty.controller.invalidate();
64
+ expect(empty.controller.render(40)).not.toBe(firstRender);
65
+ empty.controller.handleInput("x");
66
+ expect(empty.done).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it("handles text questions, submission, cancellation, and editor fallback submit", () => {
70
+ const first = createController([{ id: "text", type: "text", prompt: "Explain", default: "seed" }]);
71
+ expect(first.editor.getText()).toBe("seed");
72
+
73
+ first.controller.handleInput("!");
74
+ first.controller.handleInput("\r");
75
+ expect(first.done).toHaveBeenCalledWith({
76
+ title: "Title",
77
+ questions: first.questions,
78
+ answers: [{ id: "text", type: "text", value: "seed!", wasCustom: true }],
79
+ cancelled: false,
80
+ });
81
+ expect(first.requestRender).toHaveBeenCalled();
82
+
83
+ const cancelled = createController([{ id: "text", type: "text", prompt: "Explain" }]);
84
+ cancelled.controller.handleInput("\u001b");
85
+ expect(cancelled.done).toHaveBeenCalledWith({
86
+ title: "Title",
87
+ questions: cancelled.questions,
88
+ answers: [{ id: "text", type: "text", value: "", wasCustom: true }],
89
+ cancelled: true,
90
+ });
91
+
92
+ const fallback = createController([{ id: "text", type: "text", prompt: "Explain" }]);
93
+ fallback.editor.onSubmit?.(" from submit ");
94
+ expect(fallback.done).toHaveBeenCalledWith({
95
+ title: "Title",
96
+ questions: fallback.questions,
97
+ answers: [{ id: "text", type: "text", value: "from submit", wasCustom: true }],
98
+ cancelled: false,
99
+ });
100
+ });
101
+
102
+ it("handles radio other-mode editing, tab navigation, and cancellation", () => {
103
+ const setup = createController([
104
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }] },
105
+ { id: "text", type: "text", prompt: "Explain" },
106
+ ]);
107
+ setup.answerState.radioAnswers.set("radio", { value: "saved", label: "saved", wasCustom: true });
108
+
109
+ setup.controller.handleInput("\u001b[B");
110
+ setup.controller.handleInput("\u001b[A");
111
+ setup.controller.handleInput("\u001b[B");
112
+ setup.controller.handleInput("\r");
113
+ expect(setup.controller.getState()).toMatchObject({ otherMode: true, otherQuestionId: "radio" });
114
+ expect(setup.editor.getText()).toBe("saved");
115
+ expect(setup.controller.render(80).join("\n")).toContain("Your answer:");
116
+
117
+ setup.controller.handleInput("x");
118
+ setup.controller.handleInput("\t");
119
+ expect(setup.answerState.radioAnswers.get("radio")).toEqual({ value: "savedx", label: "savedx", wasCustom: true });
120
+ expect(setup.controller.getState()).toMatchObject({ currentTab: 1, otherMode: false });
121
+
122
+ setup.controller.handleInput("\u001b[Z");
123
+ expect(setup.controller.getState().currentTab).toBe(0);
124
+ setup.controller.handleInput("\u001b");
125
+ expect(setup.done).toHaveBeenCalledWith({
126
+ title: "Title",
127
+ questions: setup.questions,
128
+ answers: [
129
+ { id: "radio", type: "radio", value: "savedx", wasCustom: true },
130
+ { id: "text", type: "text", value: "", wasCustom: true },
131
+ ],
132
+ cancelled: true,
133
+ });
134
+ });
135
+
136
+ it("supports radio selection, checkbox toggles, other-mode enter, and successful submit", () => {
137
+ const setup = createController([
138
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }] },
139
+ { id: "check", type: "checkbox", prompt: "Pick many", options: [{ value: "b", label: "Beta" }] },
140
+ ]);
141
+
142
+ setup.controller.handleInput("\r");
143
+ expect(setup.controller.getState().currentTab).toBe(1);
144
+ expect(setup.answerState.radioAnswers.get("radio")).toEqual({ value: "a", label: "Alpha", wasCustom: false });
145
+
146
+ setup.controller.handleInput(" ");
147
+ expect(setup.answerState.checkAnswers.get("check")).toEqual(new Set(["b"]));
148
+ setup.controller.handleInput(" ");
149
+ expect(setup.answerState.checkAnswers.get("check")).toEqual(new Set());
150
+
151
+ setup.controller.handleInput("\u001b[B");
152
+ setup.controller.handleInput(" ");
153
+ expect(setup.controller.getState().otherMode).toBe(true);
154
+ setup.editor.setText("custom");
155
+ setup.controller.handleInput("\r");
156
+ expect(setup.answerState.checkCustom.get("check")).toBe("custom");
157
+ expect(setup.controller.getState().currentTab).toBe(2);
158
+
159
+ setup.controller.handleInput("\r");
160
+ expect(setup.done).toHaveBeenCalledWith({
161
+ title: "Title",
162
+ questions: setup.questions,
163
+ answers: [
164
+ { id: "radio", type: "radio", value: "a", wasCustom: false },
165
+ { id: "check", type: "checkbox", value: ["custom"], wasCustom: true },
166
+ ],
167
+ cancelled: false,
168
+ });
169
+ });
170
+
171
+ it("handles onSubmit custom input, no-option branches, submit-tab navigation, and other-mode escape", () => {
172
+ const submitOther = createController([
173
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }] },
174
+ { id: "text", type: "text", prompt: "Explain" },
175
+ ]);
176
+ submitOther.controller.handleInput("\u001b[B");
177
+ submitOther.controller.handleInput("\r");
178
+ submitOther.editor.onSubmit?.("typed");
179
+ expect(submitOther.answerState.radioAnswers.get("radio")).toEqual({
180
+ value: "typed",
181
+ label: "typed",
182
+ wasCustom: true,
183
+ });
184
+ expect(submitOther.controller.getState().currentTab).toBe(1);
185
+
186
+ const nonTextSubmit = createController([
187
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }] },
188
+ ]);
189
+ nonTextSubmit.editor.onSubmit?.("ignored");
190
+ expect(nonTextSubmit.done).not.toHaveBeenCalled();
191
+
192
+ const shiftOther = createController([
193
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }] },
194
+ { id: "text", type: "text", prompt: "Explain" },
195
+ ]);
196
+ shiftOther.controller.handleInput("\u001b[B");
197
+ shiftOther.controller.handleInput("\r");
198
+ shiftOther.editor.setText("back");
199
+ shiftOther.controller.handleInput("\u001b[Z");
200
+ expect(shiftOther.answerState.radioAnswers.get("radio")).toEqual({ value: "back", label: "back", wasCustom: true });
201
+ expect(shiftOther.controller.getState().currentTab).toBe(2);
202
+
203
+ const noOptions = createController([
204
+ { id: "radio", type: "radio", prompt: "Pick one", options: [], allowOther: false },
205
+ { id: "check", type: "checkbox", prompt: "Pick many", options: [], allowOther: false },
206
+ ]);
207
+ noOptions.controller.handleInput("\r");
208
+ expect(noOptions.done).not.toHaveBeenCalled();
209
+ noOptions.controller.handleInput("\u001b[C");
210
+ noOptions.controller.handleInput("\u001b[D");
211
+ expect(noOptions.controller.getState().currentTab).toBe(0);
212
+ noOptions.controller.handleInput("\u001b[C");
213
+ noOptions.controller.handleInput(" ");
214
+ noOptions.controller.handleInput("\r");
215
+ expect(noOptions.controller.getState().currentTab).toBe(2);
216
+
217
+ const deletedSet = createController([
218
+ { id: "check", type: "checkbox", prompt: "Pick many", options: [{ value: "b", label: "Beta" }] },
219
+ ]);
220
+ deletedSet.answerState.checkAnswers.delete("check");
221
+ deletedSet.controller.handleInput(" ");
222
+ expect(deletedSet.answerState.checkAnswers.get("check")).toEqual(new Set(["b"]));
223
+ deletedSet.controller.handleInput("x");
224
+ expect(deletedSet.done).not.toHaveBeenCalled();
225
+
226
+ const checkboxSingle = createController([{ id: "check", type: "checkbox", prompt: "Pick many", options: [] }]);
227
+ checkboxSingle.controller.handleInput("\r");
228
+ expect(checkboxSingle.done).toHaveBeenCalledWith({
229
+ title: "Title",
230
+ questions: checkboxSingle.questions,
231
+ answers: [{ id: "check", type: "checkbox", value: [], wasCustom: false }],
232
+ cancelled: false,
233
+ });
234
+
235
+ const blocked = createController([
236
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }] },
237
+ { id: "text", type: "text", prompt: "Explain" },
238
+ ]);
239
+ blocked.controller.handleInput("\u001b[C");
240
+ blocked.controller.handleInput("\t");
241
+ expect(blocked.controller.getState().currentTab).toBe(2);
242
+ blocked.controller.handleInput("\r");
243
+ expect(blocked.done).not.toHaveBeenCalled();
244
+ blocked.controller.handleInput("\u001b[D");
245
+ expect(blocked.controller.getState().currentTab).toBe(1);
246
+ blocked.controller.handleInput("\t");
247
+ expect(blocked.controller.getState().currentTab).toBe(2);
248
+ blocked.controller.handleInput("\t");
249
+ expect(blocked.controller.getState().currentTab).toBe(0);
250
+ blocked.controller.handleInput("\u001b");
251
+ expect(blocked.done).toHaveBeenCalled();
252
+
253
+ const submitCancel = createController([
254
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }], default: "a" },
255
+ { id: "text", type: "text", prompt: "Explain", default: "done" },
256
+ ]);
257
+ submitCancel.controller.handleInput("\u001b[C");
258
+ submitCancel.controller.handleInput("\t");
259
+ submitCancel.controller.handleInput("\u001b");
260
+ expect(submitCancel.done).toHaveBeenCalled();
261
+
262
+ const submitNav = createController([
263
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }], default: "a" },
264
+ { id: "text", type: "text", prompt: "Explain", default: "done" },
265
+ ]);
266
+ submitNav.controller.handleInput("\u001b[C");
267
+ submitNav.controller.handleInput("\t");
268
+ expect(submitNav.controller.getState().currentTab).toBe(2);
269
+ submitNav.controller.handleInput("\u001b[C");
270
+ expect(submitNav.controller.getState().currentTab).toBe(0);
271
+
272
+ const otherEscape = createController([
273
+ { id: "radio", type: "radio", prompt: "Pick one", options: [{ value: "a", label: "Alpha" }] },
274
+ ]);
275
+ otherEscape.controller.handleInput("\u001b[B");
276
+ otherEscape.controller.handleInput("\r");
277
+ otherEscape.controller.handleInput("\u001b");
278
+ expect(otherEscape.controller.getState()).toMatchObject({ otherMode: false, otherQuestionId: null });
279
+ expect(otherEscape.editor.getText()).toBe("");
280
+ });
281
+ });
package/controller.ts ADDED
@@ -0,0 +1,280 @@
1
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
2
+
3
+ import { allRequiredAnswered, buildAnswers, optionCount, saveOtherAnswer, saveTextAnswer } from "./state.ts";
4
+ import type { AnswerState, EditorAdapter, FormResult, NormalizedQuestion, RenderTheme } from "./types.ts";
5
+ import { renderForm } from "./view.ts";
6
+
7
+ export interface FormController {
8
+ render(width: number): string[];
9
+ invalidate(): void;
10
+ handleInput(data: string): void;
11
+ getState(): {
12
+ currentTab: number;
13
+ cursorIdx: number;
14
+ otherMode: boolean;
15
+ otherQuestionId: string | null;
16
+ };
17
+ }
18
+
19
+ interface CreateFormControllerInput {
20
+ title?: string;
21
+ description?: string;
22
+ questions: NormalizedQuestion[];
23
+ answerState: AnswerState;
24
+ editor: EditorAdapter;
25
+ theme: RenderTheme;
26
+ requestRender(): void;
27
+ done(result: FormResult): void;
28
+ }
29
+
30
+ export function createFormController(input: CreateFormControllerInput): FormController {
31
+ const { questions, answerState, editor, theme } = input;
32
+ const isMulti = questions.length > 1;
33
+ const totalTabs = questions.length + (isMulti ? 1 : 0);
34
+
35
+ let currentTab = 0;
36
+ let cursorIdx = 0;
37
+ let otherMode = false;
38
+ let otherQuestionId: string | null = null;
39
+ let cachedLines: string[] | undefined;
40
+
41
+ function getCurrentQuestion(): NormalizedQuestion | undefined {
42
+ return questions[currentTab];
43
+ }
44
+
45
+ function refresh(): void {
46
+ cachedLines = undefined;
47
+ input.requestRender();
48
+ }
49
+
50
+ function saveCurrentTextQuestion(): void {
51
+ const question = getCurrentQuestion();
52
+ if (question?.type !== "text") return;
53
+ saveTextAnswer(answerState, question.id, editor.getText());
54
+ }
55
+
56
+ function saveOtherModeText(questionId: string): void {
57
+ saveOtherAnswer(answerState, questions, questionId, editor.getText());
58
+ otherMode = false;
59
+ otherQuestionId = null;
60
+ editor.setText("");
61
+ }
62
+
63
+ function switchTab(nextTab: number): void {
64
+ saveCurrentTextQuestion();
65
+ currentTab = ((nextTab % totalTabs) + totalTabs) % totalTabs;
66
+ cursorIdx = 0;
67
+ otherMode = false;
68
+ otherQuestionId = null;
69
+ const question = getCurrentQuestion();
70
+ if (question?.type === "text") {
71
+ editor.setText(answerState.textAnswers.get(question.id) ?? "");
72
+ }
73
+ refresh();
74
+ }
75
+
76
+ function finish(cancelled: boolean): void {
77
+ saveCurrentTextQuestion();
78
+ input.done({
79
+ title: input.title,
80
+ questions,
81
+ answers: buildAnswers(answerState, questions),
82
+ cancelled,
83
+ });
84
+ }
85
+
86
+ function advanceTab(): void {
87
+ if (!isMulti) {
88
+ finish(false);
89
+ return;
90
+ }
91
+ switchTab(currentTab < questions.length - 1 ? currentTab + 1 : questions.length);
92
+ }
93
+
94
+ editor.onSubmit = (value) => {
95
+ const trimmed = value.trim();
96
+ if (otherMode && otherQuestionId) {
97
+ saveOtherAnswer(answerState, questions, otherQuestionId, trimmed);
98
+ otherMode = false;
99
+ otherQuestionId = null;
100
+ editor.setText("");
101
+ advanceTab();
102
+ return;
103
+ }
104
+
105
+ const question = getCurrentQuestion();
106
+ if (question?.type === "text") {
107
+ saveTextAnswer(answerState, question.id, trimmed);
108
+ editor.setText(trimmed);
109
+ advanceTab();
110
+ }
111
+ };
112
+
113
+ if (questions[0]?.type === "text") {
114
+ editor.setText(answerState.textAnswers.get(questions[0].id) ?? "");
115
+ }
116
+
117
+ return {
118
+ render(width) {
119
+ if (cachedLines) return cachedLines;
120
+ cachedLines = renderForm({
121
+ title: input.title,
122
+ description: input.description,
123
+ questions,
124
+ answerState,
125
+ currentTab,
126
+ cursorIdx,
127
+ otherMode,
128
+ width,
129
+ theme,
130
+ editorLines: editor.render(Math.min(width, 120) - (otherMode ? 6 : 4)),
131
+ editorText: editor.getText(),
132
+ });
133
+ return cachedLines;
134
+ },
135
+ invalidate() {
136
+ cachedLines = undefined;
137
+ },
138
+ handleInput(data) {
139
+ if (otherMode) {
140
+ if (matchesKey(data, Key.escape)) {
141
+ otherMode = false;
142
+ otherQuestionId = null;
143
+ editor.setText("");
144
+ refresh();
145
+ return;
146
+ }
147
+ if (matchesKey(data, Key.enter) && otherQuestionId) {
148
+ saveOtherModeText(otherQuestionId);
149
+ advanceTab();
150
+ return;
151
+ }
152
+ if (isMulti && (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab"))) && otherQuestionId) {
153
+ saveOtherModeText(otherQuestionId);
154
+ switchTab(currentTab + (matchesKey(data, Key.shift("tab")) ? -1 : 1));
155
+ return;
156
+ }
157
+ editor.handleInput(data);
158
+ refresh();
159
+ return;
160
+ }
161
+
162
+ const question = getCurrentQuestion();
163
+ if (question?.type === "text") {
164
+ if (matchesKey(data, Key.enter)) {
165
+ saveCurrentTextQuestion();
166
+ advanceTab();
167
+ return;
168
+ }
169
+ if (isMulti && (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab")))) {
170
+ saveCurrentTextQuestion();
171
+ switchTab(currentTab + (matchesKey(data, Key.shift("tab")) ? -1 : 1));
172
+ return;
173
+ }
174
+ if (matchesKey(data, Key.escape)) {
175
+ finish(true);
176
+ return;
177
+ }
178
+ editor.handleInput(data);
179
+ refresh();
180
+ return;
181
+ }
182
+
183
+ if (isMulti && currentTab === questions.length) {
184
+ if (matchesKey(data, Key.enter) && allRequiredAnswered(answerState, questions)) {
185
+ finish(false);
186
+ return;
187
+ }
188
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
189
+ switchTab(0);
190
+ return;
191
+ }
192
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
193
+ switchTab(currentTab - 1);
194
+ return;
195
+ }
196
+ if (matchesKey(data, Key.escape)) {
197
+ finish(true);
198
+ return;
199
+ }
200
+ return;
201
+ }
202
+
203
+ if (!question) return;
204
+
205
+ if (isMulti) {
206
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
207
+ switchTab(currentTab + 1);
208
+ return;
209
+ }
210
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
211
+ switchTab(currentTab - 1);
212
+ return;
213
+ }
214
+ }
215
+
216
+ const totalOptions = optionCount(question);
217
+ if (matchesKey(data, Key.up)) {
218
+ cursorIdx = Math.max(0, cursorIdx - 1);
219
+ refresh();
220
+ return;
221
+ }
222
+ if (matchesKey(data, Key.down)) {
223
+ cursorIdx = Math.min(totalOptions - 1, cursorIdx + 1);
224
+ refresh();
225
+ return;
226
+ }
227
+ if (matchesKey(data, Key.escape)) {
228
+ finish(true);
229
+ return;
230
+ }
231
+
232
+ if (question.type === "radio" && matchesKey(data, Key.enter)) {
233
+ const isOther = question.allowOther && cursorIdx === question.options.length;
234
+ if (isOther) {
235
+ otherMode = true;
236
+ otherQuestionId = question.id;
237
+ const existing = answerState.radioAnswers.get(question.id);
238
+ editor.setText(existing?.wasCustom ? existing.label : "");
239
+ refresh();
240
+ return;
241
+ }
242
+
243
+ const option = question.options[cursorIdx];
244
+ if (option) {
245
+ answerState.radioAnswers.set(question.id, { value: option.value, label: option.label, wasCustom: false });
246
+ advanceTab();
247
+ }
248
+ return;
249
+ }
250
+
251
+ if (question.type === "checkbox" && matchesKey(data, Key.space)) {
252
+ const isOther = question.allowOther && cursorIdx === question.options.length;
253
+ if (isOther) {
254
+ otherMode = true;
255
+ otherQuestionId = question.id;
256
+ editor.setText(answerState.checkCustom.get(question.id) ?? "");
257
+ refresh();
258
+ return;
259
+ }
260
+
261
+ const option = question.options[cursorIdx];
262
+ if (option) {
263
+ const selected = answerState.checkAnswers.get(question.id) ?? new Set<string>();
264
+ if (selected.has(option.value)) selected.delete(option.value);
265
+ else selected.add(option.value);
266
+ answerState.checkAnswers.set(question.id, selected);
267
+ refresh();
268
+ }
269
+ return;
270
+ }
271
+
272
+ if (question.type === "checkbox" && matchesKey(data, Key.enter)) {
273
+ advanceTab();
274
+ }
275
+ },
276
+ getState() {
277
+ return { currentTab, cursorIdx, otherMode, otherQuestionId };
278
+ },
279
+ };
280
+ }
@@ -0,0 +1,57 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { createEditorTheme, runAskUserQuestionForm } from "./form-ui.ts";
5
+ import { normalizeQuestions } from "./state.ts";
6
+ import type { FormResult, RenderTheme } from "./types.ts";
7
+
8
+ const theme: RenderTheme = {
9
+ fg: (_color, text) => text,
10
+ bg: (_color, text) => `[${text}]`,
11
+ bold: (text) => text,
12
+ };
13
+
14
+ describe("ask-user-question/form-ui", () => {
15
+ it("creates the editor theme mapping", () => {
16
+ const editorTheme = createEditorTheme(theme);
17
+ expect(editorTheme.borderColor("x")).toBe("x");
18
+ expect(editorTheme.selectList.selectedPrefix("a")).toBe("a");
19
+ expect(editorTheme.selectList.selectedText("b")).toBe("b");
20
+ expect(editorTheme.selectList.description("c")).toBe("c");
21
+ expect(editorTheme.selectList.scrollInfo("d")).toBe("d");
22
+ expect(editorTheme.selectList.noMatch("e")).toBe("e");
23
+ });
24
+
25
+ it("wires the custom UI factory to the controller", async () => {
26
+ const questions = normalizeQuestions([{ id: "text", type: "text", prompt: "Explain", default: "seed" }]);
27
+ let captured: FormResult | undefined;
28
+ const done = vi.fn((result: unknown) => {
29
+ captured = result as FormResult;
30
+ });
31
+ const requestRender = vi.fn();
32
+ const custom = vi.fn(async (factory: Parameters<NonNullable<ExtensionContext["ui"]>["custom"]>[0]) => {
33
+ const component = (await factory({ requestRender } as never, theme as never, {} as never, done)) as {
34
+ handleInput(data: string): void;
35
+ };
36
+ component.handleInput("!");
37
+ component.handleInput("\r");
38
+ expect(requestRender).toHaveBeenCalled();
39
+ if (!captured) throw new Error("result was not captured");
40
+ return captured;
41
+ });
42
+
43
+ const ctx = {
44
+ hasUI: true,
45
+ ui: { custom },
46
+ } as unknown as ExtensionContext;
47
+
48
+ const result = await runAskUserQuestionForm(ctx, { title: "Title", description: "Desc" }, questions);
49
+ expect(custom).toHaveBeenCalledTimes(1);
50
+ expect(result).toEqual({
51
+ title: "Title",
52
+ questions,
53
+ answers: [{ id: "text", type: "text", value: "seed!", wasCustom: true }],
54
+ cancelled: false,
55
+ });
56
+ });
57
+ });