@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9
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/README.md +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/ui/fleet-list.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
|
|
3
3
|
*
|
|
4
|
+
* Starts as a one-line state/count summary; ctrl+shift+a toggles the full list.
|
|
5
|
+
* ctrl+shift+k, pressed twice within three seconds, stops every running agent and workflow.
|
|
4
6
|
* Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
|
|
5
7
|
* ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
|
|
6
8
|
* Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
|
|
@@ -11,16 +13,32 @@
|
|
|
11
13
|
* can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
import { isKeyRelease, Key, matchesKey, type ScrollViewScrollbar, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import { isKeyRelease, isKeyRepeat, Key, matchesKey, type ScrollViewScrollbar, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
17
|
import { hasAgentBadge, renderAgentName } from "../agent-color.js";
|
|
16
|
-
import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
|
|
18
|
+
import { type AgentManager, isTopLevelAgent, markConsumed } from "../agent-manager.js";
|
|
17
19
|
import { type Clock, realClock, type TimerHandle } from "../clock.js";
|
|
18
20
|
import type { RunningDetached } from "../detached-results.js";
|
|
19
21
|
import { describeModelCall } from "../model-call.js";
|
|
20
22
|
import type { AgentRecord, ViewerMarkdownMode } from "../types.js";
|
|
21
23
|
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
22
|
-
import { type AgentActivity, buildInvocationTags, formatCost, type Theme } from "./agent-widget.js";
|
|
24
|
+
import { type AgentActivity, buildInvocationTags, compactThinkingTag, fgPreservingNestedStyles, formatCompactTokens, formatCost, type Theme } from "./agent-widget.js";
|
|
23
25
|
import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./conversation-viewer.js";
|
|
26
|
+
import { fallbackFlag, hasFallback, withLeadingFlag } from "./fallback-flag.js";
|
|
27
|
+
import { FleetJournal, type FleetJournalEntry, type FleetSnapshot, fleetAlert, MAX_COMPACT_ALERT_COUNT } from "./fleet-state.js";
|
|
28
|
+
|
|
29
|
+
/** Matches the todo/monitor disclosure shortcut family; arrows keep their navigation roles. */
|
|
30
|
+
export const FLEET_COLLAPSE_KEY = "ctrl+shift+a";
|
|
31
|
+
/** Stop every running agent and workflow: Claude Code's `ctrl+x ctrl+k`, minus pi's `ctrl+x` (copy). */
|
|
32
|
+
export const STOP_ALL_KEY = "ctrl+shift+k";
|
|
33
|
+
/** A second stop-all press within this window confirms the first. */
|
|
34
|
+
export const STOP_ALL_CONFIRM_MS = 3000;
|
|
35
|
+
/** Quiet-period debounce bounds journal writes per toggle burst; shutdown flushes immediately. */
|
|
36
|
+
export const FLEET_DISCLOSURE_SETTLE_MS = 300;
|
|
37
|
+
|
|
38
|
+
/** Existing result-consumption semantics also acknowledge a failed fleet row. */
|
|
39
|
+
function isOutstandingFailure(record: AgentRecord): boolean {
|
|
40
|
+
return (record.status === "error" || record.status === "aborted") && !record.resultConsumed;
|
|
41
|
+
}
|
|
24
42
|
|
|
25
43
|
/** Widget key for the below-editor fleet list. */
|
|
26
44
|
const FLEET_KEY = "fleet";
|
|
@@ -67,6 +85,8 @@ export interface FleetWorkflow {
|
|
|
67
85
|
/** Set once the run settles, which is what freezes its clock. */
|
|
68
86
|
completedAt?: number;
|
|
69
87
|
tokens: number;
|
|
88
|
+
/** Stable, weakly observed owner when the source rebuilds its display projection each tick. */
|
|
89
|
+
observationKey?: object;
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
type MainEntry = { kind: "main" };
|
|
@@ -81,11 +101,7 @@ export function formatFleetElapsed(ms: number): string {
|
|
|
81
101
|
|
|
82
102
|
/** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */
|
|
83
103
|
export function formatFleetTokens(count: number): string {
|
|
84
|
-
|
|
85
|
-
if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`;
|
|
86
|
-
else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`;
|
|
87
|
-
else compact = `${count}`;
|
|
88
|
-
return `↓ ${compact} tokens`;
|
|
104
|
+
return `↓ ${formatCompactTokens(count)} tokens`;
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
/**
|
|
@@ -97,19 +113,133 @@ function rightAlign(left: string, right: string, width: number): string {
|
|
|
97
113
|
const rightW = visibleWidth(right);
|
|
98
114
|
const maxLeft = Math.max(0, width - rightW - 1);
|
|
99
115
|
const leftClamped = truncateToWidth(left, maxLeft);
|
|
100
|
-
const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW);
|
|
116
|
+
const gap = Math.max(leftClamped ? 1 : 0, width - visibleWidth(leftClamped) - rightW);
|
|
101
117
|
return truncateToWidth(leftClamped + " ".repeat(gap) + right, width);
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
120
|
+
/** One agent-row tail column. */
|
|
121
|
+
interface TailSlot {
|
|
122
|
+
/**
|
|
123
|
+
* Pad after the cell, left-aligning it. Unset pads before the cell, so every
|
|
124
|
+
* other column ends on the same right edge whether or not a row has a value.
|
|
125
|
+
*/
|
|
126
|
+
readonly padEnd?: true;
|
|
127
|
+
/**
|
|
128
|
+
* How early the column goes on a narrow terminal (1 first). Unset is never
|
|
129
|
+
* dropped, so state, elapsed, tokens and cost survive and the description is
|
|
130
|
+
* what truncates (#144).
|
|
131
|
+
*/
|
|
132
|
+
readonly drop?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Agent-row tail columns, left to right. */
|
|
136
|
+
const TAIL_SLOTS: readonly TailSlot[] = [
|
|
137
|
+
{ padEnd: true }, // state: glyph + label
|
|
138
|
+
{ drop: 3 }, // model
|
|
139
|
+
{ drop: 2 }, // thinking level
|
|
140
|
+
{ drop: 1 }, // live model-call activity
|
|
141
|
+
{}, // elapsed
|
|
142
|
+
{}, // tokens
|
|
143
|
+
{}, // cost
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
/** Columns reserved for the bullet, name and the start of the description. */
|
|
147
|
+
const MIN_LEFT_WIDTH = 24;
|
|
148
|
+
|
|
149
|
+
/** Which bucket a state counts under in an overflow summary. */
|
|
150
|
+
type StateBucket = "running" | "queued" | "paused" | "done" | "stopped" | "failed";
|
|
151
|
+
|
|
152
|
+
/** How one agent or run state reads: a glyph AND a word, so colour is never the only cue. */
|
|
153
|
+
interface FleetState {
|
|
154
|
+
glyph: string;
|
|
155
|
+
label: string;
|
|
156
|
+
color: string;
|
|
157
|
+
bucket: StateBucket;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The state of an agent record. */
|
|
161
|
+
function agentState(status: AgentRecord["status"]): FleetState {
|
|
162
|
+
switch (status) {
|
|
163
|
+
case "queued": return { glyph: "◦", label: "queued", color: "muted", bucket: "queued" };
|
|
164
|
+
case "running": return { glyph: "▶", label: "running", color: "accent", bucket: "running" };
|
|
165
|
+
case "completed": return { glyph: "✓", label: "done", color: "success", bucket: "done" };
|
|
166
|
+
case "steered": return { glyph: "✓", label: "limit", color: "warning", bucket: "done" };
|
|
167
|
+
case "stopped": return { glyph: "■", label: "stopped", color: "dim", bucket: "stopped" };
|
|
168
|
+
case "aborted": return { glyph: "✗", label: "aborted", color: "error", bucket: "failed" };
|
|
169
|
+
case "error": return { glyph: "✗", label: "failed", color: "error", bucket: "failed" };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The bucket a workflow run counts under in an overflow summary. */
|
|
174
|
+
function workflowBucket(status: FleetWorkflow["status"]): StateBucket {
|
|
175
|
+
switch (status) {
|
|
176
|
+
case "running": return "running";
|
|
177
|
+
case "paused": return "paused";
|
|
178
|
+
case "completed": return "done";
|
|
179
|
+
case "failed":
|
|
180
|
+
case "killed": return "failed";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const BUCKET_ORDER: readonly StateBucket[] = ["running", "queued", "paused", "failed", "stopped", "done"];
|
|
185
|
+
|
|
186
|
+
/** `3 running, 1 failed` for the rows an indicator hides. */
|
|
187
|
+
function summarizeHidden(rows: readonly (WorkflowEntry | AgentEntry)[]): string {
|
|
188
|
+
const counts = new Map<StateBucket, number>();
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
const bucket = row.kind === "workflow" ? workflowBucket(row.workflow.status) : agentState(row.record.status).bucket;
|
|
191
|
+
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
|
|
192
|
+
}
|
|
193
|
+
return BUCKET_ORDER.filter(b => counts.has(b)).map(b => `${counts.get(b)} ${b}`).join(", ");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One bounded line, including zero agents; the controller still hides an empty fleet. */
|
|
197
|
+
export function renderFleetSummary(
|
|
198
|
+
agents: readonly AgentRecord[],
|
|
199
|
+
workflows: readonly FleetWorkflow[],
|
|
200
|
+
parked: number,
|
|
201
|
+
showCost: boolean,
|
|
202
|
+
width: number,
|
|
203
|
+
theme: Theme,
|
|
204
|
+
overflowFailures = 0,
|
|
205
|
+
): string {
|
|
206
|
+
const rows: (AgentEntry | WorkflowEntry)[] = [
|
|
207
|
+
...agents.map(record => ({ kind: "agent" as const, record })),
|
|
208
|
+
...workflows.map(workflow => ({ kind: "workflow" as const, workflow })),
|
|
209
|
+
];
|
|
210
|
+
const failed = rows.filter(row => row.kind === "agent"
|
|
211
|
+
? agentState(row.record.status).bucket === "failed"
|
|
212
|
+
: workflowBucket(row.workflow.status) === "failed").length + overflowFailures;
|
|
213
|
+
const paused = workflows.filter(run => run.status === "paused").length;
|
|
214
|
+
const fallback = agents.filter(hasFallback).length;
|
|
215
|
+
// Actionable counts lead. Cap ONLY compact counts, then remove spaces before
|
|
216
|
+
// dropping any alert: all four fit at 20 columns even for arbitrarily large fleets.
|
|
217
|
+
const alerts = [
|
|
218
|
+
{ count: failed, label: "failed", key: "E" },
|
|
219
|
+
{ count: parked, label: "parked", key: "P" },
|
|
220
|
+
{ count: paused, label: "paused", key: "B" },
|
|
221
|
+
{ count: fallback, label: "fallback", key: "F" },
|
|
222
|
+
].filter(alert => alert.count > 0);
|
|
223
|
+
let flagText = alerts.length ? `[${alerts.map(a => `${a.count} ${a.label}`).join(", ")}]` : "";
|
|
224
|
+
if (visibleWidth(flagText) > width) {
|
|
225
|
+
const compact = alerts.map(a => `${a.key}${a.count > MAX_COMPACT_ALERT_COUNT ? `${MAX_COMPACT_ALERT_COUNT}+` : a.count}`);
|
|
226
|
+
flagText = `[${compact.join(" ")}]`;
|
|
227
|
+
if (visibleWidth(flagText) > width) flagText = `[${compact.join("")}]`;
|
|
228
|
+
// Below the supported 20-column floor, shed lowest-priority alerts first.
|
|
229
|
+
// Failure is first and never becomes the shared helper's misleading [fb].
|
|
230
|
+
while (visibleWidth(flagText) > width && compact.length > 1) {
|
|
231
|
+
compact.pop();
|
|
232
|
+
flagText = `[${compact.join("")}]`;
|
|
233
|
+
}
|
|
234
|
+
// `withLeadingFlag` renders nothing at all at zero width.
|
|
235
|
+
if (visibleWidth(flagText) > width) flagText = "!";
|
|
236
|
+
}
|
|
237
|
+
const flag = theme.fg(failed ? "error" : "warning", flagText);
|
|
238
|
+
const cost = showCost ? formatCost(agents.reduce((sum, agent) => sum + getLifetimeCost(agent.lifetimeUsage), 0)) : "";
|
|
239
|
+
const summary = [summarizeHidden(rows), overflowFailures ? `${overflowFailures} more failed` : ""].filter(Boolean).join(", ") || "0 running";
|
|
240
|
+
const text = `agents: ${summary}${parked ? `, ${parked} parked` : ""}${cost ? ` · ${cost}` : ""} · ${FLEET_COLLAPSE_KEY} to expand`;
|
|
241
|
+
return withLeadingFlag("", alerts.length ? flag : "", theme.fg("dim", text), width);
|
|
242
|
+
}
|
|
113
243
|
|
|
114
244
|
export class FleetList {
|
|
115
245
|
private ui: FleetUICtx | undefined;
|
|
@@ -121,6 +251,18 @@ export class FleetList {
|
|
|
121
251
|
private timer: TimerHandle | undefined;
|
|
122
252
|
|
|
123
253
|
private enabled = true;
|
|
254
|
+
/** Explicit session choice. Navigation temporarily reveals rows without changing it. */
|
|
255
|
+
private readonly journal = new FleetJournal();
|
|
256
|
+
private disclosureTimer: TimerHandle | undefined;
|
|
257
|
+
/** Weak observation prevents repeated journal lookups, without retaining manager records. */
|
|
258
|
+
private readonly observed = new WeakMap<AgentRecord, boolean>();
|
|
259
|
+
private readonly observedWorkflows = new WeakMap<object, number>();
|
|
260
|
+
/** Quota parking has no AgentRecord; the scheduler supplies its enabled-job count. */
|
|
261
|
+
private parkedSource: () => number = () => 0;
|
|
262
|
+
/** What the stop-all chord stops; the extension supplies it. */
|
|
263
|
+
private stopAll: () => { agents: number; workflows: number } = () => ({ agents: 0, workflows: 0 });
|
|
264
|
+
/** When the stop-all chord was first pressed, while its confirmation window is open. */
|
|
265
|
+
private stopAllArmedAt: number | undefined;
|
|
124
266
|
/** Whether arrow keys currently navigate the list (vs. flow to the editor). */
|
|
125
267
|
private active = false;
|
|
126
268
|
/** 0 = `main`, 1..N = subagents. */
|
|
@@ -181,8 +323,79 @@ export class FleetList {
|
|
|
181
323
|
*/
|
|
182
324
|
private detachedSource: () => readonly RunningDetached[] = () => [],
|
|
183
325
|
private readonly clock: Clock = realClock,
|
|
326
|
+
/** @internal Host/test session lookup; a session can disappear after roster construction. */
|
|
327
|
+
private readonly options: { sessionFor?: (record: AgentRecord) => AgentRecord["session"] } = {},
|
|
184
328
|
) {}
|
|
185
329
|
|
|
330
|
+
/** UI-only disclosure; never claims a dialog's key or touches running work. */
|
|
331
|
+
toggleCollapse(): void {
|
|
332
|
+
if (!this.enabled || !this.ui || this.viewerClose || this.viewingWorkflowId || !this.editorHasFocus()) return;
|
|
333
|
+
this.journal.collapsed = this.active ? true : !this.journal.collapsed;
|
|
334
|
+
this.disclosureTimer?.cancel();
|
|
335
|
+
this.disclosureTimer = this.clock.setTimeout(() => this.flushState(), FLEET_DISCLOSURE_SETTLE_MS);
|
|
336
|
+
this.active = false;
|
|
337
|
+
this.selectedIndex = 0;
|
|
338
|
+
this.update();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Restore the last valid active-branch disclosure entry without writing another entry. */
|
|
342
|
+
restoreSession(
|
|
343
|
+
entries: readonly FleetJournalEntry[] | (() => readonly FleetJournalEntry[]),
|
|
344
|
+
persist: (state: FleetSnapshot) => void,
|
|
345
|
+
): void {
|
|
346
|
+
this.disclosureTimer?.cancel();
|
|
347
|
+
this.disclosureTimer = undefined;
|
|
348
|
+
this.journal.restore(typeof entries === "function" ? entries : () => entries, persist);
|
|
349
|
+
this.active = false;
|
|
350
|
+
this.selectedIndex = 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Flush while the activation's appendEntry/context is still valid. */
|
|
354
|
+
flushState(): void {
|
|
355
|
+
this.disclosureTimer?.cancel();
|
|
356
|
+
this.disclosureTimer = undefined;
|
|
357
|
+
this.journal.flushDisclosure();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Terminal callback and detached-notice ingress, never dependent on a render tick. */
|
|
361
|
+
ingestFailure(value: unknown): void {
|
|
362
|
+
const alert = fleetAlert(value);
|
|
363
|
+
if (alert) this.journal.report(alert);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Shared semantic edge for result consumption, RPC and successful human inspection. */
|
|
367
|
+
acknowledgeFailure(id: string): void {
|
|
368
|
+
this.journal.acknowledge(id);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Inject scheduler display data, without coupling the fleet to scheduling behavior. */
|
|
372
|
+
setParkedSource(source: () => number): void {
|
|
373
|
+
this.parkedSource = source;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Inject what the stop-all chord stops, without coupling the fleet to workflows or orphans. */
|
|
377
|
+
setStopAll(stop: () => { agents: number; workflows: number }): void {
|
|
378
|
+
this.stopAll = stop;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The stop-all chord: the first press arms it and says so, a second within
|
|
383
|
+
* {@link STOP_ALL_CONFIRM_MS} stops everything. A press after the window
|
|
384
|
+
* re-arms rather than stopping, so a stray key never costs a run.
|
|
385
|
+
*/
|
|
386
|
+
private pressStopAll(): void {
|
|
387
|
+
const now = this.clock.now();
|
|
388
|
+
if (this.stopAllArmedAt === undefined || now - this.stopAllArmedAt > STOP_ALL_CONFIRM_MS) {
|
|
389
|
+
this.stopAllArmedAt = now;
|
|
390
|
+
this.ui?.notify(`Press ${STOP_ALL_KEY} again within ${STOP_ALL_CONFIRM_MS / 1000}s to stop every running agent and workflow.`, "warning");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
this.stopAllArmedAt = undefined;
|
|
394
|
+
const { agents, workflows } = this.stopAll();
|
|
395
|
+
this.ui?.notify(agents + workflows === 0 ? "Nothing was running." : `Stopped ${agents} agent(s) and ${workflows} workflow(s).`, "info");
|
|
396
|
+
this.update();
|
|
397
|
+
}
|
|
398
|
+
|
|
186
399
|
// ---- Lifecycle ----
|
|
187
400
|
|
|
188
401
|
setEnabled(enabled: boolean): void {
|
|
@@ -217,6 +430,7 @@ export class FleetList {
|
|
|
217
430
|
}
|
|
218
431
|
|
|
219
432
|
dispose(): void {
|
|
433
|
+
this.flushState();
|
|
220
434
|
if (this.timer) { this.timer.cancel(); this.timer = undefined; }
|
|
221
435
|
this.inputUnsub?.();
|
|
222
436
|
this.inputUnsub = undefined;
|
|
@@ -237,11 +451,9 @@ export class FleetList {
|
|
|
237
451
|
/** Re-register/refresh the below-editor widget; clears it when nothing remains. */
|
|
238
452
|
update(): void {
|
|
239
453
|
if (!this.ui) return;
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
// anything here" drifted apart once before.
|
|
244
|
-
const hasRows = this.enabled && this.roster().length > 1;
|
|
454
|
+
// Summary existence includes pending starts and quota parking: neither has
|
|
455
|
+
// an openable row yet. Navigation still uses the openable roster only.
|
|
456
|
+
const hasRows = this.enabled && (this.agentRecords(true).length > 0 || this.workflows().length > 0 || this.journal.count > 0 || this.parkedSource() > 0);
|
|
245
457
|
|
|
246
458
|
if (!hasRows) {
|
|
247
459
|
if (this.widgetRegistered) {
|
|
@@ -249,12 +461,10 @@ export class FleetList {
|
|
|
249
461
|
this.widgetRegistered = false;
|
|
250
462
|
this.tui = undefined;
|
|
251
463
|
}
|
|
252
|
-
// A just-spawned agent
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
|
|
256
|
-
if (this.enabled && this.hasStartingAgent()) this.ensureTimer();
|
|
257
|
-
else if (this.timer) { this.timer.cancel(); this.timer = undefined; }
|
|
464
|
+
// A just-spawned agent with no session yet counts in `hasRows` (the
|
|
465
|
+
// summary includes pending starts), and that path's `ensureTimer()` keeps
|
|
466
|
+
// ticking until its session appears. So nothing here is starting: stop.
|
|
467
|
+
if (this.timer) { this.timer.cancel(); this.timer = undefined; }
|
|
258
468
|
this.active = false;
|
|
259
469
|
this.selectedIndex = 0;
|
|
260
470
|
return;
|
|
@@ -289,12 +499,6 @@ export class FleetList {
|
|
|
289
499
|
}
|
|
290
500
|
}
|
|
291
501
|
|
|
292
|
-
/** Whether a top-level agent is live but not yet listable (no session yet). */
|
|
293
|
-
private hasStartingAgent(): boolean {
|
|
294
|
-
return this.manager.listAgents().some(a =>
|
|
295
|
-
isTopLevelAgent(a) && !a.session && (a.status === "running" || a.status === "queued"));
|
|
296
|
-
}
|
|
297
|
-
|
|
298
502
|
// ---- Roster ----
|
|
299
503
|
|
|
300
504
|
/**
|
|
@@ -302,22 +506,45 @@ export class FleetList {
|
|
|
302
506
|
* started sooner sit at the top. Every row is openable (has a session), so Enter
|
|
303
507
|
* never dead-ends. Included: running/queued, plus the agent currently being
|
|
304
508
|
* viewed, plus recently-finished ones (they linger briefly before dropping out).
|
|
305
|
-
* Pending agents
|
|
509
|
+
* Pending agents have no openable row; includePending counts them in the summary.
|
|
306
510
|
* (`listAgents()` is newest-first, so we re-sort.)
|
|
307
511
|
*/
|
|
308
|
-
private agentRecords(): AgentRecord[] {
|
|
512
|
+
private agentRecords(includePending = false): AgentRecord[] {
|
|
309
513
|
const now = this.clock.now();
|
|
310
514
|
// Orphans are live objects, so a settled one is simply skipped; the
|
|
311
515
|
// registry drops it itself. Own records win if an id appears in both.
|
|
312
516
|
const own = this.manager.listAgents();
|
|
313
517
|
const ownIds = new Set(own.map(a => a.id));
|
|
314
518
|
const orphans = this.detachedSource().map(d => d.record).filter(r => !ownIds.has(r.id));
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
)
|
|
519
|
+
const records = new Map([...own, ...orphans].map(a => [a.id, a]));
|
|
520
|
+
for (const a of records.values()) {
|
|
521
|
+
if (!isTopLevelAgent(a) || (a.status !== "error" && a.status !== "aborted")) continue;
|
|
522
|
+
const consumed = !!a.resultConsumed;
|
|
523
|
+
if (this.observed.get(a) === consumed) continue;
|
|
524
|
+
this.observed.set(a, consumed);
|
|
525
|
+
if (consumed) this.acknowledgeFailure(a.id);
|
|
526
|
+
else this.ingestFailure(a);
|
|
527
|
+
}
|
|
528
|
+
for (const alert of this.journal.alerts) {
|
|
529
|
+
if (alert.state !== "error" && alert.state !== "aborted") continue;
|
|
530
|
+
const live = records.get(alert.id);
|
|
531
|
+
if (!live || !isOutstandingFailure(live)) records.set(`fleet-alert:${alert.id}`, {
|
|
532
|
+
id: alert.id, type: alert.type, description: alert.description, status: alert.state,
|
|
533
|
+
startedAt: alert.timestamp, completedAt: alert.timestamp, toolUses: 0, compactionCount: 0, depth: 1,
|
|
534
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
return [...records.values()]
|
|
538
|
+
.filter(a => {
|
|
539
|
+
if (!isTopLevelAgent(a)) return false;
|
|
540
|
+
if (isOutstandingFailure(a)) return this.journal.alerts.some(alert => alert.id === a.id);
|
|
541
|
+
// Consumed failures may linger as expanded history, never as outstanding alerts.
|
|
542
|
+
if (includePending && (a.status === "error" || a.status === "aborted")) return false;
|
|
543
|
+
return (includePending || a.session) && (
|
|
544
|
+
a.status === "running" || a.status === "queued" || a.id === this.viewingAgentId
|
|
545
|
+
|| (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
|
|
546
|
+
);
|
|
547
|
+
})
|
|
321
548
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
322
549
|
}
|
|
323
550
|
|
|
@@ -347,14 +574,31 @@ export class FleetList {
|
|
|
347
574
|
|
|
348
575
|
/** Live runs, plus recently settled ones — the same linger the agents get. */
|
|
349
576
|
private workflows(): FleetWorkflow[] {
|
|
350
|
-
if (!this.workflowSource) return [];
|
|
351
577
|
const now = this.clock.now();
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
578
|
+
const runs = new Map((this.workflowSource?.() ?? []).map(run => [run.id, run]));
|
|
579
|
+
for (const run of runs.values()) {
|
|
580
|
+
if (run.status === "failed" || run.status === "killed") {
|
|
581
|
+
const key = run.observationKey ?? run;
|
|
582
|
+
const timestamp = run.completedAt ?? run.startedAt;
|
|
583
|
+
if (this.observedWorkflows.get(key) === timestamp) continue;
|
|
584
|
+
this.observedWorkflows.set(key, timestamp);
|
|
585
|
+
this.ingestFailure({ id: run.id, type: "workflow", description: run.name, status: run.status, startedAt: run.startedAt, completedAt: run.completedAt });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
for (const alert of this.journal.alerts) {
|
|
589
|
+
if (alert.state !== "failed" && alert.state !== "killed") continue;
|
|
590
|
+
const live = runs.get(alert.id);
|
|
591
|
+
if (!live || (live.status !== "failed" && live.status !== "killed")) runs.set(`fleet-alert:${alert.id}`, {
|
|
592
|
+
id: alert.id, name: alert.description, status: alert.state, startedAt: alert.timestamp,
|
|
593
|
+
completedAt: alert.timestamp, doneCount: 0, totalCount: 0, tokens: 0,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return [...runs.values()]
|
|
597
|
+
.filter(run => {
|
|
598
|
+
if (run.status === "failed" || run.status === "killed") return this.journal.alerts.some(alert => alert.id === run.id);
|
|
599
|
+
return run.status === "running" || run.status === "paused"
|
|
600
|
+
|| (run.completedAt != null && now - run.completedAt < FINISHED_LINGER_MS);
|
|
601
|
+
})
|
|
358
602
|
.sort((a, b) => a.startedAt - b.startedAt);
|
|
359
603
|
}
|
|
360
604
|
|
|
@@ -374,7 +618,6 @@ export class FleetList {
|
|
|
374
618
|
private clampSelection(): void {
|
|
375
619
|
const max = this.roster().length - 1;
|
|
376
620
|
if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
|
|
377
|
-
if (this.selectedIndex < 0) this.selectedIndex = 0;
|
|
378
621
|
}
|
|
379
622
|
|
|
380
623
|
// ---- Key handling ----
|
|
@@ -399,6 +642,18 @@ export class FleetList {
|
|
|
399
642
|
return undefined;
|
|
400
643
|
}
|
|
401
644
|
|
|
645
|
+
if (matchesKey(data, FLEET_COLLAPSE_KEY)) {
|
|
646
|
+
// Kitty event type 2 is auto-repeat; arrows deliberately still repeat.
|
|
647
|
+
if (isKeyRepeat(data)) return { consume: true };
|
|
648
|
+
this.toggleCollapse();
|
|
649
|
+
return { consume: true };
|
|
650
|
+
}
|
|
651
|
+
if (matchesKey(data, STOP_ALL_KEY)) {
|
|
652
|
+
// A held key must not confirm its own first press.
|
|
653
|
+
if (!isKeyRepeat(data)) this.pressStopAll();
|
|
654
|
+
return { consume: true };
|
|
655
|
+
}
|
|
656
|
+
|
|
402
657
|
if (!this.active) {
|
|
403
658
|
// Activate: ↓ or ← at an empty prompt moves focus into the list.
|
|
404
659
|
const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
|
|
@@ -428,7 +683,7 @@ export class FleetList {
|
|
|
428
683
|
return { consume: true };
|
|
429
684
|
}
|
|
430
685
|
if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
|
|
431
|
-
if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
|
|
686
|
+
if (matchesKey(data, Key.enter)) { this.openSelected(this.ui); return { consume: true }; }
|
|
432
687
|
|
|
433
688
|
// Any other key cancels navigation and flows to the editor.
|
|
434
689
|
this.deactivate();
|
|
@@ -457,7 +712,7 @@ export class FleetList {
|
|
|
457
712
|
this.update();
|
|
458
713
|
}
|
|
459
714
|
|
|
460
|
-
private openSelected(): void {
|
|
715
|
+
private openSelected(ui: FleetUICtx): void {
|
|
461
716
|
const entry = this.roster()[this.selectedIndex];
|
|
462
717
|
if (!entry || entry.kind === "main") {
|
|
463
718
|
// `main` = return to the prompt; the native transcript is already shown.
|
|
@@ -465,29 +720,41 @@ export class FleetList {
|
|
|
465
720
|
return;
|
|
466
721
|
}
|
|
467
722
|
if (entry.kind === "workflow") {
|
|
723
|
+
if (!this.workflowSource?.().some(run => run.id === entry.workflow.id) || !this.openWorkflow) {
|
|
724
|
+
ui.notify(`workflow: ${entry.workflow.name} (${entry.workflow.status}).`, "error");
|
|
725
|
+
this.acknowledgeFailure(entry.workflow.id);
|
|
726
|
+
this.update();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
468
729
|
// The extension owns this overlay and closes it, so there is no
|
|
469
730
|
// `viewerClose` to hold — but the list still has to know one is up, and
|
|
470
731
|
// still has to put the cursor back on the run when it comes down.
|
|
471
732
|
this.viewingWorkflowId = entry.workflow.id;
|
|
472
733
|
void Promise.resolve(this.openWorkflow?.(entry.workflow.id)).then(
|
|
473
|
-
() => this.clearViewer(),
|
|
734
|
+
() => this.clearViewer(true),
|
|
474
735
|
() => this.clearViewer(),
|
|
475
736
|
);
|
|
476
737
|
return;
|
|
477
738
|
}
|
|
478
739
|
const record = entry.record;
|
|
479
|
-
|
|
480
|
-
if (!
|
|
481
|
-
|
|
740
|
+
const session = this.options.sessionFor ? this.options.sessionFor(record) : record.session;
|
|
741
|
+
if (!session) {
|
|
742
|
+
if (isOutstandingFailure(record)) {
|
|
743
|
+
ui.notify(record.error ?? `${record.type}: ${record.description} (${record.status}).`, "error");
|
|
744
|
+
markConsumed(record, this.clock.now());
|
|
745
|
+
this.acknowledgeFailure(record.id);
|
|
746
|
+
this.update();
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
ui.notify(`Agent is ${record.status} — no session available.`, "info");
|
|
482
750
|
return;
|
|
483
751
|
}
|
|
484
|
-
const session = record.session;
|
|
485
752
|
const activity = this.activityFor(record.id);
|
|
486
753
|
const orphan = this.detachedFor(record.id);
|
|
487
754
|
this.viewingAgentId = record.id;
|
|
488
755
|
let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
|
|
489
756
|
|
|
490
|
-
void
|
|
757
|
+
void ui.custom<undefined>(
|
|
491
758
|
(tui, theme, keybindings, done) => {
|
|
492
759
|
overlayOptions = getConversationOverlayOptions(tui);
|
|
493
760
|
this.viewerClose = () => done(undefined);
|
|
@@ -515,11 +782,11 @@ export class FleetList {
|
|
|
515
782
|
overlay: true,
|
|
516
783
|
overlayOptions: () => overlayOptions,
|
|
517
784
|
},
|
|
518
|
-
).then(() => this.clearViewer(), () => this.clearViewer());
|
|
785
|
+
).then(() => this.clearViewer(true), () => this.clearViewer());
|
|
519
786
|
}
|
|
520
787
|
|
|
521
788
|
/** Reset overlay state and return to the list (on close, auto-close, or error). */
|
|
522
|
-
private clearViewer(): void {
|
|
789
|
+
private clearViewer(acknowledge = false): void {
|
|
523
790
|
// Keep the cursor on the agent we were viewing — re-resolve by id so it
|
|
524
791
|
// still feels natural if the list reordered (an earlier agent finished)
|
|
525
792
|
// while the overlay was open. If that agent is gone, leave the index for
|
|
@@ -533,6 +800,17 @@ export class FleetList {
|
|
|
533
800
|
);
|
|
534
801
|
if (idx >= 0) this.selectedIndex = idx;
|
|
535
802
|
}
|
|
803
|
+
if (acknowledge && this.viewingAgentId) {
|
|
804
|
+
const record = this.agentRecords(true).find(a => a.id === this.viewingAgentId);
|
|
805
|
+
if (record && isOutstandingFailure(record)) {
|
|
806
|
+
markConsumed(record, this.clock.now());
|
|
807
|
+
this.acknowledgeFailure(record.id);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (acknowledge && this.viewingWorkflowId) {
|
|
811
|
+
const run = this.workflows().find(run => run.id === this.viewingWorkflowId);
|
|
812
|
+
if (run?.status === "failed" || run?.status === "killed") this.acknowledgeFailure(run.id);
|
|
813
|
+
}
|
|
536
814
|
this.viewerClose = undefined;
|
|
537
815
|
this.viewingAgentId = undefined;
|
|
538
816
|
this.viewingWorkflowId = undefined;
|
|
@@ -543,16 +821,21 @@ export class FleetList {
|
|
|
543
821
|
|
|
544
822
|
private renderBar(width: number, theme: Theme): string[] {
|
|
545
823
|
const rows = this.roster().slice(1) as (WorkflowEntry | AgentEntry)[];
|
|
546
|
-
if (rows.length === 0)
|
|
824
|
+
if ((this.journal.collapsed && !this.active) || rows.length === 0) {
|
|
825
|
+
return [renderFleetSummary(this.agentRecords(true), this.workflows(), this.parkedSource(), this.showCost(), width, theme, this.journal.overflowCount)];
|
|
826
|
+
}
|
|
547
827
|
// Clamp locally so a render between a roster shrink and the next update()
|
|
548
828
|
// (e.g. on terminal resize) never loses the selection marker.
|
|
549
829
|
const sel = Math.min(this.selectedIndex, rows.length);
|
|
550
830
|
|
|
551
831
|
const hint = this.active
|
|
552
832
|
? "↑↓ select · enter view · esc back"
|
|
553
|
-
:
|
|
833
|
+
: `esc to interrupt · ← for agents · ↓ to manage · ${FLEET_COLLAPSE_KEY} to collapse`;
|
|
554
834
|
const lines: string[] = [];
|
|
555
|
-
|
|
835
|
+
// Overflow details are deliberately bounded, but their exact failure count stays visible.
|
|
836
|
+
lines.push(this.journal.overflowCount
|
|
837
|
+
? renderFleetSummary(this.agentRecords(true), this.workflows(), this.parkedSource(), this.showCost(), width, theme, this.journal.overflowCount)
|
|
838
|
+
: truncateToWidth(" " + theme.fg("dim", hint), width));
|
|
556
839
|
lines.push("");
|
|
557
840
|
lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
|
|
558
841
|
|
|
@@ -565,27 +848,41 @@ export class FleetList {
|
|
|
565
848
|
const start = selRow < visible ? 0 : selRow - visible + 1;
|
|
566
849
|
const hiddenBelow = rows.length - (start + visible);
|
|
567
850
|
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
// Column widths come from
|
|
851
|
+
// Keep both indicator slots in the row-block budget. Empty edge slots go
|
|
852
|
+
// below the visible content, never between main and the first child.
|
|
853
|
+
// Column widths come from EVERY agent row, not the window, so scrolling
|
|
854
|
+
// never shifts a column; a column too wide for the terminal is dropped
|
|
855
|
+
// lowest-value first, never state, elapsed, tokens or cost.
|
|
571
856
|
const cells = new Map<AgentRecord, string[]>();
|
|
572
|
-
for (const r of rows
|
|
573
|
-
if (r.kind === "agent") cells.set(r.record, this.tailCells(r.record));
|
|
857
|
+
for (const r of rows) {
|
|
858
|
+
if (r.kind === "agent") cells.set(r.record, this.tailCells(r.record, theme));
|
|
859
|
+
}
|
|
860
|
+
// Reserve the widest leading flag across ALL rows before fitting optional
|
|
861
|
+
// columns, so a fallback cannot hide the alias or shift columns on scroll.
|
|
862
|
+
const flagWidth = Math.max(0, ...[...cells.keys()].map(record => {
|
|
863
|
+
const flag = fallbackFlag(theme, record);
|
|
864
|
+
return flag ? visibleWidth(flag) + 1 : 0;
|
|
865
|
+
}));
|
|
866
|
+
const colWidths = this.fitColumns(
|
|
867
|
+
TAIL_SLOTS.map((_, i) => Math.max(0, ...[...cells.values()].map(c => visibleWidth(c[i])))),
|
|
868
|
+
width - flagWidth,
|
|
869
|
+
);
|
|
870
|
+
if (start > 0) {
|
|
871
|
+
lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more: ${summarizeHidden(rows.slice(0, start))}`), width));
|
|
574
872
|
}
|
|
575
|
-
const colWidths = TAIL_SLOTS.map((_, i) =>
|
|
576
|
-
Math.max(0, ...[...cells.values()].map(c => visibleWidth(c[i]))));
|
|
577
|
-
if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width));
|
|
578
|
-
else if (overflow) lines.push("");
|
|
579
873
|
for (let a = start; a < start + visible; a++) {
|
|
580
874
|
const row = rows[a];
|
|
581
875
|
lines.push(
|
|
582
876
|
row.kind === "workflow" ?
|
|
583
877
|
this.renderWorkflowRow(a + 1, sel, row.workflow, width, theme)
|
|
584
|
-
|
|
878
|
+
// Every agent row's cells were computed above.
|
|
879
|
+
: this.renderAgentRow(a + 1, sel, row.record, width, theme, cells.get(row.record)!, colWidths),
|
|
585
880
|
);
|
|
586
881
|
}
|
|
587
|
-
if (hiddenBelow > 0)
|
|
588
|
-
|
|
882
|
+
if (hiddenBelow > 0) {
|
|
883
|
+
lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more: ${summarizeHidden(rows.slice(start + visible))}`), width));
|
|
884
|
+
} else if (overflow) lines.push("");
|
|
885
|
+
if (start === 0 && overflow) lines.push("");
|
|
589
886
|
// pi stacks below-editor widgets directly on its footer with no spacer (#351).
|
|
590
887
|
lines.push("");
|
|
591
888
|
|
|
@@ -619,8 +916,26 @@ export class FleetList {
|
|
|
619
916
|
return rightAlign(left, selected ? theme.fg("text", stats) : theme.fg("dim", stats), width);
|
|
620
917
|
}
|
|
621
918
|
|
|
919
|
+
/** Zero the widths of droppable columns, lowest value first, until the tail leaves room for the description. */
|
|
920
|
+
private fitColumns(widths: number[], width: number): number[] {
|
|
921
|
+
const fitted = [...widths];
|
|
922
|
+
const tail = () => {
|
|
923
|
+
const used = fitted.filter(w => w > 0);
|
|
924
|
+
return used.reduce((sum, w) => sum + w, 0) + 3 * Math.max(0, used.length - 1);
|
|
925
|
+
};
|
|
926
|
+
const droppable = TAIL_SLOTS
|
|
927
|
+
.map((slot, i) => ({ i, rank: slot.drop ?? 0 }))
|
|
928
|
+
.filter(c => c.rank > 0)
|
|
929
|
+
.sort((x, y) => x.rank - y.rank);
|
|
930
|
+
for (const { i } of droppable) {
|
|
931
|
+
if (width - tail() >= MIN_LEFT_WIDTH) break;
|
|
932
|
+
fitted[i] = 0;
|
|
933
|
+
}
|
|
934
|
+
return fitted;
|
|
935
|
+
}
|
|
936
|
+
|
|
622
937
|
/** One agent's stats tail as {@link TAIL_SLOTS} cells; "" where a slot has no value. */
|
|
623
|
-
private tailCells(record: AgentRecord): string[] {
|
|
938
|
+
private tailCells(record: AgentRecord, theme: Theme): string[] {
|
|
624
939
|
// The record, not the activity tracker — see the note in AgentWidget's
|
|
625
940
|
// running line: only the record carries a nested child's spend, and only it
|
|
626
941
|
// outlives the agent.
|
|
@@ -632,15 +947,16 @@ export class FleetList {
|
|
|
632
947
|
// Leading, and paired: a thinking level means nothing without its model.
|
|
633
948
|
const { modelName, tags } = buildInvocationTags(record.invocation);
|
|
634
949
|
model = modelName ?? "";
|
|
635
|
-
thinking = tags
|
|
950
|
+
thinking = compactThinkingTag(tags);
|
|
636
951
|
}
|
|
952
|
+
const state = agentState(record.status);
|
|
637
953
|
// What the in-flight model call is doing, only while the agent is running:
|
|
638
954
|
// a finished row has no call to describe.
|
|
639
955
|
const call = record.status === "running"
|
|
640
956
|
? describeModelCall(this.activityFor(record.id)?.modelCall, this.clock.now())
|
|
641
957
|
: undefined;
|
|
642
958
|
const cost = this.showCost() ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
|
|
643
|
-
return [model, thinking, call ?? "", formatFleetElapsed(elapsedMs), formatFleetTokens(tokens), cost];
|
|
959
|
+
return [theme.fg(state.color, `${state.glyph} ${state.label}`), model, thinking, call ?? "", formatFleetElapsed(elapsedMs), formatFleetTokens(tokens), cost];
|
|
644
960
|
}
|
|
645
961
|
|
|
646
962
|
private renderAgentRow(
|
|
@@ -660,16 +976,45 @@ export class FleetList {
|
|
|
660
976
|
const name = renderAgentName(record.type, theme, selected
|
|
661
977
|
? { fallbackColor: "text", bold: hasAgentBadge(record.type) }
|
|
662
978
|
: { fallbackColor: "muted" });
|
|
663
|
-
|
|
664
|
-
|
|
979
|
+
// The spawner's `name` leads when there is one, so two agents of one type
|
|
980
|
+
// with similar descriptions stay distinguishable after truncation.
|
|
981
|
+
const label = record.alias ? `${record.alias}: ${record.description}` : record.description;
|
|
982
|
+
const description = selected ? theme.fg("text", label) : label;
|
|
983
|
+
let prefix = ` ${this.bullet(rosterIndex, sel, theme)} `;
|
|
984
|
+
const left = `${name} ${description}`;
|
|
665
985
|
// Fixed-width slots (blank where a row has no value) keep each column at the
|
|
666
986
|
// same cells on every row; an all-empty column is dropped.
|
|
667
|
-
|
|
987
|
+
let stats = TAIL_SLOTS.map((slot, i) => {
|
|
668
988
|
if (colWidths[i] === 0) return undefined;
|
|
669
|
-
|
|
670
|
-
|
|
989
|
+
// `tailCells` fills every slot, with "" where a row has no value.
|
|
990
|
+
const pad = " ".repeat(colWidths[i] - visibleWidth(cells[i]));
|
|
991
|
+
return slot.padEnd ? cells[i] + pad : pad + cells[i];
|
|
671
992
|
}).filter((c): c is string => c !== undefined).join(" · ");
|
|
672
|
-
|
|
673
|
-
|
|
993
|
+
let flag = fallbackFlag(theme, record);
|
|
994
|
+
if (flag && visibleWidth(prefix + flag) + 1 + visibleWidth(stats) > width) {
|
|
995
|
+
// Description space is exhausted and fitColumns already dropped activity,
|
|
996
|
+
// thinking and model. Use the SHARED helper's compact marker, never slice
|
|
997
|
+
// the flag, before sacrificing any spend cell. Rendering an empty body in
|
|
998
|
+
// the flag budget selects [fb] or ! with the same policy as every renderer.
|
|
999
|
+
const flagBudget = Math.max(1, width - visibleWidth(prefix) - visibleWidth(stats) - 1);
|
|
1000
|
+
flag = withLeadingFlag("", flag, "", flagBudget);
|
|
1001
|
+
}
|
|
1002
|
+
// Every row preserves spend, not just rows carrying a fallback marker.
|
|
1003
|
+
const markerWidth = flag ? visibleWidth(flag) + 1 : 0;
|
|
1004
|
+
if (visibleWidth(prefix) + markerWidth + visibleWidth(stats) > width) {
|
|
1005
|
+
// A state word and padded separators are not spend. Keep its glyph and
|
|
1006
|
+
// unpadded elapsed/tokens/cost, then remove navigation padding if needed.
|
|
1007
|
+
const spend = cells.slice(4).filter(Boolean);
|
|
1008
|
+
stats = [agentState(record.status).glyph, ...spend].join(" ");
|
|
1009
|
+
if (visibleWidth(prefix) + markerWidth + visibleWidth(stats) > width) prefix = "";
|
|
1010
|
+
const remaining = width - markerWidth;
|
|
1011
|
+
if (visibleWidth(stats) > remaining) stats = spend.join(" ");
|
|
1012
|
+
// Only when all three cannot physically fit may elapsed yield to spend.
|
|
1013
|
+
if (visibleWidth(stats) > remaining) stats = spend.slice(1).join(" ");
|
|
1014
|
+
}
|
|
1015
|
+
const right = fgPreservingNestedStyles(theme, selected ? "text" : "dim", stats);
|
|
1016
|
+
// The flag leads the row (even with `showModel` off: a substituted model is
|
|
1017
|
+
// not cosmetic) and the name/description/tail are laid out in what is left.
|
|
1018
|
+
return withLeadingFlag(prefix, flag, inner => rightAlign(left, right, inner), width);
|
|
674
1019
|
}
|
|
675
1020
|
}
|