@kolisachint/hoocode-agent 0.4.157 → 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 (41) hide show
  1. package/CHANGELOG.md +60 -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/settings-defaults.d.ts +1 -0
  7. package/dist/core/settings-defaults.d.ts.map +1 -1
  8. package/dist/core/settings-defaults.js +2 -1
  9. package/dist/core/settings-defaults.js.map +1 -1
  10. package/dist/core/settings-manager.d.ts +2 -0
  11. package/dist/core/settings-manager.d.ts.map +1 -1
  12. package/dist/core/settings-manager.js +8 -0
  13. package/dist/core/settings-manager.js.map +1 -1
  14. package/dist/core/settings-types.d.ts +1 -0
  15. package/dist/core/settings-types.d.ts.map +1 -1
  16. package/dist/core/settings-types.js.map +1 -1
  17. package/dist/core/tools/todo.d.ts +24 -0
  18. package/dist/core/tools/todo.d.ts.map +1 -1
  19. package/dist/core/tools/todo.js +50 -9
  20. package/dist/core/tools/todo.js.map +1 -1
  21. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  22. package/dist/modes/interactive/components/footer.js +6 -15
  23. package/dist/modes/interactive/components/footer.js.map +1 -1
  24. package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
  25. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  26. package/dist/modes/interactive/components/settings-selector.js +15 -2
  27. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  28. package/dist/modes/interactive/components/task-panel.d.ts +20 -12
  29. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  30. package/dist/modes/interactive/components/task-panel.js +170 -224
  31. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  32. package/dist/modes/interactive/interactive-mode.d.ts +42 -7
  33. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  34. package/dist/modes/interactive/interactive-mode.js +124 -19
  35. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  36. package/docs/settings.md +2 -1
  37. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  38. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  39. package/examples/extensions/sandbox/package.json +1 -1
  40. package/examples/extensions/with-deps/package.json +1 -1
  41. package/package.json +4 -4
@@ -3,7 +3,7 @@ import { formatDurationSecs } from "../../../core/format-duration.js";
3
3
  import { formatTokens } from "../../../core/format-tokens.js";
4
4
  import { taskOwnerId, taskStore } from "../../../core/task-store.js";
5
5
  import { agentColorFor, theme } from "../theme/theme.js";
