pi-bro 0.4.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 +47 -15
  2. package/bro.ts +282 -15
  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.
@@ -301,15 +301,52 @@ pi -e npm:pi-bro
301
301
  | `/bro open` | Reopen the latest explanation without calling the simplifier again. |
302
302
  | `/bro usage` | Show current Agy resource limits. |
303
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. |
304
308
  | `/bro help` | Open the built-in guide. |
305
309
 
306
310
  ### Modal controls
307
311
 
312
+ - **Mouse wheel / trackpad**: Scroll in Pi's fullscreen mode
308
313
  - **↑ / ↓**: Scroll up or down
309
314
  - **C**: Copy the full explanation to your clipboard
310
315
  - **R**: Run the simplifier again on the same response
311
316
  - **Esc**: Close the window, or cancel while Bro is running
312
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
+
313
350
  ## Custom prompt
314
351
 
315
352
  Bro uses a built-in prompt by default. To use your own, create:
@@ -331,14 +368,6 @@ Assistant response:
331
368
  Bro re-reads this file every time you simplify, so your edits take effect
332
369
  immediately without reloading Pi. Bro never creates or modifies this file.
333
370
 
334
- ### Custom model
335
-
336
- Set `PI_BRO_MODEL` before starting Pi to use a different Agy model:
337
-
338
- ```sh
339
- PI_BRO_MODEL=gemini-3.7-flash-low pi
340
- ```
341
-
342
371
  ## Privacy and files
343
372
 
344
373
  - **External requests**: Bro sends the latest completed assistant response to
@@ -352,7 +381,8 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
352
381
  Pi.
353
382
  - **File safety**: Bro does not modify project files. It runs Agy in sandbox
354
383
  mode inside a temporary empty folder. This reduces project access, but it is
355
- not a security boundary.
384
+ not a security boundary. Bro only writes its own user settings file described
385
+ above.
356
386
  - **Provider data**: Agy and your model provider may retain logs and request data
357
387
  according to their own settings and privacy policies.
358
388
  - **Clipboard**: Pressing **C** copies the text to your system clipboard, where
@@ -364,8 +394,9 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
364
394
  - Keeps only the latest explanation in memory.
365
395
  - Does not store history or export directly to files.
366
396
  - Mouse-wheel and trackpad scrolling work in Pi's fullscreen mode
367
- (`pi --tui-mode fullscreen`). In regular mode, use the arrow keys so Bro does
368
- 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.
369
400
  - In fullscreen mode, mouse text selection may visually extend outside the Bro
370
401
  window. Press **C** to copy the full explanation instead.
371
402
 
@@ -374,11 +405,12 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
374
405
  ```sh
375
406
  npm install
376
407
  npm test
377
- pi -e ./bro.ts
408
+ pi --tui-mode fullscreen -e ./bro.ts
378
409
  ```
379
410
 
380
411
  The smoke test uses a fake `agy`, so it does not call an external model. It
381
- verifies command routing, custom prompt handling, and context isolation.
412
+ verifies command routing, settings, custom prompt handling, and context
413
+ isolation.
382
414
 
383
415
  ## License
