killeros 2.0.5 → 2.0.7

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.
@@ -1,13 +1,15 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { type ExtensionAPI, type ThemeColor } from "@earendil-works/pi-coding-agent";
2
3
  import {
3
4
  decodeKittyPrintable,
4
5
  Editor,
5
6
  Markdown,
6
7
  truncateToWidth,
8
+ visibleWidth,
7
9
  wrapTextWithAnsi,
8
10
  type EditorTheme,
9
11
  } from "@earendil-works/pi-tui";
10
- import { Type } from "typebox";
12
+ import { Type, type Static } from "typebox";
11
13
  import { BoundedText } from "./bounded-text.ts";
12
14
 
13
15
  const OptionSchema = Type.Object({
@@ -23,8 +25,49 @@ const QuestionParams = Type.Object({
23
25
  maxItems: 9,
24
26
  description: "Between 1 and 9 options for the user to choose from",
25
27
  }),
28
+ mode: Type.Optional(StringEnum(["single", "multiple"] as const, {
29
+ description: "Choose one answer or multiple answers; defaults to single",
30
+ })),
31
+ minSelections: Type.Optional(Type.Integer({
32
+ minimum: 1,
33
+ maximum: 10,
34
+ description: "Minimum answers required in multiple mode; defaults to 1. Single mode accepts only an explicit 1/1 bounds pair",
35
+ })),
36
+ maxSelections: Type.Optional(Type.Integer({
37
+ minimum: 1,
38
+ maximum: 10,
39
+ description: "Maximum answers allowed in multiple mode; defaults to all options plus one custom answer. Single mode accepts only an explicit 1/1 bounds pair",
40
+ })),
26
41
  });
27
42
 
