killeros 2.0.5 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to KillerOS are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.0.6] - 2026-08-11
8
+
9
+ ### Added
10
+
11
+ - Added a durable `✻ Worked for …` transcript line below each settled TUI response, including stopped and failed runs.
12
+ - Added optional multi-select to the `question` tool with bounded checked options, one additive custom answer, a dedicated multi-word filter editor, and compact expandable results while preserving single-select defaults.
13
+
14
+ ### Changed
15
+
16
+ - Automated GitHub releases after successful `main` CI version bumps, with package, lockfile, changelog, tag, and verified-commit checks plus a manual tag recovery path.
17
+
18
+ ### Fixed
19
+
20
+ - Entered automatic `/goal` continuations into durable goal-turn state before dispatch so turn numbers advance and blocker audits work on Pi custom-message turns.
21
+
7
22
  ## [2.0.5] - 2026-08-11
8
23
 
9
24
  ### Added
package/Killeros.ts CHANGED
@@ -14,6 +14,7 @@ import { registerQuestionTool } from "./killeros/question.ts";
14
14
  import { createGoalRuntime, createInitRuntime } from "./killeros/runtime.ts";
15
15
  import { registerShellUi } from "./killeros/shell-ui.ts";
16
16
  import { registerVariants } from "./killeros/variants.ts";
17
+ import { registerWorkedFor } from "./killeros/worked-for.ts";
17
18
 
18
19
  export { CONCISE_SYSTEM_PROMPT, isConciseEnabled, isConcisedEnabled } from "./killeros/concise.ts";
19
20
  export { contextPercentRemaining, formatCost, formatContextProgress } from "./killeros/footer.ts";
@@ -43,4 +44,5 @@ export default function Killeros(pi: ExtensionAPI, options: KillerosOptions = {}
43
44
  registerGoalSettlement(pi, goalRuntime, initRuntime);
44
45
  registerInitSettlement(pi, initRuntime);
45
46
  registerCompletionNotifications(pi, options.completionNotifications);
47
+ registerWorkedFor(pi);
46
48
  }
package/README.md CHANGED
@@ -31,7 +31,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
31
31
  Pin an install to a release:
32
32
 
33
33
  ```bash
34
- pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.5
34
+ pi install git:github.com/KyrosHendrix/pi-KillerOS@v2.0.6
35
35
  ```
36
36
 
37
37
  Add `-l` to either command for a project-only install. Restart Pi after installing.
@@ -48,7 +48,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
48
48
  - `/variants` selector and direct reasoning-level arguments
49
49
  - Codex-style `/goal` with an interactive status/action panel, durable objectives, immediate pause and clear cancellation, automatic continuation, explicit completion, and durable blocker audits
50
50
  - Automatic `/init` guideline synthesis with a frozen safe evidence map, protected existing policy, and the four packaged behavioral sections adapted from `writing-great-guidelines`
51
- - `question` tool with height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
51
+ - `question` tool with single-select and opt-in bounded multi-select, height-bounded option windows, configured Pi keybindings, live option/input progress, proposal previews, custom answers, history, cancellation, and compact expandable transcript rendering
52
52
  - Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills; paths, URLs, and invalid commands remain plain text
53
53
  - Goal-aware `/clear` that confirms, aborts active work, waits for settlement, and starts a new session, plus `/exit` for graceful shutdown
54
54
  - Concise system-prompt guidance and supported native concise defaults that preserve explicit provider settings
@@ -78,6 +78,12 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
78
78
 
79
79
  The generated file uses the four behavioral sections adapted from `writing-great-guidelines`; no external skill installation is required. `/init` asks no setup questions, starts no second model process, writes no other file, and reloads Pi resources only after a successful write.
80
80
 
81
+ ### Interactive questions
82
+
83
+ Single-select remains the default. An agent opts into multi-select with `mode: "multiple"` and may set `minSelections` and `maxSelections`; the custom answer counts as one selection.
84
+
85
+ In multi-select, use Space or a visible number to toggle an option, `/` to filter, and Enter to submit. The filter accepts spaces; Enter applies it and Escape returns to the choices. Checked options remain selected when the filter changes. Select **Type a custom answer** with Enter to add or edit one custom item alongside checked options.
86
+
81
87
  Supported reasoning levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. KillerOS limits choices to levels supported by the current model.
82
88
 
83
89
  ## Configuration
@@ -120,17 +126,17 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
120
126
 
121
127
  ## Publish
122
128
 
123
- The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog.
129
+ To create a GitHub release, update the version in `package.json` and `package-lock.json`, add the matching `CHANGELOG.md` section, and push the release commit to `main`. After the full CI workflow passes, the release workflow creates the matching tag and GitHub release from that verified commit.
124
130
 
