← Commit history
dashboard/kicad_dashboard.py+70−1
@@ -49,7 +49,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse -VERSION = "2.1.4"+VERSION = "2.2.0" AI_THREAD = "kicad-dashboard" DEFAULT_PORT = 8931 UI_DIR = Path(__file__).resolve().parent / "ui"@@ -61,6 +61,67 @@ UI_DIR = Path(__file__).resolve().parent / "ui" # window tile and the taskbar badge all agree with KiCad itself. LOGO_PATH = UI_DIR / "kicad-logo.png" +# "Say it" (John via the dock thread, 2026-08-23): teach users how to ASK for+# this app by its noun, first and foremost, at the top of the dashboard. The+# list lives in dockbar.json (`say`) so the dock's interstitial and the+# dashboard teach the same phrases; the built-in copy is the fallback for an+# installed dashboard that ships without the manifest beside it.+_SAY_FALLBACK = [+    "open the symbol in kicad",+    "send this footprint to kicad",+    "show me the board in kicad 3d",+    "open my project in kicad",+    "install this part into kicad",+    "export gerbers from kicad",+]+++def say_phrases():+    for cand in (Path(__file__).resolve().parent.parent / "dockbar.json",+                 Path.home() / ".local" / "share" / "adom" / "kicad-bridge" / "dockbar.json"):+        try:+            lst = json.loads(cand.read_text(encoding="utf-8")).get("say")+            if isinstance(lst, list) and lst:+                return [str(x) for x in lst][:6]+        except Exception:+            continue+    return list(_SAY_FALLBACK)+++HYDROGEN_CONTROL = "http://127.0.0.1:47084"+++def stage_phrase(phrase):+    """'Try it': stage the phrase (unsubmitted) into this app's own AI thread+    through the Hydrogen control API. Reachable from WSL2 under mirrored+    networking; from a cloud container it is not, and the page then falls back+    to copying. Never raises; returns {staged, reason, sid}."""+    import urllib.request+    tag = "app:adom/kicad-bridge"+    try:+        with urllib.request.urlopen(+                f"{HYDROGEN_CONTROL}/ai-threads/history?tag={tag}", timeout=2) as r:+            hist = json.loads(r.read().decode("utf-8") or "{}")+    except Exception as e:+        return {"staged": False, "reason": f"hydrogen control api unreachable ({e.__class__.__name__})"}+    threads = hist.get("threads") or hist.get("items") or hist.get("data") or []+    sid = None+    for t in threads if isinstance(threads, list) else []:+        sid = t.get("sid") or t.get("id")+        if sid:+            break+    if not sid:+        return {"staged": False, "reason": f"no AI thread tagged {tag} yet"}+    try:+        body = json.dumps({"sid": sid, "prompt": phrase, "submit": False}).encode("utf-8")+        req = urllib.request.Request(f"{HYDROGEN_CONTROL}/ai-threads/run-prompt", data=body,+                                     method="POST", headers={"Content-Type": "application/json"})+        with urllib.request.urlopen(req, timeout=4) as r:+            r.read()+        return {"staged": True, "sid": sid}+    except Exception as e:+        return {"staged": False, "reason": f"run-prompt failed ({e.__class__.__name__})", "sid": sid}+  # ---------------------------------------------------------------- bridge calls def ab(target, verb, args=None, timeout=140):@@ -893,6 +954,14 @@ def do_action(target, name, args):         if mode not in ("progress", "flash", "both", "none"):             return {"success": False, "error": f"unknown mode {mode}"}         return ab(target, "kicad_set_settings", {"activityIndicator": mode}, timeout=60)+    if name == "say":+        return {"success": True, "phrases": say_phrases()}+    if name == "try_phrase":+        phrase = str(args.get("phrase") or "").strip()+        if not phrase:+            return {"success": False, "error": "phrase required"}+        r = stage_phrase(phrase)+        return {"success": True, "phrase": phrase, **r}     if name == "restore_settings":         return ab(target, "kicad_set_settings", {"restoreDefaults": True}, timeout=60)     if name == "part_tour":
dashboard/ui/index.html+56
@@ -34,6 +34,23 @@ header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;backg .iconbtn:hover{background:var(--elevated);border-color:var(--border)} .iconbtn:active{transform:scale(.9);background:var(--overlay)} main{max-width:1080px;margin:0 auto;padding:20px 16px 60px}+/* "Say it": the first thing on the page (John, 2026-08-23: teach users how to ASK for the app by its noun, first and foremost). */+.say{background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;padding:14px 16px}+.say-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin-bottom:10px}+.say-head h2{font-size:17px}+.say-sub{font-size:12.5px;color:var(--text-2);max-width:62ch}+.say-sub b{color:var(--text);font-weight:600}+.say-list{display:flex;flex-wrap:wrap;gap:8px}+.say-item{display:inline-flex;align-items:stretch;border:1px solid var(--border);border-radius:999px;background:var(--elevated);overflow:hidden;transition:border-color .15s ease}+.say-item:hover{border-color:var(--accent)}+.say-phrase{display:inline-flex;align-items:center;padding:7px 12px 7px 14px;background:none;border:none;color:var(--text);font:500 14px var(--brand-body);cursor:pointer;transition:background .15s ease,transform .06s ease}+.say-phrase em{font-style:normal;color:var(--accent)}+.say-phrase:hover{background:var(--overlay)}+.say-phrase:active{transform:translateY(1px) scale(.98)}+.say-try{display:inline-flex;align-items:center;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--text-2);font:600 12px var(--brand-heading);cursor:pointer;transition:background .15s ease,color .15s ease}+.say-try:hover{background:var(--overlay);color:var(--accent)}+.say-try:active{transform:translateY(1px)}+.say-phrase:focus-visible,.say-try:focus-visible{outline:2px solid var(--accent);outline-offset:-2px} h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);margin:26px 0 12px} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px} .card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px}@@ -189,6 +206,14 @@ main a:hover{border-bottom-color:var(--accent)}   </div> </header> <main>+  <section class="say" id="say" aria-labelledby="sayTitle">+    <div class="say-head">+      <h2 id="sayTitle" style="margin:0">Say it to your AI</h2>+      <span class="say-sub">You do not drive this dashboard. You ask your AI, using the word <b>kicad</b>. Click a phrase to copy it; Try it stages it into this app's AI thread.</span>+    </div>+    <div class="say-list" id="sayList"></div>+  </section>+   <h2>Status</h2>   <div id="targets"></div>   <div class="note" id="boxnote" style="display:none">Many boxes, one bridge: pick where KiCad should run. The bridge only ever drives the KiCad instances IT launched, so your own KiCad is never touched.</div>@@ -431,6 +456,36 @@ $('#btnPartTour').onclick=async()=>{   else{toast('part installed and project generated','ok');$('#partLabel').textContent=(d.steps||[]).join(' · ');refresh(true);} }; +// "Say it": phrases come from dockbar.json `say` via the server, so the dock+// interstitial and this strip teach the same words. Copy on click; Try it+// stages the phrase into the app's AI thread when the Hydrogen control API is+// reachable, and copies with an honest toast when it is not.+async function buildSay(){+  let phrases=[];+  try{const r=await act('say');phrases=((r.data||r).phrases)||[];}catch(e){}+  const list=$('#sayList');list.innerHTML='';+  if(!phrases.length){$('#say').style.display='none';return;}+  for(const p of phrases){+    const item=document.createElement('div');item.className='say-item';+    const b=document.createElement('button');b.className='say-phrase';b.type='button';+    b.setAttribute('data-tip','Copy this phrase');+    b.innerHTML='&ldquo;'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'&rdquo;';+    b.onclick=async()=>{try{await navigator.clipboard.writeText(p);toast('copied: '+p,'ok');}catch(e){toast('could not copy: '+e,'err');}};+    const t=document.createElement('button');t.className='say-try';t.type='button';t.textContent='Try it';+    t.setAttribute('data-tip','Stage this phrase in this app\'s AI thread (not submitted, you press Enter)');+    t.onclick=async()=>{+      t.disabled=true;+      try{+        const r=await act('try_phrase',{phrase:p});const d=r.data||r;+        if(d.staged){toast('staged in your AI thread: '+p+' (press Enter there to send)','ok');}+        else{try{await navigator.clipboard.writeText(p);}catch(e){}+          toast('copied instead: '+(d.reason||'could not reach your AI thread')+'. Paste it into your AI.','');}+      }catch(e){toast('try failed: '+e,'err');}+      finally{t.disabled=false;}+    };+    item.appendChild(b);item.appendChild(t);list.appendChild(item);+  }+} // ---- Settings (settings-layout skill: modal, four dismissals, optimistic saves) ---- // The taskbar captures are native DEVICE pixels (2560-wide display at 150%). // Show them at naturalWidth / devicePixelRatio so they render 1:1 - the same@@ -704,6 +759,7 @@ ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove( // every box, so one wedged laptop left the whole page on "connecting..." with // no LEDs and no pills). Skeleton now, then status and targets INDEPENDENTLY. renderSkeleton();+buildSay(); buildSurfaces(); refresh(); loadTargets();
dockbar.json+9−1
@@ -1,5 +1,13 @@ {   "dock_manifest": "1.2.0",+  "say": [+    "open the symbol in kicad",+    "send this footprint to kicad",+    "show me the board in kicad 3d",+    "open my project in kicad",+    "install this part into kicad",+    "export gerbers from kicad"+  ],   "webview": {     "title": "KiCad Dashboard",     "favicon": "docs/img/dock-kicad.svg"@@ -35,4 +43,4 @@       "typed": false     }   }-}\ No newline at end of file+}
install.sh+3
@@ -32,6 +32,9 @@ for asset in index.html kicad-logo.png; do done # Settings screenshots (real captures) + the shared Adom cog: the whole tree, # so a new shot never needs an installer edit to ship.+# dockbar.json beside the installed app: the dashboard reads its `say` list+# from it so the dock interstitial and the "Say it" strip teach the same words.+[ -f "$H/dockbar.json" ] && cp -f "$H/dockbar.json" "$APPDIR/dockbar.json" if [ -d "$H/dashboard/ui/assets" ]; then   rm -rf "$APPDIR/dashboard/ui/assets"   cp -R "$H/dashboard/ui/assets" "$APPDIR/dashboard/ui/assets"
package.json+3−2
@@ -1,6 +1,6 @@ {   "slug": "kicad-bridge",-  "version": "1.0.56",+  "version": "1.0.57",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [@@ -48,7 +48,8 @@     "dashboard/ui/assets/icons/settings-cog.svg",     "kicad-bridge-hero-v6.png",     "docs/img/dock-kicad.svg",-    "tools/capture_taskbar_shots.py"+    "tools/capture_taskbar_shots.py",+    "dockbar.json"   ],   "hero": {     "path": "kicad-bridge-hero-v6.png"
page.json+3−2
@@ -4,7 +4,7 @@   "slug": "kicad-bridge",   "title": "KiCad - the KiCad Bridge",   "brief": "Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.",-  "version": "1.0.56",+  "version": "1.0.57",   "tags": [     "kicad",     "pcb",@@ -107,7 +107,8 @@     "dashboard/ui/assets/icons/settings-cog.svg",     "kicad-bridge-hero-v6.png",     "docs/img/dock-kicad.svg",-    "tools/capture_taskbar_shots.py"+    "tools/capture_taskbar_shots.py",+    "dockbar.json"   ],   "org": "adom",   "name": "kicad-bridge",