pi-bro 0.23.0 → 0.23.1

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
@@ -25,6 +25,7 @@ import {
25
25
  } from "./review.ts";
26
26
  import { captureReview, readEvidence, resolveReviewTarget } from "./review-source.ts";
27
27
  import { ReviewModal, newReviewView, safeReviewText } from "./review-modal.ts";
28
+ import { consumeWheel } from "./ui-capabilities.ts";
28
29
 
29
30
  const activeReviews = new Map<string, AbortController | undefined>();
30
31
  export function stopGuidedReviews(): void {
@@ -177,6 +178,7 @@ export async function openGuidedReview(
177
178
  .map((line) => theme.fg("accent", truncateToWidth(line, width, "")));
178
179
  },
179
180
  invalidate() {},
181
+ handleMouse: consumeWheel,
180
182
  handleInput(data: string) {
181
183
  if (matchesKey(data, "escape")) {
182
184
  disposed = true;
@@ -495,12 +497,14 @@ export async function openGuidedReview(
495
497
  panel.focused = value;
496
498
  },
497
499
  dispose() {
500
+ // close() sets `closed` only after its save succeeded; other teardown still saves here.
501
+ const savedByClose = closed;
498
502
  closed = true;
499
503
  panel.dispose();
500
504
  evidenceAbort.abort();
501
505
  controller?.abort();
502
506
  if (saveTimer) clearTimeout(saveTimer);
503
- void persist().catch(() => {});
507
+ if (!savedByClose) void persist().catch(() => {});
504
508
  },
505
509
  };
506
510
  },
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,28 @@ 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
+ export function wheelDelta(data: string): number {
38
+ const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
39
+ if (!match) return 0;
40
+ const button = Number.parseInt(match[1], 10);
41
+ if ((button & 64) === 0) return 0;
42
+ return (button & 3) === 0 ? -3 : (button & 3) === 1 ? 3 : 0;
43
+ }
44
+
45
+ /** Structural subset of pi-tui's mouse event; Pi 0.84 declares no mouse types. */
46
+ export type BroMouseEvent = { type: string; wheelDelta?: number };
47
+ export type BroMouseResult = { handled: boolean; render: boolean } | undefined;
48
+
49
+ /** Pi 1.2+ scrolls the chat behind an overlay whose handleMouse leaves a wheel unhandled. */
50
+ export function consumeWheel(event: BroMouseEvent): BroMouseResult {
51
+ return event.type === "wheel" ? { handled: true, render: false } : undefined;
52
+ }
53
+
54
+ export function setRegularMouseReporting(
55
+ tui: { readonly mode: "regular" | "fullscreen"; readonly terminal?: { write?: (data: string) => void } },
56
+ enabled: boolean,
57
+ ): void {
58
+ if (tui.mode === "regular") tui.terminal?.write?.(`\x1b[?1000${enabled ? "h" : "l"}\x1b[?1006${enabled ? "h" : "l"}`);
59
+ }
60
+