pi-subagents-lite 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 +82 -0
- package/package.json +52 -0
- package/src/agent-discovery.ts +412 -0
- package/src/agent-manager.ts +545 -0
- package/src/agent-runner.ts +435 -0
- package/src/agent-types.ts +140 -0
- package/src/context.ts +13 -0
- package/src/default-agents.ts +67 -0
- package/src/index.ts +1356 -0
- package/src/model-precedence.ts +71 -0
- package/src/model-selector.ts +271 -0
- package/src/output-file.ts +176 -0
- package/src/prompts.ts +61 -0
- package/src/result-viewer.ts +218 -0
- package/src/skill-loader.ts +104 -0
- package/src/types.ts +96 -0
- package/src/ui/agent-widget.ts +666 -0
- package/src/usage.ts +39 -0
- package/src/utils.ts +40 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
|
|
3
|
+
*
|
|
4
|
+
* Ported from upstream pi-subagents. Adaptations:
|
|
5
|
+
* - buildInvocationTags removes inheritContext and isolation: "worktree" (fields
|
|
6
|
+
* we cut from AgentInvocation)
|
|
7
|
+
* - Import paths use relative imports within our extension
|
|
8
|
+
* - addUsage/getLifetimeTotal/getSessionContextPercent imported from ../usage.js
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
13
|
+
import { getConfig } from "../agent-types.js";
|
|
14
|
+
import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
|
|
15
|
+
import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
|
|
16
|
+
|
|
17
|
+
// ---- Constants ----
|
|
18
|
+
|
|
19
|
+
/** Maximum number of rendered lines before overflow collapse kicks in. */
|
|
20
|
+
const MAX_WIDGET_LINES = 12;
|
|
21
|
+
|
|
22
|
+
/** Braille spinner frames for animated running indicator. */
|
|
23
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
|
+
|
|
25
|
+
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
|
|
26
|
+
export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
|
|
27
|
+
|
|
28
|
+
/** Tree-drawing connectors used in the widget header/continuation lines. */
|
|
29
|
+
const BRANCH = "├─";
|
|
30
|
+
const CORNER = "└─";
|
|
31
|
+
const VLINE = "│";
|
|
32
|
+
|
|
33
|
+
/** Widget key used with setWidget(). */
|
|
34
|
+
const WIDGET_KEY = "agents";
|
|
35
|
+
|
|
36
|
+
/** Status bar key used with setStatus(). */
|
|
37
|
+
const STATUS_KEY = "subagents";
|
|
38
|
+
|
|
39
|
+
/** Widget refresh interval in milliseconds. */
|
|
40
|
+
const WIDGET_REFRESH_INTERVAL = 80;
|
|
41
|
+
|
|
42
|
+
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
|
|
43
|
+
const ERROR_LINGER_TURNS = 2;
|
|
44
|
+
|
|
45
|
+
/** Default activity text when no tools are active and no response text. */
|
|
46
|
+
const THINKING_TEXT = "thinking…";
|
|
47
|
+
|
|
48
|
+
/** Tool name → human-readable action for activity descriptions. */
|
|
49
|
+
const TOOL_DISPLAY: Record<string, string> = {
|
|
50
|
+
read: "reading",
|
|
51
|
+
bash: "running command",
|
|
52
|
+
edit: "editing",
|
|
53
|
+
write: "writing",
|
|
54
|
+
grep: "searching",
|
|
55
|
+
find: "finding files",
|
|
56
|
+
ls: "listing",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---- Types ----
|
|
60
|
+
|
|
61
|
+
export type Theme = {
|
|
62
|
+
fg(color: string, text: string): string;
|
|
63
|
+
bold(text: string): string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type UICtx = {
|
|
67
|
+
setStatus(key: string, text: string | undefined): void;
|
|
68
|
+
setWidget(
|
|
69
|
+
key: string,
|
|
70
|
+
content: undefined | ((tui: TUI, theme: Theme) => { render(): string[]; invalidate(): void }),
|
|
71
|
+
options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
72
|
+
): void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Minimal TUI shape used by the widget. */
|
|
76
|
+
interface TUI {
|
|
77
|
+
terminal: { columns: number };
|
|
78
|
+
requestRender?(): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A visual block: one header line plus zero or more continuation lines. */
|
|
82
|
+
interface RenderBlock {
|
|
83
|
+
header: string;
|
|
84
|
+
continuations: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Per-agent live activity state. */
|
|
88
|
+
export interface AgentActivity {
|
|
89
|
+
activeTools: Map<string, string>;
|
|
90
|
+
toolUses: number;
|
|
91
|
+
responseText: string;
|
|
92
|
+
session?: SessionLike;
|
|
93
|
+
/** Current turn count. */
|
|
94
|
+
turnCount: number;
|
|
95
|
+
/** Effective max turns for this agent (undefined = unlimited). */
|
|
96
|
+
maxTurns?: number;
|
|
97
|
+
/** Lifetime usage breakdown — see LifetimeUsage docs. */
|
|
98
|
+
lifetimeUsage: LifetimeUsage;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
// ---- Formatting helpers ----
|
|
104
|
+
|
|
105
|
+
/** Format a token count compactly: "33.8k", "1.2M". */
|
|
106
|
+
export function formatTokens(count: number): string {
|
|
107
|
+
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
108
|
+
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
109
|
+
return `${count}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Token count with optional context-fill % and compaction-count annotations.
|
|
114
|
+
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
115
|
+
* Compaction count rendered as `↻ N` in dim.
|
|
116
|
+
*
|
|
117
|
+
* "12.3k" — no annotations
|
|
118
|
+
* "12.3k(45%)" — percent only
|
|
119
|
+
* "12.3k(↻ 2)" — compactions only (e.g. right after compact)
|
|
120
|
+
* "12.3k(45%·↻ 2)" — both
|
|
121
|
+
*/
|
|
122
|
+
export function formatSessionTokens(
|
|
123
|
+
tokens: number,
|
|
124
|
+
percent: number | null,
|
|
125
|
+
theme: Theme,
|
|
126
|
+
compactions = 0,
|
|
127
|
+
): string {
|
|
128
|
+
const tokenStr = formatTokens(tokens);
|
|
129
|
+
const annot: string[] = [];
|
|
130
|
+
if (percent !== null) {
|
|
131
|
+
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
|
|
132
|
+
annot.push(theme.fg(color, `${Math.round(percent)}%`));
|
|
133
|
+
}
|
|
134
|
+
if (compactions > 0) {
|
|
135
|
+
annot.push(theme.fg("dim", `↻ ${compactions}`));
|
|
136
|
+
}
|
|
137
|
+
if (annot.length === 0) return tokenStr;
|
|
138
|
+
// Include closing paren in the last annotation's color span to prevent
|
|
139
|
+
// ANSI reset from leaving `)` in default color when wrapped in outer dim.
|
|
140
|
+
const lastIdx = annot.length - 1;
|
|
141
|
+
annot[lastIdx] += ")";
|
|
142
|
+
return `${tokenStr}(${annot.join("·")}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Format turn count with optional max limit: "5≤30⟳" or "5⟳". */
|
|
146
|
+
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
|
147
|
+
return maxTurns != null ? `${turnCount}≤${maxTurns}⟳ ` : `${turnCount}⟳ `;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Format milliseconds as human-readable duration. */
|
|
151
|
+
export function formatMs(ms: number): string {
|
|
152
|
+
return Number.isFinite(ms) ? `${(ms / 1000).toFixed(1)}s` : "0.0s";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Get display name for any agent type (built-in or custom). */
|
|
156
|
+
export function getDisplayName(type: SubagentType): string {
|
|
157
|
+
return getConfig(type).displayName;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build invocation tags from the invocation record.
|
|
162
|
+
* Adapted from upstream: removed inheritContext and isolation: "worktree" checks
|
|
163
|
+
* because our AgentInvocation doesn't have those fields.
|
|
164
|
+
*/
|
|
165
|
+
export function buildInvocationTags(
|
|
166
|
+
invocation: AgentInvocation | undefined,
|
|
167
|
+
): { modelName?: string; tags: string[] } {
|
|
168
|
+
const tags: string[] = [];
|
|
169
|
+
if (!invocation) return { tags };
|
|
170
|
+
if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
|
|
171
|
+
if (invocation.isolated) tags.push("isolated");
|
|
172
|
+
if (invocation.runInBackground) tags.push("background");
|
|
173
|
+
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
|
|
174
|
+
return { modelName: invocation.modelName, tags };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Wrap a stats line in dim ANSI codes, re-applying dim after any inner
|
|
179
|
+
* ANSI reset sequences (e.g. from formatSessionTokens annotations).
|
|
180
|
+
*/
|
|
181
|
+
function wrapInDim(theme: Theme, text: string): string {
|
|
182
|
+
const dimSample = theme.fg("dim", "x");
|
|
183
|
+
const xIdx = dimSample.indexOf("x");
|
|
184
|
+
const dimOn = dimSample.slice(0, xIdx);
|
|
185
|
+
const dimOff = dimSample.slice(xIdx + 1);
|
|
186
|
+
return dimOn + text.replaceAll(dimOff, dimOff + dimOn) + dimOff;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Truncate text to a single line, max `len` chars. */
|
|
190
|
+
function truncateLine(text: string, len = 60): string {
|
|
191
|
+
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
|
|
192
|
+
if (line.length <= len) return line;
|
|
193
|
+
return line.slice(0, len) + "…";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
197
|
+
export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
|
|
198
|
+
if (activeTools.size > 0) {
|
|
199
|
+
const groups = new Map<string, number>();
|
|
200
|
+
for (const toolName of activeTools.values()) {
|
|
201
|
+
const action = TOOL_DISPLAY[toolName] ?? toolName;
|
|
202
|
+
groups.set(action, (groups.get(action) ?? 0) + 1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const parts: string[] = [];
|
|
206
|
+
for (const [action, count] of groups) {
|
|
207
|
+
if (count > 1) {
|
|
208
|
+
parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
|
|
209
|
+
} else {
|
|
210
|
+
parts.push(action);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return parts.join(", ") + "…";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// No tools active — show truncated response text if available
|
|
217
|
+
if (responseText && responseText.trim().length > 0) {
|
|
218
|
+
return truncateLine(responseText);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return THINKING_TEXT;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- Widget manager ----
|
|
225
|
+
|
|
226
|
+
export class AgentWidget {
|
|
227
|
+
private uiCtx: UICtx | undefined;
|
|
228
|
+
private widgetFrame = 0;
|
|
229
|
+
private widgetInterval: ReturnType<typeof setInterval> | undefined;
|
|
230
|
+
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
|
|
231
|
+
private finishedTurnAge = new Map<string, number>();
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
/** Whether the widget callback is currently registered with the TUI. */
|
|
235
|
+
private widgetRegistered = false;
|
|
236
|
+
/** Cached TUI reference from widget factory callback, used for requestRender(). */
|
|
237
|
+
private tui: TUI | undefined;
|
|
238
|
+
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
239
|
+
private lastStatusText: string | undefined;
|
|
240
|
+
|
|
241
|
+
constructor(
|
|
242
|
+
private manager: AgentManager,
|
|
243
|
+
private agentActivity: Map<string, AgentActivity>,
|
|
244
|
+
) {}
|
|
245
|
+
|
|
246
|
+
/** Set the UI context (grabbed from first tool execution). */
|
|
247
|
+
setUICtx(ctx: UICtx) {
|
|
248
|
+
if (ctx !== this.uiCtx) {
|
|
249
|
+
// UICtx changed — the widget registered on the old context is gone.
|
|
250
|
+
// Force re-registration on next update().
|
|
251
|
+
this.uiCtx = ctx;
|
|
252
|
+
this.widgetRegistered = false;
|
|
253
|
+
this.tui = undefined;
|
|
254
|
+
this.lastStatusText = undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Called on each new turn (tool_execution_start).
|
|
260
|
+
* Ages finished agents and clears those that have lingered long enough.
|
|
261
|
+
*/
|
|
262
|
+
onTurnStart() {
|
|
263
|
+
// Age all finished agents
|
|
264
|
+
for (const [id, age] of this.finishedTurnAge) {
|
|
265
|
+
this.finishedTurnAge.set(id, age + 1);
|
|
266
|
+
}
|
|
267
|
+
// Trigger a widget refresh (will filter out expired agents)
|
|
268
|
+
this.update();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Ensure the widget update timer is running. */
|
|
272
|
+
ensureTimer() {
|
|
273
|
+
if (!this.widgetInterval) {
|
|
274
|
+
this.widgetInterval = setInterval(() => this.update(), WIDGET_REFRESH_INTERVAL);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Categorize all agents into running, queued, and visible finished groups. */
|
|
279
|
+
private categorizeAgents() {
|
|
280
|
+
const allAgents = this.manager.listAgents();
|
|
281
|
+
const running: AgentRecord[] = [];
|
|
282
|
+
const queued: AgentRecord[] = [];
|
|
283
|
+
const finished: AgentRecord[] = [];
|
|
284
|
+
for (const a of allAgents) {
|
|
285
|
+
if (a.status === "running") running.push(a);
|
|
286
|
+
else if (a.status === "queued") queued.push(a);
|
|
287
|
+
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) finished.push(a);
|
|
288
|
+
}
|
|
289
|
+
return { running, queued, finished };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Check if a finished agent should still be shown in the widget. */
|
|
293
|
+
private shouldShowFinished(agentId: string, status: string): boolean {
|
|
294
|
+
const age = this.finishedTurnAge.get(agentId) ?? 0;
|
|
295
|
+
const maxAge = ERROR_STATUSES.has(status) ? ERROR_LINGER_TURNS : 1;
|
|
296
|
+
return age < maxAge;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Record an agent as finished (call when agent completes). */
|
|
300
|
+
markFinished(agentId: string) {
|
|
301
|
+
if (!this.finishedTurnAge.has(agentId)) {
|
|
302
|
+
this.finishedTurnAge.set(agentId, 0);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Build the icon and status suffix for a finished agent. */
|
|
307
|
+
private finishedIconAndStatus(status: string, error?: string, theme?: Theme): { icon: string; statusText: string } {
|
|
308
|
+
if (!theme) return { icon: "?", statusText: "" }; // should not happen
|
|
309
|
+
switch (status) {
|
|
310
|
+
case "completed":
|
|
311
|
+
return { icon: theme.fg("success", "✓"), statusText: "" };
|
|
312
|
+
case "steered":
|
|
313
|
+
return { icon: theme.fg("warning", "✓"), statusText: theme.fg("warning", " (turn limit)") };
|
|
314
|
+
case "stopped":
|
|
315
|
+
return { icon: theme.fg("dim", "■"), statusText: theme.fg("dim", " stopped") };
|
|
316
|
+
case "error": {
|
|
317
|
+
const errMsg = error ? `: ${error.slice(0, 60)}` : "";
|
|
318
|
+
return { icon: theme.fg("error", "✗"), statusText: theme.fg("error", ` error${errMsg}`) };
|
|
319
|
+
}
|
|
320
|
+
default:
|
|
321
|
+
// aborted
|
|
322
|
+
return { icon: theme.fg("error", "✗"), statusText: theme.fg("warning", " aborted") };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Render a finished agent line. */
|
|
327
|
+
private renderFinishedLine(a: {
|
|
328
|
+
id: string; type: SubagentType; status: string; description: string;
|
|
329
|
+
toolUses: number; startedAt: number; completedAt?: number; error?: string;
|
|
330
|
+
compactionCount: number; lifetimeUsage: LifetimeUsage;
|
|
331
|
+
turnCount?: number; maxTurns?: number; session?: SessionLike;
|
|
332
|
+
outputFile?: string;
|
|
333
|
+
}, theme: Theme): string {
|
|
334
|
+
const name = getDisplayName(a.type);
|
|
335
|
+
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
|
|
336
|
+
const { icon, statusText } = this.finishedIconAndStatus(a.status, a.error, theme);
|
|
337
|
+
|
|
338
|
+
const parts: string[] = [];
|
|
339
|
+
const activity = this.agentActivity.get(a.id);
|
|
340
|
+
|
|
341
|
+
// Tool uses
|
|
342
|
+
if (a.toolUses > 0) parts.push(`${a.toolUses}🛠 `);
|
|
343
|
+
|
|
344
|
+
// Turn count — prefer activity (live), fall back to record (after cleanup)
|
|
345
|
+
if (activity) {
|
|
346
|
+
parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
347
|
+
} else if (a.turnCount != null) {
|
|
348
|
+
parts.push(formatTurns(a.turnCount, a.maxTurns));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Token usage with context % — read from record if activity was cleaned up
|
|
352
|
+
const tokens = getLifetimeTotal(activity?.lifetimeUsage ?? a.lifetimeUsage);
|
|
353
|
+
if (tokens > 0) {
|
|
354
|
+
const contextPct = getSessionContextPercent(activity?.session ?? a.session);
|
|
355
|
+
const tokenText = formatSessionTokens(tokens, contextPct, theme, a.compactionCount);
|
|
356
|
+
parts.push(tokenText);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
parts.push(duration);
|
|
360
|
+
|
|
361
|
+
// Wrap stats in dim, re-applying after any ANSI reset from formatSessionTokens.
|
|
362
|
+
const statsLine = parts.join("·");
|
|
363
|
+
return `${icon} ${theme.fg("dim", name)} ${theme.fg("dim", a.description)} ${wrapInDim(theme, statsLine)}${statusText}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Build the stats line (toolUses · turns · tokens · elapsed) for a running agent. */
|
|
367
|
+
private buildStatsLine(
|
|
368
|
+
agent: { toolUses: number; compactionCount: number; startedAt: number },
|
|
369
|
+
activity: AgentActivity | undefined,
|
|
370
|
+
theme: Theme,
|
|
371
|
+
): string {
|
|
372
|
+
const toolUses = activity?.toolUses ?? agent.toolUses;
|
|
373
|
+
const elapsed = formatMs(Date.now() - agent.startedAt);
|
|
374
|
+
|
|
375
|
+
const tokens = getLifetimeTotal(activity?.lifetimeUsage);
|
|
376
|
+
const contextPercent = getSessionContextPercent(activity?.session);
|
|
377
|
+
const tokenText = tokens > 0
|
|
378
|
+
? formatSessionTokens(tokens, contextPercent, theme, agent.compactionCount)
|
|
379
|
+
: "";
|
|
380
|
+
|
|
381
|
+
const parts: string[] = [];
|
|
382
|
+
if (toolUses > 0) parts.push(`${toolUses}🛠 `);
|
|
383
|
+
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
384
|
+
if (tokenText) parts.push(tokenText);
|
|
385
|
+
parts.push(elapsed);
|
|
386
|
+
return parts.join("·");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Build RenderBlocks for finished (completed/errored) agents. */
|
|
390
|
+
private buildFinishedBlocks(
|
|
391
|
+
finished: AgentRecord[],
|
|
392
|
+
theme: Theme,
|
|
393
|
+
w: number,
|
|
394
|
+
): RenderBlock[] {
|
|
395
|
+
const truncate = (line: string) => truncateToWidth(line, w);
|
|
396
|
+
const blocks: RenderBlock[] = [];
|
|
397
|
+
for (const a of finished) {
|
|
398
|
+
blocks.push({
|
|
399
|
+
header: truncate(theme.fg("dim", BRANCH) + " " + this.renderFinishedLine(a, theme)),
|
|
400
|
+
continuations: a.outputFile
|
|
401
|
+
? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
|
|
402
|
+
: [],
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
return blocks;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Build RenderBlocks for running agents. */
|
|
409
|
+
private buildRunningBlocks(
|
|
410
|
+
running: AgentRecord[],
|
|
411
|
+
theme: Theme,
|
|
412
|
+
w: number,
|
|
413
|
+
frame: string,
|
|
414
|
+
): RenderBlock[] {
|
|
415
|
+
const truncate = (line: string) => truncateToWidth(line, w);
|
|
416
|
+
const blocks: RenderBlock[] = [];
|
|
417
|
+
for (const a of running) {
|
|
418
|
+
const name = getDisplayName(a.type);
|
|
419
|
+
const bg = this.agentActivity.get(a.id);
|
|
420
|
+
const statsLine = this.buildStatsLine(a, bg, theme);
|
|
421
|
+
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : THINKING_TEXT;
|
|
422
|
+
|
|
423
|
+
blocks.push({
|
|
424
|
+
header: truncate(`${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.description} ${statsLine}`),
|
|
425
|
+
continuations: [
|
|
426
|
+
...(a.outputFile
|
|
427
|
+
? [truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} tail -f ${a.outputFile}`))]
|
|
428
|
+
: []),
|
|
429
|
+
truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)),
|
|
430
|
+
],
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return blocks;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Build a single RenderBlock for queued agents, or undefined if none. */
|
|
437
|
+
private buildQueuedBlock(
|
|
438
|
+
queued: AgentRecord[],
|
|
439
|
+
theme: Theme,
|
|
440
|
+
w: number,
|
|
441
|
+
): RenderBlock | undefined {
|
|
442
|
+
if (queued.length === 0) return undefined;
|
|
443
|
+
const truncate = (line: string) => truncateToWidth(line, w);
|
|
444
|
+
return {
|
|
445
|
+
header: truncate(theme.fg("dim", BRANCH) + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`),
|
|
446
|
+
continuations: [],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Render the widget content. Called from the registered widget's render() callback,
|
|
452
|
+
* reading live state each time instead of capturing it in a closure.
|
|
453
|
+
*
|
|
454
|
+
* Strategy: build a list of RenderBlocks with placeholder connectors (BRANCH / VLINE),
|
|
455
|
+
* determine which blocks are visible (overflow logic), then render with correct
|
|
456
|
+
* connectors in a single pass. Last visible block gets CORNER + spaces, all others
|
|
457
|
+
* keep BRANCH + VLINE.
|
|
458
|
+
*/
|
|
459
|
+
private renderWidget(tui: TUI, theme: Theme): string[] {
|
|
460
|
+
const { running, queued, finished } = this.categorizeAgents();
|
|
461
|
+
|
|
462
|
+
const hasActive = running.length > 0 || queued.length > 0;
|
|
463
|
+
const hasFinished = finished.length > 0;
|
|
464
|
+
|
|
465
|
+
// Nothing to show — return empty (widget will be unregistered by update())
|
|
466
|
+
if (!hasActive && !hasFinished) return [];
|
|
467
|
+
|
|
468
|
+
const w = tui.terminal.columns;
|
|
469
|
+
const truncate = (line: string) => truncateToWidth(line, w);
|
|
470
|
+
const headingColor = hasActive ? "accent" : "dim";
|
|
471
|
+
const headingIcon = hasActive ? "●" : "○";
|
|
472
|
+
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
473
|
+
|
|
474
|
+
// ---- Build blocks with placeholder connectors (BRANCH for headers, VLINE for continuations) ----
|
|
475
|
+
// Separate arrays so overflow logic can apply priority: running > queued > finished.
|
|
476
|
+
const finishedBlocks = this.buildFinishedBlocks(finished, theme, w);
|
|
477
|
+
const runningBlocks = this.buildRunningBlocks(running, theme, w, frame);
|
|
478
|
+
const queuedBlock = this.buildQueuedBlock(queued, theme, w);
|
|
479
|
+
|
|
480
|
+
// All blocks in display order: finished → running → queued.
|
|
481
|
+
const blocks: RenderBlock[] = [
|
|
482
|
+
...finishedBlocks,
|
|
483
|
+
...runningBlocks,
|
|
484
|
+
...(queuedBlock ? [queuedBlock] : []),
|
|
485
|
+
];
|
|
486
|
+
|
|
487
|
+
// ---- Overflow logic (works with blocks, not lines) ----
|
|
488
|
+
|
|
489
|
+
const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
|
|
490
|
+
const totalBody = blocks.reduce((sum, b) => sum + 1 + b.continuations.length, 0);
|
|
491
|
+
|
|
492
|
+
const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
|
|
493
|
+
|
|
494
|
+
if (totalBody <= maxBody) {
|
|
495
|
+
// Everything fits — render all blocks with correct connectors.
|
|
496
|
+
lines.push(...this.renderBlocks(blocks));
|
|
497
|
+
} else {
|
|
498
|
+
const { visible, overflowLine } = this.applyOverflow(
|
|
499
|
+
runningBlocks, queuedBlock, finishedBlocks, maxBody, theme,
|
|
500
|
+
);
|
|
501
|
+
lines.push(...this.renderBlocks(visible));
|
|
502
|
+
if (overflowLine) lines.push(truncate(overflowLine));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return lines;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Render a single block: replace placeholder BRANCH→CORNER and VLINE→space on the last block.
|
|
510
|
+
*/
|
|
511
|
+
private renderBlock(block: RenderBlock, isLast: boolean): string[] {
|
|
512
|
+
const header = isLast ? block.header.replace(BRANCH, CORNER) : block.header;
|
|
513
|
+
const continuations = isLast
|
|
514
|
+
? block.continuations.map(c => c.replace(VLINE, " "))
|
|
515
|
+
: block.continuations;
|
|
516
|
+
return [header, ...continuations];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Render a list of blocks with correct last-block connectors. */
|
|
520
|
+
private renderBlocks(blocks: RenderBlock[]): string[] {
|
|
521
|
+
return blocks.flatMap((b, i) => this.renderBlock(b, i === blocks.length - 1));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Overflow logic — prioritize running > queued > finished.
|
|
526
|
+
* Reserve 1 line for the overflow summary indicator.
|
|
527
|
+
*/
|
|
528
|
+
private applyOverflow(
|
|
529
|
+
runningBlocks: RenderBlock[],
|
|
530
|
+
queuedBlock: RenderBlock | undefined,
|
|
531
|
+
finishedBlocks: RenderBlock[],
|
|
532
|
+
maxBody: number,
|
|
533
|
+
theme: Theme,
|
|
534
|
+
): { visible: RenderBlock[]; overflowLine?: string } {
|
|
535
|
+
let budget = maxBody - 1;
|
|
536
|
+
let hiddenRunning = 0;
|
|
537
|
+
let hiddenFinished = 0;
|
|
538
|
+
const visible: RenderBlock[] = [];
|
|
539
|
+
|
|
540
|
+
// 1. Running blocks (highest priority)
|
|
541
|
+
for (const b of runningBlocks) {
|
|
542
|
+
const height = 1 + b.continuations.length;
|
|
543
|
+
if (budget >= height) {
|
|
544
|
+
visible.push(b);
|
|
545
|
+
budget -= height;
|
|
546
|
+
} else {
|
|
547
|
+
hiddenRunning++;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// 2. Queued block
|
|
552
|
+
if (queuedBlock && budget >= 1) {
|
|
553
|
+
visible.push(queuedBlock);
|
|
554
|
+
budget--;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 3. Finished blocks (lowest priority)
|
|
558
|
+
for (const b of finishedBlocks) {
|
|
559
|
+
if (budget >= 1) {
|
|
560
|
+
visible.push(b);
|
|
561
|
+
budget--;
|
|
562
|
+
} else {
|
|
563
|
+
hiddenFinished++;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Overflow summary line
|
|
568
|
+
const overflowLine = hiddenRunning + hiddenFinished > 0
|
|
569
|
+
? (() => {
|
|
570
|
+
const parts: string[] = [];
|
|
571
|
+
if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
|
|
572
|
+
if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
|
|
573
|
+
return theme.fg("dim", CORNER) + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${parts.join(", ")})`)}`;
|
|
574
|
+
})()
|
|
575
|
+
: undefined;
|
|
576
|
+
|
|
577
|
+
return { visible, overflowLine };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Clear widget, status bar, timer, and stale finished-turn-age entries. */
|
|
581
|
+
private clearWidget() {
|
|
582
|
+
if (this.widgetRegistered) {
|
|
583
|
+
this.uiCtx?.setWidget(WIDGET_KEY, undefined);
|
|
584
|
+
this.widgetRegistered = false;
|
|
585
|
+
this.tui = undefined;
|
|
586
|
+
}
|
|
587
|
+
if (this.lastStatusText !== undefined) {
|
|
588
|
+
this.uiCtx?.setStatus(STATUS_KEY, undefined);
|
|
589
|
+
this.lastStatusText = undefined;
|
|
590
|
+
}
|
|
591
|
+
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
592
|
+
// Clean up stale entries
|
|
593
|
+
const allAgents = this.manager.listAgents();
|
|
594
|
+
for (const [id] of this.finishedTurnAge) {
|
|
595
|
+
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Update the status bar text, only if it changed. */
|
|
600
|
+
private updateStatusBar(runningCount: number, queuedCount: number) {
|
|
601
|
+
const statusParts: string[] = [];
|
|
602
|
+
if (runningCount > 0) statusParts.push(`${runningCount} running`);
|
|
603
|
+
if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
|
|
604
|
+
const total = runningCount + queuedCount;
|
|
605
|
+
const newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
|
|
606
|
+
if (newStatusText !== this.lastStatusText) {
|
|
607
|
+
this.uiCtx?.setStatus(STATUS_KEY, newStatusText);
|
|
608
|
+
this.lastStatusText = newStatusText;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Force an immediate widget update. */
|
|
613
|
+
update() {
|
|
614
|
+
if (!this.uiCtx) return;
|
|
615
|
+
const { running, queued, finished } = this.categorizeAgents();
|
|
616
|
+
|
|
617
|
+
const hasActive = running.length > 0 || queued.length > 0;
|
|
618
|
+
const hasFinished = finished.length > 0;
|
|
619
|
+
|
|
620
|
+
// Nothing to show — clear widget
|
|
621
|
+
if (!hasActive && !hasFinished) {
|
|
622
|
+
this.clearWidget();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Status bar — only call setStatus when the text actually changes
|
|
627
|
+
this.updateStatusBar(running.length, queued.length);
|
|
628
|
+
|
|
629
|
+
this.widgetFrame++;
|
|
630
|
+
|
|
631
|
+
// Register widget callback once; subsequent updates use requestRender()
|
|
632
|
+
// which re-invokes render() without replacing the component (avoids layout thrashing).
|
|
633
|
+
if (!this.widgetRegistered) {
|
|
634
|
+
this.uiCtx.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
635
|
+
this.tui = tui;
|
|
636
|
+
return {
|
|
637
|
+
render: () => this.renderWidget(tui, theme),
|
|
638
|
+
invalidate: () => {
|
|
639
|
+
// Theme changed — force re-registration so factory captures fresh theme.
|
|
640
|
+
this.widgetRegistered = false;
|
|
641
|
+
this.tui = undefined;
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}, { placement: "aboveEditor" });
|
|
645
|
+
this.widgetRegistered = true;
|
|
646
|
+
} else {
|
|
647
|
+
// Widget already registered — just request a re-render of existing components.
|
|
648
|
+
this.tui?.requestRender?.();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
dispose() {
|
|
653
|
+
const interval = this.widgetInterval;
|
|
654
|
+
if (interval != null) {
|
|
655
|
+
clearInterval(interval);
|
|
656
|
+
this.widgetInterval = undefined;
|
|
657
|
+
}
|
|
658
|
+
if (this.uiCtx) {
|
|
659
|
+
this.uiCtx?.setWidget(WIDGET_KEY, undefined);
|
|
660
|
+
this.uiCtx?.setStatus(STATUS_KEY, undefined);
|
|
661
|
+
}
|
|
662
|
+
this.widgetRegistered = false;
|
|
663
|
+
this.tui = undefined;
|
|
664
|
+
this.lastStatusText = undefined;
|
|
665
|
+
}
|
|
666
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifetime usage components, accumulated via `message_end` events. Survives
|
|
5
|
+
* compaction (which replaces session.state.messages and would reset any
|
|
6
|
+
* stats-derived sum). cacheRead is excluded because each turn's cacheRead is
|
|
7
|
+
* the cumulative cached prefix re-read on that one call — summing across
|
|
8
|
+
* turns counts the prefix N times. See issue #38.
|
|
9
|
+
*/
|
|
10
|
+
export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
|
|
11
|
+
|
|
12
|
+
/** Sum of lifetime usage components, or 0 if undefined. */
|
|
13
|
+
export function getLifetimeTotal(u?: LifetimeUsage): number {
|
|
14
|
+
return u ? u.input + u.output + u.cacheWrite : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Add a usage delta into a target accumulator (mutates target). */
|
|
18
|
+
export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
|
|
19
|
+
into.input += delta.input;
|
|
20
|
+
into.output += delta.output;
|
|
21
|
+
into.cacheWrite += delta.cacheWrite;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Minimal shape we read from upstream `getSessionStats()`. */
|
|
25
|
+
export type SessionStatsLike = {
|
|
26
|
+
tokens: { input: number; output: number; cacheWrite: number };
|
|
27
|
+
contextUsage?: { percent: number | null };
|
|
28
|
+
};
|
|
29
|
+
export type SessionLike = { getSessionStats(): SessionStatsLike };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Context-window utilization (0–100), or null when unavailable
|
|
33
|
+
* (no model contextWindow, or post-compaction before the next response).
|
|
34
|
+
*/
|
|
35
|
+
export function getSessionContextPercent(session: SessionLike | undefined): number | null {
|
|
36
|
+
if (!session) return null;
|
|
37
|
+
try { return session.getSessionStats().contextUsage?.percent ?? null; }
|
|
38
|
+
catch { return null; }
|
|
39
|
+
}
|