pi-bro 0.23.1 → 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.
@@ -3,13 +3,10 @@ import { stripVTControlCharacters } from "node:util";
3
3
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
4
  import { copyToClipboard, getMarkdownTheme, getSelectListTheme } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
- Container,
7
6
  Editor,
8
7
  Markdown,
9
- Text,
10
8
  matchesKey,
11
- truncateToWidth,
12
- visibleWidth,
9
+ wrapTextWithAnsi,
13
10
  type Component,
14
11
  type EditorTheme,
15
12
  type Focusable,
@@ -17,7 +14,6 @@ import {
17
14
  } from "@earendil-works/pi-tui";
18
15
  import { ADVISOR_STEERING_ENTRY, ADVISOR_STEERING_FILE, resolveAdvisorState } from "./advisor.ts";
19
16
  import { runBackendText } from "./backend.ts";
20
- import type { Theme, TuiLike } from "./config-ui.ts";
21
17
  import {
22
18
  type BroMode,
23
19
  MAX_PREFERENCES_CHARS,
@@ -40,15 +36,8 @@ import {
40
36
  } from "./settings.ts";
41
37
  import { openShowHtml, stripShowDiagramFence } from "./show.ts";
42
38
  import type { BroSource } from "./sources.ts";
43
- import {
44
- broModalRows,
45
- consumeWheel,
46
- hasBroCustomUi,
47
- setRegularMouseReporting,
48
- wheelDelta,
49
- type BroMouseEvent,
50
- type BroMouseResult,
51
- } from "./ui-capabilities.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";
52
41
  import { errorMessage } from "./util.ts";
53
42
 
54
43
  const LOADING_TEXT = "Simplifying for my bro…";
@@ -93,9 +82,12 @@ export async function simplify(
93
82
 
94
83
  export type TextEditorModalOptions = {
95
84
  title: string;
85
+ /** Always shown in full above the editor; the editor asks for a bigger terminal before hiding it. */
86
+ privacy: string;
96
87
  subtitle: string;
97
88
  initialText: string;
98
89
  initialNotice?: string;
90
+ initialNoticeTone?: NoticeTone;
99
91
  // Returns an error message to refuse the save without calling onSave.
100
92
  validate?: (text: string) => string | undefined;
101
93
  // Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
@@ -117,17 +109,30 @@ export function createTextEditorModal(
117
109
  editor.setText(options.initialText);
118
110
  let disposed = false;
119
111
  let pending = false;
120
- // paddingY 0: the overlay clips from the bottom, so blank rows would push hints and notices off screen.
121
- // shortcut: Editor keeps its own min height, so preferences fits from ~22 rows at 80+ columns; revisit if users report smaller terminals.
122
- const notice = new Text(options.initialNotice ? theme.fg("accent", options.initialNotice) : "", 1, 0);
123
- const showNotice = (message: string, color: "success" | "error") => {
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) => {
124
123
  if (disposed) return;
125
- notice.setText(theme.fg(color, message));
124
+ notice = formatNotice(theme, message, tone);
126
125
  tui.requestRender();
127
126
  };
128
- editor.onChange = () => notice.setText("");
127
+ editor.onChange = () => {
128
+ notice = "";
129
+ discardArmed = false;
130
+ };
129
131
  const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
130
- const fail = (error: unknown) => showNotice(`${failure}: ${errorMessage(error)}`, "error");
132
+ const fail = (error: unknown) => {
133
+ closeRequested = false;
134
+ showNotice(`${failure}: ${errorMessage(error)}`, "error");
135
+ };
131
136
  let result: void | Promise<void>;
132
137
  try {
133
138
  result = action();
@@ -147,6 +152,11 @@ export function createTextEditorModal(
147
152
  if (disposed) return;
148
153
  after?.();
149
154
  showNotice(success, "success");
155
+ if (closeRequested) {
156
+ closeRequested = false;
157
+ disposed = true;
158
+ done(undefined);
159
+ }
150
160
  },
151
161
  (error) => {
152
162
  pending = false;
@@ -155,31 +165,86 @@ export function createTextEditorModal(
155
165
  );
156
166
  };
157
167
 
158
- const container = new Container();
159
- container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
160
- container.addChild(new Text(theme.fg("dim", options.subtitle), 1, 0));
161
- container.addChild(editor);
162
- container.addChild(notice);
163
- container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Ctrl+K clear · Ctrl+C copy · Esc close"), 1, 0));
164
-
165
168
  return {
166
- render: (w: number) => {
167
- const inner = Math.max(1, w - 4);
168
- const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
169
- return [border("┌", "┐"), ...container.render(inner).map((line) => {
170
- const text = truncateToWidth(line, inner, "");
171
- return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
172
- }), border("└", "┘")];
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);
173
210
  },
174
- invalidate: () => container.invalidate(),
175
- handleMouse: consumeWheel,
176
211
  handleInput: (data: string) => {
177
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
+ }
178
224
  disposed = true;
179
225
  done(undefined);
180
226
  return;
181
227
  }
182
- if (pending) return;
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;
183
248
  if (matchesKey(data, "ctrl+s")) {
184
249
  const text = editor.getExpandedText();
185
250
  const invalid = validate?.(text);
@@ -187,19 +252,18 @@ export function createTextEditorModal(
187
252
  showNotice(invalid, "error");
188
253
  return;
189
254
  }
190
- persist(() => onSave(text), "Saved", "Save failed");
255
+ persist(() => onSave(text), "Saved", "Save failed", () => {
256
+ lastSavedText = text;
257
+ discardArmed = false;
258
+ });
191
259
  return;
192
260
  }
193
261
  if (matchesKey(data, "ctrl+k")) {
194
- persist(onClear, "Cleared", "Clear failed", () => editor.setText(""));
195
- return;
196
- }
197
- if (matchesKey(data, "ctrl+c")) {
198
- const text = editor.getExpandedText();
199
- void Promise.resolve()
200
- .then(() => copy(text))
201
- .then(() => showNotice("Copied", "success"))
202
- .catch((error) => showNotice(`Copy failed: ${errorMessage(error)}`, "error"));
262
+ persist(onClear, "Cleared", "Clear failed", () => {
263
+ editor.setText("");
264
+ lastSavedText = "";
265
+ discardArmed = false;
266
+ });
203
267
  return;
204
268
  }
205
269
  if (matchesKey(data, "enter")) {
@@ -223,7 +287,8 @@ export function createAdvisorSteerModal(
223
287
  ): ReturnType<typeof createTextEditorModal> {
224
288
  return createTextEditorModal({
225
289
  title: "Bro · advisor steer",
226
- subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}) — never sent to the main model.`,
290
+ privacy: "Never sent to the main model.",
291
+ subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}).`,
227
292
  initialText,
228
293
  onSave,
229
294
  onClear,
@@ -248,9 +313,11 @@ export function createPreferencesModal(
248
313
  const starter = loaded.text === undefined && !loaded.error;
249
314
  return createTextEditorModal({
250
315
  title: "Bro · preferences",
251
- subtitle: "About you and how you like answers. Sent to the selected backend with every explain, show, btw, and Guided Review request — never to the advisor or Pi's main model.",
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.",
252
318
  initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
253
- initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it; Esc leaves no file." : undefined),
319
+ initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it." : undefined),
320
+ initialNoticeTone: loaded.error ? "error" : "info",
254
321
  validate: preferencesTooLong,
255
322
  onSave: persist.save,
256
323
  onClear: persist.clear,
@@ -268,18 +335,16 @@ export async function showPreferencesModal(ctx: ExtensionCommandContext): Promis
268
335
  const text = await readPreferencesRaw();
269
336
  loaded = text === undefined ? {} : { text };
270
337
  } catch (error) {
271
- loaded = { error: `${errorMessage(error)} Ctrl+K deletes it.` };
338
+ // Action first: long notices are cut after three lines.
339
+ loaded = { error: `Unusable preferences file (Ctrl+K deletes it): ${errorMessage(error)}` };
272
340
  }
273
- await ctx.ui.custom<void>(
341
+ await openBroOverlay<void>(
342
+ ctx,
274
343
  createPreferencesModal(loaded, {
275
344
  // ponytail: last writer wins against external edits while the editor is open, like writeSettings.
276
345
  save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
277
346
  clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
278
347
  }),
279
- {
280
- overlay: true,
281
- overlayOptions: { width: "78%", minWidth: 48, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
282
- },
283
348
  );
284
349
  }
285
350
 
@@ -289,16 +354,13 @@ export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: Ex
289
354
  return;
290
355
  }
291
356
  const { steering } = resolveAdvisorState(ctx.sessionManager.getBranch());
292
- await ctx.ui.custom<void>(
357
+ await openBroOverlay<void>(
358
+ ctx,
293
359
  createAdvisorSteerModal(
294
360
  steering,
295
361
  (text) => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text }),
296
362
  () => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text: "" }),
297
363
  ),
298
- {
299
- overlay: true,
300
- overlayOptions: { width: "78%", minWidth: 48, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
301
- },
302
364
  );
303
365
  }
304
366
 
@@ -335,7 +397,7 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
335
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).
336
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.
337
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.
338
- - Copy finding copies to the system clipboard, not GitHub. Regenerate guide is optional and makes a confirmed call about the same captured code. Review generation/questions require Claude or Muse file-only tools: shell, writes and web tools are disabled. Set a review override in /bro config; other backends can still read saved reviews. Repository text can still mislead the model; citations are not proof.
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.
339
401
 
340
402
  ## Side conversation
341
403
 
@@ -352,7 +414,7 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
352
414
  ## Configure and check
353
415
 
354
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)
355
- - \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately.
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.
356
418
  - \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
357
419
  - \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
358
420
 
@@ -375,7 +437,7 @@ The mode decides how much of the source to keep; your preferences decide who it'
375
437
 
376
438
  ## Controls
377
439
 
378
- - **Mouse wheel / trackpad**, **↑ / ↓** — scroll
440
+ - **Mouse wheel / trackpad**, **↑ / ↓**, **PgUp / PgDn** — scroll
379
441
  - **C** — copy the full explanation
380
442
  - **R** — repeat the current action (an explanation keeps its mode)
381
443
  - **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
@@ -395,10 +457,13 @@ export class BroModal implements Focusable {
395
457
  private rawText = "";
396
458
  private sourceLabel = "";
397
459
  private modelLabel = "";
398
- private notice = "";
399
- private offset = 0;
400
- private maxOffset = 0;
401
- private bodyHeight = 1;
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
+ });
402
467
  private copyable = false;
403
468
  private retryable = false;
404
469
  private disposed = false;
@@ -416,9 +481,7 @@ export class BroModal implements Focusable {
416
481
  private readonly onDispose: () => void,
417
482
  private readonly retryLabel: string,
418
483
  private readonly onSwitchMode: () => void = () => {},
419
- ) {
420
- setRegularMouseReporting(this.tui, true);
421
- }
484
+ ) {}
422
485
 
423
486
  setLoading(text = LOADING_TEXT): void {
424
487
  this.setContent("loading", `**${text}**`, "", false, false);
@@ -428,8 +491,23 @@ export class BroModal implements Focusable {
428
491
  this.setContent("streaming", text, "", false, false);
429
492
  }
430
493
 
431
- setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = ""): void {
432
- this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel);
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();
433
511
  }
