<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LeanCTX Observatory</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#06060a;--surface:#0a0a12;--surface-2:#0f0f1a;--surface-3:#161625;
--border:rgba(255,255,255,0.06);--border-light:rgba(255,255,255,0.1);
--text:#e2e2ef;--text-bright:#f5f5ff;--muted:#6b6b88;
--green:#34d399;--green-dim:rgba(52,211,153,0.08);--green-glow:rgba(52,211,153,0.15);
--purple:#818cf8;--purple-dim:rgba(129,140,248,0.08);
--blue:#38bdf8;--blue-dim:rgba(56,189,248,0.08);
--pink:#f472b6;--pink-dim:rgba(244,114,182,0.08);
--yellow:#fbbf24;--yellow-dim:rgba(251,191,36,0.08);
--red:#f87171;--red-dim:rgba(248,113,113,0.08);
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
--mono:ui-monospace,'SF Mono','Cascadia Mono','Segoe UI Mono','DejaVu Sans Mono',monospace;
--r:16px;--rs:10px;
--sidebar-w:56px;--sidebar-exp:220px;
}
body{background:var(--bg);color:var(--text);font-family:var(--font);min-height:100vh;
font-weight:350;-webkit-font-smoothing:antialiased;overflow-x:hidden}
::selection{background:var(--green);color:var(--bg)}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.14)}
.app-layout{display:flex;min-height:100vh}
.sidebar{
position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);
background:var(--surface);border-right:1px solid var(--border);
display:flex;flex-direction:column;z-index:200;
transition:width .25s cubic-bezier(.22,1,.36,1);overflow:hidden;
}
.sidebar:hover,.sidebar.pinned{width:var(--sidebar-exp)}
.sidebar-logo{
height:56px;display:flex;align-items:center;padding:0 16px;gap:8px;
border-bottom:1px solid var(--border);flex-shrink:0;cursor:default;
}
.sidebar-logo svg{flex-shrink:0;width:24px;height:24px}
.sidebar-logo-text{
font-size:15px;font-weight:700;letter-spacing:-0.02em;white-space:nowrap;opacity:0;
transition:opacity .2s .05s;
}
.sidebar:hover .sidebar-logo-text,.sidebar.pinned .sidebar-logo-text{opacity:1}
.sidebar-logo-text span{
background:linear-gradient(135deg,var(--green),#6ee7b7);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-weight:300;
}
.sidebar-nav{flex:1;padding:8px;overflow-y:auto;overflow-x:hidden}
.nav-item{
display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:10px;
cursor:pointer;color:var(--muted);transition:all .15s;white-space:nowrap;
font-size:13px;font-weight:450;position:relative;
}
.nav-item:hover{color:var(--text);background:var(--surface-2)}
.nav-item.active{color:var(--green);background:rgba(52,211,153,0.06)}
.nav-item.active::before{
content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);
width:3px;height:20px;background:var(--green);border-radius:0 3px 3px 0;
}
.nav-item svg{flex-shrink:0;width:18px;height:18px}
.nav-label{opacity:0;transition:opacity .2s .05s}
.sidebar:hover .nav-label,.sidebar.pinned .nav-label{opacity:1}
.sidebar-footer{
padding:12px 16px;border-top:1px solid var(--border);font-size:10px;
color:var(--muted);font-family:var(--mono);white-space:nowrap;
opacity:0;transition:opacity .2s .05s;
}
.sidebar:hover .sidebar-footer,.sidebar.pinned .sidebar-footer{opacity:1}
.main{
flex:1;margin-left:var(--sidebar-w);padding:24px 32px 80px;
transition:margin-left .25s cubic-bezier(.22,1,.36,1);
max-width:1400px;
}
.view{display:none;opacity:0;transition:opacity .2s ease}
.view.active{display:block;opacity:1}
.topbar{
display:flex;align-items:center;justify-content:space-between;
margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid var(--border);
}
.topbar-title{font-size:22px;font-weight:700;letter-spacing:-0.03em}
.topbar-actions{display:flex;align-items:center;gap:8px}
.pulse{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--muted)}
.dot{width:5px;height:5px;border-radius:50%;background:var(--green);animation:p 2s ease infinite}
@keyframes p{0%,100%{opacity:1}50%{opacity:.3}}
.dot.off{background:var(--muted);animation:none}
.btn{
background:transparent;border:1px solid var(--border);color:var(--muted);padding:5px 14px;
border-radius:8px;font-size:11px;font-family:var(--font);cursor:pointer;transition:.15s;
}
.btn:hover{border-color:var(--green);color:var(--green)}
.ver-badge{
color:var(--muted);font-size:9px;font-family:var(--mono);
background:var(--surface-2);padding:2px 6px;border-radius:4px;border:1px solid var(--border);
}
.tf-bar{display:flex;gap:4px;margin-bottom:12px}
.tf-btn{
padding:5px 14px;font-size:11px;font-weight:500;border-radius:8px;
border:1px solid var(--border);background:none;color:var(--muted);
cursor:pointer;font-family:var(--font);transition:all .15s;
}
.tf-btn:hover{border-color:var(--border-light);color:var(--text)}
.tf-btn.active{border-color:var(--green-glow);background:var(--green-dim);color:var(--green)}
.hero{display:grid;grid-template-columns:1.8fr 1fr 1fr 1fr;gap:10px;margin-bottom:14px}
.hero-main{
position:relative;border-radius:var(--r);padding:32px 28px;overflow:hidden;
background:linear-gradient(145deg,rgba(52,211,153,0.06),rgba(129,140,248,0.03));
border:1px solid var(--green-glow);
}
.hero-main::before{
content:'';position:absolute;top:-60%;right:-30%;width:300px;height:300px;
background:radial-gradient(circle,rgba(52,211,153,0.08),transparent 60%);pointer-events:none;
}
.hero-main .hv{
font-size:48px;font-weight:700;letter-spacing:-0.04em;line-height:1;
color:var(--green);margin-bottom:8px;text-shadow:0 0 40px rgba(52,211,153,0.2);
}
.hero-main .hl{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.15em;font-weight:600}
.hero-main .hs{font-size:11px;color:var(--muted);margin-top:14px;line-height:1.7}
.hero-main .hs b{color:var(--text);font-weight:500}
.hc{
background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
padding:22px 18px;display:flex;flex-direction:column;justify-content:center;transition:.2s;
}
.hc:hover{border-color:var(--border-light);transform:translateY(-1px)}
.hc .hv{font-size:32px;font-weight:700;letter-spacing:-0.04em;line-height:1;margin-bottom:5px}
.hc .hl{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.18em;font-weight:600}
.hc .hs{font-size:10px;color:var(--muted);margin-top:8px;font-family:var(--mono)}
.row{display:grid;gap:10px;margin-bottom:10px}
.r3{grid-template-columns:1fr 1fr 1fr}
.r21{grid-template-columns:2fr 1fr}
.r12{grid-template-columns:1fr 2fr}
.r11{grid-template-columns:1fr 1fr}
.r1{grid-template-columns:1fr}
.r4{grid-template-columns:1fr 1fr 1fr 1fr}
.card{
background:var(--surface);border:1px solid var(--border);border-radius:var(--r);
padding:20px;transition:.2s;
}
.card:hover{border-color:var(--border-light)}
.card h3{
font-size:10px;color:var(--muted);margin-bottom:14px;font-weight:600;
text-transform:uppercase;letter-spacing:.18em;display:flex;align-items:center;gap:8px;
}
.badge{
font-size:8px;background:var(--green-dim);color:var(--green);padding:2px 6px;
border-radius:4px;letter-spacing:.05em;font-family:var(--mono);font-weight:600;
}
.card canvas{max-height:220px}
.cost-row{display:flex;align-items:stretch;gap:0}
.cost-box{flex:1;padding:16px 12px;text-align:center;border-radius:var(--rs)}
.cost-box.bad{background:var(--red-dim);border:1px solid rgba(248,113,113,0.1)}
.cost-box.good{background:var(--green-dim);border:1px solid rgba(52,211,153,0.1)}
.cost-box .amt{font-size:28px;font-weight:700;letter-spacing:-.03em}
.cost-box .lb{font-size:8px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-weight:600;margin-top:3px}
.cost-arrow{display:flex;align-items:center;padding:0 12px;color:var(--green);font-size:18px;opacity:.6}
.cost-detail{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:12px}
.cd-item{background:var(--surface-2);border-radius:8px;padding:10px;text-align:center}
.cd-item .v{font-size:18px;font-weight:700;letter-spacing:-.02em}
.cd-item .l{font-size:8px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-top:2px}
.src-grid{display:grid;grid-template-columns:1fr 1fr;gap:0;border:1px solid var(--border);border-radius:var(--rs);overflow:hidden}
.src-item{padding:14px 16px;background:var(--surface)}
.src-item:first-child{border-right:1px solid var(--border)}
.src-item h4{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:5px}
.src-item h4 .d{width:7px;height:7px;border-radius:50%}
.sr{display:flex;justify-content:space-between;padding:2px 0;font-size:10px}
.sr .sl{color:var(--muted)}.sr .sv{font-family:var(--mono);font-weight:500}
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;
padding:7px 10px;border-bottom:1px solid var(--border);font-weight:600}
th.r{text-align:right}
td{padding:6px 10px;font-size:11px;border-bottom:1px solid var(--border);font-family:var(--mono)}
td.r{text-align:right}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--surface-2)}
.tag{display:inline-block;padding:1px 5px;border-radius:4px;font-size:9px;font-weight:600;font-family:var(--mono)}
.tg{background:var(--green-dim);color:var(--green)}
.tp{background:var(--purple-dim);color:var(--purple)}
.tb{background:var(--blue-dim);color:var(--blue)}
.ty{background:var(--yellow-dim);color:var(--yellow)}
.td{background:var(--red-dim);color:var(--red)}
.tpk{background:var(--pink-dim);color:var(--pink)}
.bar-bg{background:var(--surface-2);border-radius:3px;height:4px;overflow:hidden;margin-top:2px}
.bar-f{height:100%;border-radius:3px;transition:width .6s cubic-bezier(.22,1,.36,1)}
.empty-state{
text-align:center;padding:80px 20px;color:var(--muted);
}
.empty-state h2{font-size:18px;font-weight:700;color:var(--text);margin-bottom:8px}
.empty-state p{font-size:12px;line-height:1.7;max-width:400px;margin:0 auto}
.loading-state{text-align:center;padding:80px 20px;color:var(--muted);font-size:12px}
.how-it-works{
margin-top:20px;border:1px solid var(--border);border-radius:var(--rs);overflow:hidden;
}
.how-toggle{
display:flex;align-items:center;gap:8px;padding:12px 16px;cursor:pointer;
font-size:11px;color:var(--muted);background:var(--surface);transition:.15s;
border:none;width:100%;text-align:left;font-family:var(--font);
}
.how-toggle:hover{color:var(--text);background:var(--surface-2)}
.how-toggle svg{transition:transform .2s;flex-shrink:0}
.how-toggle.open svg{transform:rotate(90deg)}
.how-content{
display:none;padding:16px;font-size:12px;line-height:1.8;color:var(--muted);
background:var(--surface);border-top:1px solid var(--border);
}
.how-content.open{display:block}
.how-content strong{color:var(--text);font-weight:500}
.how-content code{
background:var(--surface-3);border:1px solid var(--border);border-radius:4px;
padding:1px 5px;font-family:var(--mono);font-size:11px;
}
.buddy-card{
position:relative;background:rgba(10,10,18,0.6);backdrop-filter:blur(24px);
border:1px solid var(--border-light);border-radius:var(--r);padding:28px;overflow:hidden;
display:flex;gap:28px;align-items:center;flex-wrap:wrap;transition:all .3s ease;
}
.buddy-card:hover{transform:translateY(-2px);border-color:rgba(255,255,255,0.15)}
.buddy-card::before{
content:'';position:absolute;top:-40%;right:-20%;width:300px;height:300px;
border-radius:50%;pointer-events:none;transition:opacity .3s;
}
.buddy-card.rarity-Uncommon::before{background:radial-gradient(circle,rgba(52,211,153,0.1),transparent 60%)}
.buddy-card.rarity-Rare::before{background:radial-gradient(circle,rgba(56,189,248,0.12),transparent 60%)}
.buddy-card.rarity-Epic::before{background:radial-gradient(circle,rgba(192,132,252,0.15),transparent 60%);animation:glow-pulse 3s ease infinite}
.buddy-card.rarity-Legendary::before{background:radial-gradient(circle,rgba(251,191,36,0.18),transparent 60%);animation:shimmer 2s ease infinite}
.buddy-card.lvl-t1{--lvlGlow:rgba(56,189,248,0.10)}
.buddy-card.lvl-t2{--lvlGlow:rgba(52,211,153,0.12)}
.buddy-card.lvl-t3{--lvlGlow:rgba(192,132,252,0.16)}
.buddy-card.lvl-t4{--lvlGlow:rgba(251,191,36,0.20)}
.buddy-card.lvl-t1::after,
.buddy-card.lvl-t2::after,
.buddy-card.lvl-t3::after,
.buddy-card.lvl-t4::after{content:'';position:absolute;inset:-1px;border-radius:14px;pointer-events:none;opacity:0.0;background:radial-gradient(circle at 20% 20%,var(--lvlGlow),transparent 55%),radial-gradient(circle at 80% 70%,var(--lvlGlow),transparent 60%);transition:opacity .2s ease}
.buddy-card.lvl-t1:hover::after,
.buddy-card.lvl-t2:hover::after,
.buddy-card.lvl-t3:hover::after,
.buddy-card.lvl-t4:hover::after{opacity:1}
.buddy-sprite.lvl-t1 pre{animation:buddyFloat 3.2s ease-in-out infinite}
.buddy-sprite.lvl-t2 pre{animation:buddyFloat 2.6s ease-in-out infinite}
.buddy-sprite.lvl-t3 pre{animation:buddyFloat 2.1s ease-in-out infinite}
.buddy-sprite.lvl-t4 pre{animation:buddyFloat 1.8s ease-in-out infinite}
@keyframes buddyFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
@keyframes glow-pulse{0%,100%{opacity:0.7}50%{opacity:1}}
@keyframes shimmer{0%{transform:translate(0,0) scale(1);opacity:0.7}50%{transform:translate(5%,-5%) scale(1.1);opacity:1}100%{transform:translate(0,0) scale(1);opacity:0.7}}
.buddy-sprite{position:relative;z-index:1}
.buddy-sprite pre{font-family:var(--mono);font-size:14px;line-height:1.35;margin:0;white-space:pre;letter-spacing:0.05em}
.buddy-sprite.rarity-Uncommon pre{color:var(--green);text-shadow:0 0 12px rgba(52,211,153,0.3)}
.buddy-sprite.rarity-Rare pre{color:var(--blue);text-shadow:0 0 12px rgba(56,189,248,0.3)}
.buddy-sprite.rarity-Epic pre{color:var(--purple);text-shadow:0 0 16px rgba(192,132,252,0.4)}
.buddy-sprite.rarity-Legendary pre{color:var(--yellow);text-shadow:0 0 20px rgba(251,191,36,0.5)}
.buddy-sprite pre{color:var(--text)}
.buddy-info{flex:1;min-width:240px;position:relative;z-index:1}
.buddy-name{font-size:20px;font-weight:700;letter-spacing:-0.02em;margin-bottom:2px;display:flex;align-items:center;gap:8px}
.buddy-meta{font-size:11px;color:var(--muted);margin-bottom:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.rarity-badge{font-size:9px;padding:2px 8px;border-radius:4px;font-weight:700;font-family:var(--mono);letter-spacing:.05em;text-transform:uppercase}
.rarity-badge.r-Uncommon{background:var(--green-dim);color:var(--green);border:1px solid rgba(52,211,153,0.2)}
.rarity-badge.r-Rare{background:var(--blue-dim);color:var(--blue);border:1px solid rgba(56,189,248,0.2)}
.rarity-badge.r-Epic{background:var(--purple-dim);color:var(--purple);border:1px solid rgba(192,132,252,0.2)}
.rarity-badge.r-Legendary{background:var(--yellow-dim);color:var(--yellow);border:1px solid rgba(251,191,36,0.2);animation:glow-pulse 2s ease infinite}
.mood-dot{width:6px;height:6px;border-radius:50%;display:inline-block}
.mood-Ecstatic{background:var(--yellow);box-shadow:0 0 6px var(--yellow)}
.mood-Happy{background:var(--green);box-shadow:0 0 6px var(--green)}
.mood-Content{background:var(--blue)}
.mood-Worried{background:var(--red);box-shadow:0 0 6px var(--red)}
.mood-Sleeping{background:var(--muted)}
.xp-wrap{margin-bottom:14px}
.xp-label{display:flex;justify-content:space-between;font-size:9px;color:var(--muted);font-family:var(--mono);margin-bottom:4px;letter-spacing:.05em}
.xp-track{background:var(--surface-2);border-radius:6px;height:8px;overflow:hidden;border:1px solid var(--border)}
.xp-fill{height:100%;border-radius:5px;transition:width 1s cubic-bezier(.22,1,.36,1);background:linear-gradient(90deg,var(--green),var(--blue))}
.buddy-stats-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin-bottom:14px}
.stat-cell{text-align:center;background:var(--surface-2);border-radius:8px;padding:8px 4px;border:1px solid var(--border);transition:border-color .2s}
.stat-cell:hover{border-color:var(--border-light)}
.stat-label{font-size:8px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-weight:600;margin-bottom:4px}
.stat-gauge{position:relative;width:36px;height:36px;margin:0 auto 4px}
.stat-gauge svg{transform:rotate(-90deg)}
.stat-gauge circle{fill:none;stroke-width:3}
.stat-gauge .bg{stroke:var(--surface-3)}
.stat-gauge .fg{stroke-linecap:round;transition:stroke-dashoffset 1s ease}
.stat-val{font-size:13px;font-weight:700;letter-spacing:-0.02em}
.buddy-speech{font-size:11px;color:var(--muted);font-style:italic;padding:10px 14px;background:var(--surface-2);border-radius:8px;border:1px solid var(--border);position:relative;margin-top:2px}
.buddy-speech::before{content:'';position:absolute;left:16px;top:-5px;width:10px;height:10px;background:var(--surface-2);border-left:1px solid var(--border);border-top:1px solid var(--border);transform:rotate(45deg)}
.event-card{
background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);
padding:12px 16px;display:flex;align-items:center;gap:12px;transition:.15s;
}
.event-card:hover{border-color:var(--border-light)}
.event-icon{
width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;
font-size:14px;flex-shrink:0;
}
.event-body{flex:1;min-width:0}
.event-tool{font-size:12px;font-weight:600;color:var(--text-bright)}
.event-detail{font-size:10px;color:var(--muted);font-family:var(--mono);margin-top:2px}
.event-time{font-size:9px;color:var(--muted);font-family:var(--mono);flex-shrink:0}
.filter-row{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:12px}
.filter-btn{
background:var(--surface-2);border:1px solid var(--border);color:var(--muted);
padding:4px 12px;border-radius:6px;font-size:10px;font-family:var(--font);
cursor:pointer;transition:.15s;
}
.filter-btn:hover{color:var(--text);border-color:var(--border-light)}
.filter-btn.active{color:var(--green);border-color:var(--green);background:var(--green-dim)}
.d3-container{
width:100%;height:500px;border-radius:var(--rs);overflow:hidden;
background:var(--surface);border:1px solid var(--border);position:relative;
}
.d3-container svg{width:100%;height:100%}
.detail-panel{
position:fixed;top:0;right:-380px;width:380px;height:100vh;
background:var(--surface);border-left:1px solid var(--border);
z-index:300;transition:right .25s cubic-bezier(.22,1,.36,1);
overflow-y:auto;padding:24px;
}
.detail-panel.open{right:0}
.detail-panel-close{
position:absolute;top:12px;right:12px;background:none;border:none;
color:var(--muted);cursor:pointer;font-size:18px;padding:4px;transition:.15s;
}
.detail-panel-close:hover{color:var(--text)}
.detail-panel h3{font-size:14px;font-weight:700;margin-bottom:16px;padding-right:30px}
.detail-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);font-size:11px}
.detail-row .dl{color:var(--muted)}
.detail-row .dv{font-family:var(--mono);color:var(--text-bright)}
.split-pane{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--border);border-radius:var(--rs);overflow:hidden}
.split-side{background:var(--surface);padding:16px;overflow:auto;max-height:500px}
.split-side pre{font-family:var(--mono);font-size:11px;line-height:1.6;white-space:pre-wrap;word-break:break-all}
.split-side h4{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.12em;font-weight:600;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.mode-tabs{display:flex;gap:0;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:3px;margin-bottom:12px}
.mode-tab{
flex:1;padding:8px 12px;text-align:center;border-radius:6px;
font-size:11px;font-weight:500;color:var(--muted);cursor:pointer;transition:.15s;
}
.mode-tab:hover{color:var(--text)}
.mode-tab.active{background:var(--surface-2);color:var(--green);border:1px solid var(--border)}
.file-list{
max-height:300px;overflow-y:auto;border:1px solid var(--border);
border-radius:var(--rs);background:var(--surface);
}
.file-item{
padding:8px 14px;font-size:11px;font-family:var(--mono);cursor:pointer;
border-bottom:1px solid var(--border);transition:.1s;color:var(--muted);
}
.file-item:last-child{border-bottom:none}
.file-item:hover{background:var(--surface-2);color:var(--text)}
.file-item.selected{background:var(--green-dim);color:var(--green)}
.swimlane{
background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);
padding:14px 18px;margin-bottom:8px;display:flex;align-items:center;gap:16px;
}
.agent-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.agent-dot.active{background:var(--green);box-shadow:0 0 8px rgba(52,211,153,0.4)}
.agent-dot.idle{background:var(--yellow)}
.agent-dot.offline{background:var(--muted)}
.agent-info{flex:1;min-width:0}
.agent-name{font-size:12px;font-weight:600;color:var(--text-bright)}
.agent-meta{font-size:10px;color:var(--muted);font-family:var(--mono);margin-top:2px}
.search-input{
width:100%;padding:10px 14px;background:var(--surface);border:1px solid var(--border);
border-radius:var(--rs);color:var(--text);font-family:var(--mono);font-size:12px;
outline:none;transition:border-color .15s;
}
.search-input:focus{border-color:var(--green)}
.search-input::placeholder{color:var(--muted)}
.token-counter{
font-size:36px;font-weight:700;letter-spacing:-0.04em;color:var(--green);
text-shadow:0 0 30px rgba(52,211,153,0.2);font-variant-numeric:tabular-nums;
}
[data-live]{transition:opacity .15s}[data-live].flash{opacity:.6}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
.hero>*,.card{animation:fadeUp .4s ease both}
.hero>:nth-child(1){animation-delay:.03s}.hero>:nth-child(2){animation-delay:.06s}
.hero>:nth-child(3){animation-delay:.09s}.hero>:nth-child(4){animation-delay:.12s}
.update-banner{
display:none;background:linear-gradient(135deg,#f59e0b22,#f59e0b11);
border:1px solid #f59e0b44;border-radius:10px;padding:10px 20px;margin-bottom:16px;
text-align:center;font-size:12px;color:#f59e0b;
}
.severity-critical{color:var(--red)}
.severity-warning{color:var(--yellow)}
.severity-info{color:var(--blue)}
@media(max-width:1024px){
.hero{grid-template-columns:1fr 1fr}
.r21,.r12,.r11,.r3{grid-template-columns:1fr}
.r4{grid-template-columns:1fr 1fr}
.buddy-card{flex-direction:column;text-align:center}
.buddy-stats-grid{grid-template-columns:repeat(5,1fr)}
.split-pane{grid-template-columns:1fr}
}
@media(max-width:768px){
.sidebar{width:0;border:none}
.sidebar:hover,.sidebar.pinned{width:var(--sidebar-exp)}
.main{margin-left:0}
.hero{grid-template-columns:1fr}
.r4{grid-template-columns:1fr}
.buddy-stats-grid{grid-template-columns:repeat(3,1fr)}
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
<span class="sidebar-logo-text">Lean<span>CTX</span></span>
</div>
<nav class="sidebar-nav" id="sidebarNav"></nav>
<div class="sidebar-footer" id="sidebarVersion">v---</div>
</aside>
<main class="main" id="mainContent">
<div class="topbar">
<div style="display:flex;align-items:center;gap:12px">
<span class="topbar-title" id="viewTitle">Overview</span>
<span class="ver-badge" id="verBadge">v---</span>
</div>
<div class="topbar-actions">
<div class="pulse"><span class="dot" id="aDot"></span><span id="aLbl">Live</span></div>
<button class="btn" id="aTog" onclick="toggleAutoRefresh()">Pause</button>
<button class="btn" onclick="loadCurrentView()">Refresh</button>
</div>
</div>
<div id="updateBanner" class="update-banner">
<strong>⟳ Update available:</strong> <span id="ubCur"></span> → <strong><span id="ubNew"></span></strong>
— run: <code style="background:#f59e0b22;padding:2px 8px;border-radius:4px;font-weight:600">lean-ctx update</code>
</div>
<section id="view-overview" class="view active"></section>
<section id="view-live" class="view"></section>
<section id="view-knowledge" class="view"></section>
<section id="view-deps" class="view"></section>
<section id="view-compression" class="view"></section>
<section id="view-agents" class="view"></section>
<section id="view-bugs" class="view"></section>
<section id="view-search" class="view"></section>
<section id="view-learning" class="view"></section>
<section id="view-symbols" class="view"></section>
<section id="view-callgraph" class="view"></section>
<section id="view-routes" class="view"></section>
</main>
</div>
<div class="detail-panel" id="detailPanel">
<button class="detail-panel-close" onclick="closeDetail()">×</button>
<div id="detailContent"></div>
</div>
<script>
const _origFetch = window.fetch;
window.fetch = function(url, opts) {
if (window.__LEAN_CTX_TOKEN__ && typeof url === 'string' && url.startsWith('/api/')) {
opts = opts || {};
opts.headers = opts.headers || {};
opts.headers['Authorization'] = 'Bearer ' + window.__LEAN_CTX_TOKEN__;
}
return _origFetch.call(this, url, opts);
};
const $ = id => document.getElementById(id);
const esc = s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
const fmt = n => { if (n >= 1e6) return (n/1e6).toFixed(1)+'M'; if (n >= 1e3) return (n/1e3).toFixed(1)+'K'; return String(n); };
const ff = n => n.toLocaleString('en-US');
const pc = (a,b) => b > 0 ? Math.round(a/b*100) : 0;
const fu = a => '$' + a.toFixed(2);
const CM = { i: 2.50, o: 10.0, v: 450, c: 120 };
const isM = n => n.startsWith('ctx_');
const sb = n => isM(n) ? '<span class="tag tp">MCP</span>' : '<span class="tag tb">Hook</span>';
function gc(inp, out, n) {
const iW = inp/1e6*CM.i, iC = out/1e6*CM.i;
const saved = inp - out;
const rate = inp > 0 ? saved / inp : 0;
const eW = n*CM.v;
const eC = rate > 0.01 ? n*CM.c : eW;
const oW = eW/1e6*CM.o, oC = eC/1e6*CM.o;
return { iW, iC, oW, oC, tW: iW+oW, tC: iC+oC, sv: iW+oW-iC-oC, os: eW-eC };
}
function ss(cmds) {
let m = {c:0,i:0,o:0,s:0}, h = {c:0,i:0,o:0,s:0};
for (const [n,s] of cmds) { const t = isM(n)?m:h; t.c+=s.count; t.i+=s.input_tokens; t.o+=s.output_tokens; t.s+=s.input_tokens-s.output_tokens; }
return { m, h };
}
function fd(d,r) { return (!r || r===0) ? d : d.slice(-r); }
function lv(id, val) {
const el = $(id); if (!el) return;
const s = String(val); if (el.textContent === s) return;
el.textContent = s; el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 200);
}
const VIEWS = [
{ id:'overview', label:'Overview', icon:'<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/>' },
{ id:'live', label:'Live Observatory', icon:'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>' },
{ id:'knowledge', label:'Knowledge Graph', icon:'<circle cx="12" cy="5" r="3"/><circle cx="5" cy="19" r="3"/><circle cx="19" cy="19" r="3"/><line x1="12" y1="8" x2="5" y2="16"/><line x1="12" y1="8" x2="19" y2="16"/>' },
{ id:'deps', label:'Dependency Map', icon:'<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/><line x1="14" y1="4" x2="10" y2="20"/>' },
{ id:'compression', label:'Compression Lab', icon:'<rect x="4" y="4" width="16" height="16" rx="2"/><line x1="4" y1="10" x2="20" y2="10"/><line x1="10" y1="4" x2="10" y2="20"/>' },
{ id:'agents', label:'Agent World', icon:'<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>' },
{ id:'bugs', label:'Bug Memory', icon:'<path d="M12 2a3 3 0 00-3 3v1H6a2 2 0 00-2 2v1h16V8a2 2 0 00-2-2h-3V5a3 3 0 00-3-3z"/><rect x="4" y="9" width="16" height="12" rx="2"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/>' },
{ id:'search', label:'Search Explorer', icon:'<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>' },
{ id:'learning', label:'Learning Curves', icon:'<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>' },
{ id:'symbols', label:'Symbol Explorer', icon:'<path d="M20 7h-7L10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2z"/>' },
{ id:'callgraph', label:'Call Graph', icon:'<circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><circle cx="18" cy="6" r="3"/><line x1="8.5" y1="7.5" x2="15.5" y2="16.5"/><line x1="8.5" y1="6" x2="15.5" y2="6"/>' },
{ id:'routes', label:'Route Map', icon:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>' },
];
let currentView = 'overview';
let autoRefresh = true;
let autoRefreshInterval = null;
let charts = {};
let overviewBuilt = false;
let overviewRaw = null;
let overviewCR = 0;
let overviewTF = 0;
let overviewShowAll = false;
let overviewPH = '';
let liveInterval = null;
let liveTotalTokens = 0;
let liveFilter = 'all';
function buildSidebar() {
const nav = $('sidebarNav');
nav.innerHTML = VIEWS.map(v =>
`<div class="nav-item${v.id === currentView ? ' active' : ''}" data-view="${v.id}" onclick="switchView('${v.id}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${v.icon}</svg>
<span class="nav-label">${v.label}</span>
</div>`
).join('');
}
function switchView(name) {
if (currentView === name) return;
currentView = name;
document.querySelectorAll('.view').forEach(el => el.classList.remove('active'));
const target = $('view-' + name);
if (target) target.classList.add('active');
document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.view === name));
const viewDef = VIEWS.find(v => v.id === name);
$('viewTitle').textContent = viewDef ? viewDef.label : name;
if (name === 'live') startLivePolling();
else stopLivePolling();
loadCurrentView();
}
function loadCurrentView() {
const loaders = {
overview: loadOverview,
live: loadLive,
knowledge: loadKnowledge,
deps: loadDeps,
compression: loadCompression,
agents: loadAgents,
bugs: loadBugs,
search: loadSearch,
learning: loadLearning,
symbols: loadSymbols,
callgraph: loadCallGraph,
routes: loadRoutes,
};
if (loaders[currentView]) loaders[currentView]();
}
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
$('aTog').textContent = autoRefresh ? 'Pause' : 'Resume';
$('aDot').classList.toggle('off', !autoRefresh);
$('aLbl').textContent = autoRefresh ? 'Live' : 'Paused';
if (autoRefresh) startAutoRefresh(); else stopAutoRefresh();
}
function startAutoRefresh() { if (autoRefreshInterval) clearInterval(autoRefreshInterval); autoRefreshInterval = setInterval(loadCurrentView, 5000); }
function stopAutoRefresh() { if (autoRefreshInterval) { clearInterval(autoRefreshInterval); autoRefreshInterval = null; } }
function startLivePolling() { if (liveInterval) clearInterval(liveInterval); liveInterval = setInterval(loadLive, 2000); }
function stopLivePolling() { if (liveInterval) { clearInterval(liveInterval); liveInterval = null; } }
function closeDetail() { $('detailPanel').classList.remove('open'); }
function howItWorks(title, content) {
return `<div class="how-it-works">
<button class="how-toggle" onclick="this.classList.toggle('open');this.nextElementSibling.classList.toggle('open')">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4,2 8,6 4,10"/></svg>
How it works: ${esc(title)}
</button>
<div class="how-content">${content}</div>
</div>`;
}
function showLoading(container) { container.innerHTML = '<div class="loading-state">Loading...</div>'; }
function showEmpty(container, msg) { container.innerHTML = `<div class="empty-state"><h2>No data yet</h2><p>${msg}</p></div>`; }
function showError(container, msg) { container.innerHTML = `<div class="empty-state"><h2>Connection Error</h2><p>${esc(msg)}</p></div>`; }
const retryTimers = new Map();
const retryDelays = new Map();
function scheduleRetry(view, fn) {
if (retryTimers.get(view)) return;
const d = retryDelays.get(view) || 1000;
retryDelays.set(view, Math.min(15000, Math.round(d * 1.7)));
retryTimers.set(view, setTimeout(() => {
retryTimers.delete(view);
if (currentView === view) fn();
}, d));
}
function resetRetry(view) {
retryDelays.set(view, 1000);
const t = retryTimers.get(view);
if (t) { clearTimeout(t); retryTimers.delete(view); }
}
function isBuildingData(d) { return d && d.status === 'building'; }
function showIndexing(container, msg, view, fn) { showEmpty(container, msg); scheduleRetry(view, fn); }
const inFlight = new Map();
async function apiFetch(path, opts) {
const timeoutMs = (opts && opts.timeoutMs) ?? (
path.startsWith('/api/graph') || path.startsWith('/api/search-index') ? 8000 : 3000
);
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(path, { signal: ctrl.signal, cache: 'no-store' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
} catch (e) {
if (e && e.name === 'AbortError') throw new Error('timeout');
throw e;
} finally {
clearTimeout(t);
}
}
function chartDefaults() {
return {
responsive: true, maintainAspectRatio: true,
animation: { duration: 500, easing: 'easeOutQuart' },
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#6b6b88', font: { size: 8 } }, grid: { color: 'rgba(255,255,255,0.025)' }, border: { display: false } },
y: { ticks: { color: '#6b6b88', font: { size: 8 }, callback: v => fmt(v) }, grid: { color: 'rgba(255,255,255,0.025)' }, border: { display: false } },
},
};
}
async function loadOverview() {
const container = $('view-overview');
try {
const [stats, buddy, gotchas, version] = await Promise.all([
apiFetch('/api/stats').catch(() => null),
apiFetch('/api/buddy').catch(() => null),
apiFetch('/api/gotchas').catch(() => null),
apiFetch('/api/version').catch(() => null),
]);
if (version && version.current) {
$('verBadge').textContent = 'v' + version.current;
$('sidebarVersion').textContent = 'v' + version.current;
}
if (version && version.update_available) {
$('ubCur').textContent = 'v' + version.current;
$('ubNew').textContent = 'v' + version.latest;
$('updateBanner').style.display = 'block';
}
overviewRaw = stats;
if (!stats || !stats.total_commands) {
showEmpty(container, 'Start using LeanCTX tools (ctx_read, ctx_shell, etc.) in your IDE to see savings here.');
overviewBuilt = false;
return;
}
if (!overviewBuilt) buildOverview(container);
updateOverview(stats);
renderBuddy(buddy);
} catch (e) {
showError(container, 'Dashboard API not responding. Make sure lean-ctx dashboard is running.');
}
}
function setOverviewTF(days) {
overviewTF = days;
document.querySelectorAll('.tf-btn').forEach(b => b.classList.toggle('active', Number(b.dataset.tf) === days));
if (overviewRaw) { overviewPH = ''; updateOverview(overviewRaw); }
}
function buildOverview(container) {
container.innerHTML = `
<div class="tf-bar">
<button class="tf-btn${overviewTF===7?' active':''}" data-tf="7" onclick="setOverviewTF(7)">7d</button>
<button class="tf-btn${overviewTF===30?' active':''}" data-tf="30" onclick="setOverviewTF(30)">30d</button>
<button class="tf-btn${overviewTF===90?' active':''}" data-tf="90" onclick="setOverviewTF(90)">90d</button>
<button class="tf-btn${overviewTF===0?' active':''}" data-tf="0" onclick="setOverviewTF(0)">All</button>
</div>
<div class="hero">
<div class="hero-main">
<div class="hv" data-live id="vSaved"></div>
<div class="hl">Total Tokens Saved</div>
<div class="hs"><b data-live id="vSavedIn"></b> input tokens compressed<br><b data-live id="vSavedOut"></b> output tokens reduced via CEP/TDD</div>
</div>
<div class="hc"><div class="hv" data-live id="vCost" style="color:var(--yellow)"></div><div class="hl">Cost Saved</div><div class="hs" data-live id="vCostSub"></div></div>
<div class="hc"><div class="hv" data-live id="vRate" style="color:var(--purple)"></div><div class="hl">Compression</div><div class="hs" data-live id="vRateSub"></div></div>
<div class="hc"><div class="hv" data-live id="vCalls" style="color:var(--blue)"></div><div class="hl">Total Calls</div><div class="hs" data-live id="vCallsSub"></div></div>
</div>
<div class="row r1" id="buddySection" style="display:none">
<div class="buddy-card" id="buddyCard">
<div class="buddy-sprite" id="buddySpriteWrap"><pre id="buddyAscii"></pre></div>
<div class="buddy-info">
<div class="buddy-name"><span id="buddyName"></span><span class="rarity-badge" id="buddyRarityBadge"></span></div>
<div class="buddy-meta"><span id="buddySpecies"></span> · Lv.<span id="buddyLevel"></span> · <span class="mood-dot" id="buddyMoodDot"></span> <span id="buddyMood"></span> · <span id="buddyStreak"></span>d streak</div>
<div class="xp-wrap"><div class="xp-label"><span>XP <span id="buddyXp">0</span></span><span>Next Lv. <span id="buddyXpNext">0</span></span></div><div class="xp-track"><div class="xp-fill" id="buddyXpBar" style="width:0%"></div></div></div>
<div class="buddy-stats-grid">
<div class="stat-cell"><div class="stat-label">CMP</div><div class="stat-gauge"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15.9"/><circle class="fg" id="sgComp" cx="18" cy="18" r="15.9" stroke="var(--green)" stroke-dasharray="100" stroke-dashoffset="100"/></svg></div><div class="stat-val" style="color:var(--green)" id="bsComp">0</div></div>
<div class="stat-cell"><div class="stat-label">VIG</div><div class="stat-gauge"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15.9"/><circle class="fg" id="sgVig" cx="18" cy="18" r="15.9" stroke="var(--yellow)" stroke-dasharray="100" stroke-dashoffset="100"/></svg></div><div class="stat-val" style="color:var(--yellow)" id="bsVig">0</div></div>
<div class="stat-cell"><div class="stat-label">END</div><div class="stat-gauge"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15.9"/><circle class="fg" id="sgEnd" cx="18" cy="18" r="15.9" stroke="var(--purple)" stroke-dasharray="100" stroke-dashoffset="100"/></svg></div><div class="stat-val" style="color:var(--purple)" id="bsEnd">0</div></div>
<div class="stat-cell"><div class="stat-label">WIS</div><div class="stat-gauge"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15.9"/><circle class="fg" id="sgWis" cx="18" cy="18" r="15.9" stroke="var(--blue)" stroke-dasharray="100" stroke-dashoffset="100"/></svg></div><div class="stat-val" style="color:var(--blue)" id="bsWis">0</div></div>
<div class="stat-cell"><div class="stat-label">EXP</div><div class="stat-gauge"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15.9"/><circle class="fg" id="sgExp" cx="18" cy="18" r="15.9" stroke="var(--text)" stroke-dasharray="100" stroke-dashoffset="100"/></svg></div><div class="stat-val" id="bsExp">0</div></div>
</div>
<div class="buddy-speech" id="buddySpeech"></div>
</div>
</div>
</div>
<div class="row r21">
<div class="card"><h3>Cumulative Token Savings</h3><canvas id="cCum"></canvas></div>
<div class="card">
<h3>Cost Analysis <span class="badge">$2.50/M in · $10/M out</span></h3>
<div class="cost-row">
<div class="cost-box bad"><div class="amt" data-live id="vCW" style="color:var(--red)"></div><div class="lb">Without</div></div>
<div class="cost-arrow">→</div>
<div class="cost-box good"><div class="amt" data-live id="vCC" style="color:var(--green)"></div><div class="lb">With</div></div>
</div>
<div class="cost-detail">
<div class="cd-item"><div class="v" data-live id="vIS" style="color:var(--purple)"></div><div class="l">Input Saved</div></div>
<div class="cd-item"><div class="v" data-live id="vOS" style="color:var(--pink)"></div><div class="l">Output Saved</div></div>
</div>
<div style="text-align:center;margin-top:8px;font-size:8px;color:var(--muted)">~<span data-live id="vOT"></span> output tokens reduced via CEP/TDD</div>
</div>
</div>
<div class="row r3">
<div class="card"><h3>Daily Activity</h3><canvas id="cDay"></canvas></div>
<div class="card"><h3>Savings Rate</h3><canvas id="cRate"></canvas></div>
<div class="card">
<h3>MCP vs Shell Hook</h3>
<canvas id="cPie" style="max-height:140px"></canvas>
<div class="src-grid" style="margin-top:10px">
<div class="src-item"><h4><span class="d" style="background:var(--purple)"></span>MCP Tools</h4><div class="sr"><span class="sl">Calls</span><span class="sv" data-live id="vMC"></span></div><div class="sr"><span class="sl">Saved</span><span class="sv" style="color:var(--green)" data-live id="vMS"></span></div><div class="sr"><span class="sl">Rate</span><span class="sv" style="color:var(--purple)" data-live id="vMR"></span></div></div>
<div class="src-item"><h4><span class="d" style="background:var(--blue)"></span>Shell Hook</h4><div class="sr"><span class="sl">Calls</span><span class="sv" data-live id="vHC"></span></div><div class="sr"><span class="sl">Saved</span><span class="sv" style="color:var(--green)" data-live id="vHS"></span></div><div class="sr"><span class="sl">Rate</span><span class="sv" style="color:var(--blue)" data-live id="vHR"></span></div></div>
</div>
</div>
</div>
<div class="row r1">
<div class="card">
<h3>Command Breakdown</h3>
<div style="overflow-x:auto"><table><thead><tr><th>Source</th><th>Command</th><th class="r">Count</th><th class="r">Input</th><th class="r">Output</th><th class="r">Saved</th><th class="r">Rate</th><th style="width:90px"></th></tr></thead><tbody id="tC"></tbody></table></div>
<div style="text-align:center"><button class="btn" style="margin-top:8px" id="cTog" onclick="toggleCmds()"></button></div>
</div>
</div>`;
overviewBuilt = true;
}
function filterDailyByDays(daily, days) {
if (!days) return daily;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const cutStr = cutoff.toISOString().slice(0, 10);
return daily.filter(d => d.date >= cutStr);
}
function updateOverview(d) {
const allDaily = d.daily || [];
const daily = overviewTF ? filterDailyByDays(allDaily, overviewTF) : fd(allDaily, overviewCR);
const tfInp = daily.reduce((a, x) => a + (x.input_tokens || 0), 0);
const tfOut = daily.reduce((a, x) => a + (x.output_tokens || 0), 0);
const tfCmds = daily.reduce((a, x) => a + (x.commands || 0), 0);
const iSv = tfInp - tfOut;
const co = gc(tfInp, tfOut, tfCmds);
const rate = pc(iSv, tfInp);
const cmds = Object.entries(d.commands || {});
cmds.sort((a,b) => (b[1].input_tokens-b[1].output_tokens) - (a[1].input_tokens-a[1].output_tokens));
const sp = ss(cmds);
let fu1 = '---'; if (d.first_use) fu1 = d.first_use.substring(0,10);
lv('vSaved', fmt(iSv)); lv('vSavedIn', ff(iSv)); lv('vSavedOut', '~'+fmt(co.os));
lv('vCost', fu(co.sv)); lv('vCostSub', 'input '+fu(co.iW-co.iC)+' \u00B7 output '+fu(co.oW-co.oC));
lv('vRate', rate+'%'); lv('vRateSub', fmt(tfInp)+' \u2192 '+fmt(tfOut));
lv('vCalls', ff(tfCmds)); lv('vCallsSub', daily.length+' days \u00B7 since '+fu1);
lv('vCW', fu(co.tW)); lv('vCC', fu(co.tC));
lv('vIS', fu(co.iW-co.iC)); lv('vOS', fu(co.oW-co.oC)); lv('vOT', fmt(co.os));
lv('vMC', ff(sp.m.c)); lv('vMS', fmt(sp.m.s)); lv('vMR', pc(sp.m.s,sp.m.i)+'%');
lv('vHC', ff(sp.h.c)); lv('vHS', fmt(sp.h.s)); lv('vHR', pc(sp.h.s,sp.h.i)+'%');
const dh = JSON.stringify(daily.map(x => x.date+x.commands));
const chh = JSON.stringify(cmds.map(c => c[0]+c[1].count));
if (dh+chh !== overviewPH) { updateOverviewCharts(daily, cmds, sp); renderCmds(cmds); overviewPH = dh+chh; }
}
function updateOverviewCharts(daily, cmds, sp) {
const lb = daily.map(d => d.date.substring(5));
const sv = daily.map(d => d.input_tokens - d.output_tokens);
const ot = daily.map(d => d.output_tokens);
const rt = daily.map(d => d.input_tokens > 0 ? Math.round((d.input_tokens-d.output_tokens)/d.input_tokens*100) : 0);
let cum = [], s = 0; for (const x of sv) { s += x; cum.push(s); }
const sm = daily.length > 30;
const df = chartDefaults;
if (charts.cum) { charts.cum.data.labels = lb; charts.cum.data.datasets[0].data = cum; charts.cum.update('none'); }
else { charts.cum = new Chart($('cCum'), { type:'line', data:{ labels:lb, datasets:[{ data:cum, fill:true, borderColor:'#34d399', backgroundColor:'rgba(52,211,153,.04)', borderWidth:2, pointRadius:sm?0:3, pointBackgroundColor:'#34d399', tension:.4 }] }, options:{ ...df(), plugins:{ legend:{display:false}, tooltip:{callbacks:{label:c=>`${fmt(c.parsed.y)} tokens saved`}} } } }); }
if (charts.day) { charts.day.data.labels = lb; charts.day.data.datasets[0].data = sv; charts.day.data.datasets[1].data = ot; charts.day.update('none'); }
else { charts.day = new Chart($('cDay'), { type:'bar', data:{ labels:lb, datasets:[ {label:'Saved',data:sv,backgroundColor:'rgba(52,211,153,.4)',borderRadius:3,borderSkipped:false}, {label:'Sent',data:ot,backgroundColor:'rgba(129,140,248,.15)',borderRadius:3,borderSkipped:false} ] }, options:{ ...df(), plugins:{legend:{display:true,position:'bottom',labels:{color:'#6b6b88',font:{size:9},usePointStyle:true,pointStyle:'circle',padding:12}}}, scales:{...df().scales,x:{...df().scales.x,stacked:true},y:{...df().scales.y,stacked:true}} } }); }
if (charts.rate) { charts.rate.data.labels = lb; charts.rate.data.datasets[0].data = rt; charts.rate.update('none'); }
else { charts.rate = new Chart($('cRate'), { type:'line', data:{ labels:lb, datasets:[{ data:rt, fill:true, borderColor:'#818cf8', backgroundColor:'rgba(129,140,248,.04)', borderWidth:2, pointRadius:sm?0:3, pointBackgroundColor:'#818cf8', tension:.4 }] }, options:{ ...df(), scales:{...df().scales,y:{...df().scales.y,ticks:{...df().scales.y.ticks,callback:v=>v+'%'},suggestedMin:0,suggestedMax:100}}, plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>c.parsed.y+'% savings rate'}}} } }); }
if (charts.pie) { charts.pie.data.datasets[0].data = [sp.m.s, sp.h.s]; charts.pie.update('none'); }
else { charts.pie = new Chart($('cPie'), { type:'doughnut', data:{ labels:['MCP Tools','Shell Hook'], datasets:[{data:[sp.m.s,sp.h.s],backgroundColor:['#818cf8','#38bdf8'],borderWidth:0,hoverOffset:4,borderRadius:3}] }, options:{ responsive:true, maintainAspectRatio:true, cutout:'70%', plugins:{legend:{position:'bottom',labels:{color:'#6b6b88',font:{size:9},padding:10,usePointStyle:true,pointStyle:'circle'}},tooltip:{callbacks:{label:c=>`${c.label}: ${fmt(c.parsed)} tokens`}}} } }); }
}
function toggleCmds() {
overviewShowAll = !overviewShowAll;
if (overviewRaw) { const c = Object.entries(overviewRaw.commands||{}); c.sort((a,b)=>(b[1].input_tokens-b[1].output_tokens)-(a[1].input_tokens-a[1].output_tokens)); renderCmds(c); }
const b = $('cTog'); if (b) b.textContent = overviewShowAll ? 'Show active only' : 'Show all commands';
}
function renderCmds(cmds) {
const t = $('tC'); if (!t) return;
const f = overviewShowAll ? cmds : cmds.filter(([,s]) => s.input_tokens-s.output_tokens > 0);
const mx = f.length > 0 ? Math.max(...f.map(c => c[1].input_tokens-c[1].output_tokens), 1) : 1;
t.innerHTML = f.map(([n,s]) => {
const sv = s.input_tokens-s.output_tokens, r = pc(sv,s.input_tokens), bw = pc(Math.abs(sv),mx), ng = sv < 0;
return `<tr><td>${sb(n)}</td><td style="color:var(--text-bright)">${esc(n)}</td><td class="r">${ff(s.count)}</td><td class="r">${fmt(s.input_tokens)}</td><td class="r">${fmt(s.output_tokens)}</td><td class="r"><span class="tag ${ng?'td':'tg'}">${ng?'-':''}${fmt(Math.abs(sv))}</span></td><td class="r"><span class="tag ${r>=50?'tg':r>=20?'tp':'ty'}">${r}%</span></td><td><div class="bar-bg"><div class="bar-f" style="width:${bw}%;background:${isM(n)?'var(--purple)':'var(--green)'}"></div></div></td></tr>`;
}).join('');
const hid = cmds.length - f.length; const b = $('cTog');
if (b) { if (hid === 0 && !overviewShowAll) b.style.display = 'none'; else { b.style.display = ''; b.textContent = overviewShowAll ? 'Show active only' : 'Show all ('+hid+' hidden)'; } }
}
function renderBuddy(b) {
if (!b || !b.name) return;
$('buddySection').style.display = '';
const lvlTier = (b.level >= 75) ? 't4' : (b.level >= 50) ? 't3' : (b.level >= 25) ? 't2' : (b.level >= 10) ? 't1' : 't0';
$('buddyCard').className = 'buddy-card rarity-' + b.rarity + ' lvl-' + lvlTier;
$('buddyName').textContent = b.name; $('buddySpecies').textContent = b.species;
$('buddyLevel').textContent = b.level; $('buddyMood').textContent = b.mood;
$('buddyStreak').textContent = b.streak_days || 0;
$('buddySpeech').textContent = '"' + b.speech + '"';
$('buddyRarityBadge').textContent = b.rarity; $('buddyRarityBadge').className = 'rarity-badge r-' + b.rarity;
$('buddyMoodDot').className = 'mood-dot mood-' + b.mood;
$('buddySpriteWrap').className = 'buddy-sprite rarity-' + b.rarity + ' lvl-' + lvlTier;
if (window.__buddyAnimTimer) { clearInterval(window.__buddyAnimTimer); window.__buddyAnimTimer = null; }
const frames = (b.ascii_frames && b.ascii_frames.length) ? b.ascii_frames : (b.ascii_art ? [b.ascii_art] : []);
if (frames.length) $('buddyAscii').textContent = frames[0].join('\n');
if (frames.length > 1) {
let i = 0;
const ms = b.anim_ms || 850;
window.__buddyAnimTimer = setInterval(() => {
i = (i + 1) % frames.length;
$('buddyAscii').textContent = frames[i].join('\n');
}, ms);
}
const xpPct = b.xp_next_level > 0 ? Math.min(100, Math.round(b.xp/b.xp_next_level*100)) : 0;
$('buddyXp').textContent = fmt(b.xp); $('buddyXpNext').textContent = fmt(b.xp_next_level);
$('buddyXpBar').style.width = xpPct + '%';
if (b.stats) {
const st = b.stats;
setBuddyStat('Comp', st.compression); setBuddyStat('Vig', st.vigilance);
setBuddyStat('End', st.endurance); setBuddyStat('Wis', st.wisdom); setBuddyStat('Exp', st.experience);
}
}
function setBuddyStat(id, val) {
const el = $('bs'+id); if (el) el.textContent = val;
const c = $('sg'+id); if (c) c.setAttribute('stroke-dashoffset', 100 - val);
}
async function loadLive() {
const container = $('view-live');
try {
const events = await apiFetch('/api/events');
if (!container.dataset.built) {
container.innerHTML = `
<div class="row r1" style="margin-bottom:16px">
<div class="card" style="text-align:center;padding:24px">
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.18em;font-weight:600;margin-bottom:8px">Tokens Saved This Session</div>
<div class="token-counter" id="liveCounter">0</div>
</div>
</div>
<div class="filter-row">
<button class="filter-btn active" data-filter="all" onclick="setLiveFilter('all',this)">All</button>
<button class="filter-btn" data-filter="read" onclick="setLiveFilter('read',this)">Reads</button>
<button class="filter-btn" data-filter="shell" onclick="setLiveFilter('shell',this)">Shell</button>
<button class="filter-btn" data-filter="search" onclick="setLiveFilter('search',this)">Search</button>
<button class="filter-btn" data-filter="cache" onclick="setLiveFilter('cache',this)">Cache</button>
</div>
<div id="liveEventFeed" style="display:flex;flex-direction:column;gap:6px"></div>
${howItWorks('Live Event Stream', 'Events are captured in real-time as LeanCTX processes tool calls. Each event records the <strong>tool name</strong>, <strong>token count</strong> before and after compression, and the <strong>compression mode</strong> used. The feed polls <code>/api/events</code> every 2 seconds.')}`;
container.dataset.built = 'true';
}
renderLiveEvents(events);
} catch (e) {
if (!container.dataset.built) showError(container, 'Could not load live events.');
}
}
function setLiveFilter(f, btn) {
liveFilter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === f));
loadLive();
}
function flattenEvent(ev) {
const k = ev.kind || {};
const type = k.type || 'unknown';
let tool = '', saved = 0, detail = '', color = 'var(--muted)', filterCat = 'other';
if (type === 'ToolCall') {
tool = k.tool || 'unknown';
saved = k.tokens_saved || 0;
const orig = k.tokens_original || 0;
const mode = k.mode || '';
const pct = orig > 0 ? Math.round(saved / orig * 100) : 0;
detail = (mode ? '<span class="tag tp">' + esc(mode) + '</span> ' : '') + (saved > 0 ? '<span class="tag tg">-' + fmt(saved) + ' tok (' + pct + '%)</span>' : '');
if (k.path) detail += ' <span style="color:var(--muted);font-size:9px">' + esc(k.path) + '</span>';
color = tool.includes('read') ? 'var(--green)' : tool.includes('shell') ? 'var(--blue)' : tool.includes('search') ? 'var(--purple)' : tool.includes('tree') ? 'var(--pink)' : 'var(--yellow)';
filterCat = tool.includes('read') ? 'read' : tool.includes('shell') ? 'shell' : tool.includes('search') ? 'search' : 'other';
} else if (type === 'CacheHit') {
tool = 'cache hit';
saved = k.saved_tokens || 0;
detail = '<span class="tag tp">cache</span> <span class="tag tg">-' + fmt(saved) + ' tok</span>';
if (k.path) detail += ' <span style="color:var(--muted);font-size:9px">' + esc(k.path) + '</span>';
color = 'var(--purple)';
filterCat = 'cache';
} else if (type === 'Compression') {
tool = 'compression';
const removed = k.removed_line_count || 0;
detail = '<span class="tag tb">' + esc(k.strategy || '') + '</span> ' + (k.before_lines||0) + 'L → ' + (k.after_lines||0) + 'L <span class="tag tg">-' + removed + ' lines</span>';
if (k.path) detail += ' <span style="color:var(--muted);font-size:9px">' + esc(k.path) + '</span>';
color = 'var(--blue)';
} else if (type === 'AgentAction') {
tool = 'agent: ' + (k.agent_id || '').split('-').pop();
detail = '<span class="tag ty">' + esc(k.action || '') + '</span>';
if (k.tool) detail += ' via ' + esc(k.tool);
color = 'var(--yellow)';
} else if (type === 'KnowledgeUpdate') {
tool = 'knowledge';
const actionTag = k.action === 'contradict' ? 'td' : 'tg';
detail = '<span class="tag ' + actionTag + '">' + esc(k.action || '') + '</span> ' + esc(k.category || '') + '/' + esc(k.key || '');
color = 'var(--purple)';
} else if (type === 'ThresholdShift') {
tool = 'threshold';
detail = '<span class="tag tb">' + esc(k.language || '') + '</span> entropy ' + (k.old_entropy||0).toFixed(2) + '→' + (k.new_entropy||0).toFixed(2) + ' jaccard ' + (k.old_jaccard||0).toFixed(2) + '→' + (k.new_jaccard||0).toFixed(2);
color = 'var(--muted)';
}
return { tool, saved, detail, color, filterCat, ts: ev.timestamp || '', type };
}
function renderLiveEvents(events) {
if (!events || !events.length) {
const feed = $('liveEventFeed');
if (feed) feed.innerHTML = '<div class="empty-state" style="padding:40px"><h2>Waiting for events</h2><p>Use LeanCTX tools in your IDE to see real-time activity here.</p></div>';
return;
}
const flat = events.map(flattenEvent);
let total = 0;
flat.forEach(e => { total += e.saved; });
liveTotalTokens = total;
const counter = $('liveCounter');
if (counter) counter.textContent = ff(total);
const filtered = flat.filter(e => {
if (liveFilter === 'all') return true;
return e.filterCat === liveFilter;
});
const feed = $('liveEventFeed');
if (!feed) return;
feed.innerHTML = filtered.slice(-50).reverse().map(e => {
const ts = e.ts ? e.ts.substring(11, 19) : '';
return `<div class="event-card">
<div class="event-icon" style="background:${e.color}22;color:${e.color}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
<div class="event-body">
<div class="event-tool">${esc(e.tool)}</div>
<div class="event-detail">${e.detail}</div>
</div>
<div class="event-time">${esc(ts)}</div>
</div>`;
}).join('');
}
let knowledgeSimulation = null;
async function loadKnowledge() {
const container = $('view-knowledge');
if (!container.dataset.built) showLoading(container);
try {
if (inFlight.get('knowledge')) return;
inFlight.set('knowledge', true);
const data = await apiFetch('/api/knowledge', { timeoutMs: 4000 });
const facts = data.facts || [];
if (!facts.length) {
const root = data.root || data.project_root;
showEmpty(container, 'No knowledge facts stored yet for this project' + (root ? ` (${root})` : '') + '. LeanCTX builds project knowledge automatically as you work.');
return;
}
container.innerHTML = `
<div class="d3-container" id="knowledgeGraph"></div>
${howItWorks('Knowledge Graph', 'LeanCTX maintains a project knowledge base where facts are stored with <strong>confidence scores</strong> that decay over time (unused facts lose confidence). Facts are organized by <strong>category</strong> (Architecture, Testing, etc.) and <strong>room</strong>. Each fact has <strong>temporal validity</strong> — it knows when it was learned and when it was last confirmed. <strong>Contradiction detection</strong> automatically resolves conflicting facts by preferring the most recent with highest confidence.')}`;
container.dataset.built = 'true';
renderKnowledgeGraph(facts);
} catch (e) {
showError(container, (e && e.message === 'timeout')
? 'Knowledge is still loading. If this persists, restart the dashboard and run a few tool calls to populate facts.'
: 'Could not load knowledge data.'
);
} finally { inFlight.delete('knowledge'); }
}
function renderKnowledgeGraph(facts) {
const graphEl = $('knowledgeGraph');
if (!graphEl) return;
graphEl.innerHTML = '';
const width = graphEl.clientWidth, height = graphEl.clientHeight || 500;
const categoryColors = {
'ARCHITECTURE': '#38bdf8', 'TESTING': '#34d399', 'DEBUGGING': '#f87171',
'WORKFLOW': '#818cf8', 'DEPLOYMENT': '#f472b6', 'PERFORMANCE': '#fbbf24',
'E2E': '#34d399', 'SECURITY': '#f87171',
};
const nodes = facts.map((f, i) => {
const cat = (f.category || '').split(':')[0].toUpperCase();
return { id: i, label: f.key || f.category || 'fact-'+i, category: cat, confidence: f.confidence || 0.5, fact: f, color: categoryColors[cat] || '#6b6b88' };
});
const links = [];
const roomMap = {};
nodes.forEach(n => {
const room = n.category;
if (!roomMap[room]) roomMap[room] = [];
roomMap[room].push(n);
});
Object.values(roomMap).forEach(group => {
for (let i = 1; i < group.length; i++) {
links.push({ source: group[0].id, target: group[i].id });
}
});
const svg = d3.select(graphEl).append('svg').attr('width', width).attr('height', height);
const g = svg.append('g');
const zoom = d3.zoom().scaleExtent([0.2, 5]).on('zoom', e => g.attr('transform', e.transform));
svg.call(zoom);
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width/2, height/2))
.force('collision', d3.forceCollide().radius(d => 8 + d.confidence * 20));
const link = g.append('g').selectAll('line').data(links).join('line')
.attr('stroke', 'rgba(255,255,255,0.06)').attr('stroke-width', 1);
const node = g.append('g').selectAll('circle').data(nodes).join('circle')
.attr('r', d => 4 + d.confidence * 14)
.attr('fill', d => d.color)
.attr('fill-opacity', 0.8)
.attr('stroke', d => d.color)
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.3)
.style('cursor', 'pointer')
.call(d3.drag().on('start', (e,d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e,d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e,d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }))
.on('click', (e, d) => showKnowledgeDetail(d));
const label = g.append('g').selectAll('text').data(nodes).join('text')
.text(d => d.label.length > 20 ? d.label.substring(0,18)+'...' : d.label)
.attr('font-size', 9).attr('fill', '#6b6b88').attr('text-anchor', 'middle').attr('dy', d => -(8 + d.confidence * 14) - 4);
simulation.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('cx', d => d.x).attr('cy', d => d.y);
label.attr('x', d => d.x).attr('y', d => d.y);
});
knowledgeSimulation = simulation;
}
function showKnowledgeDetail(d) {
const f = d.fact;
$('detailContent').innerHTML = `
<h3>${esc(d.label)}</h3>
<div class="detail-row"><span class="dl">Category</span><span class="dv">${esc(d.category)}</span></div>
<div class="detail-row"><span class="dl">Confidence</span><span class="dv">${Math.round(d.confidence*100)}%</span></div>
<div class="detail-row"><span class="dl">Value</span><span class="dv" style="word-break:break-all">${esc(f.value || f.fact || JSON.stringify(f))}</span></div>
${f.learned_at ? `<div class="detail-row"><span class="dl">Learned</span><span class="dv">${esc(f.learned_at)}</span></div>` : ''}
${f.last_confirmed ? `<div class="detail-row"><span class="dl">Last Confirmed</span><span class="dv">${esc(f.last_confirmed)}</span></div>` : ''}
${f.source ? `<div class="detail-row"><span class="dl">Source</span><span class="dv">${esc(f.source)}</span></div>` : ''}`;
$('detailPanel').classList.add('open');
}
let depsSimulation = null;
async function loadDeps() {
const container = $('view-deps');
if (!container.dataset.built) showLoading(container);
try {
if (inFlight.get('deps')) return;
inFlight.set('deps', true);
const data = await apiFetch('/api/graph', { timeoutMs: 8000 });
if (isBuildingData(data)) {
showIndexing(container, 'Dependency graph is building in the background. This view will refresh automatically in a few seconds.', 'deps', loadDeps);
return;
}
resetRetry('deps');
const files = data.files ? Object.values(data.files) : [];
const edges = data.edges || [];
if (!files.length) { showEmpty(container, 'No dependency data yet. LeanCTX builds a dependency graph using tree-sitter parsing.'); return; }
container.innerHTML = `
<div class="d3-container" id="depsGraph"></div>
${howItWorks('Dependency Map', 'LeanCTX uses <strong>tree-sitter</strong> to parse source files and extract import/export relationships. Files become nodes sized by their <strong>token count</strong> (larger = more tokens). Edges represent import dependencies. Nodes are colored by <strong>language</strong>. Directory clusters are formed by grouping files in the same folder. The force-directed layout naturally clusters tightly-coupled modules together.')}`;
container.dataset.built = 'true';
renderDepsGraph(files, edges);
} catch (e) {
showError(container, (e && e.message === 'timeout')
? 'Dependency graph is still indexing. Try again in a few seconds.'
: 'Could not load dependency graph.'
);
} finally { inFlight.delete('deps'); }
}
function renderDepsGraph(files, edges) {
const graphEl = $('depsGraph');
if (!graphEl) return;
graphEl.innerHTML = '';
const width = graphEl.clientWidth, height = graphEl.clientHeight || 500;
const langColors = {
'rust': '#f87171', 'rs': '#f87171', 'javascript': '#fbbf24', 'js': '#fbbf24',
'typescript': '#38bdf8', 'ts': '#38bdf8', 'tsx': '#38bdf8', 'python': '#34d399', 'py': '#34d399',
'go': '#818cf8', 'toml': '#f472b6', 'json': '#6b6b88', 'yaml': '#6b6b88', 'md': '#6b6b88',
};
const maxTokens = Math.max(...files.map(f => f.token_count || 1), 1);
const nodeMap = {};
const nodes = files.map((f, i) => {
const ext = f.path.split('.').pop() || '';
const lang = f.language || ext;
const n = { id: f.path, label: f.path.split('/').pop(), tokens: f.token_count || 0, language: lang, color: langColors[lang.toLowerCase()] || langColors[ext] || '#6b6b88', file: f, radius: 3 + Math.sqrt(f.token_count || 0) / Math.sqrt(maxTokens) * 15 };
nodeMap[f.path] = n;
return n;
});
const resolveEdgeTarget = (t) => {
if (nodeMap[t]) return t;
const normalized = t.replace(/::/g, '/');
const candidates = Object.keys(nodeMap);
const byModule = candidates.find(p => p.includes(normalized) || p.includes(normalized.split('/')[0]));
if (byModule) return byModule;
const byName = candidates.find(p => {
const exps = (nodeMap[p].file.exports || []);
return exps.some(ex => t.includes(ex) || ex.includes(t.split('::').pop()));
});
return byName || null;
};
const validEdges = edges.map(e => {
const src = resolveEdgeTarget(e.from);
const tgt = resolveEdgeTarget(e.to);
return (src && tgt && src !== tgt) ? { source: src, target: tgt } : null;
}).filter(Boolean);
const svg = d3.select(graphEl).append('svg').attr('width', width).attr('height', height);
const g = svg.append('g');
svg.call(d3.zoom().scaleExtent([0.1, 5]).on('zoom', e => g.attr('transform', e.transform)));
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(validEdges).id(d => d.id).distance(60))
.force('charge', d3.forceManyBody().strength(-80))
.force('center', d3.forceCenter(width/2, height/2))
.force('collision', d3.forceCollide().radius(d => d.radius + 2));
const link = g.append('g').selectAll('line').data(validEdges).join('line')
.attr('stroke', 'rgba(255,255,255,0.04)').attr('stroke-width', 1);
const node = g.append('g').selectAll('circle').data(nodes).join('circle')
.attr('r', d => d.radius)
.attr('fill', d => d.color)
.attr('fill-opacity', 0.7)
.attr('stroke', d => d.color)
.attr('stroke-width', 1)
.attr('stroke-opacity', 0.2)
.style('cursor', 'pointer')
.call(d3.drag().on('start', (e,d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e,d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e,d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }))
.on('click', (e, d) => showFileDetail(d));
const label = g.append('g').selectAll('text').data(nodes).join('text')
.text(d => d.label.length > 18 ? d.label.substring(0,16)+'..' : d.label)
.attr('font-size', 8).attr('fill', '#6b6b88').attr('text-anchor', 'middle').attr('dy', d => -d.radius - 3)
.style('pointer-events', 'none');
simulation.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('cx', d => d.x).attr('cy', d => d.y);
label.attr('x', d => d.x).attr('y', d => d.y);
});
depsSimulation = simulation;
}
function showFileDetail(d) {
const f = d.file;
const exports = (f.exports || []).join(', ') || 'none';
$('detailContent').innerHTML = `
<h3>${esc(d.label)}</h3>
<div class="detail-row"><span class="dl">Path</span><span class="dv" style="word-break:break-all">${esc(f.path)}</span></div>
<div class="detail-row"><span class="dl">Language</span><span class="dv">${esc(d.language)}</span></div>
<div class="detail-row"><span class="dl">Tokens</span><span class="dv">${ff(f.token_count || 0)}</span></div>
<div class="detail-row"><span class="dl">Lines</span><span class="dv">${ff(f.line_count || 0)}</span></div>
<div class="detail-row"><span class="dl">Exports</span><span class="dv" style="word-break:break-all">${esc(exports)}</span></div>`;
$('detailPanel').classList.add('open');
}
let compressionFiles = [];
let compressionSelected = null;
let compressionMode = 'map';
async function loadCompression() {
const container = $('view-compression');
if (!container.dataset.built) {
showLoading(container);
try {
const [graph, searchIdx] = await Promise.all([
apiFetch('/api/graph').catch(() => null),
apiFetch('/api/search-index').catch(() => null),
]);
if (isBuildingData(graph) || isBuildingData(searchIdx)) {
showIndexing(container, 'Indexes are building in the background. This view will refresh automatically in a few seconds.', 'compression', loadCompression);
return;
}
resetRetry('compression');
const graphPaths = graph && graph.files ? Object.values(graph.files).map(f => f.path) : [];
const searchPaths = searchIdx && searchIdx.top_chunks_by_token_count ? searchIdx.top_chunks_by_token_count.map(c => c.file_path) : [];
const all = new Set([...graphPaths, ...searchPaths]);
compressionFiles = [...all].sort();
} catch { compressionFiles = []; }
container.innerHTML = `
<div class="row r12">
<div class="card" style="max-height:600px;overflow:hidden;display:flex;flex-direction:column">
<h3>Select File</h3>
<input type="text" class="search-input" placeholder="Filter files..." oninput="filterCompressionFiles(this.value)" style="margin-bottom:8px">
<div class="file-list" id="compressionFileList"></div>
</div>
<div class="card" id="compressionResult">
<div class="empty-state" style="padding:40px"><h2>Select a file</h2><p>Choose a file from the list to see compression modes in action.</p></div>
</div>
</div>
${howItWorks('Compression Algorithms', '<strong>map</strong> — Extracts dependency graph + exports + key function signatures. Best for understanding file structure without reading code.<br><br><strong>signatures</strong> — Extracts only the API surface (function/class/type signatures). Strips all implementation details.<br><br><strong>aggressive</strong> — Removes all comments, blank lines, and non-essential whitespace. Preserves code logic but minimizes formatting.<br><br><strong>entropy</strong> — Uses Shannon entropy analysis and Jaccard similarity to identify and remove low-information-density sections. Keeps only the most unique, information-rich lines.')}`;
container.dataset.built = 'true';
renderCompressionFileList(compressionFiles);
}
}
function filterCompressionFiles(query) {
const q = query.toLowerCase();
const filtered = compressionFiles.filter(f => f.toLowerCase().includes(q));
renderCompressionFileList(filtered);
}
function renderCompressionFileList(files) {
const el = $('compressionFileList');
if (!el) return;
el.innerHTML = files.slice(0, 100).map(f =>
`<div class="file-item${f === compressionSelected ? ' selected' : ''}" data-path="${esc(f)}">${esc(f)}</div>`
).join('');
el.querySelectorAll('.file-item').forEach(item => {
item.onclick = () => selectCompressionFile(item.dataset.path);
});
}
async function selectCompressionFile(path) {
compressionSelected = path;
renderCompressionFileList(compressionFiles);
const result = $('compressionResult');
if (!result) return;
result.innerHTML = '<div class="loading-state">Compressing...</div>';
try {
const data = await apiFetch('/api/compression-demo?path=' + encodeURIComponent(path));
if (data.error) { result.innerHTML = `<div class="empty-state"><p>${esc(data.error)}</p></div>`; return; }
renderCompressionResult(data);
} catch (e) { result.innerHTML = '<div class="empty-state"><p>Failed to load compression demo.</p></div>'; }
}
function renderCompressionResult(data) {
const result = $('compressionResult');
if (!result) return;
const modes = data.modes || {};
const modeKeys = ['map', 'signatures', 'aggressive', 'entropy'];
result.innerHTML = `
<h3>Compression: ${esc(data.path.split('/').pop())} <span class="badge">${ff(data.original_tokens)} tokens · ${ff(data.original_lines)} lines</span></h3>
<div class="mode-tabs" id="compressionModeTabs">
${modeKeys.map(m => `<div class="mode-tab${m === compressionMode ? ' active' : ''}" onclick="switchCompressionMode('${m}')">${m} <span class="tag tg" style="margin-left:4px">${modes[m] ? modes[m].savings_pct+'%' : '---'}</span></div>`).join('')}
</div>
<div class="row r4" style="margin-bottom:12px">
${modeKeys.map(m => {
const md = modes[m] || {};
return `<div class="hc" style="padding:12px;text-align:center"><div class="hv" style="font-size:18px;color:var(--green)">${md.savings_pct || 0}%</div><div class="hl">${m}</div><div class="hs">${ff(md.tokens || 0)} tok</div></div>`;
}).join('')}
</div>
<div id="compressionSplitPane"></div>`;
renderCompressionPane(data);
}
function switchCompressionMode(mode) {
compressionMode = mode;
document.querySelectorAll('#compressionModeTabs .mode-tab').forEach(t => t.classList.toggle('active', t.textContent.trim().startsWith(mode)));
if (compressionSelected) {
const result = $('compressionResult');
if (result && result.dataset.lastData) renderCompressionPane(JSON.parse(result.dataset.lastData));
else selectCompressionFile(compressionSelected);
}
}
function renderCompressionPane(data) {
const result = $('compressionResult');
if (result) result.dataset.lastData = JSON.stringify(data);
const pane = $('compressionSplitPane');
if (!pane) return;
const modeData = (data.modes || {})[compressionMode] || {};
const origLines = data.original_lines || 0;
const compLines = (modeData.output || '').split('\n').length;
const removedLines = Math.max(0, origLines - compLines);
pane.innerHTML = `<div class="split-pane">
<div class="split-side"><h4>Original <span class="badge">${ff(data.original_tokens)} tok · ${ff(origLines)} lines</span></h4><pre style="color:var(--red);opacity:0.6">${removedLines > 0 ? '[-] '+ff(removedLines)+' lines removed by '+esc(compressionMode) : 'Full file'}</pre><pre style="max-height:400px;overflow:auto">${esc((data.original || modeData.output || '').substring(0, 5000))}</pre></div>
<div class="split-side"><h4>${esc(compressionMode)} <span class="badge">${ff(modeData.tokens || 0)} tok · ${modeData.savings_pct || 0}% saved</span></h4><pre style="max-height:400px;overflow:auto">${esc((modeData.output || '').substring(0, 5000) || 'No output')}</pre></div>
</div>`;
}
async function loadAgents() {
const container = $('view-agents');
try {
const data = await apiFetch('/api/agents');
const agents = data.agents || [];
container.innerHTML = `
<div class="row r3" style="margin-bottom:16px">
<div class="hc"><div class="hv" style="color:var(--green);font-size:24px">${data.total_active || 0}</div><div class="hl">Active Agents</div></div>
<div class="hc"><div class="hv" style="color:var(--purple);font-size:24px">${data.pending_messages || 0}</div><div class="hl">Pending Messages</div></div>
<div class="hc"><div class="hv" style="color:var(--blue);font-size:24px">${data.shared_contexts || 0}</div><div class="hl">Shared Contexts</div></div>
</div>
<div id="agentTimeline"></div>
${howItWorks('Multi-Agent Coordination', 'LeanCTX supports <strong>multi-agent</strong> workflows where multiple AI coding assistants can work concurrently. Agents register themselves and share context through a <strong>scratchpad</strong> mechanism. Each agent has a <strong>type</strong> (parent, child, specialist), a <strong>role</strong> description, and a <strong>status</strong>. The system coordinates context sharing via <code>ctx_share</code> and task handoff via <code>ctx_agent(action=handoff)</code>.')}`;
const timeline = $('agentTimeline');
if (!agents.length) {
timeline.innerHTML = '<div class="empty-state" style="padding:40px"><h2>No active agents</h2><p>Agents appear here when multi-agent workflows are running.</p></div>';
} else {
timeline.innerHTML = agents.map(a => {
const st = (a.status || '').toLowerCase();
const statusColor = st === 'active' ? 'active' : st === 'idle' ? 'idle' : 'offline';
return `<div class="swimlane">
<div class="agent-dot ${statusColor}"></div>
<div class="agent-info">
<div class="agent-name">${esc(a.id)}</div>
<div class="agent-meta">${esc(a.type || '')} · ${esc(a.role || '')} · ${esc(a.status || '')} ${a.status_message ? ' · '+esc(a.status_message) : ''}</div>
</div>
<div style="font-size:10px;color:var(--muted);font-family:var(--mono)">${a.last_active_minutes_ago != null ? a.last_active_minutes_ago+'m ago' : ''}</div>
</div>`;
}).join('');
}
} catch (e) { showError(container, 'Could not load agent data.'); }
}
async function loadBugs() {
const container = $('view-bugs');
try {
const data = await apiFetch('/api/gotchas');
const gotchas = data.gotchas || [];
const stats = data.stats || {};
container.innerHTML = `
<div class="row r4" style="margin-bottom:16px">
<div class="hc"><div class="hv" style="color:var(--blue);font-size:24px">${stats.total_errors_detected || 0}</div><div class="hl">Total Detected</div></div>
<div class="hc"><div class="hv" style="color:var(--green);font-size:24px">${stats.total_prevented || 0}</div><div class="hl">Prevented</div></div>
<div class="hc"><div class="hv" style="color:var(--purple);font-size:24px">${gotchas.length}</div><div class="hl">Active Gotchas</div></div>
<div class="hc"><div class="hv" style="color:var(--yellow);font-size:24px">${stats.total_fixes_correlated || 0}</div><div class="hl">Fixes Correlated</div></div>
</div>
<div class="card">
<h3>Gotcha Patterns</h3>
<div style="overflow-x:auto">
<table>
<thead><tr><th>Severity</th><th>Category</th><th>Trigger</th><th>Resolution</th><th class="r">Seen</th><th class="r">Confidence</th><th class="r">Prevented</th></tr></thead>
<tbody id="bugsTable"></tbody>
</table>
</div>
</div>
${howItWorks('Bug Memory (Gotcha Tracker)', 'LeanCTX automatically detects <strong>error patterns</strong> by monitoring compiler/linter output. When the same error is seen multiple times, it becomes a <strong>gotcha</strong> with a trigger pattern and resolution. The system tracks <strong>confidence</strong> based on how often the pattern-fix correlation holds. When a known gotcha is about to recur, LeanCTX can <strong>prevent</strong> it by injecting the fix into context before the error happens.')}`;
const tb = $('bugsTable');
if (!gotchas.length) { tb.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--muted);padding:20px">No gotchas detected yet</td></tr>'; return; }
tb.innerHTML = gotchas.slice(0, 30).map(g => {
const sevClass = g.severity === 'Critical' ? 'severity-critical' : g.severity === 'Warning' ? 'severity-warning' : 'severity-info';
const conf = Math.round((g.confidence || 0) * 100);
return `<tr>
<td><span class="${sevClass}" style="font-weight:600">${esc(g.severity || 'Info')}</span></td>
<td>${esc((g.category || '').toLowerCase())}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(g.trigger)}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(g.resolution)}</td>
<td class="r">${g.occurrences || 1}x</td>
<td class="r"><span class="tag ${conf >= 70 ? 'tg' : conf >= 40 ? 'ty' : 'td'}">${conf}%</span></td>
<td class="r">${g.prevented_count || 0}</td>
</tr>`;
}).join('');
} catch (e) { showError(container, 'Could not load bug memory data.'); }
}
async function loadSearch() {
const container = $('view-search');
try {
const data = await apiFetch('/api/search-index');
if (isBuildingData(data)) {
showIndexing(container, 'Search index is building in the background. This view will refresh automatically in a few seconds.', 'search', loadSearch);
return;
}
resetRetry('search');
container.innerHTML = `
<div class="row r3" style="margin-bottom:16px">
<div class="hc"><div class="hv" style="color:var(--green);font-size:24px">${ff(data.chunk_count || 0)}</div><div class="hl">Total Chunks</div></div>
<div class="hc"><div class="hv" style="color:var(--purple);font-size:24px">${ff(data.doc_count || 0)}</div><div class="hl">Documents Indexed</div></div>
<div class="hc"><div class="hv" style="color:var(--blue);font-size:24px">${Object.keys(data.language_distribution || {}).length}</div><div class="hl">Languages</div></div>
</div>
<div class="row r11">
<div class="card">
<h3>Language Distribution</h3>
<canvas id="searchLangChart" style="max-height:300px"></canvas>
</div>
<div class="card">
<h3>Top Chunks by Token Count</h3>
<div style="overflow-x:auto;max-height:320px;overflow-y:auto">
<table>
<thead><tr><th>Symbol</th><th>File</th><th>Kind</th><th class="r">Tokens</th><th class="r">Lines</th></tr></thead>
<tbody id="searchChunksTable"></tbody>
</table>
</div>
</div>
</div>
<div class="card" style="margin-top:10px">
<h3>Search Playground</h3>
<div style="display:flex;gap:8px;margin-bottom:12px">
<input id="searchPlaygroundInput" type="text" class="search-input" placeholder="Search your codebase (BM25)…" onkeydown="if(event.key==='Enter')runSearch()">
<button class="btn" onclick="runSearch()" style="white-space:nowrap;padding:8px 20px">Search</button>
</div>
<div id="searchPlaygroundResults" style="font-size:12px;color:var(--muted)">Enter a query and press Enter to search.</div>
</div>
${howItWorks('BM25 Search Index', 'The search index uses the <strong>BM25 (Best Matching 25)</strong> ranking algorithm, a probabilistic retrieval model. Source files are broken into <strong>chunks</strong> by function/class boundaries using tree-sitter. Each chunk is indexed with term frequency (TF) and inverse document frequency (IDF). BM25 uses parameters <code>k1=1.2</code> and <code>b=0.75</code> to balance term saturation and document length normalization.')}`;
const langDist = data.language_distribution || {};
const langEntries = Object.entries(langDist).sort((a,b) => b[1] - a[1]);
const langColors = { rs:'#f87171', js:'#fbbf24', ts:'#38bdf8', tsx:'#38bdf8', py:'#34d399', go:'#818cf8', toml:'#f472b6', json:'#6b6b88', md:'#6b6b88', yaml:'#6b6b88' };
if (langEntries.length && $('searchLangChart')) {
if (charts.searchLang) charts.searchLang.destroy();
charts.searchLang = new Chart($('searchLangChart'), {
type: 'bar',
data: {
labels: langEntries.map(e => e[0] || 'unknown'),
datasets: [{ data: langEntries.map(e => e[1]), backgroundColor: langEntries.map(e => (langColors[e[0]] || '#6b6b88') + '88'), borderRadius: 4, borderSkipped: false }],
},
options: { ...chartDefaults(), indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { ...chartDefaults().scales.x }, y: { ...chartDefaults().scales.y, ticks: { color: '#6b6b88', font: { size: 10, family: 'var(--mono)' } } } } },
});
}
const tb = $('searchChunksTable');
const top = data.top_chunks_by_token_count || [];
tb.innerHTML = top.map(c => `<tr>
<td style="color:var(--text-bright)">${esc(c.symbol_name || '---')}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.file_path)}</td>
<td><span class="tag tp">${esc(c.kind || '')}</span></td>
<td class="r">${ff(c.token_count)}</td>
<td class="r">${c.start_line}-${c.end_line}</td>
</tr>`).join('');
} catch (e) { showError(container, 'Could not load search index data.'); }
}
async function runSearch() {
const q = ($('searchPlaygroundInput') || {}).value || '';
const el = $('searchPlaygroundResults');
if (!el || !q.trim()) return;
el.innerHTML = '<div class="loading-state" style="padding:20px">Searching…</div>';
try {
const data = await apiFetch('/api/search?q=' + encodeURIComponent(q.trim()) + '&limit=20', { timeoutMs: 5000 });
if (isBuildingData(data)) {
el.innerHTML = '<div style="color:var(--muted);padding:16px">Search index is still building…</div>';
return;
}
const results = data.results || [];
if (!results.length) {
el.innerHTML = '<div style="color:var(--muted);padding:16px">No results for <strong>' + esc(q) + '</strong></div>';
return;
}
el.innerHTML = '<div style="margin-bottom:8px;font-size:11px;color:var(--muted)">' + results.length + ' result(s) for <strong>' + esc(q) + '</strong></div>'
+ '<table style="width:100%;border-collapse:collapse"><thead><tr style="text-align:left;font-size:11px;color:var(--muted)"><th style="padding:6px">Score</th><th style="padding:6px">Symbol</th><th style="padding:6px">Kind</th><th style="padding:6px">File</th><th style="padding:6px">Lines</th></tr></thead><tbody>'
+ results.map(r => `<tr style="border-top:1px solid var(--border)"><td style="padding:6px;font-family:var(--mono);color:var(--yellow)">${r.score.toFixed(2)}</td><td style="padding:6px;font-family:var(--mono);color:var(--green)">${esc(r.symbol_name || '—')}</td><td style="padding:6px"><span style="background:var(--surface-2);padding:2px 6px;border-radius:4px;font-size:10px">${esc(r.kind)}</span></td><td style="padding:6px;font-size:11px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.file_path)}</td><td style="padding:6px;font-size:11px">${r.start_line}-${r.end_line}</td></tr>`)
.join('')
+ '</tbody></table>'
+ (results.length > 0 ? '<div style="margin-top:12px"><div style="font-size:10px;color:var(--muted);margin-bottom:6px">Preview: ' + esc(results[0].symbol_name || results[0].file_path) + '</div><pre style="background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:12px;font-family:var(--mono);font-size:11px;line-height:1.6;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-all">' + esc(results[0].snippet || '') + '</pre></div>' : '');
} catch (e) {
el.innerHTML = '<div style="color:var(--red);padding:16px">Search failed: ' + esc(e.message || 'unknown error') + '</div>';
}
}
async function loadLearning() {
const container = $('view-learning');
try {
const data = await apiFetch('/api/feedback');
const thresholds = data.learned_thresholds || {};
const outcomes = data.outcomes || [];
const hasThresholds = Object.keys(thresholds).length > 0;
container.innerHTML = `
<div class="row r11">
<div class="card">
<h3>Learned Compression Thresholds</h3>
<div id="learningThresholdsContainer">
${hasThresholds ? '<canvas id="learningThresholdsChart"></canvas>' : '<div class="empty-state" style="padding:30px"><p>No learned thresholds yet. LeanCTX adapts compression parameters as you use it.</p></div>'}
</div>
</div>
<div class="card">
<h3>Compression Outcomes</h3>
<div id="learningOutcomesContainer">
${outcomes.length ? '<canvas id="learningOutcomesChart"></canvas>' : '<div class="empty-state" style="padding:30px"><p>No compression outcome data yet.</p></div>'}
</div>
</div>
</div>
${howItWorks('Adaptive Learning', 'LeanCTX uses an <strong>exponential moving average (EMA)</strong> to adapt compression thresholds per language. After each tool call, the system records whether the compression was successful (agent completed the task) and the compression ratio achieved. Over time, the <strong>entropy threshold</strong> and <strong>Jaccard similarity threshold</strong> are tuned to maximize compression while maintaining task success. Languages with different information densities (e.g., Rust vs. JSON) naturally converge to different optimal thresholds.')}`;
if (hasThresholds && $('learningThresholdsChart')) {
const langs = Object.keys(thresholds);
const entropy = langs.map(l => thresholds[l].entropy_threshold || thresholds[l].entropy || 0);
const jaccard = langs.map(l => thresholds[l].jaccard_threshold || thresholds[l].jaccard || 0);
if (charts.thresholds) charts.thresholds.destroy();
charts.thresholds = new Chart($('learningThresholdsChart'), {
type: 'bar',
data: {
labels: langs,
datasets: [
{ label: 'Entropy', data: entropy, backgroundColor: 'rgba(52,211,153,0.5)', borderRadius: 4 },
{ label: 'Jaccard', data: jaccard, backgroundColor: 'rgba(129,140,248,0.5)', borderRadius: 4 },
],
},
options: { ...chartDefaults(), plugins: { legend: { display: true, position: 'bottom', labels: { color: '#6b6b88', font: { size: 9 }, usePointStyle: true, pointStyle: 'circle', padding: 12 } } } },
});
}
if (outcomes.length && $('learningOutcomesChart')) {
const scatterData = outcomes.slice(-200).map(o => {
let ratio = o.compression_ratio || o.savings_pct || 0;
if (!ratio && o.tokens_original > 0) {
ratio = Math.round((o.tokens_saved / o.tokens_original) * 100);
}
return { x: ratio, y: o.task_completed ? 1 : 0 };
});
if (charts.outcomes) charts.outcomes.destroy();
charts.outcomes = new Chart($('learningOutcomesChart'), {
type: 'scatter',
data: {
datasets: [{
data: scatterData,
backgroundColor: scatterData.map(d => d.y ? 'rgba(52,211,153,0.6)' : 'rgba(248,113,113,0.6)'),
pointRadius: 4,
}],
},
options: {
...chartDefaults(),
scales: {
x: { ...chartDefaults().scales.x, title: { display: true, text: 'Compression Ratio', color: '#6b6b88', font: { size: 9 } } },
y: { ...chartDefaults().scales.y, title: { display: true, text: 'Task Completed', color: '#6b6b88', font: { size: 9 } }, ticks: { ...chartDefaults().scales.y.ticks, callback: v => v ? 'Yes' : 'No', stepSize: 1 }, min: -0.2, max: 1.2 },
},
},
});
}
} catch (e) { showError(container, 'Could not load learning data.'); }
}
// ─── Symbol Explorer ─────────────────────────────────
async function loadSymbols() {
const container = $('view-symbols');
if (!container) return;
container.innerHTML = `
<div style="margin-bottom:16px;display:flex;gap:8px;align-items:center">
<input id="symSearch" type="text" placeholder="Search symbols…" style="flex:1;padding:8px 12px;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px" oninput="filterSymbols()">
<select id="symKind" style="padding:8px;background:var(--surface-2);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px" onchange="filterSymbols()">
<option value="">All kinds</option>
<option value="fn">Functions</option>
<option value="struct">Structs</option>
<option value="class">Classes</option>
<option value="method">Methods</option>
<option value="trait">Traits</option>
<option value="enum">Enums</option>
</select>
</div>
<div id="symResults" style="font-size:13px"></div>`;
filterSymbols();
}
async function filterSymbols() {
const q = ($('symSearch') || {}).value || '';
const kind = ($('symKind') || {}).value || '';
const params = new URLSearchParams();
if (q) params.set('q', q);
if (kind) params.set('kind', kind);
try {
const data = await apiFetch('/api/symbols?' + params.toString());
const el = $('symResults');
if (!el) return;
if (isBuildingData(data)) {
el.innerHTML = '<div style="color:var(--dim);padding:16px">Indexing symbols…</div>';
scheduleRetry('symbols', filterSymbols);
return;
}
resetRetry('symbols');
if (!data || !data.length) { el.innerHTML = '<div style="color:var(--dim);padding:16px">No symbols found</div>'; return; }
el.innerHTML = '<table style="width:100%;border-collapse:collapse"><thead><tr style="text-align:left;font-size:11px;color:var(--dim)"><th style="padding:6px">Name</th><th style="padding:6px">Kind</th><th style="padding:6px">File</th><th style="padding:6px">Lines</th><th style="padding:6px">Vis</th></tr></thead><tbody>'
+ data.map(s => `<tr style="border-top:1px solid var(--border)"><td style="padding:6px;font-family:var(--mono);color:var(--green)">${esc(s.name)}</td><td style="padding:6px"><span style="background:var(--surface-2);padding:2px 6px;border-radius:4px;font-size:11px">${esc(s.kind)}</span></td><td style="padding:6px;font-size:12px;color:var(--dim)">${esc(s.file)}</td><td style="padding:6px;font-size:12px">${s.start_line}-${s.end_line}</td><td style="padding:6px">${s.is_exported ? '⊛' : '−'}</td></tr>`).join('')
+ '</tbody></table>';
} catch (e) { showError($('symResults'), 'Could not load symbols.'); }
}
// ─── Call Graph Visualizer ───────────────────────────
async function loadCallGraph() {
const container = $('view-callgraph');
if (!container) return;
container.innerHTML = '<div style="color:var(--dim);padding:16px">Loading call graph…</div>';
try {
const data = await apiFetch('/api/call-graph', { timeoutMs: 10000 });
if (isBuildingData(data)) {
showIndexing(container, 'Call graph is building in the background. This view will refresh automatically in a few seconds.', 'callgraph', loadCallGraph);
return;
}
resetRetry('callgraph');
if (!data || !data.edges || !data.edges.length) {
container.innerHTML = '<div style="color:var(--dim);padding:24px;text-align:center">No call graph data available yet. LeanCTX builds it automatically once indexing finishes.</div>';
return;
}
const nodes = new Map();
data.edges.forEach(e => {
nodes.set(e.caller_symbol, (nodes.get(e.caller_symbol) || 0) + 1);
nodes.set(e.callee_name, (nodes.get(e.callee_name) || 0) + 1);
});
const topNodes = [...nodes.entries()].sort((a,b) => b[1]-a[1]).slice(0, 40).map(n => n[0]);
const topSet = new Set(topNodes);
const filteredEdges = data.edges.filter(e => topSet.has(e.caller_symbol) || topSet.has(e.callee_name));
const uniqueEdges = new Map();
filteredEdges.forEach(e => {
const key = e.caller_symbol + '→' + e.callee_name;
if (!uniqueEdges.has(key)) uniqueEdges.set(key, { source: e.caller_symbol, target: e.callee_name, count: 1 });
else uniqueEdges.get(key).count++;
});
const graphNodes = [...new Set([...uniqueEdges.values()].flatMap(e => [e.source, e.target]))].map(n => ({ id: n, count: nodes.get(n) || 0 }));
container.innerHTML = `<div style="margin-bottom:8px;font-size:12px;color:var(--dim)">${data.edges.length} call edges, showing top ${graphNodes.length} symbols</div><div id="cgViz" style="width:100%;height:500px;background:var(--surface);border-radius:12px;border:1px solid var(--border)"></div>`;
const width = container.clientWidth, height = 500;
const svg = d3.select('#cgViz').append('svg').attr('width', width).attr('height', height);
const sim = d3.forceSimulation(graphNodes)
.force('link', d3.forceLink([...uniqueEdges.values()]).id(d => d.id).distance(80))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width/2, height/2));
const link = svg.append('g').selectAll('line').data([...uniqueEdges.values()]).join('line')
.attr('stroke', 'var(--border)').attr('stroke-opacity', 0.6);
const node = svg.append('g').selectAll('circle').data(graphNodes).join('circle')
.attr('r', d => Math.min(4 + d.count, 14)).attr('fill', 'var(--green)').attr('stroke', 'var(--surface)').attr('stroke-width', 1.5)
.call(d3.drag().on('start', (e,d) => { if(!e.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
.on('drag', (e,d) => { d.fx=e.x; d.fy=e.y; })
.on('end', (e,d) => { if(!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }));
const label = svg.append('g').selectAll('text').data(graphNodes).join('text')
.text(d => d.id).attr('font-size', 10).attr('fill', 'var(--text)').attr('dx', 12).attr('dy', 4);
sim.on('tick', () => {
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
node.attr('cx', d => d.x).attr('cy', d => d.y);
label.attr('x', d => d.x).attr('y', d => d.y);
});
} catch (e) { showError(container, 'Could not load call graph.'); }
}
// ─── Route Map ───────────────────────────────────────
async function loadRoutes() {
const container = $('view-routes');
if (!container) return;
container.innerHTML = '<div style="color:var(--dim);padding:16px">Loading routes…</div>';
try {
const data = await apiFetch('/api/routes', { timeoutMs: 8000 });
if (isBuildingData(data)) {
showIndexing(container, 'Route map is building in the background. This view will refresh automatically in a few seconds.', 'routes', loadRoutes);
return;
}
resetRetry('routes');
if (!data || !data.length) {
container.innerHTML = '<div style="color:var(--dim);padding:24px;text-align:center">No HTTP routes found in this project.</div>';
return;
}
const methodColors = { GET: '#34d399', POST: '#60a5fa', PUT: '#fbbf24', PATCH: '#c084fc', DELETE: '#f87171', HEAD: '#94a3b8', OPTIONS: '#94a3b8' };
container.innerHTML = `<div style="margin-bottom:12px;font-size:12px;color:var(--dim)">${data.length} route(s) found</div>
<table style="width:100%;border-collapse:collapse"><thead><tr style="text-align:left;font-size:11px;color:var(--dim)"><th style="padding:6px">Method</th><th style="padding:6px">Path</th><th style="padding:6px">Handler</th><th style="padding:6px">File</th><th style="padding:6px">Line</th></tr></thead><tbody>`
+ data.map(r => `<tr style="border-top:1px solid var(--border)"><td style="padding:6px"><span style="background:${methodColors[r.method] || '#6b6b88'};color:#000;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700">${esc(r.method)}</span></td><td style="padding:6px;font-family:var(--mono);font-size:13px">${esc(r.path)}</td><td style="padding:6px;font-size:12px;color:var(--green)">${esc(r.handler || '—')}</td><td style="padding:6px;font-size:12px;color:var(--dim)">${esc(r.file)}</td><td style="padding:6px;font-size:12px">${r.line}</td></tr>`).join('')
+ '</tbody></table>';
} catch (e) { showError(container, 'Could not load routes.'); }
}
buildSidebar();
loadCurrentView();
startAutoRefresh();
</script>
</body>
</html>