@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1444 -2
- package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
- package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
- package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
- package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
- package/dist/index.ts +9874 -0
- package/dist/index.ts.map +7 -0
- package/package.json +82 -4
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +298 -0
- package/src/agent-manager.ts +2431 -0
- package/src/agent-runner.ts +1703 -0
- package/src/agent-source-badge.ts +75 -0
- package/src/agent-types.ts +346 -0
- package/src/child-context.ts +33 -0
- package/src/clock.ts +25 -0
- package/src/completion-outbox.ts +144 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +200 -0
- package/src/custom-agents.ts +441 -0
- package/src/default-agents.ts +126 -0
- package/src/detached-results.ts +150 -0
- package/src/enabled-models.ts +181 -0
- package/src/enterprise-local.ts +67 -0
- package/src/env.ts +33 -0
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +142 -0
- package/src/home-paths.ts +27 -0
- package/src/index.ts +5047 -0
- package/src/invocation-config.ts +158 -0
- package/src/memory.ts +179 -0
- package/src/mention-clone.ts +200 -0
- package/src/mention.ts +141 -0
- package/src/model-call.ts +83 -0
- package/src/model-resolver.ts +333 -0
- package/src/model-scope.ts +71 -0
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +512 -0
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +186 -0
- package/src/package-resources.ts +639 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +346 -0
- package/src/prompts.ts +161 -0
- package/src/quota-admission.ts +121 -0
- package/src/resource-loader.ts +65 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +514 -0
- package/src/settings.ts +851 -0
- package/src/skill-loader.ts +288 -0
- package/src/stale-ctx.ts +16 -0
- package/src/startup-error.ts +29 -0
- package/src/status-note.ts +113 -0
- package/src/structured-output.ts +130 -0
- package/src/types.ts +487 -0
- package/src/ui/agent-mention.ts +219 -0
- package/src/ui/agent-row.ts +245 -0
- package/src/ui/agent-widget.ts +722 -0
- package/src/ui/conversation-viewer.ts +837 -0
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +1020 -0
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +139 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/select-item.ts +45 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/ui/workflow-card.ts +459 -0
- package/src/ui/workflow-dialog.ts +1138 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +167 -0
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +398 -0
- package/src/workflow/journal.ts +176 -0
- package/src/workflow/json-schema.ts +129 -0
- package/src/workflow/meta.ts +329 -0
- package/src/workflow/progress.ts +554 -0
- package/src/workflow/runtime.ts +1249 -0
- package/src/workflow/saved.ts +259 -0
- package/src/workflow/task.ts +317 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +903 -0
- package/src/worktree.ts +520 -0
- package/src/xml.ts +13 -0
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
|
|
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.
|
|
6
|
+
* Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
|
|
7
|
+
* ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
|
|
8
|
+
* Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
|
|
9
|
+
* A viewer stays open when its agent finishes; finished agents linger briefly in the list.
|
|
10
|
+
*
|
|
11
|
+
* Mechanics (see plan): the list is a `belowEditor` widget (render-only), and ALL key
|
|
12
|
+
* handling goes through `onTerminalInput` — which fires before the focused editor and
|
|
13
|
+
* can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { isKeyRelease, isKeyRepeat, Key, matchesKey, type ScrollViewScrollbar, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
import { hasAgentBadge, renderAgentName } from "../agent-color.js";
|
|
18
|
+
import { type AgentManager, isTopLevelAgent, markConsumed } from "../agent-manager.js";
|
|
19
|
+
import { type Clock, realClock, type TimerHandle } from "../clock.js";
|
|
20
|
+
import type { RunningDetached } from "../detached-results.js";
|
|
21
|
+
import { describeModelCall } from "../model-call.js";
|
|
22
|
+
import type { AgentRecord, ViewerMarkdownMode } from "../types.js";
|
|
23
|
+
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
24
|
+
import { type AgentActivity, buildInvocationTags, compactThinkingTag, fgPreservingNestedStyles, formatCompactTokens, formatCost, type Theme } from "./agent-widget.js";
|
|
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
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Widget key for the below-editor fleet list. */
|
|
44
|
+
const FLEET_KEY = "fleet";
|
|
45
|
+
/** Max fleet rows shown at once; extras collapse into indicator rows. */
|
|
46
|
+
const MAX_AGENT_ROWS = 5;
|
|
47
|
+
/** Agent rows left when overflow reserves the two indicator rows. */
|
|
48
|
+
const OVERFLOW_AGENT_ROWS = Math.max(1, MAX_AGENT_ROWS - 2);
|
|
49
|
+
/** Re-render cadence so elapsed/token stats tick while agents run. */
|
|
50
|
+
const TICK_MS = 200;
|
|
51
|
+
/** How long a finished agent lingers in the list before it drops out. */
|
|
52
|
+
const FINISHED_LINGER_MS = 4000;
|
|
53
|
+
|
|
54
|
+
/** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
|
|
55
|
+
export type FleetUICtx = {
|
|
56
|
+
setWidget(
|
|
57
|
+
key: string,
|
|
58
|
+
content: undefined | ((tui: any, theme: Theme) => { render(width: number): string[]; invalidate(): void; dispose?(): void }),
|
|
59
|
+
options?: { placement?: "aboveEditor" | "belowEditor" },
|
|
60
|
+
): void;
|
|
61
|
+
onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
|
|
62
|
+
getEditorText(): string;
|
|
63
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
64
|
+
custom<T>(
|
|
65
|
+
factory: (tui: any, theme: Theme, keybindings: any, done: (result: T) => void) => { render(width: number): string[]; invalidate(): void; dispose?(): void },
|
|
66
|
+
options?: { overlay?: boolean; overlayOptions?: unknown; onHandle?: (handle: unknown) => void },
|
|
67
|
+
): Promise<T>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A workflow run, as the fleet list needs to see it.
|
|
72
|
+
*
|
|
73
|
+
* Narrow on purpose: the list knows nothing about `WorkflowTask`, the runtime
|
|
74
|
+
* or the dialog, so it stays as testable as it was when it only held agents.
|
|
75
|
+
* The extension maps its tasks into this shape and injects an opener.
|
|
76
|
+
*/
|
|
77
|
+
export interface FleetWorkflow {
|
|
78
|
+
id: string;
|
|
79
|
+
/** The `meta.name` of the run, or its id when the script named nothing. */
|
|
80
|
+
name: string;
|
|
81
|
+
status: "running" | "completed" | "failed" | "killed" | "paused";
|
|
82
|
+
doneCount: number;
|
|
83
|
+
totalCount: number;
|
|
84
|
+
startedAt: number;
|
|
85
|
+
/** Set once the run settles, which is what freezes its clock. */
|
|
86
|
+
completedAt?: number;
|
|
87
|
+
tokens: number;
|
|
88
|
+
/** Stable, weakly observed owner when the source rebuilds its display projection each tick. */
|
|
89
|
+
observationKey?: object;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type MainEntry = { kind: "main" };
|
|
93
|
+
type AgentEntry = { kind: "agent"; record: AgentRecord };
|
|
94
|
+
type WorkflowEntry = { kind: "workflow"; workflow: FleetWorkflow };
|
|
95
|
+
type FleetEntry = MainEntry | WorkflowEntry | AgentEntry;
|
|
96
|
+
|
|
97
|
+
/** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */
|
|
98
|
+
export function formatFleetElapsed(ms: number): string {
|
|
99
|
+
return `${Math.max(0, Math.round(ms / 1000))}s`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */
|
|
103
|
+
export function formatFleetTokens(count: number): string {
|
|
104
|
+
return `↓ ${formatCompactTokens(count)} tokens`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Place `right` flush to `width`, truncating `left` first so the stats survive.
|
|
109
|
+
* The final clamp guarantees the line never exceeds `width` (which would wrap and
|
|
110
|
+
* desync pi's line-diff → flicker) even on a terminal too narrow for the stats.
|
|
111
|
+
*/
|
|
112
|
+
function rightAlign(left: string, right: string, width: number): string {
|
|
113
|
+
const rightW = visibleWidth(right);
|
|
114
|
+
const maxLeft = Math.max(0, width - rightW - 1);
|
|
115
|
+
const leftClamped = truncateToWidth(left, maxLeft);
|
|
116
|
+
const gap = Math.max(leftClamped ? 1 : 0, width - visibleWidth(leftClamped) - rightW);
|
|
117
|
+
return truncateToWidth(leftClamped + " ".repeat(gap) + right, width);
|
|
118
|
+
}
|
|
119
|
+
|
|
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
|
+
}
|
|
243
|
+
|
|
244
|
+
export class FleetList {
|
|
245
|
+
private ui: FleetUICtx | undefined;
|
|
246
|
+
private tui: any | undefined;
|
|
247
|
+
private inputUnsub: (() => void) | undefined;
|
|
248
|
+
private widgetRegistered = false;
|
|
249
|
+
/** The registered widget component, so a late `dispose()` of a replaced one is ignored. */
|
|
250
|
+
private component: object | undefined;
|
|
251
|
+
private timer: TimerHandle | undefined;
|
|
252
|
+
|
|
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;
|
|
266
|
+
/** Whether arrow keys currently navigate the list (vs. flow to the editor). */
|
|
267
|
+
private active = false;
|
|
268
|
+
/** 0 = `main`, 1..N = subagents. */
|
|
269
|
+
private selectedIndex = 0;
|
|
270
|
+
/** Set while a conversation overlay is open; calling it closes the overlay. */
|
|
271
|
+
private viewerClose: (() => void) | undefined;
|
|
272
|
+
private viewingAgentId: string | undefined;
|
|
273
|
+
/** Injected by the extension; absent until workflows are wired (or at all). */
|
|
274
|
+
private workflowSource: (() => readonly FleetWorkflow[]) | undefined;
|
|
275
|
+
private openWorkflow: ((id: string) => Promise<void> | void) | undefined;
|
|
276
|
+
/**
|
|
277
|
+
* Set while the workflow inspector is up.
|
|
278
|
+
*
|
|
279
|
+
* It does the two jobs `viewerClose` does for an agent's overlay — keep the
|
|
280
|
+
* list out of the dialog's keys, and remember which row to come back to —
|
|
281
|
+
* minus the close handle, because that overlay belongs to the extension.
|
|
282
|
+
*/
|
|
283
|
+
private viewingWorkflowId: string | undefined;
|
|
284
|
+
|
|
285
|
+
constructor(
|
|
286
|
+
private manager: AgentManager,
|
|
287
|
+
private agentActivity: Map<string, AgentActivity>,
|
|
288
|
+
/**
|
|
289
|
+
* Read live at render time. Whether each row shows an estimated cost after
|
|
290
|
+
* its token count. Defaults to off — the extension supplies the user's
|
|
291
|
+
* `showCost` setting.
|
|
292
|
+
*/
|
|
293
|
+
private showCost: () => boolean = () => false,
|
|
294
|
+
/**
|
|
295
|
+
* The user's `viewerMarkdown` setting, for a conversation overlay opened
|
|
296
|
+
* from here. Read live rather than captured, because the viewer's `m` key
|
|
297
|
+
* changes it while the overlay is up. Omitted → the viewer's own default.
|
|
298
|
+
*/
|
|
299
|
+
private viewerMarkdown?: () => ViewerMarkdownMode,
|
|
300
|
+
/**
|
|
301
|
+
* Persist a mode chosen with `m` in that overlay, so the key means the same
|
|
302
|
+
* thing here as it does from `/agents` — one setting, not one per entry
|
|
303
|
+
* point. Omitted → `m` still cycles, viewer-locally.
|
|
304
|
+
*/
|
|
305
|
+
private onViewerMarkdown?: (mode: ViewerMarkdownMode) => void,
|
|
306
|
+
private scrollbarMode: () => ScrollViewScrollbar = () => "auto",
|
|
307
|
+
/**
|
|
308
|
+
* The viewer's `b` for this row's run, supplied by the extension: only it
|
|
309
|
+
* knows which runs have a tool call waiting to be released, and it owns the
|
|
310
|
+
* widget this list shares with. Omitted → no `b` affordance.
|
|
311
|
+
*/
|
|
312
|
+
private backgroundAction?: (record: AgentRecord, notify: (message: string) => void) => ViewerBackgroundAction,
|
|
313
|
+
/**
|
|
314
|
+
* Read live at render time. Whether each row names the model and thinking
|
|
315
|
+
* level it runs at. Defaults to off — the extension supplies the user's
|
|
316
|
+
* `showModel` setting, which until now only the widget honoured.
|
|
317
|
+
*/
|
|
318
|
+
private showModel: () => boolean = () => false,
|
|
319
|
+
/**
|
|
320
|
+
* Agents still running in an orphaned manager (the instance before a
|
|
321
|
+
* `/reload`, `/new`, ...). Listed with this session's own and opened,
|
|
322
|
+
* stopped and steered through the orphan's manager. Omitted -> none.
|
|
323
|
+
*/
|
|
324
|
+
private detachedSource: () => readonly RunningDetached[] = () => [],
|
|
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"] } = {},
|
|
328
|
+
) {}
|
|
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
|
+
|
|
399
|
+
// ---- Lifecycle ----
|
|
400
|
+
|
|
401
|
+
setEnabled(enabled: boolean): void {
|
|
402
|
+
if (enabled === this.enabled) return;
|
|
403
|
+
this.enabled = enabled;
|
|
404
|
+
if (!enabled) this.active = false;
|
|
405
|
+
this.update();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Capture the UI context and (re)register the global input handler. */
|
|
409
|
+
setUICtx(ui: FleetUICtx): void {
|
|
410
|
+
if (ui === this.ui) return;
|
|
411
|
+
this.inputUnsub?.();
|
|
412
|
+
this.ui = ui;
|
|
413
|
+
this.widgetRegistered = false;
|
|
414
|
+
this.component = undefined;
|
|
415
|
+
this.tui = undefined;
|
|
416
|
+
this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Ensure the re-render timer is running (called when an agent spawns). */
|
|
420
|
+
ensureTimer(): void {
|
|
421
|
+
if (!this.timer) this.timer = this.clock.setInterval(() => this.update(), TICK_MS);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Called when an agent finishes. The viewer (if open on it) stays open so the
|
|
426
|
+
* final output remains readable, and the row lingers in the list — just refresh.
|
|
427
|
+
*/
|
|
428
|
+
onAgentFinished(_id: string): void {
|
|
429
|
+
this.update();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
dispose(): void {
|
|
433
|
+
this.flushState();
|
|
434
|
+
if (this.timer) { this.timer.cancel(); this.timer = undefined; }
|
|
435
|
+
this.inputUnsub?.();
|
|
436
|
+
this.inputUnsub = undefined;
|
|
437
|
+
if (this.viewerClose) { this.viewerClose(); this.viewerClose = undefined; }
|
|
438
|
+
this.viewingAgentId = undefined;
|
|
439
|
+
// No handle to close the workflow inspector with, but the list is going
|
|
440
|
+
// away — leaving the id set would keep it swallowing input forever.
|
|
441
|
+
this.viewingWorkflowId = undefined;
|
|
442
|
+
if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
|
|
443
|
+
this.widgetRegistered = false;
|
|
444
|
+
this.component = undefined;
|
|
445
|
+
this.tui = undefined;
|
|
446
|
+
this.active = false;
|
|
447
|
+
// Null last so a `viewerClose()` microtask above can't re-register the widget.
|
|
448
|
+
this.ui = undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Re-register/refresh the below-editor widget; clears it when nothing remains. */
|
|
452
|
+
update(): void {
|
|
453
|
+
if (!this.ui) return;
|
|
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);
|
|
457
|
+
|
|
458
|
+
if (!hasRows) {
|
|
459
|
+
if (this.widgetRegistered) {
|
|
460
|
+
this.ui.setWidget(FLEET_KEY, undefined);
|
|
461
|
+
this.widgetRegistered = false;
|
|
462
|
+
this.tui = undefined;
|
|
463
|
+
}
|
|
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; }
|
|
468
|
+
this.active = false;
|
|
469
|
+
this.selectedIndex = 0;
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
this.clampSelection();
|
|
474
|
+
this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
|
|
475
|
+
|
|
476
|
+
if (!this.widgetRegistered) {
|
|
477
|
+
this.ui.setWidget(FLEET_KEY, (tui, theme) => {
|
|
478
|
+
this.tui = tui;
|
|
479
|
+
const component = {
|
|
480
|
+
render: (w: number) => this.renderBar(w, theme),
|
|
481
|
+
invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
|
|
482
|
+
// Core disposes extension widgets on /reload and plan-mode enter/exit.
|
|
483
|
+
// Reset the flag so the next update() registers a fresh one instead of
|
|
484
|
+
// asking a dead component to re-render. Only the live component counts:
|
|
485
|
+
// a replaced one is disposed after its successor registered.
|
|
486
|
+
dispose: () => {
|
|
487
|
+
if (this.component !== component) return;
|
|
488
|
+
this.component = undefined;
|
|
489
|
+
this.widgetRegistered = false;
|
|
490
|
+
this.tui = undefined;
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
this.component = component;
|
|
494
|
+
return component;
|
|
495
|
+
}, { placement: "belowEditor" });
|
|
496
|
+
this.widgetRegistered = true;
|
|
497
|
+
} else {
|
|
498
|
+
this.tui?.requestRender();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---- Roster ----
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Agents shown in the list, ordered earliest-launched first so the ones you
|
|
506
|
+
* started sooner sit at the top. Every row is openable (has a session), so Enter
|
|
507
|
+
* never dead-ends. Included: running/queued, plus the agent currently being
|
|
508
|
+
* viewed, plus recently-finished ones (they linger briefly before dropping out).
|
|
509
|
+
* Pending agents have no openable row; includePending counts them in the summary.
|
|
510
|
+
* (`listAgents()` is newest-first, so we re-sort.)
|
|
511
|
+
*/
|
|
512
|
+
private agentRecords(includePending = false): AgentRecord[] {
|
|
513
|
+
const now = this.clock.now();
|
|
514
|
+
// Orphans are live objects, so a settled one is simply skipped; the
|
|
515
|
+
// registry drops it itself. Own records win if an id appears in both.
|
|
516
|
+
const own = this.manager.listAgents();
|
|
517
|
+
const ownIds = new Set(own.map(a => a.id));
|
|
518
|
+
const orphans = this.detachedSource().map(d => d.record).filter(r => !ownIds.has(r.id));
|
|
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
|
+
})
|
|
548
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private detachedFor(id: string): RunningDetached | undefined {
|
|
552
|
+
return this.detachedSource().find(d => d.record.id === id);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private activityFor(id: string): AgentActivity | undefined {
|
|
556
|
+
return this.agentActivity.get(id) ?? this.detachedFor(id)?.activity;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Wire workflow runs into the list.
|
|
561
|
+
*
|
|
562
|
+
* Injected rather than constructed here because the fleet list predates
|
|
563
|
+
* workflows and must keep working without them — a session with the feature
|
|
564
|
+
* switched off never calls this, and the roster is agents-only exactly as
|
|
565
|
+
* before.
|
|
566
|
+
*/
|
|
567
|
+
setWorkflowSource(
|
|
568
|
+
source: () => readonly FleetWorkflow[],
|
|
569
|
+
open: (id: string) => Promise<void> | void,
|
|
570
|
+
): void {
|
|
571
|
+
this.workflowSource = source;
|
|
572
|
+
this.openWorkflow = open;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Live runs, plus recently settled ones — the same linger the agents get. */
|
|
576
|
+
private workflows(): FleetWorkflow[] {
|
|
577
|
+
const now = this.clock.now();
|
|
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
|
+
})
|
|
602
|
+
.sort((a, b) => a.startedAt - b.startedAt);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Runs sit above the agents rather than interleaved by start time: a run owns
|
|
607
|
+
* most of the agents under it, so listing the container first is what makes
|
|
608
|
+
* the list read as a hierarchy rather than a shuffle.
|
|
609
|
+
*/
|
|
610
|
+
private roster(): FleetEntry[] {
|
|
611
|
+
return [
|
|
612
|
+
{ kind: "main" },
|
|
613
|
+
...this.workflows().map(workflow => ({ kind: "workflow" as const, workflow })),
|
|
614
|
+
...this.agentRecords().map(record => ({ kind: "agent" as const, record })),
|
|
615
|
+
];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private clampSelection(): void {
|
|
619
|
+
const max = this.roster().length - 1;
|
|
620
|
+
if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ---- Key handling ----
|
|
624
|
+
|
|
625
|
+
/** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
|
|
626
|
+
handleKey(data: string): { consume?: boolean; data?: string } | undefined {
|
|
627
|
+
if (!this.enabled || !this.ui) return undefined;
|
|
628
|
+
// Input listeners receive BOTH key-press and key-release (the kitty protocol
|
|
629
|
+
// emits both, and matchesKey matches either) — act on press only, or every
|
|
630
|
+
// tap would move/fire twice. Repeats still pass through for held-key nav.
|
|
631
|
+
if (isKeyRelease(data)) return undefined;
|
|
632
|
+
// While an overlay is open, let it own all input. Checked before the focus
|
|
633
|
+
// test below, which would otherwise read the dialog holding the keyboard as
|
|
634
|
+
// "the user left the list" and reset the selection out from under it.
|
|
635
|
+
if (this.viewerClose || this.viewingWorkflowId) return undefined;
|
|
636
|
+
// Input listeners fire BEFORE the focused component, and dialogs
|
|
637
|
+
// (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
|
|
638
|
+
// while getEditorText() still reads the detached — empty — editor. So when
|
|
639
|
+
// anything but the editor owns the keyboard, stay out of its keys (#123).
|
|
640
|
+
if (!this.editorHasFocus()) {
|
|
641
|
+
if (this.active) this.deactivate();
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
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
|
+
|
|
657
|
+
if (!this.active) {
|
|
658
|
+
// Activate: ↓ or ← at an empty prompt moves focus into the list.
|
|
659
|
+
const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
|
|
660
|
+
// Gated on the roster, not the agents: a session whose only row is a
|
|
661
|
+
// workflow run still has somewhere to go, and requiring an agent would
|
|
662
|
+
// render the row but refuse to move into it.
|
|
663
|
+
if (isActivator && this.roster().length > 1 && this.ui.getEditorText() === "") {
|
|
664
|
+
this.active = true;
|
|
665
|
+
this.selectedIndex = 0;
|
|
666
|
+
this.update();
|
|
667
|
+
return { consume: true };
|
|
668
|
+
}
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
|
|
673
|
+
if (matchesKey(data, "down")) {
|
|
674
|
+
const max = this.roster().length - 1;
|
|
675
|
+
this.selectedIndex = Math.min(max, this.selectedIndex + 1);
|
|
676
|
+
this.update();
|
|
677
|
+
return { consume: true };
|
|
678
|
+
}
|
|
679
|
+
if (matchesKey(data, "up")) {
|
|
680
|
+
if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
|
|
681
|
+
this.selectedIndex -= 1;
|
|
682
|
+
this.update();
|
|
683
|
+
return { consume: true };
|
|
684
|
+
}
|
|
685
|
+
if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
|
|
686
|
+
if (matchesKey(data, Key.enter)) { this.openSelected(this.ui); return { consume: true }; }
|
|
687
|
+
|
|
688
|
+
// Any other key cancels navigation and flows to the editor.
|
|
689
|
+
this.deactivate();
|
|
690
|
+
return undefined;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* True when pi's prompt editor owns the keyboard. The editor is pluggable
|
|
695
|
+
* (ui.setEditorComponent) and need not extend `Editor` (#374), so match pi-tui's
|
|
696
|
+
* `EditorComponent` contract instead: `getText` + `setText`, which pi calls on
|
|
697
|
+
* every editor and none of pi's dialogs, selectors or overlays implement.
|
|
698
|
+
* `focusedComponent` is TUI-private (no public accessor), hence the best-effort
|
|
699
|
+
* peek: unknowable focus (no tui seen yet, nothing focused) counts as the
|
|
700
|
+
* editor so activation keeps working.
|
|
701
|
+
*/
|
|
702
|
+
editorHasFocus(): boolean {
|
|
703
|
+
const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
|
|
704
|
+
if (focused == null) return true;
|
|
705
|
+
const c = focused as { getText?: unknown; setText?: unknown };
|
|
706
|
+
return typeof c.getText === "function" && typeof c.setText === "function";
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private deactivate(): void {
|
|
710
|
+
this.active = false;
|
|
711
|
+
this.selectedIndex = 0;
|
|
712
|
+
this.update();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private openSelected(ui: FleetUICtx): void {
|
|
716
|
+
const entry = this.roster()[this.selectedIndex];
|
|
717
|
+
if (!entry || entry.kind === "main") {
|
|
718
|
+
// `main` = return to the prompt; the native transcript is already shown.
|
|
719
|
+
this.deactivate();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
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
|
+
}
|
|
729
|
+
// The extension owns this overlay and closes it, so there is no
|
|
730
|
+
// `viewerClose` to hold — but the list still has to know one is up, and
|
|
731
|
+
// still has to put the cursor back on the run when it comes down.
|
|
732
|
+
this.viewingWorkflowId = entry.workflow.id;
|
|
733
|
+
void Promise.resolve(this.openWorkflow?.(entry.workflow.id)).then(
|
|
734
|
+
() => this.clearViewer(true),
|
|
735
|
+
() => this.clearViewer(),
|
|
736
|
+
);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const record = entry.record;
|
|
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");
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const activity = this.activityFor(record.id);
|
|
753
|
+
const orphan = this.detachedFor(record.id);
|
|
754
|
+
this.viewingAgentId = record.id;
|
|
755
|
+
let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
|
|
756
|
+
|
|
757
|
+
void ui.custom<undefined>(
|
|
758
|
+
(tui, theme, keybindings, done) => {
|
|
759
|
+
overlayOptions = getConversationOverlayOptions(tui);
|
|
760
|
+
this.viewerClose = () => done(undefined);
|
|
761
|
+
return new ConversationViewer(
|
|
762
|
+
tui,
|
|
763
|
+
session,
|
|
764
|
+
record,
|
|
765
|
+
activity,
|
|
766
|
+
theme,
|
|
767
|
+
done,
|
|
768
|
+
() => {
|
|
769
|
+
if ((orphan ? orphan.abort() : this.manager.abort(record.id))) this.ui?.notify(`Stopped "${record.description}".`, "info");
|
|
770
|
+
},
|
|
771
|
+
keybindings,
|
|
772
|
+
(message: string) => (orphan ? orphan.steer(message) : this.manager.steer(record.id, message)),
|
|
773
|
+
this.showCost(),
|
|
774
|
+
this.viewerMarkdown,
|
|
775
|
+
this.onViewerMarkdown,
|
|
776
|
+
this.scrollbarMode(),
|
|
777
|
+
this.backgroundAction?.(record, message => this.ui?.notify(message, "info")),
|
|
778
|
+
this.clock,
|
|
779
|
+
);
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
overlay: true,
|
|
783
|
+
overlayOptions: () => overlayOptions,
|
|
784
|
+
},
|
|
785
|
+
).then(() => this.clearViewer(true), () => this.clearViewer());
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Reset overlay state and return to the list (on close, auto-close, or error). */
|
|
789
|
+
private clearViewer(acknowledge = false): void {
|
|
790
|
+
// Keep the cursor on the agent we were viewing — re-resolve by id so it
|
|
791
|
+
// still feels natural if the list reordered (an earlier agent finished)
|
|
792
|
+
// while the overlay was open. If that agent is gone, leave the index for
|
|
793
|
+
// update()'s clamp to settle.
|
|
794
|
+
const viewed = this.viewingAgentId ?? this.viewingWorkflowId;
|
|
795
|
+
if (viewed !== undefined) {
|
|
796
|
+
const idx = this.roster().findIndex(e =>
|
|
797
|
+
e.kind === "agent" ? e.record.id === viewed
|
|
798
|
+
: e.kind === "workflow" ? e.workflow.id === viewed
|
|
799
|
+
: false,
|
|
800
|
+
);
|
|
801
|
+
if (idx >= 0) this.selectedIndex = idx;
|
|
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
|
+
}
|
|
814
|
+
this.viewerClose = undefined;
|
|
815
|
+
this.viewingAgentId = undefined;
|
|
816
|
+
this.viewingWorkflowId = undefined;
|
|
817
|
+
this.update();
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ---- Rendering ----
|
|
821
|
+
|
|
822
|
+
private renderBar(width: number, theme: Theme): string[] {
|
|
823
|
+
const rows = this.roster().slice(1) as (WorkflowEntry | AgentEntry)[];
|
|
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
|
+
}
|
|
827
|
+
// Clamp locally so a render between a roster shrink and the next update()
|
|
828
|
+
// (e.g. on terminal resize) never loses the selection marker.
|
|
829
|
+
const sel = Math.min(this.selectedIndex, rows.length);
|
|
830
|
+
|
|
831
|
+
const hint = this.active
|
|
832
|
+
? "↑↓ select · enter view · esc back"
|
|
833
|
+
: `esc to interrupt · ← for agents · ↓ to manage · ${FLEET_COLLAPSE_KEY} to collapse`;
|
|
834
|
+
const lines: string[] = [];
|
|
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));
|
|
839
|
+
lines.push("");
|
|
840
|
+
lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
|
|
841
|
+
|
|
842
|
+
// At the cap, the row block holds rows alone. Once it overflows, reserve
|
|
843
|
+
// two of those slots for indicators, keeping the block exactly cap lines
|
|
844
|
+
// tall while the selected row stays in the agent window.
|
|
845
|
+
const overflow = rows.length > MAX_AGENT_ROWS;
|
|
846
|
+
const visible = Math.min(overflow ? OVERFLOW_AGENT_ROWS : MAX_AGENT_ROWS, rows.length);
|
|
847
|
+
const selRow = Math.max(0, sel - 1);
|
|
848
|
+
const start = selRow < visible ? 0 : selRow - visible + 1;
|
|
849
|
+
const hiddenBelow = rows.length - (start + visible);
|
|
850
|
+
|
|
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.
|
|
856
|
+
const cells = new Map<AgentRecord, string[]>();
|
|
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));
|
|
872
|
+
}
|
|
873
|
+
for (let a = start; a < start + visible; a++) {
|
|
874
|
+
const row = rows[a];
|
|
875
|
+
lines.push(
|
|
876
|
+
row.kind === "workflow" ?
|
|
877
|
+
this.renderWorkflowRow(a + 1, sel, row.workflow, width, theme)
|
|
878
|
+
// Every agent row's cells were computed above.
|
|
879
|
+
: this.renderAgentRow(a + 1, sel, row.record, width, theme, cells.get(row.record)!, colWidths),
|
|
880
|
+
);
|
|
881
|
+
}
|
|
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("");
|
|
886
|
+
// pi stacks below-editor widgets directly on its footer with no spacer (#351).
|
|
887
|
+
lines.push("");
|
|
888
|
+
|
|
889
|
+
return lines;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private bullet(rosterIndex: number, sel: number, theme: Theme): string {
|
|
893
|
+
return rosterIndex === sel ? theme.fg("accent", "●") : theme.fg("dim", "○");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* A run's row. Shaped like an agent's — bullet, kind, name, stats flush right
|
|
898
|
+
* — so the two read as one list, with the agent count where an agent has its
|
|
899
|
+
* description and the same elapsed/token tail.
|
|
900
|
+
*/
|
|
901
|
+
private renderWorkflowRow(
|
|
902
|
+
rosterIndex: number,
|
|
903
|
+
sel: number,
|
|
904
|
+
workflow: FleetWorkflow,
|
|
905
|
+
width: number,
|
|
906
|
+
theme: Theme,
|
|
907
|
+
): string {
|
|
908
|
+
const selected = rosterIndex === sel;
|
|
909
|
+
const kind = theme.fg(selected ? "text" : "muted", "workflow");
|
|
910
|
+
const name = selected ? theme.fg("text", workflow.name) : workflow.name;
|
|
911
|
+
const left = ` ${this.bullet(rosterIndex, sel, theme)} ${kind} ${name}`;
|
|
912
|
+
// Frozen once the run settles, exactly as an agent's clock is.
|
|
913
|
+
const elapsed = (workflow.completedAt ?? this.clock.now()) - workflow.startedAt;
|
|
914
|
+
const agents = `${workflow.doneCount}/${workflow.totalCount} agent${workflow.totalCount === 1 ? "" : "s"}`;
|
|
915
|
+
const stats = `${agents} · ${formatFleetElapsed(elapsed)} · ${formatFleetTokens(workflow.tokens)}`;
|
|
916
|
+
return rightAlign(left, selected ? theme.fg("text", stats) : theme.fg("dim", stats), width);
|
|
917
|
+
}
|
|
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
|
+
|
|
937
|
+
/** One agent's stats tail as {@link TAIL_SLOTS} cells; "" where a slot has no value. */
|
|
938
|
+
private tailCells(record: AgentRecord, theme: Theme): string[] {
|
|
939
|
+
// The record, not the activity tracker — see the note in AgentWidget's
|
|
940
|
+
// running line: only the record carries a nested child's spend, and only it
|
|
941
|
+
// outlives the agent.
|
|
942
|
+
const tokens = getLifetimeTotal(record.lifetimeUsage);
|
|
943
|
+
const elapsedMs = (record.completedAt ?? this.clock.now()) - record.startedAt; // freezes once finished
|
|
944
|
+
let model = "";
|
|
945
|
+
let thinking = "";
|
|
946
|
+
if (this.showModel()) {
|
|
947
|
+
// Leading, and paired: a thinking level means nothing without its model.
|
|
948
|
+
const { modelName, tags } = buildInvocationTags(record.invocation);
|
|
949
|
+
model = modelName ?? "";
|
|
950
|
+
thinking = compactThinkingTag(tags);
|
|
951
|
+
}
|
|
952
|
+
const state = agentState(record.status);
|
|
953
|
+
// What the in-flight model call is doing, only while the agent is running:
|
|
954
|
+
// a finished row has no call to describe.
|
|
955
|
+
const call = record.status === "running"
|
|
956
|
+
? describeModelCall(this.activityFor(record.id)?.modelCall, this.clock.now())
|
|
957
|
+
: undefined;
|
|
958
|
+
const cost = this.showCost() ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
|
|
959
|
+
return [theme.fg(state.color, `${state.glyph} ${state.label}`), model, thinking, call ?? "", formatFleetElapsed(elapsedMs), formatFleetTokens(tokens), cost];
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private renderAgentRow(
|
|
963
|
+
rosterIndex: number,
|
|
964
|
+
sel: number,
|
|
965
|
+
record: AgentRecord,
|
|
966
|
+
width: number,
|
|
967
|
+
theme: Theme,
|
|
968
|
+
cells: string[],
|
|
969
|
+
colWidths: number[],
|
|
970
|
+
): string {
|
|
971
|
+
// The selected row renders in the theme's primary text color so it reads as
|
|
972
|
+
// one selection (#230). A configured badge survives — Claude Code's FleetView
|
|
973
|
+
// keeps the agent color on the selected row too and only bolds it — which also
|
|
974
|
+
// keeps the row's width fixed as the selection moves.
|
|
975
|
+
const selected = rosterIndex === sel;
|
|
976
|
+
const name = renderAgentName(record.type, theme, selected
|
|
977
|
+
? { fallbackColor: "text", bold: hasAgentBadge(record.type) }
|
|
978
|
+
: { fallbackColor: "muted" });
|
|
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}`;
|
|
985
|
+
// Fixed-width slots (blank where a row has no value) keep each column at the
|
|
986
|
+
// same cells on every row; an all-empty column is dropped.
|
|
987
|
+
let stats = TAIL_SLOTS.map((slot, i) => {
|
|
988
|
+
if (colWidths[i] === 0) return undefined;
|
|
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];
|
|
992
|
+
}).filter((c): c is string => c !== undefined).join(" · ");
|
|
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);
|
|
1019
|
+
}
|
|
1020
|
+
}
|