@danypops/papyrus 0.26.0 → 0.27.0
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.
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discuss-ask-view.ts — Discuss's own live:true synchronous ask UI: searchable single-select
|
|
3
|
+
* with a split-pane description preview, a checkbox multi-select, an integrated freeform
|
|
4
|
+
* editor, an optional post-selection comment, overlay/inline display modes, toggle shortcuts,
|
|
5
|
+
* and an auto-dismiss timeout. Owned end-to-end by Papyrus/Discuss -- no runtime dependency on
|
|
6
|
+
* or delegation to another package's registered tool.
|
|
7
|
+
*
|
|
8
|
+
* Substantially adapted from pi-ask-user's index.ts (MIT, Copyright (c) 2026 Enzo Lucchesi --
|
|
9
|
+
* full notice in THIRD_PARTY_LICENSES.md), with the standalone-tool plumbing (schema, tool
|
|
10
|
+
* registration, its own event emission, malformed-options recovery for other tools' schemas)
|
|
11
|
+
* removed since Discuss already owns its schema, persistence, and rendering.
|
|
12
|
+
*/
|
|
13
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import {
|
|
16
|
+
Container,
|
|
17
|
+
type Component,
|
|
18
|
+
CURSOR_MARKER,
|
|
19
|
+
decodeKittyPrintable,
|
|
20
|
+
Editor,
|
|
21
|
+
type EditorTheme,
|
|
22
|
+
fuzzyFilter,
|
|
23
|
+
Key,
|
|
24
|
+
type Keybinding,
|
|
25
|
+
type KeybindingsManager,
|
|
26
|
+
Markdown,
|
|
27
|
+
type MarkdownTheme,
|
|
28
|
+
matchesKey,
|
|
29
|
+
type OverlayHandle,
|
|
30
|
+
type OverlayOptions,
|
|
31
|
+
Spacer,
|
|
32
|
+
Text,
|
|
33
|
+
type TUI,
|
|
34
|
+
truncateToWidth,
|
|
35
|
+
wrapTextWithAnsi,
|
|
36
|
+
} from "@earendil-works/pi-tui";
|
|
37
|
+
import { renderSingleSelectRows, type AskOption } from "./discuss-ask-layout.ts";
|
|
38
|
+
|
|
39
|
+
/** See pi-ask-user's identical safeMarkdownTheme() comment: a broken theme Proxy throws only on
|
|
40
|
+
* property access, not construction, so a bare try/catch around getMarkdownTheme() alone would
|
|
41
|
+
* still crash mid-render. Probing bold("") forces the throw eagerly, so callers can fall back
|
|
42
|
+
* to plain Text rendering instead. */
|
|
43
|
+
function safeMarkdownTheme(): MarkdownTheme | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const md = getMarkdownTheme();
|
|
46
|
+
if (!md) return undefined;
|
|
47
|
+
md.bold("");
|
|
48
|
+
return md;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type AskDisplayMode = "overlay" | "inline";
|
|
55
|
+
|
|
56
|
+
export interface AskQuestionParams {
|
|
57
|
+
question: string;
|
|
58
|
+
context?: string;
|
|
59
|
+
options?: AskOption[];
|
|
60
|
+
allowMultiple?: boolean;
|
|
61
|
+
allowFreeform?: boolean;
|
|
62
|
+
allowComment?: boolean;
|
|
63
|
+
displayMode?: AskDisplayMode;
|
|
64
|
+
timeout?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AskAnswer {
|
|
68
|
+
content: string;
|
|
69
|
+
selected?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type AskResponse =
|
|
73
|
+
| { kind: "selection"; selections: string[]; comment?: string }
|
|
74
|
+
| { kind: "freeform"; text: string };
|
|
75
|
+
|
|
76
|
+
function normalizeOptionalComment(text: string | null | undefined): string | undefined {
|
|
77
|
+
const trimmed = text?.trim();
|
|
78
|
+
return trimmed ? trimmed : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseBooleanPreference(value: string | undefined): boolean | undefined {
|
|
82
|
+
if (value === undefined) return undefined;
|
|
83
|
+
switch (value.trim().toLowerCase()) {
|
|
84
|
+
case "1": case "true": case "yes": case "on": return true;
|
|
85
|
+
case "0": case "false": case "no": case "off": return false;
|
|
86
|
+
default: return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createFreeformResponse(text: string | null | undefined): AskResponse | null {
|
|
91
|
+
const trimmed = text?.trim();
|
|
92
|
+
return trimmed ? { kind: "freeform", text: trimmed } : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createSelectionResponse(selections: string[], comment?: string | null): AskResponse | null {
|
|
96
|
+
const normalizedSelections = selections.map((selection) => selection.trim()).filter(Boolean);
|
|
97
|
+
if (normalizedSelections.length === 0) return null;
|
|
98
|
+
const normalizedComment = normalizeOptionalComment(comment);
|
|
99
|
+
return normalizedComment ? { kind: "selection", selections: normalizedSelections, comment: normalizedComment } : { kind: "selection", selections: normalizedSelections };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toAskAnswer(response: AskResponse): AskAnswer {
|
|
103
|
+
if (response.kind === "freeform") return { content: response.text };
|
|
104
|
+
const content = response.comment ? `${response.selections.join(", ")} — ${response.comment}` : response.selections.join(", ");
|
|
105
|
+
return { content, selected: response.selections };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatOptionsForMessage(options: AskOption[]): string {
|
|
109
|
+
return options.map((option, index) => `${index + 1}. ${option.title}${option.description ? ` — ${option.description}` : ""}`).join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildCommentPrompt(prompt: string, selections: string[]): string {
|
|
113
|
+
const label = selections.length === 1 ? "Selected option" : "Selected options";
|
|
114
|
+
return `${prompt}\n\n${label}:\n${selections.map((selection) => `- ${selection}`).join("\n")}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseDialogSelections(input: string): string[] {
|
|
118
|
+
return input.split(",").map((selection) => selection.trim()).filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isCancelledInput(value: unknown): value is null | undefined {
|
|
122
|
+
return value === null || value === undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createSelectListTheme(theme: Theme) {
|
|
126
|
+
return {
|
|
127
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
128
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
129
|
+
description: (t: string) => theme.fg("muted", t),
|
|
130
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
131
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createEditorTheme(theme: Theme): EditorTheme {
|
|
136
|
+
return { borderColor: (s: string) => theme.fg("accent", s), selectList: createSelectListTheme(theme) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const BOX_BORDER_LEFT = "│ ";
|
|
140
|
+
const BOX_BORDER_RIGHT = " │";
|
|
141
|
+
const BOX_BORDER_OVERHEAD = BOX_BORDER_LEFT.length + BOX_BORDER_RIGHT.length;
|
|
142
|
+
|
|
143
|
+
class BoxBorderTop implements Component {
|
|
144
|
+
constructor(private color: (s: string) => string, private title?: string, private titleColor?: (s: string) => string) {}
|
|
145
|
+
invalidate(): void {}
|
|
146
|
+
render(width: number): string[] {
|
|
147
|
+
const inner = Math.max(0, width - 2);
|
|
148
|
+
if (!this.title || inner < this.title.length + 4) return [this.color(`╭${"─".repeat(inner)}╮`)];
|
|
149
|
+
const label = ` ${this.title} `;
|
|
150
|
+
const remaining = inner - 1 - label.length;
|
|
151
|
+
const titleStyle = this.titleColor ?? this.color;
|
|
152
|
+
return [this.color("╭─") + titleStyle(label) + this.color(`${"─".repeat(Math.max(0, remaining))}╮`)];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
class BoxBorderBottom implements Component {
|
|
157
|
+
constructor(private color: (s: string) => string) {}
|
|
158
|
+
invalidate(): void {}
|
|
159
|
+
render(width: number): string[] {
|
|
160
|
+
const inner = Math.max(0, width - 2);
|
|
161
|
+
return [this.color(`╰${"─".repeat(inner)}╯`)];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatKeyList(keys: string[]): string {
|
|
166
|
+
return keys.join("/");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function keybindingHint(theme: Theme, keybindings: KeybindingsManager, keybinding: Keybinding, description: string): string {
|
|
170
|
+
return `${theme.fg("dim", formatKeyList(keybindings.getKeys(keybinding)))}${theme.fg("muted", ` ${description}`)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function literalHint(theme: Theme, key: string, description: string): string {
|
|
174
|
+
return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type ResolvedShortcut = { disabled: false; spec: string; matches: (data: string) => boolean } | { disabled: true; spec: null; matches: (data: string) => false };
|
|
178
|
+
|
|
179
|
+
const DISABLED_SHORTCUT: ResolvedShortcut = { disabled: true, spec: null, matches: (() => false) as (data: string) => false };
|
|
180
|
+
const SHORTCUT_DISABLE_VALUES = new Set(["off", "none", "disabled", ""]);
|
|
181
|
+
|
|
182
|
+
function normalizeShortcutSpec(value: string | null | undefined): string | null | undefined {
|
|
183
|
+
if (value === undefined) return undefined;
|
|
184
|
+
if (value === null) return null;
|
|
185
|
+
const trimmed = value.trim().toLowerCase();
|
|
186
|
+
return SHORTCUT_DISABLE_VALUES.has(trimmed) ? null : trimmed;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isValidShortcutSpec(spec: string): boolean {
|
|
190
|
+
if (!spec) return false;
|
|
191
|
+
if (!/^[a-z0-9+_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]+$/i.test(spec)) return false;
|
|
192
|
+
if (spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) return false;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildShortcut(spec: string): ResolvedShortcut {
|
|
197
|
+
return { disabled: false, spec, matches: (data: string) => matchesKey(data, spec as any) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveShortcut(paramValue: string | null | undefined, envValue: string | undefined, defaultSpec: string): ResolvedShortcut {
|
|
201
|
+
for (const raw of [paramValue, envValue, defaultSpec]) {
|
|
202
|
+
const normalized = normalizeShortcutSpec(raw);
|
|
203
|
+
if (normalized === undefined) continue;
|
|
204
|
+
if (normalized === null) return DISABLED_SHORTCUT;
|
|
205
|
+
if (isValidShortcutSpec(normalized)) return buildShortcut(normalized);
|
|
206
|
+
}
|
|
207
|
+
return DISABLED_SHORTCUT;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type AskMode = "select" | "freeform" | "comment";
|
|
211
|
+
|
|
212
|
+
const OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
213
|
+
const OVERLAY_MIN_RENDER_LINES = 8;
|
|
214
|
+
const OVERLAY_WIDTH = "92%";
|
|
215
|
+
const OVERLAY_MIN_WIDTH = 40;
|
|
216
|
+
const SPLIT_PANE_MIN_WIDTH = 84;
|
|
217
|
+
const SPLIT_PANE_LEFT_MIN_WIDTH = 32;
|
|
218
|
+
const SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
|
|
219
|
+
const SPLIT_PANE_SEPARATOR = " │ ";
|
|
220
|
+
const FREEFORM_SENTINEL = "\u270f\ufe0f Type a custom answer...";
|
|
221
|
+
const COMMENT_TOGGLE_LABEL = "Add extra context after selection";
|
|
222
|
+
const DEFAULT_OVERLAY_TOGGLE_KEY = "alt+o";
|
|
223
|
+
const DEFAULT_COMMENT_TOGGLE_KEY = "ctrl+g";
|
|
224
|
+
|
|
225
|
+
const VIM_SELECT_UP_KEY = Key.ctrl("k");
|
|
226
|
+
const VIM_SELECT_DOWN_KEY = Key.ctrl("j");
|
|
227
|
+
const PROMPT_SCROLL_PAGE_UP_KEY = Key.pageUp;
|
|
228
|
+
const PROMPT_SCROLL_PAGE_DOWN_KEY = Key.pageDown;
|
|
229
|
+
const PROMPT_SCROLL_HOME_KEY = Key.home;
|
|
230
|
+
const PROMPT_SCROLL_END_KEY = Key.end;
|
|
231
|
+
const PROMPT_SCROLL_HALF_PAGE_UP_KEY = Key.ctrl("u");
|
|
232
|
+
const PROMPT_SCROLL_HALF_PAGE_DOWN_KEY = Key.ctrl("d");
|
|
233
|
+
|
|
234
|
+
function getOverlayMaxRenderLinesForRows(rows: number): number {
|
|
235
|
+
const normalizedRows = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
|
|
236
|
+
const availableRows = Math.max(1, normalizedRows - 2);
|
|
237
|
+
const ratioRows = Math.max(1, Math.floor(normalizedRows * OVERLAY_MAX_HEIGHT_RATIO));
|
|
238
|
+
const minimumRows = Math.min(OVERLAY_MIN_RENDER_LINES, availableRows);
|
|
239
|
+
return Math.min(availableRows, Math.max(minimumRows, ratioRows));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function matchesSelectUp(data: string, keybindings: KeybindingsManager): boolean {
|
|
243
|
+
return keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab")) || matchesKey(data, VIM_SELECT_UP_KEY);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function matchesSelectDown(data: string, keybindings: KeybindingsManager): boolean {
|
|
247
|
+
return keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab) || matchesKey(data, VIM_SELECT_DOWN_KEY);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function buildCustomUIOptions(displayMode: AskDisplayMode, onHandle?: (handle: OverlayHandle) => void): { overlay?: boolean; overlayOptions?: OverlayOptions; onHandle?: (handle: OverlayHandle) => void } | undefined {
|
|
251
|
+
if (displayMode === "inline") return undefined;
|
|
252
|
+
return {
|
|
253
|
+
overlay: true,
|
|
254
|
+
overlayOptions: { anchor: "center" as const, width: OVERLAY_WIDTH, minWidth: OVERLAY_MIN_WIDTH, maxHeight: "85%", margin: 1 },
|
|
255
|
+
...(onHandle ? { onHandle } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
class MultiSelectList implements Component {
|
|
260
|
+
private selectedIndex = 0;
|
|
261
|
+
private checked = new Set<number>();
|
|
262
|
+
private commentEnabled = false;
|
|
263
|
+
private cachedWidth?: number;
|
|
264
|
+
private cachedLines?: string[];
|
|
265
|
+
|
|
266
|
+
public onCancel?: () => void;
|
|
267
|
+
public onSubmit?: (result: string[]) => void;
|
|
268
|
+
public onEnterFreeform?: () => void;
|
|
269
|
+
|
|
270
|
+
constructor(
|
|
271
|
+
private options: AskOption[],
|
|
272
|
+
private allowFreeform: boolean,
|
|
273
|
+
private allowComment: boolean,
|
|
274
|
+
private theme: Theme,
|
|
275
|
+
private keybindings: KeybindingsManager,
|
|
276
|
+
private commentToggle: ResolvedShortcut,
|
|
277
|
+
) {}
|
|
278
|
+
|
|
279
|
+
public isCommentEnabled(): boolean { return this.commentEnabled; }
|
|
280
|
+
invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; }
|
|
281
|
+
|
|
282
|
+
private getItemCount(): number { return this.options.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0); }
|
|
283
|
+
private getCommentToggleIndex(): number | null { return this.allowComment ? this.options.length : null; }
|
|
284
|
+
private getFreeformIndex(): number { return this.options.length + (this.allowComment ? 1 : 0); }
|
|
285
|
+
private isCommentToggleRow(index: number): boolean { const i = this.getCommentToggleIndex(); return i !== null && index === i; }
|
|
286
|
+
private isFreeformRow(index: number): boolean { return this.allowFreeform && index === this.getFreeformIndex(); }
|
|
287
|
+
|
|
288
|
+
private toggle(index: number): void {
|
|
289
|
+
if (index < 0 || index >= this.options.length) return;
|
|
290
|
+
if (this.checked.has(index)) this.checked.delete(index); else this.checked.add(index);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private toggleComment(): void {
|
|
294
|
+
if (!this.allowComment) return;
|
|
295
|
+
this.commentEnabled = !this.commentEnabled;
|
|
296
|
+
this.invalidate();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
handleInput(data: string): void {
|
|
300
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) { this.onCancel?.(); return; }
|
|
301
|
+
const count = this.getItemCount();
|
|
302
|
+
if (count === 0) { this.onCancel?.(); return; }
|
|
303
|
+
if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) { this.toggleComment(); return; }
|
|
304
|
+
|
|
305
|
+
if (matchesSelectUp(data, this.keybindings)) { this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1; this.invalidate(); return; }
|
|
306
|
+
if (matchesSelectDown(data, this.keybindings)) { this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1; this.invalidate(); return; }
|
|
307
|
+
|
|
308
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
309
|
+
if (numMatch) {
|
|
310
|
+
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
311
|
+
if (idx >= 0 && idx < this.options.length) { this.toggle(idx); this.selectedIndex = Math.min(idx, count - 1); this.invalidate(); }
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (matchesKey(data, Key.space)) {
|
|
316
|
+
if (this.isCommentToggleRow(this.selectedIndex)) { this.toggleComment(); return; }
|
|
317
|
+
if (this.isFreeformRow(this.selectedIndex)) { this.onEnterFreeform?.(); return; }
|
|
318
|
+
this.toggle(this.selectedIndex);
|
|
319
|
+
this.invalidate();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
324
|
+
if (this.isCommentToggleRow(this.selectedIndex)) { this.toggleComment(); return; }
|
|
325
|
+
if (this.isFreeformRow(this.selectedIndex)) { this.onEnterFreeform?.(); return; }
|
|
326
|
+
const selectedTitles = [...this.checked].sort((a, b) => a - b).map((i) => this.options[i]?.title).filter((t): t is string => !!t);
|
|
327
|
+
const fallback = this.options[this.selectedIndex]?.title;
|
|
328
|
+
const result = selectedTitles.length > 0 ? selectedTitles : fallback ? [fallback] : [];
|
|
329
|
+
if (result.length > 0) this.onSubmit?.(result); else this.onCancel?.();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
render(width: number): string[] {
|
|
334
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
335
|
+
const theme = this.theme;
|
|
336
|
+
const count = this.getItemCount();
|
|
337
|
+
const maxVisible = Math.min(count, 10);
|
|
338
|
+
if (count === 0) { this.cachedLines = [theme.fg("warning", "No options")]; this.cachedWidth = width; return this.cachedLines; }
|
|
339
|
+
|
|
340
|
+
const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));
|
|
341
|
+
const endIndex = Math.min(startIndex + maxVisible, count);
|
|
342
|
+
const lines: string[] = [];
|
|
343
|
+
|
|
344
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
345
|
+
const isSelected = i === this.selectedIndex;
|
|
346
|
+
const prefix = isSelected ? theme.fg("accent", "→") : " ";
|
|
347
|
+
|
|
348
|
+
if (this.isCommentToggleRow(i)) {
|
|
349
|
+
const checkbox = this.commentEnabled ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
350
|
+
const label = isSelected ? theme.fg("accent", theme.bold(COMMENT_TOGGLE_LABEL)) : theme.fg("text", theme.bold(COMMENT_TOGGLE_LABEL));
|
|
351
|
+
lines.push(truncateToWidth(`${prefix} ${checkbox} ${label}`, width, ""));
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (this.isFreeformRow(i)) {
|
|
355
|
+
const label = theme.fg("text", theme.bold("Type something."));
|
|
356
|
+
const desc = theme.fg("muted", "Enter a custom response");
|
|
357
|
+
lines.push(truncateToWidth(`${prefix} ${label} ${theme.fg("dim", "—")} ${desc}`, width, ""));
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const option = this.options[i];
|
|
361
|
+
if (!option) continue;
|
|
362
|
+
const checkbox = this.checked.has(i) ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
363
|
+
const num = theme.fg("dim", `${i + 1}.`);
|
|
364
|
+
const title = isSelected ? theme.fg("accent", theme.bold(option.title)) : theme.fg("text", theme.bold(option.title));
|
|
365
|
+
lines.push(truncateToWidth(`${prefix} ${num} ${checkbox} ${title}`, width, ""));
|
|
366
|
+
if (option.description) {
|
|
367
|
+
const indent = " ";
|
|
368
|
+
for (const w of wrapTextWithAnsi(option.description, Math.max(10, width - indent.length))) lines.push(truncateToWidth(indent + theme.fg("muted", w), width, ""));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (startIndex > 0 || endIndex < count) lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
|
|
373
|
+
this.cachedWidth = width;
|
|
374
|
+
this.cachedLines = lines;
|
|
375
|
+
return lines;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
class WrappedSingleSelectList implements Component {
|
|
380
|
+
private selectedIndex = 0;
|
|
381
|
+
private searchQuery = "";
|
|
382
|
+
private commentEnabled = false;
|
|
383
|
+
private maxVisibleRows = 12;
|
|
384
|
+
private cachedWidth?: number;
|
|
385
|
+
private cachedLines?: string[];
|
|
386
|
+
|
|
387
|
+
public onCancel?: () => void;
|
|
388
|
+
public onSubmit?: (result: string) => void;
|
|
389
|
+
public onEnterFreeform?: () => void;
|
|
390
|
+
|
|
391
|
+
constructor(
|
|
392
|
+
private options: AskOption[],
|
|
393
|
+
private allowFreeform: boolean,
|
|
394
|
+
private allowComment: boolean,
|
|
395
|
+
private theme: Theme,
|
|
396
|
+
private keybindings: KeybindingsManager,
|
|
397
|
+
private commentToggle: ResolvedShortcut,
|
|
398
|
+
) {}
|
|
399
|
+
|
|
400
|
+
public isCommentEnabled(): boolean { return this.commentEnabled; }
|
|
401
|
+
setMaxVisibleRows(rows: number): void {
|
|
402
|
+
const next = Math.max(1, Math.floor(rows));
|
|
403
|
+
if (next !== this.maxVisibleRows) { this.maxVisibleRows = next; this.invalidate(); }
|
|
404
|
+
}
|
|
405
|
+
invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; }
|
|
406
|
+
|
|
407
|
+
private getFilteredOptions(): AskOption[] {
|
|
408
|
+
return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
|
|
409
|
+
}
|
|
410
|
+
private getItemCount(filteredOptions: AskOption[]): number { return filteredOptions.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0); }
|
|
411
|
+
private isCommentToggleRow(index: number, filteredOptions: AskOption[]): boolean { return this.allowComment && index === filteredOptions.length; }
|
|
412
|
+
private isFreeformRow(index: number, filteredOptions: AskOption[]): boolean { return this.allowFreeform && index === filteredOptions.length + (this.allowComment ? 1 : 0); }
|
|
413
|
+
|
|
414
|
+
private toggleComment(): void {
|
|
415
|
+
if (!this.allowComment) return;
|
|
416
|
+
this.commentEnabled = !this.commentEnabled;
|
|
417
|
+
this.invalidate();
|
|
418
|
+
}
|
|
419
|
+
private setSearchQuery(query: string): void { this.searchQuery = query; this.selectedIndex = 0; this.invalidate(); }
|
|
420
|
+
private popSearchCharacter(): void {
|
|
421
|
+
if (!this.searchQuery) return;
|
|
422
|
+
const characters = [...this.searchQuery];
|
|
423
|
+
characters.pop();
|
|
424
|
+
this.setSearchQuery(characters.join(""));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private getPrintableInput(data: string): string | null {
|
|
428
|
+
const kittyPrintable = decodeKittyPrintable(data);
|
|
429
|
+
if (kittyPrintable !== undefined) return kittyPrintable;
|
|
430
|
+
const characters = [...data];
|
|
431
|
+
if (characters.length !== 1) return null;
|
|
432
|
+
const [character] = characters;
|
|
433
|
+
if (!character) return null;
|
|
434
|
+
const code = character.charCodeAt(0);
|
|
435
|
+
if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return null;
|
|
436
|
+
return character;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private styleListLine(line: string, width: number, isSelected: boolean): string {
|
|
440
|
+
const trimmed = line.trim();
|
|
441
|
+
if (trimmed.startsWith("(")) return truncateToWidth(this.theme.fg("dim", line), width, "");
|
|
442
|
+
if (isSelected) return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
|
|
443
|
+
if (line.startsWith(" ")) return truncateToWidth(this.theme.fg("muted", line), width, "");
|
|
444
|
+
if (line.startsWith("→")) return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
|
|
445
|
+
return truncateToWidth(this.theme.fg("text", line), width, "");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private getSplitPaneWidths(width: number): { left: number; right: number } | null {
|
|
449
|
+
if (width < SPLIT_PANE_MIN_WIDTH) return null;
|
|
450
|
+
const availableWidth = width - SPLIT_PANE_SEPARATOR.length;
|
|
451
|
+
if (availableWidth < SPLIT_PANE_LEFT_MIN_WIDTH + SPLIT_PANE_RIGHT_MIN_WIDTH) return null;
|
|
452
|
+
const preferredLeftWidth = Math.floor(availableWidth * 0.42);
|
|
453
|
+
const left = Math.max(SPLIT_PANE_LEFT_MIN_WIDTH, Math.min(preferredLeftWidth, availableWidth - SPLIT_PANE_RIGHT_MIN_WIDTH));
|
|
454
|
+
const right = availableWidth - left;
|
|
455
|
+
return right < SPLIT_PANE_RIGHT_MIN_WIDTH ? null : { left, right };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private buildListLines(width: number, filteredOptions: AskOption[], hideDescriptions = false): string[] {
|
|
459
|
+
const lines: string[] = [];
|
|
460
|
+
const count = this.getItemCount(filteredOptions);
|
|
461
|
+
const searchValue = this.searchQuery ? this.theme.fg("text", this.searchQuery) : this.theme.fg("dim", "type to filter");
|
|
462
|
+
lines.push(truncateToWidth(`${this.theme.fg("accent", "Filter:")} ${searchValue}`, width, ""));
|
|
463
|
+
if (this.searchQuery && filteredOptions.length === 0) lines.push(truncateToWidth(this.theme.fg("warning", "No matching options"), width, ""));
|
|
464
|
+
if (count === 0) {
|
|
465
|
+
if (!this.searchQuery) lines.push(truncateToWidth(this.theme.fg("warning", "No options"), width, ""));
|
|
466
|
+
return lines.slice(0, this.maxVisibleRows);
|
|
467
|
+
}
|
|
468
|
+
const maxRows = Math.max(1, this.maxVisibleRows - lines.length);
|
|
469
|
+
const optionRows = renderSingleSelectRows({
|
|
470
|
+
options: filteredOptions, selectedIndex: this.selectedIndex, width, allowFreeform: this.allowFreeform,
|
|
471
|
+
allowComment: this.allowComment, commentEnabled: this.commentEnabled, maxRows, hideDescriptions,
|
|
472
|
+
});
|
|
473
|
+
lines.push(...optionRows.map((row) => this.styleListLine(row.line, width, row.selected)));
|
|
474
|
+
return lines.slice(0, this.maxVisibleRows);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private buildPreviewLines(width: number, filteredOptions: AskOption[], maxLines: number): string[] {
|
|
478
|
+
if (maxLines <= 0) return [];
|
|
479
|
+
const mdTheme = safeMarkdownTheme();
|
|
480
|
+
let md = "";
|
|
481
|
+
if (this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
482
|
+
md += "## Additional context\n\n";
|
|
483
|
+
md += `Currently: **${this.commentEnabled ? "Enabled" : "Disabled"}**\n\n`;
|
|
484
|
+
md += "Turn this on when the selected option needs extra explanation before it submits.\n";
|
|
485
|
+
} else if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
486
|
+
md += "## Custom answer\n\nOpen the editor to write **any** answer.\n\n*Use this when none of the listed options fit.*\n";
|
|
487
|
+
if (this.searchQuery) md += `\n> Current filter: \`${this.searchQuery}\`\n`;
|
|
488
|
+
} else {
|
|
489
|
+
const selected = filteredOptions[this.selectedIndex];
|
|
490
|
+
if (!selected) {
|
|
491
|
+
md += "*No option selected*\n";
|
|
492
|
+
} else {
|
|
493
|
+
md += `## ${selected.title}\n\n`;
|
|
494
|
+
md += selected.description?.trim() ? `${selected.description}\n` : "*No additional details provided for this option.*\n";
|
|
495
|
+
md += "\n---\n\nPress `Enter` to select this option.\n";
|
|
496
|
+
if (this.searchQuery) md += `\n> Filter: \`${this.searchQuery}\`\n`;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
let lines: string[];
|
|
501
|
+
if (mdTheme) {
|
|
502
|
+
lines = new Markdown(md.trim(), 0, 0, mdTheme).render(width);
|
|
503
|
+
} else {
|
|
504
|
+
lines = wrapTextWithAnsi(md.trim(), Math.max(10, width)).map((line) => truncateToWidth(line, width, ""));
|
|
505
|
+
}
|
|
506
|
+
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
|
|
507
|
+
if (lines.length <= maxLines) return lines;
|
|
508
|
+
if (maxLines === 1) return [truncateToWidth(this.theme.fg("dim", "…"), width, "")];
|
|
509
|
+
const visibleLines = lines.slice(0, maxLines - 1);
|
|
510
|
+
visibleLines.push(truncateToWidth(this.theme.fg("dim", "…"), width, ""));
|
|
511
|
+
return visibleLines;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
handleInput(data: string): void {
|
|
515
|
+
if (this.searchQuery && matchesKey(data, Key.escape)) { this.setSearchQuery(""); return; }
|
|
516
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) { this.onCancel?.(); return; }
|
|
517
|
+
if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) { this.toggleComment(); return; }
|
|
518
|
+
|
|
519
|
+
const filteredOptions = this.getFilteredOptions();
|
|
520
|
+
const count = this.getItemCount(filteredOptions);
|
|
521
|
+
|
|
522
|
+
if (matchesSelectUp(data, this.keybindings) && count > 0) { this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1; this.invalidate(); return; }
|
|
523
|
+
if (matchesSelectDown(data, this.keybindings) && count > 0) { this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1; this.invalidate(); return; }
|
|
524
|
+
|
|
525
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
526
|
+
if (numMatch && filteredOptions.length > 0) {
|
|
527
|
+
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
528
|
+
if (idx >= 0 && idx < filteredOptions.length) { this.selectedIndex = idx; this.invalidate(); return; }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (matchesKey(data, Key.space) && count > 0 && this.isCommentToggleRow(this.selectedIndex, filteredOptions)) { this.toggleComment(); return; }
|
|
532
|
+
|
|
533
|
+
if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
|
|
534
|
+
if (this.isCommentToggleRow(this.selectedIndex, filteredOptions)) { this.toggleComment(); return; }
|
|
535
|
+
if (this.isFreeformRow(this.selectedIndex, filteredOptions)) { this.onEnterFreeform?.(); return; }
|
|
536
|
+
const result = filteredOptions[this.selectedIndex]?.title;
|
|
537
|
+
if (result) this.onSubmit?.(result); else this.onCancel?.();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, Key.backspace)) { this.popSearchCharacter(); return; }
|
|
542
|
+
|
|
543
|
+
const printableInput = this.getPrintableInput(data);
|
|
544
|
+
if (printableInput) this.setSearchQuery(this.searchQuery + printableInput);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
render(width: number): string[] {
|
|
548
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
549
|
+
const filteredOptions = this.getFilteredOptions();
|
|
550
|
+
const count = this.getItemCount(filteredOptions);
|
|
551
|
+
this.selectedIndex = count > 0 ? Math.max(0, Math.min(this.selectedIndex, count - 1)) : 0;
|
|
552
|
+
|
|
553
|
+
const splitPane = this.getSplitPaneWidths(width);
|
|
554
|
+
let lines: string[];
|
|
555
|
+
if (!splitPane) {
|
|
556
|
+
lines = this.buildListLines(width, filteredOptions);
|
|
557
|
+
} else {
|
|
558
|
+
const listLines = this.buildListLines(splitPane.left, filteredOptions, true);
|
|
559
|
+
const previewLines = this.buildPreviewLines(splitPane.right, filteredOptions, this.maxVisibleRows);
|
|
560
|
+
const rowCount = Math.min(this.maxVisibleRows, Math.max(listLines.length, previewLines.length));
|
|
561
|
+
const separator = this.theme.fg("dim", SPLIT_PANE_SEPARATOR);
|
|
562
|
+
lines = Array.from({ length: rowCount }, (_, index) => `${truncateToWidth(listLines[index] ?? "", splitPane.left, "", true)}${separator}${truncateToWidth(previewLines[index] ?? "", splitPane.right, "")}`);
|
|
563
|
+
}
|
|
564
|
+
this.cachedWidth = width;
|
|
565
|
+
this.cachedLines = lines;
|
|
566
|
+
return lines;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
interface ResolvedAskShortcuts {
|
|
571
|
+
overlayToggle: ResolvedShortcut;
|
|
572
|
+
commentToggle: ResolvedShortcut;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Root Container: swaps between select (single/multi) and an Editor (freeform/comment). */
|
|
576
|
+
class AskComponent extends Container {
|
|
577
|
+
private mode: AskMode = "select";
|
|
578
|
+
private pendingSelections: string[] = [];
|
|
579
|
+
private freeformDraft = "";
|
|
580
|
+
private commentDraft = "";
|
|
581
|
+
private promptScrollOffset = 0;
|
|
582
|
+
private promptMaxScrollOffset = 0;
|
|
583
|
+
private promptViewportRows = 0;
|
|
584
|
+
|
|
585
|
+
private titleText: Text;
|
|
586
|
+
private questionText: Text;
|
|
587
|
+
private contextComponent?: Component;
|
|
588
|
+
private modeContainer: Container;
|
|
589
|
+
private helpText: Text;
|
|
590
|
+
|
|
591
|
+
private singleSelectList?: WrappedSingleSelectList;
|
|
592
|
+
private multiSelectList?: MultiSelectList;
|
|
593
|
+
private editor?: Editor;
|
|
594
|
+
|
|
595
|
+
private _focused = false;
|
|
596
|
+
get focused(): boolean { return this._focused; }
|
|
597
|
+
set focused(value: boolean) {
|
|
598
|
+
this._focused = value;
|
|
599
|
+
if (this.editor && (this.mode === "freeform" || this.mode === "comment")) (this.editor as any).focused = value;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
constructor(
|
|
603
|
+
private question: string,
|
|
604
|
+
private context: string | undefined,
|
|
605
|
+
private options: AskOption[],
|
|
606
|
+
private allowMultiple: boolean,
|
|
607
|
+
private allowFreeform: boolean,
|
|
608
|
+
private allowComment: boolean,
|
|
609
|
+
private displayMode: AskDisplayMode,
|
|
610
|
+
private tui: TUI,
|
|
611
|
+
private theme: Theme,
|
|
612
|
+
private keybindings: KeybindingsManager,
|
|
613
|
+
private shortcuts: ResolvedAskShortcuts,
|
|
614
|
+
private onDone: (result: AskResponse | null) => void,
|
|
615
|
+
) {
|
|
616
|
+
super();
|
|
617
|
+
this.addChild(new BoxBorderTop((s) => theme.fg("accent", s), "discuss", (s) => theme.fg("dim", theme.bold(s))));
|
|
618
|
+
this.addChild(new Spacer(1));
|
|
619
|
+
this.titleText = new Text("", 1, 0);
|
|
620
|
+
this.addChild(this.titleText);
|
|
621
|
+
this.addChild(new Spacer(1));
|
|
622
|
+
this.questionText = new Text("", 1, 0);
|
|
623
|
+
this.addChild(this.questionText);
|
|
624
|
+
|
|
625
|
+
if (this.context) {
|
|
626
|
+
this.addChild(new Spacer(1));
|
|
627
|
+
const mdTheme = safeMarkdownTheme();
|
|
628
|
+
this.contextComponent = mdTheme ? new Markdown("", 1, 0, mdTheme) : new Text("", 1, 0);
|
|
629
|
+
this.addChild(this.contextComponent);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
this.addChild(new Spacer(1));
|
|
633
|
+
this.modeContainer = new Container();
|
|
634
|
+
this.addChild(this.modeContainer);
|
|
635
|
+
this.addChild(new Spacer(1));
|
|
636
|
+
this.helpText = new Text("", 1, 0);
|
|
637
|
+
this.addChild(this.helpText);
|
|
638
|
+
this.addChild(new Spacer(1));
|
|
639
|
+
this.addChild(new BoxBorderBottom((s) => theme.fg("accent", s)));
|
|
640
|
+
|
|
641
|
+
this.updateStaticText();
|
|
642
|
+
this.showSelectMode();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
override invalidate(): void { super.invalidate(); this.updateStaticText(); this.updateHelpText(); }
|
|
646
|
+
|
|
647
|
+
override render(width: number): string[] {
|
|
648
|
+
const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
|
|
649
|
+
if (this.displayMode === "overlay") return this.renderOverlayLayout(width, innerWidth);
|
|
650
|
+
if (this.mode === "select" && !this.allowMultiple) this.ensureSingleSelectList().setMaxVisibleRows(12);
|
|
651
|
+
return this.frameRawLines(super.render(innerWidth), width, innerWidth);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private getOverlayMaxRenderLines(): number {
|
|
655
|
+
const rows = Number.isFinite(this.tui.terminal.rows) ? Math.floor(this.tui.terminal.rows) : 24;
|
|
656
|
+
return getOverlayMaxRenderLinesForRows(rows);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private renderOverlayLayout(width: number, innerWidth: number): string[] {
|
|
660
|
+
const maxLines = this.getOverlayMaxRenderLines();
|
|
661
|
+
if (maxLines <= 1) return [this.renderTopBorder(width)];
|
|
662
|
+
if (maxLines === 2) return [this.renderTopBorder(width), this.renderBottomBorder(width)];
|
|
663
|
+
|
|
664
|
+
const bodyCapacity = Math.max(0, maxLines - 2);
|
|
665
|
+
const promptLines = this.buildPromptLines(innerWidth);
|
|
666
|
+
const helpFullLines = this.helpText.render(innerWidth);
|
|
667
|
+
const helpBudget = this.getOverlayHelpBudget(bodyCapacity, helpFullLines.length);
|
|
668
|
+
const contentRows = Math.max(0, bodyCapacity - helpBudget);
|
|
669
|
+
|
|
670
|
+
let promptBudget = 0;
|
|
671
|
+
let modeBudget = 0;
|
|
672
|
+
let separatorRows = 0;
|
|
673
|
+
|
|
674
|
+
if (this.mode === "select") {
|
|
675
|
+
separatorRows = contentRows >= 4 ? 1 : 0;
|
|
676
|
+
const promptAndModeRows = Math.max(0, contentRows - separatorRows);
|
|
677
|
+
promptBudget = promptAndModeRows;
|
|
678
|
+
if (promptAndModeRows > 0) {
|
|
679
|
+
const promptMinRows = promptLines.length > 0 ? 1 : 0;
|
|
680
|
+
const maximumModeRows = Math.max(0, promptAndModeRows - promptMinRows);
|
|
681
|
+
const modeMinRows = Math.min(this.getMinimumModeRows(), maximumModeRows);
|
|
682
|
+
modeBudget = Math.min(this.getPreferredModeRows(), maximumModeRows);
|
|
683
|
+
modeBudget = Math.max(modeMinRows, modeBudget);
|
|
684
|
+
promptBudget = promptAndModeRows - modeBudget;
|
|
685
|
+
const usefulPromptRows = Math.min(promptLines.length, promptAndModeRows >= modeMinRows + 2 ? 2 : promptMinRows);
|
|
686
|
+
if (promptBudget < usefulPromptRows && modeBudget > modeMinRows) {
|
|
687
|
+
const shiftedRows = Math.min(usefulPromptRows - promptBudget, modeBudget - modeMinRows);
|
|
688
|
+
modeBudget -= shiftedRows;
|
|
689
|
+
promptBudget += shiftedRows;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
modeBudget = Math.min(this.getPreferredModeRows(), contentRows);
|
|
694
|
+
modeBudget = Math.max(Math.min(this.getMinimumModeRows(), contentRows), modeBudget);
|
|
695
|
+
promptBudget = Math.max(0, contentRows - modeBudget);
|
|
696
|
+
if (promptBudget > 0 && modeBudget > 0) { separatorRows = 1; promptBudget = Math.max(0, promptBudget - separatorRows); }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const modeLines = this.renderModeLines(innerWidth, modeBudget);
|
|
700
|
+
if (modeLines.length < modeBudget) promptBudget += modeBudget - modeLines.length;
|
|
701
|
+
|
|
702
|
+
const promptPaneLines = this.renderPromptPane(promptLines, promptBudget, innerWidth);
|
|
703
|
+
const helpLines = this.limitLines(helpFullLines, helpBudget, innerWidth, false);
|
|
704
|
+
const bodyLines = [
|
|
705
|
+
...promptPaneLines,
|
|
706
|
+
...(separatorRows > 0 && promptPaneLines.length > 0 && modeLines.length > 0 ? [""] : []),
|
|
707
|
+
...modeLines,
|
|
708
|
+
...helpLines,
|
|
709
|
+
];
|
|
710
|
+
return this.frameBodyLines(bodyLines.slice(0, bodyCapacity), width, innerWidth);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private buildPromptLines(width: number): string[] {
|
|
714
|
+
return [...this.titleText.render(width), ...this.questionText.render(width), ...(this.contextComponent ? ["", ...this.contextComponent.render(width)] : [])];
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private getOverlayHelpBudget(bodyCapacity: number, renderedHelpRows: number): number {
|
|
718
|
+
if (renderedHelpRows <= 0 || bodyCapacity <= 0) return 0;
|
|
719
|
+
return bodyCapacity >= 12 ? Math.min(2, renderedHelpRows) : 1;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private getMinimumModeRows(): number {
|
|
723
|
+
if (this.mode === "freeform") return 5;
|
|
724
|
+
if (this.mode === "comment") return 6;
|
|
725
|
+
return this.allowMultiple ? 3 : 4;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private getPreferredModeRows(): number {
|
|
729
|
+
if (this.mode === "freeform") return 10;
|
|
730
|
+
if (this.mode === "comment") return 11;
|
|
731
|
+
return 8;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private renderModeLines(width: number, budget: number): string[] {
|
|
735
|
+
const safeBudget = Math.max(0, Math.floor(budget));
|
|
736
|
+
if (safeBudget <= 0) return [];
|
|
737
|
+
if (this.mode === "select") {
|
|
738
|
+
if (!this.allowMultiple) this.ensureSingleSelectList().setMaxVisibleRows(Math.max(1, safeBudget));
|
|
739
|
+
return this.limitLines(this.modeContainer.render(width), safeBudget, width, true);
|
|
740
|
+
}
|
|
741
|
+
return this.renderEditorModeLines(width, safeBudget);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private renderEditorModeLines(width: number, budget: number): string[] {
|
|
745
|
+
const headerLines = this.buildEditorModeHeaderLines(width);
|
|
746
|
+
const minimumEditorRows = Math.min(3, budget);
|
|
747
|
+
const headerBudget = Math.max(0, budget - minimumEditorRows);
|
|
748
|
+
const visibleHeaderLines = this.limitLines(headerLines, headerBudget, width, true);
|
|
749
|
+
const editorBudget = Math.max(0, budget - visibleHeaderLines.length);
|
|
750
|
+
return [...visibleHeaderLines, ...this.limitEditorLines(this.ensureEditor().render(width), editorBudget, width)];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private buildEditorModeHeaderLines(width: number): string[] {
|
|
754
|
+
if (this.mode === "comment") {
|
|
755
|
+
const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
|
|
756
|
+
return [
|
|
757
|
+
...new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0).render(width),
|
|
758
|
+
...new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0).render(width),
|
|
759
|
+
"",
|
|
760
|
+
];
|
|
761
|
+
}
|
|
762
|
+
return [...new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0).render(width), ""];
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private limitEditorLines(lines: string[], budget: number, width: number): string[] {
|
|
766
|
+
const safeBudget = Math.max(0, Math.floor(budget));
|
|
767
|
+
if (safeBudget <= 0) return [];
|
|
768
|
+
if (lines.length <= safeBudget) return lines.map((line) => truncateToWidth(line, width, "", true));
|
|
769
|
+
if (safeBudget === 1) return [this.theme.fg("dim", "…")];
|
|
770
|
+
|
|
771
|
+
const topBorder = truncateToWidth(lines[0] ?? "", width, "", true);
|
|
772
|
+
const bottomBorder = truncateToWidth(lines[lines.length - 1] ?? "", width, "", true);
|
|
773
|
+
if (safeBudget === 2) return [topBorder, bottomBorder];
|
|
774
|
+
|
|
775
|
+
const contentLines = lines.slice(1, -1);
|
|
776
|
+
const contentBudget = safeBudget - 2;
|
|
777
|
+
const cursorLineIndex = contentLines.findIndex((line) => line.includes(CURSOR_MARKER) || line.includes("\x1b[7m"));
|
|
778
|
+
const maxStart = Math.max(0, contentLines.length - contentBudget);
|
|
779
|
+
const start = cursorLineIndex >= 0 ? Math.max(0, Math.min(cursorLineIndex - contentBudget + 1, maxStart)) : maxStart;
|
|
780
|
+
const visibleContentLines = contentLines.slice(start, start + contentBudget);
|
|
781
|
+
const markedContentLines = this.applyPromptOverflowMarkers(visibleContentLines, width, start > 0, start + contentBudget < contentLines.length);
|
|
782
|
+
return [topBorder, ...markedContentLines, bottomBorder];
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private renderPromptPane(promptLines: string[], budget: number, width: number): string[] {
|
|
786
|
+
const viewportRows = Math.max(0, Math.floor(budget));
|
|
787
|
+
this.promptViewportRows = viewportRows;
|
|
788
|
+
if (viewportRows <= 0 || promptLines.length === 0) { this.promptMaxScrollOffset = 0; this.promptScrollOffset = 0; return []; }
|
|
789
|
+
this.promptMaxScrollOffset = Math.max(0, promptLines.length - viewportRows);
|
|
790
|
+
this.promptScrollOffset = Math.max(0, Math.min(this.promptScrollOffset, this.promptMaxScrollOffset));
|
|
791
|
+
const visibleLines = promptLines.slice(this.promptScrollOffset, this.promptScrollOffset + viewportRows);
|
|
792
|
+
return this.applyPromptOverflowMarkers(visibleLines, width, this.promptScrollOffset > 0, this.promptScrollOffset + viewportRows < promptLines.length);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private applyPromptOverflowMarkers(lines: string[], width: number, hasHiddenAbove: boolean, hasHiddenBelow: boolean): string[] {
|
|
796
|
+
if (lines.length === 0) return lines;
|
|
797
|
+
const marked = [...lines];
|
|
798
|
+
if (hasHiddenAbove && hasHiddenBelow && marked.length === 1) { marked[0] = this.addPromptOverflowMarker(marked[0] ?? "", "↕", width); return marked; }
|
|
799
|
+
if (hasHiddenAbove) marked[0] = this.addPromptOverflowMarker(marked[0] ?? "", "↑", width);
|
|
800
|
+
if (hasHiddenBelow) { const lastIndex = marked.length - 1; marked[lastIndex] = this.addPromptOverflowMarker(marked[lastIndex] ?? "", "↓", width); }
|
|
801
|
+
return marked;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private addPromptOverflowMarker(line: string, marker: string, width: number): string {
|
|
805
|
+
return truncateToWidth(`${this.theme.fg("dim", marker)} ${line}`, width, "", true);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private limitLines(lines: string[], budget: number, width: number, showOverflowMarker: boolean): string[] {
|
|
809
|
+
const safeBudget = Math.max(0, Math.floor(budget));
|
|
810
|
+
if (safeBudget <= 0) return [];
|
|
811
|
+
if (lines.length <= safeBudget) return lines.map((line) => truncateToWidth(line, width, "", true));
|
|
812
|
+
if (!showOverflowMarker) return lines.slice(0, safeBudget).map((line) => truncateToWidth(line, width, "", true));
|
|
813
|
+
if (safeBudget === 1) return [this.theme.fg("dim", "…")];
|
|
814
|
+
return [...lines.slice(0, safeBudget - 1).map((line) => truncateToWidth(line, width, "", true)), this.theme.fg("dim", "…")];
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private renderTopBorder(width: number): string {
|
|
818
|
+
return new BoxBorderTop((s) => this.theme.fg("accent", s), "discuss", (s) => this.theme.fg("dim", this.theme.bold(s))).render(width)[0] ?? "";
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private renderBottomBorder(width: number): string {
|
|
822
|
+
return new BoxBorderBottom((s) => this.theme.fg("accent", s)).render(width)[0] ?? "";
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private frameBodyLines(bodyLines: string[], width: number, innerWidth: number): string[] {
|
|
826
|
+
const borderColor = (s: string) => this.theme.fg("accent", s);
|
|
827
|
+
return [
|
|
828
|
+
this.renderTopBorder(width),
|
|
829
|
+
...bodyLines.map((line) => `${borderColor(BOX_BORDER_LEFT)}${truncateToWidth(line, innerWidth, "", true)}${borderColor(BOX_BORDER_RIGHT)}`),
|
|
830
|
+
this.renderBottomBorder(width),
|
|
831
|
+
];
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private frameRawLines(rawLines: string[], width: number, innerWidth: number): string[] {
|
|
835
|
+
const borderColor = (s: string) => this.theme.fg("accent", s);
|
|
836
|
+
return rawLines.map((line, index) => {
|
|
837
|
+
if (index === 0) return this.renderTopBorder(width);
|
|
838
|
+
if (index === rawLines.length - 1) return this.renderBottomBorder(width);
|
|
839
|
+
return `${borderColor(BOX_BORDER_LEFT)}${truncateToWidth(line, innerWidth, "", true)}${borderColor(BOX_BORDER_RIGHT)}`;
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private updateStaticText(): void {
|
|
844
|
+
const theme = this.theme;
|
|
845
|
+
this.titleText.setText(theme.fg("accent", theme.bold(this.mode === "comment" ? "Optional comment" : "Question")));
|
|
846
|
+
this.questionText.setText(theme.fg("text", theme.bold(this.question)));
|
|
847
|
+
if (this.contextComponent && this.context) {
|
|
848
|
+
if (this.contextComponent instanceof Markdown) (this.contextComponent as Markdown).setText(`**Context:**\n${this.context}`);
|
|
849
|
+
else (this.contextComponent as Text).setText(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
private updateHelpText(): void {
|
|
854
|
+
const theme = this.theme;
|
|
855
|
+
const overlayHint = this.displayMode === "overlay" && !this.shortcuts.overlayToggle.disabled ? literalHint(theme, this.shortcuts.overlayToggle.spec, "hide") : null;
|
|
856
|
+
const promptScrollHint = this.displayMode === "overlay" ? literalHint(theme, "PgUp/PgDn", "prompt") : null;
|
|
857
|
+
const commentHint = this.allowComment && !this.shortcuts.commentToggle.disabled ? literalHint(theme, this.shortcuts.commentToggle.spec, "toggle context") : null;
|
|
858
|
+
|
|
859
|
+
if (this.mode === "freeform" || this.mode === "comment") {
|
|
860
|
+
const alternateCancelKeys = this.keybindings.getKeys("tui.select.cancel").filter((key) => key !== "escape" && key !== "esc");
|
|
861
|
+
const hints = [
|
|
862
|
+
keybindingHint(theme, this.keybindings, "tui.input.submit", this.mode === "comment" ? "submit/skip" : "submit"),
|
|
863
|
+
keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
|
|
864
|
+
literalHint(theme, "esc", "back"),
|
|
865
|
+
overlayHint,
|
|
866
|
+
alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
|
|
867
|
+
].filter((hint): hint is string => !!hint).join(" • ");
|
|
868
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (this.allowMultiple) {
|
|
873
|
+
const hints = [
|
|
874
|
+
literalHint(theme, "↑↓", "navigate"), literalHint(theme, "space", "toggle"), commentHint, promptScrollHint, overlayHint,
|
|
875
|
+
keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
|
|
876
|
+
keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
|
|
877
|
+
].filter((hint): hint is string => !!hint).join(" • ");
|
|
878
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
879
|
+
} else {
|
|
880
|
+
const alternateCancelKeys = this.keybindings.getKeys("tui.select.cancel").filter((key) => key !== "escape" && key !== "esc");
|
|
881
|
+
const hints = [
|
|
882
|
+
literalHint(theme, "type", "filter"), commentHint, promptScrollHint,
|
|
883
|
+
keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
|
|
884
|
+
literalHint(theme, "↑↓", "navigate"), overlayHint,
|
|
885
|
+
keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
|
|
886
|
+
literalHint(theme, "esc", "clear/cancel"),
|
|
887
|
+
alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
|
|
888
|
+
].filter((hint): hint is string => !!hint).join(" • ");
|
|
889
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
894
|
+
if (this.singleSelectList) return this.singleSelectList;
|
|
895
|
+
const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.allowComment, this.theme, this.keybindings, this.shortcuts.commentToggle);
|
|
896
|
+
list.onSubmit = (result) => this.handleSelectionSubmit([result], list.isCommentEnabled());
|
|
897
|
+
list.onCancel = () => this.onDone(null);
|
|
898
|
+
list.onEnterFreeform = () => this.showFreeformMode();
|
|
899
|
+
this.singleSelectList = list;
|
|
900
|
+
return list;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private ensureMultiSelectList(): MultiSelectList {
|
|
904
|
+
if (this.multiSelectList) return this.multiSelectList;
|
|
905
|
+
const list = new MultiSelectList(this.options, this.allowFreeform, this.allowComment, this.theme, this.keybindings, this.shortcuts.commentToggle);
|
|
906
|
+
list.onCancel = () => this.onDone(null);
|
|
907
|
+
list.onSubmit = (result) => this.handleSelectionSubmit(result, list.isCommentEnabled());
|
|
908
|
+
list.onEnterFreeform = () => this.showFreeformMode();
|
|
909
|
+
this.multiSelectList = list;
|
|
910
|
+
return list;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private ensureEditor(): Editor {
|
|
914
|
+
if (this.editor) return this.editor;
|
|
915
|
+
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
916
|
+
editor.disableSubmit = false;
|
|
917
|
+
editor.onSubmit = (text: string) => this.handleEditorSubmit(text);
|
|
918
|
+
this.editor = editor;
|
|
919
|
+
return editor;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private saveEditorDraft(): void {
|
|
923
|
+
if (!this.editor) return;
|
|
924
|
+
const getText = (this.editor as any).getText;
|
|
925
|
+
if (typeof getText !== "function") return;
|
|
926
|
+
const currentText = String(getText.call(this.editor) ?? "");
|
|
927
|
+
if (this.mode === "freeform") this.freeformDraft = currentText;
|
|
928
|
+
else if (this.mode === "comment") this.commentDraft = currentText;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
private setEditorText(text: string): void {
|
|
932
|
+
const editor = this.ensureEditor();
|
|
933
|
+
const setText = (editor as any).setText;
|
|
934
|
+
if (typeof setText === "function") setText.call(editor, text);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private handleSelectionSubmit(selections: string[], wantsComment: boolean): void {
|
|
938
|
+
if (this.allowComment && wantsComment) { this.pendingSelections = selections; this.commentDraft = ""; this.showCommentMode(); return; }
|
|
939
|
+
this.onDone(createSelectionResponse(selections));
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private handleEditorSubmit(text: string): void {
|
|
943
|
+
if (this.mode === "freeform") { this.onDone(createFreeformResponse(text)); return; }
|
|
944
|
+
if (this.mode === "comment") { this.commentDraft = text; this.onDone(createSelectionResponse(this.pendingSelections, text)); }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private showSelectMode(): void {
|
|
948
|
+
if (this.mode === "freeform" || this.mode === "comment") this.saveEditorDraft();
|
|
949
|
+
this.mode = "select";
|
|
950
|
+
this.pendingSelections = [];
|
|
951
|
+
this.modeContainer.clear();
|
|
952
|
+
this.modeContainer.addChild(this.allowMultiple ? this.ensureMultiSelectList() : this.ensureSingleSelectList());
|
|
953
|
+
this.updateHelpText();
|
|
954
|
+
this.invalidate();
|
|
955
|
+
this.tui.requestRender();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private showFreeformMode(): void {
|
|
959
|
+
if (this.mode === "comment") this.saveEditorDraft();
|
|
960
|
+
this.mode = "freeform";
|
|
961
|
+
this.modeContainer.clear();
|
|
962
|
+
const editor = this.ensureEditor();
|
|
963
|
+
this.setEditorText(this.freeformDraft);
|
|
964
|
+
(editor as any).focused = this._focused;
|
|
965
|
+
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0));
|
|
966
|
+
this.modeContainer.addChild(new Spacer(1));
|
|
967
|
+
this.modeContainer.addChild(editor);
|
|
968
|
+
this.updateHelpText();
|
|
969
|
+
this.invalidate();
|
|
970
|
+
this.tui.requestRender();
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private showCommentMode(): void {
|
|
974
|
+
if (this.mode === "freeform") this.saveEditorDraft();
|
|
975
|
+
this.mode = "comment";
|
|
976
|
+
this.modeContainer.clear();
|
|
977
|
+
const editor = this.ensureEditor();
|
|
978
|
+
this.setEditorText(this.commentDraft);
|
|
979
|
+
(editor as any).focused = this._focused;
|
|
980
|
+
const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
|
|
981
|
+
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0));
|
|
982
|
+
this.modeContainer.addChild(new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0));
|
|
983
|
+
this.modeContainer.addChild(new Spacer(1));
|
|
984
|
+
this.modeContainer.addChild(editor);
|
|
985
|
+
this.updateHelpText();
|
|
986
|
+
this.invalidate();
|
|
987
|
+
this.tui.requestRender();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
private setPromptScrollOffset(nextOffset: number): boolean {
|
|
991
|
+
if (this.displayMode !== "overlay" || this.promptMaxScrollOffset <= 0) return false;
|
|
992
|
+
const clamped = Math.max(0, Math.min(Math.floor(nextOffset), this.promptMaxScrollOffset));
|
|
993
|
+
const changed = clamped !== this.promptScrollOffset;
|
|
994
|
+
this.promptScrollOffset = clamped;
|
|
995
|
+
return changed;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
private handlePromptScrollInput(data: string): boolean {
|
|
999
|
+
if (this.displayMode !== "overlay" || this.promptMaxScrollOffset <= 0) return false;
|
|
1000
|
+
if (this.mode !== "select") return false;
|
|
1001
|
+
const pageRows = Math.max(1, this.promptViewportRows - 1);
|
|
1002
|
+
const halfPageRows = Math.max(1, Math.floor(this.promptViewportRows / 2));
|
|
1003
|
+
if (matchesKey(data, PROMPT_SCROLL_PAGE_UP_KEY)) { this.setPromptScrollOffset(this.promptScrollOffset - pageRows); return true; }
|
|
1004
|
+
if (matchesKey(data, PROMPT_SCROLL_PAGE_DOWN_KEY)) { this.setPromptScrollOffset(this.promptScrollOffset + pageRows); return true; }
|
|
1005
|
+
if (matchesKey(data, PROMPT_SCROLL_HOME_KEY)) { this.setPromptScrollOffset(0); return true; }
|
|
1006
|
+
if (matchesKey(data, PROMPT_SCROLL_END_KEY)) { this.setPromptScrollOffset(this.promptMaxScrollOffset); return true; }
|
|
1007
|
+
if (matchesKey(data, PROMPT_SCROLL_HALF_PAGE_UP_KEY)) { this.setPromptScrollOffset(this.promptScrollOffset - halfPageRows); return true; }
|
|
1008
|
+
if (matchesKey(data, PROMPT_SCROLL_HALF_PAGE_DOWN_KEY)) { this.setPromptScrollOffset(this.promptScrollOffset + halfPageRows); return true; }
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
handleInput(data: string): void {
|
|
1013
|
+
if (this.handlePromptScrollInput(data)) { this.tui.requestRender(); return; }
|
|
1014
|
+
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1015
|
+
if (matchesKey(data, Key.escape)) { this.showSelectMode(); return; }
|
|
1016
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) { this.onDone(null); return; }
|
|
1017
|
+
this.ensureEditor().handleInput(data);
|
|
1018
|
+
this.tui.requestRender();
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (this.allowMultiple) { this.ensureMultiSelectList().handleInput?.(data); this.tui.requestRender(); return; }
|
|
1022
|
+
this.ensureSingleSelectList().handleInput?.(data);
|
|
1023
|
+
this.tui.requestRender();
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** RPC/headless fallback: ctx.ui.custom() returns undefined outside a real TUI, so degrade to dialog methods (select/input). */
|
|
1028
|
+
async function askViaDialogs(
|
|
1029
|
+
ui: { select: Function; input: Function },
|
|
1030
|
+
question: string,
|
|
1031
|
+
context: string | undefined,
|
|
1032
|
+
options: AskOption[],
|
|
1033
|
+
allowMultiple: boolean,
|
|
1034
|
+
allowFreeform: boolean,
|
|
1035
|
+
allowComment: boolean,
|
|
1036
|
+
timeout?: number,
|
|
1037
|
+
): Promise<AskResponse | null> {
|
|
1038
|
+
const dialogOpts = timeout ? { timeout } : undefined;
|
|
1039
|
+
const prompt = context ? `${question}\n\nContext:\n${context}` : question;
|
|
1040
|
+
|
|
1041
|
+
if (allowMultiple) {
|
|
1042
|
+
const rawSelections = (await ui.input(`${prompt}\n\nOptions (select one or more):\n${formatOptionsForMessage(options)}`, "Type your selection(s)...", dialogOpts)) as string | undefined;
|
|
1043
|
+
if (isCancelledInput(rawSelections)) return null;
|
|
1044
|
+
const selections = parseDialogSelections(rawSelections);
|
|
1045
|
+
if (selections.length === 0) return null;
|
|
1046
|
+
if (!allowComment) return createSelectionResponse(selections);
|
|
1047
|
+
const comment = (await ui.input(buildCommentPrompt(prompt, selections), "Optional comment (press Enter to skip)...", dialogOpts)) as string | undefined;
|
|
1048
|
+
return createSelectionResponse(selections, comment);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const selectOptions = options.map((o) => o.title);
|
|
1052
|
+
if (allowFreeform) selectOptions.push(FREEFORM_SENTINEL);
|
|
1053
|
+
const selected = (await ui.select(prompt, selectOptions, dialogOpts)) as string | undefined;
|
|
1054
|
+
if (isCancelledInput(selected)) return null;
|
|
1055
|
+
|
|
1056
|
+
if (selected === FREEFORM_SENTINEL) {
|
|
1057
|
+
const answer = (await ui.input(prompt, "Type your answer...", dialogOpts)) as string | undefined;
|
|
1058
|
+
return isCancelledInput(answer) ? null : createFreeformResponse(answer);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (!allowComment) return createSelectionResponse([selected]);
|
|
1062
|
+
const comment = (await ui.input(buildCommentPrompt(prompt, [selected]), "Optional comment (press Enter to skip)...", dialogOpts)) as string | undefined;
|
|
1063
|
+
return createSelectionResponse([selected], comment);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
|
|
1068
|
+
* dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
|
|
1069
|
+
* interactive UI at all. Never fabricates an answer: cancel, timeout, and non-interactive
|
|
1070
|
+
* contexts all resolve to undefined.
|
|
1071
|
+
*/
|
|
1072
|
+
export async function askQuestion(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
|
|
1073
|
+
if (!ctx.hasUI || !ctx.ui) return undefined;
|
|
1074
|
+
|
|
1075
|
+
const options = params.options ?? [];
|
|
1076
|
+
const allowMultiple = params.allowMultiple ?? false;
|
|
1077
|
+
const allowFreeform = params.allowFreeform ?? true;
|
|
1078
|
+
const allowComment = params.allowComment ?? parseBooleanPreference(process.env["PAPYRUS_DISCUSS_ALLOW_COMMENT"]) ?? false;
|
|
1079
|
+
const envMode = process.env["PAPYRUS_DISCUSS_DISPLAY_MODE"]?.trim().toLowerCase();
|
|
1080
|
+
const envDisplayMode: AskDisplayMode | undefined = envMode === "overlay" || envMode === "inline" ? envMode : undefined;
|
|
1081
|
+
const displayMode: AskDisplayMode = params.displayMode ?? envDisplayMode ?? "overlay";
|
|
1082
|
+
const normalizedContext = params.context?.trim() || undefined;
|
|
1083
|
+
|
|
1084
|
+
if (options.length === 0) {
|
|
1085
|
+
const prompt = normalizedContext ? `${params.question}\n\nContext:\n${normalizedContext}` : params.question;
|
|
1086
|
+
const answer = await ctx.ui.input(prompt, "Type your answer...", params.timeout ? { timeout: params.timeout } : undefined);
|
|
1087
|
+
const response = createFreeformResponse(answer);
|
|
1088
|
+
return response ? toAskAnswer(response) : undefined;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const shortcuts: ResolvedAskShortcuts = {
|
|
1092
|
+
overlayToggle: resolveShortcut(undefined, process.env["PAPYRUS_DISCUSS_OVERLAY_TOGGLE_KEY"], DEFAULT_OVERLAY_TOGGLE_KEY),
|
|
1093
|
+
commentToggle: resolveShortcut(undefined, process.env["PAPYRUS_DISCUSS_COMMENT_TOGGLE_KEY"], DEFAULT_COMMENT_TOGGLE_KEY),
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
1097
|
+
let removeOverlayInputListener: (() => void) | undefined;
|
|
1098
|
+
let hasAnnouncedHide = false;
|
|
1099
|
+
let response: AskResponse | null;
|
|
1100
|
+
try {
|
|
1101
|
+
const factory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskResponse | null) => void) => {
|
|
1102
|
+
if (ctx.signal) ctx.signal.addEventListener("abort", () => done(null), { once: true });
|
|
1103
|
+
if (params.timeout && params.timeout > 0) setTimeout(() => done(null), params.timeout);
|
|
1104
|
+
return new AskComponent(params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, displayMode, tui, theme, keybindings, shortcuts, done);
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
const overlayToggle = shortcuts.overlayToggle;
|
|
1108
|
+
if (displayMode === "overlay" && !overlayToggle.disabled && typeof ctx.ui.onTerminalInput === "function") {
|
|
1109
|
+
removeOverlayInputListener = ctx.ui.onTerminalInput((data) => {
|
|
1110
|
+
if (!overlayToggle.matches(data) || !overlayHandle) return undefined;
|
|
1111
|
+
const nextHidden = !overlayHandle.isHidden();
|
|
1112
|
+
overlayHandle.setHidden(nextHidden);
|
|
1113
|
+
if (nextHidden && !hasAnnouncedHide) { hasAnnouncedHide = true; ctx.ui.notify?.(`Question hidden — press ${overlayToggle.spec} to reopen`, "info"); }
|
|
1114
|
+
return { consume: true };
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const customResult = await ctx.ui.custom<AskResponse | null>(factory, buildCustomUIOptions(displayMode, (handle) => { overlayHandle = handle; }));
|
|
1119
|
+
response = customResult !== undefined ? customResult : await askViaDialogs(ctx.ui, params.question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, params.timeout);
|
|
1120
|
+
} finally {
|
|
1121
|
+
removeOverlayInputListener?.();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return response ? toAskAnswer(response) : undefined;
|
|
1125
|
+
}
|