@hank-warren/pi-ask-user-question 0.2.1 → 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 = {
@@ -99,7 +99,24 @@ export function registerTool(pi: ExtensionAPI): void {
99
99
  done,
100
100
  requestRender: () => tui.requestRender(),
101
101
  }),
102
- { overlay: true },
102
+ {
103
+ overlay: true,
104
+ // Bottom-anchored and full width, so the questionnaire sits
105
+ // directly above the input dock instead of floating over the
106
+ // middle of the transcript. Mirrors the geometry
107
+ // @juicesharp/rpiv-ask-user-question used.
108
+ //
109
+ // width MUST stay "100%" in step with the dialog rendering full
110
+ // width: pi composites only the columns the component emits, so
111
+ // a narrower box inside a full-width overlay region would let
112
+ // the transcript show through beside it.
113
+ overlayOptions: {
114
+ anchor: "bottom-center",
115
+ width: "100%",
116
+ maxHeight: "100%",
117
+ margin: { left: 0, right: 0, bottom: 0 },
118
+ },
119
+ },
103
120
  );
104
121
 
105
122
  // `custom()` resolving undefined means the host reported hasUI but
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-ask-user-question",
3
- "version": "0.2.1",
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,18 +30,31 @@
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.
34
+ * 3. ALWAYS clamp lines to the inner width. A single over-long line breaks the
35
+ * right border and spills into the transcript, so `render` truncates as a
36
+ * last-resort invariant no matter what any content source produces.
21
37
  */
22
38
 
23
39
  import {
24
40
  isBackspaceKey,
41
+ isCharKey,
25
42
  isEnterKey,
26
43
  isEscapeKey,
44
+ isLeftKey,
27
45
  isPrintable,
46
+ isRightKey,
47
+ isShiftTabKey,
48
+ isTabKey,
28
49
  } from "@hank-warren/pi-permission-selector/keys.ts";
29
50
  import { OptionSelector, type SelectorOption } from "@hank-warren/pi-permission-selector/selector.ts";
30
- import { CURSOR_MARKER, decodeKittyPrintable, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
51
+ import {
52
+ CURSOR_MARKER,
53
+ decodeKittyPrintable,
54
+ truncateToWidth,
55
+ visibleWidth,
56
+ wrapTextWithAnsi,
57
+ } from "@earendil-works/pi-tui";
31
58
  import type { QuestionnaireSession } from "../questionnaire.ts";
32
59
  import type { QuestionnaireResult } from "../tool/schema.ts";
33
60
 
@@ -42,72 +69,99 @@ export interface DialogOptions {
42
69
  /** Called exactly once with the final outcome. */
43
70
  done(result: QuestionnaireResult): void;
44
71
  requestRender?(): void;
45
- /** Overlay width cap. Defaults to 84 columns. */
72
+ /**
73
+ * Optional width cap. Unset means fill the overlay, which is what the
74
+ * bottom-anchored `width: "100%"` overlay wants: a narrower box would let
75
+ * the transcript show through to its right.
76
+ */
46
77
  maxWidth?: number;
47
78
  }
48
79
 
49
- const DEFAULT_MAX_WIDTH = 84;
50
80
  /** Left/right border plus one space of padding on each side. */
51
81
  const CHROME_COLUMNS = 4;
82
+ /** Indent for the custom-answer field, in columns. */
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
+ }
52
94
 
53
95
  export class QuestionnaireDialog {
54
96
  /** Focusable — set by the TUI when focus changes. Drives CURSOR_MARKER. */
55
97
  focused = false;
56
98
 
57
99
  private readonly opts: DialogOptions;
58
- private selector: OptionSelector;
59
- private customText: string | undefined;
60
- private pendingNotes: string | undefined;
100
+ private readonly tabs: TabState[] = [];
61
101
  private finished = false;
62
102
 
63
103
  constructor(opts: DialogOptions) {
64
104
  this.opts = opts;
65
- this.selector = this.buildSelector();
105
+ for (let i = 0; i < this.session.total; i++) this.tabs.push({ selector: this.buildSelector(i) });
66
106
  }
67
107
 
68
108
  private get session() {
69
109
  return this.opts.session;
70
110
  }
71
111
 
112
+ private get tab(): TabState {
113
+ return this.tabs[this.session.questionIndex];
114
+ }
115
+
72
116
  private repaint(): void {
73
117
  this.opts.requestRender?.();
74
118
  }
75
119
 
76
- private buildSelector(): OptionSelector {
120
+ private buildSelector(index: number): OptionSelector {
77
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);
78
126
  const options: SelectorOption[] = session.rows().map((row) => ({
79
127
  value: row.value,
80
128
  label: row.label,
81
129
  description: row.description,
82
130
  }));
131
+ previous(restore);
83
132
 
84
133
  return new OptionSelector({
85
134
  options,
86
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,
87
140
  onSelect: (option, comment) => {
141
+ session.goTo(index);
88
142
  if (session.isCustomRow(option.value)) {
89
143
  // Enter free-text mode. A note typed on the sentinel row is
90
144
  // carried across so it is not silently lost.
91
- this.pendingNotes = comment;
92
- this.customText = "";
145
+ this.tabs[index].pendingNotes = comment;
146
+ this.tabs[index].customText = "";
93
147
  this.repaint();
94
148
  return;
95
149
  }
96
150
  session.recordAnswer(option.value, { notes: comment });
97
- this.advance();
151
+ this.afterAnswer();
98
152
  },
99
153
  onCancel: () => this.finish(session.cancelledResult()),
100
154
  requestRender: () => this.repaint(),
101
155
  });
102
156
  }
103
157
 
104
- private advance(): void {
158
+ /** Submit when everything is answered, else jump to the next gap. */
159
+ private afterAnswer(): void {
105
160
  if (this.session.isComplete()) {
106
161
  this.finish(this.session.result());
107
162
  return;
108
163
  }
109
- // v0.2: multi-question calls rebind the selector to the next question.
110
- this.selector = this.buildSelector();
164
+ this.session.advanceToUnanswered();
111
165
  this.repaint();
112
166
  }
113
167
 
@@ -119,11 +173,16 @@ export class QuestionnaireDialog {
119
173
 
120
174
  /** True while the free-text custom-answer editor is open. */
121
175
  isTypingCustom(): boolean {
122
- 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();
123
182
  }
124
183
 
125
184
  invalidate(): void {
126
- this.selector.invalidate();
185
+ for (const tab of this.tabs) tab.selector.invalidate();
127
186
  }
128
187
 
129
188
  private style(role: string, text: string): string {
@@ -131,37 +190,67 @@ export class QuestionnaireDialog {
131
190
  return role === "dim" ? `\x1b[2m${text}\x1b[0m` : text;
132
191
  }
133
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
+
134
205
  /** Inner content lines, before the box is drawn around them. */
135
206
  private contentLines(inner: number): string[] {
136
207
  const session = this.session;
137
- 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("");
138
211
  lines.push(...wrapTextWithAnsi(session.current?.question ?? "", inner));
139
212
  lines.push("");
140
213
 
141
- if (this.customText !== undefined) {
214
+ const tab = this.tab;
215
+ if (tab.customText !== undefined) {
216
+ // Wrap the typed answer. Without this a long answer ran past the right
217
+ // border and off the screen forever, because an input field renders as
218
+ // one line unless something breaks it up.
219
+ const avail = Math.max(1, inner - FIELD_INDENT.length - 1); // -1 reserves the caret cell
220
+ const wrapped = wrapTextWithAnsi(tab.customText, avail);
142
221
  // CURSOR_MARKER is a zero-width APC sequence: the TUI strips it and
143
222
  // parks the hardware cursor there, so the caret lands in the field
144
223
  // instead of at the bottom of the screen.
145
- const caret = this.focused ? CURSOR_MARKER : "";
146
- lines.push(` ${this.customText}${caret}▌`);
224
+ const caret = `${this.focused ? CURSOR_MARKER : ""}▌`;
225
+ for (let i = 0; i < wrapped.length; i++) {
226
+ const last = i === wrapped.length - 1;
227
+ lines.push(`${FIELD_INDENT}${wrapped[i]}${last ? caret : ""}`);
228
+ }
147
229
  lines.push("");
148
230
  lines.push(this.style("dim", " enter submit · esc back to options"));
149
231
  return lines;
150
232
  }
151
233
 
152
- 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
+ }
153
238
  return lines;
154
239
  }
155
240
 
156
241
  render(width: number): string[] {
157
- const outer = Math.max(20, Math.min(width, this.opts.maxWidth ?? DEFAULT_MAX_WIDTH));
242
+ const cap = this.opts.maxWidth ?? width;
243
+ const outer = Math.max(20, Math.min(width, cap));
158
244
  const inner = outer - CHROME_COLUMNS;
159
245
  const border = (text: string) => this.style("dim", text);
160
246
 
161
247
  const out: string[] = [border(`┌${"─".repeat(outer - 2)}┐`)];
162
- for (const line of this.contentLines(inner)) {
163
- // Pad to the full inner width. Without this, pi's overlay compositing
164
- // leaves the underlying chat text visible to the right of each line.
248
+ for (const raw of this.contentLines(inner)) {
249
+ // Invariant (3): never let a line break the right border, whatever
250
+ // produced it.
251
+ const line = visibleWidth(raw) > inner ? truncateToWidth(raw, inner) : raw;
252
+ // Invariant (2): pad to the full inner width, or pi's overlay
253
+ // compositing leaves chat text visible to the right of each line.
165
254
  const pad = Math.max(0, inner - visibleWidth(line));
166
255
  out.push(`${border("│")} ${line}${" ".repeat(pad)} ${border("│")}`);
167
256
  }
@@ -170,43 +259,62 @@ export class QuestionnaireDialog {
170
259
  }
171
260
 
172
261
  handleInput(keyData: string): void {
173
- if (this.customText === undefined) {
174
- 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);
175
283
  return;
176
284
  }
177
285
 
178
286
  // Free-text mode. Every check below MUST use a shared predicate — see
179
- // the header comment. Esc unwinds to the option list rather than
180
- // 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.
181
289
  if (isEscapeKey(keyData)) {
182
- this.customText = undefined;
183
- this.pendingNotes = undefined;
290
+ tab.customText = undefined;
291
+ tab.pendingNotes = undefined;
184
292
  this.repaint();
185
293
  return;
186
294
  }
187
295
  if (isEnterKey(keyData)) {
188
- const text = this.customText.trim();
296
+ const text = tab.customText.trim();
189
297
  if (text.length === 0) return; // Empty custom answers are not submittable.
190
- this.session.recordAnswer(text, { custom: true, notes: this.pendingNotes });
191
- this.customText = undefined;
192
- this.pendingNotes = undefined;
193
- this.advance();
298
+ this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
299
+ tab.customText = undefined;
300
+ tab.pendingNotes = undefined;
301
+ this.afterAnswer();
194
302
  return;
195
303
  }
196
304
  if (isBackspaceKey(keyData)) {
197
- this.customText = this.customText.slice(0, -1);
305
+ tab.customText = tab.customText.slice(0, -1);
198
306
  this.repaint();
199
307
  return;
200
308
  }
201
309
  if (isPrintable(keyData)) {
202
- this.customText += keyData;
310
+ tab.customText += keyData;
203
311
  this.repaint();
204
312
  return;
205
313
  }
206
314
  // Kitty/modifyOtherKeys terminals encode plain printables as CSI-u.
207
315
  const decoded = decodeKittyPrintable(keyData);
208
316
  if (decoded !== undefined && isPrintable(decoded)) {
209
- this.customText += decoded;
317
+ tab.customText += decoded;
210
318
  this.repaint();
211
319
  }
212
320
  // Anything else (arrows, unhandled chords) is inert — never inserted