43
+ type QuestionParamsValue = Static<typeof QuestionParams>;
44
+
45
+ type NormalizedQuestionSelection =
46
+ | { mode: "single"; minSelections: 1; maxSelections: 1 }
47
+ | { mode: "multiple"; minSelections: number; maxSelections: number };
48
+
49
+ function normalizeQuestionSelection(params: QuestionParamsValue): NormalizedQuestionSelection {
50
+ const mode = params.mode ?? "single";
51
+ if (mode === "single") {
52
+ const hasSelectionBounds = params.minSelections !== undefined || params.maxSelections !== undefined;
53
+ if (hasSelectionBounds && (params.minSelections !== 1 || params.maxSelections !== 1)) {
54
+ throw new Error("Single-select question bounds must be omitted or both be 1");
55
+ }
56
+ return { mode, minSelections: 1, maxSelections: 1 };
57
+ }
58
+
59
+ const maximumAvailable = params.options.length + 1;
60
+ const minSelections = params.minSelections ?? 1;
61
+ const maxSelections = params.maxSelections ?? maximumAvailable;
62
+ if (minSelections > maxSelections) {
63
+ throw new Error("Question minimum selections cannot exceed maximum selections");
64
+ }
65
+ if (maxSelections > maximumAvailable) {
66
+ throw new Error(`Question allows at most ${maximumAvailable} selections including one custom answer`);
67
+ }
68
+ return { mode, minSelections, maxSelections };
69
+ }
70
+
28
71
  interface DisplayOption {
29
72
  label: string;
30
73
  description?: string;
@@ -33,7 +76,7 @@ interface DisplayOption {
33
76
  isOther: boolean;
34
77
  }
35
78
 
36
- interface QuestionDetails {
79
+ interface SingleQuestionDetails {
37
80
  question: string;
38
81
  options: string[];
39
82
  answer: string | null;
@@ -42,9 +85,22 @@ interface QuestionDetails {
42
85
  cancelled?: boolean;
43
86
  }
44
87
 
88
+ interface MultipleQuestionDetails {
89
+ question: string;
90
+ options: string[];
91
+ mode: "multiple";
92
+ answers: string[];
93
+ selectedIndices: number[];
94
+ customAnswer?: string;
95
+ cancelled?: boolean;
96
+ }
97
+
98
+ type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
99
+
45
100
  type QuestionSelection =
46
101
  | { kind: "selected"; answer: string; originalIndex: number }
47
102
  | { kind: "custom"; answer: string }
103
+ | { kind: "multiple"; answers: string[]; selectedIndices: number[]; customAnswer?: string }
48
104
  | { kind: "cancelled" }
49
105
  | { kind: "aborted" };
50
106
 
@@ -107,6 +163,53 @@ function boundedQuestionLines(question: string, width: number, rowLimit: number)
107
163
  return visible;
108
164
  }
109
165
 
166
+ function compactMultipleAnswers(answers: readonly string[], width: number): string {
167
+ const prefix = "✓ ";
168
+ if (answers.length === 0) return truncateToWidth(prefix + "No answers", width, "…");
169
+ const visible: string[] = [];
170
+ for (let index = 0; index < answers.length; index += 1) {
171
+ const remaining = answers.length - index - 1;
172
+ const candidate = [...visible, oneLine(answers[index]!)].join(", ");
173
+ const suffix = remaining > 0 ? `, +${remaining} more` : "";
174
+ if (visibleWidth(prefix + candidate + suffix) > width) break;
175
+ visible.push(oneLine(answers[index]!));
176
+ }
177
+ if (visible.length === answers.length) return prefix + visible.join(", ");
178
+ const hidden = answers.length - visible.length;
179
+ if (visible.length === 0) return truncateToWidth(`${prefix}+${hidden} more`, width, "…");
180
+ return truncateToWidth(`${prefix}${visible.join(", ")}, +${hidden} more`, width, "…");
181
+ }
182
+
183
+ class MultipleResultText {
184
+ private readonly answers: readonly string[];
185
+ private readonly expanded: boolean;
186
+ private readonly customAnswer: string | undefined;
187
+ private readonly color: (name: ThemeColor, text: string) => string;
188
+
189
+ constructor(
190
+ answers: readonly string[],
191
+ expanded: boolean,
192
+ customAnswer: string | undefined,
193
+ color: (name: ThemeColor, text: string) => string,
194
+ ) {
195
+ this.answers = answers;
196
+ this.expanded = expanded;
197
+ this.customAnswer = customAnswer;
198
+ this.color = color;
199
+ }
200
+
201
+ render(width: number): string[] {
202
+ if (width <= 0) return [];
203
+ if (!this.expanded) return [this.color("accent", compactMultipleAnswers(this.answers, width))];
204
+ return this.answers.flatMap((answer) => wrapTextWithAnsi(
205
+ `${this.color("success", "✓ ")}${answer === this.customAnswer ? this.color("muted", "(wrote) ") : ""}${this.color("accent", answer)}`,
206
+ width,
207
+ ));
208
+ }
209
+
210
+ invalidate(): void {}
211
+ }
212
+
110
213
  export function registerQuestionTool(pi: ExtensionAPI): void {
111
214
  const customInputHistory: string[] = [];
112
215
  let customInputHistoryBytes = 0;
@@ -142,15 +245,17 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
142
245
  pi.registerTool<typeof QuestionParams, QuestionDetails>({
143
246
  name: "question",
144
247
  label: "Question",
145
- description: `Ask one interactive multiple-choice question. Provide 1-9 concise options. The user can filter options or type a custom answer. Filter queries are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes.`,
248
+ description: `Ask one interactive multiple-choice question. Provide 1-9 concise options. Single-select is the default; opt into bounded multi-select with mode "multiple". The user can filter options or type a custom answer. Filter queries are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes.`,
146
249
  promptSnippet: "Ask the user one multiple-choice question when a decision is required to proceed",
147
250
  promptGuidelines: [
148
251
  "Use question only when user input is required to choose between concrete alternatives; do not use question for rhetorical or optional follow-up prompts.",
252
+ "Use multiple mode only when the user may need to choose more than one answer; ordinary either/or decisions remain single-select.",
149
253
  ],
150
254
  parameters: QuestionParams,
151
255
  executionMode: "sequential",
152
256
 
153
257
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
258
+ const { mode, minSelections, maxSelections } = normalizeQuestionSelection(params);
154
259
  if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
155
260
  if (signal?.aborted) throw new Error("Question cancelled before it opened");
156
261
 
@@ -162,18 +267,17 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
162
267
  originalIndex: index + 1,
163
268
  isOther: false,
164
269
  })),
165
- {
166
- label: "Type a custom answer",
167
- originalIndex: params.options.length + 1,
168
- isOther: true,
169
- },
270
+ { label: "Type a custom answer", originalIndex: params.options.length + 1, isOther: true },
170
271
  ];
171
272
 
172
273
  let finishFromAbort: (() => void) | undefined;
173
274
  const resultPromise = ctx.ui.custom<QuestionSelection>((tui, theme, keybindings, done) => {
174
275
  let optionIndex = 0;
175
- let editMode = false;
276
+ type EditMode = "none" | "filter" | "custom";
277
+ let editMode: EditMode = "none";
176
278
  let filterQuery = "";
279
+ const selectedOriginalIndices = new Set<number>();
280
+ let customAnswer: string | undefined;
177
281
  let cachedWidth: number | undefined;
178
282
  let cachedRows: number | undefined;
179
283
  let cachedLines: string[] | undefined;
@@ -186,17 +290,11 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
186
290
  };
187
291
  finishFromAbort = () => finish({ kind: "aborted" });
188
292
 
189
- const keyHint = (
190
- keybinding: Parameters<typeof keybindings.getKeys>[0],
191
- description: string,
192
- ): string => {
293
+ const keyHint = (keybinding: Parameters<typeof keybindings.getKeys>[0], description: string): string => {
193
294
  const keyText = keybindings.getKeys(keybinding)
194
295
  .join("/")
195
296
  .split("/")
196
- .map((key) => key
197
- .split("+")
198
- .map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part)
199
- .join("+"))
297
+ .map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part).join("+"))
200
298
  .join("/");
201
299
  return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
202
300
  };