6
- import { appKeyLabel, matchesAppKey, rawKeyHint } from "./keybinding-hints.js";
6
+ import { appKeyLabel, formatKeyText, matchesAppKey, rawKeyHint } from "./keybinding-hints.js";
7
7
  const TASK_STATUS_ICON = {
8
8
  // Hollow = not started, matching the hollow-means-lighter logic of ◇ and the
9
9
  // selectors' ○/◉ convention; also keeps ● exclusive to the chat's tool
@@ -32,16 +32,19 @@ const SELECTED_GLYPH = "▶︎";
32
32
  * title (see formatTaskLine).
33
33
  */
34
34
  const MCP_SOURCE_GLYPH = "⧉";
35
- /** Braille spinner frames + cadence, matched to the TUI Loader so the active
36
- * row animates in step. Every tick re-renders the whole component tree, so the
37
- * cadence is a direct tax on long sessions 120ms stays smooth while cutting
38
- * a third of the render load the old 80ms cadence generated. */
39
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
40
- 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;
41
46
  /** A thin colored left rail groups the pane without a box, the way the design's `border-left` does. */
42
47
  const RAIL = "▎";
43
- /** Cells in the deterministic progress bar (matches the design's 14-cell track). */
44
- const PROGRESS_CELLS = 14;
45
48
  const VIEW_LABEL = { flat: "tasks", subagents: "subagents", teams: "teams" };
46
49
  /**
47
50
  * A top-level task the main agent owns directly: its own TodoWrite plan. Source
@@ -55,6 +58,15 @@ function isMainTask(task) {
55
58
  // root (not a child merged in from a subagent's subtree).
56
59
  return task.source === undefined && task.agent === undefined && task.parentTaskId === undefined;
57
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
+ }
58
70
  /**
59
71
  * Lenses that currently have content, split by ownership:
60
72
  * - flat ("tasks") → only when the main agent has its own TodoWrite plan.
@@ -97,10 +109,15 @@ const AGENT_STATE_COLOR = {
97
109
  };
98
110
  /** Two-cell indent under a group header, with a faint vertical guide. */
99
111
  const GROUP_INDENT_PLAIN = "│ ";
100
- const STATE_PRESENTATION = {
101
- working: { icon: "◐", label: "working", color: "warning" },
102
- reviewed: { icon: "✓", label: "reviewed", color: "success" },
103
- 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",
104
121
  };
105
122
  function panelState(tasks) {
106
123
  if (tasks.some((t) => t.status === "failed"))
@@ -127,164 +144,85 @@ function taskStatusColor(status) {
127
144
  * span; a task still in progress is measured against the clock so its elapsed
128
145
  * time advances while it runs (updatedAt barely moves during a run, which used
129
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.
130
155
  */
131
156
  function taskElapsedSecs(task, now = Date.now()) {
132
157
  const end = task.status === "in_progress" ? now : task.updatedAt;
133
158
  return Math.max(0, (end - task.createdAt) / 1000);
134
159
  }
135
- /**
136
- * Wall-clock span of the whole visible batch: earliest creation → now (while
137
- * anything is still running) or the latest settle time. NOT the sum of
138
- * per-task spans — summing made the header clock tick at 2× with two
139
- * concurrent subagents and 3× with three, a nonsense "double time" display.
140
- * Per-run timers live on the individual rows.
141
- */
142
- function turnElapsedSecs(tasks, now = Date.now()) {
143
- if (tasks.length === 0)
144
- return 0;
145
- let earliest = Number.POSITIVE_INFINITY;
146
- let latestEnd = 0;
147
- let anyRunning = false;
148
- for (const task of tasks) {
149
- earliest = Math.min(earliest, task.createdAt);
150
- if (task.status === "in_progress")
151
- anyRunning = true;
152
- else
153
- latestEnd = Math.max(latestEnd, task.updatedAt);
154
- }
155
- const end = anyRunning ? now : latestEnd;
156
- return Math.max(0, (end - earliest) / 1000);
157
- }
158
160
  /** The agent-type key a roster entry colors by: "explore#2" → "explore". */
159
161
  function agentTypeOfName(name) {
160
162
  const idx = name.indexOf("#");
161
163
  return idx > 0 ? name.slice(0, idx) : name;
162
164
  }
163
- /** Sum the token + cost usage reported by the tasks shown this turn. */
164
- function sumTurnUsage(tasks) {
165
- let input = 0;
166
- let output = 0;
167
- let cost = 0;
168
- for (const task of tasks) {
169
- if (!task.usage)
170
- continue;
171
- input += task.usage.input;
172
- output += task.usage.output;
173
- cost += task.usage.cost;
174
- }
175
- if (input === 0 && output === 0 && cost === 0)
176
- return null;
177
- return { input, output, cost };
178
- }
179
165
  /**
180
- * Deterministic block-glyph progress bar: a heavy run (━) for the completed
181
- * fraction over a dim track. In-progress tasks count as half, so the bar moves
182
- * 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.
183
180
  */
184
- function progressBar(done, active, total) {
185
- const ratio = total > 0 ? Math.max(0, Math.min(1, (done + active * 0.5) / total)) : 0;
186
- const filled = Math.round(ratio * PROGRESS_CELLS);
187
- const fill = "".repeat(filled);
188
- const track = "━".repeat(PROGRESS_CELLS - filled);
189
- return {
190
- plain: fill + track,
191
- 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(""),
192
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, "…");
193
223
  }
194
- /**
195
- * View switcher rendered at the right edge of the ledger header: the labels
196
- * of the lenses that have content joined by `·`, the active one in bold
197
- * accent. Hidden entirely when only one lens is available. Purely an
198
- * indicator in the TUI — the bound key cycles it (see app.tasks.cycleView).
199
- */
200
- function formatViewSwitcher(view, available) {
201
- if (available.length < 2)
202
- return { plain: "", styled: "" };
203
- const plain = available.map((v) => VIEW_LABEL[v]).join(" · ");
204
- const styled = available
205
- .map((v) => (v === view ? theme.bold(theme.fg("accent", VIEW_LABEL[v])) : theme.fg("dim", VIEW_LABEL[v])))
206
- .join(theme.fg("dim", " · "));
207
- return { plain, styled };
208
- }
209
- /**
210
- * Ledger header: a state stamp (◐ working / ✓ reviewed / ✗ stopped) + a
211
- * deterministic progress bar and done/total count on the left, and the per-turn
212
- * token + elapsed + cost delta (summed across the tasks below) plus the view
213
- * switcher on the right.
214
- */
215
- function formatHeader(tasks, width, state, totalSecs, view, available) {
216
- const total = tasks.length;
217
- const done = tasks.filter((t) => t.status === "done").length;
218
- const active = tasks.filter((t) => t.status === "in_progress").length;
219
- const { icon, label, color } = STATE_PRESENTATION[state];
220
- const stampPlain = `${icon} ${label.toUpperCase()}`;
221
- const stamp = `${theme.fg(color, icon)} ${theme.bold(theme.fg(color, label.toUpperCase()))}`;
222
- const bar = progressBar(done, active, total);
223
- const countPlain = `${done}/${total}`;
224
- const count = theme.fg("muted", `${done}`) + theme.fg("dim", "/") + theme.fg("muted", `${total}`);
225
- // Left cluster has a full form (stamp · bar · count) and a compact fallback
226
- // (stamp · count) that drops the bar when the terminal is too narrow.
227
- const leftFullPlain = `${stampPlain} ${bar.plain} ${countPlain}`;
228
- const leftFull = `${stamp} ${bar.styled} ${count}`;
229
- const leftMinPlain = `${stampPlain} ${countPlain}`;
230
- const leftMin = `${stamp} ${count}`;
231
- const turn = sumTurnUsage(tasks);
232
- let turnPlain = "";
233
- let turnText = "";
234
- if (turn) {
235
- const inTok = formatTokens(turn.input);
236
- const outTok = formatTokens(turn.output);
237
- const elapsed = formatDurationSecs(totalSecs);
238
- const showCost = turn.cost > 0;
239
- const costStr = showCost ? `$${turn.cost.toFixed(3)}` : "";
240
- turnPlain = `turn ↑${inTok} ↓${outTok} · ${elapsed}${showCost ? ` · ${costStr}` : ""}`;
241
- // Turn delta: muted framing, numbers one step brighter (bold), separators dim.
242
- turnText =
243
- theme.fg("muted", "turn ↑") +
244
- theme.bold(inTok) +
245
- theme.fg("muted", " ↓") +
246
- theme.bold(outTok) +
247
- theme.fg("dim", " · ") +
248
- theme.fg("muted", elapsed) +
249
- (showCost ? theme.fg("dim", " · ") + theme.bold(costStr) : "");
250
- }
251
- // Right cluster: turn delta, then the view switcher at the far edge. The
252
- // switcher is the first thing dropped when the terminal narrows; the turn
253
- // delta next; the stamp/count survive to the end. Either piece may be
254
- // absent (no usage reported / only one lens available).
255
- const switcher = formatViewSwitcher(view, available);
256
- const rightVariants = [];
257
- if (turnPlain && switcher.plain) {
258
- rightVariants.push({
259
- plain: `${turnPlain} ${switcher.plain}`,
260
- styled: `${turnText} ${switcher.styled}`,
261
- });
262
- }
263
- if (turnPlain)
264
- rightVariants.push({ plain: turnPlain, styled: turnText });
265
- else if (switcher.plain)
266
- rightVariants.push(switcher);
267
- for (const right of rightVariants) {
268
- if (visibleWidth(leftFullPlain) + 2 + visibleWidth(right.plain) <= width) {
269
- const pad = Math.max(2, width - visibleWidth(leftFullPlain) - visibleWidth(right.plain));
270
- return leftFull + " ".repeat(pad) + right.styled;
271
- }
272
- if (visibleWidth(leftMinPlain) + 2 + visibleWidth(right.plain) <= width) {
273
- const pad = Math.max(2, width - visibleWidth(leftMinPlain) - visibleWidth(right.plain));
274
- return leftMin + " ".repeat(pad) + right.styled;
275
- }
276
- }
277
- if (visibleWidth(leftFullPlain) <= width) {
278
- return leftFull + " ".repeat(width - visibleWidth(leftFullPlain));
279
- }
280
- return truncateToWidth(leftMin, width, "…");
281
- }
282
- function formatTaskLine(task, width, frame, options = {}) {
283
- const isProgress = task.status === "in_progress";
284
- const iconGlyph = isProgress
285
- ? (SPINNER_FRAMES[frame] ?? TASK_STATUS_ICON.in_progress)
286
- : TASK_STATUS_ICON[task.status];
287
- 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]);
288
226
  // In grouped views the group header already carries the row's origin, so the
289
227
  // owner glyph and tag are suppressed; the rows sit on a faint indent guide.
290
228
  const grouped = options.grouped === true;
@@ -353,8 +291,12 @@ function formatTaskLine(task, width, frame, options = {}) {
353
291
  default:
354
292
  styledTitle = title;
355
293
  }
356
- // Right column: settled rows carry their token usage; the
357
- // 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.
358
300
  let rightPlain = "";
359
301
  let rightStyled = "";
360
302
  if (task.status === "done" || task.status === "failed") {
@@ -369,28 +311,21 @@ function formatTaskLine(task, width, frame, options = {}) {
369
311
  rightStyled = theme.fg("muted", tokenText);
370
312
  }
371
313
  }
372
- else if (task.status === "cancelled") {
373
- rightPlain = "cancelled";
374
- rightStyled = theme.fg("dim", rightPlain);
375
- }
376
314
  else if (task.status === "in_progress") {
377
- // Surface the owning agent's live activity (the tool it's currently running,
378
- // fed by the pool's task_progress events) so a delegated row reads "⋯ grep"
379
- // rather than a static "running…" — the difference between looking busy and
380
- // looking stuck. Falls back to "running…" between tools (activity cleared) or
381
- // when no owner is resolved. Each running row carries its own live timer —
382
- // the per-run clock the header (a single wall-clock span) deliberately
383
- // doesn't show. Ticks smoothly because active rows re-render per spinner
384
- // frame anyway.
385
- const liveActivity = options.owner?.activity;
386
- const runFor = formatDurationSecs(taskElapsedSecs(task));
387
- rightPlain = `${liveActivity ? `⋯ ${liveActivity}` : "running…"} · ${runFor}`;
388
- rightStyled =
389
- theme.fg("warning", liveActivity ? `⋯ ${liveActivity}` : "running…") + theme.fg("dim", ` · ${runFor}`);
390
- }
391
- else if (task.status === "pending") {
392
- rightPlain = "queued";
393
- 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
+ }
394
329
  }
395
330
  // A warning note (e.g. inherited-model fallback, exhaustion skip) takes over the
396
331
  // right column as a ⚠ cue, replacing the usage/status stamp for that row.
@@ -631,10 +566,12 @@ function formatGroupHeader(meta, items, width, selected = false) {
631
566
  *
632
567
  * - A state-colored left rail groups the pane (working=warning, reviewed=success,
633
568
  * stopped=error) without drawing a box.
634
- * - A ledger header tops the list: a state stamp + deterministic progress bar +
635
- * 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.
636
572
  * - Shows all tasks with all statuses (pending / in_progress / done / failed).
637
- * 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.
638
575
  * - A single-cell owner glyph (◇ subagent / ▸ team role / ⧉ MCP) sits before
639
576
  * the id where it disambiguates; main-owned rows carry no glyph in the flat
640
577
  * lens (the rail already attributes the pane to the main agent). A text
@@ -667,16 +604,15 @@ function formatGroupHeader(meta, items, width, selected = false) {
667
604
  */
668
605
  export class TaskPanelComponent {
669
606
  ui;
670
- frame = 0;
671
- animationTimer = null;
607
+ runClockTimer = null;
672
608
  view = "flat";
673
609
  disposed = false;
674
- // Render memoization. The spinner re-renders the whole pane every 80ms, but
675
- // between store mutations only the in-progress rows actually change (spinner
676
- // frame + live activity). Settled/pending row strings and derived structures
677
- // (agentById, the subagents tree walk) are cached and invalidated by the
678
- // store's mutation version + pane width, so a frame tick reuses them instead
679
- // 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).
680
616
  memoVersion = -1;
681
617
  memoWidth = -1;
682
618
  lineMemo = new Map();
@@ -714,20 +650,22 @@ export class TaskPanelComponent {
714
650
  return this.flatRowsMemo;
715
651
  }
716
652
  /**
717
- * formatTaskLine with caching for rows that don't animate. In-progress rows
718
- * depend on the spinner frame and the owner's live activity, so they render
719
- * fresh every frame; everything else is stable until the next store mutation
720
- * 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).
721
659
  */
722
660
  taskLine(task, width, options = {}) {
723
- if (task.status === "in_progress") {
724
- return formatTaskLine(task, width, this.frame, options);
661
+ if (task.status === "in_progress" && isDelegated(task)) {
662
+ return formatTaskLine(task, width, options);
725
663
  }
726
664
  const key = `${task.id}:${options.grouped ? 1 : 0}:${options.treePrefix ?? ""}:${options.owner?.kind ?? ""}`;
727
665
  const hit = this.lineMemo.get(key);
728
666
  if (hit !== undefined)
729
667
  return hit;
730
- const line = formatTaskLine(task, width, this.frame, options);
668
+ const line = formatTaskLine(task, width, options);
731
669
  this.lineMemo.set(key, line);
732
670
  return line;
733
671
  }
@@ -808,34 +746,36 @@ export class TaskPanelComponent {
808
746
  this.ui?.requestRender();
809
747
  return this.view;
810
748
  }
811
- /** Run the spinner timer only while a task is active, ticking re-renders. */
812
- 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) {
813
756
  if (this.disposed) {
814
- if (this.animationTimer) {
815
- clearInterval(this.animationTimer);
816
- this.animationTimer = null;
817
- this.frame = 0;
757
+ if (this.runClockTimer) {
758
+ clearInterval(this.runClockTimer);
759
+ this.runClockTimer = null;
818
760
  }
819
761
  return;
820
762
  }
821
- if (active && this.ui && !this.animationTimer) {
822
- this.animationTimer = setInterval(() => {
823
- this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
763
+ if (active && this.ui && !this.runClockTimer) {
764
+ this.runClockTimer = setInterval(() => {
824
765
  this.ui?.requestRender();
825
- }, SPINNER_INTERVAL_MS);
826
- this.animationTimer.unref?.();
766
+ }, RUN_CLOCK_INTERVAL_MS);
767
+ this.runClockTimer.unref?.();
827
768
  }
828
- else if (!active && this.animationTimer) {
829
- clearInterval(this.animationTimer);
830
- this.animationTimer = null;
831
- this.frame = 0;
769
+ else if (!active && this.runClockTimer) {
770
+ clearInterval(this.runClockTimer);
771
+ this.runClockTimer = null;
832
772
  }
833
773
  }
834
- /** Stop the spinner timer. Call on teardown. */
774
+ /** Stop the run clock. Call on teardown. */
835
775
  dispose() {
836
- if (this.animationTimer) {
837
- clearInterval(this.animationTimer);
838
- this.animationTimer = null;
776
+ if (this.runClockTimer) {
777
+ clearInterval(this.runClockTimer);
778
+ this.runClockTimer = null;
839
779
  }
840
780
  this.disposed = true;
841
781
  }
@@ -862,7 +802,7 @@ export class TaskPanelComponent {
862
802
  // upcoming work).
863
803
  const hasRoleRoster = view === "teams" && allAgents.some((a) => a.kind === "role");
864
804
  if (tasks.length === 0 && !hasRoleRoster) {
865
- this.ensureAnimation(false);
805
+ this.ensureRunClock(false);
866
806
  return [];
867
807
  }
868
808
  // Scope all visual state to the tasks visible in the current lens so the
@@ -874,14 +814,20 @@ export class TaskPanelComponent {
874
814
  : view === "flat"
875
815
  ? this.flatRows(tasks).map((row) => row.task)
876
816
  : filterTasksForLens(tasks, allAgents, view);
877
- const hasActive = lensTasks.some((t) => t.status === "in_progress");
878
- this.ensureAnimation(hasActive);
879
- const state = panelState(lensTasks);
880
- const totalSecs = turnElapsedSecs(lensTasks);
881
- 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)];
882
819
  const gutter = `${theme.fg(railColor, RAIL)} `;
883
820
  const inner = Math.max(0, width - visibleWidth(RAIL) - 1);
884
- 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
+ }
885
831
  if (view === "flat") {
886
832
  // The main agent's TodoWrite plan, with each dispatched run nested under
887
833
  // the plan item it executes (see flatLensRows). Resolve each row's owner