<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sparrow — webview console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0e0b08;--panel:#16120d;--panel2:#1b1611;--line:#2c251c;--line-hot:#3a3122;
--dim:#897d6c;--dimmer:#5c5346;--fg:#ece2cf;
--brand:#f2a93c;--coral:#f0674a;--agent:#4ec9b0;--planner:#6fa6e6;--verifier:#c9a14e;
--add:#74c258;--rem:#d96a63;--steel:#b9b0a3;--gold:#f2c94c;
--sup:#74c258;--tru:#f2a93c;--aut:#d96a63;
--ui-font:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--mono-font:"SF Mono","IBM Plex Mono","Cascadia Code","JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:var(--ui-font);color:var(--fg);min-height:100%;
background:radial-gradient(1100px 700px at 72% -12%,rgba(242,169,60,.13),transparent 60%),
radial-gradient(900px 600px at 10% 114%,rgba(240,103,74,.09),transparent 55%),var(--bg);
overflow:hidden;letter-spacing:.2px}
body::after{content:"";position:fixed;inset:0;pointer-events:none;opacity:.05;z-index:99;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}
.ember{position:fixed;bottom:-10px;width:3px;height:3px;border-radius:50%;opacity:0;z-index:1;filter:blur(.4px);animation:drift linear infinite}
@keyframes drift{0%{transform:translateY(0)}12%{opacity:.5}88%{opacity:.35}100%{transform:translateY(-106vh) translateX(36px);opacity:0}}
.win{width:100vw;height:100dvh;background:linear-gradient(180deg,#14110c,#0f0c09);
border:1px solid var(--line);border-radius:0;overflow:hidden;display:grid;grid-template-rows:auto auto auto minmax(0,1fr) auto;
box-shadow:0 44px 130px -34px rgba(0,0,0,.86),0 0 0 1px rgba(242,169,60,.04),inset 0 1px 0 rgba(255,255,255,.03);
animation:rise .7s cubic-bezier(.2,.8,.2,1) both}
@keyframes rise{from{opacity:0;transform:translateY(14px) scale(.99)}to{opacity:1;transform:none}}
.chrome{display:flex;align-items:center;gap:9px;padding:11px 15px;border-bottom:1px solid var(--line);
background:linear-gradient(180deg,#1a150f,#15110b)}
.dot{width:11px;height:11px;border-radius:50%}
.dot.r{background:#e15a52}.dot.y{background:#e0a93a}.dot.g{background:#62b85a}
.chrome .nm{margin-left:8px;color:var(--dim);font-size:12.5px}.chrome .nm b{color:var(--brand)}
.chrome .right{margin-left:auto;display:flex;gap:8px;align-items:center}
.btn{font-family:var(--mono-font);font-size:11px;letter-spacing:.5px;color:var(--brand);cursor:pointer;
background:color-mix(in srgb,var(--brand) 10%,transparent);border:1px solid color-mix(in srgb,var(--brand) 40%,transparent);
padding:4px 12px;border-radius:7px;transition:.15s}
.btn:hover{background:color-mix(in srgb,var(--brand) 22%,transparent)}
.btn.sm{font-size:10px;padding:2px 8px}
.cockpit{display:grid;grid-template-columns:minmax(190px,auto) minmax(220px,1fr) repeat(5,max-content) max-content;align-items:center;gap:0;padding:0 clamp(16px,2vw,30px);
border-bottom:1px solid var(--line);background:linear-gradient(180deg,#1b150d,#161108);font-size:12px;min-width:0;overflow:hidden}
.cmark{display:flex;align-items:center;gap:8px;padding:9px 16px 9px 0;border-right:1px solid var(--line);min-width:0}
.cmark svg{flex:0 0 auto}
.cmark .w,.word{font-weight:700;letter-spacing:11px;font-size:13px;
background:linear-gradient(90deg,#f2a93c,#f0674a,#f2a93c);background-size:220% 100%;
-webkit-background-clip:text;background-clip:text;color:transparent;
animation:wordin .6s ease 1.0s both,shimmer 3.2s linear 1.6s infinite}
@keyframes shimmer{to{background-position:220% 0}}
@keyframes wordin{from{opacity:0;letter-spacing:24px;filter:blur(6px)}to{opacity:1;letter-spacing:11px;filter:none}}
.logo-eye-open{transform-box:fill-box;transform-origin:center;animation:logo-blink 4.4s infinite}
@keyframes logo-blink{0%,93%,100%{transform:scaleY(1)}96.5%{transform:scaleY(.08)}}
.logo-tool{transform-box:fill-box;transform-origin:bottom center;animation:logo-tap 2.6s ease-in-out infinite}
@keyframes logo-tap{0%,100%{rotate:26deg}50%{rotate:34deg}}
.stat{display:flex;align-items:center;gap:7px;min-width:0;height:100%;padding:9px 12px;border-right:1px solid var(--line);color:var(--dim)}
.stat:last-child{border-right:none}
.stat .k{color:var(--dimmer);font-size:10px;letter-spacing:.7px;text-transform:uppercase}
.route-stat{overflow:hidden}
.route{display:block;min-width:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--planner);font-weight:500}
.route .hop{color:var(--planner)}
.route .arrow{display:inline-block;margin:0 5px;color:var(--brand);animation:route-step 1.7s ease-in-out infinite}
.route .arrow:nth-of-type(2n){animation-delay:.28s}
@keyframes route-step{0%,100%{opacity:.45;transform:translateX(0)}50%{opacity:1;transform:translateX(2px)}}
.budget-track{width:44px;height:5px;border:1px solid var(--line);border-radius:999px;background:#0d0a07;overflow:hidden;flex-shrink:0}
.budget-fill{display:block;height:100%;width:0;background:var(--add);transition:width .4s ease}
.budget-fill.warn{background:var(--coral)}
.budget-fill.danger{background:var(--rem)}
.cost{color:var(--brand);font-weight:600;font-variant-numeric:tabular-nums;transition:color .3s,text-shadow .3s,transform .3s}
.cost.up{animation:flash-cost .55s ease-out}
@keyframes flash-cost{0%{filter:brightness(1.8) saturate(1.4);text-shadow:0 0 12px var(--brand);transform:translateY(-1px) scale(1.05)}100%{filter:none;text-shadow:none;transform:none}}
.tok{font-variant-numeric:tabular-nums;transition:color .3s,text-shadow .3s,transform .3s;display:inline-block}
.tok.up{color:var(--brand);animation:flash-tok .55s ease-out}
@keyframes flash-tok{0%{filter:brightness(1.6);text-shadow:0 0 10px var(--brand);transform:translateY(-1px)}100%{filter:none;text-shadow:none;transform:none}}
.stat .pill{transition:.4s}
.hero-providers-pulse{animation:pulse-providers 1.2s ease-out}
@keyframes pulse-providers{0%{transform:scale(1)}30%{transform:scale(1.15);color:var(--add);text-shadow:0 0 10px var(--add)}100%{transform:scale(1)}}
.pill{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:10.5px;font-weight:600;letter-spacing:.5px;transition:.3s}
.pill .led{width:6px;height:6px;border-radius:50%}
.swarm-cockpit{display:flex;flex-wrap:wrap;gap:0;border-bottom:1px solid var(--line);
background:linear-gradient(180deg,#17120d,#120f0b);font-size:12px;
max-height:200px;overflow-y:auto}
.swarm-cockpit::-webkit-scrollbar{width:5px}
.swarm-cockpit::-webkit-scrollbar-thumb{background:#2c2419;border-radius:6px}
.lane{flex:1 1 220px;min-width:220px;
display:flex;flex-direction:column;gap:4px;
padding:8px clamp(13px,1.4vw,20px);
border-right:1px solid var(--line);
border-bottom:1px solid color-mix(in srgb,var(--line) 60%, transparent);
animation:lanein .35s ease both}
.lane .lane-head{display:flex;align-items:center;gap:8px}
.lane .who{font-weight:700;letter-spacing:.4px;font-size:11px}
.lane.planner .who{color:var(--planner)}
.lane.coder .who{color:var(--agent)}
.lane.verifier .who{color:var(--verifier)}
.lane.gold .who{color:var(--gold)}
.lane.coral .who{color:var(--coral)}
.lane.steel .who{color:var(--steel)}
.lane .st{display:flex;align-items:center;gap:6px;font-size:10.5px;color:var(--dim);margin-left:auto;white-space:nowrap}
.lane .msg{color:var(--dim);font-size:11px;line-height:1.45;
overflow-wrap:anywhere;word-break:break-word;white-space:normal}
.lane .msg b{color:var(--fg);font-weight:500}
.lane.working .st{color:var(--agent)}.lane.done .st{color:var(--add)}.lane.idle .st{color:var(--dimmer)}.lane.error .st{color:var(--rem)}
.lane.idle-card{opacity:.65}
.lane .led-mini{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 9px currentColor}
.lane.working .led-mini{animation:pulse 1.3s ease-in-out infinite}
@keyframes lanein{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
.lane-more{flex:0 0 auto;align-self:stretch;display:flex;align-items:center;justify-content:center;
padding:0 14px;font-size:10.5px;color:var(--brand);cursor:pointer;
background:color-mix(in srgb,var(--brand) 7%, transparent);
border-left:1px solid var(--line-hot);font-family:inherit}
.lane-more:hover{background:color-mix(in srgb,var(--brand) 14%, transparent)}
@media(max-width:820px){
.cockpit{grid-template-columns:1fr 1fr}
.cmark{grid-column:1/-1;border-right:none;border-bottom:1px solid var(--line)}
.route-stat{grid-column:1/-1;border-right:none;border-bottom:1px solid var(--line)}
.lane{flex:1 1 100%;border-right:none;border-bottom:1px solid var(--line)}
}
#term{min-height:0;overflow-y:auto;padding:18px clamp(18px,2vw,34px) 22px;font-size:13px;line-height:1.62;scroll-behavior:smooth}
#term::-webkit-scrollbar{width:9px}#term::-webkit-scrollbar-thumb{background:#2c2419;border-radius:6px}
.ln{white-space:pre-wrap;word-break:break-word;animation:in .18s ease}
@keyframes in{from{opacity:0;transform:translateY(3px)}to{opacity:1}}
.muted{color:var(--dim)}.dimd{color:var(--dimmer)}
.prompt{color:var(--brand)}.cmd{color:var(--fg)}
.ok{color:var(--add)}.warn{color:var(--verifier)}.err{color:var(--rem)}.acc{color:var(--brand)}
.planner{color:var(--planner)}.coder{color:var(--agent)}.verifier{color:var(--verifier)}
.tg{color:var(--planner)}.b{color:var(--fg);font-weight:500}
.cur,.cur2{display:inline-block;width:8px;height:15px;background:var(--brand);vertical-align:-2px;animation:bl 1s steps(1) infinite}
.cur2{background:var(--fg)}
.caret{display:inline-block;width:7px;height:13px;background:var(--agent);margin-left:2px;vertical-align:-2px;animation:bl 1.05s steps(1) infinite}
@keyframes bl{50%{opacity:0}}
.sp{display:inline-block;color:var(--brand)}
@keyframes spin{to{transform:rotate(360deg)}}
.spin-star{display:inline-block;animation:spin 1s steps(8) infinite;color:var(--brand)}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}}
.led-pulse{display:inline-block;width:7px;height:7px;border-radius:50%;animation:pulse 1.3s ease-in-out infinite;background:var(--agent)}
.verb{display:inline-block;color:var(--brand);min-width:68px;margin-left:4px}
.sec{color:var(--dimmer);font-size:10.5px;letter-spacing:2px;text-transform:uppercase;margin:14px 0 4px}
.home-panel{display:grid;grid-template-columns:minmax(280px,.9fr) minmax(360px,1.1fr);gap:0;margin-bottom:18px;
border:1px solid color-mix(in srgb,var(--brand) 52%,var(--line));border-radius:8px;overflow:hidden;
background:linear-gradient(180deg,rgba(242,169,60,.045),rgba(15,12,9,.44));box-shadow:inset 0 1px 0 rgba(255,255,255,.03)}
.home-left{display:grid;grid-template-columns:112px minmax(0,1fr);gap:18px;align-items:center;padding:22px 26px;border-right:1px solid var(--line)}
.hero-logo{width:112px;height:112px;filter:drop-shadow(0 0 18px rgba(242,169,60,.28));animation:logo-float 4.6s ease-in-out infinite}
@keyframes logo-float{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
.home-title{color:var(--fg);font-weight:700;font-size:18px;margin-bottom:8px}
.home-sub{color:var(--dim);font-size:12.5px;line-height:1.55}
.home-sub b{color:var(--brand);font-weight:700;letter-spacing:8px}
.home-path{color:var(--dimmer);font-size:12px;margin-top:8px;white-space:normal;overflow-wrap:anywhere}
.home-right{display:grid;grid-template-rows:auto 1fr;padding:18px 22px;gap:12px}
.home-block+.home-block{border-top:1px solid color-mix(in srgb,var(--brand) 44%,var(--line));padding-top:12px}
.home-block h3{color:var(--brand);font-size:13px;letter-spacing:.7px;margin-bottom:7px}
.home-block p{color:var(--dim);font-size:12.5px;line-height:1.48;max-width:100%;overflow-wrap:anywhere}
@media(max-width:900px){
.home-panel{grid-template-columns:1fr}
.home-left{border-right:none;border-bottom:1px solid var(--line);grid-template-columns:84px 1fr;padding:18px}
.hero-logo{width:84px;height:84px}
}
.status{position:fixed;bottom:48px;right:16px;font-size:10px;color:var(--dimmer);z-index:10}
.status.on{color:var(--add)}.status.off{color:var(--rem)}
.input-bar{display:grid;grid-template-columns:auto minmax(0,1fr) minmax(190px,270px) auto;gap:8px;padding:8px clamp(16px,2vw,30px) 12px;border-top:1px solid var(--line);align-items:end;background:linear-gradient(180deg,#15110c,#100d09)}
.input-bar textarea{width:100%;min-height:32px;max-height:190px;background:var(--panel);border:1px solid var(--line);border-radius:7px;color:var(--fg);
font-family:inherit;font-size:12px;line-height:1.45;padding:7px 10px;outline:none;resize:none;overflow-y:auto}
.input-bar textarea:focus{border-color:var(--brand)}
.input-bar button{font-family:inherit;font-size:11px;padding:6px 14px;cursor:pointer}
.attach-btn{display:inline-flex;align-items:center;justify-content:center;width:34px;height:31px;border-radius:7px;color:var(--steel);
border:1px solid var(--line);background:var(--panel);cursor:pointer;transition:.15s}
.attach-btn:hover{color:var(--brand);border-color:color-mix(in srgb,var(--brand) 45%,var(--line))}
.attach-btn svg{width:15px;height:15px}
#fileInput{display:none}
.context-side{display:grid;gap:4px;min-width:0}
.context-label{display:flex;justify-content:space-between;gap:8px;color:var(--dimmer);font-size:10px;text-transform:uppercase;letter-spacing:.7px}
.context-label b{color:var(--steel);font-weight:500;text-transform:none;letter-spacing:0;font-variant-numeric:tabular-nums}
.context-track{height:5px;border:1px solid var(--line);border-radius:999px;background:#0d0a07;overflow:hidden}
.context-fill{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--agent),var(--brand),var(--coral));transition:width .25s ease}
.attachments{grid-column:1/-1;display:none;gap:6px;align-items:center;flex-wrap:wrap;min-height:0}
.attachments.show{display:flex}
.file-chip{display:inline-flex;align-items:center;gap:7px;max-width:360px;padding:4px 8px;border:1px solid var(--line);border-radius:7px;background:rgba(185,176,163,.07);color:var(--dim);font-size:11px}
.file-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-chip button{border:0;background:transparent;color:var(--rem);font-size:13px;padding:0;cursor:pointer}
.drop-zone{position:fixed;inset:0;z-index:80;display:none;place-items:center;background:color-mix(in srgb,var(--bg) 68%,transparent);backdrop-filter:blur(8px);border:2px dashed color-mix(in srgb,var(--brand) 70%,var(--line));color:var(--brand);font-size:15px;font-weight:700;letter-spacing:1.4px;text-transform:uppercase}
.drop-zone.show{display:grid}
@media(max-width:920px){
.input-bar{grid-template-columns:auto minmax(0,1fr) auto}
.context-side{grid-column:1/-1;grid-row:2}
.attachments{grid-row:3}
}
.win{
display:grid;
grid-template-columns:48px 280px minmax(0,1fr);
grid-template-rows:auto 1fr;
height:100dvh;width:100vw;
margin:0;border-radius:0;border:none;
}
.win .chrome{grid-column:1 / -1;grid-row:1}
.win .rail{grid-column:1;grid-row:2}
.win .drawer{grid-column:2;grid-row:2}
.win .main{grid-column:3;grid-row:2;display:grid;grid-template-rows:auto auto minmax(0,1fr) auto auto;min-width:0;min-height:0}
.rail{
background:#0c0907;border-right:1px solid var(--line);
display:flex;flex-direction:column;align-items:center;gap:3px;
padding:8px 0 8px;font-family:inherit;position:relative;overflow:hidden;
}
.rail-logo{
width:32px;height:32px;margin-bottom:6px;
filter:drop-shadow(0 0 6px rgba(242,169,60,.45))
}
.rail .ico{width:36px;height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;
color:var(--dim);font-size:14px;cursor:pointer;transition:.15s;position:relative;
background:transparent;border:none;font-family:inherit}
.rail .ico:hover{color:var(--fg);background:color-mix(in srgb,var(--brand) 8%,transparent)}
.rail .ico.active{color:var(--brand);background:color-mix(in srgb,var(--brand) 14%,transparent)}
.rail .ico.active::before{content:"";position:absolute;left:-1px;top:7px;bottom:7px;width:2px;background:var(--brand);border-radius:2px}
.rail .ico .badge{position:absolute;top:3px;right:2px;min-width:13px;height:13px;border-radius:7px;padding:0 4px;
background:var(--coral);color:#fff;font-size:8.5px;font-weight:700;
display:flex;align-items:center;justify-content:center}
.rail .sep{width:22px;height:1px;background:var(--line);margin:5px 0}
.rail .spacer{flex:1}
.drawer{
background:linear-gradient(180deg,var(--panel),color-mix(in srgb,var(--bg) 80%,var(--panel)));
border-right:1px solid var(--line);
overflow-y:auto;padding:14px 13px 18px;min-width:0;min-height:0;
animation:drawer-in .28s ease both;
}
@keyframes drawer-in{from{opacity:.35;transform:translateX(-8px)}to{opacity:1;transform:none}}
.drawer::-webkit-scrollbar{width:6px}
.drawer::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--line-hot) 70%,var(--bg));border-radius:6px}
.drawer h3{font-size:9.5px;letter-spacing:1.7px;text-transform:uppercase;color:var(--dimmer);
margin:14px 4px 8px;font-weight:700;display:flex;align-items:center;gap:6px}
.drawer h3:first-of-type{margin-top:0}
.drawer h3 .count{margin-left:auto;background:color-mix(in srgb,var(--brand) 14%,transparent);
color:var(--brand);padding:1px 6px;border-radius:4px;font-size:9px;letter-spacing:.8px}
.drawer .panel{display:none !important;padding:6px 4px}
.drawer .panel.active{display:block !important;border-left:2px solid var(--brand);background:color-mix(in srgb,var(--brand) 5%, transparent)}
.drawer .panel + .panel{margin-top:6px}
@keyframes fade{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.drw-row{display:flex;flex-direction:column;gap:3px;padding:8px 10px;border-radius:7px;
background:var(--panel2);border:1px solid transparent;cursor:pointer;transition:.15s;margin-bottom:5px}
.drw-row:hover{border-color:var(--line-hot)}
.drw-row.cur{border-color:color-mix(in srgb,var(--brand) 55%,var(--line));background:color-mix(in srgb,var(--brand) 10%,var(--panel))}
.drw-row .ttl{font-size:11.5px;color:var(--fg);font-weight:500;display:flex;align-items:center;gap:6px;
overflow-wrap:break-word;word-break:normal;white-space:normal;min-width:0;text-overflow:ellipsis}
.drw-row .ttl .led{width:6px;height:6px;border-radius:50%;background:var(--add);box-shadow:0 0 6px var(--add)}
.drw-row .meta{font-size:10px;color:var(--dim);display:flex;gap:8px;flex-wrap:wrap}
.drw-row .meta b{color:var(--fg);font-weight:500}
.drw-empty{color:var(--dimmer);font-size:11px;font-style:italic;padding:8px 10px;text-align:center}
.mem-doc{padding:8px 10px;border-radius:7px;background:var(--panel2);font-size:11px;margin-bottom:5px}
.mem-doc .label{color:var(--dim);font-size:9.5px;letter-spacing:.7px;text-transform:uppercase;
margin-bottom:5px;display:flex;align-items:center}
.mem-doc .label b{margin-left:auto;color:var(--brand);font-variant-numeric:tabular-nums;
font-weight:600;letter-spacing:0;text-transform:none}
.mem-doc .bar{height:4px;border-radius:3px;background:color-mix(in srgb,var(--line-hot) 50%,var(--bg));
overflow:hidden;margin-top:3px}
.mem-doc .bar > span{display:block;height:100%;background:linear-gradient(90deg,var(--brand),var(--coral));
border-radius:3px;transition:width .5s ease}
.sec-score{display:flex;align-items:center;gap:10px;padding:10px;border-radius:8px;
background:var(--panel2);margin-bottom:8px}
.sec-score .num{font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--add)}
.sec-score .num.warn{color:var(--verifier)}.sec-score .num.crit{color:var(--rem)}
.sec-finding{padding:7px 9px;border-radius:6px;background:var(--panel2);font-size:10.5px;
margin-bottom:4px;border-left:3px solid var(--dimmer)}
.sec-finding.warn{border-left-color:var(--verifier)}
.sec-finding.crit{border-left-color:var(--rem)}
.sec-finding .cat{color:var(--dim);text-transform:uppercase;letter-spacing:.6px;font-size:8.5px}
.sec-finding .msg{color:var(--fg);margin-top:2px;overflow-wrap:anywhere}
@media(max-width:1100px){
.win{grid-template-columns:48px 240px minmax(0,1fr)}
}
@media(max-width:980px){
.win{grid-template-columns:48px 0 minmax(0,1fr)}
.drawer{position:fixed;top:42px;left:48px;bottom:0;width:280px;z-index:25;
transform:translateX(-100%);transition:transform .25s cubic-bezier(.2,.8,.2,1);
border-right:1px solid var(--line-hot)}
body.drawer-open .drawer{transform:translateX(0);box-shadow:0 12px 40px -8px rgba(0,0,0,.6)}
}
@media(max-width:720px){
.win{grid-template-columns:42px 0 minmax(0,1fr)}
.rail{width:42px}.rail .ico{width:32px;height:32px;font-size:13px}
.rail-logo{width:28px;height:28px}
body.drawer-open .drawer{width:min(280px,calc(100vw - 42px))}
}
:root[data-theme="paper"]{
--bg:#f3eee1;--panel:#e7e0cc;--panel2:#ded5bd;
--line:#c6bca3;--line-hot:#aea38a;
--dim:#6a604c;--dimmer:#9a907c;--fg:#2a2418;
--brand:#a65a1a;--coral:#a63a2a;
--agent:#2f7d67;--planner:#2e5a9c;--verifier:#7a5e2c;
--add:#3a7d2e;--rem:#a63a2a;--gold:#a6801a;--steel:#5e5547;
--sup:#3a7d2e;--tru:#a65a1a;--aut:#a63a2a;
}
:root[data-theme="paper"] body{
background:
radial-gradient(1100px 700px at 72% -12%, rgba(166,90,26,.10), transparent 60%),
radial-gradient(900px 600px at 10% 114%, rgba(166,58,42,.08), transparent 55%),
var(--bg);
}
:root[data-theme="paper"] body::after{opacity:.025}
:root[data-theme="paper"] .ember{opacity:.18!important}
:root[data-theme="paper"] .win{background:linear-gradient(180deg,#ede5cf,#e7e0cc)}
:root[data-theme="paper"] .chrome,
:root[data-theme="paper"] .cockpit{background:linear-gradient(180deg, color-mix(in srgb,var(--brand) 6%, var(--panel)), var(--panel))}
:root[data-theme="paper"] .swarm-cockpit{background:var(--panel)}
:root[data-theme="paper"] .input-bar{background:linear-gradient(180deg, color-mix(in srgb,var(--panel) 60%, var(--bg)), var(--panel))}
:root[data-theme="paper"] .rail{background:#ebe3cd}
:root[data-theme="paper"] .context-track{background:color-mix(in srgb,var(--line-hot) 42%,var(--bg));height:4px}
.chip-btn{font-family:inherit;font-size:11px;padding:3px 9px;border-radius:6px;
background:transparent;color:var(--dim);border:1px solid var(--line);cursor:pointer;
display:inline-flex;align-items:center;gap:5px;transition:.15s;letter-spacing:.3px}
.chip-btn:hover{color:var(--fg);border-color:var(--line-hot);background:color-mix(in srgb,var(--brand) 5%, transparent)}
.chip-btn.solid{background:color-mix(in srgb,var(--brand) 9%,transparent);
border-color:color-mix(in srgb,var(--brand) 32%,transparent);color:var(--brand)}
.chip-btn.solid:hover{background:color-mix(in srgb,var(--brand) 20%,transparent)}
.live-tag{color:var(--add);display:inline-flex;align-items:center;gap:5px;font-size:11px;margin-left:4px}
.live-tag::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--add);box-shadow:0 0 8px var(--add);animation:pulse 1.3s ease-in-out infinite}
.live-tag.offline{color:var(--dimmer)}
.live-tag.offline::before{background:var(--dimmer);box-shadow:none;animation:none}
.hero{display:flex;align-items:center;gap:18px;padding:10px 4px 16px;margin-bottom:8px;
border-bottom:1px solid color-mix(in srgb,var(--line) 60%, transparent);
animation:fade-in .45s ease both}
.hero .h-logo{flex:0 0 auto;filter:drop-shadow(0 0 14px rgba(242,169,60,.42))}
:root[data-theme="paper"] .hero .h-logo{filter:drop-shadow(0 0 10px rgba(166,90,26,.32))}
.hero .h-text{flex:1;min-width:0}
.hero .hi{font-size:15px;color:var(--fg);font-weight:500;margin-bottom:4px}
.hero .hi b{background:linear-gradient(90deg,var(--brand),var(--coral));-webkit-background-clip:text;background-clip:text;color:transparent;font-weight:700;letter-spacing:1.5px}
.hero .tag{color:var(--dim);font-size:12px;letter-spacing:.7px;margin-bottom:7px}
.hero .meta{display:flex;gap:14px;flex-wrap:wrap;color:var(--dimmer);font-size:10.5px;letter-spacing:.4px}
.hero .meta span b{color:var(--fg);font-weight:500}
.hero .meta .ok-dot{color:var(--add)}
@keyframes fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.composer-hints{display:flex;align-items:center;gap:14px;font-size:10px;color:var(--dimmer);
letter-spacing:.3px;padding:0 clamp(16px,2vw,30px) 4px}
.composer-hints .kbds{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.composer-hints kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;
padding:1px 5px;font-family:inherit;font-size:10px;color:var(--dim)}
.route-hop{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line);transition:.15s}
.route-hop:last-child{border-bottom:none}
.route-hop.active{background:color-mix(in srgb,var(--planner) 7%,transparent)}
.route-hop .rh-led{width:8px;height:8px;border-radius:50%;flex:0 0 auto}
.route-hop.active .rh-led{background:var(--add);box-shadow:0 0 8px var(--add);animation:pulse 1.3s ease-in-out infinite}
.route-hop.standby .rh-led{background:var(--dimmer)}
.route-hop.error .rh-led{background:var(--rem)}
.route-hop .rh-info{flex:1;min-width:0}
.route-hop .rh-name{font-size:11.5px;color:var(--fg);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.route-hop .rh-name.active{color:var(--planner)}
.route-hop .rh-meta{font-size:9.5px;color:var(--dimmer);margin-top:2px;display:flex;gap:8px}
.route-hop .rh-ctx{color:var(--agent)}.route-hop .rh-cost{color:var(--brand)}
.route-hop .rh-pos{font-size:10px;color:var(--dimmer);font-weight:600}
.route-current-box{padding:10px 12px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--brand) 5%,transparent)}
.route-current-box .rc-label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:var(--dimmer);margin-bottom:4px}
.route-current-box .rc-model{font-size:13px;font-weight:600;color:var(--brand)}
.route-current-box .rc-ctx{font-size:10px;color:var(--agent);margin-top:3px}
.mp-drop{position:absolute;bottom:calc(100% + 8px);left:0;z-index:200;
background:var(--panel);border:1px solid var(--line-hot);border-radius:12px;
box-shadow:0 24px 64px -12px rgba(0,0,0,.8);width:340px;overflow:hidden;
animation:in .14s ease}
.mp-drop-head{padding:9px 14px 7px;font-size:9px;letter-spacing:2px;text-transform:uppercase;
color:var(--dimmer);border-bottom:1px solid var(--line)}
.mp-section{padding:4px 0}
.mp-provider{padding:4px 14px 2px;font-size:9px;letter-spacing:1.8px;text-transform:uppercase;color:var(--dimmer)}
.mp-item{display:flex;align-items:center;gap:10px;padding:6px 14px;cursor:pointer;transition:.12s}
.mp-item:hover{background:color-mix(in srgb,var(--brand) 8%,transparent)}
.mp-item.selected{background:color-mix(in srgb,var(--brand) 12%,transparent)}
.mp-item.selected .mp-name{color:var(--brand)}
.mp-name{font-size:11.5px;color:var(--fg);font-weight:500;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.mp-ctx{font-size:9.5px;color:var(--dimmer);white-space:nowrap}
.mp-cost{font-size:9px;color:var(--agent);white-space:nowrap}
.mp-rec{font-size:8px;color:var(--brand);border:1px solid color-mix(in srgb,var(--brand) 40%,transparent);border-radius:999px;padding:0 5px;letter-spacing:.5px}
.mp-auto{padding:6px 14px;border-top:1px solid var(--line);font-size:10.5px;color:var(--dim);cursor:pointer;transition:.12s}
.mp-auto:hover{color:var(--fg);background:color-mix(in srgb,var(--brand) 5%,transparent)}
.boot-overlay{position:fixed;inset:0;background:radial-gradient(900px 600px at 50% 50%, color-mix(in srgb,var(--brand) 15%, var(--bg)), var(--bg) 70%);
z-index:5000;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;
animation:fade-in .25s ease both}
.boot-overlay[hidden]{display:none!important}
.boot-overlay.fading{animation:fade-out .35s ease forwards}
@keyframes fade-out{to{opacity:0;visibility:hidden}}
.boot-overlay .b-logo{filter:drop-shadow(0 0 24px rgba(242,169,60,.45));animation:bob 2.4s ease-in-out infinite}
@keyframes bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-4px)}}
.boot-overlay .b-word{font-weight:700;letter-spacing:14px;font-size:18px;
background:linear-gradient(90deg,var(--brand),var(--coral),var(--brand));background-size:220% 100%;
-webkit-background-clip:text;background-clip:text;color:transparent;
animation:b-wordin .6s ease both, shimmer 2.6s linear .8s infinite}
@keyframes b-wordin{from{opacity:0;letter-spacing:30px;filter:blur(8px)}to{opacity:1;letter-spacing:14px;filter:none}}
@keyframes shimmer{to{background-position:220% 0}}
.boot-overlay .b-tag{color:var(--dim);font-size:12px;letter-spacing:1.2px;animation:fade-in .35s ease .35s both}
.boot-overlay .b-status{display:flex;flex-direction:column;gap:3px;font-size:11px;color:var(--dim);
min-width:280px;margin-top:8px}
.boot-overlay .b-line{opacity:0;animation:fade-in .25s ease forwards;display:flex;gap:8px}
.boot-overlay .b-line .l-key{color:var(--dimmer);letter-spacing:.4px;min-width:80px}
.boot-overlay .b-line .l-val{color:var(--fg)}
.boot-overlay .b-skip{position:absolute;bottom:24px;color:var(--dimmer);font-size:10px;letter-spacing:.5px}
.boot-overlay .b-skip kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;
padding:1px 5px;color:var(--dim);font-family:inherit;font-size:10px}
.tool-card{margin:6px 0 8px;border:1px solid var(--line-hot);border-radius:8px;background:var(--panel);overflow:hidden}
.tool-card summary{list-style:none;cursor:pointer;padding:7px 11px;display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)}
.tool-card summary::-webkit-details-marker{display:none}
.tool-card summary .g{color:var(--brand);font-size:12px}
.tool-card summary .nm{color:var(--fg);font-weight:500;font-size:11.5px}
.tool-card summary .ok{margin-left:auto;color:var(--add);font-size:10.5px}
.tool-card summary .chev{color:var(--dimmer);transition:transform .2s;font-size:9px}
.tool-card[open] summary .chev{transform:rotate(90deg)}
.tool-card[open] summary{border-bottom:1px solid var(--line)}
.tool-card.running summary .g{display:inline-block;animation:spin .7s linear infinite}
.tool-card.running summary .ok{color:var(--brand);animation:blink-ok 1.1s ease-in-out infinite}
@keyframes blink-ok{0%,100%{opacity:.55}50%{opacity:1}}
.tool-card.done summary .ok{animation:flash-ok .5s ease-out}
.tool-card.error summary .ok{animation:flash-ok .5s ease-out;color:var(--rem)}
@keyframes flash-ok{0%{filter:brightness(2.5) saturate(2)}100%{filter:none}}
.tool-card .det{padding:9px 12px;font-size:11px;color:var(--dim);background:var(--panel2);animation:fold-in .18s ease both}
.tool-card .det code{color:var(--fg);background:transparent;font-family:inherit;display:block;white-space:pre-wrap;line-height:1.5;overflow-wrap:anywhere}
.tool-card .det .lbl{color:var(--dimmer);text-transform:uppercase;font-size:9px;letter-spacing:1.2px;margin-bottom:3px}
@keyframes fold-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
.diff-card{margin:6px 0 8px;border:1px solid var(--line-hot);border-radius:8px;background:var(--panel);overflow:hidden}
.diff-card .h{display:flex;gap:8px;padding:7px 11px;border-bottom:1px solid var(--line);font-size:10.5px;color:var(--dim)}
.diff-card .h .p{color:var(--fg);font-weight:500}.diff-card .h .m{margin-left:auto}
.diff-card .h .pl{color:var(--add)}.diff-card .h .mn{color:var(--rem)}
.diff-card pre{font-size:11.5px;line-height:1.6;padding:6px 0;overflow-x:auto;font-family:inherit}
.diff-card .dl{display:block;padding:0 10px;border-left:2px solid transparent;white-space:pre}
.diff-card .num{display:inline-block;width:30px;color:var(--dimmer);user-select:none}
.diff-card .ctx{color:var(--dim)}
.diff-card .pls{color:var(--add);background:color-mix(in srgb,var(--add) 9%,transparent);border-left-color:var(--add)!important}
.diff-card .mns{color:var(--rem);background:color-mix(in srgb,var(--rem) 9%,transparent);border-left-color:var(--rem)!important}
.diff-card .hunk{border-top:1px solid var(--line)}
.diff-card .hunk:first-child{border-top:0}
.diff-card .hunk-head{display:flex;align-items:center;gap:8px;padding:5px 10px;background:color-mix(in srgb,var(--panel2) 70%,transparent);color:var(--dimmer);font-size:10px}
.diff-card .hunk-head .meta{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.diff-card .hunk-actions{display:flex;gap:5px}
.diff-card .hunk-actions button{font-family:inherit;font-size:9px;padding:2px 7px;border-radius:999px;border:1px solid var(--line);background:transparent;color:var(--dim);cursor:pointer}
.diff-card .hunk-actions button.accept{color:var(--add);border-color:color-mix(in srgb,var(--add) 45%,transparent)}
.diff-card .hunk-actions button.reject{color:var(--rem);border-color:color-mix(in srgb,var(--rem) 45%,transparent)}
.diff-card .hunk[data-state="accepted"] .hunk-head{background:color-mix(in srgb,var(--add) 10%,var(--panel2))}
.diff-card .hunk[data-state="rejected"] .hunk-head{background:color-mix(in srgb,var(--rem) 10%,var(--panel2))}
.diff-card .hunk[data-state="rejected"] .dl{opacity:.45}
.code-card,.code-block{margin:7px 0 8px 10px;border:1px solid color-mix(in srgb,var(--line-hot) 82%,transparent);
border-radius:10px;background:linear-gradient(180deg,color-mix(in srgb,var(--panel) 92%,transparent),color-mix(in srgb,var(--panel2) 96%,transparent));
overflow:hidden;box-shadow:0 14px 32px -28px rgba(0,0,0,.68),inset 0 1px 0 rgba(255,255,255,.045)}
.code-card summary,.code-block summary{list-style:none;cursor:pointer;padding:7px 10px;display:flex;align-items:center;gap:8px;
font-family:var(--ui-font);font-size:11px;color:var(--dim);background:linear-gradient(180deg,color-mix(in srgb,var(--fg) 4%,transparent),transparent)}
.code-card summary::-webkit-details-marker,.code-block summary::-webkit-details-marker{display:none}
.code-card .cc-icon,.code-block .cb-chev{color:var(--brand);transition:transform .18s ease;font-size:10px}
.code-card[open] summary .cc-icon,.code-block[open] summary .cb-chev{transform:rotate(90deg)}
.code-card .cc-lang,.code-block .cb-lbl{color:var(--fg);font-weight:700;font-size:11.5px;letter-spacing:.2px;text-transform:none}
.code-card .cc-meta,.code-block .cb-size{margin-left:auto;color:var(--dimmer);font-size:10px;font-variant-numeric:tabular-nums}
.code-card .cc-meta.live{color:var(--brand);animation:tokpulse .8s ease-in-out infinite}
.code-card .cc-copy{font-family:var(--ui-font);font-size:10px;font-weight:600;color:var(--brand);cursor:pointer;
background:color-mix(in srgb,var(--brand) 9%,transparent);border:1px solid color-mix(in srgb,var(--brand) 28%,transparent);
border-radius:999px;padding:3px 9px;transition:.15s}
.code-card .cc-copy:hover{background:color-mix(in srgb,var(--brand) 18%,transparent);transform:translateY(-1px)}
.code-card pre,.code-block .cb-body{margin:0;padding:10px 12px;background:color-mix(in srgb,var(--bg) 76%,#000);
border-top:1px solid var(--line);animation:fold-in .18s ease both;overflow-x:auto}
.code-card code,.code-block .cb-body code{font-family:var(--mono-font);font-size:12.5px;line-height:1.58;color:var(--steel);white-space:pre-wrap;tab-size:2}
.code-card code .syn-key{color:#ffb454;font-weight:650}
.code-card code .syn-str{color:#aad94c}
.code-card code .syn-num{color:#d2a6ff}
.code-card code .syn-com{color:#7f8c98;font-style:italic}
.code-card code .syn-fn{color:#7dcfff}
.code-card code .syn-type{color:#73d0ff}
.code-card code .syn-op{color:#f29e74}
.code-card code .syn-prop{color:#ffd580}
:root[data-theme="paper"] .code-card code{color:#3f3629;font-weight:520}
:root[data-theme="paper"] .code-card code .syn-key{color:#a94700;font-weight:700}
:root[data-theme="paper"] .code-card code .syn-str{color:#3f7800}
:root[data-theme="paper"] .code-card code .syn-num{color:#6d3db8}
:root[data-theme="paper"] .code-card code .syn-com{color:#786f61}
:root[data-theme="paper"] .code-card code .syn-fn{color:#006cae}
:root[data-theme="paper"] .code-card code .syn-type{color:#087d8d}
:root[data-theme="paper"] .code-card code .syn-op{color:#9b4317}
:root[data-theme="paper"] .code-card code .syn-prop{color:#8a5700}
:root[data-theme="paper"] .code-card,:root[data-theme="paper"] .code-block{box-shadow:0 18px 42px -34px rgba(64,42,12,.36),inset 0 1px 0 rgba(255,255,255,.55)}
:root[data-theme="paper"] .code-card pre,:root[data-theme="paper"] .code-block .cb-body{background:color-mix(in srgb,var(--panel) 82%,#fff)}
@keyframes fold-in{from{opacity:0;max-height:0}to{opacity:1;max-height:600px}}
.compact-banner{margin:8px 0;padding:7px 12px;border-radius:8px;
background:color-mix(in srgb,var(--brand) 9%,transparent);
border:1px solid color-mix(in srgb,var(--brand) 30%,transparent);
color:var(--dim);font-size:11px;display:flex;align-items:center;gap:9px}
.compact-banner .g{color:var(--brand);font-size:12px}
.compact-banner b{color:var(--fg);font-weight:500}
.compact-banner a{color:var(--brand);text-decoration:none;margin-left:auto;font-size:10.5px}
.compact-banner a:hover{text-decoration:underline}
.checkpoint-timeline{display:flex;align-items:center;gap:7px;margin:8px 0 10px 18px;min-height:20px;color:var(--dimmer);font-size:10px}
.checkpoint-timeline::before{content:"checkpoints";text-transform:uppercase;letter-spacing:1.4px;margin-right:3px}
.checkpoint-node{width:10px;height:10px;border-radius:50%;background:var(--brand);box-shadow:0 0 0 1px color-mix(in srgb,var(--brand) 45%,transparent),0 0 14px rgba(242,169,60,.35);animation:checkpoint-drop .25s ease both,checkpoint-ping .7s ease-out .05s 1}
@keyframes checkpoint-drop{from{opacity:0;transform:translateY(-8px) scale(.6)}to{opacity:1;transform:none}}
@keyframes checkpoint-ping{0%{box-shadow:0 0 0 0 rgba(242,169,60,.35)}100%{box-shadow:0 0 0 12px transparent}}
.skill-pop{display:inline-block;padding:2px 8px;border-radius:5px;
background:color-mix(in srgb,var(--gold) 12%,transparent);
border:1px solid color-mix(in srgb,var(--gold) 35%,transparent);
color:var(--gold);font-size:10.5px;font-weight:500;animation:pop-in .3s cubic-bezier(.2,.8,.2,1) both}
@keyframes pop-in{from{opacity:0;transform:scale(.7)}to{opacity:1;transform:scale(1)}}
.streaming{display:inline}
.streaming::after{content:"";display:inline-block;width:6px;height:11px;background:var(--agent);
margin-left:1px;vertical-align:-1px;animation:caret-blink 1.05s steps(1) infinite}
@keyframes caret-blink{50%{opacity:0}}
.palette{position:fixed;inset:0;align-items:flex-start;justify-content:center;
background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(6px);
z-index:1000;padding-top:14vh;display:none}
.palette.open{display:flex;animation:fadein .2s ease both}
@keyframes fadein{from{opacity:0}to{opacity:1}}
.palette .box{width:min(780px,94vw);background:var(--panel);border:1px solid var(--line-hot);
border-radius:12px;box-shadow:0 30px 80px -20px rgba(0,0,0,.75);overflow:hidden;
animation:popin .22s cubic-bezier(.2,.8,.2,1) both}
@keyframes popin{from{opacity:0;transform:translateY(-8px) scale(.98)}to{opacity:1;transform:none}}
.palette .pinput{display:flex;align-items:center;gap:10px;padding:14px 17px;border-bottom:1px solid var(--line)}
.palette .pinput svg{flex:0 0 auto;filter:drop-shadow(0 0 6px rgba(242,169,60,.45))}
.palette .pinput input{flex:1;background:transparent;border:none;outline:none;color:var(--fg);
font-family:inherit;font-size:14px;letter-spacing:.2px}
.palette .pinput input::placeholder{color:var(--dim)}
.palette .res-list{max-height:50vh;overflow-y:auto;padding:6px 6px 10px}
.palette .res-list::-webkit-scrollbar{width:6px}
.palette .res-list::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--line-hot) 70%,var(--bg));border-radius:6px}
.palette .res{display:grid;grid-template-columns:minmax(135px,190px) auto minmax(220px,1fr);align-items:center;gap:10px;padding:8px 12px;border-radius:7px;cursor:pointer;font-size:12.5px}
.palette .res.sel,.palette .res:hover{background:color-mix(in srgb,var(--brand) 12%,transparent)}
.palette .res .nm{color:var(--fg);font-weight:600;overflow-wrap:anywhere;word-break:break-word}
.palette .res .src{color:var(--dimmer);font-size:9px;letter-spacing:.8px;text-transform:uppercase;
padding:1px 6px;border-radius:4px;background:color-mix(in srgb,var(--line-hot) 60%,transparent)}
.palette .res.builtin .src{background:color-mix(in srgb,var(--brand) 18%,transparent);color:var(--brand)}
.palette .res.skill .src{background:color-mix(in srgb,var(--gold) 18%,transparent);color:var(--gold)}
.palette .res.plugin .src{background:color-mix(in srgb,var(--agent) 18%,transparent);color:var(--agent)}
.palette .res .desc{color:var(--dim);font-size:11px;line-height:1.35;overflow-wrap:anywhere;word-break:break-word}
.palette .res .desc b{display:block;color:var(--fg);font-size:11px;font-weight:500;margin-bottom:2px}
.palette .res .desc .usage{display:block;color:var(--dimmer);font-size:10px;font-family:var(--mono-font);line-height:1.35}
@media(max-width:640px){.palette .res{grid-template-columns:1fr auto}.palette .res .desc{grid-column:1/-1}}
.palette .pfoot{display:flex;gap:14px;padding:8px 16px;border-top:1px solid var(--line);
font-size:10.5px;color:var(--dimmer);align-items:center}
.palette .pfoot kbd{background:var(--panel2);border:1px solid var(--line);border-radius:4px;
padding:1px 5px;color:var(--dim);font-family:inherit;font-size:10px}
.palette .pempty{padding:18px;text-align:center;color:var(--dimmer);font-size:12px;font-style:italic}
.picker{position:absolute;background:var(--panel);border:1px solid var(--line-hot);
border-radius:9px;box-shadow:0 12px 40px -10px rgba(0,0,0,.6);
min-width:240px;max-width:340px;padding:5px;z-index:200;
animation:popin .18s cubic-bezier(.2,.8,.2,1) both}
.picker .head{font-size:9px;color:var(--dimmer);letter-spacing:1.4px;text-transform:uppercase;padding:5px 8px 7px}
.picker .opt{display:flex;align-items:center;gap:8px;padding:7px 9px;border-radius:6px;cursor:pointer;font-size:11.5px}
.picker .opt.sel,.picker .opt:hover{background:color-mix(in srgb,var(--brand) 12%,transparent)}
.picker .opt .at{color:var(--brand);font-weight:600}
.picker .opt .nm{color:var(--fg);font-weight:500}
.picker .opt .role{margin-left:auto;color:var(--dim);font-size:10px}
.picker.hidden{display:none}
.config-panel{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:200;
background:var(--panel);border:1px solid var(--line);border-radius:14px;width:min(920px,94vw);max-height:88vh;
overflow-y:auto;padding:18px 20px;box-shadow:0 30px 80px rgba(0,0,0,.9)}
.cfg-provider{border:1px solid var(--line);border-radius:10px;margin:6px 0;background:var(--panel2);overflow:hidden;transition:.15s}
.cfg-provider.has-key{border-color:color-mix(in srgb,var(--add) 35%,var(--line))}
.cfg-provider summary{cursor:pointer;padding:10px 14px;display:flex;align-items:center;gap:10px;list-style:none;font-size:11.5px}
.cfg-provider summary::-webkit-details-marker{display:none}
.cfg-provider summary:hover{background:color-mix(in srgb,var(--brand) 5%,transparent)}
.cfg-provider .led{width:8px;height:8px;border-radius:50%;flex:0 0 auto}
.cfg-provider.has-key .led{background:var(--add);box-shadow:0 0 6px var(--add)}
.cfg-provider:not(.has-key) .led{background:var(--dimmer)}
.cfg-provider .lbl{font-weight:600;color:var(--fg);font-size:12px}
.cfg-provider .adp{font-size:9.5px;color:var(--dimmer);background:var(--bg);padding:1px 6px;border-radius:4px;letter-spacing:.5px}
.cfg-provider .count{margin-left:auto;font-size:10px;color:var(--dim)}
.cfg-provider .chev{color:var(--dimmer);transition:transform .2s;font-size:9px}
.cfg-provider[open] summary .chev{transform:rotate(90deg)}
.cfg-provider .body{padding:6px 14px 12px;border-top:1px solid var(--line);background:var(--bg)}
.cfg-provider .notes{color:var(--dim);font-size:10px;line-height:1.5;margin-bottom:8px;padding:6px 9px;border-radius:6px;background:color-mix(in srgb,var(--brand) 4%,transparent);border-left:2px solid color-mix(in srgb,var(--brand) 40%,transparent)}
.cfg-provider .models{display:flex;flex-direction:column;gap:4px;margin-top:6px}
.cfg-provider .mrow{display:flex;align-items:center;gap:8px;padding:5px 9px;border:1px solid var(--line);border-radius:6px;font-size:11px;background:var(--panel)}
.cfg-provider .mrow .mname{font-weight:500;color:var(--fg)}
.cfg-provider .mrow .mctx{font-size:9.5px;color:var(--agent);margin-left:auto}
.cfg-provider .mrow .mcost{font-size:9px;color:var(--brand)}
.cfg-provider .mrow .mstar{font-size:9px;color:var(--brand)}
.cfg-provider .mrow .mset{font-size:9px;padding:2px 7px;border-radius:5px;cursor:pointer;background:transparent;border:1px solid var(--line);color:var(--dim);transition:.12s;font-family:inherit}
.cfg-provider .mrow .mset:hover{color:var(--brand);border-color:color-mix(in srgb,var(--brand) 40%,var(--line))}
.cfg-provider .mrow.is-default{background:color-mix(in srgb,var(--add) 8%,var(--panel));border-color:color-mix(in srgb,var(--add) 35%,var(--line))}
.cfg-provider .mrow.is-default .mset{color:var(--add);border-color:color-mix(in srgb,var(--add) 40%,var(--line))}
.cfg-provider .keyrow{display:flex;gap:8px;align-items:center;margin-top:8px}
.cfg-provider .keyrow input{flex:1;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--fg);font-family:inherit;font-size:11px;padding:5px 8px;outline:none}
.cfg-provider .keyrow input:focus{border-color:var(--brand)}
.cfg-provider .keyrow button{font-family:inherit;font-size:10px;padding:5px 12px;border-radius:6px;cursor:pointer;background:color-mix(in srgb,var(--brand) 12%,transparent);border:1px solid color-mix(in srgb,var(--brand) 40%,transparent);color:var(--brand);transition:.12s}
.cfg-provider .keyrow button:hover{background:color-mix(in srgb,var(--brand) 22%,transparent)}
.cfg-provider .keyrow .envhint{font-size:9.5px;color:var(--dimmer);font-family:inherit}
.cfg-section{margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}
.cfg-section h4{font-size:9px;text-transform:uppercase;letter-spacing:2px;color:var(--dimmer);margin-bottom:10px}
.cfg-tab-row{display:flex;gap:4px;margin-bottom:14px;border-bottom:1px solid var(--line)}
.cfg-tab{font-family:inherit;background:transparent;border:none;color:var(--dimmer);font-size:11px;padding:8px 14px;cursor:pointer;border-bottom:2px solid transparent;letter-spacing:.5px;transition:.15s}
.cfg-tab:hover{color:var(--dim)}
.cfg-tab.active{color:var(--brand);border-bottom-color:var(--brand)}
.cfg-search{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:7px;color:var(--fg);font-family:inherit;font-size:11px;padding:7px 10px;outline:none;margin-bottom:8px}
.cfg-search:focus{border-color:var(--brand)}
.cfg-summary-row{display:flex;gap:14px;margin-bottom:10px;font-size:10px;color:var(--dim)}
.cfg-summary-row b{color:var(--brand);font-weight:600}
.config-panel.show{display:block;animation:in .2s ease}
.config-panel h3{color:var(--brand);font-size:14px;letter-spacing:2px;margin-bottom:14px}
.config-panel .row{display:flex;gap:8px;align-items:center;margin:6px 0}
.config-panel label{color:var(--dim);font-size:10px;text-transform:uppercase;letter-spacing:1px;min-width:70px}
.config-panel select,.config-panel input{background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--fg);
font-family:inherit;font-size:11px;padding:5px 8px;flex:1;outline:none}
.config-panel select:focus,.config-panel input:focus{border-color:var(--brand)}
.config-panel .badge{font-size:9px;padding:3px 8px;border-radius:999px;letter-spacing:.5px}
.config-panel .badge.found{background:rgba(116,194,88,.15);color:var(--add);border:1px solid rgba(116,194,88,.3)}
.config-panel .badge.missing{background:rgba(217,106,99,.12);color:var(--rem);border:1px solid rgba(217,106,99,.25)}
.config-panel .tags{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}
.config-panel .tag{font-size:9px;padding:2px 7px;border-radius:999px;background:rgba(185,176,163,.1);color:var(--steel)}
.config-panel .presets{display:flex;gap:8px;margin-bottom:14px}
.config-panel .preset{cursor:pointer;padding:8px 14px;border:1px solid var(--line);border-radius:8px;font-size:11px;color:var(--dim);transition:.15s}
.config-panel .preset:hover,.config-panel .preset.sel{border-color:var(--brand);color:var(--brand);background:rgba(242,169,60,.08)}
.config-panel .close{position:absolute;top:10px;right:14px;cursor:pointer;color:var(--dim);font-size:16px}
.overlay{display:none;position:fixed;inset:0;z-index:199;background:rgba(0,0,0,.7)}
.overlay.show{display:block}
.token-meter{display:inline-flex;gap:10px;align-items:center;margin:3px 0 5px 18px;color:var(--dim);font-size:11px}
.token-meter b{font-weight:600;color:var(--steel);font-variant-numeric:tabular-nums}
.token-meter .in b{color:var(--brand)}
.token-meter .out b{color:var(--add)}
.token-meter.pulse b{animation:tokpulse .44s ease-out}
@keyframes tokpulse{0%{filter:brightness(1);transform:translateY(0)}35%{filter:brightness(1.7);transform:translateY(-1px)}100%{filter:brightness(1);transform:translateY(0)}}
.approval-actions{display:flex;gap:8px;margin:7px 0 10px 18px}
.approval-actions button{font-family:inherit;font-size:11px;padding:5px 12px;border-radius:7px;cursor:pointer}
.approval-actions .approve{color:var(--add);background:rgba(116,194,88,.1);border:1px solid rgba(116,194,88,.35)}
.approval-actions .deny{color:var(--rem);background:rgba(217,106,99,.1);border:1px solid rgba(217,106,99,.35)}
.diff-panel{position:fixed;top:0;right:0;bottom:0;width:min(480px,48vw);z-index:500;
background:var(--panel);border-left:1px solid var(--line-hot);
display:flex;flex-direction:column;
transform:translateX(100%);transition:transform .26s cubic-bezier(.2,.8,.2,1);
box-shadow:-20px 0 60px -12px rgba(0,0,0,.7)}
.diff-panel.open{transform:none}
.diff-panel-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--line);flex:0 0 auto}
.diff-panel-head .dp-path{flex:1;font-size:11px;color:var(--fg);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.diff-panel-head .dp-close{border:none;background:transparent;color:var(--dimmer);cursor:pointer;font-size:16px;padding:2px 6px;font-family:inherit;border-radius:4px;transition:.12s}
.diff-panel-head .dp-close:hover{color:var(--fg);background:var(--panel2)}
.diff-panel-body{flex:1;overflow:auto;padding:4px 0;font-size:11px;line-height:1.6;font-family:inherit}
.diff-panel-body::-webkit-scrollbar{width:6px}.diff-panel-body::-webkit-scrollbar-thumb{background:#2c2419;border-radius:6px}
.diff-panel-stats{padding:6px 14px;border-top:1px solid var(--line);font-size:10px;color:var(--dimmer);flex:0 0 auto;display:flex;gap:14px}
.approval-modal{position:fixed;inset:0;z-index:1100;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 68%,transparent);backdrop-filter:blur(8px)}
.approval-modal.show{display:flex;animation:fadein .16s ease both}
.approval-card{width:min(520px,92vw);border:1px solid var(--line-hot);border-radius:12px;background:var(--panel);box-shadow:0 30px 90px rgba(0,0,0,.75);padding:18px;animation:popin .2s cubic-bezier(.2,.8,.2,1) both}
.approval-card h3{font-size:13px;color:var(--brand);letter-spacing:1.8px;text-transform:uppercase;margin-bottom:8px}
.approval-card p{font-size:12px;color:var(--dim);line-height:1.55;overflow-wrap:anywhere}
.approval-card .risk{display:inline-flex;margin:10px 0 14px;padding:2px 8px;border-radius:999px;border:1px solid var(--line);color:var(--gold);font-size:10px;text-transform:uppercase;letter-spacing:1px}
.approval-card .actions{display:flex;gap:10px;justify-content:flex-end}
.approval-card button{font-family:inherit;font-size:12px;border-radius:8px;padding:8px 14px;cursor:pointer}
.approval-card .approve{color:var(--add);background:rgba(116,194,88,.12);border:1px solid rgba(116,194,88,.4)}
.approval-card .deny{color:var(--rem);background:rgba(217,106,99,.12);border:1px solid rgba(217,106,99,.38)}
.run-summary{margin:10px 0;border:1px solid var(--line-hot);border-radius:10px;background:var(--panel);overflow:hidden;animation:in .2s ease}
.run-summary .rs-head{display:flex;align-items:center;gap:10px;padding:8px 13px;border-bottom:1px solid var(--line);font-size:10.5px}
.run-summary summary.rs-head{list-style:none;cursor:pointer}
.run-summary summary.rs-head::-webkit-details-marker{display:none}
.run-summary[open] .rs-head{border-bottom:1px solid var(--line)}
.run-summary .rs-head .rs-ok{color:var(--add);font-size:13px}
.run-summary .rs-head .rs-status{color:var(--fg);font-weight:500;flex:1}
.run-summary .rs-head .rs-dur{color:var(--dimmer);font-size:10px}
.run-summary .rs-grid{display:grid;grid-template-columns:repeat(4,1fr);padding:10px 13px;gap:6px}
.run-summary .rs-stat{display:flex;flex-direction:column;gap:2px}
.run-summary .rs-stat .k{font-size:9px;text-transform:uppercase;letter-spacing:1.2px;color:var(--dimmer)}
.run-summary .rs-stat .v{font-size:12px;font-weight:600;font-variant-numeric:tabular-nums}
.run-summary .rs-stat .v.cost{color:var(--brand)}
.run-summary .rs-stat .v.tok{color:var(--agent)}
.run-summary .rs-stat .v.files{color:var(--planner)}
.run-summary .rs-stat .v.checks{color:var(--gold)}
.error-banner{margin:8px 0;padding:9px 12px;border-radius:8px;background:rgba(217,106,99,.1);border:1px solid rgba(217,106,99,.32);color:var(--dim);font-size:11px;line-height:1.45}
.error-banner .title{color:var(--rem);font-weight:700;text-transform:uppercase;letter-spacing:1px;font-size:10px;margin-bottom:3px}
.error-banner .fix{color:var(--steel);margin-top:4px}
.error-banner a{color:var(--brand);text-decoration:none}
.learn-toast{animation:learnpop 4.2s ease}
@keyframes learnpop{0%{opacity:0;transform:translateY(5px)}10%{opacity:1;transform:none}82%{opacity:1}100%{opacity:0;transform:translateY(-5px)}}
.boot-line{opacity:0;animation:fin .35s ease forwards}
@keyframes fin{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.sp{display:inline-block;color:var(--brand);width:1.2em;text-align:center;animation:sp-dot .7s linear infinite}
@keyframes sp-dot{to{transform:rotate(360deg)}}
.tool-ico{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;font-size:11px;flex-shrink:0}
.inline-tok{display:inline-flex;gap:5px;font-size:10px;margin-left:7px;white-space:nowrap;font-variant-numeric:tabular-nums;vertical-align:middle;background:color-mix(in srgb,var(--brand) 6%,var(--bg));padding:0 5px;border-radius:4px}
.inline-tok .up-c{color:var(--add);animation:tk-pulse .35s ease-out}
.inline-tok .dn-c{color:var(--rem);animation:tk-pulse .35s ease-out}
@keyframes tk-pulse{0%{filter:brightness(1.6);text-shadow:0 0 6px currentColor}100%{filter:none;text-shadow:none}}
.bird{line-height:1.18;font-size:14px;margin:4px 0 6px;white-space:pre;font-family:'IBM Plex Mono',ui-monospace,monospace}
.bird .fr{color:var(--brand)} .bird .eye{color:#fff} .bird .patch{color:#1c150c;background:#1c150c;border-radius:2px;display:inline-block;min-width:1px}
.bird .beak{color:var(--coral)} .bird .tool{color:var(--steel)} .bird .gold{color:var(--gold)} .bird .belly{color:#f7d089}
.bird .brow{color:#5a4326} .bird .cheek{color:#f0674a}
.verb-think{font-style:italic;color:var(--dimmer);animation:verb-in .25s ease;margin-top:1px;font-size:11px}
@keyframes verb-in{from{opacity:0;transform:translateX(-3px)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion: reduce){
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}
.ember{display:none!important}
.streaming::after{animation:none!important;opacity:1}
.boot-overlay{display:none!important}
.caret,.cur,.cur2{animation:none!important;opacity:1}
.sp{animation:none!important}
}
</style>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<defs>
<linearGradient id="sp-body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f8bf57"/><stop offset="1" stop-color="#ef9b2f"/>
</linearGradient>
<linearGradient id="sp-wing" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#e8901f"/><stop offset="1" stop-color="#d2761d"/>
</linearGradient>
<linearGradient id="sp-belly" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fde6bd"/><stop offset="1" stop-color="#f7d089"/>
</linearGradient>
<linearGradient id="sp-steel" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#cdc6ba"/><stop offset="1" stop-color="#8f877a"/>
</linearGradient>
<symbol id="sparrow" viewBox="0 0 240 240">
<path d="M150 150 q58 6 60 52 q-46 -2 -64 -28 z" fill="url(#sp-wing)"/>
<path d="M120 50 C168 50 182 92 182 132 C182 184 154 204 120 204
C86 204 58 184 58 132 C58 92 72 50 120 50 Z"
fill="url(#sp-body)" stroke="#7a4f1c" stroke-width="3.5"/>
<path d="M112 52 q-4 -22 8 -28 q3 12 0 27 z" fill="url(#sp-wing)" stroke="#7a4f1c" stroke-width="2.4"/>
<path d="M122 50 q6 -20 18 -20 q-2 12 -12 24 z" fill="url(#sp-wing)" stroke="#7a4f1c" stroke-width="2.4"/>
<ellipse cx="118" cy="150" rx="40" ry="46" fill="url(#sp-belly)"/>
<path d="M70 110 C58 132 60 168 86 182 C92 156 88 128 84 112 Z"
fill="url(#sp-wing)" stroke="#7a4f1c" stroke-width="3"/>
<path d="M104 124 L130 121 L113 142 Z" fill="#f1862a" stroke="#7a4f1c" stroke-width="2.4" stroke-linejoin="round"/>
<ellipse cx="92" cy="138" rx="9" ry="6" fill="#f0674a" opacity=".45"/>
<g class="logo-eye-open">
<circle cx="100" cy="106" r="17" fill="#fff" stroke="#7a4f1c" stroke-width="2.6"/>
<circle cx="103" cy="108" r="8.5" fill="#2a1c0e"/>
<circle cx="99" cy="103" r="3.4" fill="#fff"/>
</g>
<path d="M86 86 C110 74 150 80 168 100" fill="none" stroke="#221910" stroke-width="6" stroke-linecap="round"/>
<ellipse cx="146" cy="108" rx="20" ry="17" fill="#221910"/>
<ellipse cx="146" cy="106" rx="20" ry="15" fill="#2c2114"/>
<circle cx="150" cy="138" r="7" fill="none" stroke="#f2c94c" stroke-width="3.4"/>
<path d="M138 150 C166 150 178 168 168 192 C150 188 138 172 134 156 Z"
fill="url(#sp-wing)" stroke="#7a4f1c" stroke-width="3"/>
<g transform="translate(156,176)">
<g class="logo-tool" transform="rotate(28)">
<rect x="-5" y="-44" width="10" height="46" rx="5" fill="url(#sp-steel)" stroke="#5f584d" stroke-width="2"/>
<path d="M-11 -44 L11 -44 L11 -58 L4.5 -58 L4.5 -50 L-4.5 -50 L-4.5 -58 L-11 -58 Z"
fill="url(#sp-steel)" stroke="#5f584d" stroke-width="2" stroke-linejoin="round"/>
<rect x="-2" y="-40" width="2.6" height="34" rx="1.3" fill="#fff" opacity=".35"/>
</g>
</g>
<g stroke="#e07e26" stroke-width="5" stroke-linecap="round" fill="none">
<path d="M108 200 L108 214 M101 218 L108 214 L115 218 M108 214 L108 220"/>
<path d="M132 200 L132 214 M125 218 L132 214 L139 218 M132 214 L132 220"/>
</g>
</symbol>
</defs>
</svg>
<div class="overlay" id="overlay"></div>
<div class="config-panel" id="configPanel">
<span class="close" id="closeConfig">×</span>
<h3>⚙ providers · models · routing</h3>
<div class="cfg-tab-row">
<button class="cfg-tab active" data-cfg-tab="providers">providers & models</button>
<button class="cfg-tab" data-cfg-tab="routing">routing & autonomy</button>
<button class="cfg-tab" data-cfg-tab="permissions">permissions</button>
</div>
<div data-cfg-pane="providers">
<div class="cfg-summary-row">
<span><b id="cfgProvCount">0</b> providers</span>
<span><b id="cfgKeyCount" style="color:var(--add)">0</b> configured</span>
<span><b id="cfgModelCount">0</b> models available</span>
<span style="margin-left:auto;color:var(--dimmer)">click ▸ to expand · set default · paste key</span>
</div>
<input type="text" class="cfg-search" id="cfgSearch" placeholder="search provider or model…" oninput="filterProviders(this.value)">
<div id="providerList"></div>
</div>
<div data-cfg-pane="routing" style="display:none">
<div class="row">
<label>preferred provider</label>
<select id="preferredProviderSelect" style="max-width:220px">
<option value="">-- auto (smart routing per tier) --</option>
</select>
</div>
<div class="row">
<label>auto-discover models</label>
<input type="checkbox" id="autoDiscoverToggle" checked>
<span class="muted" style="font-size:10px">scan provider APIs for available models on startup</span>
</div>
<div class="row">
<button class="cfg-save-btn" onclick="saveRouting()">💾 save routing</button>
<span class="save-status" id="routingSaveStatus" style="display:none;color:var(--add);font-size:10px;margin-left:8px">✓ saved</span>
</div>
<div class="row">
<label>autonomy</label>
<select id="autonomySelect">
<option value="supervised">supervised · ask before writes/exec</option>
<option value="trusted">trusted · checkpoint then act</option>
<option value="autonomous">autonomous · act within limits</option>
</select>
</div>
<div class="row">
<label>sandbox</label>
<select id="sandboxSelect">
<option value="local">local</option>
<option value="local-hardened">local-hardened</option>
<option value="docker">docker</option>
</select>
</div>
<div class="row">
<label>budget</label>
<input type="number" id="budgetSession" placeholder="session $" style="width:120px">
<input type="number" id="budgetDaily" placeholder="daily $" style="width:120px">
</div>
<div class="cfg-section">
<h4>quick presets</h4>
<div class="presets" id="presets">
<div class="preset" data-preset="local">🏠 local / free</div>
<div class="preset" data-preset="cloud">☁ cloud cheap</div>
<div class="preset" data-preset="strong">⚡ cloud strong</div>
</div>
</div>
</div>
<div data-cfg-pane="permissions" style="display:none">
<div class="row">
<label>permit</label>
<select id="permissionSelect">
<option value="read-only">read-only · no mutation</option>
<option value="plan">plan · no tools</option>
<option value="supervised">supervised · ask by risk</option>
<option value="trusted">trusted · checkpoint guard</option>
<option value="autonomous">autonomous · bounded action</option>
<option value="emergency-stop">emergency-stop · deny all</option>
</select>
</div>
<div class="cfg-section">
<h4>per-task model override</h4>
<p style="font-size:10.5px;color:var(--dim);line-height:1.5;margin-bottom:8px">Click <b>set default</b> next to any model above to force it for new tasks. Click <b>auto</b> below to clear and let the router decide.</p>
<button class="btn sm" onclick="setModelOverride(null)">⟳ clear override · auto-route</button>
<div style="margin-top:8px;font-size:10.5px;color:var(--dim)">current: <span id="cfgCurrentModel" style="color:var(--brand)">auto</span></div>
</div>
</div>
<div class="row" style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
<button class="btn" id="saveConfig">save all</button>
<span class="dimd" id="saveMsg" style="font-size:11px"></span>
</div>
<div style="display:none">
<select id="provSelect"></select>
<select id="modelSelect"></select>
<input type="password" id="apiKey">
<span id="credBadge" class="badge missing">no key</span>
<div id="modelTags"></div>
</div>
</div>
<div class="win">
<div class="chrome">
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
<span class="nm"><b>sparrow</b> · webview console <span class="live-tag" id="conn-tag">connecting…</span></span>
<span class="right">
<button class="chip-btn" id="cmdkBtn" title="Cmd+K commands">⌘K commands</button>
<button class="chip-btn solid" id="replayBtn" title="Replay last run">▸ replay</button>
<button class="chip-btn" id="abortChromeBtn" title="Abort the running task" style="display:none;color:var(--rem);background:color-mix(in srgb,var(--rem) 12%,transparent);border-color:color-mix(in srgb,var(--rem) 50%,transparent)">◼ abort</button>
<button class="chip-btn" id="soundBtn" title="Cmd+M mute sounds">🔊</button>
<button class="chip-btn" id="verboseBtn" title="Verbose mode — show tool args, token ticks, internal reasoning" aria-pressed="false">⊕ verbose</button>
<button class="chip-btn" id="themeBtn" title="Cmd+Shift+L toggle theme">☾</button>
<button class="btn sm" id="cfgBtn">config</button>
<span style="font-size:11px;color:var(--dimmer)">v0.3.6</span>
</span>
</div>
<nav class="rail" aria-label="Sparrow panels">
<svg class="rail-logo" viewBox="0 0 240 240" aria-label="Sparrow"><use href="#sparrow"/></svg>
<button class="ico" data-panel="crew" title="Crew — persistent agents" id="crewRailBtn"><span aria-hidden="true">◈</span></button>
<button class="ico" data-panel="sessions" title="Sessions"><span aria-hidden="true">◷</span></button>
<button class="ico" data-panel="memory" title="Memory"><span aria-hidden="true">✦</span></button>
<button class="ico" data-panel="plugins" title="Plugins"><span aria-hidden="true">⌗</span></button>
<button class="ico" data-panel="tools" title="Tools"><span aria-hidden="true">⚒</span></button>
<button class="ico" data-panel="permissions" title="Permissions"><span aria-hidden="true">◉</span></button>
<button class="ico" data-panel="security" title="Security"><span aria-hidden="true">⛨</span></button>
<button class="ico" data-panel="route" title="Route Explorer"><span aria-hidden="true">⇄</span></button>
<button class="ico" data-panel="artifacts" title="Artifacts"><span aria-hidden="true">◇</span></button>
<div class="sep"></div>
<div class="spacer"></div>
<button class="ico" id="rail-settings" title="Settings"><span aria-hidden="true">⚙</span></button>
</nav>
<aside class="drawer" id="drawer" aria-label="Drawer">
<div class="panel" data-panel-body="crew">
<h3>Crew <span class="count" id="drw-crew-count">0</span></h3>
<div id="drw-crew-list"><div class="drw-empty">loading agents…</div></div>
</div>
<div class="panel" data-panel-body="sessions">
<h3>Sessions <span class="count" id="drw-sessions-count">0</span></h3>
<div id="drw-sessions-list"><div class="drw-empty">_none yet_ · start your first run</div></div>
</div>
<div class="panel" data-panel-body="memory">
<h3>Memory</h3>
<div id="drw-memory-body"><div class="drw-empty">_none yet_ · MEMORY.md is empty — Sparrow will learn as you go</div></div>
</div>
<div class="panel" data-panel-body="plugins">
<h3>Plugins <span class="count" id="drw-plugins-count">0</span></h3>
<div id="drw-plugins-list"><div class="drw-empty">_none yet_ · no plugins installed</div></div>
</div>
<div class="panel" data-panel-body="tools">
<h3>Tools <span class="count" id="drw-tools-count">0</span></h3>
<div id="drw-tools-list"><div class="drw-empty">_none yet_ · no tools loaded</div></div>
</div>
<div class="panel" data-panel-body="permissions">
<h3>Permissions</h3>
<div id="drw-permissions-body"><div class="drw-empty">_none yet_ · connect to load</div></div>
</div>
<div class="panel" data-panel-body="security">
<h3>Security</h3>
<div id="drw-security-body"><div class="drw-empty">_none yet_ · connect to audit</div></div>
</div>
<div class="panel" data-panel-body="route">
<h3>Route Explorer</h3>
<div id="drw-route-body"><div class="drw-empty">start a run to see the active route chain</div></div>
</div>
<div class="panel" data-panel-body="artifacts">
<h3>Artifacts <span class="count" id="drw-artifacts-count">0</span></h3>
<div id="drw-artifacts-list"><div class="drw-empty">_none yet_ · drag a file onto the page to upload</div></div>
</div>
</aside>
<button id="newConversation" title="Start a fresh conversation (clears retained context)"
style="position:absolute;top:88px;right:14px;font-family:inherit;font-size:10px;padding:4px 10px;
border-radius:7px;cursor:pointer;color:var(--dim);background:var(--panel);border:1px solid var(--line);
z-index:30;letter-spacing:.3px;transition:.15s"
onclick="newConversation()">⟲ new conversation</button>
<section class="main">
<div class="cockpit">
<div class="cmark"><svg width="32" height="32" aria-label="Sparrow canonical logo" style="filter:drop-shadow(0 0 8px rgba(242,169,60,.52))"><use href="#sparrow"/></svg><span class="w word">SPARROW</span></div>
<div class="stat route-stat"><span class="k">route</span><span class="route" id="route">idle</span></div>
<div class="stat"><span class="k">cost</span><span class="cost" id="cost">$0.000</span></div>
<div class="stat"><span class="k">in</span><span class="tok" id="tokIn">0</span></div>
<div class="stat"><span class="k">out</span><span class="tok" id="tokOut">0</span></div>
<div class="stat"><span class="k">total</span><span class="tok" id="tok">0</span></div>
<div class="stat"><span class="k">session</span><span class="tok" id="tokSession">0 tok · $0.000</span></div>
<div class="stat" title="Daily budget usage"><span class="k">budget</span><span class="budget-track"><span class="budget-fill" id="budgetFill"></span></span><span class="tok" id="budget" style="color:var(--gold);margin-left:6px">0% / —</span></div>
<div class="stat"><span class="k">autonomy</span><span class="pill" id="auto"><span class="led"></span><span id="autotxt">—</span></span></div>
</div>
<div class="swarm-cockpit" id="swarm-cockpit" aria-label="Sparrow swarm cockpit">
<div class="lane planner idle" id="swarm-planner" title="planner — orchestrator triad">
<div class="lane-head"><span class="who">planner</span><span class="st"><span class="led-mini"></span><span data-role-state>idle</span></span></div>
<span class="msg" data-role-msg>waiting for task decomposition</span>
</div>
<div class="lane coder idle" id="swarm-coder" title="coder — orchestrator triad">
<div class="lane-head"><span class="who">coder</span><span class="st"><span class="led-mini"></span><span data-role-state>idle</span></span></div>
<span class="msg" data-role-msg>ready to edit with checkpoints</span>
</div>
<div class="lane verifier idle" id="swarm-verifier" title="verifier — orchestrator triad">
<div class="lane-head"><span class="who">verifier</span><span class="st"><span class="led-mini"></span><span data-role-state>idle</span></span></div>
<span class="msg" data-role-msg>standing by for adversarial review</span>
</div>
<span id="swarm-extras-anchor" hidden></span>
<div class="lane-more" id="swarm-more" hidden></div>
</div>
<div id="term"></div>
<div class="composer-hints">
<span><span class="ok-dot" style="color:var(--add)">●</span> context <b id="ctxHintPct" style="color:var(--fg)">—</b></span>
<div style="position:relative">
<button id="modelPickerBtn" title="Switch model" style="font-family:inherit;font-size:10px;color:var(--planner);background:color-mix(in srgb,var(--planner) 10%,transparent);border:1px solid color-mix(in srgb,var(--planner) 35%,transparent);padding:2px 9px;border-radius:999px;cursor:pointer;letter-spacing:.3px;transition:.15s" onclick="toggleModelPicker(event)">⬡ <span id="modelPickerLabel">auto</span></button>
<div class="mp-drop" id="modelPickerDrop" style="display:none">
<div class="mp-drop-head">select model</div>
<div id="modelPickerList"><div style="padding:12px 14px;color:var(--dimmer);font-size:11px">loading…</div></div>
<div class="mp-auto" onclick="setModelOverride(null)">⟳ auto-route (recommended)</div>
</div>
</div>
<span class="kbds">
<kbd>⌘K</kbd>commands
<kbd>@</kbd>agent
<kbd>⇧⏎</kbd>newline
<kbd>⌘M</kbd>sound
</span>
</div>
<div class="input-bar">
<label class="attach-btn" for="fileInput" title="Attach files">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21.4 11.3 12 20.7a6 6 0 0 1-8.5-8.5l9.8-9.8a4 4 0 0 1 5.7 5.7L9.2 17.9a2 2 0 1 1-2.8-2.8l8.9-8.9" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</label>
<input type="file" id="fileInput" multiple>
<textarea id="taskInput" rows="1" placeholder="Type a task and press Enter…" autofocus></textarea>
<div class="context-side" aria-label="Context usage">
<div class="context-label"><span>context</span><b id="contextText">0% · 0 / 32k tok</b></div>
<div class="context-track"><span class="context-fill" id="contextFill"></span></div>
</div>
<button class="btn" id="runBtn">run</button>
<div class="attachments" id="attachments"></div>
</div>
</section>
</div>
<div class="diff-panel" id="diffPanel" aria-label="Diff viewer">
<div class="diff-panel-head">
<span style="color:var(--coral);font-size:12px">◇</span>
<span class="dp-path" id="dpPath">—</span>
<span id="dpStats" style="font-size:9.5px;color:var(--dimmer)"></span>
<button class="dp-close" onclick="closeDiffPanel()" title="Close">✕</button>
</div>
<div class="diff-panel-body" id="dpBody"></div>
</div>
<div class="boot-overlay" id="bootOverlay" hidden>
<svg class="b-logo" width="120" height="120" aria-label="Sparrow"><use href="#sparrow"/></svg>
<div class="b-word">SPARROW</div>
<div class="b-tag">one cli · grows with you</div>
<div class="b-status" id="bootStatus"></div>
<div class="b-skip">click anywhere or press <kbd>esc</kbd> to skip</div>
</div>
<div class="palette" id="palette" role="dialog" aria-label="Slash command palette">
<div class="box">
<div class="pinput">
<svg width="22" height="22" aria-label="Sparrow"><use href="#sparrow"/></svg>
<input type="text" id="paletteInput" placeholder="Type / to filter commands, skills, agents; Shift+Enter runs…" autocomplete="off">
</div>
<div class="res-list" id="paletteResults"></div>
<div class="pfoot">
<span><kbd>↑↓</kbd> nav</span>
<span><kbd>↵</kbd> insert</span>
<span><kbd>⇧↵</kbd> run command</span>
<span style="margin-left:auto"><kbd>esc</kbd> close</span>
</div>
</div>
</div>
<div class="picker hidden" id="agentPicker" role="listbox" aria-label="Agent picker"></div>
<div class="approval-modal" id="approvalModal" role="dialog" aria-modal="true" aria-label="Approval required">
<div class="approval-card">
<h3>Approval required</h3>
<p id="approvalSummary">Sparrow is asking before it acts.</p>
<span class="risk" id="approvalRisk">risk: unknown</span>
<div class="actions">
<button class="deny" id="approvalDeny">deny</button>
<button class="approve" id="approvalApprove">approve</button>
</div>
</div>
</div>
<div class="status off" id="status">● disconnected</div>
<div class="drop-zone" id="dropZone">drop files to attach to Sparrow</div>
<script>
const term=document.getElementById('term');
const $=id=>document.getElementById(id);
let costV=0,tokV=0,tokInV=0,tokOutV=0,sessionTok=0,sessionCost=0;
let runStartMs=0,runDiffsApplied=0,runCheckpoints=0;
let actualInV=0,actualOutV=0,estInV=0,estOutV=0;
let tokenMeter=null,tokenMeterTimer=null;
let demoTokenInterval=null;
let attachedFiles=[];
let HISTORY_INPUTS=[];
let HISTORY_IDX=null;
let HISTORY_DRAFT='';
let contextLimit=32000;
const ATTACHMENT_CHAR_LIMIT=120000;
const MAX_ATTACHMENT_BYTES=10*1024*1024;
const FLIGHT_VERBS=['Soaring','Gliding','Diving','Scouting','Perching','Foraging','Wheeling','Lofting','Skimming','Swooping'];
let _vi=0;
const BIRD=
'<span class="fr"> ^^</span>\n'+
'<span class="fr"> .-~~~-.</span>\n'+
'<span class="fr"> /</span><span class="brow">__</span><span class="fr"> \\</span>\n'+
'<span class="fr"> | </span><span class="eye">o</span><span class="fr"> </span><span class="patch">██</span><span class="fr"> |</span>\n'+
'<span class="fr"> | </span><span class="beak">v</span><span class="fr"> |</span>\n'+
'<span class="fr"> | </span><span class="cheek">.</span><span class="fr"> |</span>\n'+
'<span class="fr"> \\ </span><span class="belly">\\__/</span><span class="fr"> /</span>\n'+
'<span class="fr"> \'-..-\'</span>\n'+
'<span class="fr"> /| |\\</span> <span class="tool">╤━</span><span class="gold">o</span>\n'+
'<span class="fr"> \'_| |_\'</span>';
const TOOL_ICONS={
read:'📖',search:'🔍',grep:'🎯',find:'🔍',
write:'✏️',edit:'🖊️',patch:'🩹',create:'✨',
run:'🏃',exec:'⚡',bash:'💻',terminal:'🖥️',
delete:'🗑️',remove:'🗑️',rm:'🧹',
list:'📂',ls:'📁',glob:'📁',
web:'🌐',fetch:'📡',http:'🔌',curl:'🌍',
diff:'📊',checkpoint:'💠',
memory:'🧠',remember:'📝',recall:'🔮',
think:'💭',ask:'❓',plan:'📋',
default:'⚒'
};
function toolIcon(name){
if(!name)return TOOL_ICONS.default;
const n=name.toLowerCase();
for(const [k,v] of Object.entries(TOOL_ICONS)){
if(n.startsWith(k)||n.includes(k))return v;
}
return TOOL_ICONS.default;
}
function startVerbCycle(elId){
const el=document.getElementById(elId);
if(!el)return;
setInterval(()=>{el.textContent=FLIGHT_VERBS[_vi++%FLIGHT_VERBS.length]},2200);
}
setInterval(()=>{document.querySelectorAll('.verb').forEach(v=>{v.textContent=FLIGHT_VERBS[_vi++%FLIGHT_VERBS.length]})},2200);
const LEARNED_SKILLS=['fix_revoked_token','run_auth_suite','patch_session','audit_emit','write-and-fix-tests','refactor-safely','debug-systematically'];
let _si=0;
function toast(msg, kind){
kind = kind || 'info';
let host = document.getElementById('sparrow-toast-host');
if(!host){
host = document.createElement('div');
host.id = 'sparrow-toast-host';
host.style.cssText = 'position:fixed;bottom:18px;right:18px;z-index:99999;display:flex;flex-direction:column;gap:8px;pointer-events:none;max-width:380px';
document.body.appendChild(host);
}
const palette = {
error: ['var(--rem,#d44)', 'rgba(212,68,68,.12)', 'rgba(212,68,68,.45)'],
ok: ['var(--add,#3a8)', 'rgba(58,168,136,.12)', 'rgba(58,168,136,.45)'],
info: ['var(--fg,#888)', 'rgba(120,120,120,.10)', 'rgba(120,120,120,.40)'],
}[kind] || ['#888','rgba(120,120,120,.10)','rgba(120,120,120,.40)'];
const el = document.createElement('div');
el.style.cssText = `pointer-events:auto;padding:9px 13px;border-radius:8px;font:500 12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:${palette[0]};background:${palette[1]};border:1px solid ${palette[2]};box-shadow:0 4px 12px rgba(0,0,0,.18);opacity:0;transform:translateY(6px);transition:opacity .18s,transform .18s;cursor:pointer`;
el.textContent = String(msg);
el.addEventListener('click', ()=>el.remove());
host.appendChild(el);
requestAnimationFrame(()=>{el.style.opacity='1';el.style.transform='translateY(0)';});
setTimeout(()=>{
el.style.opacity='0';el.style.transform='translateY(6px)';
setTimeout(()=>el.remove(), 220);
}, 4000);
}
function pulseLearnToast(elId){
const el=document.getElementById(elId);
if(!el)return;
function pulse(){
el.querySelector('b').textContent=LEARNED_SKILLS[_si++%LEARNED_SKILLS.length];
el.classList.remove('learn-toast');void el.offsetWidth;el.classList.add('learn-toast');
}
setTimeout(()=>{pulse();setInterval(pulse,9000)},5500);
}
function startTokenCounter(elId){
const el=document.getElementById(elId);
if(!el)return;
let n=0;
demoTokenInterval=setInterval(()=>{
if(actualInV||actualOutV||estInV||estOutV)return;
n+=Math.floor(Math.random()*41);
el.textContent=n.toLocaleString('en-US');
el.classList.add('up');
setTimeout(()=>el.classList.remove('up'),300);
},850);
}
function addBootLine(parent,html,index){
const d=document.createElement('div');
d.className='boot-line';
d.innerHTML=html;
d.style.animationDelay=(index*250)+'ms';
parent.appendChild(d);
term.scrollTop=term.scrollHeight;
return d;
}
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function typeCmd(text,container){
const d=document.createElement('div');
d.className='ln';
d.innerHTML='<span class="prompt">sparrow ›</span> <span class="cmd"></span><span class="cur2"></span>';
container.appendChild(d);
const cmd=d.querySelector('.cmd'),cur=d.querySelector('.cur2');
for(const ch of text){cmd.textContent+=ch;container.scrollTop=container.scrollHeight;await sleep(26+Math.random()*36)}
cur.remove();
return d;
}
function line(html,cls=''){const d=document.createElement('div');d.className='ln '+cls;d.innerHTML=html;term.appendChild(d);term.scrollTop=term.scrollHeight;return d;}
function setRoute(t){
const el=$('route');
const parts=String(t||'idle').split(/\s*(?:→|->)\s*/).filter(Boolean);
if(parts.length>1){
el.innerHTML=parts.map((p,i)=>`${i?'<span class="arrow">→</span>':''}<span class="hop">${esc(p)}</span>`).join('');
}else{
el.textContent=t||'idle';
}
el.title=t;
}
function updateSession(){
$('tokSession').textContent=sessionTok.toLocaleString('en-US')+' tok · $'+sessionCost.toFixed(4);
}
let _budgetDailyUsd=5; function updateBudget(){
const el=$('budget');if(!el)return;
const pct=_budgetDailyUsd>0?Math.min(100,Math.round(sessionCost/_budgetDailyUsd*100)):0;
el.textContent=pct+'% / $'+_budgetDailyUsd+' day';
el.style.color=pct>=85?'var(--rem)':pct>=60?'var(--coral)':pct>=30?'var(--gold)':'var(--add)';
const fill=$('budgetFill');if(!fill)return;
fill.style.width=pct+'%';
fill.className='budget-fill'+(pct>=80?' danger':pct>=60?' warn':'');
}
function setCost(c){
const next=Math.max(0,Number(c)||0);
sessionCost+=next-costV;
costV=next;
const el=$('cost');
el.textContent='$'+costV.toFixed(4);
el.classList.remove('up');void el.offsetWidth;el.classList.add('up');
setTimeout(()=>el.classList.remove('up'),600);
updateBudget();
if(tokenMeter)updateTokenMeter();
updateSession();
}
function countUp(el,to,ms=420){
if(!el)return;
const fromTxt=String(el.textContent||'').replace(/[^\d.-]/g,'');
const from=parseFloat(fromTxt)||0;
if(el._countUpTimer)clearInterval(el._countUpTimer);
if(from===to){el.textContent=to.toLocaleString('en-US');return;}
const start=Date.now();
const tick=()=>{
const t=Math.min(1,(Date.now()-start)/ms);
const eased=1-Math.pow(1-t,3); const val=Math.round(from+(to-from)*eased);
el.textContent=val.toLocaleString('en-US');
if(t>=1){clearInterval(el._countUpTimer);el._countUpTimer=null;el.textContent=to.toLocaleString('en-US');}
};
el._countUpTimer=setInterval(tick,16); tick();
}
function refreshTokens(){
const prev=tokV;
tokInV=actualInV+estInV;tokOutV=actualOutV+estOutV;tokV=tokInV+tokOutV;
sessionTok+=tokV-prev;
const elIn=$('tokIn');if(elIn)elIn.textContent=tokInV.toLocaleString('en-US');
const elOut=$('tokOut');if(elOut)elOut.textContent=tokOutV.toLocaleString('en-US');
const elTok=$('tok');if(elTok)elTok.textContent=tokV.toLocaleString('en-US');
if(prev!==tokV)countUp(elTok,tokV,400);
updateSession();
updateTokenMeter();
updateContextMeter();
['tokIn','tokOut','tok'].forEach(id=>{
const el=$(id);if(!el)return;
el.classList.remove('up');void el.offsetWidth;el.classList.add('up');
setTimeout(()=>el.classList.remove('up'),600);
});
}
function addTokEstimate(input=0,output=0){estInV+=input||0;estOutV+=output||0;refreshTokens();}
function addTokActual(input=0,output=0){
actualInV+=input||0;actualOutV+=output||0;
estInV=Math.max(0,estInV-(input||0));estOutV=Math.max(0,estOutV-(output||0));
refreshTokens();
}
function resetRunMetrics(){
if(demoTokenInterval){clearInterval(demoTokenInterval);demoTokenInterval=null}
costV=0;tokV=0;tokInV=0;tokOutV=0;actualInV=0;actualOutV=0;estInV=0;estOutV=0;
$('cost').textContent='$0.000';$('tokIn').textContent='0';$('tokOut').textContent='0';$('tok').textContent='0';
tokenMeter=null;
updateContextMeter();
}
function ensureTokenMeter(){
if(tokenMeter)return tokenMeter;
tokenMeter=line('<span class="token-meter"><span class="in">sent <b data-tok-in>0</b> tok</span><span class="out">recv <b data-tok-out>0</b> tok</span><span class="cost">live $0.0000</span></span>');
return tokenMeter;
}
function updateTokenMeter(){
const row=ensureTokenMeter().querySelector('.token-meter');
row.querySelector('[data-tok-in]').textContent=tokInV.toLocaleString('en-US');
row.querySelector('[data-tok-out]').textContent=tokOutV.toLocaleString('en-US');
row.querySelector('.cost').textContent='live $'+costV.toFixed(4);
row.classList.remove('pulse');void row.offsetWidth;row.classList.add('pulse');
clearTimeout(tokenMeterTimer);tokenMeterTimer=setTimeout(()=>row.classList.remove('pulse'),460);
}
function estimateTokens(text){return Math.ceil(String(text||'').length/4);}
function draftContextTokens(){
const input=$('taskInput')?.value||'';
const attachments=attachedFiles.reduce((sum,f)=>sum+estimateTokens(f.content||f.note||f.name),0);
return tokV+estimateTokens(input)+attachments;
}
function fmtCtx(n){if(n>=1000000)return(n/1000000).toFixed(n%1000000===0?0:1)+'M';if(n>=1000)return Math.round(n/1000)+'k';return String(n);}
function updateContextMeter(){
const used=Math.min(contextLimit,draftContextTokens());
const pct=Math.min(100,Math.round((used/contextLimit)*100));
if($('contextText'))$('contextText').textContent=`${pct}% · ${used.toLocaleString('en-US')} / ${fmtCtx(contextLimit)} tok`;
if($('contextFill'))$('contextFill').style.width=pct+'%';
}
function setAuto(level){
const map={supervised:'--sup',trusted:'--tru',autonomous:'--aut'};
const col=`var(${map[level]})`;const p=$('auto');
p.style.color=col;p.style.border='1px solid color-mix(in srgb,'+col+' 45%,transparent)';
p.style.background='color-mix(in srgb,'+col+' 13%,transparent)';
p.querySelector('.led').style.background=col;p.querySelector('.led').style.boxShadow='0 0 8px '+col;
$('autotxt').textContent=level.toUpperCase();
}
function cleanModelReason(reason){
const text=String(reason||'');
if(text.includes('Ollama API error 404')&&text.includes('model')){
return 'modèle local indisponible';
}
if(text.toLowerCase().includes('ollama')){
return 'provider local indisponible';
}
return text;
}
function routeSwitchLabel(from,to,reason){
const clean=cleanModelReason(reason);
if(clean==='modèle local indisponible'||clean==='provider local indisponible'){
return `↳ modèle local indisponible → routage modèle cloud <span class="planner">${esc(to)}</span>`;
}
return `↳ fallback: ${esc(from)} → ${esc(to)} <span class="dimd">${esc(clean)}</span>`;
}
function summarizeChain(chain,limit=5){
const list=Array.isArray(chain)?chain:[];
if(!list.length)return 'aucun modèle disponible';
const visible=list.slice(0,limit);
if(list.length>limit)visible.push(`+${list.length-limit} autres fallbacks`);
return visible.join(' → ');
}
function compactChain(chain){
const list=Array.isArray(chain)?chain:[];
if(!list.length)return 'idle';
if(list.length===1)return list[0];
return `${list[0]} → ${list[1]}${list.length>2?` · +${list.length-2}`:''}`;
}
function setSwarm(role,state,msg){
const lane=$('swarm-'+role);
if(!lane)return;
const cleanState=String(state||'idle').toLowerCase();
lane.classList.remove('idle','working','done','error');
lane.classList.add(cleanState.includes('error')?'error':cleanState.includes('done')?'done':cleanState.includes('work')||cleanState.includes('think')?'working':'idle');
lane.querySelector('[data-role-state]').textContent=cleanState.includes('think')?'thinking':cleanState.includes('work')?'working':cleanState.includes('done')?'done':cleanState.includes('error')?'error':'idle';
lane.querySelector('[data-role-msg]').innerHTML=esc(msg||'ready');
lane.style.animation='none';void lane.offsetWidth;lane.style.animation='';
}
function resetSwarm(){
setSwarm('planner','idle','waiting for task decomposition');
setSwarm('coder','idle','ready to edit with checkpoints');
setSwarm('verifier','idle','standing by for adversarial review');
}
function injectHero(){
const term=document.getElementById('term');if(!term)return;
if(term.querySelector('.hero'))return;
const hero=document.createElement('div');
hero.className='hero';
hero.innerHTML=
'<svg class="h-logo" width="84" height="84" aria-label="Sparrow"><use href="#sparrow"/></svg>'+
'<div class="h-text">'+
'<div class="hi">Welcome back, <b>'+escHtml(navigator.userAgent.includes('Windows')?'Captain':'friend')+'</b></div>'+
'<div class="tag">one cli · grows with you</div>'+
'<div class="meta">'+
'<span><span class="ok-dot">●</span> router · <b id="hero-providers">…</b> providers · <b id="hero-active" style="color:var(--add)">…</b> active</span>'+
'<span><span class="ok-dot">●</span> skills · <b id="hero-skills">…</b></span>'+
'<span><span class="ok-dot">●</span> facts · <b id="hero-facts">…</b></span>'+
'<span><span class="ok-dot">●</span> autonomy · <b id="hero-auto">…</b></span>'+
'</div>'+
'</div>';
term.prepend(hero);
}
async function hydrateHero(){
try{
const [cfgR,memR,permR]=await Promise.all([fetch('/config'),fetch('/memory'),fetch('/permissions')]);
const cfg=await cfgR.json(),mem=await memR.json(),perm=await permR.json();
const setIf=(id,val)=>{const el=document.getElementById(id);if(el)el.textContent=val;};
const allProviders=Array.isArray(cfg.providers)?cfg.providers:[];
const activeProviders=allProviders.filter(p=>p.has_credential||p.configured);
setIf('hero-providers',allProviders.length||'—');
const heroActive=document.getElementById('hero-active');
if(heroActive){
const prev=heroActive.textContent;heroActive.textContent=activeProviders.length||'0';
if(prev!==String(activeProviders.length||'0')){heroActive.classList.remove('hero-providers-pulse');void heroActive.offsetWidth;heroActive.classList.add('hero-providers-pulse');}
}
if(cfg.budget?.daily_usd){_budgetDailyUsd=cfg.budget.daily_usd;updateBudget();}
setIf('hero-facts',(mem.stats&&mem.stats.facts!==undefined)?mem.stats.facts:(mem.facts||[]).length);
setIf('hero-skills',(cfg.skills_count!==undefined)?cfg.skills_count:'—');
setIf('hero-auto',(perm.permissions&&perm.permissions.mode)||'—');
window._configuredProviders=new Set(activeProviders.map(p=>p.name||p.id));
const hp=document.getElementById('homePath');
if(hp)hp.textContent=cfg.workdir||location.host;
const ms=document.getElementById('memorySummary');
if(ms){
const facts=(mem.stats&&mem.stats.facts!==undefined)?mem.stats.facts:(mem.facts||[]).length;
const mode=(perm.permissions&&perm.permissions.mode)||'supervised';
ms.textContent=`${facts} facts stored · permission mode: ${mode}`;
}
if(activeProviders.length===0){
showOnboardingBanner();
}
}catch(_){}
}
function showOnboardingBanner(){
if(document.getElementById('onboarding-banner'))return;
const b=document.createElement('div');
b.id='onboarding-banner';
b.style.cssText='margin:10px 0;padding:12px 16px;border:1px solid color-mix(in srgb,var(--brand) 40%,var(--line));border-radius:10px;background:color-mix(in srgb,var(--brand) 7%,transparent);font-size:11.5px;line-height:1.6';
b.innerHTML=`<div style="font-weight:700;color:var(--brand);letter-spacing:.5px;margin-bottom:6px">⬡ No providers configured</div>
<div style="color:var(--dim)">Sparrow needs at least one API key to route tasks. Run setup to configure providers:</div>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<button onclick="document.getElementById('taskInput').value='sparrow setup';document.getElementById('runBtn').click()" style="font-family:inherit;font-size:10.5px;padding:4px 12px;border-radius:7px;cursor:pointer;color:var(--brand);background:color-mix(in srgb,var(--brand) 12%,transparent);border:1px solid color-mix(in srgb,var(--brand) 40%,transparent)">▸ run sparrow setup</button>
<button onclick="openPanel('permissions')" style="font-family:inherit;font-size:10.5px;padding:4px 12px;border-radius:7px;cursor:pointer;color:var(--dim);background:var(--panel);border:1px solid var(--line)">◉ open permissions</button>
<button onclick="this.closest('#onboarding-banner').remove()" style="font-family:inherit;font-size:10.5px;padding:4px 12px;border-radius:7px;cursor:pointer;color:var(--dimmer);background:transparent;border:1px solid var(--line)">dismiss</button>
</div>
<div style="margin-top:8px;font-size:10px;color:var(--dimmer)">Or set <code style="color:var(--steel)">NVIDIA_API_KEY</code>, <code style="color:var(--steel)">ANTHROPIC_API_KEY</code>, or <code style="color:var(--steel)">OPENAI_API_KEY</code> in your environment and restart.</div>`;
const term=document.getElementById('term');if(term)term.prepend(b);
}
const BOOT_STATUS_LINES=[
['router','providers discovered'],
['surfaces','cli · webview · gateway'],
['sandbox','workdir guards armed'],
['skills','library indexed'],
['memory','MEMORY.md + USER.md loaded'],
['autonomy','dial ready'],
];
function runBootAnimation(){
if(new URLSearchParams(location.search).get('boot')==='0')return;
if(sessionStorage.getItem('sparrow-booted')==='1')return;
sessionStorage.setItem('sparrow-booted','1');
const ov=document.getElementById('bootOverlay');if(!ov)return;
ov.hidden=false;ov.classList.remove('fading');
const tag=ov.querySelector('.b-tag');if(tag){var txt=tag.textContent;tag.textContent='';(async function(){for(var i=0;i<txt.length;i++){tag.textContent+=txt[i];await sleep(40+Math.random()*30);}})();}
const host=document.getElementById('bootStatus');host.innerHTML='';
BOOT_STATUS_LINES.forEach((pair,i)=>{
const div=document.createElement('div');
div.className='b-line';
div.style.animationDelay=(.25+i*.18)+'s';
div.innerHTML=`<span class="l-key">${escHtml(pair[0])}</span><span class="l-val">${escHtml(pair[1])}</span>`;
host.appendChild(div);
});
const skip=()=>{
if(ov.classList.contains('fading'))return;
ov.classList.add('fading');
setTimeout(()=>{ov.hidden=true;document.removeEventListener('keydown',onKey);ov.removeEventListener('click',skip);},420);
};
const onKey=(e)=>{if(e.key==='Escape'||e.key==='Enter'||e.key===' ')skip();};
ov.addEventListener('click',skip);
document.addEventListener('keydown',onKey);
setTimeout(skip,2400); if(typeof chirp==='function'){chirp(880,.06);setTimeout(()=>chirp(1320,.06),140);setTimeout(()=>chirp(1760,.08),280);}
}
function getTheme(){return document.documentElement.getAttribute('data-theme')||'captain'}
function setTheme(t){
document.documentElement.setAttribute('data-theme',t);
const btn=document.getElementById('themeBtn');
if(btn)btn.textContent=t==='captain'?'☾':'☀';
localStorage.setItem('sparrow-theme',t);
}
function initTheme(){
const queryTheme=new URLSearchParams(location.search).get('theme');
if(queryTheme==='captain'||queryTheme==='paper'){
setTheme(queryTheme);
return;
}
const stored=localStorage.getItem('sparrow-theme');
const prefersLight=window.matchMedia('(prefers-color-scheme: light)').matches;
setTheme(stored||(prefersLight?'paper':'captain'));
}
let audioCtx=null;
let muted=localStorage.getItem('sparrow-muted')==='1';
function chirp(freq=900,dur=0.08){
if(muted)return;
try{
if(!audioCtx)audioCtx=new (window.AudioContext||window.webkitAudioContext)();
const o=audioCtx.createOscillator(),g=audioCtx.createGain();
o.type='triangle';o.frequency.setValueAtTime(freq,audioCtx.currentTime);
o.frequency.exponentialRampToValueAtTime(freq*1.8,audioCtx.currentTime+dur);
g.gain.setValueAtTime(.16,audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(.001,audioCtx.currentTime+dur);
o.connect(g).connect(audioCtx.destination);
o.start();o.stop(audioCtx.currentTime+dur);
}catch(_){}
}
function syncSoundBtn(){const b=document.getElementById('soundBtn');if(b)b.textContent=muted?'🔇':'🔊'}
function bindChromeChips(){
const cmdk=document.getElementById('cmdkBtn');if(cmdk)cmdk.addEventListener('click',()=>{chirp(1000,.04);paletteOpen()});
const rb=document.getElementById('replayBtn');if(rb)rb.addEventListener('click',async()=>{
chirp(800,.05);setTimeout(()=>chirp(1200,.05),80);
var runId=prompt('Run ID to replay (leave empty for boot animation):');
if(runId&&runId.trim()){
try{
var r=await fetch('/replay?run_id='+encodeURIComponent(runId.trim()));
var j=await r.json();
if(j.ok&&j.events){term.innerHTML='';j.events.forEach(function(ev){handleEvent(ev);});return;}
}catch(e){}
}
sessionStorage.removeItem('sparrow-booted');runBootAnimation();
});
const sb=document.getElementById('soundBtn');if(sb)sb.addEventListener('click',()=>{
muted=!muted;localStorage.setItem('sparrow-muted',muted?'1':'0');syncSoundBtn();if(!muted)chirp(1200,.06);
});
const tb=document.getElementById('themeBtn');if(tb)tb.addEventListener('click',()=>{
setTheme(getTheme()==='captain'?'paper':'captain');chirp(900,.05);
});
document.addEventListener('keydown',e=>{
if((e.metaKey||e.ctrlKey)&&e.shiftKey&&e.key.toLowerCase()==='l'){e.preventDefault();if(tb)tb.click();}
if((e.metaKey||e.ctrlKey)&&!e.shiftKey&&e.key.toLowerCase()==='m'){e.preventDefault();if(sb)sb.click();}
});
}
function mirrorContextPct(){
const src=document.getElementById('contextText');const dst=document.getElementById('ctxHintPct');
if(!src||!dst)return;
const m=src.textContent.match(/^([\d.]+%)/);if(m)dst.textContent=m[1];
}
const _mirrorIv=setInterval(mirrorContextPct,800);
const _heroRefreshIv=setInterval(()=>{hydrateHero();},15000);
initTheme();
syncSoundBtn();
(function(){
const termEl=document.getElementById('term');
if(!termEl)return;
const ro=new ResizeObserver(function(entries){
for(var i=0;i<entries.length;i++){
var w=entries[i].contentRect.width;
var base=Math.round(11+Math.min(3,Math.max(0,(w-360)/210)));
termEl.style.fontSize=base+'px';
termEl.style.padding=w<520?'12px 14px 18px':'';
}
});
ro.observe(termEl);
})();
bindChromeChips();
const PANEL_LOADERS={
route:loadRoutePanel,
crew:loadCrewPanel,
sessions:loadSessionsPanel,
memory:loadMemoryPanel,
plugins:loadPluginsPanel,
tools:loadToolsPanel,
permissions:loadPermissionsPanel,
security:loadSecurityPanel,
artifacts:loadArtifactsPanel,
};
function openPanel(name){
if(window.innerWidth<=980)document.body.classList.add('drawer-open');
document.querySelectorAll('.rail .ico').forEach(b=>b.classList.toggle('active',b.dataset.panel===name));
document.querySelectorAll('.drawer .panel').forEach(p=>p.classList.toggle('active',p.dataset.panelBody===name));
localStorage.setItem('sparrow-active-panel',name);
const loader=PANEL_LOADERS[name];if(loader)loader().catch(()=>{});
const tgt=document.querySelector('.drawer .panel[data-panel-body="'+name+'"]');
if(tgt&&tgt.scrollIntoView)tgt.scrollIntoView({behavior:'smooth',block:'start'});
}
document.addEventListener('click',e=>{
const ico=e.target.closest('.rail .ico[data-panel]');
if(ico)openPanel(ico.dataset.panel);
});
function restoreActivePanel(){
const stored=localStorage.getItem('sparrow-active-panel');
const name=(stored&&PANEL_LOADERS[stored])?stored:'sessions';
openPanel(name);
Object.values(PANEL_LOADERS).forEach(fn=>fn().catch(()=>{}));
setTimeout(()=>{
const active=document.querySelectorAll('.drawer .panel.active');
if(active.length!==1){
document.querySelectorAll('.drawer .panel').forEach(p=>p.classList.remove('active'));
const target=document.querySelector(`.drawer .panel[data-panel-body="${name}"]`);
if(target)target.classList.add('active');
}
},0);
}
function escHtml(s){return String(s||'').replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]))}
let _currentRouteChain=[];let _currentRouteCtx=32000;
async function loadRoutePanel(){
const host=document.getElementById('drw-route-body');if(!host)return;
let configured=window._configuredProviders||new Set();
try{
const cfg=await fetch('/config').then(r=>r.json());
configured=new Set((cfg.providers||[]).filter(p=>p.has_credential||p.configured).map(p=>p.name||p.id));
window._configuredProviders=configured;
}catch{}
if(!_currentRouteChain.length){
host.innerHTML='<div class="drw-empty">start a run to see the active route chain</div>';
return;
}
const chain=_currentRouteChain;
const ctxFmt=fmtCtx(_currentRouteCtx);
let modelCosts={};
try{
const md=await fetch('/models').then(r=>r.json());
(md.providers||[]).forEach(p=>{
p.models.forEach(m=>{modelCosts[p.id+':'+m.name]={ctx:m.context_window,cost_in:m.cost_input_per_mtok,cost_out:m.cost_output_per_mtok};});
});
}catch{}
const currentBox=`<div class="route-current-box">
<div class="rc-label">active model</div>
<div class="rc-model">${escHtml(chain[0]||'—')}</div>
<div class="rc-ctx">context ${escHtml(ctxFmt)} · ${escHtml(chain.length)} hop${chain.length!==1?'s':''} in chain</div>
</div>`;
const hops=chain.map((id,i)=>{
const info=modelCosts[id]||{};
const active=i===0;
const hasKey=configured.has(id.split(':')[0]);
const ledClass=active?'active':(hasKey?'standby':'error');
const ctxStr=info.ctx?fmtCtx(info.ctx):'—';
const costStr=info.cost_in!==undefined?(info.cost_in===0?'free':'$'+info.cost_in+'/M'):'—';
return `<div class="route-hop ${ledClass}">
<span class="rh-led"></span>
<div class="rh-info">
<div class="rh-name${active?' active':''}">${escHtml(id)}</div>
<div class="rh-meta"><span class="rh-ctx">ctx ${escHtml(ctxStr)}</span><span class="rh-cost">${escHtml(costStr)}</span>${!hasKey?'<span style="color:var(--rem);font-size:9px">no key</span>':''}</div>
</div>
<span class="rh-pos">#${i+1}</span>
</div>`;
}).join('');
host.innerHTML=currentBox+hops;
}
function updateRoutePanel(chain,ctxWindow){
_currentRouteChain=chain||[];_currentRouteCtx=ctxWindow||_currentRouteCtx;
const active=document.querySelector('.drawer .panel.active[data-panel-body="route"]');
if(active)loadRoutePanel();
}
function updateCrewLiveStatus(name,status,note){
const list=document.getElementById('drw-crew-list');if(!list)return;
const items=list.querySelectorAll('[data-agent-name]');
items.forEach(el=>{
if(el.dataset.agentName===name){
const pill=el.querySelector('[data-crew-status]');
if(pill){pill.textContent=status;
const stMap={working:'var(--agent)',done:'var(--add)',error:'var(--rem)',idle:'var(--dimmer)'};
pill.style.color=stMap[(status||'').toLowerCase()]||'var(--steel)';
if(note){const nd=el.querySelector('[data-crew-note]');if(nd)nd.textContent=note.slice(0,40);}
}
}
});
}
async function newConversation(){
if(!confirm('Clear retained conversation context and start fresh?'))return;
try{
await fetch('/conversation/reset',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
localStorage.removeItem('sparrow-turn-count');
const term=document.getElementById('term');
const banner=document.createElement('div');
banner.style.cssText='margin:10px 0;padding:8px 14px;border-radius:8px;background:color-mix(in srgb,var(--add) 10%,transparent);border:1px solid color-mix(in srgb,var(--add) 35%,transparent);color:var(--add);font-size:11px';
banner.textContent='⟲ conversation cleared · context retained from this point forward';
term.appendChild(banner);term.scrollTop=term.scrollHeight;
}catch(e){toast('reset failed: '+e.message,'error');}
}
let ACTIVE_AGENT = null;
function activateAgent(name) {
document.querySelectorAll('[data-agent-name]').forEach(el => {
if (el.dataset.agentName === name) {
el.style.background = 'color-mix(in srgb,var(--brand) 12%,transparent)';
el.style.borderLeft = '2px solid var(--brand)';
} else {
el.style.background = 'transparent';
el.style.borderLeft = '';
}
});
ACTIVE_AGENT = name;
const inp = document.getElementById('taskInput');
if (inp && name) {
inp.value = '@' + name + ' ' + inp.value.replace(/^@\S+\s*/, '');
inp.focus();
}
const coderWho = document.querySelector('#swarm-coder .who');
if (coderWho) coderWho.textContent = name;
}
async function loadCrewPanel(){
const host=document.getElementById('drw-crew-list');
try{
const r=await fetch('/agents');const j=await r.json();
const agents=Array.isArray(j.agents)?j.agents:[];
document.getElementById('drw-crew-count').textContent=agents.length;
if(!agents.length){host.innerHTML='<div class="drw-empty">no agents installed · create a <code>.agent.md</code> in <code>~/.config/sparrow/agents/</code></div>';return;}
const colorMap={planner:'var(--planner)',coder:'var(--agent)',verifier:'var(--verifier)',gold:'var(--gold)',coral:'var(--coral)',steel:'var(--steel)'};
host.innerHTML=agents.map(a=>{
const col=colorMap[a.color_key]||'var(--steel)';
return `<div data-agent-name="${escHtml(a.name)}" onclick="activateAgent('${escHtml(a.name)}')" style="cursor:pointer;padding:9px 10px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:3px;transition:background .15s" onmouseenter="this.style.background='color-mix(in srgb,var(--brand) 8%,transparent)'" onmouseleave="this.style.background='transparent'">
<div style="display:flex;align-items:center;gap:7px">
<span style="width:7px;height:7px;border-radius:50%;background:${col};box-shadow:0 0 7px ${col};flex:0 0 auto"></span>
<span style="font-weight:600;font-size:11.5px;color:${col}">${escHtml(a.name)}</span>
<span style="margin-left:auto;font-size:9.5px;color:var(--dimmer);letter-spacing:.5px">${escHtml(a.role)}</span>
</div>
${a.description?`<div style="font-size:10.5px;color:var(--dim);padding-left:14px;line-height:1.4">${escHtml(a.description)}</div>`:''}
<div style="padding-left:14px;margin-top:3px;display:flex;gap:6px;align-items:center">
<span data-crew-status style="font-size:9px;color:var(--dimmer);background:color-mix(in srgb,${col} 10%,transparent);border:1px solid color-mix(in srgb,${col} 25%,transparent);padding:1px 7px;border-radius:999px">${escHtml(a.status||'idle')}</span>
<span data-crew-note style="font-size:9.5px;color:var(--dimmer)"></span>
</div>
</div>`;
}).join('');
}catch{host.innerHTML='<div class="drw-empty">⚠ could not reach /agents</div>';}
}
async function loadSessionsPanel(){
const host=document.getElementById('drw-sessions-list');
try{
const r=await fetch('/sessions');const j=await r.json();
const sess=Array.isArray(j.sessions)?j.sessions:[];
document.getElementById('drw-sessions-count').textContent=sess.length;
if(!sess.length){host.innerHTML='<div class="drw-empty">_none yet_ · start your first run.</div>';return;}
host.innerHTML=sess.slice(0,20).map(s=>{
const ts=s.updated_at?new Date(s.updated_at*1000).toLocaleString('fr-FR',{hour:'2-digit',minute:'2-digit',day:'2-digit',month:'2-digit'}):'';
const turns=(s.messages_json||'[]').match(/"role":/g)?.length||0;
return `<div class="drw-row" data-session-id="${escHtml(s.id)}" title="Click to load · ${escHtml(s.id)}" style="cursor:pointer"><div class="ttl">${escHtml(s.name||s.id)}</div><div class="meta"><span><b>${turns}</b> turns</span><span>${escHtml(s.status||'')}</span><span>${ts}</span></div></div>`;
}).join('');
host.querySelectorAll('.drw-row[data-session-id]').forEach(row=>{
row.addEventListener('click',async ()=>{
const id=row.getAttribute('data-session-id');
host.querySelectorAll('.drw-row').forEach(r=>r.classList.remove('cur'));
row.classList.add('cur');
try{
const rr=await fetch('/sessions/load',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});
const jj=await rr.json();
if(jj.ok){toast(`session loaded · ${id.slice(0,8)}`,'ok');}
else{toast('load failed: '+(jj.message||''),'error');}
}catch(e){toast('load failed: '+e.message,'error');}
});
});
}catch(_){host.innerHTML='<div class="drw-empty">_none yet_ · unable to load /sessions</div>';}
}
async function loadMemoryPanel(){
const host=document.getElementById('drw-memory-body');
try{
const r=await fetch('/memory');const j=await r.json();
const facts=Array.isArray(j.facts)?j.facts:[];
const docs=Array.isArray(j.docs)?j.docs:[];
let html='';
docs.forEach(d=>{
const used=d.chars||0,lim=d.limit||1;
const pct=Math.min(100,Math.round(used/lim*100));
html+=`<div class="mem-doc"><div class="label">${escHtml(d.kind||'doc')} <b>${used} / ${lim}</b></div><div class="bar"><span style="width:${pct}%"></span></div></div>`;
});
html+=`<div class="mem-doc"><div class="label">Facts <b>${facts.length}</b></div></div>`;
if(facts.length){
html+='<div style="margin-top:6px">'+facts.slice(0,8).map(f=>`<div class="drw-row"><div class="ttl">${escHtml(f.key)}</div><div class="meta"><span>${escHtml((f.value||'').slice(0,80))}</span></div></div>`).join('')+'</div>';
}
host.innerHTML=html||'<div class="drw-empty">_none yet_ · MEMORY.md is empty, Sparrow will learn as you go.</div>';
}catch(_){host.innerHTML='<div class="drw-empty">_none yet_ · unable to load /memory</div>';}
}
async function loadPluginsPanel(){
const host=document.getElementById('drw-plugins-list');
try{
const r=await fetch('/plugins');const j=await r.json();
const ps=Array.isArray(j.plugins)?j.plugins:[];
document.getElementById('drw-plugins-count').textContent=ps.length;
if(!ps.length){host.innerHTML='<div class="drw-empty">_none yet_ · try <code>sparrow plugins install</code></div>';return;}
host.innerHTML=ps.map(p=>`<div class="drw-row"><div class="ttl">${escHtml(p.name)}</div><div class="meta"><span>${escHtml(p.version||'')}</span><span>${p.commands||0} cmds</span><span>${p.skills||0} skills</span></div></div>`).join('');
}catch(_){host.innerHTML='<div class="drw-empty">_none yet_ · unable to load /plugins</div>';}
}
async function loadToolsPanel(){
const host=document.getElementById('drw-tools-list');
try{
const r=await fetch('/tools');const j=await r.json();
const ts=Array.isArray(j.tools)?j.tools:[];
document.getElementById('drw-tools-count').textContent=ts.length;
if(!ts.length){host.innerHTML='<div class="drw-empty">_none yet_ · no tools metadata loaded</div>';return;}
host.innerHTML=ts.map(t=>`<div class="drw-row" title="${escHtml(t.toolset||'')}"><div class="ttl">${escHtml(t.name)}</div><div class="meta"><span>${escHtml(t.toolset||'')}</span>${t.exec?'<span style="color:var(--rem)">exec</span>':''}${t.mutates_files?'<span style="color:var(--verifier)">mutates</span>':''}${t.network?'<span>net</span>':''}</div></div>`).join('');
}catch(_){host.innerHTML='<div class="drw-empty">_none yet_ · unable to load /tools</div>';}
}
async function loadPermissionsPanel(){
const host=document.getElementById('drw-permissions-body');
try{
const r=await fetch('/permissions');const j=await r.json();
const p=j.permissions||{};
const tools=p.tools||{};
const paths=p.paths||{};
host.innerHTML=
`<div class="mem-doc"><div class="label">Mode <b>${escHtml(p.mode||'unknown')}</b></div></div>`+
`<div class="mem-doc"><div class="label">Tools allow <b>${(tools.allow||[]).length}</b></div></div>`+
`<div class="mem-doc"><div class="label">Tools deny <b>${(tools.deny||[]).length}</b></div></div>`+
`<div class="mem-doc"><div class="label">Paths deny <b>${(paths.deny||[]).length}</b></div></div>`;
}catch(_){host.innerHTML='<div class="drw-empty">_none yet_ · unable to load /permissions</div>';}
}
async function loadSecurityPanel(){
const host=document.getElementById('drw-security-body');
try{
const r=await fetch('/security');const j=await r.json();
const a=j.audit||{};const findings=Array.isArray(a.findings)?a.findings:[];
const score=a.score??100;
const klass=score>=80?'':score>=50?'warn':'crit';
let html=`<div class="sec-score"><div class="num ${klass}">${score}</div><div style="font-size:11px;color:var(--dim)">/ 100<div style="color:var(--dimmer);font-size:9.5px;letter-spacing:.5px">${findings.filter(f=>(f.severity==='Critical')).length} crit · ${findings.filter(f=>(f.severity==='Warning')).length} warn</div></div></div>`;
html+=findings.slice(0,12).map(f=>{
const kl=f.severity==='Critical'?'crit':f.severity==='Warning'?'warn':'';
return `<div class="sec-finding ${kl}"><div class="cat">${escHtml(f.category||'')}</div><div class="msg">${escHtml(f.message||'')}</div></div>`;
}).join('');
host.innerHTML=html;
}catch(_){host.innerHTML='<div class="drw-empty">_none yet_ · unable to load /security</div>';}
}
async function loadArtifactsPanel(){
const host=document.getElementById('drw-artifacts-list');
try{
const r=await fetch('/artifacts');const j=await r.json();
const its=Array.isArray(j.items)?j.items:[];
document.getElementById('drw-artifacts-count').textContent=its.length;
if(!its.length){host.innerHTML='<div class="drw-empty">_none yet_ · drag a file onto the page to upload.</div>';return;}
host.innerHTML=its.map(it=>`<div class="drw-row" title="${escHtml(it.path||'')}"><div class="ttl">${escHtml(it.name)}</div><div class="meta"><span>${escHtml(it.kind||'file')}</span><span>${Math.round((it.size||0)/1024)} KB</span></div></div>`).join('');
}catch(_){host.innerHTML='<div class="drw-empty">_none yet_ · unable to load /artifacts</div>';}
}
const SWARM_TRIAD=new Set(['planner','coder','verifier']);
const SWARM_MAX_VISIBLE=8;
async function loadSwarmAgents(){
try{
const r=await fetch('/agents');
if(!r.ok)return;
const j=await r.json();
renderSwarmExtras(Array.isArray(j.agents)?j.agents:[]);
}catch(_){}
}
function renderSwarmExtras(agents){
const anchor=document.getElementById('swarm-extras-anchor');
const more=document.getElementById('swarm-more');
if(!anchor||!more)return;
let n=anchor.nextElementSibling;
while(n&&n!==more){const next=n.nextElementSibling;if(n.id&&n.id.startsWith('swarm-extra-'))n.remove();n=next;}
const extras=agents.filter(a=>!SWARM_TRIAD.has(a.name.toLowerCase())&&!SWARM_TRIAD.has((a.role||'').toLowerCase()));
const visible=extras.slice(0,SWARM_MAX_VISIBLE);
const overflow=extras.length-visible.length;
visible.forEach(a=>{
const lane=document.createElement('div');
const colorKey=a.color_key||'steel';
lane.className=`lane ${colorKey} ${a.status==='working'?'working':a.status==='done'?'done':a.status==='error'?'error':'idle idle-card'}`;
lane.id=`swarm-extra-${cssSlug(a.name)}`;
lane.title=`${a.name} — ${a.role||'agent'}${a.description?` · ${a.description}`:''}`;
const head=document.createElement('div');head.className='lane-head';
const who=document.createElement('span');who.className='who';who.textContent=a.name;
const st=document.createElement('span');st.className='st';
const led=document.createElement('span');led.className='led-mini';
const txt=document.createElement('span');txt.setAttribute('data-role-state','');txt.textContent=a.status||'idle';
st.append(led,txt);
head.append(who,st);
const msg=document.createElement('span');msg.className='msg';msg.setAttribute('data-role-msg','');
msg.textContent=(a.msg&&a.msg.length)?a.msg:(a.description||'ready');
lane.append(head,msg);
anchor.parentNode.insertBefore(lane,more);
});
if(overflow>0){
more.hidden=false;
more.textContent=`+${overflow} agent${overflow>1?'s':''}…`;
more.title=extras.slice(SWARM_MAX_VISIBLE).map(a=>a.name).join(', ');
}else{
more.hidden=true;
more.textContent='';
}
}
function cssSlug(s){return String(s).toLowerCase().replace(/[^a-z0-9-]+/g,'-').replace(/^-+|-+$/g,'')||'agent';}
let PALETTE_CMDS=[]; let PALETTE_AGENTS=[]; let PALETTE_FILTERED=[];
let PALETTE_SEL=0;
async function loadCommandsCache(){
try{
const r=await fetch('/commands');const j=await r.json();
PALETTE_CMDS=Array.isArray(j.commands)?j.commands:[];
}catch(_){}
}
async function loadAgentsCache(){
try{
const r=await fetch('/agents');const j=await r.json();
PALETTE_AGENTS=Array.isArray(j.agents)?j.agents:[];
}catch(_){
PALETTE_AGENTS=[
{name:'planner',role:'planner',description:'decomposes the task',status:'idle',msg:'',color_key:'planner'},
{name:'coder',role:'coder',description:'edits files',status:'idle',msg:'',color_key:'coder'},
{name:'verifier',role:'verifier',description:'adversarial review',status:'idle',msg:'',color_key:'verifier'},
];
}
}
async function loadHistoryCache(){
try{
const r=await fetch('/history?limit=80');
const j=await r.json();
HISTORY_INPUTS=Array.isArray(j.inputs)?j.inputs:[];
}catch(_){HISTORY_INPUTS=[]}
}
let selectedModel=localStorage.getItem('sparrow-model')||null;
let modelsCache=null;
let modelsCacheTs=0;
const MODELS_CACHE_TTL_MS=30_000;
function fmtCtxShort(n){if(n>=1000000)return(n/1e6).toFixed(0)+'M';if(n>=1000)return Math.round(n/1000)+'k';return String(n);}
function fmtCost(c){return c===0?'free':'$'+(c).toFixed(2)+'/Mtok';}
function applyModelPickerLabel(){
const lbl=document.getElementById('modelPickerLabel');
if(!lbl)return;
lbl.textContent=selectedModel?selectedModel.replace(/^[^:]+:/,'').slice(0,22):
(document.getElementById('modelPickerLabel').dataset.live||'auto');
}
async function loadModels(force){
const fresh=modelsCache&&(Date.now()-modelsCacheTs<MODELS_CACHE_TTL_MS);
if(!force&&fresh)return modelsCache;
try{const r=await fetch('/models');modelsCache=await r.json();modelsCacheTs=Date.now();}
catch{modelsCache={providers:[]};modelsCacheTs=Date.now();}
return modelsCache;
}
function invalidateModelsCache(){modelsCache=null;modelsCacheTs=0;}
async function toggleModelPicker(e){
e.stopPropagation();
const drop=document.getElementById('modelPickerDrop');
if(drop.style.display!=='none'){drop.style.display='none';return;}
drop.style.display='block';
const data=await loadModels();
const list=document.getElementById('modelPickerList');
const providers=(data.providers||[]).filter(p=>p.models&&p.models.length);
if(!providers.length){list.innerHTML='<div style="padding:12px 14px;color:var(--dimmer);font-size:11px">no providers configured</div>';return;}
const configured=window._configuredProviders||new Set();
list.innerHTML=providers.map(p=>{
const hasKey=configured.has(p.id);
const dot=`<span style="width:6px;height:6px;border-radius:50%;flex:0 0 auto;background:${hasKey?'var(--add)':'var(--dimmer)'};box-shadow:${hasKey?'0 0 5px var(--add)':''}" title="${hasKey?'configured':'no API key'}"></span>`;
return `<div class="mp-section">
<div class="mp-provider" style="display:flex;align-items:center;gap:6px">${dot}${escHtml(p.label)}</div>
${p.models.map(m=>{
const id=p.id+':'+m.name;const sel=selectedModel===id;
return `<div class="mp-item${sel?' selected':''}" data-ctx-tokens="${m.context_window||0}" onclick="setModelOverride('${escHtml(id)}','${escHtml(fmtCtxShort(m.context_window))}',${m.context_window||0})">
<span class="mp-name">${escHtml(m.label||m.name)}</span>
<span class="mp-ctx">${escHtml(fmtCtxShort(m.context_window))}</span>
<span class="mp-cost">${escHtml(m.cost_in===0?'free':'$'+m.cost_in+'/M')}</span>
${m.recommended?'<span class="mp-rec">★</span>':''}
</div>`;
}).join('')}
</div>`;
}).join('');
}
function setModelOverride(id,ctxLabel,ctxTokens){
selectedModel=id;
if(id)localStorage.setItem('sparrow-model',id);
else localStorage.removeItem('sparrow-model');
document.getElementById('modelPickerDrop').style.display='none';
const lbl=document.getElementById('modelPickerLabel');
if(lbl)lbl.textContent=id?id.replace(/^[^:]+:/,'').slice(0,22):'auto';
if(id){
let limit=0;
if(typeof ctxTokens==='number' && ctxTokens>0)limit=ctxTokens;
else if(ctxLabel){
const m=String(ctxLabel).match(/([\d.]+)\s*([kKmM])?/);
if(m){
const n=parseFloat(m[1]);
const u=(m[2]||'').toLowerCase();
limit=u==='m'?n*1_000_000:u==='k'?n*1_000:n;
}
}
if(limit>0){contextLimit=Math.round(limit);updateContextMeter();}
}
}
document.addEventListener('click',e=>{
if(!e.target.closest('#modelPickerBtn')&&!e.target.closest('#modelPickerDrop')){
const d=document.getElementById('modelPickerDrop');if(d)d.style.display='none';
}
});
function paletteOpen(){
document.getElementById('palette').classList.add('open');
const inp=document.getElementById('paletteInput');
const source=document.getElementById('taskInput')?.value||'';
inp.value=source.startsWith('/')?source:'';
PALETTE_SEL=0;
paletteFilter(inp.value);
setTimeout(()=>inp.focus(),20);
}
function paletteClose(){document.getElementById('palette').classList.remove('open')}
function paletteFilter(q){
const norm=q.toLowerCase().replace(/^\//,'');
const items=[];
PALETTE_SEL=0;
PALETTE_CMDS.forEach(c=>{
const display=String(c.name||'').startsWith('/')?String(c.name||''):'/'+String(c.name||'');
const raw=display.replace(/^\//,'');
const name=raw.toLowerCase();
const desc=(c.description||'').toLowerCase();
const usage=(c.usage||'').toLowerCase();
const source=(c.source||'').toLowerCase();
if(!norm||name.includes(norm)||desc.includes(norm)||usage.includes(norm)||source.includes(norm)){
const score=!norm?0:(name.startsWith(norm)?0:name.includes(norm)?1:desc.includes(norm)?2:usage.includes(norm)?3:4);
items.push({kind:c.source||'builtin',label:display,desc:c.description||'',usage:c.usage||'',insert:display+' ',score});
}
});
PALETTE_AGENTS.forEach(a=>{
const name=(a.name||'').toLowerCase();
if(!norm||name.includes(norm)||(a.role||'').toLowerCase().includes(norm)){
const score=!norm?4:(name.startsWith(norm)?4:name.includes(norm)?5:6);
items.push({kind:'agent',label:'@'+(a.name||''),desc:a.description||a.role||'',usage:'Mention this agent in the next task.',insert:'@'+(a.name||'')+' ',score});
}
});
PALETTE_FILTERED=items.sort((a,b)=>(a.score??9)-(b.score??9)||a.label.localeCompare(b.label)).slice(0,40);
PALETTE_SEL=Math.min(PALETTE_SEL,Math.max(0,PALETTE_FILTERED.length-1));
paletteRender();
}
function paletteRender(){
const host=document.getElementById('paletteResults');
if(!PALETTE_FILTERED.length){host.innerHTML='<div class="pempty">No matches.</div>';return;}
host.innerHTML=PALETTE_FILTERED.map((it,i)=>{
const bucket=paletteKindBucket(it.kind);
const usage=it.usage?`<span class="usage">${escHtml(it.usage)}</span>`:'';
return `<div class="res ${bucket} ${i===PALETTE_SEL?'sel':''}" data-i="${i}"><div class="nm">${escHtml(it.label)}</div><div class="src">${escHtml(paletteSourceLabel(it.kind))}</div><div class="desc"><b>${escHtml(it.desc||'Run Sparrow command')}</b>${usage}</div></div>`;
}).join('');
const sel=host.querySelector('.res.sel');
if(sel)sel.scrollIntoView({block:'nearest'});
}
function paletteKindBucket(kind){
const k=String(kind||'builtin').split(':')[0];
return ({builtin:'builtin',user:'builtin',project:'builtin',skill:'skill',plugin:'plugin',agent:'plugin'})[k]||'';
}
function paletteSourceLabel(kind){
const text=String(kind||'builtin');
if(text.startsWith('project:'))return 'project';
if(text.startsWith('user:'))return 'user';
if(text.startsWith('skill:'))return 'skill';
if(text.startsWith('plugin:'))return 'plugin';
return text;
}
function paletteAccept(submit){
const it=PALETTE_FILTERED[PALETTE_SEL];if(!it)return;
const ti=document.getElementById('taskInput');
if(!ti.value||/^[/@]\S*$/.test(ti.value.trim()))ti.value=it.insert;
else ti.value=(ti.value.trimEnd()+' '+it.insert).trimStart();
paletteClose();
ti.focus();
composerInput();
if(submit){const btn=document.getElementById('runBtn');if(btn)btn.click();}
}
document.getElementById('paletteInput').addEventListener('input',e=>{paletteFilter(e.target.value)});
document.getElementById('paletteInput').addEventListener('keydown',e=>{
if(e.key==='ArrowDown'){e.preventDefault();PALETTE_SEL=Math.min(PALETTE_FILTERED.length-1,PALETTE_SEL+1);paletteRender();}
else if(e.key==='ArrowUp'){e.preventDefault();PALETTE_SEL=Math.max(0,PALETTE_SEL-1);paletteRender();}
else if(e.key==='Enter'){e.preventDefault();paletteAccept(e.shiftKey);}
else if(e.key==='Escape'){e.preventDefault();paletteClose();}
});
document.getElementById('paletteResults').addEventListener('click',e=>{
const row=e.target.closest('.res');if(!row)return;
PALETTE_SEL=parseInt(row.dataset.i,10);paletteAccept(e.shiftKey);
});
document.getElementById('palette').addEventListener('click',e=>{if(e.target.id==='palette')paletteClose()});
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA')return;
if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='k'){e.preventDefault();paletteOpen();return;}
if(e.key.toLowerCase()==='d'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){
const dp=document.getElementById('diffPanel');
if(dp){dp.classList.toggle('open');return;}
}
if(e.key.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){
const btn=document.getElementById('modelPickerBtn');
if(btn)btn.click();
}
});
function agentPickerState(input){
const value=input.value;const caret=input.selectionStart??value.length;
const upto=value.slice(0,caret);
const at=upto.lastIndexOf('@');
if(at<0)return null;
if(at>0){
const prev=upto[at-1];if(prev&&!/\s/.test(prev))return null;
}
const frag=upto.slice(at+1);
if(/\s/.test(frag))return null;
return {at,frag};
}
function agentPickerMatches(frag){
const f=frag.toLowerCase();
return PALETTE_AGENTS.filter(a=>(a.name||'').toLowerCase().startsWith(f)).slice(0,6);
}
let PICKER_SEL=0;
function agentPickerShow(){
const inp=document.getElementById('taskInput');
const state=agentPickerState(inp);
const picker=document.getElementById('agentPicker');
if(!state){picker.classList.add('hidden');return;}
const matches=agentPickerMatches(state.frag);
if(!matches.length){picker.classList.add('hidden');return;}
PICKER_SEL=Math.min(PICKER_SEL,matches.length-1);
picker.dataset.matches=JSON.stringify(matches);
picker.dataset.at=String(state.at);
picker.innerHTML='<div class="head">agent picker · '+matches.length+' match'+(matches.length>1?'es':'')+'</div>'+
matches.map((a,i)=>`<div class="opt ${i===PICKER_SEL?'sel':''}" data-i="${i}"><span class="at">@</span><span class="nm">${escHtml(a.name)}</span><span class="role">${escHtml(a.role||'agent')}</span></div>`).join('');
const r=inp.getBoundingClientRect();
picker.style.left=(r.left)+'px';
picker.style.bottom=(window.innerHeight-r.top+6)+'px';
picker.classList.remove('hidden');
}
function agentPickerAccept(){
const picker=document.getElementById('agentPicker');
if(picker.classList.contains('hidden'))return false;
const matches=JSON.parse(picker.dataset.matches||'[]');
const at=parseInt(picker.dataset.at||'-1',10);
const m=matches[PICKER_SEL];if(!m||at<0)return false;
const inp=document.getElementById('taskInput');
const before=inp.value.slice(0,at);
const after=inp.value.slice(inp.selectionStart??inp.value.length);
const tail=after.startsWith(' ')?after:' '+after;
inp.value=before+'@'+m.name+tail;
const newCaret=(before+'@'+m.name+' ').length;
inp.selectionStart=inp.selectionEnd=newCaret;
picker.classList.add('hidden');
inp.focus();
return true;
}
document.getElementById('taskInput').addEventListener('input',()=>{PICKER_SEL=0;agentPickerShow()});
document.getElementById('taskInput').addEventListener('click',()=>agentPickerShow());
document.getElementById('taskInput').addEventListener('keydown',e=>{
const picker=document.getElementById('agentPicker');
const open=!picker.classList.contains('hidden');
if(!open)return;
if(e.key==='ArrowDown'){const m=JSON.parse(picker.dataset.matches||'[]');if(m.length){e.preventDefault();PICKER_SEL=Math.min(m.length-1,PICKER_SEL+1);agentPickerShow();}}
else if(e.key==='ArrowUp'){const m=JSON.parse(picker.dataset.matches||'[]');if(m.length){e.preventDefault();PICKER_SEL=Math.max(0,PICKER_SEL-1);agentPickerShow();}}
else if(e.key==='Enter'||e.key==='Tab'){if(agentPickerAccept())e.preventDefault();}
else if(e.key==='Escape'){picker.classList.add('hidden');}
});
document.getElementById('agentPicker').addEventListener('click',e=>{
const opt=e.target.closest('.opt');if(!opt)return;
PICKER_SEL=parseInt(opt.dataset.i,10);agentPickerAccept();
});
document.addEventListener('click',e=>{
if(!e.target.closest('#taskInput')&&!e.target.closest('#agentPicker'))document.getElementById('agentPicker').classList.add('hidden');
});
let ws;
function connect(){
ws=new WebSocket(`ws://${location.host}/ws`);
ws.onopen=()=>{
const conn=$('conn');if(conn)conn.textContent='live';
const tag=document.getElementById('conn-tag');if(tag){tag.textContent='live';tag.classList.remove('offline');}
$('status').className='status on';$('status').textContent='● live';
loadConfig();
loadSwarmAgents();
loadAgentsCache();
loadCommandsCache();
loadHistoryCache();
injectHero();hydrateHero();
restoreActivePanel();
runBootAnimation();
};
ws.onclose=()=>{const conn=$('conn');if(conn)conn.textContent='reconnecting…';const tag=document.getElementById('conn-tag');if(tag){tag.textContent='offline';tag.classList.add('offline');}$('status').className='status off';$('status').textContent='● disconnected';setTimeout(connect,2000)};
ws.onmessage=(e)=>{try{handleEvent(JSON.parse(e.data))}catch(err){}};
}
injectHero();
restoreActivePanel();
connect();
const TOOL_CARDS=new Map(); function openToolCard(ev){
const id=ev.id||ev.tool_use_id||ev.run||cryptoUid();
const argsStr=ev.args?(typeof ev.args==='string'?ev.args:JSON.stringify(ev.args,null,2)):'';
const meaningfulArgs=argsStr && argsStr!=='{}' && argsStr.trim().length>2;
let d=TOOL_CARDS.get(id);
if(d){
if(meaningfulArgs){
let det=d.querySelector('.det.tc-args');
if(!det){
det=document.createElement('div');det.className='det tc-args';
det.innerHTML='<div class="lbl">arguments</div><code></code>';
d.appendChild(det);
}
det.querySelector('code').textContent=argsStr;
if(ev.name && d.querySelector('.nm'))d.querySelector('.nm').textContent=ev.name;
if(typeof VERBOSE!=='undefined' && VERBOSE)d.open=true;
}
return;
}
d=document.createElement('details');
d.className='tool-card running';
d.open=!!(typeof VERBOSE!=='undefined' && VERBOSE);
d.dataset.tokBaseIn=actualInV+estInV;
d.dataset.tokBaseOut=actualOutV+estOutV;
const ico=(typeof toolIcon==='function')?toolIcon(ev.name||''):'⚒';
d.innerHTML='<summary><span class="chev">▸</span><span class="tool-ico">'+ico+'</span><span class="nm">'+esc(ev.name||'tool')+'</span><span class="ok" data-status>running…</span></summary>'+
(meaningfulArgs?'<div class="det tc-args"><div class="lbl">arguments</div><code>'+esc(argsStr)+'</code></div>':'');
term.appendChild(d);TOOL_CARDS.set(id,d);
term.scrollTop=term.scrollHeight;
}
function inferToolSummary(name,output){
if(!output)return '';
const s=typeof output==='string'?output:JSON.stringify(output);
const kb=Math.round(s.length/1024);
const sizeStr=kb>=1?`${kb} KB`:`${s.length} B`;
if(/^fs_read|^read|^fs_list|^glob/i.test(name||'')){
const fileCount=(s.match(/^[─#├└│]|^\s*[a-zA-Z0-9_.\-/\\]+\.\w+/gm)||[]).length;
return fileCount?`${fileCount} file${fileCount>1?'s':''} · ${sizeStr}`:sizeStr;
}
if(/^search|^web/i.test(name||'')){
const lines=s.split('\n').length;return `${lines} hits · ${sizeStr}`;
}
return sizeStr;
}
function closeToolCard(ev){
const id=ev.id||ev.tool_use_id||ev.run||'';
let d=TOOL_CARDS.get(id);
if(!d){const cards=term.querySelectorAll('.tool-card');d=cards[cards.length-1];}
if(!d)return;
const isErr=ev.is_error||ev.error;
d.classList.remove('running');d.classList.add(isErr?'error':'done');
const st=d.querySelector('[data-status]');
let outRaw='';
if(ev.output!==undefined)outRaw=ev.output;
else if(ev.text)outRaw=ev.text;
else if(Array.isArray(ev.blocks)){
outRaw=ev.blocks.map(b=>{
if(typeof b==='string')return b;
if(b&&typeof b==='object'){
if(typeof b.Text==='string')return b.Text;
if(typeof b.text==='string')return b.text;
if(b.Json!==undefined)return JSON.stringify(b.Json,null,2);
if(b.Diff)return `[diff ${b.Diff.file||''}]\n${b.Diff.patch||''}`;
if(b.Image)return '[image]';
return JSON.stringify(b);
}
return String(b);
}).filter(Boolean).join('\n');
}
const summary=isErr?'✗ error':(inferToolSummary(d.querySelector('.nm')?.textContent,outRaw)||'✓ done');
if(st){
const baseIn=parseInt(d.dataset.tokBaseIn||'0',10);
const baseOut=parseInt(d.dataset.tokBaseOut||'0',10);
const dIn=actualInV+estInV-baseIn;
const dOut=actualOutV+estOutV-baseOut;
const tokHtml=(dIn>0||dOut>0)
?'<span class="inline-tok">'+(dIn>0?'<span class="up-c">↑'+dIn.toLocaleString('en-US')+'</span>':'')+(dOut>0?'<span class="dn-c">↓'+dOut.toLocaleString('en-US')+'</span>':'')+'</span>'
:'';
st.innerHTML=summary+tokHtml;st.style.color=isErr?'var(--rem)':'var(--add)';
}
const out=outRaw;
if(out){
let det=d.querySelector('.det.tc-result');
if(!det){
det=document.createElement('div');det.className='det tc-result';
d.appendChild(det);
}
const outStr=typeof out==='string'?out:JSON.stringify(out,null,2);
const truncated=outStr.length>1200?outStr.slice(0,1200)+'\n… ['+ (outStr.length-1200) +' more chars]':outStr;
det.innerHTML='<div class="lbl" style="margin-top:8px">result</div><code>'+esc(truncated)+'</code>';
}
TOOL_CARDS.delete(id);
}
function parseDiffHunks(patch){
const lines=String(patch||'').split(/\r?\n/);
const hunks=[];let current=null;
lines.forEach(line=>{
if(/^@@/.test(line)){
current={header:line,lines:[]};
hunks.push(current);
return;
}
if(!current){
current={header:'@@',lines:[]};
hunks.push(current);
}
current.lines.push(line);
});
return hunks.length?hunks:[{header:'@@',lines:[]}];
}
function diffLineHtml(line,lineState){
let kls='ctx';
if(/^\+/.test(line)){kls='pls';lineState.lineNum++;}
else if(/^-/.test(line)){kls='mns';}
else if(line.length){lineState.lineNum++;}
return '<span class="dl '+kls+'"><span class="num">'+(kls==='mns'?'':lineState.lineNum)+'</span>'+esc(line)+'</span>';
}
function renderHunkedDiff(patch,opts){
const options=opts||{};
const hunkControls=options.controls!==false;
const lineState={lineNum:0};
return parseDiffHunks(patch).map((hunk,idx)=>{
const lines=hunk.lines.map(line=>diffLineHtml(line,lineState)).join('\n');
const controls=hunkControls
? `<span class="hunk-actions"><button class="accept" data-hunk-action="accept" data-hunk="${idx}">accept</button><button class="reject" data-hunk-action="reject" data-hunk="${idx}">reject</button></span>`
: '';
return `<div class="hunk" data-hunk="${idx}" data-state="pending"><div class="hunk-head"><span class="meta">${esc(hunk.header||'@@')}</span>${controls}</div>${lines}</div>`;
}).join('');
}
function bindHunkControls(root){
root.querySelectorAll('[data-hunk-action]').forEach(btn=>{
btn.addEventListener('click',()=>{
const hunk=btn.closest('.hunk');
if(!hunk)return;
const action=btn.dataset.hunkAction;
hunk.dataset.state=action==='accept'?'accepted':'rejected';
btn.closest('.hunk-actions')?.querySelectorAll('button').forEach(b=>b.disabled=true);
});
});
}
function openDiffPanel(ev){
const path=ev.file||ev.path||'<unknown>';
const patch=ev.patch||ev.diff||'';
let pl=0,mn=0;
patch.split(/\r?\n/).forEach(l=>{if(/^\+[^+]/.test(l))pl++;else if(/^-[^-]/.test(l))mn++;});
const dpPath=document.getElementById('dpPath');
dpPath.textContent=path;
dpPath.style.cursor='pointer';dpPath.title='Click to view full file';
dpPath.onclick=()=>loadFileInPanel(path);
document.getElementById('dpStats').textContent=`+${pl} −${mn}`;
const body=document.getElementById('dpBody');
body.innerHTML=`<div class="diff-card" style="border:0;border-radius:0;margin:0;background:transparent"><pre>${renderHunkedDiff(patch,{controls:true})}</pre></div>`;
bindHunkControls(body);
document.getElementById('diffPanel').classList.add('open');
}
function closeDiffPanel(){document.getElementById('diffPanel').classList.remove('open');}
async function loadFileInPanel(path){
const body=document.getElementById('dpBody');
const stats=document.getElementById('dpStats');
body.innerHTML='<div style="padding:14px;color:var(--dimmer)">loading…</div>';
stats.textContent='';
try{
const r=await fetch('/file?path='+encodeURIComponent(path));
if(!r.ok){body.innerHTML='<div style="padding:14px;color:var(--rem)">cannot read file (outside workdir or not found)</div>';return;}
const j=await r.json();
const lines=j.content.split('\n');
stats.textContent=`${j.lines} lines · ${j.lang}`;
body.innerHTML='<div style="font-family:inherit">'+
lines.map((l,i)=>`<span style="display:flex;min-height:1.6em"><span style="display:inline-block;width:38px;padding:0 8px;color:var(--dimmer);user-select:none;font-size:10px;text-align:right;border-right:1px solid var(--line);flex:0 0 auto">${i+1}</span><span style="padding:0 10px;color:var(--dim);white-space:pre;overflow-x:auto">${esc(l)}</span></span>`).join('')+
'</div>';
document.getElementById('diffPanel').classList.add('open');
}catch(e){body.innerHTML='<div style="padding:14px;color:var(--rem)">fetch failed: '+esc(String(e))+'</div>';}
}
function renderDiffCard(ev){
const path=ev.file||ev.path||'<unknown>';
const patch=ev.patch||ev.diff||'';
const applied=(ev.type==='DiffApplied');
let pl=0,mn=0;patch.split(/\r?\n/).forEach(l=>{if(/^\+[^+]/.test(l)||l.startsWith('+ '))pl++;else if(/^-[^-]/.test(l)||l.startsWith('- '))mn++;});
const card=document.createElement('div');card.className='diff-card';
card.innerHTML='<div class="h">◇ <span class="p">'+esc(path)+'</span><span class="m"><span class="pl">+'+pl+'</span> <span class="mn">−'+mn+'</span> · '+(applied?'<span style="color:var(--add)">applied</span>':'proposed')+'</span></div><pre>'+renderHunkedDiff(patch,{controls:true})+'</pre>';
bindHunkControls(card);
term.appendChild(card);
term.scrollTop=term.scrollHeight;
if(applied)chirp(1500,.06);
}
function renderCompactBanner(ev){
const before=ev.before_chars||0,after=ev.after_chars||0;
const path=ev.handoff_path||'';
const banner=document.createElement('div');banner.className='compact-banner';
banner.innerHTML='<span class="g">⟳</span><span>context compacted · <b>'+before.toLocaleString('fr-FR').replace(/,/g,' ')+' → '+after.toLocaleString('fr-FR').replace(/,/g,' ')+' chars</b> · handoff written</span>'+
(path?'<a href="#" title="'+escAttr(path)+'">'+esc(path.split(/[/\\\\]/).pop()||'handoff')+' →</a>':'');
term.appendChild(banner);term.scrollTop=term.scrollHeight;
chirp(1200,.07);setTimeout(()=>chirp(800,.06),100);
}
let CHECKPOINT_TIMELINE=null;
let runCheckpointIds=[];
function addCheckpointNode(label,id){
if(!CHECKPOINT_TIMELINE||!CHECKPOINT_TIMELINE.isConnected){
CHECKPOINT_TIMELINE=document.createElement('div');
CHECKPOINT_TIMELINE.className='checkpoint-timeline';
const hint=document.createElement('span');hint.style.cssText='font-size:9px;color:var(--dimmer);letter-spacing:.7px;text-transform:uppercase;margin-right:4px';hint.textContent='checkpoints';
CHECKPOINT_TIMELINE.prepend(hint);
term.appendChild(CHECKPOINT_TIMELINE);
}
if(id)runCheckpointIds.push(id);
const node=document.createElement('span');
node.className='checkpoint-node';
node.title=(label||'checkpoint')+(id?` · click to rewind (${id.slice(0,8)}…)`:'');
node.style.cursor=id?'pointer':'default';
if(id){
node.addEventListener('click',()=>{
if(confirm(`Rewind to checkpoint: ${label||id}?`)){
const input=document.getElementById('taskInput');
if(input){input.value=`/rewind ${id}`;document.getElementById('runBtn')?.click();}
}
});
}
CHECKPOINT_TIMELINE.appendChild(node);
term.scrollTop=term.scrollHeight;
}
function showError(title,cause,fix,doc){
const banner=document.createElement('div');
banner.className='error-banner';
banner.innerHTML='<div class="title">'+esc(title||'Error')+'</div>'+
'<div>'+esc(cause||'Something went wrong.')+'</div>'+
(fix?'<div class="fix">'+esc(fix)+(doc?' · <a href="'+escAttr(doc)+'">docs</a>':'')+'</div>':'');
term.appendChild(banner);
term.scrollTop=term.scrollHeight;
}
let STREAM_BUF=null;
let STREAM_STATE=null;
function _cleanStreamChunk(text){
if(!text)return text;
if(text.indexOf('<tool_call')!==-1||text.indexOf('<function')!==-1
||text.indexOf('\x1b[')!==-1||text.indexOf('\\033[')!==-1){
return null; }
return text;
}
function ensureStreamRoot(){
if(STREAM_BUF&&STREAM_BUF.isConnected)return;
STREAM_BUF=document.createElement('div');
STREAM_BUF.className='ln streaming-line';
term.appendChild(STREAM_BUF);
STREAM_STATE={mode:'prose',el:null,cardEl:null,lang:'',codeStarted:false,pending:''};
openProseSpan();
}
function openProseSpan(){
const wrap=document.createElement('span');wrap.className='prose-wrap';
wrap.style.cssText='display:block;position:relative;width:100%;margin:0';
const span=document.createElement('span');
span.className='cmd streaming md-prose';
span.style.whiteSpace='pre-wrap';
wrap.appendChild(span);
const copyBtn=document.createElement('button');
copyBtn.className='prose-copy';
copyBtn.type='button';
copyBtn.title='Copy this response';
copyBtn.textContent='⧉ copy';
copyBtn.style.cssText='position:absolute;top:-2px;right:-2px;font-family:inherit;font-size:9.5px;padding:1px 7px;border-radius:5px;cursor:pointer;color:var(--dimmer);background:color-mix(in srgb,var(--panel) 88%,transparent);border:1px solid var(--line);opacity:0;transition:.15s';
copyBtn.addEventListener('click',()=>{
navigator.clipboard.writeText(STREAM_STATE?.proseRaw||span.textContent||'');
copyBtn.textContent='copied ✓';copyBtn.style.color='var(--add)';
setTimeout(()=>{copyBtn.textContent='⧉ copy';copyBtn.style.color='';},1400);
});
wrap.appendChild(copyBtn);
wrap.addEventListener('mouseenter',()=>{copyBtn.style.opacity='1';});
wrap.addEventListener('mouseleave',()=>{copyBtn.style.opacity='0';});
STREAM_BUF.appendChild(wrap);
STREAM_STATE.el=span;
STREAM_STATE.cardEl=null;
STREAM_STATE.mode='prose';
STREAM_STATE.proseRaw='';
}
const _EMOJI_MAP={
':tada:':'🎉',':bug:':'🐛',':check:':'✅',':white_check_mark:':'✅',
':x:':'❌',':warning:':'⚠️',':rocket:':'🚀',':zap:':'⚡',':fire:':'🔥',
':sparkles:':'✨',':bulb:':'💡',':wrench:':'🔧',':hammer:':'🔨',
':lock:':'🔒',':unlock:':'🔓',':key:':'🔑',':book:':'📖',':pencil:':'✏️',
':star:':'⭐',':heart:':'❤️',':100:':'💯',':eyes:':'👀',':rotating_light:':'🚨'
};
function _markdownify(src){
if(!src)return '';
let s=String(src);
s=s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
s=s.replace(/:([a-z_0-9]+):/gi,(m,k)=>_EMOJI_MAP[m.toLowerCase()]||m);
const lines=s.split('\n');
const out=[];
let i=0;
while(i<lines.length){
const ln=lines[i];
if(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(ln)){
out.push('<hr class="md-hr">');i++;continue;
}
const h=ln.match(/^(#{1,6})\s+(.*)$/);
if(h){
const lvl=h[1].length;
out.push(`<div class="md-h md-h${lvl}">${_inlineMd(h[2])}</div>`);
i++;continue;
}
if(/^\s*>\s?/.test(ln)){
const bq=[];while(i<lines.length && /^\s*>\s?/.test(lines[i])){bq.push(lines[i].replace(/^\s*>\s?/,''));i++;}
out.push(`<blockquote class="md-bq">${_inlineMd(bq.join('\n'))}</blockquote>`);
continue;
}
if(/\|/.test(ln) && i+1<lines.length && /^\s*\|?\s*:?-{2,}/.test(lines[i+1])){
const headers=ln.replace(/^\s*\||\|\s*$/g,'').split('|').map(c=>c.trim());
i+=2;
const rows=[];
while(i<lines.length && /\|/.test(lines[i])){
rows.push(lines[i].replace(/^\s*\||\|\s*$/g,'').split('|').map(c=>c.trim()));
i++;
}
let t='<table class="md-tbl"><thead><tr>';
for(const h of headers)t+=`<th>${_inlineMd(h)}</th>`;
t+='</tr></thead><tbody>';
for(const r of rows){
t+='<tr>';
for(const c of r)t+=`<td>${_inlineMd(c)}</td>`;
t+='</tr>';
}
t+='</tbody></table>';
out.push(t);
continue;
}
if(/^\s*[-*+]\s+/.test(ln)){
const items=[];
while(i<lines.length && /^\s*[-*+]\s+/.test(lines[i])){
items.push(lines[i].replace(/^\s*[-*+]\s+/,''));i++;
}
out.push(`<ul class="md-ul">${items.map(x=>`<li>${_inlineMd(x)}</li>`).join('')}</ul>`);
continue;
}
if(/^\s*\d+\.\s+/.test(ln)){
const items=[];
while(i<lines.length && /^\s*\d+\.\s+/.test(lines[i])){
items.push(lines[i].replace(/^\s*\d+\.\s+/,''));i++;
}
out.push(`<ol class="md-ol">${items.map(x=>`<li>${_inlineMd(x)}</li>`).join('')}</ol>`);
continue;
}
out.push(_inlineMd(ln));
i++;
}
return out.join('\n');
}
function _inlineMd(s){
if(!s)return '';
s=s.replace(/`([^`\n]+)`/g,'<code class="md-ic">$1</code>');
s=s.replace(/\*\*\*([^*\n]+)\*\*\*/g,'<strong><em>$1</em></strong>');
s=s.replace(/\*\*([^*\n]+)\*\*/g,'<strong>$1</strong>');
s=s.replace(/__([^_\n]+)__/g,'<strong>$1</strong>');
s=s.replace(/(^|\W)\*([^*\n]+)\*/g,'$1<em>$2</em>');
s=s.replace(/(^|\W)_([^_\n]+)_/g,'$1<em>$2</em>');
s=s.replace(/~~([^~\n]+)~~/g,'<del>$1</del>');
s=s.replace(/\[([^\]\n]+)\]\((https?:[^)\s]+)\)/g,'<a href="$2" target="_blank" rel="noreferrer">$1</a>');
s=s.replace(/\[ \]/g,'<span class="md-cb">☐</span>');
s=s.replace(/\[x\]/gi,'<span class="md-cb done">☑</span>');
return s;
}
function _flushProseSync(){
if(STREAM_STATE && STREAM_STATE.mode==='prose' && STREAM_STATE.el){
const raw=STREAM_STATE.proseRaw||'';
if(!raw.trim()){
const wrap=STREAM_STATE.el.closest?.('.prose-wrap')||STREAM_STATE.el.parentElement;
if(wrap&&wrap.parentElement)wrap.remove();
return;
}
STREAM_STATE.el.innerHTML=_markdownify(raw);
}
}
function normalizeCodeLang(lang){
const l=String(lang||'').trim().toLowerCase();
const aliases={rs:'rust',py:'python',js:'javascript',jsx:'javascript',ts:'typescript',tsx:'typescript',sh:'bash',shell:'bash',zsh:'bash',ps1:'powershell',yml:'yaml',htm:'html'};
return aliases[l]||l||'text';
}
function spanToken(cls,text){return `<span class="${cls}">${text}</span>`;}
function highlightCode(raw,lang){
const normalized=normalizeCodeLang(lang);
const source=String(raw||'');
const placeholders=[];
const stash=(cls,value)=>{
const id=`\u0000${placeholders.length}\u0000`;
placeholders.push(spanToken(cls,esc(value)));
return id;
};
let s=source;
if(['rust','javascript','typescript','css'].includes(normalized)){
s=s.replace(/\/\*[\s\S]*?\*\//g,m=>stash('syn-com',m));
s=s.replace(/\/\/[^\n]*/g,m=>stash('syn-com',m));
}else if(['python','bash','powershell','yaml'].includes(normalized)){
s=s.replace(/(^|[\s;])#[^\n]*/g,m=>stash('syn-com',m));
}else if(normalized==='html'){
s=s.replace(/<!--[\s\S]*?-->/g,m=>stash('syn-com',m));
}
s=s.replace(/`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g,m=>stash('syn-str',m));
s=esc(s);
const mark=(cls,value)=>{
const id=`\u0000${placeholders.length}\u0000`;
placeholders.push(spanToken(cls,value));
return id;
};
s=s.replace(/\b(0x[\da-fA-F]+|\d+(?:\.\d+)?)\b/g,m=>mark('syn-num',m));
if(normalized==='rust'){
s=s.replace(/\b([a-zA-Z_]\w*)\b/g,(m,word,off,str)=>{
if(/\s*\(/.test(str.slice(off+m.length,off+m.length+3)))return mark('syn-fn',m);
if(/^(fn|let|mut|pub|crate|mod|impl|trait|struct|enum|use|where|async|await|match|if|else|for|while|loop|return|Self|self|super|const|static|move|ref|type|unsafe|dyn)$/.test(word))return mark('syn-key',m);
if(/^(String|Vec|Option|Result|HashMap|PathBuf|Arc|Box|usize|u64|i64|bool|str)$/.test(word))return mark('syn-type',m);
return m;
});
}else if(['javascript','typescript'].includes(normalized)){
s=s.replace(/\.([a-zA-Z_$][\w$]*)/g,(_,p)=>'.'+mark('syn-prop',p));
s=s.replace(/\b([a-zA-Z_$][\w$]*)\b/g,(m,word,off,str)=>{
if(/\s*\(/.test(str.slice(off+m.length,off+m.length+3)))return mark('syn-fn',m);
if(/^(const|let|var|function|return|async|await|import|export|from|class|extends|new|if|else|for|while|switch|case|break|try|catch|throw|typeof|interface|type|public|private)$/.test(word))return mark('syn-key',m);
return m;
});
}else if(normalized==='python'){
s=s.replace(/\b([a-zA-Z_]\w*)\b/g,(m,word,off,str)=>{
if(/\s*\(/.test(str.slice(off+m.length,off+m.length+3)))return mark('syn-fn',m);
if(/^(def|class|return|import|from|as|async|await|if|elif|else|for|while|try|except|finally|with|lambda|yield|True|False|None|self|pass|break|continue|raise|in|is|and|or|not)$/.test(word))return mark('syn-key',m);
return m;
});
}else if(['bash','powershell'].includes(normalized)){
s=s.replace(/(\$[A-Za-z_][\w]*)/g,m=>mark('syn-prop',m));
s=s.replace(/\b(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|export|set|param|foreach|where|select)\b/g,m=>mark('syn-key',m));
}else if(normalized==='yaml'){
s=s.replace(/^(\s*[\w.-]+)(:)/gm,(_,k,op)=>mark('syn-prop',k)+mark('syn-op',op));
}else if(normalized==='json'){
s=s.replace(/\b(true|false|null)\b/g,m=>mark('syn-key',m));
}else if(normalized==='html'){
s=s.replace(/(<\/?)([a-zA-Z][\w:-]*)/g,(_,p,t)=>p+mark('syn-key',t));
s=s.replace(/\s([a-zA-Z_:][\w:.-]*)(=)/g,(_,a,op)=>' '+mark('syn-prop',a)+mark('syn-op',op));
}else if(normalized==='css'){
s=s.replace(/([.#]?[a-zA-Z_-][\w-]*)(\s*\{)/g,(_,sel,op)=>mark('syn-prop',sel)+op);
s=s.replace(/([a-zA-Z-]+)(\s*:)/g,(_,p,op)=>mark('syn-key',p)+mark('syn-op',op));
}
return s.replace(/\u0000(\d+)\u0000/g,(_,i)=>placeholders[Number(i)]||'');
}
function applyCodeHighlight(card){
const codeEl=card?.querySelector('code');
if(!codeEl)return;
const raw=codeEl.textContent||'';
const lang=card.dataset.lang||card.querySelector('.cc-lang')?.textContent||'text';
codeEl.innerHTML=highlightCode(raw,lang);
codeEl.className=`language-${normalizeCodeLang(lang)}`;
}
function openCodeCard(){
_flushProseSync();
const d=document.createElement('details');
d.className='code-card streaming-code';
d.open=true; d.innerHTML='<summary><span class="cc-icon">▸</span>'+
'<span class="cc-lang">code</span>'+
'<span class="cc-meta live" style="color:var(--brand)">streaming…</span>'+
'<button class="cc-copy" onclick="navigator.clipboard.writeText(this.closest(\'.code-card\').querySelector(\'code\').textContent);this.textContent=\'copied ✓\'">copy</button>'+
'</summary><pre><code></code></pre>';
STREAM_BUF.appendChild(d);
STREAM_STATE.el=d.querySelector('code');
STREAM_STATE.cardEl=d;
STREAM_STATE.lang='';
STREAM_STATE.codeStarted=false; STREAM_STATE.mode='code';
}
function closeCodeCard(){
const card=STREAM_STATE.cardEl;
if(card){
card.classList.remove('streaming-code');
applyCodeHighlight(card);
const meta=card.querySelector('.cc-meta');
if(meta){
const codeEl=card.querySelector('code');
const lc=(codeEl?codeEl.textContent:'').split('\n').filter(l=>l.length).length;
meta.textContent=`${lc} lines`;
meta.style.color='';
}
card.open=false; }
openProseSpan();
}
function appendToActive(text){
if(!text)return;
if(STREAM_STATE.mode==='code' && !STREAM_STATE.codeStarted){
const nl=text.indexOf('\n');
if(nl===-1){
STREAM_STATE.lang+=text;
return;
}
STREAM_STATE.lang+=text.slice(0,nl);
const normalizedLang=normalizeCodeLang(STREAM_STATE.lang);
const langEl=STREAM_STATE.cardEl?.querySelector('.cc-lang');
if(langEl && STREAM_STATE.lang.trim())langEl.textContent=normalizedLang;
if(STREAM_STATE.cardEl)STREAM_STATE.cardEl.dataset.lang=normalizedLang;
STREAM_STATE.codeStarted=true;
text=text.slice(nl+1);
if(!text)return;
}
if(STREAM_STATE.mode==='code'){
STREAM_STATE.el.textContent+=text;
return;
}
STREAM_STATE.proseRaw=(STREAM_STATE.proseRaw||'')+text;
if(!STREAM_STATE._scheduled){
STREAM_STATE._scheduled=true;
requestAnimationFrame(()=>{
if(!STREAM_STATE)return;
STREAM_STATE._scheduled=false;
if(STREAM_STATE.mode!=='prose'||!STREAM_STATE.el)return;
STREAM_STATE.el.innerHTML=_markdownify(STREAM_STATE.proseRaw||'');
});
}
}
function streamDelta(text){
const cleaned=_cleanStreamChunk(String(text||''));
if(cleaned===null){
ensureStreamRoot();
if(STREAM_STATE && STREAM_STATE.el && !STREAM_STATE.el.querySelector('.tk-stall')){
const s=document.createElement('span');s.className='tk-stall';s.textContent='⏳';s.style.opacity='.55';
STREAM_STATE.el.appendChild(s);
}
term.scrollTop=term.scrollHeight;
return;
}
text=cleaned;
ensureStreamRoot();
let buf=STREAM_STATE.pending+text;
STREAM_STATE.pending='';
while(buf.length>0){
const idx=buf.indexOf('```');
if(idx===-1){
const tail=buf.match(/(`{1,2})$/);
if(tail){
STREAM_STATE.pending=tail[1];
buf=buf.slice(0,buf.length-tail[1].length);
}
appendToActive(buf);
break;
}
if(idx>0)appendToActive(buf.slice(0,idx));
buf=buf.slice(idx+3); if(STREAM_STATE.mode==='prose')openCodeCard();
else closeCodeCard();
}
term.scrollTop=term.scrollHeight;
}
function cryptoUid(){return Math.random().toString(36).slice(2,10)}
function finalizeStreamBlock(buf){
if(!buf)return;
_flushProseSync();
buf.querySelectorAll('.streaming').forEach(s=>s.classList.remove('streaming'));
const card=buf.querySelector('.streaming-code');
if(card){
card.classList.remove('streaming-code');
const meta=card.querySelector('.cc-meta');
if(meta){
const codeEl=card.querySelector('code');
const lc=(codeEl?codeEl.textContent:'').split('\n').filter(l=>l.length).length;
meta.textContent=`${lc} lines · partial`;
meta.style.color='';
}
}
STREAM_STATE=null;
}
const STREAM_TRANSPARENT = new Set([
'ThinkingDelta',
'CostUpdate',
'TokenUsage',
'TokenUsageEstimated',
'AgentStatus',
'RouteSelected',
'Message',
]);
let VERBOSE=localStorage.getItem('sparrow-verbose')==='1';
function setVerbose(on){
VERBOSE=!!on;
localStorage.setItem('sparrow-verbose',VERBOSE?'1':'0');
const btn=document.getElementById('verboseBtn');
if(btn){
btn.classList.toggle('solid',VERBOSE);
btn.setAttribute('aria-pressed',VERBOSE?'true':'false');
btn.style.color=VERBOSE?'#fff':'';
btn.style.background=VERBOSE?'linear-gradient(180deg,var(--brand),var(--coral))':'';
btn.style.borderColor=VERBOSE?'var(--brand)':'';
btn.style.boxShadow=VERBOSE?'0 0 12px color-mix(in srgb,var(--brand) 45%,transparent)':'none';
btn.textContent=VERBOSE?'⊕ verbose ON':'⊕ verbose';
}
document.body.classList.toggle('verbose-on',VERBOSE);
if(VERBOSE && typeof toast==='function')toast('verbose mode ON · tool args + token ticks visible','ok');
else if(on===false && document.getElementById('verboseBtn') && typeof toast==='function')toast('verbose mode OFF','info');
}
{
const btn=document.getElementById('verboseBtn');
if(btn){
btn.addEventListener('click',()=>setVerbose(!VERBOSE));
if(VERBOSE){
btn.classList.add('solid');
btn.setAttribute('aria-pressed','true');
btn.style.color='#fff';
btn.style.background='linear-gradient(180deg,var(--brand),var(--coral))';
btn.style.borderColor='var(--brand)';
btn.style.boxShadow='0 0 12px color-mix(in srgb,var(--brand) 45%,transparent)';
btn.textContent='⊕ verbose ON';
document.body.classList.add('verbose-on');
}
}
}
function verboseLine(text,cls){
if(!VERBOSE)return;
line(`<span class="muted" style="font-size:10px;opacity:.85">${cls?`<span class="${cls}">`:''}∙ ${esc(text)}${cls?'</span>':''}</span>`);
}
let VERBOSE_TICK_ROW=null;
let VERBOSE_TICK_TOTAL_IN=0;
let VERBOSE_TICK_TOTAL_OUT=0;
let VERBOSE_TICK_RAF=null;
function pushVerboseTick(input,output){
if(!VERBOSE)return;
VERBOSE_TICK_TOTAL_IN+=input||0;
VERBOSE_TICK_TOTAL_OUT+=output||0;
if(VERBOSE_TICK_RAF)return;
VERBOSE_TICK_RAF=requestAnimationFrame(()=>{
VERBOSE_TICK_RAF=null;
if(!VERBOSE_TICK_ROW||!VERBOSE_TICK_ROW.isConnected){
VERBOSE_TICK_ROW=document.createElement('div');
VERBOSE_TICK_ROW.className='ln verbose-tick';
term.appendChild(VERBOSE_TICK_ROW);
}
VERBOSE_TICK_ROW.innerHTML=`∙ streamed estimate · <b>${VERBOSE_TICK_TOTAL_IN}</b> in · <b>${VERBOSE_TICK_TOTAL_OUT}</b> out`;
term.scrollTop=term.scrollHeight;
});
}
let LAST_AGENT_LINE=null;
let LAST_ACTIVITY=null;
function stripActiveDecorations(){
if(LAST_AGENT_LINE){
LAST_AGENT_LINE.querySelectorAll('.caret,.agent-activity,.sp').forEach(e=>e.remove());
LAST_AGENT_LINE=null;
}
if(LAST_ACTIVITY){LAST_ACTIVITY.remove();LAST_ACTIVITY=null;}
}
function handleEvent(ev){
if(STREAM_BUF&&!STREAM_TRANSPARENT.has(ev.type)){
const s=STREAM_BUF.querySelector('.streaming');if(s)s.classList.remove('streaming');
finalizeStreamBlock(STREAM_BUF);
STREAM_BUF=null;
}
switch(ev.type){
case 'RunStarted':{
const turnCount=parseInt(localStorage.getItem('sparrow-turn-count')||'0',10)+1;
localStorage.setItem('sparrow-turn-count',String(turnCount));
if(turnCount===1||turnCount%5===1){
line(BIRD,'bird');
line('<span class="tagline" style="color:var(--dim);font-size:12px;letter-spacing:1px;margin-bottom:6px"><b style="background:linear-gradient(90deg,var(--brand),var(--coral));-webkit-background-clip:text;background-clip:text;color:transparent;font-weight:700;letter-spacing:2px">SPARROW</b> — one cli · grows with you</span>');
}
line(`<span class="acc">▸ started</span>${turnCount>1?` <span style="color:var(--add);font-size:10px;margin-left:8px">● context retained · turn #${turnCount}</span>`:''}`);
resetRunMetrics();resetSwarm();runStartMs=Date.now();runDiffsApplied=0;runCheckpoints=0;runCheckpointIds=[];CHECKPOINT_TIMELINE=null;
VERBOSE_TICK_ROW=null; VERBOSE_TICK_TOTAL_IN=0; VERBOSE_TICK_TOTAL_OUT=0;
LAST_AGENT_LINE=null;
setSwarm('planner','thinking','classifying request and selecting route');ensureTokenMeter();break;
}
case 'RouteSelected':{
const chain=summarizeChain(ev.chain);
const compact=compactChain(ev.chain);
setRoute(chain);
setSwarm('planner','done','route selected · '+compact);
line(`<span class="muted">↳ route: <span class="planner">${esc(chain)}</span></span>`);
if(ev.context_window&&ev.context_window>0){contextLimit=ev.context_window;updateContextMeter();}
const primary=Array.isArray(ev.chain)&&ev.chain[0]?ev.chain[0]:'auto';
const lbl=$('modelPickerLabel');if(lbl)lbl.textContent=primary.length>22?primary.slice(0,20)+'…':primary;
updateRoutePanel(ev.chain,ev.context_window);
break;
}
case 'ModelSwitched':line(`<span class="warn">${routeSwitchLabel(ev.from,ev.to,ev.reason)}</span> <span style="color:var(--add);font-size:10px;margin-left:6px">● context preserved</span>`);break;
case 'Message':
if(ev.role==='router'){
if(ev.text&&!ev.text.startsWith('requete:')){
line('<span class="muted">↳ </span><span class="planner">'+esc(ev.text)+'</span>');
}
}
else{line('<span class="muted">'+esc(ev.role)+':</span> <span class="cmd">'+esc(ev.text)+'</span>')}
break;
case 'ThinkingDelta':streamDelta(ev.text);break;
case 'ToolUseProposed':
setSwarm('coder','working','using tool · '+ev.name);
openToolCard(ev);
if(VERBOSE){
const card=TOOL_CARDS.get(ev.id||ev.tool_use_id);
if(card)card.open=true;
verboseLine(`tool proposed · ${ev.name||'(unknown)'} · risk ${ev.risk||'n/a'}`);
}
break;
case 'ToolUseStarted':
openToolCard(ev);
if(VERBOSE)verboseLine(`tool started · id=${ev.id||'?'}`);
break;
case 'ToolOutput':
closeToolCard(ev);
if(VERBOSE){
const card=TOOL_CARDS.get(ev.id||ev.tool_use_id);
if(card)card.open=true;
const blocks=ev.blocks||[];
verboseLine(`tool output · ${blocks.length} block${blocks.length===1?'':'s'}`);
}
break;
case 'DiffProposed':renderDiffCard(ev);break;
case 'DiffApplied':renderDiffCard(ev);runDiffsApplied++;break;
case 'Compacted':renderCompactBanner(ev);break;
case 'ApprovalRequested':
line(`<span class="warn">◌ approval required · ${esc(ev.summary)}</span>`);
showApprovalModal(ev);
break;
case 'ApprovalResolved':line(`<span class="muted">↳ approval: <span class="planner">${esc(ev.decision)}</span></span>`);break;
case 'AgentSpawned':setSwarm(ev.role,'working',`model ${ev.model} online`);line(`<span class="${roleCls(ev.role)}">◆ ${esc(ev.role)} spawned (${esc(ev.model)})</span>`);updateCrewLiveStatus(ev.role||ev.name,'working',`spawned · ${ev.model}`);break;
case 'AgentStatus':{
const isThinking=ev.status==='Thinking'||ev.status==='Working';
const note=ev.note||'';
const verbedNote=isThinking&&!note.includes('·')?FLIGHT_VERBS[(_vi++)%FLIGHT_VERBS.length]+' · '+note:note;
setSwarm(ev.role,ev.status,verbedNote);
stripActiveDecorations();
const dot=icon(ev.status);
const roleCl=roleCls(ev.role);
const newLine=line('<span class="'+roleCl+'">'+dot+'</span> <span class="'+roleCl+'" style="font-weight:500">'+esc(ev.role)+'</span>'+(isThinking?' <span class="sp">◌</span>':'')+' <span class="muted">'+esc(verbedNote)+'</span>'+(isThinking?'<span class="caret"></span>':'')+(isThinking?'<span class="agent-activity">'+esc(note||'working')+'</span>':''));
if(isThinking){
LAST_AGENT_LINE=newLine;
LAST_ACTIVITY=newLine.querySelector('.agent-activity');
}
updateCrewLiveStatus(ev.role||ev.name,ev.status,verbedNote);
break;
}
case 'CheckpointCreated':line(`<span class="acc">● checkpoint:</span> <span class="muted">${esc(ev.label)}</span>`);addCheckpointNode(ev.label,ev.id);runCheckpoints++;chirp(1400,.07);break;
case 'SkillLearned':line(`<span class="skill-pop">✦ skill learned · ${esc(ev.name)}</span>`);chirp(1100,.05);setTimeout(()=>chirp(1700,.07),80);break;
case 'CostUpdate':
setCost(ev.usd);
break;
case 'TokenUsageEstimated':
addTokEstimate(ev.input,ev.output);
pushVerboseTick(ev.input,ev.output);
break;
case 'TokenUsage':
addTokActual(ev.input,ev.output);
if(VERBOSE)verboseLine(`tokens actual · ${ev.input||0} in · ${ev.output||0} out`);
break;
case 'AutonomyChanged':setAuto(ev.level.toLowerCase());break;
case 'TestResult':
setSwarm('verifier',ev.failed>0?'error':'done',ev.failed>0?`${ev.failed} failing tests`:`${ev.passed} tests passed`);
if(ev.failed>0){line(`<span class="warn">⚠ tests ${ev.passed} passed · ${ev.failed} failed</span>`)}
else{line(`<span class="ok">✓ tests ${ev.passed} passed</span>`)}break;
case 'RunFinished':{
stripActiveDecorations();
setSwarm('coder','done','response completed');
setSwarm('verifier','done','run closed · metrics captured');
const durMs=runStartMs?Date.now()-runStartMs:0;
const durStr=durMs>60000?Math.floor(durMs/60000)+'m '+Math.round((durMs%60000)/1000)+'s':durMs>1000?(durMs/1000).toFixed(1)+'s':durMs+'ms';
const finalCost=ev.outcome?.cost_usd??costV;
const finalTok=ev.outcome?.tokens?.input+ev.outcome?.tokens?.output||tokV;
const status=esc(ev.outcome?.status||'done');
const card=document.createElement('details');card.className='run-summary';card.open=true;
card.innerHTML='<summary class="rs-head">'+
'<span class="rs-ok">✓</span>'+
'<span class="rs-status">'+status+'</span>'+
'<span style="color:var(--brand);font-size:10px">$'+finalCost.toFixed(4)+'</span>'+
'<span style="color:var(--agent);font-size:10px">'+(finalTok||tokV).toLocaleString('en-US')+' tok</span>'+
'<span class="rs-dur">'+durStr+'</span>'+
'<span class="rs-chev" style="margin-left:auto;color:var(--dimmer);font-size:9px;transition:transform .2s">▼</span>'+
'</summary>'+
'<div class="rs-grid">'+
'<div class="rs-stat"><span class="k">cost</span><span class="v cost">$'+finalCost.toFixed(4)+'</span></div>'+
'<div class="rs-stat"><span class="k">tokens</span><span class="v tok">'+(finalTok||tokV).toLocaleString('en-US')+'</span></div>'+
'<div class="rs-stat"><span class="k">files edited</span><span class="v files">'+runDiffsApplied+'</span></div>'+
'<div class="rs-stat"><span class="k">checkpoints</span><span class="v checks">'+runCheckpoints+'</span></div>'+
'</div>';
term.appendChild(card);term.scrollTop=term.scrollHeight;
setTimeout(function(){card.open=false;},2500);
chirp(900,.05);setTimeout(function(){chirp(1400,.07);},100);
setRunActive(false);
break;
}
case 'Error':
if(!String(ev.message||'').includes('Ollama API error 404')){
showError('Runtime error',ev.message,'Check config, provider availability, or retry after fixing the reported cause.','docs/troubleshooting.md')
}
break;
default:line(`<span class="dimd">${esc(JSON.stringify(ev))}</span>`);break;
}
}
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
function escAttr(s){return esc(s).replace(/"/g,'"');}
function roleCls(r){return r==='planner'?'planner':r==='coder'?'coder':r==='verifier'?'verifier':'muted';}
function icon(s){const m={Done:'✓',Working:'●',Thinking:'○',Error:'✗'};return m[s]||'◌';}
async function bootIntro(){
if(term.childElementCount>0)return;
let providerSummary='loading…';
let activeCount=0,totalProviders=0,totalModels=0;
try{
const [mr,cr]=await Promise.all([fetch('/models'),fetch('/config')]);
const md=await mr.json();const cfg=await cr.json();
const ps=md.providers||[];
totalProviders=ps.length;
totalModels=ps.reduce((s,p)=>s+p.models.length,0);
activeCount=(cfg.providers||[]).filter(p=>p.has_credential||p.configured).length;
providerSummary=`${totalProviders} providers · ${totalModels} models · <b style="color:var(--add)">${activeCount}</b> with key`;
}catch{providerSummary='provider catalog unavailable';}
const bootTime=new Date().toLocaleTimeString('en-US',{hour12:false});
const home=document.createElement('div');
home.className='home-panel';
home.innerHTML=`
<section class="home-left">
<svg class="hero-logo" viewBox="0 0 240 240" aria-label="Sparrow logo"><use href="#sparrow"/></svg>
<div>
<div class="home-title">Welcome back, <b style="color:var(--brand)">${escHtml(navigator.userAgent.includes('Windows')?'Captain':'friend')}</b></div>
<div class="home-sub"><b>SPARROW</b> · one cli · grows with you · booted at <span style="color:var(--fg)">${bootTime}</span></div>
<div class="home-path" id="homePath">—</div>
</div>
</section>
<section class="home-right">
<div class="home-block">
<h3>Route cockpit</h3>
<p>${providerSummary} · automatic fallback · cost and token tracking live.</p>
</div>
<div class="home-block">
<h3>Model picker</h3>
<p>Click <b>⬡ auto</b> in the bar above to fix a specific model. Auto-route selects the cheapest capable brain per task.</p>
</div>
<div class="home-block">
<h3>Memory & crew</h3>
<p id="memorySummary">Loading memory and agent index…</p>
</div>
</section>`;
term.appendChild(home);
const boot=line('<span class="sec">— cockpit online —</span>');
const bootParent=term;
[
'<span class="planner">○ router</span> <span class="muted">provider catalog loaded · config ready</span>',
'<span class="coder">○ tools</span> <span class="muted">fs · edit · search · exec · git · web · todo</span><span class="caret"></span>',
'<span class="verifier">○ audit</span> <span class="muted">tokens, cost, routing and checkpoints stream live</span>',
'<span id="learn-toast" class="coder">✦ skill learned · <b>write-and-fix-tests</b></span>'
].forEach((html,index)=>addBootLine(bootParent,html,index));
pulseLearnToast('learn-toast');
startTokenCounter('tok');
updateContextMeter();
loadMemory();
typeCmd('status',term);
}
let runActive=false;
function setRunActive(active){
runActive=active;
const stop=$('stopBtn'),run=$('runBtn'),ta=$('taskInput'),chromeAbort=$('abortChromeBtn');
if(stop){
stop.style.display=active?'inline-flex':'none';
stop.style.boxShadow=active?'0 0 0 0 color-mix(in srgb,var(--rem) 60%,transparent)':'none';
stop.style.animation=active?'abort-pulse 1.4s ease-in-out infinite':'none';
}
if(chromeAbort){
chromeAbort.style.display=active?'inline-flex':'none';
chromeAbort.style.animation=active?'abort-pulse 1.4s ease-in-out infinite':'none';
}
if(run){
run.textContent=active?'inject':'run';
run.style.opacity=active?'0.85':'1';
}
if(ta)ta.placeholder=active?'type to inject into the running task · Enter to send':'Type a task and press Enter…';
let badge=document.getElementById('abortBadge');
if(active){
if(!badge){
badge=document.createElement('button');
badge.id='abortBadge';
badge.type='button';
badge.title='Abort the running task';
badge.innerHTML='◼ abort run';
badge.style.cssText='position:fixed;left:64px;bottom:64px;z-index:9999;font-family:inherit;'+
'font-size:12px;font-weight:600;letter-spacing:.6px;padding:8px 16px;border-radius:999px;cursor:pointer;'+
'color:#fff;background:linear-gradient(180deg,#d44,#a22);'+
'border:1px solid #f55;'+
'box-shadow:0 0 18px rgba(212,68,68,.55), 0 2px 8px rgba(0,0,0,.35);'+
'animation:abort-pulse 1.4s ease-in-out infinite';
badge.addEventListener('click',stopRun);
document.body.appendChild(badge);
}
badge.style.display='inline-flex';
}else if(badge){
badge.style.display='none';
}
}
document.addEventListener('DOMContentLoaded',()=>{
const cab=document.getElementById('abortChromeBtn');
if(cab)cab.addEventListener('click',stopRun);
});
{
const cab=document.getElementById('abortChromeBtn');
if(cab)cab.addEventListener('click',stopRun);
}
(function injectAbortPulse(){
if(document.getElementById('abort-pulse-kf'))return;
const st=document.createElement('style');st.id='abort-pulse-kf';
st.textContent=`
@keyframes abort-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--rem) 55%,transparent)}50%{box-shadow:0 0 0 6px color-mix(in srgb,var(--rem) 0%,transparent)}}
/* Inline markdown rendering — applied to streamed prose */
.md-prose .md-h{font-weight:700;letter-spacing:.4px;margin:6px 0 4px}
.md-prose .md-h1{font-size:16px;color:var(--brand)}
.md-prose .md-h2{font-size:14px;color:var(--coral)}
.md-prose .md-h3{font-size:13px;color:var(--planner)}
.md-prose .md-h4,.md-prose .md-h5,.md-prose .md-h6{font-size:12.5px;color:var(--agent)}
.md-prose strong{color:var(--fg);font-weight:700}
.md-prose em{color:var(--coral);font-style:italic}
.md-prose del{color:var(--dimmer);text-decoration:line-through}
.md-prose .md-ic{background:color-mix(in srgb,var(--brand) 10%,transparent);color:var(--brand);padding:1px 6px;border-radius:4px;font-family:inherit;font-size:.95em;border:1px solid color-mix(in srgb,var(--brand) 25%,transparent)}
.md-prose a{color:var(--planner);text-decoration:underline dotted;text-underline-offset:2px}
.md-prose a:hover{color:var(--coral)}
.md-prose .md-hr{border:0;height:1px;background:linear-gradient(90deg,transparent,var(--line-hot),transparent);margin:8px 0}
.md-prose .md-bq{margin:6px 0;padding:6px 10px;border-left:3px solid var(--brand);background:color-mix(in srgb,var(--brand) 5%,transparent);color:var(--dim);font-style:italic;border-radius:0 6px 6px 0}
.md-prose .md-ul,.md-prose .md-ol{margin:4px 0 4px 16px;padding:0}
.md-prose .md-ul li,.md-prose .md-ol li{margin:2px 0;color:var(--fg)}
.md-prose .md-ul li::marker{color:var(--brand)}
.md-prose .md-ol li::marker{color:var(--coral);font-weight:600}
.md-prose .md-cb{color:var(--brand);font-size:1.1em;margin-right:4px}
.md-prose .md-cb.done{color:var(--add)}
.md-prose .md-tbl{border-collapse:collapse;margin:6px 0;font-size:11.5px;border:1px solid var(--line-hot);border-radius:6px;overflow:hidden}
.md-prose .md-tbl th{background:color-mix(in srgb,var(--brand) 12%,var(--panel));color:var(--brand);font-weight:700;text-align:left;padding:5px 10px;border-bottom:1px solid var(--line-hot);font-size:10.5px;letter-spacing:.5px;text-transform:uppercase}
.md-prose .md-tbl td{padding:4px 10px;border-top:1px solid var(--line);color:var(--fg)}
.md-prose .md-tbl tr:nth-child(odd) td{background:color-mix(in srgb,var(--panel2) 70%,transparent)}
/* Verbose flood: collapse repeated streamed-output-estimate ticks into 1 live row */
.verbose-tick{font-size:10px;color:var(--dimmer);opacity:.7;letter-spacing:.3px;padding:1px 8px;border-left:2px solid var(--line);background:color-mix(in srgb,var(--planner) 4%,transparent)}
.verbose-tick b{color:var(--brand);font-variant-numeric:tabular-nums}
/* Live activity line near agent spinner */
.agent-activity{display:inline-block;margin-left:8px;padding:1px 8px;border-radius:4px;background:color-mix(in srgb,var(--brand) 10%,transparent);color:var(--brand);font-size:10.5px;font-style:italic;animation:pulse-act 1.6s ease-in-out infinite}
@keyframes pulse-act{0%,100%{opacity:.85}50%{opacity:.45}}
/* Caret animation (spinner dot) — only on currently active line */
.caret{display:inline-block;margin-left:6px;animation:caret-bl 1s steps(1) infinite;color:var(--brand)}
.caret::after{content:"●"}
@keyframes caret-bl{0%,49%{opacity:1}50%,100%{opacity:0}}`;
document.head.appendChild(st);
})();
async function stopRun(){
try{await fetch('/stop',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});}
catch(e){}
setRunActive(false);
if(typeof toast==='function')toast('run aborted','ok');
}
$('runBtn').addEventListener('click',runTask);
const _stopBtn=$('stopBtn');if(_stopBtn)_stopBtn.addEventListener('click',stopRun);
$('taskInput').addEventListener('keydown',composerKeydown);
$('taskInput').addEventListener('input',composerInput);
$('taskInput').addEventListener('paste',composerPaste);
$('fileInput').addEventListener('change',handleFiles);
restoreDraft();
autoResizeComposer();
function composerInput(){
HISTORY_IDX=null;
const value=$('taskInput').value;
localStorage.setItem('sparrow-composer-draft',value);
autoResizeComposer();
updateContextMeter();
const palette=document.getElementById('palette');
if(value.startsWith('/')&&!/\s/.test(value.trim())&&document.activeElement===$('taskInput')){
if(!palette.classList.contains('open')){
palette.classList.add('open');
const pinput=document.getElementById('paletteInput');
pinput.value=value;
PALETTE_SEL=0;
paletteFilter(value);
setTimeout(()=>pinput.focus(),0);
}
}else if(palette.classList.contains('open')&&document.activeElement===$('taskInput')){
paletteClose();
}
}
function restoreDraft(){
const draft=localStorage.getItem('sparrow-composer-draft')||'';
if(draft&&!$('taskInput').value)$('taskInput').value=draft;
}
function autoResizeComposer(){
const input=$('taskInput');
input.style.height='auto';
const max=parseFloat(getComputedStyle(input).lineHeight||'18')*12+18;
input.style.height=Math.min(input.scrollHeight,max)+'px';
input.style.overflowY=input.scrollHeight>max?'auto':'hidden';
}
function composerKeydown(e){
if(e.defaultPrevented)return;
if(e.key==='Enter'&&!e.shiftKey){
e.preventDefault();
runTask();
return;
}
if(e.key==='ArrowUp'&&!attachedFiles.length&&composerAtFirstLine()){
if(navigateHistory(1))e.preventDefault();
return;
}
if(e.key==='ArrowDown'&&HISTORY_IDX!==null&&composerAtLastLine()){
if(navigateHistory(-1))e.preventDefault();
return;
}
if(e.key==='Escape'&&HISTORY_IDX!==null){
e.preventDefault();
HISTORY_IDX=null;
$('taskInput').value=HISTORY_DRAFT;
composerInput();
}
}
function composerAtFirstLine(){
const input=$('taskInput');
return input.value.slice(0,input.selectionStart||0).indexOf('\n')<0;
}
function composerAtLastLine(){
const input=$('taskInput');
return input.value.slice(input.selectionStart||0).indexOf('\n')<0;
}
function navigateHistory(direction){
if(!HISTORY_INPUTS.length)return false;
if(HISTORY_IDX===null){
HISTORY_DRAFT=$('taskInput').value;
HISTORY_IDX=direction>0?0:null;
}else{
HISTORY_IDX+=direction>0?1:-1;
}
if(HISTORY_IDX===null||HISTORY_IDX<0){
HISTORY_IDX=null;
$('taskInput').value=HISTORY_DRAFT;
}else if(HISTORY_IDX>=HISTORY_INPUTS.length){
HISTORY_IDX=HISTORY_INPUTS.length-1;
}
if(HISTORY_IDX!==null)$('taskInput').value=HISTORY_INPUTS[HISTORY_IDX];
localStorage.setItem('sparrow-composer-draft',$('taskInput').value);
autoResizeComposer();
updateContextMeter();
const input=$('taskInput');
input.selectionStart=input.selectionEnd=input.value.length;
return true;
}
function rememberSubmitted(text){
const clean=text.trim();
if(!clean)return;
HISTORY_INPUTS=HISTORY_INPUTS.filter(v=>v!==clean);
HISTORY_INPUTS.unshift(clean);
HISTORY_INPUTS=HISTORY_INPUTS.slice(0,80);
HISTORY_IDX=null;
HISTORY_DRAFT='';
localStorage.removeItem('sparrow-composer-draft');
}
async function composerPaste(e){
const files=Array.from(e.clipboardData?.files||[]);
if(files.length){
e.preventDefault();
await attachFiles(files);
insertComposerText(files.map(f=>`[attachment: ${f.name}]`).join(' '));
return;
}
const text=e.clipboardData?.getData('text')||'';
if(text.split(/\r?\n/).length>5){
setTimeout(autoResizeComposer,0);
}
}
let dragDepth=0;
window.addEventListener('dragenter',e=>{
if(!Array.from(e.dataTransfer?.types||[]).includes('Files'))return;
dragDepth++;
e.preventDefault();
$('dropZone').classList.add('show');
});
window.addEventListener('dragover',e=>{
if(Array.from(e.dataTransfer?.types||[]).includes('Files'))e.preventDefault();
});
window.addEventListener('dragleave',e=>{
if(!Array.from(e.dataTransfer?.types||[]).includes('Files'))return;
dragDepth=Math.max(0,dragDepth-1);
if(dragDepth===0)$('dropZone').classList.remove('show');
});
window.addEventListener('drop',async e=>{
const files=Array.from(e.dataTransfer?.files||[]);
if(!files.length)return;
e.preventDefault();
dragDepth=0;
$('dropZone').classList.remove('show');
await attachFiles(files);
insertComposerText(files.map(f=>`[attachment: ${f.name}]`).join(' '));
});
function renderAttachments(){
const box=$('attachments');
if(!attachedFiles.length){box.classList.remove('show');box.innerHTML='';updateContextMeter();return;}
box.classList.add('show');
box.innerHTML=attachedFiles.map((f,i)=>`
<span class="file-chip" title="${escAttr(f.name)}">
<span>${esc(f.name)} · ${f.tokens.toLocaleString('en-US')} tok</span>
<button type="button" aria-label="remove ${escAttr(f.name)}" data-remove-file="${i}">×</button>
</span>`).join('');
box.querySelectorAll('[data-remove-file]').forEach(btn=>btn.addEventListener('click',()=>{
attachedFiles.splice(Number(btn.dataset.removeFile),1);
renderAttachments();
}));
updateContextMeter();
}
async function handleFiles(e){
const files=Array.from(e.target.files||[]);
await attachFiles(files);
e.target.value='';
}
async function attachFiles(files){
let usedChars=attachedFiles.reduce((sum,f)=>sum+(f.content||'').length,0);
for(const file of files){
if(file.size>MAX_ATTACHMENT_BYTES){
showError('Upload rejected',`${file.name} too large: ${file.size} bytes > limit ${MAX_ATTACHMENT_BYTES}`,'Attach a smaller file or compress it before retrying.','docs/media.md');
continue;
}
const isText=file.type.startsWith('text/')||/\.(md|txt|json|rs|js|ts|tsx|jsx|py|toml|yaml|yml|html|css|csv)$/i.test(file.name);
const uploaded=await uploadFile(file);
if(!isText){
attachedFiles.push({name:file.name,content:'',note:uploaded?.path?`[uploaded: ${uploaded.path}]`:`[binary file omitted: ${file.name}, ${file.size} bytes]`,tokens:estimateTokens(file.name)});
continue;
}
const raw=await file.text();
const remaining=Math.max(0,ATTACHMENT_CHAR_LIMIT-usedChars);
const content=raw.slice(0,remaining);
usedChars+=content.length;
const truncated=raw.length>content.length;
attachedFiles.push({
name:file.name,
content,
note:(uploaded?.path?`uploaded: ${uploaded.path}`:'')+(truncated?'\ntruncated to fit console attachment budget':''),
tokens:estimateTokens(content)
});
if(usedChars>=ATTACHMENT_CHAR_LIMIT)break;
}
renderAttachments();
}
async function uploadFile(file){
const form=new FormData();
form.append('file',file,file.name);
try{
const r=await fetch('/upload',{method:'POST',body:form});
const j=await r.json();
const rejected=Array.isArray(j.rejected)?j.rejected:[];
rejected.forEach(x=>showError('Upload rejected',`${x.name||file.name} ${x.reason||''}`,'Attach a smaller or supported file and retry.','docs/media.md'));
const accepted=Array.isArray(j.accepted)?j.accepted:[];
if(accepted[0]){
loadArtifacts();
return accepted[0];
}
}catch(e){
showError('Upload failed',e.message,'Check that the WebView server is still running.');
}
return null;
}
function insertComposerText(text){
const input=$('taskInput');
const start=input.selectionStart??input.value.length;
const end=input.selectionEnd??input.value.length;
const prefix=input.value&&start>0&&!/\s$/.test(input.value.slice(0,start))?' ':'';
const suffix=end<input.value.length&&!/^\s/.test(input.value.slice(end))?' ':'';
input.value=input.value.slice(0,start)+prefix+text+suffix+input.value.slice(end);
const caret=start+prefix.length+text.length+suffix.length;
input.selectionStart=input.selectionEnd=caret;
composerInput();
input.focus();
}
function attachmentPrompt(){
if(!attachedFiles.length)return '';
const blocks=attachedFiles.map(f=>{
if(!f.content)return `### file: ${f.name}\n${f.note}`;
const note=f.note?`\n${f.note}`:'';
return `### file: ${f.name}${note}\n\`\`\`text\n${f.content}\n\`\`\``;
});
return `\n\n[Attached files]\n${blocks.join('\n\n')}`;
}
async function runTask(){
const task=$('taskInput').value.trim();if(!task&&!attachedFiles.length)return;
if(task.startsWith('/')){
const handled=await handleSlashCommand(task);
if(handled)return;
}
const payload=task+attachmentPrompt();
$('taskInput').value='';
localStorage.removeItem('sparrow-composer-draft');
autoResizeComposer();
const runBtn=$('runBtn');if(runBtn){runBtn.style.transform='scale(0.92)';setTimeout(function(){runBtn.style.transform='';},120);}
updateContextMeter();
const attachNote=attachedFiles.length?` <span class="muted">+ ${attachedFiles.length} file${attachedFiles.length>1?'s':''}</span>`:'';
line(`<span class="prompt">sparrow ›</span> <span class="cmd">${esc(task||'analyse attached files')}</span>${attachNote}`);
try{
const runBody={task:payload};
if(selectedModel)runBody.model_override=selectedModel;
if(ACTIVE_AGENT)runBody.agent_name=ACTIVE_AGENT;
const r=await fetch('/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(runBody)});
const j=await r.json();
if(!j.ok)showError('Run failed',j.message,'Check the command channel and retry.');
else{rememberSubmitted(task||'analyse attached files');attachedFiles=[];renderAttachments();updateContextMeter();}
}catch(e){showError('Run failed',e.message,'Check that the WebView server is still connected.')}
}
async function handleSlashCommand(task){
const lower=task.toLowerCase();
const cmd=(lower.match(/^\/([a-z0-9_-]+)/)||[])[1]||'';
if(cmd==='help'||cmd==='commands'){await showCommands();clearComposerAfterCommand();return true;}
if(cmd==='plan'){await planTask(task.replace(/^\/plan\s*/i,'').trim());return true;}
if(cmd==='run'){
const prompt=task.replace(/^\/run\s*/i,'').trim();
if(prompt){
$('taskInput').value=prompt;
await runTask();
}else{
line('<span class="warn">Usage: /run <task></span>');
clearComposerAfterCommand();
}
return true;
}
if(cmd==='clear'){
term.innerHTML='';
clearComposerAfterCommand();
return true;
}
if(cmd==='reset'){
clearComposerAfterCommand();
await resetConversationFromSlash();
return true;
}
if(cmd==='stop'){
clearComposerAfterCommand();
await stopRun();
return true;
}
if(cmd==='config'){
clearComposerAfterCommand();
openConfig();
line('<span class="acc">/config</span> <span class="muted">configuration panel opened</span>');
return true;
}
if(cmd==='upload'){
clearComposerAfterCommand();
$('fileInput')?.click();
line('<span class="acc">/upload</span> <span class="muted">choose files, or drag them into Sparrow</span>');
return true;
}
const localEndpoints={
status:'/status',
memory:'/memory',
tools:'/tools',
plugins:'/plugins',
security:'/security',
sessions:'/sessions',
agents:'/agents',
routing:'/routing',
permissions:'/permissions',
models:'/models'
};
if(localEndpoints[cmd] && task.trim()===`/${cmd}`){
await showEndpointCommand(task,localEndpoints[cmd]);
clearComposerAfterCommand();
return true;
}
await runWebviewCliCommand(task);
clearComposerAfterCommand();
return true;
}
function clearComposerAfterCommand(){
$('taskInput').value='';
localStorage.removeItem('sparrow-composer-draft');
autoResizeComposer();
updateContextMeter();
}
async function resetConversationFromSlash(){
try{
const r=await fetch('/conversation/reset',{method:'POST'});
const j=await r.json();
line(`<span class="${j.ok?'acc':'warn'}">/reset</span> <span class="muted">${esc(j.message||'conversation reset')}</span>`);
}catch(e){showError('Reset failed',e.message,'Reload the WebView and retry.')}
}
async function runWebviewCliCommand(task){
line(`<span class="prompt">sparrow ›</span> <span class="cmd">${esc(task)}</span>`);
try{
const r=await fetch('/cli',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({command:task})});
const j=await r.json();
const label=j.ok?'completed':(j.message||'failed');
line(`<span class="${j.ok?'acc':'warn'}">↳ command ${esc(label)}</span>`);
if(j.stdout)renderCommandOutput('stdout',j.stdout);
if(j.stderr)renderCommandOutput('stderr',j.stderr);
if(!j.stdout&&!j.stderr&&!j.ok)line(`<span class="warn">${esc(j.message||'command failed')}</span>`);
if(j.ok)rememberSubmitted(task);
}catch(e){showError('Command failed',e.message,'Check that the WebView server is still connected.')}
}
async function showEndpointCommand(task,url){
line(`<span class="prompt">sparrow ›</span> <span class="cmd">${esc(task)}</span>`);
try{
const r=await fetch(url);
const j=await r.json();
renderCommandOutput(url.replace('/','')||'json',JSON.stringify(j,null,2),'json');
rememberSubmitted(task);
}catch(e){showError('Command failed',e.message,`Unable to read ${url}.`)}
}
function renderCommandOutput(label,text,lang='text'){
const d=document.createElement('details');
d.className='code-card';
d.open=true;
const lines=String(text).split(/\r?\n/).filter(Boolean).length||1;
d.dataset.lang=lang;
d.innerHTML='<summary><span class="cc-icon">▸</span>'+
`<span class="cc-lang">${escHtml(label)}</span>`+
`<span class="cc-meta">${lines} lines</span>`+
'<button class="cc-copy" onclick="navigator.clipboard.writeText(this.closest(\'.code-card\').querySelector(\'code\').textContent);this.textContent=\'copied ✓\'">copy</button>'+
'</summary><pre><code></code></pre>';
d.querySelector('code').textContent=text;
term.appendChild(d);
term.scrollTop=term.scrollHeight;
}
async function showCommands(){
try{
const r=await fetch('/commands');
const j=await r.json();
if(!j.ok){showError('Commands failed',j.message,'Reload the WebView or run `sparrow commands` in the terminal.');return;}
line('<span class="acc">/help</span> <span class="muted">available slash commands</span>');
(j.commands||[]).forEach(cmd=>{
line(`<span class="planner">${esc(cmd.name)}</span> <span class="cmd">— ${esc(cmd.description||'Run Sparrow command')}</span> <span class="muted">· ${esc(cmd.usage||'Usage: '+cmd.name)} · ${esc(cmd.source)}</span>`);
});
}catch(e){showError('Commands failed',e.message,'Reload the WebView or run `sparrow commands` in the terminal.')}
}
async function loadMemory(){
try{
const r=await fetch('/memory');
const j=await r.json();
if(!j.ok||!j.stats)return;
const s=j.stats;
const text=`${s.facts} facts · MEMORY.md ${s.memory_chars}/${s.memory_limit} · USER.md ${s.user_chars}/${s.user_limit}`;
const target=$('memorySummary');
if(target)target.textContent=text;
}catch(_e){
const target=$('memorySummary');
if(target)target.textContent='Memory status unavailable.';
}
}
async function planTask(task){
if(!task&&attachedFiles.length){task='analyse attached files'}
if(!task){line('<span class="warn">Usage: /plan <task></span>');return;}
const payload=task+attachmentPrompt();
$('taskInput').value='';
updateContextMeter();
line(`<span class="prompt">sparrow ›</span> <span class="cmd">/plan ${esc(task)}</span>`);
try{
const r=await fetch('/plan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({task:payload,agent_name:ACTIVE_AGENT||undefined})});
const j=await r.json();
if(!j.ok||!j.plan){showError('Plan failed',j.message,'Try a more specific task or check provider configuration.');return;}
renderPlan(j.plan,task);
}catch(e){showError('Plan failed',e.message,'Check that the WebView server is still connected.')}
}
function renderPlan(plan,task){
line(`<span class="acc">◆ read-only plan</span> <span class="muted">tier ${esc(plan.estimated_tier)} · no tools or edits executed</span>`);
line(`<span class="muted">${esc(plan.summary)}</span>`);
(plan.steps||[]).forEach((step,i)=>line(`<span class="planner">${i+1}.</span> <span class="cmd">${esc(step)}</span>`));
if((plan.risks||[]).length){
line('<span class="warn">Risks</span>');
plan.risks.forEach(r=>line(`<span class="muted">- ${esc(r)}</span>`));
}
const actions=line(`<span class="approval-actions" data-plan-task="${escAttr(task)}"><button class="approve" data-plan-action="run">run plan</button><button data-plan-action="edit">edit</button><button class="deny" data-plan-action="reject">reject</button></span>`);
actions.querySelector('[data-plan-action="run"]').addEventListener('click',()=>acceptPlan(task));
actions.querySelector('[data-plan-action="edit"]').addEventListener('click',()=>editPlan(task));
actions.querySelector('[data-plan-action="reject"]').addEventListener('click',()=>rejectPlan(task));
}
function acceptPlan(task){
$('taskInput').value=task;
runTask();
}
function editPlan(task){
$('taskInput').value=task;
$('taskInput').focus();
updateContextMeter();
}
function rejectPlan(task){
line(`<span class="muted">↳ plan rejected · ${esc(task||'no task')}</span>`);
}
function showApprovalModal(ev){
const modal=$('approvalModal');
modal.dataset.id=ev.id||'';
$('approvalSummary').textContent=ev.summary||'Sparrow is asking before it acts.';
$('approvalRisk').textContent='risk: '+(ev.risk||'unknown');
modal.classList.add('show');
setTimeout(()=>$('approvalApprove').focus(),40);
}
function closeApprovalModal(){
const modal=$('approvalModal');
modal.classList.remove('show');
modal.dataset.id='';
}
async function resolveApprovalFromModal(decision){
const modal=$('approvalModal');
const id=modal.dataset.id;
if(!id)return;
[...modal.querySelectorAll('button')].forEach(b=>b.disabled=true);
try{
const r=await fetch('/approval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,decision})});
const j=await r.json();
if(j.ok){
closeApprovalModal();
line(`<span class="${decision==='approve'?'ok':'warn'}">approval ${decision==='approve'?'approved':'denied'}</span>`);
}else{
showError('Approval failed',j.message,'The request may have expired; retry the action if it is still needed.');
}
}catch(e){
showError('Approval failed',e.message,'Check that the WebView server is still connected.');
}finally{
[...modal.querySelectorAll('button')].forEach(b=>b.disabled=false);
}
}
$('approvalApprove').addEventListener('click',()=>resolveApprovalFromModal('approve'));
$('approvalDeny').addEventListener('click',()=>resolveApprovalFromModal('deny'));
$('approvalModal').addEventListener('click',e=>{if(e.target.id==='approvalModal')closeApprovalModal()});
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('approvalModal').classList.contains('show'))closeApprovalModal()});
async function resolveApproval(id,decision,btn){
const wrap=btn.closest('.approval-actions');
[...wrap.querySelectorAll('button')].forEach(b=>b.disabled=true);
try{
const r=await fetch('/approval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,decision})});
const j=await r.json();
if(j.ok){wrap.innerHTML=`<span class="${decision==='approve'?'ok':'warn'}">${decision==='approve'?'approved':'denied'}</span>`}
else{wrap.innerHTML=`<span class="err">${esc(j.message)}</span>`}
}catch(e){wrap.innerHTML=`<span class="err">${esc(e.message)}</span>`}
}
function openConfig(){$('overlay').classList.add('show');$('configPanel').classList.add('show');(async()=>{await loadConfig();await loadRouting();})();setTimeout(()=>$('provSelect').focus(),60)}
function closeConfig(){$('overlay').classList.remove('show');$('configPanel').classList.remove('show');$('taskInput').focus()}
$('cfgBtn').addEventListener('click',openConfig);
$('closeConfig').addEventListener('click',closeConfig);
$('overlay').addEventListener('click',closeConfig);
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&$('configPanel').classList.contains('show'))closeConfig()});
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&document.getElementById('diffPanel').classList.contains('open'))closeDiffPanel();});
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&document.getElementById('modelPickerDrop').style.display!=='none')document.getElementById('modelPickerDrop').style.display='none';});
let providers=[];
let _modelsRegistry={};
async function loadConfig(){
try{
const [cr,mr]=await Promise.all([fetch('/config'),fetch('/models')]);
const j=await cr.json();const md=await mr.json();
if(!j.ok)return;
providers=j.providers||[];
_modelsRegistry={};
(md.providers||[]).forEach(p=>{_modelsRegistry[p.id]={};p.models.forEach(m=>{_modelsRegistry[p.id][m.name]=m;});});
if(j.autonomy){$('autonomySelect').value=j.autonomy.toLowerCase();}
if(j.sandbox){$('sandboxSelect').value=j.sandbox;}
await loadPermissions();
setAuto(($('autonomySelect').value||'trusted').toLowerCase());
const sel=$('provSelect');if(sel)sel.innerHTML='<option value="">— choose provider —</option>'+providers.map(p=>`<option value="${escAttr(p.name)}">${esc(p.label||p.name)}</option>`).join('');
const badge=$('credBadge');
const keyCount=providers.filter(p=>p.has_credential).length;
if(badge){badge.textContent=keyCount+'/'+providers.length+' keys';badge.className='badge '+(keyCount>0?'found':'missing');}
renderProviderList();
const totalModels=providers.reduce((s,p)=>s+(p.models||[]).length,0);
if($('cfgProvCount'))$('cfgProvCount').textContent=providers.length;
if($('cfgKeyCount'))$('cfgKeyCount').textContent=keyCount;
if($('cfgModelCount'))$('cfgModelCount').textContent=totalModels;
const drs=$('defaultRouteSelect');
if(drs){
drs.innerHTML='<option value="">— auto (cheapest capable) —</option>'+
providers.flatMap(p=>(p.models||[]).map(m=>`<option value="${escAttr(p.name+':'+m)}">${esc(p.label||p.name)} · ${esc(m)}${p.has_credential?'':' ⚠ no key'}</option>`)).join('');
if(selectedModel)drs.value=selectedModel;
}
if($('cfgCurrentModel'))$('cfgCurrentModel').textContent=selectedModel||'auto-route';
}catch(e){console.error('loadConfig failed',e);}
}
function renderProviderList(){
const host=document.getElementById('providerList');if(!host)return;
host.innerHTML=providers.map(p=>{
const reg=_modelsRegistry[p.name]||{};
const models=(p.models||[]).map(m=>{
const info=reg[m]||{};
const isDefault=selectedModel===p.name+':'+m;
const star=info.recommended?'<span class="mstar" title="recommended">★</span>':'';
const ctx=info.context_window?fmtCtxShort(info.context_window):'';
const cost=info.cost_in!==undefined?(info.cost_in===0?'free':'$'+info.cost_in+'/M'):'';
return `<div class="mrow${isDefault?' is-default':''}">
<span class="mname">${escHtml(m)}</span>
${star}
${ctx?'<span class="mctx">'+escHtml(ctx)+'</span>':''}
${cost?'<span class="mcost">'+escHtml(cost)+'</span>':''}
<button class="mset" onclick="setModelDefault('${escHtml(p.name+':'+m)}')">${isDefault?'✓ default':'set default'}</button>
</div>`;
}).join('');
const envHint=p.api_key_env?`<span class="envhint">env: <b>${escHtml(p.api_key_env)}</b></span>`:'';
return `<details class="cfg-provider${p.has_credential?' has-key':''}" data-prov="${escHtml(p.name)}">
<summary>
<span class="led"></span>
<span class="lbl">${escHtml(p.label||p.name)}</span>
<span class="adp">${escHtml(p.adapter||'')}</span>
<span class="count">${(p.models||[]).length} model${(p.models||[]).length!==1?'s':''}${p.has_credential?' · key ✓':''}</span>
<span class="chev">▸</span>
</summary>
<div class="body">
${p.notes?'<div class="notes">'+escHtml(p.notes)+'</div>':''}
<div class="models">${models||'<div style="color:var(--dimmer);font-size:10.5px;padding:6px">no models declared</div>'}</div>
<div class="keyrow">
${envHint}
<input type="password" id="key-${escHtml(p.name)}" placeholder="${p.has_credential?'key configured · paste to override':'paste API key'}">
<button onclick="saveProviderKey('${escHtml(p.name)}')">save key</button>
<button class="btn sm" onclick="scanProviderModels('${escHtml(p.name)}',this)" title="Fetch all available models from this provider API">🔍 scan models</button>
</div>
<div class="scan-result" id="scan-${escHtml(p.name)}" style="font-size:10.5px;color:var(--dim);margin-top:4px;display:none"></div>
</div>
</details>`;
}).join('');
}
function filterProviders(q){
const norm=q.toLowerCase().trim();
document.querySelectorAll('.cfg-provider').forEach(d=>{
const txt=d.textContent.toLowerCase();
d.style.display=(!norm||txt.includes(norm))?'':'none';
if(norm&&txt.includes(norm))d.setAttribute('open','');
});
}
function setModelDefault(id){
setModelOverride(id,_modelsRegistry[id.split(':')[0]]?.[id.split(':').slice(1).join(':')]?.context_window?fmtCtxShort(_modelsRegistry[id.split(':')[0]][id.split(':').slice(1).join(':')].context_window):'');
renderProviderList();
if($('cfgCurrentModel'))$('cfgCurrentModel').textContent=id;
const drs=$('defaultRouteSelect');if(drs)drs.value=id;
}
async function saveProviderKey(name){
const p=providers.find(p=>p.name===name);if(!p)return;
const inp=document.getElementById('key-'+name);const key=inp.value.trim();
if(!key){toast('paste a key first','error');return;}
const body={
name,adapter:p.adapter||'openai-compatible',base_url:p.base_url||null,
models:p.models||[],
api_key_env:p.api_key_env||null,
api_key:key,
autonomy:$('autonomySelect').value,
sandbox:$('sandboxSelect').value,
};
try{
const r=await fetch('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json();
if(j.ok){inp.value='';await loadConfig();toast('key saved','ok');}
else toast('save failed: '+j.message,'error');
}catch(e){toast('save failed: '+e.message,'error');}
}
async function scanProviderModels(name, btn){
const result=document.getElementById('scan-'+name);
if(!result)return;
btn.disabled=true;btn.textContent='scanning…';
result.style.display='block';result.textContent='⏳ fetching models…';
try{
const r=await fetch('/providers/scan',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider:name})});
const j=await r.json();
if(j.ok){
result.style.color='var(--add)';result.textContent='✓ '+j.message+' — '+j.models.join(', ');
const prov=document.querySelector('.cfg-provider[data-prov="'+name+'"] .models');
if(prov&&j.models.length){
const existing=new Set();prov.querySelectorAll('.mname').forEach(function(m){existing.add(m.textContent);});
var added=0;
j.models.forEach(function(m){
if(!existing.has(m)){
var d=document.createElement('div');d.className='mrow';
var reg=_modelsRegistry[name]||{};var info=reg[m]||{};
var ctx=info.context_window?fmtCtxShort(info.context_window):'';
var cost=info.cost_in!==undefined?(info.cost_in===0?'free':'$'+info.cost_in+'/M'):'';
d.innerHTML='<span class="mname" style="color:var(--add)">'+escHtml(m)+'</span>'+
(ctx?'<span class="mctx">'+escHtml(ctx)+'</span>':'')+
(cost?'<span class="mcost">'+escHtml(cost)+'</span>':'');
var btn=document.createElement('button');btn.className='mset';
var mid=name+':'+m;
btn.textContent='set default';
btn.onclick=function(){setModelDefault(mid);};
d.appendChild(btn);
prov.appendChild(d);added++;
}
});
if(added)result.textContent+=' · '+added+' new model'+(added>1?'s':'')+' added ↑';
}
}else{
result.style.color='var(--rem)';result.textContent='✗ '+j.message;
}
}catch(e){
result.style.color='var(--rem)';result.textContent='✗ '+e.message;
}finally{btn.disabled=false;btn.textContent='🔍 scan models';}
}
async function loadRouting(){
try{
const r=await fetch('/routing');const j=await r.json();
const sel=document.getElementById('preferredProviderSelect');
if(sel){
const cur=j.preferred_provider||'';
sel.innerHTML='<option value="">-- auto (smart routing per tier) --</option>'+
(j.all_providers||[]).map(function(id){return '<option value="'+id+'"'+(id===cur?' selected':'')+'>'+id+'</option>';}).join('');
}
const tog=document.getElementById('autoDiscoverToggle');
if(tog)tog.checked=j.auto_discover!==false;
}catch(e){console.warn('loadRouting failed',e);}
}
async function saveRouting(){
const sel=document.getElementById('preferredProviderSelect');
const tog=document.getElementById('autoDiscoverToggle');
const body={preferred_provider:sel?sel.value:'',auto_discover:tog?tog.checked:true};
try{
const r=await fetch('/routing',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json();
const st=document.getElementById('routingSaveStatus');
if(j.ok&&st){st.style.display='inline';setTimeout(function(){st.style.display='none';},2500);}
else if(!j.ok)alert('save routing failed: '+j.message);
}catch(e){alert('save routing failed: '+e.message);}
}
document.addEventListener('click',e=>{
const tab=e.target.closest('.cfg-tab');if(!tab)return;
const name=tab.dataset.cfgTab;
document.querySelectorAll('.cfg-tab').forEach(t=>t.classList.toggle('active',t===tab));
document.querySelectorAll('[data-cfg-pane]').forEach(p=>{p.style.display=p.dataset.cfgPane===name?'':'none';});
});
async function loadPermissions(){
try{
const r=await fetch('/permissions');const j=await r.json();
const mode=j.permissions?.mode||'supervised';
if($('permissionSelect'))$('permissionSelect').value=mode;
}catch(e){}
}
function selectProvider(name){
const p=providers.find(p=>p.name===name);
if(!p){$('modelSelect').innerHTML='<option value="">— select provider first —</option>';$('modelTags').innerHTML='';$('apiKey').value='';$('credBadge').className='badge missing';$('credBadge').textContent='no key';return;}
$('modelSelect').innerHTML=p.models.map(m=>`<option value="${escAttr(m)}">${esc(m)}</option>`).join('');
$('apiKey').value='';
$('apiKey').placeholder=p.api_key_env?'env: '+p.api_key_env:'paste key here';
$('credBadge').className='badge '+(p.has_credential?'found':'missing');
$('credBadge').textContent=p.has_credential?'key found':(p.configured?'configured · no key':'not configured');
const tags=(p.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('');
$('modelTags').innerHTML=tags+(p.notes?`<span class="tag">${esc(p.notes)}</span>`:'');
}
$('saveConfig').addEventListener('click',async()=>{
const name=$('provSelect').value;
const p=providers.find(p=>p.name===name);
const body={
name,adapter:p?.adapter||'openai-compatible',base_url:p?.base_url||null,
models:[$('modelSelect').value].filter(m=>m),
api_key_env:$('apiKey').placeholder.startsWith('env:')?$('apiKey').placeholder.slice(5):null,
api_key:$('apiKey').value.trim()||null,
autonomy:$('autonomySelect').value,
sandbox:$('sandboxSelect').value,
};
try{
const r=await fetch('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json();
$('saveMsg').textContent=j.ok?'saved ✓':'error: '+j.message;
$('saveMsg').style.color=j.ok?'var(--add)':'var(--rem)';
if(j.ok){
await savePermissions();
$('apiKey').value='';
await loadConfig();
if(name)selectProvider(name);
setAuto(($('autonomySelect').value||'trusted').toLowerCase());
setTimeout(closeConfig,450)
}
}catch(e){$('saveMsg').textContent='error: '+e.message;$('saveMsg').style.color='var(--rem)'}
});
async function savePermissions(){
if(!$('permissionSelect'))return;
const r=await fetch('/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:$('permissionSelect').value})});
const j=await r.json();
if(!j.ok)throw new Error(j.message||'permissions save failed');
}
document.querySelectorAll('.preset').forEach(el=>{
el.addEventListener('click',()=>{
document.querySelectorAll('.preset').forEach(e=>e.classList.remove('sel'));
el.classList.add('sel');
const p=el.dataset.preset;
const sel=$('provSelect');
if(p==='local'){sel.value='ollama';selectProvider('ollama')}
else if(p==='cloud'){sel.value='nvidia';selectProvider('nvidia')}
else if(p==='strong'){sel.value='anthropic';selectProvider('anthropic')}
});
});
for(let i=0;i<14;i++){const e=document.createElement('div');e.className='ember';
e.style.left=Math.random()*100+'vw';e.style.background=Math.random()>.5?'#f2a93c':'#f0674a';
e.style.animationDuration=(8+Math.random()*7)+'s';e.style.animationDelay=(Math.random()*7)+'s';document.body.appendChild(e);}
bootIntro();
</script>
</body>
</html>