@kolisachint/hoocode-agent 0.4.156 → 0.4.158

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 (75) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/core/agent-session-stats.d.ts +17 -1
  3. package/dist/core/agent-session-stats.d.ts.map +1 -1
  4. package/dist/core/agent-session-stats.js +20 -0
  5. package/dist/core/agent-session-stats.js.map +1 -1
  6. package/dist/core/context-files.d.ts +14 -4
  7. package/dist/core/context-files.d.ts.map +1 -1
  8. package/dist/core/context-files.js +7 -4
  9. package/dist/core/context-files.js.map +1 -1
  10. package/dist/core/format-tokens.d.ts +9 -0
  11. package/dist/core/format-tokens.d.ts.map +1 -0
  12. package/dist/core/format-tokens.js +19 -0
  13. package/dist/core/format-tokens.js.map +1 -0
  14. package/dist/core/mcp-status.d.ts +32 -0
  15. package/dist/core/mcp-status.d.ts.map +1 -0
  16. package/dist/core/mcp-status.js +27 -0
  17. package/dist/core/mcp-status.js.map +1 -0
  18. package/dist/core/resource-loader.d.ts +6 -16
  19. package/dist/core/resource-loader.d.ts.map +1 -1
  20. package/dist/core/resource-loader.js.map +1 -1
  21. package/dist/core/settings-defaults.d.ts +1 -0
  22. package/dist/core/settings-defaults.d.ts.map +1 -1
  23. package/dist/core/settings-defaults.js +2 -1
  24. package/dist/core/settings-defaults.js.map +1 -1
  25. package/dist/core/settings-manager.d.ts +2 -0
  26. package/dist/core/settings-manager.d.ts.map +1 -1
  27. package/dist/core/settings-manager.js +8 -0
  28. package/dist/core/settings-manager.js.map +1 -1
  29. package/dist/core/settings-types.d.ts +1 -0
  30. package/dist/core/settings-types.d.ts.map +1 -1
  31. package/dist/core/settings-types.js.map +1 -1
  32. package/dist/core/tools/todo.d.ts +24 -0
  33. package/dist/core/tools/todo.d.ts.map +1 -1
  34. package/dist/core/tools/todo.js +50 -9
  35. package/dist/core/tools/todo.js.map +1 -1
  36. package/dist/core/wordmark.d.ts +2 -2
  37. package/dist/core/wordmark.d.ts.map +1 -1
  38. package/dist/core/wordmark.js +4 -4
  39. package/dist/core/wordmark.js.map +1 -1
  40. package/dist/extensions/core/hoo-core.d.ts +3 -0
  41. package/dist/extensions/core/hoo-core.d.ts.map +1 -1
  42. package/dist/extensions/core/hoo-core.js +3 -0
  43. package/dist/extensions/core/hoo-core.js.map +1 -1
  44. package/dist/extensions/core/mcp-loader.d.ts.map +1 -1
  45. package/dist/extensions/core/mcp-loader.js +27 -3
  46. package/dist/extensions/core/mcp-loader.js.map +1 -1
  47. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  48. package/dist/modes/interactive/components/footer.js +7 -26
  49. package/dist/modes/interactive/components/footer.js.map +1 -1
  50. package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
  51. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  52. package/dist/modes/interactive/components/settings-selector.js +15 -2
  53. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  54. package/dist/modes/interactive/components/task-panel.d.ts +20 -12
  55. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  56. package/dist/modes/interactive/components/task-panel.js +171 -233
  57. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  58. package/dist/modes/interactive/interactive-mode.d.ts +56 -7
  59. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  60. package/dist/modes/interactive/interactive-mode.js +174 -20
  61. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  62. package/dist/modes/interactive/model-controller.d.ts +6 -0
  63. package/dist/modes/interactive/model-controller.d.ts.map +1 -1
  64. package/dist/modes/interactive/model-controller.js +33 -14
  65. package/dist/modes/interactive/model-controller.js.map +1 -1
  66. package/dist/modes/interactive/resource-display.d.ts +22 -0
  67. package/dist/modes/interactive/resource-display.d.ts.map +1 -1
  68. package/dist/modes/interactive/resource-display.js +73 -16
  69. package/dist/modes/interactive/resource-display.js.map +1 -1
  70. package/docs/settings.md +2 -1
  71. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  72. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  73. package/examples/extensions/sandbox/package.json +1 -1
  74. package/examples/extensions/with-deps/package.json +1 -1
  75. package/package.json +4 -4
