@esso0428/pi-subagents 0.17.12 → 0.17.14
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/CHANGELOG.md +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -42
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-widget.d.ts +96 -96
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +561 -371
- package/dist/ui/agent-widget.js.map +1 -1
- package/package.json +4 -3
- package/src/index.ts +24 -38
- package/src/ui/agent-widget.ts +590 -364
package/dist/ui/agent-widget.js
CHANGED
|
@@ -4,19 +4,24 @@
|
|
|
4
4
|
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
|
|
5
5
|
* Uses the callback form of setWidget for themed rendering.
|
|
6
6
|
*/
|
|
7
|
-
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import { renderAgentName } from "../agent-color.js";
|
|
9
|
-
import { isTopLevelAgent } from "../agent-manager.js";
|
|
10
9
|
import { getConfig } from "../agent-types.js";
|
|
11
10
|
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
12
11
|
// ---- Constants ----
|
|
13
12
|
/** Maximum number of rendered lines before overflow collapse kicks in. */
|
|
14
13
|
export const MAX_WIDGET_LINES = 12;
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
/** Keep a small editor/input area visible below the above-editor widget. */
|
|
15
|
+
const MIN_EDITOR_LINES = 4;
|
|
16
|
+
/**
|
|
17
|
+
* Derive the widget ceiling from the terminal height while retaining the
|
|
18
|
+
* historical MAX_WIDGET_LINES ceiling. Very short terminals may have no room
|
|
19
|
+
* for the widget; that is preferable to consuming the editor/input area.
|
|
20
|
+
*/
|
|
21
|
+
export function getWidgetLineBudget(rows) {
|
|
22
|
+
if (!Number.isFinite(rows))
|
|
18
23
|
return MAX_WIDGET_LINES;
|
|
19
|
-
return Math.min(MAX_WIDGET_LINES, Math.max(0, Math.floor(
|
|
24
|
+
return Math.min(MAX_WIDGET_LINES, Math.max(0, Math.floor(rows) - MIN_EDITOR_LINES));
|
|
20
25
|
}
|
|
21
26
|
/** Braille spinner frames for animated running indicator. */
|
|
22
27
|
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -47,33 +52,6 @@ export function formatTokens(count) {
|
|
|
47
52
|
return `${(count / 1_000).toFixed(1)}k token`;
|
|
48
53
|
return `${count} token`;
|
|
49
54
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Format a cost as `~$0.0042`, or "" when there is nothing to show.
|
|
52
|
-
*
|
|
53
|
-
* The tilde is load-bearing: this is pi's own estimate from the model's listed
|
|
54
|
-
* rates, not a billed figure, and the surfaces that print it sit next to token
|
|
55
|
-
* counts that ARE exact.
|
|
56
|
-
*
|
|
57
|
-
* Nothing is printed for zero, which is also what a model with no pricing data
|
|
58
|
-
* reports: `$0.00` beside a local model's tokens would claim its cost was
|
|
59
|
-
* measured and found to be nothing, rather than never measured at all. For the
|
|
60
|
-
* same reason a real cost too small for four decimals reads `<$0.0001` — it was
|
|
61
|
-
* measured, and rounding it to `~$0.0000` would say the opposite.
|
|
62
|
-
*/
|
|
63
|
-
export function formatCost(cost) {
|
|
64
|
-
if (!(cost > 0))
|
|
65
|
-
return ""; // also catches NaN
|
|
66
|
-
if (cost < 0.0001)
|
|
67
|
-
return "<$0.0001";
|
|
68
|
-
if (cost >= 1)
|
|
69
|
-
return `~$${cost.toFixed(2)}`;
|
|
70
|
-
// Under a dollar: cents at minimum, four decimals at most, nothing trailing.
|
|
71
|
-
// Most single runs land between a tenth of a cent and a dime, where rounding
|
|
72
|
-
// to cents would collapse a 4x difference in spend into the same figure.
|
|
73
|
-
const rounded = Number(cost.toFixed(4));
|
|
74
|
-
const decimals = (String(rounded).split(".")[1] ?? "").length;
|
|
75
|
-
return `~$${rounded.toFixed(Math.max(2, decimals))}`;
|
|
76
|
-
}
|
|
77
55
|
/**
|
|
78
56
|
* Token count with optional context-fill % and compaction-count annotations.
|
|
79
57
|
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
@@ -112,6 +90,18 @@ export function formatDuration(startedAt, completedAt) {
|
|
|
112
90
|
return formatMs(completedAt - startedAt);
|
|
113
91
|
return `${formatMs(Date.now() - startedAt)} (running)`;
|
|
114
92
|
}
|
|
93
|
+
/** Format an estimated model cost without implying billing precision. */
|
|
94
|
+
export function formatCost(cost) {
|
|
95
|
+
if (!(cost > 0))
|
|
96
|
+
return "";
|
|
97
|
+
if (cost < 0.0001)
|
|
98
|
+
return "<$0.0001";
|
|
99
|
+
if (cost >= 1)
|
|
100
|
+
return `~$${cost.toFixed(2)}`;
|
|
101
|
+
const rounded = Number(cost.toFixed(4));
|
|
102
|
+
const decimals = (String(rounded).split(".")[1] ?? "").length;
|
|
103
|
+
return `~$${rounded.toFixed(Math.max(2, decimals))}`;
|
|
104
|
+
}
|
|
115
105
|
/** Get display name for any agent type (built-in or custom). */
|
|
116
106
|
export function getDisplayName(type) {
|
|
117
107
|
return getConfig(type).displayName;
|
|
@@ -121,13 +111,7 @@ export function getPromptModeLabel(type) {
|
|
|
121
111
|
const config = getConfig(type);
|
|
122
112
|
return config.promptMode === "append" ? "twin" : undefined;
|
|
123
113
|
}
|
|
124
|
-
/**
|
|
125
|
-
* Mode label is not included — callers add it where they want it.
|
|
126
|
-
*
|
|
127
|
-
* Both model forms come back so each surface can pick by width; the
|
|
128
|
-
* "(asked X)" annotation is applied here rather than by callers, so a value the
|
|
129
|
-
* spawn did not honor cannot be rendered as though it had been (#182).
|
|
130
|
-
*/
|
|
114
|
+
/** Mode label is not included — callers add it where they want it. */
|
|
131
115
|
export function buildInvocationTags(invocation) {
|
|
132
116
|
const tags = [];
|
|
133
117
|
if (!invocation)
|
|
@@ -152,9 +136,9 @@ export function buildInvocationTags(invocation) {
|
|
|
152
136
|
tags,
|
|
153
137
|
};
|
|
154
138
|
}
|
|
155
|
-
/**
|
|
139
|
+
/** Normalize and truncate text so it can never add physical widget rows. */
|
|
156
140
|
function truncateLine(text, len = 60) {
|
|
157
|
-
const line = text.
|
|
141
|
+
const line = text.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
|
|
158
142
|
if (line.length <= len)
|
|
159
143
|
return line;
|
|
160
144
|
return line.slice(0, len) + "…";
|
|
@@ -192,46 +176,36 @@ export class AgentWidget {
|
|
|
192
176
|
uiCtx;
|
|
193
177
|
widgetFrame = 0;
|
|
194
178
|
widgetInterval;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
179
|
+
inputUnsub;
|
|
180
|
+
/** Whether arrow keys currently navigate the agent roster. */
|
|
181
|
+
navigationActive = false;
|
|
182
|
+
/** Stable identity of the selected row, so roster changes do not jump selection. */
|
|
183
|
+
selectedAgentId;
|
|
184
|
+
/** Last logical roster index of the selected row, used when it disappears. */
|
|
185
|
+
selectedRosterIndex = 0;
|
|
186
|
+
/** First logical row currently represented by the bounded viewport. */
|
|
187
|
+
viewportStart = 0;
|
|
188
|
+
/** Cached records and roster. Rebuilt by update(), never by an arrow press. */
|
|
189
|
+
cachedAgents = [];
|
|
190
|
+
cachedRoster = [];
|
|
191
|
+
rosterCacheKey;
|
|
192
|
+
rosterCacheInitialized = false;
|
|
193
|
+
/** Terminal history capability is a potentially filesystem-backed lookup. */
|
|
194
|
+
historyOpenabilityCache = new Map();
|
|
199
195
|
/** Whether the widget callback is currently registered with the TUI. */
|
|
200
196
|
widgetRegistered = false;
|
|
201
197
|
/** Cached TUI reference from widget factory callback, used for requestRender(). */
|
|
202
198
|
tui;
|
|
203
199
|
/** Last status bar text, used to avoid redundant setStatus calls. */
|
|
204
200
|
lastStatusText;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
navigationSuspended = false;
|
|
208
|
-
navigationActive = false;
|
|
209
|
-
selectedAgentId;
|
|
210
|
-
viewportStart = 0;
|
|
211
|
-
selectedRosterIndex = 0;
|
|
201
|
+
/** Snapshot of the state used for the last widget registration/render request. */
|
|
202
|
+
lastRenderKey;
|
|
212
203
|
openOptions;
|
|
213
204
|
showCost;
|
|
214
205
|
showModel;
|
|
215
206
|
constructor(manager, agentActivity,
|
|
216
|
-
/**
|
|
217
|
-
|
|
218
|
-
* `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
|
|
219
|
-
* extension supplies one defaulting to `"background"`.
|
|
220
|
-
*/
|
|
221
|
-
mode = () => "all",
|
|
222
|
-
/**
|
|
223
|
-
* Read live at render time, like `mode`. Whether running agents show an
|
|
224
|
-
* estimated cost beside their token count. Defaults to off — the extension
|
|
225
|
-
* supplies the user's `showCost` setting.
|
|
226
|
-
*/
|
|
227
|
-
/**
|
|
228
|
-
* Read live at render time, like `mode`. Whether running agents name the
|
|
229
|
-
* model driving them and the thinking level it is running at. Defaults to
|
|
230
|
-
* off — the extension supplies the user's `showModel` setting — because the
|
|
231
|
-
* row is already dense and the same pair is on the tool result and in the
|
|
232
|
-
* conversation viewer unconditionally.
|
|
233
|
-
*/
|
|
234
|
-
showCostOrOptions = () => false, showModel = () => false) {
|
|
207
|
+
/** Read live at render time. Selects which agents the widget shows. */
|
|
208
|
+
mode = () => "all", showCostOrOptions = () => false, showModel = () => false) {
|
|
235
209
|
this.manager = manager;
|
|
236
210
|
this.agentActivity = agentActivity;
|
|
237
211
|
this.mode = mode;
|
|
@@ -256,176 +230,259 @@ export class AgentWidget {
|
|
|
256
230
|
* only proven-foreground runs drop out — nothing else silently vanishes.
|
|
257
231
|
* - `all`: every agent.
|
|
258
232
|
*/
|
|
259
|
-
|
|
260
|
-
const all = this.manager.listAgents()
|
|
233
|
+
widgetAgentsFromManager() {
|
|
234
|
+
const all = this.manager.listAgents();
|
|
261
235
|
switch (this.mode()) {
|
|
262
236
|
case "off": return [];
|
|
263
237
|
case "background": return all.filter(a => a.isBackground !== false);
|
|
264
238
|
default: return all;
|
|
265
239
|
}
|
|
266
240
|
}
|
|
267
|
-
/**
|
|
268
|
-
|
|
269
|
-
this.
|
|
241
|
+
/** Records currently visible to the widget; safe for render and input paths. */
|
|
242
|
+
widgetAgents() {
|
|
243
|
+
return this.rosterCacheInitialized ? this.cachedAgents : this.widgetAgentsFromManager();
|
|
244
|
+
}
|
|
245
|
+
canOpenHistory(record) {
|
|
246
|
+
return this.openOptions.canOpenHistory?.(record) ?? true;
|
|
270
247
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
248
|
+
historyOpenabilityKey(record) {
|
|
249
|
+
return JSON.stringify([
|
|
250
|
+
record.status,
|
|
251
|
+
record.completedAt ?? null,
|
|
252
|
+
record.transcriptPath ?? null,
|
|
253
|
+
record.session !== undefined,
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Cache terminal history capability by the record state that can affect it.
|
|
258
|
+
* The supplied predicate may call `existsSync`, so it must not run from a
|
|
259
|
+
* timer refresh, render-key calculation, or arrow-navigation path more than
|
|
260
|
+
* once for the same terminal record state.
|
|
261
|
+
*/
|
|
262
|
+
cachedCanOpenHistory(record) {
|
|
263
|
+
if (record.status === "running" || record.status === "queued")
|
|
264
|
+
return true;
|
|
265
|
+
const key = this.historyOpenabilityKey(record);
|
|
266
|
+
const cached = this.historyOpenabilityCache.get(record.id);
|
|
267
|
+
if (cached?.key === key)
|
|
268
|
+
return cached.value;
|
|
269
|
+
const value = this.canOpenHistory(record);
|
|
270
|
+
this.historyOpenabilityCache.set(record.id, { key, value });
|
|
271
|
+
return value;
|
|
272
|
+
}
|
|
273
|
+
/** Refresh the manager snapshot and openability cache at lifecycle/update time. */
|
|
274
|
+
refreshRoster() {
|
|
275
|
+
const agents = this.widgetAgentsFromManager();
|
|
276
|
+
const key = JSON.stringify({
|
|
277
|
+
mode: this.mode(),
|
|
278
|
+
agents: agents.map(record => [
|
|
279
|
+
record.id,
|
|
280
|
+
record.type,
|
|
281
|
+
record.description,
|
|
282
|
+
record.status,
|
|
283
|
+
record.completedAt ?? null,
|
|
284
|
+
record.startedAt,
|
|
285
|
+
record.toolUses,
|
|
286
|
+
record.error ?? null,
|
|
287
|
+
record.transcriptPath ?? null,
|
|
288
|
+
record.session !== undefined,
|
|
289
|
+
record.isBackground,
|
|
290
|
+
record.status !== "running" && record.status !== "queued"
|
|
291
|
+
? this.cachedCanOpenHistory(record)
|
|
292
|
+
: undefined,
|
|
293
|
+
]),
|
|
294
|
+
});
|
|
295
|
+
if (this.rosterCacheInitialized && key === this.rosterCacheKey)
|
|
296
|
+
return this.cachedRoster;
|
|
297
|
+
const finished = agents.filter(record => record.status !== "running" && record.status !== "queued"
|
|
298
|
+
&& record.completedAt !== undefined
|
|
299
|
+
&& this.cachedCanOpenHistory(record));
|
|
300
|
+
const running = agents.filter(record => record.status === "running");
|
|
301
|
+
const queued = agents.filter(record => record.status === "queued");
|
|
302
|
+
this.cachedAgents = agents;
|
|
303
|
+
this.cachedRoster = [...running, ...queued, ...finished];
|
|
304
|
+
this.rosterCacheKey = key;
|
|
305
|
+
this.rosterCacheInitialized = true;
|
|
306
|
+
return this.cachedRoster;
|
|
274
307
|
}
|
|
275
308
|
/** Set the UI context (grabbed from first tool execution). */
|
|
276
309
|
setUICtx(ctx) {
|
|
277
310
|
if (ctx === this.uiCtx)
|
|
278
311
|
return false;
|
|
279
|
-
|
|
280
|
-
|
|
312
|
+
// UICtx changed — the widget and input handler registered on the old
|
|
313
|
+
// context are gone. Re-register both on the next update().
|
|
314
|
+
this.inputUnsub?.();
|
|
315
|
+
this.inputUnsub = undefined;
|
|
281
316
|
this.uiCtx = ctx;
|
|
282
317
|
this.widgetRegistered = false;
|
|
283
318
|
this.tui = undefined;
|
|
284
319
|
this.lastStatusText = undefined;
|
|
285
|
-
this.
|
|
320
|
+
this.lastRenderKey = undefined;
|
|
286
321
|
this.navigationActive = false;
|
|
287
322
|
this.selectedAgentId = undefined;
|
|
323
|
+
this.selectedRosterIndex = 0;
|
|
288
324
|
this.viewportStart = 0;
|
|
289
|
-
|
|
290
|
-
|
|
325
|
+
this.cachedAgents = [];
|
|
326
|
+
this.cachedRoster = [];
|
|
327
|
+
this.rosterCacheKey = undefined;
|
|
328
|
+
this.rosterCacheInitialized = false;
|
|
329
|
+
this.historyOpenabilityCache.clear();
|
|
330
|
+
// Print/RPC tests and lightweight embedders may provide only the widget
|
|
331
|
+
// surface; real interactive contexts always implement this hook.
|
|
332
|
+
if (typeof ctx.onTerminalInput === "function") {
|
|
333
|
+
this.inputUnsub = ctx.onTerminalInput(data => this.handleKey(data));
|
|
334
|
+
}
|
|
291
335
|
return true;
|
|
292
336
|
}
|
|
293
|
-
/**
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
337
|
+
/** Request a render on the currently registered TUI without touching input. */
|
|
338
|
+
requestUiRefresh(force = true) {
|
|
339
|
+
if (!this.tui || typeof this.tui.requestRender !== "function")
|
|
340
|
+
return false;
|
|
341
|
+
this.tui.requestRender(force);
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
/** Called on each new turn (tool_execution_start). */
|
|
297
345
|
onTurnStart() {
|
|
298
|
-
// Age all finished agents
|
|
299
|
-
for (const [id, age] of this.finishedTurnAge) {
|
|
300
|
-
this.finishedTurnAge.set(id, age + 1);
|
|
301
|
-
}
|
|
302
|
-
// Trigger a widget refresh (will filter out expired agents)
|
|
303
346
|
this.update();
|
|
304
347
|
}
|
|
305
|
-
/**
|
|
348
|
+
/** Keep the spinner/elapsed-time timer alive only while a visible agent runs. */
|
|
306
349
|
ensureTimer() {
|
|
350
|
+
if (!this.uiCtx)
|
|
351
|
+
return;
|
|
352
|
+
const roster = this.rosterCacheInitialized ? this.cachedRoster : this.refreshRoster();
|
|
353
|
+
if (!roster.some(a => a.status === "running"))
|
|
354
|
+
return;
|
|
307
355
|
if (!this.widgetInterval) {
|
|
308
356
|
this.widgetInterval = setInterval(() => this.update(true), 250);
|
|
309
357
|
}
|
|
310
358
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const age = this.finishedTurnAge.get(agentId) ?? 0;
|
|
319
|
-
const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
|
|
320
|
-
return age < maxAge;
|
|
321
|
-
}
|
|
322
|
-
/** Record an agent as finished (call when agent completes). */
|
|
323
|
-
markFinished(agentId) {
|
|
324
|
-
if (!this.finishedTurnAge.has(agentId)) {
|
|
325
|
-
this.finishedTurnAge.set(agentId, 0);
|
|
359
|
+
syncTimer(shouldRun) {
|
|
360
|
+
if (shouldRun) {
|
|
361
|
+
this.ensureTimer();
|
|
362
|
+
}
|
|
363
|
+
else if (this.widgetInterval) {
|
|
364
|
+
clearInterval(this.widgetInterval);
|
|
365
|
+
this.widgetInterval = undefined;
|
|
326
366
|
}
|
|
327
367
|
}
|
|
328
368
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* seen before, so a resumed agent would otherwise keep the age from its
|
|
332
|
-
* previous run — already past the linger limit, hiding the new run's
|
|
333
|
-
* completion line entirely.
|
|
369
|
+
* Retained for the lifecycle call sites. Terminal visibility is determined
|
|
370
|
+
* by the manager record and the history capability, not a one-turn timer.
|
|
334
371
|
*/
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const budget = getWidgetLineBudget(this.tui?.terminal?.rows ?? 24);
|
|
348
|
-
const visibleRows = Math.max(1, budget - 1);
|
|
349
|
-
if (this.selectedRosterIndex < this.viewportStart)
|
|
350
|
-
this.viewportStart = this.selectedRosterIndex;
|
|
351
|
-
if (this.selectedRosterIndex >= this.viewportStart + visibleRows)
|
|
352
|
-
this.viewportStart = this.selectedRosterIndex - visibleRows + 1;
|
|
353
|
-
this.viewportStart = Math.max(0, Math.min(this.viewportStart, Math.max(0, roster.length - visibleRows)));
|
|
372
|
+
markFinished(_agentId) { }
|
|
373
|
+
/** Retained for lifecycle call sites; terminal rows are record-driven now. */
|
|
374
|
+
markRunning(_agentId) { }
|
|
375
|
+
/**
|
|
376
|
+
* Records represented by selectable rows in the above-editor widget.
|
|
377
|
+
*
|
|
378
|
+
* Keep this order as the single source of truth for both rendering and key
|
|
379
|
+
* navigation. `listAgents()` is newest-first; active rows come first so the
|
|
380
|
+
* panel exposes currently useful work before terminal history.
|
|
381
|
+
*/
|
|
382
|
+
roster() {
|
|
383
|
+
return this.rosterCacheInitialized ? this.cachedRoster : this.refreshRoster();
|
|
354
384
|
}
|
|
385
|
+
/** True when pi's prompt editor owns the keyboard. */
|
|
355
386
|
editorHasFocus() {
|
|
356
387
|
const focused = this.tui?.focusedComponent;
|
|
357
388
|
return focused == null || focused instanceof Editor;
|
|
358
389
|
}
|
|
390
|
+
selectedIndexOf(records) {
|
|
391
|
+
if (!this.selectedAgentId)
|
|
392
|
+
return -1;
|
|
393
|
+
return records.findIndex(record => record.id === this.selectedAgentId);
|
|
394
|
+
}
|
|
359
395
|
deactivate() {
|
|
396
|
+
// Keep the selected row and viewport so re-entering navigation can resume
|
|
397
|
+
// where the user left off. Lifecycle resets (context, dispose, empty
|
|
398
|
+
// roster) clear this state explicitly instead of treating every exit as a
|
|
399
|
+
// reset.
|
|
360
400
|
this.navigationActive = false;
|
|
361
401
|
this.requestUiRefresh();
|
|
362
402
|
}
|
|
363
|
-
|
|
403
|
+
/** Resume navigation from the retained row, or select the first row. */
|
|
404
|
+
activate(records) {
|
|
405
|
+
this.navigationActive = true;
|
|
406
|
+
const selectedIndex = this.selectedIndexOf(records);
|
|
407
|
+
if (selectedIndex >= 0) {
|
|
408
|
+
this.selectedRosterIndex = selectedIndex;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
// The previously selected row may have been cleaned up while navigation
|
|
412
|
+
// was inactive. Resume at its old logical position, clamped to the new
|
|
413
|
+
// roster, rather than jumping back to the first row.
|
|
414
|
+
const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, records.length - 1));
|
|
415
|
+
this.selectedAgentId = records[fallbackIndex].id;
|
|
416
|
+
this.selectedRosterIndex = fallbackIndex;
|
|
417
|
+
this.viewportStart = Math.min(this.viewportStart, Math.max(0, records.length - 1));
|
|
418
|
+
}
|
|
419
|
+
this.requestUiRefresh();
|
|
420
|
+
}
|
|
421
|
+
/** Move the selected row, activating only from an empty focused editor. */
|
|
422
|
+
moveSelection(direction) {
|
|
423
|
+
const records = this.roster();
|
|
424
|
+
const ui = this.uiCtx;
|
|
425
|
+
if (records.length === 0 || !ui)
|
|
426
|
+
return false;
|
|
427
|
+
if (!this.navigationActive) {
|
|
428
|
+
if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "")
|
|
429
|
+
return false;
|
|
430
|
+
this.activate(records);
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
const currentIndex = Math.max(0, this.selectedIndexOf(records));
|
|
434
|
+
if (direction === -1 && currentIndex === 0) {
|
|
435
|
+
this.deactivate();
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
const nextIndex = Math.max(0, Math.min(records.length - 1, currentIndex + direction));
|
|
439
|
+
this.selectedAgentId = records[nextIndex].id;
|
|
440
|
+
this.selectedRosterIndex = nextIndex;
|
|
441
|
+
this.requestUiRefresh();
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
openSelected() {
|
|
445
|
+
const record = this.roster().find(candidate => candidate.id === this.selectedAgentId);
|
|
446
|
+
this.deactivate();
|
|
447
|
+
if (!record)
|
|
448
|
+
return;
|
|
449
|
+
const mode = record.status === "running" || record.status === "queued" ? "live" : "history";
|
|
450
|
+
void this.openOptions.onOpen?.(record, mode);
|
|
451
|
+
}
|
|
452
|
+
/** Handle terminal input before it reaches the focused prompt editor. */
|
|
453
|
+
handleKey(data) {
|
|
364
454
|
if (!this.uiCtx || isKeyRelease(data))
|
|
365
455
|
return undefined;
|
|
366
|
-
if (this.navigationSuspended)
|
|
367
|
-
return undefined;
|
|
368
456
|
if (!this.editorHasFocus()) {
|
|
369
457
|
if (this.navigationActive)
|
|
370
458
|
this.deactivate();
|
|
371
459
|
return undefined;
|
|
372
460
|
}
|
|
373
461
|
if (!this.navigationActive) {
|
|
374
|
-
const
|
|
375
|
-
if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" ||
|
|
462
|
+
const records = this.roster();
|
|
463
|
+
if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || records.length === 0) {
|
|
376
464
|
return undefined;
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
this.selectedRosterIndex = index >= 0 ? index : 0;
|
|
380
|
-
this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
|
|
381
|
-
this.updateViewport(roster);
|
|
382
|
-
this.requestUiRefresh();
|
|
465
|
+
}
|
|
466
|
+
this.activate(records);
|
|
383
467
|
return { consume: true };
|
|
384
468
|
}
|
|
385
469
|
if (matchesKey(data, "escape")) {
|
|
386
470
|
this.deactivate();
|
|
387
471
|
return { consume: true };
|
|
388
472
|
}
|
|
389
|
-
if (matchesKey(data, "up")
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (matchesKey(data, "up") && this.selectedRosterIndex === 0) {
|
|
394
|
-
this.deactivate();
|
|
395
|
-
}
|
|
396
|
-
else {
|
|
397
|
-
this.selectedRosterIndex = Math.max(0, Math.min(roster.length - 1, this.selectedRosterIndex + (matchesKey(data, "down") ? 1 : -1)));
|
|
398
|
-
this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
|
|
399
|
-
this.updateViewport(roster);
|
|
400
|
-
this.requestUiRefresh();
|
|
401
|
-
}
|
|
402
|
-
return { consume: true };
|
|
403
|
-
}
|
|
473
|
+
if (matchesKey(data, "up"))
|
|
474
|
+
return this.moveSelection(-1) ? { consume: true } : undefined;
|
|
475
|
+
if (matchesKey(data, "down"))
|
|
476
|
+
return this.moveSelection(1) ? { consume: true } : undefined;
|
|
404
477
|
if (matchesKey(data, Key.enter)) {
|
|
405
|
-
|
|
406
|
-
if (record) {
|
|
407
|
-
const terminal = record.status !== "running" && record.status !== "queued";
|
|
408
|
-
if (!terminal || (this.openOptions.canOpenHistory?.(record) ?? true)) {
|
|
409
|
-
this.openOptions.onOpen?.(record, terminal ? "history" : "live");
|
|
410
|
-
}
|
|
411
|
-
}
|
|
478
|
+
this.openSelected();
|
|
412
479
|
return { consume: true };
|
|
413
480
|
}
|
|
414
|
-
// Only
|
|
481
|
+
// Only ↑/↓ navigate. Other keys, including j/k/←/→, flow to the editor
|
|
482
|
+
// and leave navigation mode.
|
|
415
483
|
this.deactivate();
|
|
416
484
|
return undefined;
|
|
417
485
|
}
|
|
418
|
-
requestUiRefresh(force = true) {
|
|
419
|
-
if (!this.tui || typeof this.tui.requestRender !== "function")
|
|
420
|
-
return false;
|
|
421
|
-
this.tui.requestRender(force);
|
|
422
|
-
return true;
|
|
423
|
-
}
|
|
424
|
-
selectionBullet(agentId, theme) {
|
|
425
|
-
return this.navigationActive && this.selectedAgentId === agentId
|
|
426
|
-
? theme.fg("accent", "●")
|
|
427
|
-
: theme.fg("dim", "○");
|
|
428
|
-
}
|
|
429
486
|
/** Render a finished agent line. */
|
|
430
487
|
renderFinishedLine(a, theme) {
|
|
431
488
|
const modeLabel = getPromptModeLabel(a.type);
|
|
@@ -446,7 +503,7 @@ export class AgentWidget {
|
|
|
446
503
|
}
|
|
447
504
|
else if (a.status === "error") {
|
|
448
505
|
icon = theme.fg("error", "✗");
|
|
449
|
-
const errMsg = a.error ? `: ${a.error
|
|
506
|
+
const errMsg = a.error ? `: ${truncateLine(a.error)}` : "";
|
|
450
507
|
statusText = theme.fg("error", ` error${errMsg}`);
|
|
451
508
|
}
|
|
452
509
|
else {
|
|
@@ -460,15 +517,12 @@ export class AgentWidget {
|
|
|
460
517
|
parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
461
518
|
if (a.toolUses > 0)
|
|
462
519
|
parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
|
|
463
|
-
|
|
464
|
-
// moment an agent finishes, and "what did it cost" is a question asked
|
|
465
|
-
// about finished agents.
|
|
466
|
-
const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
|
|
520
|
+
const costText = this.showCost() && a.lifetimeUsage ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
|
|
467
521
|
if (costText)
|
|
468
522
|
parts.push(costText);
|
|
469
523
|
parts.push(duration);
|
|
470
524
|
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
471
|
-
const description = a.description
|
|
525
|
+
const description = truncateLine(a.description);
|
|
472
526
|
return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
|
|
473
527
|
}
|
|
474
528
|
/**
|
|
@@ -476,29 +530,31 @@ export class AgentWidget {
|
|
|
476
530
|
* reading live state each time instead of capturing it in a closure.
|
|
477
531
|
*/
|
|
478
532
|
renderWidget(tui, theme) {
|
|
479
|
-
const
|
|
480
|
-
const running =
|
|
481
|
-
const queued =
|
|
482
|
-
const finished =
|
|
483
|
-
|
|
533
|
+
const roster = this.roster();
|
|
534
|
+
const running = roster.filter(a => a.status === "running");
|
|
535
|
+
const queued = roster.filter(a => a.status === "queued");
|
|
536
|
+
const finished = roster.filter(a => a.status !== "running" && a.status !== "queued");
|
|
537
|
+
const selectedId = this.navigationActive ? this.selectedAgentId : undefined;
|
|
484
538
|
const hasActive = running.length > 0 || queued.length > 0;
|
|
485
539
|
const hasFinished = finished.length > 0;
|
|
486
540
|
// Nothing to show — return empty (widget will be unregistered by update())
|
|
487
541
|
if (!hasActive && !hasFinished)
|
|
488
542
|
return [];
|
|
489
543
|
const w = tui.terminal.columns;
|
|
490
|
-
const
|
|
491
|
-
if (
|
|
544
|
+
const maxLines = getWidgetLineBudget(tui.terminal.rows);
|
|
545
|
+
if (maxLines === 0)
|
|
492
546
|
return [];
|
|
493
|
-
const truncate = (line) => truncateToWidth(line.
|
|
547
|
+
const truncate = (line, width = w) => truncateToWidth(line, Math.max(0, width));
|
|
494
548
|
const headingColor = hasActive ? "accent" : "dim";
|
|
495
549
|
const headingIcon = hasActive ? "●" : "○";
|
|
496
550
|
const frame = SPINNER[this.widgetFrame % SPINNER.length];
|
|
497
|
-
// Build sections separately for overflow-aware assembly.
|
|
498
|
-
// Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
|
|
499
551
|
const finishedLines = [];
|
|
500
552
|
for (const a of finished) {
|
|
501
|
-
|
|
553
|
+
const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
|
|
554
|
+
finishedLines.push({
|
|
555
|
+
record: a,
|
|
556
|
+
lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ` + this.renderFinishedLine(a, theme))],
|
|
557
|
+
});
|
|
502
558
|
}
|
|
503
559
|
const runningLines = []; // each entry is [header, activity]
|
|
504
560
|
for (const a of running) {
|
|
@@ -508,18 +564,13 @@ export class AgentWidget {
|
|
|
508
564
|
const bg = this.agentActivity.get(a.id);
|
|
509
565
|
const toolUses = bg?.toolUses ?? a.toolUses;
|
|
510
566
|
// Spend comes from the record, never from the activity tracker: the record
|
|
511
|
-
//
|
|
512
|
-
// folds a hidden child's spend into. Reading the tracker while an agent
|
|
513
|
-
// runs and the record once it stops made the figure jump at completion.
|
|
567
|
+
// survives the agent finishing and includes nested-child usage.
|
|
514
568
|
const tokens = getLifetimeTotal(a.lifetimeUsage);
|
|
515
569
|
const contextPercent = getSessionContextPercent(bg?.session);
|
|
516
570
|
const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
|
|
517
571
|
const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
|
|
518
572
|
const parts = [];
|
|
519
573
|
if (this.showModel()) {
|
|
520
|
-
// Leading, and paired: a thinking level means nothing without the model
|
|
521
|
-
// it applies to. The tag is taken from buildInvocationTags rather than
|
|
522
|
-
// rebuilt so the "(asked X)" annotation survives.
|
|
523
574
|
const { modelName, tags } = buildInvocationTags(a.invocation);
|
|
524
575
|
if (modelName)
|
|
525
576
|
parts.push(modelName);
|
|
@@ -537,166 +588,294 @@ export class AgentWidget {
|
|
|
537
588
|
parts.push(costText);
|
|
538
589
|
parts.push(elapsed);
|
|
539
590
|
const statsText = parts.join(" · ");
|
|
540
|
-
const activity =
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
591
|
+
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
|
|
592
|
+
const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
|
|
593
|
+
runningLines.push({
|
|
594
|
+
record: a,
|
|
595
|
+
lines: [
|
|
596
|
+
truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", truncateLine(a.description))} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
|
|
597
|
+
truncate(theme.fg("dim", "│ ") + ` ${theme.fg("dim", `⎿ ${truncateLine(activity)}`)}`),
|
|
598
|
+
],
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const queuedLines = queued.map(a => {
|
|
602
|
+
const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
|
|
603
|
+
return {
|
|
604
|
+
record: a,
|
|
605
|
+
lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${getDisplayName(a.type)} ${truncateLine(a.description)} · queued`)}`)],
|
|
606
|
+
};
|
|
607
|
+
});
|
|
608
|
+
// Keep the idle widget compact: a queue is one logical status line until
|
|
609
|
+
// navigation starts. While navigating, expand it back to the same per-agent
|
|
610
|
+
// roster used by key handling so every selected queued record can render.
|
|
611
|
+
const queuedSummary = queued.length > 0 && !this.navigationActive
|
|
612
|
+
? [{
|
|
613
|
+
record: queued[0],
|
|
614
|
+
recordCount: queued.length,
|
|
615
|
+
lines: [truncate(theme.fg("dim", "├─") + ` ${theme.fg("dim", "○")} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)],
|
|
616
|
+
}]
|
|
617
|
+
: [];
|
|
618
|
+
// Assemble with a responsive cap (heading + overflow indicator = 2
|
|
619
|
+
// reserved lines when content exceeds the available body budget).
|
|
620
|
+
const maxBody = maxLines - 1; // heading takes 1 line
|
|
621
|
+
const rows = [
|
|
622
|
+
...runningLines,
|
|
623
|
+
...(this.navigationActive ? queuedLines : queuedSummary),
|
|
624
|
+
...finishedLines,
|
|
625
|
+
];
|
|
626
|
+
const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
|
|
627
|
+
const heading = "Agents ↑↓ select · enter view · esc back";
|
|
628
|
+
const lines = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, heading))];
|
|
629
|
+
if (maxLines === 1) {
|
|
630
|
+
// There is room only for the heading; do not consume the editor/input row.
|
|
631
|
+
return lines;
|
|
632
|
+
}
|
|
555
633
|
if (totalBody <= maxBody) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
lines.
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// The last two lines are the last running agent's header + activity.
|
|
570
|
-
if (last >= 2) {
|
|
571
|
-
lines[last - 1] = lines[last - 1].replace("├─", "└─");
|
|
572
|
-
lines[last] = lines[last].replace("│ ", " ");
|
|
573
|
-
}
|
|
634
|
+
this.viewportStart = 0;
|
|
635
|
+
for (const row of rows)
|
|
636
|
+
lines.push(...row.lines);
|
|
637
|
+
if (rows.length > 0) {
|
|
638
|
+
const lastRow = rows[rows.length - 1];
|
|
639
|
+
const lastStart = lines.length - lastRow.lines.length;
|
|
640
|
+
// Keep the selected row's bullet adjacent to the normal branch marker.
|
|
641
|
+
// This makes the active row unambiguous even when it is the last row.
|
|
642
|
+
const lastRowSelected = this.navigationActive && lastRow.record.id === selectedId;
|
|
643
|
+
if (!lastRowSelected)
|
|
644
|
+
lines[lastStart] = lines[lastStart].replace("├─", "└─");
|
|
645
|
+
if (lastRow.lines.length === 2) {
|
|
646
|
+
lines[lastStart + 1] = lines[lastStart + 1].replace("│ ", " ");
|
|
574
647
|
}
|
|
575
648
|
}
|
|
576
649
|
}
|
|
577
|
-
else {
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
for (const pair of runningLines) {
|
|
594
|
-
if (budget >= 2) {
|
|
595
|
-
lines.push(...pair);
|
|
596
|
-
budget -= 2;
|
|
650
|
+
else if (!this.navigationActive && queuedSummary.length > 0) {
|
|
651
|
+
// In the idle view the queue is deliberately one summary row. Preserve
|
|
652
|
+
// that row before spending space on terminal history, and account for
|
|
653
|
+
// the real number of queued records if the summary itself cannot fit.
|
|
654
|
+
// Navigation expands the queue into per-record rows and uses the full
|
|
655
|
+
// contiguous viewport below.
|
|
656
|
+
const visibleRows = [];
|
|
657
|
+
const hiddenRows = [];
|
|
658
|
+
let bodyBudget = Math.max(0, maxBody - 1); // reserve the footer
|
|
659
|
+
let runningBudget = Math.max(0, bodyBudget - 1); // reserve queued summary
|
|
660
|
+
for (const row of runningLines) {
|
|
661
|
+
const height = row.lines.length;
|
|
662
|
+
if (height <= runningBudget) {
|
|
663
|
+
visibleRows.push(row);
|
|
664
|
+
runningBudget -= height;
|
|
665
|
+
bodyBudget -= height;
|
|
597
666
|
}
|
|
598
667
|
else {
|
|
599
|
-
|
|
668
|
+
hiddenRows.push(row);
|
|
600
669
|
}
|
|
601
670
|
}
|
|
602
|
-
|
|
603
|
-
if (
|
|
604
|
-
|
|
605
|
-
lines.
|
|
606
|
-
budget--;
|
|
671
|
+
const summaryRow = queuedSummary[0];
|
|
672
|
+
if (bodyBudget > 0) {
|
|
673
|
+
visibleRows.push(summaryRow);
|
|
674
|
+
bodyBudget -= summaryRow.lines.length;
|
|
607
675
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
676
|
+
else {
|
|
677
|
+
hiddenRows.push(summaryRow);
|
|
678
|
+
}
|
|
679
|
+
for (const row of finishedLines) {
|
|
680
|
+
const height = row.lines.length;
|
|
681
|
+
if (height <= bodyBudget) {
|
|
682
|
+
visibleRows.push(row);
|
|
683
|
+
bodyBudget -= height;
|
|
613
684
|
}
|
|
614
685
|
else {
|
|
615
|
-
|
|
686
|
+
hiddenRows.push(row);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
for (const row of visibleRows)
|
|
690
|
+
lines.push(...row.lines);
|
|
691
|
+
if (visibleRows.length > 0) {
|
|
692
|
+
const lastRow = visibleRows[visibleRows.length - 1];
|
|
693
|
+
const lastStart = lines.length - lastRow.lines.length;
|
|
694
|
+
lines[lastStart] = lines[lastStart].replace("├─", "└─");
|
|
695
|
+
if (lastRow.lines.length === 2) {
|
|
696
|
+
lines[lastStart + 1] = lines[lastStart + 1].replace("│ ", " ");
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const hiddenCount = (rows) => rows.reduce((total, row) => total + (row.recordCount ?? 1), 0);
|
|
700
|
+
const categoryCount = (status) => hiddenRows
|
|
701
|
+
.filter(row => row.record.status === status)
|
|
702
|
+
.reduce((total, row) => total + (row.recordCount ?? 1), 0);
|
|
703
|
+
const finishedCount = hiddenRows
|
|
704
|
+
.filter(row => row.record.status !== "running" && row.record.status !== "queued")
|
|
705
|
+
.reduce((total, row) => total + (row.recordCount ?? 1), 0);
|
|
706
|
+
const categoryCounts = [
|
|
707
|
+
["running", categoryCount("running")],
|
|
708
|
+
["queued", categoryCount("queued")],
|
|
709
|
+
["finished", finishedCount],
|
|
710
|
+
].filter(([, count]) => count > 0).map(([label, count]) => `${count} ${label}`);
|
|
711
|
+
const hidden = hiddenCount(hiddenRows);
|
|
712
|
+
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hidden} more (${categoryCounts.join(", ")})`)}`));
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
// Reserve one line for a directional overflow summary. The viewport is
|
|
716
|
+
// a contiguous slice in roster order, so the same slice is navigable and
|
|
717
|
+
// renderable even when the selected row is currently hidden.
|
|
718
|
+
const baseContentBudget = Math.max(0, maxBody - 1);
|
|
719
|
+
const heightAt = (index) => rows[index]?.lines.length ?? 0;
|
|
720
|
+
const endFor = (start, budget) => {
|
|
721
|
+
let used = 0;
|
|
722
|
+
let end = start;
|
|
723
|
+
while (end < rows.length && used + heightAt(end) <= budget) {
|
|
724
|
+
used += heightAt(end++);
|
|
616
725
|
}
|
|
726
|
+
return end;
|
|
727
|
+
};
|
|
728
|
+
let start = Math.max(0, Math.min(this.viewportStart, Math.max(0, rows.length - 1)));
|
|
729
|
+
const selectedIndex = this.navigationActive && selectedId
|
|
730
|
+
? rows.findIndex(row => row.record.id === selectedId)
|
|
731
|
+
: -1;
|
|
732
|
+
if (selectedIndex >= 0) {
|
|
733
|
+
if (selectedIndex < start)
|
|
734
|
+
start = selectedIndex;
|
|
735
|
+
if (selectedIndex >= endFor(start, baseContentBudget))
|
|
736
|
+
start = selectedIndex;
|
|
737
|
+
}
|
|
738
|
+
// Keep both directional affordances in the viewport chrome: the top
|
|
739
|
+
// count sits below the heading and the bottom count sits below the
|
|
740
|
+
// bounded roster. Each consumes one row when that direction is hidden.
|
|
741
|
+
let showTopMore = start > 0;
|
|
742
|
+
let contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
|
|
743
|
+
if (selectedIndex >= 0 && selectedIndex >= endFor(start, contentBudget)) {
|
|
744
|
+
start = selectedIndex;
|
|
745
|
+
showTopMore = start > 0;
|
|
746
|
+
contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
|
|
617
747
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
overflowParts.push(`${hiddenRunning} running`);
|
|
627
|
-
if (hiddenFinished > 0)
|
|
628
|
-
overflowParts.push(`${hiddenFinished} finished`);
|
|
629
|
-
if (hiddenQueued > 0)
|
|
630
|
-
overflowParts.push(`${hiddenQueued} queued`);
|
|
631
|
-
const hiddenTotal = hiddenRunning + hiddenFinished + hiddenQueued;
|
|
632
|
-
const overflowText = overflowParts.join(", ");
|
|
633
|
-
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenTotal} more (${overflowText})`)}`));
|
|
634
|
-
}
|
|
635
|
-
if (this.navigationActive) {
|
|
636
|
-
const roster = this.navigationAgents();
|
|
637
|
-
const selected = roster[this.selectedRosterIndex];
|
|
638
|
-
const hasSelected = selected ? lines.some(line => line.includes(selected.description.replace(/[\r\n]+/g, " "))) : true;
|
|
639
|
-
if (selected && !hasSelected) {
|
|
640
|
-
const selectedLine = truncate(theme.fg("dim", "└─") + ` ${this.selectionBullet(selected.id, theme)} ${selected.status === "queued" ? "◦" : "⠋"} ${theme.fg("muted", selected.description.replace(/[\r\n]+/g, " "))}`);
|
|
641
|
-
if (lines.length >= budget)
|
|
642
|
-
lines[lines.length - 1] = selectedLine;
|
|
643
|
-
else
|
|
644
|
-
lines.push(selectedLine);
|
|
748
|
+
this.viewportStart = start;
|
|
749
|
+
let used = 0;
|
|
750
|
+
let end = start;
|
|
751
|
+
const visibleBody = [];
|
|
752
|
+
while (end < rows.length && used + heightAt(end) <= contentBudget) {
|
|
753
|
+
visibleBody.push(...rows[end].lines);
|
|
754
|
+
used += heightAt(end);
|
|
755
|
+
end++;
|
|
645
756
|
}
|
|
646
|
-
if
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
757
|
+
// A selected two-line row must remain addressable even if only one body
|
|
758
|
+
// line is available. Showing its header is preferable to hiding it.
|
|
759
|
+
if (end === start && rows[start] && contentBudget > 0) {
|
|
760
|
+
visibleBody.push(rows[start].lines[0]);
|
|
761
|
+
end = start + 1;
|
|
651
762
|
}
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
763
|
+
const countRows = (selectedRows) => selectedRows.reduce((total, row) => total + (row.recordCount ?? 1), 0);
|
|
764
|
+
const hiddenBefore = countRows(rows.slice(0, start));
|
|
765
|
+
const hiddenAfter = countRows(rows.slice(end));
|
|
766
|
+
const hidden = hiddenBefore + hiddenAfter;
|
|
767
|
+
const hiddenRows = rows.filter((_row, index) => index < start || index >= end);
|
|
768
|
+
const categoryCount = (status) => hiddenRows
|
|
769
|
+
.filter(row => row.record.status === status)
|
|
770
|
+
.reduce((total, row) => total + (row.recordCount ?? 1), 0);
|
|
771
|
+
const categoryCounts = [
|
|
772
|
+
["running", categoryCount("running")],
|
|
773
|
+
["queued", categoryCount("queued")],
|
|
774
|
+
["finished", hiddenRows
|
|
775
|
+
.filter(row => row.record.status !== "running" && row.record.status !== "queued")
|
|
776
|
+
.reduce((total, row) => total + (row.recordCount ?? 1), 0)],
|
|
777
|
+
].filter(([, count]) => count > 0).map(([label, count]) => `${count} ${label}`);
|
|
778
|
+
const direction = [
|
|
779
|
+
...(hiddenBefore > 0 ? [`↑ ${hiddenBefore} more`] : []),
|
|
780
|
+
...(hiddenAfter > 0 ? [`↓ ${hiddenAfter} more`] : []),
|
|
781
|
+
].join(" · ");
|
|
782
|
+
const summary = `+${hidden} more (${direction}${categoryCounts.length > 0 ? `; ${categoryCounts.join(", ")}` : ""})`;
|
|
783
|
+
if (showTopMore)
|
|
784
|
+
lines.push(truncate(theme.fg("dim", `↑ ${hiddenBefore} more`)));
|
|
785
|
+
// Fill the bounded viewport so the scrollbar track has a stable height.
|
|
786
|
+
while (visibleBody.length < contentBudget)
|
|
787
|
+
visibleBody.push("");
|
|
788
|
+
const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
|
|
789
|
+
const startOffset = rows.slice(0, start).reduce((total, row) => total + row.lines.length, 0);
|
|
790
|
+
const trackHeight = Math.max(0, contentBudget);
|
|
791
|
+
const minThumbHeight = Math.min(2, trackHeight);
|
|
792
|
+
const thumbHeight = Math.max(minThumbHeight, Math.min(trackHeight, Math.round((trackHeight * trackHeight) / totalBody)));
|
|
793
|
+
const maxScrollTop = Math.max(0, totalBody - trackHeight);
|
|
794
|
+
const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
|
|
795
|
+
const thumbOffset = maxScrollTop === 0
|
|
796
|
+
? 0
|
|
797
|
+
: Math.round((startOffset / maxScrollTop) * maxThumbTop);
|
|
798
|
+
const contentWidth = Math.max(0, w - 1);
|
|
799
|
+
for (let index = 0; index < visibleBody.length; index++) {
|
|
800
|
+
const isThumb = index >= thumbOffset && index < thumbOffset + thumbHeight;
|
|
801
|
+
const scrollbar = isThumb
|
|
802
|
+
? theme.fg("scrollbarThumb", this.navigationActive ? "█" : "┃")
|
|
803
|
+
: theme.fg("scrollbarTrack", "│");
|
|
804
|
+
const bodyLine = truncate(visibleBody[index] ?? "", contentWidth);
|
|
805
|
+
lines.push(bodyLine + " ".repeat(Math.max(0, contentWidth - visibleWidth(bodyLine))) + scrollbar);
|
|
806
|
+
}
|
|
807
|
+
// The directional count is outside the viewport, like ScrollView's
|
|
808
|
+
// surrounding chrome, and therefore does not consume scrollbar track.
|
|
809
|
+
if (hiddenAfter > 0) {
|
|
810
|
+
lines.push(truncate(theme.fg("dim", `↓ ${hiddenAfter} more · +${hidden} more (${categoryCounts.length > 0 ? categoryCounts.join(", ") : ""})`)));
|
|
811
|
+
}
|
|
812
|
+
else if (!showTopMore) {
|
|
813
|
+
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", summary)}`));
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
// Keep a stable footer row when the selected viewport reaches the end.
|
|
817
|
+
lines.push(truncate(theme.fg("dim", "└─")));
|
|
656
818
|
}
|
|
657
|
-
if (lines.length > budget)
|
|
658
|
-
lines.length = budget;
|
|
659
819
|
}
|
|
660
820
|
return lines;
|
|
661
821
|
}
|
|
662
|
-
/**
|
|
822
|
+
/** Build a render-relevant snapshot without capturing mutable records. */
|
|
823
|
+
renderKey(allAgents) {
|
|
824
|
+
const activities = allAgents.map(a => {
|
|
825
|
+
const activity = this.agentActivity.get(a.id);
|
|
826
|
+
return activity ? {
|
|
827
|
+
id: a.id,
|
|
828
|
+
activeTools: [...activity.activeTools.entries()],
|
|
829
|
+
toolUses: activity.toolUses,
|
|
830
|
+
responseText: activity.responseText,
|
|
831
|
+
turnCount: activity.turnCount,
|
|
832
|
+
maxTurns: activity.maxTurns,
|
|
833
|
+
lifetimeUsage: activity.lifetimeUsage,
|
|
834
|
+
contextPercent: getSessionContextPercent(activity.session),
|
|
835
|
+
} : undefined;
|
|
836
|
+
});
|
|
837
|
+
return JSON.stringify({
|
|
838
|
+
frame: this.widgetFrame,
|
|
839
|
+
agents: allAgents.map(a => ({
|
|
840
|
+
id: a.id,
|
|
841
|
+
type: a.type,
|
|
842
|
+
description: a.description,
|
|
843
|
+
status: a.status,
|
|
844
|
+
toolUses: a.toolUses,
|
|
845
|
+
startedAt: a.startedAt,
|
|
846
|
+
completedAt: a.completedAt,
|
|
847
|
+
error: a.error,
|
|
848
|
+
lifetimeUsage: a.lifetimeUsage,
|
|
849
|
+
compactionCount: a.compactionCount,
|
|
850
|
+
isBackground: a.isBackground,
|
|
851
|
+
hasSession: a.session !== undefined,
|
|
852
|
+
transcriptPath: a.transcriptPath,
|
|
853
|
+
openableHistory: this.cachedCanOpenHistory(a),
|
|
854
|
+
})),
|
|
855
|
+
activities,
|
|
856
|
+
navigationActive: this.navigationActive,
|
|
857
|
+
selectedAgentId: this.selectedAgentId,
|
|
858
|
+
viewportStart: this.viewportStart,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
/** Force an immediate widget update. `advanceSpinner` is reserved for the timer. */
|
|
663
862
|
update(advanceSpinner = false) {
|
|
664
863
|
if (!this.uiCtx)
|
|
665
864
|
return;
|
|
865
|
+
// Refresh the manager snapshot once per lifecycle update. Arrow handling
|
|
866
|
+
// only changes selection and requests a render; it never scans the manager.
|
|
867
|
+
const roster = this.refreshRoster();
|
|
666
868
|
const allAgents = this.widgetAgents();
|
|
667
|
-
if (this.navigationActive || this.selectedAgentId) {
|
|
668
|
-
const roster = this.navigationAgents();
|
|
669
|
-
const selectedIndex = this.selectedAgentId ? roster.findIndex(agent => agent.id === this.selectedAgentId) : -1;
|
|
670
|
-
if (selectedIndex >= 0) {
|
|
671
|
-
this.selectedRosterIndex = selectedIndex;
|
|
672
|
-
}
|
|
673
|
-
else if (roster.length > 0 && this.selectedAgentId) {
|
|
674
|
-
this.selectedRosterIndex = Math.min(this.selectedRosterIndex, roster.length - 1);
|
|
675
|
-
this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
|
|
676
|
-
}
|
|
677
|
-
else if (roster.length === 0) {
|
|
678
|
-
this.navigationActive = false;
|
|
679
|
-
this.selectedAgentId = undefined;
|
|
680
|
-
this.selectedRosterIndex = 0;
|
|
681
|
-
this.viewportStart = 0;
|
|
682
|
-
}
|
|
683
|
-
this.updateViewport(roster);
|
|
684
|
-
}
|
|
685
869
|
// Lightweight existence checks — full categorization happens in renderWidget()
|
|
686
870
|
let runningCount = 0;
|
|
687
871
|
let queuedCount = 0;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (a.status === "running") {
|
|
872
|
+
for (const a of roster) {
|
|
873
|
+
if (a.status === "running")
|
|
691
874
|
runningCount++;
|
|
692
|
-
|
|
693
|
-
else if (a.status === "queued") {
|
|
875
|
+
else if (a.status === "queued")
|
|
694
876
|
queuedCount++;
|
|
695
|
-
}
|
|
696
|
-
else if (a.completedAt && this.shouldShowFinished(a.id, a.status, a.transcriptPath !== undefined)) {
|
|
697
|
-
hasFinished = true;
|
|
698
|
-
}
|
|
699
877
|
}
|
|
878
|
+
const hasFinished = roster.some(a => a.status !== "running" && a.status !== "queued");
|
|
700
879
|
const hasActive = runningCount > 0 || queuedCount > 0;
|
|
701
880
|
// Nothing to show — clear widget
|
|
702
881
|
if (!hasActive && !hasFinished) {
|
|
@@ -709,15 +888,12 @@ export class AgentWidget {
|
|
|
709
888
|
this.uiCtx.setStatus("subagents", undefined);
|
|
710
889
|
this.lastStatusText = undefined;
|
|
711
890
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
if (!allAgents.some(a => a.id === id))
|
|
719
|
-
this.finishedTurnAge.delete(id);
|
|
720
|
-
}
|
|
891
|
+
this.lastRenderKey = undefined;
|
|
892
|
+
this.navigationActive = false;
|
|
893
|
+
this.selectedAgentId = undefined;
|
|
894
|
+
this.selectedRosterIndex = 0;
|
|
895
|
+
this.viewportStart = 0;
|
|
896
|
+
this.syncTimer(false);
|
|
721
897
|
return;
|
|
722
898
|
}
|
|
723
899
|
// Status bar — only call setStatus when the text actually changes
|
|
@@ -735,28 +911,33 @@ export class AgentWidget {
|
|
|
735
911
|
this.uiCtx.setStatus("subagents", newStatusText);
|
|
736
912
|
this.lastStatusText = newStatusText;
|
|
737
913
|
}
|
|
914
|
+
// Spinner animation is driven only by the timer while a visible running
|
|
915
|
+
// agent exists. Event-driven updates refresh changed stats without making
|
|
916
|
+
// an otherwise idle widget advance.
|
|
738
917
|
if (advanceSpinner && runningCount > 0)
|
|
739
918
|
this.widgetFrame++;
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
this.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
919
|
+
this.syncTimer(runningCount > 0);
|
|
920
|
+
const selectedIndex = this.selectedIndexOf(roster);
|
|
921
|
+
if (selectedIndex >= 0) {
|
|
922
|
+
this.selectedRosterIndex = selectedIndex;
|
|
923
|
+
}
|
|
924
|
+
else if (this.navigationActive) {
|
|
925
|
+
if (roster.length === 0) {
|
|
926
|
+
this.navigationActive = false;
|
|
927
|
+
this.selectedAgentId = undefined;
|
|
928
|
+
this.selectedRosterIndex = 0;
|
|
929
|
+
this.viewportStart = 0;
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
// Keep the selection near the row that disappeared. Both the saved
|
|
933
|
+
// logical index and viewport are clamped as the roster shrinks.
|
|
934
|
+
const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, roster.length - 1));
|
|
935
|
+
this.selectedAgentId = roster[fallbackIndex].id;
|
|
936
|
+
this.selectedRosterIndex = fallbackIndex;
|
|
937
|
+
this.viewportStart = Math.min(this.viewportStart, roster.length - 1);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const renderKey = this.renderKey(allAgents);
|
|
760
941
|
// Register widget callback once; subsequent updates use requestRender()
|
|
761
942
|
// which re-invokes render() without replacing the component (avoids layout thrashing).
|
|
762
943
|
if (!this.widgetRegistered) {
|
|
@@ -768,23 +949,20 @@ export class AgentWidget {
|
|
|
768
949
|
// Theme changed — force re-registration so factory captures fresh theme.
|
|
769
950
|
this.widgetRegistered = false;
|
|
770
951
|
this.tui = undefined;
|
|
952
|
+
this.lastRenderKey = undefined;
|
|
771
953
|
},
|
|
772
954
|
};
|
|
773
955
|
}, { placement: "aboveEditor" });
|
|
774
956
|
this.widgetRegistered = true;
|
|
775
957
|
}
|
|
776
|
-
else {
|
|
777
|
-
// Widget already registered —
|
|
958
|
+
else if (renderKey !== this.lastRenderKey) {
|
|
959
|
+
// Widget already registered — request a re-render only when visible state
|
|
960
|
+
// changed (or the spinner timer advanced).
|
|
778
961
|
this.tui?.requestRender();
|
|
779
962
|
}
|
|
963
|
+
this.lastRenderKey = renderKey;
|
|
780
964
|
}
|
|
781
965
|
dispose() {
|
|
782
|
-
this.inputCleanup?.();
|
|
783
|
-
this.inputCleanup = undefined;
|
|
784
|
-
this.navigationActive = false;
|
|
785
|
-
this.selectedAgentId = undefined;
|
|
786
|
-
this.viewportStart = 0;
|
|
787
|
-
this.selectedRosterIndex = 0;
|
|
788
966
|
if (this.widgetInterval) {
|
|
789
967
|
clearInterval(this.widgetInterval);
|
|
790
968
|
this.widgetInterval = undefined;
|
|
@@ -793,10 +971,22 @@ export class AgentWidget {
|
|
|
793
971
|
this.uiCtx.setWidget("agents", undefined);
|
|
794
972
|
this.uiCtx.setStatus("subagents", undefined);
|
|
795
973
|
}
|
|
974
|
+
this.inputUnsub?.();
|
|
975
|
+
this.inputUnsub = undefined;
|
|
796
976
|
this.widgetRegistered = false;
|
|
797
977
|
this.tui = undefined;
|
|
798
978
|
this.lastStatusText = undefined;
|
|
799
|
-
this.
|
|
979
|
+
this.lastRenderKey = undefined;
|
|
980
|
+
this.navigationActive = false;
|
|
981
|
+
this.selectedAgentId = undefined;
|
|
982
|
+
this.selectedRosterIndex = 0;
|
|
983
|
+
this.viewportStart = 0;
|
|
984
|
+
this.cachedAgents = [];
|
|
985
|
+
this.cachedRoster = [];
|
|
986
|
+
this.rosterCacheKey = undefined;
|
|
987
|
+
this.rosterCacheInitialized = false;
|
|
988
|
+
this.historyOpenabilityCache.clear();
|
|
989
|
+
this.uiCtx = undefined;
|
|
800
990
|
}
|
|
801
991
|
}
|
|
802
992
|
//# sourceMappingURL=agent-widget.js.map
|