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.
Files changed (3) hide show
  1. package/README.md +10 -6
  2. package/bro.ts +140 -20
  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 create plain-language explanations.
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 plan and
350
- sandbox modes inside a temporary empty folder. This reduces project access,
351
- but it is not a security boundary.
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 scrolling is disabled; use the arrow keys to scroll and **C** to copy.
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
- async function simplify(pi: ExtensionAPI, response: string, signal: AbortSignal): Promise<string> {
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 result = await pi.exec(
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
- "text",
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
- { cwd: runDirectory, signal, timeout: 125_000 },
130
+ {
131
+ cwd: runDirectory,
132
+ signal,
133
+ timeout: 125_000,
134
+ stdio: ["ignore", "pipe", "pipe"],
135
+ windowsHide: true,
136
+ },
87
137
  );
88
138
 
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.");
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
- - **↑ / ↓** — scroll
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 plan and sandbox modes inside a temporary empty folder. This reduces project access, but it is not a security boundary.
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
- if (matchesKey(data, "up") || matchesKey(data, "down")) {
261
- const delta = matchesKey(data, "up") ? -1 : 1;
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?: (signal: AbortSignal, source?: AssistantSource) => Promise<BroResult>;
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 (signal: AbortSignal, source?: AssistantSource): Promise<BroResult> => {
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(pi, target.text, signal) };
545
+ return { source: target, text: await simplify(target.text, signal, onProgress) };
426
546
  };
427
547
 
428
548
  if (action === "open") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.1.1",
3
+ "version": "0.3.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",