diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 41a9f51..94b2149 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,13 @@ on:
branches: [ master ]
paths: &build-paths
- '**.nix'
+ # Sources of the generated modules/agent-box.nix (issue #140): a change
+ # here can reassemble to the same bytes (a pure refactor), so it would
+ # not touch a *.nix file — match them explicitly so the module-generated-
+ # up-to-date guard (and the rest of CI) still runs.
+ - 'modules/agent-box.nix.in'
+ - 'modules/src/**'
+ - 'bin/assemble-module.py'
- 'flake.lock'
- '.github/workflows/ci.yml'
pull_request:
diff --git a/modules/src/settings-daemon.py b/modules/src/settings-daemon.py
index d75c64e..926449c 100644
--- a/modules/src/settings-daemon.py
+++ b/modules/src/settings-daemon.py
@@ -534,129 +534,7 @@ def change_password(previous, new):
"""
STYLE = """
"""
@@ -887,524 +765,7 @@ def change_password(previous, new):
# inline confirm() guards run before the submit event reaches us — a
# dismissed dialog cancels the event, so we only see accepted ones.
SCRIPT = """
"""
diff --git a/modules/src/settings.css b/modules/src/settings.css
new file mode 100644
index 0000000..c27af08
--- /dev/null
+++ b/modules/src/settings.css
@@ -0,0 +1,123 @@
+body { margin: 0; min-height: 100vh; background: #0d1117; color: #e6edf3;
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
+main { max-width: 720px; margin: 0 auto; padding: 32px 20px 48px; }
+h1 { font-size: 24px; font-weight: 600; margin: 8px 0 4px; }
+h2 { font-size: 16px; font-weight: 600; margin: 0; }
+section { margin: 28px 0; }
+.sec-head { display: flex; align-items: center; justify-content: space-between;
+ gap: 12px; }
+.repo { position: fixed; top: 16px; right: 16px; display: inline-flex;
+ align-items: center; gap: 8px; padding: 8px 10px;
+ border: 1px solid #30363d; border-radius: 8px; background: #161b22;
+ color: #e6edf3; font-size: 13px; text-decoration: none; }
+.repo:hover { border-color: #e8a087; color: #e8a087; text-decoration: none; }
+.repo svg { width: 16px; height: 16px; fill: currentColor; }
+a.back { color: #8b949e; text-decoration: none; font-size: 13px; }
+a.back:hover { color: #e6edf3; }
+.note { color: #8b949e; font-size: 13px; margin: 6px 0 0; }
+.note a { color: #58a6ff; text-decoration: none; }
+.note a:hover { text-decoration: underline; }
+code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px; }
+.tbl { list-style: none; margin: 12px 0 0; padding: 0;
+ border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
+.tbl li { display: flex; align-items: center; justify-content: space-between;
+ gap: 12px; padding: 10px 16px; border-top: 1px solid #30363d; }
+.tbl li:first-child { border-top: 0; }
+.tbl-head { background: #161b22; color: #8b949e; font-size: 13px;
+ font-weight: 600; }
+li.empty { color: #8b949e; font-size: 13px; }
+.nm { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.nm svg { color: #8b949e; flex: none; }
+a.sess { color: #58a6ff; text-decoration: none; }
+a.sess:hover { text-decoration: underline; }
+.acts { display: flex; align-items: center; gap: 4px; flex: none; }
+.meta { color: #8b949e; font-size: 12px; }
+.state { font-size: 12px; color: #8b949e; }
+.state::before { content: ""; display: inline-block; width: 8px; height: 8px;
+ border-radius: 50%; background: currentColor; margin-right: 5px; }
+.state[data-state=live] { color: #3fb950; }
+.state[data-state=starting] { color: #d29922; }
+.btn { font: inherit; font-size: 13px; font-weight: 500; padding: 5px 14px;
+ border-radius: 6px; border: 1px solid #30363d; background: #21262d;
+ color: #e6edf3; cursor: pointer; white-space: nowrap; }
+.btn:hover { background: #30363d; }
+.btn.small { padding: 3px 10px; font-size: 12px; }
+button.icon { display: inline-flex; padding: 5px 8px; background: transparent;
+ border: 0; border-radius: 6px; color: #8b949e; cursor: pointer; }
+button.icon:hover { background: #21262d; color: #e6edf3; }
+button.icon.idanger:hover { color: #f85149; background: rgba(248,81,73,.1); }
+.danger-btn { color: #f85149; }
+.danger-btn:hover { background: #da3633; border-color: #f85149; color: #fff; }
+.tbl.danger { border-color: rgba(248,81,73,.4); }
+.dz { display: flex; flex-direction: column; min-width: 0; }
+.dz strong { font-size: 14px; }
+.dz .note { margin: 2px 0 0; }
+.update-state { color: #8b949e; }
+.update-state[data-state=available] { color: #d29922; }
+.update-state[data-state=current] { color: #3fb950; }
+.update-state[data-state=blocked] { color: #f85149; }
+.editor { border: 1px solid #30363d; border-radius: 8px; background: #161b22;
+ padding: 14px 16px; margin: 12px 0 0; }
+input, select { font: inherit; font-size: 13px; padding: 6px 10px;
+ border-radius: 6px; border: 1px solid #30363d;
+ background: #0d1117; color: #e6edf3; }
+input[type=text] { width: 200px; max-width: 100%; }
+input[type=password] { width: 280px; max-width: 100%; }
+.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+/* Working-directory combobox (issue #131): the input plus an
+ absolutely-positioned suggestions list the daemon fills one
+ directory level at a time. */
+.combo { position: relative; display: inline-block; }
+input.cwd { width: 260px; max-width: 100%;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+.ac { position: absolute; z-index: 20; left: 0; top: calc(100% + 4px);
+ margin: 0; padding: 4px; list-style: none; min-width: 100%;
+ max-width: 360px; max-height: 220px; overflow-y: auto;
+ background: #161b22; border: 1px solid #30363d; border-radius: 8px;
+ box-shadow: 0 8px 24px rgba(1,4,9,.6); }
+.ac[hidden] { display: none; }
+.ac li { padding: 5px 10px; border-radius: 6px; cursor: pointer;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 13px; color: #e6edf3; white-space: nowrap; }
+.ac li:hover, .ac li[aria-selected=true] { background: #30363d; }
+.ac li.empty { color: #8b949e; cursor: default; }
+.ac li.empty:hover { background: transparent; }
+.fields { display: grid; grid-template-columns: 1fr; gap: 12px; }
+.field { display: flex; flex-direction: column; align-items: flex-start;
+ gap: 4px; color: #8b949e; font-size: 13px; }
+.field input { box-sizing: border-box; width: 100%; }
+form.inline { display: inline; }
+.msg { padding: 10px 14px; border-radius: 8px; margin: 12px 0;
+ border: 1px solid rgba(63,185,80,.4); background: #10251a;
+ color: #7ee787; font-size: 13px; }
+/* Tabbed terminal workspace (the HOME root page, issue #119). The
+ page is a fixed-viewport column: tab bar on top, terminal panes
+ filling the rest. Panes are stacked and toggled with visibility
+ (NOT display) so a hidden terminal keeps its layout size — xterm
+ would otherwise re-measure a 0x0 box on every tab switch. */
+body.ws { display: flex; flex-direction: column; height: 100vh;
+ height: 100dvh; min-height: 0; overflow: hidden; }
+.tabs { display: flex; align-items: flex-end; gap: 2px; flex: none;
+ padding: 8px 8px 0; background: #010409;
+ border-bottom: 1px solid #30363d; overflow-x: auto; }
+.tab { display: inline-flex; align-items: center; padding: 6px 14px 8px;
+ font-size: 13px; color: #8b949e; text-decoration: none;
+ border: 1px solid transparent; border-bottom: 0;
+ border-radius: 8px 8px 0 0; white-space: nowrap; }
+.tab:hover { color: #e6edf3; }
+.tab[aria-current] { background: #0d1117; border-color: #30363d;
+ color: #e6edf3; }
+.tab-empty { color: #8b949e; font-size: 13px; padding: 6px 8px 8px; }
+.tabs .btn.add { margin: 0 4px 6px; padding: 2px 9px; }
+.tabs .spacer { flex: 1; }
+.tabs a.gear { color: #8b949e; text-decoration: none; font-size: 15px;
+ padding: 2px 8px 8px; }
+.tabs a.gear:hover { color: #e6edf3; }
+.ws .editor, .ws .msg { margin: 8px; flex: none; }
+.panes { position: relative; flex: 1; min-height: 0; }
+.pane { position: absolute; inset: 0; width: 100%; height: 100%;
+ border: 0; visibility: hidden; }
+.pane.active { visibility: visible; }
+.pane.placeholder { display: flex; align-items: center;
+ justify-content: center; color: #8b949e; }
diff --git a/modules/src/settings.js b/modules/src/settings.js
new file mode 100644
index 0000000..08e75c3
--- /dev/null
+++ b/modules/src/settings.js
@@ -0,0 +1,518 @@
+(function () {
+ "use strict";
+ function applyDoc(doc, ids) {
+ ids.forEach(function (id) {
+ var from = doc.getElementById(id);
+ var to = document.getElementById(id);
+ if (from && to) { to.replaceWith(document.importNode(from, true)); }
+ });
+ }
+ function parseHTML(text) {
+ return new DOMParser().parseFromString(text, "text/html");
+ }
+
+ // Shared writer for the Danger-zone progress spans (#restart-status,
+ // #update-status): set the data-state colour + text, optionally
+ // appending a trailing link.
+ function setStatus(el, state, text, linkText, href) {
+ if (!el) { return; }
+ el.setAttribute("data-state", state);
+ el.textContent = text;
+ if (linkText && href) {
+ var link = document.createElement("a");
+ link.href = href;
+ link.textContent = linkText;
+ link.rel = "noreferrer";
+ el.appendChild(document.createTextNode(" "));
+ el.appendChild(link);
+ }
+ }
+ // One GET of the JSON progress feed; resolves null on any error so
+ // callers treat "daemon briefly gone" (mid-rebuild) like any other
+ // not-yet-ready poll rather than a hard failure.
+ function fetchStatus(url) {
+ return fetch(url, { headers: { "Accept": "application/json" } })
+ .then(function (r) { if (!r.ok) { throw new Error(); } return r.json(); })
+ .catch(function () { return null; });
+ }
+ // Re-fetch the current page and patch the live session list/tabs, so
+ // the visible list tracks a restart even when nothing was "starting"
+ // at submit time (which is what otherwise gates schedulePoll).
+ function pollPageOnce() {
+ return fetch(window.location.pathname + window.location.search)
+ .then(function (r) { return r.text(); })
+ .then(function (t) { applyDoc(parseHTML(t), ["sessions-list", "tab-bar"]); wsSync(); })
+ .catch(function () {});
+ }
+
+ // After "Update box": watch the update oneshot from `baseline` (its
+ // start time before we triggered) until a strictly newer run
+ // finishes. The rebuild may restart this daemon, so a failed fetch is
+ // "still rebuilding", not an error.
+ function watchUpdate(url, baseline, rev0) {
+ var el = document.getElementById("update-status");
+ if (!el) { return; }
+ var tries = 0, MAX = 300; // ~12 min at 2.5s
+ setStatus(el, "checking", "Starting update…");
+ (function tick() {
+ if (tries++ > MAX) {
+ setStatus(el, "blocked", "Update still running — check the box shortly.");
+ return;
+ }
+ fetchStatus(url).then(function (s) {
+ if (!s || !s.update) { // daemon switching, or no unit to watch
+ setStatus(el, "checking", "Rebuilding the system…");
+ window.setTimeout(tick, 2500);
+ return;
+ }
+ var u = s.update;
+ if (u.active === "activating" || u.active === "active") {
+ setStatus(el, "available", "Update in progress…");
+ window.setTimeout(tick, 2500);
+ return;
+ }
+ if (u.since > baseline) { // a newer run started and is no longer active → done
+ if (u.active === "failed" || u.result !== "success") {
+ setStatus(el, "blocked", "Update failed — check the update service journal.");
+ } else if (s.rev && rev0 && s.rev !== rev0) {
+ var repo = el.getAttribute("data-repo");
+ var short = s.rev.slice(0, 12);
+ if (repo) {
+ var href = "https://github.com/" +
+ repo.split("/").map(encodeURIComponent).join("/") +
+ "/commit/" + encodeURIComponent(s.rev);
+ setStatus(el, "current", "Updated — now at " + short + ".", "View commit", href);
+ } else {
+ setStatus(el, "current", "Updated — now at " + short + ".");
+ }
+ } else {
+ setStatus(el, "current", "Update finished.");
+ }
+ return;
+ }
+ setStatus(el, "checking", "Starting update…"); // triggered, run not registered yet
+ window.setTimeout(tick, 2500);
+ });
+ })();
+ }
+ // After "Restart all": watch the live session count recover. Wait to
+ // see it dip below the configured count before declaring success, so
+ // the pre-kill "all live" state isn't misread as "done".
+ function watchRestart(url) {
+ var el = document.getElementById("restart-status");
+ if (!el) { return; }
+ var dipped = false, tries = 0, MAX = 40; // ~100s at 2.5s
+ setStatus(el, "checking", "Restarting sessions…");
+ (function tick() {
+ if (tries++ > MAX) {
+ setStatus(el, "blocked", "Still restarting — check the session list.");
+ return;
+ }
+ pollPageOnce();
+ fetchStatus(url).then(function (s) {
+ if (!s || !s.sessions) { window.setTimeout(tick, 2500); return; }
+ var conf = s.sessions.configured, live = s.sessions.live;
+ if (conf === 0) { setStatus(el, "current", "Restart requested."); return; }
+ if (live < conf) { dipped = true; }
+ if (dipped && live >= conf) {
+ setStatus(el, "current", "All sessions restarted.");
+ return;
+ }
+ window.setTimeout(tick, 2500);
+ });
+ })();
+ }
+
+ // The page itself never waits on GitHub. Once it is visible, make a
+ // single compare request: GitHub reports whether repository HEAD is
+ // ahead of the running revision and provides the commit count. The
+ // rendered compare link remains useful if the request is blocked or
+ // rate-limited.
+ function checkForUpdate() {
+ var el = document.getElementById("update-status");
+ if (!el) { return; }
+ var repo = el.getAttribute("data-repo");
+ var rev = el.getAttribute("data-rev");
+ var fallback = el.getAttribute("data-compare-url");
+ if (!repo || !rev || !fallback) { return; }
+
+ function show(state, text, linkText, href) {
+ el.setAttribute("data-state", state);
+ el.textContent = text;
+ if (linkText && href) {
+ var link = document.createElement("a");
+ link.href = href;
+ link.textContent = linkText;
+ link.rel = "noreferrer";
+ el.appendChild(document.createTextNode(" "));
+ el.appendChild(link);
+ }
+ }
+
+ var repoPath = repo.split("/").map(encodeURIComponent).join("/");
+ var api = "https://api.github.com/repos/" + repoPath +
+ "/compare/" + encodeURIComponent(rev) + "...HEAD";
+ show("checking", "Checking GitHub for agent-box updates…");
+ fetch(api, {
+ credentials: "omit",
+ headers: { "Accept": "application/vnd.github+json" },
+ referrerPolicy: "no-referrer"
+ })
+ .then(function (r) {
+ if (!r.ok) { throw new Error("GitHub returned " + r.status); }
+ return r.json();
+ })
+ .then(function (result) {
+ if (result.status === "identical") {
+ show("current", "No agent-box code update.");
+ return;
+ }
+ if (result.status === "ahead") {
+ var count = Number(result.ahead_by) || 0;
+ var commits = count ? count + " commit" + (count === 1 ? "" : "s") : "new commits";
+ var head = result.head_commit && result.head_commit.sha;
+ var href = head
+ ? "https://github.com/" + repoPath + "/compare/" +
+ encodeURIComponent(rev) + "..." + encodeURIComponent(head)
+ : fallback;
+ show("available", "agent-box update available — " + commits + ".", "View changes", href);
+ return;
+ }
+ show("blocked", "Automatic agent-box update unavailable.", "Compare revisions", fallback);
+ })
+ .catch(function () {
+ show("unknown", "Couldn’t check agent-box updates.", "Check GitHub", fallback);
+ });
+ }
+
+ var pollLeft = 0;
+ var pollTimer = null;
+ function schedulePoll() {
+ if (pollTimer || pollLeft <= 0) { return; }
+ if (!document.querySelector(
+ "#sessions-list [data-state=starting], #tab-bar [data-state=starting]")) { return; }
+ pollLeft -= 1;
+ pollTimer = window.setTimeout(function () {
+ pollTimer = null;
+ // Keep the query string: on the workspace it carries ?tab=, so
+ // the fetched tab bar marks the same tab current.
+ fetch(window.location.pathname + window.location.search)
+ .then(function (r) { return r.text(); })
+ .then(function (t) {
+ applyDoc(parseHTML(t), ["sessions-list", "tab-bar"]);
+ wsSync();
+ schedulePoll();
+ });
+ }, 2500);
+ }
+ function startPolling(n) { pollLeft = n; schedulePoll(); }
+
+ // Tabbed terminal workspace (the HOME root page, issue #119). The
+ // server renders tabs as plain ?tab= links and only the selected
+ // pane; this upgrades clicks to client-side switching, creating
+ // panes lazily on first activation and keeping them mounted after,
+ // so background sessions stay attached like a terminal app's tabs.
+ // Everything re-queries the DOM — polling replaces #tab-bar
+ // wholesale, and a pane may be a placeholder until its session is
+ // live (the ttyd attach wrapper errors out on a session that does
+ // not exist yet).
+ function tabBar() { return document.getElementById("tab-bar"); }
+ function tabEl(name) {
+ var bar = tabBar();
+ return bar ? bar.querySelector('.tab[data-tab="' + name + '"]') : null;
+ }
+ function tabLive(name) {
+ var t = tabEl(name);
+ return !!(t && t.querySelector("[data-state=live]"));
+ }
+ function ensurePane(name) {
+ var cur = document.querySelector('#panes .pane[data-pane="' + name + '"]');
+ if (cur && (cur.tagName === "IFRAME" || !tabLive(name))) { return cur; }
+ var el;
+ if (tabLive(name)) {
+ el = document.createElement("iframe");
+ el.src = tabBar().getAttribute("data-term-base") +
+ "?arg=" + encodeURIComponent(name);
+ el.title = name + " terminal";
+ el.setAttribute("allow", "clipboard-read; clipboard-write");
+ el.className = "pane";
+ } else {
+ el = document.createElement("div");
+ el.textContent = name + " is starting…";
+ el.className = "pane placeholder";
+ }
+ el.setAttribute("data-pane", name);
+ if (cur) {
+ if (cur.classList.contains("active")) { el.classList.add("active"); }
+ cur.replaceWith(el);
+ } else {
+ document.getElementById("panes").appendChild(el);
+ }
+ return el;
+ }
+ function wsSelect(name, focus) {
+ var bar = tabBar();
+ if (!bar || !tabEl(name)) { return; }
+ bar.querySelectorAll(".tab").forEach(function (t) {
+ if (t.getAttribute("data-tab") === name) { t.setAttribute("aria-current", "page"); }
+ else { t.removeAttribute("aria-current"); }
+ });
+ var pane = ensurePane(name);
+ document.querySelectorAll("#panes .pane").forEach(function (p) {
+ p.classList.toggle("active", p === pane);
+ });
+ history.replaceState(null, "", "/?tab=" + encodeURIComponent(name));
+ if (focus && pane.tagName === "IFRAME") {
+ try { pane.contentWindow.focus(); } catch (err) { /* cross-origin never happens; be safe */ }
+ }
+ }
+ function wsActive() {
+ var bar = tabBar();
+ var t = bar ? bar.querySelector(".tab[aria-current]") : null;
+ return t ? t.getAttribute("data-tab") : null;
+ }
+ function wsSync() {
+ if (!tabBar()) { return; }
+ // Drop panes whose sessions are gone; upgrade placeholders whose
+ // sessions came live. No focus steal — the user may be typing.
+ document.querySelectorAll("#panes .pane[data-pane]").forEach(function (p) {
+ var name = p.getAttribute("data-pane");
+ if (!tabEl(name)) { p.remove(); return; }
+ ensurePane(name);
+ });
+ var cur = wsActive();
+ if (cur) { wsSelect(cur, false); }
+ }
+ document.addEventListener("click", function (e) {
+ var t = e.target && e.target.closest ? e.target.closest("#tab-bar .tab[data-tab]") : null;
+ if (!t) { return; }
+ e.preventDefault();
+ wsSelect(t.getAttribute("data-tab"), true);
+ });
+
+ // The editors render expanded (no-JS fallback); collapse them once
+ // JS is live so the page opens in list-only, GitHub-style form.
+ ["secret-editor", "session-editor", "password-editor"].forEach(function (id) {
+ var el = document.getElementById(id);
+ if (el) { el.hidden = true; }
+ });
+
+ document.addEventListener("click", function (e) {
+ var t = e.target && e.target.closest ? e.target.closest("[data-toggle],[data-edit]") : null;
+ if (!t) { return; }
+ var form = document.getElementById("secret-form");
+ if (t.hasAttribute("data-edit")) {
+ document.getElementById("secret-editor").hidden = false;
+ form.reset();
+ var key = form.querySelector("input[name=key]");
+ key.value = t.getAttribute("data-edit");
+ key.readOnly = true;
+ form.querySelector("input[name=value]").focus();
+ return;
+ }
+ var el = document.getElementById(t.getAttribute("data-toggle"));
+ if (!el) { return; }
+ el.hidden = !el.hidden;
+ if (!el.hidden && el.id === "secret-editor") {
+ form.reset();
+ var ki = form.querySelector("input[name=key]");
+ ki.readOnly = false;
+ ki.focus();
+ }
+ });
+
+ document.addEventListener("submit", function (e) {
+ var f = e.target;
+ if (e.defaultPrevented || !f || (f.method || "").toLowerCase() !== "post") { return; }
+ // Password rotation invalidates the current cookie and cached basic
+ // credentials. Let the browser follow its native 303/401 flow so it
+ // can prompt for the new password; fetch() suppresses that UX.
+ if (f.hasAttribute("data-native")) { return; }
+ e.preventDefault();
+ var body = new URLSearchParams();
+ new FormData(f).forEach(function (v, k) { body.append(k, v); });
+ // On the workspace, adding a session should focus its new tab —
+ // and a FAILED add (the fetched error page defaults aria-current)
+ // must not yank the user off the tab they were on.
+ var addedSession =
+ (f.getAttribute("action") || "").endsWith("/sessions/add") && tabBar()
+ ? body.get("name") : null;
+ var wasActive = wsActive();
+ var poll = f.getAttribute("data-poll");
+ var statusUrl = f.getAttribute("data-status");
+
+ function afterPost(t) {
+ applyDoc(parseHTML(t), ["msg-slot", "secrets-list", "sessions-list", "tab-bar"]);
+ var ed = f.closest(".editor");
+ if (ed) { f.reset(); ed.hidden = true; }
+ if (addedSession && tabEl(addedSession)) { wsSelect(addedSession, true); }
+ else if (wasActive && tabEl(wasActive)) { wsSelect(wasActive, false); }
+ wsSync();
+ }
+ function post() {
+ return fetch(f.getAttribute("action"), { method: "POST", body: body })
+ .then(function (r) { return r.text(); });
+ }
+
+ // The two Danger-zone actions get a long-polled progress line;
+ // everything else keeps the brief session-state poll.
+ if (poll === "update" && statusUrl) {
+ // Snapshot the run's start time + rev BEFORE triggering, so the
+ // watcher can distinguish the new run from any earlier one.
+ fetchStatus(statusUrl).then(function (s0) {
+ var baseline = (s0 && s0.update && typeof s0.update.since === "number")
+ ? s0.update.since : 0;
+ var rev0 = s0 ? s0.rev : null;
+ post().then(function (t) { afterPost(t); watchUpdate(statusUrl, baseline, rev0); });
+ });
+ return;
+ }
+ if (poll === "restart" && statusUrl) {
+ post().then(function (t) { afterPost(t); watchRestart(statusUrl); });
+ return;
+ }
+ post().then(function (t) { afterPost(t); startPolling(8); });
+ });
+
+ // Working-directory autocomplete (issue #131). The add-session cwd
+ // field browses the filesystem one level at a time: the daemon lists
+ // the children of whatever directory the text names so far (up to
+ // the last "/"), and the client filters those by the trailing
+ // fragment. Picking an entry appends "/" and re-fetches, so
+ // the next level appears — like tab-completing a path. Everything is
+ // event-delegated so it survives the DOM swaps applyDoc() does; each
+ // input carries its own tiny state on the element.
+ function acList(input) {
+ var combo = input.closest ? input.closest(".combo") : null;
+ return combo ? combo.querySelector(".ac") : null;
+ }
+ function acSplit(v) {
+ // Directory portion (browsed) and trailing fragment (filter).
+ var slash = v.lastIndexOf("/");
+ if (slash < 0) { return { dir: "~", frag: v === "~" ? "" : v }; }
+ return { dir: v.slice(0, slash) || "/", frag: v.slice(slash + 1) };
+ }
+ function acJoin(dir, name) {
+ return (dir === "/" ? "/" : dir + "/") + name;
+ }
+ function acClose(input) {
+ var ul = acList(input);
+ if (ul) { ul.hidden = true; ul.innerHTML = ""; }
+ var st = input._dir;
+ if (st) { st.active = -1; }
+ }
+ function acRender(input) {
+ var ul = acList(input);
+ var st = input._dir;
+ if (!ul || !st) { return; }
+ var frag = acSplit(input.value).frag.toLowerCase();
+ var matches = st.entries.filter(function (n) {
+ return n.toLowerCase().indexOf(frag) === 0;
+ });
+ ul.innerHTML = "";
+ st.active = -1;
+ if (!st.entries.length) {
+ var e = document.createElement("li");
+ e.className = "empty";
+ e.textContent = "No subfolders here";
+ ul.appendChild(e);
+ ul.hidden = false;
+ return;
+ }
+ if (!matches.length) { ul.hidden = true; return; }
+ matches.slice(0, 200).forEach(function (name) {
+ var li = document.createElement("li");
+ li.setAttribute("role", "option");
+ li.setAttribute("data-name", name);
+ li.textContent = name + "/";
+ ul.appendChild(li);
+ });
+ ul.hidden = false;
+ }
+ function acFetch(input) {
+ var st = input._dir || (input._dir = { dir: null, entries: [], active: -1, seq: 0 });
+ var dir = acSplit(input.value).dir;
+ if (dir === st.dir) { acRender(input); return; }
+ var base = input.getAttribute("data-dir-base") || "";
+ var my = ++st.seq;
+ fetch(base + "/sessions/dirs?path=" + encodeURIComponent(dir), {
+ headers: { "Accept": "application/json" }
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (res) {
+ if (my !== st.seq) { return; } // a newer keystroke won
+ st.dir = dir;
+ st.entries = (res && res.dirs) || [];
+ acRender(input);
+ })
+ .catch(function () { acClose(input); });
+ }
+ function acItems(input) {
+ var ul = acList(input);
+ return ul ? [].slice.call(ul.querySelectorAll("li[data-name]")) : [];
+ }
+ function acHighlight(input, idx) {
+ var items = acItems(input);
+ var st = input._dir;
+ if (!items.length || !st) { return; }
+ if (idx < 0) { idx = items.length - 1; }
+ if (idx >= items.length) { idx = 0; }
+ items.forEach(function (li, i) {
+ if (i === idx) { li.setAttribute("aria-selected", "true"); li.scrollIntoView({ block: "nearest" }); }
+ else { li.removeAttribute("aria-selected"); }
+ });
+ st.active = idx;
+ }
+ function acApply(input, li) {
+ var dir = acSplit(input.value).dir;
+ input.value = acJoin(dir, li.getAttribute("data-name")) + "/";
+ input.focus();
+ acFetch(input); // reveal the next level
+ }
+ var acTimer = null;
+ document.addEventListener("input", function (e) {
+ var input = e.target;
+ if (!input || !input.hasAttribute || !input.hasAttribute("data-dir-input")) { return; }
+ if (acTimer) { window.clearTimeout(acTimer); }
+ acTimer = window.setTimeout(function () { acTimer = null; acFetch(input); }, 120);
+ });
+ document.addEventListener("focusin", function (e) {
+ var input = e.target;
+ if (input && input.hasAttribute && input.hasAttribute("data-dir-input")) { acFetch(input); }
+ });
+ document.addEventListener("focusout", function (e) {
+ var input = e.target;
+ if (!input || !input.hasAttribute || !input.hasAttribute("data-dir-input")) { return; }
+ // Delay so a mousedown-selected item still registers its click.
+ window.setTimeout(function () { acClose(input); }, 150);
+ });
+ document.addEventListener("keydown", function (e) {
+ var input = e.target;
+ if (!input || !input.hasAttribute || !input.hasAttribute("data-dir-input")) { return; }
+ var ul = acList(input);
+ var open = ul && !ul.hidden;
+ var st = input._dir;
+ if (e.key === "ArrowDown") { e.preventDefault(); if (open) { acHighlight(input, (st ? st.active : -1) + 1); } else { acFetch(input); } }
+ else if (e.key === "ArrowUp") { if (open) { e.preventDefault(); acHighlight(input, (st ? st.active : 0) - 1); } }
+ else if (e.key === "Enter") {
+ var items = acItems(input);
+ if (open && st && st.active >= 0 && items[st.active]) {
+ e.preventDefault(); // accept the suggestion, don't submit yet
+ acApply(input, items[st.active]);
+ }
+ } else if (e.key === "Escape") { if (open) { e.preventDefault(); acClose(input); } }
+ });
+ document.addEventListener("mousedown", function (e) {
+ var li = e.target && e.target.closest ? e.target.closest(".ac li[data-name]") : null;
+ if (!li) { return; }
+ e.preventDefault(); // keep focus on the input (no focusout close)
+ var combo = li.closest(".combo");
+ var input = combo ? combo.querySelector("[data-dir-input]") : null;
+ if (input) { acApply(input, li); }
+ });
+
+ checkForUpdate();
+ // Land in the terminal: focus the server-selected tab's pane.
+ if (wsActive()) { wsSelect(wsActive(), true); }
+ startPolling(8);
+})();