pi-bro 0.23.1 → 0.25.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
 
@@ -333,26 +395,32 @@ Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/bl
333
395
  ## Guided Review
334
396
 
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
- - 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
- - 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.
398
+ - Reviews save automatically. Reopen the same PR without a model call; bare \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Captured code stays unchanged.
399
+ - Enter opens items or activates focused actions. Tab changes focus; Esc goes back, then closes from Contents. Close saves; Stop and close cancels active work first.
400
+ - Copy finding uses the clipboard, not GitHub. Regenerate requires confirmation and reviews the same capture. For untrusted PRs, prefer Claude/Muse: file-only tools, no shell/writes/web. Codex/Grok allow shell in a read-only sandbox; Agy's sandbox permits project writes. All three show an advisory. Choose a review override in /bro config. Repository text can mislead; citations are not proof.
401
+
402
+ ## Elevate
403
+
404
+ - \`/bro elevate [request]\` — improve a task before sending it. Ctrl+Shift+E uses the draft. Choose Messages/Full context; Space/Enter ticks findings; Tab focuses actions.
405
+ - Enter adds a line; Ctrl+S or Ctrl+Enter discovers gaps. Use labelled buttons for actions.
406
+ - Shares BTW settings; see docs/elevate.md.
339
407
 
340
408
  ## Side conversation
341
409
 
342
410
  - \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
343
- - 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.
411
+ - Enter asks; empty Enter or \`/retry\` re-asks. \`/mode\` toggles conversation-only/full workspace access, retaining the thread. \`/copy\`, \`/copy-all\` copy; \`/insert\`, \`/insert-all\` fill an empty main editor without submitting. \`/clear\` resets; Esc closes.
344
412
 
345
413
  ## Advisor
346
414
 
347
415
  - \`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
348
416
  - \`/bro advisor\` — whether \`bro_advisor\` is available right now
349
417
  - \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
350
- - Standing advisor steering: \`${ADVISOR_STEERING_FILE}\` sets standing priorities across sessions (session steering takes precedence)
418
+ - Standing advisor steering sets cross-session priorities; session steering takes precedence.
351
419
 
352
420
  ## Configure and check
353
421
 
354
- - \`/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.
422
+ - \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, Elevate and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
423
+ - \`/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
424
  - \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
357
425
  - \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
358
426
 
@@ -375,7 +443,7 @@ The mode decides how much of the source to keep; your preferences decide who it'
375
443
 
376
444
  ## Controls
377
445
 
378
- - **Mouse wheel / trackpad**, **↑ / ↓** — scroll
446
+ - **Mouse wheel / trackpad**, **↑ / ↓**, **PgUp / PgDn** — scroll
379
447
  - **C** — copy the full explanation
380
448
  - **R** — repeat the current action (an explanation keeps its mode)
381
449
  - **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
@@ -395,10 +463,13 @@ export class BroModal implements Focusable {
395
463
  private rawText = "";
396
464
  private sourceLabel = "";
397
465
  private modelLabel = "";
398
- private notice = "";
399
- private offset = 0;
400
- private maxOffset = 0;
401
- private bodyHeight = 1;
466
+ private noticeText = "";
467
+ private noticeTone: NoticeTone = "info";
468
+ private rawArea: Area | undefined;
469
+ private readonly scroller = new Scroller(() => {
470
+ this.noticeText = "";
471
+ this.tui.requestRender();
472
+ });
402
473
  private copyable = false;
403
474
  private retryable = false;
404
475
  private disposed = false;
@@ -416,9 +487,7 @@ export class BroModal implements Focusable {
416
487
  private readonly onDispose: () => void,
417
488
  private readonly retryLabel: string,
418
489
  private readonly onSwitchMode: () => void = () => {},
419
- ) {
420
- setRegularMouseReporting(this.tui, true);
421
- }
490
+ ) {}
422
491
 
423
492
  setLoading(text = LOADING_TEXT): void {
424
493
  this.setContent("loading", `**${text}**`, "", false, false);
@@ -428,8 +497,23 @@ export class BroModal implements Focusable {
428
497
  this.setContent("streaming", text, "", false, false);
429
498
  }
430
499
 
431
- setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = ""): void {
432
- this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel);
500
+ get notice(): string {
501
+ return this.noticeText;
502
+ }
503
+
504
+ set notice(text: string) {
505
+ this.setNotice(text);
506
+ }
507
+
508
+ setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = "", noticeTone: NoticeTone = "info"): void {
509
+ this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel, noticeTone);
510
+ }
511
+
512
+ setNotice(text: string, tone: NoticeTone = "info"): void {
513
+ if (this.disposed) return;
514
+ this.noticeText = text;
515
+ this.noticeTone = tone;
516
+ this.tui.requestRender();
433
517
  }
434
518
 
435
519
  setHtmlPath(path: string): void {
@@ -464,39 +548,25 @@ export class BroModal implements Focusable {
464
548
  notice = "",
465
549
  sourceLabel = "",
466
550
  modelLabel = "",
551
+ noticeTone: NoticeTone = "info",
467
552
  ): void {
468
553
  this.kind = kind;
469
554
  if (kind !== "result") this.htmlPath = "";
470
555
  this.rawText = rawText;
471
556
  this.copyable = copyable;
472
557
  this.retryable = retryable;
473
- this.notice = notice;
558
+ this.noticeText = notice;
559
+ this.noticeTone = noticeTone;
474
560
  this.sourceLabel = stripVTControlCharacters(sourceLabel)
475
561
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
476
562
  .replace(/\s+/g, " ")
477
563
  .trim();
478
564
  this.modelLabel = modelLabel;
479
- if (kind !== "streaming") this.offset = 0;
565
+ if (kind !== "streaming") this.scroller.toStart();
480
566
  this.markdown.setText(text);
481
567
  this.tui.requestRender();
482
568
  }
483
569
 
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
570
  private canSwitchMode(): boolean {
501
571
  return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
502
572
  }
@@ -514,55 +584,28 @@ export class BroModal implements Focusable {
514
584
  }
515
585
 
516
586
  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;
587
+ const height = modalHeight(this.tui, BRO_OVERLAY, 32);
588
+ this.rawArea = broOverlayArea(this.tui, width, height);
589
+ return renderFrame(this.theme, width, height, {
590
+ header: () => {
591
+ const scroll = this.scroller.indicator();
592
+ return (
593
+ this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
594
+ this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll ? ` · ${scroll}` : ""}`)
595
+ );
596
+ },
597
+ body: (rows, innerWidth) => this.scroller.view(this.markdown.render(innerWidth), rows),
598
+ notice: this.noticeText ? formatNotice(this.theme, this.noticeText, this.noticeTone) : undefined,
599
+ hints: this.controls(),
600
+ });
548
601
  }
