@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 prompt orchestration: searchable single-select, checkbox multi-select,
3
- * freeform replies, and optional comments docked in Pi's input editor. Multi-select state and
4
- * viewport behavior come from Vehicle's Pi binding of Malevich; this module owns only
5
- * Discussion-specific modes and response mapping.
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
- createMultiSelectList,
12
- type MultiSelectListItem,
13
- type MultiSelectList as SharedMultiSelectList,
14
- } from "@danypops/vehicle-client-pi/multi-select-list";
15
- import type { AgentToolUpdateCallback, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
16
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
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
- /** See pi-ask-user's identical safeMarkdownTheme() comment: a broken theme Proxy throws only on
41
- * property access, not construction, so a bare try/catch around getMarkdownTheme() alone would
42
- * still crash mid-render. Probing bold("") forces the throw eagerly, so callers can fall back
43
- * to plain Text rendering instead. */
44
- function safeMarkdownTheme(): MarkdownTheme | undefined {
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
- function createFreeformResponse(text: string | null | undefined): AskResponse | null {
118
- const trimmed = text?.trim();
119
- return trimmed ? { kind: "freeform", text: trimmed } : null;
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
- private renderBudgetedLayout(width: number, innerWidth: number): string[] {
739
- const maxLines = this.getAskMaxRenderLines();
740
- if (maxLines <= 1) return [this.renderTopBorder(width)];
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
- * Ambient, session-lifetime keystroke clock -- deliberately NOT scoped per-ask. A per-ask listener
1346
- * would only see keystrokes from the moment the tool call happens to start, missing typing already
1347
- * in progress when it began (the exact case this feature exists to protect). Attached once per
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
- if (!ctx.hasUI || !ctx.ui) return undefined;
1409
- return askQuestionUnguarded(ctx, params);
1410
- }
1411
-
1412
- async function askQuestionUnguarded(ctx: ExtensionContext, params: AskQuestionParams): Promise<AskAnswer | undefined> {
1413
- const options = params.options ?? [];
1414
- const allowMultiple = params.allowMultiple ?? false;
1415
- const allowFreeform = params.allowFreeform ?? true;
1416
- const allowComment = params.allowComment ?? parseBooleanPreference(process.env.PAPYRUS_DISCUSS_ALLOW_COMMENT) ?? false;
1417
- const normalizedContext = params.context?.trim() || undefined;
1418
-
1419
- if (isTypingCourtesyEnabled()) ensureTypingCourtesyTracking(ctx.ui);
1420
- livePendingCount += 1;
1421
- try {
1422
- // Only actually awaits (yielding a microtask) when there's real typing activity to wait out --
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
  }