@luanpdd/kit-mcp 1.3.0 → 1.5.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.
@@ -802,6 +802,154 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
802
802
  }
803
803
  .tw-actions button:hover { color: var(--text); background: var(--surface-3); }
804
804
 
805
+ /* ───────────── tokens chips ───────────── */
806
+ .tokens-chip {
807
+ display: inline-flex; align-items: center; gap: 4px;
808
+ font-family: var(--mono);
809
+ font-size: 11px;
810
+ color: var(--text-2);
811
+ padding: 1px 6px;
812
+ border: 1px solid var(--line);
813
+ border-radius: 999px;
814
+ background: var(--surface-2);
815
+ font-variant-numeric: tabular-nums;
816
+ white-space: nowrap;
817
+ }
818
+ .tokens-chip::before {
819
+ content: "⌬";
820
+ color: var(--text-3);
821
+ font-size: 10px;
822
+ }
823
+ .rc-tokens {
824
+ margin-top: 6px;
825
+ font-family: var(--mono);
826
+ color: var(--text-3);
827
+ font-size: 11px;
828
+ display: flex; gap: 14px;
829
+ align-items: center;
830
+ }
831
+ .rc-tokens .num { color: var(--text-2); font-variant-numeric: tabular-nums; }
832
+
833
+ /* ───────────── history drawer ───────────── */
834
+ .history {
835
+ position: fixed;
836
+ right: 16px; bottom: 16px;
837
+ width: 360px;
838
+ max-height: 70vh;
839
+ background: var(--surface-2);
840
+ border: 1px solid var(--line-strong);
841
+ border-radius: 12px;
842
+ box-shadow: 0 24px 60px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.02);
843
+ z-index: 50;
844
+ overflow: hidden;
845
+ display: none;
846
+ flex-direction: column;
847
+ animation: tweaks-in .25s var(--ease);
848
+ }
849
+ .history.open { display: flex; }
850
+ .history h3 {
851
+ margin: 0;
852
+ padding: 12px 14px;
853
+ font-family: var(--mono);
854
+ font-size: 10px;
855
+ text-transform: uppercase;
856
+ letter-spacing: .14em;
857
+ color: var(--text-3);
858
+ display: flex; align-items: center; gap: 8px;
859
+ border-bottom: 1px solid var(--line);
860
+ flex-shrink: 0;
861
+ }
862
+ .history h3 .hist-meta {
863
+ color: var(--text-2);
864
+ font-size: 10px;
865
+ font-weight: 400;
866
+ text-transform: none;
867
+ letter-spacing: .04em;
868
+ margin-left: auto;
869
+ }
870
+ .history h3 .close {
871
+ width: 18px; height: 18px;
872
+ display: grid; place-items: center;
873
+ border-radius: 4px;
874
+ color: var(--text-3);
875
+ }
876
+ .history h3 .close:hover { background: var(--surface-3); color: var(--text); }
877
+ .history-body { padding: 8px; overflow-y: auto; flex: 1; }
878
+ .hist-empty {
879
+ padding: 24px 12px;
880
+ text-align: center;
881
+ color: var(--text-3);
882
+ font-size: 12px;
883
+ }
884
+ .hist-row {
885
+ display: grid;
886
+ grid-template-columns: 22px 1fr auto;
887
+ gap: 4px 10px;
888
+ padding: 10px;
889
+ border: 1px solid var(--line);
890
+ border-radius: 8px;
891
+ margin-bottom: 6px;
892
+ background: var(--surface-1);
893
+ transition: border-color .15s var(--ease);
894
+ cursor: pointer;
895
+ }
896
+ .hist-row:hover { border-color: var(--line-strong); background: var(--surface-3); }
897
+ .hist-row[data-status="error"] { border-left: 3px solid var(--err); }
898
+ .hist-row[data-status="ok"] { border-left: 3px solid var(--accent); }
899
+ .hist-status {
900
+ width: 18px; height: 18px;
901
+ display: grid; place-items: center;
902
+ border-radius: 999px;
903
+ font-family: var(--mono);
904
+ font-size: 10px;
905
+ font-weight: 700;
906
+ grid-row: 1 / span 1;
907
+ }
908
+ .hist-row[data-status="ok"] .hist-status { color: var(--accent); background: var(--accent-soft); }
909
+ .hist-row[data-status="error"] .hist-status { color: var(--err); background: var(--err-soft); }
910
+ .hist-row[data-status="running"] .hist-status { color: var(--text-2); background: var(--surface-3); }
911
+ .hist-title {
912
+ font-size: 13px;
913
+ color: var(--text);
914
+ font-weight: 500;
915
+ min-width: 0;
916
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
917
+ }
918
+ .hist-when {
919
+ font-family: var(--mono);
920
+ font-size: 10px;
921
+ color: var(--text-3);
922
+ text-align: right;
923
+ grid-row: 1 / span 1;
924
+ }
925
+ .hist-meta-row {
926
+ grid-column: 2 / span 2;
927
+ display: flex; gap: 12px; flex-wrap: wrap;
928
+ font-family: var(--mono);
929
+ font-size: 10px;
930
+ color: var(--text-3);
931
+ }
932
+ .hist-meta-row .num { color: var(--text-2); font-variant-numeric: tabular-nums; }
933
+ .hist-detail {
934
+ grid-column: 1 / -1;
935
+ margin-top: 6px;
936
+ padding: 8px;
937
+ border: 1px solid var(--line);
938
+ border-radius: 6px;
939
+ background: var(--surface-2);
940
+ font-family: var(--mono);
941
+ font-size: 11px;
942
+ color: var(--text-2);
943
+ max-height: 240px;
944
+ overflow-y: auto;
945
+ display: none;
946
+ }
947
+ .hist-row.expanded .hist-detail { display: block; }
948
+ .hist-detail-row { padding: 2px 0; display: flex; gap: 6px; }
949
+ .hist-detail-row .pct { color: var(--accent); flex-shrink: 0; min-width: 36px; font-variant-numeric: tabular-nums; }
950
+ .hist-detail-row .lbl { color: var(--text-2); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
951
+ .hist-detail-row .tok { color: var(--text-3); flex-shrink: 0; }
952
+
805
953
  /* density-driven log padding handled via --pad-tight above */
806
954
 
807
955
  /* ───────────────────────── reduced motion ───────────────────────── */
@@ -868,6 +1016,11 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
868
1016
  <rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>
869
1017
  </svg>
870
1018
  </button>
1019
+ <button class="iconbtn" id="history-btn" aria-pressed="false" aria-label="Histórico desta sessão" title="Histórico desta sessão">
1020
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1021
+ <path d="M3 12a9 9 0 1 0 3-7"/><path d="M3 4v5h5"/><path d="M12 7v5l3 2"/>
1022
+ </svg>
1023
+ </button>
871
1024
  <button class="iconbtn" id="tweaks-btn" aria-pressed="false" aria-label="Tweaks" title="Tweaks">
872
1025
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
873
1026
  <circle cx="12" cy="12" r="3"/>
@@ -910,6 +1063,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
910
1063
  <span>fonte: <span class="live" id="src-label">ao vivo</span></span>
911
1064
  <span class="sep">·</span>
912
1065
  <span id="last-seen">aguardando…</span>
1066
+ <span class="sep" id="footer-tokens-sep" hidden>·</span>
1067
+ <span id="footer-tokens" hidden>0 tokens</span>
1068
+ <span class="sep">·</span>
1069
+ <span id="footer-runs">0 runs concluídas</span>
913
1070
  </footer>
914
1071
 
915
1072
  </div>
@@ -953,23 +1110,26 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
953
1110
  </div>
954
1111
  </div>
955
1112
 
956
- <div class="tw-group">
957
- <span class="tw-label">Cenário (mock)</span>
958
- <div class="seg" id="tw-scenario">
959
- <button data-v="sync" aria-pressed="true">Sync</button>
960
- <button data-v="multi">Multi</button>
961
- <button data-v="error">Erro</button>
962
- <button data-v="idle">Idle</button>
963
- </div>
964
- </div>
965
-
966
1113
  <div class="tw-actions">
967
- <button id="tw-replay">▸ replay</button>
968
- <button id="tw-clear">limpar</button>
1114
+ <button id="tw-clear">limpar tela</button>
969
1115
  </div>
970
1116
  </div>
971
1117
  </aside>
972
1118
 
1119
+ <!-- HISTORY DRAWER -->
1120
+ <aside class="history" id="history" role="dialog" aria-label="Histórico da sessão">
1121
+ <h3>
1122
+ Histórico desta sessão
1123
+ <span class="hist-meta" id="hist-meta">0 runs</span>
1124
+ <button class="close" id="history-close" aria-label="Fechar">
1125
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
1126
+ </button>
1127
+ </h3>
1128
+ <div class="history-body" id="history-body">
1129
+ <div class="hist-empty">Nada por aqui ainda. Quando uma run terminar, ela aparece aqui.</div>
1130
+ </div>
1131
+ </aside>
1132
+
973
1133
  <!-- SVG sprite (in-doc, no external) -->
974
1134
  <svg width="0" height="0" style="position:absolute" aria-hidden="true">
975
1135
  <defs>
@@ -1057,11 +1217,13 @@ function clockTime(ts) {
1057
1217
  /* ---------- state ---------- */
1058
1218
  const state = {
1059
1219
  events: [], // newest first
1060
- runs: new Map(), // runId → { startTs, tool, lastProgress, lastLabel, ended, ok, current, total }
1220
+ runs: new Map(), // runId → { startTs, tool, lastProgress, lastLabel, ended, ok, current, total, tokens, endTs }
1061
1221
  filterText: "",
1062
1222
  // tool_invocation + shutdown não têm chip de filtro, mas devem aparecer por default
1063
1223
  filters: new Set(["run.start","progress","milestone","run.end","error","tool_invocation","shutdown"]),
1064
1224
  paused: false,
1225
+ totalTokens: 0, // soma de payload.tokens em TODOS os eventos da sessão
1226
+ history: [], // runs já concluídas neste tab — persistido em sessionStorage
1065
1227
  };
1066
1228
 
1067
1229
  /* ---------- DOM refs ---------- */
@@ -1085,6 +1247,14 @@ const els = {
1085
1247
  tweaks: $("#tweaks"),
1086
1248
  tweaksClose: $("#tweaks-close"),
1087
1249
  srcLabel: $("#src-label"),
1250
+ historyBtn: $("#history-btn"),
1251
+ history: $("#history"),
1252
+ historyClose: $("#history-close"),
1253
+ historyBody: $("#history-body"),
1254
+ histMeta: $("#hist-meta"),
1255
+ footerTokens: $("#footer-tokens"),
1256
+ footerTokensSep: $("#footer-tokens-sep"),
1257
+ footerRuns: $("#footer-runs"),
1088
1258
  };
1089
1259
 
1090
1260
  /* ---------- ingest one event ---------- */
@@ -1093,16 +1263,24 @@ function ingest(evt) {
1093
1263
  state.events.unshift(evt);
1094
1264
  if (state.events.length > 200) state.events.length = 200;
1095
1265
 
1266
+ // tokens — soma global da sessão + por run
1267
+ const tk = readTokens(evt);
1268
+ if (tk > 0) state.totalTokens += tk;
1269
+
1096
1270
  // run tracking
1097
1271
  if (evt.runId) {
1098
1272
  let run = state.runs.get(evt.runId);
1099
1273
  if (!run) {
1100
- run = { runId: evt.runId, startTs: evt.ts, tool: null, lastProgress: 0, lastLabel: "", ended: false, ok: true, current: 0, total: 0, lastTs: evt.ts };
1274
+ run = { runId: evt.runId, startTs: evt.ts, tool: null, lastProgress: 0, lastLabel: "", ended: false, ok: true, current: 0, total: 0, tokens: 0, events: [], lastTs: evt.ts };
1101
1275
  state.runs.set(evt.runId, run);
1102
1276
  }
1103
1277
  run.lastTs = evt.ts;
1278
+ run.tokens = (run.tokens || 0) + tk;
1279
+ // store a slimmed-down record of every event in the run so the history
1280
+ // drawer can show what happened later
1281
+ run.events.push({ ts: evt.ts, type: evt.type, percent: evt.payload?.percent, label: evt.payload?.label, name: evt.payload?.name, message: evt.payload?.message, tokens: tk });
1104
1282
  if (evt.type === "run.start") {
1105
- run.tool = evt.payload?.tool;
1283
+ run.tool = evt.payload?.tool || run.tool;
1106
1284
  run.startTs = evt.ts;
1107
1285
  } else if (evt.type === "progress") {
1108
1286
  if (typeof evt.payload?.percent === "number") run.lastProgress = evt.payload.percent;
@@ -1112,9 +1290,13 @@ function ingest(evt) {
1112
1290
  } else if (evt.type === "run.end") {
1113
1291
  run.ended = true;
1114
1292
  run.ok = !!evt.payload?.ok;
1293
+ run.endTs = evt.ts;
1115
1294
  run.lastProgress = run.ok ? 100 : run.lastProgress;
1295
+ // arquive into history (client-only, sessionStorage)
1296
+ archiveRun(run);
1116
1297
  } else if (evt.type === "error") {
1117
- // don't end the run on error event alone; matches server semantics
1298
+ // não termina o run; registra
1299
+ run.lastError = evt.payload?.message || "erro";
1118
1300
  }
1119
1301
  }
1120
1302
 
@@ -1122,6 +1304,55 @@ function ingest(evt) {
1122
1304
  els.lastSeen.textContent = "último: " + clockTime(evt.ts);
1123
1305
  }
1124
1306
 
1307
+ /* ---------- helpers — tokens & history ---------- */
1308
+ function readTokens(evt) {
1309
+ // Aceita várias chaves comuns: payload.tokens (preferido), payload.usage.total_tokens, payload.cost.tokens
1310
+ const p = evt.payload || {};
1311
+ if (typeof p.tokens === "number") return p.tokens;
1312
+ if (p.usage && typeof p.usage.total_tokens === "number") return p.usage.total_tokens;
1313
+ if (p.cost && typeof p.cost.tokens === "number") return p.cost.tokens;
1314
+ return 0;
1315
+ }
1316
+
1317
+ function fmtTokens(n) {
1318
+ if (n < 1000) return String(n);
1319
+ if (n < 10_000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
1320
+ if (n < 1_000_000) return Math.round(n / 1000) + "k";
1321
+ return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
1322
+ }
1323
+
1324
+ function archiveRun(run) {
1325
+ // Snapshot leve da run completa — o que vai pra sessionStorage tem que ser serializável.
1326
+ const entry = {
1327
+ runId: run.runId,
1328
+ tool: run.tool,
1329
+ startTs: run.startTs,
1330
+ endTs: run.endTs,
1331
+ durationMs: (run.endTs || Date.now()) - run.startTs,
1332
+ ok: run.ok,
1333
+ tokens: run.tokens || 0,
1334
+ eventsCount: run.events.length,
1335
+ events: run.events.slice(-100), // últimos 100 eventos da run pra detalhe
1336
+ };
1337
+ state.history.unshift(entry);
1338
+ // cap em 50 runs pra não estourar sessionStorage
1339
+ if (state.history.length > 50) state.history.length = 50;
1340
+ saveHistory();
1341
+ }
1342
+
1343
+ function saveHistory() {
1344
+ try {
1345
+ sessionStorage.setItem("kit-mcp-history", JSON.stringify(state.history));
1346
+ } catch (_) { /* quota? sem espaço? sem stress. */ }
1347
+ }
1348
+
1349
+ function loadHistory() {
1350
+ try {
1351
+ const raw = sessionStorage.getItem("kit-mcp-history");
1352
+ if (raw) state.history = JSON.parse(raw) || [];
1353
+ } catch (_) { state.history = []; }
1354
+ }
1355
+
1125
1356
  /* ---------- render ---------- */
1126
1357
  function passesFilter(evt) {
1127
1358
  if (!state.filters.has(evt.type)) return false;
@@ -1137,6 +1368,64 @@ function render() {
1137
1368
  const total = state.events.length;
1138
1369
  els.evtCount.textContent = `${total} evento${total === 1 ? "" : "s"}`;
1139
1370
  els.logCount.textContent = `${total} evento${total === 1 ? "" : "s"}`;
1371
+ // Tokens (mostra só quando algum evento da sessão veio com payload.tokens)
1372
+ if (state.totalTokens > 0) {
1373
+ els.footerTokens.hidden = false;
1374
+ els.footerTokensSep.hidden = false;
1375
+ els.footerTokens.textContent = `${fmtTokens(state.totalTokens)} tokens nesta sessão`;
1376
+ } else {
1377
+ els.footerTokens.hidden = true;
1378
+ els.footerTokensSep.hidden = true;
1379
+ }
1380
+ const completed = state.history.length;
1381
+ els.footerRuns.textContent = `${completed} run${completed === 1 ? "" : "s"} concluída${completed === 1 ? "" : "s"}`;
1382
+ if (els.histMeta) els.histMeta.textContent = `${completed} run${completed === 1 ? "" : "s"}`;
1383
+ }
1384
+
1385
+ /* ---------- history drawer renderer ---------- */
1386
+ function renderHistory() {
1387
+ if (!els.historyBody) return;
1388
+ if (state.history.length === 0) {
1389
+ els.historyBody.innerHTML = `<div class="hist-empty">Nada por aqui ainda. Quando uma run terminar, ela aparece aqui.</div>`;
1390
+ return;
1391
+ }
1392
+ const rows = state.history.map((h) => historyRowHtml(h)).join("");
1393
+ els.historyBody.innerHTML = rows;
1394
+ els.historyBody.querySelectorAll(".hist-row").forEach((row) => {
1395
+ row.addEventListener("click", () => row.classList.toggle("expanded"));
1396
+ });
1397
+ }
1398
+
1399
+ function historyRowHtml(h) {
1400
+ const status = h.ok === true ? "ok" : h.ok === false ? "error" : "running";
1401
+ const statusGlyph = status === "ok" ? "✓" : status === "error" ? "✕" : "·";
1402
+ const title = humanizeTool(safeStr(h.tool || "")) || safeStr(h.tool) || "Processo";
1403
+ const dur = formatElapsed(h.durationMs || 0);
1404
+ const when = clockTime(h.startTs);
1405
+ const tokens = h.tokens > 0 ? `<span class="num">${fmtTokens(h.tokens)}</span> tokens` : "<span>sem tokens registrados</span>";
1406
+ const detailRows = (h.events || [])
1407
+ .filter((e) => e.type === "progress" || e.type === "milestone" || e.type === "error" || e.type === "run.end")
1408
+ .map((e) => {
1409
+ const pct = typeof e.percent === "number" ? `${Math.round(e.percent)}%` : "·";
1410
+ const lbl = safeStr(e.label) || safeStr(e.name) || safeStr(e.message) || humanizeEventType(e.type);
1411
+ const tk = e.tokens > 0 ? fmtTokens(e.tokens) : "";
1412
+ return `<div class="hist-detail-row"><span class="pct">${escapeHtml(pct)}</span><span class="lbl">${escapeHtml(lbl)}</span>${tk ? `<span class="tok">${tk}t</span>` : ""}</div>`;
1413
+ })
1414
+ .join("");
1415
+ return `
1416
+ <div class="hist-row" data-status="${status}" data-runid="${h.runId}">
1417
+ <div class="hist-status">${statusGlyph}</div>
1418
+ <div class="hist-title">${escapeHtml(title)}</div>
1419
+ <div class="hist-when">${when}</div>
1420
+ <div class="hist-meta-row">
1421
+ <span><span class="num">${dur}</span> dur</span>
1422
+ <span>${tokens}</span>
1423
+ <span><span class="num">${h.eventsCount || 0}</span> eventos</span>
1424
+ <span class="num">id ${h.runId.slice(0,8)}</span>
1425
+ </div>
1426
+ <div class="hist-detail">${detailRows || '<div class="hist-detail-row"><span class="lbl">(sem progresso registrado)</span></div>'}</div>
1427
+ </div>
1428
+ `;
1140
1429
  }
1141
1430
 
1142
1431
  function renderActive() {
@@ -1165,18 +1454,44 @@ function renderActive() {
1165
1454
  });
1166
1455
  }
1167
1456
 
1457
+ /**
1458
+ * Sanitização: remove o "U+FFFD replacement character" e variantes que
1459
+ * indicam mojibake — eventos vindos de publishers em locale ruim podem
1460
+ * vazar bytes corrompidos. Em vez de mostrar `�`, devolvemos string vazia
1461
+ * e deixamos o fallback escolher um nome decente.
1462
+ */
1463
+ function safeStr(s) {
1464
+ if (typeof s !== "string") return "";
1465
+ // remove FFFD (U+FFFD) e null bytes
1466
+ const cleaned = s.replace(/�+/g, "").replace(/+/g, "").trim();
1467
+ return cleaned;
1468
+ }
1469
+
1470
+ /**
1471
+ * Resolve o melhor título legível para uma run, NUNCA devolve "—" sozinho.
1472
+ * Cascata: humanizeTool(payload.tool) → payload.title → payload.label →
1473
+ * payload.name → "Processo".
1474
+ */
1475
+ function runTitle(run) {
1476
+ const t = humanizeTool(safeStr(run.tool || ""));
1477
+ if (t && t !== "—") return t;
1478
+ const fallback = safeStr(run.lastTitle) || safeStr(run.lastLabel) || safeStr(run.lastName);
1479
+ if (fallback) return fallback;
1480
+ return "Processo";
1481
+ }
1482
+
1168
1483
  function updateActiveCard(card, run) {
1169
1484
  const family = TOOL_FAMILIES[run.tool] || "sync";
1170
1485
  const percent = Math.max(0, Math.min(100, Math.round(run.lastProgress)));
1171
1486
  const elapsed = Date.now() - run.startTs;
1172
1487
  const longRunning = elapsed > 30_000;
1173
- const stepLabel = humanizePath(run.lastLabel) || "iniciando…";
1488
+ const stepLabel = humanizePath(safeStr(run.lastLabel)) || "iniciando…";
1174
1489
  const stepCount = run.total ? `${run.current}/${run.total}` : "";
1175
1490
 
1176
1491
  const set = (sel, fn) => { const n = card.querySelector(sel); if (n) fn(n); };
1177
1492
  set(".rc-icon", (n) => { n.dataset.tool = family; n.querySelector("use")?.setAttribute("href", `#i-${family}`); });
1178
- set(".rc-tool", (n) => { n.textContent = run.tool || ""; });
1179
- set(".rc-title", (n) => { n.textContent = humanizeTool(run.tool || "—"); });
1493
+ set(".rc-tool", (n) => { n.textContent = safeStr(run.tool) || "processo"; });
1494
+ set(".rc-title", (n) => { n.textContent = runTitle(run); });
1180
1495
  set(".rc-bar-fill", (n) => { n.style.width = percent + "%"; });
1181
1496
  set(".rc-pct", (n) => { n.textContent = percent + "%"; });
1182
1497
  set(".rc-step-text", (n) => { if (n.textContent !== stepLabel) n.textContent = stepLabel; });
@@ -1184,24 +1499,27 @@ function updateActiveCard(card, run) {
1184
1499
  set(".rc-elapsed-val", (n) => { n.textContent = formatElapsed(elapsed); });
1185
1500
  set(".rc-elapsed .em", (n) => n.classList.toggle("warn", longRunning));
1186
1501
  set(".rc-runid", (n) => { n.textContent = "id " + run.runId.slice(0, 8); });
1502
+ set(".rc-tokens-val", (n) => { n.textContent = run.tokens > 0 ? fmtTokens(run.tokens) : "—"; });
1503
+ set(".rc-tokens", (n) => { n.classList.toggle("hidden", !(run.tokens > 0)); });
1187
1504
  }
1188
1505
 
1189
1506
  function activeCardHtml(run) {
1190
1507
  const family = TOOL_FAMILIES[run.tool] || "sync";
1191
1508
  const iconHref = `#i-${family}`;
1192
- const title = humanizeTool(run.tool || "—");
1193
- const stepLabel = humanizePath(run.lastLabel) || "iniciando…";
1509
+ const title = runTitle(run);
1510
+ const stepLabel = humanizePath(safeStr(run.lastLabel)) || "iniciando…";
1194
1511
  const percent = Math.max(0, Math.min(100, Math.round(run.lastProgress)));
1195
1512
  const elapsed = Date.now() - run.startTs;
1196
1513
  const longRunning = elapsed > 30_000;
1197
1514
  const stepCount = run.total ? `${run.current}/${run.total}` : "";
1515
+ const showTokens = run.tokens > 0;
1198
1516
  return `
1199
1517
  <article class="run-card" data-runid="${run.runId}">
1200
1518
  <div class="rc-head">
1201
1519
  <div class="rc-icon" data-tool="${family}"><svg><use href="${iconHref}"/></svg></div>
1202
1520
  <div class="rc-title-block">
1203
- <div class="rc-tool">${run.tool || ""}</div>
1204
- <div class="rc-title">${title}</div>
1521
+ <div class="rc-tool">${escapeHtml(safeStr(run.tool) || "processo")}</div>
1522
+ <div class="rc-title">${escapeHtml(title)}</div>
1205
1523
  </div>
1206
1524
  <div class="rc-elapsed">
1207
1525
  <span class="em ${longRunning ? "warn" : ""}"><span class="rc-elapsed-val">${formatElapsed(elapsed)}</span></span>
@@ -1220,10 +1538,15 @@ function activeCardHtml(run) {
1220
1538
  ${stepCount ? `<span class="rc-step-count">${stepCount}</span>` : ""}
1221
1539
  </div>
1222
1540
 
1541
+ <div class="rc-tokens" ${showTokens ? "" : "style=\"display:none\""}>
1542
+ <span class="tokens-chip"><span class="rc-tokens-val">${showTokens ? fmtTokens(run.tokens) : "—"}</span></span>
1543
+ <span>tokens nesta run</span>
1544
+ </div>
1545
+
1223
1546
  <div class="rc-foot">
1224
1547
  <span class="rc-runid">id ${run.runId.slice(0, 8)}</span>
1225
1548
  <span class="sep">·</span>
1226
- <span>${run.tool || ""}</span>
1549
+ <span>${escapeHtml(safeStr(run.tool) || "")}</span>
1227
1550
  </div>
1228
1551
  </article>
1229
1552
  `;
@@ -1296,39 +1619,71 @@ function rowHtml(evt, idx, prev) {
1296
1619
  const time = clockTime(evt.ts);
1297
1620
  const rel = relTime(Date.now() - evt.ts);
1298
1621
  const badge = humanizeEventType(evt.type);
1299
- const isNew = (Date.now() - evt.ts) < 1500;
1622
+ const tk = readTokens(evt);
1623
+ const tokenChip = tk > 0 ? ` <span class="tokens-chip" title="tokens">${fmtTokens(tk)}</span>` : "";
1624
+ /**
1625
+ * Cascata defensiva pra NUNCA renderizar uma row sem texto:
1626
+ * 1) campo específico do tipo (name pra milestone, message pra error, …)
1627
+ * 2) payload.label
1628
+ * 3) payload.title
1629
+ * 4) humanizeTool(payload.tool)
1630
+ * 5) o tipo humanizado em si (último recurso — "Marco", "Erro", "Iniciado")
1631
+ */
1632
+ const fallback = (...candidates) => {
1633
+ for (const c of candidates) {
1634
+ const s = safeStr(typeof c === "string" ? c : (c == null ? "" : String(c)));
1635
+ if (s) return s;
1636
+ }
1637
+ return badge.toLowerCase();
1638
+ };
1639
+
1300
1640
  let msg = "";
1301
1641
  switch (evt.type) {
1302
1642
  case "run.start": {
1303
- msg = `<strong>${escapeHtml(humanizeTool(evt.payload?.tool))}</strong> <span class="ident">${escapeHtml(evt.payload?.tool || "")}</span>${evt.payload?.target ? ` <span class="arrow">→</span> <span class="ident">${escapeHtml(evt.payload.target)}</span>` : ""}`;
1643
+ const tool = safeStr(evt.payload?.tool || "");
1644
+ const title = humanizeTool(tool) || fallback(evt.payload?.title, evt.payload?.label, evt.payload?.name);
1645
+ const ident = tool ? ` <span class="ident">${escapeHtml(tool)}</span>` : "";
1646
+ const target = evt.payload?.target ? ` <span class="arrow">→</span> <span class="ident">${escapeHtml(safeStr(evt.payload.target))}</span>` : "";
1647
+ msg = `<strong>${escapeHtml(title)}</strong>${ident}${target}`;
1304
1648
  break;
1305
1649
  }
1306
1650
  case "run.end": {
1307
1651
  const dur = evt.payload?.duration_ms;
1308
- msg = `<strong>${escapeHtml(humanizeTool(evt.payload?.tool))}</strong> ${ok ? "concluído" : "falhou"}${dur ? ` <span class="ident">${(dur/1000).toFixed(2)}s</span>` : ""}`;
1652
+ const tool = safeStr(evt.payload?.tool || "");
1653
+ const title = humanizeTool(tool) || fallback(evt.payload?.title, evt.payload?.label, evt.payload?.name);
1654
+ msg = `<strong>${escapeHtml(title)}</strong> ${ok ? "concluído" : "falhou"}${dur ? ` <span class="ident">${(dur/1000).toFixed(2)}s</span>` : ""}`;
1309
1655
  break;
1310
1656
  }
1311
1657
  case "progress": {
1312
1658
  const pct = typeof evt.payload?.percent === "number" ? `${Math.round(evt.payload.percent)}%` : "";
1313
- const lbl = evt.payload?.label || "";
1659
+ const lbl = fallback(evt.payload?.label, evt.payload?.title, evt.payload?.name, "em andamento");
1314
1660
  const ct = evt.payload?.current && evt.payload?.total ? ` <span class="ident">${evt.payload.current}/${evt.payload.total}</span>` : "";
1315
1661
  msg = `${pct ? `<strong>${pct}</strong> ` : ""}<span class="path">${escapeHtml(humanizePath(lbl))}</span>${ct}`;
1316
1662
  break;
1317
1663
  }
1318
1664
  case "milestone": {
1319
- msg = `<strong>${escapeHtml(evt.payload?.name || "")}</strong>`;
1665
+ const lbl = fallback(evt.payload?.name, evt.payload?.title, evt.payload?.label, "marco");
1666
+ msg = `<strong>${escapeHtml(lbl)}</strong>`;
1320
1667
  break;
1321
1668
  }
1322
1669
  case "error": {
1323
- msg = `<strong>${escapeHtml(evt.payload?.message || "erro")}</strong>${evt.payload?.code ? ` <span class="ident">${evt.payload.code}</span>` : ""}`;
1670
+ const lbl = fallback(evt.payload?.message, evt.payload?.title, evt.payload?.label, evt.payload?.name, "erro");
1671
+ const code = evt.payload?.code ? ` <span class="ident">${escapeHtml(safeStr(evt.payload.code))}</span>` : "";
1672
+ msg = `<strong>${escapeHtml(lbl)}</strong>${code}`;
1324
1673
  break;
1325
1674
  }
1326
1675
  case "tool_invocation": {
1327
- msg = `<span class="ident">${escapeHtml(evt.payload?.tool || "")}</span>`;
1676
+ const tool = safeStr(evt.payload?.tool || "");
1677
+ const title = humanizeTool(tool) || fallback(evt.payload?.title, evt.payload?.label, "ferramenta invocada");
1678
+ msg = `<strong>${escapeHtml(title)}</strong>${tool ? ` <span class="ident">${escapeHtml(tool)}</span>` : ""}`;
1679
+ break;
1680
+ }
1681
+ case "shutdown": {
1682
+ msg = `<strong>${escapeHtml(fallback(evt.payload?.message, "sidecar encerrado"))}</strong>`;
1328
1683
  break;
1329
1684
  }
1330
1685
  default:
1331
- msg = "";
1686
+ msg = `<span class="ident">${escapeHtml(badge.toLowerCase())}</span>`;
1332
1687
  }
1333
1688
  return `
1334
1689
  <div class="tl-row" data-type="${evt.type}" data-ok="${ok}" data-grouped="${grouped}">
@@ -1337,6 +1692,7 @@ function rowHtml(evt, idx, prev) {
1337
1692
  <div class="tl-content">
1338
1693
  <span class="tl-badge">${badge}</span>
1339
1694
  <span class="tl-msg">${msg}</span>
1695
+ ${tokenChip}
1340
1696
  ${evt.runId ? `<span class="tl-runid">${evt.runId.slice(0,6)}</span>` : ""}
1341
1697
  </div>
1342
1698
  </div>
@@ -1365,104 +1721,14 @@ setInterval(() => {
1365
1721
  });
1366
1722
  }, 1000);
1367
1723
 
1368
- /* ───────────────────── mock event generator ───────────────────── */
1369
- let mockTimers = [];
1370
- function clearMock() {
1371
- mockTimers.forEach(clearTimeout);
1372
- mockTimers = [];
1373
- }
1374
- function later(ms, fn) { mockTimers.push(setTimeout(fn, ms)); }
1375
-
1376
- function genRunId() { return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 8); }
1377
-
1378
- function scenarioSync(delay = 0) {
1379
- const runId = genRunId();
1380
- const tool = "sync.install";
1381
- const start = Date.now() + delay;
1382
- const target = "claude-code";
1383
- later(delay, () => ingest({ type: "run.start", ts: start, runId, payload: { tool, target } }));
1384
-
1385
- const total = 19;
1386
- const labels = [
1387
- "lendo agente planner",
1388
- "analisando dependências",
1389
- "projetando framework",
1390
- "compilando templates",
1391
- "writing .claude/agents/planner.md",
1392
- "writing .claude/agents/builder.md",
1393
- "writing .claude/agents/reviewer.md",
1394
- "writing .claude/commands/sync.md",
1395
- "validando schema",
1396
- "merging com kit base",
1397
- "writing .claude/agents/coordinator.md",
1398
- "writing .claude/agents/specialist.md",
1399
- "configurando hooks",
1400
- "writing .claude/agents/architect.md",
1401
- "writing .claude/agents/qa.md",
1402
- "writing .claude/agents/docs.md",
1403
- "selando manifest",
1404
- "rodando linter final",
1405
- "gravando metadata",
1406
- ];
1407
- for (let i = 0; i < total; i++) {
1408
- const t = delay + 220 + i * 540;
1409
- later(t, () => ingest({
1410
- type: "progress", ts: Date.now(), runId,
1411
- payload: { percent: Math.round(((i + 1) / total) * 100), label: labels[i], current: i + 1, total, kind: "fs" }
1412
- }));
1413
- }
1414
- later(delay + 220 + total * 540 + 100, () => ingest({
1415
- type: "milestone", ts: Date.now(), runId, payload: { name: `✓ ${total} agentes projetados` }
1416
- }));
1417
- later(delay + 220 + total * 540 + 600, () => ingest({
1418
- type: "run.end", ts: Date.now(), runId, payload: { tool, ok: true, duration_ms: 220 + total * 540 + 400 }
1419
- }));
1420
- }
1421
-
1422
- function scenarioMulti() {
1423
- scenarioSync(0);
1424
- // gates run starting in parallel
1425
- const runId = genRunId();
1426
- later(800, () => ingest({ type: "run.start", ts: Date.now(), runId, payload: { tool: "gates.run", target: "claude-code" } }));
1427
- const stages = ["lint", "typecheck", "schema", "smoke"];
1428
- stages.forEach((s, i) => {
1429
- later(800 + (i + 1) * 1100, () => ingest({
1430
- type: "progress", ts: Date.now(), runId,
1431
- payload: { percent: Math.round(((i + 1) / stages.length) * 100), label: `gate: ${s}`, current: i + 1, total: stages.length, kind: "task" }
1432
- }));
1433
- });
1434
- later(800 + stages.length * 1100 + 300, () => ingest({
1435
- type: "run.end", ts: Date.now(), runId, payload: { tool: "gates.run", ok: true, duration_ms: stages.length * 1100 }
1436
- }));
1437
- }
1438
-
1439
- function scenarioError() {
1440
- const runId = genRunId();
1441
- later(0, () => ingest({ type: "run.start", ts: Date.now(), runId, payload: { tool: "reverse.scan", target: "cursor" } }));
1442
- later(400, () => ingest({ type: "progress", ts: Date.now(), runId, payload: { percent: 18, label: "lendo .cursor/rules/", current: 2, total: 11, kind: "fs" } }));
1443
- later(900, () => ingest({ type: "progress", ts: Date.now(), runId, payload: { percent: 38, label: "scanning .cursor/rules/architecture.mdc", current: 4, total: 11, kind: "fs" } }));
1444
- later(1500, () => ingest({ type: "error", ts: Date.now(), runId, payload: { message: "ENOENT: file not found", code: "ENOENT" } }));
1445
- later(1700, () => ingest({ type: "run.end", ts: Date.now(), runId, payload: { tool: "reverse.scan", ok: false, duration_ms: 1700 } }));
1446
- }
1447
-
1448
- function scenarioIdle() {
1449
- /* nothing */
1450
- }
1451
-
1452
- let currentScenario = "sync";
1453
- function runScenario(name) {
1454
- currentScenario = name;
1455
- if (name === "sync") scenarioSync();
1456
- else if (name === "multi") scenarioMulti();
1457
- else if (name === "error") scenarioError();
1458
- else if (name === "idle") scenarioIdle();
1459
- }
1460
-
1724
+ /* ───────────────────── view actions ───────────────────── */
1725
+ /* "limpar tela" zera o que está VISÍVEL (eventos correntes + cards ativos),
1726
+ mas NUNCA toca o histórico de runs concluídas — esse é o ponto do drawer. */
1461
1727
  function clearAll() {
1462
- clearMock();
1463
1728
  state.events = [];
1464
1729
  state.runs.clear();
1465
1730
  state._seenKeys = new Set();
1731
+ state.totalTokens = 0;
1466
1732
  els.active.innerHTML = "";
1467
1733
  render();
1468
1734
  els.lastSeen.textContent = "aguardando…";
@@ -1498,14 +1764,30 @@ function wireSeg(rootSel, attr, fn) {
1498
1764
  }
1499
1765
  wireSeg("#tw-density", "v", (v) => document.documentElement.dataset.density = v);
1500
1766
  wireSeg("#tw-motion", "v", (v) => document.documentElement.dataset.motion = v);
1501
- wireSeg("#tw-scenario","v", (v) => { clearAll(); runScenario(v); });
1502
1767
 
1503
1768
  document.documentElement.dataset.density = "normal";
1504
1769
  document.documentElement.dataset.motion = "medium";
1505
1770
 
1506
- document.getElementById("tw-replay").addEventListener("click", () => { clearAll(); runScenario(currentScenario); });
1507
1771
  document.getElementById("tw-clear").addEventListener("click", clearAll);
1508
1772
 
1773
+ /* ───────────────────── history wiring ───────────────────── */
1774
+ els.historyBtn.addEventListener("click", () => {
1775
+ const open = els.history.classList.toggle("open");
1776
+ els.historyBtn.setAttribute("aria-pressed", open);
1777
+ if (open) renderHistory();
1778
+ });
1779
+ els.historyClose.addEventListener("click", () => {
1780
+ els.history.classList.remove("open");
1781
+ els.historyBtn.setAttribute("aria-pressed", "false");
1782
+ });
1783
+ // Esc fecha o drawer se ele estiver aberto e nada mais focado
1784
+ document.addEventListener("keydown", (e) => {
1785
+ if (e.key === "Escape" && els.history.classList.contains("open") && document.activeElement !== els.q) {
1786
+ els.history.classList.remove("open");
1787
+ els.historyBtn.setAttribute("aria-pressed", "false");
1788
+ }
1789
+ });
1790
+
1509
1791
  /* ───────────────────── toolbar wiring ───────────────────── */
1510
1792
  els.q.addEventListener("input", (e) => { state.filterText = e.target.value.trim(); render(); });
1511
1793
  document.addEventListener("keydown", (e) => {
@@ -1671,22 +1953,14 @@ function showShutdownBanner() {
1671
1953
  }
1672
1954
 
1673
1955
  /* ───────────────────── boot ───────────────────── */
1674
- /* In production we DON'T auto-run the mock the real /events stream is the
1675
- source of truth. The mock scenarios are still wired and accessible through
1676
- the tweaks panel for demo/dev. */
1956
+ /* `/events` SSE é a única fonte de verdade. Nenhum mock no boot. */
1677
1957
 
1958
+ loadHistory(); // restaura o histórico desta sessão (sessionStorage)
1678
1959
  (async () => {
1679
1960
  await hydrateFromState();
1680
1961
  connectRealSource();
1962
+ render(); // pinta footer + drawer mesmo antes do primeiro evento real
1681
1963
  })();
1682
-
1683
- // Detect "we're being served via http(s)" — when not, we're probably the
1684
- // design preview opened via file://; in that case fall back to the mock.
1685
- if (location.protocol === "file:") {
1686
- setTimeout(() => {
1687
- if (state.events.length === 0) runScenario("sync");
1688
- }, 200);
1689
- }
1690
1964
  </script>
1691
1965
  </body>
1692
1966
  </html>