125
- For a release, publish after the validation checks pass:
131
+ Do not manually tag a normal release. If automation must recover a missing GitHub release, push the matching version tag; the same workflow validates the tag against the package and changelog before creating the release.
132
+
133
+ The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog. GitHub release automation does not publish to npm. Publish there separately after validation:
126
134
 
127
135
  ```bash
128
136
  npm login
129
137
  npm publish
130
138
  ```
131
139
 
132
- For later releases, choose `patch`, `minor`, or `major` with `npm version`, then publish and push the version commit and tag.
133
-
134
140
  ## Security
135
141
 
136
142
  Pi extensions run with your user permissions. Review the source before installing KillerOS globally. KillerOS executes lifecycle hook commands only for projects Pi marks as trusted; review `.pi/killeros-hooks.json` before enabling project trust.
package/killeros/goals.ts CHANGED
@@ -333,6 +333,34 @@ function recoverGoalAfterManualCompaction(
333
333
  return true;
334
334
  }
335
335
 
336
+ function beginGoalTurn(
337
+ pi: ExtensionAPI,
338
+ runtime: GoalRuntime,
339
+ ctx: ExtensionContext,
340
+ current: GoalState,
341
+ ): GoalState | undefined {
342
+ const now = Date.now();
343
+ const next: GoalState = {
344
+ ...current,
345
+ revision: current.revision + 1,
346
+ turns: current.turns + 1,
347
+ updatedAt: now,
348
+ activeStartedAt: current.activeStartedAt ?? now,
349
+ resumeAfterManualCompaction: undefined,
350
+ };
351
+ try {
352
+ persistGoalState(pi, runtime, "turn", next);
353
+ } catch (error) {
354
+ pauseGoalAfterFailure(pi, runtime, ctx, `turn state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
355
+ return undefined;
356
+ }
357
+ runtime.goalTurnInFlight = true;
358
+ runtime.agentEndObserved = false;
359
+ runtime.lastStopReason = undefined;
360
+ runtime.lastError = undefined;
361
+ return next;
362
+ }
363
+
336
364
  function scheduleGoalContinuation(
337
365
  pi: ExtensionAPI,
338
366
  runtime: GoalRuntime,
@@ -348,16 +376,13 @@ function scheduleGoalContinuation(
348
376
  || initState.active
349
377
  || !ctx.isIdle()
350
378
  || ctx.hasPendingMessages()) return false;
351
- const current = runtime.state;
352
379
  runtime.continuationScheduled = true;
353
- runtime.goalTurnInFlight = false;
354
- runtime.agentEndObserved = false;
355
- runtime.lastStopReason = undefined;
356
- runtime.lastError = undefined;
380
+ const next = beginGoalTurn(pi, runtime, ctx, runtime.state);
381
+ if (!next) return false;
357
382
  try {
358
383
  pi.sendMessage({
359
384
  customType: GOAL_CONTINUATION_TYPE,
360
- content: goalContinuationMessage(current, ctx),
385
+ content: goalContinuationMessage(next, ctx),
361
386
  display: false,
362
387
  }, { triggerTurn: true, deliverAs: "followUp" });
363
388
  return true;
@@ -550,25 +575,8 @@ export function registerGoal(
550
575
  const current = runtime.state;
551
576
  if (!isGoalModeSupported(ctx) || !isSavedSession(ctx) || !current || current.status !== "active" || initState.active) return;
552
577
  if (runtime.goalTurnInFlight) return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(current)}` };
553
- const now = Date.now();
554
- const next: GoalState = {
555
- ...current,
556
- revision: current.revision + 1,
557
- turns: current.turns + 1,
558
- updatedAt: now,
559
- activeStartedAt: current.activeStartedAt ?? now,
560
- resumeAfterManualCompaction: undefined,
561
- };
562
- try {
563
- persistGoalState(pi, runtime, "turn", next);
564
- } catch (error) {
565
- pauseGoalAfterFailure(pi, runtime, ctx, `turn state could not be saved: ${error instanceof Error ? error.message : String(error)}`);
566
- return;
567
- }
568
- runtime.goalTurnInFlight = true;
569
- runtime.agentEndObserved = false;
570
- runtime.lastStopReason = undefined;
571
- runtime.lastError = undefined;
578
+ const next = beginGoalTurn(pi, runtime, ctx, current);
579
+ if (!next) return;
572
580
  return { systemPrompt: `${event.systemPrompt}\n\n${goalSystemPrompt(next)}` };
573
581
  });
574
582
 
