@hank-warren/pi-ask-user-question 0.4.2 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # @hank-warren/pi-ask-user-question
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 385212b: add `multiSelect` questions
8
+
9
+ A question with `multiSelect: true` renders as checkboxes for the cases where
10
+ several answers hold at once ("which of these packages should change", "which
11
+ checks to run before merging"). Space or a digit toggles a row, Enter submits
12
+ the checked options, and the answer comes back as the chosen labels joined with
13
+ `", "` — `answer` stays a plain string, so the envelope and every consumer of
14
+ `details.answers` are unchanged. The parts are also available as
15
+ `answers[].selected`.
16
+
17
+ Multi-select questions may carry 2-6 options instead of 2-4; the
18
+ `bad_option_count` message now names the mode and its range. Previews work on
19
+ multi-select options (the pane keys off the highlighted row), though
20
+ multi-select answers carry no `preview` into the envelope.
21
+
22
+ Checking the appended `Type something.` row opens the free-text field with the
23
+ other ticks preserved, and the typed value is **appended** to them rather than
24
+ replacing them, so `pi-stats, pi-plan-mode, and also the docs site` is one
25
+ answer. Such an answer records `custom: true`.
26
+
27
+ `hank:ask-user:prompt` payloads gain an optional `multiSelect` field, emitted
28
+ only when true — append-only, no new channel.
29
+
30
+ ### Patch Changes
31
+
32
+ - e05dfb5: render the questionnaire in the editor area instead of as an overlay
33
+
34
+ An overlay is composited over the bottom rows of the viewport, so the transcript
35
+ underneath the dialog was unreachable: you are already scrolled to the bottom of
36
+ the session and there is nothing left to scroll. Several lines of chat sat
37
+ behind the box with no way to read them while answering.
38
+
39
+ The dialog now mounts in the editor area — where `ctx.ui.select` renders pi's
40
+ own selectors, and where `pi-auto-permissions` already puts its approval prompt.
41
+ In the normal document flow the transcript is pushed up rather than covered, so
42
+ every line stays readable in the terminal's own scrollback.
43
+
44
+ Nothing else changes: same box, same keys, same width behavior. The input dock
45
+ is replaced for the duration of the questionnaire and restored when it closes.
46
+
47
+ - Updated dependencies [385212b]
48
+ - @hank-warren/pi-permission-selector@1.1.0
49
+
3
50
  ## 0.4.2
4
51
 
5
52
  ### 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.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": [
@@ -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.1.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,9 +29,10 @@
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.
@@ -71,9 +73,8 @@ export interface DialogOptions {
71
73
  done(result: QuestionnaireResult): void;
72
74
  requestRender?(): void;
73
75
  /**
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.
76
+ * Optional width cap. Unset means fill the width the host gives us, which is
77
+ * what the full-width editor area wants.
77
78
  */
78
79
  maxWidth?: number;
79
80
  /**
@@ -99,6 +100,13 @@ interface TabState {
99
100
  selector: OptionSelector;
100
101
  customText?: string;
101
102
  pendingNotes?: string;
103
+ /**
104
+ * Labels checked alongside the sentinel row on a multi-select question, held
105
+ * while the free-text field is open. Committed text is APPENDED to these
106
+ * rather than replacing them (amends spec §5.3), so "A, B, and also …" is
107
+ * one answer. Undefined means the field belongs to a single-select question.
108
+ */
109
+ pendingSelected?: string[];
102
110
  }
103
111
 
104
112
  export class QuestionnaireDialog {
@@ -137,6 +145,8 @@ export class QuestionnaireDialog {
137
145
  label: row.label,
138
146
  description: row.description,
139
147
  }));
148
+ // Read the mode while the cursor is still parked on this tab's question.
149
+ const multiSelect = session.isMultiSelect();
140
150
  previous(restore);
141
151
 
142
152
  return new OptionSelector({
@@ -146,6 +156,21 @@ export class QuestionnaireDialog {
146
156
  // left unconsumed by the selector so this dialog can intercept it.
147
157
  commentTrigger: isNoteKey,
148
158
  commentKeyHint: NOTE_KEY,
159
+ multiSelect,
160
+ onSubmit: (checked, comment) => {
161
+ session.goTo(index);
162
+ const labels = checked.filter((o) => !session.isCustomRow(o.value)).map((o) => o.label);
163
+ if (checked.some((o) => session.isCustomRow(o.value))) {
164
+ // Free-text mode with the other ticks held; Esc restores them.
165
+ this.tabs[index].pendingNotes = comment;
166
+ this.tabs[index].pendingSelected = labels;
167
+ this.tabs[index].customText = "";
168
+ this.repaint();
169
+ return;
170
+ }
171
+ session.recordMultiAnswer(labels, { notes: comment });
172
+ this.afterAnswer();
173
+ },
149
174
  onSelect: (option, comment) => {
150
175
  session.goTo(index);
151
176
  if (session.isCustomRow(option.value)) {
@@ -309,8 +334,8 @@ export class QuestionnaireDialog {
309
334
  // Invariant (3): never let a line break the right border, whatever
310
335
  // produced it.
311
336
  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.
337
+ // Invariant (2): pad to the full inner width, so every row of the box is
338
+ // the same rectangle and nothing shows through beside a short line.
314
339
  const pad = Math.max(0, inner - visibleWidth(line));
315
340
  out.push(`${border("│")} ${line}${" ".repeat(pad)} ${border("│")}`);
316
341
  }
@@ -347,17 +372,30 @@ export class QuestionnaireDialog {
347
372
  // rule 1. Esc unwinds to the option list rather than cancelling the
348
373
  // dialog: one Esc never discards more than one layer.
349
374
  if (isEscapeKey(keyData)) {
375
+ // Back to the option list. The selector still holds every tick, so a
376
+ // multi-select question is exactly as the user left it.
350
377
  tab.customText = undefined;
351
378
  tab.pendingNotes = undefined;
379
+ tab.pendingSelected = undefined;
352
380
  this.repaint();
353
381
  return;
354
382
  }
355
383
  if (isEnterKey(keyData)) {
356
384
  const text = tab.customText.trim();
357
385
  if (text.length === 0) return; // Empty custom answers are not submittable.
358
- this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
386
+ if (tab.pendingSelected !== undefined) {
387
+ // Append, never replace: the ticks the user left standing are part of
388
+ // the answer, and the typed value goes last.
389
+ this.session.recordMultiAnswer([...tab.pendingSelected, text], {
390
+ custom: true,
391
+ notes: tab.pendingNotes,
392
+ });
393
+ } else {
394
+ this.session.recordAnswer(text, { custom: true, notes: tab.pendingNotes });
395
+ }
359
396
  tab.customText = undefined;
360
397
  tab.pendingNotes = undefined;
398
+ tab.pendingSelected = undefined;
361
399
  this.afterAnswer();
362
400
  return;
363
401
  }