@hank-warren/pi-ask-user-question 0.2.2 → 0.3.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
@@ -4,8 +4,8 @@ A structured questionnaire the model can put to you when it would otherwise
4
4
  guess. Instead of a free-form "which do you prefer?" in chat, you get a dialog
5
5
  with numbered options, digit hotkeys, a typed-answer escape, and Tab-to-comment.
6
6
 
7
- > **v0.1** ships one question per call, single-select, no preview pane. See
8
- > [the spec](../../docs/specs/pi-ask-user-question.md) §14 for the road to v0.2.
7
+ > **v0.2** ships 1-4 questions per call as cycleable tabs, single-select, no
8
+ > preview pane. See [the spec](../../docs/specs/pi-ask-user-question.md) §14.
9
9
 
10
10
  ## Install
11
11
 
@@ -23,8 +23,19 @@ register a tool named `ask_user_question`.
23
23
  | `1`–`9` | Select that option immediately |
24
24
  | `↑` / `↓` | Move the highlight |
25
25
  | `Enter` | Confirm the highlighted option |
26
- | `Tab` | Attach a note to your choice, then `Enter` to send both |
27
- | `Esc` | Decline the question (or leave note/typed-answer mode) |
26
+ | `n` | Attach a note to your choice, then `Enter` to send both |
27
+ | `Tab` / `→` | Next question |
28
+ | `Shift+Tab` / `←` | Previous question |
29
+ | `Esc` | Decline the questionnaire (or leave note/typed-answer mode) |
30
+
31
+ With several questions, each is a tab you can cycle through in any order;
32
+ answering one jumps to the next unanswered question, and the call returns once
33
+ every question has an answer. Cycling back and re-answering replaces that
34
+ question's answer rather than recording a second one.
35
+
36
+ **Why `n` and not `Tab` for notes?** Tab cycles questions here, matching
37
+ `@juicesharp/rpiv-ask-user-question`. `pi-auto-permissions` approval prompts
38
+ keep `Tab` for notes — they are single-question and have no tabs to cycle.
28
39
 
29
40
  Every question gets an appended **`Type something.`** row for a free-text
30
41
  answer. The model is not allowed to author that row itself — reserved labels
@@ -35,16 +35,16 @@ const ERROR_NO_UI = "Error: UI not available (running in non-interactive mode)";
35
35
  const ERROR_NO_CUSTOM_UI =
36
36
  "Error: this client cannot render the questionnaire (custom UI is unavailable). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.";
37
37
 
38
- const DESCRIPTION = `Ask the user a structured question during execution. Use when you need to:
38
+ const DESCRIPTION = `Ask the user one or more structured questions during execution. Use when you need to:
39
39
  1. Gather user preferences or requirements
40
40
  2. Clarify ambiguous instructions
41
41
  3. Get decisions on implementation choices as you work
42
42
  4. Offer choices to the user about what direction to take
43
43
 
44
44
  Usage notes:
45
- - The user can pick an option with the number keys, type a custom answer via the automatically appended "Type something." row, attach a note to their choice with Tab, or press Esc to decline. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
45
+ - The user can pick an option with the number keys, type a custom answer via the automatically appended "Type something." row, attach a note to their choice by pressing n, or press Esc to decline. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
46
46
  - If you recommend a specific option, make it the first option and add "(Recommended)" at the end of the label.
47
- - This version accepts exactly one question per call with 2-4 options.`;
47
+ - Ask 1-4 questions per call, each with 2-4 options. Multiple questions render as tabs the user cycles with Tab; every question must be answered before the call returns. Group questions that belong to one decision rather than asking them in separate calls, but do not pad a single decision into several questions.`;
48
48
 
49
49
  function emitPrompt(pi: ExtensionAPI, params: AskUserParams): void {
50
50
  const payload: AskUserPromptEventPayload = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-ask-user-question",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Structured questionnaire tool for Pi with numbered options, digit hotkeys and Tab-to-comment, composed from the shared permission-selector component.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "LICENSE"
46
46
  ],
