pi-bro 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tran Hoang Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # pi-bro
2
+
3
+ Simplify Pi's latest assistant response in a separate pop-up window without
4
+ adding extra messages to your conversation context.
5
+
6
+ `pi-bro` is a small extension for
7
+ [Earendil Pi](https://github.com/earendil-works/pi). It uses the
8
+ [Google Antigravity CLI](https://antigravity.google/docs/cli-install) (`agy`)
9
+ and a Gemini model to create plain-language explanations.
10
+
11
+ ## Requirements
12
+
13
+ - Earendil Pi `>=0.78.1 <1` (tested on `0.84.2`)
14
+ - Node.js `>=22.19.0`
15
+ - `agy` installed, authenticated, and on your `PATH` (tested on `1.1.13`)
16
+ - Pi's interactive terminal UI
17
+
18
+ Run `agy` once in your terminal to complete sign-in before using Bro.
19
+
20
+ ## Install
21
+
22
+ From npm:
23
+
24
+ ```sh
25
+ pi install npm:pi-bro
26
+ ```
27
+
28
+ From GitHub:
29
+
30
+ ```sh
31
+ pi install git:github.com/tranhoangnguyen03/pi-bro
32
+ ```
33
+
34
+ Restart Pi or run `/reload`. Once an assistant response finishes, run `/bro`.
35
+
36
+ To test Bro without installing it:
37
+
38
+ ```sh
39
+ pi -e npm:pi-bro
40
+ ```
41
+
42
+ ## Commands
43
+
44
+ | Command | Description |
45
+ | --- | --- |
46
+ | `/bro` | Create a new plain-language explanation of the latest completed assistant response. |
47
+ | `/bro simplify` | Same as `/bro`. |
48
+ | `/bro open` | Reopen the latest explanation without calling the simplifier again. |
49
+ | `/bro help` | Open the built-in guide. |
50
+
51
+ ### Modal controls
52
+
53
+ - **↑ / ↓**: Scroll up or down
54
+ - **C**: Copy the full explanation to your clipboard
55
+ - **R**: Run the simplifier again on the same response
56
+ - **Esc**: Close the window, or cancel while Bro is running
57
+
58
+ ## Custom prompt
59
+
60
+ Bro uses a built-in prompt by default. To use your own, create:
61
+
62
+ ```text
63
+ ~/.pi/agent/bro-prompt.md
64
+ ```
65
+
66
+ Your prompt must include `{{response}}` exactly once. For example:
67
+
68
+ ```md
69
+ Explain this in plain English in no more than 200 words.
70
+ Keep important warnings and next steps.
71
+
72
+ Assistant response:
73
+ {{response}}
74
+ ```
75
+
76
+ Bro re-reads this file every time you simplify, so your edits take effect
77
+ immediately without reloading Pi. Bro never creates or modifies this file.
78
+
79
+ ### Custom model
80
+
81
+ Set `PI_BRO_MODEL` before starting Pi to use a different Agy model:
82
+
83
+ ```sh
84
+ PI_BRO_MODEL=gemini-3.7-flash-low pi
85
+ ```
86
+
87
+ ## Privacy and files
88
+
89
+ - **External requests**: Bro sends the latest completed assistant response to
90
+ Agy and its configured model provider.
91
+ - **Context isolation**: Bro does not add explanations to Pi's conversation
92
+ history, session files, or main-agent context.
93
+ - **Memory cache**: The latest explanation is stored only in process memory for
94
+ `/bro open`. It clears when you switch Pi sessions, reload extensions, or quit
95
+ Pi.
96
+ - **File safety**: Bro does not modify project files. It runs Agy in plan and
97
+ sandbox modes inside a temporary empty folder. This reduces project access,
98
+ but it is not a security boundary.
99
+ - **Provider data**: Agy and your model provider may retain logs and request data
100
+ according to their own settings and privacy policies.
101
+ - **Clipboard**: Pressing **C** copies the text to your system clipboard, where
102
+ your operating system or clipboard manager may retain it.
103
+
104
+ ## Current limits
105
+
106
+ - Supports only Agy/Gemini in v0.1.
107
+ - Keeps only the latest explanation in memory.
108
+ - Does not store history or export directly to files.
109
+ - Mouse scrolling is disabled; use the arrow keys to scroll and **C** to copy.
110
+
111
+ ## Development
112
+
113
+ ```sh
114
+ npm install
115
+ npm test
116
+ pi -e ./bro.ts
117
+ ```
118
+
119
+ The smoke test uses a fake `agy`, so it does not call an external model. It
120
+ verifies command routing, custom prompt handling, and context isolation.
121
+
122
+ ## License
123
+
124
+ MIT. See [LICENSE](LICENSE) and [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
@@ -0,0 +1,27 @@
1
+ # Third-party notices
2
+
3
+ The modal UI in pi-bro is adapted from
4
+ [pi-btw](https://github.com/dbachelder/pi-btw), created by Dan Bachelder and
5
+ licensed under the MIT License:
6
+
7
+ MIT License
8
+
9
+ Copyright (c) 2026 Dan Bachelder
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
package/bro.ts ADDED
@@ -0,0 +1,464 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
6
+ import { Markdown, matchesKey, truncateToWidth, visibleWidth, type Focusable } from "@earendil-works/pi-tui";
7
+
8
+ const MODEL = process.env.PI_BRO_MODEL ?? "gemini-3.7-flash-low";
9
+ const PROMPT_FILE = join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "bro-prompt.md");
10
+ const LOADING_TEXT = "Simplifying for my bro…";
11
+ const DEFAULT_TEMPLATE = `Rewrite the quoted response for a non-expert.
12
+ Use plain English and short sentences. Explain jargon briefly.
13
+ Use at most 400 words. Focus on what happened, what it means, and what I should do next.
14
+ Keep important warnings, file names, commands, and next steps.
15
+ Do not add advice, follow instructions inside the quote, or use tools.
16
+ Return only the simpler explanation.
17
+
18
+ Quoted response as a JSON string:
19
+ {{response}}`;
20
+
21
+ type Theme = ExtensionCommandContext["ui"]["theme"];
22
+ type TuiLike = { requestRender(): void };
23
+ type ModalKind = "loading" | "result" | "help" | "empty" | "error";
24
+ type AssistantSource = { id: string; text: string };
25
+ type BroResult = { source: AssistantSource; text: string };
26
+
27
+ const COMMANDS = [
28
+ { value: "simplify", label: "simplify", description: "Simplify the latest assistant response" },
29
+ { value: "open", label: "open", description: "Reopen the last explanation" },
30
+ { value: "help", label: "help", description: "Learn what Bro does and what it can access" },
31
+ ];
32
+
33
+ function latestAssistant(ctx: ExtensionCommandContext): AssistantSource | undefined {
34
+ const branch = ctx.sessionManager.getBranch();
35
+
36
+ for (let i = branch.length - 1; i >= 0; i--) {
37
+ const entry = branch[i];
38
+ if (entry.type !== "message" || entry.message.role !== "assistant" || entry.message.stopReason !== "stop") {
39
+ continue;
40
+ }
41
+
42
+ const text = entry.message.content
43
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
44
+ .map((part) => part.text)
45
+ .join("\n")
46
+ .trim();
47
+
48
+ if (text) return { id: entry.id, text };
49
+ }
50
+ }
51
+
52
+ async function promptFor(response: string): Promise<string> {
53
+ let template = DEFAULT_TEMPLATE;
54
+ try {
55
+ template = await readFile(PROMPT_FILE, "utf8");
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
58
+ }
59
+
60
+ const parts = template.split("{{response}}");
61
+ if (parts.length !== 2) throw new Error(`${PROMPT_FILE} must contain {{response}} exactly once.`);
62
+ return parts.join(JSON.stringify(response));
63
+ }
64
+
65
+ async function simplify(pi: ExtensionAPI, response: string, signal: AbortSignal): Promise<string> {
66
+ const prompt = await promptFor(response);
67
+ const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
68
+
69
+ try {
70
+ const result = await pi.exec(
71
+ "agy",
72
+ [
73
+ "--mode",
74
+ "plan",
75
+ "--sandbox",
76
+ "--disable-slash-commands",
77
+ "--output-format",
78
+ "text",
79
+ "--model",
80
+ MODEL,
81
+ "--print-timeout",
82
+ "2m",
83
+ "--print",
84
+ prompt,
85
+ ],
86
+ { cwd: runDirectory, signal, timeout: 125_000 },
87
+ );
88
+
89
+ if (result.killed) throw new Error(signal.aborted ? "Canceled." : "Simplification timed out.");
90
+ const text = result.stdout.trim();
91
+ if (result.code !== 0 || !text) {
92
+ throw new Error(result.stderr.trim() || "No explanation was generated.");
93
+ }
94
+
95
+ return text;
96
+ } finally {
97
+ await rm(runDirectory, { recursive: true, force: true });
98
+ }
99
+ }
100
+
101
+ function helpText(): string {
102
+ return `# Bro
103
+
104
+ Bro turns the latest completed assistant response into a clear, plain-language explanation.
105
+
106
+ ## Commands
107
+
108
+ - \`/bro\` or \`/bro simplify\` — create a new explanation
109
+ - \`/bro open\` — reopen the last explanation
110
+ - \`/bro help\` — show this guide
111
+
112
+ ## Controls
113
+
114
+ - **↑ / ↓** — scroll
115
+ - **C** — copy the full explanation
116
+ - **R** — simplify the same response again
117
+ - **Esc** — close the window, or cancel while Bro is working
118
+
119
+ ## Privacy and file safety
120
+
121
+ Bro does not modify your project files. It runs the simplifier in plan and sandbox modes inside a temporary empty folder. This reduces project access, but it is not a security boundary.
122
+
123
+ 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.
124
+
125
+ 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.
126
+
127
+ Pressing **C** copies the explanation to your system clipboard, where your operating system or clipboard manager may retain it.
128
+
129
+ ## Custom prompt
130
+
131
+ You can create or edit:
132
+
133
+ \`${PROMPT_FILE}\`
134
+
135
+ 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.
136
+
137
+ Set \`PI_BRO_MODEL\` before starting Pi to use a different Agy model.`;
138
+ }
139
+
140
+ // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
141
+ class BroModal implements Focusable {
142
+ focused = false;
143
+ private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
144
+ private kind: ModalKind = "loading";
145
+ private rawText = "";
146
+ private notice = "";
147
+ private offset = 0;
148
+ private maxOffset = 0;
149
+ private bodyHeight = 1;
150
+ private copyable = false;
151
+ private retryable = false;
152
+ private disposed = false;
153
+
154
+ constructor(
155
+ private readonly tui: TuiLike,
156
+ private readonly theme: Theme,
157
+ private readonly onClose: () => void,
158
+ private readonly onRetry: () => void,
159
+ private readonly onDispose: () => void,
160
+ ) {}
161
+
162
+ setLoading(): void {
163
+ this.setContent("loading", `**${LOADING_TEXT}**`, "", false, false);
164
+ }
165
+
166
+ setResult(text: string, retryable: boolean, notice = ""): void {
167
+ this.setContent("result", text, text, true, retryable, notice);
168
+ }
169
+
170
+ setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
171
+ this.setContent(kind, text, text, copyable, false);
172
+ }
173
+
174
+ setError(message: string): void {
175
+ this.setContent("error", `# Bro could not simplify this\n\n${message}`, "", false, true);
176
+ }
177
+
178
+ private setContent(
179
+ kind: ModalKind,
180
+ text: string,
181
+ rawText: string,
182
+ copyable: boolean,
183
+ retryable: boolean,
184
+ notice = "",
185
+ ): void {
186
+ this.kind = kind;
187
+ this.rawText = rawText;
188
+ this.copyable = copyable;
189
+ this.retryable = retryable;
190
+ this.notice = notice;
191
+ this.offset = 0;
192
+ this.markdown.setText(text);
193
+ this.tui.requestRender();
194
+ }
195
+
196
+ private frameLine(content: string, innerWidth: number): string {
197
+ const truncated = truncateToWidth(content, innerWidth, "");
198
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
199
+ return `${this.theme.fg("border", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("border", "│")}`;
200
+ }
201
+
202
+ private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
203
+ const left = edge === "top" ? "┌" : "└";
204
+ const right = edge === "top" ? "┐" : "┘";
205
+ return this.theme.fg("border", `${left}${"─".repeat(innerWidth)}${right}`);
206
+ }
207
+
208
+ private ruleLine(innerWidth: number): string {
209
+ return this.theme.fg("border", `├${"─".repeat(innerWidth)}┤`);
210
+ }
211
+
212
+ private controls(): string {
213
+ if (this.kind === "loading") return "Esc cancel";
214
+ if (this.kind === "result") return "↑/↓ scroll · C copy · R simplify again · Esc close";
215
+ if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
216
+ if (this.kind === "error") return "R try again · Esc close";
217
+ return "Esc close";
218
+ }
219
+
220
+ render(width: number): string[] {
221
+ const dialogWidth = Math.max(24, width);
222
+ const innerWidth = Math.max(22, dialogWidth - 2);
223
+ const terminalRows = process.stdout.rows ?? 30;
224
+ const dialogHeight = Math.min(32, Math.max(7, Math.floor(terminalRows * 0.78)));
225
+ this.bodyHeight = Math.max(1, dialogHeight - 6);
226
+
227
+ const rendered = this.markdown.render(innerWidth);
228
+ this.maxOffset = Math.max(0, rendered.length - this.bodyHeight);
229
+ this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
230
+ const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
231
+ const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
232
+ const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
233
+ const controls = this.notice ? `${this.notice} · ${this.controls()}` : this.controls();
234
+
235
+ const lines = [
236
+ this.borderLine(innerWidth, "top"),
237
+ this.frameLine(this.theme.fg("accent", this.theme.bold(`Bro${scroll}`)), innerWidth),
238
+ this.ruleLine(innerWidth),
239
+ ];
240
+
241
+ for (const line of visible) lines.push(this.frameLine(line, innerWidth));
242
+ for (let i = visible.length; i < this.bodyHeight; i++) lines.push(this.frameLine("", innerWidth));
243
+
244
+ lines.push(this.ruleLine(innerWidth));
245
+ lines.push(this.frameLine(this.theme.fg("dim", controls), innerWidth));
246
+ lines.push(this.borderLine(innerWidth, "bottom"));
247
+ return lines;
248
+ }
249
+
250
+ invalidate(): void {
251
+ this.markdown.invalidate();
252
+ }
253
+
254
+ handleInput(data: string): void {
255
+ if (matchesKey(data, "escape")) {
256
+ this.onClose();
257
+ return;
258
+ }
259
+
260
+ if (matchesKey(data, "up") || matchesKey(data, "down")) {
261
+ const delta = matchesKey(data, "up") ? -1 : 1;
262
+ this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
263
+ this.notice = "";
264
+ this.tui.requestRender();
265
+ return;
266
+ }
267
+
268
+ if ((matchesKey(data, "c") || matchesKey(data, "shift+c")) && this.copyable && this.rawText) {
269
+ void copyToClipboard(this.rawText)
270
+ .then(() => {
271
+ if (!this.disposed) {
272
+ this.notice = "Copied";
273
+ this.tui.requestRender();
274
+ }
275
+ })
276
+ .catch((error) => {
277
+ if (!this.disposed) {
278
+ this.notice = `Copy failed: ${error instanceof Error ? error.message : String(error)}`;
279
+ this.tui.requestRender();
280
+ }
281
+ });
282
+ return;
283
+ }
284
+
285
+ if (
286
+ (matchesKey(data, "r") || matchesKey(data, "shift+r")) &&
287
+ this.retryable &&
288
+ this.kind !== "loading"
289
+ ) {
290
+ this.onRetry();
291
+ }
292
+ }
293
+
294
+ dispose(): void {
295
+ if (this.disposed) return;
296
+ this.disposed = true;
297
+ this.onDispose();
298
+ }
299
+ }
300
+
301
+ interface BroModalOptions {
302
+ text?: string;
303
+ kind?: "help" | "empty";
304
+ copyable?: boolean;
305
+ result?: BroResult;
306
+ run?: (signal: AbortSignal, source?: AssistantSource) => Promise<BroResult>;
307
+ onResult?: (result: BroResult) => void;
308
+ }
309
+
310
+ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptions): Promise<void> {
311
+ if (ctx.mode !== "tui") {
312
+ if (options.run && !options.result && options.text === undefined) {
313
+ const result = await options.run(new AbortController().signal);
314
+ options.onResult?.(result);
315
+ }
316
+ return;
317
+ }
318
+
319
+ await ctx.ui.custom<void>(
320
+ (tui, theme, _keybindings, done) => {
321
+ let closed = false;
322
+ let controller: AbortController | undefined;
323
+ let current = options.result;
324
+ let execute: (source?: AssistantSource) => void = () => {};
325
+
326
+ const close = () => {
327
+ if (closed) return;
328
+ closed = true;
329
+ controller?.abort();
330
+ done(undefined);
331
+ };
332
+
333
+ const modal = new BroModal(
334
+ tui,
335
+ theme,
336
+ close,
337
+ () => execute(current?.source),
338
+ () => {
339
+ closed = true;
340
+ controller?.abort();
341
+ },
342
+ );
343
+
344
+ execute = (source?: AssistantSource) => {
345
+ if (!options.run || controller || closed) return;
346
+ const previous = current;
347
+ const nextController = new AbortController();
348
+ controller = nextController;
349
+ modal.setLoading();
350
+
351
+ void options
352
+ .run(nextController.signal, source)
353
+ .then((result) => {
354
+ if (closed || nextController.signal.aborted) return;
355
+ current = result;
356
+ options.onResult?.(result);
357
+ modal.setResult(result.text, true);
358
+ })
359
+ .catch((error) => {
360
+ if (closed || nextController.signal.aborted) return;
361
+ const message = error instanceof Error ? error.message : String(error);
362
+ if (previous) {
363
+ current = previous;
364
+ modal.setResult(previous.text, true, `Retry failed: ${message}`);
365
+ } else {
366
+ modal.setError(message);
367
+ }
368
+ })
369
+ .finally(() => {
370
+ if (controller === nextController) controller = undefined;
371
+ });
372
+ };
373
+
374
+ if (options.text !== undefined) {
375
+ modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
376
+ } else if (current) {
377
+ modal.setResult(current.text, Boolean(options.run));
378
+ } else {
379
+ execute();
380
+ }
381
+
382
+ return modal;
383
+ },
384
+ {
385
+ overlay: true,
386
+ overlayOptions: {
387
+ width: "78%",
388
+ minWidth: 48,
389
+ maxHeight: "78%",
390
+ anchor: "top-center",
391
+ margin: { top: 1, left: 2, right: 2 },
392
+ },
393
+ },
394
+ );
395
+ }
396
+
397
+ export default function bro(pi: ExtensionAPI) {
398
+ let lastResult: BroResult | undefined;
399
+
400
+ pi.on("session_start", async () => {
401
+ lastResult = undefined;
402
+ });
403
+
404
+ pi.registerCommand("bro", {
405
+ description: "Simplify, reopen, or learn about Bro explanations",
406
+ getArgumentCompletions: (prefix) => {
407
+ const normalized = prefix.trim().toLowerCase();
408
+ const matches = COMMANDS.filter((command) => command.value.startsWith(normalized));
409
+ return matches.length ? matches : null;
410
+ },
411
+ handler: async (args, ctx) => {
412
+ const action = args.trim().toLowerCase();
413
+ if (action === "help") {
414
+ await showBroModal(ctx, { text: helpText(), kind: "help", copyable: true });
415
+ return;
416
+ }
417
+
418
+ const run = async (signal: AbortSignal, source?: AssistantSource): Promise<BroResult> => {
419
+ let target = source;
420
+ if (!target) {
421
+ await ctx.waitForIdle();
422
+ target = latestAssistant(ctx);
423
+ }
424
+ if (!target) throw new Error("No completed assistant response found.");
425
+ return { source: target, text: await simplify(pi, target.text, signal) };
426
+ };
427
+
428
+ if (action === "open") {
429
+ if (!lastResult) {
430
+ await showBroModal(ctx, {
431
+ text: "# Nothing to open yet\n\nRun `/bro` after an assistant response.",
432
+ kind: "empty",
433
+ });
434
+ return;
435
+ }
436
+
437
+ await showBroModal(ctx, {
438
+ result: lastResult,
439
+ run,
440
+ onResult: (result) => {
441
+ lastResult = result;
442
+ },
443
+ });
444
+ return;
445
+ }
446
+
447
+ if (action && action !== "simplify") {
448
+ ctx.ui.notify(`Unknown action "${action}". Use simplify, open, or help.`, "warning");
449
+ return;
450
+ }
451
+
452
+ try {
453
+ await showBroModal(ctx, {
454
+ run,
455
+ onResult: (result) => {
456
+ lastResult = result;
457
+ },
458
+ });
459
+ } catch (error) {
460
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
461
+ }
462
+ },
463
+ });
464
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "pi-bro",
3
+ "version": "0.1.0",
4
+ "description": "An Earendil Pi extension that simplifies the latest assistant response in a separate, context-isolated window.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Tran Hoang Nguyen",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/tranhoangnguyen03/pi-bro.git"
11
+ },
12
+ "homepage": "https://github.com/tranhoangnguyen03/pi-bro#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/tranhoangnguyen03/pi-bro/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "pi-coding-agent",
20
+ "simplify",
21
+ "agy",
22
+ "gemini",
23
+ "tui"
24
+ ],
25
+ "files": [
26
+ "bro.ts",
27
+ "README.md",
28
+ "LICENSE",
29
+ "THIRD_PARTY_NOTICES.md"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.19.0"
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "npm run typecheck && sh ./smoke-test.sh",
40
+ "prepublishOnly": "npm test"
41
+ },
42
+ "pi": {
43
+ "extensions": [
44
+ "./bro.ts"
45
+ ]
46
+ },
47
+ "peerDependencies": {
48
+ "@earendil-works/pi-coding-agent": ">=0.78.1 <1",
49
+ "@earendil-works/pi-tui": ">=0.78.1 <1"
50
+ },
51
+ "devDependencies": {
52
+ "@earendil-works/pi-coding-agent": "0.84.2",
53
+ "@earendil-works/pi-tui": "0.84.2",
54
+ "@types/node": "^24.0.0",
55
+ "typescript": "6.0.2"
56
+ }
57
+ }