pi-bro 0.1.1 → 0.3.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 +10 -6
- package/bro.ts +140 -20
- 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
|
|
9
|
+
and a Gemini 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` installed, authenticated, and on your `PATH` (tested on `1.1.13`)
|
|
268
|
+
- `agy >=1.1.8` 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.
|
|
@@ -346,9 +346,9 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
|
346
346
|
- **Memory cache**: The latest explanation is stored only in process memory for
|
|
347
347
|
`/bro open`. It clears when you switch Pi sessions, reload extensions, or quit
|
|
348
348
|
Pi.
|
|
349
|
-
- **File safety**: Bro does not modify project files. It runs Agy in
|
|
350
|
-
|
|
351
|
-
|
|
349
|
+
- **File safety**: Bro does not modify project files. It runs Agy in sandbox
|
|
350
|
+
mode inside a temporary empty folder. This reduces project access, but it is
|
|
351
|
+
not a security boundary.
|
|
352
352
|
- **Provider data**: Agy and your model provider may retain logs and request data
|
|
353
353
|
according to their own settings and privacy policies.
|
|
354
354
|
- **Clipboard**: Pressing **C** copies the text to your system clipboard, where
|
|
@@ -359,7 +359,11 @@ PI_BRO_MODEL=gemini-3.7-flash-low pi
|
|
|
359
359
|
- Supports only Agy/Gemini in v0.1.
|
|
360
360
|
- Keeps only the latest explanation in memory.
|
|
361
361
|
- Does not store history or export directly to files.
|
|
362
|
-
- Mouse
|
|
362
|
+
- 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.
|
|
365
|
+
- In fullscreen mode, mouse text selection may visually extend outside the Bro
|
|
366
|
+
window. Press **C** to copy the full explanation instead.
|
|
363
367
|
|
|
364
368
|
## Development
|
|
365
369
|
|
package/bro.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
3
|
import { homedir, tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
4
6
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
6
8
|
import { Markdown, matchesKey, truncateToWidth, visibleWidth, type Focusable } from "@earendil-works/pi-tui";
|
|
@@ -20,9 +22,22 @@ Quoted response as a JSON string:
|
|
|
20
22
|
|
|
21
23
|
type Theme = ExtensionCommandContext["ui"]["theme"];
|
|
22
24
|
type TuiLike = { requestRender(): void };
|
|
23
|
-
type ModalKind = "loading" | "result" | "help" | "empty" | "error";
|
|
25
|
+
type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
|
|
24
26
|
type AssistantSource = { id: string; text: string };
|
|
25
27
|
type BroResult = { source: AssistantSource; text: string };
|
|
28
|
+
type AgyEvent = {
|
|
29
|
+
event?: string;
|
|
30
|
+
step_update?: { step_type?: string; text_delta?: unknown };
|
|
31
|
+
result?: { status?: string; response?: unknown };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function wheelDelta(data: string): number {
|
|
35
|
+
const match = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
|
|
36
|
+
if (!match) return 0;
|
|
37
|
+
const button = Number.parseInt(match[1], 10);
|
|
38
|
+
if ((button & 64) === 0) return 0;
|
|
39
|
+
return (button & 3) === 0 ? -3 : (button & 3) === 1 ? 3 : 0;
|
|
40
|
+
}
|
|
26
41
|
|
|
27
42
|
const COMMANDS = [
|
|
28
43
|
{ value: "simplify", label: "simplify", description: "Simplify the latest assistant response" },
|
|
@@ -62,20 +77,49 @@ async function promptFor(response: string): Promise<string> {
|
|
|
62
77
|
return parts.join(JSON.stringify(response));
|
|
63
78
|
}
|
|
64
79
|
|
|
65
|
-
|
|
80
|
+
function parseAgyLine(line: string): { delta?: string; result?: string } {
|
|
81
|
+
let event: AgyEvent;
|
|
82
|
+
try {
|
|
83
|
+
event = JSON.parse(line) as AgyEvent;
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error("Agy returned invalid streaming data.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
event.event === "step_update" &&
|
|
90
|
+
event.step_update?.step_type === "agent_response" &&
|
|
91
|
+
typeof event.step_update.text_delta === "string"
|
|
92
|
+
) {
|
|
93
|
+
return { delta: event.step_update.text_delta };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (event.event === "result") {
|
|
97
|
+
if (event.result?.status !== "SUCCESS" || typeof event.result.response !== "string") {
|
|
98
|
+
throw new Error("Agy did not complete the explanation successfully.");
|
|
99
|
+
}
|
|
100
|
+
return { result: event.result.response };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function simplify(
|
|
107
|
+
response: string,
|
|
108
|
+
signal: AbortSignal,
|
|
109
|
+
onProgress?: (text: string) => void,
|
|
110
|
+
): Promise<string> {
|
|
66
111
|
const prompt = await promptFor(response);
|
|
67
112
|
const runDirectory = await mkdtemp(join(tmpdir(), "pi-bro-"));
|
|
113
|
+
let updateTimer: ReturnType<typeof setTimeout> | undefined;
|
|
68
114
|
|
|
69
115
|
try {
|
|
70
|
-
const
|
|
116
|
+
const child = spawn(
|
|
71
117
|
"agy",
|
|
72
118
|
[
|
|
73
|
-
"--mode",
|
|
74
|
-
"plan",
|
|
75
119
|
"--sandbox",
|
|
76
120
|
"--disable-slash-commands",
|
|
77
121
|
"--output-format",
|
|
78
|
-
"
|
|
122
|
+
"stream-json",
|
|
79
123
|
"--model",
|
|
80
124
|
MODEL,
|
|
81
125
|
"--print-timeout",
|
|
@@ -83,17 +127,74 @@ async function simplify(pi: ExtensionAPI, response: string, signal: AbortSignal)
|
|
|
83
127
|
"--print",
|
|
84
128
|
prompt,
|
|
85
129
|
],
|
|
86
|
-
{
|
|
130
|
+
{
|
|
131
|
+
cwd: runDirectory,
|
|
132
|
+
signal,
|
|
133
|
+
timeout: 125_000,
|
|
134
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
},
|
|
87
137
|
);
|
|
88
138
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
let processError: Error | undefined;
|
|
140
|
+
let stderr = "";
|
|
141
|
+
let partial = "";
|
|
142
|
+
let final = "";
|
|
143
|
+
let parseError: Error | undefined;
|
|
144
|
+
|
|
145
|
+
child.stderr.setEncoding("utf8");
|
|
146
|
+
child.stderr.on("data", (chunk: string) => {
|
|
147
|
+
stderr += chunk;
|
|
148
|
+
});
|
|
149
|
+
child.once("error", (error) => {
|
|
150
|
+
processError = error;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const closed = new Promise<{ code: number | null; exitSignal: NodeJS.Signals | null }>((resolve) => {
|
|
154
|
+
child.once("close", (code, exitSignal) => resolve({ code, exitSignal }));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
158
|
+
try {
|
|
159
|
+
for await (const line of lines) {
|
|
160
|
+
if (!line.trim()) continue;
|
|
161
|
+
try {
|
|
162
|
+
const event = parseAgyLine(line);
|
|
163
|
+
if (event.delta) {
|
|
164
|
+
partial += event.delta;
|
|
165
|
+
if (onProgress && !updateTimer) {
|
|
166
|
+
updateTimer = setTimeout(() => {
|
|
167
|
+
updateTimer = undefined;
|
|
168
|
+
if (!signal.aborted) onProgress(partial);
|
|
169
|
+
}, 75);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (event.result !== undefined) final = event.result;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
parseError = error instanceof Error ? error : new Error(String(error));
|
|
175
|
+
child.kill();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
lines.close();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { code, exitSignal } = await closed;
|
|
184
|
+
if (signal.aborted) throw new Error("Canceled.");
|
|
185
|
+
if (parseError) throw parseError;
|
|
186
|
+
if (processError) throw processError;
|
|
187
|
+
if (exitSignal || code === null) throw new Error("Simplification timed out.");
|
|
188
|
+
if (code !== 0) throw new Error(stderr.trim() || `Agy exited with code ${code}.`);
|
|
189
|
+
|
|
190
|
+
const text = final.trim();
|
|
191
|
+
if (!text) {
|
|
192
|
+
throw new Error(stderr.trim() || "Agy returned no final explanation.");
|
|
93
193
|
}
|
|
94
194
|
|
|
95
195
|
return text;
|
|
96
196
|
} finally {
|
|
197
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
97
198
|
await rm(runDirectory, { recursive: true, force: true });
|
|
98
199
|
}
|
|
99
200
|
}
|
|
@@ -111,14 +212,17 @@ Bro turns the latest completed assistant response into a clear, plain-language e
|
|
|
111
212
|
|
|
112
213
|
## Controls
|
|
113
214
|
|
|
114
|
-
-
|
|
215
|
+
- **Mouse wheel / trackpad** — scroll in Pi's fullscreen mode
|
|
216
|
+
- **↑ / ↓** — scroll in any mode
|
|
115
217
|
- **C** — copy the full explanation
|
|
116
218
|
- **R** — simplify the same response again
|
|
117
219
|
- **Esc** — close the window, or cancel while Bro is working
|
|
118
220
|
|
|
221
|
+
Mouse text selection may extend outside the Bro window. Press **C** to copy the complete explanation instead.
|
|
222
|
+
|
|
119
223
|
## Privacy and file safety
|
|
120
224
|
|
|
121
|
-
Bro does not modify your project files. It runs the simplifier in
|
|
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.
|
|
122
226
|
|
|
123
227
|
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
228
|
|
|
@@ -163,6 +267,10 @@ class BroModal implements Focusable {
|
|
|
163
267
|
this.setContent("loading", `**${LOADING_TEXT}**`, "", false, false);
|
|
164
268
|
}
|
|
165
269
|
|
|
270
|
+
setStreaming(text: string): void {
|
|
271
|
+
this.setContent("streaming", text, "", false, false);
|
|
272
|
+
}
|
|
273
|
+
|
|
166
274
|
setResult(text: string, retryable: boolean, notice = ""): void {
|
|
167
275
|
this.setContent("result", text, text, true, retryable, notice);
|
|
168
276
|
}
|
|
@@ -188,7 +296,7 @@ class BroModal implements Focusable {
|
|
|
188
296
|
this.copyable = copyable;
|
|
189
297
|
this.retryable = retryable;
|
|
190
298
|
this.notice = notice;
|
|
191
|
-
this.offset = 0;
|
|
299
|
+
if (kind !== "streaming") this.offset = 0;
|
|
192
300
|
this.markdown.setText(text);
|
|
193
301
|
this.tui.requestRender();
|
|
194
302
|
}
|
|
@@ -211,6 +319,7 @@ class BroModal implements Focusable {
|
|
|
211
319
|
|
|
212
320
|
private controls(): string {
|
|
213
321
|
if (this.kind === "loading") return "Esc cancel";
|
|
322
|
+
if (this.kind === "streaming") return "Simplifying… · ↑/↓ scroll · Esc cancel";
|
|
214
323
|
if (this.kind === "result") return "↑/↓ scroll · C copy · R simplify again · Esc close";
|
|
215
324
|
if (this.kind === "help") return "↑/↓ scroll · C copy · Esc close";
|
|
216
325
|
if (this.kind === "error") return "R try again · Esc close";
|
|
@@ -257,8 +366,8 @@ class BroModal implements Focusable {
|
|
|
257
366
|
return;
|
|
258
367
|
}
|
|
259
368
|
|
|
260
|
-
|
|
261
|
-
|
|
369
|
+
const delta = wheelDelta(data) || (matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0);
|
|
370
|
+
if (delta) {
|
|
262
371
|
this.offset = Math.max(0, Math.min(this.offset + delta, this.maxOffset));
|
|
263
372
|
this.notice = "";
|
|
264
373
|
this.tui.requestRender();
|
|
@@ -303,7 +412,11 @@ interface BroModalOptions {
|
|
|
303
412
|
kind?: "help" | "empty";
|
|
304
413
|
copyable?: boolean;
|
|
305
414
|
result?: BroResult;
|
|
306
|
-
run?: (
|
|
415
|
+
run?: (
|
|
416
|
+
signal: AbortSignal,
|
|
417
|
+
source?: AssistantSource,
|
|
418
|
+
onProgress?: (text: string) => void,
|
|
419
|
+
) => Promise<BroResult>;
|
|
307
420
|
onResult?: (result: BroResult) => void;
|
|
308
421
|
}
|
|
309
422
|
|
|
@@ -349,7 +462,10 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
|
|
|
349
462
|
modal.setLoading();
|
|
350
463
|
|
|
351
464
|
void options
|
|
352
|
-
.run(nextController.signal, source)
|
|
465
|
+
.run(nextController.signal, source, (text) => {
|
|
466
|
+
if (closed || nextController.signal.aborted || controller !== nextController) return;
|
|
467
|
+
modal.setStreaming(text);
|
|
468
|
+
})
|
|
353
469
|
.then((result) => {
|
|
354
470
|
if (closed || nextController.signal.aborted) return;
|
|
355
471
|
current = result;
|
|
@@ -415,14 +531,18 @@ export default function bro(pi: ExtensionAPI) {
|
|
|
415
531
|
return;
|
|
416
532
|
}
|
|
417
533
|
|
|
418
|
-
const run = async (
|
|
534
|
+
const run = async (
|
|
535
|
+
signal: AbortSignal,
|
|
536
|
+
source?: AssistantSource,
|
|
537
|
+
onProgress?: (text: string) => void,
|
|
538
|
+
): Promise<BroResult> => {
|
|
419
539
|
let target = source;
|
|
420
540
|
if (!target) {
|
|
421
541
|
await ctx.waitForIdle();
|
|
422
542
|
target = latestAssistant(ctx);
|
|
423
543
|
}
|
|
424
544
|
if (!target) throw new Error("No completed assistant response found.");
|
|
425
|
-
return { source: target, text: await simplify(
|
|
545
|
+
return { source: target, text: await simplify(target.text, signal, onProgress) };
|
|
426
546
|
};
|
|
427
547
|
|
|
428
548
|
if (action === "open") {
|