@@ -221,6 +319,16 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
221
319
  || option.label.toLocaleLowerCase().includes(query)
222
320
  || option.description?.toLocaleLowerCase().includes(query));
223
321
  };
322
+ const selectedCount = (): number => selectedOriginalIndices.size + (customAnswer === undefined ? 0 : 1);
323
+ const orderedMultipleSelection = () => {
324
+ const selectedIndices = [...selectedOriginalIndices].sort((left, right) => left - right);
325
+ const predefined = selectedIndices.map((index) => params.options[index - 1]!.label);
326
+ return {
327
+ answers: customAnswer === undefined ? predefined : [...predefined, customAnswer],
328
+ selectedIndices,
329
+ ...(customAnswer === undefined ? {} : { customAnswer }),
330
+ };
331
+ };
224
332
 
225
333
  const invalidate = (): void => {
226
334
  cachedWidth = undefined;
@@ -228,34 +336,83 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
228
336
  cachedLines = undefined;
229
337
  editor.invalidate();
230
338
  };
231
-
232
339
  const refresh = (): void => {
233
340
  invalidate();
234
341
  tui.requestRender();
235
342
  };
236
-
343
+ const notifyFilterLimit = (): void => ctx.ui.notify(
344
+ `Question filters are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes`,
345
+ "error",
346
+ );
237
347
  const appendFilterInput = (value: string): void => {
238
- const nextCharacters = inputCharacterCount(filterQuery) + inputCharacterCount(value);
239
- const nextBytes = Buffer.byteLength(filterQuery, "utf8") + Buffer.byteLength(value, "utf8");
240
- if (nextCharacters > FILTER_QUERY_MAX_CHARACTERS || nextBytes > FILTER_QUERY_MAX_BYTES) {
241
- ctx.ui.notify(
242
- `Question filters are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes`,
243
- "error",
244
- );
348
+ const next = filterQuery + value;
349
+ if (inputCharacterCount(next) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(next, "utf8") > FILTER_QUERY_MAX_BYTES) {
350
+ notifyFilterLimit();
245
351
  return;
246
352
  }
247
- filterQuery += value;
353
+ filterQuery = next;
248
354
  optionIndex = 0;
249
355
  refresh();
250
356
  };
357
+ const togglePredefined = (option: DisplayOption): void => {
358
+ if (selectedOriginalIndices.has(option.originalIndex)) {
359
+ selectedOriginalIndices.delete(option.originalIndex);
360
+ refresh();
361
+ return;
362
+ }
363
+ if (selectedCount() >= maxSelections) {
364
+ ctx.ui.notify(`Select at most ${maxSelections} answer${maxSelections === 1 ? "" : "s"}`, "error");
365
+ return;
366
+ }
367
+ selectedOriginalIndices.add(option.originalIndex);
368
+ refresh();
369
+ };
370
+ const enterCustomMode = (): void => {
371
+ editMode = "custom";
372
+ editor.setText(mode === "multiple" ? customAnswer ?? "" : "");
373
+ refresh();
374
+ };
375
+ const enterFilterMode = (): void => {
376
+ editMode = "filter";
377
+ editor.setText(filterQuery);
378
+ refresh();
379
+ };
251
380
 
252
381
  editor.onSubmit = (value) => {
382
+ if (editMode === "filter") {
383
+ filterQuery = value;
384
+ optionIndex = 0;
385
+ editMode = "none";
386
+ editor.setText("");
387
+ refresh();
388
+ return;
389
+ }
253
390
  const answer = value.trim();
254
391
  if (answer) {
255
392
  if (inputCharacterCount(answer) > CUSTOM_INPUT_MAX_CHARACTERS) {
256
393
  ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS} characters`, "error");
257
394
  return;
258
395
  }
396
+ if (mode === "multiple") {
397
+ const addsSelection = customAnswer === undefined;
398
+ if (addsSelection && selectedCount() >= maxSelections) {
399
+ editor.setText(value);
400
+ ctx.ui.notify(`Select at most ${maxSelections} answer${maxSelections === 1 ? "" : "s"}`, "error");
401
+ refresh();
402
+ return;
403
+ }
404
+ if (!rememberCustomInput(answer)) {
405
+ editor.setText(value);
406
+ ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error");
407
+ refresh();
408
+ return;
409
+ }
410
+ customAnswer = answer;
411
+ editMode = "none";
412
+ editor.setText("");
413
+ refresh();
414
+ return;
415
+ }
259
416
  if (!rememberCustomInput(answer)) {
260
417
  ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error");
261
418
  return;
@@ -263,32 +420,35 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
263
420
  finish({ kind: "custom", answer });
264
421
  return;
265
422
  }
266
- editMode = false;
423
+ editMode = "none";
267
424
  editor.setText("");
268
425
  refresh();
269
426
  };
270
427
 
271
- const enterCustomMode = (): void => {
272
- editMode = true;
428
+ const handleEditorInput = (data: string): void => {
429
+ if (keybindings.matches(data, "tui.select.cancel")) {
430
+ editMode = "none";
431
+ editor.setText("");
432
+ refresh();
433
+ return;
434
+ }
435
+ const before = editor.getExpandedText();
436
+ editor.handleInput(data);
437
+ const after = editor.getExpandedText();
438
+ const overLimit = editMode === "filter"
439
+ ? inputCharacterCount(after) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(after, "utf8") > FILTER_QUERY_MAX_BYTES
440
+ : inputCharacterCount(after) > CUSTOM_INPUT_MAX_CHARACTERS;
441
+ if (overLimit) {
442
+ editor.setText(before);
443
+ if (editMode === "filter") notifyFilterLimit();
444
+ else ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS} characters`, "error");
445
+ }
273
446
  refresh();
274
447
  };
