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 +103 -3
- package/package.json +1 -1
- package/public/app.js +286 -10
- package/public/index.html +5 -0
- package/public/style.css +144 -8
- package/public/themes.css +823 -0
- package/server.js +40 -1
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
|
-
|
|
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
|
-
...(
|
|
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
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"
|
|
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 (
|
|
6875
|
-
|
|
6876
|
-
|
|
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
|
-
|
|
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, {
|
|
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"/>
|