@geminixiang/mikan 0.5.3 → 1.0.0-beta.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -2
  3. package/dist/adapter.d.ts +1 -1
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/adapter.js.map +1 -1
  6. package/dist/agent.d.ts +10 -1
  7. package/dist/agent.d.ts.map +1 -1
  8. package/dist/agent.js +153 -95
  9. package/dist/agent.js.map +1 -1
  10. package/dist/commands/model.d.ts +2 -2
  11. package/dist/commands/model.d.ts.map +1 -1
  12. package/dist/commands/model.js.map +1 -1
  13. package/dist/commands/registry.d.ts.map +1 -1
  14. package/dist/commands/registry.js +2 -5
  15. package/dist/commands/registry.js.map +1 -1
  16. package/dist/config.d.ts +33 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +83 -5
  19. package/dist/config.js.map +1 -1
  20. package/dist/execution-resolver.d.ts.map +1 -1
  21. package/dist/execution-resolver.js +2 -3
  22. package/dist/execution-resolver.js.map +1 -1
  23. package/dist/harness/auth.d.ts +16 -0
  24. package/dist/harness/auth.d.ts.map +1 -0
  25. package/dist/harness/auth.js +86 -0
  26. package/dist/harness/auth.js.map +1 -0
  27. package/dist/harness/extensions/loader.d.ts +43 -0
  28. package/dist/harness/extensions/loader.d.ts.map +1 -0
  29. package/dist/harness/extensions/loader.js +325 -0
  30. package/dist/harness/extensions/loader.js.map +1 -0
  31. package/dist/harness/extensions/registry.d.ts +23 -0
  32. package/dist/harness/extensions/registry.d.ts.map +1 -0
  33. package/dist/harness/extensions/registry.js +45 -0
  34. package/dist/harness/extensions/registry.js.map +1 -0
  35. package/dist/harness/extensions/types.d.ts +225 -0
  36. package/dist/harness/extensions/types.d.ts.map +1 -0
  37. package/dist/harness/extensions/types.js +2 -0
  38. package/dist/harness/extensions/types.js.map +1 -0
  39. package/dist/harness/http.d.ts +16 -0
  40. package/dist/harness/http.d.ts.map +1 -0
  41. package/dist/harness/http.js +71 -0
  42. package/dist/harness/http.js.map +1 -0
  43. package/dist/harness/index.d.ts +19 -0
  44. package/dist/harness/index.d.ts.map +1 -0
  45. package/dist/harness/index.js +18 -0
  46. package/dist/harness/index.js.map +1 -0
  47. package/dist/harness/models.d.ts +34 -0
  48. package/dist/harness/models.d.ts.map +1 -0
  49. package/dist/harness/models.js +248 -0
  50. package/dist/harness/models.js.map +1 -0
  51. package/dist/harness/runner.d.ts +124 -0
  52. package/dist/harness/runner.d.ts.map +1 -0
  53. package/dist/harness/runner.js +436 -0
  54. package/dist/harness/runner.js.map +1 -0
  55. package/dist/harness/session-store.d.ts +77 -0
  56. package/dist/harness/session-store.d.ts.map +1 -0
  57. package/dist/harness/session-store.js +248 -0
  58. package/dist/harness/session-store.js.map +1 -0
  59. package/dist/harness/settings.d.ts +47 -0
  60. package/dist/harness/settings.d.ts.map +1 -0
  61. package/dist/harness/settings.js +29 -0
  62. package/dist/harness/settings.js.map +1 -0
  63. package/dist/harness/skills.d.ts +54 -0
  64. package/dist/harness/skills.d.ts.map +1 -0
  65. package/dist/harness/skills.js +203 -0
  66. package/dist/harness/skills.js.map +1 -0
  67. package/dist/harness/types.d.ts +32 -0
  68. package/dist/harness/types.d.ts.map +1 -0
  69. package/dist/harness/types.js +2 -0
  70. package/dist/harness/types.js.map +1 -0
  71. package/dist/index.d.ts +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +1 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/main.js +65 -3
  76. package/dist/main.js.map +1 -1
  77. package/dist/model-registry.d.ts +2 -2
  78. package/dist/model-registry.d.ts.map +1 -1
  79. package/dist/model-registry.js.map +1 -1
  80. package/dist/runtime/conversation-runtime.js +1 -1
  81. package/dist/runtime/conversation-runtime.js.map +1 -1
  82. package/dist/runtime/types.d.ts +3 -1
  83. package/dist/runtime/types.d.ts.map +1 -1
  84. package/dist/runtime/types.js.map +1 -1
  85. package/dist/sessions/agent-memory-file-manager.d.ts.map +1 -1
  86. package/dist/sessions/agent-memory-file-manager.js +8 -8
  87. package/dist/sessions/agent-memory-file-manager.js.map +1 -1
  88. package/dist/sessions/metadata.js +2 -2
  89. package/dist/sessions/metadata.js.map +1 -1
  90. package/dist/sessions/store.d.ts +3 -3
  91. package/dist/sessions/store.d.ts.map +1 -1
  92. package/dist/sessions/store.js +4 -4
  93. package/dist/sessions/store.js.map +1 -1
  94. package/dist/sessions/types.d.ts +1 -1
  95. package/dist/sessions/types.d.ts.map +1 -1
  96. package/dist/sessions/types.js.map +1 -1
  97. package/dist/trigger.d.ts.map +1 -1
  98. package/dist/trigger.js +5 -13
  99. package/dist/trigger.js.map +1 -1
  100. package/dist/types.d.ts +6 -0
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js.map +1 -1
  103. package/dist/vault/index.d.ts.map +1 -1
  104. package/dist/vault/index.js +7 -1
  105. package/dist/vault/index.js.map +1 -1
  106. package/dist/web/admin/portal.d.ts.map +1 -1
  107. package/dist/web/admin/portal.js +328 -9
  108. package/dist/web/admin/portal.js.map +1 -1
  109. package/dist/web/admin/provider-models.d.ts +2 -2
  110. package/dist/web/admin/provider-models.d.ts.map +1 -1
  111. package/dist/web/admin/provider-models.js.map +1 -1
  112. package/dist/web/session-view/service.js +5 -5
  113. package/dist/web/session-view/service.js.map +1 -1
  114. package/package.json +3 -3
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
2
- import { homedir } from "os";
3
2
  import { basename, join, resolve as pathResolve, sep as pathSep } from "path";
