app
KiCad - the KiCad Bridge
Public Made by Adomby adom
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.
← Commit history
Publish 1.0.99
4 files changed
+73−22
dashboard/kicad_dashboard.py+31−17@@ -49,7 +49,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse -VERSION = "4.5.0"+VERSION = "4.6.0" AI_THREAD = "kicad-dashboard" DEFAULT_PORT = 8931 UI_DIR = Path(__file__).resolve().parent / "ui"@@ -126,6 +126,35 @@ def _ver_tuple(v): return tuple(out[:3]) or (0,) +def _proxy_control(handler, path, body=None, timeout=20):+ """Forward one control-API call VERBATIM: same status, same bytes.++ Both halves matter. Re-serialising the JSON (json.loads then json.dumps)+ changes key order and whitespace, and hardcoding 200 hides an upstream 207+ or 404 from the caller - the module decides what to do from the status and+ the body it actually gets, so a proxy that "helpfully" normalises either is+ lying to it. Non-2xx arrives as HTTPError and is forwarded, not swallowed.+ (Proven with assets/tools/proxy-fidelity.py, which reds a re-serialising+ proxy in its own self-test before it judges yours.)"""+ import urllib.request, urllib.error+ base = _hydrogen_control_url()+ if not base:+ return handler._json({"ok": False, "why": "no control api on this machine"}, 503)+ req = urllib.request.Request(+ base + path, method="POST" if body is not None else "GET",+ data=json.dumps(body).encode("utf-8") if body is not None else None,+ headers={"Content-Type": "application/json"} if body is not None else {})+ try:+ with urllib.request.urlopen(req, timeout=timeout) as r:+ return handler._send(r.status, r.headers.get("Content-Type", "application/json"),+ r.read())+ except urllib.error.HTTPError as e:+ return handler._send(e.code, e.headers.get("Content-Type", "application/json"),+ e.read())+ except Exception as e:+ return handler._json({"ok": False, "why": f"{e.__class__.__name__}: {e}"}, 502)++ def say_send(phrase): """Send-to-AI (un-parked 2026-08-24 on John's ruling, after the ah build thread certified the run-prompt crash family dead on the gate-fixed build).@@ -1253,22 +1282,7 @@ class Handler(BaseHTTPRequestHandler): log(f"viewport {LAST_VIEWPORT.get('innerWidth')}px cols={LAST_VIEWPORT.get('panelCols')}") return self._json({"ok": True}) if path.endswith("/ai-threads/run-prompt"):- # Proxy for the shared module's send path. The module owns the- # contract (one call, new:true, delivered_to / inject three-state);- # we only carry the bytes to the dynamic control port.- base = _hydrogen_control_url()- if not base:- return self._json({"ok": False, "why": "no AI panel on this machine"}, 503)- try:- import urllib.request- req = urllib.request.Request(- base + "/ai-threads/run-prompt",- data=json.dumps(body or {}).encode("utf-8"), method="POST",- headers={"Content-Type": "application/json"})- with urllib.request.urlopen(req, timeout=125) as r:- return self._send(200, "application/json", r.read())- except Exception as e:- return self._json({"ok": False, "why": f"{e.__class__.__name__}: {e}"}, 502)+ return _proxy_control(self, "/ai-threads/run-prompt", body=body or {}, timeout=125) if path.endswith("/api/say-send"): phrase = str((body or {}).get("phrase") or "").strip() if not phrase:
dashboard/ui/newbie-panel.js+40−3@@ -139,6 +139,35 @@ return safe.replace(re, '<b>$1</b>'); } + /* ---- copy, with a fallback that does not need the async clipboard ------+ * navigator.clipboard.writeText needs a secure context AND, in several embedded webviews,+ * transient user activation plus a clipboard-write permission that is simply absent. In+ * ah's webview it rejects with NotAllowedError, so the ONLY path the panel had would fail+ * and the user got "Could not reach the clipboard" on a button that looks like it works.+ * The execCommand path is deprecated but synchronous, needs no permission, and works in+ * exactly the contexts the async API refuses. Try the modern one, fall back, and only then+ * report failure. */+ function copyText(text) {+ return new Promise(function (resolve, reject) {+ function legacy() {+ try {+ var ta = document.createElement('textarea');+ ta.value = text;+ ta.setAttribute('readonly', '');+ ta.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';+ document.body.appendChild(ta);+ ta.select(); ta.setSelectionRange(0, ta.value.length);+ var ok = document.execCommand && document.execCommand('copy');+ ta.remove();+ ok ? resolve('legacy') : reject(new Error('execCommand refused'));+ } catch (e) { reject(e); }+ }+ if (navigator.clipboard && navigator.clipboard.writeText) {+ navigator.clipboard.writeText(text).then(function () { resolve('async'); }, legacy);+ } else legacy();+ });+ }+ /* ---- toasts ---------------------------------------------------------- */ var toastHost = null; function toast(msg, kind, ms) {@@ -275,7 +304,7 @@ tipFor(cp, 'Copy this phrase'); cp.addEventListener('click', function (e) { e.stopPropagation();- (navigator.clipboard ? navigator.clipboard.writeText(phrase) : Promise.reject())+ copyText(phrase) .then(function () { toast('Copied. Paste it to your AI.', 'ok'); }) .catch(function () { toast('Could not reach the clipboard. Select the phrase and copy it.', 'err'); }); });@@ -450,8 +479,16 @@ * adopted the pattern I had recommended; parts-search found the documentElement one.) */ var sc = panel.parentElement; while (sc && sc !== document.body) {+ /* A DECLARED scroller is the pane whether or not it is scrolling RIGHT NOW. Requiring+ * it to overflow was a bug with a nastier shape than the two before it: with+ * scrollbar-gutter:stable (which this pack mandates, so results arriving cause no+ * reflow) the track is reserved even when nothing overflows, so the loop walked PAST+ * the real pane, fell back to documentElement, and reported the right inset over by+ * one scrollbar width. The SAME PAGE passed when populated and failed when empty, and+ * empty is a dashboard's first-load state. Predicted from a code read by parts-search+ * and confirmed here: 16 left / 31 right, exact. */ var so = getComputedStyle(sc).overflowY;- if ((so === 'auto' || so === 'scroll') && sc.scrollHeight > sc.clientHeight) break;+ if (so === 'auto' || so === 'scroll') break; sc = sc.parentElement; } var paneLeft, paneW;@@ -477,6 +514,6 @@ return { ok: ok, rows: rows }; } - root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, version: '2.5.0',+ root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText, version: '2.8.0', TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "kicad-bridge",- "version": "1.0.98",+ "version": "1.0.99", "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": [
page.json+1−1@@ -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.98",+ "version": "1.0.99", "tags": [ "kicad", "pcb",