@esso0428/pi-subagents 0.17.6 → 0.17.8

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 +14 -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 +1912 -492
  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 +97 -75
  101. package/dist/ui/agent-widget.d.ts.map +1 -1
  102. package/dist/ui/agent-widget.js +398 -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 +2029 -536
  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 +393 -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,29 @@
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;
30
+ showCost?: () => boolean;
29
31
  }
30
32
 
31
33
  /** Braille spinner frames for animated running indicator. */
@@ -52,19 +54,6 @@ export type Theme = {
52
54
  bold(text: string): string;
53
55
  };
54
56
 
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
57
  export type UICtx = {
69
58
  setStatus(key: string, text: string | undefined): void;
70
59
  setWidget(
@@ -72,8 +61,8 @@ export type UICtx = {
72
61
  content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
73
62
  options?: { placement?: "aboveEditor" | "belowEditor" },
74
63
  ): void;
75
- onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
76
- getEditorText(): string;
64
+ onTerminalInput?(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
65
+ getEditorText?(): string;
77
66
  };
78
67
 
79
68
  /** Per-agent live activity state. */
@@ -86,7 +75,7 @@ export interface AgentActivity {
86
75
  turnCount: number;
87
76
  /** Effective max turns for this agent (undefined = unlimited). */
88
77
  maxTurns?: number;
89
- /** Lifetime usage breakdown — see LifetimeUsage docs. */
78
+ /** Lifetime token usage, including partial runs. */
90
79
  lifetimeUsage: LifetimeUsage;
91
80
  }
92
81
 
@@ -103,7 +92,7 @@ export interface AgentDetails {
103
92
  activity?: string;
104
93
  /** Current spinner frame index (for animated running indicator). */
105
94
  spinnerFrame?: number;
106
- /** Short model name if different from parent (e.g. "haiku", "sonnet"). */
95
+ /** Short label for the model the run used, e.g. "haiku 4.5". */
107
96
  modelName?: string;
108
97
  /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
109
98
  tags?: string[];
@@ -111,6 +100,8 @@ export interface AgentDetails {
111
100
  turnCount?: number;
112
101
  /** Effective max turns (undefined = unlimited). */
113
102
  maxTurns?: number;
103
+ /** Estimated cost in USD; 0 when the model has no pricing data. */
104
+ cost?: number;
114
105
  agentId?: string;
115
106
  error?: string;
116
107
  }
@@ -131,6 +122,31 @@ export function formatTokens(count: number): string {
131
122
  return `${count} token`;
132
123
  }
133
124
 
125
+ /**
126
+ * Format a cost as `~$0.0042`, or "" when there is nothing to show.
127
+ *
128
+ * The tilde is load-bearing: this is pi's own estimate from the model's listed
129
+ * rates, not a billed figure, and the surfaces that print it sit next to token
130
+ * counts that ARE exact.
131
+ *
132
+ * Nothing is printed for zero, which is also what a model with no pricing data
133
+ * reports: `$0.00` beside a local model's tokens would claim its cost was
134
+ * measured and found to be nothing, rather than never measured at all. For the
135
+ * same reason a real cost too small for four decimals reads `<$0.0001` — it was
136
+ * measured, and rounding it to `~$0.0000` would say the opposite.
137
+ */
138
+ export function formatCost(cost: number): string {
139
+ if (!(cost > 0)) return ""; // also catches NaN
140
+ if (cost < 0.0001) return "<$0.0001";
141
+ if (cost >= 1) return `~$${cost.toFixed(2)}`;
142
+ // Under a dollar: cents at minimum, four decimals at most, nothing trailing.
143
+ // Most single runs land between a tenth of a cent and a dime, where rounding
144
+ // to cents would collapse a 4x difference in spend into the same figure.
145
+ const rounded = Number(cost.toFixed(4));
146
+ const decimals = (String(rounded).split(".")[1] ?? "").length;
147
+ return `~$${rounded.toFixed(Math.max(2, decimals))}`;
148
+ }
149
+
134
150
  /**
135
151
  * Token count with optional context-fill % and compaction-count annotations.
136
152
  * Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
@@ -187,25 +203,37 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
187
203
  return config.promptMode === "append" ? "twin" : undefined;
188
204
  }
189
205
 
190
- /** Mode label is not included — callers add it where they want it. */
206
+ /**
207
+ * Mode label is not included — callers add it where they want it.
208
+ *
209
+ * Both model forms come back so each surface can pick by width; the
210
+ * "(asked X)" annotation is applied here rather than by callers, so a value the
211
+ * spawn did not honor cannot be rendered as though it had been (#182).
212
+ */
191
213
  export function buildInvocationTags(
192
214
  invocation: AgentInvocation | undefined,
193
- ): { modelName?: string; tags: string[] } {
215
+ ): { modelName?: string; modelId?: string; tags: string[] } {
194
216
  const tags: string[] = [];
195
217
  if (!invocation) return { tags };
196
- const thinking = invocation.effectiveThinking ?? invocation.thinking;
218
+ const asked = (value: string | undefined, requested: string | undefined): string | undefined =>
219
+ value && requested && requested !== value ? `${value} (asked ${requested})` : value;
220
+ const thinking = asked(invocation.thinking, invocation.requestedThinking);
197
221
  if (thinking) tags.push(`thinking: ${thinking}`);
198
222
  if (invocation.isolated) tags.push("isolated");
199
223
  if (invocation.isolation === "worktree") tags.push("worktree");
200
224
  if (invocation.inheritContext) tags.push("inherit context");
201
225
  if (invocation.runInBackground) tags.push("background");
202
226
  if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
203
- return { modelName: invocation.modelName, tags };
227
+ return {
228
+ modelName: asked(invocation.modelName, invocation.requestedModel),
229
+ modelId: asked(invocation.modelId, invocation.requestedModel),
230
+ tags,
231
+ };
204
232
  }
205
233
 
206
- /** Normalize and truncate text so it can never add physical widget rows. */
234
+ /** Truncate text to a single line, max `len` chars. */
207
235
  function truncateLine(text: string, len = 60): string {
208
- const line = text.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
236
+ const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
209
237
  if (line.length <= len) return line;
210
238
  return line.slice(0, len) + "…";
211
239
  }
@@ -244,15 +272,10 @@ export class AgentWidget {
244
272
  private uiCtx: UICtx | undefined;
245
273
  private widgetFrame = 0;
246
274
  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;
275
+ /** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
276
+ private finishedTurnAge = new Map<string, number>();
277
+ /** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
278
+ private static readonly ERROR_LINGER_TURNS = 2;
256
279
 
257
280
  /** Whether the widget callback is currently registered with the TUI. */
258
281
  private widgetRegistered = false;
@@ -260,19 +283,49 @@ export class AgentWidget {
260
283
  private tui: any | undefined;
261
284
  /** Last status bar text, used to avoid redundant setStatus calls. */
262
285
  private lastStatusText: string | undefined;
263
- /** Snapshot of the state used for the last widget registration/render request. */
264
- private lastRenderKey: string | undefined;
286
+ private lastSnapshot = "";
287
+ private inputCleanup: (() => void) | undefined;
288
+ private navigationActive = false;
289
+ private selectedAgentId: string | undefined;
290
+ private viewportStart = 0;
291
+ private selectedRosterIndex = 0;
292
+ private openOptions: AgentWidgetOptions;
293
+ private showCost: () => boolean;
294
+ private showModel: () => boolean;
265
295
 
266
296
  constructor(
267
297
  private manager: AgentManager,
268
298
  private agentActivity: Map<string, AgentActivity>,
269
- /** Read live at render time. Selects which agents the widget shows. */
299
+ /**
300
+ * Read live at render time. Selects which agents the widget shows — see
301
+ * `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
302
+ * extension supplies one defaulting to `"background"`.
303
+ */
270
304
  private mode: () => WidgetMode = () => "all",
271
- private options: AgentWidgetOptions = {
272
- canOpenHistory: (record) => record.session !== undefined || record.completedAt !== undefined,
273
- onOpen: () => {},
274
- },
275
- ) {}
305
+ /**
306
+ * Read live at render time, like `mode`. Whether running agents show an
307
+ * estimated cost beside their token count. Defaults to off — the extension
308
+ * supplies the user's `showCost` setting.
309
+ */
310
+ /**
311
+ * Read live at render time, like `mode`. Whether running agents name the
312
+ * model driving them and the thinking level it is running at. Defaults to
313
+ * off — the extension supplies the user's `showModel` setting — because the
314
+ * row is already dense and the same pair is on the tool result and in the
315
+ * conversation viewer unconditionally.
316
+ */
317
+ showCostOrOptions: (() => boolean) | AgentWidgetOptions = () => false,
318
+ showModel: () => boolean = () => false,
319
+ ) {
320
+ if (typeof showCostOrOptions === "function") {
321
+ this.showCost = showCostOrOptions;
322
+ this.openOptions = {};
323
+ } else {
324
+ this.showCost = showCostOrOptions.showCost ?? (() => false);
325
+ this.openOptions = showCostOrOptions;
326
+ }
327
+ this.showModel = showModel;
328
+ }
276
329
 
277
330
  /**
278
331
  * Agents eligible for the widget, per the current `WidgetMode`:
@@ -286,7 +339,7 @@ export class AgentWidget {
286
339
  * - `all`: every agent.
287
340
  */
288
341
  private widgetAgents() {
289
- const all = this.manager.listAgents();
342
+ const all = this.manager.listAgents().filter(isTopLevelAgent);
290
343
  switch (this.mode()) {
291
344
  case "off": return [];
292
345
  case "background": return all.filter(a => a.isBackground !== false);
@@ -295,191 +348,142 @@ export class AgentWidget {
295
348
  }
296
349
 
297
350
  /** 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));
351
+ setUICtx(ctx: UICtx) {
352
+ if (ctx !== this.uiCtx) {
353
+ this.inputCleanup?.();
354
+ this.inputCleanup = undefined;
355
+ this.uiCtx = ctx;
356
+ this.widgetRegistered = false;
357
+ this.tui = undefined;
358
+ this.lastStatusText = undefined;
359
+ this.navigationActive = false;
360
+ this.selectedAgentId = undefined;
361
+ this.viewportStart = 0;
362
+ if (ctx.onTerminalInput) this.inputCleanup = ctx.onTerminalInput(data => this.handleInput(data));
318
363
  }
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
364
  }
328
365
 
329
- /** Called on each new turn (tool_execution_start). */
366
+ /**
367
+ * Called on each new turn (tool_execution_start).
368
+ * Ages finished agents and clears those that have lingered long enough.
369
+ */
330
370
  onTurnStart() {
371
+ // Age all finished agents
372
+ for (const [id, age] of this.finishedTurnAge) {
373
+ this.finishedTurnAge.set(id, age + 1);
374
+ }
375
+ // Trigger a widget refresh (will filter out expired agents)
331
376
  this.update();
332
377
  }
333
378
 
334
- /** Keep the spinner/elapsed-time timer alive only while a visible agent runs. */
379
+ /** Ensure the widget update timer is running. */
335
380
  ensureTimer() {
336
- if (!this.uiCtx || !this.widgetAgents().some(a => a.status === "running")) return;
337
381
  if (!this.widgetInterval) {
338
- this.widgetInterval = setInterval(() => this.update(true), 250);
382
+ this.widgetInterval = setInterval(() => this.update(), 80);
339
383
  }
340
384
  }
341
385
 
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
- }
386
+ /** Check if a finished agent should still be shown in the widget. */
387
+ private shouldShowFinished(agentId: string, status: string): boolean {
388
+ const age = this.finishedTurnAge.get(agentId) ?? 0;
389
+ const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
390
+ return age < maxAge;
349
391
  }
350
392
 
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) {}
393
+ /** Record an agent as finished (call when agent completes). */
394
+ markFinished(agentId: string) {
395
+ if (!this.finishedTurnAge.has(agentId)) {
396
+ this.finishedTurnAge.set(agentId, 0);
397
+ }
398
+ }
356
399
 
357
400
  /**
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.
401
+ * Drop an agent's finished-age (call when a settled agent starts running
402
+ * again, i.e. a background resume). markFinished only seeds an age it has not
403
+ * seen before, so a resumed agent would otherwise keep the age from its
404
+ * previous run — already past the linger limit, hiding the new run's
405
+ * completion line entirely.
363
406
  */
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];
407
+ markRunning(agentId: string) {
408
+ this.finishedTurnAge.delete(agentId);
374
409
  }
375
410
 
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();
411
+ private navigationAgents(): AgentRecord[] {
412
+ const agents = this.widgetAgents().filter(agent =>
413
+ agent.status === "running" || agent.status === "queued" || (agent.completedAt !== undefined && this.shouldShowFinished(agent.id, agent.status)),
414
+ );
415
+ return agents.filter(agent =>
416
+ agent.status === "running" || agent.status === "queued" || (this.openOptions.canOpenHistory?.(agent) ?? true),
417
+ ).sort((a, b) => {
418
+ const activeA = a.status === "running" || a.status === "queued" ? 0 : 1;
419
+ const activeB = b.status === "running" || b.status === "queued" ? 0 : 1;
420
+ return activeA - activeB;
421
+ });
394
422
  }
395
423
 
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();
424
+ private updateViewport(roster: AgentRecord[]): void {
425
+ const budget = getWidgetLineBudget(this.tui?.terminal?.rows ?? 24);
426
+ const visibleRows = Math.max(1, budget - 1);
427
+ if (this.selectedRosterIndex < this.viewportStart) this.viewportStart = this.selectedRosterIndex;
428
+ if (this.selectedRosterIndex >= this.viewportStart + visibleRows) this.viewportStart = this.selectedRosterIndex - visibleRows + 1;
429
+ this.viewportStart = Math.max(0, Math.min(this.viewportStart, Math.max(0, roster.length - visibleRows)));
412
430
  }
413
431
 
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
-
432
+ private handleInput(data: string): { consume?: boolean; data?: string } | undefined {
433
+ const down = data === "\u001b[B";
434
+ const up = data === "\u001b[A";
420
435
  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;
436
+ if (!down || !this.tui || this.uiCtx?.getEditorText?.() !== "") return undefined;
437
+ const roster = this.navigationAgents();
438
+ if (roster.length === 0) return undefined;
439
+ this.navigationActive = true;
440
+ const index = this.selectedAgentId ? roster.findIndex(agent => agent.id === this.selectedAgentId) : -1;
441
+ this.selectedRosterIndex = index >= 0 ? index : 0;
442
+ this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
443
+ this.updateViewport(roster);
444
+ this.requestUiRefresh();
445
+ return { consume: true };
452
446
  }
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);
447
+ if (data === "\u001b") {
448
+ this.navigationActive = false;
449
+ this.requestUiRefresh();
460
450
  return { consume: true };
461
451
  }
462
-
463
- if (matchesKey(data, "escape")) {
464
- this.deactivate();
452
+ if (down || up) {
453
+ const roster = this.navigationAgents();
454
+ if (roster.length === 0) return { consume: true };
455
+ if (up && this.selectedRosterIndex === 0) {
456
+ this.navigationActive = false;
457
+ } else {
458
+ this.selectedRosterIndex = Math.max(0, Math.min(roster.length - 1, this.selectedRosterIndex + (down ? 1 : -1)));
459
+ this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
460
+ this.updateViewport(roster);
461
+ }
462
+ this.requestUiRefresh();
465
463
  return { consume: true };
466
464
  }
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();
465
+ if (data === "\r" || data === "\n") {
466
+ const record = this.navigationAgents()[this.selectedRosterIndex];
467
+ if (record) {
468
+ const terminal = record.status !== "running" && record.status !== "queued";
469
+ if (!terminal || (this.openOptions.canOpenHistory?.(record) ?? true)) {
470
+ this.openOptions.onOpen?.(record, terminal ? "history" : "live");
471
+ }
472
+ }
471
473
  return { consume: true };
472
474
  }
473
-
474
- // Only ↑/↓ navigate. Other keys, including j/k/←/→, flow to the editor
475
- // and leave navigation mode.
476
- this.deactivate();
475
+ this.navigationActive = false;
477
476
  return undefined;
478
477
  }
479
478
 
479
+ private requestUiRefresh(force = false): boolean {
480
+ if (!this.tui || typeof this.tui.requestRender !== "function") return false;
481
+ this.tui.requestRender(force);
482
+ return true;
483
+ }
484
+
480
485
  /** 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);
486
+ 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
487
  const modeLabel = getPromptModeLabel(a.type);
484
488
  const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
485
489
 
@@ -496,7 +500,7 @@ export class AgentWidget {
496
500
  statusText = theme.fg("dim", " stopped");
497
501
  } else if (a.status === "error") {
498
502
  icon = theme.fg("error", "✗");
499
- const errMsg = a.error ? `: ${truncateLine(a.error)}` : "";
503
+ const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
500
504
  statusText = theme.fg("error", ` error${errMsg}`);
501
505
  } else {
502
506
  // aborted
@@ -508,10 +512,16 @@ export class AgentWidget {
508
512
  const activity = this.agentActivity.get(a.id);
509
513
  if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
510
514
  if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
515
+ // From the record, not the activity tracker: that entry is deleted the
516
+ // moment an agent finishes, and "what did it cost" is a question asked
517
+ // about finished agents.
518
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
519
+ if (costText) parts.push(costText);
511
520
  parts.push(duration);
512
521
 
513
522
  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}`;
523
+ const description = a.description.replace(/[\r\n]+/g, " ");
524
+ return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
515
525
  }
516
526
 
517
527
  /**
@@ -523,11 +533,10 @@ export class AgentWidget {
523
533
  const running = allAgents.filter(a => a.status === "running");
524
534
  const queued = allAgents.filter(a => a.status === "queued");
525
535
  const finished = allAgents.filter(a =>
526
- a.status !== "running" && a.status !== "queued" && a.completedAt !== undefined
527
- && this.options.canOpenHistory(a),
536
+ a.status !== "running" && a.status !== "queued" && a.completedAt
537
+ && this.shouldShowFinished(a.id, a.status),
528
538
  );
529
539
 
530
- const selectedId = this.navigationActive ? this.selectedAgentId : undefined;
531
540
  const hasActive = running.length > 0 || queued.length > 0;
532
541
  const hasFinished = finished.length > 0;
533
542
 
@@ -535,9 +544,8 @@ export class AgentWidget {
535
544
  if (!hasActive && !hasFinished) return [];
536
545
 
537
546
  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));
547
+ const budget = getWidgetLineBudget(tui.terminal.rows ?? 24);
548
+ const truncate = (line: string) => truncateToWidth(line.replace(/[\r\n]+/g, " "), w);
541
549
  const headingColor = hasActive ? "accent" : "dim";
542
550
  const headingIcon = hasActive ? "●" : "○";
543
551
  const frame = SPINNER[this.widgetFrame % SPINNER.length];
@@ -545,242 +553,197 @@ export class AgentWidget {
545
553
  // Build sections separately for overflow-aware assembly.
546
554
  // Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
547
555
 
548
- const finishedLines: { record: AgentRecord; lines: string[] }[] = [];
556
+ const finishedLines: string[] = [];
549
557
  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
- });
558
+ finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
555
559
  }
556
560
 
557
- const runningLines: { record: AgentRecord; lines: string[] }[] = []; // each entry is [header, activity]
561
+ const runningLines: string[][] = []; // each entry is [header, activity]
558
562
  for (const a of running) {
559
- const name = getDisplayName(a.type);
560
563
  const modeLabel = getPromptModeLabel(a.type);
561
564
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
562
565
  const elapsed = formatMs(Date.now() - a.startedAt);
563
566
 
564
567
  const bg = this.agentActivity.get(a.id);
565
568
  const toolUses = bg?.toolUses ?? a.toolUses;
566
- const tokens = getLifetimeTotal(bg?.lifetimeUsage);
569
+ // Spend comes from the record, never from the activity tracker: the record
570
+ // is the one that survives the agent finishing, and the one nested-tools
571
+ // folds a hidden child's spend into. Reading the tracker while an agent
572
+ // runs and the record once it stops made the figure jump at completion.
573
+ const tokens = getLifetimeTotal(a.lifetimeUsage);
567
574
  const contextPercent = getSessionContextPercent(bg?.session);
568
575
  const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
576
+ const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
569
577
 
570
578
  const parts: string[] = [];
579
+ if (this.showModel()) {
580
+ // Leading, and paired: a thinking level means nothing without the model
581
+ // it applies to. The tag is taken from buildInvocationTags rather than
582
+ // rebuilt so the "(asked X)" annotation survives.
583
+ const { modelName, tags } = buildInvocationTags(a.invocation);
584
+ if (modelName) parts.push(modelName);
585
+ const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
586
+ if (thinkingTag) parts.push(thinkingTag);
587
+ }
571
588
  if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
572
589
  if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
573
590
  if (tokenText) parts.push(tokenText);
591
+ if (costText) parts.push(costText);
574
592
  parts.push(elapsed);
575
593
  const statsText = parts.join(" · ");
576
594
 
577
- const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
595
+ const activity = (bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…").replace(/[\r\n]+/g, " ");
578
596
 
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
- });
597
+ runningLines.push([
598
+ 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)}`),
599
+ truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
600
+ ]);
587
601
  }
588
602
 
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);
603
+ const queuedLine = queued.length > 0
604
+ ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
605
+ : undefined;
602
606
 
603
- const heading = "Agents ↑↓ select · enter view · esc back";
604
- const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, heading))];
607
+ // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
608
+ const maxBody = Math.max(1, budget - 1); // heading takes 1 line
609
+ const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
605
610
 
606
- if (maxLines === 1) {
607
- // There is room only for the heading; do not consume the editor/input row.
608
- return lines;
609
- }
611
+ const hint = "↑↓ select · enter view · esc back";
612
+ const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents") + " " + theme.fg("dim", hint))];
610
613
 
611
614
  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("│ ", " ");
615
+ // Everything fits — add all lines and fix up connectors for the last item.
616
+ lines.push(...finishedLines);
617
+ for (const pair of runningLines) lines.push(...pair);
618
+ if (queuedLine) lines.push(queuedLine);
619
+
620
+ // Fix last connector: swap ├─ → └─ and │ → space for activity lines.
621
+ if (lines.length > 1) {
622
+ const last = lines.length - 1;
623
+ lines[last] = lines[last].replace("├─", "└─");
624
+ // If last item is a running agent activity line, fix indent of that line
625
+ // and fix the header line above it.
626
+ if (runningLines.length > 0 && !queuedLine) {
627
+ // The last two lines are the last running agent's header + activity.
628
+ if (last >= 2) {
629
+ lines[last - 1] = lines[last - 1].replace("├─", "└─");
630
+ lines[last] = lines[last].replace("│ ", " ");
631
+ }
620
632
  }
621
633
  }
622
634
  } 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++);
635
+ // Overflow — prioritize: running > queued > finished.
636
+ // Reserve 1 line for overflow indicator.
637
+ let budget = maxBody - 1;
638
+ let hiddenRunning = 0;
639
+ let hiddenFinished = 0;
640
+
641
+ // Reserve the queued line's row up front. It is a single summary of N
642
+ // waiting agents, so it cannot be folded into the "+N more" count (which
643
+ // is denominated in agents) without either under-reporting it as 1 or
644
+ // inflating the total with agents that were never getting their own rows.
645
+ // Reserving costs at most one running agent — which IS counted below —
646
+ // and makes the drop unreachable. It matters most exactly when it used to
647
+ // vanish: the pool is saturated and the queue is what the user needs to see.
648
+ const queuedReserve = queuedLine ? 1 : 0;
649
+ budget -= queuedReserve;
650
+
651
+ // 1. Running agents (2 lines each)
652
+ for (const pair of runningLines) {
653
+ if (budget >= 2) {
654
+ lines.push(...pair);
655
+ budget -= 2;
656
+ } else {
657
+ hiddenRunning++;
633
658
  }
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
659
  }
645
660
 
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++;
661
+ // 2. Queued line (always fits — its row was reserved above)
662
+ if (queuedLine) {
663
+ budget += queuedReserve;
664
+ lines.push(queuedLine);
665
+ budget--;
665
666
  }
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;
667
+
668
+ // 3. Finished agents
669
+ for (const fl of finishedLines) {
670
+ if (budget >= 1) {
671
+ lines.push(fl);
672
+ budget--;
673
+ } else {
674
+ hiddenFinished++;
675
+ }
671
676
  }
672
677
 
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)),
678
+ // Overflow summary
679
+ // On extremely short terminals the queued summary itself consumes the
680
+ // last useful row; keep its count in the compact footer. At the normal
681
+ // 12-line ceiling the summary is a single visible row and must not be
682
+ // counted as hidden agents.
683
+ const hiddenQueued = queuedLine && (tui.terminal.rows ?? 24) < 10 ? queued.length : 0;
684
+ const overflowParts: string[] = [];
685
+ if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
686
+ if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
687
+ if (hiddenQueued > 0) overflowParts.push(`${hiddenQueued} queued`);
688
+ const hiddenTotal = hiddenRunning + hiddenFinished + hiddenQueued;
689
+ const overflowText = overflowParts.join(", ");
690
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenTotal} more (${overflowText})`)}`)
699
691
  );
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
- }
692
+ }
714
693
 
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", "└─")));
694
+ if (this.navigationActive) {
695
+ const roster = this.navigationAgents();
696
+ const selected = roster[this.selectedRosterIndex];
697
+ const hasSelected = selected ? lines.some(line => line.includes(selected.description.replace(/[\r\n]+/g, " "))) : true;
698
+ if (selected && !hasSelected) {
699
+ const selectedLine = truncate(theme.fg("dim", "└─") + ` ${selected.status === "queued" ? "◦" : "⠋"} ${theme.fg("muted", selected.description.replace(/[\r\n]+/g, " "))}`);
700
+ if (lines.length >= budget) lines[lines.length - 1] = selectedLine;
701
+ else lines.push(selectedLine);
702
+ }
703
+ if (this.viewportStart > 0) {
704
+ if (lines.length >= budget) lines.splice(1, 1, truncate(theme.fg("dim", "↑ more")));
705
+ else lines.splice(1, 0, truncate(theme.fg("dim", "↑ more")));
706
+ }
707
+ const railStart = this.viewportStart > 0 ? 2 : 1;
708
+ for (let index = railStart; index < lines.length; index++) {
709
+ const plain = lines[index] ?? "";
710
+ lines[index] = truncate(plain + (selected && plain.includes(selected.description.replace(/[\r\n]+/g, " ")) ? "┃" : "│"));
724
711
  }
712
+ if (lines.length > budget) lines.length = budget;
725
713
  }
726
-
727
714
  return lines;
728
715
  }
729
716
 
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) {
717
+ /** Force an immediate widget update. */
718
+ update() {
772
719
  if (!this.uiCtx) return;
773
720
  const allAgents = this.widgetAgents();
774
- const roster = this.roster();
721
+ if (this.navigationActive || this.selectedAgentId) {
722
+ const roster = this.navigationAgents();
723
+ const selectedIndex = this.selectedAgentId ? roster.findIndex(agent => agent.id === this.selectedAgentId) : -1;
724
+ if (selectedIndex >= 0) {
725
+ this.selectedRosterIndex = selectedIndex;
726
+ } else if (roster.length > 0 && this.selectedAgentId) {
727
+ this.selectedRosterIndex = Math.min(this.selectedRosterIndex, roster.length - 1);
728
+ this.selectedAgentId = roster[this.selectedRosterIndex]?.id;
729
+ } else if (roster.length === 0) {
730
+ this.navigationActive = false;
731
+ this.selectedAgentId = undefined;
732
+ this.selectedRosterIndex = 0;
733
+ this.viewportStart = 0;
734
+ }
735
+ this.updateViewport(roster);
736
+ }
775
737
 
776
738
  // Lightweight existence checks — full categorization happens in renderWidget()
777
739
  let runningCount = 0;
778
740
  let queuedCount = 0;
779
- for (const a of roster) {
780
- if (a.status === "running") runningCount++;
781
- else if (a.status === "queued") queuedCount++;
741
+ let hasFinished = false;
742
+ for (const a of allAgents) {
743
+ if (a.status === "running") { runningCount++; }
744
+ else if (a.status === "queued") { queuedCount++; }
745
+ else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
782
746
  }
783
- const hasFinished = roster.some(a => a.status !== "running" && a.status !== "queued");
784
747
  const hasActive = runningCount > 0 || queuedCount > 0;
785
748
 
786
749
  // Nothing to show — clear widget
@@ -794,12 +757,11 @@ export class AgentWidget {
794
757
  this.uiCtx.setStatus("subagents", undefined);
795
758
  this.lastStatusText = undefined;
796
759
  }
797
- this.lastRenderKey = undefined;
798
- this.navigationActive = false;
799
- this.selectedAgentId = undefined;
800
- this.selectedRosterIndex = 0;
801
- this.viewportStart = 0;
802
- this.syncTimer(false);
760
+ if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
761
+ // Clean up stale entries
762
+ for (const [id] of this.finishedTurnAge) {
763
+ if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
764
+ }
803
765
  return;
804
766
  }
805
767
 
@@ -817,30 +779,24 @@ export class AgentWidget {
817
779
  this.lastStatusText = newStatusText;
818
780
  }
819
781
 
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);
782
+ const snapshot = allAgents.map(agent => {
783
+ const activity = this.agentActivity.get(agent.id);
784
+ return [
785
+ agent.id,
786
+ agent.status,
787
+ agent.description,
788
+ agent.toolUses,
789
+ agent.completedAt ?? "",
790
+ this.finishedTurnAge.get(agent.id) ?? "",
791
+ getLifetimeTotal(agent.lifetimeUsage),
792
+ activity?.toolUses ?? "",
793
+ activity?.responseText ?? "",
794
+ Math.floor((Date.now() - agent.startedAt) / 1000),
795
+ ].join("\u0000");
796
+ }).join("\u0001");
797
+ if (this.widgetRegistered && snapshot === this.lastSnapshot) return;
798
+ this.lastSnapshot = snapshot;
799
+ this.widgetFrame++;
844
800
 
845
801
  // Register widget callback once; subsequent updates use requestRender()
846
802
  // which re-invokes render() without replacing the component (avoids layout thrashing).
@@ -853,20 +809,23 @@ export class AgentWidget {
853
809
  // Theme changed — force re-registration so factory captures fresh theme.
854
810
  this.widgetRegistered = false;
855
811
  this.tui = undefined;
856
- this.lastRenderKey = undefined;
857
812
  },
858
813
  };
859
814
  }, { placement: "aboveEditor" });
860
815
  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).
816
+ } else {
817
+ // Widget already registered — just request a re-render of existing components.
864
818
  this.tui?.requestRender();
865
819
  }
866
- this.lastRenderKey = renderKey;
867
820
  }
868
821
 
869
822
  dispose() {
823
+ this.inputCleanup?.();
824
+ this.inputCleanup = undefined;
825
+ this.navigationActive = false;
826
+ this.selectedAgentId = undefined;
827
+ this.viewportStart = 0;
828
+ this.selectedRosterIndex = 0;
870
829
  if (this.widgetInterval) {
871
830
  clearInterval(this.widgetInterval);
872
831
  this.widgetInterval = undefined;
@@ -875,16 +834,9 @@ export class AgentWidget {
875
834
  this.uiCtx.setWidget("agents", undefined);
876
835
  this.uiCtx.setStatus("subagents", undefined);
877
836
  }
878
- this.inputUnsub?.();
879
- this.inputUnsub = undefined;
880
837
  this.widgetRegistered = false;
881
838
  this.tui = undefined;
882
839
  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;
840
+ this.lastSnapshot = "";
889
841
  }
890
842
  }