@gotgenes/pi-subagents 6.15.0 → 6.16.1

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.
@@ -5,28 +5,11 @@
5
5
  * Uses the callback form of setWidget for themed rendering.
6
6
  */
7
7
 
8
- import { truncateToWidth } from "@earendil-works/pi-tui";
9
8
  import type { AgentManager } from "../agent-manager.js";
10
9
  import { AgentTypeRegistry } from "../agent-types.js";
11
- import type { SubagentType } from "../types.js";
12
- import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
10
  import type { AgentActivityTracker } from "./agent-activity-tracker.js";
14
- import {
15
- describeActivity,
16
- ERROR_STATUSES,
17
- formatMs,
18
- formatSessionTokens,
19
- formatTurns,
20
- getDisplayName,
21
- getPromptModeLabel,
22
- SPINNER,
23
- type Theme,
24
- } from "./display.js";
25
-
26
- // ---- Constants ----
27
-
28
- /** Maximum number of rendered lines before overflow collapse kicks in. */
29
- const MAX_WIDGET_LINES = 12;
11
+ import { ERROR_STATUSES, type Theme } from "./display.js";
12
+ import { renderWidgetLines } from "./widget-renderer.js";
30
13
 
31
14
  // ---- Types ----
32
15
 
@@ -113,177 +96,17 @@ export class AgentWidget {
113
96
  }
114
97
  }
115
98
 