275
448
 
276
449
  const handleInput = (data: string): void => {
277
- if (editMode) {
278
- if (keybindings.matches(data, "tui.select.cancel")) {
279
- editMode = false;
280
- editor.setText("");
281
- refresh();
282
- return;
283
- }
284
- const before = editor.getExpandedText();
285
- editor.handleInput(data);
286
- const after = editor.getExpandedText();
287
- if (inputCharacterCount(after) > CUSTOM_INPUT_MAX_CHARACTERS) {
288
- editor.setText(before);
289
- ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS} characters`, "error");
290
- }
291
- refresh();
450
+ if (editMode !== "none") {
451
+ handleEditorInput(data);
292
452
  return;
293
453
  }
294
454
 
@@ -315,6 +475,53 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
315
475
  refresh();
316
476
  return;
317
477
  }
478
+
479
+ const printableInput = decodeQuestionFilterInput(data);
480
+ const isPasteInput = data.includes("\x1B[200~");
481
+ if (mode === "multiple") {
482
+ const selected = visibleOptions[optionIndex];
483
+ if (!isPasteInput && printableInput === " ") {
484
+ if (!selected) return;
485
+ if (selected.isOther) {
486
+ if (customAnswer !== undefined) {
487
+ customAnswer = undefined;
488
+ refresh();
489
+ }
490
+ } else togglePredefined(selected);
491
+ return;
492
+ }
493
+ if (!isPasteInput && printableInput === "/") {
494
+ enterFilterMode();
495
+ return;
496
+ }
497
+ if (!isPasteInput && printableInput && /^[1-9]$/u.test(printableInput)) {
498
+ const numbered = visibleOptions[Number(printableInput) - 1];
499
+ if (!numbered) return;
500
+ if (numbered.isOther) enterCustomMode();
501
+ else togglePredefined(numbered);
502
+ return;
503
+ }
504
+ if (keybindings.matches(data, "tui.select.confirm")) {
505
+ if (selected?.isOther) {
506
+ enterCustomMode();
507
+ } else if (selectedCount() < minSelections) {
508
+ ctx.ui.notify(`Select at least ${minSelections} answer${minSelections === 1 ? "" : "s"}`, "error");
509
+ } else {
510
+ finish({ kind: "multiple", ...orderedMultipleSelection() });
511
+ }
512
+ return;
513
+ }
514
+ if (keybindings.matches(data, "tui.select.cancel")) {
515
+ if (filterQuery) {
516
+ filterQuery = "";
517
+ optionIndex = 0;
518
+ refresh();
519
+ } else finish({ kind: "cancelled" });
520
+ return;
521
+ }
522
+ return;
523
+ }
524
+
318
525
  if (keybindings.matches(data, "tui.select.confirm")) {
319
526
  const selected = visibleOptions[optionIndex];
320
527
  if (!selected) return;
@@ -327,9 +534,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
327
534
  filterQuery = "";
328
535
  optionIndex = 0;
329
536
  refresh();
330
- } else {
331
- finish({ kind: "cancelled" });
332
- }
537
+ } else finish({ kind: "cancelled" });
333
538
  return;
334
539
  }
335
540
  if (keybindings.matches(data, "tui.editor.deleteCharBackward")) {
@@ -340,9 +545,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
340
545
  }
341
546
  return;
342
547
  }
343
- const printableInput = decodeQuestionFilterInput(data);
344
- const isPasteInput = data.includes("\x1B[200~");
345
- if (!isPasteInput && printableInput && /^[1-9]$/.test(printableInput)) {
548
+ if (!isPasteInput && printableInput && /^[1-9]$/u.test(printableInput)) {
346
549
  const selected = visibleOptions[Number(printableInput) - 1];
347
550
  if (!selected) return;
348
551
  if (selected.isOther) enterCustomMode();
@@ -354,137 +557,119 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
354
557
 
355
558
  const render = (width: number): string[] => {
356
559
  if (width <= 0) return [];
357
- const renderWidth = width;
358
560
  const rowBudget = Math.max(1, tui.terminal.rows);
359
- if (cachedLines && cachedWidth === renderWidth && cachedRows === rowBudget) return cachedLines;
561
+ if (cachedLines && cachedWidth === width && cachedRows === rowBudget) return cachedLines;
360
562
  const visibleOptions = filteredOptions();
361
563
  if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
362
564
  const selected = visibleOptions[optionIndex];
363
565
  const position = `Option ${Math.min(optionIndex + 1, visibleOptions.length)}/${visibleOptions.length}`;
364
- const expandedAnswer = editor.getExpandedText();
365
- const answerCount = inputCharacterCount(expandedAnswer).toLocaleString();
366
- const filterCount = inputCharacterCount(filterQuery).toLocaleString();
367
- const compactDraft = expandedAnswer.replace(/\r\n|\r|\n/gu, " ↵ ") || "Type an answer";
368
-
566
+ const editorText = editor.getExpandedText();
567
+ const editorCount = inputCharacterCount(editorText).toLocaleString();
568
+ const filterCount = inputCharacterCount(editMode === "filter" ? editorText : filterQuery).toLocaleString();
569
+ const compactDraft = editorText.replace(/\r\n|\r|\n/gu, " ↵ ") || (editMode === "filter" ? "Type a filter" : "Type an answer");
570
+ const selectionRange = minSelections === maxSelections ? `${minSelections}` : `${minSelections}–${maxSelections}`;
571
+ const selectionStatus = `Selected ${selectedCount()} · required ${selectionRange}`;
572
+ const optionLabel = (option: DisplayOption, index: number): string => {
573
+ if (mode === "single") return `${index === optionIndex ? ">" : " "} ${index + 1}. ${option.label}`;
574
+ const checked = option.isOther ? customAnswer !== undefined : selectedOriginalIndices.has(option.originalIndex);
575
+ const label = option.isOther && customAnswer !== undefined ? `Custom: ${oneLine(customAnswer)}` : option.label;
576
+ return `${index === optionIndex ? ">" : " "} ${checked ? "[x]" : "[ ]"} ${index + 1}. ${label}`;
577
+ };
578
+ const browseHint = mode === "multiple"
579
+ ? `space toggle • / filter • ${keyHint("tui.select.confirm", selected?.isOther ? (customAnswer ? "edit custom" : "add custom") : "submit")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`
580
+ : `${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`;
581
+ const editHint = `${keyHint("tui.input.submit", editMode === "filter" ? "apply" : "submit")} • ${keyHint("tui.select.cancel", "options")}`;
582
+
583
+ let lines: string[];
369
584
  if (rowBudget <= 2) {
370
- const compact = editMode
371
- ? [`Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, compactDraft]
372
- : [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
373
- cachedWidth = renderWidth;
374
- cachedRows = rowBudget;
375
- cachedLines = compact.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, "…"));
376
- return cachedLines;
377
- }
378
-
379
- if (rowBudget <= 5) {
380
- const compact = [
381
- ...boundedQuestionLines(params.question, renderWidth, Math.max(1, rowBudget - 3)),
382
- editMode
383
- ? `Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
384
- : `${selected ? `> ${selected.label}` : "No matching options"} · ${position}`,
385
- editMode
386
- ? compactDraft
387
- : filterQuery
388
- ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}`
389
- : position,
390
- editMode
391
- ? `${keyHint("tui.input.submit", "submit")} • ${keyHint("tui.select.cancel", "options")}`
392
- : `${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`,
585
+ if (editMode !== "none") lines = [`${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, compactDraft];
586
+ else if (mode === "multiple") {
587
+ const focusedRow = selected ? optionLabel(selected, optionIndex) : "";
588
+ const compactSelectionStatus = visibleWidth(selectionStatus) <= width
589
+ ? selectionStatus
590
+ : `Selected ${selectedCount()}`;
591
+ lines = focusedRow && visibleWidth(focusedRow) <= width
592
+ ? [focusedRow, compactSelectionStatus]
593
+ : [compactSelectionStatus, focusedRow];
594
+ } else lines = [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
595
+ } else if (rowBudget <= 5) {
596
+ lines = [
597
+ ...boundedQuestionLines(params.question, width, Math.max(1, rowBudget - 3)),
598
+ editMode !== "none"
599
+ ? `${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
600
+ : selected ? optionLabel(selected, optionIndex) : "No matching options",
601
+ editMode !== "none" ? compactDraft : mode === "multiple" ? selectionStatus : filterQuery ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}` : position,
602
+ editMode !== "none" ? editHint : browseHint,
393
603
  ];
394
- cachedWidth = renderWidth;
395
- cachedRows = rowBudget;
396
- cachedLines = compact.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, "…"));
397
- return cachedLines;
398
- }
399
-
400
- const questionLines = boundedQuestionLines(params.question, renderWidth, Math.max(1, rowBudget - 5));
401
- const contentRows = rowBudget - questionLines.length - 4;
402
- const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
403
- const detailCapacity = Math.max(0, contentRows - optionCapacity);
404
- const { start, end } = visibleOptionRange(visibleOptions.length, optionIndex, optionCapacity);
405
- const hiddenAbove = start > 0 ? `↑ ${start}` : "";
406
- const hiddenBelowCount = visibleOptions.length - end;
407
- const hiddenBelow = hiddenBelowCount > 0 ? `↓ ${hiddenBelowCount}` : "";
408
- const hiddenStatus = [hiddenAbove, hiddenBelow].filter(Boolean).join(" · ");
409
- const progress = editMode
410
- ? `Answer ${answerCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
411
- : filterQuery
412
- ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} · ${position}`
413
- : `${position}${hiddenStatus ? ` · ${hiddenStatus}` : ""}`;
414
- const hint = editMode
415
- ? `${keyHint("tui.input.submit", "submit")} • ${keyHint("tui.select.cancel", "options")}`
416
- : `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`;
417
- const lines: string[] = [
418
- theme.fg("accent", "─".repeat(renderWidth)),
419
- ...questionLines.map((line) => theme.fg("text", line)),
420
- theme.fg("muted", truncateToWidth(` ${progress}`, renderWidth, "…")),
421
- ];
422
-
423
- if (editMode) {
424
- const editorLines = editor.render(renderWidth);
425
- const draftLines = editorLines.length > 2 ? editorLines.slice(1, -1) : editorLines;
426
- lines.push(...(draftLines.length > 0 ? draftLines : ["Type an answer"]).slice(-contentRows));
427
604
  } else {
428
- for (let index = start; index < end; index += 1) {
429
- const option = visibleOptions[index]!;
430
- const isSelected = index === optionIndex;
431
- const prefix = isSelected ? "> " : " ";
432
- const color: ThemeColor = isSelected ? "accent" : "text";
433
- lines.push(theme.fg(color, truncateToWidth(`${prefix}${index + 1}. ${option.label}`, renderWidth, "")));
434
- }
435
-
436
- const detailLines: string[] = [];
437
- if (selected?.description) detailLines.push(...wrapTextWithAnsi(theme.fg("muted", selected.description), renderWidth));
438
- if (selected?.preview) {
439
- detailLines.push(theme.fg("accent", theme.bold("Proposal preview")));
440
- detailLines.push(...new Markdown(
441
- selected.preview,
442
- 0,
443
- 0,
444
- {
445
- heading: (text) => theme.fg("accent", theme.bold(text)),
446
- link: (text) => theme.fg("accent", text),
447
- linkUrl: (text) => theme.fg("dim", text),
448
- code: (text) => theme.fg("mdCode", text),
449
- codeBlock: (text) => theme.fg("mdCodeBlock", text),
450
- codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
451
- quote: (text) => theme.fg("mdQuote", text),
452
- quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
453
- hr: (text) => theme.fg("mdHr", text),
454
- listBullet: (text) => theme.fg("mdListBullet", text),
455
- bold: (text) => theme.bold(text),
456
- italic: (text) => theme.italic(text),
457
- strikethrough: (text) => theme.strikethrough(text),
458
- underline: (text) => theme.underline(text),
459
- },
460
- { color: (text) => theme.fg("muted", text) },
461
- ).render(renderWidth));
462
- }
463
- if (detailCapacity > 0 && detailLines.length > detailCapacity) {
464
- const visibleDetailRows = Math.max(0, detailCapacity - 1);
465
- lines.push(...detailLines.slice(0, visibleDetailRows));
466
- const hiddenRows = detailLines.length - visibleDetailRows;
467
- lines.push(theme.fg("dim", `… ${hiddenRows} more line${hiddenRows === 1 ? "" : "s"}`));
605
+ const questionLines = boundedQuestionLines(params.question, width, Math.max(1, rowBudget - 5));
606
+ const contentRows = rowBudget - questionLines.length - 4;
607
+ const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
608
+ const detailCapacity = Math.max(0, contentRows - optionCapacity);
609
+ const { start, end } = visibleOptionRange(visibleOptions.length, optionIndex, optionCapacity);
610
+ const hiddenAbove = start > 0 ? `↑ ${start}` : "";
611
+ const hiddenBelowCount = visibleOptions.length - end;
612
+ const hiddenBelow = hiddenBelowCount > 0 ? `↓ ${hiddenBelowCount}` : "";
613
+ const hiddenStatus = [hiddenAbove, hiddenBelow].filter(Boolean).join(" · ");
614
+ const progress = editMode === "filter"
615
+ ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}`
616
+ : editMode === "custom"
617
+ ? `Answer ${editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
618
+ : mode === "multiple"
619
+ ? `${selectionStatus}${hiddenStatus ? ` · ${hiddenStatus}` : ""}`
620
+ : filterQuery ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} · ${position}` : `${position}${hiddenStatus ? ` · ${hiddenStatus}` : ""}`;
621
+ const navigationHint = editMode !== "none" ? editHint : mode === "multiple"
622
+ ? `${keyHint("tui.select.up", "up")} ${keyHint("tui.select.down", "down")} • ${browseHint}`
623
+ : `${keyHint("tui.select.up", "up")} ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`;
624
+ lines = [
625
+ theme.fg("accent", "─".repeat(width)),
626
+ ...questionLines.map((line) => theme.fg("text", line)),
627
+ theme.fg("muted", truncateToWidth(` ${progress}`, width, "…")),
628
+ ];
629
+ if (editMode !== "none") {
630
+ const editorLines = editor.render(width);
631
+ const draftLines = editorLines.length > 2 ? editorLines.slice(1, -1) : editorLines;
632
+ lines.push(...(draftLines.length > 0 ? draftLines : [editMode === "filter" ? "Type a filter" : "Type an answer"]).slice(-contentRows));
468
633
  } else {
469
- lines.push(...detailLines.slice(0, detailCapacity));
634
+ for (let index = start; index < end; index += 1) {
635
+ const option = visibleOptions[index]!;
636
+ const color: ThemeColor = index === optionIndex ? "accent" : "text";
637
+ lines.push(theme.fg(color, truncateToWidth(optionLabel(option, index), width, "…")));
638
+ }
639
+ const detailLines: string[] = [];
640
+ if (selected?.description) detailLines.push(...wrapTextWithAnsi(theme.fg("muted", selected.description), width));
641
+ if (selected?.preview) {
642
+ detailLines.push(theme.fg("accent", theme.bold("Proposal preview")));
643
+ detailLines.push(...new Markdown(selected.preview, 0, 0, {
644
+ heading: (text) => theme.fg("accent", theme.bold(text)), link: (text) => theme.fg("accent", text),
645
+ linkUrl: (text) => theme.fg("dim", text), code: (text) => theme.fg("mdCode", text),
646
+ codeBlock: (text) => theme.fg("mdCodeBlock", text), codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
647
+ quote: (text) => theme.fg("mdQuote", text), quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
648
+ hr: (text) => theme.fg("mdHr", text), listBullet: (text) => theme.fg("mdListBullet", text),
649
+ bold: (text) => theme.bold(text), italic: (text) => theme.italic(text), strikethrough: (text) => theme.strikethrough(text),
650
+ underline: (text) => theme.underline(text),
651
+ }, { color: (text) => theme.fg("muted", text) }).render(width));
652
+ }
653
+ if (detailCapacity > 0 && detailLines.length > detailCapacity) {
654
+ const visibleDetailRows = Math.max(0, detailCapacity - 1);
655
+ lines.push(...detailLines.slice(0, visibleDetailRows));
656
+ const hiddenRows = detailLines.length - visibleDetailRows;
657
+ lines.push(theme.fg("dim", `… ${hiddenRows} more line${hiddenRows === 1 ? "" : "s"}`));
658
+ } else lines.push(...detailLines.slice(0, detailCapacity));
470
659
  }
660
+ lines.push(theme.fg("dim", truncateToWidth(` ${navigationHint}`, width, "…")));
661
+ lines.push(theme.fg("accent", "─".repeat(width)));
471
662
  }
472
-
473
- lines.push(theme.fg("dim", truncateToWidth(` ${hint}`, renderWidth, "…")));
474
- lines.push(theme.fg("accent", "─".repeat(renderWidth)));
475
- cachedWidth = renderWidth;
663
+ cachedWidth = width;
476
664
  cachedRows = rowBudget;
477
- cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, ""));
665
+ cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, width, rowBudget <= 5 ? "" : ""));
478
666
  return cachedLines;