@@ -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
 
@@ -107,6 +137,53 @@ function boundedQuestionLines(question: string, width: number, rowLimit: number)
107
137
  return visible;
108
138
  }
109
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
+
110
187
  export function registerQuestionTool(pi: ExtensionAPI): void {
111
188
  const customInputHistory: string[] = [];
112
189
  let customInputHistoryBytes = 0;
@@ -142,15 +219,30 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
142
219
  pi.registerTool<typeof QuestionParams, QuestionDetails>({
143
220
  name: "question",
144
221
  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.`,
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.`,
146
223
  promptSnippet: "Ask the user one multiple-choice question when a decision is required to proceed",
147
224
  promptGuidelines: [
148
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.",
149
227
  ],
150
228
  parameters: QuestionParams,
151
229
  executionMode: "sequential",
152
230
 
153
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
+ }
154
246
  if (ctx.mode !== "tui") throw new Error("The question tool requires interactive TUI mode");
155
247
  if (signal?.aborted) throw new Error("Question cancelled before it opened");
156
248
 
@@ -162,18 +254,17 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
162
254
  originalIndex: index + 1,
163
255
  isOther: false,
164
256
  })),
165
- {
166
- label: "Type a custom answer",
167
- originalIndex: params.options.length + 1,
168
- isOther: true,
169
- },
257
+ { label: "Type a custom answer", originalIndex: params.options.length + 1, isOther: true },
170
258
  ];
171
259
 
172
260
  let finishFromAbort: (() => void) | undefined;
173
261
  const resultPromise = ctx.ui.custom<QuestionSelection>((tui, theme, keybindings, done) => {
174
262
  let optionIndex = 0;
175
- let editMode = false;
263
+ type EditMode = "none" | "filter" | "custom";
264
+ let editMode: EditMode = "none";
176
265
  let filterQuery = "";
266
+ const selectedOriginalIndices = new Set<number>();
267
+ let customAnswer: string | undefined;
177
268
  let cachedWidth: number | undefined;
178
269
  let cachedRows: number | undefined;
179
270
  let cachedLines: string[] | undefined;
@@ -186,17 +277,11 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
186
277
  };
187
278
  finishFromAbort = () => finish({ kind: "aborted" });
188
279
 
189
- const keyHint = (
190
- keybinding: Parameters<typeof keybindings.getKeys>[0],
191
- description: string,
192
- ): string => {
280
+ const keyHint = (keybinding: Parameters<typeof keybindings.getKeys>[0], description: string): string => {
193
281
  const keyText = keybindings.getKeys(keybinding)
194
282
  .join("/")
195
283
  .split("/")
196
- .map((key) => key
197
- .split("+")
198
- .map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part)
199
- .join("+"))
284
+ .map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLocaleLowerCase() === "alt" ? "option" : part).join("+"))
200
285
  .join("/");
201
286
  return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
202
287
  };
@@ -221,6 +306,16 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
221
306
  || option.label.toLocaleLowerCase().includes(query)
222
307
  || option.description?.toLocaleLowerCase().includes(query));
223
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
+ };
224
319
 
225
320
  const invalidate = (): void => {
226
321
  cachedWidth = undefined;
@@ -228,34 +323,83 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
228
323
  cachedLines = undefined;
229
324
  editor.invalidate();
230
325
  };
231
-
232
326
  const refresh = (): void => {
233
327
  invalidate();
234
328
  tui.requestRender();
235
329
  };
236
-
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
+ );
237
334
  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
- );
335
+ const next = filterQuery + value;
336
+ if (inputCharacterCount(next) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(next, "utf8") > FILTER_QUERY_MAX_BYTES) {
337
+ notifyFilterLimit();
245
338
  return;
246
339
  }
247
- filterQuery += value;
340
+ filterQuery = next;
248
341
  optionIndex = 0;
249
342
  refresh();
250
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
+ };
251
367
 
252
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
+ }
253
377
  const answer = value.trim();
