@esso0428/pi-subagents 0.17.6 → 0.17.7

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 (260) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/dist/abortable.d.ts +13 -0
  4. package/dist/abortable.d.ts.map +1 -0
  5. package/dist/abortable.js +43 -0
  6. package/dist/abortable.js.map +1 -0
  7. package/dist/agent-color.d.ts +36 -0
  8. package/dist/agent-color.d.ts.map +1 -0
  9. package/dist/agent-color.js +124 -0
  10. package/dist/agent-color.js.map +1 -0
  11. package/dist/agent-file-toggle.d.ts +126 -0
  12. package/dist/agent-file-toggle.d.ts.map +1 -0
  13. package/dist/agent-file-toggle.js +259 -0
  14. package/dist/agent-file-toggle.js.map +1 -0
  15. package/dist/agent-history.d.ts +4 -0
  16. package/dist/agent-history.d.ts.map +1 -1
  17. package/dist/agent-history.js +47 -1
  18. package/dist/agent-history.js.map +1 -1
  19. package/dist/agent-manager.d.ts +370 -56
  20. package/dist/agent-manager.d.ts.map +1 -1
  21. package/dist/agent-manager.js +1123 -409
  22. package/dist/agent-manager.js.map +1 -1
  23. package/dist/agent-runner.d.ts +100 -10
  24. package/dist/agent-runner.d.ts.map +1 -1
  25. package/dist/agent-runner.js +166 -21
  26. package/dist/agent-runner.js.map +1 -1
  27. package/dist/agent-types.d.ts +57 -5
  28. package/dist/agent-types.d.ts.map +1 -1
  29. package/dist/agent-types.js +164 -32
  30. package/dist/agent-types.js.map +1 -1
  31. package/dist/child-context.d.ts +3 -0
  32. package/dist/child-context.d.ts.map +1 -0
  33. package/dist/child-context.js +13 -0
  34. package/dist/child-context.js.map +1 -0
  35. package/dist/cross-extension-rpc.d.ts +23 -3
  36. package/dist/cross-extension-rpc.d.ts.map +1 -1
  37. package/dist/cross-extension-rpc.js +79 -17
  38. package/dist/cross-extension-rpc.js.map +1 -1
  39. package/dist/custom-agents.d.ts +38 -1
  40. package/dist/custom-agents.d.ts.map +1 -1
  41. package/dist/custom-agents.js +164 -12
  42. package/dist/custom-agents.js.map +1 -1
  43. package/dist/index.d.ts +34 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1908 -495
  46. package/dist/index.js.map +1 -1
  47. package/dist/invocation-config.d.ts +87 -2
  48. package/dist/invocation-config.d.ts.map +1 -1
  49. package/dist/invocation-config.js +71 -3
  50. package/dist/invocation-config.js.map +1 -1
  51. package/dist/mention-clone.d.ts +88 -0
  52. package/dist/mention-clone.d.ts.map +1 -0
  53. package/dist/mention-clone.js +154 -0
  54. package/dist/mention-clone.js.map +1 -0
  55. package/dist/mention.d.ts +82 -0
  56. package/dist/mention.d.ts.map +1 -0
  57. package/dist/mention.js +132 -0
  58. package/dist/mention.js.map +1 -0
  59. package/dist/model-resolver.d.ts +17 -0
  60. package/dist/model-resolver.d.ts.map +1 -1
  61. package/dist/model-resolver.js +15 -0
  62. package/dist/model-resolver.js.map +1 -1
  63. package/dist/model-scope.d.ts +50 -0
  64. package/dist/model-scope.d.ts.map +1 -0
  65. package/dist/model-scope.js +49 -0
  66. package/dist/model-scope.js.map +1 -0
  67. package/dist/nested-tools.d.ts +57 -0
  68. package/dist/nested-tools.d.ts.map +1 -0
  69. package/dist/nested-tools.js +301 -0
  70. package/dist/nested-tools.js.map +1 -0
  71. package/dist/output-file.d.ts +22 -3
  72. package/dist/output-file.d.ts.map +1 -1
  73. package/dist/output-file.js +58 -7
  74. package/dist/output-file.js.map +1 -1
  75. package/dist/prompts.d.ts +23 -0
  76. package/dist/prompts.d.ts.map +1 -1
  77. package/dist/prompts.js +20 -2
  78. package/dist/prompts.js.map +1 -1
  79. package/dist/schedule.d.ts.map +1 -1
  80. package/dist/schedule.js +36 -15
  81. package/dist/schedule.js.map +1 -1
  82. package/dist/settings.d.ts +228 -2
  83. package/dist/settings.d.ts.map +1 -1
  84. package/dist/settings.js +94 -0
  85. package/dist/settings.js.map +1 -1
  86. package/dist/status-note.d.ts +49 -1
  87. package/dist/status-note.d.ts.map +1 -1
  88. package/dist/status-note.js +62 -1
  89. package/dist/status-note.js.map +1 -1
  90. package/dist/structured-output.d.ts +62 -0
  91. package/dist/structured-output.d.ts.map +1 -0
  92. package/dist/structured-output.js +113 -0
  93. package/dist/structured-output.js.map +1 -0
  94. package/dist/types.d.ts +176 -10
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/ui/agent-mention.d.ts +83 -0
  97. package/dist/ui/agent-mention.d.ts.map +1 -0
  98. package/dist/ui/agent-mention.js +188 -0
  99. package/dist/ui/agent-mention.js.map +1 -0
  100. package/dist/ui/agent-widget.d.ts +96 -75
  101. package/dist/ui/agent-widget.d.ts.map +1 -1
  102. package/dist/ui/agent-widget.js +397 -420
  103. package/dist/ui/agent-widget.js.map +1 -1
  104. package/dist/ui/conversation-blocks.d.ts.map +1 -1
  105. package/dist/ui/conversation-blocks.js +6 -0
  106. package/dist/ui/conversation-blocks.js.map +1 -1
  107. package/dist/ui/conversation-timeline.d.ts +10 -2
  108. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  109. package/dist/ui/conversation-timeline.js +130 -23
  110. package/dist/ui/conversation-timeline.js.map +1 -1
  111. package/dist/ui/conversation-viewer.d.ts +15 -5
  112. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  113. package/dist/ui/conversation-viewer.js +202 -50
  114. package/dist/ui/conversation-viewer.js.map +1 -1
  115. package/dist/ui/fleet-list.d.ts +198 -0
  116. package/dist/ui/fleet-list.d.ts.map +1 -0
  117. package/dist/ui/fleet-list.js +487 -0
  118. package/dist/ui/fleet-list.js.map +1 -0
  119. package/dist/ui/schedule-menu.d.ts.map +1 -1
  120. package/dist/ui/schedule-menu.js +6 -7
  121. package/dist/ui/schedule-menu.js.map +1 -1
  122. package/dist/ui/select-item.d.ts +28 -0
  123. package/dist/ui/select-item.d.ts.map +1 -0
  124. package/dist/ui/select-item.js +35 -0
  125. package/dist/ui/select-item.js.map +1 -0
  126. package/dist/ui/workflow-card.d.ts +176 -0
  127. package/dist/ui/workflow-card.d.ts.map +1 -0
  128. package/dist/ui/workflow-card.js +333 -0
  129. package/dist/ui/workflow-card.js.map +1 -0
  130. package/dist/ui/workflow-dialog.d.ts +306 -0
  131. package/dist/ui/workflow-dialog.d.ts.map +1 -0
  132. package/dist/ui/workflow-dialog.js +844 -0
  133. package/dist/ui/workflow-dialog.js.map +1 -0
  134. package/dist/ui/workflow-menu.d.ts +61 -0
  135. package/dist/ui/workflow-menu.d.ts.map +1 -0
  136. package/dist/ui/workflow-menu.js +148 -0
  137. package/dist/ui/workflow-menu.js.map +1 -0
  138. package/dist/usage.d.ts +86 -1
  139. package/dist/usage.d.ts.map +1 -1
  140. package/dist/usage.js +72 -1
  141. package/dist/usage.js.map +1 -1
  142. package/dist/workflow/collisions.d.ts +96 -0
  143. package/dist/workflow/collisions.d.ts.map +1 -0
  144. package/dist/workflow/collisions.js +89 -0
  145. package/dist/workflow/collisions.js.map +1 -0
  146. package/dist/workflow/entry.d.ts +33 -0
  147. package/dist/workflow/entry.d.ts.map +1 -0
  148. package/dist/workflow/entry.js +30 -0
  149. package/dist/workflow/entry.js.map +1 -0
  150. package/dist/workflow/host.d.ts +63 -0
  151. package/dist/workflow/host.d.ts.map +1 -0
  152. package/dist/workflow/host.js +363 -0
  153. package/dist/workflow/host.js.map +1 -0
  154. package/dist/workflow/journal.d.ts +98 -0
  155. package/dist/workflow/journal.d.ts.map +1 -0
  156. package/dist/workflow/journal.js +121 -0
  157. package/dist/workflow/journal.js.map +1 -0
  158. package/dist/workflow/json-schema.d.ts +52 -0
  159. package/dist/workflow/json-schema.d.ts.map +1 -0
  160. package/dist/workflow/json-schema.js +112 -0
  161. package/dist/workflow/json-schema.js.map +1 -0
  162. package/dist/workflow/meta.d.ts +68 -0
  163. package/dist/workflow/meta.d.ts.map +1 -0
  164. package/dist/workflow/meta.js +318 -0
  165. package/dist/workflow/meta.js.map +1 -0
  166. package/dist/workflow/progress.d.ts +225 -0
  167. package/dist/workflow/progress.d.ts.map +1 -0
  168. package/dist/workflow/progress.js +362 -0
  169. package/dist/workflow/progress.js.map +1 -0
  170. package/dist/workflow/runtime.d.ts +335 -0
  171. package/dist/workflow/runtime.d.ts.map +1 -0
  172. package/dist/workflow/runtime.js +831 -0
  173. package/dist/workflow/runtime.js.map +1 -0
  174. package/dist/workflow/saved.d.ts +91 -0
  175. package/dist/workflow/saved.d.ts.map +1 -0
  176. package/dist/workflow/saved.js +204 -0
  177. package/dist/workflow/saved.js.map +1 -0
  178. package/dist/workflow/task.d.ts +137 -0
  179. package/dist/workflow/task.d.ts.map +1 -0
  180. package/dist/workflow/task.js +208 -0
  181. package/dist/workflow/task.js.map +1 -0
  182. package/dist/workflow/tool-description.d.ts +39 -0
  183. package/dist/workflow/tool-description.d.ts.map +1 -0
  184. package/dist/workflow/tool-description.js +200 -0
  185. package/dist/workflow/tool-description.js.map +1 -0
  186. package/dist/workflow/worker-source.d.ts +48 -0
  187. package/dist/workflow/worker-source.d.ts.map +1 -0
  188. package/dist/workflow/worker-source.js +779 -0
  189. package/dist/workflow/worker-source.js.map +1 -0
  190. package/dist/worktree.d.ts +10 -3
  191. package/dist/worktree.d.ts.map +1 -1
  192. package/dist/worktree.js +58 -54
  193. package/dist/worktree.js.map +1 -1
  194. package/dist/xml.d.ts +11 -0
  195. package/dist/xml.d.ts.map +1 -0
  196. package/dist/xml.js +13 -0
  197. package/dist/xml.js.map +1 -0
  198. package/docs/rpc.md +183 -0
  199. package/docs/superpowers/plans/2026-09-30-upstream-event-workflow-partial-history.md +195 -0
  200. package/docs/superpowers/specs/2026-09-30-upstream-event-workflow-partial-history-design.md +49 -0
  201. package/docs/workflows.md +437 -0
  202. package/examples/agent-tool-description.md +7 -7
  203. package/examples/workflows/compose.js +51 -0
  204. package/examples/workflows/fan-out-audit.js +47 -0
  205. package/examples/workflows/gated-fix.js +60 -0
  206. package/examples/workflows/lib/count-child.js +27 -0
  207. package/examples/workflows/review-panel.js +63 -0
  208. package/examples/workflows/structured-findings.js +78 -0
  209. package/package.json +1 -1
  210. package/src/abortable.ts +43 -0
  211. package/src/agent-color.ts +161 -0
  212. package/src/agent-file-toggle.ts +269 -0
  213. package/src/agent-history.ts +54 -2
  214. package/src/agent-manager.ts +1263 -402
  215. package/src/agent-runner.ts +251 -27
  216. package/src/agent-types.ts +188 -32
  217. package/src/child-context.ts +15 -0
  218. package/src/cross-extension-rpc.ts +96 -20
  219. package/src/custom-agents.ts +170 -13
  220. package/src/index.ts +2024 -537
  221. package/src/invocation-config.ts +118 -3
  222. package/src/mention-clone.ts +196 -0
  223. package/src/mention.ts +141 -0
  224. package/src/model-resolver.ts +18 -0
  225. package/src/model-scope.ts +70 -0
  226. package/src/nested-tools.ts +424 -0
  227. package/src/output-file.ts +61 -6
  228. package/src/prompts.ts +45 -2
  229. package/src/schedule.ts +35 -14
  230. package/src/settings.ts +312 -2
  231. package/src/status-note.ts +66 -1
  232. package/src/structured-output.ts +130 -0
  233. package/src/types.ts +177 -10
  234. package/src/ui/agent-mention.ts +216 -0
  235. package/src/ui/agent-widget.ts +389 -441
  236. package/src/ui/conversation-blocks.ts +6 -0
  237. package/src/ui/conversation-timeline.ts +139 -25
  238. package/src/ui/conversation-viewer.ts +212 -48
  239. package/src/ui/fleet-list.ts +558 -0
  240. package/src/ui/schedule-menu.ts +9 -8
  241. package/src/ui/select-item.ts +45 -0
  242. package/src/ui/workflow-card.ts +470 -0
  243. package/src/ui/workflow-dialog.ts +1115 -0
  244. package/src/ui/workflow-menu.ts +193 -0
  245. package/src/usage.ts +109 -2
  246. package/src/workflow/collisions.ts +123 -0
  247. package/src/workflow/entry.ts +47 -0
  248. package/src/workflow/host.ts +403 -0
  249. package/src/workflow/journal.ts +164 -0
  250. package/src/workflow/json-schema.ts +128 -0
  251. package/src/workflow/meta.ts +325 -0
  252. package/src/workflow/progress.ts +550 -0
  253. package/src/workflow/runtime.ts +1219 -0
  254. package/src/workflow/saved.ts +217 -0
  255. package/src/workflow/task.ts +302 -0
  256. package/src/workflow/tool-description.ts +200 -0
  257. package/src/workflow/worker-source.ts +781 -0
  258. package/src/worktree.ts +69 -55
  259. package/src/xml.ts +13 -0
  260. package/vitest.config.ts +0 -18