47
47
  "dependencies": {
48
- "@hank-warren/pi-permission-selector": "^0.2.0"
48
+ "@hank-warren/pi-permission-selector": "^0.3.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@earendil-works/pi-coding-agent": "*",
package/questionnaire.ts CHANGED
@@ -2,9 +2,15 @@
2
2
  * Questionnaire session state — a plain mutable object, no reducer.
3
3
  *
4
4
  * @juicesharp/rpiv-ask-user-question spends ~1,500 lines on a Redux-style
5
- * reducer plus four selector modules for this. The state here is one question
6
- * index, one answer list, and a custom-answer buffer; a reducer would add
7
- * indirection without adding a single testable guarantee.
5
+ * reducer plus four selector modules for this. The state here is a cursor and
6
+ * a sparse answer array; a reducer would add indirection without adding a
7
+ * single testable guarantee.
8
+ *
9
+ * NAVIGATION MODEL. Questions are tabs, not a queue. The cursor moves freely
10
+ * (Tab / Shift+Tab, wrapping), answers are stored BY QUESTION INDEX so
11
+ * revisiting a question replaces its answer rather than appending a second
12
+ * one, and the questionnaire is complete only when every question has an
13
+ * answer. This mirrors the tab model of rpiv-ask-user-question.
8
14
  *
9
15
  * Pure: no pi imports, no rendering. The dialog drives it; tests drive it the
10
16
  * same way.
@@ -27,10 +33,12 @@ export interface SelectableRow {
27
33
  export class QuestionnaireSession {
28
34
  private readonly params: AskUserParams;
29
35
  private index = 0;
30
- private readonly answers: QuestionAnswer[] = [];
36
+ /** Sparse by design: `answers[i]` is undefined until question i is answered. */
37
+ private readonly answers: Array<QuestionAnswer | undefined>;
31
38
 
32
39
  constructor(params: AskUserParams) {
33
40
  this.params = params;
41
+ this.answers = new Array(params.questions.length).fill(undefined);
34
42
  }
35
43
 
36
44
  get questionIndex(): number {
@@ -45,16 +53,54 @@ export class QuestionnaireSession {
45
53
  return this.params.questions[this.index];
46
54
  }
47
55
 
48
- /** True once every question has been answered. */
56
+ /** Move the cursor, wrapping at both ends. */
57
+ goTo(index: number): void {
58
+ if (this.total === 0) return;
59
+ this.index = ((index % this.total) + this.total) % this.total;
60
+ }
61
+
62
+ next(): void {
63
+ this.goTo(this.index + 1);
64
+ }
65
+
66
+ previous(): void {
67
+ this.goTo(this.index - 1);
68
+ }
69
+
70
+ /** True once every question has an answer. */
49
71
  isComplete(): boolean {
50
- return this.index >= this.total;
72
+ return this.answers.every((answer) => answer !== undefined);
73
+ }
74
+
75
+ isAnswered(index: number): boolean {
76
+ return this.answers[index] !== undefined;
77
+ }
78
+
79
+ answeredCount(): number {
80
+ return this.answers.filter((answer) => answer !== undefined).length;
81
+ }
82
+
83
+ /**
84
+ * Move to the next question without an answer, searching forward from the
85
+ * cursor and wrapping. Returns false when everything is answered, which the
86
+ * dialog treats as "submit".
87
+ */
88
+ advanceToUnanswered(): boolean {
89
+ for (let step = 1; step <= this.total; step++) {
90
+ const candidate = (this.index + step) % this.total;
91
+ if (!this.isAnswered(candidate)) {
92
+ this.index = candidate;
93
+ return true;
94
+ }
95
+ }
96
+ return false;
51
97
  }
52
98
 
53
99
  /**
54
100
  * Rows for the current question: the authored options plus the appended
55
101
  * custom-answer sentinel. The sentinel is never an authored option — it is
56
- * added here and stripped in `recordAnswer`, which is why validation
57
- * rejects models that try to author it themselves.
102
+ * added here and stripped when recording, which is why validation rejects
103
+ * models that try to author it themselves.
58
104
  */
59
105
  rows(): SelectableRow[] {
60
106
  const question = this.current;
@@ -74,23 +120,33 @@ export class QuestionnaireSession {
74
120
  }
75
121
 
76
122
  /**
77
- * Record an answer for the current question and advance. `custom` marks a
78
- * typed free-text answer; `notes` is the Tab-to-comment note.
123
+ * Record (or replace) the answer for the current question. Replacement is
124
+ * the point: cycling back to a question and picking again must not leave
125
+ * two answers for one question in the envelope.
79
126
  */
80
127
  recordAnswer(answer: string, opts: { custom?: boolean; notes?: string } = {}): void {
81
128
  const question = this.current;
82
129
  if (!question) return;
83
- this.answers.push({
130
+ this.answers[this.index] = {
84
131
  questionIndex: this.index,
85
132
  question: question.question,
86
133
  answer,
87
134
  custom: opts.custom === true,
88
135
  ...(opts.notes ? { notes: opts.notes } : {}),
89
- });
90
- this.index += 1;
136
+ };
137
+ }
138
+
139
+ /** The recorded answer for a question, if any. */
140
+ answerAt(index: number): QuestionAnswer | undefined {
141
+ return this.answers[index];
142
+ }
143
+
144
+ /** Header for question `index`, used by the tab strip. */
145
+ headerAt(index: number): string {
146
+ return this.params.questions[index]?.header ?? "";
91
147
  }
92
148
 
93
- /** Title line for the dialog: `Header` or `2 of 3 · Header` when multiple. */
149
+ /** Title line: `Header` alone, or `2 of 3 · Header` when there are tabs. */
94
150
  title(): string {
95
151
  const question = this.current;
96
152
  if (!question) return "";
@@ -99,13 +155,17 @@ export class QuestionnaireSession {
99
155
  : question.header;
100
156
  }
101
157
 
158
+ private collected(): QuestionAnswer[] {
159
+ return this.answers.filter((answer): answer is QuestionAnswer => answer !== undefined);
160
+ }
161
+
102
162
  /** Successful outcome. */
103
163
  result(): QuestionnaireResult {
104
- return { answers: [...this.answers], cancelled: false };
164
+ return { answers: this.collected(), cancelled: false };
105
165
  }
106
166
 
107
167
  /** Declined outcome, preserving any answers given before the cancel. */
108
168
  cancelledResult(): QuestionnaireResult {
109
- return { answers: [...this.answers], cancelled: true };
169
+ return { answers: this.collected(), cancelled: true };
110
170
  }
111
171
  }
package/tool/schema.ts CHANGED
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * Tool parameter schema and shared types for `ask_user_question`.
3
3
  *
4
- * v0.1 scope (docs/specs/pi-ask-user-question.md §14): a SINGLE question,
5
- * single-select, no preview. The schema still accepts the `questions` array
6
- * shape so that v0.2 can lift the count limit without changing the wire
7
- * contract the model has already learned, and so session history recorded
8
- * against the array shape keeps replaying.
4
+ * v0.2 scope (docs/specs/pi-ask-user-question.md §14): up to four questions,
5
+ * single-select, no preview. Questions render as tabs the user cycles with
6
+ * Tab / Shift+Tab.
9
7
  */
10
8
 
11
9
  import { Type } from "typebox";
@@ -16,8 +14,7 @@ export const TOOL_NAME = "ask_user_question";
16
14
  export const MIN_OPTIONS = 2;
17
15
  export const MAX_OPTIONS = 4;
18
16
  export const MIN_QUESTIONS = 1;
19
- /** v0.1 ships single-question only; v0.2 raises this to 4. */
20
- export const MAX_QUESTIONS = 1;
17
+ export const MAX_QUESTIONS = 4;
21
18
 
22
19
  export const MAX_HEADER_LENGTH = 16;
23
20
  export const MAX_LABEL_LENGTH = 60;
@@ -63,7 +60,8 @@ export const QuestionSchema = Type.Object({
63
60
 
64
61
  export const QuestionParamsSchema = Type.Object({
65
62
  questions: Type.Array(QuestionSchema, {
66
- description: "Questions to ask the user. Exactly one question is supported in this version.",
63
+ description:
64
+ "Questions to ask the user. 1-4 questions; the user cycles between them with Tab and answers each one.",
67
65
  minItems: MIN_QUESTIONS,
68
66
  maxItems: MAX_QUESTIONS,
69
67
  }),
package/tool/validate.ts CHANGED
@@ -49,10 +49,7 @@ export function validateParams(params: AskUserParams): ValidationError | undefin
49
49
  if (!Array.isArray(questions) || questions.length < MIN_QUESTIONS || questions.length > MAX_QUESTIONS) {
50
50
  return {
51
51
  code: "bad_question_count",
52
- message:
53
- MAX_QUESTIONS === 1
54
- ? `This version supports exactly ${MAX_QUESTIONS} question per call; received ${questions?.length ?? 0}. Ask the most important question first, then follow up.`
55
- : `questions must contain ${MIN_QUESTIONS}-${MAX_QUESTIONS} entries; received ${questions?.length ?? 0}.`,
52
+ message: `questions must contain ${MIN_QUESTIONS}-${MAX_QUESTIONS} entries; received ${questions?.length ?? 0}. Group questions that belong to one decision; ask further questions in a follow-up call.`,
56
53
  };
57
54
  }
58
55
 
package/view/dialog.ts CHANGED
@@ -2,13 +2,27 @@
2
2
  * The questionnaire dialog: a pi-tui component driven by QuestionnaireSession
3
3
  * and rendered through `ctx.ui.custom()`.
4
4
  *
5
- * NO MONKEY PATCHING. The numbered options, digit hotkeys and Tab-to-comment
5
+ * NO MONKEY PATCHING. The numbered options, digit hotkeys and the note editor
6
6
  * come from `OptionSelector`, imported from the PUBLISHED sibling package
7
7
  * `@hank-warren/pi-permission-selector` (plain `dependencies`, never
8
8
  * `bundledDependencies` — AGENTS.md §Structure). See
9
9
  * docs/specs/pi-ask-user-question.md §9.
10
10
  *
11
- * TWO RULES, both learned from v0.2.0 shipping broken:
11
+ * KEY MAP (matches @juicesharp/rpiv-ask-user-question):
12
+ *
13
+ * 1-9 select an option
14
+ * ↑ / ↓ move the highlight
15
+ * enter confirm the highlight
16
+ * n open the note editor for the highlighted option
17
+ * tab / → next question shift+tab / ← previous question
18
+ * esc decline the questionnaire
19
+ *
20
+ * Note that `n`, not Tab, opens notes here. Tab is reserved for cycling
21
+ * questions. pi-auto-permissions approval prompts keep Tab-to-comment, because
22
+ * they are single-question and have no tabs to cycle — `OptionSelector`
23
+ * defaults to Tab and this dialog overrides it.
24
+ *
25
+ * THREE RULES, all learned from shipping bugs:
12
26
  *
13
27
  * 1. NEVER compare key data with `===`. Every key check goes through the
14
28
  * shared predicates in `.../keys.ts`. Under the Kitty keyboard protocol
@@ -16,8 +30,7 @@
16
30
  * trapped the user in the custom-answer field with no way out.
17
31
  * 2. ALWAYS pad rendered lines to the full overlay width. pi composites an
18
32
  * overlay onto the chat line by line and only overwrites the columns the
19
- * overlay actually emits; short lines let chat text show through and the
20
- * dialog renders as garbage interleaved with the transcript.
33
+ * overlay actually emits; short lines let chat text show through.
21
34
  * 3. ALWAYS clamp lines to the inner width. A single over-long line breaks the
22
35
  * right border and spills into the transcript, so `render` truncates as a
23
36
  * last-resort invariant no matter what any content source produces.
@@ -25,9 +38,14 @@
25
38
 
26
39
  import {
27
40
  isBackspaceKey,
41
+ isCharKey,
28
42
  isEnterKey,
29
43
  isEscapeKey,
44
+ isLeftKey,
30
45
  isPrintable,
46
+ isRightKey,
47
+ isShiftTabKey,
48
+ isTabKey,
31
49
  } from "@hank-warren/pi-permission-selector/keys.ts";
32
50
  import { OptionSelector, type SelectorOption } from "@hank-warren/pi-permission-selector/selector.ts";
33
51
  import {
@@ -63,65 +81,87 @@ export interface DialogOptions {
63
81
  const CHROME_COLUMNS = 4;
64
82
  /** Indent for the custom-answer field, in columns. */
65
83
  const FIELD_INDENT = " ";
84
+ /** Opens the note editor. Tab is taken by question cycling. */
85
+ const NOTE_KEY = "n";
86
+ const isNoteKey = isCharKey(NOTE_KEY);
87
+
88
+ /** Per-question UI state, preserved while cycling between tabs. */
89
+ interface TabState {
90
+ selector: OptionSelector;
91
+ customText?: string;
92
+ pendingNotes?: string;
93
+ }
66
94
 
67
95
  export class QuestionnaireDialog {
68
96
  /** Focusable — set by the TUI when focus changes. Drives CURSOR_MARKER. */
69
97
  focused = false;
70
98
 
71
99
  private readonly opts: DialogOptions;
72
- private selector: OptionSelector;
73
- private customText: string | undefined;
74
- private pendingNotes: string | undefined;
100
+ private readonly tabs: TabState[] = [];
75
101
  private finished = false;
76
102
 
77
103
  constructor(opts: DialogOptions) {
78
104
  this.opts = opts;
79
- this.selector = this.buildSelector();
105
+ for (let i = 0; i < this.session.total; i++) this.tabs.push({ selector: this.buildSelector(i) });
80
106
  }
81
107
 
82
108
  private get session() {
83
109
  return this.opts.session;
84
110
  }
85
111
 
112
+ private get tab(): TabState {
113
+ return this.tabs[this.session.questionIndex];
114
+ }
115
+
86
116
  private repaint(): void {
87
117
  this.opts.requestRender?.();
88
118
  }
89
119
 
90
- private buildSelector(): OptionSelector {
120
+ private buildSelector(index: number): OptionSelector {
91
121
  const session = this.session;
122
+ const previous = session.goTo.bind(session);
123
+ // rows() reads the cursor, so snapshot this tab's rows at build time.
124
+ const restore = session.questionIndex;
125
+ previous(index);
92
126
  const options: SelectorOption[] = session.rows().map((row) => ({
93
127
  value: row.value,
94
128
  label: row.label,
95
129
  description: row.description,
96
130
  }));
131
+ previous(restore);
97
132
 
98
133
  return new OptionSelector({
99
134
  options,
100
135
  theme: this.opts.theme,
136
+ // `n` instead of Tab; Tab must stay free for question cycling. Tab is
137
+ // left unconsumed by the selector so this dialog can intercept it.
138
+ commentTrigger: isNoteKey,
139
+ commentKeyHint: NOTE_KEY,
101
140
  onSelect: (option, comment) => {
141
+ session.goTo(index);
102
142
  if (session.isCustomRow(option.value)) {
103
143
  // Enter free-text mode. A note typed on the sentinel row is
104
144
  // carried across so it is not silently lost.
105
- this.pendingNotes = comment;
106
- this.customText = "";
145
+ this.tabs[index].pendingNotes = comment;
146
+ this.tabs[index].customText = "";
107
147
  this.repaint();
108
148
  return;
109
149
  }
110
150
  session.recordAnswer(option.value, { notes: comment });
111
- this.advance();
151
+ this.afterAnswer();
112
152
  },
113
153
  onCancel: () => this.finish(session.cancelledResult()),
114
154
  requestRender: () => this.repaint(),
115
155
  });
116
156
  }
117
157
 
118
- private advance(): void {
158
+ /** Submit when everything is answered, else jump to the next gap. */
159
+ private afterAnswer(): void {
119
160
  if (this.session.isComplete()) {
120
161
  this.finish(this.session.result());
121
162
  return;
122
163
  }
123
- // v0.2: multi-question calls rebind the selector to the next question.
124
- this.selector = this.buildSelector();
164
+ this.session.advanceToUnanswered();
125
165
  this.repaint();
126
166
  }
127
167
 
@@ -133,11 +173,16 @@ export class QuestionnaireDialog {
133
173
 
134
174
  /** True while the free-text custom-answer editor is open. */
135
175
  isTypingCustom(): boolean {
136
- return this.customText !== undefined;
176
+ return this.tab?.customText !== undefined;
177
+ }
178
+
179
+ /** True while any text-entry mode owns the keyboard. */
180
+ private isTyping(): boolean {
181
+ return this.isTypingCustom() || this.tab.selector.isCommenting();
137
182
  }
138
183
 
139
184
  invalidate(): void {
140
- this.selector.invalidate();
185
+ for (const tab of this.tabs) tab.selector.invalidate();
141
186
  }
142
187
 
143
188
  private style(role: string, text: string): string {
@@ -145,19 +190,34 @@ export class QuestionnaireDialog {
145
190
  return role === "dim" ? `\x1b[2m${text}\x1b[0m` : text;
146
191
  }
147
192
 
193
+ /** `✓ Database ▸ Cache ○ Queue` — one line, only when tabs exist. */
194
+ private tabStrip(): string {
195
+ const parts: string[] = [];
196
+ for (let i = 0; i < this.session.total; i++) {
197
+ const current = i === this.session.questionIndex;
198
+ const mark = this.session.isAnswered(i) ? "✓" : current ? "▸" : "○";
199
+ const label = `${mark} ${this.session.headerAt(i)}`;
200
+ parts.push(current ? this.style("accent", label) : this.style("dim", label));
201
+ }
202
+ return parts.join(" ");
203
+ }
204
+
148
205
  /** Inner content lines, before the box is drawn around them. */
149
206
  private contentLines(inner: number): string[] {
150
207
  const session = this.session;
151
- const lines: string[] = [this.style("accent", session.title()), ""];
208
+ const lines: string[] = [this.style("accent", session.title())];
209
+ if (session.total > 1) lines.push(this.tabStrip());
210
+ lines.push("");
152
211
  lines.push(...wrapTextWithAnsi(session.current?.question ?? "", inner));
153
212
  lines.push("");
154
213
 
155
- if (this.customText !== undefined) {
214
+ const tab = this.tab;
215
+ if (tab.customText !== undefined) {
156
216
  // Wrap the typed answer. Without this a long answer ran past the right
157
217
  // border and off the screen forever, because an input field renders as
158
218
  // one line unless something breaks it up.
159
219
  const avail = Math.max(1, inner - FIELD_INDENT.length - 1); // -1 reserves the caret cell
160
- const wrapped = wrapTextWithAnsi(this.customText, avail);
220
+ const wrapped = wrapTextWithAnsi(tab.customText, avail);
161
221
  // CURSOR_MARKER is a zero-width APC sequence: the TUI strips it and
162
222
  // parks the hardware cursor there, so the caret lands in the field
163
223
  // instead of at the bottom of the screen.
@@ -171,7 +231,10 @@ export class QuestionnaireDialog {
171
231
  return lines;
172
232
  }
173
233
 
174
- lines.push(...this.selector.render(inner));
234
+ lines.push(...tab.selector.render(inner));
235
+ if (session.total > 1 && !tab.selector.isCommenting()) {
236
+ lines.push(this.style("dim", " tab next · shift+tab prev question"));
237
+ }
175
238
  return lines;
176
239
  }
177
240
 
@@ -196,43 +259,62 @@ export class QuestionnaireDialog {
196
259
  }
197
260
 
198
261
  handleInput(keyData: string): void {
199
- if (this.customText === undefined) {
200
- this.selector.handleInput(keyData);
262
+ const tab = this.tab;
263
+
264
+ // Question cycling. Deliberately NOT active while typing: Tab inside the
265
+ // note editor means "back to options", and inside the custom-answer field
266
+ // a stray Tab must never teleport the user to another question and strand
267
+ // their half-typed text.
268
+ if (this.session.total > 1 && !this.isTyping()) {
269
+ if (isTabKey(keyData) || isRightKey(keyData)) {
270
+ this.session.next();
271
+ this.repaint();
272
+ return;
273
+ }
274
+ if (isShiftTabKey(keyData) || isLeftKey(keyData)) {
275
+ this.session.previous();
276
+ this.repaint();
277
+ return;
278
+ }
279
+ }
280
+
281
+ if (tab.customText === undefined) {
282
+ tab.selector.handleInput(keyData);
201
283
  return;
202
284
  }
203
285
 
204
286
  // Free-text mode. Every check below MUST use a shared predicate — see
205
- // the header comment. Esc unwinds to the option list rather than
206
- // cancelling the dialog: one Esc never discards more than one layer.
287
+ // rule 1. Esc unwinds to the option list rather than cancelling the
288
+ // dialog: one Esc never discards more than one layer.
207
289
  if (isEscapeKey(keyData)) {
208
- this.customText = undefined;
209
- this.pendingNotes = undefined;
290
+ tab.customText = undefined;
291
+ tab.pendingNotes = undefined;
210
292
  this.repaint();
211
293
  return;
212
294
  }
213
295
  if (isEnterKey(keyData)) {
214
- const text = this.customText.trim();
296
+ const text = tab.customText.trim();
215
297
  if (text.length === 0) return; // Empty custom answers are not submittable.
216
- this.session.recordAnswer(text, { custom: true, notes: this.pendingNotes });
217
- this.customText = undefined;
218
- this.pendingNotes = undefined;
219
- this.advance();
298
+ this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
299
+ tab.customText = undefined;
300
+ tab.pendingNotes = undefined;
301
+ this.afterAnswer();
220
302
  return;
221
303
  }
222
304
  if (isBackspaceKey(keyData)) {
223
- this.customText = this.customText.slice(0, -1);
305
+ tab.customText = tab.customText.slice(0, -1);
224
306
  this.repaint();
225
307
  return;
226
308
  }
227
309
  if (isPrintable(keyData)) {
228
- this.customText += keyData;
310
+ tab.customText += keyData;
229
311
  this.repaint();
230
312
  return;
231
313
  }
232
314
  // Kitty/modifyOtherKeys terminals encode plain printables as CSI-u.
233
315
  const decoded = decodeKittyPrintable(keyData);
234
316
  if (decoded !== undefined && isPrintable(decoded)) {
235
- this.customText += decoded;
317
+ tab.customText += decoded;
236
318
  this.repaint();
237
319
  }
238
320
  // Anything else (arrows, unhandled chords) is inert — never inserted