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
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { copyToClipboard, getMarkdownTheme, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
Editor,
|
|
7
|
+
Markdown,
|
|
8
|
+
matchesKey,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
type Component,
|
|
11
|
+
type EditorTheme,
|
|
12
|
+
type Focusable,
|
|
13
|
+
type TUI,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import { ADVISOR_STEERING_ENTRY, ADVISOR_STEERING_FILE, resolveAdvisorState } from "./advisor.ts";
|
|
16
|
+
import { runBackendText } from "./backend.ts";
|
|
17
|
+
import {
|
|
18
|
+
type BroMode,
|
|
19
|
+
MAX_PREFERENCES_CHARS,
|
|
20
|
+
STARTER_PREFERENCES,
|
|
21
|
+
buildDefaultPrompt,
|
|
22
|
+
nextBroMode,
|
|
23
|
+
} from "./prompt.ts";
|
|
24
|
+
import {
|
|
25
|
+
type BroSettings,
|
|
26
|
+
CAPABILITIES,
|
|
27
|
+
CAPABILITY_LABELS,
|
|
28
|
+
PREFERENCES_FILE,
|
|
29
|
+
capabilityOverride,
|
|
30
|
+
queuePreferencesWrite,
|
|
31
|
+
readPreferences,
|
|
32
|
+
readPreferencesRaw,
|
|
33
|
+
selectionForCapability,
|
|
34
|
+
selectionLabel,
|
|
35
|
+
settingsFile,
|
|
36
|
+
} from "./settings.ts";
|
|
37
|
+
import { openShowHtml, stripShowDiagramFence } from "./show.ts";
|
|
38
|
+
import type { BroSource } from "./sources.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";
|
|
41
|
+
import { errorMessage } from "./util.ts";
|
|
42
|
+
|
|
43
|
+
const LOADING_TEXT = "Simplifying for my bro…";
|
|
44
|
+
|
|
45
|
+
export type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
|
|
46
|
+
// `mode` is the built-in explain mode that produced the text; absent for Show and Doctor.
|
|
47
|
+
// `preferences` records whether bro-preferences.md shaped this result; like `mode`, it belongs to the result.
|
|
48
|
+
export type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode; preferences?: boolean };
|
|
49
|
+
export type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode; preferences?: boolean };
|
|
50
|
+
|
|
51
|
+
export function latestAssistant(ctx: ExtensionCommandContext): BroSource | undefined {
|
|
52
|
+
const branch = ctx.sessionManager.getBranch();
|
|
53
|
+
|
|
54
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
55
|
+
const entry = branch[i];
|
|
56
|
+
if (entry.type !== "message" || entry.message.role !== "assistant" || entry.message.stopReason !== "stop") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const text = entry.message.content
|
|
61
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
62
|
+
.map((part) => part.text)
|
|
63
|
+
.join("\n")
|
|
64
|
+
.trim();
|
|
65
|
+
|
|
66
|
+
if (text) return { text };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function simplify(
|
|
71
|
+
response: string,
|
|
72
|
+
signal: AbortSignal,
|
|
73
|
+
settings: BroSettings,
|
|
74
|
+
onProgress?: (text: string) => void,
|
|
75
|
+
mode = settings.mode,
|
|
76
|
+
): Promise<{ text: string; model: string; mode: BroMode; preferences: boolean }> {
|
|
77
|
+
const selection = selectionForCapability(settings, "explain");
|
|
78
|
+
const preferences = await readPreferences();
|
|
79
|
+
const text = await runBackendText(buildDefaultPrompt(response, mode, preferences), selection, signal, onProgress);
|
|
80
|
+
return { text, model: selectionLabel(selection), mode, preferences: Boolean(preferences) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type TextEditorModalOptions = {
|
|
84
|
+
title: string;
|
|
85
|
+
/** Always shown in full above the editor; the editor asks for a bigger terminal before hiding it. */
|
|
86
|
+
privacy: string;
|
|
87
|
+
subtitle: string;
|
|
88
|
+
initialText: string;
|
|
89
|
+
initialNotice?: string;
|
|
90
|
+
initialNoticeTone?: NoticeTone;
|
|
91
|
+
// Returns an error message to refuse the save without calling onSave.
|
|
92
|
+
validate?: (text: string) => string | undefined;
|
|
93
|
+
// Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
|
|
94
|
+
onSave: (text: string) => void | Promise<void>;
|
|
95
|
+
onClear: () => void | Promise<void>;
|
|
96
|
+
copy?: (text: string) => Promise<void>;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Testable core shared by /bro advisor-steer and /bro preferences: persistence only happens on
|
|
100
|
+
// Ctrl+S/Ctrl+K. Esc leaves the stored text untouched; only the in-memory draft is discarded.
|
|
101
|
+
export function createTextEditorModal(
|
|
102
|
+
options: TextEditorModalOptions,
|
|
103
|
+
): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
104
|
+
const { onSave, onClear, validate, copy = copyToClipboard } = options;
|
|
105
|
+
return (tui, theme, _keybindings, done) => {
|
|
106
|
+
const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
|
|
107
|
+
const editor = new Editor(tui, editorTheme);
|
|
108
|
+
editor.focused = true;
|
|
109
|
+
editor.setText(options.initialText);
|
|
110
|
+
let disposed = false;
|
|
111
|
+
let pending = false;
|
|
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) => {
|
|
123
|
+
if (disposed) return;
|
|
124
|
+
notice = formatNotice(theme, message, tone);
|
|
125
|
+
tui.requestRender();
|
|
126
|
+
};
|
|
127
|
+
editor.onChange = () => {
|
|
128
|
+
notice = "";
|
|
129
|
+
discardArmed = false;
|
|
130
|
+
};
|
|
131
|
+
const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
|
|
132
|
+
const fail = (error: unknown) => {
|
|
133
|
+
closeRequested = false;
|
|
134
|
+
showNotice(`${failure}: ${errorMessage(error)}`, "error");
|
|
135
|
+
};
|
|
136
|
+
let result: void | Promise<void>;
|
|
137
|
+
try {
|
|
138
|
+
result = action();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
fail(error);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!(result instanceof Promise)) {
|
|
144
|
+
after?.();
|
|
145
|
+
showNotice(success, "success");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pending = true;
|
|
149
|
+
result.then(
|
|
150
|
+
() => {
|
|
151
|
+
pending = false;
|
|
152
|
+
if (disposed) return;
|
|
153
|
+
after?.();
|
|
154
|
+
showNotice(success, "success");
|
|
155
|
+
if (closeRequested) {
|
|
156
|
+
closeRequested = false;
|
|
157
|
+
disposed = true;
|
|
158
|
+
done(undefined);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
(error) => {
|
|
162
|
+
pending = false;
|
|
163
|
+
fail(error);
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return {
|
|
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);
|
|
210
|
+
},
|
|
211
|
+
handleInput: (data: string) => {
|
|
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
|
+
}
|
|
224
|
+
disposed = true;
|
|
225
|
+
done(undefined);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
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;
|
|
248
|
+
if (matchesKey(data, "ctrl+s")) {
|
|
249
|
+
const text = editor.getExpandedText();
|
|
250
|
+
const invalid = validate?.(text);
|
|
251
|
+
if (invalid) {
|
|
252
|
+
showNotice(invalid, "error");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
persist(() => onSave(text), "Saved", "Save failed", () => {
|
|
256
|
+
lastSavedText = text;
|
|
257
|
+
discardArmed = false;
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (matchesKey(data, "ctrl+k")) {
|
|
262
|
+
persist(onClear, "Cleared", "Clear failed", () => {
|
|
263
|
+
editor.setText("");
|
|
264
|
+
lastSavedText = "";
|
|
265
|
+
discardArmed = false;
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (matchesKey(data, "enter")) {
|
|
270
|
+
editor.insertTextAtCursor("\n");
|
|
271
|
+
tui.requestRender();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
editor.handleInput(data);
|
|
275
|
+
tui.requestRender();
|
|
276
|
+
},
|
|
277
|
+
dispose: () => { disposed = true; },
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function createAdvisorSteerModal(
|
|
283
|
+
initialText: string,
|
|
284
|
+
onSave: (text: string) => void,
|
|
285
|
+
onClear: () => void,
|
|
286
|
+
copy: (text: string) => Promise<void> = copyToClipboard,
|
|
287
|
+
): ReturnType<typeof createTextEditorModal> {
|
|
288
|
+
return createTextEditorModal({
|
|
289
|
+
title: "Bro · advisor steer",
|
|
290
|
+
privacy: "Never sent to the main model.",
|
|
291
|
+
subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}).`,
|
|
292
|
+
initialText,
|
|
293
|
+
onSave,
|
|
294
|
+
onClear,
|
|
295
|
+
copy,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function preferencesTooLong(text: string): string | undefined {
|
|
300
|
+
const length = text.replace(/^\uFEFF/, "").trim().length;
|
|
301
|
+
return length > MAX_PREFERENCES_CHARS
|
|
302
|
+
? `${length.toLocaleString("en-US")}/${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} characters — trim before saving`
|
|
303
|
+
: undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Testable core for /bro preferences. A missing file opens with the unsaved starter text; an
|
|
307
|
+
// unreadable or oversize-on-disk file opens empty with the error, and Clear still works.
|
|
308
|
+
export function createPreferencesModal(
|
|
309
|
+
loaded: { text?: string; error?: string },
|
|
310
|
+
persist: { save: (text: string) => Promise<void>; clear: () => Promise<void> },
|
|
311
|
+
copy?: (text: string) => Promise<void>,
|
|
312
|
+
): ReturnType<typeof createTextEditorModal> {
|
|
313
|
+
const starter = loaded.text === undefined && !loaded.error;
|
|
314
|
+
return createTextEditorModal({
|
|
315
|
+
title: "Bro · preferences",
|
|
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.",
|
|
318
|
+
initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
|
|
319
|
+
initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it." : undefined),
|
|
320
|
+
initialNoticeTone: loaded.error ? "error" : "info",
|
|
321
|
+
validate: preferencesTooLong,
|
|
322
|
+
onSave: persist.save,
|
|
323
|
+
onClear: persist.clear,
|
|
324
|
+
copy,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function showPreferencesModal(ctx: ExtensionCommandContext): Promise<void> {
|
|
329
|
+
if (!hasBroCustomUi(ctx)) {
|
|
330
|
+
ctx.ui.notify(`Edit ${PREFERENCES_FILE} directly.`, "warning");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
let loaded: { text?: string; error?: string };
|
|
334
|
+
try {
|
|
335
|
+
const text = await readPreferencesRaw();
|
|
336
|
+
loaded = text === undefined ? {} : { text };
|
|
337
|
+
} catch (error) {
|
|
338
|
+
// Action first: long notices are cut after three lines.
|
|
339
|
+
loaded = { error: `Unusable preferences file (Ctrl+K deletes it): ${errorMessage(error)}` };
|
|
340
|
+
}
|
|
341
|
+
await openBroOverlay<void>(
|
|
342
|
+
ctx,
|
|
343
|
+
createPreferencesModal(loaded, {
|
|
344
|
+
// ponytail: last writer wins against external edits while the editor is open, like writeSettings.
|
|
345
|
+
save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
|
|
346
|
+
clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
352
|
+
if (!hasBroCustomUi(ctx)) {
|
|
353
|
+
ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const { steering } = resolveAdvisorState(ctx.sessionManager.getBranch());
|
|
357
|
+
await openBroOverlay<void>(
|
|
358
|
+
ctx,
|
|
359
|
+
createAdvisorSteerModal(
|
|
360
|
+
steering,
|
|
361
|
+
(text) => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text }),
|
|
362
|
+
() => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text: "" }),
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// `preferences` is a ready status ("off", "on (312 characters)", or "error: …") so a preferences
|
|
368
|
+
// problem never hides Help or mixes with settings errors.
|
|
369
|
+
export function helpText(settings?: BroSettings, settingsError?: string, preferences = "off"): string {
|
|
370
|
+
const overrideLines = settings
|
|
371
|
+
? CAPABILITIES.map((capability) => {
|
|
372
|
+
const override = capabilityOverride(settings, capability);
|
|
373
|
+
return override
|
|
374
|
+
? `- **${CAPABILITY_LABELS[capability]} override:** ${override.backend ?? "agy"} \`${override.model}\`${override.effort === "default" ? "" : ` (${override.effort})`}`
|
|
375
|
+
: undefined;
|
|
376
|
+
}).filter((line): line is string => line !== undefined)
|
|
377
|
+
: [];
|
|
378
|
+
const settingsSummary = settings
|
|
379
|
+
? `- **Backend:** ${settings.backend ?? "agy"}\n- **Model:** \`${settings.model}\`\n- **Reasoning effort:** ${settings.effort === "default" ? "built into the selected model" : settings.effort}\n- **Mode:** ${settings.mode}\n- **Show turns:** ${settings.showTurns}${overrideLines.length ? `\n${overrideLines.join("\n")}` : ""}`
|
|
380
|
+
: `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
|
|
381
|
+
return `# Bro
|
|
382
|
+
|
|
383
|
+
Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/blob/main/docs/user-guide.md
|
|
384
|
+
|
|
385
|
+
## Explain and show
|
|
386
|
+
|
|
387
|
+
- \`/bro\` or \`/bro simplify\` — explain the latest completed assistant reply
|
|
388
|
+
- \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
|
|
389
|
+
- \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
|
|
390
|
+
- \`/bro url <url>\` — explain one public webpage
|
|
391
|
+
- \`/bro <input>\` — a lone URL, an existing supported file, or anything else as pasted text
|
|
392
|
+
- \`/bro open\` — reopen the latest explanation without a new request
|
|
393
|
+
- \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
|
|
394
|
+
|
|
395
|
+
## Guided Review
|
|
396
|
+
|
|
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).
|
|
398
|
+
- Your work is saved automatically. Open the same PR to return without a model call; \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Reopening keeps the captured revision, not newer commits.
|
|
399
|
+
- Enter opens an item or focuses actions; Enter on a highlighted action activates it. Tab changes focus. Esc goes back, then closes from Contents. Close saves automatically; Stop and close stops active work first.
|
|
400
|
+
- Copy finding copies to the system clipboard, not GitHub. Regenerate guide is optional and makes a confirmed call about the same captured code. Claude and Muse use strict file-only inspection (shell, writes and web tools disabled) and are recommended for untrusted PRs. Codex and Grok run shell commands in a read-only sandbox; Agy's sandboxed shell still allows project writes. All three show an advisory. Set a review override in /bro config; saved reviews remain readable with any backend. Repository text can still mislead the model; citations are not proof.
|
|
401
|
+
|
|
402
|
+
## Side conversation
|
|
403
|
+
|
|
404
|
+
- \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
|
|
405
|
+
- 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.
|
|
406
|
+
|
|
407
|
+
## Advisor
|
|
408
|
+
|
|
409
|
+
- \`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
|
|
410
|
+
- \`/bro advisor\` — whether \`bro_advisor\` is available right now
|
|
411
|
+
- \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
|
|
412
|
+
- Standing advisor steering: \`${ADVISOR_STEERING_FILE}\` sets standing priorities across sessions (session steering takes precedence)
|
|
413
|
+
|
|
414
|
+
## Configure and check
|
|
415
|
+
|
|
416
|
+
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
417
|
+
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately. If the terminal is too small, Esc closes config after pending saves finish; a failed save keeps it open.
|
|
418
|
+
- \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
|
|
419
|
+
- \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
|
|
420
|
+
|
|
421
|
+
## Current settings
|
|
422
|
+
|
|
423
|
+
${settingsSummary}
|
|
424
|
+
- **Preferences:** ${preferences} — \`/bro preferences\`
|
|
425
|
+
|
|
426
|
+
Saved in \`${settingsFile()}\`, \`${PREFERENCES_FILE}\`, and \`${ADVISOR_STEERING_FILE}\`.
|
|
427
|
+
|
|
428
|
+
## Explanation modes
|
|
429
|
+
|
|
430
|
+
- brief — the main point and next action, with no fixed word target
|
|
431
|
+
- balanced — default; material detail with clearer structure
|
|
432
|
+
- faithful — closest to the source, with no fixed word limit
|
|
433
|
+
|
|
434
|
+
Press **M** in an explanation to re-simplify it in the next mode without changing the saved one.
|
|
435
|
+
|
|
436
|
+
The mode decides how much of the source to keep; your preferences decide who it's written for, its tone, and its language. Neither overrides Bro's source rules.
|
|
437
|
+
|
|
438
|
+
## Controls
|
|
439
|
+
|
|
440
|
+
- **Mouse wheel / trackpad**, **↑ / ↓**, **PgUp / PgDn** — scroll
|
|
441
|
+
- **C** — copy the full explanation
|
|
442
|
+
- **R** — repeat the current action (an explanation keeps its mode)
|
|
443
|
+
- **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
|
|
444
|
+
- **O** — open the diagram (HTML layout or sequence diagram) when a show reply contains one
|
|
445
|
+
- **Esc** — close, or cancel while Bro is working
|
|
446
|
+
|
|
447
|
+
## Privacy
|
|
448
|
+
|
|
449
|
+
Bro sends the captured source (or, for the advisor, the executor's instructions, tools, and conversation) to the selected backend and its model provider, which may retain it under their own policies. Your preferences go with every explain, show, btw, and Guided Review request. Guided Review sends PR data/diff and relevant private questions; records/source remain locally under getAgentDir()/bro-reviews. Copy finding goes to the system clipboard, not GitHub. Nothing is added to Pi's conversation unless you insert it, except advisor answers, which return to the agent as tool results. Access controls differ by backend; see the user guide.`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
|
|
453
|
+
export class BroModal implements Focusable {
|
|
454
|
+
focused = false;
|
|
455
|
+
private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
|
|
456
|
+
private kind: ModalKind = "loading";
|
|
457
|
+
private rawText = "";
|
|
458
|
+
private sourceLabel = "";
|
|
459
|
+
private modelLabel = "";
|
|
460
|
+
private noticeText = "";
|
|
461
|
+
private noticeTone: NoticeTone = "info";
|
|
462
|
+
private rawArea: Area | undefined;
|
|
463
|
+
private readonly scroller = new Scroller(() => {
|
|
464
|
+
this.noticeText = "";
|
|
465
|
+
this.tui.requestRender();
|
|
466
|
+
});
|
|
467
|
+
private copyable = false;
|
|
468
|
+
private retryable = false;
|
|
469
|
+
private disposed = false;
|
|
470
|
+
private htmlPath = "";
|
|
471
|
+
// Survives loading/streaming so the header names the mode being produced; empty disables M.
|
|
472
|
+
private modeLabel = "";
|
|
473
|
+
// Set only with a result, so the tag never claims preferences for work still in flight.
|
|
474
|
+
private preferencesUsed = false;
|
|
475
|
+
|
|
476
|
+
constructor(
|
|
477
|
+
private readonly tui: TuiLike,
|
|
478
|
+
private readonly theme: Theme,
|
|
479
|
+
private readonly onClose: () => void,
|
|
480
|
+
private readonly onRetry: () => void,
|
|
481
|
+
private readonly onDispose: () => void,
|
|
482
|
+
private readonly retryLabel: string,
|
|
483
|
+
private readonly onSwitchMode: () => void = () => {},
|
|
484
|
+
) {}
|
|
485
|
+
|
|
486
|
+
setLoading(text = LOADING_TEXT): void {
|
|
487
|
+
this.setContent("loading", `**${text}**`, "", false, false);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
setStreaming(text: string): void {
|
|
491
|
+
this.setContent("streaming", text, "", false, false);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
get notice(): string {
|
|
495
|
+
return this.noticeText;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
set notice(text: string) {
|
|
499
|
+
this.setNotice(text);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = "", noticeTone: NoticeTone = "info"): void {
|
|
503
|
+
this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel, noticeTone);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
setNotice(text: string, tone: NoticeTone = "info"): void {
|
|
507
|
+
if (this.disposed) return;
|
|
508
|
+
this.noticeText = text;
|
|
509
|
+
this.noticeTone = tone;
|
|
510
|
+
this.tui.requestRender();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
setHtmlPath(path: string): void {
|
|
514
|
+
this.htmlPath = path;
|
|
515
|
+
this.tui.requestRender();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
setMode(mode: BroMode | undefined): void {
|
|
519
|
+
this.modeLabel = mode ?? "";
|
|
520
|
+
this.tui.requestRender();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
setPreferences(used: boolean): void {
|
|
524
|
+
this.preferencesUsed = used;
|
|
525
|
+
this.tui.requestRender();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
|
|
529
|
+
this.setContent(kind, text, text, copyable, false);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
setError(message: string): void {
|
|
533
|
+
this.setContent("error", `# Bro ran into a problem\n\n${message}`, "", false, true);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private setContent(
|
|
537
|
+
kind: ModalKind,
|
|
538
|
+
text: string,
|
|
539
|
+
rawText: string,
|
|
540
|
+
copyable: boolean,
|
|
541
|
+
retryable: boolean,
|
|
542
|
+
notice = "",
|
|
543
|
+
sourceLabel = "",
|
|
544
|
+
modelLabel = "",
|
|
545
|
+
noticeTone: NoticeTone = "info",
|
|
546
|
+
): void {
|
|
547
|
+
this.kind = kind;
|
|
548
|
+
if (kind !== "result") this.htmlPath = "";
|
|
549
|
+
this.rawText = rawText;
|
|
550
|
+
this.copyable = copyable;
|
|
551
|
+
this.retryable = retryable;
|
|
552
|
+
this.noticeText = notice;
|
|
553
|
+
this.noticeTone = noticeTone;
|
|
554
|
+
this.sourceLabel = stripVTControlCharacters(sourceLabel)
|
|
555
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
556
|
+
.replace(/\s+/g, " ")
|
|
557
|
+
.trim();
|
|
558
|
+
this.modelLabel = modelLabel;
|
|
559
|
+
if (kind !== "streaming") this.scroller.toStart();
|
|
560
|
+
this.markdown.setText(text);
|
|
561
|
+
this.tui.requestRender();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private canSwitchMode(): boolean {
|
|
565
|
+
return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private controls(): string {
|
|
569
|
+
const mode = this.canSwitchMode() ? " · M mode" : "";
|
|
570
|
+
if (this.kind === "loading") return mode ? "M mode · Esc cancel" : "Esc cancel";
|
|
571
|
+
if (this.kind === "streaming") return `Simplifying… · ↑/↓ scroll${mode} · Esc cancel`;
|
|
572
|
+
if (this.kind === "result") {
|
|
573
|
+
return `↑/↓ scroll · C copy${this.htmlPath ? " · O open diagram" : ""}${mode}${this.retryable ? ` · R ${this.retryLabel}` : ""} · Esc close`;
|
|
574
|
+
}
|
|
575
|
+
if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
|
|
576
|
+
if (this.kind === "error") return "R try again · Esc close";
|
|
577
|
+
return "Esc close";
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
render(width: number): string[] {
|
|
581
|
+
const height = modalHeight(this.tui, BRO_OVERLAY, 32);
|
|
582
|
+
this.rawArea = broOverlayArea(this.tui, width, height);
|
|
583
|
+
return renderFrame(this.theme, width, height, {
|
|
584
|
+
header: () => {
|
|
585
|
+
const scroll = this.scroller.indicator();
|
|
586
|
+
return (
|
|
587
|
+
this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
|
|
588
|
+
this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll ? ` · ${scroll}` : ""}`)
|
|
589
|
+
);
|
|
590
|
+
},
|
|
591
|
+
body: (rows, innerWidth) => this.scroller.view(this.markdown.render(innerWidth), rows),
|
|
592
|
+
notice: this.noticeText ? formatNotice(this.theme, this.noticeText, this.noticeTone) : undefined,
|
|
593
|
+
hints: this.controls(),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
invalidate(): void {
|
|
598
|
+
this.markdown.invalidate();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
handleMouse(event: BroMouseEvent): BroMouseResult {
|
|
602
|
+
return this.scroller.handleMouse(event);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
handleInput(data: string): void {
|
|
606
|
+
if (matchesKey(data, "escape")) {
|
|
607
|
+
this.onClose();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (this.scroller.handleKey(data, this.rawArea)) return;
|
|
612
|
+
|
|
613
|
+
if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
|
|
614
|
+
void copyToClipboard(this.rawText)
|
|
615
|
+
.then(() => {
|
|
616
|
+
this.setNotice("Copied", "success");
|
|
617
|
+
})
|
|
618
|
+
.catch((error) => {
|
|
619
|
+
this.setNotice(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
620
|
+
});
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (
|
|
625
|
+
(matchesKey(data, "r") || matchesKey(data, "shift+r")) &&
|
|
626
|
+
this.retryable &&
|
|
627
|
+
this.kind !== "loading"
|
|
628
|
+
) {
|
|
629
|
+
this.onRetry();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if ((matchesKey(data, "m") || matchesKey(data, "shift+m")) && this.canSwitchMode()) {
|
|
634
|
+
this.onSwitchMode();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if ((matchesKey(data, "o") || matchesKey(data, "shift+o")) && this.htmlPath && this.kind === "result") {
|
|
639
|
+
const opened = openShowHtml(this.htmlPath);
|
|
640
|
+
this.setNotice(
|
|
641
|
+
opened ? "Opening diagram" : `Could not open ${this.htmlPath}`,
|
|
642
|
+
opened ? "info" : "error",
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
dispose(): void {
|
|
648
|
+
if (this.disposed) return;
|
|
649
|
+
this.disposed = true;
|
|
650
|
+
this.onDispose();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export interface BroModalOptions {
|
|
655
|
+
text?: string;
|
|
656
|
+
kind?: "help" | "empty";
|
|
657
|
+
copyable?: boolean;
|
|
658
|
+
result?: ModalResult;
|
|
659
|
+
run?: (
|
|
660
|
+
signal: AbortSignal,
|
|
661
|
+
source?: BroSource,
|
|
662
|
+
onProgress?: (text: string) => void,
|
|
663
|
+
mode?: BroMode,
|
|
664
|
+
) => Promise<ModalResult>;
|
|
665
|
+
onResult?: (result: ModalResult) => void;
|
|
666
|
+
loadingText?: string;
|
|
667
|
+
retryable?: boolean;
|
|
668
|
+
retryLabel?: string;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
export async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
|
|
672
|
+
if (!hasBroCustomUi(ctx)) {
|
|
673
|
+
if (options.run && !options.result && options.text === undefined) {
|
|
674
|
+
const result = await options.run(new AbortController().signal);
|
|
675
|
+
options.onResult?.(result);
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
await openBroOverlay<void>(
|
|
681
|
+
ctx,
|
|
682
|
+
(tui, theme, _keybindings, done) => {
|
|
683
|
+
let closed = false;
|
|
684
|
+
let controller: AbortController | undefined;
|
|
685
|
+
let current = options.result;
|
|
686
|
+
let inFlightMode: BroMode | undefined;
|
|
687
|
+
let execute: (source?: BroSource, mode?: BroMode, notice?: string, noticeTone?: NoticeTone) => void = () => {};
|
|
688
|
+
|
|
689
|
+
const close = () => {
|
|
690
|
+
if (closed) return;
|
|
691
|
+
closed = true;
|
|
692
|
+
controller?.abort();
|
|
693
|
+
done(undefined);
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const modal = new BroModal(
|
|
697
|
+
tui,
|
|
698
|
+
theme,
|
|
699
|
+
close,
|
|
700
|
+
() => execute(current?.source, current?.mode),
|
|
701
|
+
() => {
|
|
702
|
+
closed = true;
|
|
703
|
+
controller?.abort();
|
|
704
|
+
},
|
|
705
|
+
options.retryLabel ?? "simplify again",
|
|
706
|
+
// M advances from the mode in flight, so repeated presses cancel and skip ahead; the saved default is untouched.
|
|
707
|
+
() => {
|
|
708
|
+
const from = inFlightMode ?? current?.mode;
|
|
709
|
+
if (!from || !current?.source || closed) return;
|
|
710
|
+
const next = nextBroMode(from);
|
|
711
|
+
controller?.abort();
|
|
712
|
+
controller = undefined;
|
|
713
|
+
execute(current.source, next, `Mode: ${next} (not saved)`);
|
|
714
|
+
},
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
const present = (result: ModalResult, notice = "", noticeTone: NoticeTone = "info") => {
|
|
718
|
+
const display = result.htmlPath ? stripShowDiagramFence(result.text) : result.text;
|
|
719
|
+
modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model, noticeTone);
|
|
720
|
+
modal.setHtmlPath(result.htmlPath ?? "");
|
|
721
|
+
modal.setMode(result.mode);
|
|
722
|
+
modal.setPreferences(Boolean(result.preferences));
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
execute = (source?: BroSource, mode?: BroMode, notice = "", noticeTone: NoticeTone = "info") => {
|
|
726
|
+
if (!options.run || controller || closed) return;
|
|
727
|
+
const previous = current;
|
|
728
|
+
const nextController = new AbortController();
|
|
729
|
+
controller = nextController;
|
|
730
|
+
inFlightMode = mode;
|
|
731
|
+
modal.setLoading(options.loadingText);
|
|
732
|
+
modal.setMode(mode);
|
|
733
|
+
modal.setPreferences(false);
|
|
734
|
+
|
|
735
|
+
void options
|
|
736
|
+
.run(nextController.signal, source, (text) => {
|
|
737
|
+
if (closed || nextController.signal.aborted || controller !== nextController) return;
|
|
738
|
+
modal.setStreaming(text);
|
|
739
|
+
}, mode)
|
|
740
|
+
.then((result) => {
|
|
741
|
+
if (closed || nextController.signal.aborted) return;
|
|
742
|
+
current = result;
|
|
743
|
+
options.onResult?.(result);
|
|
744
|
+
present(result, notice, noticeTone);
|
|
745
|
+
})
|
|
746
|
+
.catch((error) => {
|
|
747
|
+
if (closed || nextController.signal.aborted) return;
|
|
748
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
749
|
+
if (previous) {
|
|
750
|
+
current = previous;
|
|
751
|
+
present(previous, `Retry failed: ${message}`, "error");
|
|
752
|
+
} else {
|
|
753
|
+
modal.setMode(undefined);
|
|
754
|
+
modal.setError(message);
|
|
755
|
+
}
|
|
756
|
+
})
|
|
757
|
+
.finally(() => {
|
|
758
|
+
if (controller === nextController) {
|
|
759
|
+
controller = undefined;
|
|
760
|
+
inFlightMode = undefined;
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
if (options.text !== undefined) {
|
|
766
|
+
modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
|
|
767
|
+
} else if (current) {
|
|
768
|
+
present(current);
|
|
769
|
+
} else {
|
|
770
|
+
execute();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return modal;
|
|
774
|
+
},
|
|
775
|
+
{ mouse: true },
|
|
776
|
+
);
|
|
777
|
+
}
|