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