434
512
 
435
513
  setHtmlPath(path: string): void {
@@ -464,39 +542,25 @@ export class BroModal implements Focusable {
464
542
  notice = "",
465
543
  sourceLabel = "",
466
544
  modelLabel = "",
545
+ noticeTone: NoticeTone = "info",
467
546
  ): void {
468
547
  this.kind = kind;
469
548
  if (kind !== "result") this.htmlPath = "";
470
549
  this.rawText = rawText;
471
550
  this.copyable = copyable;
472
551
  this.retryable = retryable;
473
- this.notice = notice;
552
+ this.noticeText = notice;
553
+ this.noticeTone = noticeTone;
474
554
  this.sourceLabel = stripVTControlCharacters(sourceLabel)
475
555
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
476
556
  .replace(/\s+/g, " ")
477
557
  .trim();
478
558
  this.modelLabel = modelLabel;
479
- if (kind !== "streaming") this.offset = 0;
559
+ if (kind !== "streaming") this.scroller.toStart();
480
560
  this.markdown.setText(text);
481
561
  this.tui.requestRender();
482
562
  }
483
563
 
484
- private frameLine(content: string, innerWidth: number): string {
485
- const truncated = truncateToWidth(content, innerWidth, "");
486
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
487
- return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
488
- }
489
-
490
- private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
491
- const left = edge === "top" ? "┌" : "└";
492
- const right = edge === "top" ? "┐" : "┘";
493
- return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
494
- }
495
-
496
- private ruleLine(innerWidth: number): string {
497
- return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
498
- }
499
-
500
564
  private canSwitchMode(): boolean {
501
565
  return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
502
566
  }
