@danypops/pi-papyrus 0.52.1 → 0.52.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,59 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Discuss's live:true
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* The single-select/editor flow is adapted from pi-ask-user (MIT, Copyright (c) 2026 Enzo
|
|
8
|
-
* Lucchesi; full notice in THIRD_PARTY_LICENSES.md).
|
|
2
|
+
* Discuss's live:true ask UI: a thin wrapper over @danypops/vehicle-client-pi's shared
|
|
3
|
+
* requestPiAskPrompt (searchable single-select, checkbox multi-select, freeform replies,
|
|
4
|
+
* optional comments, typing courtesy, hosted integrated or overlay -- the rich UI itself, and its
|
|
5
|
+
* own test suite, now live there). This module owns exactly what's Papyrus-specific: resolving
|
|
6
|
+
* PAPYRUS_DISCUSS_* environment preferences into explicit options, and the "discuss" box label.
|
|
9
7
|
*/
|
|
10
8
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
type Component,
|
|
19
|
-
Container,
|
|
20
|
-
CURSOR_MARKER,
|
|
21
|
-
decodeKittyPrintable,
|
|
22
|
-
Editor,
|
|
23
|
-
type EditorComponent,
|
|
24
|
-
type EditorTheme,
|
|
25
|
-
fuzzyFilter,
|
|
26
|
-
Key,
|
|
27
|
-
type Keybinding,
|
|
28
|
-
type KeybindingsManager,
|
|
29
|
-
Markdown,
|
|
30
|
-
type MarkdownTheme,
|
|
31
|
-
matchesKey,
|
|
32
|
-
Spacer,
|
|
33
|
-
Text,
|
|
34
|
-
type TUI,
|
|
35
|
-
truncateToWidth,
|
|
36
|
-
wrapTextWithAnsi,
|
|
37
|
-
} from "@earendil-works/pi-tui";
|
|
38
|
-
import { type AskOption, renderSingleSelectRows } from "./discuss-ask-layout.ts";
|
|
9
|
+
type PiAskPromptOptions,
|
|
10
|
+
requestPiAskPrompt,
|
|
11
|
+
ensureTypingCourtesyTracking as sharedEnsureTypingCourtesyTracking,
|
|
12
|
+
isLiveAskPending as sharedIsLiveAskPending,
|
|
13
|
+
} from "@danypops/vehicle-client-pi/hitl-ask-prompt";
|
|
14
|
+
import type { AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
39
15
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
const md = getMarkdownTheme();
|
|
47
|
-
if (!md) return undefined;
|
|
48
|
-
md.bold("");
|
|
49
|
-
return md;
|
|
50
|
-
} catch {
|
|
51
|
-
return undefined;
|
|
52
|
-
}
|
|
16
|
+
export type AskPresentation = "integrated" | "overlay";
|
|
17
|
+
|
|
18
|
+
export interface AskOption {
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
53
21
|
}
|
|
54
22
|
|
|
55
23
|
export interface AskQuestionParams {
|
|
56
24
|
question: string;
|
|
25
|
+
/**
|
|
26
|
+
* `integrated` replaces Pi's input editor while preserving the transcript and footer;
|
|
27
|
+
* `overlay` presents the identical component as a blocking popup over the transcript.
|
|
28
|
+
* Defaults to integrated, preserving the established Discuss interaction.
|
|
29
|
+
*/
|
|
30
|
+
presentation?: AskPresentation;
|
|
57
31
|
context?: string;
|
|
58
32
|
/** Plain orientation line ("which discussion is this"), shown dim above the question -- not a
|
|
59
33
|
* labeled section like context. Typically the Discussion's own title. */
|
|
@@ -63,24 +37,7 @@ export interface AskQuestionParams {
|
|
|
63
37
|
allowFreeform?: boolean;
|
|
64
38
|
allowComment?: boolean;
|
|
65
39
|
timeout?: number;
|
|
66
|
-
/**
|
|
67
|
-
* Streamed once before blocking on the human, matching pi-ask-user's own original code (the
|
|
68
|
-
* prior art this view is adapted from) -- gives the tool call's progress UI something to show
|
|
69
|
-
* during a wait that legitimately runs far longer than a typical tool call (real human response
|
|
70
|
-
* time, not milliseconds).
|
|
71
|
-
*/
|
|
72
40
|
onUpdate?: AgentToolUpdateCallback;
|
|
73
|
-
/**
|
|
74
|
-
* The tool call's OWN abort signal (execute()'s 3rd parameter) -- fires only if this specific
|
|
75
|
-
* tool call is genuinely interrupted (e.g. the human pressed Ctrl+C on the whole agent
|
|
76
|
-
* operation). Deliberately NOT `ExtensionContext.signal`: that one tracks "is the agent
|
|
77
|
-
* currently streaming a model response" and settles/aborts within a second or two of the
|
|
78
|
-
* assistant's tool_call message finishing generation -- which is normal, unrelated bookkeeping
|
|
79
|
-
* that happens long before a human actually answers a slow interactive prompt. A live-observed
|
|
80
|
-
* bug (the picker silently self-cancelling ~7-10s after opening, well before the human
|
|
81
|
-
* finished deciding, with their real answer then arriving disconnected as a stray follow-up)
|
|
82
|
-
* traced back to listening on the wrong signal here.
|
|
83
|
-
*/
|
|
84
41
|
signal?: AbortSignal;
|
|
85
42
|
}
|
|
86
43
|
|
|
@@ -89,13 +46,6 @@ export interface AskAnswer {
|
|
|
89
46
|
selected?: string[];
|
|
90
47
|
}
|
|
91
48
|
|
|
92
|
-
type AskResponse = { kind: "selection"; selections: string[]; comment?: string } | { kind: "freeform"; text: string };
|
|
93
|
-
|
|
94
|
-
function normalizeOptionalComment(text: string | null | undefined): string | undefined {
|
|
95
|
-
const trimmed = text?.trim();
|
|
96
|
-
return trimmed ? trimmed : undefined;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
49
|
function parseBooleanPreference(value: string | undefined): boolean | undefined {
|
|
100
50
|
if (value === undefined) return undefined;
|
|
101
51
|
switch (value.trim().toLowerCase()) {
|
|
@@ -114,1430 +64,37 @@ function parseBooleanPreference(value: string | undefined): boolean | undefined
|
|
|
114
64
|
}
|
|
115
65
|
}
|
|
116
66
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
function createSelectionResponse(selections: string[], comment?: string | null): AskResponse | null {
|
|
123
|
-
const normalizedSelections = selections.map((selection) => selection.trim()).filter(Boolean);
|
|
124
|
-
if (normalizedSelections.length === 0) return null;
|
|
125
|
-
const normalizedComment = normalizeOptionalComment(comment);
|
|
126
|
-
return normalizedComment
|
|
127
|
-
? { kind: "selection", selections: normalizedSelections, comment: normalizedComment }
|
|
128
|
-
: { kind: "selection", selections: normalizedSelections };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function toAskAnswer(response: AskResponse): AskAnswer {
|
|
132
|
-
if (response.kind === "freeform") return { content: response.text };
|
|
133
|
-
const content = response.comment ? `${response.selections.join(", ")} — ${response.comment}` : response.selections.join(", ");
|
|
134
|
-
return { content, selected: response.selections };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function formatOptionsForMessage(options: AskOption[]): string {
|
|
138
|
-
return options.map((option, index) => `${index + 1}. ${option.title}${option.description ? ` — ${option.description}` : ""}`).join("\n");
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function buildCommentPrompt(prompt: string, selections: string[]): string {
|
|
142
|
-
const label = selections.length === 1 ? "Selected option" : "Selected options";
|
|
143
|
-
return `${prompt}\n\n${label}:\n${selections.map((selection) => `- ${selection}`).join("\n")}`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function parseDialogSelections(input: string): string[] {
|
|
147
|
-
return input
|
|
148
|
-
.split(",")
|
|
149
|
-
.map((selection) => selection.trim())
|
|
150
|
-
.filter(Boolean);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function isCancelledInput(value: unknown): value is null | undefined {
|
|
154
|
-
return value === null || value === undefined;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function createSelectListTheme(theme: Theme) {
|
|
158
|
-
return {
|
|
159
|
-
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
160
|
-
selectedText: (t: string) => theme.fg("accent", t),
|
|
161
|
-
description: (t: string) => theme.fg("muted", t),
|
|
162
|
-
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
163
|
-
noMatch: (t: string) => theme.fg("warning", t),
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function createEditorTheme(theme: Theme): EditorTheme {
|
|
168
|
-
return { borderColor: (s: string) => theme.fg("accent", s), selectList: createSelectListTheme(theme) };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const BOX_BORDER_LEFT = "│ ";
|
|
172
|
-
const BOX_BORDER_RIGHT = " │";
|
|
173
|
-
const BOX_BORDER_OVERHEAD = BOX_BORDER_LEFT.length + BOX_BORDER_RIGHT.length;
|
|
174
|
-
|
|
175
|
-
class BoxBorderTop implements Component {
|
|
176
|
-
constructor(
|
|
177
|
-
private color: (s: string) => string,
|
|
178
|
-
private title?: string,
|
|
179
|
-
private titleColor?: (s: string) => string,
|
|
180
|
-
) {}
|
|
181
|
-
invalidate(): void {}
|
|
182
|
-
render(width: number): string[] {
|
|
183
|
-
const inner = Math.max(0, width - 2);
|
|
184
|
-
if (!this.title || inner < this.title.length + 4) return [this.color(`╭${"─".repeat(inner)}╮`)];
|
|
185
|
-
const label = ` ${this.title} `;
|
|
186
|
-
const remaining = inner - 1 - label.length;
|
|
187
|
-
const titleStyle = this.titleColor ?? this.color;
|
|
188
|
-
return [this.color("╭─") + titleStyle(label) + this.color(`${"─".repeat(Math.max(0, remaining))}╮`)];
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
class BoxBorderBottom implements Component {
|
|
193
|
-
constructor(private color: (s: string) => string) {}
|
|
194
|
-
invalidate(): void {}
|
|
195
|
-
render(width: number): string[] {
|
|
196
|
-
const inner = Math.max(0, width - 2);
|
|
197
|
-
return [this.color(`╰${"─".repeat(inner)}╯`)];
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function formatKeyList(keys: string[]): string {
|
|
202
|
-
return keys.join("/");
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function keybindingHint(theme: Theme, keybindings: KeybindingsManager, keybinding: Keybinding, description: string): string {
|
|
206
|
-
return `${theme.fg("dim", formatKeyList(keybindings.getKeys(keybinding)))}${theme.fg("muted", ` ${description}`)}`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function literalHint(theme: Theme, key: string, description: string): string {
|
|
210
|
-
return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
type ResolvedShortcut =
|
|
214
|
-
| { disabled: false; spec: string; matches: (data: string) => boolean }
|
|
215
|
-
| { disabled: true; spec: null; matches: (data: string) => false };
|
|
216
|
-
|
|
217
|
-
const DISABLED_SHORTCUT: ResolvedShortcut = { disabled: true, spec: null, matches: (() => false) as (data: string) => false };
|
|
218
|
-
const SHORTCUT_DISABLE_VALUES = new Set(["off", "none", "disabled", ""]);
|
|
219
|
-
|
|
220
|
-
function normalizeShortcutSpec(value: string | null | undefined): string | null | undefined {
|
|
221
|
-
if (value === undefined) return undefined;
|
|
222
|
-
if (value === null) return null;
|
|
223
|
-
const trimmed = value.trim().toLowerCase();
|
|
224
|
-
return SHORTCUT_DISABLE_VALUES.has(trimmed) ? null : trimmed;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function isValidShortcutSpec(spec: string): boolean {
|
|
228
|
-
if (!spec) return false;
|
|
229
|
-
if (!/^[a-z0-9+_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]+$/i.test(spec)) return false;
|
|
230
|
-
if (spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) return false;
|
|
231
|
-
return true;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function buildShortcut(spec: string): ResolvedShortcut {
|
|
235
|
-
return { disabled: false, spec, matches: (data: string) => matchesKey(data, spec as any) };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function resolveShortcut(paramValue: string | null | undefined, envValue: string | undefined, defaultSpec: string): ResolvedShortcut {
|
|
239
|
-
for (const raw of [paramValue, envValue, defaultSpec]) {
|
|
240
|
-
const normalized = normalizeShortcutSpec(raw);
|
|
241
|
-
if (normalized === undefined) continue;
|
|
242
|
-
if (normalized === null) return DISABLED_SHORTCUT;
|
|
243
|
-
if (isValidShortcutSpec(normalized)) return buildShortcut(normalized);
|
|
244
|
-
}
|
|
245
|
-
return DISABLED_SHORTCUT;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
type AskMode = "select" | "freeform" | "comment";
|
|
249
|
-
|
|
250
|
-
// Docked in the input area: growing past this ceiling pushes the conversation transcript above
|
|
251
|
-
// it out of view, so the scroll keys below do real work on a long question instead of the picker
|
|
252
|
-
// consuming the terminal outright.
|
|
253
|
-
const ASK_MAX_HEIGHT_RATIO = 0.5;
|
|
254
|
-
const ASK_MIN_RENDER_LINES = 8;
|
|
255
|
-
const SPLIT_PANE_MIN_WIDTH = 84;
|
|
256
|
-
const SPLIT_PANE_LEFT_MIN_WIDTH = 32;
|
|
257
|
-
const SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
|
|
258
|
-
const SPLIT_PANE_SEPARATOR = " │ ";
|
|
259
|
-
const FREEFORM_SENTINEL = "\u270f\ufe0f Type a custom answer...";
|
|
260
|
-
const COMMENT_TOGGLE_LABEL = "Add extra context after selection";
|
|
261
|
-
const DEFAULT_COMMENT_TOGGLE_KEY = "ctrl+g";
|
|
262
|
-
|
|
263
|
-
const VIM_SELECT_UP_KEY = Key.ctrl("k");
|
|
264
|
-
const VIM_SELECT_DOWN_KEY = Key.ctrl("j");
|
|
265
|
-
const PROMPT_SCROLL_PAGE_UP_KEY = Key.pageUp;
|
|
266
|
-
const PROMPT_SCROLL_PAGE_DOWN_KEY = Key.pageDown;
|
|
267
|
-
const PROMPT_SCROLL_HOME_KEY = Key.home;
|
|
268
|
-
const PROMPT_SCROLL_END_KEY = Key.end;
|
|
269
|
-
const PROMPT_SCROLL_HALF_PAGE_UP_KEY = Key.ctrl("u");
|
|
270
|
-
const PROMPT_SCROLL_HALF_PAGE_DOWN_KEY = Key.ctrl("d");
|
|
271
|
-
|
|
272
|
-
function getAskMaxRenderLinesForRows(rows: number): number {
|
|
273
|
-
const normalizedRows = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
|
|
274
|
-
const availableRows = Math.max(1, normalizedRows - 2);
|
|
275
|
-
const ratioRows = Math.max(1, Math.floor(normalizedRows * ASK_MAX_HEIGHT_RATIO));
|
|
276
|
-
const minimumRows = Math.min(ASK_MIN_RENDER_LINES, availableRows);
|
|
277
|
-
return Math.min(availableRows, Math.max(minimumRows, ratioRows));
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function matchesSelectUp(data: string, keybindings: KeybindingsManager): boolean {
|
|
281
|
-
return keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab")) || matchesKey(data, VIM_SELECT_UP_KEY);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function matchesSelectDown(data: string, keybindings: KeybindingsManager): boolean {
|
|
285
|
-
return keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab) || matchesKey(data, VIM_SELECT_DOWN_KEY);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
type DiscussionMultiSelectChoice =
|
|
289
|
-
| { readonly kind: "option"; readonly option: AskOption }
|
|
290
|
-
| { readonly kind: "comment" }
|
|
291
|
-
| { readonly kind: "freeform" };
|
|
292
|
-
|
|
293
|
-
class DiscussionMultiSelectList implements Component {
|
|
294
|
-
private readonly list: SharedMultiSelectList<DiscussionMultiSelectChoice>;
|
|
295
|
-
private readonly commentIndex: number | undefined;
|
|
296
|
-
private commentEnabled = false;
|
|
297
|
-
|
|
298
|
-
public onCancel?: () => void;
|
|
299
|
-
public onSubmit?: (result: string[]) => void;
|
|
300
|
-
public onEnterFreeform?: () => void;
|
|
301
|
-
|
|
302
|
-
constructor(
|
|
303
|
-
options: AskOption[],
|
|
304
|
-
allowFreeform: boolean,
|
|
305
|
-
allowComment: boolean,
|
|
306
|
-
theme: Theme,
|
|
307
|
-
keybindings: KeybindingsManager,
|
|
308
|
-
private readonly commentToggle: ResolvedShortcut,
|
|
309
|
-
) {
|
|
310
|
-
const items: Array<MultiSelectListItem<DiscussionMultiSelectChoice>> = options.map((option, index) => ({
|
|
311
|
-
value: { kind: "option", option },
|
|
312
|
-
label: option.title,
|
|
313
|
-
...(option.description ? { description: option.description } : {}),
|
|
314
|
-
...(index < 9 ? { shortcut: String(index + 1) } : {}),
|
|
315
|
-
numberLabel: String(index + 1),
|
|
316
|
-
}));
|
|
317
|
-
if (allowComment) {
|
|
318
|
-
this.commentIndex = items.length;
|
|
319
|
-
items.push({
|
|
320
|
-
value: { kind: "comment" },
|
|
321
|
-
label: COMMENT_TOGGLE_LABEL,
|
|
322
|
-
includeInSelection: false,
|
|
323
|
-
confirmAction: "toggle",
|
|
324
|
-
numberLabel: false,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
if (allowFreeform) {
|
|
328
|
-
items.push({
|
|
329
|
-
value: { kind: "freeform" },
|
|
330
|
-
label: "Type something.",
|
|
331
|
-
description: "Enter a custom response",
|
|
332
|
-
toggleable: false,
|
|
333
|
-
confirmAction: "activate",
|
|
334
|
-
numberLabel: false,
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
this.list = createMultiSelectList<DiscussionMultiSelectChoice>({
|
|
338
|
-
items,
|
|
339
|
-
theme,
|
|
340
|
-
keybindings,
|
|
341
|
-
onCancel: () => this.onCancel?.(),
|
|
342
|
-
onToggle: (item: MultiSelectListItem<DiscussionMultiSelectChoice>, checked: boolean) => {
|
|
343
|
-
if (item.value.kind === "comment") this.commentEnabled = checked;
|
|
344
|
-
},
|
|
345
|
-
onActivate: (item: MultiSelectListItem<DiscussionMultiSelectChoice>) => {
|
|
346
|
-
if (item.value.kind === "freeform") this.onEnterFreeform?.();
|
|
347
|
-
},
|
|
348
|
-
onSubmit: (choices: DiscussionMultiSelectChoice[]) => {
|
|
349
|
-
const titles = choices
|
|
350
|
-
.filter((choice): choice is Extract<DiscussionMultiSelectChoice, { kind: "option" }> => choice.kind === "option")
|
|
351
|
-
.map((choice) => choice.option.title);
|
|
352
|
-
if (titles.length > 0) this.onSubmit?.(titles);
|
|
353
|
-
else this.onCancel?.();
|
|
354
|
-
},
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
public isCommentEnabled(): boolean {
|
|
359
|
-
return this.commentEnabled;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
setMaxVisibleRows(rows: number): void {
|
|
363
|
-
this.list.setMaxVisibleRows(rows);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
invalidate(): void {
|
|
367
|
-
this.list.invalidate();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
handleInput(data: string): void {
|
|
371
|
-
if (this.commentIndex !== undefined && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
|
|
372
|
-
this.list.setChecked(this.commentIndex, !this.commentEnabled);
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
this.list.handleInput(data);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
render(width: number): string[] {
|
|
379
|
-
return this.list.render(width);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
class WrappedSingleSelectList implements Component {
|
|
384
|
-
private selectedIndex = 0;
|
|
385
|
-
private searchQuery = "";
|
|
386
|
-
private commentEnabled = false;
|
|
387
|
-
private maxVisibleRows = 12;
|
|
388
|
-
private cachedWidth?: number;
|
|
389
|
-
private cachedLines?: string[];
|
|
390
|
-
|
|
391
|
-
public onCancel?: () => void;
|
|
392
|
-
public onSubmit?: (result: string) => void;
|
|
393
|
-
public onEnterFreeform?: () => void;
|
|
394
|
-
|
|
395
|
-
constructor(
|
|
396
|
-
private options: AskOption[],
|
|
397
|
-
private allowFreeform: boolean,
|
|
398
|
-
private allowComment: boolean,
|
|
399
|
-
private theme: Theme,
|
|
400
|
-
private keybindings: KeybindingsManager,
|
|
401
|
-
private commentToggle: ResolvedShortcut,
|
|
402
|
-
) {}
|
|
403
|
-
|
|
404
|
-
public isCommentEnabled(): boolean {
|
|
405
|
-
return this.commentEnabled;
|
|
406
|
-
}
|
|
407
|
-
setMaxVisibleRows(rows: number): void {
|
|
408
|
-
const next = Math.max(1, Math.floor(rows));
|
|
409
|
-
if (next !== this.maxVisibleRows) {
|
|
410
|
-
this.maxVisibleRows = next;
|
|
411
|
-
this.invalidate();
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
invalidate(): void {
|
|
415
|
-
this.cachedWidth = undefined;
|
|
416
|
-
this.cachedLines = undefined;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
private getFilteredOptions(): AskOption[] {
|
|
420
|
-
return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
|
|
421
|
-
}
|
|
422
|
-
private getItemCount(filteredOptions: AskOption[]): number {
|
|
423
|
-
return filteredOptions.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0);
|
|
424
|
-
}
|
|
425
|
-
private isCommentToggleRow(index: number, filteredOptions: AskOption[]): boolean {
|
|
426
|
-
return this.allowComment && index === filteredOptions.length;
|
|
427
|
-
}
|
|
428
|
-
private isFreeformRow(index: number, filteredOptions: AskOption[]): boolean {
|
|
429
|
-
return this.allowFreeform && index === filteredOptions.length + (this.allowComment ? 1 : 0);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
private toggleComment(): void {
|
|
433
|
-
if (!this.allowComment) return;
|
|
434
|
-
this.commentEnabled = !this.commentEnabled;
|
|
435
|
-
this.invalidate();
|
|
436
|
-
}
|
|
437
|
-
private setSearchQuery(query: string): void {
|
|
438
|
-
this.searchQuery = query;
|
|
439
|
-
this.selectedIndex = 0;
|
|
440
|
-
this.invalidate();
|
|
441
|
-
}
|
|
442
|
-
private popSearchCharacter(): void {
|
|
443
|
-
if (!this.searchQuery) return;
|
|
444
|
-
const characters = [...this.searchQuery];
|
|
445
|
-
characters.pop();
|
|
446
|
-
this.setSearchQuery(characters.join(""));
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
private getPrintableInput(data: string): string | null {
|
|
450
|
-
const kittyPrintable = decodeKittyPrintable(data);
|
|
451
|
-
if (kittyPrintable !== undefined) return kittyPrintable;
|
|
452
|
-
const characters = [...data];
|
|
453
|
-
if (characters.length !== 1) return null;
|
|
454
|
-
const [character] = characters;
|
|
455
|
-
if (!character) return null;
|
|
456
|
-
const code = character.charCodeAt(0);
|
|
457
|
-
if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return null;
|
|
458
|
-
return character;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
private styleListLine(line: string, width: number, isSelected: boolean): string {
|
|
462
|
-
const trimmed = line.trim();
|
|
463
|
-
if (trimmed.startsWith("(")) return truncateToWidth(this.theme.fg("dim", line), width, "");
|
|
464
|
-
if (isSelected) return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
|
|
465
|
-
if (line.startsWith(" ")) return truncateToWidth(this.theme.fg("muted", line), width, "");
|
|
466
|
-
if (line.startsWith("→")) return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
|
|
467
|
-
return truncateToWidth(this.theme.fg("text", line), width, "");
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
private getSplitPaneWidths(width: number): { left: number; right: number } | null {
|
|
471
|
-
if (width < SPLIT_PANE_MIN_WIDTH) return null;
|
|
472
|
-
const availableWidth = width - SPLIT_PANE_SEPARATOR.length;
|
|
473
|
-
if (availableWidth < SPLIT_PANE_LEFT_MIN_WIDTH + SPLIT_PANE_RIGHT_MIN_WIDTH) return null;
|
|
474
|
-
const preferredLeftWidth = Math.floor(availableWidth * 0.42);
|
|
475
|
-
const left = Math.max(SPLIT_PANE_LEFT_MIN_WIDTH, Math.min(preferredLeftWidth, availableWidth - SPLIT_PANE_RIGHT_MIN_WIDTH));
|
|
476
|
-
const right = availableWidth - left;
|
|
477
|
-
return right < SPLIT_PANE_RIGHT_MIN_WIDTH ? null : { left, right };
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
private buildListLines(width: number, filteredOptions: AskOption[], hideDescriptions = false): string[] {
|
|
481
|
-
const lines: string[] = [];
|
|
482
|
-
const count = this.getItemCount(filteredOptions);
|
|
483
|
-
const searchValue = this.searchQuery ? this.theme.fg("text", this.searchQuery) : this.theme.fg("dim", "type to filter");
|
|
484
|
-
lines.push(truncateToWidth(`${this.theme.fg("accent", "Filter:")} ${searchValue}`, width, ""));
|
|
485
|
-
if (this.searchQuery && filteredOptions.length === 0)
|
|
486
|
-
lines.push(truncateToWidth(this.theme.fg("warning", "No matching options"), width, ""));
|
|
487
|
-
if (count === 0) {
|
|
488
|
-
if (!this.searchQuery) lines.push(truncateToWidth(this.theme.fg("warning", "No options"), width, ""));
|
|
489
|
-
return lines.slice(0, this.maxVisibleRows);
|
|
490
|
-
}
|
|
491
|
-
const maxRows = Math.max(1, this.maxVisibleRows - lines.length);
|
|
492
|
-
const optionRows = renderSingleSelectRows({
|
|
493
|
-
options: filteredOptions,
|
|
494
|
-
selectedIndex: this.selectedIndex,
|
|
495
|
-
width,
|
|
496
|
-
allowFreeform: this.allowFreeform,
|
|
497
|
-
allowComment: this.allowComment,
|
|
498
|
-
commentEnabled: this.commentEnabled,
|
|
499
|
-
maxRows,
|
|
500
|
-
hideDescriptions,
|
|
501
|
-
});
|
|
502
|
-
lines.push(...optionRows.map((row) => this.styleListLine(row.line, width, row.selected)));
|
|
503
|
-
return lines.slice(0, this.maxVisibleRows);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
private buildPreviewLines(width: number, filteredOptions: AskOption[], maxLines: number): string[] {
|
|
507
|
-
if (maxLines <= 0) return [];
|
|
508
|
-
const mdTheme = safeMarkdownTheme();
|
|
509
|
-
let md = "";
|
|
510
|
-
if (this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
511
|
-
md += "## Additional context\n\n";
|
|
512
|
-
md += `Currently: **${this.commentEnabled ? "Enabled" : "Disabled"}**\n\n`;
|
|
513
|
-
md += "Turn this on when the selected option needs extra explanation before it submits.\n";
|
|
514
|
-
} else if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
515
|
-
md += "## Custom answer\n\nOpen the editor to write **any** answer.\n\n*Use this when none of the listed options fit.*\n";
|
|
516
|
-
if (this.searchQuery) md += `\n> Current filter: \`${this.searchQuery}\`\n`;
|
|
517
|
-
} else {
|
|
518
|
-
const selected = filteredOptions[this.selectedIndex];
|
|
519
|
-
if (!selected) {
|
|
520
|
-
md += "*No option selected*\n";
|
|
521
|
-
} else {
|
|
522
|
-
md += `## ${selected.title}\n\n`;
|
|
523
|
-
md += selected.description?.trim() ? `${selected.description}\n` : "*No additional details provided for this option.*\n";
|
|
524
|
-
md += "\n---\n\nPress `Enter` to select this option.\n";
|
|
525
|
-
if (this.searchQuery) md += `\n> Filter: \`${this.searchQuery}\`\n`;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
let lines: string[];
|
|
530
|
-
if (mdTheme) {
|
|
531
|
-
lines = new Markdown(md.trim(), 0, 0, mdTheme).render(width);
|
|
532
|
-
} else {
|
|
533
|
-
lines = wrapTextWithAnsi(md.trim(), Math.max(10, width)).map((line) => truncateToWidth(line, width, ""));
|
|
534
|
-
}
|
|
535
|
-
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
|
|
536
|
-
if (lines.length <= maxLines) return lines;
|
|
537
|
-
if (maxLines === 1) return [truncateToWidth(this.theme.fg("dim", "…"), width, "")];
|
|
538
|
-
const visibleLines = lines.slice(0, maxLines - 1);
|
|
539
|
-
visibleLines.push(truncateToWidth(this.theme.fg("dim", "…"), width, ""));
|
|
540
|
-
return visibleLines;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
handleInput(data: string): void {
|
|
544
|
-
if (this.searchQuery && matchesKey(data, Key.escape)) {
|
|
545
|
-
this.setSearchQuery("");
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
549
|
-
this.onCancel?.();
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
|
|
553
|
-
this.toggleComment();
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const filteredOptions = this.getFilteredOptions();
|
|
558
|
-
const count = this.getItemCount(filteredOptions);
|
|
559
|
-
|
|
560
|
-
if (matchesSelectUp(data, this.keybindings) && count > 0) {
|
|
561
|
-
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
562
|
-
this.invalidate();
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
if (matchesSelectDown(data, this.keybindings) && count > 0) {
|
|
566
|
-
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
567
|
-
this.invalidate();
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
const numMatch = data.match(/^[1-9]$/);
|
|
572
|
-
if (numMatch && filteredOptions.length > 0) {
|
|
573
|
-
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
574
|
-
if (idx >= 0 && idx < filteredOptions.length) {
|
|
575
|
-
this.selectedIndex = idx;
|
|
576
|
-
this.invalidate();
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
if (matchesKey(data, Key.space) && count > 0 && this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
582
|
-
this.toggleComment();
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
|
|
587
|
-
if (this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
588
|
-
this.toggleComment();
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
592
|
-
this.onEnterFreeform?.();
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
const result = filteredOptions[this.selectedIndex]?.title;
|
|
596
|
-
if (result) this.onSubmit?.(result);
|
|
597
|
-
else this.onCancel?.();
|
|
598
|
-
return;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, Key.backspace)) {
|
|
602
|
-
this.popSearchCharacter();
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const printableInput = this.getPrintableInput(data);
|
|
607
|
-
if (printableInput) this.setSearchQuery(this.searchQuery + printableInput);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
render(width: number): string[] {
|
|
611
|
-
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
612
|
-
const filteredOptions = this.getFilteredOptions();
|
|
613
|
-
const count = this.getItemCount(filteredOptions);
|
|
614
|
-
this.selectedIndex = count > 0 ? Math.max(0, Math.min(this.selectedIndex, count - 1)) : 0;
|
|
615
|
-
|
|
616
|
-
const splitPane = this.getSplitPaneWidths(width);
|
|
617
|
-
let lines: string[];
|
|
618
|
-
if (!splitPane) {
|
|
619
|
-
lines = this.buildListLines(width, filteredOptions);
|
|
620
|
-
} else {
|
|
621
|
-
const listLines = this.buildListLines(splitPane.left, filteredOptions, true);
|
|
622
|
-
const previewLines = this.buildPreviewLines(splitPane.right, filteredOptions, this.maxVisibleRows);
|
|
623
|
-
const rowCount = Math.min(this.maxVisibleRows, Math.max(listLines.length, previewLines.length));
|
|
624
|
-
const separator = this.theme.fg("dim", SPLIT_PANE_SEPARATOR);
|
|
625
|
-
lines = Array.from(
|
|
626
|
-
{ length: rowCount },
|
|
627
|
-
(_, index) =>
|
|
628
|
-
`${truncateToWidth(listLines[index] ?? "", splitPane.left, "", true)}${separator}${truncateToWidth(previewLines[index] ?? "", splitPane.right, "")}`,
|
|
629
|
-
);
|
|
630
|
-
}
|
|
631
|
-
this.cachedWidth = width;
|
|
632
|
-
this.cachedLines = lines;
|
|
633
|
-
return lines;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
interface ResolvedAskShortcuts {
|
|
638
|
-
commentToggle: ResolvedShortcut;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/** Root Container: swaps between select (single/multi) and an Editor (freeform/comment). */
|
|
642
|
-
class AskComponent extends Container {
|
|
643
|
-
private mode: AskMode = "select";
|
|
644
|
-
private pendingSelections: string[] = [];
|
|
645
|
-
private freeformDraft = "";
|
|
646
|
-
private commentDraft = "";
|
|
647
|
-
private promptScrollOffset = 0;
|
|
648
|
-
private promptMaxScrollOffset = 0;
|
|
649
|
-
private promptViewportRows = 0;
|
|
650
|
-
|
|
651
|
-
private titleText: Text;
|
|
652
|
-
private questionText: Text;
|
|
653
|
-
private contextComponent?: Component;
|
|
654
|
-
private modeContainer: Container;
|
|
655
|
-
private helpText: Text;
|
|
656
|
-
|
|
657
|
-
private singleSelectList?: WrappedSingleSelectList;
|
|
658
|
-
private multiSelectList?: DiscussionMultiSelectList;
|
|
659
|
-
private editor?: Editor;
|
|
660
|
-
|
|
661
|
-
private _focused = false;
|
|
662
|
-
get focused(): boolean {
|
|
663
|
-
return this._focused;
|
|
664
|
-
}
|
|
665
|
-
set focused(value: boolean) {
|
|
666
|
-
this._focused = value;
|
|
667
|
-
if (this.editor && (this.mode === "freeform" || this.mode === "comment")) (this.editor as any).focused = value;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
constructor(
|
|
671
|
-
private question: string,
|
|
672
|
-
private context: string | undefined,
|
|
673
|
-
private subtitle: string | undefined,
|
|
674
|
-
private options: AskOption[],
|
|
675
|
-
private allowMultiple: boolean,
|
|
676
|
-
private allowFreeform: boolean,
|
|
677
|
-
private allowComment: boolean,
|
|
678
|
-
private tui: TUI,
|
|
679
|
-
private theme: Theme,
|
|
680
|
-
private keybindings: KeybindingsManager,
|
|
681
|
-
private shortcuts: ResolvedAskShortcuts,
|
|
682
|
-
private onDone: (result: AskResponse | null) => void,
|
|
683
|
-
) {
|
|
684
|
-
super();
|
|
685
|
-
this.addChild(
|
|
686
|
-
new BoxBorderTop(
|
|
687
|
-
(s) => theme.fg("accent", s),
|
|
688
|
-
"discuss",
|
|
689
|
-
(s) => theme.fg("dim", theme.bold(s)),
|
|
690
|
-
),
|
|
691
|
-
);
|
|
692
|
-
this.addChild(new Spacer(1));
|
|
693
|
-
this.titleText = new Text("", 1, 0);
|
|
694
|
-
this.addChild(this.titleText);
|
|
695
|
-
this.addChild(new Spacer(1));
|
|
696
|
-
this.questionText = new Text("", 1, 0);
|
|
697
|
-
this.addChild(this.questionText);
|
|
698
|
-
|
|
699
|
-
if (this.context) {
|
|
700
|
-
this.addChild(new Spacer(1));
|
|
701
|
-
const mdTheme = safeMarkdownTheme();
|
|
702
|
-
this.contextComponent = mdTheme ? new Markdown("", 1, 0, mdTheme) : new Text("", 1, 0);
|
|
703
|
-
this.addChild(this.contextComponent);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
this.addChild(new Spacer(1));
|
|
707
|
-
this.modeContainer = new Container();
|
|
708
|
-
this.addChild(this.modeContainer);
|
|
709
|
-
this.addChild(new Spacer(1));
|
|
710
|
-
this.helpText = new Text("", 1, 0);
|
|
711
|
-
this.addChild(this.helpText);
|
|
712
|
-
this.addChild(new Spacer(1));
|
|
713
|
-
this.addChild(new BoxBorderBottom((s) => theme.fg("accent", s)));
|
|
714
|
-
|
|
715
|
-
this.updateStaticText();
|
|
716
|
-
// A freeform-only ask (no options at all) has no select list to show -- start directly in
|
|
717
|
-
// the freeform editor instead of a select mode that would have nothing to render.
|
|
718
|
-
if (this.options.length === 0) this.showFreeformMode();
|
|
719
|
-
else this.showSelectMode();
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
override invalidate(): void {
|
|
723
|
-
super.invalidate();
|
|
724
|
-
this.updateStaticText();
|
|
725
|
-
this.updateHelpText();
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
override render(width: number): string[] {
|
|
729
|
-
const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
|
|
730
|
-
return this.renderBudgetedLayout(width, innerWidth);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
private getAskMaxRenderLines(): number {
|
|
734
|
-
const rows = Number.isFinite(this.tui.terminal.rows) ? Math.floor(this.tui.terminal.rows) : 24;
|
|
735
|
-
return getAskMaxRenderLinesForRows(rows);
|
|
736
|
-
}
|
|
67
|
+
/** Re-exported for pi-papyrus's own typing-courtesy tracking call sites (extension/src/index.ts). */
|
|
68
|
+
export const ensureTypingCourtesyTracking = sharedEnsureTypingCourtesyTracking;
|
|
69
|
+
/** Re-exported for extension/src/index.ts's active-task-continuation guard -- see the shared
|
|
70
|
+
* module's own isLiveAskPending doc comment for why that guard exists. */
|
|
71
|
+
export const isLiveAskPending = sharedIsLiveAskPending;
|
|
737
72
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
if (maxLines === 2) return [this.renderTopBorder(width), this.renderBottomBorder(width)];
|
|
742
|
-
|
|
743
|
-
const bodyCapacity = Math.max(0, maxLines - 2);
|
|
744
|
-
const promptLines = this.buildPromptLines(innerWidth);
|
|
745
|
-
const helpFullLines = this.helpText.render(innerWidth);
|
|
746
|
-
const helpBudget = this.getHelpBudget(bodyCapacity, helpFullLines.length);
|
|
747
|
-
const contentRows = Math.max(0, bodyCapacity - helpBudget);
|
|
748
|
-
|
|
749
|
-
let promptBudget = 0;
|
|
750
|
-
let modeBudget = 0;
|
|
751
|
-
let separatorRows = 0;
|
|
752
|
-
|
|
753
|
-
if (this.mode === "select") {
|
|
754
|
-
separatorRows = contentRows >= 4 ? 1 : 0;
|
|
755
|
-
const promptAndModeRows = Math.max(0, contentRows - separatorRows);
|
|
756
|
-
promptBudget = promptAndModeRows;
|
|
757
|
-
if (promptAndModeRows > 0) {
|
|
758
|
-
const promptMinRows = promptLines.length > 0 ? 1 : 0;
|
|
759
|
-
const maximumModeRows = Math.max(0, promptAndModeRows - promptMinRows);
|
|
760
|
-
const modeMinRows = Math.min(this.getMinimumModeRows(), maximumModeRows);
|
|
761
|
-
modeBudget = Math.min(this.getPreferredModeRows(), maximumModeRows);
|
|
762
|
-
modeBudget = Math.max(modeMinRows, modeBudget);
|
|
763
|
-
promptBudget = promptAndModeRows - modeBudget;
|
|
764
|
-
const usefulPromptRows = Math.min(promptLines.length, promptAndModeRows >= modeMinRows + 2 ? 2 : promptMinRows);
|
|
765
|
-
if (promptBudget < usefulPromptRows && modeBudget > modeMinRows) {
|
|
766
|
-
const shiftedRows = Math.min(usefulPromptRows - promptBudget, modeBudget - modeMinRows);
|
|
767
|
-
modeBudget -= shiftedRows;
|
|
768
|
-
promptBudget += shiftedRows;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
} else {
|
|
772
|
-
modeBudget = Math.min(this.getPreferredModeRows(), contentRows);
|
|
773
|
-
modeBudget = Math.max(Math.min(this.getMinimumModeRows(), contentRows), modeBudget);
|
|
774
|
-
promptBudget = Math.max(0, contentRows - modeBudget);
|
|
775
|
-
if (promptBudget > 0 && modeBudget > 0) {
|
|
776
|
-
separatorRows = 1;
|
|
777
|
-
promptBudget = Math.max(0, promptBudget - separatorRows);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
const modeLines = this.renderModeLines(innerWidth, modeBudget);
|
|
782
|
-
if (modeLines.length < modeBudget) promptBudget += modeBudget - modeLines.length;
|
|
783
|
-
|
|
784
|
-
const promptPaneLines = this.renderPromptPane(promptLines, promptBudget, innerWidth);
|
|
785
|
-
const helpLines = this.limitLines(helpFullLines, helpBudget, innerWidth, false);
|
|
786
|
-
const bodyLines = [
|
|
787
|
-
...promptPaneLines,
|
|
788
|
-
...(separatorRows > 0 && promptPaneLines.length > 0 && modeLines.length > 0 ? [""] : []),
|
|
789
|
-
...modeLines,
|
|
790
|
-
...helpLines,
|
|
791
|
-
];
|
|
792
|
-
return this.frameBodyLines(bodyLines.slice(0, bodyCapacity), width, innerWidth);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
private buildPromptLines(width: number): string[] {
|
|
796
|
-
return [
|
|
797
|
-
...this.titleText.render(width),
|
|
798
|
-
...this.questionText.render(width),
|
|
799
|
-
...(this.contextComponent ? ["", ...this.contextComponent.render(width)] : []),
|
|
800
|
-
];
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
private getHelpBudget(bodyCapacity: number, renderedHelpRows: number): number {
|
|
804
|
-
if (renderedHelpRows <= 0 || bodyCapacity <= 0) return 0;
|
|
805
|
-
return bodyCapacity >= 12 ? Math.min(2, renderedHelpRows) : 1;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
private getMinimumModeRows(): number {
|
|
809
|
-
if (this.mode === "freeform") return 5;
|
|
810
|
-
if (this.mode === "comment") return 6;
|
|
811
|
-
return this.allowMultiple ? 3 : 4;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
private getPreferredModeRows(): number {
|
|
815
|
-
if (this.mode === "freeform") return 10;
|
|
816
|
-
if (this.mode === "comment") return 11;
|
|
817
|
-
return 8;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
private renderModeLines(width: number, budget: number): string[] {
|
|
821
|
-
const safeBudget = Math.max(0, Math.floor(budget));
|
|
822
|
-
if (safeBudget <= 0) return [];
|
|
823
|
-
if (this.mode === "select") {
|
|
824
|
-
if (this.allowMultiple) this.ensureMultiSelectList().setMaxVisibleRows(Math.max(1, safeBudget));
|
|
825
|
-
else this.ensureSingleSelectList().setMaxVisibleRows(Math.max(1, safeBudget));
|
|
826
|
-
return this.limitLines(this.modeContainer.render(width), safeBudget, width, true);
|
|
827
|
-
}
|
|
828
|
-
return this.renderEditorModeLines(width, safeBudget);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
private renderEditorModeLines(width: number, budget: number): string[] {
|
|
832
|
-
const headerLines = this.buildEditorModeHeaderLines(width);
|
|
833
|
-
const minimumEditorRows = Math.min(3, budget);
|
|
834
|
-
const headerBudget = Math.max(0, budget - minimumEditorRows);
|
|
835
|
-
const visibleHeaderLines = this.limitLines(headerLines, headerBudget, width, true);
|
|
836
|
-
const editorBudget = Math.max(0, budget - visibleHeaderLines.length);
|
|
837
|
-
return [...visibleHeaderLines, ...this.limitEditorLines(this.ensureEditor().render(width), editorBudget, width)];
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
private buildEditorModeHeaderLines(width: number): string[] {
|
|
841
|
-
if (this.mode === "comment") {
|
|
842
|
-
const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
|
|
843
|
-
return [
|
|
844
|
-
...new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0).render(width),
|
|
845
|
-
...new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0).render(width),
|
|
846
|
-
"",
|
|
847
|
-
];
|
|
848
|
-
}
|
|
849
|
-
// Only meaningful when reached by escaping OUT of a real select list -- see showFreeformMode's
|
|
850
|
-
// identical guard.
|
|
851
|
-
if (this.options.length === 0) return [];
|
|
852
|
-
return [...new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0).render(width), ""];
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
private limitEditorLines(lines: string[], budget: number, width: number): string[] {
|
|
856
|
-
const safeBudget = Math.max(0, Math.floor(budget));
|
|
857
|
-
if (safeBudget <= 0) return [];
|
|
858
|
-
if (lines.length <= safeBudget) return lines.map((line) => truncateToWidth(line, width, "", true));
|
|
859
|
-
if (safeBudget === 1) return [this.theme.fg("dim", "…")];
|
|
860
|
-
|
|
861
|
-
const topBorder = truncateToWidth(lines[0] ?? "", width, "", true);
|
|
862
|
-
const bottomBorder = truncateToWidth(lines[lines.length - 1] ?? "", width, "", true);
|
|
863
|
-
if (safeBudget === 2) return [topBorder, bottomBorder];
|
|
864
|
-
|
|
865
|
-
const contentLines = lines.slice(1, -1);
|
|
866
|
-
const contentBudget = safeBudget - 2;
|
|
867
|
-
const cursorLineIndex = contentLines.findIndex((line) => line.includes(CURSOR_MARKER) || line.includes("\x1b[7m"));
|
|
868
|
-
const maxStart = Math.max(0, contentLines.length - contentBudget);
|
|
869
|
-
const start = cursorLineIndex >= 0 ? Math.max(0, Math.min(cursorLineIndex - contentBudget + 1, maxStart)) : maxStart;
|
|
870
|
-
const visibleContentLines = contentLines.slice(start, start + contentBudget);
|
|
871
|
-
const markedContentLines = this.applyPromptOverflowMarkers(
|
|
872
|
-
visibleContentLines,
|
|
873
|
-
width,
|
|
874
|
-
start > 0,
|
|
875
|
-
start + contentBudget < contentLines.length,
|
|
876
|
-
);
|
|
877
|
-
return [topBorder, ...markedContentLines, bottomBorder];
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
private renderPromptPane(promptLines: string[], budget: number, width: number): string[] {
|
|
881
|
-
const viewportRows = Math.max(0, Math.floor(budget));
|
|
882
|
-
this.promptViewportRows = viewportRows;
|
|
883
|
-
if (viewportRows <= 0 || promptLines.length === 0) {
|
|
884
|
-
this.promptMaxScrollOffset = 0;
|
|
885
|
-
this.promptScrollOffset = 0;
|
|
886
|
-
return [];
|
|
887
|
-
}
|
|
888
|
-
this.promptMaxScrollOffset = Math.max(0, promptLines.length - viewportRows);
|
|
889
|
-
this.promptScrollOffset = Math.max(0, Math.min(this.promptScrollOffset, this.promptMaxScrollOffset));
|
|
890
|
-
const visibleLines = promptLines.slice(this.promptScrollOffset, this.promptScrollOffset + viewportRows);
|
|
891
|
-
return this.applyPromptOverflowMarkers(
|
|
892
|
-
visibleLines,
|
|
893
|
-
width,
|
|
894
|
-
this.promptScrollOffset > 0,
|
|
895
|
-
this.promptScrollOffset + viewportRows < promptLines.length,
|
|
896
|
-
);
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
private applyPromptOverflowMarkers(lines: string[], width: number, hasHiddenAbove: boolean, hasHiddenBelow: boolean): string[] {
|
|
900
|
-
if (lines.length === 0) return lines;
|
|
901
|
-
const marked = [...lines];
|
|
902
|
-
if (hasHiddenAbove && hasHiddenBelow && marked.length === 1) {
|
|
903
|
-
marked[0] = this.addPromptOverflowMarker(marked[0] ?? "", "↕", width);
|
|
904
|
-
return marked;
|
|
905
|
-
}
|
|
906
|
-
if (hasHiddenAbove) marked[0] = this.addPromptOverflowMarker(marked[0] ?? "", "↑", width);
|
|
907
|
-
if (hasHiddenBelow) {
|
|
908
|
-
const lastIndex = marked.length - 1;
|
|
909
|
-
marked[lastIndex] = this.addPromptOverflowMarker(marked[lastIndex] ?? "", "↓", width);
|
|
910
|
-
}
|
|
911
|
-
return marked;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
private addPromptOverflowMarker(line: string, marker: string, width: number): string {
|
|
915
|
-
return truncateToWidth(`${this.theme.fg("dim", marker)} ${line}`, width, "", true);
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
private limitLines(lines: string[], budget: number, width: number, showOverflowMarker: boolean): string[] {
|
|
919
|
-
const safeBudget = Math.max(0, Math.floor(budget));
|
|
920
|
-
if (safeBudget <= 0) return [];
|
|
921
|
-
if (lines.length <= safeBudget) return lines.map((line) => truncateToWidth(line, width, "", true));
|
|
922
|
-
if (!showOverflowMarker) return lines.slice(0, safeBudget).map((line) => truncateToWidth(line, width, "", true));
|
|
923
|
-
if (safeBudget === 1) return [this.theme.fg("dim", "…")];
|
|
924
|
-
return [...lines.slice(0, safeBudget - 1).map((line) => truncateToWidth(line, width, "", true)), this.theme.fg("dim", "…")];
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
private renderTopBorder(width: number): string {
|
|
928
|
-
return (
|
|
929
|
-
new BoxBorderTop(
|
|
930
|
-
(s) => this.theme.fg("accent", s),
|
|
931
|
-
"discuss",
|
|
932
|
-
(s) => this.theme.fg("dim", this.theme.bold(s)),
|
|
933
|
-
).render(width)[0] ?? ""
|
|
934
|
-
);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
private renderBottomBorder(width: number): string {
|
|
938
|
-
return new BoxBorderBottom((s) => this.theme.fg("accent", s)).render(width)[0] ?? "";
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
private frameBodyLines(bodyLines: string[], width: number, innerWidth: number): string[] {
|
|
942
|
-
const borderColor = (s: string) => this.theme.fg("accent", s);
|
|
943
|
-
return [
|
|
944
|
-
this.renderTopBorder(width),
|
|
945
|
-
...bodyLines.map(
|
|
946
|
-
(line) => `${borderColor(BOX_BORDER_LEFT)}${truncateToWidth(line, innerWidth, "", true)}${borderColor(BOX_BORDER_RIGHT)}`,
|
|
947
|
-
),
|
|
948
|
-
this.renderBottomBorder(width),
|
|
949
|
-
];
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
private updateStaticText(): void {
|
|
953
|
-
const theme = this.theme;
|
|
954
|
-
// Reuses the same slot for two different purposes: a plain "which discussion is this" subtitle
|
|
955
|
-
// normally, or "Optional comment" while in comment mode. A generic "Question" header above the
|
|
956
|
-
// real question text added nothing beyond what the question itself already says, and read
|
|
957
|
-
// confusingly like the question text WAS the header.
|
|
958
|
-
this.titleText.setText(
|
|
959
|
-
this.mode === "comment" ? theme.fg("accent", theme.bold("Optional comment")) : this.subtitle ? theme.fg("dim", this.subtitle) : "",
|
|
960
|
-
);
|
|
961
|
-
this.questionText.setText(theme.fg("text", theme.bold(this.question)));
|
|
962
|
-
if (this.contextComponent && this.context) {
|
|
963
|
-
if (this.contextComponent instanceof Markdown) (this.contextComponent as Markdown).setText(`**Context:**\n${this.context}`);
|
|
964
|
-
else (this.contextComponent as Text).setText(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`);
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
private updateHelpText(): void {
|
|
969
|
-
const theme = this.theme;
|
|
970
|
-
const promptScrollHint = literalHint(theme, "PgUp/PgDn", "prompt");
|
|
971
|
-
const commentHint =
|
|
972
|
-
this.allowComment && !this.shortcuts.commentToggle.disabled
|
|
973
|
-
? literalHint(theme, this.shortcuts.commentToggle.spec, "toggle context")
|
|
974
|
-
: null;
|
|
975
|
-
|
|
976
|
-
if (this.mode === "freeform" || this.mode === "comment") {
|
|
977
|
-
const alternateCancelKeys = this.keybindings.getKeys("tui.select.cancel").filter((key) => key !== "escape" && key !== "esc");
|
|
978
|
-
const canGoBack = this.options.length > 0;
|
|
979
|
-
const hints = [
|
|
980
|
-
keybindingHint(theme, this.keybindings, "tui.input.submit", this.mode === "comment" ? "submit/skip" : "submit"),
|
|
981
|
-
keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
|
|
982
|
-
literalHint(theme, "esc", canGoBack ? "back" : "cancel"),
|
|
983
|
-
canGoBack && alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
|
|
984
|
-
]
|
|
985
|
-
.filter((hint): hint is string => !!hint)
|
|
986
|
-
.join(" • ");
|
|
987
|
-
this.helpText.setText(theme.fg("dim", hints));
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
if (this.allowMultiple) {
|
|
992
|
-
const hints = [
|
|
993
|
-
literalHint(theme, "↑↓", "navigate"),
|
|
994
|
-
literalHint(theme, "space", "toggle"),
|
|
995
|
-
commentHint,
|
|
996
|
-
promptScrollHint,
|
|
997
|
-
keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
|
|
998
|
-
keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
|
|
999
|
-
]
|
|
1000
|
-
.filter((hint): hint is string => !!hint)
|
|
1001
|
-
.join(" • ");
|
|
1002
|
-
this.helpText.setText(theme.fg("dim", hints));
|
|
1003
|
-
} else {
|
|
1004
|
-
const alternateCancelKeys = this.keybindings.getKeys("tui.select.cancel").filter((key) => key !== "escape" && key !== "esc");
|
|
1005
|
-
const hints = [
|
|
1006
|
-
literalHint(theme, "type", "filter"),
|
|
1007
|
-
commentHint,
|
|
1008
|
-
promptScrollHint,
|
|
1009
|
-
keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
|
|
1010
|
-
literalHint(theme, "↑↓", "navigate"),
|
|
1011
|
-
keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
|
|
1012
|
-
literalHint(theme, "esc", "clear/cancel"),
|
|
1013
|
-
alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
|
|
1014
|
-
]
|
|
1015
|
-
.filter((hint): hint is string => !!hint)
|
|
1016
|
-
.join(" • ");
|
|
1017
|
-
this.helpText.setText(theme.fg("dim", hints));
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
1022
|
-
if (this.singleSelectList) return this.singleSelectList;
|
|
1023
|
-
const list = new WrappedSingleSelectList(
|
|
1024
|
-
this.options,
|
|
1025
|
-
this.allowFreeform,
|
|
1026
|
-
this.allowComment,
|
|
1027
|
-
this.theme,
|
|
1028
|
-
this.keybindings,
|
|
1029
|
-
this.shortcuts.commentToggle,
|
|
1030
|
-
);
|
|
1031
|
-
list.onSubmit = (result) => this.handleSelectionSubmit([result], list.isCommentEnabled());
|
|
1032
|
-
list.onCancel = () => this.onDone(null);
|
|
1033
|
-
list.onEnterFreeform = () => this.showFreeformMode();
|
|
1034
|
-
this.singleSelectList = list;
|
|
1035
|
-
return list;
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
private ensureMultiSelectList(): DiscussionMultiSelectList {
|
|
1039
|
-
if (this.multiSelectList) return this.multiSelectList;
|
|
1040
|
-
const list = new DiscussionMultiSelectList(
|
|
1041
|
-
this.options,
|
|
1042
|
-
this.allowFreeform,
|
|
1043
|
-
this.allowComment,
|
|
1044
|
-
this.theme,
|
|
1045
|
-
this.keybindings,
|
|
1046
|
-
this.shortcuts.commentToggle,
|
|
1047
|
-
);
|
|
1048
|
-
list.onCancel = () => this.onDone(null);
|
|
1049
|
-
list.onSubmit = (result) => this.handleSelectionSubmit(result, list.isCommentEnabled());
|
|
1050
|
-
list.onEnterFreeform = () => this.showFreeformMode();
|
|
1051
|
-
this.multiSelectList = list;
|
|
1052
|
-
return list;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
private ensureEditor(): Editor {
|
|
1056
|
-
if (this.editor) return this.editor;
|
|
1057
|
-
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
1058
|
-
editor.disableSubmit = false;
|
|
1059
|
-
editor.onSubmit = (text: string) => this.handleEditorSubmit(text);
|
|
1060
|
-
this.editor = editor;
|
|
1061
|
-
return editor;
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
private saveEditorDraft(): void {
|
|
1065
|
-
if (!this.editor) return;
|
|
1066
|
-
const getText = (this.editor as any).getText;
|
|
1067
|
-
if (typeof getText !== "function") return;
|
|
1068
|
-
const currentText = String(getText.call(this.editor) ?? "");
|
|
1069
|
-
if (this.mode === "freeform") this.freeformDraft = currentText;
|
|
1070
|
-
else if (this.mode === "comment") this.commentDraft = currentText;
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
private setEditorText(text: string): void {
|
|
1074
|
-
const editor = this.ensureEditor();
|
|
1075
|
-
const setText = (editor as any).setText;
|
|
1076
|
-
if (typeof setText === "function") setText.call(editor, text);
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
private handleSelectionSubmit(selections: string[], wantsComment: boolean): void {
|
|
1080
|
-
if (this.allowComment && wantsComment) {
|
|
1081
|
-
this.pendingSelections = selections;
|
|
1082
|
-
this.commentDraft = "";
|
|
1083
|
-
this.showCommentMode();
|
|
1084
|
-
return;
|
|
1085
|
-
}
|
|
1086
|
-
this.onDone(createSelectionResponse(selections));
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
private handleEditorSubmit(text: string): void {
|
|
1090
|
-
if (this.mode === "freeform") {
|
|
1091
|
-
this.onDone(createFreeformResponse(text));
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
if (this.mode === "comment") {
|
|
1095
|
-
this.commentDraft = text;
|
|
1096
|
-
this.onDone(createSelectionResponse(this.pendingSelections, text));
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
private showSelectMode(): void {
|
|
1101
|
-
if (this.mode === "freeform" || this.mode === "comment") this.saveEditorDraft();
|
|
1102
|
-
this.mode = "select";
|
|
1103
|
-
this.pendingSelections = [];
|
|
1104
|
-
this.modeContainer.clear();
|
|
1105
|
-
this.modeContainer.addChild(this.allowMultiple ? this.ensureMultiSelectList() : this.ensureSingleSelectList());
|
|
1106
|
-
this.updateHelpText();
|
|
1107
|
-
this.invalidate();
|
|
1108
|
-
this.tui.requestRender();
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
private showFreeformMode(): void {
|
|
1112
|
-
if (this.mode === "comment") this.saveEditorDraft();
|
|
1113
|
-
this.mode = "freeform";
|
|
1114
|
-
this.modeContainer.clear();
|
|
1115
|
-
const editor = this.ensureEditor();
|
|
1116
|
-
this.setEditorText(this.freeformDraft);
|
|
1117
|
-
(editor as any).focused = this._focused;
|
|
1118
|
-
// Only meaningful when reached by escaping OUT of a real select list ("instead of these
|
|
1119
|
-
// options, here's a custom one") -- with no options at all there's nothing to contrast
|
|
1120
|
-
// against, so the label is pure noise.
|
|
1121
|
-
if (this.options.length > 0) {
|
|
1122
|
-
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom answer")), 1, 0));
|
|
1123
|
-
this.modeContainer.addChild(new Spacer(1));
|
|
1124
|
-
}
|
|
1125
|
-
this.modeContainer.addChild(editor);
|
|
1126
|
-
this.updateHelpText();
|
|
1127
|
-
this.invalidate();
|
|
1128
|
-
this.tui.requestRender();
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
private showCommentMode(): void {
|
|
1132
|
-
if (this.mode === "freeform") this.saveEditorDraft();
|
|
1133
|
-
this.mode = "comment";
|
|
1134
|
-
this.modeContainer.clear();
|
|
1135
|
-
const editor = this.ensureEditor();
|
|
1136
|
-
this.setEditorText(this.commentDraft);
|
|
1137
|
-
(editor as any).focused = this._focused;
|
|
1138
|
-
const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
|
|
1139
|
-
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0));
|
|
1140
|
-
this.modeContainer.addChild(new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0));
|
|
1141
|
-
this.modeContainer.addChild(new Spacer(1));
|
|
1142
|
-
this.modeContainer.addChild(editor);
|
|
1143
|
-
this.updateHelpText();
|
|
1144
|
-
this.invalidate();
|
|
1145
|
-
this.tui.requestRender();
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
private setPromptScrollOffset(nextOffset: number): boolean {
|
|
1149
|
-
if (this.promptMaxScrollOffset <= 0) return false;
|
|
1150
|
-
const clamped = Math.max(0, Math.min(Math.floor(nextOffset), this.promptMaxScrollOffset));
|
|
1151
|
-
const changed = clamped !== this.promptScrollOffset;
|
|
1152
|
-
this.promptScrollOffset = clamped;
|
|
1153
|
-
return changed;
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
private handlePromptScrollInput(data: string): boolean {
|
|
1157
|
-
if (this.promptMaxScrollOffset <= 0) return false;
|
|
1158
|
-
if (this.mode !== "select") return false;
|
|
1159
|
-
const pageRows = Math.max(1, this.promptViewportRows - 1);
|
|
1160
|
-
const halfPageRows = Math.max(1, Math.floor(this.promptViewportRows / 2));
|
|
1161
|
-
if (matchesKey(data, PROMPT_SCROLL_PAGE_UP_KEY)) {
|
|
1162
|
-
this.setPromptScrollOffset(this.promptScrollOffset - pageRows);
|
|
1163
|
-
return true;
|
|
1164
|
-
}
|
|
1165
|
-
if (matchesKey(data, PROMPT_SCROLL_PAGE_DOWN_KEY)) {
|
|
1166
|
-
this.setPromptScrollOffset(this.promptScrollOffset + pageRows);
|
|
1167
|
-
return true;
|
|
1168
|
-
}
|
|
1169
|
-
if (matchesKey(data, PROMPT_SCROLL_HOME_KEY)) {
|
|
1170
|
-
this.setPromptScrollOffset(0);
|
|
1171
|
-
return true;
|
|
1172
|
-
}
|
|
1173
|
-
if (matchesKey(data, PROMPT_SCROLL_END_KEY)) {
|
|
1174
|
-
this.setPromptScrollOffset(this.promptMaxScrollOffset);
|
|
1175
|
-
return true;
|
|
1176
|
-
}
|
|
1177
|
-
if (matchesKey(data, PROMPT_SCROLL_HALF_PAGE_UP_KEY)) {
|
|
1178
|
-
this.setPromptScrollOffset(this.promptScrollOffset - halfPageRows);
|
|
1179
|
-
return true;
|
|
1180
|
-
}
|
|
1181
|
-
if (matchesKey(data, PROMPT_SCROLL_HALF_PAGE_DOWN_KEY)) {
|
|
1182
|
-
this.setPromptScrollOffset(this.promptScrollOffset + halfPageRows);
|
|
1183
|
-
return true;
|
|
1184
|
-
}
|
|
1185
|
-
return false;
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
handleInput(data: string): void {
|
|
1189
|
-
if (this.handlePromptScrollInput(data)) {
|
|
1190
|
-
this.tui.requestRender();
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1194
|
-
// A freeform-only ask has no select mode to go back to -- escape cancels outright.
|
|
1195
|
-
if (matchesKey(data, Key.escape)) {
|
|
1196
|
-
if (this.options.length > 0) this.showSelectMode();
|
|
1197
|
-
else this.onDone(null);
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
1201
|
-
this.onDone(null);
|
|
1202
|
-
return;
|
|
1203
|
-
}
|
|
1204
|
-
this.ensureEditor().handleInput(data);
|
|
1205
|
-
this.tui.requestRender();
|
|
1206
|
-
return;
|
|
1207
|
-
}
|
|
1208
|
-
if (this.allowMultiple) {
|
|
1209
|
-
this.ensureMultiSelectList().handleInput?.(data);
|
|
1210
|
-
this.tui.requestRender();
|
|
1211
|
-
return;
|
|
1212
|
-
}
|
|
1213
|
-
this.ensureSingleSelectList().handleInput?.(data);
|
|
1214
|
-
this.tui.requestRender();
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
/** Plain dialog fallback (select/input) for a UI mode without setEditorComponent support. */
|
|
1219
|
-
async function askViaDialogs(
|
|
1220
|
-
ui: ExtensionContext["ui"],
|
|
1221
|
-
question: string,
|
|
1222
|
-
context: string | undefined,
|
|
1223
|
-
options: AskOption[],
|
|
1224
|
-
allowMultiple: boolean,
|
|
1225
|
-
allowFreeform: boolean,
|
|
1226
|
-
allowComment: boolean,
|
|
1227
|
-
timeout?: number,
|
|
1228
|
-
): Promise<AskResponse | null> {
|
|
1229
|
-
const dialogOpts = timeout ? { timeout } : undefined;
|
|
1230
|
-
const prompt = context ? `${question}\n\nContext:\n${context}` : question;
|
|
1231
|
-
|
|
1232
|
-
if (options.length === 0) {
|
|
1233
|
-
const answer = (await ui.input(prompt, "Type your answer...", dialogOpts)) as string | undefined;
|
|
1234
|
-
return isCancelledInput(answer) ? null : createFreeformResponse(answer);
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
if (allowMultiple) {
|
|
1238
|
-
const rawSelections = (await ui.input(
|
|
1239
|
-
`${prompt}\n\nOptions (select one or more):\n${formatOptionsForMessage(options)}`,
|
|
1240
|
-
"Type your selection(s)...",
|
|
1241
|
-
dialogOpts,
|
|
1242
|
-
)) as string | undefined;
|
|
1243
|
-
if (isCancelledInput(rawSelections)) return null;
|
|
1244
|
-
const selections = parseDialogSelections(rawSelections);
|
|
1245
|
-
if (selections.length === 0) return null;
|
|
1246
|
-
if (!allowComment) return createSelectionResponse(selections);
|
|
1247
|
-
const comment = (await ui.input(buildCommentPrompt(prompt, selections), "Optional comment (press Enter to skip)...", dialogOpts)) as
|
|
1248
|
-
| string
|
|
1249
|
-
| undefined;
|
|
1250
|
-
return createSelectionResponse(selections, comment);
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
const selectOptions = options.map((o) => o.title);
|
|
1254
|
-
if (allowFreeform) selectOptions.push(FREEFORM_SENTINEL);
|
|
1255
|
-
const selected = (await ui.select(prompt, selectOptions, dialogOpts)) as string | undefined;
|
|
1256
|
-
if (isCancelledInput(selected)) return null;
|
|
1257
|
-
|
|
1258
|
-
if (selected === FREEFORM_SENTINEL) {
|
|
1259
|
-
const answer = (await ui.input(prompt, "Type your answer...", dialogOpts)) as string | undefined;
|
|
1260
|
-
return isCancelledInput(answer) ? null : createFreeformResponse(answer);
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
if (!allowComment) return createSelectionResponse([selected]);
|
|
1264
|
-
const comment = (await ui.input(buildCommentPrompt(prompt, [selected]), "Optional comment (press Enter to skip)...", dialogOpts)) as
|
|
1265
|
-
| string
|
|
1266
|
-
| undefined;
|
|
1267
|
-
return createSelectionResponse([selected], comment);
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
/**
|
|
1271
|
-
* Tracks whether a live ask is genuinely mid-flight, blocked on the human. `ExtensionContext.isIdle()`
|
|
1272
|
-
* means "not streaming a model response" -- it reads true while a slow, human-blocking tool call
|
|
1273
|
-
* like this one is still pending, since the model already finished emitting the tool_call and
|
|
1274
|
-
* is not itself generating anything. Left unguarded, that lets the active-task continuation
|
|
1275
|
-
* driver (extension/src/index.ts's driveActiveTasks, on agent_settled) queue a "continue the
|
|
1276
|
-
* active task" nudge as a `deliverAs: "nextTurn"` message while this exact live ask is still
|
|
1277
|
-
* awaiting an answer -- starting a second, concurrent turn that reasons about the very Discussion
|
|
1278
|
-
* this call is already resolving, independently of it. driveActiveTasks checks isLiveAskPending()
|
|
1279
|
-
* and skips queuing while true.
|
|
1280
|
-
*/
|
|
1281
|
-
let livePendingCount = 0;
|
|
1282
|
-
|
|
1283
|
-
export function isLiveAskPending(): boolean {
|
|
1284
|
-
return livePendingCount > 0;
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
const DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS = 100;
|
|
1288
|
-
const DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS = 1_500;
|
|
1289
|
-
const DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS = 300;
|
|
1290
|
-
const DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS = 10_000;
|
|
1291
|
-
|
|
1292
|
-
let typingCourtesyPollMs = DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS;
|
|
1293
|
-
let typingCourtesyInitialQuietMs = DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS;
|
|
1294
|
-
let typingCourtesyQuietFloorMs = DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS;
|
|
1295
|
-
let typingCourtesyDecayHorizonMs = DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS;
|
|
1296
|
-
|
|
1297
|
-
/** Test-only: the real decay curve runs over seconds, too slow to exercise at its real scale in a unit test. */
|
|
1298
|
-
export function setTypingCourtesyTimingForTests(overrides?: {
|
|
1299
|
-
pollMs?: number;
|
|
1300
|
-
initialQuietMs?: number;
|
|
1301
|
-
floorMs?: number;
|
|
1302
|
-
decayHorizonMs?: number;
|
|
1303
|
-
}): void {
|
|
1304
|
-
typingCourtesyPollMs = overrides?.pollMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_POLL_MS;
|
|
1305
|
-
typingCourtesyInitialQuietMs = overrides?.initialQuietMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_INITIAL_QUIET_MS;
|
|
1306
|
-
typingCourtesyQuietFloorMs = overrides?.floorMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_QUIET_FLOOR_MS;
|
|
1307
|
-
typingCourtesyDecayHorizonMs = overrides?.decayHorizonMs ?? DISCUSS_TYPING_COURTESY_DEFAULT_DECAY_HORIZON_MS;
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
function isTypingCourtesyEnabled(): boolean {
|
|
1311
|
-
return parseBooleanPreference(process.env.PAPYRUS_DISCUSS_TYPING_COURTESY) ?? true;
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1315
|
-
return new Promise((resolve) => {
|
|
1316
|
-
if (signal?.aborted) {
|
|
1317
|
-
resolve();
|
|
1318
|
-
return;
|
|
1319
|
-
}
|
|
1320
|
-
const timer = setTimeout(resolve, ms);
|
|
1321
|
-
signal?.addEventListener(
|
|
1322
|
-
"abort",
|
|
1323
|
-
() => {
|
|
1324
|
-
clearTimeout(timer);
|
|
1325
|
-
resolve();
|
|
1326
|
-
},
|
|
1327
|
-
{ once: true },
|
|
1328
|
-
);
|
|
1329
|
-
});
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
/**
|
|
1333
|
-
* Required quiet gap (no keystroke) before a live ask may open, as a function of how long we've
|
|
1334
|
-
* already been waiting. Starts wide (a natural inter-word pause shouldn't count as "done typing")
|
|
1335
|
-
* and decays toward a floor -- someone typing continuously gets pickier treatment over time
|
|
1336
|
-
* rather than never being asked. No outer cap: someone typing with sub-floor gaps forever waits
|
|
1337
|
-
* forever, same as the picker itself already waits indefinitely for a real human answer once open.
|
|
1338
|
-
*/
|
|
1339
|
-
function requiredQuietMsAt(elapsedMs: number): number {
|
|
1340
|
-
const t = Math.min(1, Math.max(0, elapsedMs / typingCourtesyDecayHorizonMs));
|
|
1341
|
-
return typingCourtesyInitialQuietMs - t * (typingCourtesyInitialQuietMs - typingCourtesyQuietFloorMs);
|
|
1342
|
-
}
|
|
73
|
+
/** Test-only: forwarded so existing tests can still reset the shared module's ambient keystroke
|
|
74
|
+
* clock between cases without importing it directly. */
|
|
75
|
+
export { resetTypingCourtesyTrackingForTests, setTypingCourtesyTimingForTests } from "@danypops/vehicle-client-pi/hitl-ask-prompt";
|
|
1343
76
|
|
|
1344
77
|
/**
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
* distinct ui instance (reference equality; a session's real ui object is stable for its lifetime)
|
|
1349
|
-
* and left attached -- there is no unregister, matching onTerminalInput's own listener-return-value
|
|
1350
|
-
* contract elsewhere in this file.
|
|
1351
|
-
*/
|
|
1352
|
-
let lastKeystrokeAt = 0;
|
|
1353
|
-
let trackedUi: ExtensionContext["ui"] | undefined;
|
|
1354
|
-
|
|
1355
|
-
export function ensureTypingCourtesyTracking(ui: ExtensionContext["ui"]): void {
|
|
1356
|
-
if (typeof ui.onTerminalInput !== "function" || trackedUi === ui) return;
|
|
1357
|
-
trackedUi = ui;
|
|
1358
|
-
ui.onTerminalInput(() => {
|
|
1359
|
-
lastKeystrokeAt = Date.now();
|
|
1360
|
-
return undefined;
|
|
1361
|
-
});
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
/** Test-only: clears the ambient keystroke clock so one test's simulated typing can't bleed into another's. */
|
|
1365
|
-
export function resetTypingCourtesyTrackingForTests(): void {
|
|
1366
|
-
lastKeystrokeAt = 0;
|
|
1367
|
-
trackedUi = undefined;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
/**
|
|
1371
|
-
* Whether there is real, recent typing activity to wait out right now -- a plain synchronous read
|
|
1372
|
-
* of the ambient keystroke clock so the common case (nobody typing) never forces the caller
|
|
1373
|
-
* through an extra microtask. Deliberately not folded into waitForTypingCourtesy itself: an
|
|
1374
|
-
* unconditional `await` there -- even one that resolves immediately -- still yields once, which is
|
|
1375
|
-
* enough to let a signal aborted synchronously right after invoking askQuestion race past the
|
|
1376
|
-
* abort listener registered deeper in askQuestionBlocking and get missed entirely.
|
|
1377
|
-
*/
|
|
1378
|
-
export function isRecentlyTyping(): boolean {
|
|
1379
|
-
return lastKeystrokeAt > 0 && Date.now() - lastKeystrokeAt < typingCourtesyInitialQuietMs;
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
/**
|
|
1383
|
-
* Waits out real keystroke activity (not editor text content -- that can't distinguish "actively
|
|
1384
|
-
* typing" from "a stale draft sitting there", and misses a mid-thought erase-and-resume) before
|
|
1385
|
-
* popping the live ask over it. Only call when isRecentlyTyping() is already true.
|
|
1386
|
-
*/
|
|
1387
|
-
export async function waitForTypingCourtesy(params: Pick<AskQuestionParams, "onUpdate" | "signal">): Promise<void> {
|
|
1388
|
-
const startedAt = Date.now();
|
|
1389
|
-
let announced = false;
|
|
1390
|
-
while (lastKeystrokeAt > 0 && !params.signal?.aborted) {
|
|
1391
|
-
const elapsed = Date.now() - startedAt;
|
|
1392
|
-
if (Date.now() - lastKeystrokeAt >= requiredQuietMsAt(elapsed)) return;
|
|
1393
|
-
if (!announced) {
|
|
1394
|
-
announced = true;
|
|
1395
|
-
params.onUpdate?.({ content: [{ type: "text", text: "Waiting for you to finish typing before asking..." }], details: undefined });
|
|
1396
|
-
}
|
|
1397
|
-
await sleep(typingCourtesyPollMs, params.signal);
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
/**
|
|
1402
|
-
* Discuss's live:true synchronous ask -- interactive AskComponent when a real TUI is available,
|
|
1403
|
-
* dialog fallback (ctx.ui.select/input) in RPC/headless mode, no-op undefined without any
|
|
1404
|
-
* interactive UI at all. Never fabricates an answer: cancel, timeout, and non-interactive
|
|
1405
|
-
* contexts all resolve to undefined.
|
|
78
|
+
* Discuss's live:true synchronous ask -- resolves PAPYRUS_DISCUSS_* environment preferences into
|
|
79
|
+
* requestPiAskPrompt's explicit options, brands the box "discuss", and forwards everything else
|
|
80
|
+
* unchanged.
|
|
1406
81
|
*/
|
|
1407
82
|
export async function askQuestion(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
// see isRecentlyTyping's own comment for why the common case must stay synchronous.
|
|
1424
|
-
if (isTypingCourtesyEnabled() && isRecentlyTyping()) await waitForTypingCourtesy(params);
|
|
1425
|
-
params.onUpdate?.({ content: [{ type: "text", text: "Waiting for human input..." }], details: undefined });
|
|
1426
|
-
return await askQuestionBlocking(ctx, params, options, allowMultiple, allowFreeform, allowComment, normalizedContext);
|
|
1427
|
-
} finally {
|
|
1428
|
-
livePendingCount -= 1;
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
/**
|
|
1433
|
-
* Hosts an AskComponent in place of the real input editor (ctx.ui.setEditorComponent), the same
|
|
1434
|
-
* mechanism Pi's own slash-command menu ecosystem uses. getText() always returns the human's
|
|
1435
|
-
* real in-progress draft, captured once before swapping in -- setEditorComponent's own swap
|
|
1436
|
-
* logic reads getText() off the OUTGOING editor to carry a draft forward when restoring the
|
|
1437
|
-
* previous one afterward; if this returned anything else, restoring would silently overwrite a
|
|
1438
|
-
* real draft with an empty string. Implements EditorComponent directly rather than extending
|
|
1439
|
-
* CustomEditor: CustomEditor's
|
|
1440
|
-
* duck-typed actionHandlers Map would otherwise get every app-level action (model switching,
|
|
1441
|
-
* clear, suspend) copied onto it by Pi's own editor-swap code, none of which this host uses or
|
|
1442
|
-
* forwards -- avoiding the inheritance sidesteps that dead weight entirely.
|
|
1443
|
-
*/
|
|
1444
|
-
class DiscussEditorHost implements EditorComponent {
|
|
1445
|
-
constructor(
|
|
1446
|
-
private readonly ask: AskComponent,
|
|
1447
|
-
private readonly preservedText: string,
|
|
1448
|
-
) {}
|
|
1449
|
-
getText(): string {
|
|
1450
|
-
return this.preservedText;
|
|
1451
|
-
}
|
|
1452
|
-
setText(_text: string): void {}
|
|
1453
|
-
render(width: number): string[] {
|
|
1454
|
-
return this.ask.render(width);
|
|
1455
|
-
}
|
|
1456
|
-
handleInput(data: string): void {
|
|
1457
|
-
this.ask.handleInput(data);
|
|
1458
|
-
}
|
|
1459
|
-
invalidate(): void {
|
|
1460
|
-
this.ask.invalidate();
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
async function askViaEditorSwap(
|
|
1465
|
-
ctx: ExtensionContext,
|
|
1466
|
-
params: AskQuestionParams,
|
|
1467
|
-
options: AskOption[],
|
|
1468
|
-
allowMultiple: boolean,
|
|
1469
|
-
allowFreeform: boolean,
|
|
1470
|
-
allowComment: boolean,
|
|
1471
|
-
normalizedContext: string | undefined,
|
|
1472
|
-
shortcuts: ResolvedAskShortcuts,
|
|
1473
|
-
): Promise<AskResponse | null> {
|
|
1474
|
-
const previousFactory = ctx.ui.getEditorComponent();
|
|
1475
|
-
const preservedText = ctx.ui.getEditorText();
|
|
1476
|
-
// setEditorComponent's factory only receives an EditorTheme (borderColor + selectList) --
|
|
1477
|
-
// nowhere near AskComponent's actual dependency on the full Theme surface (.fg(), .bold(),
|
|
1478
|
-
// etc). ctx.ui.theme is the real, rich Theme; captured here rather than from the factory.
|
|
1479
|
-
const theme = ctx.ui.theme;
|
|
1480
|
-
return new Promise<AskResponse | null>((resolve) => {
|
|
1481
|
-
let settled = false;
|
|
1482
|
-
const finish = (result: AskResponse | null) => {
|
|
1483
|
-
if (settled) return;
|
|
1484
|
-
settled = true;
|
|
1485
|
-
ctx.ui.setEditorComponent(previousFactory);
|
|
1486
|
-
resolve(result);
|
|
1487
|
-
};
|
|
1488
|
-
if (params.signal) params.signal.addEventListener("abort", () => finish(null), { once: true });
|
|
1489
|
-
if (params.timeout && params.timeout > 0) setTimeout(() => finish(null), params.timeout);
|
|
1490
|
-
ctx.ui.setEditorComponent((tui: TUI, _editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
|
|
1491
|
-
const ask = new AskComponent(
|
|
1492
|
-
params.question,
|
|
1493
|
-
normalizedContext,
|
|
1494
|
-
params.subtitle,
|
|
1495
|
-
options,
|
|
1496
|
-
allowMultiple,
|
|
1497
|
-
allowFreeform,
|
|
1498
|
-
allowComment,
|
|
1499
|
-
tui,
|
|
1500
|
-
theme,
|
|
1501
|
-
keybindings,
|
|
1502
|
-
shortcuts,
|
|
1503
|
-
finish,
|
|
1504
|
-
);
|
|
1505
|
-
return new DiscussEditorHost(ask, preservedText);
|
|
1506
|
-
});
|
|
1507
|
-
});
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
async function askQuestionBlocking(
|
|
1511
|
-
ctx: ExtensionContext,
|
|
1512
|
-
params: AskQuestionParams,
|
|
1513
|
-
options: AskOption[],
|
|
1514
|
-
allowMultiple: boolean,
|
|
1515
|
-
allowFreeform: boolean,
|
|
1516
|
-
allowComment: boolean,
|
|
1517
|
-
normalizedContext: string | undefined,
|
|
1518
|
-
): Promise<AskAnswer | undefined> {
|
|
1519
|
-
const shortcuts: ResolvedAskShortcuts = {
|
|
1520
|
-
commentToggle: resolveShortcut(undefined, process.env.PAPYRUS_DISCUSS_COMMENT_TOGGLE_KEY, DEFAULT_COMMENT_TOGGLE_KEY),
|
|
83
|
+
const options: PiAskPromptOptions = {
|
|
84
|
+
question: params.question,
|
|
85
|
+
presentation: params.presentation,
|
|
86
|
+
context: params.context,
|
|
87
|
+
subtitle: params.subtitle,
|
|
88
|
+
boxTitle: "discuss",
|
|
89
|
+
options: params.options,
|
|
90
|
+
allowMultiple: params.allowMultiple,
|
|
91
|
+
allowFreeform: params.allowFreeform,
|
|
92
|
+
allowComment: params.allowComment ?? parseBooleanPreference(process.env.PAPYRUS_DISCUSS_ALLOW_COMMENT),
|
|
93
|
+
commentToggleKey: process.env.PAPYRUS_DISCUSS_COMMENT_TOGGLE_KEY,
|
|
94
|
+
typingCourtesy: parseBooleanPreference(process.env.PAPYRUS_DISCUSS_TYPING_COURTESY) ?? true,
|
|
95
|
+
timeout: params.timeout,
|
|
96
|
+
onUpdate: params.onUpdate,
|
|
97
|
+
signal: params.signal,
|
|
1521
98
|
};
|
|
1522
|
-
|
|
1523
|
-
// Falls to the plain dialog fallback if setEditorComponent isn't available in this UI mode.
|
|
1524
|
-
if (
|
|
1525
|
-
typeof ctx.ui.setEditorComponent === "function" &&
|
|
1526
|
-
typeof ctx.ui.getEditorComponent === "function" &&
|
|
1527
|
-
typeof ctx.ui.getEditorText === "function"
|
|
1528
|
-
) {
|
|
1529
|
-
const response = await askViaEditorSwap(ctx, params, options, allowMultiple, allowFreeform, allowComment, normalizedContext, shortcuts);
|
|
1530
|
-
return response ? toAskAnswer(response) : undefined;
|
|
1531
|
-
}
|
|
1532
|
-
const response = await askViaDialogs(
|
|
1533
|
-
ctx.ui,
|
|
1534
|
-
params.question,
|
|
1535
|
-
normalizedContext,
|
|
1536
|
-
options,
|
|
1537
|
-
allowMultiple,
|
|
1538
|
-
allowFreeform,
|
|
1539
|
-
allowComment,
|
|
1540
|
-
params.timeout,
|
|
1541
|
-
);
|
|
1542
|
-
return response ? toAskAnswer(response) : undefined;
|
|
99
|
+
return requestPiAskPrompt(ctx, options);
|
|
1543
100
|
}
|