@kolisachint/hoocode-agent 0.4.110 → 0.4.111

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 (79) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/core/format-duration.d.ts +7 -0
  3. package/dist/core/format-duration.d.ts.map +1 -0
  4. package/dist/core/format-duration.js +16 -0
  5. package/dist/core/format-duration.js.map +1 -0
  6. package/dist/core/keybindings.d.ts +10 -0
  7. package/dist/core/keybindings.d.ts.map +1 -1
  8. package/dist/core/keybindings.js +10 -0
  9. package/dist/core/keybindings.js.map +1 -1
  10. package/dist/core/lifeguard.d.ts +15 -0
  11. package/dist/core/lifeguard.d.ts.map +1 -1
  12. package/dist/core/lifeguard.js +48 -18
  13. package/dist/core/lifeguard.js.map +1 -1
  14. package/dist/core/subagent-inbox.d.ts +1 -1
  15. package/dist/core/subagent-inbox.d.ts.map +1 -1
  16. package/dist/core/subagent-inbox.js +2 -0
  17. package/dist/core/subagent-inbox.js.map +1 -1
  18. package/dist/core/subagent-pool-instance.d.ts.map +1 -1
  19. package/dist/core/subagent-pool-instance.js +18 -11
  20. package/dist/core/subagent-pool-instance.js.map +1 -1
  21. package/dist/core/subagent-pool.d.ts +25 -3
  22. package/dist/core/subagent-pool.d.ts.map +1 -1
  23. package/dist/core/subagent-pool.js +120 -25
  24. package/dist/core/subagent-pool.js.map +1 -1
  25. package/dist/core/subagent-result.d.ts.map +1 -1
  26. package/dist/core/subagent-result.js +6 -4
  27. package/dist/core/subagent-result.js.map +1 -1
  28. package/dist/core/task-store.d.ts +45 -3
  29. package/dist/core/task-store.d.ts.map +1 -1
  30. package/dist/core/task-store.js +51 -5
  31. package/dist/core/task-store.js.map +1 -1
  32. package/dist/core/team-view.d.ts.map +1 -1
  33. package/dist/core/team-view.js +2 -0
  34. package/dist/core/team-view.js.map +1 -1
  35. package/dist/core/token-budget.d.ts +13 -1
  36. package/dist/core/token-budget.d.ts.map +1 -1
  37. package/dist/core/token-budget.js +18 -1
  38. package/dist/core/token-budget.js.map +1 -1
  39. package/dist/core/tools/subagent.d.ts +1 -1
  40. package/dist/core/tools/subagent.d.ts.map +1 -1
  41. package/dist/core/tools/subagent.js +184 -53
  42. package/dist/core/tools/subagent.js.map +1 -1
  43. package/dist/core/tools/todo.d.ts.map +1 -1
  44. package/dist/core/tools/todo.js +39 -7
  45. package/dist/core/tools/todo.js.map +1 -1
  46. package/dist/modes/interactive/components/keybinding-hints.d.ts +10 -0
  47. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  48. package/dist/modes/interactive/components/keybinding-hints.js +27 -1
  49. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  50. package/dist/modes/interactive/components/task-panel.d.ts +22 -1
  51. package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
  52. package/dist/modes/interactive/components/task-panel.js +302 -80
  53. package/dist/modes/interactive/components/task-panel.js.map +1 -1
  54. package/dist/modes/interactive/components/team-attach-panel.d.ts.map +1 -1
  55. package/dist/modes/interactive/components/team-attach-panel.js +15 -5
  56. package/dist/modes/interactive/components/team-attach-panel.js.map +1 -1
  57. package/dist/modes/interactive/theme/dark.json +8 -1
  58. package/dist/modes/interactive/theme/light.json +8 -1
  59. package/dist/modes/interactive/theme/theme-schema.json +28 -1
  60. package/dist/modes/interactive/theme/theme.d.ts +12 -1
  61. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  62. package/dist/modes/interactive/theme/theme.js +52 -0
  63. package/dist/modes/interactive/theme/theme.js.map +1 -1
  64. package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
  65. package/dist/modes/interactive/voice/voice-panel.js +5 -2
  66. package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
  67. package/dist/modes/rpc/jsonl.d.ts +16 -1
  68. package/dist/modes/rpc/jsonl.d.ts.map +1 -1
  69. package/dist/modes/rpc/jsonl.js +53 -8
  70. package/dist/modes/rpc/jsonl.js.map +1 -1
  71. package/dist/utils/atomic-file.d.ts +13 -0
  72. package/dist/utils/atomic-file.d.ts.map +1 -0
  73. package/dist/utils/atomic-file.js +26 -0
  74. package/dist/utils/atomic-file.js.map +1 -0
  75. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  76. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  77. package/examples/extensions/sandbox/package.json +1 -1
  78. package/examples/extensions/with-deps/package.json +1 -1
  79. package/package.json +4 -4
