@ryan_nookpi/pi-extension-ask-user-question 0.1.1 → 0.2.1

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,26 +1,90 @@
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
+ pi에서 `ask_user_question` 도구를 추가해, 사용자에게 여러 질문을 번에 구조화해서 받을 있게 해주는 익스텐션입니다.
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.
6
-
7
- ## Install
5
+ ## 설치
8
6
 
9
7
  ```bash
10
8
  pi install npm:@ryan_nookpi/pi-extension-ask-user-question
11
9
  ```
12
10
 
13
- ## Great for
11
+ ## 무엇을 할 수 있나
12
+
13
+ - `radio`: 하나만 고르는 단일 선택 질문
14
+ - `checkbox`: 여러 개를 고르는 복수 선택 질문
15
+ - `text`: 자유 입력 질문
16
+ - `allowOther: true`: 선택지 외 값을 직접 입력하는 `기타...` 경로 제공
17
+ - 여러 질문을 한 번의 폼으로 묶어 입력 받기
18
+
19
+ ## 언제 쓰면 좋은가
20
+
21
+ - 요구사항이 모호해서 확인이 필요할 때
22
+ - 여러 선택지 중 하나를 고르게 해야 할 때
23
+ - 선호도, 배포 환경, 옵션 조합 등을 한 번에 수집할 때
24
+ - 일반 텍스트 질문보다 구조화된 응답이 필요할 때
25
+
26
+ ## 파라미터 가이드
27
+
28
+ ### 최상위 필드
14
29
 
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
30
+ - `title`: 상단 제목
31
+ - `description`: 설명/안내 문구
32
+ - `questions`: 질문 배열
19
33
 
20
- ## Example prompts
34
+ ### 질문 필드
35
+
36
+ - `id`: 질문 식별자
37
+ - `type`: `radio` | `checkbox` | `text`
38
+ - `prompt`: 사용자에게 보여줄 질문 문구
39
+ - `label`: 탭에 표시할 짧은 이름. 생략하면 `Q1`, `Q2`처럼 자동 생성
40
+ - `options`: `radio`/`checkbox`에서 사용할 선택지 목록
41
+ - `allowOther`: `기타...` 직접 입력 허용 여부
42
+ - `required`: 필수 응답 여부
43
+ - `placeholder`: `text` 입력창 플레이스홀더
44
+ - `default`: 기본값. `radio`/`text`는 문자열, `checkbox`는 문자열 배열
45
+
46
+ ### 옵션 필드
47
+
48
+ - `value`: 실제 반환값
49
+ - `label`: 화면에 보이는 문구
50
+ - `description`: 선택지 아래 보조 설명
51
+
52
+ ## 예시
53
+
54
+ ```json
55
+ {
56
+ "title": "배포 설정 확인",
57
+ "description": "진행 전에 몇 가지 선택이 필요합니다.",
58
+ "questions": [
59
+ {
60
+ "id": "env",
61
+ "type": "radio",
62
+ "prompt": "어느 환경에 배포할까요?",
63
+ "options": [
64
+ { "value": "staging", "label": "스테이징" },
65
+ { "value": "prod", "label": "프로덕션" }
66
+ ]
67
+ },
68
+ {
69
+ "id": "targets",
70
+ "type": "checkbox",
71
+ "prompt": "이번에 함께 반영할 항목은 무엇인가요?",
72
+ "options": [
73
+ { "value": "web", "label": "웹" },
74
+ { "value": "admin", "label": "어드민" }
75
+ ]
76
+ },
77
+ {
78
+ "id": "notes",
79
+ "type": "text",
80
+ "prompt": "추가로 알아야 할 점이 있나요?",
81
+ "required": false,
82
+ "placeholder": "선택 사항"
83
+ }
84
+ ]
85
+ }
86
+ ```
21
87
 
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."
88
+ ## 반환 형태
25
89
 
26
- After installation, pi can use the `AskUserQuestion` tool for interactive decision-making.
90
+ 응답은 질문의 `id` 기준으로 정리되어 반환됩니다. 취소 시에는 `cancelled: true`가 내려오고, 완료 시에는 질문별 답변 목록이 포함됩니다.
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("직접 입력:");
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
+ }