@@ -1,8 +1,9 @@
1
1
  import { getKeybindings, matchesKey, truncateToWidth, visibleWidth } from "@kolisachint/hoocode-tui";
2
2
  import { formatDurationSecs } from "../../../core/format-duration.js";
3
+ import { formatTokens } from "../../../core/format-tokens.js";
3
4
  import { taskOwnerId, taskStore } from "../../../core/task-store.js";
4
5
  import { agentColorFor, theme } from "../theme/theme.js";
5
- import { appKeyLabel, matchesAppKey, rawKeyHint } from "./keybinding-hints.js";
6
+ import { appKeyLabel, formatKeyText, matchesAppKey, rawKeyHint } from "./keybinding-hints.js";
6
7
  const TASK_STATUS_ICON = {
7
8
  // Hollow = not started, matching the hollow-means-lighter logic of ◇ and the
8
9
  // selectors' ○/◉ convention; also keeps ● exclusive to the chat's tool
@@ -31,16 +32,19 @@ const SELECTED_GLYPH = "▶︎";
31
32
  * title (see formatTaskLine).
32
33
  */
33
34
  const MCP_SOURCE_GLYPH = "⧉";
34
- /** Braille spinner frames + cadence, matched to the TUI Loader so the active
35
- * row animates in step. Every tick re-renders the whole component tree, so the
36
- * cadence is a direct tax on long sessions 120ms stays smooth while cutting
37
- * a third of the render load the old 80ms cadence generated. */
38
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
- const SPINNER_INTERVAL_MS = 120;
35
+ /**
36
+ * Cadence of the delegated-row run clock. The pane used to animate a braille
37
+ * spinner at 120ms, duplicating the transcript Loader's own spinner (same
38
+ * frames, independent timer, permanently out of phase) and re-rendering the
39
+ * whole component tree eight times a second. The in-progress glyph is now the
40
+ * static ◐ the design's glyph table specifies, and the only motion left is this
41
+ * clock — the same 1s tick the bash block's `Elapsed Ns` uses. It runs only
42
+ * while a subagent/MCP row is live, because those are the only rows that carry
43
+ * a clock at all.
44
+ */
45
+ const RUN_CLOCK_INTERVAL_MS = 1000;
40
46
  /** A thin colored left rail groups the pane without a box, the way the design's `border-left` does. */
41
47
  const RAIL = "▎";
42
- /** Cells in the deterministic progress bar (matches the design's 14-cell track). */
43
- const PROGRESS_CELLS = 14;
44
48
  const VIEW_LABEL = { flat: "tasks", subagents: "subagents", teams: "teams" };
45
49
  /**
46
50
  * A top-level task the main agent owns directly: its own TodoWrite plan. Source
@@ -54,6 +58,15 @@ function isMainTask(task) {
54
58
  // root (not a child merged in from a subagent's subtree).
55
59
  return task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;
56
60
  }
61
+ /**
62
+ * Delegated work: a dispatched subagent run or an MCP call. These are the only
63
+ * rows that carry a run clock and the only rows that ever report token usage —
64
+ * both need an attribution boundary, and a TodoWrite plan item has none (see
65
+ * taskElapsedSecs, and note that todo.ts never writes `usage`).
66
+ */
67
+ function isDelegated(task) {
68
+ return task.source === "subagent" || task.source === "mcp";
69
+ }
57
70
  /**
58
71
  * Lenses that currently have content, split by ownership:
59
72
  * - flat ("tasks") → only when the main agent has its own TodoWrite plan.
@@ -96,10 +109,15 @@ const AGENT_STATE_COLOR = {
96
109
  };
97
110
  /** Two-cell indent under a group header, with a faint vertical guide. */
98
111
  const GROUP_INDENT_PLAIN = "│ ";
99
- const STATE_PRESENTATION = {
100
- working: { icon: "◐", label: "working", color: "warning" },
101
- reviewed: { icon: "✓", label: "reviewed", color: "success" },
102
- stopped: { icon: "✗", label: "stopped", color: "error" },
112
+ /**
113
+ * Rail color per panel state. State used to be encoded five times over — rail,
114
+ * stamp glyph, stamp word, progress-bar fill and done/total — so the stamp and
115
+ * the bar are gone and the rail is the single channel.
116
+ */
117
+ const PANEL_STATE_COLOR = {
118
+ working: "warning",
119
+ reviewed: "success",
120
+ stopped: "error",
103
121
  };
104
122
  function panelState(tasks) {
105
123
  if (tasks.some((t) => t.status === "failed"))
@@ -126,173 +144,85 @@ function taskStatusColor(status) {
126
144
  * span; a task still in progress is measured against the clock so its elapsed
127
145
  * time advances while it runs (updatedAt barely moves during a run, which used
128
146
  * to freeze the display at ~0s until completion).
147
+ *
148
+ * Only meaningful for delegated rows, and only they call it. A TodoWrite plan
149
+ * item shares its createdAt with every other item in the plan — the tool creates
150
+ * the whole list in one taskStore.batch() — so this would report the age of the
151
+ * PLAN, not of the task: item #3 would include all the time spent on #1 and #2,
152
+ * and the numbers would read as an ascending series of per-item durations that
153
+ * are in fact cumulative. A dispatched run's createdAt is its dispatch time, so
154
+ * its clock is real.
129
155
  */
130
156
  function taskElapsedSecs(task, now = Date.now()) {
131
157
  const end = task.status === "in_progress" ? now : task.updatedAt;
132
158
  return Math.max(0, (end - task.createdAt) / 1000);
133
159
  }
134
- /**
135
- * Wall-clock span of the whole visible batch: earliest creation → now (while
136
- * anything is still running) or the latest settle time. NOT the sum of
137
- * per-task spans — summing made the header clock tick at 2× with two
138
- * concurrent subagents and 3× with three, a nonsense "double time" display.
139
- * Per-run timers live on the individual rows.
140
- */
141
- function turnElapsedSecs(tasks, now = Date.now()) {
142
- if (tasks.length === 0)
143
- return 0;
144
- let earliest = Number.POSITIVE_INFINITY;
145
- let latestEnd = 0;
146
- let anyRunning = false;
147
- for (const task of tasks) {
148
- earliest = Math.min(earliest, task.createdAt);
149
- if (task.status === "in_progress")
150
- anyRunning = true;
151
- else
152
- latestEnd = Math.max(latestEnd, task.updatedAt);
153
- }
154
- const end = anyRunning ? now : latestEnd;
155
- return Math.max(0, (end - earliest) / 1000);
156
- }
157
160
  /** The agent-type key a roster entry colors by: "explore#2" → "explore". */
158
161
  function agentTypeOfName(name) {
159
162
  const idx = name.indexOf("#");
160
163
  return idx > 0 ? name.slice(0, idx) : name;
161
164
  }
162
- /** Sum the token + cost usage reported by the tasks shown this turn. */
163
- function sumTurnUsage(tasks) {
164
- let input = 0;
165
- let output = 0;
166
- let cost = 0;
167
- for (const task of tasks) {
168
- if (!task.usage)
169
- continue;
170
- input += task.usage.input;
171
- output += task.usage.output;
172
- cost += task.usage.cost;
173
- }
174
- if (input === 0 && output === 0 && cost === 0)
175
- return null;
176
- return { input, output, cost };
177
- }
178
165
  /**
179
- * Deterministic block-glyph progress bar: a heavy run (━) for the completed
180
- * fraction over a dim track. In-progress tasks count as half, so the bar moves
181
- * the moment work starts. Fraction is the only input no animation, no guess.
166
+ * Ledger header one tab per lens that has content, each carrying that lens's
167
+ * own done/total. The counts of the lenses you are NOT looking at stay visible,
168
+ * so a `subagents 0/2` tab reports unfinished delegated work while you read the
169
+ * plan.
170
+ *
171
+ * The selected tab fills with `selectedBg` only while its lens has live work,
172
+ * and drops to a flat bold-accent label once everything settles — the fill means
173
+ * "running", not merely "here". Padding cells are identical filled or not, so
174
+ * cycling never shifts the strip. `dim` muddies against `selectedBg` (#666666 on
175
+ * #3a3a4a), so a selected tab's count steps up to `muted`.
176
+ *
177
+ * The header IS the switcher: with a single lens there is nothing to switch to
178
+ * and the pane renders rows only (see render()). The navigable teams roster is
179
+ * the one exception and keeps its anchor.
182
180
  */
183
- function progressBar(done, active, total) {
184
- const ratio = total > 0 ? Math.max(0, Math.min(1, (done + active * 0.5) / total)) : 0;
185
- const filled = Math.round(ratio * PROGRESS_CELLS);
186
- const fill = "".repeat(filled);
187
- const track = "━".repeat(PROGRESS_CELLS - filled);
188
- return {
189
- plain: fill + track,
190
- styled: theme.fg("success", fill) + theme.fg("dim", track),
181
+ function formatLensTabs(tasks, agents, width, view, tabViews, showCycleHint) {
182
+ const tabs = tabViews.map((v) => {
183
+ const lensTasks = filterTasksForLens(tasks, agents, v);
184
+ const done = lensTasks.filter((t) => t.status === "done").length;
185
+ const label = VIEW_LABEL[v];
186
+ const count = `${done}/${lensTasks.length}`;
187
+ const plain = ` ${label} ${count} `;
188
+ if (v !== view)
189
+ return { plain, styled: theme.fg("dim", plain) };
190
+ const body = ` ${theme.bold(theme.fg("accent", label))} ${theme.fg("muted", count)} `;
191
+ const live = lensTasks.some((t) => t.status === "in_progress");
192
+ return { plain, styled: live ? theme.bg("selectedBg", body) : body };
193
+ });
194
+ const selected = tabs[tabViews.indexOf(view)] ?? tabs[0];
195
+ // Joined without a separator: each tab already carries a pad cell on both
196
+ // sides, so neighbours sit two cells apart and two fills never touch.
197
+ const strip = {
198
+ plain: tabs.map((t) => t.plain).join(""),
199
+ styled: tabs.map((t) => t.styled).join(""),
191
200
  };
201
+ let hintPlain = "";
202
+ let hintStyled = "";
203
+ if (showCycleHint) {
204
+ const key = appKeyLabel("app.tasks.cycleView");
205
+ hintPlain = `${formatKeyText(key)} cycle`;
206
+ hintStyled = rawKeyHint(key, "cycle");
207
+ }
208
+ // Narrowing order: drop the cycle hint, then the unselected tabs. Each tab
209
+ // closes its own background, so a variant is never cut mid-escape; only the
210
+ // final unstyled fallback truncates. This inverts the old priority, where the
211
+ // switcher was the FIRST thing dropped — the pane's only navigation affordance
212
+ // used to lose the line to a token counter.
213
+ if (hintPlain && visibleWidth(strip.plain) + 2 + visibleWidth(hintPlain) <= width) {
214
+ const pad = width - visibleWidth(strip.plain) - visibleWidth(hintPlain);
215
+ return strip.styled + " ".repeat(pad) + hintStyled;
216
+ }
217
+ for (const variant of [strip, selected]) {
218
+ if (variant && visibleWidth(variant.plain) <= width) {
219
+ return variant.styled + " ".repeat(width - visibleWidth(variant.plain));
220
+ }
221
+ }
222
+ return truncateToWidth(selected?.plain ?? "", width, "…");
192
223
  }
193
- /**
194
- * View switcher rendered at the right edge of the ledger header: the labels
195
- * of the lenses that have content joined by `·`, the active one in bold
196
- * accent. Hidden entirely when only one lens is available. Purely an
197
- * indicator in the TUI — the bound key cycles it (see app.tasks.cycleView).
198
- */
199
- function formatViewSwitcher(view, available) {
200
- if (available.length < 2)
201
- return { plain: "", styled: "" };
202
- const plain = available.map((v) => VIEW_LABEL[v]).join(" · ");
203
- const styled = available
204
- .map((v) => (v === view ? theme.bold(theme.fg("accent", VIEW_LABEL[v])) : theme.fg("dim", VIEW_LABEL[v])))
205
- .join(theme.fg("dim", " · "));
206
- return { plain, styled };
207
- }
208
- /**
209
- * Ledger header: a state stamp (◐ working / ✓ reviewed / ✗ stopped) + a
210
- * deterministic progress bar and done/total count on the left, and the per-turn
211
- * token + elapsed + cost delta (summed across the tasks below) plus the view
212
- * switcher on the right.
213
- */
214
- function formatHeader(tasks, width, state, totalSecs, view, available) {
215
- const total = tasks.length;
216
- const done = tasks.filter((t) => t.status === "done").length;
217
- const active = tasks.filter((t) => t.status === "in_progress").length;
218
- const { icon, label, color } = STATE_PRESENTATION[state];
219
- const stampPlain = `${icon} ${label.toUpperCase()}`;
220
- const stamp = `${theme.fg(color, icon)} ${theme.bold(theme.fg(color, label.toUpperCase()))}`;
221
- const bar = progressBar(done, active, total);
222
- const countPlain = `${done}/${total}`;
223
- const count = theme.fg("muted", `${done}`) + theme.fg("dim", "/") + theme.fg("muted", `${total}`);
224
- // Left cluster has a full form (stamp · bar · count) and a compact fallback
225
- // (stamp · count) that drops the bar when the terminal is too narrow.
226
- const leftFullPlain = `${stampPlain} ${bar.plain} ${countPlain}`;
227
- const leftFull = `${stamp} ${bar.styled} ${count}`;
228
- const leftMinPlain = `${stampPlain} ${countPlain}`;
229
- const leftMin = `${stamp} ${count}`;
230
- const turn = sumTurnUsage(tasks);
231
- let turnPlain = "";
232
- let turnText = "";
233
- if (turn) {
234
- const inTok = formatTokens(turn.input);
235
- const outTok = formatTokens(turn.output);
236
- const elapsed = formatDurationSecs(totalSecs);
237
- const showCost = turn.cost > 0;
238
- const costStr = showCost ? `$${turn.cost.toFixed(3)}` : "";
239
- turnPlain = `turn ↑${inTok} ↓${outTok} · ${elapsed}${showCost ? ` · ${costStr}` : ""}`;
240
- // Turn delta: muted framing, numbers one step brighter (bold), separators dim.
241
- turnText =
242
- theme.fg("muted", "turn ↑") +
243
- theme.bold(inTok) +
244
- theme.fg("muted", " ↓") +
245
- theme.bold(outTok) +
246
- theme.fg("dim", " · ") +
247
- theme.fg("muted", elapsed) +
248
- (showCost ? theme.fg("dim", " · ") + theme.bold(costStr) : "");
249
- }
250
- // Right cluster: turn delta, then the view switcher at the far edge. The
251
- // switcher is the first thing dropped when the terminal narrows; the turn
252
- // delta next; the stamp/count survive to the end. Either piece may be
253
- // absent (no usage reported / only one lens available).
254
- const switcher = formatViewSwitcher(view, available);
255
- const rightVariants = [];
256
- if (turnPlain && switcher.plain) {
257
- rightVariants.push({
258
- plain: `${turnPlain} ${switcher.plain}`,
259
- styled: `${turnText} ${switcher.styled}`,
260
- });
261
- }
262
- if (turnPlain)
263
- rightVariants.push({ plain: turnPlain, styled: turnText });
264
- else if (switcher.plain)
265
- rightVariants.push(switcher);
266
- for (const right of rightVariants) {
267
- if (visibleWidth(leftFullPlain) + 2 + visibleWidth(right.plain) <= width) {
268
- const pad = Math.max(2, width - visibleWidth(leftFullPlain) - visibleWidth(right.plain));
269
- return leftFull + " ".repeat(pad) + right.styled;
270
- }
271
- if (visibleWidth(leftMinPlain) + 2 + visibleWidth(right.plain) <= width) {
272
- const pad = Math.max(2, width - visibleWidth(leftMinPlain) - visibleWidth(right.plain));
273
- return leftMin + " ".repeat(pad) + right.styled;
274
- }
275
- }
276
- if (visibleWidth(leftFullPlain) <= width) {
277
- return leftFull + " ".repeat(width - visibleWidth(leftFullPlain));
278
- }
279
- return truncateToWidth(leftMin, width, "…");
280
- }
281
- function formatTokens(count) {
282
- if (count < 1000)
283
- return count.toString();
284
- if (count < 10000)
285
- return `${(count / 1000).toFixed(1)}k`;
286
- if (count < 1000000)
287
- return `${Math.round(count / 1000)}k`;
288
- return `${(count / 1000000).toFixed(1)}M`;
289
- }
290
- function formatTaskLine(task, width, frame, options = {}) {
291
- const isProgress = task.status === "in_progress";
292
- const iconGlyph = isProgress
293
- ? (SPINNER_FRAMES[frame] ?? TASK_STATUS_ICON.in_progress)
294
- : TASK_STATUS_ICON[task.status];
295
- const icon = theme.fg(taskStatusColor(task.status), iconGlyph);
224
+ function formatTaskLine(task, width, options = {}) {
225
+ const icon = theme.fg(taskStatusColor(task.status), TASK_STATUS_ICON[task.status]);
296
226
  // In grouped views the group header already carries the row's origin, so the
297
227
  // owner glyph and tag are suppressed; the rows sit on a faint indent guide.
298
228
  const grouped = options.grouped === true;
@@ -361,8 +291,12 @@ function formatTaskLine(task, width, frame, options = {}) {
361
291
  default:
362
292
  styledTitle = title;
363
293
  }
364
- // Right column: settled rows carry their token usage; the
365
- // active row reads `running…`, pending rows read `queued`.
294
+ // Right column: settled rows carry their token usage; an active row carries the
295
+ // owning agent's live tool activity (fed by the pool's task_progress events) so
296
+ // a delegated row reads "⋯ grep" rather than looking stuck. Only delegated rows
297
+ // add a run clock — see taskElapsedSecs for why a plan item has no honest one.
298
+ // Pending and cancelled rows say nothing: ○ and a struck ⊘ title already do,
299
+ // and "queued"/"cancelled" only repeated the glyph in words.
366
300
  let rightPlain = "";
367
301
  let rightStyled = "";
368
302
  if (task.status === "done" || task.status === "failed") {
@@ -377,28 +311,21 @@ function formatTaskLine(task, width, frame, options = {}) {
377
311
  rightStyled = theme.fg("muted", tokenText);
378
312
  }
379
313
  }
380
- else if (task.status === "cancelled") {
381
- rightPlain = "cancelled";
382
- rightStyled = theme.fg("dim", rightPlain);
383
- }
384
314
  else if (task.status === "in_progress") {
385
- // Surface the owning agent's live activity (the tool it's currently running,
386
- // fed by the pool's task_progress events) so a delegated row reads "⋯ grep"
387
- // rather than a static "running…" — the difference between looking busy and
388
- // looking stuck. Falls back to "running…" between tools (activity cleared) or
389
- // when no owner is resolved. Each running row carries its own live timer —
390
- // the per-run clock the header (a single wall-clock span) deliberately
391
- // doesn't show. Ticks smoothly because active rows re-render per spinner
392
- // frame anyway.
393
- const liveActivity = options.owner?.activity;
394
- const runFor = formatDurationSecs(taskElapsedSecs(task));
395
- rightPlain = `${liveActivity ? `⋯ ${liveActivity}` : "running…"} · ${runFor}`;
396
- rightStyled =
397
- theme.fg("warning", liveActivity ? `⋯ ${liveActivity}` : "running…") + theme.fg("dim", ` · ${runFor}`);
398
- }
399
- else if (task.status === "pending") {
400
- rightPlain = "queued";
401
- rightStyled = theme.fg("dim", rightPlain);
315
+ const activity = options.owner?.activity ? `⋯ ${options.owner.activity}` : "";
316
+ const runFor = isDelegated(task) ? formatDurationSecs(taskElapsedSecs(task)) : "";
317
+ if (activity && runFor) {
318
+ rightPlain = `${activity} · ${runFor}`;
319
+ rightStyled = theme.fg("warning", activity) + theme.fg("dim", ` · ${runFor}`);
320
+ }
321
+ else if (activity) {
322
+ rightPlain = activity;
323
+ rightStyled = theme.fg("warning", activity);
324
+ }
325
+ else if (runFor) {
326
+ rightPlain = runFor;
327
+ rightStyled = theme.fg("dim", runFor);
328
+ }
402
329
  }
403
330
  // A warning note (e.g. inherited-model fallback, exhaustion skip) takes over the
404
331
  // right column as a ⚠ cue, replacing the usage/status stamp for that row.
@@ -639,10 +566,12 @@ function formatGroupHeader(meta, items, width, selected = false) {
639
566
  *
640
567
  * - A state-colored left rail groups the pane (working=warning, reviewed=success,
641
568
  * stopped=error) without drawing a box.
642
- * - A ledger header tops the list: a state stamp + deterministic progress bar +
643
- * done/total count on the left, the per-turn token/elapsed/cost delta on the right.
569
+ * - A tab strip tops the list one tab per lens with content, each carrying its
570
+ * own done/total but only when there is more than one lens to switch to (or
571
+ * the navigable teams roster is showing). A single-lens pane is rows only.
644
572
  * - Shows all tasks with all statuses (pending / in_progress / done / failed).
645
- * The active row animates a braille spinner; pending rows read `queued`.
573
+ * The active row carries the static of the design's glyph table; only
574
+ * delegated rows animate, and only their 1s run clock.
646
575
  * - A single-cell owner glyph (◇ subagent / ▸ team role / ⧉ MCP) sits before
647
576
  * the id where it disambiguates; main-owned rows carry no glyph in the flat
648
577
  * lens (the rail already attributes the pane to the main agent). A text
@@ -675,16 +604,15 @@ function formatGroupHeader(meta, items, width, selected = false) {
675
604
  */
676
605
  export class TaskPanelComponent {
677
606
  ui;
678
- frame = 0;
679
- animationTimer = null;
607
+ runClockTimer = null;
680
608
  view = "flat";
681
609
  disposed = false;
682
- // Render memoization. The spinner re-renders the whole pane every 80ms, but
683
- // between store mutations only the in-progress rows actually change (spinner
684
- // frame + live activity). Settled/pending row strings and derived structures
685
- // (agentById, the subagents tree walk) are cached and invalidated by the
686
- // store's mutation version + pane width, so a frame tick reuses them instead
687
- // of rebuilding every string.
610
+ // Render memoization. Row strings and derived structures (agentById, the
611
+ // subagents tree walk) are cached and invalidated by the store's mutation
612
+ // version + pane width. Live tool activity arrives via patchAgent, which bumps
613
+ // that version, so an in-progress row is refreshed by the same mechanism as
614
+ // everything else only the delegated run clock moves between mutations and
615
+ // has to bypass the cache (see taskLine).
688
616
  memoVersion = -1;
689
617
  memoWidth = -1;
690
618
  lineMemo = new Map();
@@ -722,20 +650,22 @@ export class TaskPanelComponent {
722
650
  return this.flatRowsMemo;
723
651
  }
724
652
  /**
725
- * formatTaskLine with caching for rows that don't animate. In-progress rows
726
- * depend on the spinner frame and the owner's live activity, so they render
727
- * fresh every frame; everything else is stable until the next store mutation
728
- * or width change (handled by syncMemo).
653
+ * formatTaskLine with caching for rows that are stable between store mutations.
654
+ * Only a live delegated row is not: its run clock advances against Date.now()
655
+ * with no mutation to invalidate the entry, so it renders fresh on every tick.
656
+ * Everything else including a live main-plan row, whose activity text arrives
657
+ * through a store mutation — is cached until the next mutation or width change
658
+ * (handled by syncMemo).
729
659
  */
730
660
  taskLine(task, width, options = {}) {
731
- if (task.status === "in_progress") {
732
- return formatTaskLine(task, width, this.frame, options);
661
+ if (task.status === "in_progress" && isDelegated(task)) {
662
+ return formatTaskLine(task, width, options);
733
663
  }
734
664
  const key = `${task.id}:${options.grouped ? 1 : 0}:${options.treePrefix ?? ""}:${options.owner?.kind ?? ""}`;
735
665
  const hit = this.lineMemo.get(key);
736
666
  if (hit !== undefined)
737
667
  return hit;
738
- const line = formatTaskLine(task, width, this.frame, options);
668
+ const line = formatTaskLine(task, width, options);
739
669
  this.lineMemo.set(key, line);
740
670
  return line;
741
671
  }
@@ -816,34 +746,36 @@ export class TaskPanelComponent {
816
746
  this.ui?.requestRender();
817
747
  return this.view;
818
748
  }
819
- /** Run the spinner timer only while a task is active, ticking re-renders. */
820
- ensureAnimation(active) {
749
+ /**
750
+ * Tick the run clock only while a delegated row is live. Nothing else in the
751
+ * pane moves, so with no subagent or MCP call running there is no timer at all
752
+ * — a stale in-progress plan item can no longer pin the process to a render
753
+ * loop the way the old 120ms spinner did.
754
+ */
755
+ ensureRunClock(active) {
821
756
  if (this.disposed) {
822
- if (this.animationTimer) {
823
- clearInterval(this.animationTimer);
824
- this.animationTimer = null;
825
- this.frame = 0;
757
+ if (this.runClockTimer) {
758
+ clearInterval(this.runClockTimer);
759
+ this.runClockTimer = null;
826
760
  }
827
761
  return;
828
762
  }
829
- if (active && this.ui && !this.animationTimer) {
830
- this.animationTimer = setInterval(() => {
831
- this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
763
+ if (active && this.ui && !this.runClockTimer) {
764
+ this.runClockTimer = setInterval(() => {
832
765
  this.ui?.requestRender();
833
- }, SPINNER_INTERVAL_MS);
834
- this.animationTimer.unref?.();
766
+ }, RUN_CLOCK_INTERVAL_MS);
767
+ this.runClockTimer.unref?.();
835
768
  }
836
- else if (!active && this.animationTimer) {
837
- clearInterval(this.animationTimer);
838
- this.animationTimer = null;
839
- this.frame = 0;
769
+ else if (!active && this.runClockTimer) {
770
+ clearInterval(this.runClockTimer);
771
+ this.runClockTimer = null;
840
772
  }
841
773
  }
842
- /** Stop the spinner timer. Call on teardown. */
774
+ /** Stop the run clock. Call on teardown. */
843
775
  dispose() {
844
- if (this.animationTimer) {
845
- clearInterval(this.animationTimer);
846
- this.animationTimer = null;
776
+ if (this.runClockTimer) {
777
+ clearInterval(this.runClockTimer);
778
+ this.runClockTimer = null;
847
779
  }
848
780
  this.disposed = true;
849
781
  }
@@ -870,7 +802,7 @@ export class TaskPanelComponent {
870
802
  // upcoming work).
871
803
  const hasRoleRoster = view === "teams" && allAgents.some((a) => a.kind === "role");
872
804
  if (tasks.length === 0 && !hasRoleRoster) {
873
- this.ensureAnimation(false);
805
+ this.ensureRunClock(false);
874
806
  return [];
875
807
  }
876
808
  // Scope all visual state to the tasks visible in the current lens so the
@@ -882,14 +814,20 @@ export class TaskPanelComponent {
882
814
  : view === "flat"
883
815
  ? this.flatRows(tasks).map((row) => row.task)
884
816
  : filterTasksForLens(tasks, allAgents, view);
885
- const hasActive = lensTasks.some((t) => t.status === "in_progress");
886
- this.ensureAnimation(hasActive);
887
- const state = panelState(lensTasks);
888
- const totalSecs = turnElapsedSecs(lensTasks);
889
- const railColor = STATE_PRESENTATION[state].color;
817
+ this.ensureRunClock(lensTasks.some((t) => t.status === "in_progress" && isDelegated(t)));
818
+ const railColor = PANEL_STATE_COLOR[panelState(lensTasks)];
890
819
  const gutter = `${theme.fg(railColor, RAIL)} `;
891
820
  const inner = Math.max(0, width - visibleWidth(RAIL) - 1);
892
- const lines = [gutter + formatHeader(lensTasks, inner, state, totalSecs, view, available)];
821
+ // The header IS the switcher, so a single-lens pane renders rows only. The
822
+ // teams roster is the exception: it is navigable (app.team.focus), so it keeps
823
+ // an anchor even alone. The cycle hint is suppressed when there is nothing to
824
+ // cycle to, and in team focus, which locks the view to teams.
825
+ const lines = [];
826
+ if (available.length >= 2 || view === "teams") {
827
+ const tabViews = available.includes(view) ? available : [...available, view];
828
+ const showCycleHint = available.length >= 2 && !this.focused;
829
+ lines.push(gutter + formatLensTabs(tasks, allAgents, inner, view, tabViews, showCycleHint));
830
+ }
893
831
  if (view === "flat") {
894
832
  // The main agent's TodoWrite plan, with each dispatched run nested under
895
833
  // the plan item it executes (see flatLensRows). Resolve each row's owner