killeros 3.0.4 → 3.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 +39 -0
- package/Killeros.ts +4 -2
- package/README.md +263 -94
- package/killeros/auto-compaction.ts +13 -1
- package/killeros/footer.ts +59 -23
- 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 +11 -9
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
|
}
|