anvilsign in

collin/browser-terminal-extension

1// termbridge sidebar.
2//
3// Owns the WebSocket directly. There is deliberately no relay through the
4// service worker: `runtime.sendMessage` is reachable from content scripts, so
5// routing terminal data through it would create exactly the bridge that lets a
6// hostile page reach the shell. This document has no inbound message listeners.
7
8// One of the two always exists — this file only ever runs as sidebar.html — but
9// neither is declared unconditionally, so say which.
10const api = /** @type {TbExtensionApi} */ (globalThis.browser ?? globalThis.chrome);
11const storage = api.storage.local;
12
13/**
14 * Every id passed here is one this file's own sidebar.html declares, so a miss
15 * is a bug in the pair rather than a runtime condition to handle. Asserting
16 * that is what keeps the call sites readable.
17 *
18 * @param {string} id
19 * @returns {HTMLElement}
20 */
21const $ = (id) => /** @type {HTMLElement} */ (document.getElementById(id));
22
23/** @param {string} id @returns {HTMLInputElement} */
24const $input = (id) => /** @type {HTMLInputElement} */ ($(id));
25/** @param {string} id @returns {HTMLSelectElement} */
26const $select = (id) => /** @type {HTMLSelectElement} */ ($(id));
27
28const logEl = $("log");
29/** @param {string} m */
30const log = (m) => {
31 logEl.textContent += `${m}\n`;
32 logEl.scrollTop = logEl.scrollHeight;
33};
34
35const ORIGIN = location.origin;
36$("pair-cmd").textContent = `termbridge pair ${ORIGIN}`;
37
38// --- terminal ---------------------------------------------------------------
39
40// --- theme -------------------------------------------------------------------
41
42const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
43let themePref = "auto";
44let termReady = false;
45
46function currentTheme() {
47 return Themes.resolveTheme(themePref, darkQuery.matches);
48}
49
50function applyTheme() {
51 const t = currentTheme();
52 const root = document.documentElement;
53 for (const [k, v] of Object.entries(t.ui)) root.style.setProperty(k, v);
54 // Drives native scrollbars, form controls and the caret.
55 root.style.colorScheme = t.colorScheme;
56 if (termReady) term.options.theme = t.xterm;
57 $select("theme-select").value = themePref;
58}
59
60/** @type {Record<string, { cls: string, hint: string }>} */
61const DENSITIES = {
62 normal: { cls: "", hint: "Session tabs, status text and icons." },
63 compact: { cls: "compact", hint: "Tighter tabs — about one extra terminal row." },
64 hidden: { cls: "chromeless", hint: "Hover the top edge of the panel to bring it back." },
65};
66let density = "normal";
67
68function applyDensity() {
69 const d = DENSITIES[density] ?? DENSITIES.normal;
70 document.body.classList.remove("compact", "chromeless");
71 if (d.cls) document.body.classList.add(d.cls);
72 $select("density-select").value = density;
73 $("density-hint").textContent = d.hint;
74 // The terminal has a different number of rows now.
75 if (termReady) {
76 fit.fit();
77 sendSize();
78 }
79}
80
81/** @param {string} value */
82function setDensity(value) {
83 density = DENSITIES[value] ? value : "normal";
84 storage.set({ density });
85 applyDensity();
86}
87
88// Below ~8px xterm.js's glyph atlas stops being legible; above ~32 a sidebar
89// holds too few columns to be a terminal.
90const FONT_DEFAULT = 12;
91const FONT_MIN = 8;
92const FONT_MAX = 32;
93let fontSize = FONT_DEFAULT;
94
95function applyFontSize() {
96 $("font-size").textContent = String(fontSize);
97 if (!termReady) return;
98 term.options.fontSize = fontSize;
99 // Cell metrics changed, so the row/column count did too.
100 fit.fit();
101 sendSize();
102}
103
104/** @param {number|string} value */
105function setFontSize(value) {
106 const n = Math.round(Number(value));
107 fontSize = Number.isFinite(n) ? Math.min(FONT_MAX, Math.max(FONT_MIN, n)) : FONT_DEFAULT;
108 storage.set({ fontSize });
109 applyFontSize();
110}
111
112/** `_` and `+` are what the shifted keys report. @type {Record<string, number>} */
113const FONT_STEP = { "=": 1, "+": 1, "-": -1, _: -1 };
114
115/**
116 * Ctrl+Alt rather than plain Ctrl: Ctrl+= / Ctrl+- / Ctrl+0 are browser zoom
117 * accelerators, which pages cannot cancel, so binding them would zoom the whole
118 * panel instead. Ctrl+wheel *is* cancelable, so that gesture works as expected.
119 *
120 * Returns true if the event was a font-size command and has been handled.
121 *
122 * @param {KeyboardEvent} e
123 */
124function handleFontKey(e) {
125 if (!e.ctrlKey || !e.altKey || e.metaKey) return false;
126 const delta = FONT_STEP[e.key];
127 if (delta) setFontSize(fontSize + delta);
128 else if (e.key === "0") setFontSize(FONT_DEFAULT);
129 else return false;
130 e.preventDefault();
131 return true;
132}
133
134/** @param {string} pref */
135function setTheme(pref) {
136 themePref = Themes.PREFERENCES.includes(pref) ? pref : "auto";
137 storage.set({ theme: themePref });
138 applyTheme();
139}
140
141// Only matters while the preference is "auto", but the listener is harmless
142// otherwise and avoids add/remove churn.
143darkQuery.addEventListener("change", () => {
144 if (themePref === "auto") applyTheme();
145});
146
147const term = new Terminal({
148 fontFamily:
149 'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace',
150 fontSize: FONT_DEFAULT,
151 lineHeight: 1.2,
152 cursorBlink: true,
153 scrollback: 5000,
154 theme: Themes.resolveTheme("auto", darkQuery.matches).xterm,
155 // tmux owns the scrollback (copy-mode). Letting xterm.js also scroll fights
156 // with it and with mouse-mode applications.
157 allowProposedApi: true,
158});
159
160const fit = new FitAddon.FitAddon();
161term.loadAddon(fit);
162term.open($("term"));
163fit.fit();
164
165// Returning false keeps the keystroke out of the pty.
166term.attachCustomKeyEventHandler((e) => {
167 if (e.type !== "keydown") return true;
168 return !handleFontKey(e);
169});
170
171$("term").addEventListener(
172 "wheel",
173 (e) => {
174 if (!e.ctrlKey) return;
175 e.preventDefault();
176 setFontSize(fontSize + (e.deltaY < 0 ? 1 : -1));
177 },
178 { passive: false },
179);
180
181// Set only once term *and* fit are usable — applyTheme/applyDensity both touch
182// them, and a flag that runs ahead of the objects it guards is worse than none.
183termReady = true;
184
185const enc = new TextEncoder();
186
187// --- connection -------------------------------------------------------------
188
189let ws = /** @type {WebSocket | null} */ (null);
190let connected = false;
191// Session tmux says we are on. The name we asked to attach to is only the
192// starting point — switch-client moves us and the header should follow.
193let sessionName = /** @type {string | null} */ (null);
194
195// Whether the daemon's program is tmux. Without it there are no windows to put
196// in tabs, and "+" would have nothing to create.
197let tmuxMode = false;
198
199/**
200 * @param {"on"|"off"|"pending"} state drives the dot's colour class
201 * @param {string} text
202 */
203function setStatus(state, text) {
204 $("status").className = `dot ${state}`;
205 $("status").title = text;
206 $("status-text").textContent = text;
207 $("reconnect").hidden = state === "on";
208}
209
210// One place decides what the header says, so anything that repaints it (a
211// finished pick, a fresh session name) can't quietly drop the other half.
212function refreshStatus() {
213 if (!connected) {
214 setStatus("off", "disconnected");
215 return;
216 }
217 setStatus("on", sessionName ? `connected · ${sessionName}` : "connected");
218}
219
220function sendSize() {
221 if (!connected || !ws) return;
222 const { cols, rows } = term;
223 ws.send(JSON.stringify({ type: "resize", cols, rows }));
224}
225
226// The daemon can only answer "invalid token" on the wire; it cannot reach into
227// the sidebar. Translate that into the one instruction that actually fixes it.
228const TOKEN_HELP =
229 "Run termbridge token in the terminal running the daemon, then paste " +
230 "the 64 hex characters it prints into the Token field above.";
231
232/** @param {string} text */
233function tokenProblem(text) {
234 $("token-hint").textContent = `${text} ${TOKEN_HELP}`;
235 $("token-hint").hidden = false;
236 $("settings").hidden = false;
237 term.write(
238 `\r\n\x1b[33m ${text}\x1b[0m\r\n` +
239 " Open \x1b[1msettings\x1b[0m (top right) and set the Token.\r\n" +
240 " \x1b[90mtermbridge token\x1b[0m prints the value to paste.\r\n",
241 );
242 $("token").focus();
243}
244
245function connect() {
246 const url = $input("url").value.trim();
247 const token = $input("token").value.trim();
248
249 if (!token) {
250 // Connecting with an empty token just produces an "invalid token" reject in
251 // the daemon's log, which reads like a wrong token rather than a missing one.
252 setStatus("off", "no token");
253 log("no token set — not connecting");
254 tokenProblem("No token is set.");
255 return;
256 }
257 $("token-hint").hidden = true;
258
259 if (ws) {
260 ws.onclose = null;
261 ws.close();
262 }
263 connected = false;
264 setStatus("pending", "connecting…");
265 log(`connecting to ${url}`);
266
267 try {
268 ws = new WebSocket(url);
269 } catch (e) {
270 setStatus("off", "failed");
271 log(`constructor threw: ${e}`);
272 return;
273 }
274 // The handlers close over *this* socket rather than whatever `ws` points at
275 // when they fire: a reconnect replaces the module-level reference, and a
276 // late frame from the old socket must not be answered on the new one.
277 const sock = ws;
278 sock.binaryType = "arraybuffer";
279
280 sock.onopen = () => {
281 // Token goes in a frame, never the URL — query strings end up in logs,
282 // crash dumps and devtools history.
283 sock.send(JSON.stringify({ type: "auth", token }));
284 };
285
286 sock.onmessage = (ev) => {
287 if (typeof ev.data === "string") {
288 // A claim about the frame, not a guarantee — see types/globals.d.ts. It
289 // buys the narrowing below, not trust.
290 const msg = /** @type {TbFrame} */ (JSON.parse(ev.data));
291 if (msg.type === "ok") {
292 connected = true;
293 tmuxMode = msg.tmux === true;
294 $("trust-hint").hidden = true;
295 $("token-hint").hidden = true;
296 renderSessions(msg);
297 const want = $input("session").value.trim() || undefined;
298 // Optimistic; the daemon's first status frame replaces this with what
299 // tmux actually attached us to, usually within a second.
300 sessionName = want ?? msg.defaultSession ?? msg.profile;
301 refreshStatus();
302 // Session names only at this point — the window list needs the control
303 // channel, which the first status frame brings a moment later.
304 renderSessionSwitch(
305 (msg.sessions ?? []).map((name) => ({ name, windows: 0, attached: false })),
306 sessionName,
307 );
308 log(`authenticated, running ${msg.profile}`);
309 fit.fit();
310 // The daemon validates this name and falls back to its default if it
311 // doesn't like it; we never get to choose the program.
312 sock.send(
313 JSON.stringify({
314 type: "open",
315 cols: term.cols,
316 rows: term.rows,
317 ...(want ? { session: want } : {}),
318 }),
319 );
320 $("settings").hidden = true;
321 term.focus();
322 } else if (msg.type === "status") {
323 if (msg.session && msg.session !== sessionName) {
324 sessionName = msg.session;
325 refreshStatus();
326 }
327 renderSessionSwitch(msg.sessions ?? [], msg.session);
328 renderTabs(msg.windows ?? [], msg.agents ?? []);
329 } else if (msg.type === "tmux-error") {
330 // tmux refused the command (session gone, pane closed). The next status
331 // frame already shows the truth, so this only needs to explain itself.
332 log(`tmux: ${msg.reason}`);
333 } else if (msg.type === "exit") {
334 log(`session exited (${msg.code})`);
335 term.write(`\r\n\x1b[90m[session exited: ${msg.code}]\x1b[0m\r\n`);
336 } else if (msg.type === "error") {
337 setStatus("off", msg.reason);
338 log(`rejected: ${msg.reason}`);
339 if (/token|auth/i.test(msg.reason)) {
340 tokenProblem("The daemon rejected this token.");
341 } else if (/origin/i.test(msg.reason)) {
342 $("settings").hidden = false;
343 term.write(
344 `\r\n\x1b[33m ${msg.reason}\x1b[0m\r\n` +
345 " Open \x1b[1msettings\x1b[0m and run the pairing command shown there.\r\n",
346 );
347 }
348 }
349 return;
350 }
351 // Raw terminal bytes. xterm.js takes Uint8Array directly, so nothing is
352 // decoded, re-encoded, or mangled on the way in.
353 term.write(new Uint8Array(ev.data));
354 };
355
356 sock.onerror = () => {
357 // The browser hides the reason from JS by design. The daemon's stdout has
358 // the real one (unpaired origin / bad token / rate limited).
359 log("socket error — check the daemon's output for the reason");
360 // Firefox's HTTPS-Only Mode silently rewrites ws:// to wss://, so the most
361 // likely cause here is that our self-signed certificate isn't trusted yet.
362 $("trust-hint").hidden = false;
363 };
364
365 sock.onclose = (ev) => {
366 connected = false;
367 sessionName = null;
368 tmuxMode = false;
369 renderTabs([], []);
370 renderSessionSwitch([], null);
371 refreshStatus();
372 log(`closed code=${ev.code} ${ev.reason || ""}`);
373 };
374}
375
376// Ask the daemon to move tmux. The daemon accepts three commands and validates
377// every argument; the sidebar cannot name a tmux command of its own.
378/**
379 * @param {{ cmd: "switch"|"create"|"focus"|"select-window"|"new-window"|"kill-window" }
380 * & Record<string, string>} body
381 */
382function tmuxCommand(body) {
383 if (!connected || !ws) return;
384 ws.send(JSON.stringify({ type: "tmux", ...body }));
385}
386
387// --- window tabs ------------------------------------------------------------
388//
389// A tab is a window of the attached session — the thing `prefix 2` selects, not
390// a session and not a pane. Window names come from tmux (a shell sets them from
391// whatever it is running, so they are as page-influenced as any other terminal
392// output) and only ever reach the DOM through textContent.
393
394// --- pinning ----------------------------------------------------------------
395//
396// A pin is a property of this panel, not of the session. tmux is never asked to
397// renumber anything: the window keeps its real index, so `prefix 4` still goes
398// where it always went, and nothing about your terminal's status line changes.
399// All a pin does is move the tab to the front of *this* strip and shrink it to
400// its dot and index, which is what buys the room in a narrow sidebar.
401//
402// Keyed by session name, because window ids are only meaningful inside the tmux
403// server that issued them and only prunable against the session we can see.
404/** @type {Record<string, string[]>} */
405let pins = {};
406
407// The last frame's worth of windows, so a pin — which changes nothing on the
408// wire and so produces no status frame — can repaint the strip on its own.
409/** @type {TbWindowInfo[]} */
410let lastWindows = [];
411/** @type {TbAgent[]} */
412let lastAgents = [];
413
414/** @returns {Set<string>} pinned window ids in the attached session */
415function pinnedIds() {
416 return new Set((sessionName && pins[sessionName]) || []);
417}
418
419/** @param {Set<string>} ids */
420function savePins(ids) {
421 if (!sessionName) return;
422 if (ids.size) pins[sessionName] = [...ids];
423 else delete pins[sessionName];
424 storage.set({ pins });
425}
426
427/**
428 * @param {TbWindowInfo[]} windows in index order
429 * @param {TbAgent[]} agents so a tab can say what Claude is doing in it
430 */
431function renderTabs(windows, agents) {
432 lastWindows = windows;
433 lastAgents = agents;
434 const strip = $("tabs");
435 const show = connected && tmuxMode && windows.length > 0;
436 $("tab-new").hidden = !(connected && tmuxMode && sessionName);
437 strip.hidden = !show;
438 if (!show) {
439 strip.textContent = "";
440 strip.dataset.sig = "";
441 syncSpinner();
442 return;
443 }
444
445 // A window that closed takes its pin with it. Pruning is safe here and only
446 // here: this is the one moment we hold the full window list for a session.
447 const pinned = pinnedIds();
448 const live = new Set(windows.map((w) => w.id));
449 for (const id of pinned) {
450 if (!live.has(id)) pinned.delete(id);
451 }
452 if (pinned.size !== ((sessionName && pins[sessionName]) || []).length) savePins(pinned);
453
454 // Pinned first, each group still in tmux's own index order — nothing is
455 // reordered on the server, so the indexes stay the ground truth they are.
456 const ordered = [
457 ...windows.filter((w) => pinned.has(w.id)),
458 ...windows.filter((w) => !pinned.has(w.id)),
459 ];
460
461 // Repainting drops hover and any focus ring; status frames arrive on every
462 // tmux notification and once a second besides.
463 const claude = agentByWindow(agents);
464 const sig = JSON.stringify(
465 ordered.map((w) => {
466 const a = claude[w.id];
467 return [w.id, w.index, w.name, w.active, w.activity, a?.state, agentLabel(a), pinned.has(w.id)];
468 }),
469 );
470 if (strip.dataset.sig !== sig) {
471 strip.dataset.sig = sig;
472 strip.textContent = "";
473 // Killing a session's last window kills the session, and with it the client
474 // this panel is looking through. That is not a tab close, so it isn't
475 // offered as one.
476 const closable = windows.length > 1;
477 for (const w of ordered) {
478 strip.appendChild(windowTab(w, claude[w.id], closable, pinned.has(w.id)));
479 }
480 }
481
482 syncSpinner();
483
484 const active = strip.querySelector('[aria-selected="true"]');
485 // Sidebars are narrow enough that the window you are on can be scrolled out
486 // of the strip entirely.
487 if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
488}
489
490// Loudest first: a window with something waiting on you outranks one that is
491// merely busy. A window with no Claude in it gets no entry at all.
492const AGENT_RANK = ["waiting", "working", "idle", "unknown"];
493
494/**
495 * @param {TbAgent[]} agents
496 * @returns {Record<string, TbAgent>} window id → the one worth reporting
497 */
498function agentByWindow(agents) {
499 /** @type {Record<string, TbAgent>} */
500 const out = {};
501 for (const a of agents) {
502 const seen = out[a.window_id];
503 if (!seen || AGENT_RANK.indexOf(a.state) < AGENT_RANK.indexOf(seen.state)) {
504 out[a.window_id] = a;
505 }
506 }
507 return out;
508}
509
510/**
511 * A slot rather than a bare button, because the close ✕ has to be a sibling:
512 * a button inside a button is invalid markup and unreachable to a screen
513 * reader, and closing a window is not selecting it.
514 *
515 * @param {TbWindowInfo} w
516 * @param {TbAgent} [claude] the agent worth reporting in this window, if any
517 * @param {boolean} [closable]
518 * @param {boolean} [isPinned]
519 */
520function windowTab(w, claude, closable, isPinned) {
521 // A pinned tab drops its name and its ✕, the same two things Chrome takes
522 // away: it is down to a dot and a number, and it cannot be closed by a
523 // mis-aimed click. The name is still in the tooltip.
524 const canClose = closable && !isPinned;
525 const slot = document.createElement("div");
526 slot.className =
527 `tab-slot${w.active ? " active" : ""}${canClose ? " closable" : ""}` +
528 `${isPinned ? " pinned" : ""}`;
529
530 const tab = document.createElement("button");
531 tab.type = "button";
532 tab.className = "tab";
533 // Activity is tmux's own "something happened here while you were away", and
534 // it is the whole reason a background tab is worth looking at.
535 if (!w.active && w.activity) tab.classList.add("activity");
536 tab.setAttribute("role", "tab");
537 tab.setAttribute("aria-selected", String(w.active));
538 tab.dataset.window = w.id;
539
540 const glyph = document.createElement("span");
541 // The favicon slot. Empty unless Claude is in there, so a plain window of
542 // shell doesn't wear a status light it has no state to report.
543 const state = claude?.state ?? "none";
544 glyph.className = `glyph ${state}`;
545 glyph.textContent = state === "working" ? spinnerGlyph() : STATIC_GLYPH[state] ?? "";
546 glyph.setAttribute("aria-hidden", "true");
547 tab.appendChild(glyph);
548
549 const index = document.createElement("span");
550 index.className = "index";
551 index.textContent = String(w.index);
552 tab.appendChild(index);
553
554 if (!isPinned) {
555 const name = document.createElement("span");
556 name.className = "name";
557 name.textContent = w.name;
558 tab.appendChild(name);
559 }
560
561 // With no chip row left, the tooltip is where the detail lives: which tool is
562 // in flight, what Claude is blocked on, and — for a pinned tab — the name it
563 // gave up to fit.
564 tab.title = [
565 `window ${w.index}: ${w.name}`,
566 w.panes > 1 ? `${w.panes} panes` : "",
567 claude ? `claude ${claude.state} — ${agentLabel(claude)}` : "",
568 claude?.title ?? "",
569 !w.active && w.activity ? "activity" : "",
570 isPinned ? "pinned — right-click to unpin" : "",
571 ]
572 .filter(Boolean)
573 .join("\n");
574
575 tab.addEventListener("click", () => {
576 // Selecting a window is a property of the session, so this moves anyone
577 // else watching it too — exactly as pressing prefix-2 in the terminal does.
578 if (!w.active) tmuxCommand({ cmd: "select-window", window: w.id });
579 term.focus();
580 });
581 slot.appendChild(tab);
582
583 if (canClose) slot.appendChild(closeButton(w));
584
585 slot.addEventListener("contextmenu", (e) => {
586 e.preventDefault();
587 openTabMenu(e, w, !!isPinned, !!closable);
588 });
589 return slot;
590}
591
592// --- tab context menu -------------------------------------------------------
593//
594// Built rather than native: an extension page gets the browser's own menu here,
595// which has nothing to say about tmux windows. Deliberately not a <dialog> or
596// anything modal — a modal in this panel would block the socket's message
597// handler while it is up.
598
599let openMenu = /** @type {HTMLElement | null} */ (null);
600
601function closeTabMenu() {
602 openMenu?.remove();
603 openMenu = null;
604}
605
606/**
607 * @param {MouseEvent} e
608 * @param {TbWindowInfo} w
609 * @param {boolean} isPinned
610 * @param {boolean} closable
611 */
612function openTabMenu(e, w, isPinned, closable) {
613 closeTabMenu();
614 const menu = document.createElement("div");
615 menu.className = "tab-menu";
616 menu.setAttribute("role", "menu");
617
618 /**
619 * @param {string} label
620 * @param {() => void} run
621 */
622 const item = (label, run) => {
623 const b = document.createElement("button");
624 b.type = "button";
625 b.setAttribute("role", "menuitem");
626 b.textContent = label;
627 b.addEventListener("click", () => {
628 closeTabMenu();
629 run();
630 });
631 menu.appendChild(b);
632 return b;
633 };
634
635 item(isPinned ? "Unpin" : "Pin", () => {
636 const ids = pinnedIds();
637 if (isPinned) ids.delete(w.id);
638 else ids.add(w.id);
639 savePins(ids);
640 // Nothing on the wire changed, so nothing will arrive to trigger a repaint.
641 $("tabs").dataset.sig = "";
642 renderTabs(lastWindows, lastAgents);
643 });
644
645 if (!w.active) {
646 item("Select", () => {
647 tmuxCommand({ cmd: "select-window", window: w.id });
648 term.focus();
649 });
650 }
651
652 if (closable) {
653 const kill = item("Close window", () => {
654 tmuxCommand({ cmd: "kill-window", window: w.id });
655 log(`killed window ${w.index} (${w.name})`);
656 term.focus();
657 });
658 kill.className = "danger";
659 }
660
661 document.body.appendChild(menu);
662 // Placed after measuring, so a menu opened near an edge folds back inside
663 // rather than off the panel.
664 const r = menu.getBoundingClientRect();
665 const x = Math.min(e.clientX, Math.max(0, window.innerWidth - r.width - 4));
666 const y = Math.min(e.clientY, Math.max(0, window.innerHeight - r.height - 4));
667 menu.style.left = `${x}px`;
668 menu.style.top = `${y}px`;
669 openMenu = menu;
670
671 // Any click that isn't on the menu, anywhere, dismisses it.
672 setTimeout(() => {
673 window.addEventListener("pointerdown", onDismiss, { once: true, capture: true });
674 }, 0);
675}
676
677/** @param {Event} e */
678function onDismiss(e) {
679 if (openMenu && e.target instanceof Node && openMenu.contains(e.target)) return;
680 closeTabMenu();
681}
682
683/** @param {TbWindowInfo} w */
684function closeButton(w) {
685 const btn = document.createElement("button");
686 btn.type = "button";
687 btn.className = "tab-close";
688 const label = `Close window ${w.index}: ${w.name}`;
689 btn.title = label;
690 btn.setAttribute("aria-label", label);
691 btn.appendChild(strokeIcon("M4 4l8 8M12 4l-8 8"));
692
693 btn.addEventListener("click", (e) => {
694 // The tab underneath would otherwise read this as "select me".
695 e.stopPropagation();
696 tmuxCommand({ cmd: "kill-window", window: w.id });
697 // The one destructive thing in the panel, and tmux has no undo for it, so
698 // it at least leaves a record of what went.
699 log(`killed window ${w.index} (${w.name})`);
700 term.focus();
701 });
702 return btn;
703}
704
705const SVG_NS = "http://www.w3.org/2000/svg";
706
707/**
708 * The header's icons are inline SVG rather than unicode glyphs, which render at
709 * wildly different weights depending on the platform's fallback font. The ones
710 * built here are the same, just built rather than written out.
711 *
712 * @param {string} d
713 */
714function strokeIcon(d) {
715 const svg = document.createElementNS(SVG_NS, "svg");
716 svg.setAttribute("viewBox", "0 0 16 16");
717 svg.setAttribute("aria-hidden", "true");
718 const path = document.createElementNS(SVG_NS, "path");
719 path.setAttribute("d", d);
720 path.setAttribute("stroke", "currentColor");
721 path.setAttribute("stroke-width", "2");
722 path.setAttribute("stroke-linecap", "round");
723 svg.appendChild(path);
724 return svg;
725}
726
727// A new window needs no name: tmux names it after whatever it runs, and the
728// terminal renames it as you cd around. So this is one click, no prompt.
729$("tab-new").addEventListener("click", () => {
730 if (sessionName) tmuxCommand({ cmd: "new-window", session: sessionName });
731 term.focus();
732});
733
734// The session the tabs belong to. One session is not a choice, but the control
735// still earns its place: it is where the session name is shown now that the
736// status text has given up the row.
737/**
738 * @param {TbSessionInfo[]} sessions
739 * @param {string | null | undefined} current
740 */
741function renderSessionSwitch(sessions, current) {
742 const select = $select("session-switch");
743 // Rebuilding while the menu is open would close it under the user's cursor;
744 // the next status frame repaints it anyway.
745 if (document.activeElement === select) return;
746
747 const names = sessions.map((s) => s.name);
748 const same = select.dataset.names === names.join(" ") && select.value === current;
749 if (same) return;
750
751 select.textContent = "";
752 for (const s of sessions) {
753 const o = document.createElement("option");
754 o.value = s.name;
755 o.textContent = s.name;
756 select.appendChild(o);
757 }
758 select.dataset.names = names.join(" ");
759 if (current) select.value = current;
760 select.hidden = sessions.length === 0;
761 // Two things cannot both hold the row: the select shows the session name, so
762 // the status text only speaks when there is no select to speak for it.
763 document.body.classList.toggle("has-session", !select.hidden);
764}
765
766$select("session-switch").addEventListener("change", (e) => {
767 // Moves the existing client: no reconnect, no second pty, and whatever is
768 // running in the session we leave keeps running.
769 tmuxCommand({ cmd: "switch", session: /** @type {HTMLSelectElement} */ (e.target).value });
770 term.focus();
771});
772
773/* --- the working spinner ---------------------------------------------------
774 Claude Code's own asterisk cycle, so a tab that is thinking looks like the
775 transcript that is thinking. The frames grow and shrink rather than spin:
776 a dot swelling to a full asterisk and back, which reads as activity at
777 10px where a rotating glyph would just shimmer. */
778const SPINNER_FRAMES = ["·", "✢", "✳", "∗", "✻", "✽", "✻", "∗", "✳", "✢"];
779/** What a tab shows when it is not mid-cycle. */
780/** @type {Record<string, string>} */
781const STATIC_GLYPH = { waiting: "✳", idle: "✻", unknown: "·", none: "" };
782const SPINNER_MS = 130;
783
784const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
785let spinnerStep = 0;
786/** @type {number | undefined} */
787let spinnerTimer;
788
789// Reduced motion keeps the glyph — the tab still says "working" — and parks it
790// on the frame the animation spends the most time looking like.
791function spinnerGlyph() {
792 return reducedMotion.matches ? "✻" : SPINNER_FRAMES[spinnerStep % SPINNER_FRAMES.length];
793}
794
795/**
796 * One timer for the whole strip, running only while something is working, so
797 * an idle panel is not repainting four times a second forever. Repaints touch
798 * textContent only: renderTabs owns the elements and skips its rebuild whenever
799 * the signature is unchanged, so the spinner never fights it.
800 */
801function syncSpinner() {
802 const working = document.querySelectorAll("#tabs .glyph.working");
803 if (!working.length || reducedMotion.matches) {
804 clearInterval(spinnerTimer);
805 spinnerTimer = undefined;
806 return;
807 }
808 if (spinnerTimer !== undefined) return;
809 spinnerTimer = setInterval(() => {
810 spinnerStep++;
811 const frame = spinnerGlyph();
812 const live = document.querySelectorAll("#tabs .glyph.working");
813 if (!live.length) return syncSpinner();
814 for (const el of live) el.textContent = frame;
815 }, SPINNER_MS);
816}
817
818// Turning the preference on mid-run has to stop the timer and settle the
819// glyphs where they are, not leave them frozen on whatever frame was up.
820reducedMotion.addEventListener("change", () => {
821 const frame = spinnerGlyph();
822 for (const el of document.querySelectorAll("#tabs .glyph.working")) el.textContent = frame;
823 syncSpinner();
824});
825
826// A tooltip's worth of room, so show the most specific thing known: what it is
827// blocked on, what tool it is running, else the mode.
828//
829// Every value here comes from Claude Code's hook payloads by way of the daemon
830// — a user prompt, a tool name, a notification message — and is only ever
831// assigned through textContent/title, never parsed as markup.
832/** @param {TbAgent} [a] */
833function agentLabel(a) {
834 if (!a) return "";
835 if (a.state === "waiting") return a.message || "waiting";
836 if (a.state === "working") return a.tool || shortMode(a.mode) || "working";
837 if (a.state === "unknown") return "no hook records — run: termbridge hooks";
838 return shortMode(a.mode) || "idle";
839}
840
841// permission_mode arrives camelCased, straight from Claude's hook payload.
842/** @type {Record<string, string>} */
843const MODE_SHORT = {
844 default: "idle",
845 acceptEdits: "accept edits",
846 plan: "plan",
847 bypassPermissions: "bypass",
848};
849
850/** @param {string | null | undefined} mode */
851function shortMode(mode) {
852 if (!mode) return "";
853 return MODE_SHORT[mode] ?? mode;
854}
855
856/** @param {TbOkFrame} msg */
857function renderSessions(msg) {
858 if (!msg.tmux) return;
859 const names = msg.sessions ?? [];
860 const list = $("session-list");
861 list.innerHTML = "";
862 for (const name of names) {
863 const o = document.createElement("option");
864 o.value = name;
865 list.appendChild(o);
866 }
867 $input("session").placeholder = msg.defaultSession ?? "browser";
868 if (names.length) log(`tmux sessions: ${names.join(", ")}`);
869}
870
871$("session-apply").addEventListener("click", () => {
872 const name = $input("session").value.trim();
873 storage.set({ session: name });
874 // Connected, this creates-or-attaches and moves the live client — the field
875 // is how you reach a session that doesn't exist yet, which the header's
876 // switcher (existing sessions only) can't do. Disconnected, it's the session
877 // the next connection opens with.
878 if (connected && name) {
879 tmuxCommand({ cmd: "create", session: name });
880 $("settings").hidden = true;
881 term.focus();
882 return;
883 }
884 connect();
885});
886
887// --- element picker ---------------------------------------------------------
888//
889// Everything the picker returns is page-controlled data. It is displayed, and
890// it only reaches the terminal when the user explicitly clicks "insert" — and
891// then only after Sanitize.forTerminal has stripped control characters and
892// shell-quoted it.
893
894let picked = /** @type {TbPicked | null} */ (null);
895
896/** Why the panel is open, kept so switching format doesn't erase it. */
897let pickedProblem = "";
898
899// XPath by default: it always exists, it addresses exactly one node, and it
900// survives the class-name churn that a CSS selector built from a framework's
901// generated class names does not.
902let pickedFormat = /** @type {TbPickedFormat} */ ("xpath");
903
904// Ordered by how often they're the one you want.
905/** @type {[TbPickedFormat, string][]} */
906const FORMATS = [
907 ["xpath", "XPath"],
908 ["css", "CSS"],
909 ["id", "id"],
910 ["testid", "test id"],
911 ["text", "text"],
912 ["href", "href"],
913];
914
915async function activePickTab() {
916 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
917 return tab;
918}
919
920/**
921 * `https://example.com/*` — the narrowest pattern that covers this page.
922 * @param {string | undefined} url
923 */
924function originPattern(url) {
925 if (!url) return null;
926 try {
927 return `${new URL(url).origin}/*`;
928 } catch {
929 return null;
930 }
931}
932
933/**
934 * @param {string} text
935 * @param {string} [bad] a warning to show alongside it
936 */
937function pickNote(text, bad) {
938 // Loud enough to notice without opening the log: the picker failing silently
939 // is the whole reason this feature felt broken.
940 $("picked").hidden = false;
941 $("picked-value").textContent = text;
942 $("picked-warn").hidden = !bad;
943 $("picked-warn").textContent = bad ?? "";
944 $("picked-grant").hidden = true;
945 log(text);
946}
947
948/**
949 * Offer a per-site grant rather than shipping a blanket <all_urls> permission.
950 *
951 * localhost is granted up front because it's your own machine; everything else
952 * is opt-in, one origin at a time, via a prompt the browser shows.
953 *
954 * @param {string} pattern
955 * @param {number | undefined} tabId
956 */
957function offerGrant(pattern, tabId) {
958 $("picked").hidden = false;
959 $("picked-value").textContent = `No access to ${pattern}`;
960 $("picked-warn").hidden = false;
961 $("picked-warn").textContent =
962 "Grant access to this site, or use Alt+Shift+P which needs no permission.";
963 const btn = $("picked-grant");
964 btn.hidden = false;
965 btn.textContent = `Allow ${pattern}`;
966 btn.onclick = async () => {
967 // Must be called from a user gesture, which this click is.
968 const granted = await api.permissions.request({ origins: [pattern] });
969 if (granted) {
970 btn.hidden = true;
971 log(`granted ${pattern}`);
972 runPick(tabId);
973 } else {
974 log(`declined ${pattern}`);
975 }
976 };
977}
978
979/**
980 * The capture the picker wants is the one permission a per-origin grant cannot
981 * buy. `tabs.captureVisibleTab` accepts exactly two things: the `activeTab`
982 * grant a keyboard command mints, or a host permission set that contains the
983 * literal `<all_urls>` pattern. A per-origin grant fails the check, and so does
984 * the all-scheme-wildcard pattern in optional_host_permissions, which misses
985 * `file:` and so isn't "all". That is why picking from the panel button used to
986 * hand back a selector and no image on every site you'd approved.
987 *
988 * @param {string} reason the failure copyPickedShot reported
989 */
990function isCapturePermissionError(reason) {
991 return /all_urls|activeTab/i.test(reason);
992}
993
994/**
995 * Offer the one grant that makes the panel button capture, alongside a pick
996 * that already succeeded. Screenshots stay opt-in: nothing here is requested
997 * until the button is pressed, and Alt+Shift+P keeps working without it.
998 *
999 * @param {string} reason
1000 */
1001function offerCaptureGrant(reason) {
1002 const btn = /** @type {HTMLButtonElement} */ ($("picked-grant"));
1003 btn.hidden = false;
1004 btn.textContent = "Allow screenshots on all sites";
1005 btn.onclick = async () => {
1006 // Must be called from a user gesture, which this click is.
1007 const granted = await api.permissions.request({ origins: ["<all_urls>"] });
1008 if (!granted) {
1009 log("declined <all_urls>");
1010 return;
1011 }
1012 btn.hidden = true;
1013 log("granted <all_urls>");
1014 // The shot that prompted this is long gone from the viewport's timeline;
1015 // re-picking is the honest way to get one, so say so rather than silently
1016 // leaving the old warning up.
1017 pickedProblem = "Screenshots are on. Pick again to get one.";
1018 refreshPicked();
1019 };
1020 log(`screenshot needs <all_urls>: ${reason}`);
1021}
1022
1023const SKIP_URL = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/;
1024
1025// The tab a pick is currently running in, or null. Also the "is picking" flag.
1026let pickTabId = /** @type {number | null | undefined} */ (null);
1027
1028/**
1029 * Cancel an in-flight pick.
1030 *
1031 * picker.js parks its cleanup function on `window.__tbPickerActive`, so we
1032 * re-enter the page and call it. That resolves the original promise with null,
1033 * which unwinds runPick exactly as a user Escape would.
1034 */
1035async function cancelPick() {
1036 const tabId = pickTabId;
1037 if (tabId == null) return;
1038 try {
1039 await api.scripting.executeScript({
1040 target: { tabId },
1041 func: () => {
1042 if (window.__tbPickerActive) window.__tbPickerActive();
1043 },
1044 });
1045 } catch {
1046 // Tab closed or navigated; the picker went with it.
1047 }
1048}
1049
1050/**
1051 * Run a pick in `tabId`. Returns true on success, or a message explaining why
1052 * the injection failed.
1053 *
1054 * @param {number | undefined} tabId
1055 * @returns {Promise<true | string>}
1056 */
1057async function runPick(tabId) {
1058 const btn = $("pick");
1059 pickTabId = tabId;
1060 btn.classList.add("active");
1061 setStatus("pending", "pick mode — click an element, Esc cancels");
1062 try {
1063 const results = await api.scripting.executeScript({
1064 target: { tabId },
1065 func: tbPickElement,
1066 });
1067 const value = results?.[0]?.result ?? null;
1068 if (value) {
1069 const shot = await Shot.copyPickedShot(api, tabId, value);
1070 await deliverPick(value, shot);
1071 } else log("pick cancelled");
1072 return true;
1073 } catch (e) {
1074 return e instanceof Error ? e.message : String(e);
1075 } finally {
1076 pickTabId = null;
1077 btn.classList.remove("active");
1078 refreshStatus();
1079 }
1080}
1081
1082$("pick").addEventListener("click", async () => {
1083 // Second press toggles it back off rather than doing nothing.
1084 if (pickTabId != null) {
1085 await cancelPick();
1086 return;
1087 }
1088
1089 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
1090 if (!tab || SKIP_URL.test(tab.url ?? "")) {
1091 pickNote(
1092 "Can't pick here.",
1093 "Browser-internal and extension pages are off limits to all extensions. Switch to a normal web page.",
1094 );
1095 return;
1096 }
1097
1098 setStatus("pending", "pick mode — click an element, Esc cancels");
1099 const outcome = await runPick(tab.id);
1100 if (outcome === true) return;
1101
1102 // The failure is nearly always a missing host permission for this origin.
1103 const pattern = originPattern(tab.url);
1104 if (pattern && /permission|access/i.test(outcome)) {
1105 offerGrant(pattern, tab.id);
1106 } else {
1107 pickNote("Couldn't reach the page.", `Try Alt+Shift+P instead. [${outcome}]`);
1108 }
1109});
1110
1111// Escape from the sidebar too. The picker handles Escape itself, but only when
1112// the page has keyboard focus — if you started the pick from here, focus is
1113// still in the panel and the key never reaches the page.
1114window.addEventListener("keydown", (e) => {
1115 // Font shortcuts work anywhere in the panel, not just with the terminal
1116 // focused. xterm.js consumes its own keydowns before they reach here, so it
1117 // has its own handler for these.
1118 if (handleFontKey(e)) return;
1119 if (e.key !== "Escape") return;
1120 if (openMenu) {
1121 e.preventDefault();
1122 closeTabMenu();
1123 return;
1124 }
1125 if (pickTabId != null) {
1126 e.preventDefault();
1127 cancelPick();
1128 }
1129});
1130
1131// Results arriving from the background worker (the keyboard-shortcut path).
1132//
1133// Guarded: content scripts always have `sender.tab` set, so rejecting those
1134// leaves only our own extension pages and worker. This is the one inbound
1135// message path in the sidebar, and it exists solely because the shortcut has to
1136// be handled in the background.
1137api.runtime.onMessage.addListener((msg, sender) => {
1138 if (sender?.id !== api.runtime.id) return;
1139 if (sender?.tab) return;
1140 if (msg?.type === "picked" && msg.value) {
1141 // The worker owns the screenshot on this path: it holds the activeTab
1142 // grant the shortcut just minted, and the clipboard write has to happen
1143 // while the page it captured is still the focused one.
1144 deliverPick(msg.value, msg.shot === true ? true : msg.shot || "not captured");
1145 }
1146});
1147
1148// A pick made while the sidebar was closed is parked in storage. Nothing is
1149// typed for these: the terminal has moved on, and whatever screenshot went with
1150// it left the clipboard long ago. Show it and let the user decide.
1151storage.get("pendingPick").then((v) => {
1152 if (v.pendingPick) {
1153 showPicked(v.pendingPick);
1154 storage.remove("pendingPick");
1155 }
1156});
1157
1158function currentPickedRaw() {
1159 if (!picked) return "";
1160 return picked[pickedFormat] ?? "";
1161}
1162
1163/**
1164 * Open the panel on a pick. Only called when the pick needs the user's
1165 * attention — see deliverPick.
1166 *
1167 * @param {TbPicked} value everything in here is page-controlled
1168 * @param {string} [problem] why the panel is opening
1169 */
1170function showPicked(value, problem) {
1171 picked = value;
1172 $("picked-warn").hidden = true;
1173 $("picked-grant").hidden = true;
1174
1175 $("picked-tag").textContent = value.tag ? `<${value.tag}>` : "?";
1176 $("picked-meta").textContent = value.text || value.href || value.pageUrl || "";
1177 $("picked-meta").title = value.pageUrl || "";
1178
1179 // Only offer formats this element actually has — an empty tab is a dead end.
1180 const available = FORMATS.filter(([key]) => value[key]);
1181 if (!available.some(([key]) => key === pickedFormat)) {
1182 pickedFormat = available[0]?.[0] ?? "css";
1183 }
1184
1185 const bar = $("picked-formats");
1186 bar.innerHTML = "";
1187 for (const [key, label] of available) {
1188 const b = document.createElement("button");
1189 b.type = "button";
1190 b.textContent = label;
1191 b.setAttribute("role", "tab");
1192 b.setAttribute("aria-selected", String(key === pickedFormat));
1193 b.addEventListener("click", () => {
1194 pickedFormat = key;
1195 for (const other of bar.children) {
1196 other.setAttribute("aria-selected", String(other === b));
1197 }
1198 refreshPicked();
1199 });
1200 bar.appendChild(b);
1201 }
1202
1203 pickedProblem = problem ?? "";
1204 $("picked").hidden = false;
1205 refreshPicked();
1206}
1207
1208function refreshPicked() {
1209 const raw = currentPickedRaw();
1210 const el = $("picked-value");
1211 el.textContent = raw || "not present on this element";
1212 el.classList.toggle("empty", !raw);
1213
1214 const { removedControl, truncated } = Sanitize.forTerminal(raw);
1215 const notes = [];
1216 if (removedControl) notes.push("control characters removed");
1217 if (truncated) notes.push("truncated");
1218 const warn = $("picked-warn");
1219 const modified = notes.length ? `Modified before use: ${notes.join(", ")}.` : "";
1220 warn.textContent = [pickedProblem, modified].filter(Boolean).join(" ");
1221 warn.hidden = !warn.textContent;
1222
1223 /** @type {HTMLButtonElement} */ ($("picked-insert")).disabled = !raw;
1224}
1225
1226$("picked-close").addEventListener("click", () => {
1227 $("picked").hidden = true;
1228 picked = null;
1229});
1230
1231$("picked-copy").addEventListener("click", async () => {
1232 await navigator.clipboard.writeText(Sanitize.forClipboard(currentPickedRaw()));
1233 log("copied to clipboard");
1234});
1235
1236/**
1237 * Send the pick to the terminal: the screenshot first, then the selector.
1238 *
1239 * The screenshot is already on the system clipboard by the time this runs, so
1240 * "sending" it is a literal ^V. That byte is not a paste as far as this panel
1241 * is concerned — xterm.js never sees it — it goes down the wire, and an agent
1242 * on the other end that handles image paste (Claude Code does) reads the
1243 * clipboard itself and attaches the PNG. In a bare shell ^V is literal-next
1244 * instead, which is why it is only sent when there is an image to fetch.
1245 *
1246 * @param {boolean} withShot
1247 */
1248async function insertPicked(withShot) {
1249 if (!connected || !ws) {
1250 log("not connected");
1251 return;
1252 }
1253 const { text } = Sanitize.forTerminal(currentPickedRaw());
1254 if (withShot) {
1255 ws.send(enc.encode("\x16"));
1256 // Reading the clipboard is a round trip out to a helper process on the
1257 // agent's side. Text sent in the same breath can arrive first and end up
1258 // ahead of the attachment on the line.
1259 await new Promise((r) => setTimeout(r, 250));
1260 ws.send(enc.encode(" "));
1261 }
1262 // No trailing newline, ever. The user presses Enter themselves.
1263 ws.send(enc.encode(text));
1264 term.focus();
1265 log(withShot ? `inserted screenshot + ${text.length} chars` : `inserted ${text.length} chars`);
1266}
1267
1268/**
1269 * What a fresh pick does: type it into the terminal, and stay out of the way.
1270 *
1271 * The panel is deliberately *not* opened on the happy path. It shifts the
1272 * terminal down the moment you pick something, which is a poor trade when the
1273 * result has already been typed where you were looking. It opens only when
1274 * there is something to decide — no connection, or no screenshot — and then it
1275 * carries the reason.
1276 *
1277 * @param {TbPicked} value page-controlled, all of it
1278 * @param {true | string} shot true, or why there is no screenshot
1279 */
1280async function deliverPick(value, shot) {
1281 picked = value;
1282 log(`picked ${value.tag} on ${value.pageUrl}`);
1283 log(shot === true ? "screenshot on clipboard, sending ^V" : `no screenshot: ${shot}`);
1284
1285 const needsGrant = shot !== true && isCapturePermissionError(shot);
1286
1287 if (!connected || !ws) {
1288 showPicked(value, "Not connected — nothing was typed.");
1289 if (needsGrant) offerCaptureGrant(shot);
1290 return;
1291 }
1292 await insertPicked(shot === true);
1293 if (shot !== true) {
1294 showPicked(
1295 value,
1296 needsGrant
1297 ? "Selector only. Screenshots from this button need access to all sites; Alt+Shift+P needs none."
1298 : `Selector only, no screenshot: ${shot}`,
1299 );
1300 if (needsGrant) offerCaptureGrant(shot);
1301 }
1302}
1303
1304$("picked-insert").addEventListener("click", () => insertPicked(false));
1305
1306// Keystrokes out as binary, so nothing is lost to UTF-8 round-tripping.
1307term.onData((data) => {
1308 if (connected && ws) ws.send(enc.encode(data));
1309});
1310term.onBinary((data) => {
1311 if (!connected || !ws) return;
1312 const buf = new Uint8Array(data.length);
1313 for (let i = 0; i < data.length; i++) buf[i] = data.charCodeAt(i) & 255;
1314 ws.send(buf);
1315});
1316
1317new ResizeObserver(() => {
1318 fit.fit();
1319 sendSize();
1320}).observe($("term"));
1321
1322// --- settings / persistence -------------------------------------------------
1323
1324const themeSelect = $select("theme-select");
1325themeSelect.addEventListener("change", () => setTheme(themeSelect.value));
1326const densitySelect = $select("density-select");
1327densitySelect.addEventListener("change", () => setDensity(densitySelect.value));
1328
1329$("font-smaller").addEventListener("click", () => setFontSize(fontSize - 1));
1330$("font-bigger").addEventListener("click", () => setFontSize(fontSize + 1));
1331$("font-reset").addEventListener("click", () => setFontSize(FONT_DEFAULT));
1332
1333$("settings-toggle").addEventListener("click", () => {
1334 $("settings").hidden = !$("settings").hidden;
1335});
1336$("reconnect").addEventListener("click", connect);
1337$("connect").addEventListener("click", connect);
1338$("trust-cert").addEventListener("click", () => {
1339 const u = new URL($input("url").value.trim());
1340 api.tabs.create({ url: `https://${u.host}/` });
1341});
1342$("copy-origin").addEventListener("click", () =>
1343 navigator.clipboard.writeText(`termbridge pair ${ORIGIN}`),
1344);
1345const tokenInput = $input("token");
1346const urlInput = $input("url");
1347const revealBox = $input("reveal");
1348revealBox.addEventListener("change", () => {
1349 tokenInput.type = revealBox.checked ? "text" : "password";
1350});
1351tokenInput.addEventListener("change", () =>
1352 storage.set({ token: tokenInput.value.trim() }),
1353);
1354urlInput.addEventListener("change", () => storage.set({ url: urlInput.value.trim() }));
1355
1356storage.get(["token", "url", "session", "theme", "density", "fontSize", "pins"]).then((v) => {
1357 if (v.pins && typeof v.pins === "object") pins = v.pins;
1358 themePref = Themes.PREFERENCES.includes(v.theme) ? v.theme : "auto";
1359 density = DENSITIES[v.density] ? v.density : "normal";
1360 const stored = Number(v.fontSize);
1361 fontSize =
1362 Number.isFinite(stored) && stored >= FONT_MIN && stored <= FONT_MAX
1363 ? Math.round(stored)
1364 : FONT_DEFAULT;
1365 applyTheme();
1366 applyDensity();
1367 applyFontSize();
1368 if (v.token) $input("token").value = v.token;
1369 if (v.url) $input("url").value = v.url;
1370 if (v.session) $input("session").value = v.session;
1371 log(`origin: ${ORIGIN}`);
1372 if (v.token) {
1373 connect();
1374 } else {
1375 setStatus("off", "needs setup");
1376 $("settings").hidden = false;
1377 term.write(
1378 "\x1b[90m terminal\x1b[0m\r\n\r\n" +
1379 " Not configured yet. Open \x1b[1msettings\x1b[0m (top right)\r\n" +
1380 " to pair and paste your token.\r\n",
1381 );
1382 }
1383});