@@ -1,12 +1,28 @@
1
1
  import { getKeybindings, matchesKey, truncateToWidth, visibleWidth } from "@kolisachint/hoocode-tui";
2
+ import { formatDurationSecs } from "../../../core/format-duration.js";
2
3
  import { taskOwnerId, taskStore } from "../../../core/task-store.js";
3
- import { theme } from "../theme/theme.js";
4
+ import { agentColorFor, theme } from "../theme/theme.js";
5
+ import { appKeyLabel, matchesAppKey, rawKeyHint } from "./keybinding-hints.js";
4
6
  const TASK_STATUS_ICON = {
5
- pending: "●",
7
+ // Hollow = not started, matching the hollow-means-lighter logic of ◇ and the
8
+ // selectors' ○/◉ convention; also keeps ● exclusive to the chat's tool
9
+ // status dot so the two never read as the same signal.
10
+ pending: "○",
6
11
  in_progress: "◐",
7
12
  done: "✓",
8
13
  failed: "✗",
14
+ cancelled: "⊘",
9
15
  };
16
+ /**
17
+ * U+26A0 with VS15 (text presentation). Bare ⚠ carries the Unicode Emoji
18
+ * property, and terminals with emoji font fallback render it as a double-width
19
+ * color glyph while the width math counts 1 cell — misaligning exactly the
20
+ * rows that carry a warning. VS15 forces single-cell text rendering and is
21
+ * itself zero-width, so visibleWidth stays correct.
22
+ */
23
+ const WARNING_GLYPH = "⚠︎";
24
+ /** U+25B6 with VS15 — same emoji-presentation hazard as WARNING_GLYPH. */
25
+ const SELECTED_GLYPH = "▶︎";
10
26
  /**
11
27
  * Single-cell marker for MCP-sourced rows, which have no owning agent. Every
12
28
  * other row derives its marker from the owner's kind via AGENT_GLYPH (◆ main /
@@ -72,6 +88,8 @@ const AGENT_STATE_COLOR = {
72
88
  idle: "dim",
73
89
  waiting: "mdLink",
74
90
  failed: "error",
91
+ // User-initiated stop: settled and intentional, so muted rather than red.
92
+ cancelled: "dim",
75
93
  };
76
94
  /** Two-cell indent under a group header, with a faint vertical guide. */
77
95
  const GROUP_INDENT_PLAIN = "│ ";
@@ -95,23 +113,48 @@ function taskStatusColor(status) {
95
113
  case "failed":
96
114
  return "error";
97
115
  default:
116
+ // pending and cancelled: quiet gray — a user-initiated cancel is not an
117
+ // error and must not paint the row (or the rail) red.
98
118
  return "dim";
99
119
  }
100
120
  }
