killeros 3.0.4 → 3.0.5
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/README.md +19 -1
- package/killeros/auto-compaction.ts +7 -1
- package/killeros/goal-settlement.ts +13 -1
- package/killeros/notifications.ts +2 -2
- package/killeros/question-ui.ts +173 -494
- package/killeros/question.ts +27 -20
- package/killeros/worked-for.ts +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ All notable changes to KillerOS are documented here. Releases use New Features f
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [3.0.5] - 2026-10-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Used Pi 1.1.0's final cancellation signal for `Stopped` task receipts and silent completion notifications, including cancellation after a normal assistant response or without an assistant result. Kept observed changes and checks and the Pi 1.0.4 fallback.
|
|
12
|
+
- Stopped accepted goal continuation after final request cancellation while preserving compaction recovery through its completion and cancellation callbacks.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added real Pi 1.1.0 cancellation, busy-error retry, native Mistral transport retry, and additive tool-selection checks. Documented native tool modifiers and reliable extension-tool exclusion through `--exclude-tools`, including Pi 1.1.0's modifier limitation. Kept the Pi and Node support floors and locked development packages unchanged.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Moved question input to Pi's native dialogs, with KillerOS coordinating bounded multi-select, filtering, custom answers, recent-answer history, and paged text previews. Each step uses native dialog controls and Pi 1.1.0's blocked-status reporting. Preserved the question tool's inputs and results, TUI restriction, and sequential model-only exposure; cancellation and session changes prevent stale steps or partial success.
|
|
21
|
+
|
|
7
22
|
## [3.0.4] - 2026-10-07
|
|
8
23
|
|
|
9
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ Or from GitHub:
|
|
|
36
36
|
pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Pin a release by appending its tag, for example `@v3.0.
|
|
39
|
+
Pin a release by appending its tag, for example `@v3.0.5`. Add `-l` to install only for the current project. Restart Pi after installing.
|
|
40
40
|
|
|
41
41
|
## Commands
|
|
42
42
|
|
|
@@ -77,6 +77,22 @@ With Pi 1.0.3's default [keybindings](https://github.com/earendil-works/pi/blob/
|
|
|
77
77
|
|
|
78
78
|
Pi 1.0.4's native `--tools` and `--exclude-tools` support `*` patterns. Quote patterns such as `"killeros_*"` so the shell does not expand them. An explicit allowlist must include `question` for interactive questions and permit `killeros_goal_update` for goals. Selection does not make questions usable outside TUI mode. Excluding the goal tool pauses goals that are active before any model request. A later ordinary request with a blocked goal still runs, but cannot record its completion when the tool is excluded. See Pi's [tool selection](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/cli.md#tools).
|
|
79
79
|
|
|
80
|
+
Pi 1.1.0 also accepts a list containing only exact `+name` and `-name` entries to change its default selection. For example, `pi --tools +codemode` adds codemode while retaining the default tools, and `pi --tools +codemode,-write` also removes `write` from that selection. Plain allowlists still replace the selection. Pi rejects mixed lists such as `read,+codemode` and modifier patterns such as `-killeros_*`. Use `--exclude-tools "killeros_*"` for wildcard exclusions.
|
|
81
|
+
|
|
82
|
+
In Pi 1.1.0, removing an extension tool with a `-name` modifier does not prevent Pi from activating it on registration or reload. To exclude the goal tool reliably, use `pi --exclude-tools killeros_goal_update`. KillerOS delegates selection to Pi; it does not parse or repair native CLI filters.
|
|
83
|
+
|
|
84
|
+
Pi 1.1.0 reports program status through OSC 7501 when the terminal supports it. KillerOS questions use Pi's native selectors and text inputs. Each open question step reports `blocked` and restores the previous status after an answer, cancellation, or abort. Existing native confirmation dialogs do the same. `PI_PROGRAM_STATUS=1` forces native reporting and `PI_PROGRAM_STATUS=0` disables it. KillerOS preserves this preference and lets Pi handle all status reporting. See Pi's [program status](https://github.com/earendil-works/pi/blob/v1.1.0/packages/coding-agent/docs/terminal-setup.md#program-status).
|
|
85
|
+
|
|
86
|
+
### Questions
|
|
87
|
+
|
|
88
|
+
The `question` tool asks one question per invocation. Single-select is the default; choosing a numbered option submits that answer. In multi-select mode, choosing an option toggles its selection and opens the selector again. Choose **Submit answers** when finished. KillerOS checks the requested minimum and maximum and returns the selected predefined answers in their original order, followed by an optional custom answer. Dialog steps stay within the same tool execution and do not start model requests or grant goal turns.
|
|
89
|
+
|
|
90
|
+
Choose **Custom answer** to open Pi's text input. Multi-select allows one custom answer, which you can edit or remove. Blank answers do not submit; custom answers accept up to 4,000 Unicode characters. Cancelling custom input returns to the selector with existing selections intact. **More actions** includes filtering, clearing a filter, reviewing the full question and option details, and selecting a recent custom answer. Filtering matches labels and descriptions without losing hidden selections. Filter queries accept up to 4,000 Unicode characters and 16,000 UTF-8 bytes.
|
|
91
|
+
|
|
92
|
+
Descriptions appear in bounded option summaries. Review shows full descriptions and proposal previews as paged plain text, including any markdown source. **Next page**, **Previous page**, and **Back** navigate the review. Long option lists and recent-answer history also use pages. Recent answers stay within the current session, with at most 100 distinct entries and 64 KiB of text; session changes clear them.
|
|
93
|
+
|
|
94
|
+
The flow uses Pi's native dialog keybindings and editing behavior. Multi-select takes successive selection steps; the former space-toggle and slash-filter shortcuts are replaced by explicit actions. Resizing selectors rebuilds their pages while preserving selections, and Pi resizes text input without discarding its draft. Cancelling the main selector discards unsubmitted answers. Cancelling a filter, history, or review step returns to the selector. Agent cancellation or session invalidation closes the question and prevents another step from opening.
|
|
95
|
+
|
|
80
96
|
## Configuration
|
|
81
97
|
|
|
82
98
|
The packaged `killeros` theme activates on TUI start. Compaction triggers by default at 15% tokens remaining, stored in global `killeros.json`:
|
|
@@ -123,6 +139,8 @@ Passive Git scans compare content without running repository filters. CRLF text
|
|
|
123
139
|
|
|
124
140
|
Completion sounds are off by default; change with `/notification` in TUI mode. The tab-title indicator requires a Nerd Font.
|
|
125
141
|
|
|
142
|
+
On Pi 1.1.0, final request cancellation records `Stopped` and suppresses the completion sound even when the last assistant response ended normally. Recorded changes and checks remain in the receipt. Cancellation stops goal continuation and provider retry work; cancelling a pending compaction prevents its recovery. Pi 1.0.4 retains the assistant stop-reason fallback. Receipts and sounds still wait for final `agent_settled`, including across provider retries.
|
|
143
|
+
|
|
126
144
|
### Pi model and MCP settings
|
|
127
145
|
|
|
128
146
|
Use Pi's native `/thinking` selector for reasoning levels. Pi 1.0.2 can also apply different sampling parameters for each level through `samplingParamsByThinkingLevel` in `models.json`. For example, a model entry can contain:
|
|
@@ -343,8 +343,14 @@ export function registerAutoCompaction(
|
|
|
343
343
|
}
|
|
344
344
|
});
|
|
345
345
|
|
|
346
|
-
pi.on("agent_settled", (
|
|
346
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
347
347
|
goalRequestOwned = false;
|
|
348
|
+
// Pi aborts the agent when our pending compaction starts. Its callback owns
|
|
349
|
+
// recovery, including cancellation; the settlement flag alone is ambiguous.
|
|
350
|
+
if ("aborted" in event && event.aborted === true && request?.phase !== "compacting") {
|
|
351
|
+
request = undefined;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
348
354
|
if (request?.phase === "continuation-dispatched") {
|
|
349
355
|
request = undefined;
|
|
350
356
|
notifyFailure(ctx, new Error("continuation was not accepted"));
|
|
@@ -219,7 +219,7 @@ export function registerGoalSettlement(
|
|
|
219
219
|
pi: ExtensionAPI,
|
|
220
220
|
runtime: GoalRuntime,
|
|
221
221
|
): AutoCompactionGoalHandlers {
|
|
222
|
-
pi.on("agent_settled", (
|
|
222
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
223
223
|
runtime.blockedCompletion = undefined;
|
|
224
224
|
syncGoalUpdateTool(pi, runtime);
|
|
225
225
|
const wasGoalTurn = runtime.goalTurnInFlight;
|
|
@@ -230,6 +230,18 @@ export function registerGoalSettlement(
|
|
|
230
230
|
runtime.agentEndObserved = false;
|
|
231
231
|
runtime.continuationScheduled = false;
|
|
232
232
|
|
|
233
|
+
// ctx.compact() also aborts the request. An owned compaction must settle through
|
|
234
|
+
// its success/error callback; its cancellation cannot authorize recovery.
|
|
235
|
+
if ("aborted" in event && event.aborted === true && !runtime.automaticCompaction
|
|
236
|
+
&& runtime.lastStopReason !== "aborted") {
|
|
237
|
+
runtime.lastStopReason = undefined;
|
|
238
|
+
runtime.lastError = undefined;
|
|
239
|
+
if (runtime.state?.status === "active") {
|
|
240
|
+
pauseGoalAfterFailure(pi, runtime, ctx, "the agent request was cancelled");
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
233
245
|
if (runtime.automaticCompaction) {
|
|
234
246
|
const stopReason = runtime.lastStopReason;
|
|
235
247
|
const error = safeTerminalText(runtime.lastError ?? "");
|
|
@@ -116,11 +116,11 @@ export function registerCompletionNotifications(
|
|
|
116
116
|
}
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
pi.on("agent_settled", (
|
|
119
|
+
pi.on("agent_settled", (event, ctx) => {
|
|
120
120
|
if (!requestPending || ctx.mode !== "tui") return;
|
|
121
121
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
122
122
|
requestPending = false;
|
|
123
|
-
if (!enabled || lastStopReason === "aborted") return;
|
|
123
|
+
if (!enabled || "aborted" in event && event.aborted === true || lastStopReason === "aborted") return;
|
|
124
124
|
try {
|
|
125
125
|
runtime.ring();
|
|
126
126
|
} catch (error) {
|
package/killeros/question-ui.ts
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
Editor,
|
|
5
|
-
Markdown,
|
|
6
|
-
truncateToWidth,
|
|
7
|
-
visibleWidth,
|
|
8
|
-
wrapTextWithAnsi,
|
|
9
|
-
type EditorTheme,
|
|
10
|
-
} from "@earendil-works/pi-tui";
|
|
11
|
-
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { safeTerminalText } from "./safe-terminal-text.ts";
|
|
12
4
|
|
|
13
5
|
export interface DisplayOption {
|
|
14
6
|
label: string;
|
|
7
|
+
answer: string;
|
|
15
8
|
description?: string;
|
|
16
9
|
preview?: string;
|
|
17
10
|
originalIndex: number;
|
|
18
|
-
isOther: boolean;
|
|
19
11
|
}
|
|
20
12
|
|
|
21
13
|
export type QuestionSelection =
|
|
@@ -31,65 +23,12 @@ export const CUSTOM_INPUT_HISTORY_BYTES = 64 * 1024;
|
|
|
31
23
|
export const FILTER_QUERY_MAX_CHARACTERS = 4_000;
|
|
32
24
|
export const FILTER_QUERY_MAX_BYTES = 16_000;
|
|
33
25
|
|
|
34
|
-
function isPrintableInput(data: string): boolean {
|
|
35
|
-
return data.length > 0 && !/[\u0000-\u001F\u007F-\u009F]/u.test(data);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
39
|
-
|
|
40
|
-
function decodeQuestionFilterInput(data: string): string | undefined {
|
|
41
|
-
const kittyPrintable = decodeKittyPrintable(data);
|
|
42
|
-
if (kittyPrintable !== undefined) return isPrintableInput(kittyPrintable) ? kittyPrintable : undefined;
|
|
43
|
-
|
|
44
|
-
const pasteStart = "\x1B[200~";
|
|
45
|
-
const pasteEnd = "\x1B[201~";
|
|
46
|
-
const startIndex = data.indexOf(pasteStart);
|
|
47
|
-
const endIndex = data.indexOf(pasteEnd, startIndex + pasteStart.length);
|
|
48
|
-
if (startIndex >= 0 && endIndex >= 0) {
|
|
49
|
-
return data
|
|
50
|
-
.slice(startIndex + pasteStart.length, endIndex)
|
|
51
|
-
.replace(/\r\n|\r|\n/gu, "")
|
|
52
|
-
.replace(/\t/gu, " ")
|
|
53
|
-
.replace(/[\u0000-\u001F\u007F-\u009F]/gu, "");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return isPrintableInput(data) ? data : undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function removeLastGrapheme(value: string): string {
|
|
60
|
-
const segments = [...graphemeSegmenter.segment(value)];
|
|
61
|
-
const last = segments.at(-1);
|
|
62
|
-
return last ? value.slice(0, last.index) : "";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function inputCharacterCount(value: string): number {
|
|
66
|
-
let count = 0;
|
|
67
|
-
for (const _character of value) count += 1;
|
|
68
|
-
return count;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
26
|
export function oneLine(value: string): string {
|
|
72
27
|
return value.replace(/\s+/gu, " ").trim();
|
|
73
28
|
}
|
|
74
29
|
|
|
75
|
-
function
|
|
76
|
-
return
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function visibleOptionRange(total: number, selected: number, capacity: number): { start: number; end: number } {
|
|
80
|
-
const size = Math.max(1, Math.min(total, capacity));
|
|
81
|
-
const start = Math.max(0, Math.min(selected - Math.floor(size / 2), total - size));
|
|
82
|
-
return { start, end: Math.min(total, start + size) };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function boundedQuestionLines(question: string, width: number, rowLimit: number): string[] {
|
|
86
|
-
const wrapped = wrapTextWithAnsi(question.replace(/\s+/gu, " ").trim(), width);
|
|
87
|
-
if (wrapped.length <= rowLimit) return wrapped;
|
|
88
|
-
const visible = wrapped.slice(0, rowLimit);
|
|
89
|
-
const finalIndex = rowLimit - 1;
|
|
90
|
-
const finalLine = visible[finalIndex];
|
|
91
|
-
if (finalLine !== undefined) visible[finalIndex] = truncateToWidth(finalLine, width, "…");
|
|
92
|
-
return visible;
|
|
30
|
+
function inputCharacterCount(value: string): number {
|
|
31
|
+
return [...value].length;
|
|
93
32
|
}
|
|
94
33
|
|
|
95
34
|
function compactMultipleAnswers(answers: readonly string[], width: number): string {
|
|
@@ -139,452 +78,192 @@ export class MultipleResultText {
|
|
|
139
78
|
invalidate(): void {}
|
|
140
79
|
}
|
|
141
80
|
|
|
81
|
+
type Choice<T> = { label: string; value: T };
|
|
82
|
+
type DialogSize = { width: number; rows: number };
|
|
83
|
+
type MainAction =
|
|
84
|
+
| { kind: "option"; option: DisplayOption }
|
|
85
|
+
| { kind: "custom" }
|
|
86
|
+
| { kind: "submit" }
|
|
87
|
+
| { kind: "actions" };
|
|
88
|
+
type MoreAction = "filter" | "clear" | "history" | "remove" | "review" | "question";
|
|
89
|
+
class QuestionAborted extends Error {}
|
|
90
|
+
|
|
91
|
+
function dialogSize(): DialogSize {
|
|
92
|
+
return { width: Math.max(1, (process.stdout.columns || 80) - 6), rows: process.stdout.rows || 24 };
|
|
93
|
+
}
|
|
94
|
+
|
|
142
95
|
export async function openQuestionUi(config: {
|
|
143
96
|
ctx: ExtensionContext;
|
|
144
|
-
signal
|
|
97
|
+
signal: AbortSignal;
|
|
145
98
|
question: string;
|
|
146
99
|
options: DisplayOption[];
|
|
147
|
-
originalOptions: readonly { label: string }[];
|
|
148
100
|
mode: "single" | "multiple";
|
|
149
101
|
minSelections: number;
|
|
150
102
|
maxSelections: number;
|
|
151
103
|
customInputHistory: readonly string[];
|
|
152
104
|
rememberCustomInput: (value: string) => boolean;
|
|
153
105
|
}): Promise<QuestionSelection> {
|
|
154
|
-
const {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
let cachedRows: number | undefined;
|
|
177
|
-
let cachedLines: string[] | undefined;
|
|
178
|
-
let completed = false;
|
|
179
|
-
|
|
180
|
-
const finish = (selection: QuestionSelection): void => {
|
|
181
|
-
if (completed) return;
|
|
182
|
-
completed = true;
|
|
183
|
-
done(selection);
|
|
184
|
-
};
|
|
185
|
-
finishFromAbort = () => finish({ kind: "aborted" });
|
|
186
|
-
|
|
187
|
-
const keyHint = (keybinding: Parameters<typeof keybindings.getKeys>[0], description: string): string => {
|
|
188
|
-
const keyText = keybindings.getKeys(keybinding)
|
|
189
|
-
.join("/")
|
|
190
|
-
.split("/")
|
|
191
|
-
.map((key) => key.split("+").map((part) => process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part).join("+"))
|
|
192
|
-
.join("/");
|
|
193
|
-
return theme.fg("dim", keyText) + theme.fg("muted", ` ${description}`);
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
const editorTheme: EditorTheme = {
|
|
197
|
-
borderColor: (text) => theme.fg("accent", text),
|
|
198
|
-
selectList: {
|
|
199
|
-
selectedPrefix: (text) => theme.fg("accent", text),
|
|
200
|
-
selectedText: (text) => theme.fg("accent", text),
|
|
201
|
-
description: (text) => theme.fg("muted", text),
|
|
202
|
-
scrollInfo: (text) => theme.fg("dim", text),
|
|
203
|
-
noMatch: (text) => theme.fg("warning", text),
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
const editor = new Editor(tui, editorTheme);
|
|
207
|
-
customInputHistory.forEach((value) => editor.addToHistory(value));
|
|
208
|
-
|
|
209
|
-
const filteredOptions = (): DisplayOption[] => {
|
|
210
|
-
const query = filterQuery.trim().toLowerCase();
|
|
211
|
-
return options.filter((option) => option.isOther
|
|
212
|
-
|| query.length === 0
|
|
213
|
-
|| option.label.toLowerCase().includes(query)
|
|
214
|
-
|| option.description?.toLowerCase().includes(query));
|
|
215
|
-
};
|
|
216
|
-
const selectedCount = (): number => selectedOriginalIndices.size + (customAnswer === undefined ? 0 : 1);
|
|
217
|
-
const orderedMultipleSelection = () => {
|
|
218
|
-
const selectedIndices = [...selectedOriginalIndices].sort((left, right) => left - right);
|
|
219
|
-
const predefined = selectedIndices.map((index) => {
|
|
220
|
-
const option = originalOptions[index - 1];
|
|
221
|
-
if (!option) throw new Error("Question selection no longer matches an available option");
|
|
222
|
-
return option.label;
|
|
223
|
-
});
|
|
224
|
-
return {
|
|
225
|
-
answers: customAnswer === undefined ? predefined : [...predefined, customAnswer],
|
|
226
|
-
selectedIndices,
|
|
227
|
-
...(customAnswer === undefined ? {} : { customAnswer }),
|
|
228
|
-
};
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const invalidate = (): void => {
|
|
232
|
-
cachedWidth = undefined;
|
|
233
|
-
cachedRows = undefined;
|
|
234
|
-
cachedLines = undefined;
|
|
235
|
-
editor.invalidate();
|
|
236
|
-
};
|
|
237
|
-
const refresh = (): void => {
|
|
238
|
-
invalidate();
|
|
239
|
-
tui.requestRender();
|
|
240
|
-
};
|
|
241
|
-
const notifyFilterLimit = (): void => 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
|
-
);
|
|
245
|
-
const appendFilterInput = (value: string): void => {
|
|
246
|
-
const next = filterQuery + value;
|
|
247
|
-
if (inputCharacterCount(next) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(next, "utf8") > FILTER_QUERY_MAX_BYTES) {
|
|
248
|
-
notifyFilterLimit();
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
filterQuery = next;
|
|
252
|
-
optionIndex = 0;
|
|
253
|
-
refresh();
|
|
254
|
-
};
|
|
255
|
-
const togglePredefined = (option: DisplayOption): void => {
|
|
256
|
-
if (selectedOriginalIndices.has(option.originalIndex)) {
|
|
257
|
-
selectedOriginalIndices.delete(option.originalIndex);
|
|
258
|
-
refresh();
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
if (selectedCount() >= maxSelections) {
|
|
262
|
-
ctx.ui.notify(`Select at most ${maxSelections} answer${maxSelections === 1 ? "" : "s"}`, "error");
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
selectedOriginalIndices.add(option.originalIndex);
|
|
266
|
-
refresh();
|
|
267
|
-
};
|
|
268
|
-
const enterCustomMode = (): void => {
|
|
269
|
-
editMode = "custom";
|
|
270
|
-
editor.setText(mode === "multiple" ? customAnswer ?? "" : "");
|
|
271
|
-
refresh();
|
|
272
|
-
};
|
|
273
|
-
const enterFilterMode = (): void => {
|
|
274
|
-
editMode = "filter";
|
|
275
|
-
editor.setText(filterQuery);
|
|
276
|
-
refresh();
|
|
277
|
-
};
|
|
106
|
+
const { ctx, signal, question, options, mode, minSelections, maxSelections, customInputHistory, rememberCustomInput } = config;
|
|
107
|
+
const selected = new Set<number>();
|
|
108
|
+
let customAnswer: string | undefined;
|
|
109
|
+
let filter = "";
|
|
110
|
+
const checkAbort = (): void => { if (signal.aborted) throw new QuestionAborted(); };
|
|
111
|
+
const count = (): number => selected.size + (customAnswer === undefined ? 0 : 1);
|
|
112
|
+
const notifyMaximum = (): void => ctx.ui.notify(`Select at most ${maxSelections} answer${maxSelections === 1 ? "" : "s"}`, "error");
|
|
113
|
+
|
|
114
|
+
// Resize dismisses only this native step. Rebuild its pages without losing question state.
|
|
115
|
+
async function nativeRequest(request: (stepSignal: AbortSignal) => Promise<string | undefined>) {
|
|
116
|
+
checkAbort();
|
|
117
|
+
const resize = new AbortController();
|
|
118
|
+
const onResize = (): void => resize.abort();
|
|
119
|
+
process.stdout.on("resize", onResize);
|
|
120
|
+
try {
|
|
121
|
+
const value = await request(AbortSignal.any([signal, resize.signal]));
|
|
122
|
+
checkAbort();
|
|
123
|
+
return resize.signal.aborted ? { kind: "resize" as const } : { kind: "result" as const, value };
|
|
124
|
+
} finally {
|
|
125
|
+
process.stdout.off("resize", onResize);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
278
128
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error");
|
|
305
|
-
refresh();
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
customAnswer = answer;
|
|
309
|
-
editMode = "none";
|
|
310
|
-
editor.setText("");
|
|
311
|
-
refresh();
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
if (!rememberCustomInput(answer)) {
|
|
315
|
-
ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error");
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
finish({ kind: "custom", answer });
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
editMode = "none";
|
|
322
|
-
editor.setText("");
|
|
323
|
-
refresh();
|
|
324
|
-
};
|
|
129
|
+
async function choose<T>(title: string | ((size: DialogSize) => string), entries: Choice<T>[]): Promise<T | undefined> {
|
|
130
|
+
let start = 0;
|
|
131
|
+
while (true) {
|
|
132
|
+
const size = dialogSize();
|
|
133
|
+
const text = typeof title === "string" ? title.split("\n").slice(0, 2).map((line) => truncateToWidth(oneLine(line), size.width, "…")).join("\n") : title(size);
|
|
134
|
+
// Reserve native borders, hints, header/footer, and both page controls.
|
|
135
|
+
const capacity = Math.max(1, size.rows - 13 - text.split("\n").length);
|
|
136
|
+
start = Math.min(start, Math.max(0, entries.length - 1));
|
|
137
|
+
const page = entries.slice(start, start + capacity);
|
|
138
|
+
const labels = page.map((entry, index) => truncateToWidth(`${start + index + 1}. ${oneLine(entry.label)}`, size.width, "…"));
|
|
139
|
+
const next = "Next choices";
|
|
140
|
+
const previous = "Previous choices";
|
|
141
|
+
const offered = [...labels, ...(start + capacity < entries.length ? [next] : []), ...(start > 0 ? [previous] : [])];
|
|
142
|
+
const response = await nativeRequest((stepSignal) => ctx.ui.select(text, offered, { signal: stepSignal }));
|
|
143
|
+
checkAbort();
|
|
144
|
+
if (response.kind === "resize") continue;
|
|
145
|
+
if (response.value === undefined) return undefined;
|
|
146
|
+
if (response.value === next && offered.includes(next)) { start += capacity; continue; }
|
|
147
|
+
if (response.value === previous && offered.includes(previous)) { start = Math.max(0, start - capacity); continue; }
|
|
148
|
+
const index = labels.indexOf(response.value);
|
|
149
|
+
const entry = page[index];
|
|
150
|
+
if (!entry) throw new Error("Native question selection does not match an offered choice");
|
|
151
|
+
return entry.value;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
325
154
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
editor.handleInput(data);
|
|
335
|
-
const after = editor.getExpandedText();
|
|
336
|
-
const overLimit = editMode === "filter"
|
|
337
|
-
? inputCharacterCount(after) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(after, "utf8") > FILTER_QUERY_MAX_BYTES
|
|
338
|
-
: inputCharacterCount(after) > CUSTOM_INPUT_MAX_CHARACTERS;
|
|
339
|
-
if (overLimit) {
|
|
340
|
-
editor.setText(before);
|
|
341
|
-
if (editMode === "filter") notifyFilterLimit();
|
|
342
|
-
else ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS} characters`, "error");
|
|
343
|
-
}
|
|
344
|
-
refresh();
|
|
345
|
-
};
|
|
155
|
+
async function input(title: string, placeholder = ""): Promise<string | undefined> {
|
|
156
|
+
checkAbort();
|
|
157
|
+
const size = dialogSize();
|
|
158
|
+
// Pi reflows native input on resize. Keep its component and in-progress draft.
|
|
159
|
+
const value = await ctx.ui.input(truncateToWidth(title, size.width, "…"), truncateToWidth(oneLine(placeholder), size.width, "…"), { signal });
|
|
160
|
+
checkAbort();
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
346
163
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
164
|
+
async function review(content: string): Promise<void> {
|
|
165
|
+
let page = 0;
|
|
166
|
+
let pages = 1;
|
|
167
|
+
while (true) {
|
|
168
|
+
const action = await choose<"next" | "previous" | "back">((size) => {
|
|
169
|
+
const lines = wrapTextWithAnsi(content, size.width);
|
|
170
|
+
const capacity = Math.max(1, size.rows - 18);
|
|
171
|
+
pages = Math.max(1, Math.ceil(lines.length / capacity));
|
|
172
|
+
page = Math.min(page, pages - 1);
|
|
173
|
+
return [`Review ${page + 1}/${pages}`, ...lines.slice(page * capacity, (page + 1) * capacity)].join("\n");
|
|
174
|
+
}, [ { label: "Next page", value: "next" }, { label: "Previous page", value: "previous" }, { label: "Back", value: "back" } ]);
|
|
175
|
+
if (action === undefined || action === "back") return;
|
|
176
|
+
if (action === "next") page = Math.min(page + 1, pages - 1);
|
|
177
|
+
else page = Math.max(0, page - 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
352
180
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (keybindings.matches(data, "tui.select.pageUp")) {
|
|
367
|
-
optionIndex = Math.max(0, optionIndex - pageSize);
|
|
368
|
-
refresh();
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
if (keybindings.matches(data, "tui.select.pageDown")) {
|
|
372
|
-
optionIndex = Math.min(visibleOptions.length - 1, optionIndex + pageSize);
|
|
373
|
-
refresh();
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
181
|
+
function acceptCustom(raw: string): QuestionSelection | undefined {
|
|
182
|
+
checkAbort();
|
|
183
|
+
const answer = safeTerminalText(raw).trim();
|
|
184
|
+
if (!answer) { ctx.ui.notify("Enter a nonempty custom answer", "error"); return; }
|
|
185
|
+
if (inputCharacterCount(raw.trim()) > CUSTOM_INPUT_MAX_CHARACTERS) {
|
|
186
|
+
ctx.ui.notify(`Custom answers are limited to ${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()} characters`, "error");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (mode === "multiple" && customAnswer === undefined && count() >= maxSelections) { notifyMaximum(); return; }
|
|
190
|
+
if (!rememberCustomInput(answer)) { ctx.ui.notify(`Custom answer history is limited to ${CUSTOM_INPUT_HISTORY_BYTES} bytes`, "error"); return; }
|
|
191
|
+
if (mode === "single") return { kind: "custom", answer };
|
|
192
|
+
customAnswer = answer;
|
|
193
|
+
}
|
|
376
194
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
195
|
+
try {
|
|
196
|
+
while (true) {
|
|
197
|
+
checkAbort();
|
|
198
|
+
const query = filter.trim().toLowerCase();
|
|
199
|
+
const visible = options.filter((option) => !query || option.label.toLowerCase().includes(query) || option.description?.toLowerCase().includes(query));
|
|
200
|
+
const entries: Choice<MainAction>[] = visible.map((option) => ({
|
|
201
|
+
label: `${mode === "multiple" ? selected.has(option.originalIndex) ? "[x] " : "[ ] " : ""}${option.label}${option.description ? ` · ${oneLine(option.description)}` : ""}`,
|
|
202
|
+
value: { kind: "option", option },
|
|
203
|
+
}));
|
|
204
|
+
if (mode === "multiple") entries.push({ label: "Submit answers", value: { kind: "submit" } });
|
|
205
|
+
entries.push({ label: customAnswer === undefined ? "Custom answer" : "Edit custom answer", value: { kind: "custom" } }, { label: "More actions", value: { kind: "actions" } });
|
|
206
|
+
const summary = mode === "multiple" ? `Selected ${count()} · choose ${minSelections}–${maxSelections}` : "Choose one answer";
|
|
207
|
+
const action = await choose(`${summary}${filter ? " · filtered" : ""}\n${question}`, entries);
|
|
208
|
+
checkAbort();
|
|
209
|
+
if (action === undefined) return { kind: "cancelled" };
|
|
210
|
+
switch (action.kind) {
|
|
211
|
+
case "option":
|
|
212
|
+
if (mode === "single") return { kind: "selected", answer: action.option.answer, originalIndex: action.option.originalIndex };
|
|
213
|
+
if (selected.has(action.option.originalIndex)) selected.delete(action.option.originalIndex);
|
|
214
|
+
else if (count() >= maxSelections) notifyMaximum();
|
|
215
|
+
else selected.add(action.option.originalIndex);
|
|
216
|
+
break;
|
|
217
|
+
case "submit": {
|
|
218
|
+
if (count() < minSelections) { ctx.ui.notify(`Select at least ${minSelections} answer${minSelections === 1 ? "" : "s"}`, "error"); break; }
|
|
219
|
+
const ordered = options.filter((option) => selected.has(option.originalIndex));
|
|
220
|
+
return { kind: "multiple", answers: [...ordered.map((option) => option.answer), ...(customAnswer === undefined ? [] : [customAnswer])], selectedIndices: ordered.map((option) => option.originalIndex), ...(customAnswer === undefined ? {} : { customAnswer }) };
|
|
221
|
+
}
|
|
222
|
+
case "custom": {
|
|
223
|
+
const raw = await input("Custom answer", customAnswer);
|
|
224
|
+
if (raw !== undefined) { const result = acceptCustom(raw); if (result) return result; }
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "actions": {
|
|
228
|
+
const more: Choice<MoreAction>[] = [ { label: "Filter options", value: "filter" }, { label: "Review question", value: "question" }, { label: "Review option details", value: "review" } ];
|
|
229
|
+
if (filter) more.push({ label: "Clear filter", value: "clear" });
|
|
230
|
+
if (customInputHistory.length > 0) more.push({ label: "Recent answers", value: "history" });
|
|
231
|
+
if (customAnswer !== undefined) more.push({ label: "Remove custom answer", value: "remove" });
|
|
232
|
+
const extra = await choose("More actions", more);
|
|
233
|
+
checkAbort();
|
|
234
|
+
switch (extra) {
|
|
235
|
+
case undefined: break;
|
|
236
|
+
case "clear": filter = ""; break;
|
|
237
|
+
case "remove": customAnswer = undefined; break;
|
|
238
|
+
case "question": await review(question); break;
|
|
239
|
+
case "review": {
|
|
240
|
+
const option = await choose("Review option details", options.map((option) => ({ label: option.label, value: option })));
|
|
241
|
+
if (option) await review([option.label, option.description, option.preview].filter((value) => value !== undefined).join("\n\n"));
|
|
242
|
+
break;
|
|
401
243
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
ctx.ui.notify(`
|
|
407
|
-
|
|
408
|
-
finish({ kind: "multiple", ...orderedMultipleSelection() });
|
|
244
|
+
case "filter": {
|
|
245
|
+
const raw = await input("Filter options", filter);
|
|
246
|
+
checkAbort();
|
|
247
|
+
if (raw !== undefined) {
|
|
248
|
+
if (inputCharacterCount(raw) > FILTER_QUERY_MAX_CHARACTERS || Buffer.byteLength(raw, "utf8") > FILTER_QUERY_MAX_BYTES) ctx.ui.notify(`Question filters are limited to ${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} characters and ${FILTER_QUERY_MAX_BYTES.toLocaleString()} bytes`, "error");
|
|
249
|
+
else filter = safeTerminalText(raw);
|
|
409
250
|
}
|
|
410
|
-
|
|
251
|
+
break;
|
|
411
252
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
refresh();
|
|
417
|
-
} else finish({ kind: "cancelled" });
|
|
418
|
-
return;
|
|
253
|
+
case "history": {
|
|
254
|
+
const answer = await choose("Recent answers", [...customInputHistory].reverse().map((answer) => ({ label: answer, value: answer })));
|
|
255
|
+
if (answer !== undefined) { const result = acceptCustom(answer); if (result) return result; }
|
|
256
|
+
break;
|
|
419
257
|
}
|
|
420
|
-
return;
|
|
258
|
+
default: { const exhaustive: never = extra; return exhaustive; }
|
|
421
259
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!selected) return;
|
|
426
|
-
if (selected.isOther) enterCustomMode();
|
|
427
|
-
else finish({ kind: "selected", answer: selected.label, originalIndex: selected.originalIndex });
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
431
|
-
if (filterQuery) {
|
|
432
|
-
filterQuery = "";
|
|
433
|
-
optionIndex = 0;
|
|
434
|
-
refresh();
|
|
435
|
-
} else finish({ kind: "cancelled" });
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
if (keybindings.matches(data, "tui.editor.deleteCharBackward")) {
|
|
439
|
-
if (filterQuery) {
|
|
440
|
-
filterQuery = removeLastGrapheme(filterQuery);
|
|
441
|
-
optionIndex = 0;
|
|
442
|
-
refresh();
|
|
443
|
-
}
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
if (!isPasteInput && printableInput && /^[1-9]$/u.test(printableInput)) {
|
|
447
|
-
const selected = visibleOptions[Number(printableInput) - 1];
|
|
448
|
-
if (!selected) return;
|
|
449
|
-
if (selected.isOther) enterCustomMode();
|
|
450
|
-
else finish({ kind: "selected", answer: selected.label, originalIndex: selected.originalIndex });
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
if (printableInput) appendFilterInput(printableInput);
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
const render = (width: number): string[] => {
|
|
457
|
-
if (width <= 0) return [];
|
|
458
|
-
const rowBudget = tui.terminal.rows;
|
|
459
|
-
if (rowBudget <= 0) return [];
|
|
460
|
-
if (cachedLines && cachedWidth === width && cachedRows === rowBudget) return cachedLines;
|
|
461
|
-
const visibleOptions = filteredOptions();
|
|
462
|
-
if (optionIndex >= visibleOptions.length) optionIndex = Math.max(0, visibleOptions.length - 1);
|
|
463
|
-
const selected = visibleOptions[optionIndex];
|
|
464
|
-
const position = `Option ${Math.min(optionIndex + 1, visibleOptions.length)}/${visibleOptions.length}`;
|
|
465
|
-
const editorText = editor.getExpandedText();
|
|
466
|
-
const editorCount = inputCharacterCount(editorText).toLocaleString();
|
|
467
|
-
const filterCount = inputCharacterCount(editMode === "filter" ? editorText : filterQuery).toLocaleString();
|
|
468
|
-
const compactDraft = editorText.replace(/\r\n|\r|\n/gu, " ↵ ") || (editMode === "filter" ? "Type a filter" : "Type an answer");
|
|
469
|
-
const selectionRange = minSelections === maxSelections ? `${minSelections}` : `${minSelections}–${maxSelections}`;
|
|
470
|
-
const selectionStatus = `Selected ${selectedCount()} · required ${selectionRange}`;
|
|
471
|
-
const optionLabel = (option: DisplayOption, index: number): string => {
|
|
472
|
-
if (mode === "single") return `${index === optionIndex ? ">" : " "} ${index + 1}. ${option.label}`;
|
|
473
|
-
const checked = option.isOther ? customAnswer !== undefined : selectedOriginalIndices.has(option.originalIndex);
|
|
474
|
-
const label = option.isOther && customAnswer !== undefined ? `Custom: ${oneLine(customAnswer)}` : option.label;
|
|
475
|
-
return `${index === optionIndex ? ">" : " "} ${checked ? "[x]" : "[ ]"} ${index + 1}. ${label}`;
|
|
476
|
-
};
|
|
477
|
-
const browseHint = mode === "multiple"
|
|
478
|
-
? `space toggle • / filter • ${keyHint("tui.select.confirm", selected?.isOther ? (customAnswer ? "edit custom" : "add custom") : "submit")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`
|
|
479
|
-
: `${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", "cancel")}`;
|
|
480
|
-
const editHint = `${keyHint("tui.input.submit", editMode === "filter" ? "apply" : "submit")} • ${keyHint("tui.select.cancel", "options")}`;
|
|
481
|
-
|
|
482
|
-
let lines: string[];
|
|
483
|
-
if (rowBudget <= 2) {
|
|
484
|
-
if (editMode !== "none") lines = [`${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`, compactDraft];
|
|
485
|
-
else if (mode === "multiple") {
|
|
486
|
-
const focusedRow = selected ? optionLabel(selected, optionIndex) : "";
|
|
487
|
-
const compactSelectionStatus = visibleWidth(selectionStatus) <= width
|
|
488
|
-
? selectionStatus
|
|
489
|
-
: `Selected ${selectedCount()}`;
|
|
490
|
-
lines = focusedRow && visibleWidth(focusedRow) <= width
|
|
491
|
-
? [focusedRow, compactSelectionStatus]
|
|
492
|
-
: [compactSelectionStatus, focusedRow];
|
|
493
|
-
} else lines = [`${selected ? `> ${selected.label}` : "No matching options"} · ${position}`];
|
|
494
|
-
} else if (rowBudget <= 5) {
|
|
495
|
-
lines = [
|
|
496
|
-
...boundedQuestionLines(question, width, Math.max(1, rowBudget - 3)),
|
|
497
|
-
editMode !== "none"
|
|
498
|
-
? `${editMode === "filter" ? "Filter" : "Answer"} ${editMode === "filter" ? filterCount : editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
|
|
499
|
-
: selected ? optionLabel(selected, optionIndex) : "No matching options",
|
|
500
|
-
editMode !== "none" ? compactDraft : mode === "multiple" ? selectionStatus : filterQuery ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}` : position,
|
|
501
|
-
editMode !== "none" ? editHint : browseHint,
|
|
502
|
-
];
|
|
503
|
-
} else {
|
|
504
|
-
const questionLines = boundedQuestionLines(question, width, Math.max(1, rowBudget - 5));
|
|
505
|
-
const contentRows = rowBudget - questionLines.length - 4;
|
|
506
|
-
const optionCapacity = Math.max(1, Math.min(5, Math.ceil(contentRows / 2)));
|
|
507
|
-
const detailCapacity = Math.max(0, contentRows - optionCapacity);
|
|
508
|
-
const { start, end } = visibleOptionRange(visibleOptions.length, optionIndex, optionCapacity);
|
|
509
|
-
const hiddenAbove = start > 0 ? `↑ ${start}` : "";
|
|
510
|
-
const hiddenBelowCount = visibleOptions.length - end;
|
|
511
|
-
const hiddenBelow = hiddenBelowCount > 0 ? `↓ ${hiddenBelowCount}` : "";
|
|
512
|
-
const hiddenStatus = [hiddenAbove, hiddenBelow].filter(Boolean).join(" · ");
|
|
513
|
-
const progress = editMode === "filter"
|
|
514
|
-
? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()}`
|
|
515
|
-
: editMode === "custom"
|
|
516
|
-
? `Answer ${editorCount}/${CUSTOM_INPUT_MAX_CHARACTERS.toLocaleString()}`
|
|
517
|
-
: mode === "multiple"
|
|
518
|
-
? `${selectionStatus}${hiddenStatus ? ` · ${hiddenStatus}` : ""}`
|
|
519
|
-
: filterQuery ? `Filter ${filterCount}/${FILTER_QUERY_MAX_CHARACTERS.toLocaleString()} · ${position}` : `${position}${hiddenStatus ? ` · ${hiddenStatus}` : ""}`;
|
|
520
|
-
const navigationHint = editMode !== "none" ? editHint : mode === "multiple"
|
|
521
|
-
? `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${browseHint}`
|
|
522
|
-
: `${keyHint("tui.select.up", "up")} • ${keyHint("tui.select.down", "down")} • ${keyHint("tui.select.confirm", "select")} • ${keyHint("tui.select.cancel", filterQuery ? "clear filter" : "cancel")}`;
|
|
523
|
-
lines = [
|
|
524
|
-
theme.fg("accent", "─".repeat(width)),
|
|
525
|
-
...questionLines.map((line) => theme.fg("text", line)),
|
|
526
|
-
theme.fg("muted", truncateToWidth(` ${progress}`, width, "…")),
|
|
527
|
-
];
|
|
528
|
-
if (editMode !== "none") {
|
|
529
|
-
const editorLines = editor.render(width);
|
|
530
|
-
const draftLines = editorLines.length > 2 ? editorLines.slice(1, -1) : editorLines;
|
|
531
|
-
lines.push(...(draftLines.length > 0 ? draftLines : [editMode === "filter" ? "Type a filter" : "Type an answer"]).slice(-contentRows));
|
|
532
|
-
} else {
|
|
533
|
-
for (let index = start; index < end; index += 1) {
|
|
534
|
-
const option = visibleOptions[index];
|
|
535
|
-
if (!option) continue;
|
|
536
|
-
const color: ThemeColor = index === optionIndex ? "accent" : "text";
|
|
537
|
-
lines.push(theme.fg(color, truncateToWidth(optionLabel(option, index), width, "…")));
|
|
538
|
-
}
|
|
539
|
-
const detailLines: string[] = [];
|
|
540
|
-
if (selected?.description) detailLines.push(...wrapTextWithAnsi(theme.fg("muted", selected.description), width));
|
|
541
|
-
if (selected?.preview) {
|
|
542
|
-
detailLines.push(theme.fg("accent", theme.bold("Proposal preview")));
|
|
543
|
-
detailLines.push(...new Markdown(selected.preview, 0, 0, {
|
|
544
|
-
heading: (text) => theme.fg("accent", theme.bold(text)), link: (text) => theme.fg("accent", text),
|
|
545
|
-
linkUrl: (text) => theme.fg("dim", text), code: (text) => theme.fg("mdCode", text),
|
|
546
|
-
codeBlock: (text) => theme.fg("mdCodeBlock", text), codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
|
|
547
|
-
quote: (text) => theme.fg("mdQuote", text), quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
|
|
548
|
-
hr: (text) => theme.fg("mdHr", text), listBullet: (text) => theme.fg("mdListBullet", text),
|
|
549
|
-
bold: (text) => theme.bold(text), italic: (text) => theme.italic(text), strikethrough: (text) => theme.strikethrough(text),
|
|
550
|
-
underline: (text) => theme.underline(text),
|
|
551
|
-
}, { color: (text) => theme.fg("muted", text) }).render(width));
|
|
552
|
-
}
|
|
553
|
-
if (detailCapacity > 0 && detailLines.length > detailCapacity) {
|
|
554
|
-
const visibleDetailRows = Math.max(0, detailCapacity - 1);
|
|
555
|
-
lines.push(...detailLines.slice(0, visibleDetailRows));
|
|
556
|
-
const hiddenRows = detailLines.length - visibleDetailRows;
|
|
557
|
-
lines.push(theme.fg("dim", `… ${hiddenRows} more line${hiddenRows === 1 ? "" : "s"}`));
|
|
558
|
-
} else lines.push(...detailLines.slice(0, detailCapacity));
|
|
559
|
-
}
|
|
560
|
-
lines.push(theme.fg("dim", truncateToWidth(` ${navigationHint}`, width, "…")));
|
|
561
|
-
lines.push(theme.fg("accent", "─".repeat(width)));
|
|
562
|
-
}
|
|
563
|
-
cachedWidth = width;
|
|
564
|
-
cachedRows = rowBudget;
|
|
565
|
-
cachedLines = lines.slice(0, rowBudget).map((line) => boundedRenderLine(line, width, rowBudget <= 5 ? "…" : ""));
|
|
566
|
-
return cachedLines;
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
let focused = false;
|
|
570
|
-
return {
|
|
571
|
-
get focused(): boolean { return focused; },
|
|
572
|
-
set focused(value: boolean) { focused = value; editor.focused = value; },
|
|
573
|
-
render,
|
|
574
|
-
handleInput,
|
|
575
|
-
invalidate,
|
|
576
|
-
};
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
const abortHandler = (): void => finishFromAbort?.();
|
|
580
|
-
signal?.addEventListener("abort", abortHandler, { once: true });
|
|
581
|
-
if (signal?.aborted) abortHandler();
|
|
582
|
-
let result: QuestionSelection;
|
|
583
|
-
try {
|
|
584
|
-
result = await resultPromise;
|
|
585
|
-
} finally {
|
|
586
|
-
signal?.removeEventListener("abort", abortHandler);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
default: { const exhaustive: never = action; return exhaustive; }
|
|
587
263
|
}
|
|
588
|
-
|
|
589
|
-
|
|
264
|
+
}
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof QuestionAborted) return { kind: "aborted" };
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
590
269
|
}
|
package/killeros/question.ts
CHANGED
|
@@ -87,8 +87,10 @@ type QuestionDetails = SingleQuestionDetails | MultipleQuestionDetails;
|
|
|
87
87
|
|
|
88
88
|
export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
89
89
|
const customInputHistory: string[] = [];
|
|
90
|
+
const pendingQuestions = new Set<AbortController>();
|
|
90
91
|
let customInputHistoryBytes = 0;
|
|
91
92
|
const clearCustomInputHistory = (): void => {
|
|
93
|
+
for (const pending of pendingQuestions) pending.abort();
|
|
92
94
|
customInputHistory.length = 0;
|
|
93
95
|
customInputHistoryBytes = 0;
|
|
94
96
|
};
|
|
@@ -132,32 +134,37 @@ export function registerQuestionTool(pi: ExtensionAPI): void {
|
|
|
132
134
|
if (signal?.aborted) throw new Error("Question cancelled before it opened");
|
|
133
135
|
|
|
134
136
|
const question = safeTerminalText(params.question);
|
|
135
|
-
const options: DisplayOption[] =
|
|
136
|
-
...params.options.map((option, index) => ({
|
|
137
|
+
const options: DisplayOption[] = params.options.map((option, index) => ({
|
|
137
138
|
label: safeTerminalText(option.label),
|
|
139
|
+
answer: option.label,
|
|
138
140
|
description: option.description === undefined ? undefined : safeTerminalText(option.description),
|
|
139
141
|
preview: option.preview === undefined ? undefined : safeTerminalText(option.preview),
|
|
140
142
|
originalIndex: index + 1,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
const pending = new AbortController();
|
|
146
|
+
pendingQuestions.add(pending);
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = await openQuestionUi({
|
|
150
|
+
ctx,
|
|
151
|
+
signal: signal ? AbortSignal.any([signal, pending.signal]) : pending.signal,
|
|
152
|
+
question,
|
|
153
|
+
options,
|
|
154
|
+
mode,
|
|
155
|
+
minSelections,
|
|
156
|
+
maxSelections,
|
|
157
|
+
customInputHistory,
|
|
158
|
+
rememberCustomInput,
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
pendingQuestions.delete(pending);
|
|
162
|
+
}
|
|
158
163
|
|
|
159
164
|
const simpleOptions = params.options.map((option) => option.label);
|
|
160
|
-
if (result.kind === "aborted"
|
|
165
|
+
if (result.kind === "aborted" || pending.signal.aborted || signal?.aborted) {
|
|
166
|
+
throw new Error("Question cancelled because the agent operation was aborted");
|
|
167
|
+
}
|
|
161
168
|
if (result.kind === "cancelled" && mode === "multiple") {
|
|
162
169
|
return {
|
|
163
170
|
content: [{ type: "text", text: "User cancelled the question" }],
|
package/killeros/worked-for.ts
CHANGED
|
@@ -454,7 +454,7 @@ export function registerWorkedFor(
|
|
|
454
454
|
}
|
|
455
455
|
});
|
|
456
456
|
|
|
457
|
-
pi.on("agent_settled", async (
|
|
457
|
+
pi.on("agent_settled", async (event, ctx) => {
|
|
458
458
|
if (!active || ctx.mode !== "tui") return;
|
|
459
459
|
const settled = active;
|
|
460
460
|
const generation = lifecycleGeneration;
|
|
@@ -469,7 +469,7 @@ export function registerWorkedFor(
|
|
|
469
469
|
ctx.ui.notify(`Change receipt unavailable: ${changes.reason}`, "warning");
|
|
470
470
|
}
|
|
471
471
|
const settledTokens = sessionTokenTotal(ctx);
|
|
472
|
-
const outcome = workedForOutcome(settled.stopReason);
|
|
472
|
+
const outcome = "aborted" in event && event.aborted === true ? "stopped" : workedForOutcome(settled.stopReason);
|
|
473
473
|
const throughput = outcome === "done" && !settled.throughputInvalid && settled.responseStartedAt === undefined
|
|
474
474
|
&& positiveInteger(settled.outputTokens) && positiveDuration(settled.responseMilliseconds)
|
|
475
475
|
? { outputTokens: settled.outputTokens, responseMilliseconds: settled.responseMilliseconds }
|