pi-bro 0.3.0 → 0.5.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/README.md +52 -16
- package/bro.ts +379 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ adding extra messages to your conversation context.
|
|
|
6
6
|
`pi-bro` is a small extension for
|
|
7
7
|
[Earendil Pi](https://github.com/earendil-works/pi). It uses the
|
|
8
8
|
[Google Antigravity CLI](https://antigravity.google/docs/cli-install) (`agy`)
|
|
9
|
-
and
|
|
9
|
+
and your selected Agy model to stream plain-language explanations.
|
|
10
10
|
|
|
11
11
|
## Bro in action
|
|
12
12
|
|
|
@@ -265,7 +265,7 @@ cached files, not your source code or dependencies.
|
|
|
265
265
|
|
|
266
266
|
- Earendil Pi `>=0.78.1 <1` (tested on `0.84.2`)
|
|
267
267
|
- Node.js `>=22.19.0`
|
|
268
|
-
- `agy >=1.1.
|
|
268
|
+
- `agy >=1.1.11` installed, authenticated, and on your `PATH` (tested on `1.1.13`)
|
|
269
269
|
- Pi's interactive terminal UI
|
|
270
270
|
|
|
271
271
|
Run `agy` once in your terminal to complete sign-in before using Bro.
|
|
@@ -299,15 +299,54 @@ pi -e npm:pi-bro
|
|
|
299
299
|
| `/bro` | Create a new plain-language explanation of the latest completed assistant response. |
|
|
300
300
|
| `/bro simplify` | Same as `/bro`. |
|
|
301
301
|
| `/bro open` | Reopen the latest explanation without calling the simplifier again. |
|
|
302
|
+
| `/bro usage` | Show current Agy resource limits. |
|
|
303
|
+
| `/bro usage --provider agy` | Same as `/bro usage`, with the provider stated explicitly. |
|
|
304
|
+
| `/bro model` | Choose from the models currently available through Agy. |
|
|
305
|
+
| `/bro model <id>` | Set an available Agy model directly. |
|
|
306
|
+
| `/bro effort` | Choose an effort supported by the current model. |
|
|
307
|
+
| `/bro effort <low\|medium\|high>` | Set a supported reasoning effort directly. |
|
|
302
308
|
| `/bro help` | Open the built-in guide. |
|
|
303
309
|
|
|
304
310
|
### Modal controls
|
|
305
311
|
|
|
312
|
+
- **Mouse wheel / trackpad**: Scroll in Pi's fullscreen mode
|
|
306
313
|
- **↑ / ↓**: Scroll up or down
|
|
307
314
|
- **C**: Copy the full explanation to your clipboard
|
|
308
315
|
- **R**: Run the simplifier again on the same response
|
|
309
316
|
- **Esc**: Close the window, or cancel while Bro is running
|
|
310
317
|
|
|
318
|
+
In Pi's regular terminal mode, the Bro title warns that mouse-wheel scrolling
|
|
319
|
+
needs fullscreen mode. Arrow-key scrolling still works.
|
|
320
|
+
|
|
321
|
+
## Settings
|
|
322
|
+
|
|
323
|
+
Bro creates this user-editable settings file when the extension loads:
|
|
324
|
+
|
|
325
|
+
```text
|
|
326
|
+
~/.pi/agent/bro-settings.json
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
```json
|
|
330
|
+
{
|
|
331
|
+
"model": "gemini-3.7-flash",
|
|
332
|
+
"effort": "low"
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Use `/bro model` and `/bro effort` to update it from Pi, or edit it directly.
|
|
337
|
+
Bro reads the file again before each explanation, so manual changes apply to
|
|
338
|
+
the next `/bro`. Use a model ID shown by `/bro model`; `effort` must be
|
|
339
|
+
one of the levels shown by `/bro effort`. Models without adjustable effort use
|
|
340
|
+
`default`. The choices remain active across Pi restarts until you change them.
|
|
341
|
+
`/bro help` shows the active settings and the exact file path.
|
|
342
|
+
|
|
343
|
+
If `PI_CODING_AGENT_DIR` is set, the file lives there instead. `PI_BRO_MODEL`
|
|
344
|
+
chooses the initial model only when Bro creates a missing settings file:
|
|
345
|
+
|
|
346
|
+
```sh
|
|
347
|
+
PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
348
|
+
```
|
|
349
|
+
|
|
311
350
|
## Custom prompt
|
|
312
351
|
|
|
313
352
|
Bro uses a built-in prompt by default. To use your own, create:
|
|
@@ -329,18 +368,12 @@ Assistant response:
|
|
|
329
368
|
Bro re-reads this file every time you simplify, so your edits take effect
|
|
330
369
|
immediately without reloading Pi. Bro never creates or modifies this file.
|
|
331
370
|
|
|
332
|
-
### Custom model
|
|
333
|
-
|
|
334
|
-
Set `PI_BRO_MODEL` before starting Pi to use a different Agy model:
|
|
335
|
-
|
|
336
|
-
```sh
|
|
337
|
-
PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
338
|
-
```
|
|
339
|
-
|
|
340
371
|
## Privacy and files
|
|
341
372
|
|
|
342
373
|
- **External requests**: Bro sends the latest completed assistant response to
|
|
343
374
|
Agy and its configured model provider.
|
|
375
|
+
- **Usage checks**: `/bro usage` checks your authenticated Agy limits without
|
|
376
|
+
sending an assistant response or running a model turn.
|
|
344
377
|
- **Context isolation**: Bro does not add explanations to Pi's conversation
|
|
345
378
|
history, session files, or main-agent context.
|
|
346
379
|
- **Memory cache**: The latest explanation is stored only in process memory for
|
|
@@ -348,7 +381,8 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
|
348
381
|
Pi.
|
|
349
382
|
- **File safety**: Bro does not modify project files. It runs Agy in sandbox
|
|
350
383
|
mode inside a temporary empty folder. This reduces project access, but it is
|
|
351
|
-
not a security boundary.
|
|
384
|
+
not a security boundary. Bro only writes its own user settings file described
|
|
385
|
+
above.
|
|
352
386
|
- **Provider data**: Agy and your model provider may retain logs and request data
|
|
353
387
|
according to their own settings and privacy policies.
|
|
354
388
|
- **Clipboard**: Pressing **C** copies the text to your system clipboard, where
|
|
@@ -356,12 +390,13 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
|
356
390
|
|
|
357
391
|
## Current limits
|
|
358
392
|
|
|
359
|
-
-
|
|
393
|
+
- Uses Agy as its only provider.
|
|
360
394
|
- Keeps only the latest explanation in memory.
|
|
361
395
|
- Does not store history or export directly to files.
|
|
362
396
|
- Mouse-wheel and trackpad scrolling work in Pi's fullscreen mode
|
|
363
|
-
(`pi --tui-mode fullscreen`). In regular mode,
|
|
364
|
-
not interfere with your terminal's
|
|
397
|
+
(`pi --tui-mode fullscreen`). In regular mode, Bro shows a warning in its
|
|
398
|
+
title; use the arrow keys so Bro does not interfere with your terminal's
|
|
399
|
+
native text selection.
|
|
365
400
|
- In fullscreen mode, mouse text selection may visually extend outside the Bro
|
|
366
401
|
window. Press **C** to copy the full explanation instead.
|
|
367
402
|
|
|
@@ -370,11 +405,12 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
|
370
405
|
```sh
|
|
371
406
|
npm install
|
|
372
407
|
npm test
|
|
373
|
-
pi -e ./bro.ts
|
|
408
|
+
pi --tui-mode fullscreen -e ./bro.ts
|
|
374
409
|
```
|
|
375
410
|
|
|
376
411
|
The smoke test uses a fake `agy`, so it does not call an external model. It
|
|
377
|
-
verifies command routing, custom prompt handling, and context
|
|
412
|
+
verifies command routing, settings, custom prompt handling, and context
|
|
413
|
+
isolation.
|
|
378
414
|
|
|
379
415
|
## License
|
|
380
416
|
|
package/bro.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
@@ -7,8 +7,11 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
7
7
|
import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Markdown, matchesKey, truncateToWidth, visibleWidth, type Focusable } from "@earendil-works/pi-tui";
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
const
|
|
10
|
+
const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
11
|
+
const ENV_MODEL = process.env.PI_BRO_MODEL?.trim();
|
|
12
|
+
const DEFAULT_MODEL = ENV_MODEL || "gemini-3.7-flash";
|
|
13
|
+
const PROMPT_FILE = join(AGENT_DIR, "bro-prompt.md");
|
|
14
|
+
const SETTINGS_FILE = join(AGENT_DIR, "bro-settings.json");
|
|
12
15
|
const LOADING_TEXT = "Simplifying for my bro…";
|
|
13
16
|
const DEFAULT_TEMPLATE = `Rewrite the quoted response for a non-expert.
|
|
14
17
|
Use plain English and short sentences. Explain jargon briefly.
|
|
@@ -21,10 +24,21 @@ Quoted response as a JSON string:
|
|
|
21
24
|
{{response}}`;
|
|
22
25
|
|
|
23
26
|
type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
24
|
-
type TuiLike = { requestRender(): void };
|
|
27
|
+
type TuiLike = { readonly mode: "regular" | "fullscreen"; requestRender(): void };
|
|
25
28
|
type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
|
|
26
29
|
type AssistantSource = { id: string; text: string };
|
|
27
30
|
type BroResult = { source: AssistantSource; text: string };
|
|
31
|
+
type ModalResult = { source?: AssistantSource; text: string };
|
|
32
|
+
const EFFORTS = ["default", "low", "medium", "high"] as const;
|
|
33
|
+
type BroEffort = (typeof EFFORTS)[number];
|
|
34
|
+
type AgyEffort = Exclude<BroEffort, "default">;
|
|
35
|
+
type BroSettings = { model: string; effort: BroEffort };
|
|
36
|
+
type AgyModelFamily = {
|
|
37
|
+
id: string;
|
|
38
|
+
label: string;
|
|
39
|
+
efforts: AgyEffort[];
|
|
40
|
+
variants: Array<{ id: string; effort?: AgyEffort }>;
|
|
41
|
+
};
|
|
28
42
|
type AgyEvent = {
|
|
29
43
|
event?: string;
|
|
30
44
|
step_update?: { step_type?: string; text_delta?: unknown };
|
|
@@ -42,9 +56,172 @@ export function wheelDelta(data: string): number {
|
|
|
42
56
|
const COMMANDS = [
|
|
43
57
|
{ value: "simplify", label: "simplify", description: "Simplify the latest assistant response" },
|
|
44
58
|
{ value: "open", label: "open", description: "Reopen the last explanation" },
|
|
59
|
+
{ value: "usage", label: "usage", description: "Show current Agy usage" },
|
|
60
|
+
{ value: "model", label: "model", description: "Choose the Agy model" },
|
|
61
|
+
{ value: "effort", label: "effort", description: "Choose the Agy reasoning effort" },
|
|
45
62
|
{ value: "help", label: "help", description: "Learn what Bro does and what it can access" },
|
|
46
63
|
];
|
|
47
64
|
|
|
65
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
66
|
+
return typeof value === "object" && value !== null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseBroSettings(value: unknown): BroSettings {
|
|
70
|
+
if (
|
|
71
|
+
!isRecord(value) ||
|
|
72
|
+
typeof value.model !== "string" ||
|
|
73
|
+
!value.model.trim() ||
|
|
74
|
+
!EFFORTS.some((effort) => effort === value.effort)
|
|
75
|
+
) {
|
|
76
|
+
throw new Error('Settings must contain a model and effort set to "default", "low", "medium", or "high".');
|
|
77
|
+
}
|
|
78
|
+
return { model: value.model.trim(), effort: value.effort as BroSettings["effort"] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ensureSettingsFile(): Promise<void> {
|
|
82
|
+
await mkdir(AGENT_DIR, { recursive: true });
|
|
83
|
+
try {
|
|
84
|
+
await writeFile(
|
|
85
|
+
SETTINGS_FILE,
|
|
86
|
+
`${JSON.stringify({ model: DEFAULT_MODEL, effort: ENV_MODEL ? "default" : "low" }, null, 2)}\n`,
|
|
87
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
88
|
+
);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readSettings(): Promise<BroSettings> {
|
|
95
|
+
await ensureSettingsFile();
|
|
96
|
+
try {
|
|
97
|
+
return parseBroSettings(JSON.parse(await readFile(SETTINGS_FILE, "utf8")));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error instanceof SyntaxError) throw new Error(`${SETTINGS_FILE} is not valid JSON.`);
|
|
100
|
+
if (error instanceof Error) throw new Error(`${SETTINGS_FILE}: ${error.message}`);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function writeSettings(settings: BroSettings): Promise<void> {
|
|
106
|
+
// ponytail: last writer wins across concurrent Pi processes; add locking only if that becomes a common workflow.
|
|
107
|
+
await writeFile(SETTINGS_FILE, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatAgyUsage(value: unknown): string {
|
|
111
|
+
if (!isRecord(value) || value.status !== "SUCCESS" || typeof value.response !== "string") {
|
|
112
|
+
throw new Error("Agy returned invalid usage data.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const groups = new Map<string, string[]>();
|
|
116
|
+
for (const line of value.response.trim().split("\n")) {
|
|
117
|
+
const [group, limit, remaining, resetTime] = line.split("\t");
|
|
118
|
+
if (!group || !limit || !remaining) throw new Error("Agy returned invalid usage data.");
|
|
119
|
+
const reset = resetTime ? new Date(resetTime) : undefined;
|
|
120
|
+
const resetText = reset && !Number.isNaN(reset.getTime()) ? ` — resets ${reset.toLocaleString()}` : "";
|
|
121
|
+
const items = groups.get(group) ?? [];
|
|
122
|
+
items.push(`- **${limit}:** ${remaining}${resetText}`);
|
|
123
|
+
groups.set(group, items);
|
|
124
|
+
}
|
|
125
|
+
if (!groups.size) throw new Error("Agy returned no usage information.");
|
|
126
|
+
|
|
127
|
+
const sections = [...groups].map(([group, items]) => `## ${group}\n\n${items.join("\n")}`);
|
|
128
|
+
return `# Agy usage\n\n${sections.join("\n\n")}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function parseAgyModels(output: string): AgyModelFamily[] {
|
|
132
|
+
// ponytail: Agy 1.1.13 exposes a tab-separated variant list; use structured catalog data when available here.
|
|
133
|
+
const families = new Map<string, AgyModelFamily>();
|
|
134
|
+
for (const line of output.split(/\r?\n/)) {
|
|
135
|
+
const [rawId, ...rawLabel] = line.split("\t");
|
|
136
|
+
if (!rawId?.trim() || !rawLabel.length) continue;
|
|
137
|
+
const id = rawId.trim();
|
|
138
|
+
const label = rawLabel.join(" ").trim();
|
|
139
|
+
const effort = (["low", "medium", "high"] as const).find(
|
|
140
|
+
(value) => id.endsWith(`-${value}`) && label.endsWith(`(${value[0].toUpperCase()}${value.slice(1)})`),
|
|
141
|
+
);
|
|
142
|
+
const familyId = effort ? id.slice(0, -effort.length - 1) : id;
|
|
143
|
+
const family = families.get(familyId) ?? {
|
|
144
|
+
id: familyId,
|
|
145
|
+
label: effort ? label.replace(/\s+\((Low|Medium|High)\)$/, "") : label,
|
|
146
|
+
efforts: [],
|
|
147
|
+
variants: [],
|
|
148
|
+
};
|
|
149
|
+
if (effort && !family.efforts.includes(effort)) family.efforts.push(effort);
|
|
150
|
+
family.variants.push({ id, effort });
|
|
151
|
+
families.set(familyId, family);
|
|
152
|
+
}
|
|
153
|
+
if (!families.size) throw new Error("Agy returned no available models.");
|
|
154
|
+
for (const family of families.values()) {
|
|
155
|
+
family.efforts.sort((a, b) => EFFORTS.indexOf(a) - EFFORTS.indexOf(b));
|
|
156
|
+
}
|
|
157
|
+
return [...families.values()];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function listAgyModels(pi: ExtensionAPI): Promise<AgyModelFamily[]> {
|
|
161
|
+
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
162
|
+
try {
|
|
163
|
+
const result = await pi.exec("agy", ["models"], { cwd: runDirectory, timeout: 30_000 });
|
|
164
|
+
if (result.killed) throw new Error("Agy model check timed out.");
|
|
165
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || `Agy exited with code ${result.code}.`);
|
|
166
|
+
return parseAgyModels(result.stdout);
|
|
167
|
+
} finally {
|
|
168
|
+
await rm(runDirectory, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveCatalogSettings(
|
|
173
|
+
settings: BroSettings,
|
|
174
|
+
families: AgyModelFamily[],
|
|
175
|
+
): { settings: BroSettings; family?: AgyModelFamily } {
|
|
176
|
+
const family = families.find(
|
|
177
|
+
(item) => item.id === settings.model || item.variants.some((variant) => variant.id === settings.model),
|
|
178
|
+
);
|
|
179
|
+
if (!family) return { settings };
|
|
180
|
+
const variant = family.variants.find((item) => item.id === settings.model);
|
|
181
|
+
return {
|
|
182
|
+
family,
|
|
183
|
+
settings: {
|
|
184
|
+
model: family.id,
|
|
185
|
+
effort: settings.effort === "default" && variant?.effort ? variant.effort : settings.effort,
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function preferredEffort(family: AgyModelFamily): BroEffort {
|
|
191
|
+
return family.efforts.includes("low") ? "low" : (family.efforts[0] ?? "default");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function agySelection(settings: BroSettings): { model: string; effort?: AgyEffort } {
|
|
195
|
+
if (settings.effort === "default") return { model: settings.model };
|
|
196
|
+
const suffix = (["low", "medium", "high"] as const).find((effort) => settings.model.endsWith(`-${effort}`));
|
|
197
|
+
return {
|
|
198
|
+
model: suffix ? settings.model.slice(0, -suffix.length - 1) : settings.model,
|
|
199
|
+
effort: settings.effort,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
|
|
204
|
+
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
205
|
+
try {
|
|
206
|
+
const result = await pi.exec(
|
|
207
|
+
"agy",
|
|
208
|
+
["-p", "/usage", "--output-format", "json", "--print-timeout", "30s", "--sandbox"],
|
|
209
|
+
{ cwd: runDirectory, signal, timeout: 35_000 },
|
|
210
|
+
);
|
|
211
|
+
if (signal.aborted) throw new Error("Canceled.");
|
|
212
|
+
if (result.killed) throw new Error("Agy usage check timed out.");
|
|
213
|
+
if (result.code !== 0) throw new Error(result.stderr.trim() || `Agy exited with code ${result.code}.`);
|
|
214
|
+
try {
|
|
215
|
+
return formatAgyUsage(JSON.parse(result.stdout));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof SyntaxError) throw new Error("Agy returned invalid usage data.");
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
await rm(runDirectory, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
48
225
|
function latestAssistant(ctx: ExtensionCommandContext): AssistantSource | undefined {
|
|
49
226
|
const branch = ctx.sessionManager.getBranch();
|
|
50
227
|
|
|
@@ -106,9 +283,11 @@ function parseAgyLine(line: string): { delta?: string; result?: string } {
|
|
|
106
283
|
async function simplify(
|
|
107
284
|
response: string,
|
|
108
285
|
signal: AbortSignal,
|
|
286
|
+
settings: BroSettings,
|
|
109
287
|
onProgress?: (text: string) => void,
|
|
110
288
|
): Promise<string> {
|
|
111
289
|
const prompt = await promptFor(response);
|
|
290
|
+
const selection = agySelection(settings);
|
|
112
291
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
113
292
|
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
114
293
|
|
|
@@ -121,7 +300,8 @@ async function simplify(
|
|
|
121
300
|
"--output-format",
|
|
122
301
|
"stream-json",
|
|
123
302
|
"--model",
|
|
124
|
-
|
|
303
|
+
selection.model,
|
|
304
|
+
...(selection.effort ? ["--effort", selection.effort] : []),
|
|
125
305
|
"--print-timeout",
|
|
126
306
|
"2m",
|
|
127
307
|
"--print",
|
|
@@ -199,7 +379,7 @@ async function simplify(
|
|
|
199
379
|
}
|
|
200
380
|
}
|
|
201
381
|
|
|
202
|
-
function helpText(): string {
|
|
382
|
+
function helpText(settings: BroSettings): string {
|
|
203
383
|
return `# Bro
|
|
204
384
|
|
|
205
385
|
Bro turns the latest completed assistant response into a clear, plain-language explanation.
|
|
@@ -208,8 +388,22 @@ Bro turns the latest completed assistant response into a clear, plain-language e
|
|
|
208
388
|
|
|
209
389
|
- \`/bro\` or \`/bro simplify\` — create a new explanation
|
|
210
390
|
- \`/bro open\` — reopen the last explanation
|
|
391
|
+
- \`/bro usage\` or \`/bro usage --provider agy\` — show current Agy usage
|
|
392
|
+
- \`/bro model\` — choose the Agy model
|
|
393
|
+
- \`/bro effort\` — choose the Agy reasoning effort
|
|
211
394
|
- \`/bro help\` — show this guide
|
|
212
395
|
|
|
396
|
+
## Current simplifier settings
|
|
397
|
+
|
|
398
|
+
- **Model:** \`${settings.model}\`
|
|
399
|
+
- **Reasoning effort:** ${settings.effort === "default" ? "built into the selected model" : settings.effort}
|
|
400
|
+
|
|
401
|
+
These choices are saved in:
|
|
402
|
+
|
|
403
|
+
\`${SETTINGS_FILE}\`
|
|
404
|
+
|
|
405
|
+
Use the slash commands or edit that file directly. Changes apply to future explanations and remain active across Pi restarts until you change them. Use a model ID shown by \`/bro model\`. Use an effort shown by \`/bro effort\`; fixed-effort models use \`default\`.
|
|
406
|
+
|
|
213
407
|
## Controls
|
|
214
408
|
|
|
215
409
|
- **Mouse wheel / trackpad** — scroll in Pi's fullscreen mode
|
|
@@ -218,15 +412,19 @@ Bro turns the latest completed assistant response into a clear, plain-language e
|
|
|
218
412
|
- **R** — simplify the same response again
|
|
219
413
|
- **Esc** — close the window, or cancel while Bro is working
|
|
220
414
|
|
|
415
|
+
In Pi's regular terminal mode, the Bro title warns that mouse-wheel scrolling needs fullscreen mode. Arrow-key scrolling still works.
|
|
416
|
+
|
|
221
417
|
Mouse text selection may extend outside the Bro window. Press **C** to copy the complete explanation instead.
|
|
222
418
|
|
|
223
419
|
## Privacy and file safety
|
|
224
420
|
|
|
225
|
-
Bro does not modify your project files. It runs the simplifier in sandbox mode inside a temporary empty folder. This reduces project access, but it is not a security boundary.
|
|
421
|
+
Bro does not modify your project files. It creates and updates only its user settings file shown above. It runs the simplifier in sandbox mode inside a temporary empty folder. This reduces project access, but it is not a security boundary.
|
|
226
422
|
|
|
227
423
|
Bro does not add explanations to Pi's conversation history, session files, or main-agent context. The latest explanation is kept in process memory only so \`/bro open\` can reopen it. It is cleared when you change sessions, reload extensions, or exit Pi.
|
|
228
424
|
|
|
229
|
-
Bro sends the assistant response to an external simplifier (currently Agy with
|
|
425
|
+
Bro sends the assistant response to an external simplifier (currently Agy with your selected model). Agy and the model provider may retain request data or logs under their own policies.
|
|
426
|
+
|
|
427
|
+
\`/bro usage\` checks your authenticated Agy limits without sending an assistant response or running a model turn.
|
|
230
428
|
|
|
231
429
|
Pressing **C** copies the explanation to your system clipboard, where your operating system or clipboard manager may retain it.
|
|
232
430
|
|
|
@@ -238,7 +436,7 @@ You can create or edit:
|
|
|
238
436
|
|
|
239
437
|
Bro reads this file when running but never creates or edits it. Include \`{{response}}\` exactly once in your template. Changes take effect on the next simplification.
|
|
240
438
|
|
|
241
|
-
|
|
439
|
+
When the settings file does not exist yet, \`PI_BRO_MODEL\` can choose its initial model.`;
|
|
242
440
|
}
|
|
243
441
|
|
|
244
442
|
// The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
|
|
@@ -263,8 +461,8 @@ class BroModal implements Focusable {
|
|
|
263
461
|
private readonly onDispose: () => void,
|
|
264
462
|
) {}
|
|
265
463
|
|
|
266
|
-
setLoading(): void {
|
|
267
|
-
this.setContent("loading", `**${
|
|
464
|
+
setLoading(text = LOADING_TEXT): void {
|
|
465
|
+
this.setContent("loading", `**${text}**`, "", false, false);
|
|
268
466
|
}
|
|
269
467
|
|
|
270
468
|
setStreaming(text: string): void {
|
|
@@ -280,7 +478,7 @@ class BroModal implements Focusable {
|
|
|
280
478
|
}
|
|
281
479
|
|
|
282
480
|
setError(message: string): void {
|
|
283
|
-
this.setContent("error", `# Bro
|
|
481
|
+
this.setContent("error", `# Bro ran into a problem\n\n${message}`, "", false, true);
|
|
284
482
|
}
|
|
285
483
|
|
|
286
484
|
private setContent(
|
|
@@ -320,7 +518,9 @@ class BroModal implements Focusable {
|
|
|
320
518
|
private controls(): string {
|
|
321
519
|
if (this.kind === "loading") return "Esc cancel";
|
|
322
520
|
if (this.kind === "streaming") return "Simplifying… · ↑/↓ scroll · Esc cancel";
|
|
323
|
-
if (this.kind === "result")
|
|
521
|
+
if (this.kind === "result") {
|
|
522
|
+
return `↑/↓ scroll · C copy${this.retryable ? " · R simplify again" : ""} · Esc close`;
|
|
523
|
+
}
|
|
324
524
|
if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
|
|
325
525
|
if (this.kind === "error") return "R try again · Esc close";
|
|
326
526
|
return "Esc close";
|
|
@@ -338,12 +538,13 @@ class BroModal implements Focusable {
|
|
|
338
538
|
this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
|
|
339
539
|
const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
|
|
340
540
|
const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
|
|
541
|
+
const modeHint = this.tui.mode === "regular" ? " · mouse wheel needs fullscreen" : "";
|
|
341
542
|
const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
|
|
342
543
|
const controls = this.notice ? `${this.notice} · ${this.controls()}` : this.controls();
|
|
343
544
|
|
|
344
545
|
const lines = [
|
|
345
546
|
this.borderLine(innerWidth, "top"),
|
|
346
|
-
this.frameLine(this.theme.fg("accent", this.theme.bold(`Bro${scroll}`)), innerWidth),
|
|
547
|
+
this.frameLine(this.theme.fg("accent", this.theme.bold(`Bro${modeHint}${scroll}`)), innerWidth),
|
|
347
548
|
this.ruleLine(innerWidth),
|
|
348
549
|
];
|
|
349
550
|
|
|
@@ -411,13 +612,15 @@ interface BroModalOptions {
|
|
|
411
612
|
text?: string;
|
|
412
613
|
kind?: "help" | "empty";
|
|
413
614
|
copyable?: boolean;
|
|
414
|
-
result?:
|
|
615
|
+
result?: ModalResult;
|
|
415
616
|
run?: (
|
|
416
617
|
signal: AbortSignal,
|
|
417
618
|
source?: AssistantSource,
|
|
418
619
|
onProgress?: (text: string) => void,
|
|
419
|
-
) => Promise<
|
|
420
|
-
onResult?: (result:
|
|
620
|
+
) => Promise<ModalResult>;
|
|
621
|
+
onResult?: (result: ModalResult) => void;
|
|
622
|
+
loadingText?: string;
|
|
623
|
+
retryable?: boolean;
|
|
421
624
|
}
|
|
422
625
|
|
|
423
626
|
async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
|
|
@@ -459,7 +662,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
|
|
|
459
662
|
const previous = current;
|
|
460
663
|
const nextController = new AbortController();
|
|
461
664
|
controller = nextController;
|
|
462
|
-
modal.setLoading();
|
|
665
|
+
modal.setLoading(options.loadingText);
|
|
463
666
|
|
|
464
667
|
void options
|
|
465
668
|
.run(nextController.signal, source, (text) => {
|
|
@@ -470,14 +673,14 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
|
|
|
470
673
|
if (closed || nextController.signal.aborted) return;
|
|
471
674
|
current = result;
|
|
472
675
|
options.onResult?.(result);
|
|
473
|
-
modal.setResult(result.text, true);
|
|
676
|
+
modal.setResult(result.text, options.retryable ?? true);
|
|
474
677
|
})
|
|
475
678
|
.catch((error) => {
|
|
476
679
|
if (closed || nextController.signal.aborted) return;
|
|
477
680
|
const message = error instanceof Error ? error.message : String(error);
|
|
478
681
|
if (previous) {
|
|
479
682
|
current = previous;
|
|
480
|
-
modal.setResult(previous.text, true, `Retry failed: ${message}`);
|
|
683
|
+
modal.setResult(previous.text, options.retryable ?? true, `Retry failed: ${message}`);
|
|
481
684
|
} else {
|
|
482
685
|
modal.setError(message);
|
|
483
686
|
}
|
|
@@ -490,7 +693,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
|
|
|
490
693
|
if (options.text !== undefined) {
|
|
491
694
|
modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
|
|
492
695
|
} else if (current) {
|
|
493
|
-
modal.setResult(current.text, Boolean(options.run));
|
|
696
|
+
modal.setResult(current.text, options.retryable ?? Boolean(options.run));
|
|
494
697
|
} else {
|
|
495
698
|
execute();
|
|
496
699
|
}
|
|
@@ -510,24 +713,164 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
|
|
|
510
713
|
);
|
|
511
714
|
}
|
|
512
715
|
|
|
513
|
-
export default function bro(pi: ExtensionAPI) {
|
|
716
|
+
export default async function bro(pi: ExtensionAPI) {
|
|
717
|
+
await ensureSettingsFile();
|
|
514
718
|
let lastResult: BroResult | undefined;
|
|
719
|
+
const remember = (result: ModalResult) => {
|
|
720
|
+
if (result.source) lastResult = { source: result.source, text: result.text };
|
|
721
|
+
};
|
|
515
722
|
|
|
516
723
|
pi.on("session_start", async () => {
|
|
517
724
|
lastResult = undefined;
|
|
518
725
|
});
|
|
519
726
|
|
|
520
727
|
pi.registerCommand("bro", {
|
|
521
|
-
description: "Simplify
|
|
728
|
+
description: "Simplify responses and manage Bro",
|
|
522
729
|
getArgumentCompletions: (prefix) => {
|
|
523
730
|
const normalized = prefix.trim().toLowerCase();
|
|
524
731
|
const matches = COMMANDS.filter((command) => command.value.startsWith(normalized));
|
|
525
732
|
return matches.length ? matches : null;
|
|
526
733
|
},
|
|
527
734
|
handler: async (args, ctx) => {
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
735
|
+
const normalized = args.trim().toLowerCase();
|
|
736
|
+
const parts = normalized ? normalized.split(/\s+/) : [];
|
|
737
|
+
const action = parts[0] ?? "";
|
|
738
|
+
|
|
739
|
+
if (action === "usage") {
|
|
740
|
+
const valid = parts.length === 1 || (parts.length === 3 && parts[1] === "--provider" && parts[2] === "agy");
|
|
741
|
+
if (!valid) {
|
|
742
|
+
ctx.ui.notify("Use /bro usage or /bro usage --provider agy.", "warning");
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
await showBroModal(ctx, {
|
|
747
|
+
loadingText: "Checking Agy usage…",
|
|
748
|
+
retryable: false,
|
|
749
|
+
run: async (signal) => ({ text: await checkAgyUsage(pi, signal) }),
|
|
750
|
+
});
|
|
751
|
+
} catch (error) {
|
|
752
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
753
|
+
}
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (action === "model") {
|
|
758
|
+
if (parts.length > 2) {
|
|
759
|
+
ctx.ui.notify("Use /bro model or /bro model <id>.", "warning");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
try {
|
|
763
|
+
const settings = await readSettings();
|
|
764
|
+
const models = await listAgyModels(pi);
|
|
765
|
+
const current = resolveCatalogSettings(settings, models);
|
|
766
|
+
const requested = parts[1];
|
|
767
|
+
let selected: AgyModelFamily | undefined;
|
|
768
|
+
let selectedEffort: BroEffort | undefined;
|
|
769
|
+
if (requested) {
|
|
770
|
+
selected = models.find((item) => item.id.toLowerCase() === requested);
|
|
771
|
+
if (!selected) {
|
|
772
|
+
for (const family of models) {
|
|
773
|
+
const variant = family.variants.find((item) => item.id.toLowerCase() === requested);
|
|
774
|
+
if (variant) {
|
|
775
|
+
selected = family;
|
|
776
|
+
selectedEffort = variant.effort ?? "default";
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (!selected) {
|
|
782
|
+
ctx.ui.notify(`Unknown Agy model "${requested}". Run /bro model to see available choices.`, "warning");
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
} else {
|
|
786
|
+
if (ctx.mode !== "tui") {
|
|
787
|
+
ctx.ui.notify("Use /bro model <id> outside Pi's interactive UI.", "warning");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const ordered = [...models].sort((a, b) => Number(b.id === current.family?.id) - Number(a.id === current.family?.id));
|
|
791
|
+
const choices = ordered.map(
|
|
792
|
+
(item) =>
|
|
793
|
+
`${item.id} — ${item.label} · ${item.efforts.length ? item.efforts.join("/") : "fixed effort"}${item.id === current.family?.id ? " (current)" : ""}`,
|
|
794
|
+
);
|
|
795
|
+
const choice = await ctx.ui.select(`Agy model (current: ${current.settings.model})`, choices);
|
|
796
|
+
if (!choice) return;
|
|
797
|
+
selected = ordered[choices.indexOf(choice)];
|
|
798
|
+
}
|
|
799
|
+
if (!selectedEffort) {
|
|
800
|
+
const currentEffort = current.settings.effort;
|
|
801
|
+
const canKeepCurrent =
|
|
802
|
+
current.family?.id === selected.id &&
|
|
803
|
+
(currentEffort === "default" ? !selected.efforts.length : selected.efforts.includes(currentEffort));
|
|
804
|
+
selectedEffort = canKeepCurrent ? currentEffort : preferredEffort(selected);
|
|
805
|
+
}
|
|
806
|
+
await writeSettings({ model: selected.id, effort: selectedEffort });
|
|
807
|
+
ctx.ui.notify(
|
|
808
|
+
`Bro model: ${selected.id}${selectedEffort === "default" ? "" : ` (${selectedEffort})`}`,
|
|
809
|
+
"info",
|
|
810
|
+
);
|
|
811
|
+
} catch (error) {
|
|
812
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
813
|
+
}
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (action === "effort") {
|
|
818
|
+
const requested = parts[1];
|
|
819
|
+
if (parts.length > 2 || (requested && !EFFORTS.some((effort) => effort === requested))) {
|
|
820
|
+
ctx.ui.notify("Use /bro effort, or choose low, medium, or high.", "warning");
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
const settings = await readSettings();
|
|
825
|
+
const current = resolveCatalogSettings(settings, await listAgyModels(pi));
|
|
826
|
+
if (!current.family) {
|
|
827
|
+
ctx.ui.notify(`Model "${settings.model}" is not in Agy's current model list. Run /bro model first.`, "warning");
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (!current.family.efforts.length) {
|
|
831
|
+
if (requested && requested !== "default") {
|
|
832
|
+
ctx.ui.notify(`${current.family.label} uses a fixed effort level.`, "warning");
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
await writeSettings({ model: current.family.id, effort: "default" });
|
|
836
|
+
ctx.ui.notify(`${current.family.label} uses its built-in effort level.`, "info");
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (requested === "default" || (requested && !current.family.efforts.includes(requested as AgyEffort))) {
|
|
840
|
+
ctx.ui.notify(
|
|
841
|
+
`${current.family.label} supports ${current.family.efforts.join(" or ")} effort.`,
|
|
842
|
+
"warning",
|
|
843
|
+
);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
let selected = requested as AgyEffort | undefined;
|
|
847
|
+
if (!selected) {
|
|
848
|
+
if (ctx.mode !== "tui") {
|
|
849
|
+
ctx.ui.notify("Use /bro effort <low|medium|high> outside Pi's interactive UI.", "warning");
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const efforts = [...current.family.efforts].sort(
|
|
853
|
+
(a, b) => Number(b === current.settings.effort) - Number(a === current.settings.effort),
|
|
854
|
+
);
|
|
855
|
+
const choices = efforts.map((effort) => `${effort}${effort === current.settings.effort ? " (current)" : ""}`);
|
|
856
|
+
const choice = await ctx.ui.select(`Agy reasoning effort (current: ${current.settings.effort})`, choices);
|
|
857
|
+
if (!choice) return;
|
|
858
|
+
selected = efforts[choices.indexOf(choice)];
|
|
859
|
+
}
|
|
860
|
+
await writeSettings({ model: current.family.id, effort: selected });
|
|
861
|
+
ctx.ui.notify(`Bro reasoning effort: ${selected}`, "info");
|
|
862
|
+
} catch (error) {
|
|
863
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
864
|
+
}
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (normalized === "help") {
|
|
869
|
+
try {
|
|
870
|
+
await showBroModal(ctx, { text: helpText(await readSettings()), kind: "help", copyable: true });
|
|
871
|
+
} catch (error) {
|
|
872
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
873
|
+
}
|
|
531
874
|
return;
|
|
532
875
|
}
|
|
533
876
|
|
|
@@ -542,10 +885,14 @@ export default function bro(pi: ExtensionAPI) {
|
|
|
542
885
|
target = latestAssistant(ctx);
|
|
543
886
|
}
|
|
544
887
|
if (!target) throw new Error("No completed assistant response found.");
|
|
545
|
-
|
|
888
|
+
const settings = await readSettings();
|
|
889
|
+
return {
|
|
890
|
+
source: target,
|
|
891
|
+
text: await simplify(target.text, signal, settings, onProgress),
|
|
892
|
+
};
|
|
546
893
|
};
|
|
547
894
|
|
|
548
|
-
if (
|
|
895
|
+
if (normalized === "open") {
|
|
549
896
|
if (!lastResult) {
|
|
550
897
|
await showBroModal(ctx, {
|
|
551
898
|
text: "# Nothing to open yet\n\nRun `/bro` after an assistant response.",
|
|
@@ -557,24 +904,20 @@ export default function bro(pi: ExtensionAPI) {
|
|
|
557
904
|
await showBroModal(ctx, {
|
|
558
905
|
result: lastResult,
|
|
559
906
|
run,
|
|
560
|
-
onResult:
|
|
561
|
-
lastResult = result;
|
|
562
|
-
},
|
|
907
|
+
onResult: remember,
|
|
563
908
|
});
|
|
564
909
|
return;
|
|
565
910
|
}
|
|
566
911
|
|
|
567
|
-
if (
|
|
568
|
-
ctx.ui.notify(`Unknown action "${
|
|
912
|
+
if (normalized && normalized !== "simplify") {
|
|
913
|
+
ctx.ui.notify(`Unknown action "${normalized}". Use simplify, open, usage, model, effort, or help.`, "warning");
|
|
569
914
|
return;
|
|
570
915
|
}
|
|
571
916
|
|
|
572
917
|
try {
|
|
573
918
|
await showBroModal(ctx, {
|
|
574
919
|
run,
|
|
575
|
-
onResult:
|
|
576
|
-
lastResult = result;
|
|
577
|
-
},
|
|
920
|
+
onResult: remember,
|
|
578
921
|
});
|
|
579
922
|
} catch (error) {
|
|
580
923
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|