diff --git a/.gitea/workflows/helmd-image.yaml b/.gitea/workflows/helmd-image.yaml
new file mode 100644
index 0000000..7eac204
--- /dev/null
+++ b/.gitea/workflows/helmd-image.yaml
@@ -0,0 +1,32 @@
+name: helmd-image
+
+# Bygger helmd-imagen (arm64, nativt på Pi-runnern) och pushar till den
+# lokala registryn. Deployas som stack "helmd" i Dockge (LAN-only,
+# ingen NPM-vhost).
+on:
+ push:
+ branches: [master, main]
+ paths:
+ - "helmd/**"
+ - ".gitea/workflows/helmd-image.yaml"
+ workflow_dispatch: {}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Build image
+ run: |
+ docker build \
+ --build-arg "VERSION=latest-${GITHUB_SHA::7}" \
+ -t localhost:5000/helmd:latest \
+ -t "localhost:5000/helmd:${GITHUB_SHA::12}" \
+ helmd/
+
+ - name: Push image
+ run: |
+ docker push localhost:5000/helmd:latest
+ docker push "localhost:5000/helmd:${GITHUB_SHA::12}"
diff --git a/doc/plan.md b/doc/plan.md
index 47c2908..39afbf2 100644
--- a/doc/plan.md
+++ b/doc/plan.md
@@ -41,11 +41,22 @@ webb-PWA (LAN/tunnel) ─────┼──► helmd :8788 (Go, REST+SSE, tok
(event-pushar + `POST /api/notify`, topics `agent-helm`/`claude`).
- Isolerat integrationstest i Docker (26 tester), CI-release
`helmd-latest` (x64 + arm64).
-2. ⬜ **Webbklient v2** (`packages/web`): porta till helmd-API:t.
- - Sessionslista, skärmvy (poll + SSE), promptfält, frågekort med
- knappar, läges-/inställningsvy.
- - Delningsflik: html/md renderas, png/jpg visas, md till html.
- - Uppdaterad look. Serveras statiskt av helmd (embed) eller nginx.
+2. ✅ **Webbklient v2** (`helmd/web/`): **ramverksfri** ES-modul-SPA,
+ inbäddad i binären med `go:embed` — hela v2 är fortfarande EN fil,
+ ingen Node-toolchain i CI. (Beslut: vanilla i stället för Vue-port —
+ UI-ytan är liten, eslint/byggkedjan försvinner, agenter underhåller
+ koden lättare.)
+ - Token-login (localStorage), sessionslista med status/❓-badge,
+ ANSI-färgad skärmvy (SSE + poll-fallback), promptfält,
+ **frågekort med knappar**, tangent-verktygsrad (Esc/pilar/Enter/
+ ^C/Läge), delningsflik (bilder, sandboxad HTML-iframe, egen
+ md-renderare), inställningsvy mot `PUT /api/config`.
+ - Browser-verifierad mot riktig agy: prompt → godkännandefråga →
+ svar via kort → kommandot kört; delningar renderade.
+ - Deploy på Pi5: docker-stack `helmd` (arm64-image via
+ `.gitea/workflows/helmd-image.yaml` → `localhost:5000/helmd`),
+ **LAN-only — ingen NPM-vhost**; sessioner kör bash/agenter i
+ containern.
3. ⬜ **Android-klient**: nytt projekt eller Capacitor-port av PWA:n.
- Egen SSH-tunnel (t.ex. sshj/JSch) → Pi5 → helmd; token +
värdprofil i appen; push via ntfy-appen eller UnifiedPush.
diff --git a/helmd/.dockerignore b/helmd/.dockerignore
new file mode 100644
index 0000000..4841b63
--- /dev/null
+++ b/helmd/.dockerignore
@@ -0,0 +1,4 @@
+build/
+scripts/
+testdata/
+README.md
diff --git a/helmd/Dockerfile b/helmd/Dockerfile
new file mode 100644
index 0000000..8302b3f
--- /dev/null
+++ b/helmd/Dockerfile
@@ -0,0 +1,15 @@
+# helmd-image för Pi5 (byggs nativt arm64 på Gitea-runnern).
+# Innehåller tmux + bash så att sessioner kan köras direkt i containern.
+FROM golang:1.24-alpine AS build
+WORKDIR /src
+COPY . .
+ARG VERSION=dev
+RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o /helmd .
+
+FROM alpine:3.21
+RUN apk add --no-cache tmux bash curl ca-certificates coreutils
+COPY --from=build /helmd /usr/local/bin/helmd
+ENV HELMD_CONFIG=/data/config.json
+VOLUME /data
+EXPOSE 8788
+CMD ["helmd", "serve", "--listen", "0.0.0.0:8788"]
diff --git a/helmd/README.md b/helmd/README.md
index c121520..8ca0444 100644
--- a/helmd/README.md
+++ b/helmd/README.md
@@ -107,6 +107,25 @@ kommandogodkännande) och mot mock i integrationstestet.
}
```
+## Webbklienten
+
+Inbäddad i binären (`web/` + `go:embed`) och serveras på `/` — surfa
+till servern (t.ex. `http://127.0.0.1:8788` genom en SSH-tunnel) och
+klistra in din token. Ramverksfri (ren ES-moduler): sessionslista,
+ANSI-färgad skärmvy (SSE + poll), promptfält, frågekort med knappar,
+tangent-verktygsrad, delningsflik (bilder, sandboxad HTML, markdown)
+och inställningar. Statiska filer kräver ingen auth (koden är publik);
+allt data går genom det token-skyddade API:et.
+
+## Docker på Pi5 (LAN-only)
+
+`Dockerfile` bygger en arm64-image med tmux+bash via
+`.gitea/workflows/helmd-image.yaml` → `localhost:5000/helmd:latest`.
+Stack: `/srv/dockge-staks/helmd/` med host-port **8788** och volym
+`/srv/docker/helmd → /data`. **Ingen NPM-vhost** — nås bara på LAN
+(`http://192.168.0.19:8788`) eller via SSH-tunnel utifrån. Sessionerna
+kör i containern (bash tills en agent installeras där).
+
## Bygga & testa
```bash
diff --git a/helmd/api.go b/helmd/api.go
index a552473..4bd9e71 100644
--- a/helmd/api.go
+++ b/helmd/api.go
@@ -356,6 +356,8 @@ func serve(cfg *Config) error {
return err
}
api := &API{cfg: cfg, mgr: mgr, shares: shares, ntfy: ntfy}
+ mux := api.routes()
+ mountWeb(mux)
log.Printf("helmd %s lyssnar på http://%s (token i %s)", version, cfg.Listen, cfg.path)
- return http.ListenAndServe(cfg.Listen, api.routes())
+ return http.ListenAndServe(cfg.Listen, mux)
}
diff --git a/helmd/scripts/integration-test.sh b/helmd/scripts/integration-test.sh
index 6abd95e..dbebca8 100755
--- a/helmd/scripts/integration-test.sh
+++ b/helmd/scripts/integration-test.sh
@@ -43,6 +43,13 @@ CODE=$(curl -s -o /dev/null -w '%{http_code}' $API/api/sessions)
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$API/api/sessions?token=$TOKEN")
[ "$CODE" = 200 ] && ok "query-token => 200" || fail "query-token gav $CODE"
+say "webbklient (inbäddad)"
+curl -s $API/ > /tmp/ui.html
+expect_contains /tmp/ui.html '
agent-helm' "index.html serveras utan auth"
+curl -s $API/app.js | grep -q 'ansiToHtml' && ok "app.js serveras" || fail "app.js"
+CODE=$(curl -s -o /dev/null -w '%{http_code}' $API/style.css)
+[ "$CODE" = 200 ] && ok "style.css => 200" || fail "style.css gav $CODE"
+
say "stäng av ntfy för testet (PUT /api/config)"
curl_api -X PUT -d '{"ntfy":{"server":"https://ntfy.brasse-pc.eu","topic":"agent-helm","allowed_topics":["agent-helm","claude"],"enabled":false,"notify_on_question":false}}' \
$API/api/config > /tmp/cfg.json
diff --git a/helmd/web/ansi.js b/helmd/web/ansi.js
new file mode 100644
index 0000000..5ba93ce
--- /dev/null
+++ b/helmd/web/ansi.js
@@ -0,0 +1,78 @@
+// Minimal ANSI(SGR) -> HTML. Stödjer 16/256-färg, truecolor, bold/dim/
+// invers/understruket. Allt annat (cursorstyrning m.m.) strippas —
+// helmd skickar capture-pane-text, så bara färgkoder förekommer.
+
+const BASE = [
+ "#1c1f26", "#e5605c", "#3fca7c", "#e0a63f",
+ "#4f9cf9", "#b877d9", "#3fc4c4", "#cfd6e4",
+ "#5b6472", "#ff7b76", "#5fe09a", "#ffc65c",
+ "#7bc4ff", "#d49df0", "#6ee0e0", "#ffffff",
+];
+
+function color256(n) {
+ n = Number(n);
+ if (n < 16) return BASE[n];
+ if (n < 232) {
+ const c = n - 16, v = [0, 95, 135, 175, 215, 255];
+ return `rgb(${v[(c / 36) | 0]},${v[((c / 6) | 0) % 6]},${v[c % 6]})`;
+ }
+ const g = 8 + (n - 232) * 10;
+ return `rgb(${g},${g},${g})`;
+}
+
+const esc = (s) => s.replace(/&/g, "&").replace(//g, ">");
+
+export function ansiToHtml(text) {
+ let out = "", open = false;
+ const st = { fg: null, bg: null, bold: false, dim: false, ul: false, inv: false };
+
+ const flushSpan = () => { if (open) { out += ""; open = false; } };
+ const openSpan = () => {
+ const css = [];
+ let fg = st.fg, bg = st.bg;
+ if (st.inv) [fg, bg] = [bg || "#cfd6e4", fg || "#05070b"];
+ if (fg) css.push(`color:${fg}`);
+ if (bg) css.push(`background:${bg}`);
+ if (st.bold) css.push("font-weight:700");
+ if (st.dim) css.push("opacity:.6");
+ if (st.ul) css.push("text-decoration:underline");
+ if (css.length) { out += ``; open = true; }
+ };
+
+ // dela på escape-sekvenser; behåll SGR, släng övriga
+ const parts = text.split(/(\x1b\[[0-9;:]*[A-Za-z])/);
+ for (const p of parts) {
+ if (!p) continue;
+ if (p.startsWith("\x1b[")) {
+ if (!p.endsWith("m")) continue; // icke-SGR: ignorera
+ const codes = p.slice(2, -1).split(/[;:]/).map((x) => (x === "" ? 0 : Number(x)));
+ for (let i = 0; i < codes.length; i++) {
+ const c = codes[i];
+ if (c === 0) { st.fg = st.bg = null; st.bold = st.dim = st.ul = st.inv = false; }
+ else if (c === 1) st.bold = true;
+ else if (c === 2) st.dim = true;
+ else if (c === 4) st.ul = true;
+ else if (c === 7) st.inv = true;
+ else if (c === 22) { st.bold = st.dim = false; }
+ else if (c === 24) st.ul = false;
+ else if (c === 27) st.inv = false;
+ else if (c >= 30 && c <= 37) st.fg = BASE[c - 30];
+ else if (c === 38 && codes[i + 1] === 5) { st.fg = color256(codes[i + 2]); i += 2; }
+ else if (c === 38 && codes[i + 1] === 2) { st.fg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`; i += 4; }
+ else if (c === 39) st.fg = null;
+ else if (c >= 40 && c <= 47) st.bg = BASE[c - 40];
+ else if (c === 48 && codes[i + 1] === 5) { st.bg = color256(codes[i + 2]); i += 2; }
+ else if (c === 48 && codes[i + 1] === 2) { st.bg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`; i += 4; }
+ else if (c === 49) st.bg = null;
+ else if (c >= 90 && c <= 97) st.fg = BASE[c - 90 + 8];
+ else if (c >= 100 && c <= 107) st.bg = BASE[c - 100 + 8];
+ }
+ flushSpan();
+ openSpan();
+ } else {
+ out += esc(p.replace(/\x1b./g, ""));
+ }
+ }
+ flushSpan();
+ return out;
+}
diff --git a/helmd/web/app.js b/helmd/web/app.js
new file mode 100644
index 0000000..b5ecf01
--- /dev/null
+++ b/helmd/web/app.js
@@ -0,0 +1,335 @@
+import { ansiToHtml } from "./ansi.js";
+
+// ---------- state ----------
+let token = localStorage.getItem("helmd_token") || "";
+let sessions = [];
+let current = null; // valt sessionsnamn
+let currentSeq = -1;
+let shares = [];
+let currentShare = null;
+let es = null; // EventSource
+let pollTimer = null;
+
+const $ = (id) => document.getElementById(id);
+
+// ---------- api ----------
+async function api(path, opts = {}) {
+ opts.headers = Object.assign({ Authorization: "Bearer " + token }, opts.headers);
+ const r = await fetch(path, opts);
+ if (r.status === 401) { showLogin("Ogiltig token."); throw new Error("401"); }
+ if (!r.ok) {
+ let msg = r.statusText;
+ try { msg = (await r.json()).error || msg; } catch {}
+ throw new Error(msg);
+ }
+ const ct = r.headers.get("content-type") || "";
+ return ct.includes("json") ? r.json() : r.text();
+}
+const post = (p, body) => api(p, { method: "POST", body: JSON.stringify(body) });
+
+// ---------- login ----------
+function showLogin(err = "") {
+ stopLive();
+ $("login").classList.remove("hidden");
+ $("app").classList.add("hidden");
+ $("loginError").textContent = err;
+}
+
+async function tryConnect() {
+ try {
+ const h = await api("/api/health");
+ $("login").classList.add("hidden");
+ $("app").classList.remove("hidden");
+ $("verInfo").textContent = "helmd " + h.version;
+ startLive();
+ await Promise.all([refreshSessions(), refreshShares(), loadConfig()]);
+ if (!current && sessions.length) selectSession(sessions[0].name);
+ } catch (e) {
+ if (e.message !== "401") showLogin("Kunde inte nå servern: " + e.message);
+ }
+}
+
+$("tokenSave").onclick = () => {
+ token = $("tokenInput").value.trim();
+ localStorage.setItem("helmd_token", token);
+ tryConnect();
+};
+$("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") $("tokenSave").click(); });
+$("logoutBtn").onclick = () => { localStorage.removeItem("helmd_token"); token = ""; showLogin(); };
+
+// ---------- tabs ----------
+document.querySelectorAll(".tab").forEach((b) => {
+ b.onclick = () => {
+ document.querySelectorAll(".tab").forEach((x) => x.classList.toggle("active", x === b));
+ ["sessions", "shares", "settings"].forEach((t) =>
+ $("tab-" + t).classList.toggle("hidden", b.dataset.tab !== t));
+ if (b.dataset.tab === "shares") { $("shareBadge").classList.add("hidden"); $("shareBadge").textContent = ""; }
+ };
+});
+
+// ---------- SSE + poll ----------
+function startLive() {
+ stopLive();
+ es = new EventSource("/api/events?token=" + encodeURIComponent(token));
+ es.onopen = () => $("connDot").classList.add("ok");
+ es.onerror = () => $("connDot").classList.remove("ok");
+ es.addEventListener("screen", (e) => {
+ const ev = JSON.parse(e.data);
+ if (ev.session === current) refreshScreen();
+ refreshSessions();
+ });
+ es.addEventListener("question", (e) => {
+ const ev = JSON.parse(e.data);
+ if (ev.session === current) renderQuestion(ev.data);
+ });
+ es.addEventListener("session", () => refreshSessions());
+ es.addEventListener("share", (e) => {
+ refreshShares();
+ const b = $("shareBadge");
+ if ($("tab-shares").classList.contains("hidden")) {
+ b.textContent = String(Number(b.textContent || 0) + 1);
+ b.classList.remove("hidden");
+ }
+ });
+ // fallback-poll om SSE tappas
+ pollTimer = setInterval(() => {
+ if (es && es.readyState === 2) { startLive(); return; }
+ if (current) refreshScreen();
+ }, 5000);
+}
+function stopLive() {
+ if (es) { es.close(); es = null; }
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+ $("connDot").classList.remove("ok");
+}
+
+// ---------- sessioner ----------
+async function refreshSessions() {
+ sessions = (await api("/api/sessions")) || [];
+ sessions.sort((a, b) => a.name.localeCompare(b.name));
+ const box = $("sessItems");
+ box.innerHTML = "";
+ for (const s of sessions) {
+ const d = document.createElement("div");
+ d.className = "sess-item" + (s.name === current ? " active" : "");
+ d.innerHTML = `
+ ${s.name}${s.question ? '❓' : ""}`;
+ d.onclick = () => selectSession(s.name);
+ box.appendChild(d);
+ }
+ if (current) {
+ const cur = sessions.find((s) => s.name === current);
+ if (!cur) { current = null; $("sessPane").classList.add("hidden"); $("noSess").classList.remove("hidden"); }
+ else if (cur.seq !== currentSeq) refreshScreen();
+ }
+}
+
+async function selectSession(name) {
+ current = name; currentSeq = -1;
+ $("noSess").classList.add("hidden");
+ $("sessPane").classList.remove("hidden");
+ $("sessTitle").textContent = name;
+ const s = sessions.find((x) => x.name === name);
+ renderQuestion(s && s.question);
+ await refreshScreen();
+ refreshSessions();
+}
+
+async function refreshScreen() {
+ if (!current) return;
+ try {
+ const s = sessions.find((x) => x.name === current);
+ if (s) currentSeq = s.seq;
+ const txt = await api(`/api/sessions/${current}/screen?ansi=1`);
+ const el = $("screen");
+ const stick = el.scrollTop + el.clientHeight >= el.scrollHeight - 30;
+ el.innerHTML = ansiToHtml(txt);
+ if (stick) el.scrollTop = el.scrollHeight;
+ const st = await api(`/api/sessions/${current}`);
+ renderQuestion(st.question);
+ } catch {}
+}
+
+function renderQuestion(q) {
+ const card = $("questionCard");
+ if (!q) { card.classList.add("hidden"); card.innerHTML = ""; return; }
+ card.classList.remove("hidden");
+ card.innerHTML = `❓ ${escapeHtml(q.text)}
`;
+ const opts = card.querySelector(".opts");
+ q.options.forEach((o, i) => {
+ const b = document.createElement("button");
+ b.textContent = (o.num ? o.num + ". " : "") + o.label;
+ if (i === q.selected) b.classList.add("selected");
+ b.onclick = async () => {
+ b.disabled = true;
+ try { await post(`/api/sessions/${current}/answer`, { option: i + 1 }); }
+ catch (e) { alert("Kunde inte svara: " + e.message); b.disabled = false; }
+ };
+ opts.appendChild(b);
+ });
+}
+
+$("newSessBtn").onclick = () => $("newSessForm").classList.toggle("hidden");
+$("newSessCancel").onclick = () => $("newSessForm").classList.add("hidden");
+$("newSessForm").onsubmit = async (e) => {
+ e.preventDefault();
+ try {
+ await post("/api/sessions", {
+ name: $("newSessName").value.trim(),
+ cmd: $("newSessCmd").value.trim(),
+ cwd: $("newSessCwd").value.trim(),
+ });
+ $("newSessForm").reset();
+ $("newSessForm").classList.add("hidden");
+ await refreshSessions();
+ selectSession($("newSessName").value.trim() || sessions[sessions.length - 1]?.name);
+ } catch (err) { alert(err.message); }
+};
+
+$("killBtn").onclick = async () => {
+ if (!current || !confirm(`Döda sessionen ${current}?`)) return;
+ await api(`/api/sessions/${current}`, { method: "DELETE" });
+ current = null;
+ refreshSessions();
+ $("sessPane").classList.add("hidden");
+ $("noSess").classList.remove("hidden");
+};
+
+const sendKeys = (keys) => current && post(`/api/sessions/${current}/keys`, { keys }).catch(() => {});
+$("keyEsc").onclick = () => sendKeys(["Escape"]);
+$("keyUp").onclick = () => sendKeys(["Up"]);
+$("keyDown").onclick = () => sendKeys(["Down"]);
+$("keyEnter").onclick = () => sendKeys(["Enter"]);
+$("keyCtrlC").onclick = () => sendKeys(["C-c"]);
+$("modeBtn").onclick = () => current && post(`/api/sessions/${current}/mode`, { action: "cycle" }).catch(() => {});
+
+$("promptForm").onsubmit = async (e) => {
+ e.preventDefault();
+ const t = $("promptText").value;
+ if (!t.trim() || !current) return;
+ try {
+ await post(`/api/sessions/${current}/prompt`, { text: t });
+ $("promptText").value = "";
+ } catch (err) { alert("Kunde inte skicka: " + err.message); }
+};
+$("promptText").addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ $("promptForm").requestSubmit();
+ }
+});
+
+// ---------- delningar ----------
+async function refreshShares() {
+ shares = (await api("/api/shares")) || [];
+ const box = $("shareItems");
+ box.innerHTML = "";
+ for (const sh of shares) {
+ const d = document.createElement("div");
+ d.className = "share-item" + (currentShare === sh.id ? " active" : "");
+ const when = new Date(sh.created).toLocaleString("sv-SE", { dateStyle: "short", timeStyle: "short" });
+ d.innerHTML = `
+ ${escapeHtml(sh.name)}
+ ${when}${sh.session ? " · " + escapeHtml(sh.session) : ""}${sh.note ? "
" + escapeHtml(sh.note) : ""}
`;
+ d.querySelector(".del").onclick = async (e) => {
+ e.stopPropagation();
+ await api(`/api/shares/${sh.id}`, { method: "DELETE" });
+ if (currentShare === sh.id) { currentShare = null; $("shareView").innerHTML = "Välj en delning i listan"; }
+ refreshShares();
+ };
+ d.onclick = () => viewShare(sh);
+ box.appendChild(d);
+ }
+}
+
+async function viewShare(sh) {
+ currentShare = sh.id;
+ refreshShares();
+ const v = $("shareView");
+ v.classList.remove("empty");
+ const raw = `/api/shares/${sh.id}/raw?token=${encodeURIComponent(token)}`;
+ const ext = sh.name.toLowerCase().split(".").pop();
+ if (["png", "jpg", "jpeg", "gif", "svg"].includes(ext)) {
+ v.innerHTML = `
`;
+ } else if (["html", "htm", "pdf"].includes(ext)) {
+ v.innerHTML = ``;
+ } else if (ext === "md") {
+ const txt = await api(`/api/shares/${sh.id}/raw`);
+ v.innerHTML = `${mdToHtml(txt)}
`;
+ } else {
+ const txt = await api(`/api/shares/${sh.id}/raw`);
+ v.innerHTML = `${escapeHtml(txt)}`;
+ }
+}
+
+// ---------- inställningar ----------
+async function loadConfig() {
+ const c = await api("/api/config");
+ $("cfgCmd").value = c.agent_cmd || "";
+ $("cfgArgs").value = (c.agent_args || []).join(" ");
+ $("cfgWorkdir").value = c.workdir || "";
+ $("cfgPoll").value = c.poll_ms || 500;
+ $("cfgNtfyOn").checked = !!c.ntfy?.enabled;
+ $("cfgNtfyQ").checked = !!c.ntfy?.notify_on_question;
+ $("cfgNtfyServer").value = c.ntfy?.server || "";
+ $("cfgNtfyTopic").value = c.ntfy?.topic || "";
+ window._ntfyAllowed = c.ntfy?.allowed_topics || ["agent-helm", "claude"];
+}
+
+$("cfgForm").onsubmit = async (e) => {
+ e.preventDefault();
+ try {
+ await api("/api/config", {
+ method: "PUT",
+ body: JSON.stringify({
+ agent_cmd: $("cfgCmd").value.trim(),
+ agent_args: $("cfgArgs").value.trim() ? $("cfgArgs").value.trim().split(/\s+/) : [],
+ workdir: $("cfgWorkdir").value.trim(),
+ poll_ms: Number($("cfgPoll").value),
+ ntfy: {
+ enabled: $("cfgNtfyOn").checked,
+ notify_on_question: $("cfgNtfyQ").checked,
+ server: $("cfgNtfyServer").value.trim(),
+ topic: $("cfgNtfyTopic").value.trim(),
+ allowed_topics: window._ntfyAllowed,
+ },
+ }),
+ });
+ $("cfgStatus").textContent = "Sparat ✓";
+ setTimeout(() => ($("cfgStatus").textContent = ""), 2500);
+ } catch (err) { $("cfgStatus").textContent = "Fel: " + err.message; }
+};
+
+// ---------- hjälp ----------
+function escapeHtml(s) {
+ return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+
+// Miniminimal markdown: rubriker, fet/kursiv, kod, listor, länkar, hr.
+function mdToHtml(md) {
+ const blocks = md.split(/\n{2,}/);
+ return blocks.map((b) => {
+ if (/^```/.test(b)) return `${escapeHtml(b.replace(/^```\w*\n?|```$/g, ""))}`;
+ if (/^#{1,6} /.test(b)) {
+ const n = b.match(/^#+/)[0].length;
+ return `${inline(b.replace(/^#+ /, ""))}`;
+ }
+ if (/^([-*] |\d+\. )/m.test(b)) {
+ const items = b.split("\n").filter(Boolean).map((l) => `${inline(l.replace(/^([-*]|\d+\.) /, ""))}`).join("");
+ return /^\d/.test(b) ? `${items}
` : ``;
+ }
+ if (/^(-{3,}|_{3,}|\*{3,})$/.test(b.trim())) return "
";
+ return `${inline(b)}
`;
+ }).join("\n");
+ function inline(s) {
+ return escapeHtml(s)
+ .replace(/`([^`]+)`/g, "$1")
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/\*([^*]+)\*/g, "$1")
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1')
+ .replace(/\n/g, "
");
+ }
+}
+
+// ---------- start ----------
+if (token) tryConnect(); else showLogin();
diff --git a/helmd/web/index.html b/helmd/web/index.html
new file mode 100644
index 0000000..b80188d
--- /dev/null
+++ b/helmd/web/index.html
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+agent-helm
+
+
+
+
+
+
+
+
⛵ agent-helm
+
Klistra in API-token från servern (helmd token).
+
+
+
+
+
+
+
+
+
+
+
diff --git a/helmd/web/manifest.json b/helmd/web/manifest.json
new file mode 100644
index 0000000..55ce948
--- /dev/null
+++ b/helmd/web/manifest.json
@@ -0,0 +1,17 @@
+{
+ "name": "agent-helm",
+ "short_name": "helm",
+ "description": "Fjärrstyr CLI-agenter (agy m.fl.) via helmd",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0b0e14",
+ "theme_color": "#0b0e14",
+ "icons": [
+ {
+ "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%230b0e14'/%3E%3Ctext x='50' y='72' font-size='60' text-anchor='middle'%3E⛵%3C/text%3E%3C/svg%3E",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any"
+ }
+ ]
+}
diff --git a/helmd/web/style.css b/helmd/web/style.css
new file mode 100644
index 0000000..2d6553a
--- /dev/null
+++ b/helmd/web/style.css
@@ -0,0 +1,165 @@
+:root {
+ --bg: #0b0e14;
+ --bg2: #12161f;
+ --bg3: #1a2029;
+ --border: #262d3a;
+ --fg: #d6dbe5;
+ --muted: #8a93a5;
+ --accent: #4f9cf9;
+ --accent2: #7bc4ff;
+ --green: #3fca7c;
+ --red: #e5605c;
+ --amber: #e0a63f;
+ --radius: 10px;
+ font-size: 15px;
+}
+* { box-sizing: border-box; }
+html, body { height: 100%; margin: 0; }
+body {
+ background: var(--bg); color: var(--fg);
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+.hidden { display: none !important; }
+button {
+ background: var(--bg3); color: var(--fg); border: 1px solid var(--border);
+ border-radius: 7px; padding: 6px 12px; cursor: pointer; font: inherit;
+}
+button:hover { border-color: var(--accent); }
+button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+button.primary:hover { background: var(--accent2); }
+button.danger { color: var(--red); }
+button.danger:hover { border-color: var(--red); }
+input, textarea {
+ background: var(--bg2); color: var(--fg); border: 1px solid var(--border);
+ border-radius: 7px; padding: 8px 10px; font: inherit; width: 100%;
+}
+input:focus, textarea:focus { outline: none; border-color: var(--accent); }
+code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; }
+.error { color: var(--red); min-height: 1.2em; }
+.muted { color: var(--muted); }
+
+/* ---- login ---- */
+.login {
+ height: 100%; display: flex; align-items: center; justify-content: center;
+}
+.login-card {
+ background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 32px; width: min(420px, 92vw); display: flex; flex-direction: column; gap: 12px;
+}
+.login-card h1 { margin: 0 0 4px; font-size: 1.5rem; }
+.login-card p { margin: 0; color: var(--muted); }
+
+/* ---- app frame ---- */
+.app { height: 100%; display: flex; flex-direction: column; }
+header {
+ display: flex; align-items: center; gap: 16px;
+ padding: 10px 16px; background: var(--bg2); border-bottom: 1px solid var(--border);
+}
+.brand { font-weight: 600; }
+nav { display: flex; gap: 4px; flex: 1; }
+.tab { border: none; background: none; color: var(--muted); padding: 8px 12px; position: relative; }
+.tab.active { color: var(--fg); }
+.tab.active::after {
+ content: ""; position: absolute; left: 10px; right: 10px; bottom: -1px;
+ height: 2px; background: var(--accent); border-radius: 2px;
+}
+.badge {
+ background: var(--accent); color: #fff; border-radius: 9px;
+ font-size: 0.7rem; padding: 1px 6px; margin-left: 6px;
+}
+.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--red); }
+.dot.ok { background: var(--green); }
+
+.tab-page { flex: 1; min-height: 0; display: flex; }
+
+/* ---- sessions ---- */
+.sess-list {
+ width: 240px; border-right: 1px solid var(--border); background: var(--bg2);
+ display: flex; flex-direction: column; padding: 10px; gap: 8px; overflow-y: auto;
+}
+.sess-head { display: flex; justify-content: space-between; align-items: center; color: var(--muted); }
+.sess-item {
+ padding: 10px 12px; border-radius: 7px; cursor: pointer; border: 1px solid transparent;
+ display: flex; align-items: center; gap: 8px;
+}
+.sess-item:hover { background: var(--bg3); }
+.sess-item.active { background: var(--bg3); border-color: var(--border); }
+.sess-item .dot { width: 8px; height: 8px; flex: none; }
+.sess-item .q { color: var(--amber); margin-left: auto; }
+#newSessForm { display: flex; flex-direction: column; gap: 8px; }
+.row { display: flex; gap: 8px; align-items: center; }
+
+.sess-view { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+.empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--muted); }
+#sessPane { flex: 1; min-height: 0; display: flex; flex-direction: column; }
+.sess-toolbar {
+ display: flex; gap: 6px; align-items: center; padding: 8px 12px;
+ border-bottom: 1px solid var(--border); flex-wrap: wrap;
+}
+.sess-toolbar .spacer { flex: 1; }
+.sess-toolbar button { padding: 4px 9px; font-size: 0.85rem; }
+#sessTitle { font-weight: 600; }
+
+.screen {
+ flex: 1; margin: 0; padding: 12px; overflow: auto; background: #05070b;
+ font: 12.5px/1.35 ui-monospace, "JetBrains Mono", Consolas, monospace;
+ white-space: pre; color: #cfd6e4;
+}
+#promptForm { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border); }
+#promptForm textarea { resize: vertical; }
+
+/* ---- question card ---- */
+.question {
+ margin: 10px 12px 0; padding: 14px; border: 1px solid var(--amber);
+ border-radius: var(--radius); background: color-mix(in srgb, var(--amber) 8%, var(--bg2));
+}
+.question h3 { margin: 0 0 10px; font-size: 0.95rem; }
+.question .opts { display: flex; flex-direction: column; gap: 6px; }
+.question .opts button {
+ text-align: left; white-space: normal;
+}
+.question .opts button.selected { border-color: var(--amber); }
+.question .opts button:hover { border-color: var(--accent); }
+
+/* ---- shares ---- */
+.shares-wrap { flex: 1; display: flex; min-height: 0; }
+.share-list {
+ width: 280px; border-right: 1px solid var(--border); background: var(--bg2);
+ overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 6px;
+}
+.share-item {
+ padding: 10px 12px; border-radius: 7px; cursor: pointer; border: 1px solid transparent;
+}
+.share-item:hover, .share-item.active { background: var(--bg3); border-color: var(--border); }
+.share-item .name { font-weight: 600; word-break: break-all; }
+.share-item .meta { color: var(--muted); font-size: 0.8rem; }
+.share-item .del { float: right; }
+.share-view { flex: 1; min-width: 0; overflow: auto; padding: 16px; }
+.share-view iframe {
+ width: 100%; height: 100%; border: 1px solid var(--border); border-radius: 7px; background: #fff;
+}
+.share-view img { max-width: 100%; border-radius: 7px; }
+.share-view pre {
+ background: var(--bg2); padding: 14px; border-radius: 7px; overflow-x: auto;
+}
+.md { max-width: 820px; line-height: 1.55; }
+.md h1, .md h2, .md h3 { border-bottom: 1px solid var(--border); padding-bottom: 4px; }
+.md pre { background: var(--bg2); padding: 12px; border-radius: 7px; overflow-x: auto; }
+.md code { background: var(--bg3); }
+.md a { color: var(--accent2); }
+
+/* ---- settings ---- */
+.cfg { max-width: 560px; padding: 24px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
+.cfg h2 { margin: 12px 0 2px; font-size: 1.05rem; }
+.cfg label { display: flex; flex-direction: column; gap: 4px; color: var(--muted); font-size: 0.9rem; }
+.cfg label.check { flex-direction: row; align-items: center; gap: 8px; }
+.cfg input[type="checkbox"] { width: auto; }
+
+/* ---- mobil ---- */
+@media (max-width: 720px) {
+ .tab-page { flex-direction: column; }
+ .sess-list { width: 100%; max-height: 30vh; border-right: none; border-bottom: 1px solid var(--border); }
+ .shares-wrap { flex-direction: column; }
+ .share-list { width: 100%; max-height: 30vh; }
+ .screen { font-size: 11px; }
+}
diff --git a/helmd/webfs.go b/helmd/webfs.go
new file mode 100644
index 0000000..27044ca
--- /dev/null
+++ b/helmd/webfs.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+ "embed"
+ "io/fs"
+ "net/http"
+)
+
+// Webbklienten byggs in i binären — hela v2 är fortfarande en enda fil.
+//
+//go:embed web
+var webFiles embed.FS
+
+// mountWeb serves the embedded UI on / (the /api/* routes are more
+// specific and win in the mux).
+func mountWeb(mux *http.ServeMux) {
+ sub, err := fs.Sub(webFiles, "web")
+ if err != nil {
+ panic(err)
+ }
+ fileServer := http.FileServerFS(sub)
+ mux.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // UI:t behöver ingen auth (koden är publik i repot); API:t
+ // skyddas separat. Strikt CSP så att inget externt kan läcka in.
+ w.Header().Set("Content-Security-Policy",
+ "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'")
+ fileServer.ServeHTTP(w, r)
+ }))
+}