@ryan_nookpi/pi-extension-ask-user-question 0.1.0 → 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,15 +1,43 @@
1
1
  # @ryan_nookpi/pi-extension-ask-user-question
2
2
 
3
- Independent pi package for the `AskUserQuestion` extension.
3
+ Interactive multi-question form tool for pi.
4
+
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
4
11
 
5
12
  ## Install
6
13
 
7
14
  ```bash
8
- pi install /Users/creatrip/Documents/pi-extension/packages/ask-user-question
9
15
  pi install npm:@ryan_nookpi/pi-extension-ask-user-question
10
16
  ```
11
17
 
12
- ## What it provides
18
+ ## Example
13
19
 
14
- - `AskUserQuestion` tool
15
- - `./index.ts` entry
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
+ });