claude-code-kanban 4.7.1 → 4.9.0

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/lib/parsers.js CHANGED
@@ -146,17 +146,49 @@ function parseImageMarkers(text) {
146
146
  return { refs, text: cleaned };
147
147
  }
148
148
 
149
+ // Compact usage suffix for a task-notification chip: " · 22.3k tok · 6 tools · 119s".
150
+ function formatTaskUsage(usage) {
151
+ if (!usage) return '';
152
+ const parts = [];
153
+ if (usage.subagentTokens != null) {
154
+ const t = usage.subagentTokens;
155
+ const tok = t >= 1000 ? (t / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(t);
156
+ parts.push(tok + ' tok');
157
+ }
158
+ if (usage.toolUses != null) parts.push(usage.toolUses + (usage.toolUses === 1 ? ' tool' : ' tools'));
159
+ if (usage.durationMs != null) parts.push(Math.round(usage.durationMs / 1000) + 's');
160
+ return parts.length ? ' · ' + parts.join(' · ') : '';
161
+ }
162
+
149
163
  function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
150
164
  if (sysLabel === '__skip__') return;
151
- const safeText = text || '';
165
+ let safeText = text || '';
166
+ let label = sysLabel;
167
+ // Background-task completion notifications are harness events, not user input.
168
+ // Whichever path delivered them (normal type:'user' or a queued enqueue), show
169
+ // a rich summary+usage chip and surface the agent's actual result as the body —
170
+ // never the raw <task-notification> envelope under a person icon.
171
+ const notif = parseTaskNotification(safeText);
172
+ if (notif) {
173
+ const base = notif.summary || (notif.status ? `Background task ${notif.status}` : 'Background task notification');
174
+ label = base + formatTaskUsage(notif.usage);
175
+ safeText = notif.result || base;
176
+ }
152
177
  const truncated = safeText.length > USER_TEXT_MAX;
153
178
  const msg = {
154
179
  type: 'user',
155
180
  text: truncated ? safeText.slice(0, USER_TEXT_MAX) + '...' : safeText,
156
181
  fullText: truncated ? safeText : null,
157
182
  timestamp,
158
- ...(sysLabel && { systemLabel: sysLabel })
183
+ ...(label && { systemLabel: label })
159
184
  };
185
+ // Tag task-notifications so the client can group the duplicate enqueue+delivered
186
+ // pair (same taskId) and join the agent type from the agents list.
187
+ if (notif) {
188
+ msg.taskNotification = true;
189
+ if (notif.taskId) msg.taskId = notif.taskId;
190
+ if (notif.status) msg.taskStatus = notif.status;
191
+ }
160
192
  if (extras) {
161
193
  if (extras.uuid) msg.uuid = extras.uuid;
162
194
  if (extras.images && extras.images.length) msg.images = extras.images;
@@ -435,6 +467,58 @@ function readSessionInfoFromJsonl(jsonlPath) {
435
467
  return result;
436
468
  }
437
469
 
470
+ // Background-task completion records arrive as a user message whose content is a
471
+ // <task-notification> envelope. Rendering the raw text leaks the tag values as a
472
+ // run-on line (task-id + tool-use-id + output-file path) plus the <usage> numbers
473
+ // mashed together (subagent_tokens|tool_uses|duration_ms with tags stripped).
474
+ // Parse the envelope into structured fields so the UI can show just the agent's
475
+ // result and a clean metadata line instead of the raw wrapper. Returns null when
476
+ // the text is not a task-notification.
477
+ function parseTaskNotification(text) {
478
+ if (typeof text !== 'string' || !text.includes('<task-notification>')) return null;
479
+ // Metadata fields are machine-generated single-line values that appear before
480
+ // the free-form <result>, so first-match non-greedy is safe for them.
481
+ const pick = (tag) => {
482
+ const m = text.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
483
+ return m ? m[1].trim() : null;
484
+ };
485
+ // <result> and <usage> carry UNescaped agent text that can itself contain
486
+ // literal "</result>" / "<usage>" (e.g. an agent describing this very format).
487
+ // The real closing tags are always the LAST occurrence, so match greedily and
488
+ // take the last <usage> block to avoid truncating on embedded markers.
489
+ const lastBlock = (tag) => {
490
+ const m = text.match(new RegExp(`<${tag}>([\\s\\S]*)<\\/${tag}>`));
491
+ return m ? m[1] : null;
492
+ };
493
+ let usage = null;
494
+ const usageRaw = lastBlock('usage');
495
+ if (usageRaw != null) {
496
+ const uNum = (tag) => {
497
+ // Take the LAST match: the real <usage> wins over any example block an agent
498
+ // embedded in its result.
499
+ const last = [...usageRaw.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g'))].at(-1);
500
+ if (!last) return null;
501
+ const n = Number(last[1]);
502
+ return Number.isFinite(n) ? n : null;
503
+ };
504
+ usage = {
505
+ subagentTokens: uNum('subagent_tokens'),
506
+ toolUses: uNum('tool_uses'),
507
+ durationMs: uNum('duration_ms')
508
+ };
509
+ }
510
+ const resultRaw = lastBlock('result');
511
+ return {
512
+ taskId: pick('task-id'),
513
+ toolUseId: pick('tool-use-id'),
514
+ outputFile: pick('output-file'),
515
+ status: pick('status'),
516
+ summary: pick('summary'),
517
+ result: resultRaw != null ? resultRaw.trim() : null,
518
+ usage
519
+ };
520
+ }
521
+
438
522
  function getSystemMessageLabel(text) {
439
523
  const taskMatch = text.match(/<summary>([^<]+)<\/summary>/);
440
524
  if (taskMatch) return taskMatch[1].trim();
@@ -1044,11 +1128,25 @@ function buildSessionDigest(jsonlPath) {
1044
1128
  const obj = JSON.parse(line);
1045
1129
  const tur = obj.toolUseResult;
1046
1130
  if (tur?.agentId) {
1131
+ // Foreground agents report their cost on the completion toolUseResult
1132
+ // (totalTokens/totalToolUseCount/totalDurationMs) — the same numbers a
1133
+ // background agent surfaces via its <task-notification> <usage> block. Keep
1134
+ // the raw numbers (for the agent modal's per-stat chips) and a formatted
1135
+ // one-liner via the shared formatter (for the inline message row):
1136
+ // " · 31.5k tok · 5 tools · 34s".
1137
+ const usage = {};
1138
+ if (tur.totalTokens != null) usage.tokens = tur.totalTokens;
1139
+ if (tur.totalToolUseCount != null) usage.tools = tur.totalToolUseCount;
1140
+ if (tur.totalDurationMs != null) usage.durationMs = tur.totalDurationMs;
1141
+ const hasUsage = Object.keys(usage).length > 0;
1142
+ const usageText = hasUsage
1143
+ ? formatTaskUsage({ subagentTokens: usage.tokens, toolUses: usage.tools, durationMs: usage.durationMs })
1144
+ : null;
1047
1145
  const blocks = obj.message?.content;
1048
1146
  if (Array.isArray(blocks)) {
1049
1147
  for (const b of blocks) {
1050
1148
  if (b.type === 'tool_result' && b.tool_use_id && !map[b.tool_use_id]) {
1051
- map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null };
1149
+ map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null, usage: hasUsage ? usage : null, usageText };
1052
1150
  }
1053
1151
  }
1054
1152
  }
@@ -1333,6 +1431,8 @@ module.exports = {
1333
1431
  parseTeamConfig,
1334
1432
  parseSessionsIndex,
1335
1433
  parseJsonlLine,
1434
+ parseTaskNotification,
1435
+ getSystemMessageLabel,
1336
1436
  readSessionInfoFromJsonl,
1337
1437
  readRecentMessages,
1338
1438
  readMessagesPage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.7.1",
3
+ "version": "4.9.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -945,6 +945,130 @@ function cleanMessageText(text) {
945
945
  .trim();
946
946
  }
947
947
 
948
+ // A GFM table delimiter row (|---|:--:|): only pipes/dashes/colons/spaces, with
949
+ // at least one dash. Pairs with a preceding line that has cells to mark a table.
950
+ function isTableDelimiter(line) {
951
+ const l = (line || '').trim();
952
+ return l.includes('-') && /^\|?[ :|-]+\|?$/.test(l);
953
+ }
954
+
955
+ // Cut a string to at most `max` chars at a word boundary (no mid-word chops).
956
+ function truncateAtWord(s, max) {
957
+ if (s.length <= max) return s;
958
+ const cut = s.slice(0, max);
959
+ const sp = cut.lastIndexOf(' ');
960
+ return (sp > max * 0.6 ? cut.slice(0, sp) : cut).trimEnd();
961
+ }
962
+
963
+ // Build a markdown preview for an assistant message: render real markdown but
964
+ // truncate on clean structural boundaries so the feed stays compact and the
965
+ // output is always valid markdown (never cut mid code-fence or mid table-row).
966
+ // Prose is bounded by a line count AND a char budget (long single paragraphs are
967
+ // cut at a word boundary). Returns { md, remainder } where remainder is a short
968
+ // label describing what was cut (e.g. "+12 lines"), or '' when nothing was cut.
969
+ function buildAssistantPreview(text) {
970
+ const lines = stripAnsi(text || '')
971
+ .replace(/\r/g, '')
972
+ .split('\n');
973
+ const MAX_LINES = 5; // non-blank content lines kept before truncating
974
+ const MAX_CHARS = 280; // total prose/code/table chars kept
975
+ const MAX_ROWS = 3; // table body rows
976
+ const MAX_CODE_LINES = 8; // lines kept inside a fenced code block
977
+ const countRest = (idx) => {
978
+ let c = 0;
979
+ for (let j = idx; j < lines.length; j++) if (lines[j].trim()) c++;
980
+ return c;
981
+ };
982
+ const pluralize = (n, word) => `+${n} ${word}${n > 1 ? 's' : ''}`;
983
+ const out = [];
984
+ let remainder = '';
985
+ let content = 0;
986
+ let chars = 0;
987
+ let i = 0;
988
+
989
+ for (; i < lines.length; i++) {
990
+ const line = lines[i];
991
+ const trimmed = line.trim();
992
+ if (content >= MAX_LINES || chars >= MAX_CHARS) {
993
+ const rest = countRest(i);
994
+ if (rest) remainder = pluralize(rest, 'line');
995
+ break;
996
+ }
997
+
998
+ // Fenced code block — copy verbatim (capped), never break the fence open.
999
+ const fence = trimmed.match(/^(```|~~~)/);
1000
+ if (fence) {
1001
+ const marker = fence[1];
1002
+ out.push(line);
1003
+ let codeLines = 0;
1004
+ let dropped = 0;
1005
+ // Advance to the real closing fence, keeping at most MAX_CODE_LINES lines,
1006
+ // so leftover code isn't re-parsed as top-level markdown after the cap.
1007
+ for (i++; i < lines.length && !lines[i].trim().startsWith(marker); i++) {
1008
+ if (codeLines < MAX_CODE_LINES) {
1009
+ out.push(lines[i]);
1010
+ codeLines++;
1011
+ chars += lines[i].length;
1012
+ } else {
1013
+ dropped++;
1014
+ }
1015
+ }
1016
+ out.push(marker); // close the fence (covers capped / unterminated blocks)
1017
+ content++;
1018
+ if (dropped) {
1019
+ // Stop at the truncated code block rather than resuming after the gap.
1020
+ remainder = pluralize(dropped, 'code line');
1021
+ break;
1022
+ }
1023
+ continue;
1024
+ }
1025
+
1026
+ // Table — keep header + separator + up to MAX_ROWS body rows.
1027
+ if (line.includes('|') && isTableDelimiter(lines[i + 1])) {
1028
+ out.push(line, lines[i + 1]);
1029
+ content++;
1030
+ chars += line.length;
1031
+ i++;
1032
+ let rows = 0;
1033
+ let droppedRows = 0;
1034
+ while (lines[i + 1]?.includes('|')) {
1035
+ if (rows >= MAX_ROWS) {
1036
+ // Skip remaining rows so they aren't re-rendered as prose lines.
1037
+ droppedRows++;
1038
+ i++;
1039
+ continue;
1040
+ }
1041
+ out.push(lines[++i]);
1042
+ rows++;
1043
+ }
1044
+ if (droppedRows) {
1045
+ // Stop at the truncated table — don't resume with later content across
1046
+ // the gap; the "+N rows" chip signals the table continues.
1047
+ remainder = pluralize(droppedRows, 'row');
1048
+ break;
1049
+ }
1050
+ continue;
1051
+ }
1052
+
1053
+ // Prose / list / heading — bound by the remaining char budget, cutting a
1054
+ // long line at a word boundary rather than rendering a giant paragraph.
1055
+ const remaining = MAX_CHARS - chars;
1056
+ if (trimmed && line.length > remaining) {
1057
+ out.push(truncateAtWord(line, remaining));
1058
+ // Mid-line word cut — line counts would be misleading; count only the
1059
+ // additional full lines that follow, else just signal "more".
1060
+ const rest = countRest(i + 1);
1061
+ remainder = rest ? pluralize(rest, 'line') : 'more';
1062
+ break;
1063
+ }
1064
+ out.push(line);
1065
+ chars += line.length;
1066
+ if (trimmed) content++;
1067
+ }
1068
+
1069
+ return { md: out.join('\n').trim(), remainder };
1070
+ }
1071
+
948
1072
  function renderMsgPinBtn(m, i) {
949
1073
  const pinned = isPinned(m);
950
1074
  return `<button class="msg-pin-btn${pinned ? ' pinned' : ''}" onclick="event.stopPropagation();togglePin(${i})" title="${pinned ? 'Unpin' : 'Pin'} message">${PIN_SVG}</button>`;
@@ -1019,10 +1143,17 @@ function toolGroupKey(m) {
1019
1143
 
1020
1144
  function renderToolItem(m, i, compact) {
1021
1145
  const toolDetail = getToolDetail(m.tool, m.params, m.detail);
1146
+ // m.agentId is resolved server-side even while the agent is still running (correlated
1147
+ // from the live agent-activity files), so the ⇗ link, agent-log button, and modal
1148
+ // click all light up DURING the run, identical to post-completion.
1022
1149
  const agentLink =
1023
1150
  m.tool === 'Agent' && m.agentId
1024
1151
  ? ` <span class="msg-agent-link" title="View agent" onclick="event.stopPropagation();showAgentModal('${escapeHtml(m.agentId)}')">⇗</span>`
1025
1152
  : '';
1153
+ // Usage chip (tokens · tools · duration) on completed Agent rows — same stats a
1154
+ // background agent shows, sourced here from the foreground toolUseResult.
1155
+ const agentUsage =
1156
+ m.tool === 'Agent' && m.agentUsage ? `<span class="msg-agent-usage">${escapeHtml(m.agentUsage)}</span>` : '';
1026
1157
  const agentLogBtn = resolveAgentLogBtn(m);
1027
1158
  const recipientColor = m.tool === 'SendMessage' && m.params?.to ? resolveNamedColor(teamColorMap[m.params.to]) : null;
1028
1159
  const borderStyle = recipientColor ? `border-left:3px solid ${recipientColor.color};` : '';
@@ -1035,7 +1166,7 @@ function renderToolItem(m, i, compact) {
1035
1166
  const pinBtn = renderMsgPinBtn(m, i);
1036
1167
  return `<div class="msg-item msg-tool${compactClass}" data-msg-idx="${i}" ${itemClickAttr}>
1037
1168
  ${getToolIcon(m.tool)}
1038
- <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
1169
+ <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}${agentUsage}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
1039
1170
  </div>`;
1040
1171
  }
1041
1172
 
@@ -1075,6 +1206,53 @@ function renderMessageList(messages) {
1075
1206
  continue;
1076
1207
  }
1077
1208
 
1209
+ // Background task-notifications: the harness writes each one twice (an enqueue
1210
+ // record + the delivered type:'user' record, same taskId). Collapse a run of
1211
+ // same-taskId notifications into one ×N group (like tool groups), prefixed with
1212
+ // the agent type joined from the loaded agents list.
1213
+ if (m.taskNotification) {
1214
+ let runEnd = i + 1;
1215
+ while (runEnd < messages.length && messages[runEnd].taskNotification && messages[runEnd].taskId === m.taskId)
1216
+ runEnd++;
1217
+ const count = runEnd - i;
1218
+ const agentType = m.taskId ? currentAgents.find((a) => a.agentId === m.taskId)?.type : null;
1219
+ const typePrefix = agentType ? `${escapeHtml(agentType)} · ` : '';
1220
+ const labelHtml = `${typePrefix}<code>${escapeHtml(m.systemLabel || 'Background task')}</code>`;
1221
+ // Shared header row for both the single notification and the ×N group header.
1222
+ const headerRow = (extraClass, onclick, badge, queuedTag) =>
1223
+ `<div class="msg-item msg-system${extraClass}" ${onclick} style="cursor:pointer">
1224
+ ${ICON_AGENT}
1225
+ <div class="msg-body"><div class="msg-text">${labelHtml}${badge}</div><div class="msg-time">${formatDate(m.timestamp)}${queuedTag}</div></div>${renderMsgPinBtn(m, i)}
1226
+ </div>`;
1227
+
1228
+ if (count >= 2) {
1229
+ const gid = `task-group-${i}`;
1230
+ const items = Array.from({ length: count }, (_, j) => {
1231
+ const r = messages[i + j];
1232
+ const idx = i + j;
1233
+ return `<div class="msg-item msg-system msg-tool-grouped" data-msg-idx="${idx}" onclick="msgDetailFollowLatest=false;showMsgDetail(${idx})" style="cursor:pointer">
1234
+ ${ICON_AGENT}
1235
+ <div class="msg-body"><div class="msg-text">${r.queued ? 'queued' : 'delivered'}</div><div class="msg-time">${formatDate(r.timestamp)}</div></div>
1236
+ </div>`;
1237
+ }).join('');
1238
+ parts.push(`<div class="msg-tool-group">
1239
+ ${headerRow(' msg-tool-group-header', `onclick="toggleToolGroup('${gid}')"`, `<span class="tool-count-badge">×${count}</span>`, '')}
1240
+ <div class="msg-tool-group-items" id="${gid}">${items}</div>
1241
+ </div>`);
1242
+ i = runEnd;
1243
+ continue;
1244
+ }
1245
+
1246
+ // Not collapsed (the enqueue/delivered pair wasn't consecutive): tag the queued
1247
+ // record with the same golden marker used for other queued messages.
1248
+ const queuedTag = m.queued ? '<span class="msg-queued-tag">queued</span>' : '';
1249
+ parts.push(
1250
+ headerRow('', `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})"`, '', queuedTag),
1251
+ );
1252
+ i++;
1253
+ continue;
1254
+ }
1255
+
1078
1256
  const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
1079
1257
  const pinBtn = renderMsgPinBtn(m, i);
1080
1258
  if (m.type === 'user') {
@@ -1108,9 +1286,16 @@ function renderMessageList(messages) {
1108
1286
  </div>`);
1109
1287
  }
1110
1288
  } else if (m.type === 'assistant') {
1289
+ const preview = buildAssistantPreview(m.fullText || m.text);
1290
+ const moreChip = preview.remainder ? `<div class="msg-md-more">${escapeHtml(preview.remainder)}</div>` : '';
1291
+ const bodyHtml = preview.md
1292
+ ? `<div class="msg-text msg-text-md">
1293
+ <div class="msg-md-content rendered-md${preview.remainder ? ' is-truncated' : ''}">${renderMarkdown(preview.md)}</div>${moreChip}
1294
+ </div>`
1295
+ : `<div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div>`;
1111
1296
  parts.push(`<div class="msg-item msg-assistant" ${clickable}>
1112
1297
  ${MSG_ICON_ASSISTANT}
1113
- <div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div><div class="msg-time">${m.model ? `${escapeHtml(m.model)} · ` : ''}${formatDate(m.timestamp)}</div></div>${pinBtn}
1298
+ <div class="msg-body">${bodyHtml}<div class="msg-time">${m.model ? `${escapeHtml(m.model)} · ` : ''}${formatDate(m.timestamp)}</div></div>${pinBtn}
1114
1299
  </div>`);
1115
1300
  } else if (m.type === 'teammate') {
1116
1301
  if (m.teammateId && m.color && !teamColorMap[m.teammateId]) teamColorMap[m.teammateId] = m.color;
@@ -1309,6 +1494,8 @@ const ICON_CHECKMARK =
1309
1494
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 6L9 17l-5-5"/></svg>';
1310
1495
  const ICON_AGENT_WAITING =
1311
1496
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
1497
+ const ICON_AGENT_ACTIVE =
1498
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>';
1312
1499
  const ICON_CHAT =
1313
1500
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
1314
1501
  const TOOL_ICONS = {
@@ -2519,10 +2706,18 @@ function showAgentModal(agentId) {
2519
2706
  return `<span class="agent-chip${cls}"${style}${title}>${labelHtml}<span class="agent-chip-val">${value}</span></span>`;
2520
2707
  };
2521
2708
 
2709
+ // Agent cost stats live on the launch row's message (resolved from the completion
2710
+ // toolUseResult), not on the agent-activity record — so read them from there.
2711
+ const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2712
+ const usageStats = agentMsg?.agentUsageStats || null;
2713
+ const fmtTok = (t) => (t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`);
2714
+
2522
2715
  const chips = [];
2523
2716
  if (agent.agentId) chips.push(chip('id', escapeHtml(shortId), { cls: 'agent-chip-mono', title: agent.agentId }));
2524
2717
  chips.push(chip('', escapeHtml(agent.status), { cls: `agent-chip-status agent-chip-${agent.status}` }));
2525
2718
  chips.push(chip('⏱', formatDuration(elapsed)));
2719
+ if (usageStats?.tokens != null) chips.push(chip('tokens', fmtTok(usageStats.tokens), { cls: 'agent-chip-mono' }));
2720
+ if (usageStats?.tools != null) chips.push(chip('tools', String(usageStats.tools), { cls: 'agent-chip-mono' }));
2526
2721
  if (shortModel) chips.push(chip('model', escapeHtml(shortModel), { cls: 'agent-chip-mono' }));
2527
2722
  if (agent.agentName) {
2528
2723
  const c = getOwnerColor(agent.agentName);
@@ -2535,8 +2730,6 @@ function showAgentModal(agentId) {
2535
2730
  if (started) chips.push(chip('started', started.toLocaleTimeString()));
2536
2731
  if (stopped) chips.push(chip('stopped', stopped.toLocaleTimeString()));
2537
2732
 
2538
- const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2539
-
2540
2733
  let html = `<div class="agent-chips">${chips.join('')}</div>`;
2541
2734
 
2542
2735
  const promptText = stripTeammateWrapper(agentMsg?.agentPrompt || agent.prompt || null);
@@ -2818,6 +3011,7 @@ function renderSessions() {
2818
3011
  ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${session.id}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
2819
3012
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
2820
3013
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3014
+ ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
2821
3015
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
2822
3016
  </span>
2823
3017
  <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
@@ -5162,6 +5356,11 @@ function setupEventSource() {
5162
5356
  refreshProjectAgents();
5163
5357
  } else if (currentSessionId && pendingAgentSessionIds.has(currentSessionId)) {
5164
5358
  fetchAgents(currentSessionId);
5359
+ // An agent starting/stopping adds its Agent tool_use + completion rows to
5360
+ // the transcript. Refresh the message log on the same signal so they appear
5361
+ // at start, not only when an unrelated metadata/context refresh coincides
5362
+ // with completion.
5363
+ if (!agentLogMode) fetchMessages(currentSessionId);
5165
5364
  }
5166
5365
  pendingAgentSessionIds.clear();
5167
5366
  }, 500);
@@ -5787,6 +5986,69 @@ function loadTheme() {
5787
5986
  updateThemeIcon();
5788
5987
  updateThemeColor(document.body.classList.contains('light'));
5789
5988
  syncHljsTheme();
5989
+ buildThemeMenu();
5990
+ const colorTheme = localStorage.getItem('color-theme');
5991
+ if (colorTheme) document.body.dataset.colorTheme = colorTheme;
5992
+ syncColorThemeSelect(colorTheme || 'ember');
5993
+ }
5994
+
5995
+ const COLOR_THEMES = [
5996
+ ['ember', 'Ember'],
5997
+ ['gruvbox', 'Gruvbox'],
5998
+ ['catppuccin', 'Catppuccin'],
5999
+ ['tokyo-night', 'Tokyo Night'],
6000
+ ['solarized', 'Solarized'],
6001
+ ['dracula', 'Dracula'],
6002
+ ['nord', 'Nord'],
6003
+ ['rose-pine', 'Rosé Pine'],
6004
+ ['everforest', 'Everforest'],
6005
+ ['kanagawa', 'Kanagawa'],
6006
+ ['one-dark', 'One Dark'],
6007
+ ['night-owl', 'Night Owl'],
6008
+ ['monokai', 'Monokai Pro'],
6009
+ ['github', 'GitHub'],
6010
+ ['ayu', 'Ayu'],
6011
+ ['vitesse', 'Vitesse'],
6012
+ ['synthwave', "Synthwave '84"],
6013
+ ];
6014
+
6015
+ function buildThemeMenu() {
6016
+ const menu = document.getElementById('themeMenu');
6017
+ menu.innerHTML = COLOR_THEMES.map(
6018
+ ([id, label]) =>
6019
+ `<button type="button" class="theme-menu-item" data-theme-id="${id}"
6020
+ onclick="event.stopPropagation(); setColorTheme('${id}'); toggleThemeMenu()">
6021
+ <span class="theme-swatch theme-swatch-${id}"><i class="sw-bg"></i><i class="sw-accent"></i><i class="sw-ink"></i></span>${label}
6022
+ </button>`,
6023
+ ).join('');
6024
+ }
6025
+
6026
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6027
+ function toggleThemeMenu(e) {
6028
+ e?.stopPropagation();
6029
+ const menu = document.getElementById('themeMenu');
6030
+ const open = menu.classList.toggle('open');
6031
+ if (open) {
6032
+ document.addEventListener('click', () => menu.classList.remove('open'), { once: true });
6033
+ }
6034
+ }
6035
+
6036
+ function syncColorThemeSelect(id) {
6037
+ document.querySelectorAll('.theme-menu-item').forEach((el) => {
6038
+ el.classList.toggle('on', el.dataset.themeId === (id || 'ember'));
6039
+ });
6040
+ }
6041
+
6042
+ // 'ember' (the :root default) has no override block — selecting it clears the attribute.
6043
+ function setColorTheme(id) {
6044
+ if (!id || id === 'ember') {
6045
+ delete document.body.dataset.colorTheme;
6046
+ localStorage.removeItem('color-theme');
6047
+ } else {
6048
+ document.body.dataset.colorTheme = id;
6049
+ localStorage.setItem('color-theme', id);
6050
+ }
6051
+ syncColorThemeSelect(id);
5790
6052
  }
5791
6053
 
5792
6054
  //#endregion
@@ -6866,21 +7128,35 @@ window.hubNavigate = function hubNavigate(app, url) {
6866
7128
 
6867
7129
  (function initHubTheme() {
6868
7130
  const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
7131
+ const getColorTheme = () => document.body.dataset.colorTheme || 'ember';
6869
7132
  const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
7133
+ // lastTheme/lastColorTheme are updated synchronously when applying a hub
7134
+ // message, so the (async) observer sees no diff and doesn't echo it back.
6870
7135
  let lastTheme = getTheme();
7136
+ let lastColorTheme = getColorTheme();
6871
7137
  window.addEventListener('message', (e) => {
6872
7138
  if (e.source !== window.parent || e.origin !== hubOrigin()) return;
6873
7139
  if (e.data?.type !== 'hub:theme') return;
6874
- if (getTheme() === e.data.theme) return;
6875
- window.toggleTheme();
6876
- lastTheme = getTheme();
7140
+ if (typeof e.data.colorTheme === 'string' && e.data.colorTheme !== getColorTheme()) {
7141
+ setColorTheme(e.data.colorTheme);
7142
+ lastColorTheme = getColorTheme();
7143
+ }
7144
+ if (getTheme() !== e.data.theme) {
7145
+ window.toggleTheme();
7146
+ lastTheme = getTheme();
7147
+ }
6877
7148
  });
6878
7149
  new MutationObserver(() => {
6879
7150
  const t = getTheme();
6880
- if (t === lastTheme) return;
7151
+ const ct = getColorTheme();
7152
+ if (t === lastTheme && ct === lastColorTheme) return;
6881
7153
  lastTheme = t;
7154
+ lastColorTheme = ct;
6882
7155
  const origin = hubOrigin();
6883
- if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t }, origin);
6884
- }).observe(document.body, { attributes: true, attributeFilter: ['class'] });
7156
+ if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t, colorTheme: ct }, origin);
7157
+ }).observe(document.body, {
7158
+ attributes: true,
7159
+ attributeFilter: ['class', 'data-color-theme'],
7160
+ });
6885
7161
  })();
6886
7162
  // #endregion HUB_INTEGRATION
package/public/index.html CHANGED
@@ -15,6 +15,7 @@
15
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
16
  <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Playfair+Display:wght@400;500;600&display=swap" rel="stylesheet">
17
17
  <link rel="stylesheet" href="/style.css">
18
+ <link rel="stylesheet" href="/themes.css">
18
19
  <script defer src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
19
20
  <script defer src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
20
21
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css" id="hljs-theme-dark">
@@ -143,6 +144,10 @@
143
144
  <ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
144
145
  </svg>
145
146
  </button>
147
+ <span class="icon-btn theme-picker" id="themePickerBtn" title="Color theme" onclick="toggleThemeMenu(event)">
148
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>
149
+ <div class="theme-menu" id="themeMenu"></div>
150
+ </span>
146
151
  <button id="theme-toggle" class="icon-btn" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
147
152
  <svg id="theme-icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
148
153
  <path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>