@xynogen/pix-subagent 0.1.2 → 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/package.json +3 -2
- package/src/agent-manager.ts +6 -1
- package/src/index.ts +4 -2
- package/src/model-resolver.ts +67 -2
- package/src/tools.ts +48 -16
- package/src/types.ts +5 -0
- package/src/ui/widget.ts +87 -54
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi tool — planner-driven sub-agents: spawn, fetch, steer scoped child agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@sinclair/typebox": "^0.34.0"
|
|
39
|
+
"@sinclair/typebox": "^0.34.0",
|
|
40
|
+
"@xynogen/pix-data": "*"
|
|
40
41
|
},
|
|
41
42
|
"peerDependencies": {
|
|
42
43
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/agent-manager.ts
CHANGED
|
@@ -180,6 +180,8 @@ export class AgentManager {
|
|
|
180
180
|
abortController,
|
|
181
181
|
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
182
182
|
compactionCount: 0,
|
|
183
|
+
turnCount: 0,
|
|
184
|
+
maxTurns: options.maxTurns,
|
|
183
185
|
invocation: options.invocation,
|
|
184
186
|
};
|
|
185
187
|
this.agents.set(id, record);
|
|
@@ -258,7 +260,10 @@ export class AgentManager {
|
|
|
258
260
|
if (activity.type === "end") record.toolUses++;
|
|
259
261
|
options.onToolActivity?.(activity);
|
|
260
262
|
},
|
|
261
|
-
onTurnEnd:
|
|
263
|
+
onTurnEnd: (turnCount) => {
|
|
264
|
+
record.turnCount = turnCount;
|
|
265
|
+
options.onTurnEnd?.(turnCount);
|
|
266
|
+
},
|
|
262
267
|
onTextDelta: options.onTextDelta,
|
|
263
268
|
onAssistantUsage: (usage) => {
|
|
264
269
|
addUsage(record.lifetimeUsage, usage);
|
package/src/index.ts
CHANGED
|
@@ -86,7 +86,6 @@ export default function registerPixSubagent(pi: ExtensionAPI): void {
|
|
|
86
86
|
// onComplete — fire subagent-notification nudge for each finished bg agent
|
|
87
87
|
(record) => {
|
|
88
88
|
agentActivity.delete(record.id);
|
|
89
|
-
widget.markFinished(record.id);
|
|
90
89
|
|
|
91
90
|
if (record.resultConsumed) {
|
|
92
91
|
widget.update();
|
|
@@ -136,8 +135,11 @@ export default function registerPixSubagent(pi: ExtensionAPI): void {
|
|
|
136
135
|
widget.update();
|
|
137
136
|
},
|
|
138
137
|
4, // maxConcurrent
|
|
139
|
-
// onStart
|
|
138
|
+
// onStart — re-arm the 80ms spinner loop. update() clears the timer when
|
|
139
|
+
// the last agent finishes; a fresh spawn mid-turn (no new turn_start) must
|
|
140
|
+
// restart it or the spinner freezes on one frame.
|
|
140
141
|
(_record) => {
|
|
142
|
+
widget.ensureTimer();
|
|
141
143
|
widget.update();
|
|
142
144
|
},
|
|
143
145
|
);
|
package/src/model-resolver.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { lookupBenchmark, lookupModelsDev } from "@xynogen/pix-data";
|
|
6
|
+
|
|
5
7
|
export interface ModelEntry {
|
|
6
8
|
id: string;
|
|
7
9
|
name: string;
|
|
@@ -91,8 +93,71 @@ export function resolveModel(
|
|
|
91
93
|
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
|
|
96
|
+
// ── Enrichment for orchestrator model choice ────────────────────────────────
|
|
97
|
+
// The orchestrator picks a worker model from the `agent` tool description. Bare
|
|
98
|
+
// ids carry no signal, so each line is annotated with bench score, context,
|
|
99
|
+
// price, and a coarse tier — sourced from pix-data (the shared data layer).
|
|
100
|
+
|
|
101
|
+
const SCORE_FRONTIER = 88;
|
|
102
|
+
const SCORE_STRONG = 75;
|
|
103
|
+
const CHEAP_OUTPUT_PRICE = 3; // $/M output tokens — below this counts as cheap
|
|
104
|
+
|
|
105
|
+
/** Context window → compact "200k" / "1M". */
|
|
106
|
+
function fmtCtx(n: number | undefined): string {
|
|
107
|
+
if (!n || n < 1_000) return n ? `${n}` : "";
|
|
108
|
+
if (n >= 1_000_000) {
|
|
109
|
+
const m = n / 1_000_000;
|
|
110
|
+
return `${Number.isInteger(m) ? m : m.toFixed(1).replace(/\.0$/, "")}M ctx`;
|
|
111
|
+
}
|
|
112
|
+
return `${Math.round(n / 1_000)}k ctx`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Cost → "$3/$15" (input/output per Mtok), "free", or "" when unknown. */
|
|
116
|
+
function fmtCost(input?: number, output?: number): string {
|
|
117
|
+
if (input == null && output == null) return "";
|
|
118
|
+
const i = input ?? 0;
|
|
119
|
+
const o = output ?? 0;
|
|
120
|
+
if (i === 0 && o === 0) return "free";
|
|
121
|
+
return `$${i}/$${o}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Coarse decision tier from score + output price. */
|
|
125
|
+
function tier(score: number | null | undefined, output?: number): string {
|
|
126
|
+
if (typeof score !== "number") return "";
|
|
127
|
+
if (score >= SCORE_FRONTIER) return "frontier";
|
|
128
|
+
if (score >= SCORE_STRONG) return "strong";
|
|
129
|
+
if (output != null && output <= CHEAP_OUTPUT_PRICE) return "fast-cheap";
|
|
130
|
+
return "basic";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One enriched line: "provider/id — ⚡95 · 200k ctx · $3/$15 · frontier". */
|
|
134
|
+
function annotate(m: ModelEntry): { line: string; score: number } {
|
|
135
|
+
const dev = lookupModelsDev(m.provider, m.id);
|
|
136
|
+
const bench = lookupBenchmark(m.name ?? m.id);
|
|
137
|
+
const score = bench?.overallScore ?? null;
|
|
138
|
+
const out = bench?.outputPrice ?? dev?.cost?.output;
|
|
139
|
+
const segs = [
|
|
140
|
+
typeof score === "number" ? `⚡${score}` : "",
|
|
141
|
+
fmtCtx(dev?.limit?.context),
|
|
142
|
+
fmtCost(bench?.inputPrice ?? dev?.cost?.input, out),
|
|
143
|
+
tier(score, out),
|
|
144
|
+
].filter(Boolean);
|
|
145
|
+
const id = `${m.provider}/${m.id}`;
|
|
146
|
+
return {
|
|
147
|
+
line: segs.length ? `${id} — ${segs.join(" · ")}` : id,
|
|
148
|
+
score: score ?? -1,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* List available models, enriched + ranked for orchestrator decisions.
|
|
154
|
+
* Sorted by bench score desc (best first); unscored fall to the bottom
|
|
155
|
+
* alphabetically (preserved by the stable id tiebreak).
|
|
156
|
+
*/
|
|
95
157
|
export function listAvailable(registry: ModelRegistry): string[] {
|
|
96
158
|
const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
|
|
97
|
-
return all
|
|
159
|
+
return all
|
|
160
|
+
.map((m) => ({ ...annotate(m), id: `${m.provider}/${m.id}` }))
|
|
161
|
+
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
|
|
162
|
+
.map((r) => r.line);
|
|
98
163
|
}
|
package/src/tools.ts
CHANGED
|
@@ -29,7 +29,11 @@ import {
|
|
|
29
29
|
import { resolveAgentInvocationConfig } from "./invocation-config.ts";
|
|
30
30
|
import { resolveModel } from "./model-resolver.ts";
|
|
31
31
|
import type { AgentInvocation, LifetimeUsage } from "./types.ts";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
getLifetimeTotal,
|
|
34
|
+
getSessionContextPercent,
|
|
35
|
+
type SessionLike,
|
|
36
|
+
} from "./usage.ts";
|
|
33
37
|
|
|
34
38
|
// ── Types shared with ui/widget.ts (widget imports from here to avoid circular) ─
|
|
35
39
|
|
|
@@ -54,6 +58,10 @@ export interface AgentDetails {
|
|
|
54
58
|
subagentType: string;
|
|
55
59
|
toolUses: number;
|
|
56
60
|
tokens: string;
|
|
61
|
+
/** Context-window utilization 0–100, or null/undefined when unavailable. */
|
|
62
|
+
contextPercent?: number | null;
|
|
63
|
+
/** Raw output tokens — for t/s = outputTokens / durationMs. */
|
|
64
|
+
outputTokens?: number;
|
|
57
65
|
durationMs: number;
|
|
58
66
|
status:
|
|
59
67
|
| "queued"
|
|
@@ -106,6 +114,15 @@ export function formatMs(ms: number): string {
|
|
|
106
114
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
107
115
|
}
|
|
108
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Output tokens per second over a duration. "" when either input is
|
|
119
|
+
* non-positive (no work / zero elapsed) so callers can skip the segment.
|
|
120
|
+
*/
|
|
121
|
+
export function formatSpeed(outputTokens: number, durationMs: number): string {
|
|
122
|
+
if (outputTokens <= 0 || durationMs <= 0) return "";
|
|
123
|
+
return `${Math.round(outputTokens / (durationMs / 1000))} t/s`;
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
110
127
|
|
|
111
128
|
function textResult(msg: string, details?: AgentDetails) {
|
|
@@ -137,7 +154,13 @@ function buildStats(d: AgentDetails, theme: Theme): string {
|
|
|
137
154
|
parts.push(
|
|
138
155
|
theme.fg("dim", `${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`),
|
|
139
156
|
);
|
|
140
|
-
if (d.tokens)
|
|
157
|
+
if (d.tokens) {
|
|
158
|
+
const pct =
|
|
159
|
+
d.contextPercent != null
|
|
160
|
+
? ` ${theme.fg("dim", `(${Math.round(d.contextPercent)}%)`)}`
|
|
161
|
+
: "";
|
|
162
|
+
parts.push(theme.fg("dim", d.tokens) + pct);
|
|
163
|
+
}
|
|
141
164
|
return parts.join(` ${theme.fg("dim", "·")} `);
|
|
142
165
|
}
|
|
143
166
|
|
|
@@ -296,12 +319,10 @@ export function createAgentTool(
|
|
|
296
319
|
|
|
297
320
|
const stats = buildStats(details, theme);
|
|
298
321
|
|
|
299
|
-
// Streaming / running
|
|
322
|
+
// Streaming / running — live state shown by the ● Agents widget, so the
|
|
323
|
+
// inline transcript stays empty to avoid stacking one card per agent.
|
|
300
324
|
if (isPartial || details.status === "running") {
|
|
301
|
-
|
|
302
|
-
let line = theme.fg("accent", frame) + (stats ? ` ${stats}` : "");
|
|
303
|
-
line += `\n${theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`)}`;
|
|
304
|
-
return new Text(line, 0, 0);
|
|
325
|
+
return new Text("", 0, 0);
|
|
305
326
|
}
|
|
306
327
|
|
|
307
328
|
// Background launched
|
|
@@ -316,21 +337,34 @@ export function createAgentTool(
|
|
|
316
337
|
);
|
|
317
338
|
}
|
|
318
339
|
|
|
319
|
-
// Completed / steered
|
|
340
|
+
// Completed / steered. Collapsed view stays empty — the ● Agents widget
|
|
341
|
+
// carries the full finished line, so the inline transcript doesn't echo
|
|
342
|
+
// it (caller shouldn't output the result). Expanded view still shows the
|
|
343
|
+
// summary + full output on demand.
|
|
320
344
|
if (details.status === "completed" || details.status === "steered") {
|
|
345
|
+
if (!expanded) return new Text("", 0, 0);
|
|
321
346
|
const duration = formatMs(details.durationMs);
|
|
322
347
|
const isSteered = details.status === "steered";
|
|
323
348
|
const icon = isSteered
|
|
324
349
|
? theme.fg("warning", "✓")
|
|
325
350
|
: theme.fg("success", "✓");
|
|
351
|
+
const speed = formatSpeed(
|
|
352
|
+
details.outputTokens ?? 0,
|
|
353
|
+
details.durationMs,
|
|
354
|
+
);
|
|
326
355
|
let line =
|
|
327
356
|
icon +
|
|
328
357
|
(stats ? ` ${stats}` : "") +
|
|
329
358
|
" " +
|
|
330
359
|
theme.fg("dim", "·") +
|
|
331
360
|
" " +
|
|
332
|
-
theme.fg("dim", duration)
|
|
361
|
+
theme.fg("dim", duration) +
|
|
362
|
+
(speed ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", speed)}` : "") +
|
|
363
|
+
// Steered = stopped at turn limit; keep that note inline since the
|
|
364
|
+
// stats alone don't say why it ended.
|
|
365
|
+
(isSteered ? ` ${theme.fg("warning", "(turn limit)")}` : "");
|
|
333
366
|
|
|
367
|
+
// Expanded view appends the full result below the one-line summary.
|
|
334
368
|
if (expanded) {
|
|
335
369
|
const resultText =
|
|
336
370
|
result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
@@ -345,13 +379,6 @@ export function createAgentTool(
|
|
|
345
379
|
" … (use agent_result with verbose for full output)",
|
|
346
380
|
);
|
|
347
381
|
}
|
|
348
|
-
} else {
|
|
349
|
-
line +=
|
|
350
|
-
"\n" +
|
|
351
|
-
theme.fg(
|
|
352
|
-
"dim",
|
|
353
|
-
` ⎿ ${isSteered ? "Wrapped up (turn limit)" : "Done"}`,
|
|
354
|
-
);
|
|
355
382
|
}
|
|
356
383
|
return new Text(line, 0, 0);
|
|
357
384
|
}
|
|
@@ -806,10 +833,15 @@ function buildDetails(
|
|
|
806
833
|
activity?: AgentActivity & { durationMs?: number },
|
|
807
834
|
): AgentDetails {
|
|
808
835
|
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
836
|
+
const contextPercent = activity?.session
|
|
837
|
+
? getSessionContextPercent(activity.session as SessionLike)
|
|
838
|
+
: null;
|
|
809
839
|
return {
|
|
810
840
|
...base,
|
|
811
841
|
toolUses: record.toolUses,
|
|
812
842
|
tokens: totalTokens > 0 ? formatTokens(totalTokens) : "",
|
|
843
|
+
contextPercent,
|
|
844
|
+
outputTokens: record.lifetimeUsage.output,
|
|
813
845
|
turnCount: activity?.turnCount,
|
|
814
846
|
maxTurns: activity?.maxTurns,
|
|
815
847
|
durationMs:
|
package/src/types.ts
CHANGED
|
@@ -89,6 +89,11 @@ export interface AgentRecord {
|
|
|
89
89
|
lifetimeUsage: LifetimeUsage;
|
|
90
90
|
/** Number of times this agent's session has compacted. */
|
|
91
91
|
compactionCount: number;
|
|
92
|
+
/** Cumulative agentic turns. Persisted on the record so the finished widget
|
|
93
|
+
* line stays full after agentActivity is cleared on completion. */
|
|
94
|
+
turnCount: number;
|
|
95
|
+
/** Turn cap, if any — for the ↻N≤M display. */
|
|
96
|
+
maxTurns?: number;
|
|
92
97
|
/** Resolved spawn params, captured for UI display. */
|
|
93
98
|
invocation?: AgentInvocation;
|
|
94
99
|
}
|
package/src/ui/widget.ts
CHANGED
|
@@ -12,12 +12,22 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
|
12
12
|
import type { AgentManager } from "../agent-manager.ts";
|
|
13
13
|
import { getConfig } from "../agent-types.ts";
|
|
14
14
|
import type { AgentActivity, AgentDetails, Theme } from "../tools.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
formatMs,
|
|
17
|
+
formatSpeed,
|
|
18
|
+
formatTokens,
|
|
19
|
+
formatTurns,
|
|
20
|
+
SPINNER,
|
|
21
|
+
} from "../tools.ts";
|
|
16
22
|
import type { AgentInvocation, SubagentType } from "../types.ts";
|
|
17
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
getLifetimeTotal,
|
|
25
|
+
getSessionContextPercent,
|
|
26
|
+
type SessionLike,
|
|
27
|
+
} from "../usage.ts";
|
|
18
28
|
|
|
19
29
|
export type { AgentActivity, AgentDetails, Theme };
|
|
20
|
-
export { formatMs, formatTokens, formatTurns, SPINNER };
|
|
30
|
+
export { formatMs, formatSpeed, formatTokens, formatTurns, SPINNER };
|
|
21
31
|
|
|
22
32
|
// ── constants ─────────────────────────────────────────────────────────────────
|
|
23
33
|
|
|
@@ -97,14 +107,16 @@ export function buildInvocationTags(invocation: AgentInvocation | undefined): {
|
|
|
97
107
|
return { modelName: invocation.modelName, tags };
|
|
98
108
|
}
|
|
99
109
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Live tail of agent output: latest non-empty line, tail-anchored to `len`
|
|
112
|
+
* chars (keeps the moving edge, not the stale first line). Leading … marks
|
|
113
|
+
* the clip. e.g. "…ting batch 6".
|
|
114
|
+
*/
|
|
115
|
+
function truncateLine(text: string, len = 16): string {
|
|
116
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
117
|
+
const line = lines.length ? lines[lines.length - 1].trim() : "";
|
|
106
118
|
if (line.length <= len) return line;
|
|
107
|
-
return
|
|
119
|
+
return `…${line.slice(-len)}`;
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
export function describeActivity(
|
|
@@ -133,8 +145,8 @@ export class AgentWidget {
|
|
|
133
145
|
private uiCtx: UICtx | undefined;
|
|
134
146
|
private widgetFrame = 0;
|
|
135
147
|
private widgetInterval: ReturnType<typeof setInterval> | undefined;
|
|
136
|
-
private
|
|
137
|
-
private static readonly
|
|
148
|
+
private static readonly FINISHED_LINGER_MS = 5_000;
|
|
149
|
+
private static readonly ERROR_LINGER_MS = 15_000;
|
|
138
150
|
private widgetRegistered = false;
|
|
139
151
|
private tui: unknown = undefined;
|
|
140
152
|
private lastStatusText: string | undefined;
|
|
@@ -154,9 +166,6 @@ export class AgentWidget {
|
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
onTurnStart() {
|
|
157
|
-
for (const [id, age] of this.finishedTurnAge) {
|
|
158
|
-
this.finishedTurnAge.set(id, age + 1);
|
|
159
|
-
}
|
|
160
169
|
this.update();
|
|
161
170
|
}
|
|
162
171
|
|
|
@@ -166,18 +175,14 @@ export class AgentWidget {
|
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
177
|
|
|
169
|
-
private shouldShowFinished(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
markFinished(agentId: string) {
|
|
178
|
-
if (!this.finishedTurnAge.has(agentId)) {
|
|
179
|
-
this.finishedTurnAge.set(agentId, 0);
|
|
180
|
-
}
|
|
178
|
+
private shouldShowFinished(status: string, completedAt: number): boolean {
|
|
179
|
+
// Linger a few seconds after finish, then drop. The ✓ … Done line in the
|
|
180
|
+
// transcript is the permanent record; errors stay longer so failures are
|
|
181
|
+
// noticed. The 80ms widget timer re-evaluates this continuously.
|
|
182
|
+
const linger = ERROR_STATUSES.has(status)
|
|
183
|
+
? AgentWidget.ERROR_LINGER_MS
|
|
184
|
+
: AgentWidget.FINISHED_LINGER_MS;
|
|
185
|
+
return Date.now() - completedAt < linger;
|
|
181
186
|
}
|
|
182
187
|
|
|
183
188
|
private renderFinishedLine(
|
|
@@ -191,12 +196,18 @@ export class AgentWidget {
|
|
|
191
196
|
completedAt?: number;
|
|
192
197
|
error?: string;
|
|
193
198
|
invocation?: AgentInvocation;
|
|
199
|
+
lifetimeUsage?: { input: number; output: number; cacheWrite: number };
|
|
200
|
+
session?: unknown;
|
|
201
|
+
compactionCount?: number;
|
|
202
|
+
turnCount?: number;
|
|
203
|
+
maxTurns?: number;
|
|
194
204
|
},
|
|
195
205
|
theme: Theme,
|
|
196
206
|
): string {
|
|
197
207
|
const name = getDisplayName(a.type);
|
|
198
208
|
const modeLabel = getPromptModeLabel(a.type);
|
|
199
|
-
const
|
|
209
|
+
const durationMs = (a.completedAt ?? Date.now()) - a.startedAt;
|
|
210
|
+
const duration = formatMs(durationMs);
|
|
200
211
|
|
|
201
212
|
// model label (the pix twist — always shown)
|
|
202
213
|
const modelLabel = a.invocation?.modelName
|
|
@@ -225,12 +236,31 @@ export class AgentWidget {
|
|
|
225
236
|
}
|
|
226
237
|
|
|
227
238
|
const parts: string[] = [];
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
239
|
+
// Turns read from the record (a.*), not agentActivity — onComplete deletes
|
|
240
|
+
// the activity entry before this line renders, which had dropped ↻N.
|
|
241
|
+
if (a.turnCount != null && a.turnCount > 0)
|
|
242
|
+
parts.push(formatTurns(a.turnCount, a.maxTurns));
|
|
231
243
|
if (a.toolUses > 0)
|
|
232
244
|
parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
|
|
245
|
+
// Token + context% + speed read from the record (survives the
|
|
246
|
+
// agentActivity delete that fires in onComplete before this renders).
|
|
247
|
+
const tokens = getLifetimeTotal(a.lifetimeUsage);
|
|
248
|
+
if (tokens > 0) {
|
|
249
|
+
const contextPercent = a.session
|
|
250
|
+
? getSessionContextPercent(a.session as SessionLike)
|
|
251
|
+
: null;
|
|
252
|
+
parts.push(
|
|
253
|
+
formatSessionTokens(
|
|
254
|
+
tokens,
|
|
255
|
+
contextPercent,
|
|
256
|
+
theme,
|
|
257
|
+
a.compactionCount ?? 0,
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
233
261
|
parts.push(duration);
|
|
262
|
+
const speed = formatSpeed(a.lifetimeUsage?.output ?? 0, durationMs);
|
|
263
|
+
if (speed) parts.push(speed);
|
|
234
264
|
|
|
235
265
|
return `${icon} ${theme.fg("dim", name)}${modelLabel}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
|
|
236
266
|
}
|
|
@@ -246,8 +276,8 @@ export class AgentWidget {
|
|
|
246
276
|
(a) =>
|
|
247
277
|
a.status !== "running" &&
|
|
248
278
|
a.status !== "queued" &&
|
|
249
|
-
a.completedAt &&
|
|
250
|
-
this.shouldShowFinished(a.
|
|
279
|
+
a.completedAt != null &&
|
|
280
|
+
this.shouldShowFinished(a.status, a.completedAt),
|
|
251
281
|
);
|
|
252
282
|
|
|
253
283
|
if (running.length === 0 && queued.length === 0 && finished.length === 0)
|
|
@@ -257,7 +287,8 @@ export class AgentWidget {
|
|
|
257
287
|
const truncate = (line: string) => truncateToWidth(line, w);
|
|
258
288
|
const hasActive = running.length > 0 || queued.length > 0;
|
|
259
289
|
const headingColor = hasActive ? "accent" : "dim";
|
|
260
|
-
|
|
290
|
+
// ○ hollow = incomplete (still running), ● filled disk = complete (all done).
|
|
291
|
+
const headingIcon = hasActive ? "○" : "●";
|
|
261
292
|
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
262
293
|
|
|
263
294
|
const finishedLines: string[] = [];
|
|
@@ -269,7 +300,7 @@ export class AgentWidget {
|
|
|
269
300
|
);
|
|
270
301
|
}
|
|
271
302
|
|
|
272
|
-
const runningLines: string[]
|
|
303
|
+
const runningLines: string[] = [];
|
|
273
304
|
for (const a of running) {
|
|
274
305
|
const name = getDisplayName(a.type);
|
|
275
306
|
const modeLabel = getPromptModeLabel(a.type);
|
|
@@ -304,19 +335,26 @@ export class AgentWidget {
|
|
|
304
335
|
parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
|
|
305
336
|
if (tokenText) parts.push(tokenText);
|
|
306
337
|
parts.push(elapsed);
|
|
338
|
+
const liveSpeed = formatSpeed(
|
|
339
|
+
bg?.lifetimeUsage.output ?? 0,
|
|
340
|
+
Date.now() - a.startedAt,
|
|
341
|
+
);
|
|
342
|
+
if (liveSpeed) parts.push(liveSpeed);
|
|
307
343
|
const statsText = parts.join(" · ");
|
|
308
344
|
|
|
345
|
+
// Activity leads (next to the spinner — the moving part by the moving
|
|
346
|
+
// part), then static identity + cumulative stats. One line per agent so
|
|
347
|
+
// many concurrent workers stay readable instead of doubling the height.
|
|
309
348
|
const activity = bg
|
|
310
349
|
? describeActivity(bg.activeTools, bg.responseText)
|
|
311
350
|
: "thinking…";
|
|
312
351
|
|
|
313
|
-
runningLines.push(
|
|
352
|
+
runningLines.push(
|
|
314
353
|
truncate(
|
|
315
354
|
theme.fg("dim", "├─") +
|
|
316
|
-
` ${theme.fg("accent", frame)} ${theme.bold(name)}${modelLabel}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`,
|
|
355
|
+
` ${theme.fg("accent", frame)} ${theme.fg("dim", activity)} ${theme.fg("dim", "·")} ${theme.bold(name)}${modelLabel}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", statsText)}`,
|
|
317
356
|
),
|
|
318
|
-
|
|
319
|
-
]);
|
|
357
|
+
);
|
|
320
358
|
}
|
|
321
359
|
|
|
322
360
|
const queuedLine =
|
|
@@ -329,7 +367,7 @@ export class AgentWidget {
|
|
|
329
367
|
|
|
330
368
|
const maxBody = MAX_WIDGET_LINES - 1;
|
|
331
369
|
const totalBody =
|
|
332
|
-
finishedLines.length + runningLines.length
|
|
370
|
+
finishedLines.length + runningLines.length + (queuedLine ? 1 : 0);
|
|
333
371
|
|
|
334
372
|
const lines: string[] = [
|
|
335
373
|
truncate(
|
|
@@ -341,27 +379,23 @@ export class AgentWidget {
|
|
|
341
379
|
|
|
342
380
|
if (totalBody <= maxBody) {
|
|
343
381
|
lines.push(...finishedLines);
|
|
344
|
-
|
|
382
|
+
lines.push(...runningLines);
|
|
345
383
|
if (queuedLine) lines.push(queuedLine);
|
|
346
384
|
|
|
347
385
|
// Fix last connector ├─ → └─
|
|
348
386
|
if (lines.length > 1) {
|
|
349
387
|
const last = lines.length - 1;
|
|
350
388
|
lines[last] = lines[last].replace("├─", "└─");
|
|
351
|
-
if (runningLines.length > 0 && !queuedLine && last >= 2) {
|
|
352
|
-
lines[last - 1] = lines[last - 1].replace("├─", "└─");
|
|
353
|
-
lines[last] = lines[last].replace("│ ", " ");
|
|
354
|
-
}
|
|
355
389
|
}
|
|
356
390
|
} else {
|
|
357
391
|
let budget = maxBody - 1;
|
|
358
392
|
let hiddenRunning = 0;
|
|
359
393
|
let hiddenFinished = 0;
|
|
360
394
|
|
|
361
|
-
for (const
|
|
362
|
-
if (budget >=
|
|
363
|
-
lines.push(
|
|
364
|
-
budget
|
|
395
|
+
for (const line of runningLines) {
|
|
396
|
+
if (budget >= 1) {
|
|
397
|
+
lines.push(line);
|
|
398
|
+
budget--;
|
|
365
399
|
} else {
|
|
366
400
|
hiddenRunning++;
|
|
367
401
|
}
|
|
@@ -403,7 +437,10 @@ export class AgentWidget {
|
|
|
403
437
|
for (const a of allAgents) {
|
|
404
438
|
if (a.status === "running") runningCount++;
|
|
405
439
|
else if (a.status === "queued") queuedCount++;
|
|
406
|
-
else if (
|
|
440
|
+
else if (
|
|
441
|
+
a.completedAt != null &&
|
|
442
|
+
this.shouldShowFinished(a.status, a.completedAt)
|
|
443
|
+
)
|
|
407
444
|
hasFinished = true;
|
|
408
445
|
}
|
|
409
446
|
const hasActive = runningCount > 0 || queuedCount > 0;
|
|
@@ -422,10 +459,6 @@ export class AgentWidget {
|
|
|
422
459
|
clearInterval(this.widgetInterval);
|
|
423
460
|
this.widgetInterval = undefined;
|
|
424
461
|
}
|
|
425
|
-
for (const [id] of this.finishedTurnAge) {
|
|
426
|
-
if (!allAgents.some((a) => a.id === id))
|
|
427
|
-
this.finishedTurnAge.delete(id);
|
|
428
|
-
}
|
|
429
462
|
return;
|
|
430
463
|
}
|
|
431
464
|
|