agent-harness 0.4.0

Drive LLM coding agents — Claude Code, OpenAI Codex, and local Ollama / OpenAI-compatible models — from Rust behind one trait, with a normalized streaming event vocabulary. Bring your own agent too.
Documentation
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>harness-local playground</title>
<style>
  :root {
    --bg:#0d1017; --panel:#151a23; --panel-2:#1b2230; --border:#232b39;
    --text:#d7dce5; --muted:#828c9c; --faint:#5b6473;
    --accent:#4c8bf5; --green:#3fb950; --amber:#d6a020; --red:#f0584b;
    --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
  }
  * { box-sizing:border-box; }
  body { margin:0; background:var(--bg); color:var(--text);
    font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
  a { color:var(--accent); }

  header { position:sticky; top:0; z-index:5; display:flex; gap:10px; align-items:center; flex-wrap:wrap;
    padding:10px 16px; background:rgba(13,16,23,.85); backdrop-filter:blur(8px); border-bottom:1px solid var(--border); }
  header .brand { font-weight:650; letter-spacing:.2px; margin-right:4px; }
  header .brand b { color:var(--accent); }
  .field { display:flex; align-items:center; background:var(--panel); border:1px solid var(--border);
    border-radius:8px; padding:0 4px 0 10px; }
  .field input { background:transparent; border:0; color:var(--text); padding:7px 6px; font:inherit; outline:none; }
  .field select { background:var(--panel); border:0; color:var(--text); padding:7px 4px; font:inherit; outline:none; cursor:pointer; }
  .field input::placeholder { color:var(--faint); }
  .iconbtn { background:transparent; border:0; color:var(--muted); cursor:pointer; font-size:15px; padding:6px 8px; border-radius:6px; }
  .iconbtn:hover { background:var(--panel-2); color:var(--text); }
  .toggle { display:flex; align-items:center; gap:6px; color:var(--muted); font-size:13px; user-select:none; }
  .ghost { background:var(--panel); color:var(--muted); border:1px solid var(--border); border-radius:8px; padding:7px 12px; cursor:pointer; font:inherit; }
  .ghost:hover { color:var(--text); border-color:#33405a; }
  .status { color:var(--faint); font-size:12px; margin-left:auto; display:flex; align-items:center; gap:6px; }
  .status .spin { width:11px; height:11px; border:2px solid var(--faint); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; display:none; }
  .status.busy .spin { display:inline-block; }
  @keyframes spin { to { transform:rotate(360deg); } }

  #log { max-width:820px; margin:0 auto; padding:22px 16px 120px; }
  .msg { margin:18px 0; }
  .msg.user { display:flex; justify-content:flex-end; }
  .msg.user .bubble { background:var(--accent); color:#fff; padding:9px 13px; border-radius:14px 14px 4px 14px; max-width:80%; white-space:pre-wrap; box-shadow:0 1px 3px rgba(0,0,0,.3); }
  .assistant { display:flex; flex-direction:column; gap:8px; }

  details.think { border:1px solid var(--border); border-radius:9px; background:var(--panel); overflow:hidden; }
  details.think > summary { cursor:pointer; list-style:none; padding:7px 12px; color:var(--muted); font-size:13px; }
  details.think > summary::-webkit-details-marker { display:none; }
  details.think > summary::before { content:""; color:var(--faint); }
  details.think[open] > summary::before { content:""; }
  details.think .body { padding:2px 13px 11px 24px; color:var(--muted); font-style:italic; white-space:pre-wrap; font-size:13.5px; border-top:1px solid var(--border); }

  .tool { border:1px solid var(--border); border-radius:9px; background:var(--panel); border-left:3px solid var(--faint); overflow:hidden; }
  .tool.running { border-left-color:var(--amber); }
  .tool.ok { border-left-color:var(--green); }
  .tool.err { border-left-color:var(--red); }
  .tool .head { display:flex; align-items:center; gap:8px; padding:8px 12px; }
  .tool .dot { width:8px; height:8px; border-radius:50%; background:var(--faint); flex:none; }
  .tool.running .dot { background:var(--amber); animation:pulse 1s ease-in-out infinite; }
  .tool.ok .dot { background:var(--green); }
  .tool.err .dot { background:var(--red); }
  @keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:.35;} }
  .tool .tname { font-family:var(--mono); font-size:13px; color:#e8c987; }
  .tool .tsubj { font-family:var(--mono); font-size:12.5px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .tool .tstat { margin-left:auto; font-size:12px; color:var(--muted); }
  .tool details { border-top:1px solid var(--border); }
  .tool summary { cursor:pointer; list-style:none; padding:5px 12px; font-size:12px; color:var(--faint); }
  .tool summary::-webkit-details-marker { display:none; }
  .tool summary::before { content:""; }
  .tool details[open] summary::before { content:""; }
  .tool pre { margin:0; padding:8px 12px 12px; font-family:var(--mono); font-size:12px; line-height:1.45;
    color:#c2cad6; white-space:pre-wrap; word-break:break-word; max-height:340px; overflow:auto; background:var(--panel-2); }
  .tool details.filepreview > summary { color:var(--accent); }
  .tool .fpbody { padding:9px 13px; max-height:360px; overflow:auto; background:var(--panel-2);
    font-size:13px; color:#c2cad6; white-space:pre-wrap; word-break:break-word; font-family:var(--mono); }
  .tool .fpbody.md { white-space:normal; font-family:inherit; }

  .md { } /* rendered markdown */
  .md > :first-child { margin-top:0; } .md > :last-child { margin-bottom:0; }
  .md h1,.md h2,.md h3 { line-height:1.3; margin:18px 0 8px; }
  .md h1 { font-size:1.5em; border-bottom:1px solid var(--border); padding-bottom:5px; }
  .md h2 { font-size:1.25em; } .md h3 { font-size:1.08em; }
  .md p { margin:9px 0; } .md ul,.md ol { margin:9px 0; padding-left:24px; } .md li { margin:3px 0; }
  .md code { font-family:var(--mono); font-size:.88em; background:var(--panel-2); padding:1px 5px; border-radius:5px; }
  .md pre { background:var(--panel-2); border:1px solid var(--border); border-radius:8px; padding:12px; overflow:auto; }
  .md pre code { background:none; padding:0; }
  .md a { text-decoration:none; } .md a:hover { text-decoration:underline; }

  .plan { border:1px solid var(--border); border-radius:9px; background:var(--panel); padding:9px 13px; }
  .plan .ptitle { font-size:12px; color:var(--muted); margin-bottom:5px; text-transform:uppercase; letter-spacing:.4px; }
  .plan .row { display:flex; gap:9px; align-items:baseline; padding:2px 0; font-size:14px; }
  .plan .row .pi { flex:none; width:14px; color:var(--faint); }
  .plan .row.completed { color:var(--muted); } .plan .row.completed .pi { color:var(--green); }
  .plan .row.inProgress .pi { color:var(--amber); }
  .plan .row.cancelled { color:var(--faint); text-decoration:line-through; }
  .meta { color:var(--faint); font-size:12px; }
  .err { color:var(--red); white-space:pre-wrap; }

  form { position:fixed; bottom:0; left:0; right:0; display:flex; gap:10px; padding:14px 16px;
    background:linear-gradient(transparent, var(--bg) 22%); }
  form .wrap { max-width:820px; margin:0 auto; width:100%; display:flex; gap:10px; }
  textarea { flex:1; background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:11px;
    padding:11px 13px; resize:none; font:inherit; outline:none; max-height:180px; }
  textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(76,139,245,.18); }
  button.send { background:var(--accent); color:#fff; border:0; border-radius:11px; padding:0 22px; cursor:pointer; font:inherit; font-weight:600; }
  button.send:disabled { opacity:.45; cursor:default; }

  .modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.55); z-index:20; align-items:center; justify-content:center; }
  .modal .box { background:var(--panel); border:1px solid var(--border); border-radius:12px; width:min(560px,92vw); max-height:80vh; display:flex; flex-direction:column; box-shadow:0 12px 40px rgba(0,0,0,.5); }
  .modal .top { display:flex; align-items:center; gap:8px; padding:12px 14px; border-bottom:1px solid var(--border); }
  .modal .top strong { flex:1; }
  .modal .path { font-family:var(--mono); font-size:12px; color:var(--muted); padding:8px 14px; border-bottom:1px solid var(--border); word-break:break-all; }
  .modal .list { overflow:auto; padding:6px; }
  .modal .item { padding:7px 10px; border-radius:7px; cursor:pointer; color:var(--text); font-size:14px; }
  .modal .item:hover { background:var(--panel-2); }
  .modal .foot { padding:12px 14px; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:8px; }
  button.primary { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:8px 16px; cursor:pointer; font:inherit; }
</style>
</head>
<body>
<header>
  <span class="brand"><b>harness</b>-local</span>
  <span class="field"><select id="harness" title="which harness to drive">
    <option value="">OpenAI-compatible</option>
    <option value="acp">ACP · opencode</option>
    <option value="acp-gemini">ACP · gemini</option>
  </select></span>
  <span class="field"><input id="model" list="modelList" value="qwen2.5:7b-instruct" size="18" title="model id"></span><datalist id="modelList"></datalist>
  <span class="field"><input id="endpoint" placeholder="endpoint (Ollama default)" size="20" title="OpenAI-compatible base URL — blank = Ollama; e.g. http://localhost:2276 for Llamabarn"></span>
  <span class="field" style="min-width:230px">
    <input id="cwd" placeholder="working dir (default: scratch)" style="flex:1" title="agent working directory">
    <button class="iconbtn" title="Browse folders" onclick="openPicker()">📁</button>
  </span>
  <label class="toggle"><input type="checkbox" id="edit" checked> Edit mode</label>
  <button class="ghost" id="reset">New chat</button>
  <span class="status" id="status"><span class="spin"></span><span id="statusText"></span></span>
</header>

<div id="log"></div>

<form id="f"><div class="wrap">
  <textarea id="prompt" rows="1" placeholder="Ask the agent…   (Enter to send · Shift+Enter for newline)"></textarea>
  <button class="send" id="send">Send</button>
</div></form>

<div class="modal" id="picker">
  <div class="box">
    <div class="top"><strong>Pick a working folder</strong><button class="iconbtn" onclick="closePicker()"></button></div>
    <div class="path" id="pickerPath"></div>
    <div class="list" id="pickerList"></div>
    <div class="foot"><button class="ghost" onclick="closePicker()">Cancel</button><button class="primary" onclick="usePicker()">Use this folder</button></div>
  </div>
</div>

<script>
const log = document.getElementById('log');
const statusEl = document.getElementById('status');
const statusText = document.getElementById('statusText');
const promptEl = document.getElementById('prompt');
let session = null, es = null;

function setStatus(t, busy){ statusText.textContent = t || ''; statusEl.classList.toggle('busy', !!busy); }
function scroll(){ window.scrollTo({ top: document.body.scrollHeight }); }
function elem(tag, cls){ const d=document.createElement(tag); if(cls) d.className=cls; return d; }

// --- minimal, dependency-free markdown → HTML (escaped first, so it's safe) ---
function esc(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function inlineMd(s){
  return s
    .replace(/`([^`]+)`/g, '<code>$1</code>')
    .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
    .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
    .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
}
function renderMd(src){
  const lines = esc(src).split('\n');
  let out='', inCode=false, code='', list=null;
  const closeList=()=>{ if(list){ out+='</'+list+'>'; list=null; } };
  for(const raw of lines){
    if(raw.trimStart().startsWith('```')){
      if(inCode){ out+='<pre><code>'+code+'</code></pre>'; code=''; inCode=false; }
      else { closeList(); inCode=true; }
      continue;
    }
    if(inCode){ code+=raw+'\n'; continue; }
    let m;
    if(m=raw.match(/^(#{1,6})\s+(.*)$/)){ closeList(); const n=m[1].length; out+='<h'+n+'>'+inlineMd(m[2])+'</h'+n+'>'; continue; }
    if(m=raw.match(/^\s*[-*]\s+(.*)$/)){ if(list!=='ul'){ closeList(); out+='<ul>'; list='ul'; } out+='<li>'+inlineMd(m[1])+'</li>'; continue; }
    if(m=raw.match(/^\s*\d+\.\s+(.*)$/)){ if(list!=='ol'){ closeList(); out+='<ol>'; list='ol'; } out+='<li>'+inlineMd(m[1])+'</li>'; continue; }
    if(raw.trim()===''){ closeList(); continue; }
    closeList(); out+='<p>'+inlineMd(raw)+'</p>';
  }
  closeList(); if(inCode) out+='<pre><code>'+code+'</code></pre>';
  return out;
}

// A concise one-line subject for a tool call, pulled from its arguments.
function toolSubject(input){
  try { const a = JSON.parse(input || '{}');
    return a.path || a.command || a.pattern || a.query || a.url || a.name || a.subagent_type || ''; }
  catch(e){ return ''; }
}
function pretty(input){ try { return JSON.stringify(JSON.parse(input), null, 2); } catch(e){ return input; } }

function addDetails(parent, label, text){
  const d=elem('details'); const s=elem('summary'); s.textContent=label; d.appendChild(s);
  const p=elem('pre'); p.textContent=text; d.appendChild(p); parent.appendChild(d);
}

// The file a tool call created/edited, so the page can link to + preview it (the
// "artifact"). Path from the structured ToolEnd locations (our tools), else
// parsed from opencode's <path>…</path>. Only file ops (not list/glob/grep/bash),
// and never a directory.
function fileForPreview(ev){
  const name=(ev.title||'').toLowerCase();
  if(!['write','edit','apply_patch','create','read'].includes(name)) return null;
  let path = (ev.locations && ev.locations.length===1) ? ev.locations[0].path : '';
  let type='';
  if(ev.content){ const mp=ev.content.match(/<path>([^<]+)<\/path>/); if(!path && mp) path=mp[1].trim();
                  const mt=ev.content.match(/<type>([^<]+)<\/type>/); if(mt) type=mt[1].trim(); }
  if(!path || type==='directory') return null;
  return path;
}
// A lazy file-preview disclosure: fetches /file on first open, rendering markdown
// for .md/.txt and plain text otherwise (server scopes the read to the run dir).
function addPreview(card, path){
  const d=elem('details','filepreview'); const s=elem('summary');
  s.textContent='📄 '+path.split('/').pop(); d.appendChild(s);
  const body=elem('div','fpbody'); body.textContent='loading…'; d.appendChild(body);
  let loaded=false;
  d.addEventListener('toggle', async ()=>{
    if(!d.open || loaded) return; loaded=true;
    const cwd=document.getElementById('cwd').value.trim();
    let url='/file?path='+encodeURIComponent(path); if(cwd) url+='&cwd='+encodeURIComponent(cwd);
    try {
      const r=await (await fetch(url)).json();
      if(r.error){ body.classList.remove('md'); body.textContent=''+r.error; return; }
      if(/\.(md|markdown|txt)$/i.test(path)){ body.classList.add('md'); body.innerHTML=renderMd(r.content); }
      else { body.classList.remove('md'); body.textContent=r.content; }
    } catch(e){ body.textContent=''+e; }
  });
  card.appendChild(d);
}

document.getElementById('reset').addEventListener('click', () => { session=null; log.innerHTML=''; setStatus('new chat'); });

// The endpoint field is the OpenAI-compatible base URL, so it applies only to
// archetype A (Ollama / custom) — greyed for any ACP agent. The model field
// applies whenever the selected harness offers a model list: archetype A always
// (free-text + datalist), opencode via `opencode models`. A generic ACP agent
// (gemini) manages its own model, so its model field is greyed (agent-managed).
let availableModels=[];   // the loaded list — a run falls back to availableModels[0]
let modelLoadSeq=0;       // guards a slow earlier load from clobbering a newer one
async function loadModels(){
  const seq=++modelLoadSeq;
  const harness=document.getElementById('harness').value;
  const acp=harness.startsWith('acp');
  const mi=document.getElementById('model');
  const dl=document.getElementById('modelList'); dl.innerHTML=''; availableModels=[];
  // Switching harness/endpoint: clear the stale model and show a loading state
  // until the new list arrives, so an old model never lingers under a new harness.
  mi.value=''; mi.disabled=true; mi.style.opacity='0.4'; mi.placeholder='loading models…';
  const endpoint=document.getElementById('endpoint').value.trim();
  let url='/models?harness='+encodeURIComponent(harness);
  if(endpoint) url+='&base='+encodeURIComponent(endpoint);
  let models=[];
  try { models=((await (await fetch(url)).json()).models)||[]; } catch(e){}
  if(seq!==modelLoadSeq) return;   // a newer load started while we awaited — drop this
  availableModels=models;
  models.forEach(m=>{ const o=document.createElement('option'); o.value=m; dl.appendChild(o); });
  // Archetype A always accepts a (free-text) model; an ACP agent only when it lists
  // models (opencode). Leave the field empty so the full list shows on click; the
  // placeholder hints the default that runs if nothing is picked (a run falls back
  // to models[0]). No list + ACP ⇒ the agent manages its own model.
  const modelEnabled = !acp || models.length>0;
  mi.disabled=!modelEnabled; mi.style.opacity=modelEnabled?'1':'0.4';
  mi.placeholder = !modelEnabled ? 'agent-managed'
                 : models.length ? models[0]+' (default)' : 'model';
}
function syncHarnessFields(){
  const acp = document.getElementById('harness').value.startsWith('acp');
  const ep = document.getElementById('endpoint');
  ep.disabled = acp; ep.style.opacity = acp ? '0.4' : '1';
  loadModels();   // sets the model field's enabled state from the live list
}
document.getElementById('harness').addEventListener('change', syncHarnessFields);
document.getElementById('endpoint').addEventListener('change', loadModels);
syncHarnessFields();
document.getElementById('f').addEventListener('submit', send);
promptEl.addEventListener('input', () => { promptEl.style.height='auto'; promptEl.style.height=Math.min(promptEl.scrollHeight,180)+'px'; });
promptEl.addEventListener('keydown', e => { if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(e); } });

function send(e){
  e.preventDefault();
  const prompt = promptEl.value.trim();
  if(!prompt || es) return;
  promptEl.value=''; promptEl.style.height='auto';
  const u=elem('div','msg user'); const b=elem('div','bubble'); b.textContent=prompt; u.appendChild(b); log.appendChild(u);
  const asst=elem('div','msg assistant'); log.appendChild(asst); scroll();

  const harnessSel=document.getElementById('harness').value;
  const modelEl=document.getElementById('model');
  // Typed model, else fall back to the first available (the placeholder's default).
  // A disabled field (agent-managed, e.g. gemini) sends nothing → the agent decides.
  const model = modelEl.disabled ? '' : (modelEl.value.trim() || availableModels[0] || '');
  const endpoint=document.getElementById('endpoint').value.trim();
  const cwd=document.getElementById('cwd').value.trim();
  const mode=document.getElementById('edit').checked?'edit':'ask';
  let url='/run?prompt='+encodeURIComponent(prompt)+'&mode='+mode;
  if(harnessSel){
    url+='&harness='+harnessSel;   // opencode takes a launch-time model; gemini decides
    if(model) url+='&model='+encodeURIComponent(model);
  } else {
    if(model) url+='&model='+encodeURIComponent(model);
    if(endpoint) url+='&base='+encodeURIComponent(endpoint);
  }
  if(cwd) url+='&cwd='+encodeURIComponent(cwd);
  if(session) url+='&session='+encodeURIComponent(session);
  document.getElementById('send').disabled=true; setStatus('running', true);

  let textDiv=null, thinkBody=null, planEl=null; const tools={};
  es=new EventSource(url);
  es.onmessage = ev => {
    const e2=JSON.parse(ev.data);
    switch(e2.kind){
      case 'session': session=e2.sessionId; break;
      case 'thinking': {
        if(!thinkBody){ const d=elem('details','think'); const s=elem('summary'); s.textContent='Thinking'; d.appendChild(s); thinkBody=elem('div','body'); d.appendChild(thinkBody); asst.appendChild(d); }
        thinkBody.textContent+=e2.delta; textDiv=null; scroll(); break;
      }
      case 'text': {
        if(!textDiv){ textDiv=elem('div','md'); textDiv._raw=''; asst.appendChild(textDiv); }
        textDiv._raw+=e2.delta; textDiv.innerHTML=renderMd(textDiv._raw); thinkBody=null; scroll(); break;
      }
      case 'toolStart': {
        const card=elem('div','tool running'); const head=elem('div','head');
        head.innerHTML='<span class="dot"></span><span class="tname">'+esc(e2.title||'tool')+'</span>';
        // Subject: prefer ACP's structured locations (the path it touches), else
        // parse raw_input — so `read /tmp` vs `read /tmp/404.html` are distinct.
        const subj=(e2.locations&&e2.locations.length) ? e2.locations.map(l=>l.path).join(', ') : toolSubject(e2.rawInput);
        if(subj){ const s=elem('span','tsubj'); s.textContent=subj; head.appendChild(s); }
        const st=elem('span','tstat'); st.textContent='running…'; head.appendChild(st); card.appendChild(head);
        if(e2.rawInput && e2.rawInput!=='{}') addDetails(card,'input',pretty(e2.rawInput));
        asst.appendChild(card); tools[e2.toolCallId]={card,st,title:e2.title,locations:e2.locations}; textDiv=null; thinkBody=null; scroll(); break;
      }
      case 'toolEnd': {
        const t=tools[e2.toolCallId]; if(!t) break;
        t.card.className='tool '+(e2.ok?'ok':'err'); t.st.textContent=e2.ok?'✓ done':'✗ failed';
        // Surface the created/edited file as a previewable artifact (path from the
        // ToolEnd locations, else the toolStart's; opencode → parsed from content).
        const merged={ title:t.title, content:e2.content,
          locations:(e2.locations&&e2.locations.length)?e2.locations:t.locations };
        const fp = e2.ok ? fileForPreview(merged) : null;
        if(fp) addPreview(t.card, fp);
        if(e2.content!=null && e2.content!=='') addDetails(t.card, e2.ok?'output':'error', e2.content);
        if(e2.rawOutput) addDetails(t.card,'raw output',e2.rawOutput);
        scroll(); break;
      }
      case 'plan': {
        // `todowrite` replaces the list each call, so update one live checklist.
        if(!planEl){ planEl=elem('div','plan'); asst.appendChild(planEl); }
        const icon = s => ({pending:'', inProgress:'', completed:'', cancelled:''}[s] || '');
        planEl.innerHTML = '<div class="ptitle">Plan</div>' + (e2.entries||[]).map(x =>
          '<div class="row '+(x.status||'pending')+'"><span class="pi">'+icon(x.status)+'</span><span>'+esc(x.content||'')+'</span></div>'
        ).join('');
        textDiv=null; thinkBody=null; scroll(); break;
      }
      case 'askQuestion': { const c=elem('div','tool running'); const h=elem('div','head'); h.innerHTML='<span class="dot"></span><span class="tname">question</span><span class="tstat">answer below</span>'; c.appendChild(h); let t=''; (e2.questions||[]).forEach(q=>{ t+=(q.prompt||'')+'\n'; (q.options||[]).forEach(o=>{ t+=''+o.label+(o.description?''+o.description:'')+'\n'; }); }); addDetails(c,'options',t); asst.appendChild(c); scroll(); break; }
      case 'activity': { const m=elem('div','meta'); m.textContent=e2.message; asst.appendChild(m); scroll(); break; }
      case 'usage': { const p=[]; if(e2.inputTokens!=null)p.push('in '+e2.inputTokens); if(e2.outputTokens!=null)p.push('out '+e2.outputTokens); if(e2.cacheReadTokens!=null)p.push('cache '+e2.cacheReadTokens); if(e2.costUsd!=null)p.push('$'+e2.costUsd.toFixed(5)); const m=elem('div','meta'); m.textContent='usage · '+p.join(' · '); asst.appendChild(m); break; }
      case 'error': { const m=elem('div','err'); m.textContent=''+e2.message; asst.appendChild(m); break; }
      case 'exited': { es.close(); es=null; document.getElementById('send').disabled=false; setStatus(e2.cancelled?'cancelled':'done'); promptEl.focus(); break; }
    }
  };
  es.onerror = () => { if(es){ es.close(); es=null; document.getElementById('send').disabled=false; setStatus('connection closed'); } };
}

// --- folder picker (server-side directory browser) ---
let pickerPath=null;
function openPicker(){ document.getElementById('picker').style.display='flex'; loadDirs(document.getElementById('cwd').value.trim()||null); }
function closePicker(){ document.getElementById('picker').style.display='none'; }
function usePicker(){ if(pickerPath) document.getElementById('cwd').value=pickerPath; closePicker(); }
async function loadDirs(path){
  let url='/dirs'; if(path) url+='?path='+encodeURIComponent(path);
  let d;
  try { const r=await fetch(url); if(!r.ok) throw new Error('HTTP '+r.status); d=await r.json(); }
  catch(err){
    document.getElementById('pickerPath').textContent='⚠ couldn’t reach /dirs ('+err+') — open http://127.0.0.1:8765 directly (not the preview panel).';
    document.getElementById('pickerList').innerHTML='';
    return;
  }
  pickerPath=d.path; document.getElementById('pickerPath').textContent=d.path;
  const list=document.getElementById('pickerList'); list.innerHTML='';
  if(d.parent){ const it=elem('div','item'); it.textContent='📁  ..'; it.onclick=()=>loadDirs(d.parent); list.appendChild(it); }
  (d.dirs||[]).forEach(name=>{ const it=elem('div','item'); it.textContent='📁  '+name; it.onclick=()=>loadDirs(d.path.replace(/\/?$/,'/')+name); list.appendChild(it); });
  if(!d.dirs || !d.dirs.length){ const it=elem('div','meta'); it.style.padding='8px 10px'; it.textContent='(no sub-folders)'; list.appendChild(it); }
}
document.getElementById('picker').addEventListener('click', e => { if(e.target.id==='picker') closePicker(); });
</script>
</body>
</html>