@@ -5,27 +5,28 @@
5
5
  * Uses the callback form of setWidget for themed rendering.
6
6
  */
7
7
 
8
- import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
- import type { AgentManager } from "../agent-manager.js";
8
+ import { truncateToWidth } from "@earendil-works/pi-tui";
9
+ import { renderAgentName } from "../agent-color.js";
10
+ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
10
11
  import { getConfig } from "../agent-types.js";
11
12
  import type { AgentInvocation, AgentRecord, SubagentType, WidgetMode } from "../types.js";
12
- import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
13
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
13
14
 
14
15
  // ---- Constants ----
15
16
 
16
17
  /** Maximum number of rendered lines before overflow collapse kicks in. */
17
18
  export const MAX_WIDGET_LINES = 12;
18
- /** Keep a small editor/input area visible below the above-editor widget. */
19
- const MIN_EDITOR_LINES = 4;
20
19
 
21
- /**
22
- * Derive the widget ceiling from the terminal height while retaining the
23
- * historical MAX_WIDGET_LINES ceiling. Very short terminals may have no room
24
- * for the widget; that is preferable to consuming the editor/input area.
25
- */
26
- export function getWidgetLineBudget(rows: number): number {
27
- if (!Number.isFinite(rows)) return MAX_WIDGET_LINES;
28
- return Math.min(MAX_WIDGET_LINES, Math.max(0, Math.floor(rows) - MIN_EDITOR_LINES));
20
+ /** Number of lines available above an editor, retaining room for the prompt. */
21
+ export function getWidgetLineBudget(terminalRows: number): number {
22
+ return Math.max(1, Math.min(MAX_WIDGET_LINES, Math.floor(terminalRows) - 4));
23
+ }
24
+
25
+ export type AgentWidgetOpenMode = "live" | "history";
26
+
27
+ export interface AgentWidgetOptions {
28
+ canOpenHistory?: (record: import("../types.js").AgentRecord) => boolean;
29
+ onOpen?: (record: import("../types.js").AgentRecord, mode: AgentWidgetOpenMode) => void;
29
30
  }
30
31
 
31
32
  /** Braille spinner frames for animated running indicator. */
@@ -52,19 +53,6 @@ export type Theme = {
52
53
  bold(text: string): string;
53
54
  };
54
55
 
55
- export type AgentWidgetOpenMode = "live" | "history";
56
- export type AgentWidgetOpenCallback = (record: AgentRecord, mode: AgentWidgetOpenMode) => void | Promise<void>;
57
- export type AgentWidgetOptions = {
58
- canOpenHistory: (record: AgentRecord) => boolean;
59
- onOpen: AgentWidgetOpenCallback;
60
- };
61
- /** @deprecated Use AgentWidgetOpenMode. */
62
- export type AgentOpenMode = AgentWidgetOpenMode;
63
- /** @deprecated Use AgentWidgetOpenCallback. */
64
- export type AgentOpenCallback = AgentWidgetOpenCallback;
65
- /** @deprecated Use AgentWidgetOptions.canOpenHistory. */
66
- export type AgentHistoryCapability = AgentWidgetOptions["canOpenHistory"];
67
-
68
56
  export type UICtx = {
69
57
  setStatus(key: string, text: string | undefined): void;
70
58
  setWidget(
@@ -72,8 +60,8 @@ export type UICtx = {
72
60
  content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
73
61
  options?: { placement?: "aboveEditor" | "belowEditor" },
74
62
  ): void;
75
- onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
76
- getEditorText(): string;
63
+ onTerminalInput?(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
64
+ getEditorText?(): string;
77
65
  };
78
66
 
79
67
  /** Per-agent live activity state. */
@@ -86,7 +74,7 @@ export interface AgentActivity {
86
74
  turnCount: number;
87
75
  /** Effective max turns for this agent (undefined = unlimited). */
88
76
  maxTurns?: number;
89
- /** Lifetime usage breakdown — see LifetimeUsage docs. */
77
+ /** Lifetime token usage, including partial runs. */
90
78
  lifetimeUsage: LifetimeUsage;
91
79
  }
92
80
 
@@ -103,7 +91,7 @@ export interface AgentDetails {
103
91
  activity?: string;
104
92
  /** Current spinner frame index (for animated running indicator). */
105
93
  spinnerFrame?: number;
106
- /** Short model name if different from parent (e.g. "haiku", "sonnet"). */
94
+ /** Short label for the model the run used, e.g. "haiku 4.5". */
107
95
  modelName?: string;
108
96
  /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
109
97
  tags?: string[];
@@ -111,6 +99,8 @@ export interface AgentDetails {
111
99
  turnCount?: number;
112
100
  /** Effective max turns (undefined = unlimited). */
113
101
  maxTurns?: number;
102
+ /** Estimated cost in USD; 0 when the model has no pricing data. */
103
+ cost?: number;
114
104
  agentId?: string;
115
105
  error?: string;
116
106
  }
@@ -131,6 +121,31 @@ export function formatTokens(count: number): string {
131
121
  return `${count} token`;
132
122
  }
133
123
 
124
+ /**
125
+ * Format a cost as `~$0.0042`, or "" when there is nothing to show.
126
+ *
127
+ * The tilde is load-bearing: this is pi's own estimate from the model's listed
128
+ * rates, not a billed figure, and the surfaces that print it sit next to token
129
+ * counts that ARE exact.
130
+ *
131
+ * Nothing is printed for zero, which is also what a model with no pricing data
132
+ * reports: `$0.00` beside a local model's tokens would claim its cost was
133
+ * measured and found to be nothing, rather than never measured at all. For the
134
+ * same reason a real cost too small for four decimals reads `<$0.0001` — it was
135
+ * measured, and rounding it to `~$0.0000` would say the opposite.
136
+ */
137
+ export function formatCost(cost: number): string {
138
+ if (!(cost > 0)) return ""; // also catches NaN
139
+ if (cost < 0.0001) return "<$0.0001";
140
+ if (cost >= 1) return `~$${cost.toFixed(2)}`;
141
+ // Under a dollar: cents at minimum, four decimals at most, nothing trailing.
142
+ // Most single runs land between a tenth of a cent and a dime, where rounding
143
+ // to cents would collapse a 4x difference in spend into the same figure.
144
+ const rounded = Number(cost.toFixed(4));
145
+ const decimals = (String(rounded).split(".")[1] ?? "").length;
146
+ return `~$${rounded.toFixed(Math.max(2, decimals))}`;
147
+ }
148
+
134
149
  /**
135
150
  * Token count with optional context-fill % and compaction-count annotations.
136
151
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
@@ -187,25 +202,37 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
187
202
  return config.promptMode === "append" ? "twin" : undefined;
188
203
  }
189
204
 
190
- /** Mode label is not included — callers add it where they want it. */
205
+ /**
206
+ * Mode label is not included — callers add it where they want it.
207
+ *
208
+ * Both model forms come back so each surface can pick by width; the
209
+ * "(asked X)" annotation is applied here rather than by callers, so a value the
210
+ * spawn did not honor cannot be rendered as though it had been (#182).
211
+ */
191
212
  export function buildInvocationTags(
192
213
  invocation: AgentInvocation | undefined,
193
- ): { modelName?: string; tags: string[] } {
214
+ ): { modelName?: string; modelId?: string; tags: string[] } {
194
215
  const tags: string[] = [];
195
216
  if (!invocation) return { tags };
196
- const thinking = invocation.effectiveThinking ?? invocation.thinking;
217
+ const asked = (value: string | undefined, requested: string | undefined): string | undefined =>
218
+ value && requested && requested !== value ? `${value} (asked ${requested})` : value;
219
+ const thinking = asked(invocation.thinking, invocation.requestedThinking);
197
220
  if (thinking) tags.push(`thinking: ${thinking}`);
198
221
  if (invocation.isolated) tags.push("isolated");
199
222
  if (invocation.isolation === "worktree") tags.push("worktree");
200
223
  if (invocation.inheritContext) tags.push("inherit context");
201
224
  if (invocation.runInBackground) tags.push("background");
202
225
  if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
203
- return { modelName: invocation.modelName, tags };
226
+ return {
227
+ modelName: asked(invocation.modelName, invocation.requestedModel),
228
+ modelId: asked(invocation.modelId, invocation.requestedModel),
229
+ tags,
230
+ };
204
231
  }
205
232
 
206
- /** Normalize and truncate text so it can never add physical widget rows. */
233
+ /** Truncate text to a single line, max `len` chars. */
207
234
  function truncateLine(text: string, len = 60): string {
208
- const line = text.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
235
+ const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
209
236
  if (line.length <= len) return line;
210
237
  return line.slice(0, len) + "…";
211
238
  }
@@ -244,15 +271,10 @@ export class AgentWidget {
244
271
  private uiCtx: UICtx | undefined;
245
272
  private widgetFrame = 0;
246
273
  private widgetInterval: ReturnType<typeof setInterval> | undefined;
247
- private inputUnsub: (() => void) | undefined;
248
- /** Whether arrow keys currently navigate the agent roster. */
249
- private navigationActive = false;
250
- /** Stable identity of the selected row, so roster changes do not jump selection. */
251
- private selectedAgentId: string | undefined;
252
- /** Last logical roster index of the selected row, used when it disappears. */
253
- private selectedRosterIndex = 0;
254
- /** First logical row currently represented by the bounded viewport. */
255
- private viewportStart = 0;
274
+ /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
275
+ private finishedTurnAge = new Map<string, number>();
276
+ /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
277
+ private static readonly ERROR_LINGER_TURNS = 2;
256
278
 
257
279
  /** Whether the widget callback is currently registered with the TUI. */
258
280
  private widgetRegistered = false;
@@ -260,19 +282,49 @@ export class AgentWidget {
260
282
  private tui: any | undefined;
261
283
  /** Last status bar text, used to avoid redundant setStatus calls. */
262
284
  private lastStatusText: string | undefined;
263
- /** Snapshot of the state used for the last widget registration/render request. */
264
- private lastRenderKey: string | undefined;
285
+ private lastSnapshot = "";
286
+ private inputCleanup: (() => void) | undefined;
287
+ private navigationActive = false;
288
+ private selectedAgentId: string | undefined;
289
+ private viewportStart = 0;
290
+ private selectedRosterIndex = 0;
291
+ private openOptions: AgentWidgetOptions;
292
+ private showCost: () => boolean;
293
+ private showModel: () => boolean;
265
294
 
266
295
  constructor(
267
296
  private manager: AgentManager,
268
297
  private agentActivity: Map<string, AgentActivity>,
269
- /** Read live at render time. Selects which agents the widget shows. */
298
+ /**
299
+ * Read live at render time. Selects which agents the widget shows — see
300
+ * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
301
+ * extension supplies one defaulting to `"background"`.
302
+ */
270
303
  private mode: () => WidgetMode = () => "all",
271
- private options: AgentWidgetOptions = {
272
- canOpenHistory: (record) => record.session !== undefined || record.completedAt !== undefined,
273
- onOpen: () => {},
274
- },
275
- ) {}
304
+ /**
305
+ * Read live at render time, like `mode`. Whether running agents show an
306
+ * estimated cost beside their token count. Defaults to off — the extension
307
+ * supplies the user's `showCost` setting.
308
+ */
309
+ /**
310
+ * Read live at render time, like `mode`. Whether running agents name the
311
+ * model driving them and the thinking level it is running at. Defaults to
312
+ * off — the extension supplies the user's `showModel` setting — because the
313
+ * row is already dense and the same pair is on the tool result and in the
314
+ * conversation viewer unconditionally.
315
+ */
316
+ showCostOrOptions: (() => boolean) | AgentWidgetOptions = () => false,
317
+ showModel: () => boolean = () => false,
318
+ ) {
319
+ if (typeof showCostOrOptions === "function") {
320
+ this.showCost = showCostOrOptions;
321
+ this.openOptions = {};
322
+ } else {
323
+ this.showCost = () => false;
324
+ this.openOptions = showCostOrOptions;
325
+ }
326
+ this.showModel = showModel;
327
+ }
276
328
 
277
329
  /**
278
330
  * Agents eligible for the widget, per the current `WidgetMode`:
@@ -286,7 +338,7 @@ export class AgentWidget {
286
338
  * - `all`: every agent.
287
339
  */
288
340
  private widgetAgents() {
289
- const all = this.manager.listAgents();
341
+ const all = this.manager.listAgents().filter(isTopLevelAgent);
290
342
  switch (this.mode()) {
291
343
  case "off": return [];
292
344
  case "background": return all.filter(a => a.isBackground !== false);
@@ -295,191 +347,140 @@ export class AgentWidget {
295
347
  }
296
348
 
297
349
  /** Set the UI context (grabbed from first tool execution). */
298
- setUICtx(ctx: UICtx): boolean {
299
- if (ctx === this.uiCtx) return false;
300
-
301
- // UICtx changed — the widget and input handler registered on the old
302
- // context are gone. Re-register both on the next update().
303
- this.inputUnsub?.();
304
- this.inputUnsub = undefined;
305
- this.uiCtx = ctx;
306
- this.widgetRegistered = false;
307
- this.tui = undefined;
308
- this.lastStatusText = undefined;
309
- this.lastRenderKey = undefined;
310
- this.navigationActive = false;
311
- this.selectedAgentId = undefined;
312
- this.selectedRosterIndex = 0;
313
- this.viewportStart = 0;
314
- // Print/RPC tests and lightweight embedders may provide only the widget
315
- // surface; real interactive contexts always implement this hook.
316
- if (typeof ctx.onTerminalInput === "function") {
317
- this.inputUnsub = ctx.onTerminalInput(data => this.handleKey(data));
350
+ setUICtx(ctx: UICtx) {
351
+ if (ctx !== this.uiCtx) {
352
+ this.inputCleanup?.();
353
+ this.inputCleanup = undefined;
354
+ this.uiCtx = ctx;
355
+ this.widgetRegistered = false;
356
+ this.tui = undefined;
357
+ this.lastStatusText = undefined;
358
+ this.navigationActive = false;
359
+ this.selectedAgentId = undefined;
360
+ this.viewportStart = 0;
361
+ if (ctx.onTerminalInput) this.inputCleanup = ctx.onTerminalInput(data => this.handleInput(data));
318
362
  }
319
- return true;
320
- }
321
-
322
- /** Request a render on the currently registered TUI without touching input. */
323
- requestUiRefresh(force = true): boolean {
324
- if (!this.tui) return false;
325
- this.tui.requestRender(force);
326
- return true;
327
363
  }
328
364
 
329
- /** Called on each new turn (tool_execution_start). */
365
+ /**
366
+ * Called on each new turn (tool_execution_start).
367
+ * Ages finished agents and clears those that have lingered long enough.
368
+ */
330
369
  onTurnStart() {
370
+ // Age all finished agents
371
+ for (const [id, age] of this.finishedTurnAge) {
372
+ this.finishedTurnAge.set(id, age + 1);
373
+ }
374
+ // Trigger a widget refresh (will filter out expired agents)
331
375
  this.update();
332
376
  }
333
377
 
334
- /** Keep the spinner/elapsed-time timer alive only while a visible agent runs. */
378
+ /** Ensure the widget update timer is running. */
335
379
  ensureTimer() {
336
- if (!this.uiCtx || !this.widgetAgents().some(a => a.status === "running")) return;
337
380
  if (!this.widgetInterval) {
338
- this.widgetInterval = setInterval(() => this.update(true), 250);
381
+ this.widgetInterval = setInterval(() => this.update(), 80);
339
382
  }
340
383
  }
341
384
 
342
- private syncTimer(shouldRun: boolean): void {
343
- if (shouldRun) {
344
- this.ensureTimer();
345
- } else if (this.widgetInterval) {
346
- clearInterval(this.widgetInterval);
347
- this.widgetInterval = undefined;
348
- }
385
+ /** Check if a finished agent should still be shown in the widget. */
386
+ private shouldShowFinished(agentId: string, status: string): boolean {
387
+ const age = this.finishedTurnAge.get(agentId) ?? 0;
388
+ const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
389
+ return age < maxAge;
349
390
  }
350
391
 
351
- /**
352
- * Retained for the lifecycle call sites. Terminal visibility is determined
353
- * by the manager record and the history capability, not a one-turn timer.
354
- */
355
- markFinished(_agentId: string) {}
392
+ /** Record an agent as finished (call when agent completes). */
393
+ markFinished(agentId: string) {
394
+ if (!this.finishedTurnAge.has(agentId)) {
395
+ this.finishedTurnAge.set(agentId, 0);
396
+ }
397
+ }
356
398
 
357
399
  /**
358
- * Records represented by selectable rows in the above-editor widget.
359
- *
360
- * Keep this order as the single source of truth for both rendering and key
361
- * navigation. `listAgents()` is newest-first; active rows come first so the
362
- * panel exposes currently useful work before terminal history.
400
+ * Drop an agent's finished-age (call when a settled agent starts running
401
+ * again, i.e. a background resume). markFinished only seeds an age it has not
402
+ * seen before, so a resumed agent would otherwise keep the age from its
403
+ * previous run — already past the linger limit, hiding the new run's
404
+ * completion line entirely.
363
405
  */
364
- private roster(): AgentRecord[] {
365
- const agents = this.widgetAgents();
366
- const finished = agents.filter(record =>
367
- record.status !== "running" && record.status !== "queued"
368
- && record.completedAt !== undefined
369
- && this.options.canOpenHistory(record),
370
- );
371
- const running = agents.filter(record => record.status === "running");
372
- const queued = agents.filter(record => record.status === "queued");
373
- return [...running, ...queued, ...finished];
406
+ markRunning(agentId: string) {
407
+ this.finishedTurnAge.delete(agentId);
374
408
  }
375
409
 
376
- /** True when pi's prompt editor owns the keyboard. */
377
- private editorHasFocus(): boolean {
378
- const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
379
- return focused == null || focused instanceof Editor;
380
- }
381
-
382
- private selectedIndexOf(records: readonly AgentRecord[]): number {
383
- if (!this.selectedAgentId) return -1;
384
- return records.findIndex(record => record.id === this.selectedAgentId);
385
- }
386
-
387
- private deactivate(): void {
388
- // Keep the selected row and viewport so re-entering navigation can resume
389
- // where the user left off. Lifecycle resets (context, dispose, empty
390
- // roster) clear this state explicitly instead of treating every exit as a
391
- // reset.
392
- this.navigationActive = false;
393
- this.update();
410
+ private navigationAgents(): AgentRecord[] {
411
+ const agents = this.widgetAgents().filter(agent =>
412
+ agent.status === "running" || agent.status === "queued" || (agent.completedAt !== undefined && this.shouldShowFinished(agent.id, agent.status)),
413
+ );
414
+ return agents.sort((a, b) => {
415
+ const activeA = a.status === "running" || a.status === "queued" ? 0 : 1;
416
+ const activeB = b.status === "running" || b.status === "queued" ? 0 : 1;
417
+ return activeA - activeB;
418
+ });
394
419
  }
395
420
 
396
- /** Resume navigation from the retained row, or select the first row. */
397
- private activate(records: readonly AgentRecord[]): void {
398
- this.navigationActive = true;
399
- const selectedIndex = this.selectedIndexOf(records);
400
- if (selectedIndex >= 0) {
401
- this.selectedRosterIndex = selectedIndex;
402
- } else {
403
- // The previously selected row may have been cleaned up while navigation
404
- // was inactive. Resume at its old logical position, clamped to the new
405
- // roster, rather than jumping back to the first row.
406
- const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, records.length - 1));
407
- this.selectedAgentId = records[fallbackIndex].id;
408
- this.selectedRosterIndex = fallbackIndex;
409
- this.viewportStart = Math.min(this.viewportStart, Math.max(0, records.length - 1));
410
- }
411
- this.update();
421
+ private updateViewport(roster: AgentRecord[]): void {
422
+ const budget = getWidgetLineBudget(this.tui?.terminal?.rows ?? 24);
423
+ const visibleRows = Math.max(1, budget - 1);
424
+ if (this.selectedRosterIndex < this.viewportStart) this.viewportStart = this.selectedRosterIndex;
425
+ if (this.selectedRosterIndex >= this.viewportStart + visibleRows) this.viewportStart = this.selectedRosterIndex - visibleRows + 1;
426
+ this.viewportStart = Math.max(0, Math.min(this.viewportStart, Math.max(0, roster.length - visibleRows)));
412
427
  }
413
428
 
414
- /** Move the selected row, activating only from an empty focused editor. */
415
- private moveSelection(direction: -1 | 1): boolean {
416
- const records = this.roster();
417
- const ui = this.uiCtx;
418
- if (records.length === 0 || !ui) return false;
419
-
429
+ private handleInput(data: string): { consume?: boolean; data?: string } | undefined {
430
+ const down = data === "\u001b[B";
431
+ const up = data === "\u001b[A";
420
432
  if (!this.navigationActive) {
421
- if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "") return false;
422
- this.activate(records);
423
- return true;
424
- }
425
-
426
- const currentIndex = Math.max(0, this.selectedIndexOf(records));
427
- if (direction === -1 && currentIndex === 0) {
428
- this.deactivate();
429
- return true;
430
- }
431
- const nextIndex = Math.max(0, Math.min(records.length - 1, currentIndex + direction));
432
- this.selectedAgentId = records[nextIndex].id;
433
- this.selectedRosterIndex = nextIndex;
434
- this.update();
435
- return true;
436
- }
437
-
438
- private openSelected(): void {
439
- const record = this.roster().find(candidate => candidate.id === this.selectedAgentId);
440
- this.deactivate();
441
- if (!record) return;
442
- const mode: AgentWidgetOpenMode = record.status === "running" || record.status === "queued" ? "live" : "history";
443
- void this.options.onOpen(record, mode);
444
- }
445
-
446
- /** Handle terminal input before it reaches the focused prompt editor. */
447
- handleKey(data: string): { consume?: boolean; data?: string } | undefined {
448
- if (!this.uiCtx || isKeyRelease(data)) return undefined;
449
- if (!this.editorHasFocus()) {
450
- if (this.navigationActive) this.deactivate();
451
- return undefined;
433
+ if (!down || !this.tui || this.uiCtx?.getEditorText?.() !== "") return undefined;
434
+ const roster = this.navigationAgents();
435
+ if (roster.length === 0) return undefined;
436
+ this.navigationActive = true;
437
+ const index = this.selectedAgentId ? roster.findIndex(agent => agent.id === this.selectedAgentId) : -1;
438
+ this.selectedRosterIndex = index >= 0 ? index : 0;
439
+ this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
440
+ this.updateViewport(roster);
441
+ this.requestUiRefresh();
442
+ return { consume: true };
452
443
  }
453
-
454
- if (!this.navigationActive) {
455
- const records = this.roster();
456
- if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || records.length === 0) {
457
- return undefined;
458
- }
459
- this.activate(records);
444
+ if (data === "\u001b") {
445
+ this.navigationActive = false;
446
+ this.requestUiRefresh();
460
447
  return { consume: true };
461
448
  }
462
-
463
- if (matchesKey(data, "escape")) {
464
- this.deactivate();
449
+ if (down || up) {
450
+ const roster = this.navigationAgents();
451
+ if (roster.length === 0) return { consume: true };
452
+ if (up && this.selectedRosterIndex === 0) {
453
+ this.navigationActive = false;
454
+ } else {
455
+ this.selectedRosterIndex = Math.max(0, Math.min(roster.length - 1, this.selectedRosterIndex + (down ? 1 : -1)));
456
+ this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
457
+ this.updateViewport(roster);
458
+ }
459
+ this.requestUiRefresh();
465
460
  return { consume: true };
466
461
  }
467
- if (matchesKey(data, "up")) return this.moveSelection(-1) ? { consume: true } : undefined;
468
- if (matchesKey(data, "down")) return this.moveSelection(1) ? { consume: true } : undefined;
469
- if (matchesKey(data, Key.enter)) {
470
- this.openSelected();
462
+ if (data === "\r" || data === "\n") {
463
+ const record = this.navigationAgents()[this.selectedRosterIndex];
464
+ if (record) {
465
+ const terminal = record.status !== "running" && record.status !== "queued";
466
+ if (!terminal || (this.openOptions.canOpenHistory?.(record) ?? true)) {
467
+ this.openOptions.onOpen?.(record, terminal ? "history" : "live");
468
+ }
469
+ }
471
470
  return { consume: true };
472
471
  }
473
-
474
- // Only ↑/↓ navigate. Other keys, including j/k/←/→, flow to the editor
475
- // and leave navigation mode.
476
- this.deactivate();
472
+ this.navigationActive = false;
477
473
  return undefined;
478
474
  }
479
475
 
476
+ private requestUiRefresh(force = false): boolean {
477
+ if (!this.tui || typeof this.tui.requestRender !== "function") return false;
478
+ this.tui.requestRender(force);
479
+ return true;
480
+ }
481
+
480
482
  /** Render a finished agent line. */
481
- private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
482
- const name = getDisplayName(a.type);
483
+ private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
483
484
  const modeLabel = getPromptModeLabel(a.type);
484
485
  const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
485
486
 
@@ -496,7 +497,7 @@ export class AgentWidget {
496
497
  statusText = theme.fg("dim", " stopped");
497
498
  } else if (a.status === "error") {
498
499
  icon = theme.fg("error", "✗");
499
- const errMsg = a.error ? `: ${truncateLine(a.error)}` : "";
500
+ const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
500
501
  statusText = theme.fg("error", ` error${errMsg}`);
501
502
  } else {
502
503
  // aborted
@@ -508,10 +509,16 @@ export class AgentWidget {
508
509
  const activity = this.agentActivity.get(a.id);
509
510
  if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
510
511
  if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
512
+ // From the record, not the activity tracker: that entry is deleted the
513
+ // moment an agent finishes, and "what did it cost" is a question asked
514
+ // about finished agents.
515
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
516
+ if (costText) parts.push(costText);
511
517
  parts.push(duration);
512
518
 
513
519
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
514
- return `${icon} ${theme.fg("dim", name)}${modeTag} ${theme.fg("dim", truncateLine(a.description))} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
520
+ const description = a.description.replace(/[\r\n]+/g, " ");
521
+ return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
515
522
  }
516
523
 
517
524
  /**
@@ -523,11 +530,10 @@ export class AgentWidget {
523
530
  const running = allAgents.filter(a => a.status === "running");
524
531
  const queued = allAgents.filter(a => a.status === "queued");
525
532
  const finished = allAgents.filter(a =>
526
- a.status !== "running" && a.status !== "queued" && a.completedAt !== undefined
527
- && this.options.canOpenHistory(a),
533
+ a.status !== "running" && a.status !== "queued" && a.completedAt
534
+ && this.shouldShowFinished(a.id, a.status),
528
535
  );
529
536
 
530
- const selectedId = this.navigationActive ? this.selectedAgentId : undefined;
531
537
  const hasActive = running.length > 0 || queued.length > 0;
532
538
  const hasFinished = finished.length > 0;
533
539
 
@@ -535,9 +541,8 @@ export class AgentWidget {
535
541
  if (!hasActive && !hasFinished) return [];
536
542
 
537
543
  const w = tui.terminal.columns;
538
- const maxLines = getWidgetLineBudget(tui.terminal.rows);
539
- if (maxLines === 0) return [];
540
- const truncate = (line: string, width = w) => truncateToWidth(line, Math.max(0, width));
544
+ const budget = getWidgetLineBudget(tui.terminal.rows ?? 24);
545
+ const truncate = (line: string) => truncateToWidth(line.replace(/[\r\n]+/g, " "), w);
541
546
  const headingColor = hasActive ? "accent" : "dim";
542
547
  const headingIcon = hasActive ? "●" : "○";
543
548
  const frame = SPINNER[this.widgetFrame % SPINNER.length];
@@ -545,242 +550,196 @@ export class AgentWidget {
545
550
  // Build sections separately for overflow-aware assembly.
546
551
  // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
547
552
 
548
- const finishedLines: { record: AgentRecord; lines: string[] }[] = [];
553
+ const finishedLines: string[] = [];
549
554
  for (const a of finished) {
550
- const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
551
- finishedLines.push({
552
- record: a,
553
- lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ` + this.renderFinishedLine(a, theme))],
554
- });
555
+ finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
555
556
  }
556
557
 
557
- const runningLines: { record: AgentRecord; lines: string[] }[] = []; // each entry is [header, activity]
558
+ const runningLines: string[][] = []; // each entry is [header, activity]
558
559
  for (const a of running) {
559
- const name = getDisplayName(a.type);
560
560
  const modeLabel = getPromptModeLabel(a.type);
561
561
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
562
562
  const elapsed = formatMs(Date.now() - a.startedAt);
563
563
 
564
564
  const bg = this.agentActivity.get(a.id);
565
565
  const toolUses = bg?.toolUses ?? a.toolUses;
566
- const tokens = getLifetimeTotal(bg?.lifetimeUsage);
566
+ // Spend comes from the record, never from the activity tracker: the record
567
+ // is the one that survives the agent finishing, and the one nested-tools
568
+ // folds a hidden child's spend into. Reading the tracker while an agent
569
+ // runs and the record once it stops made the figure jump at completion.
570
+ const tokens = getLifetimeTotal(a.lifetimeUsage);
567
571
  const contextPercent = getSessionContextPercent(bg?.session);
568
572
  const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
573
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
569
574
 
570
575
  const parts: string[] = [];
576
+ if (this.showModel()) {
577
+ // Leading, and paired: a thinking level means nothing without the model
578
+ // it applies to. The tag is taken from buildInvocationTags rather than
579
+ // rebuilt so the "(asked X)" annotation survives.
580
+ const { modelName, tags } = buildInvocationTags(a.invocation);
581
+ if (modelName) parts.push(modelName);
582
+ const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
583
+ if (thinkingTag) parts.push(thinkingTag);
584
+ }
571
585
  if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
572
586
  if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
573
587
  if (tokenText) parts.push(tokenText);
588
+ if (costText) parts.push(costText);
574
589
  parts.push(elapsed);
575
590
  const statsText = parts.join(" · ");
576
591
 
577
- const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
592
+ const activity = (bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…").replace(/[\r\n]+/g, " ");
578
593
 
579
- const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
580
- runningLines.push({
581
- record: a,
582
- lines: [
583
- truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("accent", frame)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", truncateLine(a.description))} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
584
- truncate(theme.fg("dim", "│ ") + ` ${theme.fg("dim", `⎿ ${activity}`)}`),
585
- ],
586
- });
594
+ runningLines.push([
595
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
596
+ truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
597
+ ]);
587
598
  }
588
599
 
589
- const queuedLines: { record: AgentRecord; lines: string[] }[] = queued.map(a => {
590
- const marker = a.id === selectedId ? theme.fg("accent", "●") : theme.fg("dim", "○");
591
- return {
592
- record: a,
593
- lines: [truncate(theme.fg("dim", "├─") + ` ${marker} ${theme.fg("muted", "◦")} ${theme.fg("dim", `${getDisplayName(a.type)} ${truncateLine(a.description)} · queued`)}`)],
594
- };
595
- });
596
-
597
- // Assemble with a responsive cap (heading + overflow indicator = 2
598
- // reserved lines when content exceeds the available body budget).
599
- const maxBody = maxLines - 1; // heading takes 1 line
600
- const rows = [...runningLines, ...queuedLines, ...finishedLines];
601
- const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
600
+ const queuedLine = queued.length > 0
601
+ ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
602
+ : undefined;
602
603
 
603
- const heading = "Agents ↑↓ select · enter view · esc back";
604
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, heading))];
604
+ // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
605
+ const maxBody = Math.max(1, budget - 1); // heading takes 1 line
606
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
605
607
 
606
- if (maxLines === 1) {
607
- // There is room only for the heading; do not consume the editor/input row.
608
- return lines;
609
- }
608
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
610
609
 
611
610
  if (totalBody <= maxBody) {
612
- this.viewportStart = 0;
613
- for (const row of rows) lines.push(...row.lines);
614
- if (rows.length > 0) {
615
- const lastRow = rows[rows.length - 1];
616
- const lastStart = lines.length - lastRow.lines.length;
617
- lines[lastStart] = lines[lastStart].replace("├─", "└─");
618
- if (lastRow.lines.length === 2) {
619
- lines[lastStart + 1] = lines[lastStart + 1].replace("│ ", " ");
611
+ // Everything fits — add all lines and fix up connectors for the last item.
612
+ lines.push(...finishedLines);
613
+ for (const pair of runningLines) lines.push(...pair);
614
+ if (queuedLine) lines.push(queuedLine);
615
+
616
+ // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
617
+ if (lines.length > 1) {
618
+ const last = lines.length - 1;
619
+ lines[last] = lines[last].replace("├─", "└─");
620
+ // If last item is a running agent activity line, fix indent of that line
621
+ // and fix the header line above it.
622
+ if (runningLines.length > 0 && !queuedLine) {
623
+ // The last two lines are the last running agent's header + activity.
624
+ if (last >= 2) {
625
+ lines[last - 1] = lines[last - 1].replace("├─", "└─");
626
+ lines[last] = lines[last].replace("│ ", " ");
627
+ }
620
628
  }
621
629
  }
622
630
  } else {
623
- // Reserve one line for a directional overflow summary. The viewport is
624
- // a contiguous slice in roster order, so the same slice is navigable and
625
- // renderable even when the selected row is currently hidden.
626
- const baseContentBudget = Math.max(0, maxBody - 1);
627
- const heightAt = (index: number) => rows[index]?.lines.length ?? 0;
628
- const endFor = (start: number, budget: number): number => {
629
- let used = 0;
630
- let end = start;
631
- while (end < rows.length && used + heightAt(end) <= budget) {
632
- used += heightAt(end++);
631
+ // Overflow — prioritize: running > queued > finished.
632
+ // Reserve 1 line for overflow indicator.
633
+ let budget = maxBody - 1;
634
+ let hiddenRunning = 0;
635
+ let hiddenFinished = 0;
636
+
637
+ // Reserve the queued line's row up front. It is a single summary of N
638
+ // waiting agents, so it cannot be folded into the "+N more" count (which
639
+ // is denominated in agents) without either under-reporting it as 1 or
640
+ // inflating the total with agents that were never getting their own rows.
641
+ // Reserving costs at most one running agent — which IS counted below —
642
+ // and makes the drop unreachable. It matters most exactly when it used to
643
+ // vanish: the pool is saturated and the queue is what the user needs to see.
644
+ const queuedReserve = queuedLine ? 1 : 0;
645
+ budget -= queuedReserve;
646
+
647
+ // 1. Running agents (2 lines each)
648
+ for (const pair of runningLines) {
649
+ if (budget >= 2) {
650
+ lines.push(...pair);
651
+ budget -= 2;
652
+ } else {
653
+ hiddenRunning++;
633
654
  }
634
- return end;
635
- };
636
-
637
- let start = Math.max(0, Math.min(this.viewportStart, Math.max(0, rows.length - 1)));
638
- const selectedIndex = this.navigationActive && selectedId
639
- ? rows.findIndex(row => row.record.id === selectedId)
640
- : -1;
641
- if (selectedIndex >= 0) {
642
- if (selectedIndex < start) start = selectedIndex;
643
- if (selectedIndex >= endFor(start, baseContentBudget)) start = selectedIndex;
644
655
  }
645
656
 
646
- // Keep both directional affordances in the viewport chrome: the top
647
- // count sits below the heading and the bottom count sits below the
648
- // bounded roster. Each consumes one row when that direction is hidden.
649
- let showTopMore = start > 0;
650
- let contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
651
- if (selectedIndex >= 0 && selectedIndex >= endFor(start, contentBudget)) {
652
- start = selectedIndex;
653
- showTopMore = start > 0;
654
- contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
655
- }
656
- this.viewportStart = start;
657
-
658
- let used = 0;
659
- let end = start;
660
- const visibleBody: string[] = [];
661
- while (end < rows.length && used + heightAt(end) <= contentBudget) {
662
- visibleBody.push(...rows[end].lines);
663
- used += heightAt(end);
664
- end++;
657
+ // 2. Queued line (always fits — its row was reserved above)
658
+ if (queuedLine) {
659
+ budget += queuedReserve;
660
+ lines.push(queuedLine);
661
+ budget--;
665
662
  }
666
- // A selected two-line row must remain addressable even if only one body
667
- // line is available. Showing its header is preferable to hiding it.
668
- if (end === start && rows[start] && contentBudget > 0) {
669
- visibleBody.push(rows[start].lines[0]);
670
- end = start + 1;
663
+
664
+ // 3. Finished agents
665
+ for (const fl of finishedLines) {
666
+ if (budget >= 1) {
667
+ lines.push(fl);
668
+ budget--;
669
+ } else {
670
+ hiddenFinished++;
671
+ }
671
672
  }
672
673
 
673
- const hiddenBefore = start;
674
- const hiddenAfter = Math.max(0, rows.length - end);
675
- const hidden = hiddenBefore + hiddenAfter;
676
- const hiddenRows = rows.filter((_row, index) => index < start || index >= end);
677
- const categoryCounts: string[] = [
678
- ["running", hiddenRows.filter(row => row.record.status === "running").length] as [string, number],
679
- ["queued", hiddenRows.filter(row => row.record.status === "queued").length] as [string, number],
680
- ["finished", hiddenRows.filter(row => row.record.status !== "running" && row.record.status !== "queued").length] as [string, number],
681
- ].filter(([, count]) => count > 0).map(([label, count]) => `${count} ${label}`);
682
- const direction = [
683
- ...(hiddenBefore > 0 ? [`↑ ${hiddenBefore} more`] : []),
684
- ...(hiddenAfter > 0 ? [`↓ ${hiddenAfter} more`] : []),
685
- ].join(" · ");
686
- const summary = `+${hidden} more (${direction}${categoryCounts.length > 0 ? `; ${categoryCounts.join(", ")}` : ""})`;
687
-
688
- if (showTopMore) lines.push(truncate(theme.fg("dim", `↑ ${hiddenBefore} more`)));
689
-
690
- // Fill the bounded viewport so the scrollbar track has a stable height.
691
- while (visibleBody.length < contentBudget) visibleBody.push("");
692
- const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
693
- const startOffset = rows.slice(0, start).reduce((total, row) => total + row.lines.length, 0);
694
- const trackHeight = Math.max(0, contentBudget);
695
- const minThumbHeight = Math.min(2, trackHeight);
696
- const thumbHeight = Math.max(
697
- minThumbHeight,
698
- Math.min(trackHeight, Math.round((trackHeight * trackHeight) / totalBody)),
674
+ // Overflow summary
675
+ // On extremely short terminals the queued summary itself consumes the
676
+ // last useful row; keep its count in the compact footer. At the normal
677
+ // 12-line ceiling the summary is a single visible row and must not be
678
+ // counted as hidden agents.
679
+ const hiddenQueued = queuedLine && (tui.terminal.rows ?? 24) < 10 ? queued.length : 0;
680
+ const overflowParts: string[] = [];
681
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
682
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
683
+ if (hiddenQueued > 0) overflowParts.push(`${hiddenQueued} queued`);
684
+ const hiddenTotal = hiddenRunning + hiddenFinished + hiddenQueued;
685
+ const overflowText = overflowParts.join(", ");
686
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenTotal} more (${overflowText})`)}`)
699
687
  );
700
- const maxScrollTop = Math.max(0, totalBody - trackHeight);
701
- const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
702
- const thumbOffset = maxScrollTop === 0
703
- ? 0
704
- : Math.round((startOffset / maxScrollTop) * maxThumbTop);
705
- const contentWidth = Math.max(0, w - 1);
706
- for (let index = 0; index < visibleBody.length; index++) {
707
- const isThumb = index >= thumbOffset && index < thumbOffset + thumbHeight;
708
- const scrollbar = isThumb
709
- ? theme.fg("scrollbarThumb", this.navigationActive ? "█" : "┃")
710
- : theme.fg("scrollbarTrack", "│");
711
- const bodyLine = truncate(visibleBody[index] ?? "", contentWidth);
712
- lines.push(bodyLine + " ".repeat(Math.max(0, contentWidth - visibleWidth(bodyLine))) + scrollbar);
713
- }
688
+ }
714
689
 
715
- // The directional count is outside the viewport, like ScrollView's
716
- // surrounding chrome, and therefore does not consume scrollbar track.
717
- if (hiddenAfter > 0) {
718
- lines.push(truncate(theme.fg("dim", `↓ ${hiddenAfter} more · +${hidden} more (${categoryCounts.length > 0 ? categoryCounts.join(", ") : ""})`)));
719
- } else if (!showTopMore) {
720
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", summary)}`));
721
- } else {
722
- // Keep a stable footer row when the selected viewport reaches the end.
723
- lines.push(truncate(theme.fg("dim", "└─")));
690
+ if (this.navigationActive) {
691
+ const roster = this.navigationAgents();
692
+ const selected = roster[this.selectedRosterIndex];
693
+ const hasSelected = selected ? lines.some(line => line.includes(selected.description.replace(/[\r\n]+/g, " "))) : true;
694
+ if (selected && !hasSelected) {
695
+ const selectedLine = truncate(theme.fg("dim", "└─") + ` ${selected.status === "queued" ? "◦" : "⠋"} ${theme.fg("muted", selected.description.replace(/[\r\n]+/g, " "))}`);
696
+ if (lines.length >= budget) lines[lines.length - 1] = selectedLine;
697
+ else lines.push(selectedLine);
698
+ }
699
+ if (this.viewportStart > 0) {
700
+ if (lines.length >= budget) lines.splice(1, 1, truncate(theme.fg("dim", "↑ more")));
701
+ else lines.splice(1, 0, truncate(theme.fg("dim", "↑ more")));
702
+ }
703
+ const railStart = this.viewportStart > 0 ? 2 : 1;
704
+ for (let index = railStart; index < lines.length; index++) {
705
+ const plain = lines[index] ?? "";
706
+ lines[index] = truncate(plain + (selected && plain.includes(selected.description.replace(/[\r\n]+/g, " ")) ? "┃" : "│"));
724
707
  }
708
+ if (lines.length > budget) lines.length = budget;
725
709
  }
726
-
727
710
  return lines;
728
711
  }
729
712
 
730
- /** Build a render-relevant snapshot without capturing mutable records. */
731
- private renderKey(allAgents: ReturnType<AgentManager["listAgents"]>): string {
732
- const activities = allAgents.map(a => {
733
- const activity = this.agentActivity.get(a.id);
734
- return activity ? {
735
- id: a.id,
736
- activeTools: [...activity.activeTools.entries()],
737
- toolUses: activity.toolUses,
738
- responseText: activity.responseText,
739
- turnCount: activity.turnCount,
740
- maxTurns: activity.maxTurns,
741
- lifetimeUsage: activity.lifetimeUsage,
742
- contextPercent: getSessionContextPercent(activity.session),
743
- } : undefined;
744
- });
745
- return JSON.stringify({
746
- frame: this.widgetFrame,
747
- agents: allAgents.map(a => ({
748
- id: a.id,
749
- type: a.type,
750
- description: a.description,
751
- status: a.status,
752
- toolUses: a.toolUses,
753
- startedAt: a.startedAt,
754
- completedAt: a.completedAt,
755
- error: a.error,
756
- lifetimeUsage: a.lifetimeUsage,
757
- compactionCount: a.compactionCount,
758
- isBackground: a.isBackground,
759
- hasSession: a.session !== undefined,
760
- transcriptPath: a.transcriptPath,
761
- openableHistory: this.options.canOpenHistory(a),
762
- })),
763
- activities,
764
- navigationActive: this.navigationActive,
765
- selectedAgentId: this.selectedAgentId,
766
- viewportStart: this.viewportStart,
767
- });
768
- }
769
-
770
- /** Force an immediate widget update. `advanceSpinner` is reserved for the timer. */
771
- update(advanceSpinner = false) {
713
+ /** Force an immediate widget update. */
714
+ update() {
772
715
  if (!this.uiCtx) return;
773
716
  const allAgents = this.widgetAgents();
774
- const roster = this.roster();
717
+ if (this.navigationActive || this.selectedAgentId) {
718
+ const roster = this.navigationAgents();
719
+ const selectedIndex = this.selectedAgentId ? roster.findIndex(agent => agent.id === this.selectedAgentId) : -1;
720
+ if (selectedIndex >= 0) {
721
+ this.selectedRosterIndex = selectedIndex;
722
+ } else if (roster.length > 0 && this.selectedAgentId) {
723
+ this.selectedRosterIndex = Math.min(this.selectedRosterIndex, roster.length - 1);
724
+ this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
725
+ } else if (roster.length === 0) {
726
+ this.navigationActive = false;
727
+ this.selectedAgentId = undefined;
728
+ this.selectedRosterIndex = 0;
729
+ this.viewportStart = 0;
730
+ }
731
+ this.updateViewport(roster);
732
+ }
775
733
 
776
734
  // Lightweight existence checks — full categorization happens in renderWidget()
777
735
  let runningCount = 0;
778
736
  let queuedCount = 0;
779
- for (const a of roster) {
780
- if (a.status === "running") runningCount++;
781
- else if (a.status === "queued") queuedCount++;
737
+ let hasFinished = false;
738
+ for (const a of allAgents) {
739
+ if (a.status === "running") { runningCount++; }
740
+ else if (a.status === "queued") { queuedCount++; }
741
+ else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
782
742
  }
783
- const hasFinished = roster.some(a => a.status !== "running" && a.status !== "queued");
784
743
  const hasActive = runningCount > 0 || queuedCount > 0;
785
744
 
786
745
  // Nothing to show — clear widget
@@ -794,12 +753,11 @@ export class AgentWidget {
794
753
  this.uiCtx.setStatus("subagents", undefined);
795
754
  this.lastStatusText = undefined;
796
755
  }
797
- this.lastRenderKey = undefined;
798
- this.navigationActive = false;
799
- this.selectedAgentId = undefined;
800
- this.selectedRosterIndex = 0;
801
- this.viewportStart = 0;
802
- this.syncTimer(false);
756
+ if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
757
+ // Clean up stale entries
758
+ for (const [id] of this.finishedTurnAge) {
759
+ if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
760
+ }
803
761
  return;
804
762
  }
805
763
 
@@ -817,30 +775,24 @@ export class AgentWidget {
817
775
  this.lastStatusText = newStatusText;
818
776
  }
819
777
 
820
- // Spinner animation is driven only by the timer while a visible running
821
- // agent exists. Event-driven updates refresh changed stats without making
822
- // an otherwise idle widget advance.
823
- if (advanceSpinner && runningCount > 0) this.widgetFrame++;
824
- this.syncTimer(runningCount > 0);
825
- const selectedIndex = this.selectedIndexOf(roster);
826
- if (selectedIndex >= 0) {
827
- this.selectedRosterIndex = selectedIndex;
828
- } else if (this.navigationActive) {
829
- if (roster.length === 0) {
830
- this.navigationActive = false;
831
- this.selectedAgentId = undefined;
832
- this.selectedRosterIndex = 0;
833
- this.viewportStart = 0;
834
- } else {
835
- // Keep the selection near the row that disappeared. Both the saved
836
- // logical index and viewport are clamped as the roster shrinks.
837
- const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, roster.length - 1));
838
- this.selectedAgentId = roster[fallbackIndex].id;
839
- this.selectedRosterIndex = fallbackIndex;
840
- this.viewportStart = Math.min(this.viewportStart, roster.length - 1);
841
- }
842
- }
843
- const renderKey = this.renderKey(allAgents);
778
+ const snapshot = allAgents.map(agent => {
779
+ const activity = this.agentActivity.get(agent.id);
780
+ return [
781
+ agent.id,
782
+ agent.status,
783
+ agent.description,
784
+ agent.toolUses,
785
+ agent.completedAt ?? "",
786
+ this.finishedTurnAge.get(agent.id) ?? "",
787
+ getLifetimeTotal(agent.lifetimeUsage),
788
+ activity?.toolUses ?? "",
789
+ activity?.responseText ?? "",
790
+ Math.floor((Date.now() - agent.startedAt) / 1000),
791
+ ].join("\u0000");
792
+ }).join("\u0001");
793
+ if (this.widgetRegistered && snapshot === this.lastSnapshot) return;
794
+ this.lastSnapshot = snapshot;
795
+ this.widgetFrame++;
844
796
 
845
797
  // Register widget callback once; subsequent updates use requestRender()
846
798
  // which re-invokes render() without replacing the component (avoids layout thrashing).
@@ -853,20 +805,23 @@ export class AgentWidget {
853
805
  // Theme changed — force re-registration so factory captures fresh theme.
854
806
  this.widgetRegistered = false;
855
807
  this.tui = undefined;
856
- this.lastRenderKey = undefined;
857
808
  },
858
809
  };
859
810
  }, { placement: "aboveEditor" });
860
811
  this.widgetRegistered = true;
861
- } else if (renderKey !== this.lastRenderKey) {
862
- // Widget already registered — request a re-render only when visible state
863
- // changed (or the spinner timer advanced).
812
+ } else {
813
+ // Widget already registered — just request a re-render of existing components.
864
814
  this.tui?.requestRender();
865
815
  }
866
- this.lastRenderKey = renderKey;
867
816
  }
868
817
 
869
818
  dispose() {
819
+ this.inputCleanup?.();
820
+ this.inputCleanup = undefined;
821
+ this.navigationActive = false;
822
+ this.selectedAgentId = undefined;
823
+ this.viewportStart = 0;
824
+ this.selectedRosterIndex = 0;
870
825
  if (this.widgetInterval) {
871
826
  clearInterval(this.widgetInterval);
872
827
  this.widgetInterval = undefined;
@@ -875,16 +830,9 @@ export class AgentWidget {
875
830
  this.uiCtx.setWidget("agents", undefined);
876
831
  this.uiCtx.setStatus("subagents", undefined);
877
832
  }
878
- this.inputUnsub?.();
879
- this.inputUnsub = undefined;
880
833
  this.widgetRegistered = false;
881
834
  this.tui = undefined;
882
835
  this.lastStatusText = undefined;
883
- this.lastRenderKey = undefined;
884
- this.navigationActive = false;
885
- this.selectedAgentId = undefined;
886
- this.selectedRosterIndex = 0;
887
- this.viewportStart = 0;
888
- this.uiCtx = undefined;
836
+ this.lastSnapshot = "";
889
837
  }
890
838
  }