pi-bro 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,777 @@
1
+ import { rm, writeFile } from "node:fs/promises";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { copyToClipboard, getMarkdownTheme, getSelectListTheme } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ Editor,
7
+ Markdown,
8
+ matchesKey,
9
+ wrapTextWithAnsi,
10
+ type Component,
11
+ type EditorTheme,
12
+ type Focusable,
13
+ type TUI,
14
+ } from "@earendil-works/pi-tui";
15
+ import { ADVISOR_STEERING_ENTRY, ADVISOR_STEERING_FILE, resolveAdvisorState } from "./advisor.ts";
16
+ import { runBackendText } from "./backend.ts";
17
+ import {
18
+ type BroMode,
19
+ MAX_PREFERENCES_CHARS,
20
+ STARTER_PREFERENCES,
21
+ buildDefaultPrompt,
22
+ nextBroMode,
23
+ } from "./prompt.ts";
24
+ import {
25
+ type BroSettings,
26
+ CAPABILITIES,
27
+ CAPABILITY_LABELS,
28
+ PREFERENCES_FILE,
29
+ capabilityOverride,
30
+ queuePreferencesWrite,
31
+ readPreferences,
32
+ readPreferencesRaw,
33
+ selectionForCapability,
34
+ selectionLabel,
35
+ settingsFile,
36
+ } from "./settings.ts";
37
+ import { openShowHtml, stripShowDiagramFence } from "./show.ts";
38
+ import type { BroSource } from "./sources.ts";
39
+ import { BRO_OVERLAY, broOverlayArea, delegateMouse, formatNotice, modalHeight, openBroOverlay, renderFrame, Scroller, type Area, type NoticeTone, type Theme, type TuiLike } from "./modal-kit.ts";
40
+ import { hasBroCustomUi, type BroMouseEvent, type BroMouseResult } from "./ui-capabilities.ts";
41
+ import { errorMessage } from "./util.ts";
42
+
43
+ const LOADING_TEXT = "Simplifying for my bro…";
44
+
45
+ export type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
46
+ // `mode` is the built-in explain mode that produced the text; absent for Show and Doctor.
47
+ // `preferences` records whether bro-preferences.md shaped this result; like `mode`, it belongs to the result.
48
+ export type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode; preferences?: boolean };
49
+ export type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode; preferences?: boolean };
50
+
51
+ export function latestAssistant(ctx: ExtensionCommandContext): BroSource | undefined {
52
+ const branch = ctx.sessionManager.getBranch();
53
+
54
+ for (let i = branch.length - 1; i >= 0; i--) {
55
+ const entry = branch[i];
56
+ if (entry.type !== "message" || entry.message.role !== "assistant" || entry.message.stopReason !== "stop") {
57
+ continue;
58
+ }
59
+
60
+ const text = entry.message.content
61
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
62
+ .map((part) => part.text)
63
+ .join("\n")
64
+ .trim();
65
+
66
+ if (text) return { text };
67
+ }
68
+ }
69
+
70
+ export async function simplify(
71
+ response: string,
72
+ signal: AbortSignal,
73
+ settings: BroSettings,
74
+ onProgress?: (text: string) => void,
75
+ mode = settings.mode,
76
+ ): Promise<{ text: string; model: string; mode: BroMode; preferences: boolean }> {
77
+ const selection = selectionForCapability(settings, "explain");
78
+ const preferences = await readPreferences();
79
+ const text = await runBackendText(buildDefaultPrompt(response, mode, preferences), selection, signal, onProgress);
80
+ return { text, model: selectionLabel(selection), mode, preferences: Boolean(preferences) };
81
+ }
82
+
83
+ export type TextEditorModalOptions = {
84
+ title: string;
85
+ /** Always shown in full above the editor; the editor asks for a bigger terminal before hiding it. */
86
+ privacy: string;
87
+ subtitle: string;
88
+ initialText: string;
89
+ initialNotice?: string;
90
+ initialNoticeTone?: NoticeTone;
91
+ // Returns an error message to refuse the save without calling onSave.
92
+ validate?: (text: string) => string | undefined;
93
+ // Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
94
+ onSave: (text: string) => void | Promise<void>;
95
+ onClear: () => void | Promise<void>;
96
+ copy?: (text: string) => Promise<void>;
97
+ };
98
+
99
+ // Testable core shared by /bro advisor-steer and /bro preferences: persistence only happens on
100
+ // Ctrl+S/Ctrl+K. Esc leaves the stored text untouched; only the in-memory draft is discarded.
101
+ export function createTextEditorModal(
102
+ options: TextEditorModalOptions,
103
+ ): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
104
+ const { onSave, onClear, validate, copy = copyToClipboard } = options;
105
+ return (tui, theme, _keybindings, done) => {
106
+ const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
107
+ const editor = new Editor(tui, editorTheme);
108
+ editor.focused = true;
109
+ editor.setText(options.initialText);
110
+ let disposed = false;
111
+ let pending = false;
112
+ let closeRequested = false;
113
+ let lastSavedText = editor.getExpandedText();
114
+ let discardArmed = false;
115
+ let notice = options.initialNotice
116
+ ? formatNotice(theme, options.initialNotice, options.initialNoticeTone ?? "info")
117
+ : "";
118
+ // Where the editor was last drawn inside the overlay, for click forwarding.
119
+ let editorArea: Area = { top: 0, left: 0, width: 0, height: 0 };
120
+ // The editor did not fit at the last render; editing blind could save text the user never saw.
121
+ let tooSmall = false;
122
+ const showNotice = (message: string, tone: NoticeTone) => {
123
+ if (disposed) return;
124
+ notice = formatNotice(theme, message, tone);
125
+ tui.requestRender();
126
+ };
127
+ editor.onChange = () => {
128
+ notice = "";
129
+ discardArmed = false;
130
+ };
131
+ const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
132
+ const fail = (error: unknown) => {
133
+ closeRequested = false;
134
+ showNotice(`${failure}: ${errorMessage(error)}`, "error");
135
+ };
136
+ let result: void | Promise<void>;
137
+ try {
138
+ result = action();
139
+ } catch (error) {
140
+ fail(error);
141
+ return;
142
+ }
143
+ if (!(result instanceof Promise)) {
144
+ after?.();
145
+ showNotice(success, "success");
146
+ return;
147
+ }
148
+ pending = true;
149
+ result.then(
150
+ () => {
151
+ pending = false;
152
+ if (disposed) return;
153
+ after?.();
154
+ showNotice(success, "success");
155
+ if (closeRequested) {
156
+ closeRequested = false;
157
+ disposed = true;
158
+ done(undefined);
159
+ }
160
+ },
161
+ (error) => {
162
+ pending = false;
163
+ fail(error);
164
+ },
165
+ );
166
+ };
167
+
168
+ return {
169
+ // shortcut: Editor keeps its own min height (5 lines), so preferences fits from ~22 rows at
170
+ // 80+ columns; the subtitle gives way first, the privacy line never. Revisit if users report smaller terminals.
171
+ render: (width: number) => {
172
+ editorArea = { top: 0, left: 0, width: 0, height: 0 };
173
+ // Editor sizes itself from the terminal and cannot be cut without hiding its cursor.
174
+ const lines = editor.render(Math.max(1, width - 2));
175
+ const privacy = wrapTextWithAnsi(theme.fg("dim", options.privacy), Math.max(1, width - 2));
176
+ let fits = false;
177
+ const frame = renderFrame(theme, width, modalHeight(tui), {
178
+ header: () => theme.fg("accent", theme.bold(options.title)),
179
+ body: (rows, innerWidth) => {
180
+ fits = rows >= privacy.length + lines.length;
181
+ if (!fits) {
182
+ // Most important first: the body may have only a row or two.
183
+ const clear = notice ? "; any key clears the message below" : "";
184
+ return wrapTextWithAnsi(theme.fg("warning", `Enlarge the terminal to edit (about 22 rows). Ctrl+C still copies the text${clear}.`), innerWidth);
185
+ }
186
+ const subtitle = wrapTextWithAnsi(theme.fg("dim", options.subtitle), innerWidth).slice(0, rows - privacy.length - lines.length);
187
+ // Body rows start under the top border, header and rule.
188
+ editorArea = { top: 3 + privacy.length + subtitle.length, left: 1, width: innerWidth, height: lines.length };
189
+ return [...privacy, ...subtitle, ...lines];
190
+ },
191
+ notice,
192
+ // Hints change to discardArmed on first Esc without altering row height.
193
+ hints: discardArmed
194
+ ? "Esc again to discard · Ctrl+S save · Ctrl+C copy"
195
+ : "Ctrl+S save · Ctrl+K clear · Ctrl+C copy · Esc close",
196
+ fill: false,
197
+ // A long notice shrinks before it can push the editor or the privacy line off screen.
198
+ minBody: privacy.length + lines.length,
199
+ });
200
+ tooSmall = !fits;
201
+ return frame;
202
+ },
203
+ invalidate: () => editor.invalidate(),
204
+ handleMouse: (event: BroMouseEvent) => {
205
+ if (discardArmed && event.type === "click") {
206
+ discardArmed = false;
207
+ tui.requestRender();
208
+ }
209
+ return delegateMouse(editor, event, editorArea);
210
+ },
211
+ handleInput: (data: string) => {
212
+ if (matchesKey(data, "escape")) {
213
+ if (pending) {
214
+ closeRequested = true;
215
+ showNotice("Saving… closes when done", "info");
216
+ return;
217
+ }
218
+ const isDirty = editor.getExpandedText() !== lastSavedText;
219
+ if (isDirty && !discardArmed) {
220
+ discardArmed = true;
221
+ tui.requestRender();
222
+ return;
223
+ }
224
+ disposed = true;
225
+ done(undefined);
226
+ return;
227
+ }
228
+ if (discardArmed) {
229
+ discardArmed = false;
230
+ tui.requestRender();
231
+ }
232
+ // Copy works even when the editor does not fit, so a draft can always be rescued.
233
+ if (matchesKey(data, "ctrl+c")) {
234
+ const text = editor.getExpandedText();
235
+ void Promise.resolve()
236
+ .then(() => copy(text))
237
+ .then(() => showNotice("Copied", "success"))
238
+ .catch((error) => showNotice(`Copy failed: ${errorMessage(error)}`, "error"));
239
+ return;
240
+ }
241
+ // Even a one-line notice can tip the editor out at the edge sizes; any key clears it.
242
+ if (tooSmall && notice) {
243
+ notice = "";
244
+ tui.requestRender();
245
+ return;
246
+ }
247
+ if (pending || tooSmall) return;
248
+ if (matchesKey(data, "ctrl+s")) {
249
+ const text = editor.getExpandedText();
250
+ const invalid = validate?.(text);
251
+ if (invalid) {
252
+ showNotice(invalid, "error");
253
+ return;
254
+ }
255
+ persist(() => onSave(text), "Saved", "Save failed", () => {
256
+ lastSavedText = text;
257
+ discardArmed = false;
258
+ });
259
+ return;
260
+ }
261
+ if (matchesKey(data, "ctrl+k")) {
262
+ persist(onClear, "Cleared", "Clear failed", () => {
263
+ editor.setText("");
264
+ lastSavedText = "";
265
+ discardArmed = false;
266
+ });
267
+ return;
268
+ }
269
+ if (matchesKey(data, "enter")) {
270
+ editor.insertTextAtCursor("\n");
271
+ tui.requestRender();
272
+ return;
273
+ }
274
+ editor.handleInput(data);
275
+ tui.requestRender();
276
+ },
277
+ dispose: () => { disposed = true; },
278
+ };
279
+ };
280
+ }
281
+
282
+ export function createAdvisorSteerModal(
283
+ initialText: string,
284
+ onSave: (text: string) => void,
285
+ onClear: () => void,
286
+ copy: (text: string) => Promise<void> = copyToClipboard,
287
+ ): ReturnType<typeof createTextEditorModal> {
288
+ return createTextEditorModal({
289
+ title: "Bro · advisor steer",
290
+ privacy: "Never sent to the main model.",
291
+ subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}).`,
292
+ initialText,
293
+ onSave,
294
+ onClear,
295
+ copy,
296
+ });
297
+ }
298
+
299
+ function preferencesTooLong(text: string): string | undefined {
300
+ const length = text.replace(/^\uFEFF/, "").trim().length;
301
+ return length > MAX_PREFERENCES_CHARS
302
+ ? `${length.toLocaleString("en-US")}/${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} characters — trim before saving`
303
+ : undefined;
304
+ }
305
+
306
+ // Testable core for /bro preferences. A missing file opens with the unsaved starter text; an
307
+ // unreadable or oversize-on-disk file opens empty with the error, and Clear still works.
308
+ export function createPreferencesModal(
309
+ loaded: { text?: string; error?: string },
310
+ persist: { save: (text: string) => Promise<void>; clear: () => Promise<void> },
311
+ copy?: (text: string) => Promise<void>,
312
+ ): ReturnType<typeof createTextEditorModal> {
313
+ const starter = loaded.text === undefined && !loaded.error;
314
+ return createTextEditorModal({
315
+ title: "Bro · preferences",
316
+ privacy: "Never sent to the advisor or Pi's main model.",
317
+ subtitle: "About you and how you like answers; sent to the selected backend with every explain, show, btw, and Guided Review request.",
318
+ initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
319
+ initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it." : undefined),
320
+ initialNoticeTone: loaded.error ? "error" : "info",
321
+ validate: preferencesTooLong,
322
+ onSave: persist.save,
323
+ onClear: persist.clear,
324
+ copy,
325
+ });
326
+ }
327
+
328
+ export async function showPreferencesModal(ctx: ExtensionCommandContext): Promise<void> {
329
+ if (!hasBroCustomUi(ctx)) {
330
+ ctx.ui.notify(`Edit ${PREFERENCES_FILE} directly.`, "warning");
331
+ return;
332
+ }
333
+ let loaded: { text?: string; error?: string };
334
+ try {
335
+ const text = await readPreferencesRaw();
336
+ loaded = text === undefined ? {} : { text };
337
+ } catch (error) {
338
+ // Action first: long notices are cut after three lines.
339
+ loaded = { error: `Unusable preferences file (Ctrl+K deletes it): ${errorMessage(error)}` };
340
+ }
341
+ await openBroOverlay<void>(
342
+ ctx,
343
+ createPreferencesModal(loaded, {
344
+ // ponytail: last writer wins against external edits while the editor is open, like writeSettings.
345
+ save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
346
+ clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
347
+ }),
348
+ );
349
+ }
350
+
351
+ export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
352
+ if (!hasBroCustomUi(ctx)) {
353
+ ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
354
+ return;
355
+ }
356
+ const { steering } = resolveAdvisorState(ctx.sessionManager.getBranch());
357
+ await openBroOverlay<void>(
358
+ ctx,
359
+ createAdvisorSteerModal(
360
+ steering,
361
+ (text) => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text }),
362
+ () => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text: "" }),
363
+ ),
364
+ );
365
+ }
366
+
367
+ // `preferences` is a ready status ("off", "on (312 characters)", or "error: …") so a preferences
368
+ // problem never hides Help or mixes with settings errors.
369
+ export function helpText(settings?: BroSettings, settingsError?: string, preferences = "off"): string {
370
+ const overrideLines = settings
371
+ ? CAPABILITIES.map((capability) => {
372
+ const override = capabilityOverride(settings, capability);
373
+ return override
374
+ ? `- **${CAPABILITY_LABELS[capability]} override:** ${override.backend ?? "agy"} \`${override.model}\`${override.effort === "default" ? "" : ` (${override.effort})`}`
375
+ : undefined;
376
+ }).filter((line): line is string => line !== undefined)
377
+ : [];
378
+ const settingsSummary = settings
379
+ ? `- **Backend:** ${settings.backend ?? "agy"}\n- **Model:** \`${settings.model}\`\n- **Reasoning effort:** ${settings.effort === "default" ? "built into the selected model" : settings.effort}\n- **Mode:** ${settings.mode}\n- **Show turns:** ${settings.showTurns}${overrideLines.length ? `\n${overrideLines.join("\n")}` : ""}`
380
+ : `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
381
+ return `# Bro
382
+
383
+ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/blob/main/docs/user-guide.md
384
+
385
+ ## Explain and show
386
+
387
+ - \`/bro\` or \`/bro simplify\` — explain the latest completed assistant reply
388
+ - \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
389
+ - \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
390
+ - \`/bro url <url>\` — explain one public webpage
391
+ - \`/bro <input>\` — a lone URL, an existing supported file, or anything else as pasted text
392
+ - \`/bro open\` — reopen the latest explanation without a new request
393
+ - \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
394
+
395
+ ## Guided Review
396
+
397
+ - \`/bro guided-review <PR number or URL>\` — automatically explain and assess a PR with captured code and optional private questions (local Pi interactive only; Git and authenticated gh required).
398
+ - Your work is saved automatically. Open the same PR to return without a model call; \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Reopening keeps the captured revision, not newer commits.
399
+ - Enter opens an item or focuses actions; Enter on a highlighted action activates it. Tab changes focus. Esc goes back, then closes from Contents. Close saves automatically; Stop and close stops active work first.
400
+ - Copy finding copies to the system clipboard, not GitHub. Regenerate guide is optional and makes a confirmed call about the same captured code. Claude and Muse use strict file-only inspection (shell, writes and web tools disabled) and are recommended for untrusted PRs. Codex and Grok run shell commands in a read-only sandbox; Agy's sandboxed shell still allows project writes. All three show an advisory. Set a review override in /bro config; saved reviews remain readable with any backend. Repository text can still mislead the model; citations are not proof.
401
+
402
+ ## Side conversation
403
+
404
+ - \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
405
+ - Inside the modal: Enter asks (empty Enter re-asks); \`/mode\` toggles conversation-only / full permission (read and edit the workspace) and keeps the thread; \`/copy\` and \`/copy-all\` copy to the clipboard; \`/insert\` and \`/insert-all\` insert into an empty main editor without submitting; \`/retry\` re-asks; \`/clear\` resets. Any other text is sent as a question. Esc closes.
406
+
407
+ ## Advisor
408
+
409
+ - \`bro_advisor\` — a tool the executor agent may call for a second opinion from a fresh backend process with real, auto-approved workspace access; it is told to advise, not edit, but that is not enforced
410
+ - \`/bro advisor\` — whether \`bro_advisor\` is available right now
411
+ - \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
412
+ - Standing advisor steering: \`${ADVISOR_STEERING_FILE}\` sets standing priorities across sessions (session steering takes precedence)
413
+
414
+ ## Configure and check
415
+
416
+ - \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
417
+ - \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately. If the terminal is too small, Esc closes config after pending saves finish; a failed save keeps it open.
418
+ - \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
419
+ - \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
420
+
421
+ ## Current settings
422
+
423
+ ${settingsSummary}
424
+ - **Preferences:** ${preferences} — \`/bro preferences\`
425
+
426
+ Saved in \`${settingsFile()}\`, \`${PREFERENCES_FILE}\`, and \`${ADVISOR_STEERING_FILE}\`.
427
+
428
+ ## Explanation modes
429
+
430
+ - brief — the main point and next action, with no fixed word target
431
+ - balanced — default; material detail with clearer structure
432
+ - faithful — closest to the source, with no fixed word limit
433
+
434
+ Press **M** in an explanation to re-simplify it in the next mode without changing the saved one.
435
+
436
+ The mode decides how much of the source to keep; your preferences decide who it's written for, its tone, and its language. Neither overrides Bro's source rules.
437
+
438
+ ## Controls
439
+
440
+ - **Mouse wheel / trackpad**, **↑ / ↓**, **PgUp / PgDn** — scroll
441
+ - **C** — copy the full explanation
442
+ - **R** — repeat the current action (an explanation keeps its mode)
443
+ - **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
444
+ - **O** — open the diagram (HTML layout or sequence diagram) when a show reply contains one
445
+ - **Esc** — close, or cancel while Bro is working
446
+
447
+ ## Privacy
448
+
449
+ Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Your preferences go with every explain, show, btw, and Guided Review request. Guided Review sends PR data/diff and relevant private questions; records/source remain locally under getAgentDir()/bro-reviews. Copy finding goes to the system clipboard, not GitHub. Nothing is added to Pi's conversation unless you insert it, except advisor answers, which return to the agent as tool results. Access controls differ by backend; see the user guide.`;
450
+ }
451
+
452
+ // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
453
+ export class BroModal implements Focusable {
454
+ focused = false;
455
+ private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
456
+ private kind: ModalKind = "loading";
457
+ private rawText = "";
458
+ private sourceLabel = "";
459
+ private modelLabel = "";
460
+ private noticeText = "";
461
+ private noticeTone: NoticeTone = "info";
462
+ private rawArea: Area | undefined;
463
+ private readonly scroller = new Scroller(() => {
464
+ this.noticeText = "";
465
+ this.tui.requestRender();
466
+ });
467
+ private copyable = false;
468
+ private retryable = false;
469
+ private disposed = false;
470
+ private htmlPath = "";
471
+ // Survives loading/streaming so the header names the mode being produced; empty disables M.
472
+ private modeLabel = "";
473
+ // Set only with a result, so the tag never claims preferences for work still in flight.
474
+ private preferencesUsed = false;
475
+
476
+ constructor(
477
+ private readonly tui: TuiLike,
478
+ private readonly theme: Theme,
479
+ private readonly onClose: () => void,
480
+ private readonly onRetry: () => void,
481
+ private readonly onDispose: () => void,
482
+ private readonly retryLabel: string,
483
+ private readonly onSwitchMode: () => void = () => {},
484
+ ) {}
485
+
486
+ setLoading(text = LOADING_TEXT): void {
487
+ this.setContent("loading", `**${text}**`, "", false, false);
488
+ }
489
+
490
+ setStreaming(text: string): void {
491
+ this.setContent("streaming", text, "", false, false);
492
+ }
493
+
494
+ get notice(): string {
495
+ return this.noticeText;
496
+ }
497
+
498
+ set notice(text: string) {
499
+ this.setNotice(text);
500
+ }
501
+
502
+ setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = "", noticeTone: NoticeTone = "info"): void {
503
+ this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel, noticeTone);
504
+ }
505
+
506
+ setNotice(text: string, tone: NoticeTone = "info"): void {
507
+ if (this.disposed) return;
508
+ this.noticeText = text;
509
+ this.noticeTone = tone;
510
+ this.tui.requestRender();
511
+ }
512
+
513
+ setHtmlPath(path: string): void {
514
+ this.htmlPath = path;
515
+ this.tui.requestRender();
516
+ }
517
+
518
+ setMode(mode: BroMode | undefined): void {
519
+ this.modeLabel = mode ?? "";
520
+ this.tui.requestRender();
521
+ }
522
+
523
+ setPreferences(used: boolean): void {
524
+ this.preferencesUsed = used;
525
+ this.tui.requestRender();
526
+ }
527
+
528
+ setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
529
+ this.setContent(kind, text, text, copyable, false);
530
+ }
531
+
532
+ setError(message: string): void {
533
+ this.setContent("error", `# Bro ran into a problem\n\n${message}`, "", false, true);
534
+ }
535
+
536
+ private setContent(
537
+ kind: ModalKind,
538
+ text: string,
539
+ rawText: string,
540
+ copyable: boolean,
541
+ retryable: boolean,
542
+ notice = "",
543
+ sourceLabel = "",
544
+ modelLabel = "",
545
+ noticeTone: NoticeTone = "info",
546
+ ): void {
547
+ this.kind = kind;
548
+ if (kind !== "result") this.htmlPath = "";
549
+ this.rawText = rawText;
550
+ this.copyable = copyable;
551
+ this.retryable = retryable;
552
+ this.noticeText = notice;
553
+ this.noticeTone = noticeTone;
554
+ this.sourceLabel = stripVTControlCharacters(sourceLabel)
555
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
556
+ .replace(/\s+/g, " ")
557
+ .trim();
558
+ this.modelLabel = modelLabel;
559
+ if (kind !== "streaming") this.scroller.toStart();
560
+ this.markdown.setText(text);
561
+ this.tui.requestRender();
562
+ }
563
+
564
+ private canSwitchMode(): boolean {
565
+ return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
566
+ }
567
+
568
+ private controls(): string {
569
+ const mode = this.canSwitchMode() ? " · M mode" : "";
570
+ if (this.kind === "loading") return mode ? "M mode · Esc cancel" : "Esc cancel";
571
+ if (this.kind === "streaming") return `Simplifying… · ↑/↓ scroll${mode} · Esc cancel`;
572
+ if (this.kind === "result") {
573
+ return `↑/↓ scroll · C copy${this.htmlPath ? " · O open diagram" : ""}${mode}${this.retryable ? ` · R ${this.retryLabel}` : ""} · Esc close`;
574
+ }
575
+ if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
576
+ if (this.kind === "error") return "R try again · Esc close";
577
+ return "Esc close";
578
+ }
579
+
580
+ render(width: number): string[] {
581
+ const height = modalHeight(this.tui, BRO_OVERLAY, 32);
582
+ this.rawArea = broOverlayArea(this.tui, width, height);
583
+ return renderFrame(this.theme, width, height, {
584
+ header: () => {
585
+ const scroll = this.scroller.indicator();
586
+ return (
587
+ this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
588
+ this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll ? ` · ${scroll}` : ""}`)
589
+ );
590
+ },
591
+ body: (rows, innerWidth) => this.scroller.view(this.markdown.render(innerWidth), rows),
592
+ notice: this.noticeText ? formatNotice(this.theme, this.noticeText, this.noticeTone) : undefined,
593
+ hints: this.controls(),
594
+ });
595
+ }
596
+
597
+ invalidate(): void {
598
+ this.markdown.invalidate();
599
+ }
600
+
601
+ handleMouse(event: BroMouseEvent): BroMouseResult {
602
+ return this.scroller.handleMouse(event);
603
+ }
604
+
605
+ handleInput(data: string): void {
606
+ if (matchesKey(data, "escape")) {
607
+ this.onClose();
608
+ return;
609
+ }
610
+
611
+ if (this.scroller.handleKey(data, this.rawArea)) return;
612
+
613
+ if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
614
+ void copyToClipboard(this.rawText)
615
+ .then(() => {
616
+ this.setNotice("Copied", "success");
617
+ })
618
+ .catch((error) => {
619
+ this.setNotice(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
620
+ });
621
+ return;
622
+ }
623
+
624
+ if (
625
+ (matchesKey(data, "r") || matchesKey(data, "shift+r")) &&
626
+ this.retryable &&
627
+ this.kind !== "loading"
628
+ ) {
629
+ this.onRetry();
630
+ return;
631
+ }
632
+
633
+ if ((matchesKey(data, "m") || matchesKey(data, "shift+m")) && this.canSwitchMode()) {
634
+ this.onSwitchMode();
635
+ return;
636
+ }
637
+
638
+ if ((matchesKey(data, "o") || matchesKey(data, "shift+o")) && this.htmlPath && this.kind === "result") {
639
+ const opened = openShowHtml(this.htmlPath);
640
+ this.setNotice(
641
+ opened ? "Opening diagram" : `Could not open ${this.htmlPath}`,
642
+ opened ? "info" : "error",
643
+ );
644
+ }
645
+ }
646
+
647
+ dispose(): void {
648
+ if (this.disposed) return;
649
+ this.disposed = true;
650
+ this.onDispose();
651
+ }
652
+ }
653
+
654
+ export interface BroModalOptions {
655
+ text?: string;
656
+ kind?: "help" | "empty";
657
+ copyable?: boolean;
658
+ result?: ModalResult;
659
+ run?: (
660
+ signal: AbortSignal,
661
+ source?: BroSource,
662
+ onProgress?: (text: string) => void,
663
+ mode?: BroMode,
664
+ ) => Promise<ModalResult>;
665
+ onResult?: (result: ModalResult) => void;
666
+ loadingText?: string;
667
+ retryable?: boolean;
668
+ retryLabel?: string;
669
+ }
670
+
671
+ export async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
672
+ if (!hasBroCustomUi(ctx)) {
673
+ if (options.run && !options.result && options.text === undefined) {
674
+ const result = await options.run(new AbortController().signal);
675
+ options.onResult?.(result);
676
+ }
677
+ return;
678
+ }
679
+
680
+ await openBroOverlay<void>(
681
+ ctx,
682
+ (tui, theme, _keybindings, done) => {
683
+ let closed = false;
684
+ let controller: AbortController | undefined;
685
+ let current = options.result;
686
+ let inFlightMode: BroMode | undefined;
687
+ let execute: (source?: BroSource, mode?: BroMode, notice?: string, noticeTone?: NoticeTone) => void = () => {};
688
+
689
+ const close = () => {
690
+ if (closed) return;
691
+ closed = true;
692
+ controller?.abort();
693
+ done(undefined);
694
+ };
695
+
696
+ const modal = new BroModal(
697
+ tui,
698
+ theme,
699
+ close,
700
+ () => execute(current?.source, current?.mode),
701
+ () => {
702
+ closed = true;
703
+ controller?.abort();
704
+ },
705
+ options.retryLabel ?? "simplify again",
706
+ // M advances from the mode in flight, so repeated presses cancel and skip ahead; the saved default is untouched.
707
+ () => {
708
+ const from = inFlightMode ?? current?.mode;
709
+ if (!from || !current?.source || closed) return;
710
+ const next = nextBroMode(from);
711
+ controller?.abort();
712
+ controller = undefined;
713
+ execute(current.source, next, `Mode: ${next} (not saved)`);
714
+ },
715
+ );
716
+
717
+ const present = (result: ModalResult, notice = "", noticeTone: NoticeTone = "info") => {
718
+ const display = result.htmlPath ? stripShowDiagramFence(result.text) : result.text;
719
+ modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model, noticeTone);
720
+ modal.setHtmlPath(result.htmlPath ?? "");
721
+ modal.setMode(result.mode);
722
+ modal.setPreferences(Boolean(result.preferences));
723
+ };
724
+
725
+ execute = (source?: BroSource, mode?: BroMode, notice = "", noticeTone: NoticeTone = "info") => {
726
+ if (!options.run || controller || closed) return;
727
+ const previous = current;
728
+ const nextController = new AbortController();
729
+ controller = nextController;
730
+ inFlightMode = mode;
731
+ modal.setLoading(options.loadingText);
732
+ modal.setMode(mode);
733
+ modal.setPreferences(false);
734
+
735
+ void options
736
+ .run(nextController.signal, source, (text) => {
737
+ if (closed || nextController.signal.aborted || controller !== nextController) return;
738
+ modal.setStreaming(text);
739
+ }, mode)
740
+ .then((result) => {
741
+ if (closed || nextController.signal.aborted) return;
742
+ current = result;
743
+ options.onResult?.(result);
744
+ present(result, notice, noticeTone);
745
+ })
746
+ .catch((error) => {
747
+ if (closed || nextController.signal.aborted) return;
748
+ const message = error instanceof Error ? error.message : String(error);
749
+ if (previous) {
750
+ current = previous;
751
+ present(previous, `Retry failed: ${message}`, "error");
752
+ } else {
753
+ modal.setMode(undefined);
754
+ modal.setError(message);
755
+ }
756
+ })
757
+ .finally(() => {
758
+ if (controller === nextController) {
759
+ controller = undefined;
760
+ inFlightMode = undefined;
761
+ }
762
+ });
763
+ };
764
+
765
+ if (options.text !== undefined) {
766
+ modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
767
+ } else if (current) {
768
+ present(current);
769
+ } else {
770
+ execute();
771
+ }
772
+
773
+ return modal;
774
+ },
775
+ { mouse: true },
776
+ );
777
+ }