pi-bro 0.22.2 → 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/CHANGELOG.md +39 -0
- package/README.md +5 -7
- package/THIRD_PARTY_NOTICES.md +57 -0
- package/docs/pig-compatibility.md +1 -1
- package/docs/user-guide.md +19 -8
- package/package.json +25 -18
- package/src/advisor.ts +573 -0
- package/{backend.ts → src/backend.ts} +35 -0
- package/src/bro.ts +424 -0
- package/src/btw.ts +572 -0
- package/{config-ui.ts → src/config-ui.ts} +17 -7
- package/src/doctor.ts +541 -0
- package/src/explain-modal.ts +758 -0
- package/{prompt.ts → src/prompt.ts} +3 -1
- package/{review-ui.ts → src/review-ui.ts} +5 -1
- package/{settings.ts → src/settings.ts} +45 -3
- package/src/show.ts +243 -0
- package/{ui-capabilities.ts → src/ui-capabilities.ts} +25 -0
- package/{util.ts → src/util.ts} +16 -0
- package/src/vendor/mermaid.min.js +4328 -0
- package/bro.ts +0 -2967
- /package/{review-modal.ts → src/review-modal.ts} +0 -0
- /package/{review-source.ts → src/review-source.ts} +0 -0
- /package/{review.ts → src/review.ts} +0 -0
- /package/{sources.ts → src/sources.ts} +0 -0
|
@@ -0,0 +1,758 @@
|
|
|
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
|
+
Container,
|
|
7
|
+
Editor,
|
|
8
|
+
Markdown,
|
|
9
|
+
Text,
|
|
10
|
+
matchesKey,
|
|
11
|
+
truncateToWidth,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
type Component,
|
|
14
|
+
type EditorTheme,
|
|
15
|
+
type Focusable,
|
|
16
|
+
type TUI,
|
|
17
|
+
} from "@earendil-works/pi-tui";
|
|
18
|
+
import { ADVISOR_STEERING_ENTRY, ADVISOR_STEERING_FILE, resolveAdvisorState } from "./advisor.ts";
|
|
19
|
+
import { runBackendText } from "./backend.ts";
|
|
20
|
+
import type { Theme, TuiLike } from "./config-ui.ts";
|
|
21
|
+
import {
|
|
22
|
+
type BroMode,
|
|
23
|
+
MAX_PREFERENCES_CHARS,
|
|
24
|
+
STARTER_PREFERENCES,
|
|
25
|
+
buildDefaultPrompt,
|
|
26
|
+
nextBroMode,
|
|
27
|
+
} from "./prompt.ts";
|
|
28
|
+
import {
|
|
29
|
+
type BroSettings,
|
|
30
|
+
CAPABILITIES,
|
|
31
|
+
CAPABILITY_LABELS,
|
|
32
|
+
PREFERENCES_FILE,
|
|
33
|
+
capabilityOverride,
|
|
34
|
+
queuePreferencesWrite,
|
|
35
|
+
readPreferences,
|
|
36
|
+
readPreferencesRaw,
|
|
37
|
+
selectionForCapability,
|
|
38
|
+
selectionLabel,
|
|
39
|
+
settingsFile,
|
|
40
|
+
} from "./settings.ts";
|
|
41
|
+
import { openShowHtml, stripShowDiagramFence } from "./show.ts";
|
|
42
|
+
import type { BroSource } from "./sources.ts";
|
|
43
|
+
import {
|
|
44
|
+
broModalRows,
|
|
45
|
+
consumeWheel,
|
|
46
|
+
hasBroCustomUi,
|
|
47
|
+
setRegularMouseReporting,
|
|
48
|
+
wheelDelta,
|
|
49
|
+
type BroMouseEvent,
|
|
50
|
+
type BroMouseResult,
|
|
51
|
+
} from "./ui-capabilities.ts";
|
|
52
|
+
import { errorMessage } from "./util.ts";
|
|
53
|
+
|
|
54
|
+
const LOADING_TEXT = "Simplifying for my bro…";
|
|
55
|
+
|
|
56
|
+
export type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
|
|
57
|
+
// `mode` is the built-in explain mode that produced the text; absent for Show and Doctor.
|
|
58
|
+
// `preferences` records whether bro-preferences.md shaped this result; like `mode`, it belongs to the result.
|
|
59
|
+
export type BroResult = { source: BroSource; text: string; model?: string; mode?: BroMode; preferences?: boolean };
|
|
60
|
+
export type ModalResult = { source?: BroSource; text: string; htmlPath?: string; model?: string; mode?: BroMode; preferences?: boolean };
|
|
61
|
+
|
|
62
|
+
export function latestAssistant(ctx: ExtensionCommandContext): BroSource | undefined {
|
|
63
|
+
const branch = ctx.sessionManager.getBranch();
|
|
64
|
+
|
|
65
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
66
|
+
const entry = branch[i];
|
|
67
|
+
if (entry.type !== "message" || entry.message.role !== "assistant" || entry.message.stopReason !== "stop") {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const text = entry.message.content
|
|
72
|
+
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
|
73
|
+
.map((part) => part.text)
|
|
74
|
+
.join("\n")
|
|
75
|
+
.trim();
|
|
76
|
+
|
|
77
|
+
if (text) return { text };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function simplify(
|
|
82
|
+
response: string,
|
|
83
|
+
signal: AbortSignal,
|
|
84
|
+
settings: BroSettings,
|
|
85
|
+
onProgress?: (text: string) => void,
|
|
86
|
+
mode = settings.mode,
|
|
87
|
+
): Promise<{ text: string; model: string; mode: BroMode; preferences: boolean }> {
|
|
88
|
+
const selection = selectionForCapability(settings, "explain");
|
|
89
|
+
const preferences = await readPreferences();
|
|
90
|
+
const text = await runBackendText(buildDefaultPrompt(response, mode, preferences), selection, signal, onProgress);
|
|
91
|
+
return { text, model: selectionLabel(selection), mode, preferences: Boolean(preferences) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type TextEditorModalOptions = {
|
|
95
|
+
title: string;
|
|
96
|
+
subtitle: string;
|
|
97
|
+
initialText: string;
|
|
98
|
+
initialNotice?: string;
|
|
99
|
+
// Returns an error message to refuse the save without calling onSave.
|
|
100
|
+
validate?: (text: string) => string | undefined;
|
|
101
|
+
// Either may be async; the modal then waits, ignores further edits, and reports success only once it settles.
|
|
102
|
+
onSave: (text: string) => void | Promise<void>;
|
|
103
|
+
onClear: () => void | Promise<void>;
|
|
104
|
+
copy?: (text: string) => Promise<void>;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Testable core shared by /bro advisor-steer and /bro preferences: persistence only happens on
|
|
108
|
+
// Ctrl+S/Ctrl+K. Esc leaves the stored text untouched; only the in-memory draft is discarded.
|
|
109
|
+
export function createTextEditorModal(
|
|
110
|
+
options: TextEditorModalOptions,
|
|
111
|
+
): (tui: TUI, theme: Theme, keybindings: unknown, done: (value?: void) => void) => Component & { dispose?(): void } {
|
|
112
|
+
const { onSave, onClear, validate, copy = copyToClipboard } = options;
|
|
113
|
+
return (tui, theme, _keybindings, done) => {
|
|
114
|
+
const editorTheme: EditorTheme = { borderColor: (s: string) => theme.fg("border", s), selectList: getSelectListTheme() };
|
|
115
|
+
const editor = new Editor(tui, editorTheme);
|
|
116
|
+
editor.focused = true;
|
|
117
|
+
editor.setText(options.initialText);
|
|
118
|
+
let disposed = false;
|
|
119
|
+
let pending = false;
|
|
120
|
+
// paddingY 0: the overlay clips from the bottom, so blank rows would push hints and notices off screen.
|
|
121
|
+
// shortcut: Editor keeps its own min height, so preferences fits from ~22 rows at 80+ columns; revisit if users report smaller terminals.
|
|
122
|
+
const notice = new Text(options.initialNotice ? theme.fg("accent", options.initialNotice) : "", 1, 0);
|
|
123
|
+
const showNotice = (message: string, color: "success" | "error") => {
|
|
124
|
+
if (disposed) return;
|
|
125
|
+
notice.setText(theme.fg(color, message));
|
|
126
|
+
tui.requestRender();
|
|
127
|
+
};
|
|
128
|
+
editor.onChange = () => notice.setText("");
|
|
129
|
+
const persist = (action: () => void | Promise<void>, success: string, failure: string, after?: () => void) => {
|
|
130
|
+
const fail = (error: unknown) => showNotice(`${failure}: ${errorMessage(error)}`, "error");
|
|
131
|
+
let result: void | Promise<void>;
|
|
132
|
+
try {
|
|
133
|
+
result = action();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
fail(error);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!(result instanceof Promise)) {
|
|
139
|
+
after?.();
|
|
140
|
+
showNotice(success, "success");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
pending = true;
|
|
144
|
+
result.then(
|
|
145
|
+
() => {
|
|
146
|
+
pending = false;
|
|
147
|
+
if (disposed) return;
|
|
148
|
+
after?.();
|
|
149
|
+
showNotice(success, "success");
|
|
150
|
+
},
|
|
151
|
+
(error) => {
|
|
152
|
+
pending = false;
|
|
153
|
+
fail(error);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const container = new Container();
|
|
159
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
|
|
160
|
+
container.addChild(new Text(theme.fg("dim", options.subtitle), 1, 0));
|
|
161
|
+
container.addChild(editor);
|
|
162
|
+
container.addChild(notice);
|
|
163
|
+
container.addChild(new Text(theme.fg("dim", "Ctrl+S save · Ctrl+K clear · Ctrl+C copy · Esc close"), 1, 0));
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
render: (w: number) => {
|
|
167
|
+
const inner = Math.max(1, w - 4);
|
|
168
|
+
const border = (left: string, right: string) => theme.fg("border", left + "─".repeat(inner + 2) + right);
|
|
169
|
+
return [border("┌", "┐"), ...container.render(inner).map((line) => {
|
|
170
|
+
const text = truncateToWidth(line, inner, "");
|
|
171
|
+
return theme.fg("border", "│") + " " + text + " ".repeat(Math.max(0, inner - visibleWidth(text))) + " " + theme.fg("border", "│");
|
|
172
|
+
}), border("└", "┘")];
|
|
173
|
+
},
|
|
174
|
+
invalidate: () => container.invalidate(),
|
|
175
|
+
handleMouse: consumeWheel,
|
|
176
|
+
handleInput: (data: string) => {
|
|
177
|
+
if (matchesKey(data, "escape")) {
|
|
178
|
+
disposed = true;
|
|
179
|
+
done(undefined);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (pending) return;
|
|
183
|
+
if (matchesKey(data, "ctrl+s")) {
|
|
184
|
+
const text = editor.getExpandedText();
|
|
185
|
+
const invalid = validate?.(text);
|
|
186
|
+
if (invalid) {
|
|
187
|
+
showNotice(invalid, "error");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
persist(() => onSave(text), "Saved", "Save failed");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (matchesKey(data, "ctrl+k")) {
|
|
194
|
+
persist(onClear, "Cleared", "Clear failed", () => editor.setText(""));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
198
|
+
const text = editor.getExpandedText();
|
|
199
|
+
void Promise.resolve()
|
|
200
|
+
.then(() => copy(text))
|
|
201
|
+
.then(() => showNotice("Copied", "success"))
|
|
202
|
+
.catch((error) => showNotice(`Copy failed: ${errorMessage(error)}`, "error"));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (matchesKey(data, "enter")) {
|
|
206
|
+
editor.insertTextAtCursor("\n");
|
|
207
|
+
tui.requestRender();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
editor.handleInput(data);
|
|
211
|
+
tui.requestRender();
|
|
212
|
+
},
|
|
213
|
+
dispose: () => { disposed = true; },
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function createAdvisorSteerModal(
|
|
219
|
+
initialText: string,
|
|
220
|
+
onSave: (text: string) => void,
|
|
221
|
+
onClear: () => void,
|
|
222
|
+
copy: (text: string) => Promise<void> = copyToClipboard,
|
|
223
|
+
): ReturnType<typeof createTextEditorModal> {
|
|
224
|
+
return createTextEditorModal({
|
|
225
|
+
title: "Bro · advisor steer",
|
|
226
|
+
subtitle: `Session-specific steering brief for the advisor (takes precedence over ${ADVISOR_STEERING_FILE}) — never sent to the main model.`,
|
|
227
|
+
initialText,
|
|
228
|
+
onSave,
|
|
229
|
+
onClear,
|
|
230
|
+
copy,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function preferencesTooLong(text: string): string | undefined {
|
|
235
|
+
const length = text.replace(/^\uFEFF/, "").trim().length;
|
|
236
|
+
return length > MAX_PREFERENCES_CHARS
|
|
237
|
+
? `${length.toLocaleString("en-US")}/${MAX_PREFERENCES_CHARS.toLocaleString("en-US")} characters — trim before saving`
|
|
238
|
+
: undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Testable core for /bro preferences. A missing file opens with the unsaved starter text; an
|
|
242
|
+
// unreadable or oversize-on-disk file opens empty with the error, and Clear still works.
|
|
243
|
+
export function createPreferencesModal(
|
|
244
|
+
loaded: { text?: string; error?: string },
|
|
245
|
+
persist: { save: (text: string) => Promise<void>; clear: () => Promise<void> },
|
|
246
|
+
copy?: (text: string) => Promise<void>,
|
|
247
|
+
): ReturnType<typeof createTextEditorModal> {
|
|
248
|
+
const starter = loaded.text === undefined && !loaded.error;
|
|
249
|
+
return createTextEditorModal({
|
|
250
|
+
title: "Bro · preferences",
|
|
251
|
+
subtitle: "About you and how you like answers. Sent to the selected backend with every explain, show, btw, and Guided Review request — never to the advisor or Pi's main model.",
|
|
252
|
+
initialText: starter ? STARTER_PREFERENCES : (loaded.text ?? ""),
|
|
253
|
+
initialNotice: loaded.error ?? (starter ? "Starter text — not saved. Ctrl+S saves it; Esc leaves no file." : undefined),
|
|
254
|
+
validate: preferencesTooLong,
|
|
255
|
+
onSave: persist.save,
|
|
256
|
+
onClear: persist.clear,
|
|
257
|
+
copy,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function showPreferencesModal(ctx: ExtensionCommandContext): Promise<void> {
|
|
262
|
+
if (!hasBroCustomUi(ctx)) {
|
|
263
|
+
ctx.ui.notify(`Edit ${PREFERENCES_FILE} directly.`, "warning");
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
let loaded: { text?: string; error?: string };
|
|
267
|
+
try {
|
|
268
|
+
const text = await readPreferencesRaw();
|
|
269
|
+
loaded = text === undefined ? {} : { text };
|
|
270
|
+
} catch (error) {
|
|
271
|
+
loaded = { error: `${errorMessage(error)} Ctrl+K deletes it.` };
|
|
272
|
+
}
|
|
273
|
+
await ctx.ui.custom<void>(
|
|
274
|
+
createPreferencesModal(loaded, {
|
|
275
|
+
// ponytail: last writer wins against external edits while the editor is open, like writeSettings.
|
|
276
|
+
save: (text) => queuePreferencesWrite(() => writeFile(PREFERENCES_FILE, `${text.trim()}\n`, "utf8")),
|
|
277
|
+
clear: () => queuePreferencesWrite(() => rm(PREFERENCES_FILE, { force: true })),
|
|
278
|
+
}),
|
|
279
|
+
{
|
|
280
|
+
overlay: true,
|
|
281
|
+
overlayOptions: { width: "78%", minWidth: 48, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function showAdvisorSteerModal(ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
|
|
287
|
+
if (!hasBroCustomUi(ctx)) {
|
|
288
|
+
ctx.ui.notify("Use /bro advisor-steer in Pi's interactive UI.", "warning");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const { steering } = resolveAdvisorState(ctx.sessionManager.getBranch());
|
|
292
|
+
await ctx.ui.custom<void>(
|
|
293
|
+
createAdvisorSteerModal(
|
|
294
|
+
steering,
|
|
295
|
+
(text) => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text }),
|
|
296
|
+
() => pi.appendEntry(ADVISOR_STEERING_ENTRY, { text: "" }),
|
|
297
|
+
),
|
|
298
|
+
{
|
|
299
|
+
overlay: true,
|
|
300
|
+
overlayOptions: { width: "78%", minWidth: 48, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 } },
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// `preferences` is a ready status ("off", "on (312 characters)", or "error: …") so a preferences
|
|
306
|
+
// problem never hides Help or mixes with settings errors.
|
|
307
|
+
export function helpText(settings?: BroSettings, settingsError?: string, preferences = "off"): string {
|
|
308
|
+
const overrideLines = settings
|
|
309
|
+
? CAPABILITIES.map((capability) => {
|
|
310
|
+
const override = capabilityOverride(settings, capability);
|
|
311
|
+
return override
|
|
312
|
+
? `- **${CAPABILITY_LABELS[capability]} override:** ${override.backend ?? "agy"} \`${override.model}\`${override.effort === "default" ? "" : ` (${override.effort})`}`
|
|
313
|
+
: undefined;
|
|
314
|
+
}).filter((line): line is string => line !== undefined)
|
|
315
|
+
: [];
|
|
316
|
+
const settingsSummary = settings
|
|
317
|
+
? `- **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")}` : ""}`
|
|
318
|
+
: `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
|
|
319
|
+
return `# Bro
|
|
320
|
+
|
|
321
|
+
Quick reference. Full user guide: https://github.com/tranhoangnguyen03/pi-bro/blob/main/docs/user-guide.md
|
|
322
|
+
|
|
323
|
+
## Explain and show
|
|
324
|
+
|
|
325
|
+
- \`/bro\` or \`/bro simplify\` — explain the latest completed assistant reply
|
|
326
|
+
- \`/bro text [text]\` — explain pasted text, or the latest reply when text is omitted
|
|
327
|
+
- \`/bro file <path>\` — explain a workspace \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\` file
|
|
328
|
+
- \`/bro url <url>\` — explain one public webpage
|
|
329
|
+
- \`/bro <input>\` — a lone URL, an existing supported file, or anything else as pasted text
|
|
330
|
+
- \`/bro open\` — reopen the latest explanation without a new request
|
|
331
|
+
- \`/bro show [n-turns] [query]\` — draw recent turns' conversation text as shapes; an optional query steers the focus
|
|
332
|
+
|
|
333
|
+
## Guided Review
|
|
334
|
+
|
|
335
|
+
- \`/bro guided-review <PR number or URL>\` — automatically explain and assess a PR with captured code and optional private questions (local Pi interactive only; Git and authenticated gh required).
|
|
336
|
+
- Your work is saved automatically. Open the same PR to return without a model call; \`/bro guided-review\` lists saved reviews (\`resume\` is an alias). Reopening keeps the captured revision, not newer commits.
|
|
337
|
+
- Enter opens an item or focuses actions; Enter on a highlighted action activates it. Tab changes focus. Esc goes back, then closes from Contents. Close saves automatically; Stop and close stops active work first.
|
|
338
|
+
- Copy finding copies to the system clipboard, not GitHub. Regenerate guide is optional and makes a confirmed call about the same captured code. Review generation/questions require Claude or Muse file-only tools: shell, writes and web tools are disabled. Set a review override in /bro config; other backends can still read saved reviews. Repository text can still mislead the model; citations are not proof.
|
|
339
|
+
|
|
340
|
+
## Side conversation
|
|
341
|
+
|
|
342
|
+
- \`/bro btw [question]\` — open a side conversation seeded with recent main-session context. It starts conversation-only; reopening keeps the thread and its mode.
|
|
343
|
+
- Inside the modal: Enter asks (empty Enter re-asks); \`/mode\` toggles conversation-only / full permission (read and edit the workspace) and keeps the thread; \`/copy\` and \`/copy-all\` copy to the clipboard; \`/insert\` and \`/insert-all\` insert into an empty main editor without submitting; \`/retry\` re-asks; \`/clear\` resets. Any other text is sent as a question. Esc closes.
|
|
344
|
+
|
|
345
|
+
## Advisor
|
|
346
|
+
|
|
347
|
+
- \`bro_advisor\` — a tool the executor agent may call for a second opinion from a fresh backend process with real, auto-approved workspace access; it is told to advise, not edit, but that is not enforced
|
|
348
|
+
- \`/bro advisor\` — whether \`bro_advisor\` is available right now
|
|
349
|
+
- \`/bro advisor-steer\` — edit the session's persistent steering brief (**Ctrl+S** save, **Ctrl+K** clear, **Ctrl+C** copy, **Esc** close)
|
|
350
|
+
- Standing advisor steering: \`${ADVISOR_STEERING_FILE}\` sets standing priorities across sessions (session steering takes precedence)
|
|
351
|
+
|
|
352
|
+
## Configure and check
|
|
353
|
+
|
|
354
|
+
- \`/bro preferences\` — tell Bro about yourself and how you like answers; added to explain, show, btw, and Guided Review prompts, never the advisor (**Ctrl+S** save, **Ctrl+K** delete, **Ctrl+C** copy, **Esc** close)
|
|
355
|
+
- \`/bro config\` — shared default and per-capability (explain/show/btw/advisor/review) backend, model, and effort; explain mode; show turns. Changes save immediately.
|
|
356
|
+
- \`/bro mode [brief|balanced|faithful]\` — view or choose the explanation mode
|
|
357
|
+
- \`/bro doctor\` — check settings, preferences, and every selected backend without running a model turn
|
|
358
|
+
|
|
359
|
+
## Current settings
|
|
360
|
+
|
|
361
|
+
${settingsSummary}
|
|
362
|
+
- **Preferences:** ${preferences} — \`/bro preferences\`
|
|
363
|
+
|
|
364
|
+
Saved in \`${settingsFile()}\`, \`${PREFERENCES_FILE}\`, and \`${ADVISOR_STEERING_FILE}\`.
|
|
365
|
+
|
|
366
|
+
## Explanation modes
|
|
367
|
+
|
|
368
|
+
- brief — the main point and next action, with no fixed word target
|
|
369
|
+
- balanced — default; material detail with clearer structure
|
|
370
|
+
- faithful — closest to the source, with no fixed word limit
|
|
371
|
+
|
|
372
|
+
Press **M** in an explanation to re-simplify it in the next mode without changing the saved one.
|
|
373
|
+
|
|
374
|
+
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.
|
|
375
|
+
|
|
376
|
+
## Controls
|
|
377
|
+
|
|
378
|
+
- **Mouse wheel / trackpad**, **↑ / ↓** — scroll
|
|
379
|
+
- **C** — copy the full explanation
|
|
380
|
+
- **R** — repeat the current action (an explanation keeps its mode)
|
|
381
|
+
- **M** — re-simplify in the next mode (brief → balanced → faithful); not saved
|
|
382
|
+
- **O** — open the diagram (HTML layout or sequence diagram) when a show reply contains one
|
|
383
|
+
- **Esc** — close, or cancel while Bro is working
|
|
384
|
+
|
|
385
|
+
## Privacy
|
|
386
|
+
|
|
387
|
+
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.`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
|
|
391
|
+
export class BroModal implements Focusable {
|
|
392
|
+
focused = false;
|
|
393
|
+
private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
|
|
394
|
+
private kind: ModalKind = "loading";
|
|
395
|
+
private rawText = "";
|
|
396
|
+
private sourceLabel = "";
|
|
397
|
+
private modelLabel = "";
|
|
398
|
+
private notice = "";
|
|
399
|
+
private offset = 0;
|
|
400
|
+
private maxOffset = 0;
|
|
401
|
+
private bodyHeight = 1;
|
|
402
|
+
private copyable = false;
|
|
403
|
+
private retryable = false;
|
|
404
|
+
private disposed = false;
|
|
405
|
+
private htmlPath = "";
|
|
406
|
+
// Survives loading/streaming so the header names the mode being produced; empty disables M.
|
|
407
|
+
private modeLabel = "";
|
|
408
|
+
// Set only with a result, so the tag never claims preferences for work still in flight.
|
|
409
|
+
private preferencesUsed = false;
|
|
410
|
+
|
|
411
|
+
constructor(
|
|
412
|
+
private readonly tui: TuiLike,
|
|
413
|
+
private readonly theme: Theme,
|
|
414
|
+
private readonly onClose: () => void,
|
|
415
|
+
private readonly onRetry: () => void,
|
|
416
|
+
private readonly onDispose: () => void,
|
|
417
|
+
private readonly retryLabel: string,
|
|
418
|
+
private readonly onSwitchMode: () => void = () => {},
|
|
419
|
+
) {
|
|
420
|
+
setRegularMouseReporting(this.tui, true);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
setLoading(text = LOADING_TEXT): void {
|
|
424
|
+
this.setContent("loading", `**${text}**`, "", false, false);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
setStreaming(text: string): void {
|
|
428
|
+
this.setContent("streaming", text, "", false, false);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
setResult(text: string, retryable: boolean, notice = "", sourceLabel = "", rawText = text, modelLabel = ""): void {
|
|
432
|
+
this.setContent("result", text, rawText, true, retryable, notice, sourceLabel, modelLabel);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
setHtmlPath(path: string): void {
|
|
436
|
+
this.htmlPath = path;
|
|
437
|
+
this.tui.requestRender();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
setMode(mode: BroMode | undefined): void {
|
|
441
|
+
this.modeLabel = mode ?? "";
|
|
442
|
+
this.tui.requestRender();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
setPreferences(used: boolean): void {
|
|
446
|
+
this.preferencesUsed = used;
|
|
447
|
+
this.tui.requestRender();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
|
|
451
|
+
this.setContent(kind, text, text, copyable, false);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
setError(message: string): void {
|
|
455
|
+
this.setContent("error", `# Bro ran into a problem\n\n${message}`, "", false, true);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private setContent(
|
|
459
|
+
kind: ModalKind,
|
|
460
|
+
text: string,
|
|
461
|
+
rawText: string,
|
|
462
|
+
copyable: boolean,
|
|
463
|
+
retryable: boolean,
|
|
464
|
+
notice = "",
|
|
465
|
+
sourceLabel = "",
|
|
466
|
+
modelLabel = "",
|
|
467
|
+
): void {
|
|
468
|
+
this.kind = kind;
|
|
469
|
+
if (kind !== "result") this.htmlPath = "";
|
|
470
|
+
this.rawText = rawText;
|
|
471
|
+
this.copyable = copyable;
|
|
472
|
+
this.retryable = retryable;
|
|
473
|
+
this.notice = notice;
|
|
474
|
+
this.sourceLabel = stripVTControlCharacters(sourceLabel)
|
|
475
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
476
|
+
.replace(/\s+/g, " ")
|
|
477
|
+
.trim();
|
|
478
|
+
this.modelLabel = modelLabel;
|
|
479
|
+
if (kind !== "streaming") this.offset = 0;
|
|
480
|
+
this.markdown.setText(text);
|
|
481
|
+
this.tui.requestRender();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
485
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
486
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
487
|
+
return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
491
|
+
const left = edge === "top" ? "┌" : "└";
|
|
492
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
493
|
+
return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private ruleLine(innerWidth: number): string {
|
|
497
|
+
return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private canSwitchMode(): boolean {
|
|
501
|
+
return Boolean(this.modeLabel) && (this.kind === "loading" || this.kind === "streaming" || this.kind === "result");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private controls(): string {
|
|
505
|
+
const mode = this.canSwitchMode() ? " · M mode" : "";
|
|
506
|
+
if (this.kind === "loading") return mode ? "M mode · Esc cancel" : "Esc cancel";
|
|
507
|
+
if (this.kind === "streaming") return `Simplifying… · ↑/↓ scroll${mode} · Esc cancel`;
|
|
508
|
+
if (this.kind === "result") {
|
|
509
|
+
return `↑/↓ scroll · C copy${this.htmlPath ? " · O open diagram" : ""}${mode}${this.retryable ? ` · R ${this.retryLabel}` : ""} · Esc close`;
|
|
510
|
+
}
|
|
511
|
+
if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
|
|
512
|
+
if (this.kind === "error") return "R try again · Esc close";
|
|
513
|
+
return "Esc close";
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
render(width: number): string[] {
|
|
517
|
+
const dialogWidth = Math.max(24, width);
|
|
518
|
+
const innerWidth = Math.max(22, dialogWidth - 2);
|
|
519
|
+
const terminalRows = broModalRows(this.tui, process.stdout.rows);
|
|
520
|
+
const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
|
|
521
|
+
this.bodyHeight = Math.max(1, dialogHeight - 6);
|
|
522
|
+
|
|
523
|
+
const rendered = this.markdown.render(innerWidth);
|
|
524
|
+
this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
|
|
525
|
+
this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
|
|
526
|
+
const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
|
|
527
|
+
const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
|
|
528
|
+
const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
|
|
529
|
+
const controls = this.notice ? `${this.notice} · ${this.controls()}` : this.controls();
|
|
530
|
+
|
|
531
|
+
const lines = [
|
|
532
|
+
this.borderLine(innerWidth, "top"),
|
|
533
|
+
this.frameLine(
|
|
534
|
+
this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}`)) +
|
|
535
|
+
this.theme.fg("dim", `${this.modelLabel ? ` · ${this.modelLabel}` : ""}${this.modeLabel ? ` · ${this.modeLabel}` : ""}${this.preferencesUsed ? " · prefs" : ""}${scroll}`),
|
|
536
|
+
innerWidth,
|
|
537
|
+
),
|
|
538
|
+
this.ruleLine(innerWidth),
|
|
539
|
+
];
|
|
540
|
+
|
|
541
|
+
for (const line of visible) lines.push(this.frameLine(line, innerWidth));
|
|
542
|
+
for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
|
|
543
|
+
|
|
544
|
+
lines.push(this.ruleLine(innerWidth));
|
|
545
|
+
lines.push(this.frameLine(this.theme.fg("dim", controls), innerWidth));
|
|
546
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
547
|
+
return lines;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
invalidate(): void {
|
|
551
|
+
this.markdown.invalidate();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Pi 1.2+ fullscreen delivers the wheel here and no longer forwards raw wheel input to handleInput.
|
|
555
|
+
handleMouse(event: BroMouseEvent): BroMouseResult {
|
|
556
|
+
if (event.type !== "wheel") return;
|
|
557
|
+
const delta = Math.trunc(event.wheelDelta ?? 0);
|
|
558
|
+
if (delta) this.scroll(delta);
|
|
559
|
+
return { handled: true, render: false };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private scroll(delta: number): void {
|
|
563
|
+
this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
|
|
564
|
+
this.notice = "";
|
|
565
|
+
this.tui.requestRender();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
handleInput(data: string): void {
|
|
569
|
+
if (matchesKey(data, "escape")) {
|
|
570
|
+
this.onClose();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
|
|
575
|
+
if (delta) {
|
|
576
|
+
this.scroll(delta);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
|
|
581
|
+
void copyToClipboard(this.rawText)
|
|
582
|
+
.then(() => {
|
|
583
|
+
if (!this.disposed) {
|
|
584
|
+
this.notice = "Copied";
|
|
585
|
+
this.tui.requestRender();
|
|
586
|
+
}
|
|
587
|
+
})
|
|
588
|
+
.catch((error) => {
|
|
589
|
+
if (!this.disposed) {
|
|
590
|
+
this.notice = `Copy failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
591
|
+
this.tui.requestRender();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (
|
|
598
|
+
(matchesKey(data, "r") || matchesKey(data, "shift+r")) &&
|
|
599
|
+
this.retryable &&
|
|
600
|
+
this.kind !== "loading"
|
|
601
|
+
) {
|
|
602
|
+
this.onRetry();
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if ((matchesKey(data, "m") || matchesKey(data, "shift+m")) && this.canSwitchMode()) {
|
|
607
|
+
this.onSwitchMode();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if ((matchesKey(data, "o") || matchesKey(data, "shift+o")) && this.htmlPath && this.kind === "result") {
|
|
612
|
+
this.notice = openShowHtml(this.htmlPath)
|
|
613
|
+
? "Opening diagram"
|
|
614
|
+
: `Could not open ${this.htmlPath}`;
|
|
615
|
+
this.tui.requestRender();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
dispose(): void {
|
|
620
|
+
if (this.disposed) return;
|
|
621
|
+
this.disposed = true;
|
|
622
|
+
setRegularMouseReporting(this.tui, false);
|
|
623
|
+
this.onDispose();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export interface BroModalOptions {
|
|
628
|
+
text?: string;
|
|
629
|
+
kind?: "help" | "empty";
|
|
630
|
+
copyable?: boolean;
|
|
631
|
+
result?: ModalResult;
|
|
632
|
+
run?: (
|
|
633
|
+
signal: AbortSignal,
|
|
634
|
+
source?: BroSource,
|
|
635
|
+
onProgress?: (text: string) => void,
|
|
636
|
+
mode?: BroMode,
|
|
637
|
+
) => Promise<ModalResult>;
|
|
638
|
+
onResult?: (result: ModalResult) => void;
|
|
639
|
+
loadingText?: string;
|
|
640
|
+
retryable?: boolean;
|
|
641
|
+
retryLabel?: string;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
|
|
645
|
+
if (!hasBroCustomUi(ctx)) {
|
|
646
|
+
if (options.run && !options.result && options.text === undefined) {
|
|
647
|
+
const result = await options.run(new AbortController().signal);
|
|
648
|
+
options.onResult?.(result);
|
|
649
|
+
}
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
await ctx.ui.custom<void>(
|
|
654
|
+
(tui, theme, _keybindings, done) => {
|
|
655
|
+
let closed = false;
|
|
656
|
+
let controller: AbortController | undefined;
|
|
657
|
+
let current = options.result;
|
|
658
|
+
let inFlightMode: BroMode | undefined;
|
|
659
|
+
let execute: (source?: BroSource, mode?: BroMode, notice?: string) => void = () => {};
|
|
660
|
+
|
|
661
|
+
const close = () => {
|
|
662
|
+
if (closed) return;
|
|
663
|
+
closed = true;
|
|
664
|
+
controller?.abort();
|
|
665
|
+
done(undefined);
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const modal = new BroModal(
|
|
669
|
+
tui,
|
|
670
|
+
theme,
|
|
671
|
+
close,
|
|
672
|
+
() => execute(current?.source, current?.mode),
|
|
673
|
+
() => {
|
|
674
|
+
closed = true;
|
|
675
|
+
controller?.abort();
|
|
676
|
+
},
|
|
677
|
+
options.retryLabel ?? "simplify again",
|
|
678
|
+
// M advances from the mode in flight, so repeated presses cancel and skip ahead; the saved default is untouched.
|
|
679
|
+
() => {
|
|
680
|
+
const from = inFlightMode ?? current?.mode;
|
|
681
|
+
if (!from || !current?.source || closed) return;
|
|
682
|
+
const next = nextBroMode(from);
|
|
683
|
+
controller?.abort();
|
|
684
|
+
controller = undefined;
|
|
685
|
+
execute(current.source, next, `Mode: ${next} (not saved)`);
|
|
686
|
+
},
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
const present = (result: ModalResult, notice = "") => {
|
|
690
|
+
const display = result.htmlPath ? stripShowDiagramFence(result.text) : result.text;
|
|
691
|
+
modal.setResult(display, options.retryable ?? Boolean(options.run), notice, result.source?.label, result.text, result.model);
|
|
692
|
+
modal.setHtmlPath(result.htmlPath ?? "");
|
|
693
|
+
modal.setMode(result.mode);
|
|
694
|
+
modal.setPreferences(Boolean(result.preferences));
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
execute = (source?: BroSource, mode?: BroMode, notice = "") => {
|
|
698
|
+
if (!options.run || controller || closed) return;
|
|
699
|
+
const previous = current;
|
|
700
|
+
const nextController = new AbortController();
|
|
701
|
+
controller = nextController;
|
|
702
|
+
inFlightMode = mode;
|
|
703
|
+
modal.setLoading(options.loadingText);
|
|
704
|
+
modal.setMode(mode);
|
|
705
|
+
modal.setPreferences(false);
|
|
706
|
+
|
|
707
|
+
void options
|
|
708
|
+
.run(nextController.signal, source, (text) => {
|
|
709
|
+
if (closed || nextController.signal.aborted || controller !== nextController) return;
|
|
710
|
+
modal.setStreaming(text);
|
|
711
|
+
}, mode)
|
|
712
|
+
.then((result) => {
|
|
713
|
+
if (closed || nextController.signal.aborted) return;
|
|
714
|
+
current = result;
|
|
715
|
+
options.onResult?.(result);
|
|
716
|
+
present(result, notice);
|
|
717
|
+
})
|
|
718
|
+
.catch((error) => {
|
|
719
|
+
if (closed || nextController.signal.aborted) return;
|
|
720
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
721
|
+
if (previous) {
|
|
722
|
+
current = previous;
|
|
723
|
+
present(previous, `Retry failed: ${message}`);
|
|
724
|
+
} else {
|
|
725
|
+
modal.setMode(undefined);
|
|
726
|
+
modal.setError(message);
|
|
727
|
+
}
|
|
728
|
+
})
|
|
729
|
+
.finally(() => {
|
|
730
|
+
if (controller === nextController) {
|
|
731
|
+
controller = undefined;
|
|
732
|
+
inFlightMode = undefined;
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
if (options.text !== undefined) {
|
|
738
|
+
modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
|
|
739
|
+
} else if (current) {
|
|
740
|
+
present(current);
|
|
741
|
+
} else {
|
|
742
|
+
execute();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return modal;
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
overlay: true,
|
|
749
|
+
overlayOptions: {
|
|
750
|
+
width: "78%",
|
|
751
|
+
minWidth: 48,
|
|
752
|
+
maxHeight: "78%",
|
|
753
|
+
anchor: "top-center",
|
|
754
|
+
margin: { top: 1, left: 2, right: 2 },
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
);
|
|
758
|
+
}
|