479
667
  };
480
668
 
481
669
  let focused = false;
482
670
  return {
483
671
  get focused(): boolean { return focused; },
484
- set focused(value: boolean) {
485
- focused = value;
486
- editor.focused = value;
487
- },
672
+ set focused(value: boolean) { focused = value; editor.focused = value; },
488
673
  render,
489
674
  handleInput,
490
675
  invalidate,
@@ -503,43 +688,50 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
503
688
 
504
689
  const simpleOptions = params.options.map((option) => option.label);
505
690
  if (result.kind === "aborted") throw new Error("Question cancelled because the agent operation was aborted");
506
- if (result.kind === "cancelled") {
691
+ if (result.kind === "cancelled" && mode === "multiple") {
507
692
  return {
508
693
  content: [{ type: "text", text: "User cancelled the question" }],
509
- details: { question: params.question, options: simpleOptions, answer: null, cancelled: true },
694
+ details: { question: params.question, options: simpleOptions, mode: "multiple", answers: [], selectedIndices: [], cancelled: true },
510
695
  };
511
696
  }
512
- if (result.kind === "custom") {
697
+ if (result.kind === "multiple") {
513
698
  return {
514
- content: [{ type: "text", text: `User wrote: ${result.answer}` }],
515
- details: { question: params.question, options: simpleOptions, answer: result.answer, wasCustom: true },
699
+ content: [{ type: "text", text: `User selected multiple answers:\n${result.answers.map((answer) => `- ${answer}`).join("\n")}` }],
700
+ details: {
701
+ question: params.question, options: simpleOptions, mode: "multiple", answers: result.answers,
702
+ selectedIndices: result.selectedIndices, ...(result.customAnswer === undefined ? {} : { customAnswer: result.customAnswer }),
703
+ },
516
704
  };
517
705
  }
706
+ if (result.kind === "cancelled") {
707
+ return { content: [{ type: "text", text: "User cancelled the question" }], details: { question: params.question, options: simpleOptions, answer: null, cancelled: true } };
708
+ }
709
+ if (result.kind === "custom") {
710
+ return { content: [{ type: "text", text: `User wrote: ${result.answer}` }], details: { question: params.question, options: simpleOptions, answer: result.answer, wasCustom: true } };
711
+ }
518
712
  return {
519
713
  content: [{ type: "text", text: `User selected: ${result.answer}` }],
520
- details: {
521
- question: params.question,
522
- options: simpleOptions,
523
- answer: result.answer,
524
- selectedIndex: result.originalIndex,
525
- wasCustom: false,
526
- },
714
+ details: { question: params.question, options: simpleOptions, answer: result.answer, selectedIndex: result.originalIndex, wasCustom: false },
527
715
  };
528
716
  },
529
717
 
530
718
  renderCall(args, theme, context) {
719
+ const { mode, minSelections: minimum, maxSelections: maximum } = normalizeQuestionSelection(args);
720
+ const multiple = mode === "multiple";
531
721
  if (!context.expanded) {
532
- const summary = `${theme.fg("toolTitle", theme.bold("question "))}${theme.fg("muted", oneLine(args.question))}\n${theme.fg("dim", ` ${args.options.length} option${args.options.length === 1 ? "" : "s"}`)}`;
533
- return new BoundedText(summary, 3);
722
+ const title = multiple ? "question (multi-select) " : "question ";
723
+ const detail = multiple ? `${args.options.length} options · choose ${minimum}–${maximum}` : `${args.options.length} option${args.options.length === 1 ? "" : "s"}`;
724
+ return new BoundedText(`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", oneLine(args.question))}\n${theme.fg("dim", ` ${detail}`)}`, 3);
534
725
  }
535
-
536
- const lines = [`${theme.fg("toolTitle", theme.bold("question "))}${theme.fg("muted", args.question)}`];
726
+ const title = multiple ? "question (multi-select) " : "question ";
727
+ const lines = [`${theme.fg("toolTitle", theme.bold(title))}${theme.fg("muted", args.question)}`];
728
+ if (multiple) lines.push(theme.fg("dim", `${args.options.length} options · choose ${minimum}–${maximum}`));
537
729
  args.options.forEach((option, index) => {
538
- lines.push(theme.fg("text", `${index + 1}. ${option.label}`));
730
+ lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${index + 1}. ${option.label}`));
539
731
  if (option.description) lines.push(theme.fg("muted", ` ${option.description}`));
540
732
  if (option.preview) lines.push(theme.fg("dim", option.preview));
541
733
  });
542
- lines.push(theme.fg("text", `${args.options.length + 1}. Type a custom answer`));
734
+ lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${args.options.length + 1}. Type a custom answer`));
543
735
  return new BoundedText(lines.join("\n"));
544
736
  },
545
737
 
@@ -549,12 +741,16 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
549
741
  const first = result.content[0];
550
742
  return new BoundedText(first?.type === "text" ? first.text : "", options.expanded ? undefined : 3);
551
743
  }
552
- if (details.cancelled || details.answer === null) return new BoundedText(theme.fg("warning", "Cancelled"));
744
+ if (details.cancelled || ("answer" in details && details.answer === null)) return new BoundedText(theme.fg("warning", "Cancelled"));
745
+ if ("mode" in details && details.mode === "multiple") {
746
+ return new MultipleResultText(details.answers, options.expanded, details.customAnswer, theme.fg.bind(theme));
747
+ }
748
+ if (!("answer" in details) || details.answer === null) return new BoundedText("");
749
+ const answer = details.answer;
553
750
  if (details.wasCustom) {
554
- const text = `${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", details.answer)}`;
555
- return new BoundedText(text, options.expanded ? undefined : 3);
751
+ return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", answer)}`, options.expanded ? undefined : 3);
556
752
  }
557
- return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", details.answer)}`);
753
+ return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", answer)}`);
558
754
  },
559
755
  });
560
756
  }