@kolisachint/hoocode-agent 0.4.157 → 0.4.159
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 +62 -0
- package/dist/core/agent-session-stats.d.ts +17 -1
- package/dist/core/agent-session-stats.d.ts.map +1 -1
- package/dist/core/agent-session-stats.js +20 -0
- package/dist/core/agent-session-stats.js.map +1 -1
- package/dist/core/settings-defaults.d.ts +1 -0
- package/dist/core/settings-defaults.d.ts.map +1 -1
- package/dist/core/settings-defaults.js +2 -1
- package/dist/core/settings-defaults.js.map +1 -1
- package/dist/core/settings-manager.d.ts +2 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +8 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/settings-types.d.ts +1 -0
- package/dist/core/settings-types.d.ts.map +1 -1
- package/dist/core/settings-types.js.map +1 -1
- package/dist/core/subagent-pool.d.ts.map +1 -1
- package/dist/core/subagent-pool.js +5 -1
- package/dist/core/subagent-pool.js.map +1 -1
- package/dist/core/tools/todo.d.ts +24 -0
- package/dist/core/tools/todo.d.ts.map +1 -1
- package/dist/core/tools/todo.js +50 -9
- package/dist/core/tools/todo.js.map +1 -1
- package/dist/core/warm-subagent-pool.d.ts.map +1 -1
- package/dist/core/warm-subagent-pool.js +5 -1
- package/dist/core/warm-subagent-pool.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +6 -15
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +15 -2
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/components/task-panel.d.ts +20 -12
- package/dist/modes/interactive/components/task-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/task-panel.js +170 -224
- package/dist/modes/interactive/components/task-panel.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +42 -7
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +124 -19
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/settings.md +2 -1
- 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
|
@@ -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
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
plain
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
635
|
-
* done/total
|
|
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
|
|
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
|
-
|
|
671
|
-
animationTimer = null;
|
|
607
|
+
runClockTimer = null;
|
|
672
608
|
view = "flat";
|
|
673
609
|
disposed = false;
|
|
674
|
-
// Render memoization.
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
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
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
812
|
-
|
|
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.
|
|
815
|
-
clearInterval(this.
|
|
816
|
-
this.
|
|
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.
|
|
822
|
-
this.
|
|
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
|
-
},
|
|
826
|
-
this.
|
|
766
|
+
}, RUN_CLOCK_INTERVAL_MS);
|
|
767
|
+
this.runClockTimer.unref?.();
|
|
827
768
|
}
|
|
828
|
-
else if (!active && this.
|
|
829
|
-
clearInterval(this.
|
|
830
|
-
this.
|
|
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
|
|
774
|
+
/** Stop the run clock. Call on teardown. */
|
|
835
775
|
dispose() {
|
|
836
|
-
if (this.
|
|
837
|
-
clearInterval(this.
|
|
838
|
-
this.
|
|
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.
|
|
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
|
-
|
|
878
|
-
|
|
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
|
-
|
|
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
|