101
- /** Format a duration in seconds into a compact, terminal-friendly string. */
102
- function formatDuration(secs) {
103
- const s = Math.max(0, secs);
104
- if (s < 10)
105
- return `${s.toFixed(1)}s`;
106
- if (s < 60)
107
- return `${Math.round(s)}s`;
108
- const mins = Math.floor(s / 60);
109
- const rem = Math.round(s % 60);
110
- return `${mins}m${rem.toString().padStart(2, "0")}s`;
121
+ /**
122
+ * Wall-clock time a task occupied. A settled task reads its create→final-update
123
+ * span; a task still in progress is measured against the clock so its elapsed
124
+ * time advances while it runs (updatedAt barely moves during a run, which used
125
+ * to freeze the display at ~0s until completion).
126
+ */
127
+ function taskElapsedSecs(task, now = Date.now()) {
128
+ const end = task.status === "in_progress" ? now : task.updatedAt;
129
+ return Math.max(0, (end - task.createdAt) / 1000);
111
130
  }
112
- /** Wall-clock time a task occupied, derived from its create/update stamps. */
113
- function taskElapsedSecs(task) {
114
- return Math.max(0, (task.updatedAt - task.createdAt) / 1000);
131
+ /**
132
+ * Wall-clock span of the whole visible batch: earliest creation → now (while
133
+ * anything is still running) or the latest settle time. NOT the sum of
134
+ * per-task spans — summing made the header clock tick at 2× with two
135
+ * concurrent subagents and 3× with three, a nonsense "double time" display.
136
+ * Per-run timers live on the individual rows.
137
+ */
138
+ function turnElapsedSecs(tasks, now = Date.now()) {
139
+ if (tasks.length === 0)
140
+ return 0;
141
+ let earliest = Number.POSITIVE_INFINITY;
142
+ let latestEnd = 0;
143
+ let anyRunning = false;
144
+ for (const task of tasks) {
145
+ earliest = Math.min(earliest, task.createdAt);
146
+ if (task.status === "in_progress")
147
+ anyRunning = true;
148
+ else
149
+ latestEnd = Math.max(latestEnd, task.updatedAt);
150
+ }
151
+ const end = anyRunning ? now : latestEnd;
152
+ return Math.max(0, (end - earliest) / 1000);
153
+ }
154
+ /** The agent-type key a roster entry colors by: "explore#2" → "explore". */
155
+ function agentTypeOfName(name) {
156
+ const idx = name.indexOf("#");
157
+ return idx > 0 ? name.slice(0, idx) : name;
115
158
  }
116
159
  /** Sum the token + cost usage reported by the tasks shown this turn. */
117
160
  function sumTurnUsage(tasks) {
@@ -187,7 +230,7 @@ function formatHeader(tasks, width, state, totalSecs, view, available) {
187
230
  if (turn) {
188
231
  const inTok = formatTokens(turn.input);
189
232
  const outTok = formatTokens(turn.output);
190
- const elapsed = formatDuration(totalSecs);
233
+ const elapsed = formatDurationSecs(totalSecs);
191
234
  const showCost = turn.cost > 0;
192
235
  const costStr = showCost ? `$${turn.cost.toFixed(3)}` : "";
193
236
  turnPlain = `turn ↑${inTok} ↓${outTok} · ${elapsed}${showCost ? ` · ${costStr}` : ""}`;
@@ -259,7 +302,15 @@ function formatTaskLine(task, width, frame, options = {}) {
259
302
  const isMcp = task.source === "mcp";
260
303
  const ownerKind = options.owner?.kind ?? (task.source === "subagent" ? "subagent" : "main");
261
304
  const sourceGlyph = isMcp ? MCP_SOURCE_GLYPH : AGENT_GLYPH[ownerKind];
262
- const styledSource = grouped ? "" : theme.fg("dim", sourceGlyph);
305
+ // Subagent rows carry their agent's identity color on the glyph (and tag
306
+ // below) — the same hue as the chat's `Agent [type]` line and TaskOutput —
307
+ // so a user can trace one agent across the whole TUI at a glance.
308
+ const agentTypeName = !isMcp && ownerKind === "subagent" ? (task.subagentMode ?? task.agent) : undefined;
309
+ const styledSource = grouped
310
+ ? ""
311
+ : agentTypeName
312
+ ? theme.fg(agentColorFor(agentTypeName), sourceGlyph)
313
+ : theme.fg("dim", sourceGlyph);
263
314
  // Origin tag prefixed to the title, naming who runs the row: the subagent
264
315
  // type ("[explore]"), the team role's name ("[planner]"), or the MCP server
265
316
  // ("[github]"; "[MCP]" when no server label was recorded). Drawn in accent,
@@ -267,15 +318,20 @@ function formatTaskLine(task, width, frame, options = {}) {
267
318
  // rows drop it — the group header carries the origin — except MCP rows,
268
319
  // which group under main without being main's own work.
269
320
  let tag = "";
321
+ let tagColor = "accent";
270
322
  if (isMcp)
271
323
  tag = `[${task.subagentMode ?? "MCP"}]`;
272
324
  else if (!grouped) {
273
- if (task.subagentMode)
325
+ if (task.subagentMode) {
274
326
  tag = `[${task.subagentMode}]`;
275
- else if (ownerKind === "role" && options.owner)
327
+ tagColor = agentColorFor(task.subagentMode);
328
+ }
329
+ else if (ownerKind === "role" && options.owner) {
276
330
  tag = `[${options.owner.name}]`;
331
+ tagColor = agentColorFor(options.owner.name);
332
+ }
277
333
  }
278
- const styledTag = tag ? `${theme.fg("accent", tag)} ` : "";
334
+ const styledTag = tag ? `${theme.fg(tagColor, tag)} ` : "";
279
335
  const title = task.title;
280
336
  // The title carries the line. Done titles fade to muted
281
337
  // (settled work), pending dim (not started), active goes bold, failed turns red.
@@ -290,6 +346,9 @@ function formatTaskLine(task, width, frame, options = {}) {
290
346
  case "failed":
291
347
  styledTitle = theme.fg("error", title);
292
348
  break;
349
+ case "cancelled":
350
+ styledTitle = theme.fg("dim", theme.strikethrough(title));
351
+ break;
293
352
  case "in_progress":
294
353
  styledTitle = theme.bold(title);
295
354
  break;
@@ -312,15 +371,24 @@ function formatTaskLine(task, width, frame, options = {}) {
312
371
  rightStyled = theme.fg("muted", tokenText);
313
372
  }
314
373
  }
374
+ else if (task.status === "cancelled") {
375
+ rightPlain = "cancelled";
376
+ rightStyled = theme.fg("dim", rightPlain);
377
+ }
315
378
  else if (task.status === "in_progress") {
316
379
  // Surface the owning agent's live activity (the tool it's currently running,
317
380
  // fed by the pool's task_progress events) so a delegated row reads "⋯ grep"
318
381
  // rather than a static "running…" — the difference between looking busy and
319
382
  // looking stuck. Falls back to "running…" between tools (activity cleared) or
320
- // when no owner is resolved.
383
+ // when no owner is resolved. Each running row carries its own live timer —
384
+ // the per-run clock the header (a single wall-clock span) deliberately
385
+ // doesn't show. Ticks smoothly because active rows re-render per spinner
386
+ // frame anyway.
321
387
  const liveActivity = options.owner?.activity;
322
- rightPlain = liveActivity ? `⋯ ${liveActivity}` : "running…";
323
- rightStyled = theme.fg("warning", rightPlain);
388
+ const runFor = formatDurationSecs(taskElapsedSecs(task));
389
+ rightPlain = `${liveActivity ? `⋯ ${liveActivity}` : "running…"} · ${runFor}`;
390
+ rightStyled =
391
+ theme.fg("warning", liveActivity ? `⋯ ${liveActivity}` : "running…") + theme.fg("dim", ` · ${runFor}`);
324
392
  }
325
393
  else if (task.status === "pending") {
326
394
  rightPlain = "queued";
@@ -329,7 +397,7 @@ function formatTaskLine(task, width, frame, options = {}) {
329
397
  // A warning note (e.g. inherited-model fallback, exhaustion skip) takes over the
330
398
  // right column as a ⚠ cue, replacing the usage/status stamp for that row.
331
399
  if (task.note) {
332
- rightPlain = `⚠ ${task.note}`;
400
+ rightPlain = `${WARNING_GLYPH} ${task.note}`;
333
401
  rightStyled = theme.fg("warning", rightPlain);
334
402
  }
335
403
  const rightWidth = rightPlain ? visibleWidth(rightPlain) + 1 : 0;
@@ -365,6 +433,81 @@ function filterTasksForView(tasks, agents, _view) {
365
433
  filteredTasks: tasks.filter((t) => roleIds.has(taskOwnerId(t))),
366
434
  };
367
435
  }
436
+ /**
437
+ * The subagents lens's rows in render order: a depth-first walk of the
438
+ * delegated forest. Roots are parentless subagent/MCP tasks, plus orphans —
439
+ * children whose parent was dropped from the store (their `parentTaskId` no
440
+ * longer resolves), which used to be counted by the header but never rendered.
441
+ * A visited set guards against `parentTaskId` cycles, so a corrupt link can
442
+ * never hang the render loop. Both the lens filter (header counts) and the
443
+ * renderer consume this list, so count and rendering can never disagree.
444
+ */
445
+ function subagentLensRows(tasks) {
446
+ const ids = new Set();
447
+ for (const task of tasks)
448
+ ids.add(task.id);
449
+ const childrenByParent = new Map();
450
+ for (const task of tasks) {
451
+ if (task.parentTaskId === undefined)
452
+ continue;
453
+ const siblings = childrenByParent.get(task.parentTaskId);
454
+ if (siblings)
455
+ siblings.push(task);
456
+ else
457
+ childrenByParent.set(task.parentTaskId, [task]);
458
+ }
459
+ const roots = tasks.filter((t) => (t.parentTaskId === undefined && (t.source === "subagent" || t.source === "mcp")) ||
460
+ (t.parentTaskId !== undefined && !ids.has(t.parentTaskId)));
461
+ const rows = [];
462
+ const visited = new Set();
463
+ const walk = (task, prefix, isLast, isRoot) => {
464
+ if (visited.has(task.id))
465
+ return;
466
+ visited.add(task.id);
467
+ rows.push({ task, treePrefix: isRoot ? "" : `${prefix}${isLast ? "└─ " : "├─ "}` });
468
+ const kids = childrenByParent.get(task.id) ?? [];
469
+ const childPrefix = isRoot ? "" : `${prefix}${isLast ? " " : "│ "}`;
470
+ for (let i = 0; i < kids.length; i++) {
471
+ walk(kids[i], childPrefix, i === kids.length - 1, false);
472
+ }
473
+ };
474
+ for (const root of roots)
475
+ walk(root, "", true, true);
476
+ return rows;
477
+ }
478
+ /**
479
+ * The flat ("tasks") lens's rows in render order: the main agent's TodoWrite
480
+ * plan, with each subagent run nested (└─/├─) under the plan item it was
481
+ * dispatched for (`linkedTaskId`, recorded when exactly one item was
482
+ * in_progress). This is the visual tie between the plan and the agents
483
+ * executing it — previously a spawn was linked in spirit but invisible here,
484
+ * living only in the subagents lens. Runs with no link (or a dangling one —
485
+ * their todo was replaced) stay out of this lens; they remain visible in the
486
+ * subagents tree.
487
+ */
488
+ function flatLensRows(tasks) {
489
+ const linkedByTodo = new Map();
490
+ for (const task of tasks) {
491
+ if (task.linkedTaskId === undefined || task.source !== "subagent")
492
+ continue;
493
+ const siblings = linkedByTodo.get(task.linkedTaskId);
494
+ if (siblings)
495
+ siblings.push(task);
496
+ else
497
+ linkedByTodo.set(task.linkedTaskId, [task]);
498
+ }
499
+ const rows = [];
500
+ for (const task of tasks) {
501
+ if (!isMainTask(task))
502
+ continue;
503
+ rows.push({ task, treePrefix: "" });
504
+ const linked = linkedByTodo.get(task.id) ?? [];
505
+ for (let i = 0; i < linked.length; i++) {
506
+ rows.push({ task: linked[i], treePrefix: i === linked.length - 1 ? "└─ " : "├─ " });
507
+ }
508
+ }
509
+ return rows;
510
+ }
368
511
  /**
369
512
  * Scope the full task list to the tasks visible in the given lens. The header,
370
513
  * state stamp, and done/total count are derived from this subset so they match
@@ -373,9 +516,13 @@ function filterTasksForView(tasks, agents, _view) {
373
516
  function filterTasksForLens(tasks, agents, view) {
374
517
  switch (view) {
375
518
  case "flat":
376
- return tasks.filter(isMainTask);
519
+ // Same walk the renderer uses (plan items + their linked runs), so the
520
+ // header's done/total always matches the visible rows.
521
+ return flatLensRows(tasks).map((row) => row.task);
377
522
  case "subagents":
378
- return tasks.filter((t) => t.source === "subagent" || t.source === "mcp" || t.parentTaskId !== undefined);
523
+ // Exactly the rows the tree renders (same walk), so the header's
524
+ // done/total always matches the visible rows.
525
+ return subagentLensRows(tasks).map((row) => row.task);
379
526
  case "teams": {
380
527
  const roleIds = new Set();
381
528
  for (const a of agents) {
@@ -410,16 +557,21 @@ function groupTasks(tasks, agents) {
410
557
  groups.set(owner, [task]);
411
558
  }
412
559
  const order = [];
560
+ const ordered = new Set();
561
+ const push = (id) => {
562
+ if (!ordered.has(id)) {
563
+ ordered.add(id);
564
+ order.push(id);
565
+ }
566
+ };
413
567
  if (groups.has("main"))
414
- order.push("main");
568
+ push("main");
415
569
  for (const agent of agents) {
416
- if (groups.has(agent.id) && !order.includes(agent.id))
417
- order.push(agent.id);
418
- }
419
- for (const id of groups.keys()) {
420
- if (!order.includes(id))
421
- order.push(id);
570
+ if (groups.has(agent.id))
571
+ push(agent.id);
422
572
  }
573
+ for (const id of groups.keys())
574
+ push(id);
423
575
  return order.map((id) => ({
424
576
  id,
425
577
  meta: meta.get(id) ?? defaultAgentMeta(id),
@@ -435,10 +587,18 @@ function groupTasks(tasks, agents) {
435
587
  function formatGroupHeader(meta, items, width, selected = false) {
436
588
  // A focused role row swaps its ▸ for a filled ▶ in accent — the team-focus
437
589
  // selection cursor (the owner-kind glyph mapping itself is unchanged).
590
+ // Spawned/team agents carry their identity color on the glyph and name
591
+ // (hashed from the agent type, "explore#2" → "explore"), matching the same
592
+ // agent's color in the chat and TaskOutput; main keeps the accent.
593
+ const identityColor = meta.kind === "main" ? AGENT_GLYPH_COLOR[meta.kind] : agentColorFor(agentTypeOfName(meta.name));
438
594
  const glyph = selected
439
- ? theme.fg("accent", "▶")
440
- : theme.fg(AGENT_GLYPH_COLOR[meta.kind], AGENT_GLYPH[meta.kind] ?? AGENT_GLYPH.subagent);
441
- const name = selected ? theme.bold(theme.fg("accent", meta.name)) : theme.bold(meta.name);
595
+ ? theme.fg("accent", SELECTED_GLYPH)
596
+ : theme.fg(identityColor, AGENT_GLYPH[meta.kind] ?? AGENT_GLYPH.subagent);
597
+ const name = selected
598
+ ? theme.bold(theme.fg("accent", meta.name))
599
+ : meta.kind === "main"
600
+ ? theme.bold(meta.name)
601
+ : theme.bold(theme.fg(identityColor, meta.name));
442
602
  // Roles read as a dim "· role" suffix for spawned/team agents; the main
443
603
  // orchestrator's role sits brighter (muted), matching the design's .grp-role.
444
604
  const role = meta.role
@@ -484,7 +644,10 @@ function formatGroupHeader(meta, items, width, selected = false) {
484
644
  * ("[planner]", fed by `--team <url>`), or the MCP server ("[github]").
485
645
  * - Three views split by ownership (cycled via app.tasks.cycleView, shown as a
486
646
  * `tasks · subagents · teams` switcher in the header):
487
- * - flat ("tasks") — only the main agent's own TodoWrite plan;
647
+ * - flat ("tasks") — the main agent's own TodoWrite plan, with each
648
+ * dispatched subagent run nested under the plan item it was dispatched
649
+ * for (linkedTaskId), so the plan and the agents executing it read as
650
+ * one picture;
488
651
  * - subagents — a recursive task tree over delegated work, where a subagent
489
652
  * that spawned a subagent shows its nested tasks (roots are the dispatched
490
653
  * subagents and direct MCP calls; children link via parentTaskId, merged
@@ -509,6 +672,66 @@ export class TaskPanelComponent {
509
672
  animationTimer = null;
510
673
  view = "flat";
511
674
  disposed = false;
675
+ // Render memoization. The spinner re-renders the whole pane every 80ms, but
676
+ // between store mutations only the in-progress rows actually change (spinner
677
+ // frame + live activity). Settled/pending row strings and derived structures
678
+ // (agentById, the subagents tree walk) are cached and invalidated by the
679
+ // store's mutation version + pane width, so a frame tick reuses them instead
680
+ // of rebuilding every string.
681
+ memoVersion = -1;
682
+ memoWidth = -1;
683
+ lineMemo = new Map();
684
+ agentByIdMemo = null;
685
+ subagentRowsMemo = null;
686
+ flatRowsMemo = null;
687
+ /** Drop all memoized render state when the store or width changed. */
688
+ syncMemo(width) {
689
+ const version = taskStore.version();
690
+ if (version === this.memoVersion && width === this.memoWidth)
691
+ return;
692
+ this.memoVersion = version;
693
+ this.memoWidth = width;
694
+ this.lineMemo.clear();
695
+ this.agentByIdMemo = null;
696
+ this.subagentRowsMemo = null;
697
+ this.flatRowsMemo = null;
698
+ }
699
+ agentById(agents) {
700
+ if (!this.agentByIdMemo) {
701
+ this.agentByIdMemo = new Map(agents.map((a) => [a.id, a]));
702
+ }
703
+ return this.agentByIdMemo;
704
+ }
705
+ subagentRows(tasks) {
706
+ if (!this.subagentRowsMemo) {
707
+ this.subagentRowsMemo = subagentLensRows(tasks);
708
+ }
709
+ return this.subagentRowsMemo;
710
+ }
711
+ flatRows(tasks) {
712
+ if (!this.flatRowsMemo) {
713
+ this.flatRowsMemo = flatLensRows(tasks);
714
+ }
715
+ return this.flatRowsMemo;
716
+ }
717
+ /**
718
+ * formatTaskLine with caching for rows that don't animate. In-progress rows
719
+ * depend on the spinner frame and the owner's live activity, so they render
720
+ * fresh every frame; everything else is stable until the next store mutation
721
+ * or width change (handled by syncMemo).
722
+ */
723
+ taskLine(task, width, options = {}) {
724
+ if (task.status === "in_progress") {
725
+ return formatTaskLine(task, width, this.frame, options);
726
+ }
727
+ const key = `${task.id}:${options.grouped ? 1 : 0}:${options.treePrefix ?? ""}:${options.owner?.kind ?? ""}`;
728
+ const hit = this.lineMemo.get(key);
729
+ if (hit !== undefined)
730
+ return hit;
731
+ const line = formatTaskLine(task, width, this.frame, options);
732
+ this.lineMemo.set(key, line);
733
+ return line;
734
+ }
512
735
  // Team focus mode: when the TUI focuses the panel, role rows become a
513
736
  // navigable list (↑/↓ select, n nudge, a attach, q/esc back). The selection
514
737
  // is tracked by role name so a roster reorder doesn't move the cursor.
@@ -524,7 +747,8 @@ export class TaskPanelComponent {
524
747
  this.ui = ui ?? null;
525
748
  }
526
749
  invalidate() {
527
- // No cached rendering state.
750
+ // Force the next render to rebuild its memoized rows/structures.
751
+ this.memoVersion = -1;
528
752
  }
529
753
  roleAgents() {
530
754
  return taskStore.agents().filter((a) => a.kind === "role");
@@ -551,12 +775,12 @@ export class TaskPanelComponent {
551
775
  else if (keybindings.matches(data, "tui.select.down")) {
552
776
  this.selectedRole = roles[Math.min(roles.length - 1, index + 1)].name;
553
777
  }
554
- else if (matchesKey(data, "n")) {
778
+ else if (matchesAppKey(data, "app.team.nudge")) {
555
779
  const role = this.focusedRole();
556
780
  if (role)
557
781
  this.onNudge?.(role);
558
782
  }
559
- else if (matchesKey(data, "a")) {
783
+ else if (matchesAppKey(data, "app.team.attach")) {
560
784
  const role = this.focusedRole();
561
785
  if (role)
562
786
  this.onAttach?.(role);
@@ -619,6 +843,7 @@ export class TaskPanelComponent {
619
843
  render(width) {
620
844
  if (this.disposed)
621
845
  return [];
846
+ this.syncMemo(width);
622
847
  const tasks = taskStore.list();
623
848
  const allAgents = taskStore.agents();
624
849
  // Resolve the lens: keep the stored view while it still has content, else
@@ -643,24 +868,29 @@ export class TaskPanelComponent {
643
868
  }
644
869
  // Scope all visual state to the tasks visible in the current lens so the
645
870
  // animation, rail color, and header count match exactly what the user sees.
646
- const lensTasks = filterTasksForLens(tasks, allAgents, view);
871
+ // The flat and subagents lenses derive from their memoized row walks so
872
+ // filter and render share one computation (one source of truth for counts).
873
+ const lensTasks = view === "subagents"
874
+ ? this.subagentRows(tasks).map((row) => row.task)
875
+ : view === "flat"
876
+ ? this.flatRows(tasks).map((row) => row.task)
877
+ : filterTasksForLens(tasks, allAgents, view);
647
878
  const hasActive = lensTasks.some((t) => t.status === "in_progress");
648
879
  this.ensureAnimation(hasActive);
649
880
  const state = panelState(lensTasks);
650
- const totalSecs = lensTasks.reduce((sum, t) => sum + taskElapsedSecs(t), 0);
881
+ const totalSecs = turnElapsedSecs(lensTasks);
651
882
  const railColor = STATE_PRESENTATION[state].color;
652
883
  const gutter = `${theme.fg(railColor, RAIL)} `;
653
884
  const inner = Math.max(0, width - visibleWidth(RAIL) - 1);
654
885
  const lines = [gutter + formatHeader(lensTasks, inner, state, totalSecs, view, available)];
655
886
  if (view === "flat") {
656
- // Only the main agent's own TodoWrite plan delegated (subagent/MCP) work
657
- // belongs to the subagents lens. Resolve each row's owner from the roster
658
- // so the glyph/tag reflect the owning agent's kind, not just the source.
659
- const agentById = new Map(allAgents.map((a) => [a.id, a]));
660
- for (const task of tasks) {
661
- if (!isMainTask(task))
662
- continue;
663
- lines.push(gutter + formatTaskLine(task, inner, this.frame, { owner: agentById.get(taskOwnerId(task)) }));
887
+ // The main agent's TodoWrite plan, with each dispatched run nested under
888
+ // the plan item it executes (see flatLensRows). Resolve each row's owner
889
+ // from the roster so the glyph/tag reflect the owning agent's kind and a
890
+ // nested run shows its live activity + timer.
891
+ const agentById = this.agentById(allAgents);
892
+ for (const { task, treePrefix } of this.flatRows(tasks)) {
893
+ lines.push(gutter + this.taskLine(task, inner, { treePrefix, owner: agentById.get(taskOwnerId(task)) }));
664
894
  }
665
895
  return lines;
666
896
  }
@@ -670,37 +900,19 @@ export class TaskPanelComponent {
670
900
  // tasks they spawned in turn (linked by parentTaskId, merged across the
671
901
  // process boundary), so a subagent that spawned a subagent is visible.
672
902
  // Each task is its own node keeping its [subagentMode]/[server] tag; depth
673
- // is drawn with └─/├─/│ connectors. With only top-level tasks the roots
674
- // carry an empty prefix and read exactly like flat rows (no extra indent).
675
- const childrenByParent = new Map();
676
- for (const task of tasks) {
677
- if (task.parentTaskId === undefined)
678
- continue;
679
- const siblings = childrenByParent.get(task.parentTaskId);
680
- if (siblings)
681
- siblings.push(task);
682
- else
683
- childrenByParent.set(task.parentTaskId, [task]);
684
- }
685
- const roots = tasks.filter((t) => t.parentTaskId === undefined && (t.source === "subagent" || t.source === "mcp"));
686
- // Resolve each row's owning agent so an in-progress row can show the agent's
687
- // live tool activity (⋯ grep) instead of a static "running…".
688
- const agentById = new Map(allAgents.map((a) => [a.id, a]));
689
- const walk = (task, prefix, isLast, isRoot) => {
690
- const connector = isRoot ? "" : `${prefix}${isLast ? "└─ " : "├─ "}`;
903
+ // is drawn with └─/├─/│ connectors (see subagentLensRows for orphan and
904
+ // cycle handling). With only top-level tasks the roots carry an empty
905
+ // prefix and read exactly like flat rows (no extra indent). Owners are
906
+ // resolved so an in-progress row shows the run's live tool activity
907
+ // ( grep) instead of a static "running…".
908
+ const agentById = this.agentById(allAgents);
909
+ for (const { task, treePrefix } of this.subagentRows(tasks)) {
691
910
  lines.push(gutter +
692
- formatTaskLine(task, inner, this.frame, {
693
- treePrefix: connector,
911
+ this.taskLine(task, inner, {
912
+ treePrefix,
694
913
  owner: agentById.get(taskOwnerId(task)),
695
914
  }));
696
- const kids = childrenByParent.get(task.id) ?? [];
697
- const childPrefix = isRoot ? "" : `${prefix}${isLast ? " " : "│ "}`;
698
- for (let i = 0; i < kids.length; i++) {
699
- walk(kids[i], childPrefix, i === kids.length - 1, false);
700
- }
701
- };
702
- for (const root of roots)
703
- walk(root, "", true, true);
915
+ }
704
916
  return lines;
705
917
  }
706
918
  // teams view: role-agent groups with handoff connectors and queued placeholders.
@@ -720,7 +932,7 @@ export class TaskPanelComponent {
720
932
  const selected = cursorRole !== undefined && group.meta.kind === "role" && group.meta.name === cursorRole;
721
933
  lines.push(gutter + formatGroupHeader(group.meta, group.items, inner, selected));
722
934
  for (const task of group.items) {
723
- lines.push(gutter + formatTaskLine(task, inner, this.frame, { grouped: true }));
935
+ lines.push(gutter + this.taskLine(task, inner, { grouped: true }));
724
936
  }
725
937
  // Forward-handoff connector: emit "└──→ name" only for "→ name" arrows
726
938
  // (not back-references "← name"), and only when the target exists in the
@@ -742,7 +954,17 @@ export class TaskPanelComponent {
742
954
  }
743
955
  }
744
956
  if (this.focused) {
745
- lines.push(gutter + truncateToWidth(theme.fg("dim", "↑/↓ select · n nudge · a attach · q/esc back"), inner, "…"));
957
+ // House hint style (see keybinding-hints): dim key + muted description,
958
+ // muted · separators. Arrows and q/esc are literal by convention; the
959
+ // nudge/attach keys resolve from the live keybinding config.
960
+ const sep = theme.fg("muted", " · ");
961
+ const hint = [
962
+ rawKeyHint("↑/↓", "select"),
963
+ rawKeyHint(appKeyLabel("app.team.nudge"), "nudge"),
964
+ rawKeyHint(appKeyLabel("app.team.attach"), "attach"),
965
+ rawKeyHint("q/esc", "back"),
966
+ ].join(sep);
967
+ lines.push(gutter + truncateToWidth(hint, inner, "…"));
746
968
  }
747
969
  return lines;
748
970
  }