@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-commands",
3
- "version": "0.2.5",
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-runtime": "^0.4.0"
48
+ "@xynogen/pix-pretty": "^1.11.2",
49
+ "@xynogen/pix-runtime": "^0.5.3"
49
50
  }
50
51
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { filterBtwMessages, registerBtw, shortModelName, summarizeLiveText } from "./index.ts";
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("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
- });
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
- 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);
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 defer an empty card flush after agent_end", async () => {
30
- const handlers = new Map<string, (...args: any[]) => unknown>();
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, handler: (...args: any[]) => unknown) {
33
- handlers.set(event, handler);
34
+ on(event: string) {
35
+ events.push(event);
34
36
  },
35
37
  registerCommand() {},
36
- registerMessageRenderer() {},
38
+ registerEntryRenderer() {},
37
39
  } as any;
38
40
  registerBtw(pi);
39
41
 
40
- let idleChecks = 0;
41
- handlers.get("agent_end")?.(
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
- 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;
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
- /** 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
- });
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
- const renderJobs = (theme: Theme): string[] => {
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 running = [...jobs.values()].filter((job) => job.status === "running");
75
- if (running.length === 0) {
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
- latestUi.setStatus(STATUS_KEY, `BTW ${running.length}`);
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
- text.setText(renderJobs(theme).join("\n"));
90
- return text.render(width || tui.terminal.columns);
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
- refreshTimer ??= setInterval(updateUi, 100);
98
- };
99
-
100
- const flushPendingCards = () => {
101
- // Child BTW sessions load this extension too, so their agent_end event can
102
- // reach here with no cards. Do not touch a context that may be invalidated
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, ctx: ExtensionCommandContext) => {
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 its captured pi/ctx/UI after shutdown begins.
108
+ // replaced. Never touch the captured pi/UI after shutdown begins.
133
109
  if (!active) return;
134
110
  updateUi();
135
- pendingCards.push(details);
136
-
137
- if (!ctx.isIdle()) {
138
- ctx.ui.notify(
139
- details.error
140
- ? `BTW #${job.id} failed: ${details.error}`
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
- publish(
209
- job,
210
- {
211
- question,
212
- answer: text || "No answer returned.",
213
- model: job.model,
214
- thinkingLevel: snapshot.thinkingLevel,
215
- durationMs: Date.now() - job.startedAt,
216
- toolUses: job.toolUses,
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
- job,
226
- {
227
- question,
228
- answer: "",
229
- model: job.model,
230
- thinkingLevel: snapshot.thinkingLevel,
231
- durationMs: Date.now() - job.startedAt,
232
- toolUses: job.toolUses,
233
- error: job.error,
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
  }
@@ -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: ((message: unknown, options: unknown, theme: unknown) => unknown) | undefined;
8
+ let renderer: ((entry: unknown, options: unknown, theme: unknown) => unknown) | undefined;
9
9
  const pi = {
10
- registerMessageRenderer(_name: string, fn: typeof renderer) {
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({ details, content: details.answer }, { expanded: false }, theme) as {
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, "registerMessageRenderer">,
24
+ pi: Pick<import("@earendil-works/pi-coding-agent").ExtensionAPI, "registerEntryRenderer">,
22
25
  ): void {
23
- pi.registerMessageRenderer<BtwMessageDetails>("pix-btw-answer", (message, _options, theme) => {
24
- const details = message.details;
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 icon = failed ? theme.fg("error", "✗") : theme.fg("success", "✓");
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(`${icon} ${theme.bold("BTW")} ${theme.fg("dim", `· ${meta.join(" · ")}`)}`, 0, 0),
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(), {
@@ -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") text = "";
145
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
146
- text += event.assistantMessageEvent.delta;
147
- options.onTextDelta?.(event.assistantMessageEvent.delta, text);
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 { text: text.trim() || lastAssistantText(session.messages), session };
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
+ }
@@ -14,7 +14,7 @@ describe("pix-commands registration", () => {
14
14
  registerCommand(name: string) {
15
15
  commands.push(name);
16
16
  },
17
- registerMessageRenderer(name: string) {
17
+ registerEntryRenderer(name: string) {
18
18
  renderers.push(name);
19
19
  },
20
20
  on() {},