549
602
 
550
603
  invalidate(): void {
551
604
  this.markdown.invalidate();
552
605
  }
553
606
 
554
- // Pi 1.2+ fullscreen delivers the wheel here and no longer forwards raw wheel input to handleInput.
555
607
  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();
608
+ return this.scroller.handleMouse(event);
566
609
  }
567
610
 
568
611
  handleInput(data: string): void {
@@ -571,25 +614,15 @@ export class BroModal implements Focusable {
571
614
  return;
572
615
  }
573
616
 
574
- const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
575
- if (delta) {
576
- this.scroll(delta);
577
- return;
578
- }
617
+ if (this.scroller.handleKey(data, this.rawArea)) return;
579
618
 
580
619
  if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
581
620
  void copyToClipboard(this.rawText)
582
621
  .then(() => {
583
- if (!this.disposed) {
584
- this.notice = "Copied";
585
- this.tui.requestRender();
586
- }
622
+ this.setNotice("Copied", "success");
587
623
  })
588
624
  .catch((error) => {
589
- if (!this.disposed) {
590
- this.notice = `Copy failed: ${error instanceof Error ? error.message : String(error)}`;
591
- this.tui.requestRender();
592
- }
625
+ this.setNotice(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
593
626
  });
594
627
  return;
595
628
  }
@@ -609,17 +642,17 @@ export class BroModal implements Focusable {
609
642
  }
610
643
 
611
644
  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();
645
+ const opened = openShowHtml(this.htmlPath);
646
+ this.setNotice(
647
+ opened ? "Opening diagram" : `Could not open ${this.htmlPath}`,
648
+ opened ? "info" : "error",
649
+ );
616
650
  }
617
651
  }
618
652
 
619
653
  dispose(): void {
620
654
  if (this.disposed) return;
621
655
  this.disposed = true;
622
- setRegularMouseReporting(this.tui, false);
623
656
  this.onDispose();
624
657
  }
625
658
  }
@@ -650,13 +683,14 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
650
683
  return;
651
684
  }
652
685
 
653
- await ctx.ui.custom<void>(
686
+ await openBroOverlay<void>(
687
+ ctx,
654
688
  (tui, theme, _keybindings, done) => {
655
689
  let closed = false;
656
690
  let controller: AbortController | undefined;
657
691
  let current = options.result;
658
692
  let inFlightMode: BroMode | undefined;
659
- let execute: (source?: BroSource, mode?: BroMode, notice?: string) => void = () => {};
693
+ let execute: (source?: BroSource, mode?: BroMode, notice?: string, noticeTone?: NoticeTone) => void = () => {};
660
694
 
661
695
  const close = () => {
662
696
  if (closed) return;
@@ -686,15 +720,15 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
686
720
  },
687
721
  );
688
722
 
689
- const present = (result: ModalResult, notice = "") => {
723
+ const present = (result: ModalResult, notice = "", noticeTone: NoticeTone = "info") => {
690
724
  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);
725
+ modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model, noticeTone);
692
726
  modal.setHtmlPath(result.htmlPath ?? "");
693
727
  modal.setMode(result.mode);
694
728
  modal.setPreferences(Boolean(result.preferences));
695
729
  };
696
730
 
697
- execute = (source?: BroSource, mode?: BroMode, notice = "") => {
731
+ execute = (source?: BroSource, mode?: BroMode, notice = "", noticeTone: NoticeTone = "info") => {
698
732
  if (!options.run || controller || closed) return;
699
733
  const previous = current;
700
734
  const nextController = new AbortController();
@@ -713,14 +747,14 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
713
747
  if (closed || nextController.signal.aborted) return;
714
748
  current = result;
715
749
  options.onResult?.(result);
716
- present(result, notice);
750
+ present(result, notice, noticeTone);
717
751
  })
718
752
  .catch((error) => {
719
753
  if (closed || nextController.signal.aborted) return;
720
754
  const message = error instanceof Error ? error.message : String(error);
721
755
  if (previous) {
722
756
  current = previous;
723
- present(previous, `Retry failed: ${message}`);
757
+ present(previous, `Retry failed: ${message}`, "error");
724
758
  } else {
725
759
  modal.setMode(undefined);
726
760
  modal.setError(message);
@@ -744,15 +778,6 @@ export async function showBroModal(ctx: ExtensionCommandContext, options: BroMod
744
778
 
745
779
  return modal;
746
780
  },
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
- },
781
+ { mouse: true },
757
782
  );
758
783
  }