killeros 2.0.4 → 2.0.6

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