killeros 3.0.6 → 3.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,269 +1,269 @@
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";
4
-
5
- export interface DisplayOption {
6
- label: string;
7
- answer: string;
8
- description?: string;
9
- preview?: string;
10
- originalIndex: number;
11
- }
12
-
13
- export type QuestionSelection =
14
- | { kind: "selected"; answer: string; originalIndex: number }
15
- | { kind: "custom"; answer: string }
16
- | { kind: "multiple"; answers: string[]; selectedIndices: number[]; customAnswer?: string }
17
- | { kind: "cancelled" }
18
- | { kind: "aborted" };
19
-
20
- export const CUSTOM_INPUT_MAX_CHARACTERS = 4_000;
21
- export const CUSTOM_INPUT_HISTORY_LIMIT = 100;
22
- export const CUSTOM_INPUT_HISTORY_BYTES = 64 * 1024;
23
- export const FILTER_QUERY_MAX_CHARACTERS = 4_000;
24
- export const FILTER_QUERY_MAX_BYTES = 16_000;
25
-
26
- export function oneLine(value: string): string {
27
- return value.replace(/\s+/gu, " ").trim();
28
- }
29
-
30
- function inputCharacterCount(value: string): number {
31
- return [...value].length;
32
- }
33
-
34
- function compactMultipleAnswers(answers: readonly string[], width: number): string {
35
- const prefix = "✓ ";
36
- if (answers.length === 0) return truncateToWidth(prefix + "No answers", width, "…");
37
- const visible: string[] = [];
38
- for (const [index, answer] of answers.entries()) {
39
- const remaining = answers.length - index - 1;
40
- const candidate = [...visible, oneLine(answer)].join(", ");
41
- const suffix = remaining > 0 ? `, +${remaining} more` : "";
42
- if (visibleWidth(prefix + candidate + suffix) > width) break;
43
- visible.push(oneLine(answer));
44
- }
45
- if (visible.length === answers.length) return prefix + visible.join(", ");
46
- const hidden = answers.length - visible.length;
47
- if (visible.length === 0) return truncateToWidth(`${prefix}+${hidden} more`, width, "…");
48
- return truncateToWidth(`${prefix}${visible.join(", ")}, +${hidden} more`, width, "…");
49
- }
50
-
51
- export class MultipleResultText {
52
- private readonly answers: readonly string[];
53
- private readonly expanded: boolean;
54
- private readonly customAnswer: string | undefined;
55
- private readonly color: (name: ThemeColor, text: string) => string;
56
-
57
- constructor(
58
- answers: readonly string[],
59
- expanded: boolean,
60
- customAnswer: string | undefined,
61
- color: (name: ThemeColor, text: string) => string,
62
- ) {
63
- this.answers = answers;
64
- this.expanded = expanded;
65
- this.customAnswer = customAnswer;
66
- this.color = color;
67
- }
68
-
69
- render(width: number): string[] {
70
- if (width <= 0) return [];
71
- if (!this.expanded) return [this.color("accent", compactMultipleAnswers(this.answers, width))];
72
- return this.answers.flatMap((answer) => wrapTextWithAnsi(
73
- `${this.color("success", "✓ ")}${answer === this.customAnswer ? this.color("muted", "(wrote) ") : ""}${this.color("accent", answer)}`,
74
- width,
75
- ));
76
- }
77
-
78
- invalidate(): void {}
79
- }
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
-
95
- export async function openQuestionUi(config: {
96
- ctx: ExtensionContext;
97
- signal: AbortSignal;
98
- question: string;
99
- options: DisplayOption[];
100
- mode: "single" | "multiple";
101
- minSelections: number;
102
- maxSelections: number;
103
- customInputHistory: readonly string[];
104
- rememberCustomInput: (value: string) => boolean;
105
- }): Promise<QuestionSelection> {
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
- }
128
-
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
- }
154
-
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
- }
163
-
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
- }
180
-
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
- }
194
-
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;
243
- }
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);
250
- }
251
- break;
252
- }
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;
257
- }
258
- default: { const exhaustive: never = extra; return exhaustive; }
259
- }
260
- break;
261
- }
262
- default: { const exhaustive: never = action; return exhaustive; }
263
- }
264
- }
265
- } catch (error) {
266
- if (error instanceof QuestionAborted) return { kind: "aborted" };
267
- throw error;
268
- }
269
- }
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";
4
+
5
+ export interface DisplayOption {
6
+ label: string;
7
+ answer: string;
8
+ description?: string;
9
+ preview?: string;
10
+ originalIndex: number;
11
+ }
12
+
13
+ export type QuestionSelection =
14
+ | { kind: "selected"; answer: string; originalIndex: number }
15
+ | { kind: "custom"; answer: string }
16
+ | { kind: "multiple"; answers: string[]; selectedIndices: number[]; customAnswer?: string }
17
+ | { kind: "cancelled" }
18
+ | { kind: "aborted" };
19
+
20
+ export const CUSTOM_INPUT_MAX_CHARACTERS = 4_000;
21
+ export const CUSTOM_INPUT_HISTORY_LIMIT = 100;
22
+ export const CUSTOM_INPUT_HISTORY_BYTES = 64 * 1024;
23
+ export const FILTER_QUERY_MAX_CHARACTERS = 4_000;
24
+ export const FILTER_QUERY_MAX_BYTES = 16_000;
25
+
26
+ export function oneLine(value: string): string {
27
+ return value.replace(/\s+/gu, " ").trim();
28
+ }
29
+
30
+ function inputCharacterCount(value: string): number {
31
+ return [...value].length;
32
+ }
33
+
34
+ function compactMultipleAnswers(answers: readonly string[], width: number): string {
35
+ const prefix = "✓ ";
36
+ if (answers.length === 0) return truncateToWidth(prefix + "No answers", width, "…");
37
+ const visible: string[] = [];
38
+ for (const [index, answer] of answers.entries()) {
39
+ const remaining = answers.length - index - 1;
40
+ const candidate = [...visible, oneLine(answer)].join(", ");
41
+ const suffix = remaining > 0 ? `, +${remaining} more` : "";
42
+ if (visibleWidth(prefix + candidate + suffix) > width) break;
43
+ visible.push(oneLine(answer));
44
+ }
45
+ if (visible.length === answers.length) return prefix + visible.join(", ");
46
+ const hidden = answers.length - visible.length;
47
+ if (visible.length === 0) return truncateToWidth(`${prefix}+${hidden} more`, width, "…");
48
+ return truncateToWidth(`${prefix}${visible.join(", ")}, +${hidden} more`, width, "…");
49
+ }
50
+
51
+ export class MultipleResultText {
52
+ private readonly answers: readonly string[];
53
+ private readonly expanded: boolean;
54
+ private readonly customAnswer: string | undefined;
55
+ private readonly color: (name: ThemeColor, text: string) => string;
56
+
57
+ constructor(
58
+ answers: readonly string[],
59
+ expanded: boolean,
60
+ customAnswer: string | undefined,
61
+ color: (name: ThemeColor, text: string) => string,
62
+ ) {
63
+ this.answers = answers;
64
+ this.expanded = expanded;
65
+ this.customAnswer = customAnswer;
66
+ this.color = color;
67
+ }
68
+
69
+ render(width: number): string[] {
70
+ if (width <= 0) return [];
71
+ if (!this.expanded) return [this.color("accent", compactMultipleAnswers(this.answers, width))];
72
+ return this.answers.flatMap((answer) => wrapTextWithAnsi(
73
+ `${this.color("success", "✓ ")}${answer === this.customAnswer ? this.color("muted", "(wrote) ") : ""}${this.color("accent", answer)}`,
74
+ width,
75
+ ));
76
+ }
77
+
78
+ invalidate(): void {}
79
+ }
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
+
95
+ export async function openQuestionUi(config: {
96
+ ctx: ExtensionContext;
97
+ signal: AbortSignal;
98
+ question: string;
99
+ options: DisplayOption[];
100
+ mode: "single" | "multiple";
101
+ minSelections: number;
102
+ maxSelections: number;
103
+ customInputHistory: readonly string[];
104
+ rememberCustomInput: (value: string) => boolean;
105
+ }): Promise<QuestionSelection> {
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
+ }
128
+
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
+ }
154
+
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
+ }
163
+
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
+ }
180
+
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
+ }
194
+
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;
243
+ }
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);
250
+ }
251
+ break;
252
+ }
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;
257
+ }
258
+ default: { const exhaustive: never = extra; return exhaustive; }
259
+ }
260
+ break;
261
+ }
262
+ default: { const exhaustive: never = action; return exhaustive; }
263
+ }
264
+ }
265
+ } catch (error) {
266
+ if (error instanceof QuestionAborted) return { kind: "aborted" };
267
+ throw error;
268
+ }
269
+ }