@rmrdeveloper/sideroom-pi 8.1.0 → 8.2.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,6 +4,15 @@
4
4
  [![npm monthly downloads](https://img.shields.io/npm/dm/@rmrdeveloper/sideroom-pi?label=downloads&logo=npm)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
5
5
  [![npm license](https://img.shields.io/npm/l/@rmrdeveloper/sideroom-pi?label=license)](https://www.npmjs.com/package/@rmrdeveloper/sideroom-pi)
6
6
 
7
+ **Asks before it guesses. Shows the plan. Enforces the style. Refuses to
8
+ call it done in red.**
9
+
10
+ ```bash
11
+ pi install npm:@rmrdeveloper/sideroom-pi
12
+ ```
13
+
14
+ ![Sideroom asking a multi-question batch and keeping the live board below it](https://raw.githubusercontent.com/RMRdeveloper/sideroom-pi/main/media/preview.png)
15
+
7
16
  > Coding agents fail in predictable ways: they guess instead of asking, they
8
17
  > bury progress in chat scroll, they litter your repo with `TODO.md` files,
9
18
  > every session writes code in a slightly different style, and they declare
@@ -54,9 +63,10 @@ wall of tool calls, it:
54
63
 
55
64
  - **Asks like a senior would.** A clean TUI questionnaire appears — tabs for
56
65
  several questions, a simple list for one. Every question carries a
57
- recommendation, you can always answer *Out of scope* or write your own,
58
- and everything is written in your language. Escape cancels the whole
59
- batch. No guessing, no twenty follow-up clarifications in chat.
66
+ recommendation, and a plural one lets you tick several answers at once. You
67
+ can always answer *Out of scope* or write your own, and everything is written
68
+ in your language. Escape cancels the whole batch. No guessing, no twenty
69
+ follow-up clarifications in chat.
60
70
  - **Works in the open.** A compact board sits above the editor:
61
71
  `pending → in_progress → completed`. Exactly one step is active at a
62
72
  time; the agent completes the current step and starts the next one in the
@@ -86,9 +96,11 @@ wall of tool calls, it:
86
96
  ### `sideroom_ask` — decisions, not interrogations
87
97
 
88
98
  One call is one 1–N question batch. Each question needs an `id`, a `prompt`,
89
- at least two `{ value, label }` options, and a `recommendationIndex` pointing
90
- at the recommended option. The UI adds the rest: the recommended mark, the
91
- always-on *Out of scope* row, and a custom answer.
99
+ at least two `{ value, label }` options, and a recommendation:
100
+ `recommendationIndex` for the default single answer, or
101
+ `selectionMode: "multiple"` with `recommendedIndices` when the question accepts
102
+ several. The UI adds the rest: the recommended mark, the always-on *Out of
103
+ scope* row, and a custom answer.
92
104
 
93
105
  TUI-only by design. In print, JSON, or RPC modes it fails fast with
94
106
  `UI not available` instead of hanging.
@@ -185,9 +197,10 @@ pi update --extension npm:@rmrdeveloper/sideroom-pi
185
197
  ### Questionnaire contract
186
198
 
187
199
  Each batch accepts one to four questions, with two to four caller-provided
188
- options per question. Tab labels accept at most 16 characters and option
189
- labels at most 60. Inputs that exceed a limit are rejected rather than
190
- truncated.
200
+ options per question. A question takes one answer unless it declares
201
+ `selectionMode: "multiple"`, which requires `recommendedIndices` and at least
202
+ one selection. Tab labels accept at most 16 characters and option labels at
203
+ most 60. Inputs that exceed a limit are rejected rather than truncated.
191
204
 
192
205
  Write prompts, tab labels, and option copy in the language the user is
193
206
  speaking; keep ids, option values, and TUI chrome in English. Do not send
@@ -250,6 +263,23 @@ Single-session scope only. Files are created lazily: nothing exists until
250
263
  the first term or decision crystallises. A session with a sharper glossary
251
264
  and zero ADRs is working as designed.
252
265
 
266
+ ### Coexisting with other grilling skills
267
+
268
+ Pi routes skills by description, not by name, so several grilling skills can
269
+ be installed at once. `sideroom-grill` claims non-trivial plans before
270
+ implementation and states precedence over other grilling, interview, and spec
271
+ skills. If a third-party skill still wins, remove it from routing by adding
272
+ `disable-model-invocation: true` to its frontmatter, or exclude its path with
273
+ `-path` in `settings.skills`. A literal name collision keeps the first skill
274
+ found: project `.pi/skills`, then project `.agents/skills`, then
275
+ `~/.pi/agent/skills`, then `~/.agents/skills`, then packages.
276
+
277
+ ## Documentation
278
+
279
+ Maintainer reference lives in [`docs/`](docs/README.md): the
280
+ [architecture](docs/architecture.md) and one page per
281
+ [extension](docs/extensions/ask.md).
282
+
253
283
  ## License
254
284
 
255
285
  MIT.
@@ -9,16 +9,18 @@ import {
9
9
  } from './model.ts';
10
10
 
11
11
  export const ASK_DESCRIPTION =
12
- 'Ask the user one or more questions in the language they are speaking. Use for clarifying requirements, getting preferences, or confirming decisions. Each question must include a recommended option. For a single question, shows a simple option list. For multiple questions, shows a tab-based interface.';
12
+ 'Ask the user one or more questions in the language they are speaking. Use for clarifying requirements, getting preferences, or confirming decisions. Each question must include a recommended option, or several recommended options when it accepts multiple selections. For a single question, shows a simple option list. For multiple questions, shows a tab-based interface.';
13
13
 
14
14
  export const ASK_PROMPT_SNIPPET =
15
15
  'Ask the user one or more questions with a recommended option, in their language.';
16
16
 
17
17
  export const ASK_PROMPT_GUIDELINES = [
18
18
  'Use sideroom_ask to clarify requirements, preferences, or decisions with the user.',
19
+ 'When a non-trivial request is not yet a settled spec, follow the sideroom-grill skill before implementing; it owns the interview and drives sideroom_ask rounds.',
19
20
  'Each sideroom_ask question needs an id, a prompt, at least two options, and a recommendationIndex pointing at the recommended option.',
20
21
  'Write every sideroom_ask prompt, tab label, and option label or description in the language the user is speaking. Keep ids, option values, and tool code in English.',
21
- 'sideroom_ask always adds Out of scope and a custom answer; do not include those options yourself.',
22
+ 'sideroom_ask always adds Out of scope and a custom answer; do not include those options yourself, and in a multiple-selection question they replace the selections.',
23
+ 'For a genuinely plural sideroom_ask question, set selectionMode "multiple" with recommendedIndices (at least one) instead of recommendationIndex; never send both fields, and keep the single default otherwise.',
22
24
  'One sideroom_ask call is one batch; call it again if another round of questions is needed.',
23
25
  'sideroom_ask only works in the interactive TUI; it returns an error in print, JSON, or RPC modes.',
24
26
  ];
@@ -73,6 +75,14 @@ export default function registerAsk(pi: ExtensionAPI): void {
73
75
  if (answer.wasCustom) {
74
76
  return `${mark}${id}: ${theme.fg('muted', '(wrote) ')}${answer.label}`;
75
77
  }
78
+ if (answer.selections !== undefined && answer.selections.length > 0) {
79
+ const picked = answer.selections
80
+ .map(
81
+ (selection) => `${String(selection.index)}. ${selection.label}`,
82
+ )
83
+ .join(', ');
84
+ return `${mark}${id}: ${picked}`;
85
+ }
76
86
  if (answer.index !== undefined) {
77
87
  return `${mark}${id}: ${String(answer.index)}. ${answer.label}`;
78
88
  }
@@ -9,6 +9,13 @@ export const CUSTOM_LABEL = 'Write a custom answer';
9
9
  export const UI_UNAVAILABLE =
10
10
  'Error: UI not available (running in non-interactive mode)';
11
11
 
12
+ export const SINGLE_SELECTION_MODE = 'single';
13
+ export const MULTIPLE_SELECTION_MODE = 'multiple';
14
+
15
+ export type SelectionMode =
16
+ | typeof SINGLE_SELECTION_MODE
17
+ | typeof MULTIPLE_SELECTION_MODE;
18
+
12
19
  const MAX_QUESTIONS = 4;
13
20
  const MAX_OPTIONS = 4;
14
21
  const MAX_TAB_LABEL_LENGTH = 16;
@@ -39,10 +46,32 @@ const QuestionSchema = Type.Object({
39
46
  maxItems: MAX_OPTIONS,
40
47
  description: `Two to ${String(MAX_OPTIONS)} mutually exclusive options`,
41
48
  }),
42
- recommendationIndex: Type.Integer({
43
- minimum: 0,
44
- description: 'Index into options pointing at the recommended option',
45
- }),
49
+ selectionMode: Type.Optional(
50
+ Type.Union(
51
+ [
52
+ Type.Literal(SINGLE_SELECTION_MODE),
53
+ Type.Literal(MULTIPLE_SELECTION_MODE),
54
+ ],
55
+ {
56
+ description:
57
+ "'single' (default) answers with one option; 'multiple' answers with one or more",
58
+ },
59
+ ),
60
+ ),
61
+ recommendationIndex: Type.Optional(
62
+ Type.Integer({
63
+ minimum: 0,
64
+ description:
65
+ "Required for a 'single' question: index into options pointing at the recommended option",
66
+ }),
67
+ ),
68
+ recommendedIndices: Type.Optional(
69
+ Type.Array(Type.Integer({ minimum: 0 }), {
70
+ minItems: 1,
71
+ description:
72
+ "Required for a 'multiple' question: indexes of every recommended option, at least one",
73
+ }),
74
+ ),
46
75
  });
47
76
 
48
77
  export const AskParamsSchema = Type.Object({
@@ -61,7 +90,14 @@ export interface AskQuestion {
61
90
  readonly label: string;
62
91
  readonly prompt: string;
63
92
  readonly options: readonly QuestionOption[];
64
- readonly recommendationIndex: number;
93
+ readonly selectionMode: SelectionMode;
94
+ readonly recommendedIndices: readonly number[];
95
+ }
96
+
97
+ export interface AskSelection {
98
+ readonly value: string;
99
+ readonly label: string;
100
+ readonly index: number;
65
101
  }
66
102
 
67
103
  export interface AskAnswer {
@@ -71,6 +107,7 @@ export interface AskAnswer {
71
107
  readonly wasCustom: boolean;
72
108
  readonly outOfScope: boolean;
73
109
  readonly index?: number;
110
+ readonly selections?: readonly AskSelection[];
74
111
  }
75
112
 
76
113
  export interface AskResult {
@@ -208,11 +245,9 @@ export function parseAskParams(value: unknown): ParseAskResult {
208
245
  };
209
246
  }
210
247
  seen.add(question.id);
211
- if (question.recommendationIndex >= question.options.length) {
212
- return {
213
- ok: false,
214
- message: `Error: recommendationIndex ${String(question.recommendationIndex)} is out of range for question '${question.id}'`,
215
- };
248
+ const failure = recommendationError(question);
249
+ if (failure !== undefined) {
250
+ return { ok: false, message: failure };
216
251
  }
217
252
  }
218
253
 
@@ -224,6 +259,7 @@ export function normalizeQuestions(
224
259
  ): AskQuestion[] {
225
260
  return questions.map((question, index) => {
226
261
  const label = question.label;
262
+ const selectionMode = selectionModeOf(question);
227
263
  return {
228
264
  id: question.id,
229
265
  label:
@@ -232,15 +268,67 @@ export function normalizeQuestions(
232
268
  : `Q${String(index + 1)}`,
233
269
  prompt: question.prompt,
234
270
  options: question.options,
235
- recommendationIndex: question.recommendationIndex,
271
+ selectionMode,
272
+ recommendedIndices: recommendedIndicesFor(question, selectionMode),
236
273
  };
237
274
  });
238
275
  }
239
276
 
277
+ function selectionModeOf(
278
+ question: AskParams['questions'][number],
279
+ ): SelectionMode {
280
+ return question.selectionMode === MULTIPLE_SELECTION_MODE
281
+ ? MULTIPLE_SELECTION_MODE
282
+ : SINGLE_SELECTION_MODE;
283
+ }
284
+
285
+ function recommendedIndicesFor(
286
+ question: AskParams['questions'][number],
287
+ selectionMode: SelectionMode,
288
+ ): readonly number[] {
289
+ if (selectionMode === MULTIPLE_SELECTION_MODE) {
290
+ return question.recommendedIndices ?? [];
291
+ }
292
+ if (question.recommendationIndex === undefined) {
293
+ return [];
294
+ }
295
+ return [question.recommendationIndex];
296
+ }
297
+
298
+ function recommendationError(
299
+ question: AskParams['questions'][number],
300
+ ): string | undefined {
301
+ if (selectionModeOf(question) === SINGLE_SELECTION_MODE) {
302
+ if (question.recommendationIndex === undefined) {
303
+ return `Error: Question '${question.id}' requires recommendationIndex`;
304
+ }
305
+ if (question.recommendedIndices !== undefined) {
306
+ return `Error: Question '${question.id}' is a single-selection question; remove recommendedIndices`;
307
+ }
308
+ if (question.recommendationIndex >= question.options.length) {
309
+ return `Error: recommendationIndex ${String(question.recommendationIndex)} is out of range for question '${question.id}'`;
310
+ }
311
+ return undefined;
312
+ }
313
+ if (question.recommendedIndices === undefined) {
314
+ return `Error: Question '${question.id}' is a multiple-selection question and requires recommendedIndices`;
315
+ }
316
+ if (question.recommendationIndex !== undefined) {
317
+ return `Error: Question '${question.id}' is a multiple-selection question; remove recommendationIndex`;
318
+ }
319
+ const outOfRange = question.recommendedIndices.find(
320
+ (optionIndex) => optionIndex >= question.options.length,
321
+ );
322
+ if (outOfRange !== undefined) {
323
+ return `Error: recommendedIndices ${String(outOfRange)} is out of range for question '${question.id}'`;
324
+ }
325
+ return undefined;
326
+ }
327
+
240
328
  export function renderOptions(question: AskQuestion): RenderOption[] {
241
329
  const options: RenderOption[] = question.options.map((option, index) => ({
242
330
  ...option,
243
- isRecommended: index === question.recommendationIndex,
331
+ isRecommended: question.recommendedIndices.includes(index),
244
332
  }));
245
333
  options.push({
246
334
  value: OUT_OF_SCOPE_VALUE,
@@ -268,6 +356,13 @@ export function formatAnswerLines(
268
356
  if (answer.wasCustom) {
269
357
  return `${questionLabel}: user wrote: ${answer.label}`;
270
358
  }
359
+ const selections = answer.selections;
360
+ if (selections !== undefined && selections.length > 0) {
361
+ const picked = selections
362
+ .map((selection) => `${String(selection.index)}. ${selection.label}`)
363
+ .join(', ');
364
+ return `${questionLabel}: user selected: ${picked}`;
365
+ }
271
366
  if (answer.index !== undefined) {
272
367
  return `${questionLabel}: user selected: ${String(answer.index)}. ${answer.label}`;
273
368
  }
@@ -302,6 +397,15 @@ function schemaErrorMessage(value: unknown): string {
302
397
  }
303
398
  }
304
399
 
400
+ const recommendedMatch = /\/questions\/(\d+)\/recommendedIndices$/.exec(
401
+ error.instancePath,
402
+ );
403
+ if (recommendedMatch !== null && error.keyword === 'minItems') {
404
+ const questionIndex = Number(recommendedMatch[1]);
405
+ const id = questionId(questions, questionIndex);
406
+ return `Error: Question '${id}' must mark at least one recommended option`;
407
+ }
408
+
305
409
  const tabLabelMatch = /\/questions\/(\d+)\/label$/.exec(error.instancePath);
306
410
  if (error.keyword === 'maxLength' && tabLabelMatch !== null) {
307
411
  const questionIndex = Number(tabLabelMatch[1]);
@@ -0,0 +1,47 @@
1
+ import type { AskAnswer, AskQuestion, AskSelection } from './model.ts';
2
+
3
+ /**
4
+ * Sorted ascending so the answer and its rendering stay stable no matter the
5
+ * order the user toggled the options in.
6
+ */
7
+ export function toggleSelection(
8
+ selected: readonly number[],
9
+ optionIndex: number,
10
+ ): number[] {
11
+ if (selected.includes(optionIndex)) {
12
+ return selected.filter((index) => index !== optionIndex);
13
+ }
14
+ return [...selected, optionIndex].sort((left, right) => left - right);
15
+ }
16
+
17
+ export function answerFromSelections(
18
+ question: AskQuestion,
19
+ selected: readonly number[],
20
+ ): AskAnswer {
21
+ const selections = selected.map((optionIndex) =>
22
+ toSelection(question, optionIndex),
23
+ );
24
+ const [first] = selections;
25
+ if (first === undefined) {
26
+ throw new Error(`Question '${question.id}' needs at least one selection`);
27
+ }
28
+ return {
29
+ id: question.id,
30
+ value: first.value,
31
+ label: first.label,
32
+ index: first.index,
33
+ wasCustom: false,
34
+ outOfScope: false,
35
+ selections,
36
+ };
37
+ }
38
+
39
+ function toSelection(question: AskQuestion, optionIndex: number): AskSelection {
40
+ const option = question.options[optionIndex];
41
+ if (option === undefined) {
42
+ throw new Error(
43
+ `Unknown option index ${String(optionIndex)} for question '${question.id}'`,
44
+ );
45
+ }
46
+ return { value: option.value, label: option.label, index: optionIndex + 1 };
47
+ }
@@ -11,9 +11,11 @@ import {
11
11
  type AskAnswer,
12
12
  type AskQuestion,
13
13
  type AskResult,
14
+ MULTIPLE_SELECTION_MODE,
14
15
  type RenderOption,
15
16
  renderOptions,
16
17
  } from './model.ts';
18
+ import { answerFromSelections, toggleSelection } from './selection.ts';
17
19
 
18
20
  export async function runAskUi(
19
21
  ui: Pick<ExtensionUIContext, 'custom'>,
@@ -25,8 +27,7 @@ export async function runAskUi(
25
27
  return ui.custom<AskResult>((tui, theme, _keybindings, done) => {
26
28
  let currentTab = 0;
27
29
  const firstQuestion = questions[0];
28
- let optionIndex =
29
- firstQuestion === undefined ? 0 : firstQuestion.recommendationIndex;
30
+ let optionIndex = firstQuestion?.recommendedIndices[0] ?? 0;
30
31
  let inputMode = false;
31
32
  let inputQuestionId: string | null = null;
32
33
  let cachedLines: string[] | undefined;
@@ -82,11 +83,10 @@ export async function runAskUi(
82
83
  return;
83
84
  }
84
85
  const saved = answers.get(question.id);
85
- if (saved?.index !== undefined) {
86
- optionIndex = saved.index - 1;
87
- } else {
88
- optionIndex = question.recommendationIndex;
89
- }
86
+ optionIndex =
87
+ saved?.index !== undefined
88
+ ? saved.index - 1
89
+ : (question.recommendedIndices[0] ?? 0);
90
90
  refresh();
91
91
  }
92
92
 
@@ -106,6 +106,45 @@ export async function runAskUi(
106
106
  answers.set(answer.id, answer);
107
107
  }
108
108
 
109
+ function selectedIndices(question: AskQuestion): readonly number[] {
110
+ const saved = answers.get(question.id);
111
+ if (saved?.selections === undefined) {
112
+ return [];
113
+ }
114
+ return saved.selections.map((selection) => selection.index - 1);
115
+ }
116
+
117
+ function answerSummary(answer: AskAnswer): string {
118
+ if (answer.selections === undefined || answer.selections.length === 0) {
119
+ return answer.label;
120
+ }
121
+ return answer.selections.map((selection) => selection.label).join(', ');
122
+ }
123
+
124
+ function isReservedOption(opt: RenderOption): boolean {
125
+ return opt.isOther === true || opt.isOutOfScope === true;
126
+ }
127
+
128
+ function activateReservedOption(
129
+ question: AskQuestion,
130
+ opt: RenderOption,
131
+ index: number,
132
+ ): void {
133
+ if (opt.isOther === true) {
134
+ openCustomEditor(question);
135
+ return;
136
+ }
137
+ saveAnswer({
138
+ id: question.id,
139
+ value: opt.value,
140
+ label: opt.label,
141
+ wasCustom: false,
142
+ outOfScope: true,
143
+ index: index + 1,
144
+ });
145
+ advanceAfterAnswer();
146
+ }
147
+
109
148
  function openCustomEditor(question: AskQuestion): void {
110
149
  inputMode = true;
111
150
  inputQuestionId = question.id;
@@ -127,20 +166,8 @@ export async function runAskUi(
127
166
  if (opt === undefined) {
128
167
  return;
129
168
  }
130
- if (opt.isOther === true) {
131
- openCustomEditor(question);
132
- return;
133
- }
134
- if (opt.isOutOfScope === true) {
135
- saveAnswer({
136
- id: question.id,
137
- value: opt.value,
138
- label: opt.label,
139
- wasCustom: false,
140
- outOfScope: true,
141
- index: index + 1,
142
- });
143
- advanceAfterAnswer();
169
+ if (isReservedOption(opt)) {
170
+ activateReservedOption(question, opt, index);
144
171
  return;
145
172
  }
146
173
  const original = question.options[index];
@@ -155,6 +182,49 @@ export async function runAskUi(
155
182
  advanceAfterAnswer();
156
183
  }
157
184
 
185
+ function toggleMultipleOption(
186
+ question: AskQuestion,
187
+ opts: readonly RenderOption[],
188
+ index: number,
189
+ ): void {
190
+ const opt = opts[index];
191
+ if (opt === undefined) {
192
+ return;
193
+ }
194
+ if (isReservedOption(opt)) {
195
+ activateReservedOption(question, opt, index);
196
+ return;
197
+ }
198
+ const next = toggleSelection(selectedIndices(question), index);
199
+ if (next.length === 0) {
200
+ answers.delete(question.id);
201
+ refresh();
202
+ return;
203
+ }
204
+ answers.set(question.id, answerFromSelections(question, next));
205
+ refresh();
206
+ }
207
+
208
+ function confirmMultipleSelection(
209
+ question: AskQuestion,
210
+ opts: readonly RenderOption[],
211
+ index: number,
212
+ ): void {
213
+ const opt = opts[index];
214
+ if (opt === undefined) {
215
+ return;
216
+ }
217
+ if (isReservedOption(opt)) {
218
+ activateReservedOption(question, opt, index);
219
+ return;
220
+ }
221
+ if (selectedIndices(question).length === 0) {
222
+ refresh();
223
+ return;
224
+ }
225
+ advanceAfterAnswer();
226
+ }
227
+
158
228
  editor.onSubmit = (value) => {
159
229
  if (inputQuestionId === null) {
160
230
  return;
@@ -232,15 +302,31 @@ export async function runAskUi(
232
302
  return;
233
303
  }
234
304
 
305
+ if (
306
+ question?.selectionMode === MULTIPLE_SELECTION_MODE &&
307
+ matchesKey(data, Key.space)
308
+ ) {
309
+ toggleMultipleOption(question, opts, optionIndex);
310
+ return;
311
+ }
312
+
235
313
  const digit = optionIndexFromDigit(data, opts.length);
236
314
  if (digit !== undefined && question !== undefined) {
237
315
  optionIndex = digit;
238
- confirmOption(question, opts, digit);
316
+ if (question.selectionMode === MULTIPLE_SELECTION_MODE) {
317
+ toggleMultipleOption(question, opts, digit);
318
+ } else {
319
+ confirmOption(question, opts, digit);
320
+ }
239
321
  return;
240
322
  }
241
323
 
242
324
  if (matchesKey(data, Key.enter) && question !== undefined) {
243
- confirmOption(question, opts, optionIndex);
325
+ if (question.selectionMode === MULTIPLE_SELECTION_MODE) {
326
+ confirmMultipleSelection(question, opts, optionIndex);
327
+ } else {
328
+ confirmOption(question, opts, optionIndex);
329
+ }
244
330
  return;
245
331
  }
246
332
 
@@ -310,7 +396,10 @@ export async function runAskUi(
310
396
  lines.push('');
311
397
  }
312
398
 
313
- function renderOptionList(): void {
399
+ function renderOptionList(questionForList: AskQuestion): void {
400
+ const isMultipleAnswer =
401
+ questionForList.selectionMode === MULTIPLE_SELECTION_MODE;
402
+ const selected = selectedIndices(questionForList);
314
403
  for (let index = 0; index < opts.length; index += 1) {
315
404
  const opt = opts[index];
316
405
  if (opt === undefined) {
@@ -319,10 +408,16 @@ export async function runAskUi(
319
408
  if (opt.isOutOfScope === true) {
320
409
  lines.push('');
321
410
  }
322
- const selected = index === optionIndex;
323
- const prefix = selected ? theme.fg('accent', '> ') : ' ';
324
- const color = selected ? 'accent' : 'text';
325
- let line = theme.fg(color, `${String(index + 1)}. ${opt.label}`);
411
+ const focused = index === optionIndex;
412
+ const prefix = focused ? theme.fg('accent', '> ') : ' ';
413
+ const color = focused ? 'accent' : 'text';
414
+ let line = '';
415
+ if (isMultipleAnswer && !isReservedOption(opt)) {
416
+ line += selected.includes(index)
417
+ ? theme.fg('success', '[x] ')
418
+ : theme.fg('dim', '[ ] ');
419
+ }
420
+ line += theme.fg(color, `${String(index + 1)}. ${opt.label}`);
326
421
  if (opt.isRecommended === true) {
327
422
  line = `${line} ${theme.fg('dim', 'recommended')}`;
328
423
  }
@@ -367,7 +462,7 @@ export async function runAskUi(
367
462
  }
368
463
  addWrappedWithPrefix(
369
464
  ' ',
370
- `${name}${theme.fg('dim', kind)}${theme.fg('text', answer.label)}`,
465
+ `${name}${theme.fg('dim', kind)}${theme.fg('text', answerSummary(answer))}`,
371
466
  );
372
467
  }
373
468
  lines.push('');
@@ -395,19 +490,31 @@ export async function runAskUi(
395
490
  if (saved !== undefined) {
396
491
  addWrappedWithPrefix(
397
492
  ' ',
398
- theme.fg('muted', `Current: ${saved.label}`),
493
+ theme.fg('muted', `Current: ${answerSummary(saved)}`),
399
494
  );
400
495
  }
401
496
  lines.push('');
402
- renderOptionList();
497
+ renderOptionList(question);
403
498
  }
404
499
 
405
500
  if (!inputMode && currentTab !== questions.length) {
406
501
  lines.push('');
502
+ if (
503
+ question?.selectionMode === MULTIPLE_SELECTION_MODE &&
504
+ selectedIndices(question).length === 0
505
+ ) {
506
+ addWrappedWithPrefix(
507
+ ' ',
508
+ theme.fg('warning', 'Select at least one option'),
509
+ );
510
+ }
407
511
  const count = String(opts.length);
408
- const help = isMulti
409
- ? `↑↓ or 1–${count} · Enter · Tab next · Esc cancel`
410
- : `↑↓ or 1–${count} · Enter select · Esc cancel`;
512
+ let help = `↑↓ or 1–${count} · Enter select · Esc cancel`;
513
+ if (question?.selectionMode === MULTIPLE_SELECTION_MODE) {
514
+ help = `↑↓ or 1–${count} toggle · Space toggle · Enter confirm · Esc cancel`;
515
+ } else if (isMulti) {
516
+ help = `↑↓ or 1–${count} · Enter · Tab next · Esc cancel`;
517
+ }
411
518
  addWrappedWithPrefix(' ', theme.fg('dim', help));
412
519
  }
413
520
  lines.push(theme.fg('accent', '─'.repeat(renderWidth)));