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.
package/src/review-ui.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  import {
2
2
  matchesKey,
3
- truncateToWidth,
4
- visibleWidth,
5
3
  wrapTextWithAnsi,
6
4
  } from "@earendil-works/pi-tui";
7
5
  import {
@@ -10,8 +8,8 @@ import {
10
8
  type ExtensionCommandContext,
11
9
  } from "@earendil-works/pi-coding-agent";
12
10
  import { join } from "node:path";
13
- import { execute, reviewBackendError } from "./backend.ts";
14
- import { readSettings, selectionForCapability } from "./settings.ts";
11
+ import { execute, reviewBackendNotice } from "./backend.ts";
12
+ import { capabilityBackend, readSettings, selectionForCapability } from "./settings.ts";
15
13
  import {
16
14
  replaceGuide,
17
15
  guideEvidence,
@@ -24,18 +22,22 @@ import {
24
22
  type ReviewRecord,
25
23
  } from "./review.ts";
26
24
  import { captureReview, readEvidence, resolveReviewTarget } from "./review-source.ts";
27
- import { ReviewModal, newReviewView, safeReviewText } from "./review-modal.ts";
25
+ import { REVIEW_OVERLAY, ReviewModal, newReviewView, safeReviewText } from "./review-modal.ts";
26
+ import { formatNotice, modalHeight, openBroOverlay, renderFrame } from "./modal-kit.ts";
27
+ import { consumeWheel } from "./ui-capabilities.ts";
28
+
29
+ export const REVIEW_ACQUIRE_OVERLAY = {
30
+ anchor: "center",
31
+ width: "70%",
32
+ minWidth: 44,
33
+ maxHeight: "60%",
34
+ margin: { top: 1, bottom: 1 },
35
+ } as const;
28
36
 
29
37
  const activeReviews = new Map<string, AbortController | undefined>();
30
38
  export function stopGuidedReviews(): void {
31
39
  for (const controller of activeReviews.values()) controller?.abort();
32
40
  }
33
- const overlayOptions = {
34
- anchor: "center" as const,
35
- width: "100%" as const,
36
- maxHeight: "100%" as const,
37
- margin: { top: 1, bottom: 1 },
38
- };
39
41
 
40
42
  export async function openGuidedReview(
41
43
  ctx: ExtensionCommandContext,
@@ -73,10 +75,12 @@ export async function openGuidedReview(
73
75
  if (!picked) return;
74
76
  record = records[labels.indexOf(picked)];
75
77
  } else {
76
- record = await ctx.ui.custom<ReviewRecord | undefined>(
78
+ record = await openBroOverlay<ReviewRecord | undefined>(
79
+ ctx,
77
80
  (tui, theme, _keys, done) => {
78
81
  let controller = new AbortController();
79
82
  let message = "Resolving PR identity…";
83
+ let advisory: string | undefined;
80
84
  let failed = false;
81
85
  let retryable = true;
82
86
  let startedAt = Date.now();
@@ -94,11 +98,15 @@ export async function openGuidedReview(
94
98
  const acquire = async () => {
95
99
  failed = false;
96
100
  retryable = true;
101
+ advisory = undefined;
97
102
  startedAt = Date.now();
98
103
  controller = new AbortController();
99
104
  message = "Resolving PR identity…";
100
105
  tui.requestRender();
101
106
  try {
107
+ // Resolved before PR identity: the advisory is about the backend that will run the
108
+ // review, so it belongs on a resolution failure too, not only a capture failure.
109
+ advisory = reviewBackendNotice(capabilityBackend(await readSettings(), "review"));
102
110
  const target = await resolveReviewTarget(value, ctx.cwd, controller.signal);
103
111
  if (disposed) return;
104
112
  const id = reviewKey(target);
@@ -124,12 +132,6 @@ export async function openGuidedReview(
124
132
  if (!disposed) done(existing);
125
133
  return;
126
134
  }
127
- const selection = selectionForCapability(await readSettings(), "review");
128
- const backendError = reviewBackendError(selection.backend ?? "agy");
129
- if (backendError) {
130
- retryable = false;
131
- throw Error(backendError);
132
- }
133
135
  message = `${target.repository} #${target.number} · ${target.head.slice(0, 8)}\nFetching captured source…`;
134
136
  tui.requestRender();
135
137
  const snapshot = await captureReview(target, root, controller.signal);
@@ -152,31 +154,25 @@ export async function openGuidedReview(
152
154
  void acquire();
153
155
  return {
154
156
  render(width: number) {
155
- const inner = Math.max(1, width - 2);
156
- const height = Math.max(1, tui.terminal.rows - 2);
157
- const border = "─".repeat(inner);
158
- const row = (text: string) => {
159
- const line = truncateToWidth(text, inner, "…");
160
- return `│${line}${" ".repeat(Math.max(0, inner - visibleWidth(line)))}│`;
161
- };
162
- const body = wrapTextWithAnsi(safeReviewText(message), inner).slice(
163
- 0,
164
- Math.max(1, height - 4),
165
- );
166
- const lines = [
167
- `╭${border}╮`,
168
- row(
169
- `Bro · Review${failed ? "" : ` · ${Math.floor((Date.now() - startedAt) / 1000)}s`}`,
170
- ),
171
- ...body.map(row),
172
- row(failed ? (retryable ? "Esc close · Enter retry" : "Esc close") : "Esc cancel"),
173
- `╰${border}╯`,
174
- ];
175
- return lines
176
- .slice(0, height)
177
- .map((line) => theme.fg("accent", truncateToWidth(line, width, "")));
157
+ const height = modalHeight(tui, REVIEW_ACQUIRE_OVERLAY);
158
+ const hints = failed ? (retryable ? "Enter retry · Esc close" : "Esc close") : "Esc cancel";
159
+ return renderFrame(theme, width, height, {
160
+ header: () =>
161
+ theme.fg("accent", theme.bold("Bro · Review")) +
162
+ (failed ? "" : theme.fg("dim", ` · ${Math.floor((Date.now() - startedAt) / 1000)}s`)),
163
+ body: (_rows, innerWidth) =>
164
+ wrapTextWithAnsi(safeReviewText(message), innerWidth).map((line) =>
165
+ failed ? theme.fg("error", line) : theme.fg("dim", line),
166
+ ),
167
+ // An advisory is information, not a failure: keep it visible even when the fetch fails.
168
+ notice: advisory ? formatNotice(theme, advisory, "warning") : undefined,
169
+ hints,
170
+ fill: false,
171
+ minBody: 1,
172
+ });
178
173
  },
179
174
  invalidate() {},
175
+ handleMouse: consumeWheel,
180
176
  handleInput(data: string) {
181
177
  if (matchesKey(data, "escape")) {
182
178
  disposed = true;
@@ -193,7 +189,7 @@ export async function openGuidedReview(
193
189
  },
194
190
  };
195
191
  },
196
- { overlay: true, overlayOptions },
192
+ { overlay: REVIEW_ACQUIRE_OVERLAY },
197
193
  );
198
194
  }
199
195
  if (!record) return;
@@ -205,7 +201,8 @@ export async function openGuidedReview(
205
201
  activeReviews.set(state.id, undefined);
206
202
  let overlayHandle: { setHidden(hidden: boolean): void } | undefined;
207
203
  try {
208
- await ctx.ui.custom<void>(
204
+ await openBroOverlay<void>(
205
+ ctx,
209
206
  (tui, theme, _keys, done) => {
210
207
  let controller: AbortController | undefined;
211
208
  let running: Promise<void> | undefined;
@@ -307,7 +304,9 @@ export async function openGuidedReview(
307
304
  await persist();
308
305
  const settings = await readSettings();
309
306
  const selection = selectionForCapability(settings, "review");
310
- panel.notice = `${question ? "Answering" : regenerate ? "Regenerating guide" : "Preparing guide"} · ${selection.backend ?? "agy"}`;
307
+ const backend = capabilityBackend(settings, "review");
308
+ panel.advisory = reviewBackendNotice(backend) ?? "";
309
+ panel.notice = `${question ? "Answering" : regenerate ? "Regenerating guide" : "Preparing guide"} · ${backend}`;
311
310
  panel.stage = {
312
311
  step: "investigate",
313
312
  detail: question ? "Considering your question" : "Inspecting captured changes",
@@ -472,6 +471,14 @@ export async function openGuidedReview(
472
471
  changed,
473
472
  });
474
473
  void hydrateEvidence();
474
+ // Also on reopen: the advisory is about the selected backend, not about a new turn.
475
+ void readSettings().then(
476
+ (settings) => {
477
+ panel.advisory = reviewBackendNotice(capabilityBackend(settings, "review")) ?? "";
478
+ if (!closed) tui.requestRender();
479
+ },
480
+ () => {},
481
+ );
475
482
  if (fresh)
476
483
  queueMicrotask(() => {
477
484
  if (!closed && !closing) running = run("whole review");
@@ -495,18 +502,21 @@ export async function openGuidedReview(
495
502
  panel.focused = value;
496
503
  },
497
504
  dispose() {
505
+ // close() sets `closed` only after its save succeeded; other teardown still saves here.
506
+ const savedByClose = closed;
498
507
  closed = true;
499
508
  panel.dispose();
500
509
  evidenceAbort.abort();
501
510
  controller?.abort();
502
511
  if (saveTimer) clearTimeout(saveTimer);
503
- void persist().catch(() => {});
512
+ if (!savedByClose) void persist().catch(() => {});
504
513
  },
505
514
  };
506
515
  },
507
516
  {
508
- overlay: true,
509
- overlayOptions,
517
+ overlay: REVIEW_OVERLAY,
518
+ // Regular-mode wheel scrolling; released while hidden behind the regenerate confirm.
519
+ mouse: true,
510
520
  onHandle: (handle) => {
511
521
  overlayHandle = handle;
512
522
  },
package/src/settings.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { randomUUID } from "node:crypto";
3
- import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  type BackendSelection,
@@ -10,8 +10,8 @@ import {
10
10
  CODEX_EFFORTS,
11
11
  MUSE_EFFORTS,
12
12
  } from "./backend.ts";
13
- import { type BroMode, DEFAULT_BRO_MODE, parseBroMode } from "./prompt.ts";
14
- import { isRecord } from "./util.ts";
13
+ import { type BroMode, DEFAULT_BRO_MODE, MAX_PREFERENCES_CHARS, parseBroMode } from "./prompt.ts";
14
+ import { errorMessage, isRecord } from "./util.ts";
15
15
 
16
16
  export const EFFORTS = ["default", "low", "medium", "high"] as const;
17
17
  export const BACKENDS = ["agy", "claude", "grok", "codex", "muse"] as const;
@@ -503,3 +503,45 @@ export async function writeSettings(settings: BroSettings): Promise<void> {
503
503
  throw error;
504
504
  }
505
505
  }
506
+
507
+ export const PREFERENCES_FILE = join(getAgentDir(), "bro-preferences.md");
508
+ // Larger files are rejected before reading, even by the editor; the prompt limit is MAX_PREFERENCES_CHARS.
509
+ export const MAX_PREFERENCES_FILE_BYTES = 64 * 1024;
510
+
511
+ // Raw file text for the editor, undefined when there is no file: never rejects for length, so an
512
+ // oversize file can be opened and trimmed.
513
+ export async function readPreferencesRaw(): Promise<string | undefined> {
514
+ let size: number;
515
+ try {
516
+ size = (await stat(PREFERENCES_FILE)).size;
517
+ } catch (error) {
518
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
519
+ throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
520
+ }
521
+ if (size > MAX_PREFERENCES_FILE_BYTES) throw new Error(`${PREFERENCES_FILE} is larger than 64 KB. Edit or delete it directly.`);
522
+ try {
523
+ return (await readFile(PREFERENCES_FILE, "utf8")).replace(/^\uFEFF/, "");
524
+ } catch (error) {
525
+ throw new Error(`Cannot read ${PREFERENCES_FILE}: ${errorMessage(error)}`);
526
+ }
527
+ }
528
+
529
+ // Preferences for prompts: "" when absent or blank. Re-read on every request. An oversize file stops
530
+ // the request before any backend call instead of being silently truncated.
531
+ export async function readPreferences(): Promise<string> {
532
+ const text = ((await readPreferencesRaw()) ?? "").trim();
533
+ if (text.length > MAX_PREFERENCES_CHARS) {
534
+ throw new Error(`${PREFERENCES_FILE} is ${text.length.toLocaleString("en-US")} characters; keep it under ${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} (it's sent with every request). Use /bro preferences to trim it.`);
535
+ }
536
+ return text;
537
+ }
538
+
539
+ // Saves and deletes run one at a time, even across a closed and reopened editor, so a slow delete
540
+ // from an earlier editor can never remove a file saved by a later one.
541
+ let preferencesWrites: Promise<void> = Promise.resolve();
542
+ export function queuePreferencesWrite(write: () => Promise<void>): Promise<void> {
543
+ const next = preferencesWrites.then(write);
544
+ preferencesWrites = next.catch(() => {});
545
+ return next;
546
+ }
547
+
package/src/show.ts ADDED
@@ -0,0 +1,243 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
+ import { runBackendText } from "./backend.ts";
8
+ import { buildShowPrompt } from "./prompt.ts";
9
+ import { type BroSettings, readPreferences, selectionForCapability, selectionLabel } from "./settings.ts";
10
+ import { type BroSource, MAX_TEXT_LENGTH } from "./sources.ts";
11
+ import { errorMessage, isRenderableSequence } from "./util.ts";
12
+
13
+ const SHOW_HTML_FILE_PATTERN = /^bro-show-[0-9a-f]{8}\.html$/;
14
+
15
+ // /bro show: capture only the user- and assistant-visible conversation text of
16
+ // recent session turns and let the show prompt draw it as shapes. Tool calls,
17
+ // tool results, reasoning, and images never leave the session -- this is a
18
+ // structural role/content-type filter, not semantic or keyword-based. See
19
+ // docs/plans/2026-09-07-bro-show-visual-design.md (predates this change).
20
+ type ShowTurn = { entries: string[]; startsTurn: boolean };
21
+
22
+ function showTextContent(content: unknown): string {
23
+ if (typeof content === "string") return content.trim();
24
+ if (!Array.isArray(content)) return "";
25
+ let text = "";
26
+ for (const part of content) {
27
+ if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
28
+ text += `${(part as { text?: string }).text ?? ""}\n`;
29
+ }
30
+ }
31
+ return text.trim();
32
+ }
33
+
34
+ export function showEntriesForMessage(message: { role?: string; content?: unknown }): string[] {
35
+ if (message.role === "user") {
36
+ const text = showTextContent(message.content);
37
+ return text ? [`## user\n${JSON.stringify(text)}`] : [];
38
+ }
39
+ if (message.role === "assistant") {
40
+ const text = showTextContent(message.content);
41
+ return text ? [`## assistant\n${JSON.stringify(text)}`] : [];
42
+ }
43
+ return [];
44
+ }
45
+
46
+ function serializeShowTurns(turns: readonly ShowTurn[]): string {
47
+ return turns.flatMap((turn) => turn.entries).join("\n\n");
48
+ }
49
+
50
+ export function captureShowTranscript(ctx: ExtensionCommandContext, turnsRequested: number, maxLength = MAX_TEXT_LENGTH): BroSource | undefined {
51
+ if (!Number.isSafeInteger(maxLength) || maxLength < 128) throw new Error("Transcript limit must be an integer of at least 128 characters.");
52
+ const turns: ShowTurn[] = [];
53
+ for (const entry of ctx.sessionManager.getBranch()) {
54
+ if (entry.type !== "message") continue;
55
+ const message = entry.message as { role?: string; stopReason?: string };
56
+ // Aborted assistant text is a half-written claim, not a report.
57
+ if (message.role === "assistant" && message.stopReason === "abort") continue;
58
+ const entries = showEntriesForMessage(message);
59
+ // A user message always marks a turn boundary even when it has no
60
+ // capturable text (image-only, whitespace-only): the turn must still
61
+ // count, or /bro show 1 would silently over-capture earlier turns.
62
+ if (message.role === "user") turns.push({ entries, startsTurn: true });
63
+ else if (entries.length) turns.push({ entries, startsTurn: false });
64
+ }
65
+
66
+ let start = 0;
67
+ let seen = 0;
68
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
69
+ if (!turns[index]!.startsTurn) continue;
70
+ seen += 1;
71
+ if (seen === turnsRequested) {
72
+ start = index;
73
+ break;
74
+ }
75
+ }
76
+ if (seen === 0) return undefined;
77
+
78
+ let text = serializeShowTurns(turns.slice(start));
79
+ while (text.length > maxLength && start < turns.length - 1) {
80
+ let next = turns.length;
81
+ for (let index = start + 1; index < turns.length; index += 1) {
82
+ if (turns[index]!.startsTurn) {
83
+ next = index;
84
+ break;
85
+ }
86
+ }
87
+ if (next >= turns.length) break;
88
+ start = next;
89
+ text = serializeShowTurns(turns.slice(start));
90
+ }
91
+ if (text.length > maxLength) {
92
+ // Prefer the newest complete messages even within an oversized single turn.
93
+ const entries = turns.slice(start).flatMap(turn => turn.entries);
94
+ const notice = "[… earlier messages truncated …]\n";
95
+ while (entries.length > 1 && entries.join("\n\n").length + notice.length > maxLength) entries.shift();
96
+ text = entries.join("\n\n");
97
+ if (text.length + notice.length > maxLength) {
98
+ const newline = text.indexOf("\n");
99
+ const header = text.slice(0, newline);
100
+ const content = JSON.parse(text.slice(newline + 1)) as string;
101
+ const marker = "[… earlier text truncated …] ";
102
+ let tail = content.slice(-(maxLength - header.length - marker.length - 8));
103
+ while (`${header}\n${JSON.stringify(marker + tail)}`.length > maxLength) tail = tail.slice(Math.max(1, Math.floor(tail.length / 10)));
104
+ text = `${header}\n${JSON.stringify(marker + tail)}`;
105
+ } else text = notice + text;
106
+ }
107
+ const kept = turns.slice(start).filter((turn) => turn.startsTurn).length;
108
+ return { text: text.trim(), label: `last ${Math.max(1, kept)} turn${kept > 1 ? "s" : ""} · conversation only` };
109
+ }
110
+
111
+ export interface ParsedShowArguments {
112
+ // undefined means "use the configured default"; invalid leading numeric tokens report `invalid` instead.
113
+ requested?: string;
114
+ steering: string;
115
+ invalid: boolean;
116
+ }
117
+
118
+ // A leading token is only ever treated as the turn count, never as the start of the query — so
119
+ // "/bro show 1 404 handler" is count 1, query "404 handler", not an ambiguous double-numeric query.
120
+ export function parseShowArguments(value: string): ParsedShowArguments {
121
+ const firstSpace = value.search(/\s/);
122
+ const firstToken = firstSpace === -1 ? value : value.slice(0, firstSpace);
123
+ const looksLikeCount = firstToken !== "" && /^-?\d+(?:\.\d+)?$/.test(firstToken);
124
+ if (!looksLikeCount) return { steering: value, invalid: false };
125
+
126
+ // Slicing the raw remainder (instead of split(/\s+/).join(" ")) keeps the query's original spacing intact.
127
+ const steering = firstSpace === -1 ? "" : value.slice(firstSpace).replace(/^\s+/, "");
128
+ const valid = /^[1-9]\d*$/.test(firstToken) && Number.isSafeInteger(Number(firstToken));
129
+ return { requested: valid ? firstToken : undefined, steering, invalid: !valid };
130
+ }
131
+
132
+ export type ShowDiagram = { kind: "html" | "mermaid"; source: string };
133
+ const SHOW_DIAGRAM_FENCE = /^```(html|mermaid)[^\S\r\n]*\r?\n([\s\S]*?)^```[^\S\r\n]*$/gm;
134
+
135
+ export function extractShowDiagram(text: string): ShowDiagram | undefined {
136
+ const last = [...text.matchAll(SHOW_DIAGRAM_FENCE)].at(-1);
137
+ if (!last) return undefined;
138
+ const diagram = { kind: last[1] as ShowDiagram["kind"], source: last[2]!.trim() };
139
+ // Any other Mermaid stays in the modal as a plain text block, visible rather than hidden.
140
+ return diagram.kind === "html" || isRenderableSequence(diagram.source) ? diagram : undefined;
141
+ }
142
+
143
+ export function stripShowDiagramFence(text: string): string {
144
+ const last = [...text.matchAll(SHOW_DIAGRAM_FENCE)].at(-1);
145
+ if (!last || last.index === undefined) return text;
146
+ const shown = last[1] === "mermaid" ? `${last[0]}\n[Diagram saved — press O to render]` : "[HTML diagram saved — press O to open]";
147
+ return text.slice(0, last.index) + shown + text.slice(last.index + last[0].length);
148
+ }
149
+
150
+ export function showHtmlDirectory(): string {
151
+ // ponytail: per-uid directory so keep-one cleanup never touches other
152
+ // users' files in a shared /tmp, and readdir stays small.
153
+ return join(tmpdir(), `pi-bro-${typeof process.getuid === "function" ? process.getuid() : "user"}`);
154
+ }
155
+
156
+ function withShowCsp(html: string): string {
157
+ // Defense in depth: the prompt forbids external resources and scripts;
158
+ // a meta CSP blocks them anyway if the model slips. Always inserted: multiple
159
+ // policies are enforced together, so a model-supplied CSP can only tighten it.
160
+ // Skip only simple leading comments: browsers accept comment terminators that
161
+ // a general regex misses. Ambiguous markup gets the policy prepended instead.
162
+ const meta = '<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:;">';
163
+ return html.replace(/^(\s*(?:<!--[^<>]*-->\s*)*(?:<!doctype[^>]*>\s*)?)/i, `$1\n${meta}\n`);
164
+ }
165
+
166
+ // Bro, not the model, owns every script on a Mermaid page: the CSP allows only these two exact
167
+ // scripts by hash, and the model's source is inserted as escaped text that Mermaid parses itself.
168
+ const MERMAID_INIT = 'mermaid.initialize({startOnLoad:true,securityLevel:"strict",htmlLabels:false,flowchart:{htmlLabels:false}});';
169
+
170
+ async function vendoredMermaid(): Promise<string> {
171
+ const library = await readFile(new URL("./vendor/mermaid.min.js", import.meta.url), "utf8");
172
+ // Inlined in <script>: "<!--" or "<script" would change how HTML parses the element. Every
173
+ // occurrence sits inside a JS string or regex literal, where \x3C is the same character.
174
+ return library.replace(/<(?=!--|\/?script)/gi, "\\x3C");
175
+ }
176
+
177
+ function escapeHtml(text: string): string {
178
+ return text.replace(/[&<>"]/g, (character) => `&#${character.charCodeAt(0)};`);
179
+ }
180
+
181
+ export async function mermaidShowPage(source: string): Promise<string> {
182
+ const library = await vendoredMermaid();
183
+ const hash = (script: string) => `'sha256-${createHash("sha256").update(script).digest("base64")}'`;
184
+ const escaped = escapeHtml(source);
185
+ return `<!doctype html>
186
+ <meta charset="utf-8">
187
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src ${hash(library)} ${hash(MERMAID_INIT)}">
188
+ <title>Bro diagram</title>
189
+ <body style="margin:16px;font-family:system-ui,sans-serif">
190
+ <style>pre.mermaid svg{max-height:calc(100vh - 64px)}</style>
191
+ <pre class="mermaid">${escaped}</pre>
192
+ <details><summary>Diagram source</summary><pre>${escaped}</pre></details>
193
+ <script>${library}</script>
194
+ <script>${MERMAID_INIT}</script>
195
+ </body>`;
196
+ }
197
+
198
+ export async function writeShowDiagram(diagram: ShowDiagram): Promise<string> {
199
+ const page = diagram.kind === "mermaid" ? await mermaidShowPage(diagram.source) : withShowCsp(diagram.source);
200
+ const slug = createHash("sha256").update(page).digest("hex").slice(0, 8);
201
+ const directory = showHtmlDirectory();
202
+ await mkdir(directory, { recursive: true, mode: 0o700 });
203
+ for (const name of await readdir(directory)) {
204
+ if (SHOW_HTML_FILE_PATTERN.test(name)) await rm(join(directory, name), { force: true });
205
+ }
206
+ const path = join(directory, `bro-show-${slug}.html`);
207
+ await writeFile(path, `${page}\n`, "utf8");
208
+ return path;
209
+ }
210
+
211
+ // A diagram that cannot be written must not cost the reply: keep the text and warn instead.
212
+ export async function showDiagramPath(text: string, warn: (message: string) => void): Promise<string | undefined> {
213
+ const diagram = extractShowDiagram(text);
214
+ if (!diagram) return undefined;
215
+ try { return await writeShowDiagram(diagram); }
216
+ catch (error) {
217
+ warn(`Diagram file unavailable: ${errorMessage(error)}`);
218
+ return undefined;
219
+ }
220
+ }
221
+
222
+ export function openShowHtml(path: string): boolean {
223
+ if (process.platform === "win32") {
224
+ const result = spawnSync("cmd", ["/c", "start", "", path], { stdio: "ignore", timeout: 5_000 });
225
+ return result.status === 0;
226
+ }
227
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
228
+ const result = spawnSync(opener, [path], { stdio: "ignore", timeout: 5_000 });
229
+ return result.status === 0;
230
+ }
231
+
232
+ export async function runShowExplanation(
233
+ transcript: string,
234
+ steering: string,
235
+ signal: AbortSignal,
236
+ settings: BroSettings,
237
+ onProgress?: (text: string) => void,
238
+ ): Promise<{ text: string; model: string; preferences: boolean }> {
239
+ const selection = selectionForCapability(settings, "show");
240
+ const preferences = await readPreferences();
241
+ const text = await runBackendText(buildShowPrompt(transcript, steering, preferences), selection, signal, onProgress, "show");
242
+ return { text, model: selectionLabel(selection), preferences: Boolean(preferences) };
243
+ }
@@ -33,3 +33,35 @@ export async function insertBroDesktopText(ctx: UiContext, text: string): Promis
33
33
 
34
34
  /** Only terminal mode uses synchronous insertion; Desktop uses insertBroDesktopText instead. */
35
35
  export function canBroInsertIntoEditor(ctx: UiContext): boolean { return ctx.mode === "tui"; }
36
+
37
+ /** Any raw SGR mouse report; regular-mode reporting delivers these to handleInput. */
38
+ export const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/;
39
+
40
+ /** A raw SGR wheel report (Pi 0.84 and regular mode): ±3 lines at a 1-based terminal cell. */
41
+ export function rawWheel(data: string): { delta: number; column: number; row: number } | undefined {
42
+ const match = SGR_MOUSE.exec(data);
43
+ const button = match ? Number.parseInt(match[1]!, 10) : 0;
44
+ if ((button & 64) === 0 || (button & 3) > 1) return undefined;
45
+ return { delta: button & 1 ? 3 : -3, column: Number(match![2]), row: Number(match![3]) };
46
+ }
47
+
48
+ export function wheelDelta(data: string): number {
49
+ return rawWheel(data)?.delta ?? 0;
50
+ }
51
+
52
+ /** Structural subset of pi-tui's mouse event; Pi 0.84 declares no mouse types. */
53
+ export type BroMouseEvent = { type: string; x?: number; y?: number; wheelDelta?: number };
54
+ export type BroMouseResult = { handled: boolean; render: boolean } | undefined;
55
+
56
+ /** Pi 1.2+ scrolls the chat behind an overlay whose handleMouse leaves a wheel unhandled. */
57
+ export function consumeWheel(event: BroMouseEvent): BroMouseResult {
58
+ return event.type === "wheel" ? { handled: true, render: false } : undefined;
59
+ }
60
+
61
+ export function setRegularMouseReporting(
62
+ tui: { readonly mode: "regular" | "fullscreen"; readonly terminal?: { write?: (data: string) => void } },
63
+ enabled: boolean,
64
+ ): void {
65
+ if (tui.mode === "regular") tui.terminal?.write?.(`\x1b[?1000${enabled ? "h" : "l"}\x1b[?1006${enabled ? "h" : "l"}`);
66
+ }
67
+