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