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 +15 -0
- package/Killeros.ts +2 -0
- package/README.md +12 -6
- package/killeros/goals.ts +33 -25
- package/killeros/question.ts +369 -185
- package/killeros/worked-for.ts +80 -0
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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(
|
|
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
|
|
554
|
-
|
|
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
|
|
package/killeros/question.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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 =
|
|
410
|
+
editMode = "none";
|
|
267
411
|
editor.setText("");
|
|
268
412
|
refresh();
|
|
269
413
|
};
|
|
270
414
|
|
|
271
|
-
const
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
365
|
-
const
|
|
366
|
-
const filterCount = inputCharacterCount(filterQuery).toLocaleString();
|
|
367
|
-
const compactDraft =
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
681
|
+
details: { question: params.question, options: simpleOptions, mode: "multiple", answers: [], selectedIndices: [], cancelled: true },
|
|
510
682
|
};
|
|
511
683
|
}
|
|
512
|
-
if (result.kind === "
|
|
684
|
+
if (result.kind === "multiple") {
|
|
513
685
|
return {
|
|
514
|
-
content: [{ type: "text", text: `User
|
|
515
|
-
details: {
|
|
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
|
|
533
|
-
|
|
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(
|
|
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
|
-
|
|
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",
|
|
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
|
+
}
|