384
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,11 +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 };
28
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
+ };
29
42
  type AgyEvent = {
30
43
  event?: string;
31
44
  step_update?: { step_type?: string; text_delta?: unknown };
@@ -44,6 +57,8 @@ const COMMANDS = [
44
57
  { value: "simplify", label: "simplify", description: "Simplify the latest assistant response" },
45
58
  { value: "open", label: "open", description: "Reopen the last explanation" },
46
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" },
47
62
  { value: "help", label: "help", description: "Learn what Bro does and what it can access" },
48
63
  ];
49
64
 
@@ -51,6 +66,47 @@ function isRecord(value: unknown): value is Record<string, unknown> {
51
66
  return typeof value === "object" && value !== null;
52
67
  }
53
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
+
54
110
  export function formatAgyUsage(value: unknown): string {
55
111
  if (!isRecord(value) || value.status !== "SUCCESS" || typeof value.response !== "string") {
56
112
  throw new Error("Agy returned invalid usage data.");
@@ -72,6 +128,78 @@ export function formatAgyUsage(value: unknown): string {
72
128
  return `# Agy usage\n\n${sections.join("\n\n")}`;
73
129
  }
74
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
+
75
203
  async function checkAgyUsage(pi: ExtensionAPI, signal: AbortSignal): Promise<string> {
76
204
  const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
77
205
  try {
@@ -155,9 +283,11 @@ function parseAgyLine(line: string): { delta?: string; result?: string } {
155
283
  async function simplify(
156
284
  response: string,
157
285
  signal: AbortSignal,
286
+ settings: BroSettings,
158
287
  onProgress?: (text: string) => void,
159
288
  ): Promise<string> {
160
289
  const prompt = await promptFor(response);
290
+ const selection = agySelection(settings);
161
291
  const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
162
292
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
163
293
 
@@ -170,7 +300,8 @@ async function simplify(
170
300
  "--output-format",
171
301
  "stream-json",
172
302
  "--model",
173
- MODEL,
303
+ selection.model,
304
+ ...(selection.effort ? ["--effort", selection.effort] : []),
174
305
  "--print-timeout",
175
306
  "2m",
176
307
  "--print",
@@ -248,7 +379,7 @@ async function simplify(
248
379
  }
249
380
  }
250
381
 
251
- function helpText(): string {
382
+ function helpText(settings: BroSettings): string {
252
383
  return `# Bro
253
384
 
254
385
  Bro turns the latest completed assistant response into a clear, plain-language explanation.
@@ -258,8 +389,21 @@ Bro turns the latest completed assistant response into a clear, plain-language e
258
389
  - \`/bro\` or \`/bro simplify\` — create a new explanation
259
390
  - \`/bro open\` — reopen the last explanation
260
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
261
394
  - \`/bro help\` — show this guide
262
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
+
263
407
  ## Controls
264
408
 
265
409
  - **Mouse wheel / trackpad** — scroll in Pi's fullscreen mode
@@ -268,15 +412,17 @@ Bro turns the latest completed assistant response into a clear, plain-language e
268
412
  - **R** — simplify the same response again
269
413
  - **Esc** — close the window, or cancel while Bro is working
270
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
+
271
417
  Mouse text selection may extend outside the Bro window. Press **C** to copy the complete explanation instead.
272
418
 
273
419
  ## Privacy and file safety
274
420
 
275
- 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.
276
422
 
277
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.
278
424
 
279
- 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.
280
426
 
281
427
  \`/bro usage\` checks your authenticated Agy limits without sending an assistant response or running a model turn.
282
428
 
@@ -290,7 +436,7 @@ You can create or edit:
290
436
 
291
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.
292
438
 
293
- 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.`;
294
440
  }
295
441
 
296
442
  // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
@@ -392,12 +538,13 @@ class BroModal implements Focusable {
392
538
  this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
393
539
  const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
394
540
  const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
541
+ const modeHint = this.tui.mode === "regular" ? " · mouse wheel needs fullscreen" : "";
395
542
  const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
396
543
  const controls = this.notice ? `${this.notice} · ${this.controls()}` : this.controls();
397
544
 
398
545
  const lines = [
399
546
  this.borderLine(innerWidth, "top"),
400
- 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),
401
548
  this.ruleLine(innerWidth),
402
549
  ];
403
550
 
@@ -566,7 +713,8 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
566
713
  );
567
714
  }
568
715
 
569
- export default function bro(pi: ExtensionAPI) {
716
+ export default async function bro(pi: ExtensionAPI) {
717
+ await ensureSettingsFile();
570
718
  let lastResult: BroResult | undefined;
571
719
  const remember = (result: ModalResult) => {
572
720
  if (result.source) lastResult = { source: result.source, text: result.text };
@@ -577,7 +725,7 @@ export default function bro(pi: ExtensionAPI) {
577
725
  });
578
726
 
579
727
  pi.registerCommand("bro", {
580
- description: "Simplify responses, reopen explanations, or show Agy usage",
728
+ description: "Simplify responses and manage Bro",
581
729
  getArgumentCompletions: (prefix) => {
582
730
  const normalized = prefix.trim().toLowerCase();
583
731
  const matches = COMMANDS.filter((command) => command.value.startsWith(normalized));
@@ -606,8 +754,123 @@ export default function bro(pi: ExtensionAPI) {
606
754
  return;
607
755
  }
608
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
+
609
868
  if (normalized === "help") {
610
- await showBroModal(ctx, { text: helpText(), kind: "help", copyable: true });
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
+ }
611
874
  return;
612
875
  }
613
876
 
@@ -622,7 +885,11 @@ export default function bro(pi: ExtensionAPI) {
622
885
  target = latestAssistant(ctx);
623
886
  }
624
887
  if (!target) throw new Error("No completed assistant response found.");
625
- 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
+ };
626
893
  };
627
894
 
628
895
  if (normalized === "open") {
@@ -643,7 +910,7 @@ export default function bro(pi: ExtensionAPI) {
643
910
  }
644
911
 
645
912
  if (normalized && normalized !== "simplify") {
646
- ctx.ui.notify(`Unknown action "${normalized}". Use simplify, open, usage, or help.`, "warning");
913
+ ctx.ui.notify(`Unknown action "${normalized}". Use simplify, open, usage, model, effort, or help.`, "warning");
647
914
  return;
648
915
  }
649
916
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.4.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",