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.
Files changed (3) hide show
  1. package/README.md +52 -16
  2. package/bro.ts +379 -36
  3. 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 a Gemini model to stream plain-language explanations.
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.8` installed, authenticated, and on your `PATH` (tested on `1.1.13`)
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
- - Supports only Agy/Gemini in v0.1.
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, use the arrow keys so Bro does
364
- not interfere with your terminal's native text selection.
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 isolation.
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 MODEL = process.env.PI_BRO_MODEL ?? "gemini-3.7-flash-low";
11
- const PROMPT_FILE = join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "bro-prompt.md");
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
- MODEL,
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 a Gemini model). Agy and the model provider may retain request data or logs under their own policies.
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
- Set \`PI_BRO_MODEL\` before starting Pi to use a different Agy model.`;
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", `**${LOADING_TEXT}**`, "", false, false);
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 could not simplify this\n\n${message}`, "", false, true);
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") return "↑/↓ scroll · C copy · R simplify again · Esc close";
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?: BroResult;
615
+ result?: ModalResult;
415
616
  run?: (
416
617
  signal: AbortSignal,
417
618
  source?: AssistantSource,
418
619
  onProgress?: (text: string) => void,
419
- ) => Promise<BroResult>;
420
- onResult?: (result: BroResult) => void;
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, reopen, or learn about Bro explanations",
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 action = args.trim().toLowerCase();
529
- if (action === "help") {
530
- await showBroModal(ctx, { text: helpText(), kind: "help", copyable: true });
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
- return { source: target, text: await simplify(target.text, signal, onProgress) };
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 (action === "open") {
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: (result) => {
561
- lastResult = result;
562
- },
907
+ onResult: remember,
563
908
  });
564
909
  return;
565
910
  }
566
911
 
567
- if (action && action !== "simplify") {
568
- ctx.ui.notify(`Unknown action "${action}". Use simplify, open, or help.`, "warning");
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: (result) => {
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "An Earendil Pi extension that simplifies the latest assistant response in a separate, context-isolated window.",
5
5
  "type": "module",
6
6
  "license": "MIT",