4
- import { AuthStorage, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { MikanModels, SessionStore } from "../../harness/index.js";
5
4
  import { loadConversationAutoReplyConfig, loadGlobalSettings, resolveConversationSettings, saveConversationAutoReplyConfig, updateConversationSettings, updateGlobalSettings, } from "../../config.js";
6
5
  import { escapeHtml } from "../../utils/html.js";
7
6
  import { readRawBody } from "../../utils/http-body.js";
@@ -56,6 +55,10 @@ async function routeApiRequest(req, res, url, services) {
56
55
  serveSessionUsage(res, services);
57
56
  return;
58
57
  }
58
+ if (url.pathname === "/admin/api/conversation-usage") {
59
+ serveConversationUsage(res, url, services);
60
+ return;
61
+ }
59
62
  if (url.pathname === "/admin/api/conversation-state") {
60
63
  serveConversationState(res, url, services, token);
61
64
  return;
@@ -198,7 +201,9 @@ function listConversationDirs(workingDir) {
198
201
  const dir = join(workingDir, name);
199
202
  try {
200
203
  const items = readdirSync(dir);
201
- return items.some((item) => SETTINGS_FILES.has(item) || item.endsWith(".jsonl"));
204
+ // Conversation settings migrated to the state dir, so a sessions/
205
+ // subdir is the primary marker for existing conversation dirs.
206
+ return items.some((item) => item === "sessions" || SETTINGS_FILES.has(item) || item.endsWith(".jsonl"));
202
207
  }
203
208
  catch {
204
209
  return false;
@@ -289,7 +294,7 @@ function listConversationSessionUsage(workingDir, conversationId, label) {
289
294
  }
290
295
  function readSessionUsage(sessionFile, conversationId, label) {
291
296
  try {
292
- const manager = SessionManager.open(sessionFile);
297
+ const manager = SessionStore.open(sessionFile);
293
298
  const header = manager.getHeader();
294
299
  if (!header)
295
300
  return [];
@@ -330,6 +335,114 @@ function readSessionUsage(sessionFile, conversationId, label) {
330
335
  function numberOrZero(value) {
331
336
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
332
337
  }
338
+ function localDayKey(d) {
339
+ const year = d.getFullYear();
340
+ const month = String(d.getMonth() + 1).padStart(2, "0");
341
+ const day = String(d.getDate()).padStart(2, "0");
342
+ return `${year}-${month}-${day}`;
343
+ }
344
+ function emptyBucket(date) {
345
+ return { date, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, cost: 0 };
346
+ }
347
+ /** Per-conversation daily token usage over the last N days (N clamped to 1..7). */
348
+ function serveConversationUsage(res, url, services) {
349
+ const workingDir = requireAdminWorkingDir(res, services);
350
+ if (!workingDir)
351
+ return;
352
+ const conversationId = url.searchParams.get("conversationId") ?? "";
353
+ if (!conversationId || !listConversationDirs(workingDir).includes(conversationId)) {
354
+ jsonRes(res, 400, { error: "Unknown conversationId" });
355
+ return;
356
+ }
357
+ const days = 14;
358
+ const today = new Date();
359
+ today.setHours(0, 0, 0, 0);
360
+ const buckets = new Map();
361
+ const order = [];
362
+ for (let i = days - 1; i >= 0; i--) {
363
+ const d = new Date(today);
364
+ d.setDate(today.getDate() - i);
365
+ const key = localDayKey(d);
366
+ order.push(key);
367
+ buckets.set(key, emptyBucket(key));
368
+ }
369
+ const cutoff = new Date(today);
370
+ cutoff.setDate(today.getDate() - (days - 1));
371
+ const flags = { hasOlder: false };
372
+ const sessionDir = join(workingDir, conversationId, "sessions");
373
+ try {
374
+ for (const entry of readdirSync(sessionDir, { withFileTypes: true })) {
375
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
376
+ continue;
377
+ accumulateSessionUsageByDay(join(sessionDir, entry.name), cutoff, buckets, flags);
378
+ }
379
+ }
380
+ catch {
381
+ // No sessions directory yet — return empty buckets.
382
+ }
383
+ const series = order.map((key) => buckets.get(key) ?? emptyBucket(key));
384
+ const totals = series.reduce((sum, b) => {
385
+ sum.input += b.input;
386
+ sum.output += b.output;
387
+ sum.cacheRead += b.cacheRead;
388
+ sum.cacheWrite += b.cacheWrite;
389
+ sum.total += b.total;
390
+ sum.cost += b.cost;
391
+ return sum;
392
+ }, emptyBucket(""));
393
+ jsonRes(res, 200, {
394
+ conversationId,
395
+ label: conversationDisplayLabel(services, conversationId),
396
+ days,
397
+ hasOlder: flags.hasOlder,
398
+ buckets: series,
399
+ totals: {
400
+ input: totals.input,
401
+ output: totals.output,
402
+ cacheRead: totals.cacheRead,
403
+ cacheWrite: totals.cacheWrite,
404
+ total: totals.total,
405
+ cost: totals.cost,
406
+ },
407
+ });
408
+ }
409
+ function accumulateSessionUsageByDay(sessionFile, cutoff, buckets, flags) {
410
+ try {
411
+ const manager = SessionStore.open(sessionFile);
412
+ if (!manager.getHeader())
413
+ return;
414
+ for (const entry of manager.getEntries()) {
415
+ if (entry.type !== "message" || entry.message.role !== "assistant")
416
+ continue;
417
+ const usage = entry.message.usage;
418
+ if (!usage || !entry.timestamp)
419
+ continue;
420
+ const when = new Date(entry.timestamp);
421
+ if (Number.isNaN(when.getTime()))
422
+ continue;
423
+ if (when < cutoff) {
424
+ flags.hasOlder = true;
425
+ continue;
426
+ }
427
+ const bucket = buckets.get(localDayKey(when));
428
+ if (!bucket)
429
+ continue;
430
+ const input = numberOrZero(usage.input);
431
+ const output = numberOrZero(usage.output);
432
+ const cacheRead = numberOrZero(usage.cacheRead);
433
+ const cacheWrite = numberOrZero(usage.cacheWrite);
434
+ bucket.input += input;
435
+ bucket.output += output;
436
+ bucket.cacheRead += cacheRead;
437
+ bucket.cacheWrite += cacheWrite;
438
+ bucket.total += input + output + cacheRead + cacheWrite;
439
+ bucket.cost += numberOrZero(usage.cost?.total);
440
+ }
441
+ }
442
+ catch {
443
+ // Skip unreadable session files.
444
+ }
445
+ }
333
446
  function serveConversationState(res, url, services, token) {
334
447
  const workingDir = requireAdminWorkingDir(res, services);
335
448
  if (!workingDir)
@@ -386,8 +499,7 @@ function serveGlobalSettings(res) {
386
499
  }
387
500
  async function serveModelsList(res) {
388
501
  try {
389
- const authStorage = AuthStorage.create(join(homedir(), ".pi", "mikan", "auth.json"));
390
- const registry = ModelRegistry.create(authStorage);
502
+ const registry = MikanModels.create();
391
503
  const availableModels = await registry.getAvailable();
392
504
  const statuses = await resolveAdminModelAccessStatuses(registry, availableModels);
393
505
  const models = availableModels.map((model) => ({
@@ -1270,11 +1382,18 @@ function renderAdminPage(token) {
1270
1382
  <header class="sect-head">
1271
1383
  <div>
1272
1384
  <p class="eyebrow">Token Usage</p>
1273
- <h2 class="card-title">Top 20 sessions</h2>
1274
1385
  </div>
1275
- <button class="refresh-btn" onclick="loadSessionUsage()">↻</button>
1386
+ <button class="refresh-btn" onclick="loadTokenUsage()">↻</button>
1276
1387
  </header>
1388
+ <h2 class="card-subtitle" style="margin-bottom:10px">Top 20 sessions</h3>
1277
1389
  <div id="session-usage-content"><div class="loading-msg">Loading…</div></div>
1390
+ <h2 class="card-subtitle" style="margin:24px 0 10px">Usage timeline</h3>
1391
+ <div class="timeline-controls">
1392
+ <label>Conversation
1393
+ <select id="timeline-conv" onchange="loadUsageTimeline()"></select>
1394
+ </label>
1395
+ </div>
1396
+ <div id="usage-timeline-content"><div class="loading-msg">Loading…</div></div>
1278
1397
  </section>
1279
1398
 
1280
1399
  <section class="card sect">
@@ -1805,7 +1924,7 @@ function renderAdminPage(token) {
1805
1924
  if (globalLoaded) return;
1806
1925
  globalLoaded = true;
1807
1926
  loadAllConversations();
1808
- loadSessionUsage();
1927
+ loadTokenUsage();
1809
1928
  loadGlobalSettings();
1810
1929
  loadGlobalSkills();
1811
1930
  loadEvents();
@@ -1864,6 +1983,140 @@ function renderAdminPage(token) {
1864
1983
  return Number(value || 0).toLocaleString('en-US');
1865
1984
  }
1866
1985
 
1986
+ let timelineConvLoaded = false;
1987
+ let timelineData = null;
1988
+ let timelineFilter = null;
1989
+ async function ensureTimelineConvOptions() {
1990
+ if (timelineConvLoaded) return;
1991
+ const sel = document.getElementById('timeline-conv');
1992
+ const prev = sel.value;
1993
+ const data = await apiGet('/admin/api/conversations');
1994
+ if (!data.conversations.length) {
1995
+ sel.innerHTML = '<option value="">No conversations</option>';
1996
+ timelineConvLoaded = true;
1997
+ return;
1998
+ }
1999
+ const want = prev || defaultConversationId;
2000
+ sel.innerHTML = data.conversations.map((c) =>
2001
+ '<option value="' + escAttr(c.conversationId) + '"' +
2002
+ (c.conversationId === want ? ' selected' : '') + '>' +
2003
+ escHtml(c.label || c.conversationId) + '</option>'
2004
+ ).join('');
2005
+ timelineConvLoaded = true;
2006
+ }
2007
+
2008
+ // Shared refresh for the merged Token Usage section: reloads the session
2009
+ // ranking and re-fetches the conversation list (so new sessions appear),
2010
+ // preserving the currently selected conversation.
2011
+ async function loadTokenUsage() {
2012
+ loadSessionUsage();
2013
+ timelineConvLoaded = false;
2014
+ await loadUsageTimeline();
2015
+ }
2016
+
2017
+ async function loadUsageTimeline() {
2018
+ const container = document.getElementById('usage-timeline-content');
2019
+ try {
2020
+ await ensureTimelineConvOptions();
2021
+ const conv = document.getElementById('timeline-conv').value;
2022
+ if (!conv) {
2023
+ container.innerHTML = '<div class="empty-state">No conversations found</div>';
2024
+ return;
2025
+ }
2026
+ container.innerHTML = '<div class="loading-msg">Loading…</div>';
2027
+ const data = await apiGet('/admin/api/conversation-usage?conversationId=' +
2028
+ encodeURIComponent(conv));
2029
+ timelineData = data;
2030
+ container.innerHTML = renderUsageTimeline(data);
2031
+ } catch (err) {
2032
+ container.innerHTML = '<div class="err-msg">' + escHtml(err.message) + '</div>';
2033
+ }
2034
+ }
2035
+
2036
+ function tlCard(label, value) {
2037
+ return '<div class="tl-card"><div class="tl-card-label">' + label +
2038
+ '</div><div class="tl-card-value">' + value + '</div></div>';
2039
+ }
2040
+
2041
+ const TL_SERIES = [
2042
+ { key: 'cacheRead', seg: 'tl-cache-read', sw: 'sw-cache-read', label: 'Cache read' },
2043
+ { key: 'cacheWrite', seg: 'tl-cache-write', sw: 'sw-cache-write', label: 'Cache write' },
2044
+ { key: 'input', seg: 'tl-input', sw: 'sw-input', label: 'Input' },
2045
+ { key: 'output', seg: 'tl-output', sw: 'sw-output', label: 'Output' },
2046
+ ];
2047
+
2048
+ // Click a legend item to show only that series; click it again for all.
2049
+ function toggleTimelineFilter(key) {
2050
+ timelineFilter = timelineFilter === key ? null : key;
2051
+ if (timelineData) {
2052
+ document.getElementById('usage-timeline-content').innerHTML = renderUsageTimeline(timelineData);
2053
+ }
2054
+ }
2055
+
2056
+ function renderUsageTimeline(data) {
2057
+ const buckets = data.buckets || [];
2058
+ const totals = data.totals || { total: 0, cost: 0, cacheRead: 0 };
2059
+ const cacheHit = totals.total > 0 ? Math.round((totals.cacheRead / totals.total) * 100) : 0;
2060
+ const cards = '<div class="tl-cards">' +
2061
+ tlCard('Total cost', totals.cost > 0 ? '$' + Number(totals.cost).toFixed(4) : '—') +
2062
+ tlCard('Total tokens', fmtNum(totals.total)) +
2063
+ tlCard('Cache hit', cacheHit + '%') +
2064
+ '</div>';
2065
+
2066
+ if (totals.total === 0) {
2067
+ const emptyNote = data.hasOlder
2068
+ ? '<div class="tl-note">No usage in the last 14 days · earlier activity exists</div>'
2069
+ : '<div class="empty-state">No token usage in the last 14 days</div>';
2070
+ return cards + emptyNote;
2071
+ }
2072
+
2073
+ const active = TL_SERIES.find((s) => s.key === timelineFilter) || null;
2074
+ const valueOf = (b) => active ? (b[active.key] || 0) : b.total;
2075
+ const max = Math.max(1, ...buckets.map(valueOf));
2076
+ const px = (v) => Math.round((v / max) * 180);
2077
+
2078
+ const legend = '<div class="tl-legend">' + TL_SERIES.map((s) => {
2079
+ const cls = 'tl-legend-item' +
2080
+ (active && active.key === s.key ? ' active' : (active ? ' dim' : ''));
2081
+ return '<span class="' + cls + '" onclick="toggleTimelineFilter(\\'' + s.key + '\\')">' +
2082
+ '<i class="sw ' + s.sw + '"></i>' + s.label + '</span>';
2083
+ }).join('') + '</div>';
2084
+
2085
+ const bars = buckets.map((b) => {
2086
+ const val = valueOf(b);
2087
+ const tip = active
2088
+ ? b.date + ' · ' + active.label + ': ' + fmtNum(b[active.key] || 0) + ' tokens'
2089
+ : b.date + ' · ' + fmtNum(b.total) + ' tokens' +
2090
+ (b.cost > 0 ? ' · $' + Number(b.cost).toFixed(4) : '');
2091
+ let inner;
2092
+ if (val <= 0) {
2093
+ inner = '<span class="tl-empty"></span>';
2094
+ } else if (active) {
2095
+ inner = '<span class="tl-seg ' + active.seg + '" style="height:' + px(val) + 'px"></span>';
2096
+ } else {
2097
+ inner = '<span class="tl-seg tl-output" style="height:' + px(b.output) + 'px"></span>' +
2098
+ '<span class="tl-seg tl-input" style="height:' + px(b.input) + 'px"></span>' +
2099
+ '<span class="tl-seg tl-cache-write" style="height:' + px(b.cacheWrite) + 'px"></span>' +
2100
+ '<span class="tl-seg tl-cache-read" style="height:' + px(b.cacheRead) + 'px"></span>';
2101
+ }
2102
+ return '<div class="tl-bar">' +
2103
+ '<span class="tl-tip">' + escHtml(tip) + '</span>' +
2104
+ '<div class="tl-fill">' + inner + '</div>' +
2105
+ '</div>';
2106
+ }).join('');
2107
+
2108
+ const axis = buckets.length
2109
+ ? '<div class="tl-axis"><span>' + escHtml(buckets[0].date.slice(5)) +
2110
+ '</span><span>' + escHtml(buckets[buckets.length - 1].date.slice(5)) + '</span></div>'
2111
+ : '';
2112
+ const note = data.hasOlder
2113
+ ? '<div class="tl-note">Showing last 14 days · earlier activity not shown</div>'
2114
+ : '<div class="tl-note">Showing last 14 days</div>';
2115
+ const peak = '<div class="tl-peak" style="bottom:180px"><span class="tl-peak-label">' +
2116
+ fmtNum(max) + ' tokens</span></div>';
2117
+ return cards + legend + '<div class="tl-chart">' + peak + bars + '</div>' + axis + note;
2118
+ }
2119
+
1867
2120
  async function loadGlobalSettings() {
1868
2121
  const container = document.getElementById('global-settings-content');
1869
2122
  container.innerHTML = '<div class="loading-msg">Loading…</div>';
@@ -2235,6 +2488,72 @@ const adminViewStyles = `
2235
2488
  }
2236
2489
  .usage-table code { font-size: 0.72rem; }
2237
2490
 
2491
+ .timeline-controls {
2492
+ display: flex; flex-wrap: wrap; gap: 18px; align-items: center; margin-bottom: 16px;
2493
+ }
2494
+ .timeline-controls label {
2495
+ display: flex; align-items: center; gap: 8px;
2496
+ font-size: 0.76rem; color: var(--muted);
2497
+ }
2498
+ .timeline-controls select {
2499
+ padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px;
2500
+ background: #fff; color: var(--text); font-size: 0.8rem; max-width: 240px;
2501
+ }
2502
+ .tl-note { margin-top: 8px; font-size: 0.72rem; color: var(--subtle); }
2503
+ .tl-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; }
2504
+ .tl-card { background: rgba(0,0,0,0.025); border-radius: 10px; padding: 10px 12px; }
2505
+ .tl-card-label { font-size: 0.73rem; color: var(--muted); margin-bottom: 4px; }
2506
+ .tl-card-value { font-size: 1.35rem; font-weight: 600; color: var(--text); }
2507
+ .tl-legend { display: flex; gap: 16px; font-size: 0.73rem; color: var(--muted); margin-bottom: 10px; }
2508
+ .tl-legend span { display: inline-flex; align-items: center; gap: 6px; }
2509
+ .tl-legend-item { cursor: pointer; transition: opacity 100ms; }
2510
+ .tl-legend-item:hover { text-decoration: underline; }
2511
+ .tl-legend-item.active { color: var(--text); font-weight: 600; text-decoration: underline; }
2512
+ .tl-legend-item.dim { opacity: 0.4; }
2513
+ .sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
2514
+ .sw-cache-read { background: rgba(0,0,0,0.18); }
2515
+ .sw-cache-write { background: #3b6fb0; }
2516
+ .sw-input { background: var(--accent); }
2517
+ .sw-output { background: var(--ok-text); }
2518
+ .tl-chart {
2519
+ position: relative;
2520
+ display: flex; align-items: flex-end; gap: 6px;
2521
+ height: 216px; border-bottom: 1px solid var(--border);
2522
+ }
2523
+ .tl-peak {
2524
+ position: absolute; left: 0; right: 0; height: 0;
2525
+ border-top: 1px dashed var(--accent); pointer-events: none;
2526
+ }
2527
+ .tl-peak-label {
2528
+ position: absolute; left: 0; top: -15px;
2529
+ font-size: 0.7rem; color: var(--accent); white-space: nowrap;
2530
+ }
2531
+ .tl-bar {
2532
+ position: relative; flex: 1; min-width: 0; height: 180px;
2533
+ display: flex; flex-direction: column; justify-content: flex-end;
2534
+ border-radius: 6px 6px 0 0; transition: background 80ms ease;
2535
+ }
2536
+ .tl-bar:hover { background: rgba(0,0,0,0.06); }
2537
+ .tl-bar:hover .tl-seg { opacity: 0.55; }
2538
+ .tl-fill {
2539
+ display: flex; flex-direction: column; justify-content: flex-end;
2540
+ border-radius: 3px 3px 0 0; overflow: hidden;
2541
+ }
2542
+ .tl-tip {
2543
+ position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
2544
+ margin-bottom: 6px; padding: 5px 9px; border-radius: 6px;
2545
+ background: var(--text); color: #fafafa; font-size: 0.7rem; line-height: 1.35;
2546
+ white-space: nowrap; opacity: 0; pointer-events: none; z-index: 5;
2547
+ }
2548
+ .tl-bar:hover .tl-tip { opacity: 1; }
2549
+ .tl-seg { display: block; width: 100%; }
2550
+ .tl-seg.tl-output { background: var(--ok-text); }
2551
+ .tl-seg.tl-input { background: var(--accent); }
2552
+ .tl-seg.tl-cache-write { background: #3b6fb0; }
2553
+ .tl-seg.tl-cache-read { background: rgba(0,0,0,0.18); }
2554
+ .tl-empty { display: block; width: 100%; height: 3px; background: var(--border); }
2555
+ .tl-axis { display: flex; justify-content: space-between; margin-top: 6px; font-size: 0.7rem; color: var(--subtle); }
2556
+
2238
2557
  .status-pill {
2239
2558
  display: inline-flex; padding: 2px 9px; border-radius: 999px;
2240
2559
  font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase;