@hank-warren/pi-ask-user-question 0.2.2 → 0.4.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,9 @@ 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.3** ships 1-4 questions per call as cycleable tabs, single-select, with
8
+ > an optional preview pane. See
9
+ > [the spec](../../docs/specs/pi-ask-user-question.md) §14.
9
10
 
10
11
  ## Install
11
12
 
@@ -23,8 +24,28 @@ register a tool named `ask_user_question`.
23
24
  | `1`–`9` | Select that option immediately |
24
25
  | `↑` / `↓` | Move the highlight |
25
26
  | `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) |
27
+ | `n` | Attach a note to your choice, then `Enter` to send both |
28
+ | `Tab` / `→` | Next question |
29
+ | `Shift+Tab` / `←` | Previous question |
30
+ | `Esc` | Decline the questionnaire (or leave note/typed-answer mode) |
31
+
32
+ With several questions, each is a tab you can cycle through in any order;
33
+ answering one jumps to the next unanswered question, and the call returns once
34
+ every question has an answer. Cycling back and re-answering replaces that
35
+ question's answer rather than recording a second one.
36
+
37
+ ## Previews
38
+
39
+ An option may carry a `preview` field — markdown shown in a pane below the
40
+ options while that option is highlighted. Use it for concrete artifacts worth
41
+ comparing (ASCII mockups, code snippets, configuration variations), not for
42
+ simple preference questions. The pane is stacked rather than side-by-side, and
43
+ long previews are clipped with a `… N more lines` marker so the options always
44
+ stay visible.
45
+
46
+ **Why `n` and not `Tab` for notes?** Tab cycles questions here, matching
47
+ `@juicesharp/rpiv-ask-user-question`. `pi-auto-permissions` approval prompts
48
+ keep `Tab` for notes — they are single-question and have no tabs to cycle.
28
49
 
29
50
  Every question gets an appended **`Type something.`** row for a free-text
30
51
  answer. The model is not allowed to author that row itself — reserved labels
@@ -6,7 +6,7 @@
6
6
  * package replays cleanly. Only one of the two may be installed at a time.
7
7
  */
8
8
 
9
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
10
10
  import {
11
11
  ASK_USER_BLOCKED_EVENT,
12
12
  ASK_USER_PROMPT_EVENT,
@@ -35,16 +35,19 @@ 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
+ Preview feature:
45
+ Use the optional \`preview\` field on an option when presenting a concrete artifact the user needs to compare visually: an ASCII mockup, a code snippet, a diagram or configuration variation. It renders as markdown in a pane below the options while that option is highlighted. Do not use it for simple preference questions where the label and description already say enough.
46
+
44
47
  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.
48
+ - 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
49
  - 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.`;
50
+ - 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
51
 