116
- /** Render a finished agent line. */
117
- private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
118
- const name = getDisplayName(a.type, this.registry);
119
- const modeLabel = getPromptModeLabel(a.type, this.registry);
120
- const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
121
-
122
- let icon: string;
123
- let statusText: string;
124
- if (a.status === "completed") {
125
- icon = theme.fg("success", "✓");
126
- statusText = "";
127
- } else if (a.status === "steered") {
128
- icon = theme.fg("warning", "✓");
129
- statusText = theme.fg("warning", " (turn limit)");
130
- } else if (a.status === "stopped") {
131
- icon = theme.fg("dim", "■");
132
- statusText = theme.fg("dim", " stopped");
133
- } else if (a.status === "error") {
134
- icon = theme.fg("error", "✗");
135
- const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
136
- statusText = theme.fg("error", ` error${errMsg}`);
137
- } else {
138
- // aborted
139
- icon = theme.fg("error", "✗");
140
- statusText = theme.fg("warning", " aborted");
141
- }
142
-
143
- const parts: string[] = [];
144
- const activity = this.agentActivity.get(a.id);
145
- if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
146
- if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
147
- parts.push(duration);
148
-
149
- const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
150
- return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
151
- }
152
-
153
- /**
154
- * Render the widget content. Called from the registered widget's render() callback,
155
- * reading live state each time instead of capturing it in a closure.
156
- */
99
+ /** Delegate rendering to the pure widget-renderer module. */
157
100
  private renderWidget(tui: any, theme: Theme): string[] {
158
- const allAgents = this.manager.listAgents();
159
- const running = allAgents.filter(a => a.status === "running");
160
- const queued = allAgents.filter(a => a.status === "queued");
161
- const finished = allAgents.filter(a =>
162
- a.status !== "running" && a.status !== "queued" && a.completedAt
163
- && this.shouldShowFinished(a.id, a.status),
164
- );
165
-
166
- const hasActive = running.length > 0 || queued.length > 0;
167
- const hasFinished = finished.length > 0;
168
-
169
- // Nothing to show — return empty (widget will be unregistered by update())
170
- if (!hasActive && !hasFinished) return [];
171
-
172
- const w = tui.terminal.columns;
173
- const truncate = (line: string) => truncateToWidth(line, w);
174
- const headingColor = hasActive ? "accent" : "dim";
175
- const headingIcon = hasActive ? "●" : "○";
176
- const frame = SPINNER[this.widgetFrame % SPINNER.length];
177
-
178
- // Build sections separately for overflow-aware assembly.
179
- // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
180
-
181
- const finishedLines: string[] = [];
182
- for (const a of finished) {
183
- finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
184
- }
185
-
186
- const runningLines: string[][] = []; // each entry is [header, activity]
187
- for (const a of running) {
188
- const name = getDisplayName(a.type, this.registry);
189
- const modeLabel = getPromptModeLabel(a.type, this.registry);
190
- const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
191
- const elapsed = formatMs(Date.now() - a.startedAt);
192
-
193
- const bg = this.agentActivity.get(a.id);
194
- const toolUses = bg?.toolUses ?? a.toolUses;
195
- const tokens = getLifetimeTotal(bg?.lifetimeUsage);
196
- const contextPercent = getSessionContextPercent(bg?.session);
197
- const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
198
-
199
- const parts: string[] = [];
200
- if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
201
- if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
202
- if (tokenText) parts.push(tokenText);
203
- parts.push(elapsed);
204
- const statsText = parts.join(" · ");
205
-
206
- const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
207
-
208
- runningLines.push([
209
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`),
210
- truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
211
- ]);
212
- }
213
-
214
- const queuedLine = queued.length > 0
215
- ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
216
- : undefined;
217
-
218
- // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
219
- const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
220
- const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
221
-
222
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
223
-
224
- if (totalBody <= maxBody) {
225
- // Everything fits — add all lines and fix up connectors for the last item.
226
- lines.push(...finishedLines);
227
- for (const pair of runningLines) lines.push(...pair);
228
- if (queuedLine) lines.push(queuedLine);
229
-
230
- // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
231
- if (lines.length > 1) {
232
- const last = lines.length - 1;
233
- lines[last] = lines[last].replace("├─", "└─");
234
- // If last item is a running agent activity line, fix indent of that line
235
- // and fix the header line above it.
236
- if (runningLines.length > 0 && !queuedLine) {
237
- // The last two lines are the last running agent's header + activity.
238
- if (last >= 2) {
239
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
240
- lines[last] = lines[last].replace("│ ", " ");
241
- }
242
- }
243
- }
244
- } else {
245
- // Overflow — prioritize: running > queued > finished.
246
- // Reserve 1 line for overflow indicator.
247
- let budget = maxBody - 1;
248
- let hiddenRunning = 0;
249
- let hiddenFinished = 0;
250
-
251
- // 1. Running agents (2 lines each)
252
- for (const pair of runningLines) {
253
- if (budget >= 2) {
254
- lines.push(...pair);
255
- budget -= 2;
256
- } else {
257
- hiddenRunning++;
258
- }
259
- }
260
-
261
- // 2. Queued line
262
- if (queuedLine && budget >= 1) {
263
- lines.push(queuedLine);
264
- budget--;
265
- }
266
-
267
- // 3. Finished agents
268
- for (const fl of finishedLines) {
269
- if (budget >= 1) {
270
- lines.push(fl);
271
- budget--;
272
- } else {
273
- hiddenFinished++;
274
- }
275
- }
276
-
277
- // Overflow summary
278
- const overflowParts: string[] = [];
279
- if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
280
- if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
281
- const overflowText = overflowParts.join(", ");
282
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
283
- );
284
- }
285
-
286
- return lines;
101
+ return renderWidgetLines({
102
+ agents: this.manager.listAgents(),
103
+ activityMap: this.agentActivity,
104
+ registry: this.registry,
105
+ spinnerFrame: this.widgetFrame,
106
+ terminalWidth: tui.terminal.columns,
107
+ theme,
108
+ shouldShowFinished: (id, status) => this.shouldShowFinished(id, status),
109
+ });
287
110
  }
288
111
 
289
112
  /** Force an immediate widget update. */
@@ -155,11 +155,11 @@ export class ConversationViewer implements Component {
155
155
  const duration = formatDuration(this.record.startedAt, this.record.completedAt);
156
156
 
157
157
  const headerParts: string[] = [duration];
158
- const toolUses = this.activity?.toolUses ?? this.record.toolUses;
158
+ const toolUses = this.record.toolUses;
159
159
  if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
160
- const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
160
+ const tokens = getLifetimeTotal(this.record.lifetimeUsage);
161
161
  if (tokens > 0) {
162
- const percent = getSessionContextPercent(this.activity?.session);
162
+ const percent = getSessionContextPercent(this.record.session);
163
163
  headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
164
164
  }
165
165
 
package/src/ui/display.ts CHANGED
@@ -94,7 +94,8 @@ export function formatSessionTokens(
94
94
  annot.push(theme.fg("dim", `↻${compactions}`));
95
95
  }
96
96
  if (annot.length === 0) return tokenStr;
97
- return `${tokenStr} (${annot.join(" · ")})`;
97
+ const sep = theme.fg("dim", " · ");
98
+ return `${tokenStr} ${theme.fg("dim", "(")}${annot.join(sep)}${theme.fg("dim", ")")}`;
98
99
  }
99
100
 
100
101
  /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
@@ -40,7 +40,7 @@ export function subscribeUIObserver(
40
40
  }
41
41
 
42
42
  if (event.type === "tool_execution_end") {
43
- tracker.onToolEnd(event.toolName);
43
+ tracker.onToolDone(event.toolName);
44
44
  onUpdate?.();
45
45
  }
46
46
 
@@ -61,16 +61,5 @@ export function subscribeUIObserver(
61
61
  onUpdate?.();
62
62
  }
63
63
 
64
- if (event.type === "message_end" && event.message?.role === "assistant") {
65
- const u = event.message.usage;
66
- if (u) {
67
- tracker.onUsageUpdate({
68
- input: u.input ?? 0,
69
- output: u.output ?? 0,
70
- cacheWrite: u.cacheWrite ?? 0,
71
- });
72
- onUpdate?.();
73
- }
74
- }
75
64
  });
76
65
  }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * widget-renderer.ts — Pure rendering functions for the agent widget.
3
+ *
4
+ * All functions are stateless: they receive data and return formatted strings.
5
+ * No timers, no SDK types, no side effects. Consumed by AgentWidget.
6
+ */
7
+
8
+ import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import type { AgentConfigLookup } from "../agent-types.js";
10
+ import type { SubagentType } from "../types.js";
11
+ import type { LifetimeUsage, SessionLike } from "../usage.js";
12
+ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
+ import {
14
+ describeActivity,
15
+ formatMs,
16
+ formatSessionTokens,
17
+ formatTurns,
18
+ getDisplayName,
19
+ getPromptModeLabel,
20
+ SPINNER,
21
+ type Theme,
22
+ } from "./display.js";
23
+
24
+ // ── Data interfaces ──────────────────────────────────────────────────────────
25
+
26
+ /** Minimal agent snapshot for rendering — no class methods, no mutation surface. */
27
+ export interface WidgetAgent {
28
+ readonly id: string;
29
+ readonly type: SubagentType;
30
+ readonly status: string;
31
+ readonly description: string;
32
+ readonly toolUses: number;
33
+ readonly startedAt: number;
34
+ readonly completedAt?: number;
35
+ readonly error?: string;
36
+ readonly lifetimeUsage?: Readonly<LifetimeUsage>;
37
+ readonly compactionCount: number;
38
+ }
39
+
40
+ /** Read-only activity snapshot for widget rendering. */
41
+ export interface WidgetActivity {
42
+ readonly activeTools: ReadonlyMap<string, string>;
43
+ readonly responseText: string;
44
+ readonly turnCount: number;
45
+ readonly maxTurns?: number;
46
+ readonly session?: SessionLike;
47
+ }
48
+
49
+ // ── Per-agent rendering ──────────────────────────────────────────────────────
50
+
51
+ /** Render a single finished agent line (no tree connector prefix). */
52
+ export function renderFinishedLine(
53
+ agent: WidgetAgent,
54
+ activity: WidgetActivity | undefined,
55
+ registry: AgentConfigLookup,
56
+ theme: Theme,
57
+ ): string {
58
+ const name = getDisplayName(agent.type, registry);
59
+ const modeLabel = getPromptModeLabel(agent.type, registry);
60
+ const duration = formatMs((agent.completedAt ?? Date.now()) - agent.startedAt);
61
+
62
+ let icon: string;
63
+ let statusText: string;
64
+ if (agent.status === "completed") {
65
+ icon = theme.fg("success", "✓");
66
+ statusText = "";
67
+ } else if (agent.status === "steered") {
68
+ icon = theme.fg("warning", "✓");
69
+ statusText = theme.fg("warning", " (turn limit)");
70
+ } else if (agent.status === "stopped") {
71
+ icon = theme.fg("dim", "■");
72
+ statusText = theme.fg("dim", " stopped");
73
+ } else if (agent.status === "error") {
74
+ icon = theme.fg("error", "✗");
75
+ const errMsg = agent.error ? `: ${agent.error.slice(0, 60)}` : "";
76
+ statusText = theme.fg("error", ` error${errMsg}`);
77
+ } else {
78
+ // aborted
79
+ icon = theme.fg("error", "✗");
80
+ statusText = theme.fg("warning", " aborted");
81
+ }
82
+
83
+ const parts: string[] = [];
84
+ if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
85
+ if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
86
+ parts.push(duration);
87
+
88
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
89
+ return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", agent.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
90
+ }
91
+
92
+ /** Render a single running agent as header + activity line pair (no tree connector prefix). */
93
+ export function renderRunningLines(
94
+ agent: WidgetAgent,
95
+ activity: WidgetActivity | undefined,
96
+ registry: AgentConfigLookup,
97
+ spinnerFrame: number,
98
+ theme: Theme,
99
+ ): [header: string, activity: string] {
100
+ const name = getDisplayName(agent.type, registry);
101
+ const modeLabel = getPromptModeLabel(agent.type, registry);
102
+ const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
103
+ const elapsed = formatMs(Date.now() - agent.startedAt);
104
+
105
+ const tokens = getLifetimeTotal(agent.lifetimeUsage);
106
+ const contextPercent = activity?.session ? getSessionContextPercent(activity.session) : null;
107
+ const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, agent.compactionCount) : "";
108
+
109
+ const parts: string[] = [];
110
+ if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
111
+ if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
112
+ if (tokenText) parts.push(tokenText);
113
+ parts.push(elapsed);
114
+ const statsText = parts.join(" · ");
115
+
116
+ const frame = SPINNER[spinnerFrame % SPINNER.length];
117
+ const activityText = activity ? describeActivity(activity.activeTools, activity.responseText) : "thinking\u2026";
118
+
119
+ const header = `${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", agent.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`;
120
+ const activityLine = theme.fg("dim", ` \u23BF ${activityText}`);
121
+
122
+ return [header, activityLine];
123
+ }
124
+
125
+ // ── Full widget rendering ────────────────────────────────────────────────────
126
+
127
+ /** Maximum number of rendered lines before overflow collapse kicks in. */
128
+ const MAX_WIDGET_LINES = 12;
129
+
130
+ /** Pure rendering of the widget body. Returns lines to display. */
131
+ export function renderWidgetLines(params: {
132
+ agents: readonly WidgetAgent[];
133
+ activityMap: ReadonlyMap<string, WidgetActivity>;
134
+ registry: AgentConfigLookup;
135
+ spinnerFrame: number;
136
+ terminalWidth: number;
137
+ theme: Theme;
138
+ shouldShowFinished: (agentId: string, status: string) => boolean;
139
+ }): string[] {
140
+ const { agents, activityMap, registry, spinnerFrame, terminalWidth, theme, shouldShowFinished } = params;
141
+
142
+ const running = agents.filter(a => a.status === "running");
143
+ const queued = agents.filter(a => a.status === "queued");
144
+ const finished = agents.filter(a =>
145
+ a.status !== "running" && a.status !== "queued" && a.completedAt
146
+ && shouldShowFinished(a.id, a.status),
147
+ );
148
+
149
+ const hasActive = running.length > 0 || queued.length > 0;
150
+ const hasFinished = finished.length > 0;
151
+
152
+ if (!hasActive && !hasFinished) return [];
153
+
154
+ const truncate = (line: string) => truncateToWidth(line, terminalWidth);
155
+ const headingColor = hasActive ? "accent" : "dim";
156
+ const headingIcon = hasActive ? "\u25CF" : "\u25CB";
157
+
158
+ // Build sections separately for overflow-aware assembly.
159
+ const finishedLines: string[] = [];
160
+ for (const a of finished) {
161
+ finishedLines.push(truncate(theme.fg("dim", "\u251C\u2500") + " " + renderFinishedLine(a, activityMap.get(a.id), registry, theme)));
162
+ }
163
+
164
+ const runningLines: [string, string][] = [];
165
+ for (const a of running) {
166
+ const [header, act] = renderRunningLines(a, activityMap.get(a.id), registry, spinnerFrame, theme);
167
+ runningLines.push([
168
+ truncate(theme.fg("dim", "\u251C\u2500") + ` ${header}`),
169
+ truncate(theme.fg("dim", "\u2502 ") + act),
170
+ ]);
171
+ }
172
+
173
+ const queuedLine = queued.length > 0
174
+ ? truncate(theme.fg("dim", "\u251C\u2500") + ` ${theme.fg("muted", "\u25E6")} ${theme.fg("dim", `${queued.length} queued`)}`)
175
+ : undefined;
176
+
177
+ // Assemble with overflow cap (heading takes 1 line).
178
+ const maxBody = MAX_WIDGET_LINES - 1;
179
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
180
+
181
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
182
+
183
+ if (totalBody <= maxBody) {
184
+ lines.push(...finishedLines);
185
+ for (const pair of runningLines) lines.push(...pair);
186
+ if (queuedLine) lines.push(queuedLine);
187
+
188
+ // Fix last connector: swap \u251C\u2500 \u2192 \u2514\u2500 and \u2502 \u2192 space for activity lines.
189
+ if (lines.length > 1) {
190
+ const last = lines.length - 1;
191
+ lines[last] = lines[last].replace("\u251C\u2500", "\u2514\u2500");
192
+ if (runningLines.length > 0 && !queuedLine) {
193
+ if (last >= 2) {
194
+ lines[last - 1] = lines[last - 1].replace("\u251C\u2500", "\u2514\u2500");
195
+ lines[last] = lines[last].replace("\u2502 ", " ");
196
+ }
197
+ }
198
+ }
199
+ } else {
200
+ // Overflow — prioritize: running > queued > finished.
201
+ let budget = maxBody - 1;
202
+ let hiddenRunning = 0;
203
+ let hiddenFinished = 0;
204
+
205
+ for (const pair of runningLines) {
206
+ if (budget >= 2) {
207
+ lines.push(...pair);
208
+ budget -= 2;
209
+ } else {
210
+ hiddenRunning++;
211
+ }
212
+ }
213
+
214
+ if (queuedLine && budget >= 1) {
215
+ lines.push(queuedLine);
216
+ budget--;
217
+ }
218
+
219
+ for (const fl of finishedLines) {
220
+ if (budget >= 1) {
221
+ lines.push(fl);
222
+ budget--;
223
+ } else {
224
+ hiddenFinished++;
225
+ }
226
+ }
227
+
228
+ const overflowParts: string[] = [];
229
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
230
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
231
+ const overflowText = overflowParts.join(", ");
232
+ lines.push(truncate(theme.fg("dim", "\u2514\u2500") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`));
233
+ }
234
+
235
+ return lines;
236
+ }