@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.
Files changed (50) hide show
  1. package/README.md +136 -55
  2. package/package.json +14 -11
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +0 -1
  5. package/src/agent-manager.ts +360 -103
  6. package/src/agent-runner.ts +121 -63
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +10 -6
  10. package/src/custom-agents.ts +2 -1
  11. package/src/default-agents.ts +19 -17
  12. package/src/detached-results.ts +18 -2
  13. package/src/enterprise-local.ts +2 -1
  14. package/src/error-message.ts +11 -0
  15. package/src/git-path.ts +66 -0
  16. package/src/group-join.ts +4 -10
  17. package/src/index.ts +731 -231
  18. package/src/invocation-config.ts +1 -5
  19. package/src/mention-clone.ts +4 -3
  20. package/src/model-resolver.ts +51 -14
  21. package/src/model-selection.ts +17 -6
  22. package/src/model-tiers.ts +5 -0
  23. package/src/nested-tools.ts +80 -21
  24. package/src/notification-result.ts +118 -0
  25. package/src/plan-mode-children.ts +176 -0
  26. package/src/prompts.ts +7 -2
  27. package/src/quota-admission.ts +16 -6
  28. package/src/resource-loader.ts +20 -6
  29. package/src/schedule.ts +65 -19
  30. package/src/settings.ts +21 -1
  31. package/src/skill-loader.ts +244 -94
  32. package/src/status-note.ts +28 -5
  33. package/src/types.ts +52 -2
  34. package/src/ui/agent-mention.ts +5 -2
  35. package/src/ui/agent-widget.ts +41 -22
  36. package/src/ui/conversation-viewer.ts +4 -2
  37. package/src/ui/fallback-flag.ts +28 -4
  38. package/src/ui/fleet-list.ts +89 -54
  39. package/src/ui/notification-view.ts +4 -1
  40. package/src/ui/workflow-card.ts +1 -8
  41. package/src/ui/workflow-dialog.ts +31 -18
  42. package/src/workflow/host.ts +4 -5
  43. package/src/workflow/meta.ts +7 -4
  44. package/src/workflow/progress.ts +4 -3
  45. package/src/workflow/runtime.ts +23 -7
  46. package/src/workflow/saved.ts +2 -4
  47. package/src/workflow/task.ts +12 -7
  48. package/src/workflow/tool-description.ts +2 -2
  49. package/src/workflow/worker-source.ts +6 -2
  50. package/src/worktree.ts +309 -19
@@ -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
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
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
- /** Truncate text to a single line, max `len` chars. */
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
- const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
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?: number; error?: string; lifetimeUsage?: LifetimeUsage; modelFallback?: ModelFallback; modelTier?: TierDisclosure }, theme: Theme): string {
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((a.completedAt ?? this.clock.now()) - a.startedAt);
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.find(tag => tag.startsWith("thinking: "));
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
- if (lines.length > 1) {
536
- const last = lines.length - 1;
537
- lines[last] = lines[last].replace("├─", "└─");
538
- // If last item is a running agent activity line, fix indent of that line
539
- // and fix the header line above it.
540
- if (runningLines.length > 0 && !queuedLine) {
541
- // The last two lines are the last running agent's header + activity.
542
- if (last >= 2) {
543
- lines[last - 1] = lines[last - 1].replace("├─", "└─");
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
- lines.push(row(this.composer.render(innerW)[0] ?? ""));
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
- const footer = lines.pop() ?? row("");
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) {
@@ -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
  }
@@ -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
- let compact: string;
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
- * Agent-row tail columns, left to right; text pads right, numbers pad left.
121
- * `drop` ranks how early a column goes on a narrow terminal (1 first); an
122
- * undefined rank is never dropped, so state, elapsed, tokens and cost survive
123
- * and the description is what truncates (#144).
124
- */
125
- const TAIL_SLOTS = [
126
- { align: "end" }, // state: glyph + label
127
- { align: "end", drop: 3 }, // model
128
- { align: "end", drop: 2 }, // thinking level
129
- { align: "end", drop: 1 }, // live model-call activity
130
- { align: "start" }, // elapsed
131
- { align: "start" }, // tokens
132
- { align: "start" }, // cost
133
- ] as const;
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
- if (visibleWidth(flagText) > width) flagText = width > 0 ? "!" : "";
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 has no session until its runner finishes binding
426
- // extensions, and nothing else calls update() until the child's first model
427
- // event, which a slow first response delays by minutes. Keep ticking while
428
- // one is starting so its row appears as soon as the session exists.
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
- this.ui?.notify(`workflow: ${entry.workflow.name} (${entry.workflow.status}).`, "error");
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
- this.ui.notify(record.error ?? `${record.type}: ${record.description} (${record.status}).`, "error");
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
- this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
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 this.ui.custom<undefined>(
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
- // Both indicator slots remain in the row block, blank at an edge, so every
818
- // overflowing roster has the same height.
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
- } else if (overflow) lines.push("");
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
- : this.renderAgentRow(a + 1, sel, row.record, width, theme, cells.get(row.record) ?? [], colWidths),
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: "drop" in slot ? slot.drop : 0 }))
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
- // `think:high` here, so the configured level cannot read as a duplicate
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
- const pad = " ".repeat(colWidths[i] - visibleWidth(cells[i] ?? ""));
956
- return slot.align === "end" ? (cells[i] ?? "") + pad : pad + (cells[i] ?? "");
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).trimEnd();
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));
@@ -45,7 +45,7 @@ import {
45
45
  type WorkflowEntry,
46
46
  type WorkflowRunStatus,
47
47
  } from "../workflow/progress.js";
48
- import type { Theme } from "./agent-widget.js";
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