@@ -514,55 +578,28 @@ export class BroModal implements Focusable {
514
578
  }
515
579
 
516
580
  render(width: number): string[] {
517
- const dialogWidth = Math.max(24, width);
518
- const innerWidth = Math.max(22, dialogWidth - 2);
519
- const terminalRows = broModalRows(this.tui, process.stdout.rows);
520
- const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
521
- this.bodyHeight = Math.max(1, dialogHeight - 6);
522
-
523
- const rendered = this.markdown.render(innerWidth);
524
- this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
525
- this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
526
- const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
527
- const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
528
- const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
529
- const controls = this.notice ? `${this.notice} · ${this.controls()}` : this.controls();
530
-
531
- const lines = [
532
- this.borderLine(innerWidth, "top"),
533
- this.frameLine(
534
- this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
535
- this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll}`),
536
- innerWidth,
537
- ),
538
- this.ruleLine(innerWidth),
539
- ];
540
-
541
- for (const line of visible) lines.push(this.frameLine(line, innerWidth));
542
- for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
543
-
544
- lines.push(this.ruleLine(innerWidth));
545
- lines.push(this.frameLine(this.theme.fg("dim", controls), innerWidth));
546
- lines.push(this.borderLine(innerWidth, "bottom"));
547
- return lines;
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
+ });
548
595
  }
549
596
 
550
597
  invalidate(): void {
551
598
  this.markdown.invalidate();
552
599
  }
553
600
 
554
- // Pi 1.2+ fullscreen delivers the wheel here and no longer forwards raw wheel input to handleInput.
555
601
  handleMouse(event: BroMouseEvent): BroMouseResult {
556
- if (event.type !== "wheel") return;
557
- const delta = Math.trunc(event.wheelDelta ?? 0);
558
- if (delta) this.scroll(delta);
559
- return { handled: true, render: false };
560
- }
561
-
562
- private scroll(delta: number): void {
563
- this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
564
- this.notice = "";
565
- this.tui.requestRender();
602
+ return this.scroller.handleMouse(event);
566
603
  }
567
604
 
568
605
  handleInput(data: string): void {
@@ -571,25 +608,15 @@ export class BroModal implements Focusable {
571
608
  return;
572
609
  }
573
610
 
574
- const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
575
- if (delta) {
576
- this.scroll(delta);
577
- return;
578
- }
611
+ if (this.scroller.handleKey(data, this.rawArea)) return;
579
612
 
580
613
  if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
581
614
  void copyToClipboard(this.rawText)
582
615
  .then(() => {
583
- if (!this.disposed) {
584
- this.notice = "Copied";
585
- this.tui.requestRender();
586
- }
616
+ this.setNotice("Copied", "success");
587
617
  })
588
618
  .catch((error) => {
589
- if (!this.disposed) {
590
- this.notice = `Copy failed: ${error instanceof Error ? error.message : String(error)}`;
591
- this.tui.requestRender();
592
- }
619
+ this.setNotice(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
593
620
  });
594
621
  return;
595
622
  }
@@ -609,17 +636,17 @@ export class BroModal implements Focusable {
609
636
  }
610
637
 
611
638
  if ((matchesKey(data, "o") || matchesKey(data, "shift+o")) && this.htmlPath && this.kind === "result") {
612
- this.notice = openShowHtml(this.htmlPath)
613
- ? "Opening diagram"
614
- : `Could not open ${this.htmlPath}`;
615
- this.tui.requestRender();
639
+ const opened = openShowHtml(this.htmlPath);
640
+ this.setNotice(
641
+ opened ? "Opening diagram" : `Could not open ${this.htmlPath}`,
642
+ opened ? "info" : "error",
643
+ );
616
644
  }
617
645
  }
618
646
 
619
647
  dispose(): void {
620
648
  if (this.disposed) return;
621
649
  this.disposed = true;
622
- setRegularMouseReporting(this.tui, false);
623
650
  this.onDispose();
624
651
  }
625
652
  }
@@ -650,13 +677,14 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
650
677
  return;
651
678
  }
652
679
 
653
- await ctx.ui.custom<void>(
680
+ await openBroOverlay<void>(
681
+ ctx,
654
682
  (tui, theme, _keybindings, done) => {
655
683
  let closed = false;
656
684
  let controller: AbortController | undefined;
657
685
  let current = options.result;
658
686
  let inFlightMode: BroMode | undefined;
659
- let execute: (source?: BroSource, mode?: BroMode, notice?: string) => void = () => {};
687
+ let execute: (source?: BroSource, mode?: BroMode, notice?: string, noticeTone?: NoticeTone) => void = () => {};
660
688
 
661
689
  const close = () => {
662
690
  if (closed) return;
@@ -686,15 +714,15 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
686
714
  },
687
715
  );
688
716
 
689
- const present = (result: ModalResult, notice = "") => {
717
+ const present = (result: ModalResult, notice = "", noticeTone: NoticeTone = "info") => {
690
718
  const display = result.htmlPath ? stripShowDiagramFence(result.text) : result.text;
691
- modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model);
719
+ modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model, noticeTone);
692
720
  modal.setHtmlPath(result.htmlPath ?? "");
693
721
  modal.setMode(result.mode);
694
722
  modal.setPreferences(Boolean(result.preferences));
695
723
  };
696
724
 
697
- execute = (source?: BroSource, mode?: BroMode, notice = "") => {
725
+ execute = (source?: BroSource, mode?: BroMode, notice = "", noticeTone: NoticeTone = "info") => {
698
726
  if (!options.run || controller || closed) return;
699
727
  const previous = current;
700
728
  const nextController = new AbortController();
@@ -713,14 +741,14 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
713
741
  if (closed || nextController.signal.aborted) return;
714
742
  current = result;
715
743
  options.onResult?.(result);
716
- present(result, notice);
744
+ present(result, notice, noticeTone);
717
745
  })
718
746
  .catch((error) => {
719
747
  if (closed || nextController.signal.aborted) return;
720
748
  const message = error instanceof Error ? error.message : String(error);
721
749
  if (previous) {
722
750
  current = previous;
723
- present(previous, `Retry failed: ${message}`);
751
+ present(previous, `Retry failed: ${message}`, "error");
724
752
  } else {
725
753
  modal.setMode(undefined);
726
754
  modal.setError(message);
@@ -744,15 +772,6 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
744
772
 
745
773
  return modal;
746
774
  },
747
- {
748
- overlay: true,
749
- overlayOptions: {
750
- width: "78%",
751
- minWidth: 48,
752
- maxHeight: "78%",
753
- anchor: "top-center",
754
- margin: { top: 1, left: 2, right: 2 },
755
- },
756
- },
775
+ { mouse: true },
757
776
  );
758
777
  }