@hank-warren/pi-ask-user-question 0.4.2 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # @hank-warren/pi-ask-user-question
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 464fe1e: fix: pasting into the ask-user-question custom-answer field was silently dropped
8
+
9
+ pi-tui re-wraps pastes in bracketed-paste markers (`\x1b[200~ … \x1b[201~`) before they reach `handleInput`. The questionnaire dialog's free-text field only accepted printable chunks, so a paste — which contains ESC — fell through every key predicate into the inert branch and vanished. The note editor was unaffected because `handleCommentKey` buffers its own pastes.
10
+
11
+ The bracketed-paste state machine inside `handleCommentKey` is now extracted as a new shared export, `consumePasteChunk` (pi-permission-selector minor), and the custom-answer field routes input through it: single-chunk and chunk-spanning pastes insert, multi-line pastes flatten to one line, and input trailing the end marker is re-dispatched as ordinary keys.
12
+
13
+ - Updated dependencies [464fe1e]
14
+ - @hank-warren/pi-permission-selector@1.2.0
15
+
16
+ ## 0.5.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 385212b: add `multiSelect` questions
21
+
22
+ A question with `multiSelect: true` renders as checkboxes for the cases where
23
+ several answers hold at once ("which of these packages should change", "which
24
+ checks to run before merging"). Space or a digit toggles a row, Enter submits
25
+ the checked options, and the answer comes back as the chosen labels joined with
26
+ `", "` — `answer` stays a plain string, so the envelope and every consumer of
27
+ `details.answers` are unchanged. The parts are also available as
28
+ `answers[].selected`.
29
+
30
+ Multi-select questions may carry 2-6 options instead of 2-4; the
31
+ `bad_option_count` message now names the mode and its range. Previews work on
32
+ multi-select options (the pane keys off the highlighted row), though
33
+ multi-select answers carry no `preview` into the envelope.
34
+
35
+ Checking the appended `Type something.` row opens the free-text field with the
36
+ other ticks preserved, and the typed value is **appended** to them rather than
37
+ replacing them, so `pi-stats, pi-plan-mode, and also the docs site` is one
38
+ answer. Such an answer records `custom: true`.
39
+
40
+ `hank:ask-user:prompt` payloads gain an optional `multiSelect` field, emitted
41
+ only when true — append-only, no new channel.
42
+
43
+ ### Patch Changes
44
+
45
+ - e05dfb5: render the questionnaire in the editor area instead of as an overlay
46
+
47
+ An overlay is composited over the bottom rows of the viewport, so the transcript
48
+ underneath the dialog was unreachable: you are already scrolled to the bottom of
49
+ the session and there is nothing left to scroll. Several lines of chat sat
50
+ behind the box with no way to read them while answering.
51
+
52
+ The dialog now mounts in the editor area — where `ctx.ui.select` renders pi's
53
+ own selectors, and where `pi-auto-permissions` already puts its approval prompt.
54
+ In the normal document flow the transcript is pushed up rather than covered, so
55
+ every line stays readable in the terminal's own scrollback.
56
+
57
+ Nothing else changes: same box, same keys, same width behavior. The input dock
58
+ is replaced for the duration of the questionnaire and restored when it closes.
59
+
60
+ - Updated dependencies [385212b]
61
+ - @hank-warren/pi-permission-selector@1.1.0
62
+
3
63
  ## 0.4.2
4
64
 
5
65
  ### Patch Changes
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.3** ships 1-4 questions per call as cycleable tabs, single-select, with
8
- > an optional preview pane. See
7
+ > **v0.5** ships 1-4 questions per call as cycleable tabs, single- or
8
+ > multi-select, with an optional preview pane. See
9
9
  > [the spec](../../docs/specs/pi-ask-user-question.md) §14.
10
10
 
11
11
  ## Install
@@ -21,9 +21,10 @@ register a tool named `ask_user_question`.
21
21
 
22
22
  | Key | Action |
23
23
  |---|---|
24
- | `1`–`9` | Select that option immediately |
24
+ | `1`–`9` | Select that option immediately (toggle it, on a multi-select question) |
25
+ | `Space` | Toggle the highlighted option (multi-select only) |
25
26
  | `↑` / `↓` | Move the highlight |
26
- | `Enter` | Confirm the highlighted option |
27
+ | `Enter` | Confirm the highlighted option, or submit the checked ones |
27
28
  | `n` | Attach a note to your choice, then `Enter` to send both |
28
29
  | `Tab` / `→` | Next question |
29
30
  | `Shift+Tab` / `←` | Previous question |
@@ -34,6 +35,33 @@ answering one jumps to the next unanswered question, and the call returns once
34
35
  every question has an answer. Cycling back and re-answering replaces that
35
36
  question's answer rather than recording a second one.
36
37
 
38
+ ## Multi-select
39
+
40
+ A question with `multiSelect: true` renders as checkboxes, for the cases where
41
+ several answers hold at once — "which of these packages should change", "which
42
+ checks to run before merging":
43
+
44
+ ```
45
+ → [x] 1. pi-stats
46
+ [ ] 2. pi-statusline
47
+ [x] 3. pi-plan-mode
48
+ [ ] 4. Type something.
49
+ space/1-9 toggle · ↑↓ move · enter confirm (2) · n add note · esc cancel
50
+ ```
51
+
52
+ - Space and the digit hotkeys both toggle; a digit no longer commits, so one
53
+ keystroke cannot end the question early.
54
+ - Enter submits the checked options in list order and is inert until at least
55
+ one is checked — the count in the hint is the tell.
56
+ - The answer comes back as the chosen labels joined with `, `.
57
+ - Multi-select questions may carry 2-6 options; single-select stays at 2-4.
58
+ - Checking **`Type something.`** alongside other options opens the free-text
59
+ field with those ticks preserved, and the typed value is **appended** to them
60
+ rather than replacing them, so `pi-stats, pi-plan-mode, and also the docs
61
+ site` is a single answer.
62
+
63
+ Mutually exclusive choices stay single-select; that is still the default.
64
+
37
65
  ## Previews
38
66
 
39
67
  An option may carry a `preview` field — markdown shown in a pane below the
@@ -51,6 +79,17 @@ Every question gets an appended **`Type something.`** row for a free-text
51
79
  answer. The model is not allowed to author that row itself — reserved labels
52
80
  are rejected at runtime.
53
81
 
82
+ ## Where the dialog renders
83
+
84
+ The questionnaire renders **in the editor area**, exactly where `ctx.ui.select`
85
+ puts pi's own selectors — not as an overlay floating over the transcript.
86
+
87
+ An overlay is composited over the bottom rows of the viewport, so the chat lines
88
+ underneath it are unreachable: you are already scrolled to the bottom and there
89
+ is nothing left to scroll. Rendering in the document flow pushes the transcript
90
+ up instead of covering it, so every line stays readable in the terminal's own
91
+ scrollback while you answer.
92
+
54
93
  ## No monkey patching
55
94
 
56
95
  Numbered options and Tab-to-comment come from `OptionSelector`, imported from
@@ -77,7 +116,7 @@ pi.events.on("hank:ask-user:blocked", ({ active }) => {
77
116
  // active === true while a human is being asked
78
117
  });
79
118
  pi.events.on("hank:ask-user:prompt", ({ questions }) => {
80
- // questions[].question / .header / .options[].label
119
+ // questions[].question / .header / .multiSelect / .options[].label
81
120
  });
82
121
  ```
83
122
 
@@ -44,16 +44,21 @@ const DESCRIPTION = `Ask the user one or more structured questions during execut
44
44
  Preview feature:
45
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
46
 
47
+ Multi-select:
48
+ Set \`multiSelect: true\` on a question when several answers can hold at once — "which of these packages should change", "which checks to run before merging". Keep mutually exclusive choices single-select; that is still the default and the common case. A multi-select question may have 2-6 options instead of 2-4, renders as checkboxes, and the user toggles rows with Space or a digit and confirms with Enter. The answer comes back as the chosen labels joined with ", ", and the user may add a typed value to the checked ones through the "Type something." row. \`preview\` works on multi-select options too.
49
+
47
50
  Usage notes:
48
51
  - 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.
49
52
  - If you recommend a specific option, make it the first option and add "(Recommended)" at the end of the label.
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.`;
53
+ - Ask 1-4 questions per call, each with 2-4 options (2-6 when \`multiSelect\` is true). 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.`;
51
54
 
52
55
  function emitPrompt(pi: ExtensionAPI, params: AskUserParams): void {
53
56
  const payload: AskUserPromptEventPayload = {
54
57
  questions: params.questions.map((q) => ({
55
58
  question: q.question,
56
59
  header: q.header,
60
+ // Emitted only when true — append-only payload policy (events.ts).
61
+ ...(q.multiSelect ? { multiSelect: true } : {}),
57
62
  options: q.options.map((o) => ({ label: o.label, description: o.description })),
58
63
  })),
59
64
  };
@@ -94,6 +99,16 @@ export function registerTool(pi: ExtensionAPI): void {
94
99
  emitPrompt(pi, typed);
95
100
  emitBlocked(pi, true);
96
101
  try {
102
+ // NOT an overlay. An overlay is composited over the bottom rows of the
103
+ // viewport, so the transcript underneath it is unreachable: the user is
104
+ // already scrolled to the bottom and there is nothing left to scroll.
105
+ // Rendering in the editor area instead puts the dialog in the normal
106
+ // document flow — the transcript is pushed up rather than covered, and
107
+ // every line of it stays reachable in the terminal's own scrollback.
108
+ //
109
+ // This is also where `ctx.ui.select` renders, and where
110
+ // pi-auto-permissions' approval prompt already puts `OptionSelector`.
111
+ // pi restores the input editor when `done` fires.
97
112
  const result = await ctx.ui.custom<QuestionnaireResult | null>(
98
113
  (tui, theme, _keybindings, done) =>
99
114
  new QuestionnaireDialog({
@@ -105,24 +120,6 @@ export function registerTool(pi: ExtensionAPI): void {
105
120
  done,
106
121
  requestRender: () => tui.requestRender(),
107
122
  }),
108
- {
109
- overlay: true,
110
- // Bottom-anchored and full width, so the questionnaire sits
111
- // directly above the input dock instead of floating over the
112
- // middle of the transcript. Mirrors the geometry
113
- // @juicesharp/rpiv-ask-user-question used.
114
- //
115
- // width MUST stay "100%" in step with the dialog rendering full
116
- // width: pi composites only the columns the component emits, so
117
- // a narrower box inside a full-width overlay region would let
118
- // the transcript show through beside it.
119
- overlayOptions: {
120
- anchor: "bottom-center",
121
- width: "100%",
122
- maxHeight: "100%",
123
- margin: { left: 0, right: 0, bottom: 0 },
124
- },
125
- },
126
123
  );
127
124
 
128
125
  // `custom()` resolving undefined means the host reported hasUI but
package/events.ts CHANGED
@@ -29,6 +29,12 @@ export interface AskUserPromptOption {
29
29
  export interface AskUserPromptQuestion {
30
30
  question: string;
31
31
  header: string;
32
+ /**
33
+ * True when the question renders as checkboxes. Emitted only when true, so
34
+ * existing listeners see the payload they already knew (policy rule 2:
35
+ * append-only, new fields optional).
36
+ */
37
+ multiSelect?: boolean;
32
38
  options: ReadonlyArray<AskUserPromptOption>;
33
39
  }
34
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-ask-user-question",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
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": [
@@ -46,7 +46,7 @@
46
46
  "CHANGELOG.md"
47
47
  ],
48
48
  "dependencies": {
49
- "@hank-warren/pi-permission-selector": "^1.0.1"
49
+ "@hank-warren/pi-permission-selector": "^1.2.0"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@earendil-works/pi-coding-agent": "*",
package/questionnaire.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type AskUserParams,
21
21
  CUSTOM_ANSWER_LABEL,
22
22
  CUSTOM_ANSWER_VALUE,
23
+ MULTI_SELECT_JOIN,
23
24
  type QuestionAnswer,
24
25
  type QuestionnaireResult,
25
26
  } from "./tool/schema.ts";
@@ -122,6 +123,35 @@ export class QuestionnaireSession {
122
123
  return value === CUSTOM_ANSWER_VALUE;
123
124
  }
124
125
 
126
+ /** True when the current question renders as checkboxes. */
127
+ isMultiSelect(): boolean {
128
+ return this.current?.multiSelect === true;
129
+ }
130
+
131
+ /**
132
+ * Record (or replace) a multi-select answer.
133
+ *
134
+ * `answer` is the labels joined with `", "` — spec §5.4 — so `tool/envelope.ts`
135
+ * and every consumer of `details.answers` stay unchanged; `selected` carries
136
+ * the parts for anyone who wants them structured. `custom` means "a typed
137
+ * value is among the parts", not "the whole answer is free text".
138
+ *
139
+ * No preview is attached even when checked options declare one: concatenating
140
+ * several previews into one answer string is noise the model authored itself.
141
+ */
142
+ recordMultiAnswer(labels: string[], opts: { custom?: boolean; notes?: string } = {}): void {
143
+ const question = this.current;
144
+ if (!question || labels.length === 0) return;
145
+ this.answers[this.index] = {
146
+ questionIndex: this.index,
147
+ question: question.question,
148
+ answer: labels.join(MULTI_SELECT_JOIN),
149
+ custom: opts.custom === true,
150
+ selected: [...labels],
151
+ ...(opts.notes ? { notes: opts.notes } : {}),
152
+ };
153
+ }
154
+
125
155
  /**
126
156
  * Record (or replace) the answer for the current question. Replacement is
127
157
  * the point: cycling back to a question and picking again must not leave
package/tool/schema.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * Tool parameter schema and shared types for `ask_user_question`.
3
3
  *
4
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.
5
+ * single- or multi-select, optional per-option preview. Questions render as
6
+ * tabs the user cycles with Tab / Shift+Tab.
7
7
  */
8
8
 
9
9
  import { Type } from "typebox";
@@ -13,9 +13,19 @@ export const TOOL_NAME = "ask_user_question";
13
13
 
14
14
  export const MIN_OPTIONS = 2;
15
15
  export const MAX_OPTIONS = 4;
16
+ /**
17
+ * Multi-select questions get a larger cap. Checkboxes are a shortlist UI, not a
18
+ * pick-one UI, so six is where a list stops fitting comfortably above the input
19
+ * dock — not where it stops being a decision.
20
+ */
21
+ export const MAX_MULTI_OPTIONS = 6;
16
22
  export const MIN_QUESTIONS = 1;
17
23
  export const MAX_QUESTIONS = 4;
18
24
 
25
+ /** Upper bound on authored options for a question, by mode. */
26
+ export const maxOptionsFor = (multiSelect?: boolean): number =>
27
+ multiSelect ? MAX_MULTI_OPTIONS : MAX_OPTIONS;
28
+
19
29
  export const MAX_HEADER_LENGTH = 16;
20
30
  export const MAX_LABEL_LENGTH = 60;
21
31
 
@@ -30,6 +40,9 @@ export const CUSTOM_ANSWER_LABEL = "Type something.";
30
40
  /** Sentinel `value` for that row; never collides with a real option value. */
31
41
  export const CUSTOM_ANSWER_VALUE = "\u0000custom-answer";
32
42
 
43
+ /** Separator joining a multi-select answer's labels into `answer` (spec §5.4). */
44
+ export const MULTI_SELECT_JOIN = ", ";
45
+
33
46
  export const OptionSchema = Type.Object({
34
47
  label: Type.String({
35
48
  description:
@@ -56,11 +69,17 @@ export const QuestionSchema = Type.Object({
56
69
  description:
57
70
  'MAX 16 CHARACTERS — hard limit, requests over the limit are rejected. Very short chip/tag shown next to the question. Examples: "Auth method", "Library", "Approach".',
58
71
  }),
72
+ multiSelect: Type.Optional(
73
+ Type.Boolean({
74
+ description:
75
+ "Render this question as checkboxes so the user can pick more than one option. Use it when several answers can hold at once (\"which of these should change\", \"which checks to run before merging\"); keep mutually exclusive choices single-select. Multi-select questions may have 2-6 options instead of 2-4. The user toggles rows with Space or a digit and confirms with Enter, and the answer comes back as the chosen labels joined with \", \".",
76
+ }),
77
+ ),
59
78
  options: Type.Array(OptionSchema, {
60
79
  description:
61
- "The available choices for this question. Must have 2-4 options, each a distinct, mutually exclusive choice. The 'Type something.' row is appended automatically — do NOT author it.",
80
+ "The available choices for this question. Must have 2-4 options (2-6 when multiSelect is true), each a distinct choice; without multiSelect they must also be mutually exclusive. The 'Type something.' row is appended automatically — do NOT author it.",
62
81
  minItems: MIN_OPTIONS,
63
- maxItems: MAX_OPTIONS,
82
+ maxItems: MAX_MULTI_OPTIONS,
64
83
  }),
65
84
  });
66
85
 
@@ -82,6 +101,8 @@ export interface OptionParams {
82
101
  export interface QuestionParams {
83
102
  question: string;
84
103
  header: string;
104
+ /** Checkbox mode: the user may check several options. Default false. */
105
+ multiSelect?: boolean;
85
106
  options: OptionParams[];
86
107
  }
87
108
 
@@ -99,6 +120,12 @@ export interface QuestionAnswer {
99
120
  notes?: string;
100
121
  /** Preview text of the chosen option, when it carried one. */
101
122
  preview?: string;
123
+ /**
124
+ * The individual chosen labels, present only on multi-select answers.
125
+ * `answer` holds the same list joined with `", "`, so every existing
126
+ * consumer of `answer` — including the envelope — is unchanged.
127
+ */
128
+ selected?: string[];
102
129
  }
103
130
 
104
131
  export interface QuestionnaireResult {
package/tool/validate.ts CHANGED
@@ -14,8 +14,8 @@ import {
14
14
  type AskUserParams,
15
15
  MAX_HEADER_LENGTH,
16
16
  MAX_LABEL_LENGTH,
17
- MAX_OPTIONS,
18
17
  MAX_QUESTIONS,
18
+ maxOptionsFor,
19
19
  MIN_OPTIONS,
20
20
  MIN_QUESTIONS,
21
21
  RESERVED_LABELS,
@@ -69,10 +69,14 @@ export function validateParams(params: AskUserParams): ValidationError | undefin
69
69
  message: `${where}.header is ${q.header.length} characters; the hard limit is ${MAX_HEADER_LENGTH}. Shorten it to a chip-sized tag.`,
70
70
  };
71
71
  }
72
- if (!Array.isArray(q.options) || q.options.length < MIN_OPTIONS || q.options.length > MAX_OPTIONS) {
72
+ // The cap is mode-aware: checkboxes are a shortlist UI and get six, a
73
+ // pick-one question still gets four.
74
+ const maxOptions = maxOptionsFor(q.multiSelect);
75
+ if (!Array.isArray(q.options) || q.options.length < MIN_OPTIONS || q.options.length > maxOptions) {
76
+ const mode = q.multiSelect ? "multi-select" : "single-select";
73
77
  return {
74
78
  code: "bad_option_count",
75
- message: `${where}.options must contain ${MIN_OPTIONS}-${MAX_OPTIONS} entries; received ${q.options?.length ?? 0}.`,
79
+ message: `${where}.options must contain ${MIN_OPTIONS}-${maxOptions} entries for a ${mode} question; received ${q.options?.length ?? 0}.`,
76
80
  };
77
81
  }
78
82
 
package/view/dialog.ts CHANGED
@@ -10,9 +10,10 @@
10
10
  *
11
11
  * KEY MAP (matches @juicesharp/rpiv-ask-user-question):
12
12
  *
13
- * 1-9 select an option
13
+ * 1-9 select an option (toggle it, on a multiSelect question)
14
+ * space toggle the highlighted option (multiSelect only)
14
15
  * ↑ / ↓ move the highlight
15
- * enter confirm the highlight
16
+ * enter confirm the highlight, or submit the checked options
16
17
  * n open the note editor for the highlighted option
17
18
  * tab / → next question shift+tab / ← previous question
18
19
  * esc decline the questionnaire
@@ -28,15 +29,17 @@
28
29
  * shared predicates in `.../keys.ts`. Under the Kitty keyboard protocol
29
30
  * (Ghostty's default) Esc is `\x1b[27u`, not `\x1b`, so raw comparisons
30
31
  * trapped the user in the custom-answer field with no way out.
31
- * 2. ALWAYS pad rendered lines to the full overlay width. pi composites an
32
- * overlay onto the chat line by line and only overwrites the columns the
33
- * overlay actually emits; short lines let chat text show through.
32
+ * 2. ALWAYS pad rendered lines to the full width. The dialog is a rectangle,
33
+ * and a short line leaves whatever the renderer last drew in those columns
34
+ * visible inside the box which is exactly how it looked when this was an
35
+ * overlay composited onto the chat.
34
36
  * 3. ALWAYS clamp lines to the inner width. A single over-long line breaks the
35
37
  * right border and spills into the transcript, so `render` truncates as a
36
38
  * last-resort invariant no matter what any content source produces.
37
39
  */
38
40
 
39
41
  import {
42
+ consumePasteChunk,
40
43
  isBackspaceKey,
41
44
  isCharKey,
42
45
  isEnterKey,
@@ -71,9 +74,8 @@ export interface DialogOptions {
71
74
  done(result: QuestionnaireResult): void;
72
75
  requestRender?(): void;
73
76
  /**
74
- * Optional width cap. Unset means fill the overlay, which is what the
75
- * bottom-anchored `width: "100%"` overlay wants: a narrower box would let
76
- * the transcript show through to its right.
77
+ * Optional width cap. Unset means fill the width the host gives us, which is
78
+ * what the full-width editor area wants.
77
79
  */
78
80
  maxWidth?: number;
79
81
  /**
@@ -98,7 +100,22 @@ const PREVIEW_MAX_ROWS = 12;
98
100
  interface TabState {
99
101
  selector: OptionSelector;
100
102
  customText?: string;
103
+ /**
104
+ * In-progress bracketed paste aimed at the custom-answer field. pi-tui wraps
105
+ * pastes in `\x1b[200~ … \x1b[201~` and the markers can span input chunks;
106
+ * a paste chunk fails every key predicate, so without this buffer pasting
107
+ * into the field was silently dropped (the note editor was unaffected —
108
+ * handleCommentKey buffers its own pastes).
109
+ */
110
+ pasteBuffer?: string;
101
111
  pendingNotes?: string;
112
+ /**
113
+ * Labels checked alongside the sentinel row on a multi-select question, held
114
+ * while the free-text field is open. Committed text is APPENDED to these
115
+ * rather than replacing them (amends spec §5.3), so "A, B, and also …" is
116
+ * one answer. Undefined means the field belongs to a single-select question.
117
+ */
118
+ pendingSelected?: string[];
102
119
  }
103
120
 
104
121
  export class QuestionnaireDialog {
@@ -137,6 +154,8 @@ export class QuestionnaireDialog {
137
154
  label: row.label,
138
155
  description: row.description,
139
156
  }));
157
+ // Read the mode while the cursor is still parked on this tab's question.
158
+ const multiSelect = session.isMultiSelect();
140
159
  previous(restore);
141
160
 
142
161
  return new OptionSelector({
@@ -146,6 +165,21 @@ export class QuestionnaireDialog {
146
165
  // left unconsumed by the selector so this dialog can intercept it.
147
166
  commentTrigger: isNoteKey,
148
167
  commentKeyHint: NOTE_KEY,
168
+ multiSelect,
169
+ onSubmit: (checked, comment) => {
170
+ session.goTo(index);
171
+ const labels = checked.filter((o) => !session.isCustomRow(o.value)).map((o) => o.label);
172
+ if (checked.some((o) => session.isCustomRow(o.value))) {
173
+ // Free-text mode with the other ticks held; Esc restores them.
174
+ this.tabs[index].pendingNotes = comment;
175
+ this.tabs[index].pendingSelected = labels;
176
+ this.tabs[index].customText = "";
177
+ this.repaint();
178
+ return;
179
+ }
180
+ session.recordMultiAnswer(labels, { notes: comment });
181
+ this.afterAnswer();
182
+ },
149
183
  onSelect: (option, comment) => {
150
184
  session.goTo(index);
151
185
  if (session.isCustomRow(option.value)) {
@@ -309,8 +343,8 @@ export class QuestionnaireDialog {
309
343
  // Invariant (3): never let a line break the right border, whatever
310
344
  // produced it.
311
345
  const line = visibleWidth(raw) > inner ? truncateToWidth(raw, inner) : raw;
312
- // Invariant (2): pad to the full inner width, or pi's overlay
313
- // compositing leaves chat text visible to the right of each line.
346
+ // Invariant (2): pad to the full inner width, so every row of the box is
347
+ // the same rectangle and nothing shows through beside a short line.
314
348
  const pad = Math.max(0, inner - visibleWidth(line));
315
349
  out.push(`${border("│")} ${line}${" ".repeat(pad)} ${border("│")}`);
316
350
  }
@@ -346,18 +380,48 @@ export class QuestionnaireDialog {
346
380
  // Free-text mode. Every check below MUST use a shared predicate — see
347
381
  // rule 1. Esc unwinds to the option list rather than cancelling the
348
382
  // dialog: one Esc never discards more than one layer.
383
+ //
384
+ // Bracketed pastes come first: a paste chunk contains ESC, so it would
385
+ // otherwise fall through every predicate into the inert branch and vanish.
386
+ const paste = consumePasteChunk(tab.pasteBuffer, keyData);
387
+ if (paste !== undefined) {
388
+ tab.pasteBuffer = paste.buffer;
389
+ if (paste.text !== undefined) {
390
+ tab.customText += paste.text;
391
+ this.repaint();
392
+ }
393
+ // Ordinary input after the end marker (e.g. a trailing Enter) goes back
394
+ // through normal key handling.
395
+ if (paste.rest) this.handleInput(paste.rest);
396
+ return;
397
+ }
349
398
  if (isEscapeKey(keyData)) {
399
+ // Back to the option list. The selector still holds every tick, so a
400
+ // multi-select question is exactly as the user left it.
350
401
  tab.customText = undefined;
402
+ tab.pasteBuffer = undefined;
351
403
  tab.pendingNotes = undefined;
404
+ tab.pendingSelected = undefined;
352
405
  this.repaint();
353
406
  return;
354
407
  }
355
408
  if (isEnterKey(keyData)) {
356
409
  const text = tab.customText.trim();
357
410
  if (text.length === 0) return; // Empty custom answers are not submittable.
358
- this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
411
+ if (tab.pendingSelected !== undefined) {
412
+ // Append, never replace: the ticks the user left standing are part of
413
+ // the answer, and the typed value goes last.
414
+ this.session.recordMultiAnswer([...tab.pendingSelected, text], {
415
+ custom: true,
416
+ notes: tab.pendingNotes,
417
+ });
418
+ } else {
419
+ this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
420
+ }
359
421
  tab.customText = undefined;
422
+ tab.pasteBuffer = undefined;
360
423
  tab.pendingNotes = undefined;
424
+ tab.pendingSelected = undefined;
361
425
  this.afterAnswer();
362
426
  return;
363
427
  }