@nredd/pi-subagents 0.22.0-nredd.8 → 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 +136 -55
- package/package.json +14 -11
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +0 -1
- package/src/agent-manager.ts +360 -103
- package/src/agent-runner.ts +121 -63
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +10 -6
- package/src/custom-agents.ts +2 -1
- package/src/default-agents.ts +19 -17
- package/src/detached-results.ts +18 -2
- package/src/enterprise-local.ts +2 -1
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/index.ts +731 -231
- package/src/invocation-config.ts +1 -5
- package/src/mention-clone.ts +4 -3
- package/src/model-resolver.ts +51 -14
- package/src/model-selection.ts +17 -6
- package/src/model-tiers.ts +5 -0
- package/src/nested-tools.ts +80 -21
- package/src/notification-result.ts +118 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +16 -6
- package/src/resource-loader.ts +20 -6
- package/src/schedule.ts +65 -19
- package/src/settings.ts +21 -1
- package/src/skill-loader.ts +244 -94
- package/src/status-note.ts +28 -5
- package/src/types.ts +52 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-widget.ts +41 -22
- package/src/ui/conversation-viewer.ts +4 -2
- package/src/ui/fallback-flag.ts +28 -4
- package/src/ui/fleet-list.ts +89 -54
- package/src/ui/notification-view.ts +4 -1
- package/src/ui/workflow-card.ts +1 -8
- package/src/ui/workflow-dialog.ts +31 -18
- package/src/workflow/host.ts +4 -5
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +4 -3
- package/src/workflow/runtime.ts +23 -7
- package/src/workflow/saved.ts +2 -4
- package/src/workflow/task.ts +12 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +6 -2
- package/src/worktree.ts +309 -19
package/src/ui/agent-widget.ts
CHANGED
|
@@ -114,11 +114,21 @@ export function fgPreservingNestedStyles(theme: Theme, color: string, text: stri
|
|
|
114
114
|
return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, reset => `${reset}${styleStart}`));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Format a token count as a bare magnitude: "950", "18.4k", "1.2M". The unit is
|
|
119
|
+
* picked against the ROUNDED thousands, so 999,999 reads "1.0M", never "1000.0k".
|
|
120
|
+
* Every token formatter wraps this one so the boundary is fixed in one place.
|
|
121
|
+
*/
|
|
122
|
+
export function formatCompactTokens(count: number): string {
|
|
123
|
+
const thousands = Number((count / 1_000).toFixed(1));
|
|
124
|
+
if (thousands >= 1_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
125
|
+
if (count >= 1_000) return `${thousands.toFixed(1)}k`;
|
|
126
|
+
return `${count}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
/** Format a token count compactly: "33.8k token", "1.2M token". */
|
|
118
130
|
export function formatTokens(count: number): string {
|
|
119
|
-
|
|
120
|
-
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
|
|
121
|
-
return `${count} token`;
|
|
131
|
+
return `${formatCompactTokens(count)} token`;
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
/**
|
|
@@ -220,15 +230,27 @@ export function buildInvocationTags(
|
|
|
220
230
|
if (thinking) tags.push(`thinking: ${thinking}`);
|
|
221
231
|
if (invocation.isolated) tags.push("isolated");
|
|
222
232
|
if (invocation.isolation === "worktree") tags.push("worktree");
|
|
233
|
+
if (invocation.planMode) tags.push("plan");
|
|
223
234
|
if (invocation.inheritContext) tags.push("inherit context");
|
|
224
235
|
if (invocation.runInBackground) tags.push("background");
|
|
225
236
|
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
|
|
226
237
|
return { modelName: invocation.modelName, modelId: invocation.modelId, tags };
|
|
227
238
|
}
|
|
228
239
|
|
|
229
|
-
/**
|
|
240
|
+
/**
|
|
241
|
+
* The thinking tag from {@link buildInvocationTags} as `think:high`, or "" when
|
|
242
|
+
* there is none. Both agent lists use it so the configured level cannot read as
|
|
243
|
+
* a duplicate of the live `thinking · 4s` activity on the same row (#144).
|
|
244
|
+
*/
|
|
245
|
+
export function compactThinkingTag(tags: readonly string[]): string {
|
|
246
|
+
return (tags.find(tag => tag.startsWith("thinking: ")) ?? "").replace(/^thinking: /, "think:");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Truncate text to its first non-blank line, max `len` chars. */
|
|
230
250
|
function truncateLine(text: string, len = 60): string {
|
|
231
|
-
|
|
251
|
+
// Trimming the whole text first drops any leading blank lines, so its first
|
|
252
|
+
// line is the first non-blank one (or "" for blank text).
|
|
253
|
+
const line = text.trim().split("\n")[0].trimEnd();
|
|
232
254
|
if (line.length <= len) return line;
|
|
233
255
|
return line.slice(0, len) + "…";
|
|
234
256
|
}
|
|
@@ -397,9 +419,9 @@ export class AgentWidget {
|
|
|
397
419
|
}
|
|
398
420
|
|
|
399
421
|
/** Render a finished agent line. */
|
|
400
|
-
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt
|
|
422
|
+
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt: number; error?: string; lifetimeUsage?: LifetimeUsage; modelFallback?: ModelFallback; modelTier?: TierDisclosure }, theme: Theme): string {
|
|
401
423
|
const modeLabel = getPromptModeLabel(a.type);
|
|
402
|
-
const duration = formatMs(
|
|
424
|
+
const duration = formatMs(a.completedAt - a.startedAt);
|
|
403
425
|
|
|
404
426
|
let icon: string;
|
|
405
427
|
let statusText: string;
|
|
@@ -447,8 +469,8 @@ export class AgentWidget {
|
|
|
447
469
|
const allAgents = this.widgetAgents();
|
|
448
470
|
const running = allAgents.filter(a => a.status === "running");
|
|
449
471
|
const queued = allAgents.filter(a => a.status === "queued");
|
|
450
|
-
const finished = allAgents.filter(a =>
|
|
451
|
-
a.status !== "running" && a.status !== "queued" && a.completedAt
|
|
472
|
+
const finished = allAgents.filter((a): a is typeof a & { completedAt: number } =>
|
|
473
|
+
a.status !== "running" && a.status !== "queued" && !!a.completedAt
|
|
452
474
|
&& this.shouldShowFinished(a.id, a.status),
|
|
453
475
|
);
|
|
454
476
|
|
|
@@ -497,7 +519,7 @@ export class AgentWidget {
|
|
|
497
519
|
// rebuilt so the "(asked X)" annotation survives.
|
|
498
520
|
const { modelName, tags } = buildInvocationTags(a.invocation);
|
|
499
521
|
if (modelName) parts.push(modelName);
|
|
500
|
-
const thinkingTag = tags
|
|
522
|
+
const thinkingTag = compactThinkingTag(tags);
|
|
501
523
|
if (thinkingTag) parts.push(thinkingTag);
|
|
502
524
|
}
|
|
503
525
|
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
|
|
@@ -532,18 +554,15 @@ export class AgentWidget {
|
|
|
532
554
|
if (queuedLine) lines.push(queuedLine);
|
|
533
555
|
|
|
534
556
|
// Fix last connector: swap ├─ → └─ and │ → space for activity lines.
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
lines[last] = lines[last].replace("│ ", " ");
|
|
545
|
-
}
|
|
546
|
-
}
|
|
557
|
+
// Something is active or finished (checked above), so the body is never
|
|
558
|
+
// empty and `last` is past the heading.
|
|
559
|
+
const last = lines.length - 1;
|
|
560
|
+
lines[last] = lines[last].replace("├─", "└─");
|
|
561
|
+
// If the last item is a running agent, its header and activity are the
|
|
562
|
+
// last two lines: fix the header's connector and the activity's indent.
|
|
563
|
+
if (runningLines.length > 0 && !queuedLine) {
|
|
564
|
+
lines[last - 1] = lines[last - 1].replace("├─", "└─");
|
|
565
|
+
lines[last] = lines[last].replace("│ ", " ");
|
|
547
566
|
}
|
|
548
567
|
} else {
|
|
549
568
|
// Overflow — prioritize: running > queued > finished.
|
|
@@ -546,7 +546,8 @@ export class ConversationViewer implements Component, Focusable {
|
|
|
546
546
|
if (this.composer) {
|
|
547
547
|
// Composer row: the Input renders its own `> ` prompt and cursor.
|
|
548
548
|
this.composer.focused = this.focused;
|
|
549
|
-
|
|
549
|
+
// Input always renders exactly one row.
|
|
550
|
+
lines.push(row(this.composer.render(innerW)[0]));
|
|
550
551
|
footerActions.push({ label: "Enter send", key: "\r" }, { label: "Esc cancel", key: "\x1b" });
|
|
551
552
|
const composeHint = th.fg("dim", "Enter send · Esc cancel");
|
|
552
553
|
const composeLeft = th.fg("accent", "✎ steer");
|
|
@@ -590,7 +591,8 @@ export class ConversationViewer implements Component, Focusable {
|
|
|
590
591
|
return lines;
|
|
591
592
|
}
|
|
592
593
|
// Keep the footer on screen even in a terminal shorter than the chrome.
|
|
593
|
-
|
|
594
|
+
// Never empty: the header and footer rows were pushed above.
|
|
595
|
+
const footer = lines.pop()!;
|
|
594
596
|
const output = rows > 0 ? [...lines.slice(0, rows - 1), footer] : [];
|
|
595
597
|
const footerText = stripTerminalSequences(footer);
|
|
596
598
|
for (const action of footerActions) {
|
package/src/ui/fallback-flag.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* building their own marker; `test/fallback-flag-renderers.test.ts` walks every
|
|
10
10
|
* renderer and fails the ones that do not.
|
|
11
11
|
*/
|
|
12
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
13
13
|
import type { TierDisclosure } from "../model-tiers.js";
|
|
14
14
|
|
|
15
15
|
/** Minimal theme surface, so this module has no dependency on the widgets that use it. */
|
|
@@ -56,6 +56,29 @@ export function fallbackFlag(theme: FlagTheme, carrier: FallbackCarrier | undefi
|
|
|
56
56
|
/** What a flag degrades to when even it does not fit; plain so it cannot depend on a theme. */
|
|
57
57
|
const MINIMAL_FLAGS = ["[fb]", "!"] as const;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Wrap control text into a fixed row budget while preserving both its opening
|
|
61
|
+
* and final diagnosis around an explicit omitted-rows marker. The budget is at
|
|
62
|
+
* least three rows: one each for the opening, the marker and the diagnosis.
|
|
63
|
+
* A non-finite `maxRows` gets that floor rather than poisoning the slices.
|
|
64
|
+
*/
|
|
65
|
+
export function boundedWrappedRows(text: string, width: number, maxRows: number, marker: string): string[] {
|
|
66
|
+
const rows = wrapTextWithAnsi(text, width);
|
|
67
|
+
const budget = Number.isFinite(maxRows) ? Math.max(3, Math.trunc(maxRows)) : 3;
|
|
68
|
+
if (rows.length <= budget) return rows;
|
|
69
|
+
|
|
70
|
+
// Reserve a row for the omission marker and favor the final rows: fallback
|
|
71
|
+
// diagnoses live at the end, while the first row still names the control text.
|
|
72
|
+
// At least two rows are omitted, so the marker never replaces a single row.
|
|
73
|
+
const firstCount = Math.max(1, Math.floor((budget - 1) / 2));
|
|
74
|
+
const lastCount = budget - firstCount - 1;
|
|
75
|
+
const omitted = rows.length - firstCount - lastCount;
|
|
76
|
+
const omission = stripTerminalSequences(
|
|
77
|
+
truncateToWidth(`${marker} ${omitted} more lines`, width, ""),
|
|
78
|
+
);
|
|
79
|
+
return [...rows.slice(0, firstCount), omission, ...rows.slice(-lastCount)];
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
/**
|
|
60
83
|
* `prefix + flag + rest`, never wider than `width`, and the flag is never cut.
|
|
61
84
|
*
|
|
@@ -65,7 +88,8 @@ const MINIMAL_FLAGS = ["[fb]", "!"] as const;
|
|
|
65
88
|
* Narrow case, when `prefix + flag + " "` exceeds `width`: the prefix and then
|
|
66
89
|
* the rest are dropped before the flag is. If the flag alone still does not
|
|
67
90
|
* fit, it degrades to a plain `[fb]`, then `!`. Only a zero width renders
|
|
68
|
-
* nothing.
|
|
91
|
+
* nothing. An empty string `rest` renders no separator after the flag, so a
|
|
92
|
+
* caller sizing the flag alone gets just the flag.
|
|
69
93
|
*/
|
|
70
94
|
export function withLeadingFlag(
|
|
71
95
|
prefix: string,
|
|
@@ -73,7 +97,7 @@ export function withLeadingFlag(
|
|
|
73
97
|
rest: string | ((inner: number) => string),
|
|
74
98
|
width: number,
|
|
75
99
|
): string {
|
|
76
|
-
const lead = flag ? `${prefix}${flag} ` : prefix;
|
|
100
|
+
const lead = flag ? `${prefix}${flag}${rest === "" ? "" : " "}` : prefix;
|
|
77
101
|
if (!flag || visibleWidth(lead) <= width) {
|
|
78
102
|
const inner = Math.max(0, width - visibleWidth(lead));
|
|
79
103
|
const body = typeof rest === "function" ? rest(inner) : truncateToWidth(rest, inner);
|
|
@@ -82,7 +106,7 @@ export function withLeadingFlag(
|
|
|
82
106
|
const flagWidth = visibleWidth(flag);
|
|
83
107
|
if (flagWidth <= width) {
|
|
84
108
|
const inner = width - flagWidth - 1;
|
|
85
|
-
if (inner < 1) return flag;
|
|
109
|
+
if (inner < 1 || rest === "") return flag;
|
|
86
110
|
const body = typeof rest === "function" ? rest(inner) : truncateToWidth(rest, inner);
|
|
87
111
|
return truncateToWidth(`${flag} ${body}`, width);
|
|
88
112
|
}
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
|
|
3
3
|
*
|
|
4
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.
|
|
5
6
|
* Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
|
|
6
7
|
* ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
|
|
7
8
|
* Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
|
|
@@ -20,13 +21,17 @@ import type { RunningDetached } from "../detached-results.js";
|
|
|
20
21
|
import { describeModelCall } from "../model-call.js";
|
|
21
22
|
import type { AgentRecord, ViewerMarkdownMode } from "../types.js";
|
|
22
23
|
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
23
|
-
import { type AgentActivity, buildInvocationTags, fgPreservingNestedStyles, formatCost, type Theme } from "./agent-widget.js";
|
|
24
|
+
import { type AgentActivity, buildInvocationTags, compactThinkingTag, fgPreservingNestedStyles, formatCompactTokens, formatCost, type Theme } from "./agent-widget.js";
|
|
24
25
|
import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./conversation-viewer.js";
|
|
25
26
|
import { fallbackFlag, hasFallback, withLeadingFlag } from "./fallback-flag.js";
|
|
26
27
|
import { FleetJournal, type FleetJournalEntry, type FleetSnapshot, fleetAlert, MAX_COMPACT_ALERT_COUNT } from "./fleet-state.js";
|
|
27
28
|
|
|
28
29
|
/** Matches the todo/monitor disclosure shortcut family; arrows keep their navigation roles. */
|
|
29
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;
|
|
30
35
|
/** Quiet-period debounce bounds journal writes per toggle burst; shutdown flushes immediately. */
|
|
31
36
|
export const FLEET_DISCLOSURE_SETTLE_MS = 300;
|
|
32
37
|
|
|
@@ -96,11 +101,7 @@ export function formatFleetElapsed(ms: number): string {
|
|
|
96
101
|
|
|
97
102
|
/** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */
|
|
98
103
|
export function formatFleetTokens(count: number): string {
|
|
99
|
-
|
|
100
|
-
if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`;
|
|
101
|
-
else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`;
|
|
102
|
-
else compact = `${count}`;
|
|
103
|
-
return `↓ ${compact} tokens`;
|
|
104
|
+
return `↓ ${formatCompactTokens(count)} tokens`;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/**
|
|
@@ -116,21 +117,31 @@ function rightAlign(left: string, right: string, width: number): string {
|
|
|
116
117
|
return truncateToWidth(leftClamped + " ".repeat(gap) + right, width);
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
];
|
|
134
145
|
|
|
135
146
|
/** Columns reserved for the bullet, name and the start of the description. */
|
|
136
147
|
const MIN_LEFT_WIDTH = 24;
|
|
@@ -220,7 +231,8 @@ export function renderFleetSummary(
|
|
|
220
231
|
compact.pop();
|
|
221
232
|
flagText = `[${compact.join("")}]`;
|
|
222
233
|
}
|
|
223
|
-
|
|
234
|
+
// `withLeadingFlag` renders nothing at all at zero width.
|
|
235
|
+
if (visibleWidth(flagText) > width) flagText = "!";
|
|
224
236
|
}
|
|
225
237
|
const flag = theme.fg(failed ? "error" : "warning", flagText);
|
|
226
238
|
const cost = showCost ? formatCost(agents.reduce((sum, agent) => sum + getLifetimeCost(agent.lifetimeUsage), 0)) : "";
|
|
@@ -247,6 +259,10 @@ export class FleetList {
|
|
|
247
259
|
private readonly observedWorkflows = new WeakMap<object, number>();
|
|
248
260
|
/** Quota parking has no AgentRecord; the scheduler supplies its enabled-job count. */
|
|
249
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;
|
|
250
266
|
/** Whether arrow keys currently navigate the list (vs. flow to the editor). */
|
|
251
267
|
private active = false;
|
|
252
268
|
/** 0 = `main`, 1..N = subagents. */
|
|
@@ -357,6 +373,29 @@ export class FleetList {
|
|
|
357
373
|
this.parkedSource = source;
|
|
358
374
|
}
|
|
359
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
|
+
|
|
360
399
|
// ---- Lifecycle ----
|
|
361
400
|
|
|
362
401
|
setEnabled(enabled: boolean): void {
|
|
@@ -422,12 +461,10 @@ export class FleetList {
|
|
|
422
461
|
this.widgetRegistered = false;
|
|
423
462
|
this.tui = undefined;
|
|
424
463
|
}
|
|
425
|
-
// A just-spawned agent
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
if (this.enabled && this.hasStartingAgent()) this.ensureTimer();
|
|
430
|
-
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; }
|
|
431
468
|
this.active = false;
|
|
432
469
|
this.selectedIndex = 0;
|
|
433
470
|
return;
|
|
@@ -462,12 +499,6 @@ export class FleetList {
|
|
|
462
499
|
}
|
|
463
500
|
}
|
|
464
501
|
|
|
465
|
-
/** Whether a top-level agent is live but not yet listable (no session yet). */
|
|
466
|
-
private hasStartingAgent(): boolean {
|
|
467
|
-
return this.manager.listAgents().some(a =>
|
|
468
|
-
isTopLevelAgent(a) && !a.session && (a.status === "running" || a.status === "queued"));
|
|
469
|
-
}
|
|
470
|
-
|
|
471
502
|
// ---- Roster ----
|
|
472
503
|
|
|
473
504
|
/**
|
|
@@ -499,7 +530,7 @@ export class FleetList {
|
|
|
499
530
|
const live = records.get(alert.id);
|
|
500
531
|
if (!live || !isOutstandingFailure(live)) records.set(`fleet-alert:${alert.id}`, {
|
|
501
532
|
id: alert.id, type: alert.type, description: alert.description, status: alert.state,
|
|
502
|
-
startedAt: alert.timestamp, completedAt: alert.timestamp, toolUses: 0, compactionCount: 0,
|
|
533
|
+
startedAt: alert.timestamp, completedAt: alert.timestamp, toolUses: 0, compactionCount: 0, depth: 1,
|
|
503
534
|
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0, cost: 0 },
|
|
504
535
|
});
|
|
505
536
|
}
|
|
@@ -587,7 +618,6 @@ export class FleetList {
|
|
|
587
618
|
private clampSelection(): void {
|
|
588
619
|
const max = this.roster().length - 1;
|
|
589
620
|
if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max);
|
|
590
|
-
if (this.selectedIndex < 0) this.selectedIndex = 0;
|
|
591
621
|
}
|
|
592
622
|
|
|
593
623
|
// ---- Key handling ----
|
|
@@ -618,6 +648,11 @@ export class FleetList {
|
|
|
618
648
|
this.toggleCollapse();
|
|
619
649
|
return { consume: true };
|
|
620
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
|
+
}
|
|
621
656
|
|
|
622
657
|
if (!this.active) {
|
|
623
658
|
// Activate: ↓ or ← at an empty prompt moves focus into the list.
|
|
@@ -648,7 +683,7 @@ export class FleetList {
|
|
|
648
683
|
return { consume: true };
|
|
649
684
|
}
|
|
650
685
|
if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
|
|
651
|
-
if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
|
|
686
|
+
if (matchesKey(data, Key.enter)) { this.openSelected(this.ui); return { consume: true }; }
|
|
652
687
|
|
|
653
688
|
// Any other key cancels navigation and flows to the editor.
|
|
654
689
|
this.deactivate();
|
|
@@ -677,7 +712,7 @@ export class FleetList {
|
|
|
677
712
|
this.update();
|
|
678
713
|
}
|
|
679
714
|
|
|
680
|
-
private openSelected(): void {
|
|
715
|
+
private openSelected(ui: FleetUICtx): void {
|
|
681
716
|
const entry = this.roster()[this.selectedIndex];
|
|
682
717
|
if (!entry || entry.kind === "main") {
|
|
683
718
|
// `main` = return to the prompt; the native transcript is already shown.
|
|
@@ -686,7 +721,7 @@ export class FleetList {
|
|
|
686
721
|
}
|
|
687
722
|
if (entry.kind === "workflow") {
|
|
688
723
|
if (!this.workflowSource?.().some(run => run.id === entry.workflow.id) || !this.openWorkflow) {
|
|
689
|
-
|
|
724
|
+
ui.notify(`workflow: ${entry.workflow.name} (${entry.workflow.status}).`, "error");
|
|
690
725
|
this.acknowledgeFailure(entry.workflow.id);
|
|
691
726
|
this.update();
|
|
692
727
|
return;
|
|
@@ -702,17 +737,16 @@ export class FleetList {
|
|
|
702
737
|
return;
|
|
703
738
|
}
|
|
704
739
|
const record = entry.record;
|
|
705
|
-
if (!this.ui) return;
|
|
706
740
|
const session = this.options.sessionFor ? this.options.sessionFor(record) : record.session;
|
|
707
741
|
if (!session) {
|
|
708
742
|
if (isOutstandingFailure(record)) {
|
|
709
|
-
|
|
743
|
+
ui.notify(record.error ?? `${record.type}: ${record.description} (${record.status}).`, "error");
|
|
710
744
|
markConsumed(record, this.clock.now());
|
|
711
745
|
this.acknowledgeFailure(record.id);
|
|
712
746
|
this.update();
|
|
713
747
|
return;
|
|
714
748
|
}
|
|
715
|
-
|
|
749
|
+
ui.notify(`Agent is ${record.status} — no session available.`, "info");
|
|
716
750
|
return;
|
|
717
751
|
}
|
|
718
752
|
const activity = this.activityFor(record.id);
|
|
@@ -720,7 +754,7 @@ export class FleetList {
|
|
|
720
754
|
this.viewingAgentId = record.id;
|
|
721
755
|
let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
|
|
722
756
|
|
|
723
|
-
void
|
|
757
|
+
void ui.custom<undefined>(
|
|
724
758
|
(tui, theme, keybindings, done) => {
|
|
725
759
|
overlayOptions = getConversationOverlayOptions(tui);
|
|
726
760
|
this.viewerClose = () => done(undefined);
|
|
@@ -814,8 +848,8 @@ export class FleetList {
|
|
|
814
848
|
const start = selRow < visible ? 0 : selRow - visible + 1;
|
|
815
849
|
const hiddenBelow = rows.length - (start + visible);
|
|
816
850
|
|
|
817
|
-
//
|
|
818
|
-
//
|
|
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.
|
|
819
853
|
// Column widths come from EVERY agent row, not the window, so scrolling
|
|
820
854
|
// never shifts a column; a column too wide for the terminal is dropped
|
|
821
855
|
// lowest-value first, never state, elapsed, tokens or cost.
|
|
@@ -835,18 +869,20 @@ export class FleetList {
|
|
|
835
869
|
);
|
|
836
870
|
if (start > 0) {
|
|
837
871
|
lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more: ${summarizeHidden(rows.slice(0, start))}`), width));
|
|
838
|
-
}
|
|
872
|
+
}
|
|
839
873
|
for (let a = start; a < start + visible; a++) {
|
|
840
874
|
const row = rows[a];
|
|
841
875
|
lines.push(
|
|
842
876
|
row.kind === "workflow" ?
|
|
843
877
|
this.renderWorkflowRow(a + 1, sel, row.workflow, width, theme)
|
|
844
|
-
|
|
878
|
+
// Every agent row's cells were computed above.
|
|
879
|
+
: this.renderAgentRow(a + 1, sel, row.record, width, theme, cells.get(row.record)!, colWidths),
|
|
845
880
|
);
|
|
846
881
|
}
|
|
847
882
|
if (hiddenBelow > 0) {
|
|
848
883
|
lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more: ${summarizeHidden(rows.slice(start + visible))}`), width));
|
|
849
884
|
} else if (overflow) lines.push("");
|
|
885
|
+
if (start === 0 && overflow) lines.push("");
|
|
850
886
|
// pi stacks below-editor widgets directly on its footer with no spacer (#351).
|
|
851
887
|
lines.push("");
|
|
852
888
|
|
|
@@ -888,7 +924,7 @@ export class FleetList {
|
|
|
888
924
|
return used.reduce((sum, w) => sum + w, 0) + 3 * Math.max(0, used.length - 1);
|
|
889
925
|
};
|
|
890
926
|
const droppable = TAIL_SLOTS
|
|
891
|
-
.map((slot, i) => ({ i, rank:
|
|
927
|
+
.map((slot, i) => ({ i, rank: slot.drop ?? 0 }))
|
|
892
928
|
.filter(c => c.rank > 0)
|
|
893
929
|
.sort((x, y) => x.rank - y.rank);
|
|
894
930
|
for (const { i } of droppable) {
|
|
@@ -911,9 +947,7 @@ export class FleetList {
|
|
|
911
947
|
// Leading, and paired: a thinking level means nothing without its model.
|
|
912
948
|
const { modelName, tags } = buildInvocationTags(record.invocation);
|
|
913
949
|
model = modelName ?? "";
|
|
914
|
-
|
|
915
|
-
// of the live `thinking · 4s` activity beside it.
|
|
916
|
-
thinking = (tags.find(tag => tag.startsWith("thinking: ")) ?? "").replace(/^thinking: /, "think:");
|
|
950
|
+
thinking = compactThinkingTag(tags);
|
|
917
951
|
}
|
|
918
952
|
const state = agentState(record.status);
|
|
919
953
|
// What the in-flight model call is doing, only while the agent is running:
|
|
@@ -952,8 +986,9 @@ export class FleetList {
|
|
|
952
986
|
// same cells on every row; an all-empty column is dropped.
|
|
953
987
|
let stats = TAIL_SLOTS.map((slot, i) => {
|
|
954
988
|
if (colWidths[i] === 0) return undefined;
|
|
955
|
-
|
|
956
|
-
|
|
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];
|
|
957
992
|
}).filter((c): c is string => c !== undefined).join(" · ");
|
|
958
993
|
let flag = fallbackFlag(theme, record);
|
|
959
994
|
if (flag && visibleWidth(prefix + flag) + 1 + visibleWidth(stats) > width) {
|
|
@@ -962,7 +997,7 @@ export class FleetList {
|
|
|
962
997
|
// the flag, before sacrificing any spend cell. Rendering an empty body in
|
|
963
998
|
// the flag budget selects [fb] or ! with the same policy as every renderer.
|
|
964
999
|
const flagBudget = Math.max(1, width - visibleWidth(prefix) - visibleWidth(stats) - 1);
|
|
965
|
-
flag = withLeadingFlag("", flag, "", flagBudget)
|
|
1000
|
+
flag = withLeadingFlag("", flag, "", flagBudget);
|
|
966
1001
|
}
|
|
967
1002
|
// Every row preserves spend, not just rows carrying a fallback marker.
|
|
968
1003
|
const markerWidth = flag ? visibleWidth(flag) + 1 : 0;
|
|
@@ -46,8 +46,10 @@ export function noticeSummary(d: NotificationDetails, theme: Theme, showCost: bo
|
|
|
46
46
|
// from the right, and a substituted model must not be what gets cut.
|
|
47
47
|
const flag = fallbackFlag(theme, d) ? `${fallbackFlag(theme, d)} ` : "";
|
|
48
48
|
const head = `${icon(d, theme)} ${flag}${theme.bold(d.description)}`;
|
|
49
|
+
// Ahead of the stats for the same reason: truncation must not cut it.
|
|
50
|
+
const resume = d.resumeHint ? [theme.fg("warning", `resume ${d.id} to continue`)] : [];
|
|
49
51
|
const stats = statParts(d, showCost).map(p => theme.fg("dim", p));
|
|
50
|
-
return [head, ...stats].join(dot);
|
|
52
|
+
return [head, ...resume, ...stats].join(dot);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** `3 agents finished · ✓ a, ✓ b, ✗ c`. */
|
|
@@ -83,6 +85,7 @@ function detailLines(d: NotificationDetails, theme: Theme, showCost: boolean, in
|
|
|
83
85
|
stats.push(...statParts(d, showCost));
|
|
84
86
|
if (stats.length > 0) lines.push(indent + theme.fg("dim", stats.join(" · ")));
|
|
85
87
|
if (d.error) lines.push(indent + theme.fg("error", d.error));
|
|
88
|
+
if (d.resumeHint) lines.push(indent + theme.fg("warning", d.resumeHint));
|
|
86
89
|
if (d.modelNote) lines.push(indent + theme.fg("warning", d.modelNote));
|
|
87
90
|
for (const line of d.resultPreview.split("\n").slice(0, EXPANDED_PREVIEW_LINES)) {
|
|
88
91
|
lines.push(indent + theme.fg("dim", line));
|
package/src/ui/workflow-card.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
type WorkflowEntry,
|
|
46
46
|
type WorkflowRunStatus,
|
|
47
47
|
} from "../workflow/progress.js";
|
|
48
|
-
import type
|
|
48
|
+
import { formatCompactTokens, type Theme } from "./agent-widget.js";
|
|
49
49
|
import { fallbackFlagText, hasFallback } from "./fallback-flag.js";
|
|
50
50
|
|
|
51
51
|
/**
|
|
@@ -184,13 +184,6 @@ export interface WorkflowCardInput {
|
|
|
184
184
|
* Formatting
|
|
185
185
|
* ------------------------------------------------------------------------- */
|
|
186
186
|
|
|
187
|
-
/** `18.4k` / `1.2M` — bare magnitude, since the row already reads as a stat. */
|
|
188
|
-
export function formatCompactTokens(count: number): string {
|
|
189
|
-
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
190
|
-
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
191
|
-
return `${count}`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
187
|
/**
|
|
195
188
|
* One label for the model pair. A fallback that never differed from the primary
|
|
196
189
|
* would just be noise, so it only shows when the run actually has two models in
|