@xynogen/pix-commands 0.1.7 → 0.2.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 +25 -5
- package/package.json +8 -4
- package/src/btw/index.test.ts +28 -0
- package/src/btw/index.ts +234 -0
- package/src/btw/render.test.ts +76 -0
- package/src/btw/render.ts +62 -0
- package/src/btw/session.test.ts +89 -0
- package/src/btw/session.ts +163 -0
- package/src/clear.ts +3 -12
- package/src/extension.test.ts +41 -0
- package/src/extension.ts +2 -0
- package/src/index.ts +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xynogen
|
|
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
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
# pix-commands
|
|
2
2
|
|
|
3
|
-
Pi extension
|
|
3
|
+
Pi extension providing focused slash commands:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- `/clear` — flush Pi's cached model data.
|
|
6
|
+
- `/btw <question>` — ask an isolated side question without interrupting the main agent.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
## `/clear`
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
Deletes `~/.cache/pi` to flush stale model-data cache, then prompts you to run `/reload`.
|
|
11
|
+
|
|
12
|
+
## `/btw`
|
|
13
|
+
|
|
14
|
+
`/btw` runs a separate in-memory child session concurrently with the main agent:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
/btw what is the difference between a mutex and a semaphore?
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The child session:
|
|
21
|
+
|
|
22
|
+
- starts with an empty conversation and a lean Pix system prompt;
|
|
23
|
+
- snapshots the main session's model, thinking level, active tools, credentials, extensions, and working directory;
|
|
24
|
+
- never imports the main conversation;
|
|
25
|
+
- publishes its Markdown answer in a visually distinct side-thread card;
|
|
26
|
+
- keeps rendered BTW answers out of future main-agent LLM context;
|
|
27
|
+
- supports multiple concurrent side questions.
|
|
28
|
+
|
|
29
|
+
When the main agent is streaming, completion is shown as a notification and the durable card is appended after the main session becomes idle. This prevents the side answer from becoming steering input.
|
|
10
30
|
|
|
11
31
|
## Install
|
|
12
32
|
|
|
@@ -24,7 +44,7 @@ pi install npm:@xynogen/pix-commands
|
|
|
24
44
|
|
|
25
45
|
Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
|
|
26
46
|
|
|
27
|
-
To install the complete pix suite
|
|
47
|
+
To install the complete pix suite:
|
|
28
48
|
|
|
29
49
|
```bash
|
|
30
50
|
curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-commands",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension — slash commands
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pi extension — slash commands for cache clearing and isolated side questions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"pi",
|
|
22
22
|
"pi-package",
|
|
23
23
|
"pi-extension",
|
|
24
|
-
"pix"
|
|
24
|
+
"pix",
|
|
25
|
+
"side-question",
|
|
26
|
+
"concurrent"
|
|
25
27
|
],
|
|
26
28
|
"author": "xynogen",
|
|
27
29
|
"license": "MIT",
|
|
@@ -38,6 +40,8 @@
|
|
|
38
40
|
"access": "public"
|
|
39
41
|
},
|
|
40
42
|
"peerDependencies": {
|
|
41
|
-
"@earendil-works/pi-
|
|
43
|
+
"@earendil-works/pi-ai": "*",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*"
|
|
42
46
|
}
|
|
43
47
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { filterBtwMessages, shortModelName, summarizeLiveText } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
describe("BTW display helpers", () => {
|
|
5
|
+
test("prefers model display name and falls back to id", () => {
|
|
6
|
+
expect(shortModelName({ id: "id", name: "Friendly" })).toBe("Friendly");
|
|
7
|
+
expect(shortModelName({ id: "id", name: " " })).toBe("id");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("summarizes streaming output on one bounded line", () => {
|
|
11
|
+
expect(summarizeLiveText("hello\n\nworld", 20)).toBe("hello world");
|
|
12
|
+
expect(summarizeLiveText("abcdefghij", 6)).toBe("abcde…");
|
|
13
|
+
expect(summarizeLiveText(" ")).toBe("thinking…");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("filters BTW cards from LLM context without affecting the transcript", () => {
|
|
17
|
+
const messages = [
|
|
18
|
+
{ role: "user", content: "main question" },
|
|
19
|
+
{ role: "custom", customType: "pix-btw-answer", content: "aside" },
|
|
20
|
+
{ role: "custom", customType: "other", content: "keep" },
|
|
21
|
+
];
|
|
22
|
+
expect(filterBtwMessages(messages)).toEqual([
|
|
23
|
+
{ role: "user", content: "main question" },
|
|
24
|
+
{ role: "custom", customType: "other", content: "keep" },
|
|
25
|
+
]);
|
|
26
|
+
expect(messages).toHaveLength(3);
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/btw/index.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSession,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionCommandContext,
|
|
5
|
+
Theme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Text, type TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import { type BtwMessageDetails, registerBtwRenderer } from "./render.ts";
|
|
9
|
+
import { runBtw, snapshotMainSettings } from "./session.ts";
|
|
10
|
+
|
|
11
|
+
const STATUS_KEY = "pix-btw";
|
|
12
|
+
const WIDGET_KEY = "pix-btw-live";
|
|
13
|
+
|
|
14
|
+
interface BtwJob {
|
|
15
|
+
id: number;
|
|
16
|
+
question: string;
|
|
17
|
+
model: string;
|
|
18
|
+
startedAt: number;
|
|
19
|
+
status: "running" | "completed" | "error" | "stopped";
|
|
20
|
+
text: string;
|
|
21
|
+
activeTools: Set<string>;
|
|
22
|
+
toolUses: number;
|
|
23
|
+
session?: AgentSession;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function shortModelName(model: { name?: string; id: string }): string {
|
|
28
|
+
return model.name?.trim() || model.id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function summarizeLiveText(text: string, max = 100): string {
|
|
32
|
+
const first = text.replace(/\s+/g, " ").trim();
|
|
33
|
+
if (!first) return "thinking…";
|
|
34
|
+
return first.length > max ? `${first.slice(0, Math.max(1, max - 1))}…` : first;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Keep rendered BTW cards out of every agent's LLM conversation context. */
|
|
38
|
+
export function filterBtwMessages<T>(messages: T[]): T[] {
|
|
39
|
+
return messages.filter((message) => {
|
|
40
|
+
const candidate = message as { role?: string; customType?: string };
|
|
41
|
+
return !(candidate.role === "custom" && candidate.customType === "pix-btw-answer");
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function registerBtw(pi: ExtensionAPI): void {
|
|
46
|
+
registerBtwRenderer(pi);
|
|
47
|
+
|
|
48
|
+
const jobs = new Map<number, BtwJob>();
|
|
49
|
+
let nextId = 1;
|
|
50
|
+
let latestUi: ExtensionCommandContext["ui"] | undefined;
|
|
51
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
52
|
+
|
|
53
|
+
const renderJobs = (theme: Theme): string[] => {
|
|
54
|
+
const running = [...jobs.values()].filter((job) => job.status === "running");
|
|
55
|
+
if (running.length === 0) return [];
|
|
56
|
+
const lines = [theme.fg("accent", `○ BTW (${running.length})`)];
|
|
57
|
+
for (const job of running.slice(-4)) {
|
|
58
|
+
const elapsed = ((Date.now() - job.startedAt) / 1_000).toFixed(1);
|
|
59
|
+
const activity =
|
|
60
|
+
job.activeTools.size > 0
|
|
61
|
+
? [...job.activeTools].map((name) => `using ${name}`).join(", ")
|
|
62
|
+
: summarizeLiveText(job.text);
|
|
63
|
+
lines.push(theme.fg("dim", `└─ #${job.id} [${job.model}] · ${elapsed}s · ${activity}`));
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const updateUi = () => {
|
|
69
|
+
if (!latestUi) return;
|
|
70
|
+
const running = [...jobs.values()].filter((job) => job.status === "running");
|
|
71
|
+
if (running.length === 0) {
|
|
72
|
+
latestUi.setStatus(STATUS_KEY, undefined);
|
|
73
|
+
latestUi.setWidget(WIDGET_KEY, undefined);
|
|
74
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
75
|
+
refreshTimer = undefined;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
latestUi.setStatus(STATUS_KEY, `BTW ${running.length}`);
|
|
79
|
+
latestUi.setWidget(
|
|
80
|
+
WIDGET_KEY,
|
|
81
|
+
(tui: TUI, theme: Theme) => {
|
|
82
|
+
const text = new Text("", 0, 0);
|
|
83
|
+
return {
|
|
84
|
+
render: (width: number) => {
|
|
85
|
+
text.setText(renderJobs(theme).join("\n"));
|
|
86
|
+
return text.render(width || tui.terminal.columns);
|
|
87
|
+
},
|
|
88
|
+
invalidate: () => text.invalidate(),
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
{ placement: "aboveEditor" },
|
|
92
|
+
);
|
|
93
|
+
refreshTimer ??= setInterval(updateUi, 100);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const publish = async (job: BtwJob, details: BtwMessageDetails, ctx: ExtensionCommandContext) => {
|
|
97
|
+
job.session?.dispose();
|
|
98
|
+
job.session = undefined;
|
|
99
|
+
updateUi();
|
|
100
|
+
|
|
101
|
+
// Sending a custom message while the main agent streams would enqueue it
|
|
102
|
+
// as a steer and leak the side answer into the in-flight task. Surface the
|
|
103
|
+
// result immediately as a UI notification, then wait to append the durable
|
|
104
|
+
// rendered transcript card until the main agent is idle.
|
|
105
|
+
if (!ctx.isIdle()) {
|
|
106
|
+
ctx.ui.notify(
|
|
107
|
+
details.error
|
|
108
|
+
? `BTW #${job.id} failed: ${details.error}`
|
|
109
|
+
: `BTW #${job.id} complete\n\n${details.answer}`,
|
|
110
|
+
details.error ? "error" : "info",
|
|
111
|
+
);
|
|
112
|
+
await ctx.waitForIdle();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
pi.sendMessage<BtwMessageDetails>({
|
|
116
|
+
customType: "pix-btw-answer",
|
|
117
|
+
content: details.error
|
|
118
|
+
? `BTW question failed: ${details.error}`
|
|
119
|
+
: `BTW answer to “${details.question}”:\n\n${details.answer}`,
|
|
120
|
+
display: true,
|
|
121
|
+
details,
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
pi.on("context", (event) => ({ messages: filterBtwMessages(event.messages) }));
|
|
126
|
+
|
|
127
|
+
pi.registerCommand("btw", {
|
|
128
|
+
description: "Ask an isolated side question without interrupting the main agent",
|
|
129
|
+
handler: async (rawArgs, ctx) => {
|
|
130
|
+
const question = rawArgs.trim();
|
|
131
|
+
latestUi = ctx.ui;
|
|
132
|
+
if (!question) {
|
|
133
|
+
ctx.ui.notify("Usage: /btw <question>", "warning");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let snapshot: ReturnType<typeof snapshotMainSettings>;
|
|
138
|
+
try {
|
|
139
|
+
snapshot = snapshotMainSettings(ctx, pi.getThinkingLevel(), pi.getActiveTools());
|
|
140
|
+
} catch (error) {
|
|
141
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const id = nextId++;
|
|
146
|
+
const job: BtwJob = {
|
|
147
|
+
id,
|
|
148
|
+
question,
|
|
149
|
+
model: shortModelName(snapshot.model),
|
|
150
|
+
startedAt: Date.now(),
|
|
151
|
+
status: "running",
|
|
152
|
+
text: "",
|
|
153
|
+
activeTools: new Set(),
|
|
154
|
+
toolUses: 0,
|
|
155
|
+
};
|
|
156
|
+
jobs.set(id, job);
|
|
157
|
+
updateUi();
|
|
158
|
+
ctx.ui.notify(`BTW #${id} started — the main agent will keep running.`, "info");
|
|
159
|
+
|
|
160
|
+
void runBtw({
|
|
161
|
+
question,
|
|
162
|
+
snapshot,
|
|
163
|
+
ctx,
|
|
164
|
+
onSession: (session) => {
|
|
165
|
+
job.session = session;
|
|
166
|
+
},
|
|
167
|
+
onTextDelta: (_delta, fullText) => {
|
|
168
|
+
job.text = fullText;
|
|
169
|
+
},
|
|
170
|
+
onToolStart: (name) => {
|
|
171
|
+
job.activeTools.add(name);
|
|
172
|
+
},
|
|
173
|
+
onToolEnd: (name) => {
|
|
174
|
+
job.activeTools.delete(name);
|
|
175
|
+
job.toolUses++;
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
.then(({ text, session }) => {
|
|
179
|
+
job.status = "completed";
|
|
180
|
+
job.text = text;
|
|
181
|
+
job.session = session;
|
|
182
|
+
void publish(
|
|
183
|
+
job,
|
|
184
|
+
{
|
|
185
|
+
question,
|
|
186
|
+
answer: text || "No answer returned.",
|
|
187
|
+
model: job.model,
|
|
188
|
+
thinkingLevel: snapshot.thinkingLevel,
|
|
189
|
+
durationMs: Date.now() - job.startedAt,
|
|
190
|
+
toolUses: job.toolUses,
|
|
191
|
+
},
|
|
192
|
+
ctx,
|
|
193
|
+
).catch(() => {});
|
|
194
|
+
})
|
|
195
|
+
.catch((error) => {
|
|
196
|
+
job.status = "error";
|
|
197
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
198
|
+
void publish(
|
|
199
|
+
job,
|
|
200
|
+
{
|
|
201
|
+
question,
|
|
202
|
+
answer: "",
|
|
203
|
+
model: job.model,
|
|
204
|
+
thinkingLevel: snapshot.thinkingLevel,
|
|
205
|
+
durationMs: Date.now() - job.startedAt,
|
|
206
|
+
toolUses: job.toolUses,
|
|
207
|
+
error: job.error,
|
|
208
|
+
},
|
|
209
|
+
ctx,
|
|
210
|
+
).catch(() => {});
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
pi.on("session_start", (_event, ctx) => {
|
|
216
|
+
latestUi = ctx.ui;
|
|
217
|
+
updateUi();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
pi.on("session_shutdown", () => {
|
|
221
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
222
|
+
refreshTimer = undefined;
|
|
223
|
+
latestUi?.setStatus(STATUS_KEY, undefined);
|
|
224
|
+
latestUi?.setWidget(WIDGET_KEY, undefined);
|
|
225
|
+
for (const job of jobs.values()) {
|
|
226
|
+
if (job.status === "running") {
|
|
227
|
+
job.status = "stopped";
|
|
228
|
+
void job.session?.abort();
|
|
229
|
+
}
|
|
230
|
+
job.session?.dispose();
|
|
231
|
+
}
|
|
232
|
+
jobs.clear();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { type ExtensionAPI, initTheme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type BtwMessageDetails, formatDuration, registerBtwRenderer } from "./render.ts";
|
|
4
|
+
|
|
5
|
+
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
6
|
+
|
|
7
|
+
function captureRenderer() {
|
|
8
|
+
let renderer: ((message: unknown, options: unknown, theme: unknown) => unknown) | undefined;
|
|
9
|
+
const pi = {
|
|
10
|
+
registerMessageRenderer(_name: string, fn: typeof renderer) {
|
|
11
|
+
renderer = fn;
|
|
12
|
+
},
|
|
13
|
+
} as unknown as ExtensionAPI;
|
|
14
|
+
registerBtwRenderer(pi);
|
|
15
|
+
if (!renderer) throw new Error("renderer was not registered");
|
|
16
|
+
return renderer;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const backgrounds: string[] = [];
|
|
20
|
+
const theme = {
|
|
21
|
+
fg: (_color: string, text: string) => text,
|
|
22
|
+
bg: (color: string, text: string) => {
|
|
23
|
+
backgrounds.push(color);
|
|
24
|
+
return text;
|
|
25
|
+
},
|
|
26
|
+
bold: (text: string) => text,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function render(details: BtwMessageDetails): string {
|
|
30
|
+
const renderer = captureRenderer();
|
|
31
|
+
const component = renderer({ details, content: details.answer }, { expanded: false }, theme) as {
|
|
32
|
+
render(width: number): string[];
|
|
33
|
+
};
|
|
34
|
+
return stripAnsi(component.render(80).join("\n"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("BTW renderer", () => {
|
|
38
|
+
test("formats durations compactly", () => {
|
|
39
|
+
expect(formatDuration(450)).toBe("450ms");
|
|
40
|
+
expect(formatDuration(2_100)).toBe("2.1s");
|
|
41
|
+
expect(formatDuration(65_000)).toBe("1m 5s");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("renders metadata and question as distinct side-thread card chrome", () => {
|
|
45
|
+
backgrounds.length = 0;
|
|
46
|
+
const output = render({
|
|
47
|
+
question: "hello",
|
|
48
|
+
answer: "Hi!",
|
|
49
|
+
model: "GPT-5.6",
|
|
50
|
+
thinkingLevel: "high",
|
|
51
|
+
durationMs: 2_100,
|
|
52
|
+
toolUses: 0,
|
|
53
|
+
});
|
|
54
|
+
expect(output).toContain("✓ BTW · GPT-5.6 · high · 2.1s");
|
|
55
|
+
expect(output).toContain("▐ hello");
|
|
56
|
+
expect(output).not.toContain("SIDE THREAD");
|
|
57
|
+
expect(backgrounds).toContain("selectedBg");
|
|
58
|
+
expect(backgrounds).not.toContain("customMessageBg");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("renders the answer as Markdown", () => {
|
|
62
|
+
initTheme();
|
|
63
|
+
const output = render({
|
|
64
|
+
question: "show markdown",
|
|
65
|
+
answer: "## Heading\n\n- alpha\n- beta\n\n`code`",
|
|
66
|
+
model: "Model",
|
|
67
|
+
thinkingLevel: "medium",
|
|
68
|
+
durationMs: 1_000,
|
|
69
|
+
toolUses: 1,
|
|
70
|
+
});
|
|
71
|
+
expect(output).toContain("Heading");
|
|
72
|
+
expect(output).toContain("alpha");
|
|
73
|
+
expect(output).toContain("beta");
|
|
74
|
+
expect(output).toContain("code");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export interface BtwMessageDetails {
|
|
5
|
+
question: string;
|
|
6
|
+
answer: string;
|
|
7
|
+
model: string;
|
|
8
|
+
thinkingLevel: string;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
toolUses: number;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatDuration(ms: number): string {
|
|
15
|
+
if (ms < 1_000) return `${ms}ms`;
|
|
16
|
+
if (ms < 60_000) return `${(ms / 1_000).toFixed(ms < 10_000 ? 1 : 0)}s`;
|
|
17
|
+
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1_000)}s`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerBtwRenderer(
|
|
21
|
+
pi: Pick<import("@earendil-works/pi-coding-agent").ExtensionAPI, "registerMessageRenderer">,
|
|
22
|
+
): void {
|
|
23
|
+
pi.registerMessageRenderer<BtwMessageDetails>("pix-btw-answer", (message, _options, theme) => {
|
|
24
|
+
const details = message.details;
|
|
25
|
+
if (!details) return undefined;
|
|
26
|
+
const failed = Boolean(details.error);
|
|
27
|
+
const icon = failed ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
28
|
+
const meta = [details.model, details.thinkingLevel, formatDuration(details.durationMs)];
|
|
29
|
+
if (details.toolUses > 0) meta.push(`${details.toolUses} tools`);
|
|
30
|
+
|
|
31
|
+
// A custom renderer bypasses Pi's default custom-message box, so provide
|
|
32
|
+
// our own card. Use selectedBg rather than the generic custom-message
|
|
33
|
+
// background: it is intentionally more prominent across Pi themes, which
|
|
34
|
+
// keeps this isolated side thread visually distinct from the main thread.
|
|
35
|
+
// Keep chrome as Text and render only the answer as Markdown; ANSI-styled
|
|
36
|
+
// header text embedded inside Markdown can confuse wrapping and parsing.
|
|
37
|
+
const card = new Box(1, 1, (text) => theme.bg("selectedBg", text));
|
|
38
|
+
card.addChild(
|
|
39
|
+
new Text(`${icon} ${theme.bold("BTW")} ${theme.fg("dim", `· ${meta.join(" · ")}`)}`, 0, 0),
|
|
40
|
+
);
|
|
41
|
+
card.addChild(
|
|
42
|
+
new Text(`${theme.fg("accent", "▐")} ${theme.fg("muted", details.question)}`, 0, 0),
|
|
43
|
+
);
|
|
44
|
+
card.addChild(new Spacer(1));
|
|
45
|
+
|
|
46
|
+
if (failed) {
|
|
47
|
+
card.addChild(new Text(theme.fg("error", details.error ?? "Unknown error"), 0, 0));
|
|
48
|
+
return card;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
card.addChild(
|
|
53
|
+
new Markdown(details.answer, 0, 0, getMarkdownTheme(), {
|
|
54
|
+
color: (text) => theme.fg("customMessageText", text),
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
} catch {
|
|
58
|
+
card.addChild(new Text(theme.fg("customMessageText", details.answer), 0, 0));
|
|
59
|
+
}
|
|
60
|
+
return card;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
BTW_SYSTEM_PROMPT,
|
|
6
|
+
lastAssistantText,
|
|
7
|
+
makeLeanExtensions,
|
|
8
|
+
selectBtwTools,
|
|
9
|
+
snapshotMainSettings,
|
|
10
|
+
} from "./session.ts";
|
|
11
|
+
|
|
12
|
+
describe("BTW system prompt", () => {
|
|
13
|
+
test("is the exact lean Pix identity", () => {
|
|
14
|
+
expect(BTW_SYSTEM_PROMPT).toBe(
|
|
15
|
+
"You are Pix Coding Agent. You help users accomplish any task they request.",
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("snapshotMainSettings", () => {
|
|
21
|
+
test("captures model, thinking, cwd, and a defensive copy of active tools", () => {
|
|
22
|
+
const tools = ["read", "fetch"];
|
|
23
|
+
const model = { id: "model-id", name: "Model" } as Model<Api>;
|
|
24
|
+
const snapshot = snapshotMainSettings({ cwd: "/project", model } as never, "high", tools);
|
|
25
|
+
tools.push("write");
|
|
26
|
+
expect(snapshot.cwd).toBe("/project");
|
|
27
|
+
expect(snapshot.model).toBe(model);
|
|
28
|
+
expect(snapshot.thinkingLevel).toBe("high");
|
|
29
|
+
expect(snapshot.activeToolNames).toEqual(["read", "fetch"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rejects invocation when the main session has no model", () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
snapshotMainSettings({ cwd: "/project", model: undefined } as never, "medium", []),
|
|
35
|
+
).toThrow("No model is selected");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("selectBtwTools", () => {
|
|
40
|
+
test("uses the main active tools and removes duplicates without reordering", () => {
|
|
41
|
+
expect(selectBtwTools(["read", "fetch", "read", "agent"])).toEqual(["read", "fetch", "agent"]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("makeLeanExtensions", () => {
|
|
46
|
+
test("removes discovered before_agent_start mutators but preserves inline override", () => {
|
|
47
|
+
const regularHandlers = new Map<string, never[]>([
|
|
48
|
+
["before_agent_start", []],
|
|
49
|
+
["tool_call", []],
|
|
50
|
+
]);
|
|
51
|
+
const inlineHandlers = new Map<string, never[]>([["before_agent_start", []]]);
|
|
52
|
+
const base = {
|
|
53
|
+
extensions: [
|
|
54
|
+
{ path: "/extensions/pix-prompts.ts", handlers: regularHandlers },
|
|
55
|
+
{ path: "<inline:1>", handlers: inlineHandlers },
|
|
56
|
+
],
|
|
57
|
+
errors: [],
|
|
58
|
+
runtime: {},
|
|
59
|
+
} as unknown as LoadExtensionsResult;
|
|
60
|
+
|
|
61
|
+
const result = makeLeanExtensions(base);
|
|
62
|
+
expect(result.extensions[0]?.handlers.has("before_agent_start")).toBe(false);
|
|
63
|
+
expect(result.extensions[0]?.handlers.has("tool_call")).toBe(true);
|
|
64
|
+
expect(result.extensions[1]?.handlers.has("before_agent_start")).toBe(true);
|
|
65
|
+
// Do not mutate the loader's original extension records.
|
|
66
|
+
expect(regularHandlers.has("before_agent_start")).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("lastAssistantText", () => {
|
|
71
|
+
test("returns text from the latest assistant response", () => {
|
|
72
|
+
const messages = [
|
|
73
|
+
{ role: "assistant", content: [{ type: "text", text: "old" }] },
|
|
74
|
+
{ role: "user", content: "question" },
|
|
75
|
+
{
|
|
76
|
+
role: "assistant",
|
|
77
|
+
content: [
|
|
78
|
+
{ type: "thinking", thinking: "hidden" },
|
|
79
|
+
{ type: "text", text: "latest" },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
expect(lastAssistantText(messages)).toBe("latest");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("returns an empty string when there is no assistant text", () => {
|
|
87
|
+
expect(lastAssistantText([{ role: "user", content: "hello" }])).toBe("");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
type AgentSession,
|
|
4
|
+
type AgentSessionEvent,
|
|
5
|
+
createAgentSession,
|
|
6
|
+
DefaultResourceLoader,
|
|
7
|
+
type ExtensionAPI,
|
|
8
|
+
type ExtensionContext,
|
|
9
|
+
getAgentDir,
|
|
10
|
+
type LoadExtensionsResult,
|
|
11
|
+
SessionManager,
|
|
12
|
+
SettingsManager,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
16
|
+
|
|
17
|
+
export const BTW_SYSTEM_PROMPT =
|
|
18
|
+
"You are Pix Coding Agent. You help users accomplish any task they request.";
|
|
19
|
+
|
|
20
|
+
export interface BtwSnapshot {
|
|
21
|
+
cwd: string;
|
|
22
|
+
model: Model<Api>;
|
|
23
|
+
thinkingLevel: ThinkingLevel;
|
|
24
|
+
activeToolNames: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BtwRunOptions {
|
|
28
|
+
question: string;
|
|
29
|
+
snapshot: BtwSnapshot;
|
|
30
|
+
ctx: ExtensionContext;
|
|
31
|
+
onSession?: (session: AgentSession) => void;
|
|
32
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
33
|
+
onToolStart?: (toolName: string) => void;
|
|
34
|
+
onToolEnd?: (toolName: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BtwRunResult {
|
|
38
|
+
text: string;
|
|
39
|
+
session: AgentSession;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Capture main-session settings at invocation time so later changes do not affect a running aside. */
|
|
43
|
+
export function snapshotMainSettings(
|
|
44
|
+
ctx: Pick<ExtensionContext, "cwd" | "model">,
|
|
45
|
+
thinkingLevel: ThinkingLevel,
|
|
46
|
+
activeToolNames: string[],
|
|
47
|
+
): BtwSnapshot {
|
|
48
|
+
if (!ctx.model) throw new Error("No model is selected in the main session.");
|
|
49
|
+
return {
|
|
50
|
+
cwd: ctx.cwd,
|
|
51
|
+
model: ctx.model as Model<Api>,
|
|
52
|
+
thinkingLevel,
|
|
53
|
+
activeToolNames: [...activeToolNames],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Preserve the main session's active tool selection exactly, while removing duplicates. */
|
|
58
|
+
export function selectBtwTools(activeToolNames: string[]): string[] {
|
|
59
|
+
return [...new Set(activeToolNames)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Keep extension tools and policies, but remove per-turn system-prompt mutators.
|
|
64
|
+
* This preserves the deliberately lean /btw identity even when pix-prompts,
|
|
65
|
+
* optimizer nudges, or project extensions normally append instructions.
|
|
66
|
+
*/
|
|
67
|
+
export function makeLeanExtensions(base: LoadExtensionsResult): LoadExtensionsResult {
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
extensions: base.extensions.map((extension) => {
|
|
71
|
+
// Preserve our final inline prompt override; suppress every discovered
|
|
72
|
+
// extension's per-turn system-prompt mutation.
|
|
73
|
+
if (extension.path.startsWith("<inline:")) return extension;
|
|
74
|
+
const handlers = new Map(extension.handlers);
|
|
75
|
+
handlers.delete("before_agent_start");
|
|
76
|
+
return { ...extension, handlers };
|
|
77
|
+
}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Extract the final assistant text if no streaming deltas were observed. */
|
|
82
|
+
export function lastAssistantText(messages: readonly unknown[]): string {
|
|
83
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
84
|
+
const message = messages[index] as AssistantMessage | undefined;
|
|
85
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
86
|
+
const text = message.content
|
|
87
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
88
|
+
.map((block) => block.text)
|
|
89
|
+
.join("\n")
|
|
90
|
+
.trim();
|
|
91
|
+
if (text) return text;
|
|
92
|
+
}
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run one isolated, in-memory /btw question.
|
|
98
|
+
*
|
|
99
|
+
* The child discovers the same extensions, skills, credentials, model registry,
|
|
100
|
+
* working directory, and settings files as the main session. Its system prompt
|
|
101
|
+
* is replaced with the lean Pix identity, and its conversation starts empty.
|
|
102
|
+
*/
|
|
103
|
+
export async function runBtw(options: BtwRunOptions): Promise<BtwRunResult> {
|
|
104
|
+
const { question, snapshot, ctx } = options;
|
|
105
|
+
const agentDir = getAgentDir();
|
|
106
|
+
const settingsManager = SettingsManager.create(snapshot.cwd, agentDir);
|
|
107
|
+
const loader = new DefaultResourceLoader({
|
|
108
|
+
cwd: snapshot.cwd,
|
|
109
|
+
agentDir,
|
|
110
|
+
settingsManager,
|
|
111
|
+
noContextFiles: true,
|
|
112
|
+
extensionFactories: [
|
|
113
|
+
(pi) => {
|
|
114
|
+
pi.on("before_agent_start", () => ({ systemPrompt: BTW_SYSTEM_PROMPT }));
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
extensionsOverride: makeLeanExtensions,
|
|
118
|
+
systemPromptOverride: () => BTW_SYSTEM_PROMPT,
|
|
119
|
+
appendSystemPromptOverride: () => [],
|
|
120
|
+
});
|
|
121
|
+
await loader.reload();
|
|
122
|
+
|
|
123
|
+
const tools = selectBtwTools(snapshot.activeToolNames);
|
|
124
|
+
|
|
125
|
+
const { session } = await createAgentSession({
|
|
126
|
+
cwd: snapshot.cwd,
|
|
127
|
+
agentDir,
|
|
128
|
+
sessionManager: SessionManager.inMemory(snapshot.cwd),
|
|
129
|
+
settingsManager,
|
|
130
|
+
modelRegistry: ctx.modelRegistry,
|
|
131
|
+
model: snapshot.model,
|
|
132
|
+
thinkingLevel: snapshot.thinkingLevel,
|
|
133
|
+
tools,
|
|
134
|
+
resourceLoader: loader,
|
|
135
|
+
});
|
|
136
|
+
session.setSessionName("btw");
|
|
137
|
+
await session.bindExtensions({
|
|
138
|
+
mode: ctx.mode,
|
|
139
|
+
onError: () => {},
|
|
140
|
+
});
|
|
141
|
+
options.onSession?.(session);
|
|
142
|
+
|
|
143
|
+
let text = "";
|
|
144
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
145
|
+
if (event.type === "message_start" && event.message.role === "assistant") text = "";
|
|
146
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
147
|
+
text += event.assistantMessageEvent.delta;
|
|
148
|
+
options.onTextDelta?.(event.assistantMessageEvent.delta, text);
|
|
149
|
+
}
|
|
150
|
+
if (event.type === "tool_execution_start") options.onToolStart?.(event.toolName);
|
|
151
|
+
if (event.type === "tool_execution_end") options.onToolEnd?.(event.toolName);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await session.prompt(question, { source: "extension" });
|
|
156
|
+
return { text: text.trim() || lastAssistantText(session.messages), session };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
session.dispose();
|
|
159
|
+
throw error;
|
|
160
|
+
} finally {
|
|
161
|
+
unsubscribe();
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/clear.ts
CHANGED
|
@@ -1,25 +1,16 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionCommandContext,
|
|
4
|
-
} from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
2
|
|
|
6
3
|
async function clearCache(pi: ExtensionAPI, ctx: ExtensionCommandContext) {
|
|
7
4
|
ctx.ui.notify("Clearing ~/.cache/pi", "info");
|
|
8
5
|
const result = await pi.exec("/bin/sh", ["-lc", 'rm -rf "$HOME/.cache/pi"'], {
|
|
9
6
|
timeout: 10_000,
|
|
10
7
|
});
|
|
11
|
-
const output = [result.stdout, result.stderr]
|
|
12
|
-
.filter(Boolean)
|
|
13
|
-
.join("\n")
|
|
14
|
-
.trim();
|
|
8
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
|
|
15
9
|
if ((result.code ?? 0) !== 0) {
|
|
16
10
|
ctx.ui.notify(`Cache clear failed. ${output || "No output."}`, "error");
|
|
17
11
|
return;
|
|
18
12
|
}
|
|
19
|
-
ctx.ui.notify(
|
|
20
|
-
"~/.cache/pi cleared. Run /reload to apply changes.",
|
|
21
|
-
"warning",
|
|
22
|
-
);
|
|
13
|
+
ctx.ui.notify("~/.cache/pi cleared. Run /reload to apply changes.", "warning");
|
|
23
14
|
}
|
|
24
15
|
|
|
25
16
|
export default function (pi: ExtensionAPI) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import extension from "./extension.ts";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
delete (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("pix-commands registration", () => {
|
|
10
|
+
function host() {
|
|
11
|
+
const commands: string[] = [];
|
|
12
|
+
const renderers: string[] = [];
|
|
13
|
+
const pi = {
|
|
14
|
+
registerCommand(name: string) {
|
|
15
|
+
commands.push(name);
|
|
16
|
+
},
|
|
17
|
+
registerMessageRenderer(name: string) {
|
|
18
|
+
renderers.push(name);
|
|
19
|
+
},
|
|
20
|
+
on() {},
|
|
21
|
+
} as unknown as ExtensionAPI;
|
|
22
|
+
return { pi, commands, renderers };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("registers /clear, /btw, and the BTW renderer once per Pi instance", () => {
|
|
26
|
+
const { pi, commands, renderers } = host();
|
|
27
|
+
extension(pi);
|
|
28
|
+
extension(pi);
|
|
29
|
+
expect(commands).toEqual(["clear", "btw"]);
|
|
30
|
+
expect(renderers).toEqual(["pix-btw-answer"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("registers again for a fresh Pi session", () => {
|
|
34
|
+
const first = host();
|
|
35
|
+
const second = host();
|
|
36
|
+
extension(first.pi);
|
|
37
|
+
extension(second.pi);
|
|
38
|
+
expect(first.commands).toEqual(["clear", "btw"]);
|
|
39
|
+
expect(second.commands).toEqual(["clear", "btw"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/extension.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerBtw } from "./btw/index.ts";
|
|
2
3
|
import registerClear from "./clear.ts";
|
|
3
4
|
import { once } from "./once.ts";
|
|
4
5
|
|
|
5
6
|
export default function (pi: ExtensionAPI): void {
|
|
6
7
|
once(pi, "pix-commands", () => {
|
|
7
8
|
registerClear(pi);
|
|
9
|
+
registerBtw(pi);
|
|
8
10
|
});
|
|
9
11
|
}
|
package/src/index.ts
CHANGED