@kolisachint/hoocode-agent 0.4.110 → 0.4.112
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.
- package/CHANGELOG.md +107 -0
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +8 -3
- package/dist/cli/args.js.map +1 -1
- package/dist/core/extensions/loader.d.ts +10 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +17 -2
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/plugins/marketplace.d.ts +36 -5
- package/dist/core/extensions/plugins/marketplace.d.ts.map +1 -1
- package/dist/core/extensions/plugins/marketplace.js +63 -17
- package/dist/core/extensions/plugins/marketplace.js.map +1 -1
- package/dist/core/format-duration.d.ts +7 -0
- package/dist/core/format-duration.d.ts.map +1 -0
- package/dist/core/format-duration.js +16 -0
- package/dist/core/format-duration.js.map +1 -0
- package/dist/core/keybindings.d.ts +10 -0
- package/dist/core/keybindings.d.ts.map +1 -1
- package/dist/core/keybindings.js +10 -0
- package/dist/core/keybindings.js.map +1 -1
- package/dist/core/lifeguard.d.ts +15 -0
- package/dist/core/lifeguard.d.ts.map +1 -1
- package/dist/core/lifeguard.js +48 -18
- package/dist/core/lifeguard.js.map +1 -1
- package/dist/core/scheduler.d.ts +8 -2
- package/dist/core/scheduler.d.ts.map +1 -1
- package/dist/core/scheduler.js +9 -3
- package/dist/core/scheduler.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -1
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +2 -2
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/core/subagent-inbox.d.ts +1 -1
- package/dist/core/subagent-inbox.d.ts.map +1 -1
- package/dist/core/subagent-inbox.js +2 -0
- package/dist/core/subagent-inbox.js.map +1 -1
- package/dist/core/subagent-pool-instance.d.ts.map +1 -1
- package/dist/core/subagent-pool-instance.js +18 -11
- package/dist/core/subagent-pool-instance.js.map +1 -1
- package/dist/core/subagent-pool.d.ts +25 -3
- package/dist/core/subagent-pool.d.ts.map +1 -1
- package/dist/core/subagent-pool.js +120 -25
- package/dist/core/subagent-pool.js.map +1 -1
- package/dist/core/subagent-result.d.ts.map +1 -1
- package/dist/core/subagent-result.js +6 -4
- package/dist/core/subagent-result.js.map +1 -1
- package/dist/core/task-store.d.ts +45 -3
- package/dist/core/task-store.d.ts.map +1 -1
- package/dist/core/task-store.js +51 -5
- package/dist/core/task-store.js.map +1 -1
- package/dist/core/team-view.d.ts.map +1 -1
- package/dist/core/team-view.js +2 -0
- package/dist/core/team-view.js.map +1 -1
- package/dist/core/token-budget.d.ts +13 -1
- package/dist/core/token-budget.d.ts.map +1 -1
- package/dist/core/token-budget.js +18 -1
- package/dist/core/token-budget.js.map +1 -1
- package/dist/core/tools/subagent.d.ts +16 -4
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +211 -67
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/core/tools/todo.d.ts.map +1 -1
- package/dist/core/tools/todo.js +39 -7
- package/dist/core/tools/todo.js.map +1 -1
- package/dist/extensions/core/loop.d.ts.map +1 -1
- package/dist/extensions/core/loop.js +4 -1
- package/dist/extensions/core/loop.js.map +1 -1
- package/dist/extensions/core/marketplace.d.ts +5 -3
- package/dist/extensions/core/marketplace.d.ts.map +1 -1
- package/dist/extensions/core/marketplace.js +37 -15
- package/dist/extensions/core/marketplace.js.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.d.ts +10 -0
- package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
- package/dist/modes/interactive/components/keybinding-hints.js +27 -1
- package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
- package/dist/modes/interactive/components/task-panel.d.ts +22 -1
- package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/task-panel.js +302 -80
- package/dist/modes/interactive/components/task-panel.js.map +1 -1
- package/dist/modes/interactive/components/team-attach-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/team-attach-panel.js +15 -5
- package/dist/modes/interactive/components/team-attach-panel.js.map +1 -1
- package/dist/modes/interactive/theme/dark.json +8 -1
- package/dist/modes/interactive/theme/light.json +8 -1
- package/dist/modes/interactive/theme/theme-schema.json +28 -1
- package/dist/modes/interactive/theme/theme.d.ts +12 -1
- package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
- package/dist/modes/interactive/theme/theme.js +52 -0
- package/dist/modes/interactive/theme/theme.js.map +1 -1
- package/dist/modes/interactive/voice/voice-panel.d.ts.map +1 -1
- package/dist/modes/interactive/voice/voice-panel.js +5 -2
- package/dist/modes/interactive/voice/voice-panel.js.map +1 -1
- package/dist/modes/rpc/jsonl.d.ts +16 -1
- package/dist/modes/rpc/jsonl.d.ts.map +1 -1
- package/dist/modes/rpc/jsonl.js +53 -8
- package/dist/modes/rpc/jsonl.js.map +1 -1
- package/dist/utils/atomic-file.d.ts +13 -0
- package/dist/utils/atomic-file.d.ts.map +1 -0
- package/dist/utils/atomic-file.js +26 -0
- package/dist/utils/atomic-file.js.map +1 -0
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- 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
|
-
|
|
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
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
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
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
323
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
568
|
+
push("main");
|
|
415
569
|
for (const agent of agents) {
|
|
416
|
-
if (groups.has(agent.id)
|
|
417
|
-
|
|
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(
|
|
441
|
-
const name = selected
|
|
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") —
|
|
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
|
-
//
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// so the glyph/tag reflect the owning agent's kind
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
674
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
693
|
-
treePrefix
|
|
911
|
+
this.taskLine(task, inner, {
|
|
912
|
+
treePrefix,
|
|
694
913
|
owner: agentById.get(taskOwnerId(task)),
|
|
695
914
|
}));
|
|
696
|
-
|
|
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 +
|
|
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
|
-
|
|
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
|
}
|