@xynogen/pix-commands 0.2.5 → 0.3.5
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/package.json +3 -2
- package/src/btw/index.test.ts +27 -35
- package/src/btw/index.ts +82 -120
- package/src/btw/render.test.ts +30 -4
- package/src/btw/render.ts +30 -5
- package/src/btw/session.ts +30 -5
- package/src/btw/widget.test.ts +76 -0
- package/src/btw/widget.ts +158 -0
- package/src/extension.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-commands",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Pi extension — slash commands for cache clearing and isolated side questions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"@earendil-works/pi-tui": "*"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@xynogen/pix-
|
|
48
|
+
"@xynogen/pix-pretty": "^1.11.2",
|
|
49
|
+
"@xynogen/pix-runtime": "^0.5.3"
|
|
49
50
|
}
|
|
50
51
|
}
|
package/src/btw/index.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { registerBtw, shortModelName } from "./index.ts";
|
|
3
3
|
|
|
4
4
|
describe("BTW display helpers", () => {
|
|
5
5
|
test("prefers model display name and falls back to id", () => {
|
|
@@ -7,48 +7,40 @@ describe("BTW display helpers", () => {
|
|
|
7
7
|
expect(shortModelName({ id: "id", name: " " })).toBe("id");
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
-
test("
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
test("registers a display-only entry renderer, never a context-bearing message renderer", () => {
|
|
11
|
+
let entryRenderer: string | undefined;
|
|
12
|
+
let messageRenderer: string | undefined;
|
|
13
|
+
const pi = {
|
|
14
|
+
on() {},
|
|
15
|
+
registerCommand() {},
|
|
16
|
+
registerEntryRenderer(name: string) {
|
|
17
|
+
entryRenderer = name;
|
|
18
|
+
},
|
|
19
|
+
registerMessageRenderer(name: string) {
|
|
20
|
+
messageRenderer = name;
|
|
21
|
+
},
|
|
22
|
+
} as any;
|
|
23
|
+
registerBtw(pi);
|
|
15
24
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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);
|
|
25
|
+
// pix-btw-answer must be a CustomEntry (display-only, never in LLM context),
|
|
26
|
+
// not a CustomMessageEntry — that is what lets the card land mid-stream.
|
|
27
|
+
expect(entryRenderer).toBe("pix-btw-answer");
|
|
28
|
+
expect(messageRenderer).toBeUndefined();
|
|
27
29
|
});
|
|
28
30
|
|
|
29
|
-
test("does not
|
|
30
|
-
const
|
|
31
|
+
test("does not register a context handler (BTW cards never enter LLM context)", () => {
|
|
32
|
+
const events: string[] = [];
|
|
31
33
|
const pi = {
|
|
32
|
-
on(event: string
|
|
33
|
-
|
|
34
|
+
on(event: string) {
|
|
35
|
+
events.push(event);
|
|
34
36
|
},
|
|
35
37
|
registerCommand() {},
|
|
36
|
-
|
|
38
|
+
registerEntryRenderer() {},
|
|
37
39
|
} as any;
|
|
38
40
|
registerBtw(pi);
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
{
|
|
44
|
-
isIdle() {
|
|
45
|
-
idleChecks++;
|
|
46
|
-
throw new Error("stale extension context");
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
);
|
|
50
|
-
await Bun.sleep(10);
|
|
51
|
-
|
|
52
|
-
expect(idleChecks).toBe(0);
|
|
42
|
+
// A CustomEntry is ignored by buildSessionContext, so there is nothing to
|
|
43
|
+
// strip — the old pi.on("context", filterBtwMessages) hack is gone.
|
|
44
|
+
expect(events).not.toContain("context");
|
|
53
45
|
});
|
|
54
46
|
});
|
package/src/btw/index.ts
CHANGED
|
@@ -5,8 +5,10 @@ import type {
|
|
|
5
5
|
Theme,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Text, type TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import { getSessionContextUsage } from "@xynogen/pix-pretty/widget-format";
|
|
8
9
|
import { type BtwMessageDetails, registerBtwRenderer } from "./render.ts";
|
|
9
10
|
import { runBtw, snapshotMainSettings } from "./session.ts";
|
|
11
|
+
import { type BtwWidgetJob, hasVisibleJobs, renderBtwWidget } from "./widget.ts";
|
|
10
12
|
|
|
11
13
|
const STATUS_KEY = "pix-btw";
|
|
12
14
|
const WIDGET_KEY = "pix-btw-live";
|
|
@@ -16,30 +18,39 @@ interface BtwJob {
|
|
|
16
18
|
question: string;
|
|
17
19
|
model: string;
|
|
18
20
|
startedAt: number;
|
|
21
|
+
completedAt?: number;
|
|
19
22
|
status: "running" | "completed" | "error" | "stopped";
|
|
20
23
|
text: string;
|
|
21
24
|
activeTools: Set<string>;
|
|
22
25
|
toolUses: number;
|
|
26
|
+
turnCount: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
/** Final context usage, captured before the session is disposed on publish. */
|
|
29
|
+
contextUsage: import("@xynogen/pix-pretty/widget-format").ContextUsageLike | null;
|
|
23
30
|
session?: AgentSession;
|
|
24
31
|
error?: string;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
/** Project a live job into the widget's render shape. */
|
|
35
|
+
function toWidgetJob(job: BtwJob): BtwWidgetJob {
|
|
36
|
+
return {
|
|
37
|
+
id: job.id,
|
|
38
|
+
model: job.model,
|
|
39
|
+
status: job.status,
|
|
40
|
+
startedAt: job.startedAt,
|
|
41
|
+
completedAt: job.completedAt,
|
|
42
|
+
activeTools: [...job.activeTools],
|
|
43
|
+
text: job.text,
|
|
44
|
+
toolUses: job.toolUses,
|
|
45
|
+
turnCount: job.turnCount,
|
|
46
|
+
outputTokens: job.outputTokens,
|
|
47
|
+
contextUsage: getSessionContextUsage(job.session) ?? job.contextUsage,
|
|
48
|
+
error: job.error,
|
|
49
|
+
};
|
|
35
50
|
}
|
|
36
51
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
});
|
|
52
|
+
export function shortModelName(model: { name?: string; id: string }): string {
|
|
53
|
+
return model.name?.trim() || model.id;
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
export function registerBtw(pi: ExtensionAPI): void {
|
|
@@ -48,113 +59,68 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
48
59
|
const jobs = new Map<number, BtwJob>();
|
|
49
60
|
let nextId = 1;
|
|
50
61
|
let latestUi: ExtensionCommandContext["ui"] | undefined;
|
|
51
|
-
let latestContext: Pick<ExtensionCommandContext, "isIdle"> | undefined;
|
|
52
62
|
let active = true;
|
|
53
63
|
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
54
|
-
let publishTimer: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
-
const pendingCards: BtwMessageDetails[] = [];
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
const running = [...jobs.values()].filter((job) => job.status === "running");
|
|
59
|
-
if (running.length === 0) return [];
|
|
60
|
-
const lines = [theme.fg("accent", `○ BTW (${running.length})`)];
|
|
61
|
-
for (const job of running.slice(-4)) {
|
|
62
|
-
const elapsed = ((Date.now() - job.startedAt) / 1_000).toFixed(1);
|
|
63
|
-
const activity =
|
|
64
|
-
job.activeTools.size > 0
|
|
65
|
-
? [...job.activeTools].map((name) => `using ${name}`).join(", ")
|
|
66
|
-
: summarizeLiveText(job.text);
|
|
67
|
-
lines.push(theme.fg("dim", `└─ #${job.id} [${job.model}] · ${elapsed}s · ${activity}`));
|
|
68
|
-
}
|
|
69
|
-
return lines;
|
|
70
|
-
};
|
|
65
|
+
let frame = 0;
|
|
71
66
|
|
|
72
67
|
const updateUi = () => {
|
|
73
68
|
if (!active || !latestUi) return;
|
|
74
|
-
const
|
|
75
|
-
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
const allJobs = [...jobs.values()];
|
|
71
|
+
if (!hasVisibleJobs(allJobs.map(toWidgetJob), now)) {
|
|
76
72
|
latestUi.setStatus(STATUS_KEY, undefined);
|
|
77
73
|
latestUi.setWidget(WIDGET_KEY, undefined);
|
|
78
74
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
79
75
|
refreshTimer = undefined;
|
|
80
76
|
return;
|
|
81
77
|
}
|
|
82
|
-
|
|
78
|
+
const runningCount = allJobs.filter((job) => job.status === "running").length;
|
|
79
|
+
latestUi.setStatus(STATUS_KEY, runningCount > 0 ? `BTW ${runningCount}` : undefined);
|
|
83
80
|
latestUi.setWidget(
|
|
84
81
|
WIDGET_KEY,
|
|
85
82
|
(tui: TUI, theme: Theme) => {
|
|
86
83
|
const text = new Text("", 0, 0);
|
|
87
84
|
return {
|
|
88
85
|
render: (width: number) => {
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
const w = width || tui.terminal.columns;
|
|
87
|
+
const widgetJobs = [...jobs.values()].map(toWidgetJob);
|
|
88
|
+
text.setText(renderBtwWidget(widgetJobs, theme, frame, Date.now(), w).join("\n"));
|
|
89
|
+
return text.render(w);
|
|
91
90
|
},
|
|
92
91
|
invalidate: () => text.invalidate(),
|
|
93
92
|
};
|
|
94
93
|
},
|
|
95
94
|
{ placement: "aboveEditor" },
|
|
96
95
|
);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// immediately afterward when the completed child session is disposed.
|
|
104
|
-
if (!active || pendingCards.length === 0 || !latestContext?.isIdle()) return;
|
|
105
|
-
for (const details of pendingCards.splice(0)) {
|
|
106
|
-
pi.sendMessage<BtwMessageDetails>({
|
|
107
|
-
customType: "pix-btw-answer",
|
|
108
|
-
content: details.error
|
|
109
|
-
? `BTW question failed: ${details.error}`
|
|
110
|
-
: `BTW answer to “${details.question}”:\n\n${details.answer}`,
|
|
111
|
-
display: true,
|
|
112
|
-
details,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const scheduleCardFlush = () => {
|
|
118
|
-
if (!active || pendingCards.length === 0 || publishTimer) return;
|
|
119
|
-
// agent_end handlers run before Pi clears its streaming flag. Flush on the
|
|
120
|
-
// next macrotask, when sendMessage appends and renders synchronously instead
|
|
121
|
-
// of entering a queue that only becomes visible on another user turn.
|
|
122
|
-
publishTimer = setTimeout(() => {
|
|
123
|
-
publishTimer = undefined;
|
|
124
|
-
flushPendingCards();
|
|
125
|
-
}, 0);
|
|
96
|
+
// 80ms cadence advances the spinner and refreshes elapsed/linger. When only
|
|
97
|
+
// finished jobs remain, they drop once their linger window closes.
|
|
98
|
+
refreshTimer ??= setInterval(() => {
|
|
99
|
+
frame++;
|
|
100
|
+
updateUi();
|
|
101
|
+
}, 80);
|
|
126
102
|
};
|
|
127
103
|
|
|
128
|
-
const publish = (job: BtwJob, details: BtwMessageDetails
|
|
104
|
+
const publish = (job: BtwJob, details: BtwMessageDetails) => {
|
|
129
105
|
job.session?.dispose();
|
|
130
106
|
job.session = undefined;
|
|
131
107
|
// The side session may finish while the main extension runtime is being
|
|
132
|
-
// replaced. Never touch
|
|
108
|
+
// replaced. Never touch the captured pi/UI after shutdown begins.
|
|
133
109
|
if (!active) return;
|
|
134
110
|
updateUi();
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
: `BTW #${job.id} complete\n\n${details.answer}`,
|
|
142
|
-
details.error ? "error" : "info",
|
|
143
|
-
);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
flushPendingCards();
|
|
111
|
+
// A display-only CustomEntry never enters the main agent's LLM context and
|
|
112
|
+
// never steers the running turn, so the card lands the instant the side
|
|
113
|
+
// question finishes — even while the main agent is still streaming. This is
|
|
114
|
+
// the whole point of /btw: an immediate aside, not a deferred bottom-of-log
|
|
115
|
+
// note that only appears once the main turn goes idle.
|
|
116
|
+
pi.appendEntry<BtwMessageDetails>("pix-btw-answer", details);
|
|
148
117
|
};
|
|
149
118
|
|
|
150
|
-
pi.on("context", (event) => ({ messages: filterBtwMessages(event.messages) }));
|
|
151
|
-
|
|
152
119
|
pi.registerCommand("btw", {
|
|
153
120
|
description: "Ask an isolated side question without interrupting the main agent",
|
|
154
121
|
handler: async (rawArgs, ctx) => {
|
|
155
122
|
const question = rawArgs.trim();
|
|
156
123
|
latestUi = ctx.ui;
|
|
157
|
-
latestContext = ctx;
|
|
158
124
|
if (!question) {
|
|
159
125
|
ctx.ui.notify("Usage: /btw <question>", "warning");
|
|
160
126
|
return;
|
|
@@ -178,6 +144,9 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
178
144
|
text: "",
|
|
179
145
|
activeTools: new Set(),
|
|
180
146
|
toolUses: 0,
|
|
147
|
+
turnCount: 0,
|
|
148
|
+
outputTokens: 0,
|
|
149
|
+
contextUsage: null,
|
|
181
150
|
};
|
|
182
151
|
jobs.set(id, job);
|
|
183
152
|
updateUi();
|
|
@@ -200,54 +169,51 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
200
169
|
job.activeTools.delete(name);
|
|
201
170
|
job.toolUses++;
|
|
202
171
|
},
|
|
172
|
+
onTurnEnd: (turnCount) => {
|
|
173
|
+
job.turnCount = turnCount;
|
|
174
|
+
},
|
|
175
|
+
onOutputTokens: (output) => {
|
|
176
|
+
job.outputTokens += output;
|
|
177
|
+
job.contextUsage = getSessionContextUsage(job.session) ?? job.contextUsage;
|
|
178
|
+
},
|
|
203
179
|
})
|
|
204
|
-
.then(({ text, session }) => {
|
|
180
|
+
.then(({ text, thinking, session }) => {
|
|
205
181
|
job.status = "completed";
|
|
182
|
+
job.completedAt = Date.now();
|
|
206
183
|
job.text = text;
|
|
207
184
|
job.session = session;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
ctx,
|
|
219
|
-
);
|
|
185
|
+
job.contextUsage = getSessionContextUsage(session) ?? job.contextUsage;
|
|
186
|
+
publish(job, {
|
|
187
|
+
question,
|
|
188
|
+
answer: text || "No answer returned.",
|
|
189
|
+
thinking,
|
|
190
|
+
model: job.model,
|
|
191
|
+
thinkingLevel: snapshot.thinkingLevel,
|
|
192
|
+
durationMs: Date.now() - job.startedAt,
|
|
193
|
+
toolUses: job.toolUses,
|
|
194
|
+
});
|
|
220
195
|
})
|
|
221
196
|
.catch((error) => {
|
|
222
197
|
job.status = "error";
|
|
198
|
+
job.completedAt = Date.now();
|
|
223
199
|
job.error = error instanceof Error ? error.message : String(error);
|
|
224
|
-
publish(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
},
|
|
235
|
-
ctx,
|
|
236
|
-
);
|
|
200
|
+
publish(job, {
|
|
201
|
+
question,
|
|
202
|
+
answer: "",
|
|
203
|
+
thinking: "",
|
|
204
|
+
model: job.model,
|
|
205
|
+
thinkingLevel: snapshot.thinkingLevel,
|
|
206
|
+
durationMs: Date.now() - job.startedAt,
|
|
207
|
+
toolUses: job.toolUses,
|
|
208
|
+
error: job.error,
|
|
209
|
+
});
|
|
237
210
|
});
|
|
238
211
|
},
|
|
239
212
|
});
|
|
240
213
|
|
|
241
|
-
pi.on("agent_end", (_event, ctx) => {
|
|
242
|
-
if (!active) return;
|
|
243
|
-
latestContext = ctx;
|
|
244
|
-
scheduleCardFlush();
|
|
245
|
-
});
|
|
246
|
-
|
|
247
214
|
pi.on("session_start", (_event, ctx) => {
|
|
248
215
|
if (!active) return;
|
|
249
216
|
latestUi = ctx.ui;
|
|
250
|
-
latestContext = ctx;
|
|
251
217
|
updateUi();
|
|
252
218
|
});
|
|
253
219
|
|
|
@@ -256,10 +222,7 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
256
222
|
// completions must become no-ops before Pi invalidates this runtime.
|
|
257
223
|
active = false;
|
|
258
224
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
259
|
-
if (publishTimer) clearTimeout(publishTimer);
|
|
260
225
|
refreshTimer = undefined;
|
|
261
|
-
publishTimer = undefined;
|
|
262
|
-
latestContext = undefined;
|
|
263
226
|
latestUi?.setStatus(STATUS_KEY, undefined);
|
|
264
227
|
latestUi?.setWidget(WIDGET_KEY, undefined);
|
|
265
228
|
for (const job of jobs.values()) {
|
|
@@ -270,6 +233,5 @@ export function registerBtw(pi: ExtensionAPI): void {
|
|
|
270
233
|
job.session?.dispose();
|
|
271
234
|
}
|
|
272
235
|
jobs.clear();
|
|
273
|
-
pendingCards.length = 0;
|
|
274
236
|
});
|
|
275
237
|
}
|
package/src/btw/render.test.ts
CHANGED
|
@@ -5,9 +5,9 @@ import { type BtwMessageDetails, formatDuration, registerBtwRenderer } from "./r
|
|
|
5
5
|
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
6
6
|
|
|
7
7
|
function captureRenderer() {
|
|
8
|
-
let renderer: ((
|
|
8
|
+
let renderer: ((entry: unknown, options: unknown, theme: unknown) => unknown) | undefined;
|
|
9
9
|
const pi = {
|
|
10
|
-
|
|
10
|
+
registerEntryRenderer(_name: string, fn: typeof renderer) {
|
|
11
11
|
renderer = fn;
|
|
12
12
|
},
|
|
13
13
|
} as unknown as ExtensionAPI;
|
|
@@ -26,9 +26,13 @@ const theme = {
|
|
|
26
26
|
bold: (text: string) => text,
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
function render(details: BtwMessageDetails): string {
|
|
29
|
+
function render(details: BtwMessageDetails, expanded = false): string {
|
|
30
30
|
const renderer = captureRenderer();
|
|
31
|
-
const component = renderer(
|
|
31
|
+
const component = renderer(
|
|
32
|
+
{ type: "custom", customType: "pix-btw-answer", data: details },
|
|
33
|
+
{ expanded },
|
|
34
|
+
theme,
|
|
35
|
+
) as {
|
|
32
36
|
render(width: number): string[];
|
|
33
37
|
};
|
|
34
38
|
return stripAnsi(component.render(80).join("\n"));
|
|
@@ -46,6 +50,7 @@ describe("BTW renderer", () => {
|
|
|
46
50
|
const output = render({
|
|
47
51
|
question: "hello",
|
|
48
52
|
answer: "Hi!",
|
|
53
|
+
thinking: "",
|
|
49
54
|
model: "GPT-5.6",
|
|
50
55
|
thinkingLevel: "high",
|
|
51
56
|
durationMs: 2_100,
|
|
@@ -63,6 +68,7 @@ describe("BTW renderer", () => {
|
|
|
63
68
|
const output = render({
|
|
64
69
|
question: "show markdown",
|
|
65
70
|
answer: "## Heading\n\n- alpha\n- beta\n\n`code`",
|
|
71
|
+
thinking: "",
|
|
66
72
|
model: "Model",
|
|
67
73
|
thinkingLevel: "medium",
|
|
68
74
|
durationMs: 1_000,
|
|
@@ -73,4 +79,24 @@ describe("BTW renderer", () => {
|
|
|
73
79
|
expect(output).toContain("beta");
|
|
74
80
|
expect(output).toContain("code");
|
|
75
81
|
});
|
|
82
|
+
|
|
83
|
+
test("hides reasoning by default and reveals it when expanded", () => {
|
|
84
|
+
initTheme();
|
|
85
|
+
const details: BtwMessageDetails = {
|
|
86
|
+
question: "why",
|
|
87
|
+
answer: "Because.",
|
|
88
|
+
thinking: "first I considered the mutex",
|
|
89
|
+
model: "Model",
|
|
90
|
+
thinkingLevel: "high",
|
|
91
|
+
durationMs: 1_000,
|
|
92
|
+
toolUses: 0,
|
|
93
|
+
};
|
|
94
|
+
const collapsed = render(details, false);
|
|
95
|
+
expect(collapsed).toContain("reasoning hidden");
|
|
96
|
+
expect(collapsed).not.toContain("first I considered the mutex");
|
|
97
|
+
|
|
98
|
+
const expanded = render(details, true);
|
|
99
|
+
expect(expanded).toContain("Reasoning");
|
|
100
|
+
expect(expanded).toContain("first I considered the mutex");
|
|
101
|
+
});
|
|
76
102
|
});
|
package/src/btw/render.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Box, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
3
4
|
|
|
4
5
|
export interface BtwMessageDetails {
|
|
5
6
|
question: string;
|
|
6
7
|
answer: string;
|
|
8
|
+
/** Captured reasoning/thinking from the child session (empty when none). */
|
|
9
|
+
thinking: string;
|
|
7
10
|
model: string;
|
|
8
11
|
thinkingLevel: string;
|
|
9
12
|
durationMs: number;
|
|
@@ -18,13 +21,18 @@ export function formatDuration(ms: number): string {
|
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export function registerBtwRenderer(
|
|
21
|
-
pi: Pick<import("@earendil-works/pi-coding-agent").ExtensionAPI, "
|
|
24
|
+
pi: Pick<import("@earendil-works/pi-coding-agent").ExtensionAPI, "registerEntryRenderer">,
|
|
22
25
|
): void {
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
// A display-only CustomEntry (not a CustomMessageEntry): it never enters the
|
|
27
|
+
// main agent's LLM context and never steers the running turn, so the card can
|
|
28
|
+
// be appended the instant the side question finishes — even mid-stream.
|
|
29
|
+
pi.registerEntryRenderer<BtwMessageDetails>("pix-btw-answer", (entry, options, theme) => {
|
|
30
|
+
const details = entry.data;
|
|
25
31
|
if (!details) return undefined;
|
|
26
32
|
const failed = Boolean(details.error);
|
|
27
|
-
const
|
|
33
|
+
const statusGlyph = failed
|
|
34
|
+
? theme.fg("error", icon("status.error"))
|
|
35
|
+
: theme.fg("success", icon("status.ok"));
|
|
28
36
|
const meta = [details.model, details.thinkingLevel, formatDuration(details.durationMs)];
|
|
29
37
|
if (details.toolUses > 0) meta.push(`${details.toolUses} tools`);
|
|
30
38
|
|
|
@@ -36,7 +44,11 @@ export function registerBtwRenderer(
|
|
|
36
44
|
// header text embedded inside Markdown can confuse wrapping and parsing.
|
|
37
45
|
const card = new Box(1, 1, (text) => theme.bg("selectedBg", text));
|
|
38
46
|
card.addChild(
|
|
39
|
-
new Text(
|
|
47
|
+
new Text(
|
|
48
|
+
`${statusGlyph} ${theme.bold("BTW")} ${theme.fg("dim", `· ${meta.join(" · ")}`)}`,
|
|
49
|
+
0,
|
|
50
|
+
0,
|
|
51
|
+
),
|
|
40
52
|
);
|
|
41
53
|
card.addChild(
|
|
42
54
|
new Text(`${theme.fg("accent", "▐")} ${theme.fg("muted", details.question)}`, 0, 0),
|
|
@@ -48,6 +60,19 @@ export function registerBtwRenderer(
|
|
|
48
60
|
return card;
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
// Reasoning is preserved but collapsed by default: shown only when the host
|
|
64
|
+
// requests the expanded view, never discarded (see AGENTS.md §3).
|
|
65
|
+
if (details.thinking) {
|
|
66
|
+
if (options.expanded) {
|
|
67
|
+
card.addChild(new Text(theme.fg("dim", theme.bold("Reasoning")), 0, 0));
|
|
68
|
+
card.addChild(new Text(theme.fg("thinkingText", details.thinking), 0, 0));
|
|
69
|
+
card.addChild(new Spacer(1));
|
|
70
|
+
} else {
|
|
71
|
+
card.addChild(new Text(theme.fg("dim", "› reasoning hidden — expand to view"), 0, 0));
|
|
72
|
+
card.addChild(new Spacer(1));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
try {
|
|
52
77
|
card.addChild(
|
|
53
78
|
new Markdown(details.answer, 0, 0, getMarkdownTheme(), {
|
package/src/btw/session.ts
CHANGED
|
@@ -32,10 +32,16 @@ export interface BtwRunOptions {
|
|
|
32
32
|
onTextDelta?: (delta: string, fullText: string) => void;
|
|
33
33
|
onToolStart?: (toolName: string) => void;
|
|
34
34
|
onToolEnd?: (toolName: string) => void;
|
|
35
|
+
/** Called after each completed turn with the running turn count. */
|
|
36
|
+
onTurnEnd?: (turnCount: number) => void;
|
|
37
|
+
/** Called once per assistant message_end with that message's output tokens. */
|
|
38
|
+
onOutputTokens?: (outputTokens: number) => void;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface BtwRunResult {
|
|
38
42
|
text: string;
|
|
43
|
+
/** Reasoning/thinking captured from the child session (empty when none). */
|
|
44
|
+
thinking: string;
|
|
39
45
|
session: AgentSession;
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -140,19 +146,38 @@ export async function runBtw(options: BtwRunOptions): Promise<BtwRunResult> {
|
|
|
140
146
|
options.onSession?.(session);
|
|
141
147
|
|
|
142
148
|
let text = "";
|
|
149
|
+
let thinking = "";
|
|
150
|
+
let turnCount = 0;
|
|
143
151
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
144
|
-
if (event.type === "message_start" && event.message.role === "assistant")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
if (event.type === "message_start" && event.message.role === "assistant") {
|
|
153
|
+
text = "";
|
|
154
|
+
thinking = "";
|
|
155
|
+
}
|
|
156
|
+
if (event.type === "message_update") {
|
|
157
|
+
const ev = event.assistantMessageEvent;
|
|
158
|
+
if (ev.type === "text_delta") {
|
|
159
|
+
text += ev.delta;
|
|
160
|
+
options.onTextDelta?.(ev.delta, text);
|
|
161
|
+
} else if (ev.type === "thinking_delta") {
|
|
162
|
+
thinking += ev.delta;
|
|
163
|
+
}
|
|
148
164
|
}
|
|
149
165
|
if (event.type === "tool_execution_start") options.onToolStart?.(event.toolName);
|
|
150
166
|
if (event.type === "tool_execution_end") options.onToolEnd?.(event.toolName);
|
|
167
|
+
if (event.type === "turn_end") options.onTurnEnd?.(++turnCount);
|
|
168
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
169
|
+
const output = event.message.usage?.output;
|
|
170
|
+
if (typeof output === "number" && output > 0) options.onOutputTokens?.(output);
|
|
171
|
+
}
|
|
151
172
|
});
|
|
152
173
|
|
|
153
174
|
try {
|
|
154
175
|
await session.prompt(question, { source: "extension" });
|
|
155
|
-
return {
|
|
176
|
+
return {
|
|
177
|
+
text: text.trim() || lastAssistantText(session.messages),
|
|
178
|
+
thinking: thinking.trim(),
|
|
179
|
+
session,
|
|
180
|
+
};
|
|
156
181
|
} catch (error) {
|
|
157
182
|
session.dispose();
|
|
158
183
|
throw error;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type BtwWidgetJob,
|
|
4
|
+
hasVisibleJobs,
|
|
5
|
+
renderBtwWidget,
|
|
6
|
+
shouldShowFinished,
|
|
7
|
+
type WidgetTheme,
|
|
8
|
+
} from "./widget.ts";
|
|
9
|
+
|
|
10
|
+
const theme: WidgetTheme = {
|
|
11
|
+
fg: (_color, text) => text,
|
|
12
|
+
bold: (text) => text,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function job(overrides: Partial<BtwWidgetJob>): BtwWidgetJob {
|
|
16
|
+
return {
|
|
17
|
+
id: 1,
|
|
18
|
+
model: "Model",
|
|
19
|
+
status: "running",
|
|
20
|
+
startedAt: 0,
|
|
21
|
+
activeTools: [],
|
|
22
|
+
text: "",
|
|
23
|
+
toolUses: 0,
|
|
24
|
+
turnCount: 0,
|
|
25
|
+
outputTokens: 0,
|
|
26
|
+
contextUsage: null,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const render = (jobs: BtwWidgetJob[], now = 1_000) =>
|
|
32
|
+
renderBtwWidget(jobs, theme, 0, now, 200).join("\n");
|
|
33
|
+
|
|
34
|
+
describe("BTW widget layout", () => {
|
|
35
|
+
test("empty when there are no running or lingering jobs", () => {
|
|
36
|
+
expect(renderBtwWidget([], theme, 0, 1_000, 200)).toEqual([]);
|
|
37
|
+
const old = job({ status: "completed", completedAt: 0 });
|
|
38
|
+
expect(renderBtwWidget([old], theme, 0, 999_999, 200)).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("running heading is hollow and shows the running count", () => {
|
|
42
|
+
const out = render([job({ id: 7, model: "GPT" })]);
|
|
43
|
+
expect(out).toContain("\u25cb BTW (1)");
|
|
44
|
+
expect(out).toContain("#7");
|
|
45
|
+
expect(out).toContain("[GPT]");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("finished jobs linger with a check, then drop after the window", () => {
|
|
49
|
+
const done = job({ status: "completed", completedAt: 1_000, toolUses: 2, turnCount: 1 });
|
|
50
|
+
expect(shouldShowFinished(done, 1_500)).toBe(true);
|
|
51
|
+
expect(shouldShowFinished(done, 10_000)).toBe(false);
|
|
52
|
+
|
|
53
|
+
const out = render([done], 1_500);
|
|
54
|
+
expect(out).toContain("\u2713");
|
|
55
|
+
// All jobs finished → filled heading disk.
|
|
56
|
+
expect(out).toContain("\u25cf BTW (0)");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("errors linger longer and show the message", () => {
|
|
60
|
+
const failed = job({ status: "error", completedAt: 1_000, error: "boom" });
|
|
61
|
+
expect(shouldShowFinished(failed, 6_000)).toBe(true); // past the 5s ok-window
|
|
62
|
+
expect(render([failed], 6_000)).toContain("boom");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("overflow collapses excess rows into a +N more line", () => {
|
|
66
|
+
const many = Array.from({ length: 20 }, (_, i) => job({ id: i + 1 }));
|
|
67
|
+
const lines = renderBtwWidget(many, theme, 0, 1_000, 200);
|
|
68
|
+
expect(lines.length).toBeLessThanOrEqual(12);
|
|
69
|
+
expect(lines.at(-1)).toContain("more");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("hasVisibleJobs mirrors render visibility", () => {
|
|
73
|
+
expect(hasVisibleJobs([job({})], 1_000)).toBe(true);
|
|
74
|
+
expect(hasVisibleJobs([job({ status: "completed", completedAt: 0 })], 999_999)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* widget.ts — pure render for the live BTW above-editor widget.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from index.ts (which owns job lifecycle + Pi wiring) so the
|
|
5
|
+
* layout is unit-testable without a Pi host, and so registerBtw stays thin.
|
|
6
|
+
* Mirrors pix-subagent's AgentWidget shape (spinner, per-job stats, finished
|
|
7
|
+
* linger, overflow) for a consistent look across pix's concurrent-work UIs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
12
|
+
import {
|
|
13
|
+
type ContextUsageLike,
|
|
14
|
+
describeActivity,
|
|
15
|
+
formatContext,
|
|
16
|
+
formatMs,
|
|
17
|
+
formatSpeed,
|
|
18
|
+
formatToolUses,
|
|
19
|
+
formatTurns,
|
|
20
|
+
SPINNER,
|
|
21
|
+
} from "@xynogen/pix-pretty/widget-format";
|
|
22
|
+
|
|
23
|
+
export type BtwJobStatus = "running" | "completed" | "error" | "stopped";
|
|
24
|
+
|
|
25
|
+
/** Snapshot of one job the widget needs to render. */
|
|
26
|
+
export interface BtwWidgetJob {
|
|
27
|
+
id: number;
|
|
28
|
+
model: string;
|
|
29
|
+
status: BtwJobStatus;
|
|
30
|
+
startedAt: number;
|
|
31
|
+
completedAt?: number;
|
|
32
|
+
activeTools: string[];
|
|
33
|
+
text: string;
|
|
34
|
+
toolUses: number;
|
|
35
|
+
turnCount: number;
|
|
36
|
+
outputTokens: number;
|
|
37
|
+
contextUsage: ContextUsageLike | null;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Minimal theme surface the widget uses (matches Pi's Theme). */
|
|
42
|
+
export interface WidgetTheme {
|
|
43
|
+
fg(color: string, text: string): string;
|
|
44
|
+
bold(text: string): string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MAX_WIDGET_LINES = 12;
|
|
48
|
+
const FINISHED_LINGER_MS = 5_000;
|
|
49
|
+
const ERROR_LINGER_MS = 15_000;
|
|
50
|
+
const ERROR_STATUSES = new Set<BtwJobStatus>(["error", "stopped"]);
|
|
51
|
+
|
|
52
|
+
/** True while a finished job should still linger in the widget. */
|
|
53
|
+
export function shouldShowFinished(job: BtwWidgetJob, now: number): boolean {
|
|
54
|
+
if (job.status === "running" || job.completedAt == null) return false;
|
|
55
|
+
const linger = ERROR_STATUSES.has(job.status) ? ERROR_LINGER_MS : FINISHED_LINGER_MS;
|
|
56
|
+
return now - job.completedAt < linger;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Any job worth painting right now (running or lingering finished). */
|
|
60
|
+
export function hasVisibleJobs(jobs: Iterable<BtwWidgetJob>, now: number): boolean {
|
|
61
|
+
for (const job of jobs) {
|
|
62
|
+
if (job.status === "running" || shouldShowFinished(job, now)) return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function statsFor(job: BtwWidgetJob, endMs: number): string {
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
if (job.turnCount > 0) parts.push(formatTurns(job.turnCount));
|
|
70
|
+
if (job.toolUses > 0) parts.push(formatToolUses(job.toolUses));
|
|
71
|
+
const ctx = formatContext(job.contextUsage);
|
|
72
|
+
if (ctx) parts.push(ctx);
|
|
73
|
+
const speed = formatSpeed(job.outputTokens, endMs - job.startedAt);
|
|
74
|
+
if (speed) parts.push(speed);
|
|
75
|
+
parts.push(formatMs(endMs - job.startedAt));
|
|
76
|
+
return parts.join(" \u00b7 ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function finishedLine(job: BtwWidgetJob, theme: WidgetTheme): string {
|
|
80
|
+
const end = job.completedAt ?? Date.now();
|
|
81
|
+
let mark: string;
|
|
82
|
+
let suffix = "";
|
|
83
|
+
if (job.status === "completed") {
|
|
84
|
+
mark = theme.fg("success", "\u2713");
|
|
85
|
+
} else if (job.status === "stopped") {
|
|
86
|
+
mark = theme.fg("dim", "\u25a0");
|
|
87
|
+
suffix = theme.fg("dim", " stopped");
|
|
88
|
+
} else {
|
|
89
|
+
mark = theme.fg("error", "\u2717");
|
|
90
|
+
suffix = theme.fg("error", job.error ? ` ${job.error.slice(0, 60)}` : " error");
|
|
91
|
+
}
|
|
92
|
+
const model = theme.fg("muted", `[${job.model}]`);
|
|
93
|
+
const stats = theme.fg("dim", statsFor(job, end));
|
|
94
|
+
return `${mark} ${theme.fg("dim", `#${job.id}`)} ${model} ${theme.fg("dim", "\u00b7")} ${stats}${suffix}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function runningLine(job: BtwWidgetJob, theme: WidgetTheme, frame: string, now: number): string {
|
|
98
|
+
const model = theme.fg("muted", `[${job.model}]`);
|
|
99
|
+
const stats = theme.fg("dim", statsFor(job, now));
|
|
100
|
+
const activeMap = new Map<string, string>(job.activeTools.map((name, i) => [String(i), name]));
|
|
101
|
+
const activity = theme.fg("dim", describeActivity(activeMap, job.text));
|
|
102
|
+
const dot = theme.fg("dim", "\u00b7");
|
|
103
|
+
return `${theme.fg("accent", frame)} ${theme.fg("toolTitle", theme.bold(`#${job.id}`))} ${model} ${dot} ${stats} ${dot} ${activity}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render the full widget: heading + one line per running job, then lingering
|
|
108
|
+
* finished jobs, with a "+N more" overflow line when the budget is exceeded.
|
|
109
|
+
* Returns [] when nothing is worth showing.
|
|
110
|
+
*/
|
|
111
|
+
export function renderBtwWidget(
|
|
112
|
+
jobs: BtwWidgetJob[],
|
|
113
|
+
theme: WidgetTheme,
|
|
114
|
+
frame: number,
|
|
115
|
+
now: number,
|
|
116
|
+
width: number,
|
|
117
|
+
): string[] {
|
|
118
|
+
const running = jobs.filter((j) => j.status === "running");
|
|
119
|
+
const finished = jobs.filter((j) => j.status !== "running" && shouldShowFinished(j, now));
|
|
120
|
+
if (running.length === 0 && finished.length === 0) return [];
|
|
121
|
+
|
|
122
|
+
const truncate = (line: string) => truncateToWidth(line, width);
|
|
123
|
+
const hasActive = running.length > 0;
|
|
124
|
+
const headingColor = hasActive ? "accent" : "dim";
|
|
125
|
+
const headingIcon = hasActive ? icon("status.pending") : icon("status.done"); // running · all done
|
|
126
|
+
const spinner = SPINNER[frame % SPINNER.length] ?? "";
|
|
127
|
+
|
|
128
|
+
const runningLines = running.map((j) =>
|
|
129
|
+
truncate(`${theme.fg("dim", "\u251c\u2500")} ${runningLine(j, theme, spinner, now)}`),
|
|
130
|
+
);
|
|
131
|
+
const finishedLines = finished.map((j) =>
|
|
132
|
+
truncate(`${theme.fg("dim", "\u251c\u2500")} ${finishedLine(j, theme)}`),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const lines: string[] = [
|
|
136
|
+
truncate(
|
|
137
|
+
`${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, `BTW (${running.length})`)}`,
|
|
138
|
+
),
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const body = [...runningLines, ...finishedLines];
|
|
142
|
+
const maxBody = MAX_WIDGET_LINES - 1;
|
|
143
|
+
if (body.length <= maxBody) {
|
|
144
|
+
lines.push(...body);
|
|
145
|
+
} else {
|
|
146
|
+
const shown = body.slice(0, maxBody - 1);
|
|
147
|
+
const hidden = body.length - shown.length;
|
|
148
|
+
lines.push(...shown);
|
|
149
|
+
lines.push(
|
|
150
|
+
truncate(`${theme.fg("dim", "\u251c\u2500")} ${theme.fg("dim", `+${hidden} more`)}`),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fix the last connector ├─ → └─.
|
|
155
|
+
const last = lines.length - 1;
|
|
156
|
+
if (last > 0) lines[last] = (lines[last] ?? "").replace("\u251c\u2500", "\u2514\u2500");
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
package/src/extension.test.ts
CHANGED