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/CHANGELOG.md +36 -0
- package/README.md +10 -9
- package/docs/guided-review.md +8 -12
- package/docs/user-guide.md +38 -19
- package/package.json +8 -2
- package/src/advisor.ts +573 -0
- package/src/backend.ts +100 -42
- package/src/bro.ts +37 -2623
- package/src/btw.ts +556 -0
- package/src/config-ui.ts +76 -39
- package/src/doctor.ts +538 -0
- package/src/explain-modal.ts +777 -0
- package/src/modal-kit.ts +258 -0
- package/src/review-modal.ts +95 -48
- package/src/review-ui.ts +57 -47
- package/src/settings.ts +45 -3
- package/src/show.ts +243 -0
- package/src/ui-capabilities.ts +32 -0
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,
|
|
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
|
|
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
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
509
|
-
|
|
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
|
+
}
|
package/src/ui-capabilities.ts
CHANGED
|
@@ -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
|
+
|