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 +21 -0
- package/README.md +124 -0
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/bro.ts +464 -0
- package/package.json +57 -0
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
|
+
}
|