254
378
  if (answer) {
255
379
  if (inputCharacterCount(answer) > CUSTOM_INPUT_MAX_CHARACTERS) {
256
380
  ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS} characters`, "error");
257
381
  return;
258
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
+ }
259
403
  if (!rememberCustomInput(answer)) {
260
404
  ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error");
261
405
  return;
@@ -263,32 +407,35 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
263
407
  finish({ kind: "custom", answer });
264
408
  return;
265
409
  }
266
- editMode = false;
410
+ editMode = "none";
267
411
  editor.setText("");
268
412
  refresh();
269
413
  };
270
414
 
271
- const enterCustomMode = (): void => {
272
- 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
+ }
273
433
  refresh();
274
434
  };
275
435
 
276
436
  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();
437
+ if (editMode !== "none") {
438
+ handleEditorInput(data);
292
439
  return;
293
440
  }
294
441
 
@@ -315,6 +462,53 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
315
462
  refresh();
316
463
  return;
317
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
+
318
512
  if (keybindings.matches(data, "tui.select.confirm")) {
319
513
  const selected = visibleOptions[optionIndex];
320
514
  if (!selected) return;
@@ -327,9 +521,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
327
521
  filterQuery = "";
328
522
  optionIndex = 0;
329
523
  refresh();
330
- } else {
331
- finish({ kind: "cancelled" });
332
- }
524
+ } else finish({ kind: "cancelled" });
333
525
  return;
334
526
  }
335
527
  if (keybindings.matches(data, "tui.editor.deleteCharBackward")) {
@@ -340,9 +532,7 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
340
532
  }
341
533
  return;
342
534
  }
343
- const printableInput = decodeQuestionFilterInput(data);
344
- const isPasteInput = data.includes("\x1B[200~");
345
- if (!isPasteInput && printableInput && /^[1-9]$/.test(printableInput)) {
535
+ if (!isPasteInput && printableInput && /^[1-9]$/u.test(printableInput)) {
346
536
  const selected = visibleOptions[Number(printableInput) - 1];
347
537
  if (!selected) return;
348
538
  if (selected.isOther) enterCustomMode();
@@ -354,137 +544,119 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
354
544
 
355
545
  const render = (width: number): string[] => {
356
546
  if (width <= 0) return [];
357
- const renderWidth = width;
358
547
  const rowBudget = Math.max(1, tui.terminal.rows);
359
- if (cachedLines && cachedWidth === renderWidth && cachedRows === rowBudget) return cachedLines;
548
+ if (cachedLines && cachedWidth === width && cachedRows === rowBudget) return cachedLines;
360
549
  const visibleOptions = filteredOptions();
361
550
  if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
362
551
  const selected = visibleOptions[optionIndex];
363
552
  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
-
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[];
369
571
  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")}`,
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,
393
590
  ];
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
591
  } 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"}`));
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));
468
620
  } else {
469
- 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));
470
646
  }
647
+ lines.push(theme.fg("dim", truncateToWidth(` ${navigationHint}`, width, "…")));
648
+ lines.push(theme.fg("accent", "─".repeat(width)));
471
649
  }
472
-
473
- lines.push(theme.fg("dim", truncateToWidth(` ${hint}`, renderWidth, "…")));
474
- lines.push(theme.fg("accent", "─".repeat(renderWidth)));
475
- cachedWidth = renderWidth;
650
+ cachedWidth = width;
476
651
  cachedRows = rowBudget;
477
- cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, renderWidth, ""));
652
+ cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, width, rowBudget <= 5 ? "" : ""));
478
653
  return cachedLines;
479
654
  };
480
655
 
481
656
  let focused = false;
482
657
  return {
483
658
  get focused(): boolean { return focused; },
484
- set focused(value: boolean) {
485
- focused = value;
486
- editor.focused = value;
487
- },
659
+ set focused(value: boolean) { focused = value; editor.focused = value; },
488
660
  render,
489
661
  handleInput,
490
662
  invalidate,
@@ -503,43 +675,51 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
503
675
 
504
676
  const simpleOptions = params.options.map((option) => option.label);
505
677
  if (result.kind === "aborted") throw new Error("Question cancelled because the agent operation was aborted");
506
- if (result.kind === "cancelled") {
678
+ if (result.kind === "cancelled" && mode === "multiple") {
507
679
  return {
508
680
  content: [{ type: "text", text: "User cancelled the question" }],
509
- details: { question: params.question, options: simpleOptions, answer: null, cancelled: true },
681
+ details: { question: params.question, options: simpleOptions, mode: "multiple", answers: [], selectedIndices: [], cancelled: true },
510
682
  };
511
683
  }
512
- if (result.kind === "custom") {
684
+ if (result.kind === "multiple") {
513
685
  return {
514
- content: [{ type: "text", text: `User wrote: ${result.answer}` }],
515
- 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
+ },
516
691
  };
517
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
+ }
518
699
  return {
519
700
  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
- },
701
+ details: { question: params.question, options: simpleOptions, answer: result.answer, selectedIndex: result.originalIndex, wasCustom: false },
527
702
  };
528
703
  },
529
704
 
530
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;
531
709
  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);
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);
534
713
  }