49
52
  function emitPrompt(pi: ExtensionAPI, params: AskUserParams): void {
50
53
  const payload: AskUserPromptEventPayload = {
@@ -96,6 +99,9 @@ export function registerTool(pi: ExtensionAPI): void {
96
99
  new QuestionnaireDialog({
97
100
  session,
98
101
  theme,
102
+ // Reuse pi's own markdown theme so previews match the
103
+ // transcript rather than inventing a second code style.
104
+ markdownTheme: getMarkdownTheme(),
99
105
  done,
100
106
  requestRender: () => tui.requestRender(),
101
107
  }),
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.4.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.2"
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.
@@ -22,15 +28,19 @@ export interface SelectableRow {
22
28
  value: string;
23
29
  label: string;
24
30
  description?: string;
31
+ /** Markdown shown in the preview pane while this row is highlighted. */
32
+ preview?: string;
25
33
  }
26
34
 
27
35
  export class QuestionnaireSession {
28
36
  private readonly params: AskUserParams;
29
37
  private index = 0;
30
- private readonly answers: QuestionAnswer[] = [];
38
+ /** Sparse by design: `answers[i]` is undefined until question i is answered. */
39
+ private readonly answers: Array<QuestionAnswer | undefined>;
31
40
 
32
41
  constructor(params: AskUserParams) {
33
42
  this.params = params;
43
+ this.answers = new Array(params.questions.length).fill(undefined);
34
44
  }
35
45
 
36
46
  get questionIndex(): number {
@@ -45,16 +55,54 @@ export class QuestionnaireSession {
45
55
  return this.params.questions[this.index];
46
56
  }
47
57
 
48
- /** True once every question has been answered. */
58
+ /** Move the cursor, wrapping at both ends. */
59
+ goTo(index: number): void {
60
+ if (this.total === 0) return;
61
+ this.index = ((index % this.total) + this.total) % this.total;
62
+ }
63
+
64
+ next(): void {
65
+ this.goTo(this.index + 1);
66
+ }
67
+
68
+ previous(): void {
69
+ this.goTo(this.index - 1);
70
+ }
71
+
72
+ /** True once every question has an answer. */
49
73
  isComplete(): boolean {
50
- return this.index >= this.total;
74
+ return this.answers.every((answer) => answer !== undefined);
75
+ }
76
+
77
+ isAnswered(index: number): boolean {
78
+ return this.answers[index] !== undefined;
79
+ }
80
+
81
+ answeredCount(): number {
82
+ return this.answers.filter((answer) => answer !== undefined).length;
83
+ }
84
+
85
+ /**
86
+ * Move to the next question without an answer, searching forward from the
87
+ * cursor and wrapping. Returns false when everything is answered, which the
88
+ * dialog treats as "submit".
89
+ */
90
+ advanceToUnanswered(): boolean {
91
+ for (let step = 1; step <= this.total; step++) {
92
+ const candidate = (this.index + step) % this.total;
93
+ if (!this.isAnswered(candidate)) {
94
+ this.index = candidate;
95
+ return true;
96
+ }
97
+ }
98
+ return false;
51
99
  }
52
100
 
53
101
  /**
54
102
  * Rows for the current question: the authored options plus the appended
55
103
  * 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.
104
+ * added here and stripped when recording, which is why validation rejects
105
+ * models that try to author it themselves.
58
106
  */
59
107
  rows(): SelectableRow[] {
60
108
  const question = this.current;
@@ -63,6 +111,7 @@ export class QuestionnaireSession {
63
111
  value: option.label,
64
112
  label: option.label,
65
113
  description: option.description,
114
+ ...(option.preview ? { preview: option.preview } : {}),
66
115
  }));
67
116
  rows.push({ value: CUSTOM_ANSWER_VALUE, label: CUSTOM_ANSWER_LABEL });
68
117
  return rows;
@@ -74,23 +123,38 @@ export class QuestionnaireSession {
74
123
  }
75
124
 
76
125
  /**
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.
126
+ * Record (or replace) the answer for the current question. Replacement is
127
+ * the point: cycling back to a question and picking again must not leave
128
+ * two answers for one question in the envelope.
79
129
  */
80
130
  recordAnswer(answer: string, opts: { custom?: boolean; notes?: string } = {}): void {
81
131
  const question = this.current;
82
132
  if (!question) return;
83
- this.answers.push({
133
+ // A custom typed answer never carries a preview; only a chosen option can.
134
+ const preview = opts.custom
135
+ ? undefined
136
+ : question.options.find((option) => option.label === answer)?.preview;
137
+ this.answers[this.index] = {
84
138
  questionIndex: this.index,
85
139
  question: question.question,
86
140
  answer,
87
141
  custom: opts.custom === true,
88
142
  ...(opts.notes ? { notes: opts.notes } : {}),
89
- });
90
- this.index += 1;
143
+ ...(preview ? { preview } : {}),
144
+ };
145
+ }
146
+
147
+ /** The recorded answer for a question, if any. */
148
+ answerAt(index: number): QuestionAnswer | undefined {
149
+ return this.answers[index];
150
+ }
151
+
152
+ /** Header for question `index`, used by the tab strip. */
153
+ headerAt(index: number): string {
154
+ return this.params.questions[index]?.header ?? "";
91
155
  }
92
156
 
93
- /** Title line for the dialog: `Header` or `2 of 3 · Header` when multiple. */
157
+ /** Title line: `Header` alone, or `2 of 3 · Header` when there are tabs. */
94
158
  title(): string {
95
159
  const question = this.current;
96
160
  if (!question) return "";
@@ -99,13 +163,17 @@ export class QuestionnaireSession {
99
163
  : question.header;
100
164
  }
101
165
 
166
+ private collected(): QuestionAnswer[] {
167
+ return this.answers.filter((answer): answer is QuestionAnswer => answer !== undefined);
168
+ }
169
+
102
170
  /** Successful outcome. */
103
171
  result(): QuestionnaireResult {
104
- return { answers: [...this.answers], cancelled: false };
172
+ return { answers: this.collected(), cancelled: false };
105
173
  }
106
174
 
107
175
  /** Declined outcome, preserving any answers given before the cancel. */
108
176
  cancelledResult(): QuestionnaireResult {
109
- return { answers: [...this.answers], cancelled: true };
177
+ return { answers: this.collected(), cancelled: true };
110
178
  }
111
179
  }
package/tool/envelope.ts CHANGED
@@ -23,9 +23,14 @@ export function buildToolResult(text: string, details: QuestionnaireResult): Too
23
23
  return { content: [{ type: "text" as const, text }], details };
24
24
  }
25
25
 
26
- /** Format a single answer as `"question"="answer"`, with an optional note. */
26
+ /**
27
+ * Format a single answer as `"question"="answer"`, with the chosen option's
28
+ * preview and the user's note appended when present. Order and wording match
29
+ * rpiv's envelope and are pinned by tests.
30
+ */
27
31
  export function buildAnswerSegment(a: QuestionAnswer): string {
28
32
  const parts: string[] = [`"${a.question}"="${a.answer}"`];
33
+ if (a.preview && a.preview.length > 0) parts.push(`selected preview: ${a.preview}`);
29
34
  if (a.notes && a.notes.length > 0) parts.push(`user notes: ${a.notes}`);
30
35
  return `${parts.join(". ")}.`;
31
36
  }
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;
@@ -42,6 +39,12 @@ export const OptionSchema = Type.Object({
42
39
  description:
43
40
  "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.",
44
41
  }),
42
+ preview: Type.Optional(
43
+ Type.String({
44
+ description:
45
+ "Optional markdown shown in a pane below the options while this option is highlighted. Use for concrete artifacts the user needs to compare: ASCII mockups, code snippets, diagram or configuration variations. Do NOT use it for simple preference questions where the label and description already say enough.",
46
+ }),
47
+ ),
45
48
  });
46
49
 
47
50
  export const QuestionSchema = Type.Object({
@@ -63,7 +66,8 @@ export const QuestionSchema = Type.Object({
63
66
 
64
67
  export const QuestionParamsSchema = Type.Object({
65
68
  questions: Type.Array(QuestionSchema, {
66
- description: "Questions to ask the user. Exactly one question is supported in this version.",
69
+ description:
70
+ "Questions to ask the user. 1-4 questions; the user cycles between them with Tab and answers each one.",
67
71
  minItems: MIN_QUESTIONS,
68
72
  maxItems: MAX_QUESTIONS,
69
73
  }),
@@ -72,6 +76,7 @@ export const QuestionParamsSchema = Type.Object({
72
76
  export interface OptionParams {
73
77
  label: string;
74
78
  description: string;
79
+ preview?: string;
75
80
  }
76
81
 
77
82
  export interface QuestionParams {
@@ -90,8 +95,10 @@ export interface QuestionAnswer {
90
95
  question: string;
91
96
  answer: string;
92
97
  custom: boolean;
93
- /** Trimmed Tab-to-comment note, when the user attached one. */
98
+ /** Trimmed note, when the user attached one. */
94
99
  notes?: string;
100
+ /** Preview text of the chosen option, when it carried one. */
101
+ preview?: string;
95
102
  }
96
103
 
97
104
  export interface QuestionnaireResult {
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 {
@@ -37,6 +55,7 @@ import {
37
55
  visibleWidth,
38
56
  wrapTextWithAnsi,
39
57
  } from "@earendil-works/pi-tui";
58
+ import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
40
59
  import type { QuestionnaireSession } from "../questionnaire.ts";
41
60
  import type { QuestionnaireResult } from "../tool/schema.ts";
42
61
 
@@ -57,71 +76,101 @@ export interface DialogOptions {
57
76
  * the transcript show through to its right.
58
77
  */
59
78
  maxWidth?: number;
79
+ /**
80
+ * Markdown theme for the preview pane. Hosts pass pi's `getMarkdownTheme()`;
81
+ * when omitted the preview renders as plain wrapped text, which keeps this
82
+ * component unit-testable without constructing a 14-function theme.
83
+ */
84
+ markdownTheme?: MarkdownTheme;
60
85
  }
61
86
 
62
87
  /** Left/right border plus one space of padding on each side. */
63
88
  const CHROME_COLUMNS = 4;
64
89
  /** Indent for the custom-answer field, in columns. */
65
90
  const FIELD_INDENT = " ";
91
+ /** Opens the note editor. Tab is taken by question cycling. */
92
+ const NOTE_KEY = "n";
93
+ const isNoteKey = isCharKey(NOTE_KEY);
94
+ /** Maximum rows the preview pane may occupy before it is clipped. */
95
+ const PREVIEW_MAX_ROWS = 12;
96
+
97
+ /** Per-question UI state, preserved while cycling between tabs. */
98
+ interface TabState {
99
+ selector: OptionSelector;
100
+ customText?: string;
101
+ pendingNotes?: string;
102
+ }
66
103
 
67
104
  export class QuestionnaireDialog {
68
105
  /** Focusable — set by the TUI when focus changes. Drives CURSOR_MARKER. */
69
106
  focused = false;
70
107
 
71
108
  private readonly opts: DialogOptions;
72
- private selector: OptionSelector;
73
- private customText: string | undefined;
74
- private pendingNotes: string | undefined;
109
+ private readonly tabs: TabState[] = [];
75
110
  private finished = false;
76
111
 
77
112
  constructor(opts: DialogOptions) {
78
113
  this.opts = opts;
79
- this.selector = this.buildSelector();
114
+ for (let i = 0; i < this.session.total; i++) this.tabs.push({ selector: this.buildSelector(i) });
80
115
  }
81
116
 
82
117
  private get session() {
83
118
  return this.opts.session;
84
119
  }
85
120
 
121
+ private get tab(): TabState {
122
+ return this.tabs[this.session.questionIndex];
123
+ }
124
+
86
125
  private repaint(): void {
87
126
  this.opts.requestRender?.();
88
127
  }
89
128
 
90
- private buildSelector(): OptionSelector {
129
+ private buildSelector(index: number): OptionSelector {
91
130
  const session = this.session;
131
+ const previous = session.goTo.bind(session);
132
+ // rows() reads the cursor, so snapshot this tab's rows at build time.
133
+ const restore = session.questionIndex;
134
+ previous(index);
92
135
  const options: SelectorOption[] = session.rows().map((row) => ({
93
136
  value: row.value,
94
137
  label: row.label,
95
138
  description: row.description,
96
139
  }));
140
+ previous(restore);
97
141
 
98
142
  return new OptionSelector({
99
143
  options,
100
144
  theme: this.opts.theme,
145
+ // `n` instead of Tab; Tab must stay free for question cycling. Tab is
146
+ // left unconsumed by the selector so this dialog can intercept it.
147
+ commentTrigger: isNoteKey,
148
+ commentKeyHint: NOTE_KEY,
101
149
  onSelect: (option, comment) => {
150
+ session.goTo(index);
102
151
  if (session.isCustomRow(option.value)) {
103
152
  // Enter free-text mode. A note typed on the sentinel row is
104
153
  // carried across so it is not silently lost.
105
- this.pendingNotes = comment;
106
- this.customText = "";
154
+ this.tabs[index].pendingNotes = comment;
155
+ this.tabs[index].customText = "";
107
156
  this.repaint();
108
157
  return;
109
158
  }
110
159
  session.recordAnswer(option.value, { notes: comment });
111
- this.advance();
160
+ this.afterAnswer();
112
161
  },
113
162
  onCancel: () => this.finish(session.cancelledResult()),
114
163
  requestRender: () => this.repaint(),
115
164
  });
116
165
  }
117
166
 
118
- private advance(): void {
167
+ /** Submit when everything is answered, else jump to the next gap. */
168
+ private afterAnswer(): void {
119
169
  if (this.session.isComplete()) {
120
170
  this.finish(this.session.result());
121
171
  return;
122
172
  }
123
- // v0.2: multi-question calls rebind the selector to the next question.
124
- this.selector = this.buildSelector();
173
+ this.session.advanceToUnanswered();
125
174
  this.repaint();
126
175
  }
127
176
 
@@ -133,11 +182,16 @@ export class QuestionnaireDialog {
133
182
 
134
183
  /** True while the free-text custom-answer editor is open. */
135
184
  isTypingCustom(): boolean {
136
- return this.customText !== undefined;
185
+ return this.tab?.customText !== undefined;
186
+ }
187
+
188
+ /** True while any text-entry mode owns the keyboard. */
189
+ private isTyping(): boolean {
190
+ return this.isTypingCustom() || this.tab.selector.isCommenting();
137
191
  }
138
192
 
139
193
  invalidate(): void {
140
- this.selector.invalidate();
194
+ for (const tab of this.tabs) tab.selector.invalidate();
141
195
  }
142
196
 
143
197
  private style(role: string, text: string): string {
@@ -145,19 +199,34 @@ export class QuestionnaireDialog {
145
199
  return role === "dim" ? `\x1b[2m${text}\x1b[0m` : text;
146
200
  }
147
201
 
202
+ /** `✓ Database ▸ Cache ○ Queue` — one line, only when tabs exist. */
203
+ private tabStrip(): string {
204
+ const parts: string[] = [];
205
+ for (let i = 0; i < this.session.total; i++) {
206
+ const current = i === this.session.questionIndex;
207
+ const mark = this.session.isAnswered(i) ? "✓" : current ? "▸" : "○";
208
+ const label = `${mark} ${this.session.headerAt(i)}`;
209
+ parts.push(current ? this.style("accent", label) : this.style("dim", label));
210
+ }
211
+ return parts.join(" ");
212
+ }
213
+
148
214
  /** Inner content lines, before the box is drawn around them. */
149
215
  private contentLines(inner: number): string[] {
150
216
  const session = this.session;
151
- const lines: string[] = [this.style("accent", session.title()), ""];
217
+ const lines: string[] = [this.style("accent", session.title())];
218
+ if (session.total > 1) lines.push(this.tabStrip());
219
+ lines.push("");
152
220
  lines.push(...wrapTextWithAnsi(session.current?.question ?? "", inner));
153
221
  lines.push("");
154
222
 
155
- if (this.customText !== undefined) {
223
+ const tab = this.tab;
224
+ if (tab.customText !== undefined) {
156
225
  // Wrap the typed answer. Without this a long answer ran past the right
157
226
  // border and off the screen forever, because an input field renders as
158
227
  // one line unless something breaks it up.
159
228
  const avail = Math.max(1, inner - FIELD_INDENT.length - 1); // -1 reserves the caret cell
160
- const wrapped = wrapTextWithAnsi(this.customText, avail);
229
+ const wrapped = wrapTextWithAnsi(tab.customText, avail);
161
230
  // CURSOR_MARKER is a zero-width APC sequence: the TUI strips it and
162
231
  // parks the hardware cursor there, so the caret lands in the field
163
232
  // instead of at the bottom of the screen.
@@ -171,10 +240,64 @@ export class QuestionnaireDialog {
171
240
  return lines;
172
241
  }
173
242
 
174
- lines.push(...this.selector.render(inner));
243
+ lines.push(...tab.selector.render(inner));
244
+ lines.push(...this.previewLines(inner));
245
+ if (session.total > 1 && !tab.selector.isCommenting()) {
246
+ lines.push(this.style("dim", " tab next · shift+tab prev question"));
247
+ }
175
248
  return lines;
176
249
  }
177
250
 
251
+ /** Preview markdown for the highlighted row, if it carries any. */
252
+ private currentPreview(): string | undefined {
253
+ const selected = this.tab.selector.getSelected();
254
+ if (!selected) return undefined;
255
+ return this.session.rows().find((row) => row.value === selected.value)?.preview;
256
+ }
257
+
258
+ /**
259
+ * The preview pane: markdown between two rules, below the options.
260
+ *
261
+ * Stacked, not side-by-side. rpiv spent ~712 lines largely on making a
262
+ * two-column layout behave at narrow widths; stacking needs none of it
263
+ * (docs/specs/pi-ask-user-question.md §6.2).
264
+ */
265
+ private previewLines(inner: number): string[] {
266
+ const preview = this.currentPreview();
267
+ if (!preview) return [];
268
+
269
+ const rendered = this.renderMarkdown(preview, inner);
270
+
271
+ // Cap the pane so a long preview cannot push the options off screen.
272
+ const clipped = rendered.slice(0, PREVIEW_MAX_ROWS);
273
+ if (rendered.length > PREVIEW_MAX_ROWS) {
274
+ clipped.push(this.style("dim", `… ${rendered.length - PREVIEW_MAX_ROWS} more lines`));
275
+ }
276
+
277
+ const rule = this.style("dim", "─".repeat(Math.max(1, inner)));
278
+ return ["", rule, ...clipped, rule];
279
+ }
280
+
281
+ /**
282
+ * Render preview markdown, falling back to plain wrapped text.
283
+ *
284
+ * The fallback is not just for hosts that pass no theme. pi's
285
+ * `getMarkdownTheme()` returns lazily-bound functions that throw
286
+ * "Theme not initialized" until `initTheme()` has run, and a throw inside
287
+ * `render()` would take down the entire dialog rather than one pane. A
288
+ * preview is a nicety; it must never be able to do that.
289
+ */
290
+ private renderMarkdown(preview: string, inner: number): string[] {
291
+ if (this.opts.markdownTheme) {
292
+ try {
293
+ return new Markdown(preview, 0, 0, this.opts.markdownTheme).render(inner);
294
+ } catch {
295
+ // fall through to plain text
296
+ }
297
+ }
298
+ return preview.split("\n").flatMap((line) => wrapTextWithAnsi(line, inner));
299
+ }
300
+
178
301
  render(width: number): string[] {
179
302
  const cap = this.opts.maxWidth ?? width;
180
303
  const outer = Math.max(20, Math.min(width, cap));
@@ -196,43 +319,62 @@ export class QuestionnaireDialog {
196
319
  }
197
320
 
198
321
  handleInput(keyData: string): void {
199
- if (this.customText === undefined) {
200
- this.selector.handleInput(keyData);
322
+ const tab = this.tab;
323
+
324
+ // Question cycling. Deliberately NOT active while typing: Tab inside the
325
+ // note editor means "back to options", and inside the custom-answer field
326
+ // a stray Tab must never teleport the user to another question and strand
327
+ // their half-typed text.
328
+ if (this.session.total > 1 && !this.isTyping()) {
329
+ if (isTabKey(keyData) || isRightKey(keyData)) {
330
+ this.session.next();
331
+ this.repaint();
332
+ return;
333
+ }
334
+ if (isShiftTabKey(keyData) || isLeftKey(keyData)) {
335
+ this.session.previous();
336
+ this.repaint();
337
+ return;
338
+ }
339
+ }
340
+
341
+ if (tab.customText === undefined) {
342
+ tab.selector.handleInput(keyData);
201
343
  return;
202
344
  }
203
345
 
204
346
  // 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.
347
+ // rule 1. Esc unwinds to the option list rather than cancelling the
348
+ // dialog: one Esc never discards more than one layer.
207
349
  if (isEscapeKey(keyData)) {
208
- this.customText = undefined;
209
- this.pendingNotes = undefined;
350
+ tab.customText = undefined;
351
+ tab.pendingNotes = undefined;
210
352
  this.repaint();
211
353
  return;
212
354
  }
213
355
  if (isEnterKey(keyData)) {
214
- const text = this.customText.trim();
356
+ const text = tab.customText.trim();
215
357
  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();
358
+ this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
359
+ tab.customText = undefined;
360
+ tab.pendingNotes = undefined;
361
+ this.afterAnswer();
220
362
  return;
221
363
  }
222
364
  if (isBackspaceKey(keyData)) {
223
- this.customText = this.customText.slice(0, -1);
365
+ tab.customText = tab.customText.slice(0, -1);
224
366
  this.repaint();
225
367
  return;
226
368
  }
227
369
  if (isPrintable(keyData)) {
228
- this.customText += keyData;
370
+ tab.customText += keyData;
229
371
  this.repaint();
230
372
  return;
231
373
  }
232
374
  // Kitty/modifyOtherKeys terminals encode plain printables as CSI-u.
233
375
  const decoded = decodeKittyPrintable(keyData);
234
376
  if (decoded !== undefined && isPrintable(decoded)) {
235
- this.customText += decoded;
377
+ tab.customText += decoded;
236
378
  this.repaint();
237
379
  }
238
380
  // Anything else (arrows, unhandled chords) is inert — never inserted