535
-
536
- 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}`));
537
717
  args.options.forEach((option, index) => {
538
- lines.push(theme.fg("text", `${index + 1}. ${option.label}`));
718
+ lines.push(theme.fg("text", `${multiple ? "[ ] " : ""}${index + 1}. ${option.label}`));
539
719
  if (option.description) lines.push(theme.fg("muted", ` ${option.description}`));
540
720
  if (option.preview) lines.push(theme.fg("dim", option.preview));
541
721
  });
542
- 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`));
543
723
  return new BoundedText(lines.join("\n"));
544
724
  },
545
725
 
@@ -549,12 +729,16 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
549
729
  const first = result.content[0];
550
730
  return new BoundedText(first?.type === "text" ? first.text : "", options.expanded ? undefined : 3);
551
731
  }
552
- 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;
553
738
  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);
739
+ return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("muted", "(wrote) ")}${theme.fg("accent", answer)}`, options.expanded ? undefined : 3);
556
740
  }
557
- return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", details.answer)}`);
741
+ return new BoundedText(`${theme.fg("success", "✓ ")}${theme.fg("accent", answer)}`);
558
742
  },
559
743
  });
560
744
  }
@@ -0,0 +1,80 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+
4
+ const WORKED_FOR_ENTRY_TYPE = "killeros-worked-for";
5
+ const WORKED_FOR_ENTRY_VERSION = 1;
6
+
7
+ interface WorkedForEntryData {
8
+ version: typeof WORKED_FOR_ENTRY_VERSION;
9
+ milliseconds: number;
10
+ }
11
+
12
+ export function formatWorkedForDuration(milliseconds: number): string {
13
+ const boundedMilliseconds = Number.isFinite(milliseconds) ? Math.max(0, milliseconds) : 0;
14
+ const totalSeconds = Math.max(1, Math.floor(boundedMilliseconds / 1_000));
15
+ if (totalSeconds < 60) return `${totalSeconds}s`;
16
+
17
+ const totalMinutes = Math.floor(totalSeconds / 60);
18
+ if (totalMinutes < 60) {
19
+ return `${totalMinutes}m ${(totalSeconds % 60).toString().padStart(2, "0")}s`;
20
+ }
21
+
22
+ return `${Math.floor(totalMinutes / 60)}h ${(totalMinutes % 60).toString().padStart(2, "0")}m`;
23
+ }
24
+
25
+ function isWorkedForEntryData(data: unknown): data is WorkedForEntryData {
26
+ if (!data || typeof data !== "object") return false;
27
+ const candidate = data as Partial<WorkedForEntryData>;
28
+ return candidate.version === WORKED_FOR_ENTRY_VERSION
29
+ && typeof candidate.milliseconds === "number"
30
+ && Number.isFinite(candidate.milliseconds)
31
+ && candidate.milliseconds >= 0;
32
+ }
33
+
34
+ function errorMessage(error: unknown): string {
35
+ return error instanceof Error ? error.message : String(error);
36
+ }
37
+
38
+ export function registerWorkedFor(
39
+ pi: ExtensionAPI,
40
+ now: () => number = Date.now,
41
+ ): void {
42
+ let startedAt: number | undefined;
43
+
44
+ pi.registerEntryRenderer<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, (entry, _options, theme) => {
45
+ if (!isWorkedForEntryData(entry.data)) return undefined;
46
+ return new Text(
47
+ theme.fg("dim", `✻ Worked for ${formatWorkedForDuration(entry.data.milliseconds)}`),
48
+ 0,
49
+ 0,
50
+ );
51
+ });
52
+
53
+ pi.on("session_start", () => {
54
+ startedAt = undefined;
55
+ });
56
+
57
+ pi.on("agent_start", (_event, ctx) => {
58
+ if (ctx.mode !== "tui" || startedAt !== undefined) return;
59
+ startedAt = now();
60
+ });
61
+
62
+ pi.on("agent_settled", (_event, ctx) => {
63
+ if (ctx.mode !== "tui" || startedAt === undefined) return;
64
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
65
+ const milliseconds = Math.max(0, now() - startedAt);
66
+ startedAt = undefined;
67
+ try {
68
+ pi.appendEntry<WorkedForEntryData>(WORKED_FOR_ENTRY_TYPE, {
69
+ version: WORKED_FOR_ENTRY_VERSION,
70
+ milliseconds,
71
+ });
72
+ } catch (error) {
73
+ ctx.ui.notify(`Worked-for timing could not be saved: ${errorMessage(error)}`, "error");
74
+ }
75
+ });
76
+
77
+ pi.on("session_shutdown", () => {
78
+ startedAt = undefined;
79
+ });
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killeros",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "description": "TUI, goals, and workflow automation for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [