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. What does arrive from the worker — a finished
7// pick, and the toggle shortcut's two window commands — is checked to have come
8// from the extension itself, and none of it reaches the socket.
9
10// One of the two always exists — this file only ever runs as sidebar.html — but
11// neither is declared unconditionally, so say which.
12const api = /** @type {TbExtensionApi} */ (globalThis.browser ?? globalThis.chrome);
13const storage = api.storage.local;
14
15/**
16 * Every id passed here is one this file's own sidebar.html declares, so a miss
17 * is a bug in the pair rather than a runtime condition to handle. Asserting
18 * that is what keeps the call sites readable.
19 *
20 * @param {string} id
21 * @returns {HTMLElement}
22 */
23const $ = (id) => /** @type {HTMLElement} */ (document.getElementById(id));
24
25/** @param {string} id @returns {HTMLInputElement} */
26const $input = (id) => /** @type {HTMLInputElement} */ ($(id));
27/** @param {string} id @returns {HTMLTextAreaElement} */
28const $area = (id) => /** @type {HTMLTextAreaElement} */ ($(id));
29/** @param {string} id @returns {HTMLSelectElement} */
30const $select = (id) => /** @type {HTMLSelectElement} */ ($(id));
31/** @param {string} id @returns {HTMLButtonElement} */
32const $button = (id) => /** @type {HTMLButtonElement} */ ($(id));
33
34const logEl = $("log");
35/** @param {string} m */
36const log = (m) => {
37 logEl.textContent += `${m}\n`;
38 logEl.scrollTop = logEl.scrollHeight;
39};
40
41const ORIGIN = location.origin;
42$("pair-cmd").textContent = `termbridge pair ${ORIGIN}`;
43
44// --- terminal ---------------------------------------------------------------
45
46// --- theme -------------------------------------------------------------------
47
48const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
49let themePref = "auto";
50let termReady = false;
51
52function currentTheme() {
53 return Themes.resolveTheme(themePref, darkQuery.matches);
54}
55
56function applyTheme() {
57 const t = currentTheme();
58 const root = document.documentElement;
59 for (const [k, v] of Object.entries(t.ui)) root.style.setProperty(k, v);
60 // Drives native scrollbars, form controls and the caret.
61 root.style.colorScheme = t.colorScheme;
62 if (termReady) term.options.theme = t.xterm;
63 $select("theme-select").value = themePref;
64}
65
66/** @type {Record<string, { cls: string, hint: string }>} */
67const DENSITIES = {
68 normal: { cls: "", hint: "Session tabs, status text and icons." },
69 compact: { cls: "compact", hint: "Tighter tabs — about one extra terminal row." },
70 hidden: { cls: "chromeless", hint: "Hover the top edge of the panel to bring it back." },
71};
72let density = "normal";
73
74function applyDensity() {
75 const d = DENSITIES[density] ?? DENSITIES.normal;
76 document.body.classList.remove("compact", "chromeless");
77 if (d.cls) document.body.classList.add(d.cls);
78 $select("density-select").value = density;
79 $("density-hint").textContent = d.hint;
80 // The terminal has a different number of rows now.
81 if (termReady) {
82 fit.fit();
83 sendSize();
84 }
85}
86
87/** @param {string} value */
88function setDensity(value) {
89 density = DENSITIES[value] ? value : "normal";
90 storage.set({ density });
91 applyDensity();
92}
93
94/* --- tab modes --------------------------------------------------------------
95 Two ways to lay out one tmux server, and the same status frame feeds both.
96
97 "nested" is the panel's original shape and tmux's own: sessions on top, the
98 selected session's windows underneath. You always see one session's worth of
99 windows, and the two rows say which is which.
100
101 "groups" folds that into a single row, the way Chrome folds tabs into a
102 group: every window on the server is in the row, each session's run of them
103 introduced by a coloured chip, and a chip you are not using collapses to
104 nothing but itself. It buys back a row of terminal and puts a window in
105 another session one click away instead of two — at the cost of a row that is
106 as long as the whole server rather than as long as one session.
107
108 The mode is this panel's, not the server's: nothing here is sent to tmux, and
109 two panels on the same server can be in different modes. */
110/** @type {Record<string, { cls: string, hint: string }>} */
111const TAB_MODES = {
112 nested: { cls: "", hint: "Sessions on top, the selected session's windows below." },
113 groups: { cls: "groups", hint: "One row: every window, grouped by session. Click a chip to fold a group away." },
114};
115let tabMode = "nested";
116
117/**
118 * The header's controls belong to whichever row exists, so switching modes
119 * moves them rather than duplicating them: in groups mode there is only one
120 * row, and everything the session row was holding has to land in it.
121 */
122function applyTabMode() {
123 const m = TAB_MODES[tabMode] ?? TAB_MODES.nested;
124 document.body.classList.toggle("groups", tabMode === "groups");
125 $select("tabmode-select").value = tabMode;
126 $("tabmode-hint").textContent = m.hint;
127
128 const top = $("session-strip");
129 const row = $("window-strip");
130 if (tabMode === "groups") {
131 // One "+" in the tab row, and it means what the only "+" in a browser's tab
132 // strip means: another window, here. Making a *session* is a different kind
133 // of thing and it moves to the omnibar, where it is a named act rather than
134 // a second identical button beside the first — which is the whole reason
135 // there were two icons and no way to tell them apart.
136 row.prepend($("reconnect"), $("settings-toggle"));
137 row.insertBefore($("status-text"), $("tabs"));
138 // The name field comes with it: "+" holds the menu that makes a session
139 // now, and the field it opens has to be in a row that is on screen.
140 row.append($("tab-new"), $("session-name"));
141 // Picker and jump both follow the tabs into the row below — controls that
142 // act on a pane rather than on the tabs — but they take opposite ends of
143 // it: the picker leads, and jump sits past the box, at the end of the row
144 // you type into, where what it does is leave for somewhere else. Enter is
145 // not in either row: it lives under the mic, in the corner beside every
146 // row, and so never moves with the layout.
147 $("omni-strip").prepend($("pick"));
148 $("omni-strip").append($("jump"));
149 $("omni-strip").hidden = false;
150 // The session row is gone, so what it was showing has to go with it: a
151 // stale session tab left in a hidden strip still answers `querySelector`
152 // and would keep the spinner's timer alive on windows nobody can see.
153 $("sessions").textContent = "";
154 $("sessions").dataset.sig = "";
155 // Both of these stay behind in the hidden strip; the omnibar is how a
156 // session gets named now.
157 hideSessionInput();
158 top.hidden = true;
159 } else {
160 top.hidden = false;
161 top.prepend($("reconnect"), $("settings-toggle"));
162 top.insertBefore($("status-text"), $("sessions"));
163 top.append($("session-new"), $("session-name"));
164 // Picker then jump lead the window row, in that order: both act on a pane
165 // rather than on the tabs.
166 row.prepend($("pick"), $("jump"));
167 row.append($("tab-new"));
168 $("omni-strip").hidden = true;
169 closeOmni();
170 // The dot goes with the row, so anything hanging off it has to go too.
171 if (infoOpen) closeTabMenu();
172 }
173
174 // Neither row's signature describes the other's contents, so both have to be
175 // told that what they are holding is not what they should be holding.
176 $("tabs").dataset.sig = "";
177 $("sessions").dataset.sig = "";
178 renderHeader(lastSessions, sessionName, lastAgents);
179 // renderHeader only reaches the omnibar through the groups branch, and this
180 // has to be right in the frame the mode changes rather than a second later.
181 syncOmniHere();
182
183 // One row instead of two: the terminal has a different number of rows now.
184 if (termReady) {
185 fit.fit();
186 sendSize();
187 }
188}
189
190/** @param {string} value */
191function setTabMode(value) {
192 tabMode = TAB_MODES[value] ? value : "nested";
193 storage.set({ tabMode });
194 applyTabMode();
195}
196
197// Below ~8px xterm.js's glyph atlas stops being legible; above ~32 a sidebar
198// holds too few columns to be a terminal.
199const FONT_DEFAULT = 12;
200const FONT_MIN = 8;
201const FONT_MAX = 32;
202let fontSize = FONT_DEFAULT;
203
204// The Terminal's own `lineHeight`, named because touch scrolling needs it too:
205// `fontSize * LINE_HEIGHT` is a row's height in CSS pixels, which is how far a
206// finger travels per line of scroll.
207const LINE_HEIGHT = 1.2;
208
209function applyFontSize() {
210 $("font-size").textContent = String(fontSize);
211 if (!termReady) return;
212 term.options.fontSize = fontSize;
213 // Cell metrics changed, so the row/column count did too.
214 fit.fit();
215 sendSize();
216}
217
218/** @param {number|string} value */
219function setFontSize(value) {
220 const n = Math.round(Number(value));
221 fontSize = Number.isFinite(n) ? Math.min(FONT_MAX, Math.max(FONT_MIN, n)) : FONT_DEFAULT;
222 storage.set({ fontSize });
223 applyFontSize();
224}
225
226/** `_` and `+` are what the shifted keys report. @type {Record<string, number>} */
227const FONT_STEP = { "=": 1, "+": 1, "-": -1, _: -1 };
228
229/**
230 * The panel's own Ctrl+Alt keys: the font size, and the omnibar.
231 *
232 * Ctrl+Alt rather than plain Ctrl throughout. Ctrl+= / Ctrl+- / Ctrl+0 are
233 * browser zoom accelerators, which pages cannot cancel, so binding them would
234 * zoom the whole panel instead; Ctrl+L is the browser's address bar and, in a
235 * terminal, clear-screen. Ctrl+wheel *is* cancelable, so that gesture works as
236 * expected.
237 *
238 * Returns true if the event was one of ours and has been handled.
239 *
240 * @param {KeyboardEvent} e
241 */
242function handlePanelKey(e) {
243 if (!e.ctrlKey || !e.altKey || e.metaKey) return false;
244 const delta = FONT_STEP[e.key];
245 if (delta) setFontSize(fontSize + delta);
246 else if (e.key === "0") setFontSize(FONT_DEFAULT);
247 else return false;
248 e.preventDefault();
249 return true;
250}
251
252/** @param {string} pref */
253function setTheme(pref) {
254 themePref = Themes.PREFERENCES.includes(pref) ? pref : "auto";
255 storage.set({ theme: themePref });
256 applyTheme();
257}
258
259// Only matters while the preference is "auto", but the listener is harmless
260// otherwise and avoids add/remove churn.
261darkQuery.addEventListener("change", () => {
262 if (themePref === "auto") applyTheme();
263});
264
265const term = new Terminal({
266 fontFamily:
267 'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace',
268 fontSize: FONT_DEFAULT,
269 lineHeight: LINE_HEIGHT,
270 cursorBlink: true,
271 scrollback: 5000,
272 theme: Themes.resolveTheme("auto", darkQuery.matches).xterm,
273 // tmux owns the scrollback (copy-mode). Letting xterm.js also scroll fights
274 // with it and with mouse-mode applications.
275 allowProposedApi: true,
276});
277
278const fit = new FitAddon.FitAddon();
279term.loadAddon(fit);
280term.open($("term"));
281fit.fit();
282
283// Autorepeat under load. When the browser process is starved, the key's
284// release sits in the queue behind it while the OS keeps generating repeats
285// for a key it still believes is held; when the process catches up, the whole
286// backlog is flushed into the pty at once and a single tap comes out as a row
287// of characters. Nothing here can stop the OS repeating, but the burst is
288// easy to tell from a real hold: genuine autorepeat arrives ~30ms apart with
289// fresh timestamps, a flushed backlog arrives all in one turn with old ones.
290// Only `repeat` events are ever dropped, so the first press of a key always
291// goes through, and a deliberate hold still repeats, just never faster than
292// the OS would have delivered it live.
293const REPEAT_MIN_GAP_MS = 20;
294const REPEAT_MAX_AGE_MS = 150;
295let lastRepeatAt = 0;
296/** @param {KeyboardEvent} e */
297function staleRepeat(e) {
298 if (!e.repeat) return false;
299 const now = performance.now();
300 const stale = now - e.timeStamp > REPEAT_MAX_AGE_MS || now - lastRepeatAt < REPEAT_MIN_GAP_MS;
301 if (!stale) lastRepeatAt = now;
302 return stale;
303}
304
305// Returning false keeps the keystroke out of the pty.
306term.attachCustomKeyEventHandler((e) => {
307 if (e.type !== "keydown") return true;
308 if (staleRepeat(e)) return false;
309 return !handlePanelKey(e);
310});
311
312$("term").addEventListener(
313 "wheel",
314 (e) => {
315 if (!e.ctrlKey) return;
316 e.preventDefault();
317 setFontSize(fontSize + (e.deltaY < 0 ? 1 : -1));
318 },
319 { passive: false },
320);
321
322// --- touch scrolling --------------------------------------------------------
323//
324// xterm.js has no touch handling of its own: its mouse layer binds `mousedown`,
325// `mousemove`, `mouseup` and `wheel`, and the browser only synthesises those
326// from a *tap*. A drag is claimed as a pan gesture and never reaches the
327// terminal at all. The one thing that would have scrolled without any help —
328// `.xterm-viewport` is an `overflow-y: scroll` box — is always empty here,
329// because tmux sits on the alternate screen, repaints in place, and keeps the
330// scrollback on its own side.
331//
332// So a drag is turned back into wheel events rather than into escape sequences.
333// A wheel event is the signal every layer downstream already agrees on: tmux
334// with `mouse on` encodes it into copy-mode, an inner vim or less that turned
335// mouse reporting on gets the report it expects, and xterm.js's own fallback
336// still converts it to cursor keys when nothing asked for either. Writing SGR
337// reports here instead would mean tracking mouse mode ourselves and being wrong
338// about it every time an application changed it behind our back.
339//
340// Scrolling is all a touch does. Selection stays on the mouse: a drag has to
341// mean one or the other, and on a panel this narrow scrolling is the gesture
342// worth having.
343
344/** How far a finger may wander before a tap counts as a drag, in CSS pixels. */
345const TOUCH_SLOP = 8;
346
347/** The finger being followed, or null when no drag is in progress. */
348let touchId = /** @type {number | null} */ (null);
349let touchStartY = 0;
350/** Last position charged to `touchRest`. */
351let touchY = 0;
352/** Travel not yet worth a whole line, kept so a slow drag still scrolls. */
353let touchRest = 0;
354/** Whether the finger has passed TOUCH_SLOP and owns the gesture. */
355let touchScrolling = false;
356
357/**
358 * Hand xterm.js a wheel event it cannot tell from a real one.
359 *
360 * The mouse protocol emits one report per event and reads the cell under the
361 * pointer out of `clientX`/`clientY`, so the finger's own position is carried
362 * through and a multi-line step is sent a line at a time. `DOM_DELTA_LINE` with
363 * a delta of one is xterm's own definition of a single line, which keeps this
364 * clear of its pixel-mode accumulator.
365 *
366 * The position is clamped into `.xterm-screen` first. xterm resolves a report's
367 * cell against that element and reports nothing at all when the point falls
368 * outside it, and a finger can easily sit on `#term`'s padding or over the
369 * scrollbar column — close enough to be scrolling, far enough to report
370 * nothing. Clamping costs a cell of accuracy at the very edge and buys a
371 * gesture that works everywhere on the panel.
372 *
373 * @param {Touch} t
374 * @param {number} lines signed, in terminal rows
375 */
376function touchWheel(t, lines) {
377 const screen = term.element?.querySelector(".xterm-screen");
378 // Both of xterm's wheel listeners live on the `.xterm` root, so dispatching
379 // on the viewport underneath it reaches them by bubbling and would also reach
380 // a listener the viewport grew later.
381 const target = term.element?.querySelector(".xterm-viewport") ?? term.element;
382 if (!screen || !target) return;
383
384 const box = screen.getBoundingClientRect();
385 const clientX = Math.min(box.right - 1, Math.max(box.left, t.clientX));
386 const clientY = Math.min(box.bottom - 1, Math.max(box.top, t.clientY));
387
388 const step = Math.sign(lines);
389 for (let i = 0; i < Math.abs(lines); i++) {
390 target.dispatchEvent(
391 new WheelEvent("wheel", {
392 deltaY: step,
393 deltaMode: WheelEvent.DOM_DELTA_LINE,
394 clientX,
395 clientY,
396 bubbles: true,
397 cancelable: true,
398 }),
399 );
400 }
401}
402
403$("term").addEventListener(
404 "touchstart",
405 (e) => {
406 // A second finger cancels rather than joins: a pinch is not a scroll, and
407 // the font size is still Ctrl+wheel's.
408 if (e.touches.length !== 1) {
409 touchId = null;
410 return;
411 }
412 const t = e.touches[0];
413 touchId = t.identifier;
414 touchStartY = t.clientY;
415 touchY = t.clientY;
416 touchRest = 0;
417 touchScrolling = false;
418 // Deliberately not prevented: the tap still has to become a click, which is
419 // what focuses the terminal and what an application in mouse mode reports.
420 },
421 { passive: true },
422);
423
424$("term").addEventListener(
425 "touchmove",
426 (e) => {
427 if (touchId === null) return;
428 let t = /** @type {Touch | null} */ (null);
429 for (const c of e.changedTouches) if (c.identifier === touchId) t = c;
430 if (!t) return;
431
432 if (!touchScrolling) {
433 if (Math.abs(t.clientY - touchStartY) < TOUCH_SLOP) return;
434 // Start the accounting from here, so the slop is spent rather than
435 // scrolled: the text should not jump by TOUCH_SLOP the moment it engages.
436 touchScrolling = true;
437 touchY = t.clientY;
438 }
439
440 // Dragging the content, not the viewport. A finger moving up drags the text
441 // up, which shows what comes after it — a positive wheel delta.
442 touchRest += touchY - t.clientY;
443 touchY = t.clientY;
444 e.preventDefault();
445
446 // One line per row of travel, so the text keeps up with the finger.
447 const cell = fontSize * LINE_HEIGHT;
448 const lines = Math.trunc(touchRest / cell);
449 if (!lines) return;
450 touchRest -= lines * cell;
451 touchWheel(t, lines);
452 },
453 { passive: false },
454);
455
456for (const type of ["touchend", "touchcancel"]) {
457 $("term").addEventListener(type, () => {
458 touchId = null;
459 touchScrolling = false;
460 });
461}
462
463// Set only once term *and* fit are usable — applyTheme/applyDensity both touch
464// them, and a flag that runs ahead of the objects it guards is worse than none.
465termReady = true;
466
467const enc = new TextEncoder();
468
469// --- connection -------------------------------------------------------------
470
471let ws = /** @type {WebSocket | null} */ (null);
472let connected = false;
473// Session tmux says we are on. The name we asked to attach to is only the
474// starting point — switch-client moves us and the header should follow.
475let sessionName = /** @type {string | null} */ (null);
476
477// Whether the daemon's program is tmux. Without it there are no windows to put
478// in tabs, and "+" would have nothing to create.
479let tmuxMode = false;
480
481// The session the daemon falls back to when nothing names one — `default`
482// unless it was started with --session. This panel treats it as the home
483// session: it is where "+" puts a window when it is not adding to a particular
484// group, and it is drawn without a colour of its own, the way Chrome leaves
485// ungrouped tabs plain. The daemon names it on the ok frame; the fallback here
486// only covers the moment before that arrives.
487//
488// It is not otherwise privileged: tmux has no notion of a special session, so
489// it can be renamed, killed or dragged like any other, and if it does not exist
490// the first "+" creates it.
491let defaultSession = "default";
492
493/**
494 * Machines the omnibar can offer to connect to, from the daemon's reading of
495 * `~/.ssh/config` and `~/.ssh/known_hosts`.
496 *
497 * On the ok frame rather than the status frames because it is a fact about
498 * files on disk, not about what tmux is doing: it does not change while a panel
499 * is open, and a reconnect is what picks up an edited config.
500 *
501 * A connection is an ordinary window running ssh on the daemon's machine, so
502 * nothing else in this panel treats these differently from anywhere else —
503 * they are a source of names for one kind of row, and that is all.
504 * @type {string[]}
505 */
506let sshHosts = [];
507
508/**
509 * The header says what the socket is doing only when it is doing something
510 * other than working: a connected panel has session tabs, which report the
511 * connection by existing, and there is nothing a green light adds to that.
512 *
513 * @param {"on"|"off"|"pending"} state whether reconnect is worth offering
514 * @param {string} text
515 * @param {string} [detail] one sentence for the overlay card, when the caller
516 * knows better than `offlineHint` what would fix it
517 */
518function setStatus(state, text, detail) {
519 $("status-text").textContent = text;
520 $("reconnect").hidden = state === "on";
521 showOffline(state, text, detail);
522}
523
524/**
525 * The same state, in the middle of the panel. The header line is the whole
526 * story while things work — a connected panel says so with its tabs — but a
527 * dead socket leaves a scrollback that still looks alive above a word small
528 * enough to miss, so it gets a card over the terminal until it is fixed.
529 *
530 * @param {"on"|"off"|"pending"} state
531 * @param {string} text what the header says, which is also the card's heading
532 * @param {string} [detail] what to do about it; derived from `text` if absent
533 */
534function showOffline(state, text, detail) {
535 const box = $("offline");
536 if (state === "on") {
537 box.hidden = true;
538 return;
539 }
540 box.dataset.state = state;
541 $("offline-title").textContent = state === "pending" ? "connecting…" : text;
542 $("offline-msg").textContent = state === "pending" ? "" : (detail ?? offlineHint(text));
543 box.hidden = false;
544}
545
546/**
547 * One sentence naming the thing that would fix it. Two ways a socket stays
548 * down — nothing is listening, or what is listening does not believe us — and
549 * they are fixed in different places, so the card must not guess wrong.
550 *
551 * @param {string} reason the header's wording, which is the daemon's when it
552 * rejected us and this panel's otherwise
553 */
554function offlineHint(reason) {
555 if (/token|auth|setup/i.test(reason)) {
556 return "Open settings and paste the token termbridge token prints.";
557 }
558 if (/origin|paired/i.test(reason)) {
559 return "This extension is not paired yet. Settings has the command to run.";
560 }
561 const url = $input("url").value.trim() || "the daemon";
562 return `Nothing is answering at ${url}. Start it with termbridge serve, then reconnect.`;
563}
564
565// One place decides what the header says, so anything that repaints it (a
566// finished pick, a fresh session name) can't quietly drop the other half.
567function refreshStatus() {
568 if (!connected) {
569 setStatus("off", "disconnected");
570 return;
571 }
572 setStatus("on", sessionName ? `connected · ${sessionName}` : "connected");
573}
574
575function sendSize() {
576 if (!connected || !ws) return;
577 const { cols, rows } = term;
578 ws.send(JSON.stringify({ type: "resize", cols, rows }));
579}
580
581// The daemon can only answer "invalid token" on the wire; it cannot reach into
582// the sidebar. Translate that into the one instruction that actually fixes it.
583const TOKEN_HELP =
584 "Run termbridge token in the terminal running the daemon, then paste " +
585 "the 64 hex characters it prints into the Token field above.";
586
587/** @param {string} text */
588function tokenProblem(text) {
589 $("token-hint").textContent = `${text} ${TOKEN_HELP}`;
590 $("token-hint").hidden = false;
591 openSettings();
592 term.write(
593 `\r\n\x1b[33m ${text}\x1b[0m\r\n` +
594 " Open \x1b[1msettings\x1b[0m (top right) and set the Token.\r\n" +
595 " \x1b[90mtermbridge token\x1b[0m prints the value to paste.\r\n",
596 );
597 $("token").focus();
598}
599
600function connect() {
601 const url = $input("url").value.trim();
602 const token = $input("token").value.trim();
603
604 if (!token) {
605 // Connecting with an empty token just produces an "invalid token" reject in
606 // the daemon's log, which reads like a wrong token rather than a missing one.
607 setStatus("off", "no token");
608 log("no token set — not connecting");
609 tokenProblem("No token is set.");
610 return;
611 }
612 $("token-hint").hidden = true;
613
614 if (ws) {
615 ws.onclose = null;
616 ws.close();
617 }
618 connected = false;
619 setStatus("pending", "connecting…");
620 log(`connecting to ${url}`);
621
622 try {
623 ws = new WebSocket(url);
624 } catch (e) {
625 setStatus("off", "failed");
626 log(`constructor threw: ${e}`);
627 return;
628 }
629 // The handlers close over *this* socket rather than whatever `ws` points at
630 // when they fire: a reconnect replaces the module-level reference, and a
631 // late frame from the old socket must not be answered on the new one.
632 const sock = ws;
633 sock.binaryType = "arraybuffer";
634
635 sock.onopen = () => {
636 // Token goes in a frame, never the URL — query strings end up in logs,
637 // crash dumps and devtools history.
638 sock.send(JSON.stringify({ type: "auth", token }));
639 };
640
641 sock.onmessage = (ev) => {
642 if (typeof ev.data === "string") {
643 // A claim about the frame, not a guarantee — see types/globals.d.ts. It
644 // buys the narrowing below, not trust.
645 const msg = /** @type {TbFrame} */ (JSON.parse(ev.data));
646 if (msg.type === "ok") {
647 connected = true;
648 tmuxMode = msg.tmux === true;
649 $("trust-hint").hidden = true;
650 $("token-hint").hidden = true;
651 renderSessions(msg);
652 const want = $input("session").value.trim() || undefined;
653 // Optimistic; the daemon's first status frame replaces this with what
654 // tmux actually attached us to, usually within a second.
655 if (msg.defaultSession) defaultSession = msg.defaultSession;
656 sshHosts = msg.hosts ?? [];
657 // Directories the daemon described to a previous connection. Cheap to
658 // ask again, and a reconnect is the one moment we know nothing about
659 // what has happened on that machine in the meantime.
660 pathAnswers.clear();
661 pathAsking.clear();
662 sessionName = want ?? msg.defaultSession ?? msg.profile;
663 refreshStatus();
664 // Session names only at this point — the window lists need the control
665 // channel, which the first status frame brings a moment later.
666 renderHeader(
667 (msg.sessions ?? []).map((name) => ({ id: "", name, attached: false, windows: [] })),
668 sessionName,
669 [],
670 );
671 log(`authenticated, running ${msg.profile}`);
672 fit.fit();
673 // The daemon validates this name and falls back to its default if it
674 // doesn't like it; we never get to choose the program.
675 sock.send(
676 JSON.stringify({
677 type: "open",
678 cols: term.cols,
679 rows: term.rows,
680 ...(want ? { session: want } : {}),
681 }),
682 );
683 closeSettings();
684 term.focus();
685 } else if (msg.type === "status") {
686 if (msg.session && msg.session !== sessionName) {
687 sessionName = msg.session;
688 refreshStatus();
689 }
690 // One frame describes the whole server: sessions for the top row, the
691 // selected session's own windows for the second, and every agent in
692 // any of them — a session tab reports the Claude in a session you
693 // cannot see, which is the point of knowing about all of them.
694 const sessions = msg.sessions ?? [];
695 const agents = msg.agents ?? [];
696 // A frame that cannot say which session we are on — the moment mid
697 // switch-client, before tmux has answered for our client — is not news
698 // that we are on none. Falling for that empties the window row, which
699 // takes a row of height with it and reflows the terminal underneath:
700 // the flash. The last session we were told about stands until another
701 // one is named.
702 const current = msg.session ?? sessionName;
703 renderHeader(sessions, current, agents);
704 // The first frame is the first moment a pin can be acted on: until it
705 // arrives there is no session list to resolve one against. Opening the
706 // panel while sitting on a pinned tab lands on the right session
707 // because of this, and only because of this.
708 if (!pinAppliedOnConnect) {
709 pinAppliedOnConnect = true;
710 applyTabPin();
711 }
712 // And the other direction, after the render: this reads `lastSessions`,
713 // which `renderHeader` is what sets.
714 noteSpotChange();
715 } else if (msg.type === "path") {
716 // An answer to something the omnibar asked about a directory. Keyed by
717 // the query, so one that arrives after the box has moved on is filed
718 // rather than acted on — see `askPath`.
719 takePathAnswer(msg);
720 } else if (msg.type === "tmux-error") {
721 // tmux refused the command (session gone, pane closed). The next status
722 // frame already shows the truth, so this only needs to explain itself.
723 log(`tmux: ${msg.reason}`);
724 } else if (msg.type === "exit") {
725 log(`session exited (${msg.code})`);
726 term.write(`\r\n\x1b[90m[session exited: ${msg.code}]\x1b[0m\r\n`);
727 } else if (msg.type === "error") {
728 setStatus("off", msg.reason);
729 log(`rejected: ${msg.reason}`);
730 if (/token|auth/i.test(msg.reason)) {
731 tokenProblem("The daemon rejected this token.");
732 } else if (/origin/i.test(msg.reason)) {
733 openSettings();
734 term.write(
735 `\r\n\x1b[33m ${msg.reason}\x1b[0m\r\n` +
736 " Open \x1b[1msettings\x1b[0m and run the pairing command shown there.\r\n",
737 );
738 }
739 }
740 return;
741 }
742 // Raw terminal bytes. xterm.js takes Uint8Array directly, so nothing is
743 // decoded, re-encoded, or mangled on the way in.
744 term.write(new Uint8Array(ev.data));
745 };
746
747 sock.onerror = () => {
748 // The browser hides the reason from JS by design. The daemon's stdout has
749 // the real one (unpaired origin / bad token / rate limited).
750 log("socket error — check the daemon's output for the reason");
751 // Firefox's HTTPS-Only Mode silently rewrites ws:// to wss://, so the most
752 // likely cause here is that our self-signed certificate isn't trusted yet.
753 $("trust-hint").hidden = false;
754 };
755
756 sock.onclose = (ev) => {
757 connected = false;
758 // The socket that was carrying the held key is gone, so the hold is over
759 // whether or not the pointer knows it yet — and the Return it queued has
760 // nowhere to land, so it goes too rather than arriving on the next socket.
761 stopTalk();
762 cancelTalkSubmit();
763 sessionName = null;
764 tmuxMode = false;
765 // The next socket gets to place us again, and it is a new client on a
766 // server whose sessions may have moved since.
767 pinAppliedOnConnect = false;
768 // Nothing to hand back: the client that was borrowed is gone, and the next
769 // socket lands wherever the daemon puts it.
770 pinOwed = null;
771 // Same reasoning for the other direction: wherever the next socket lands is
772 // a starting position, not somewhere the user steered to, so it must not
773 // read as a move and rearrange the browser.
774 lastSpot = null;
775 forwardGoingTo = null;
776 clearTimeout(reverseTimer);
777 renderHeader([], null, []);
778 refreshStatus();
779 log(`closed code=${ev.code} ${ev.reason || ""}`);
780 };
781}
782
783// Ask the daemon to move tmux. The daemon accepts three commands and validates
784// every argument; the sidebar cannot name a tmux command of its own.
785/**
786 * @param {{ cmd: "switch"|"create"|"focus"|"select-window"|"goto-window"|"new-window"
787 * |"kill-window"|"move-window"|"move-window-to-session"|"new-session-with-window"
788 * |"set-session-color"|"rename-session"|"claude"|"run"|"ssh"
789 * |"new-project" }
790 * & Record<string, string | boolean>} body
791 */
792function tmuxCommand(body) {
793 if (!connected || !ws) return;
794 ws.send(JSON.stringify({ type: "tmux", ...body }));
795}
796
797// --- the header -------------------------------------------------------------
798//
799// One entry point for both layouts, because one status frame feeds both and
800// which of them is on screen is a preference rather than anything the frame
801// says. Everything below this line renders from the same three arguments.
802
803/**
804 * The last frame's sessions, kept for the repaints nothing on the wire causes:
805 * folding a group, pinning a tab, switching mode. Windows and agents have the
806 * same reason to be kept — see `lastWindows`.
807 * @type {TbSessionInfo[]}
808 */
809let lastSessions = [];
810
811/**
812 * @param {TbSessionInfo[]} sessions every session on the server
813 * @param {string | null | undefined} current the one this panel's client is on
814 * @param {TbAgent[]} agents server-wide
815 */
816function renderHeader(sessions, current, agents) {
817 lastSessions = sessions;
818 lastAgents = agents;
819 // Resolved once per frame rather than once per tab: every row asks whether it
820 // is the pinned one, and the answer cannot change between two rows of the
821 // same repaint.
822 pinNow = currentPinTarget();
823 // Before the early return below: the jump button is in whichever row exists,
824 // and what it says comes from the agents rather than from either layout.
825 syncJump();
826 if (tabMode === "groups") {
827 renderGroups(sessions, current, agents);
828 return;
829 }
830 renderSessionTabs(sessions, current, agents);
831 // A frame that names no session leaves the window row on what it had — see
832 // the note at the call site about the flash.
833 const here = sessions.find((s) => s.name === current);
834 renderTabs(here?.windows ?? lastWindows, agents);
835}
836
837// --- building elements ------------------------------------------------------
838//
839// Everything the header draws is built rather than written out, and by hand it
840// is five lines of `createElement`, `className`, `textContent`, `setAttribute`
841// and `appendChild` for every span. `el` is those five lines, and the reason it
842// is a function here rather than a template library is the same reason the
843// extension has no bundler: it ships as plain files, and nothing in
844// node_modules reaches dist/.
845//
846// It is deliberately not a renderer. There is no diffing, no keys and no state
847// — the callers below build a fresh subtree and swap it in, exactly as they did
848// before, and the signature checks in renderTabs/renderSessionTabs are still
849// what decides whether that happens at all.
850//
851// One rule it enforces by having no way around it: text arrives as `text` or as
852// a child string, both of which end up in `textContent`. Nothing here accepts
853// markup, because most of what it draws is a tmux name — a window title a shell
854// sets from whatever it is running, which is as page-influenced as any other
855// terminal output.
856
857/**
858 * @typedef {Node | string | null | undefined | false} TbChild A child to append,
859 * or a falsy value to skip — so a conditional part of a subtree can be an
860 * expression rather than an `if` with an `appendChild` in it.
861 */
862
863/**
864 * @typedef {object} TbElOpts
865 * @property {string} [class] Written as `class` rather than `className`: these
866 * read as markup at the call sites, and every one of them builds the string
867 * with the same conditional template.
868 * @property {string} [text] textContent. Never markup — see above.
869 * @property {string} [title] The tooltip. `tip()` is what builds most of them.
870 * @property {Record<string, string | number | boolean>} [attrs] Set with
871 * setAttribute, so `role`, `aria-*` and `type` are written the way the DOM
872 * spells them. Values are stringified, which is all `aria-selected` ever
873 * wanted from `String(...)`.
874 * @property {Record<string, string>} [data] dataset entries.
875 * @property {Record<string, string>} [css] Custom properties, set with
876 * setProperty — `--group-h` and nothing else so far.
877 * @property {Record<string, (e: any) => void>} [on] Listeners by event name.
878 */
879
880/**
881 * @template {keyof HTMLElementTagNameMap} K
882 * @param {K} tag
883 * @param {TbElOpts} [opts]
884 * @param {...TbChild} children
885 * @returns {HTMLElementTagNameMap[K]}
886 */
887function el(tag, opts = {}, ...children) {
888 const node = document.createElement(tag);
889 if (opts.class) node.className = opts.class;
890 if (opts.text != null) node.textContent = opts.text;
891 if (opts.title != null) node.title = opts.title;
892 for (const [k, v] of Object.entries(opts.attrs ?? {})) node.setAttribute(k, String(v));
893 for (const [k, v] of Object.entries(opts.data ?? {})) node.dataset[k] = v;
894 for (const [k, v] of Object.entries(opts.css ?? {})) node.style.setProperty(k, v);
895 for (const [k, v] of Object.entries(opts.on ?? {})) node.addEventListener(k, v);
896 for (const c of children) if (c) node.append(c);
897 return node;
898}
899
900/**
901 * A button, which is every other element here. `type="button"` because the
902 * default is `submit` and a stray Enter on one of these inside a form would
903 * reload the panel.
904 *
905 * @param {TbElOpts} [opts]
906 * @param {...TbChild} children
907 */
908function button(opts = {}, ...children) {
909 return el("button", { ...opts, attrs: { type: "button", ...opts.attrs } }, ...children);
910}
911
912/**
913 * The favicon slot, and the one piece of markup that repeats across every kind
914 * of row the panel draws: a tab, a chip, an omnibar row, a line in the session
915 * panel. All of them report the same four states with the same glyphs, and the
916 * working one has to be the spinner's current frame rather than a fixed
917 * character — see the spinner section for why it is read at build time.
918 *
919 * Empty for "none", deliberately: a plain window of shell should not wear a
920 * status light it has no state to report.
921 *
922 * @param {TbAgentState | "none" | undefined} state
923 * @param {string} [cls] extra classes, for the rows that are not a favicon slot
924 */
925function glyphSpan(state, cls) {
926 const s = state ?? "none";
927 return el("span", {
928 class: `glyph ${s}${cls ? ` ${cls}` : ""}`,
929 text: s === "working" ? spinnerGlyph() : (STATIC_GLYPH[s] ?? ""),
930 attrs: { "aria-hidden": "true" },
931 });
932}
933
934/**
935 * A multi-line tooltip from parts, any of which may be missing. Everything in
936 * the header has one: the rows are narrow enough that the detail — which tool
937 * is in flight, how many panes, the name a pinned tab gave up — has nowhere
938 * else to go.
939 *
940 * @param {...(string | false | null | undefined)} lines
941 */
942function tip(...lines) {
943 return lines.filter(Boolean).join("\n");
944}
945
946// --- window tabs ------------------------------------------------------------
947//
948// A tab is a window of the attached session — the thing `prefix 2` selects, not
949// a session and not a pane. Window names come from tmux (a shell sets them from
950// whatever it is running, so they are as page-influenced as any other terminal
951// output) and only ever reach the DOM through textContent.
952
953// --- pinning ----------------------------------------------------------------
954//
955// A pin is a property of this panel, not of the session. tmux is never asked to
956// renumber anything: the window keeps its real index, so `prefix 4` still goes
957// where it always went, and nothing about your terminal's status line changes.
958// All a pin does is move the tab to the front of *this* strip and shrink it to
959// its dot and index, which is what buys the room in a narrow sidebar.
960//
961// Keyed by session name, because window ids are only meaningful inside the tmux
962// server that issued them and only prunable against the session we can see.
963/** @type {Record<string, string[]>} */
964let pins = {};
965
966// The last frame's worth of windows, so a pin — which changes nothing on the
967// wire and so produces no status frame — can repaint the strip on its own.
968/** @type {TbWindowInfo[]} */
969let lastWindows = [];
970/** @type {TbAgent[]} */
971let lastAgents = [];
972
973/**
974 * Takes the session rather than reading `sessionName`: in groups mode the strip
975 * holds windows from every session at once, so "which session's pins" is a
976 * question each tab answers for itself.
977 *
978 * @param {string | null | undefined} session
979 * @returns {Set<string>} pinned window ids in that session
980 */
981function pinnedIds(session) {
982 return new Set((session && pins[session]) || []);
983}
984
985/**
986 * Whether a window may be offered a ✕.
987 *
988 * Killing a session's last window kills the session, and that used to take this
989 * panel with it — so the ✕ was withheld for it. It no longer does: the daemon
990 * sets `detach-on-destroy off` on every session the client lands on (see
991 * `ensure_detach_on_destroy` in daemon/src/server.rs), and tmux answers a
992 * destroyed session by moving the client to another one rather than detaching.
993 *
994 * What is left is the genuinely last thing on the server. With nothing to fall
995 * back to tmux does detach, the pty hits EOF and the panel goes dead — so that
996 * one window, and only it, still gets no ✕.
997 *
998 * The old rule was written when the window row only ever held one session's
999 * windows, where "the last window here" and "the last window anywhere" looked
1000 * the same. Groups mode is what pulled them apart: it shows every session at
1001 * once, so a one-window session sat next to five others with no ✕ on it and no
1002 * reason the eye could see.
1003 *
1004 * @param {number} windowsInSession
1005 * @param {number} sessionsOnServer
1006 */
1007function windowClosable(windowsInSession, sessionsOnServer) {
1008 return windowsInSession > 1 || sessionsOnServer > 1;
1009}
1010
1011/** @param {string | null | undefined} session @param {Set<string>} ids */
1012function savePins(session, ids) {
1013 if (!session) return;
1014 if (ids.size) pins[session] = [...ids];
1015 else delete pins[session];
1016 storage.set({ pins });
1017}
1018
1019/**
1020 * Pinned first, each group still in tmux's own index order — nothing is
1021 * reordered on the server, so the indexes stay the ground truth they are.
1022 *
1023 * Also prunes: a window that closed takes its pin with it, and this is the one
1024 * moment we hold a session's full window list to notice that by.
1025 *
1026 * @param {string | null | undefined} session
1027 * @param {TbWindowInfo[]} windows that session's windows, in index order
1028 * @returns {{ ordered: TbWindowInfo[], pinned: Set<string> }}
1029 */
1030function orderWindows(session, windows) {
1031 const pinned = pinnedIds(session);
1032 const live = new Set(windows.map((w) => w.id));
1033 let dropped = false;
1034 for (const id of pinned) {
1035 if (!live.has(id)) {
1036 pinned.delete(id);
1037 dropped = true;
1038 }
1039 }
1040 if (dropped) savePins(session, pinned);
1041 return {
1042 ordered: [
1043 ...windows.filter((w) => pinned.has(w.id)),
1044 ...windows.filter((w) => !pinned.has(w.id)),
1045 ],
1046 pinned,
1047 };
1048}
1049
1050/**
1051 * Repaint the window strip after something only this panel knows about — a pin,
1052 * a folded group. Nothing changed on the wire, so no frame is coming to trigger
1053 * the rebuild, and the signature has to be cleared or it would skip it.
1054 */
1055function repaintTabs() {
1056 $("tabs").dataset.sig = "";
1057 // The other entry point is renderHeader, and this one does not go through it:
1058 // a repaint caused by a new pin, or by the browser tab changing under us,
1059 // has to re-resolve before the rows ask which of them is marked.
1060 pinNow = currentPinTarget();
1061 if (tabMode === "groups") {
1062 renderGroups(lastSessions, sessionName, lastAgents);
1063 return;
1064 }
1065 // The nested layout keeps its two rows on separate signatures, and a
1066 // session-level browser pin marks the top one. Its signature has to be
1067 // cleared too, or the dot waits for a frame that changes something else.
1068 $("sessions").dataset.sig = "";
1069 renderSessionTabs(lastSessions, sessionName, lastAgents);
1070 renderTabs(lastWindows, lastAgents);
1071}
1072
1073/**
1074 * @param {TbWindowInfo[]} windows in index order
1075 * @param {TbAgent[]} agents so a tab can say what Claude is doing in it
1076 */
1077function renderTabs(windows, agents) {
1078 lastWindows = windows;
1079 lastAgents = agents;
1080 const strip = $("tabs");
1081 // A repaint mid-drag would tear the tab out from under the pointer; the move
1082 // it commits to brings a fresh frame of its own a moment later.
1083 if (dragging && !strip.hidden) return;
1084 const show = connected && tmuxMode && windows.length > 0;
1085 $("tab-new").hidden = !(connected && tmuxMode && sessionName);
1086 // Reset, not decoration: the groups layout points this same button at the
1087 // home session and says so, and switching back would otherwise leave that
1088 // promise on a button that no longer keeps it.
1089 $("tab-new").title = newTabTitle(sessionName);
1090 strip.hidden = !show;
1091 // The hairline between the two rows belongs to the session row, and only
1092 // while there is a window row under it to be separated from.
1093 document.body.classList.toggle("has-windows", show);
1094 if (!show) {
1095 strip.textContent = "";
1096 strip.dataset.sig = "";
1097 syncSpinner();
1098 return;
1099 }
1100
1101 const { ordered, pinned } = orderWindows(sessionName, windows);
1102
1103 // Repainting drops hover and any focus ring; status frames arrive on every
1104 // tmux notification and once a second besides.
1105 const claude = agentByWindow(agents);
1106 const closable = windowClosable(windows.length, lastSessions.length);
1107 // `closable` is in the signature because it is the one thing drawn here that
1108 // does not come from this session's windows: killing the *other* session
1109 // changes whether this session's last window may be closed, and nothing in
1110 // the window list moves when that happens. Left out, the strip keeps a ✕ that
1111 // would now take the whole server down with it.
1112 const sig = JSON.stringify([
1113 closable,
1114 ordered.map((w) => {
1115 const a = claude[w.id];
1116 return [w.id, w.index, w.name, w.active, w.activity, a?.state, agentLabel(a), pinned.has(w.id)];
1117 }),
1118 ]);
1119 if (strip.dataset.sig !== sig) {
1120 strip.dataset.sig = sig;
1121 strip.textContent = "";
1122 for (const w of ordered) {
1123 strip.appendChild(
1124 windowTab(w, {
1125 claude: claude[w.id],
1126 closable,
1127 pinned: pinned.has(w.id),
1128 lastInSession: windows.length === 1,
1129 owner: sessionName ?? "",
1130 // One session's windows, and it is the one we are on, so tmux's own
1131 // "active" is exactly the tab you are looking at.
1132 current: w.active,
1133 }),
1134 );
1135 }
1136 }
1137
1138 syncSpinner();
1139
1140 const active = strip.querySelector('[aria-selected="true"]');
1141 // Sidebars are narrow enough that the window you are on can be scrolled out
1142 // of the strip entirely.
1143 if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
1144}
1145
1146// Loudest first: a window with something waiting on you outranks one that is
1147// merely busy, and both outrank one that is only still. The two loud states are
1148// the two that want you — blocked mid-turn, or done with one you have not seen
1149// — so they sort above the two that don't. A window with no Claude in it gets
1150// no entry at all.
1151const AGENT_RANK = ["waiting", "ready", "working", "idle", "unknown"];
1152
1153/**
1154 * @param {TbAgent[]} agents
1155 * @returns {Record<string, TbAgent>} window id → the one worth reporting
1156 */
1157function agentByWindow(agents) {
1158 /** @type {Record<string, TbAgent>} */
1159 const out = {};
1160 for (const a of agents) {
1161 const seen = out[a.window_id];
1162 if (!seen || AGENT_RANK.indexOf(a.state) < AGENT_RANK.indexOf(seen.state)) {
1163 out[a.window_id] = a;
1164 }
1165 }
1166 return out;
1167}
1168
1169/* --- jump to the next pane that wants you ----------------------------------
1170 One button that walks the panes an agent is in, so cycling between agents is
1171 a repeated tap rather than a hunt through two rows of tabs. It is the tab
1172 strip's loudness order with one change: `working` drops below `idle`. A pane
1173 mid-turn is the one place there is nothing for you to do, and the whole point
1174 of the button is to land somewhere you can act — so it is offered last, when
1175 there is nothing else, rather than not at all.
1176
1177 Within a state the order is the tab rows' own — session, then window, then
1178 pane — so pressing repeatedly walks the server the way you read it, and it
1179 wraps.
1180
1181 The pane you are on is never a destination: the daemon marks it `here`, and it
1182 drops out of the walk entirely. That is what makes the button work at the one
1183 moment you most want it — the agent in front of you finishing. Its own turn
1184 ending puts it at rest, at rest is the loudest thing on the server while
1185 everything else is mid-turn, and cycling "the loudest tier" would hand you
1186 back the pane you are already looking at. `lastJumped` still skips a step
1187 inside a tier, because the frame that will mark the new pane `here` is up to a
1188 second behind the press. */
1189const JUMP_RANK = ["waiting", "ready", "idle", "unknown", "working"];
1190
1191// The pane this button last sent the client to, so a second press inside the
1192// same tier goes somewhere new even before the frame that says `here` lands.
1193let lastJumped = "";
1194
1195/**
1196 * Every pane with an agent in it *other than the one on screen*, loudest first
1197 * — see `JUMP_RANK`.
1198 * @param {TbAgent[]} agents
1199 * @returns {TbAgent[]}
1200 */
1201function jumpOrder(agents) {
1202 // tmux's own window order, which is the index and not the name: the tab rows
1203 // are drawn in it, so walking the panes in any other order would jump about
1204 // the strip you are looking at. A window we have no frame for sorts last
1205 // rather than first — an agent whose window has just gone is not where the
1206 // next press should land.
1207 /** @type {Record<string, number>} */
1208 const at = {};
1209 for (const s of lastSessions) for (const w of s.windows) at[w.id] = w.index;
1210 return agents
1211 .filter((a) => a.pane && !a.here)
1212 .slice()
1213 .sort(
1214 (x, y) =>
1215 JUMP_RANK.indexOf(x.state) - JUMP_RANK.indexOf(y.state) ||
1216 x.session.localeCompare(y.session) ||
1217 (at[x.window_id] ?? Infinity) - (at[y.window_id] ?? Infinity) ||
1218 x.pane.localeCompare(y.pane, undefined, { numeric: true }),
1219 );
1220}
1221
1222/**
1223 * The pane the jump button would go to next, or null when the only agent on the
1224 * server is the one already on screen — or there is none at all. Only the
1225 * loudest state is cycled: with something waiting on you somewhere, an idle pane
1226 * is not the next stop.
1227 *
1228 * @param {TbAgent[]} agents
1229 * @returns {TbAgent | null}
1230 */
1231function nextJump(agents) {
1232 const order = jumpOrder(agents);
1233 if (!order.length) return null;
1234 const loudest = JUMP_RANK.indexOf(order[0].state);
1235 const tier = order.filter((a) => JUMP_RANK.indexOf(a.state) === loudest);
1236 // -1 — the last jump was to some other tier, or nowhere — starts at the top,
1237 // which is what `+ 1` makes of it.
1238 const at = tier.findIndex((a) => a.pane === lastJumped);
1239 return tier[(at + 1) % tier.length];
1240}
1241
1242/**
1243 * Say what the button would do before it is pressed: the state it would take
1244 * you to is its colour, and where that is is its tooltip. Disabled when there is
1245 * nowhere else to go — a button that goes nowhere should not look pressable, and
1246 * "the only Claude is this one" is a different nowhere from "there are none".
1247 */
1248function syncJump() {
1249 const btn = $button("jump");
1250 const next = nextJump(lastAgents);
1251 btn.disabled = !next;
1252 btn.dataset.state = next?.state ?? "";
1253 btn.title = next
1254 ? `Jump to ${next.session} · ${next.window} — claude ${next.state}\n${agentLabel(next)}`
1255 : lastAgents.some((a) => a.pane)
1256 ? "The only Claude pane is this one"
1257 : "No Claude panes";
1258}
1259
1260$("jump").addEventListener("click", () => {
1261 const next = nextJump(lastAgents);
1262 if (!next) return;
1263 lastJumped = next.pane;
1264 tmuxCommand({ cmd: "focus", pane: next.pane });
1265 term.focus();
1266 // The status frame that follows redraws the button, but the tooltip should
1267 // already point at the pane *after* this one by the time the click ends.
1268 syncJump();
1269});
1270
1271/**
1272 * A slot rather than a bare button, because the close ✕ has to be a sibling:
1273 * a button inside a button is invalid markup and unreachable to a screen
1274 * reader, and closing a window is not selecting it.
1275 *
1276 * @param {TbWindowInfo} w
1277 * @param {object} opts
1278 * @param {TbAgent} [opts.claude] the agent worth reporting in this window
1279 * @param {boolean} [opts.closable]
1280 * @param {boolean} [opts.pinned]
1281 * @param {string} [opts.owner] the session this window belongs to
1282 * @param {boolean} [opts.current] this is the window the panel is looking at
1283 * @param {boolean} [opts.lastInSession] the only window its session has left
1284 */
1285function windowTab(w, { claude, closable, pinned: isPinned, owner, current, lastInSession } = {}) {
1286 // A pinned tab drops its name and its ✕, the same two things Chrome takes
1287 // away: it is down to a dot and a number, and it cannot be closed by a
1288 // mis-aimed click. The name is still in the tooltip.
1289 const canClose = closable && !isPinned;
1290 // `w.active` is per session, so in groups mode every session contributes one
1291 // — the current window *of a session you are not on*. That is worth marking,
1292 // the way a session tab marks "attached elsewhere", but it is not the tab you
1293 // are on and must not be drawn as one.
1294 const elsewhere = w.active && !current;
1295 // The browser tab on screen sends the terminal here. Worth a mark, because
1296 // otherwise the panel moves on its own and nothing on it says why.
1297 const linked = tabPinMark({ session: owner ?? sessionName, window: w.id });
1298
1299 const tab = button(
1300 {
1301 // Activity is tmux's own "something happened here while you were away",
1302 // and it is the whole reason a background tab is worth looking at.
1303 class: `tab${!w.active && w.activity ? " activity" : ""}`,
1304 attrs: { role: "tab", "aria-selected": !!current },
1305 // The session is read back by the drag commit, which has to know which
1306 // group a dropped tab came out of, and by the click below.
1307 data: { window: w.id, ...(owner ? { session: owner } : {}) },
1308 // With no chip row left, the tooltip is where the detail lives: which tool
1309 // is in flight, what Claude is blocked on, and — for a pinned tab — the
1310 // name it gave up to fit.
1311 title: tip(
1312 owner && owner !== sessionName ? `${owner}: ${w.name}` : `window ${w.index}: ${w.name}`,
1313 w.panes > 1 && `${w.panes} panes`,
1314 claude && `claude ${claude.state} — ${agentLabel(claude)}`,
1315 claude?.title,
1316 !w.active && w.activity && "activity",
1317 elsewhere && "current window of that session",
1318 isPinned && "pinned — right-click to unpin",
1319 linked && PIN_SOURCE_NOTE[linked],
1320 ),
1321 on: {
1322 click: () => {
1323 selectWindow(w, owner);
1324 term.focus();
1325 },
1326 },
1327 },
1328 glyphSpan(claude?.state),
1329 // The index is not on the tab. tmux's own status line has it, `prefix 2`
1330 // needs it and nothing here does: a tab is clicked, not counted to, and in a
1331 // sidebar the number was taking room from the one thing that identifies the
1332 // window — its name. It is still in the tooltip.
1333 //
1334 // The exception is a pinned tab, which has given its name up to shrink: the
1335 // index is what is left to tell two pinned tabs apart.
1336 isPinned
1337 ? el("span", { class: "index", text: String(w.index) })
1338 : el("span", { class: "name", text: w.name }),
1339 );
1340
1341 const slot = el(
1342 "div",
1343 {
1344 class:
1345 `tab-slot${current ? " active" : ""}${canClose ? " closable" : ""}` +
1346 `${isPinned ? " pinned" : ""}${elsewhere ? " elsewhere" : ""}` +
1347 `${linked ? " tab-linked" : ""}`,
1348 on: {
1349 /** @param {MouseEvent} e */
1350 contextmenu: (e) => {
1351 e.preventDefault();
1352 openTabMenu(e, w, {
1353 pinned: !!isPinned,
1354 closable: !!closable,
1355 lastInSession: !!lastInSession,
1356 owner,
1357 current: !!current,
1358 });
1359 },
1360 },
1361 },
1362 tab,
1363 canClose && closeButton(w, lastInSession ? owner : undefined),
1364 );
1365 // The slot and not the tab: the ✕ is its sibling, and a drag that started on
1366 // the tab alone would leave the ✕ behind.
1367 makeDraggable(slot);
1368 return slot;
1369}
1370
1371/**
1372 * Two commands for one gesture, and which one it is depends on whether the tab
1373 * is in the session this panel's client is on.
1374 *
1375 * Inside it, `select-window`: selecting a window is a property of the session,
1376 * so it moves anyone else watching that session too — exactly as pressing
1377 * prefix-2 in the terminal does, and deliberately so.
1378 *
1379 * Outside it — only reachable in groups mode, where the row spans the server —
1380 * `goto-window`, which does that *and* brings our own client along. Without the
1381 * second half the click would select a window in a session we are not looking
1382 * at, and the terminal would not move at all.
1383 *
1384 * @param {TbWindowInfo} w
1385 * @param {string} [owner] the session the window belongs to
1386 */
1387function selectWindow(w, owner) {
1388 if (owner && owner !== sessionName) tmuxCommand({ cmd: "goto-window", window: w.id });
1389 else if (!w.active) tmuxCommand({ cmd: "select-window", window: w.id });
1390}
1391
1392// --- pinning a session to a browser tab -------------------------------------
1393//
1394// The rules live in lib/tabpin.js, which is pure. This is the half that has to
1395// touch the world: which browser tab is showing, what storage holds, and the
1396// one tmux command a match turns into.
1397//
1398// The trigger is deliberately narrow. Only a tab in *this* browser window can
1399// move this panel — a side panel belongs to one window, and a tab activated in
1400// another window is another panel's business — and the panel only ever
1401// activates a tab in that same window going the other way.
1402
1403/** @type {TbTabPinStore} */
1404let tabPins = Tabpin.emptyStore();
1405
1406/** This panel's browser window. Null until `windows.getCurrent()` answers. */
1407let panelWindowId = /** @type {number | null} */ (null);
1408
1409/** The tab currently showing in this window, as far as we have been told. */
1410let browserTab = /** @type {{ id?: number, url?: string }} */ ({});
1411
1412/**
1413 * What the showing tab resolves to, as of the last repaint. Held rather than
1414 * recomputed per row — see `renderHeader`.
1415 * @type {{ session: string, window: TbWindowInfo | null, source: TbPinSource } | null}
1416 */
1417let pinNow = null;
1418
1419/**
1420 * The excursion in progress: where a pin took the terminal from, and where it
1421 * put it. `Tabpin.step` owns the rules; this is only where the answer is kept
1422 * between two tab changes.
1423 * @type {TbPinReturn | null}
1424 */
1425let pinOwed = null;
1426
1427/** Whether the pin has been applied since this socket came up. */
1428let pinAppliedOnConnect = false;
1429
1430/**
1431 * How long a tab has to stay on screen before the terminal follows it.
1432 *
1433 * Ctrl-Tab through six tabs fires six activations, and without this the panel
1434 * would drag tmux through every one of them — six switch-clients for a gesture
1435 * that meant one. Short enough to be invisible when you land somewhere on
1436 * purpose: the tmux switch is local and the status frame that redraws the
1437 * header was up to a second away regardless.
1438 */
1439const PIN_SETTLE_MS = 140;
1440
1441/** @type {ReturnType<typeof setTimeout> | undefined} */
1442let pinTimer;
1443
1444/** @param {TbTabPinStore} next */
1445function saveTabPins(next) {
1446 tabPins = next;
1447 storage.set({ [Tabpin.KEY]: next });
1448 repaintTabs();
1449 applyTabPin();
1450}
1451
1452/**
1453 * Where the tab on screen says the terminal belongs, already checked against
1454 * the server — or null when nothing does.
1455 *
1456 * @returns {{ session: string, window: TbWindowInfo | null, source: TbPinSource } | null}
1457 */
1458function currentPinTarget() {
1459 const hit = Tabpin.resolve(tabPins, browserTab, lastSessions, Devport);
1460 if (!hit) return null;
1461 const at = Tabpin.locate(hit.target, lastSessions);
1462 return at ? { ...at, source: hit.source } : null;
1463}
1464
1465/**
1466 * Where the terminal is: the session this panel's client is on, and that
1467 * session's active window. Null before the first status frame, which is the
1468 * only honest answer — a return has to know where it is returning *from*, and
1469 * guessing at that would move the terminal somewhere nobody was.
1470 *
1471 * @returns {TbSpot | null}
1472 */
1473function currentSpot() {
1474 if (!sessionName) return null;
1475 const s = lastSessions.find((x) => x.name === sessionName);
1476 if (!s) return null;
1477 return { session: sessionName, window: s.windows.find((w) => w.active)?.id ?? null };
1478}
1479
1480/**
1481 * Put the terminal somewhere. The counterpart of a tab click, minus the focus:
1482 * the hands that caused this are on a web page, and the terminal moving
1483 * underneath is the feature — the caret leaving the page for it is not.
1484 *
1485 * @param {TbSpot} spot
1486 */
1487function gotoSpot(spot) {
1488 const s = lastSessions.find((x) => x.name === spot.session);
1489 if (!s) return;
1490 const w = spot.window ? s.windows.find((x) => x.id === spot.window) : null;
1491 // A window that has closed since leaves the session as the whole answer,
1492 // which is the same fallback `Tabpin.locate` makes for a pin.
1493 if (w) selectWindow(w, spot.session);
1494 else if (spot.session !== sessionName) tmuxCommand({ cmd: "switch", session: spot.session });
1495}
1496
1497/**
1498 * Act on the tab now showing: follow its pin, or hand the terminal back if an
1499 * earlier pin borrowed it.
1500 *
1501 * Silent about every kind of miss, and they are all ordinary: no pin, a pin to
1502 * a session that is not running, a pin to where we already are, a return that
1503 * has been overtaken by a switch made by hand. None of them is a failure, and a
1504 * panel that logged them would log on every tab switch.
1505 */
1506function applyTabPin() {
1507 if (!connected) return;
1508 const at = currentSpot();
1509 if (!at) return;
1510 const hit = currentPinTarget();
1511 // The resolved *window* matters here, not the pin as written: a pin to a
1512 // window that has closed is a pin to its session, and it must compare as one.
1513 const target = hit ? { session: hit.session, window: hit.window?.id ?? null } : null;
1514 const { go, owed } = Tabpin.step(pinOwed, target, at);
1515 pinOwed = owed;
1516 if (!go) return;
1517 forwardGoingTo = go;
1518 gotoSpot(go);
1519}
1520
1521/**
1522 * Follow the browser after a beat — see `PIN_SETTLE_MS`. Every path that can
1523 * change which pin applies goes through here, so a burst of them collapses into
1524 * one move rather than a queue of them.
1525 */
1526function scheduleTabPin() {
1527 clearTimeout(pinTimer);
1528 pinTimer = setTimeout(applyTabPin, PIN_SETTLE_MS);
1529}
1530
1531// --- and the other way: the browser follows the terminal ---------------------
1532//
1533// Same pins, read backwards by `Tabpin.reverse`. The trigger is the terminal
1534// arriving somewhere new, whatever moved it — a click on a session tab in this
1535// panel, a `prefix n` typed into the pane, another client switching a session
1536// this one is watching. All of those are the user going somewhere, and none of
1537// them is distinguishable from the others by the time the status frame lands.
1538//
1539// What this will not do is as much of the design as what it will. It activates
1540// a tab that is already open in this panel's window and stops there: it does
1541// not create tabs, does not focus the browser, does not raise a window, and
1542// does not touch a tab in another window. So the whole failure mode is showing
1543// you a tab you already had open, which is recoverable with Ctrl-Tab.
1544
1545/** Where the terminal was as of the last frame, so a move can be told from a
1546 * repaint. Null until the first frame names a session.
1547 * @type {TbSpot | null} */
1548let lastSpot = null;
1549
1550/**
1551 * A tab activation this panel asked for, which must not come back around as a
1552 * reason to move the terminal.
1553 *
1554 * `Tabpin.reverse` already refuses to move a browser that is showing a tab
1555 * pointing where the terminal is, so the loop cannot run away regardless. This
1556 * closes the narrower window underneath that: our own `tabs.update` fires
1557 * `onActivated`, and the status frame that would have told `applyTabPin` where
1558 * the terminal now is may not have arrived yet. Acting on the stale answer
1559 * would send tmux a switch to the place it is already going.
1560 *
1561 * @type {number | null}
1562 */
1563let reverseGoingTo = null;
1564
1565/** @type {ReturnType<typeof setTimeout> | undefined} */
1566let reverseTimer;
1567
1568/**
1569 * A terminal move this panel's *forward* direction asked for, which must not
1570 * come back around as a reason to move the browser.
1571 *
1572 * A pin arriving somewhere is already harmless — the tab that sent it there is
1573 * showing, so `Tabpin.reverse` finds its work done. The case this exists for is
1574 * the other half of the loan: handing the terminal *back* when you leave a
1575 * pinned tab lands it wherever it was borrowed from, and if some third tab is
1576 * pinned to that place, this would chase it and undo the tab switch the user
1577 * just made by hand. A return is an undo of a move this code made, and it has
1578 * no business rearranging the browser on the way.
1579 *
1580 * @type {TbSpot | null}
1581 */
1582let forwardGoingTo = null;
1583
1584/**
1585 * Show the tab that points at wherever the terminal has landed, if one is open
1586 * and is not already showing.
1587 */
1588function applyReverseTabPin() {
1589 if (!connected || panelWindowId == null) return;
1590 const at = currentSpot();
1591 if (!at) return;
1592 api.tabs
1593 .query({ windowId: panelWindowId })
1594 .then((tabs) => {
1595 // Most recently used first, which is the tie-break `Tabpin.reverse`
1596 // leans on: of two tabs on the pinned origin, the one you were reading.
1597 // Chrome before 121 has no `lastAccessed`, and then the sort is a no-op
1598 // and tab order decides — a worse answer, not a broken one.
1599 const order = [...tabs].sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0));
1600 const hit = Tabpin.reverse(tabPins, at, order, lastSessions, Devport);
1601 if (!hit) return;
1602 reverseGoingTo = hit.tabId;
1603 api.tabs.update(hit.tabId, { active: true }).catch(() => {
1604 reverseGoingTo = null;
1605 });
1606 })
1607 .catch(() => {});
1608}
1609
1610/**
1611 * Act on where the terminal is, after a beat.
1612 *
1613 * The beat is the same bargain as `PIN_SETTLE_MS` and for the same reason from
1614 * the other end: holding prefix-n through six windows should move the browser
1615 * once, at the end, rather than flicking it through five tabs on the way.
1616 */
1617function scheduleReverseTabPin() {
1618 clearTimeout(reverseTimer);
1619 reverseTimer = setTimeout(applyReverseTabPin, PIN_SETTLE_MS);
1620}
1621
1622/**
1623 * Notice the terminal moving. Called once per status frame, which is the only
1624 * thing that can tell us — every mover of tmux is a frame by the time it gets
1625 * here, including this panel's own commands.
1626 *
1627 * The first frame after a connect is a starting position rather than a move,
1628 * and is skipped: opening the panel is not a reason to rearrange the browser,
1629 * and the forward direction is meanwhile busy putting the terminal on the tab
1630 * you already have up.
1631 */
1632function noteSpotChange() {
1633 const at = currentSpot();
1634 if (!at) return;
1635 const was = lastSpot;
1636 lastSpot = at;
1637 if (!was || Tabpin.sameSpot(was, at)) return;
1638 if (forwardGoingTo && Tabpin.sameSpot(forwardGoingTo, at)) {
1639 // The browser moved the terminal; the terminal does not get to move it
1640 // back. See `forwardGoingTo`.
1641 forwardGoingTo = null;
1642 return;
1643 }
1644 forwardGoingTo = null;
1645 scheduleReverseTabPin();
1646}
1647
1648/**
1649 * Re-read the showing tab and act on it. Called for the events that can change
1650 * which pin applies, and once at startup for the tab that was already there.
1651 */
1652function syncTabPin() {
1653 if (panelWindowId == null) return;
1654 api.tabs
1655 .query({ active: true, windowId: panelWindowId })
1656 .then(([tab]) => {
1657 if (!tab) return;
1658 browserTab = { id: tab.id, url: tab.url };
1659 scheduleTabPin();
1660 // The menu labels name the origin, and the indicator marks the row this
1661 // tab points at. Both are stale the moment the tab changed.
1662 repaintTabs();
1663 })
1664 .catch(() => {});
1665}
1666
1667api.tabs.onActivated.addListener((info) => {
1668 if (info.windowId !== panelWindowId) return;
1669 if (info.tabId === reverseGoingTo) {
1670 // Our own doing — see `reverseGoingTo`. The tab still has to be recorded
1671 // and the header still has to redraw, since the indicator dot moves with
1672 // it; what is skipped is treating it as news about where the user went.
1673 reverseGoingTo = null;
1674 api.tabs
1675 .get(info.tabId)
1676 .then((tab) => {
1677 browserTab = { id: tab.id, url: tab.url };
1678 repaintTabs();
1679 })
1680 .catch(() => {});
1681 return;
1682 }
1683 syncTabPin();
1684});
1685
1686// A tab that navigates is a different origin and so possibly a different pin.
1687// Only the showing one matters: a background tab finishing a redirect is not a
1688// reason to move the terminal.
1689api.tabs.onUpdated.addListener((tabId, change, tab) => {
1690 if (!change.url || tab.windowId !== panelWindowId || tabId !== browserTab.id) return;
1691 browserTab = { id: tabId, url: change.url };
1692 scheduleTabPin();
1693 repaintTabs();
1694});
1695
1696// Tab ids are never reissued, so a pin whose tab is gone can only ever be dead
1697// weight in storage.
1698api.tabs.onRemoved.addListener((tabId) => {
1699 if (!(String(tabId) in tabPins.byTab)) return;
1700 const next = Tabpin.put(tabPins, "tab", tabId, undefined);
1701 tabPins = next;
1702 storage.set({ [Tabpin.KEY]: next });
1703});
1704
1705/** `http://localhost:26210` reads better as `localhost:26210` in a menu.
1706 * @param {string} origin */
1707function shortOrigin(origin) {
1708 return origin.replace(/^https?:\/\//, "");
1709}
1710
1711/**
1712 * Whether the showing browser tab points at this exact row, so the row can say
1713 * so. Windows match on their own id; a session-level pin marks the session and
1714 * every row of it stays unmarked.
1715 *
1716 * @param {{ session?: string | null, window?: string | null }} row
1717 * @returns {TbPinSource | null}
1718 */
1719function tabPinMark(row) {
1720 const at = pinNow;
1721 if (!at) return null;
1722 if (row.window) return at.window?.id === row.window ? at.source : null;
1723 return !at.window && at.session === row.session ? at.source : null;
1724}
1725
1726/** How a pin explains itself in a tooltip. */
1727const PIN_SOURCE_NOTE = {
1728 tab: "pinned to this browser tab",
1729 origin: "pinned to this site",
1730 detect: "matched to this site by its devport port",
1731};
1732
1733/**
1734 * The pin lines of a context menu, for a window (a window id) or a whole
1735 * session (none). Both kinds of key are offered because they answer different
1736 * questions — see the header of lib/tabpin.js — and neither is offered for a
1737 * tab whose URL cannot carry a pin, which is every browser page and every
1738 * `file:`.
1739 *
1740 * @param {HTMLElement} menu
1741 * @param {TbPinTarget} target
1742 */
1743function pinMenuItems(menu, target) {
1744 const origin = Tabpin.originOf(browserTab.url);
1745 const tabKey = browserTab.id != null ? String(browserTab.id) : "";
1746 const here = tabPinMark({ session: target.session, window: target.window });
1747
1748 if (here) {
1749 // Unpinning a detected match cannot just delete an entry — there is no
1750 // entry, and the guess would be back before the menu had closed. It writes
1751 // the veto instead, at the level the guess was made: the origin.
1752 const label = here === "tab" ? "Unpin from this browser tab" : `Unpin from ${shortOrigin(origin)}`;
1753 menuItem(menu, label, () => {
1754 if (here === "tab") saveTabPins(Tabpin.put(tabPins, "tab", tabKey, undefined));
1755 else if (here === "origin") saveTabPins(Tabpin.put(tabPins, "origin", origin, undefined));
1756 else saveTabPins(Tabpin.put(tabPins, "origin", origin, null));
1757 });
1758 return;
1759 }
1760
1761 const what = target.window ? "this window" : target.session;
1762 if (origin) {
1763 menuItem(menu, `Pin ${what} to ${shortOrigin(origin)}`, () =>
1764 saveTabPins(Tabpin.put(tabPins, "origin", origin, target)),
1765 );
1766 }
1767 if (tabKey) {
1768 menuItem(menu, `Pin ${what} to this browser tab`, () =>
1769 saveTabPins(Tabpin.put(tabPins, "tab", tabKey, target)),
1770 );
1771 }
1772}
1773
1774// --- tab context menu -------------------------------------------------------
1775//
1776// Built rather than native: an extension page gets the browser's own menu here,
1777// which has nothing to say about tmux windows. Deliberately not a <dialog> or
1778// anything modal — a modal in this panel would block the socket's message
1779// handler while it is up.
1780
1781let openMenu = /** @type {HTMLElement | null} */ (null);
1782
1783/**
1784 * Set while a group menu's name field is open, so closing the menu commits what
1785 * was typed in it. See `renameRow`.
1786 * @type {(() => void) | null}
1787 */
1788let pendingRename = null;
1789
1790function closeTabMenu() {
1791 const commit = pendingRename;
1792 pendingRename = null;
1793 commit?.();
1794 openMenu?.remove();
1795 openMenu = null;
1796 // The session info panel shares this machinery, and the dot it hangs off has
1797 // to stop claiming it is open.
1798 if (infoOpen) {
1799 infoOpen = false;
1800 $("omni-here").setAttribute("aria-expanded", "false");
1801 }
1802}
1803
1804/**
1805 * One line of a menu. Every one of them does the same two things in the same
1806 * order — dismiss the menu, then act — because a menu still standing over the
1807 * thing it just changed is the wrong half of the gesture.
1808 *
1809 * @param {HTMLElement} menu
1810 * @param {string} label
1811 * @param {() => void} run
1812 */
1813function menuItem(menu, label, run) {
1814 const b = button({
1815 text: label,
1816 attrs: { role: "menuitem" },
1817 on: {
1818 click: () => {
1819 closeTabMenu();
1820 run();
1821 },
1822 },
1823 });
1824 menu.appendChild(b);
1825 return b;
1826}
1827
1828/**
1829 * @param {MouseEvent} e
1830 * @param {TbWindowInfo} w
1831 * @param {object} opts
1832 * @param {boolean} opts.pinned
1833 * @param {boolean} opts.closable
1834 * @param {string} [opts.owner]
1835 * @param {boolean} [opts.current]
1836 * @param {boolean} [opts.lastInSession]
1837 */
1838function openTabMenu(e, w, { pinned: isPinned, closable, owner, current, lastInSession }) {
1839 closeTabMenu();
1840 const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
1841 /** @param {string} label @param {() => void} run */
1842 const item = (label, run) => menuItem(menu, label, run);
1843
1844 item(isPinned ? "Unpin" : "Pin", () => {
1845 const ids = pinnedIds(owner);
1846 if (isPinned) ids.delete(w.id);
1847 else ids.add(w.id);
1848 savePins(owner, ids);
1849 repaintTabs();
1850 });
1851
1852 if (!current) {
1853 item(owner && owner !== sessionName ? `Go to ${owner}` : "Select", () => {
1854 selectWindow(w, owner);
1855 term.focus();
1856 });
1857 }
1858
1859 // Both levels, from the one menu: the window you right-clicked, and the
1860 // session it belongs to. They are genuinely different pins — "this browser
1861 // tab brings up my dev server window" and "this browser tab brings up that
1862 // project, wherever I left it" — and the second is the one most people want.
1863 const pinTo = owner ?? sessionName;
1864 if (pinTo) {
1865 pinMenuItems(menu, { session: pinTo, window: w.id });
1866 pinMenuItems(menu, { session: pinTo, window: null });
1867 }
1868
1869 // A tab's own menu is the nearest thing to "open another one beside this",
1870 // and beside this means in the session this tab belongs to — the owner in
1871 // groups mode, where the row spans the server, and the panel's session
1872 // otherwise.
1873 const beside = owner ?? sessionName;
1874 if (beside && lastSessions.some((s) => s.name === beside)) {
1875 item("New Claude", () => {
1876 newClaudeIn(beside);
1877 term.focus();
1878 });
1879 }
1880
1881 if (closable) {
1882 const kill = item(lastInSession && owner ? `Close window and ${owner}` : "Close window", () => {
1883 tmuxCommand({ cmd: "kill-window", window: w.id });
1884 log(`killed window ${w.index} (${w.name})`);
1885 term.focus();
1886 });
1887 kill.className = "danger";
1888 }
1889
1890 document.body.appendChild(menu);
1891 placeMenu(menu, e);
1892}
1893
1894/**
1895 * Put a menu that is already in the document under the pointer, and make it the
1896 * one open menu.
1897 *
1898 * Placed after measuring, so a menu opened near an edge folds back inside
1899 * rather than off the panel.
1900 *
1901 * @param {HTMLElement} menu
1902 * @param {{ clientX: number, clientY: number }} e
1903 */
1904function placeMenu(menu, e) {
1905 const r = menu.getBoundingClientRect();
1906 menu.style.left = `${Math.min(e.clientX, Math.max(0, window.innerWidth - r.width - 4))}px`;
1907 menu.style.top = `${Math.min(e.clientY, Math.max(0, window.innerHeight - r.height - 4))}px`;
1908 openMenu = menu;
1909
1910 // Any click that isn't on the menu, anywhere, dismisses it.
1911 setTimeout(() => {
1912 window.addEventListener("pointerdown", onDismiss, { once: true, capture: true });
1913 }, 0);
1914}
1915
1916/** @param {Event} e */
1917function onDismiss(e) {
1918 if (openMenu && e.target instanceof Node && openMenu.contains(e.target)) return;
1919 // A press on the dot is the first half of a click that means "put it away".
1920 // This guard gets there first and would leave the panel closed, so the click
1921 // behind it would read a closed panel and open it straight back up; the flag
1922 // is how that click learns the press it belongs to did the closing.
1923 infoToggledOff =
1924 infoOpen && e.target instanceof Node && !!$("omni-here").contains(e.target);
1925 closeTabMenu();
1926}
1927
1928/**
1929 * @param {TbWindowInfo} w
1930 * @param {string} [lastOf] the session this is the only window of, if it is —
1931 * closing it takes that session with it, which the label has to say
1932 */
1933function closeButton(w, lastOf) {
1934 const label = lastOf
1935 ? `Close window ${w.index}: ${w.name} — last one, so this closes ${lastOf} too`
1936 : `Close window ${w.index}: ${w.name}`;
1937 return button(
1938 {
1939 class: "tab-close",
1940 title: label,
1941 attrs: { "aria-label": label },
1942 on: {
1943 /** @param {MouseEvent} e */
1944 click: (e) => {
1945 // The tab underneath would otherwise read this as "select me".
1946 e.stopPropagation();
1947 tmuxCommand({ cmd: "kill-window", window: w.id });
1948 // The one destructive thing in the panel, and tmux has no undo for it,
1949 // so it at least leaves a record of what went.
1950 log(`killed window ${w.index} (${w.name})`);
1951 term.focus();
1952 },
1953 },
1954 },
1955 strokeIcon("M4 4l8 8M12 4l-8 8"),
1956 );
1957}
1958
1959const SVG_NS = "http://www.w3.org/2000/svg";
1960
1961/**
1962 * The header's icons are inline SVG rather than unicode glyphs, which render at
1963 * wildly different weights depending on the platform's fallback font. The ones
1964 * built here are the same, just built rather than written out.
1965 *
1966 * @param {string} d
1967 */
1968function strokeIcon(d) {
1969 const svg = document.createElementNS(SVG_NS, "svg");
1970 svg.setAttribute("viewBox", "0 0 16 16");
1971 svg.setAttribute("aria-hidden", "true");
1972 const path = document.createElementNS(SVG_NS, "path");
1973 path.setAttribute("d", d);
1974 path.setAttribute("stroke", "currentColor");
1975 path.setAttribute("stroke-width", "2");
1976 path.setAttribute("stroke-linecap", "round");
1977 svg.appendChild(path);
1978 return svg;
1979}
1980
1981/**
1982 * Open a window in a session and go to it.
1983 *
1984 * A new window needs no name: tmux names it after whatever it runs, and renames
1985 * it as you cd around. So this is one click and no prompt.
1986 *
1987 * A session that does not exist yet cannot be given a window — but making it
1988 * *is* making the window, since `create` is `new-session -A`, which comes up
1989 * with one. That is the case the home session hits on a fresh tmux server,
1990 * where "+" is the first thing that ever names it.
1991 *
1992 * @param {string | null} session
1993 */
1994function newWindowIn(session) {
1995 if (!session) return;
1996 if (lastSessions.some((s) => s.name === session)) {
1997 tmuxCommand({ cmd: "new-window", session });
1998 } else {
1999 tmuxCommand({ cmd: "create", session });
2000 }
2001}
2002
2003/**
2004 * Open a window running Claude in a session and go to it.
2005 *
2006 * The same window `!claude` and the omnibar's "New Claude" row open — `run`
2007 * rather than the daemon's Claude request, which exists to carry a prompt and
2008 * there is no prompt here.
2009 *
2010 * Unlike `newWindowIn` this cannot make the session it lands in: the daemon's
2011 * `run` is a `new-window` and nothing else, so a session that does not exist
2012 * yet would take the command nowhere. Every caller offers the row only for a
2013 * session the last frame named.
2014 *
2015 * @param {string | null} session
2016 */
2017function newClaudeIn(session) {
2018 if (!session) return;
2019 tmuxCommand({ cmd: "run", session, command: "claude" });
2020}
2021
2022/* What a plain click on "+" makes.
2023
2024 The button opens a window either way; the question is what is running in it.
2025 "claude" is the default because that is what the window is nearly always for
2026 — a shell is one `exit` away inside it, and a Claude in a shell is a command
2027 you had to type. "window" is the old behaviour, for anyone whose "+" means a
2028 shell.
2029
2030 Only the plain click follows this. The hold menu still offers both, so
2031 whichever is not the default is one gesture away rather than a settings trip,
2032 and it lists the default first. */
2033/** @type {Record<string, { verb: string, hint: string }>} */
2034const NEW_TAB_ACTIONS = {
2035 claude: {
2036 verb: "New Claude in",
2037 hint: '"+" opens a window running claude. Hold it for a plain shell.',
2038 },
2039 window: {
2040 verb: "New window in",
2041 hint: '"+" opens a window with a shell. Hold it for claude.',
2042 },
2043};
2044let newTabAction = "claude";
2045
2046/** @returns {"claude" | "window"} what "+" does, as a key of NEW_TAB_ACTIONS. */
2047function plainNewTab() {
2048 return newTabAction === "window" ? "window" : "claude";
2049}
2050
2051/**
2052 * The pref only reaches two places — the tooltips, which `renderHeader` writes
2053 * on every frame, and the settings card. So this repaints the header rather
2054 * than touching the button itself.
2055 */
2056function applyNewTabAction() {
2057 const a = NEW_TAB_ACTIONS[plainNewTab()];
2058 $select("newtab-select").value = plainNewTab();
2059 $("newtab-hint").textContent = a.hint;
2060 renderHeader(lastSessions, sessionName, lastAgents);
2061}
2062
2063/** @param {string} value */
2064function setNewTabAction(value) {
2065 newTabAction = NEW_TAB_ACTIONS[value] ? value : "claude";
2066 storage.set({ newTabAction });
2067 applyNewTabAction();
2068}
2069
2070/**
2071 * What "+" promises, in the session it will actually land in. Both layouts word
2072 * it the same way and only differ in which session that is.
2073 *
2074 * @param {string | null | undefined} target
2075 */
2076function newTabTitle(target) {
2077 const { verb } = NEW_TAB_ACTIONS[plainNewTab()];
2078 return (target ? `${verb} ${target}` : verb.replace(/ in$/, "")) + SPLIT_HINT + HOLD_HINT;
2079}
2080
2081/**
2082 * Do what a plain "+" click means, in whichever session it points at.
2083 *
2084 * @param {string | null} session
2085 */
2086function newTabIn(session) {
2087 // A session the last frame has not named cannot be given a Claude window (see
2088 // `newClaudeIn`), and `newWindowIn` is the one path that can make it. So the
2089 // first "+" on a fresh server opens a shell whatever the pref says — there is
2090 // no session yet for anything else to run in.
2091 if (plainNewTab() === "claude" && session && lastSessions.some((s) => s.name === session)) {
2092 newClaudeIn(session);
2093 } else {
2094 newWindowIn(session);
2095 }
2096}
2097
2098/**
2099 * Which session the row's "+" adds to.
2100 *
2101 * Nested, the row *is* one session's windows, so a window added anywhere else
2102 * would not appear in it — "+" adds here, as it always has.
2103 *
2104 * Groups, the row spans the server, so it can show a window wherever it lands.
2105 * It goes to the home session, which is the split Chrome makes: its "+" opens
2106 * an ungrouped tab rather than another tab in whichever group you happen to be
2107 * reading, and a group's own menu is how you add to that group.
2108 *
2109 * Unless the server is holding exactly one session — then that one, whatever it
2110 * is called. With a single group there is no ambiguity for "+" to resolve, and
2111 * answering it with `default` would spend a second group on one window and
2112 * split the work across two. It is the same judgement the daemon makes when it
2113 * adopts a sole existing session rather than creating its own beside it (see
2114 * `adopt_sole_session` in daemon/src/server.rs).
2115 *
2116 * @returns {string | null}
2117 */
2118function newWindowTarget() {
2119 if (tabMode !== "groups") return sessionName;
2120 if (lastSessions.length === 1) return lastSessions[0].name;
2121 return defaultSession;
2122}
2123
2124// --- "+" as more than a window ----------------------------------------------
2125//
2126// A click on a browser's "+" makes a tab; holding it, or right-clicking it,
2127// offers the other thing the strip can hold. Here that other thing is a tmux
2128// session — a tab group — and this is where it gets made, so the row never
2129// needs a second icon beside the first that looks the same and does something
2130// else. The plain click stays one gesture and no prompt: it opens a window
2131// immediately, running whatever `NEW_TAB_ACTIONS` says — and the menu holds the
2132// other kind, so that pref is a hold away rather than a settings trip.
2133//
2134// The menu is the same one every tab and chip in the row uses, so it dismisses
2135// the same way and only one of them is ever up.
2136
2137/** How long "+" has to be held before the menu is what the press meant. */
2138const NEW_HOLD_MS = 450;
2139
2140let newHold = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined);
2141/** Set when a hold has opened the menu, so the click that ends the press
2142 doesn't also open a window behind it. */
2143let newHeld = false;
2144
2145/** @param {{ clientX: number, clientY: number }} e */
2146function openNewMenu(e) {
2147 closeTabMenu();
2148 const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
2149 const target = newWindowTarget() ?? defaultSession;
2150 const window_ = () =>
2151 menuItem(menu, `New window in ${target}`, () => {
2152 newWindowIn(target);
2153 term.focus();
2154 });
2155 // Only once the session exists: see `newClaudeIn`. On a fresh server "+"
2156 // itself is what names the home session, and until it has, there is nowhere
2157 // for a `run` to open a window.
2158 const claude = () => {
2159 if (!lastSessions.some((s) => s.name === target)) return;
2160 menuItem(menu, `New Claude in ${target}`, () => {
2161 newClaudeIn(target);
2162 term.focus();
2163 });
2164 };
2165 // The default first, so the menu reads in the order the button does: what a
2166 // plain click would have done, then the other one.
2167 if (plainNewTab() === "claude") {
2168 claude();
2169 window_();
2170 } else {
2171 window_();
2172 claude();
2173 }
2174 // Named rather than immediate, unlike the window: a session's name is the
2175 // only handle a terminal gives you on it. See "new session" below.
2176 menuItem(menu, "New session…", showSessionInput);
2177 document.body.appendChild(menu);
2178 placeMenu(menu, e);
2179}
2180
2181function cancelNewHold() {
2182 clearTimeout(newHold);
2183 newHold = undefined;
2184}
2185
2186{
2187 const btn = $("tab-new");
2188 btn.addEventListener("click", () => {
2189 // The hold already answered this press.
2190 if (newHeld) {
2191 newHeld = false;
2192 return;
2193 }
2194 newTabIn(newWindowTarget());
2195 term.focus();
2196 });
2197
2198 btn.addEventListener("pointerdown", (e) => {
2199 const ev = /** @type {PointerEvent} */ (e);
2200 // Right button is the contextmenu event's, not the hold's.
2201 if (ev.button !== 0) return;
2202 newHeld = false;
2203 cancelNewHold();
2204 newHold = setTimeout(() => {
2205 newHold = undefined;
2206 newHeld = true;
2207 openNewMenu(ev);
2208 }, NEW_HOLD_MS);
2209 });
2210 // A press that ends, moves off the button, or is taken away by the browser
2211 // (a touch turning into a scroll) is not a hold.
2212 for (const type of ["pointerup", "pointerleave", "pointercancel"]) {
2213 btn.addEventListener(type, cancelNewHold);
2214 }
2215
2216 btn.addEventListener("contextmenu", (e) => {
2217 e.preventDefault();
2218 // On touch the browser fires this at the end of its own long press, by
2219 // which point our hold has already put the menu up.
2220 if (newHeld) return;
2221 cancelNewHold();
2222 openNewMenu(/** @type {MouseEvent} */ (e));
2223 });
2224}
2225
2226// --- push to talk -----------------------------------------------------------
2227//
2228// Claude Code's voice mode is push-to-talk: you hold space in the pane and it
2229// records until you let go. There is no key to hold from here — the terminal is
2230// what has focus, and a sidebar button is a click, not a hold — so this button
2231// holds it on your behalf for as long as the pointer is down on it.
2232//
2233// What "holding a key" *is*, at the far end of a pty, is autorepeat: the press
2234// puts one byte on the wire and the keyboard driver keeps putting the same byte
2235// there, after a delay, at a steady rate, until the key comes up. A pty carries
2236// no key-up event and no notion of a key being down, so reproducing the stream
2237// is the whole of reproducing the hold. The two constants below are the X11
2238// defaults, which is what a Linux terminal on the other end would have sent.
2239//
2240// If the agent on the other end turns out to want explicit press/release events
2241// instead — the kitty keyboard protocol reports both, where plain mode cannot —
2242// then TALK_PRESS/TALK_RELEASE are the only two things that change.
2243const TALK_DELAY_MS = 500;
2244const TALK_INTERVAL_MS = 33;
2245
2246// Letting go of the button ends the recording, and what you almost always want
2247// next is to send it — but not always: often the thought is not finished and the
2248// button goes down again. So the submit is deferred rather than immediate, and a
2249// second press inside the window cancels it. The transcript stays one prompt
2250// across as many holds as it takes, and the pause that ends it is the same
2251// gesture as pausing before hitting Return.
2252const TALK_SUBMIT_MS = 1000;
2253
2254/** Bytes for the press, each repeat, the release, and the deferred submit. */
2255const TALK_PRESS = " ";
2256const TALK_RELEASE = "";
2257// Carriage return and not a newline: that is the byte a terminal's Return key
2258// puts on the wire, and what line-editing readers — a shell, Claude Code's
2259// prompt — are waiting for. `\n` would be Ctrl+J, which some of them treat as a
2260// literal newline in the buffer instead of a submit.
2261const TALK_SUBMIT = "\r";
2262
2263/** @type {number | undefined} */
2264let talkDelay;
2265/** @type {number | undefined} */
2266let talkRepeat;
2267/** @type {number | undefined} */
2268let talkSubmit;
2269let talking = false;
2270
2271/**
2272 * Drop a submit that has not fired yet. Called when the button goes down again —
2273 * there is more to say — and whenever the panel loses the connection the submit
2274 * was going to travel over, so a reconnect does not inherit a stray Return.
2275 */
2276function cancelTalkSubmit() {
2277 clearTimeout(talkSubmit);
2278 talkSubmit = undefined;
2279 $("talk").classList.remove("pending");
2280}
2281
2282/** @param {string} bytes */
2283function talkSend(bytes) {
2284 if (!bytes || !connected || !ws) return false;
2285 ws.send(enc.encode(bytes));
2286 return true;
2287}
2288
2289function startTalk() {
2290 if (talking) return;
2291 // Before the connection check: a press that cannot record still means "I am
2292 // not done", and the queued Return would land after it.
2293 cancelTalkSubmit();
2294 if (!connected || !ws) {
2295 log("not connected");
2296 return;
2297 }
2298 talking = true;
2299 const btn = $("talk");
2300 btn.classList.add("talking");
2301 btn.setAttribute("aria-pressed", "true");
2302
2303 talkSend(TALK_PRESS);
2304 // The gap before autorepeat kicks in, then the repeat itself. A key held
2305 // briefly sends exactly one byte, which is what makes a quick tap on this
2306 // button a plain space rather than a burst of them.
2307 talkDelay = setTimeout(() => {
2308 talkRepeat = setInterval(() => {
2309 if (!talkSend(TALK_PRESS)) stopTalk();
2310 }, TALK_INTERVAL_MS);
2311 }, TALK_DELAY_MS);
2312}
2313
2314function stopTalk() {
2315 if (!talking) return;
2316 talking = false;
2317 clearTimeout(talkDelay);
2318 clearInterval(talkRepeat);
2319 talkDelay = talkRepeat = undefined;
2320 talkSend(TALK_RELEASE);
2321 const btn = $("talk");
2322 btn.classList.remove("talking");
2323 btn.setAttribute("aria-pressed", "false");
2324 btn.style.setProperty("--talk-submit", `${TALK_SUBMIT_MS}ms`);
2325 // Off and on again, so a second hold inside the window restarts the fade
2326 // rather than continuing the old one from wherever it had got to.
2327 btn.classList.remove("pending");
2328 void btn.offsetWidth;
2329 btn.classList.add("pending");
2330 talkSubmit = setTimeout(() => {
2331 talkSubmit = undefined;
2332 btn.classList.remove("pending");
2333 talkSend(TALK_SUBMIT);
2334 }, TALK_SUBMIT_MS);
2335}
2336
2337{
2338 const btn = $("talk");
2339 btn.addEventListener("pointerdown", (e) => {
2340 e.preventDefault();
2341 // Capture, so a finger or cursor that slides off the button still ends the
2342 // hold on *this* element. Without it the pointerup lands somewhere else and
2343 // the key is held down forever, which in a terminal is not a small bug.
2344 btn.setPointerCapture(/** @type {PointerEvent} */ (e).pointerId);
2345 startTalk();
2346 });
2347 for (const type of ["pointerup", "pointercancel", "lostpointercapture"]) {
2348 btn.addEventListener(type, stopTalk);
2349 }
2350 // A click is a hold that already ended; the pointer handlers own both edges.
2351 btn.addEventListener("click", (e) => e.preventDefault());
2352 btn.addEventListener("contextmenu", (e) => e.preventDefault());
2353
2354 // Keyboard: the same hold, from a focused button. `repeat` is the browser's
2355 // own autorepeat on *our* key, which would restart nothing but is not a
2356 // second press either.
2357 btn.addEventListener("keydown", (e) => {
2358 const ev = /** @type {KeyboardEvent} */ (e);
2359 if (ev.repeat || (ev.key !== " " && ev.key !== "Enter")) return;
2360 e.preventDefault();
2361 startTalk();
2362 });
2363 btn.addEventListener("keyup", (e) => {
2364 const ev = /** @type {KeyboardEvent} */ (e);
2365 if (ev.key !== " " && ev.key !== "Enter") return;
2366 e.preventDefault();
2367 stopTalk();
2368 });
2369 btn.addEventListener("blur", stopTalk);
2370}
2371
2372// Nothing may outlive the gesture: a panel that loses the window mid-hold has
2373// no way to see the release, and a stuck key would keep typing into the pane.
2374window.addEventListener("blur", stopTalk);
2375document.addEventListener("visibilitychange", () => {
2376 if (document.hidden) stopTalk();
2377});
2378
2379// --- dragging tabs ----------------------------------------------------------
2380//
2381// Both rows reorder by drag, and the two commit to different places: a window
2382// drag is a real `move-window` on the server, because tmux has an order for
2383// windows and the terminal's own status line has to agree with ours. Sessions
2384// have no such thing — tmux lists them alphabetically and offers nothing to
2385// renumber — so that order is this panel's, saved in extension storage.
2386//
2387// The drop itself is the same either way: the dragged element moves through the
2388// row live, and the commit reads the row's final DOM order.
2389
2390/** The element being dragged, while a drag is in flight. */
2391let dragging = /** @type {HTMLElement | null} */ (null);
2392
2393/**
2394 * @param {HTMLElement} el the element the pointer picks up
2395 */
2396function makeDraggable(el) {
2397 el.draggable = true;
2398 el.addEventListener("dragstart", (e) => {
2399 dragging = el;
2400 el.classList.add("dragging");
2401 const dt = /** @type {DragEvent} */ (e).dataTransfer;
2402 if (!dt) return;
2403 dt.effectAllowed = "move";
2404 // Firefox starts no drag at all without data on the transfer. Nothing
2405 // reads it: the element being moved is `dragging`, and a drop from outside
2406 // this row is ignored below.
2407 dt.setData("text/plain", "");
2408 });
2409 el.addEventListener("dragend", () => {
2410 el.classList.remove("dragging");
2411 dragging = null;
2412 });
2413}
2414
2415/**
2416 * Wire a row as a drop target. `commit` runs once, on drop, with the row's DOM
2417 * already in the order the pointer left it in.
2418 *
2419 * @param {HTMLElement} strip
2420 * @param {string} sel selector for that row's draggable items
2421 * @param {(moved: HTMLElement) => void} commit
2422 */
2423function dropZone(strip, sel, commit) {
2424 strip.addEventListener("dragover", (e) => {
2425 // A drag that started somewhere else — the other row, or another page
2426 // entirely — is not a reorder of this one.
2427 if (!dragging || dragging.parentElement !== strip) return;
2428 e.preventDefault();
2429 const x = /** @type {DragEvent} */ (e).clientX;
2430 // The first item whose midpoint is past the pointer is the one the dragged
2431 // tab belongs in front of; none means the pointer is past them all.
2432 const before =
2433 [...strip.querySelectorAll(sel)]
2434 .filter((el) => el !== dragging)
2435 .find((el) => x < el.getBoundingClientRect().left + el.getBoundingClientRect().width / 2) ??
2436 null;
2437 if (before !== dragging.nextElementSibling) strip.insertBefore(dragging, before);
2438 });
2439 strip.addEventListener("drop", (e) => {
2440 if (!dragging || dragging.parentElement !== strip) return;
2441 e.preventDefault();
2442 commit(dragging);
2443 });
2444}
2445
2446dropZone($("sessions"), ".session-tab", () => {
2447 saveSessionOrder(
2448 [...$("sessions").querySelectorAll(".session-tab")].map(
2449 (el) => /** @type {HTMLElement} */ (el).dataset.session ?? "",
2450 ),
2451 );
2452 term.focus();
2453});
2454
2455dropZone($("tabs"), ".tab-slot", (slot) => {
2456 // Expressed against a neighbour rather than an index: `move-window -a/-b`
2457 // renumbers whatever has to move, so there is no free index to find and no
2458 // window to overwrite. The next status frame brings the new indexes back.
2459 //
2460 // In groups mode the neighbours are found the same way, but the walk stops at
2461 // a chip: the tab either side of a group boundary is in a different session,
2462 // and landing "after" it would silently move the window out of the group the
2463 // pointer left it in. Where the walk finds nothing — a group with no other
2464 // window in it — the session itself is the target instead.
2465 const moved = windowIdOf(slot);
2466 if (!moved) return;
2467 const grouped = tabMode === "groups";
2468 const after = windowIdOf(neighbour(slot, "previousElementSibling"));
2469 const before = windowIdOf(neighbour(slot, "nextElementSibling"));
2470 if (after) tmuxCommand({ cmd: "move-window", window: moved, target: after, after: true });
2471 else if (before) tmuxCommand({ cmd: "move-window", window: moved, target: before });
2472 else if (grouped) {
2473 const group = groupOf(slot);
2474 if (group && group !== sessionOf(slot)) {
2475 tmuxCommand({ cmd: "move-window-to-session", window: moved, session: group });
2476 }
2477 }
2478 term.focus();
2479});
2480
2481/**
2482 * The tab next to this one, in the given direction, stopping at a group
2483 * boundary. Chips only exist in groups mode, so in the nested layout this is
2484 * just the adjacent sibling.
2485 *
2486 * @param {Element} slot
2487 * @param {"previousElementSibling" | "nextElementSibling"} dir
2488 * @returns {Element | null}
2489 */
2490function neighbour(slot, dir) {
2491 for (let el = slot[dir]; el; el = el[dir]) {
2492 if (el.classList.contains("group-chip")) return null;
2493 if (el.classList.contains("tab-slot")) return el;
2494 }
2495 return null;
2496}
2497
2498/**
2499 * Which group a dropped tab landed in: the nearest chip above it in the row.
2500 * @param {Element} slot
2501 * @returns {string} session name, or "" if the row has no chips
2502 */
2503function groupOf(slot) {
2504 for (let el = slot.previousElementSibling; el; el = el.previousElementSibling) {
2505 if (el.classList.contains("group-chip")) {
2506 return /** @type {HTMLElement} */ (el).dataset.session ?? "";
2507 }
2508 }
2509 return "";
2510}
2511
2512// --- dragging a tab onto "+" ------------------------------------------------
2513//
2514// Chrome's other tab gesture: drag a tab out of the strip and it becomes a
2515// window of its own. The tmux answer is a session of its own, and "+" is where
2516// it lands — the button that already means "another one of these", now also
2517// meaning "another one of these, holding this".
2518//
2519// Both "+"s take it, because which one is on screen is a layout question: the
2520// nested layout's session row has its own, and groups mode has a single "+" in
2521// the tab row and no session row at all.
2522//
2523// The session is not prompted for. A drag is one gesture and a name field in
2524// the middle of it would be a second one — so the window's own name becomes the
2525// session's, deduped against what is already on the server, and the chip's menu
2526// renames it after the fact like any other session.
2527
2528/** Second line of both "+" tooltips: the gesture has no affordance until a tab
2529 is already in the air, so the button is where it gets announced. */
2530const SPLIT_HINT = "\nDrop a window here to give it a session of its own";
2531
2532/** Third line of the window "+"'s tooltip: the menu holds the kind of tab the
2533 plain click is not (see `NEW_TAB_ACTIONS`) and, in groups mode, is the only
2534 place a session gets made — and a held button announces itself nowhere
2535 else. */
2536const HOLD_HINT = "\nHold or right-click for the other kind, or a new session";
2537
2538/**
2539 * A tmux session name made out of a window name. tmux windows are named after
2540 * whatever is running in them, so this is `nvim` or `fish` far more often than
2541 * it is anything with a slash in it.
2542 *
2543 * The daemon validates the result and drops the request if it doesn't like it,
2544 * so this has to land inside the same rules ([A-Za-z0-9_-], no leading dash) or
2545 * the drag does nothing at all.
2546 *
2547 * @param {string} windowName
2548 * @param {string[]} taken names already on the server
2549 * @returns {string}
2550 */
2551function sessionNameFor(windowName, taken) {
2552 const base =
2553 windowName
2554 .replace(/[^A-Za-z0-9_-]+/g, "-")
2555 .replace(/^-+|-+$/g, "")
2556 .slice(0, 60) || "session";
2557 if (!taken.includes(base)) return base;
2558 // The window name is the whole of what the user has to go on, so it stays and
2559 // takes a suffix rather than being replaced by something generated.
2560 for (let n = 2; n < 100; n++) {
2561 if (!taken.includes(`${base}-${n}`)) return `${base}-${n}`;
2562 }
2563 return `${base}-${taken.length}`;
2564}
2565
2566/**
2567 * The window a dragged slot stands for, looked up in the last status frame —
2568 * the slot itself carries an id and a session but not a name, and the name is
2569 * what the new session is called.
2570 *
2571 * @param {string} id tmux window id
2572 * @returns {{ window: TbWindowInfo, session: TbSessionInfo } | null}
2573 */
2574function windowById(id) {
2575 for (const session of lastSessions) {
2576 const window = session.windows.find((w) => w.id === id);
2577 if (window) return { window, session };
2578 }
2579 return null;
2580}
2581
2582/**
2583 * Whether this drag can become a session, and what it would be called.
2584 *
2585 * The only thing asked of the drag is that it is a window tab: a session tab is
2586 * already a session, and a window's own last window is *not* refused — tmux
2587 * destroys the session it leaves behind, which is the same session arriving
2588 * under a new name, and refusing it would mean a "+" that lights up for some
2589 * tabs and not others with nothing on screen saying which.
2590 *
2591 * The name comes from the last status frame where it can, and from the tab's
2592 * own label where it cannot: the frame is a lookup that can miss (a window
2593 * created during the drag, a frame not in yet), and a drag that dies because of
2594 * one would look exactly like a feature that does not work.
2595 *
2596 * @returns {{ window: string, name: string } | null}
2597 */
2598function pendingSessionSplit() {
2599 if (!dragging || dragging.parentElement !== $("tabs")) return null;
2600 const id = windowIdOf(dragging);
2601 if (!id) return null;
2602 const label = dragging.querySelector(".name")?.textContent ?? "";
2603 return {
2604 window: id,
2605 name: sessionNameFor(
2606 windowById(id)?.window.name || label,
2607 lastSessions.map((s) => s.name),
2608 ),
2609 };
2610}
2611
2612/**
2613 * Wire a "+" as a drop target for window tabs.
2614 *
2615 * `dragenter` is cancelled as well as `dragover`: cancelling the latter is what
2616 * makes the drop legal, and cancelling the former is what stops the browser
2617 * deciding on the way in that this element is not a target at all. Stopping the
2618 * events keeps the strip's own reorder from sliding the tab around while the
2619 * pointer is parked on a button it is going to leave the row through.
2620 *
2621 * @param {HTMLElement} button
2622 */
2623function splitZone(button) {
2624 const over = (/** @type {Event} */ e) => {
2625 if (!pendingSessionSplit()) return;
2626 e.preventDefault();
2627 e.stopPropagation();
2628 button.classList.add("drop");
2629 };
2630 button.addEventListener("dragenter", over);
2631 button.addEventListener("dragover", over);
2632 // The pointer crossing onto the "+"'s own <svg> is a dragleave on the button,
2633 // and the dragover that follows immediately puts the class back. Clearing it
2634 // on dragend as well is what covers the drag that ends somewhere else
2635 // entirely, which fires no dragleave here at all.
2636 button.addEventListener("dragleave", () => button.classList.remove("drop"));
2637 document.addEventListener("dragend", () => button.classList.remove("drop"));
2638 button.addEventListener("drop", (e) => {
2639 button.classList.remove("drop");
2640 const split = pendingSessionSplit();
2641 if (!split) return;
2642 e.preventDefault();
2643 e.stopPropagation();
2644 tmuxCommand({ cmd: "new-session-with-window", ...split });
2645 // The one gesture here whose result arrives a frame later and somewhere
2646 // else in the row: the log is what separates "the drop did nothing" from
2647 // "the daemon refused it".
2648 log(`new session ${split.name} from window ${split.window}`);
2649 term.focus();
2650 });
2651}
2652
2653splitZone($("session-new"));
2654splitZone($("tab-new"));
2655
2656/** @param {Element | null} slot @returns {string} the slot's window id, or "" */
2657function windowIdOf(slot) {
2658 const tab = slot?.querySelector(".tab");
2659 return (tab && /** @type {HTMLElement} */ (tab).dataset.window) || "";
2660}
2661
2662/** @param {Element | null} slot @returns {string} the session it belongs to */
2663function sessionOf(slot) {
2664 const tab = slot?.querySelector(".tab");
2665 return (tab && /** @type {HTMLElement} */ (tab).dataset.session) || "";
2666}
2667
2668// --- tab groups -------------------------------------------------------------
2669//
2670// Groups mode's single row. It renders into the same `#tabs` strip the nested
2671// layout uses — the same scroll behaviour, the same drop zone, the same tab
2672// silhouettes — but the strip now holds every window on the server, each
2673// session's run of them introduced by a chip.
2674//
2675// The chip is the session, and it is the only thing in this row that is not a
2676// window: it carries the name, the count, a colour that is the same colour
2677// every time you see that session, and the fold. Folding is this panel's own —
2678// tmux has no notion of a hidden session, and nothing about a folded group is
2679// sent anywhere.
2680
2681/**
2682 * Chrome's tab groups get a colour from a fixed short list rather than from
2683 * anywhere in the tab, and so do these: a hue picked out of the name means a
2684 * session is the same colour in every panel and after every restart, with no
2685 * state to store and nothing to assign by hand.
2686 *
2687 * Eight hues, spaced to stay apart at chip size and chosen to skip the
2688 * yellow-green band, which goes muddy against both themes' strip colours.
2689 */
2690const GROUP_HUES = [
2691 { hue: 210, name: "Blue" },
2692 { hue: 190, name: "Cyan" },
2693 { hue: 145, name: "Green" },
2694 { hue: 45, name: "Yellow" },
2695 { hue: 25, name: "Orange" },
2696 { hue: 0, name: "Red" },
2697 { hue: 330, name: "Pink" },
2698 { hue: 275, name: "Purple" },
2699];
2700
2701/**
2702 * Grey, which is not a hue and so cannot be one of the numbers above. It is the
2703 * home session's default and a colour you can pick outright, the way Chrome
2704 * offers grey alongside its eight.
2705 */
2706const GROUP_GREY = -1;
2707
2708/**
2709 * The colour a session has been given, if any — read off the session itself.
2710 *
2711 * It lives in a tmux user option rather than in this panel's storage, which
2712 * buys two things storage could not. It follows the session through a rename,
2713 * because tmux hangs it on the session and not on its name. And every panel on
2714 * the server sees the same colour, instead of each browser profile keeping a
2715 * private opinion about the same session. It also dies with the session, which
2716 * is right: a colour for a session that no longer exists is nothing.
2717 *
2718 * The value arrives as a string over a socket, so it is a claim rather than a
2719 * number until this says otherwise.
2720 *
2721 * @param {string} name
2722 * @returns {number | null}
2723 */
2724function chosenColor(name) {
2725 const raw = lastSessions.find((s) => s.name === name)?.color;
2726 if (typeof raw !== "string" || raw === "") return null;
2727 const n = Number(raw);
2728 if (!Number.isInteger(n)) return null;
2729 return n === GROUP_GREY || (n >= 0 && n < 360) ? n : null;
2730}
2731
2732/**
2733 * What colour a group is drawn in: the one it was given, else grey for the home
2734 * session, else a hue hashed from the name.
2735 *
2736 * The hash is what makes the automatic case worth having — a session is the
2737 * same colour in every panel and after every restart, with nothing stored and
2738 * nothing to assign by hand. Choosing one is for when the hash puts two
2739 * sessions you use together on hues you cannot tell apart, which is the one
2740 * thing hashing cannot fix by itself.
2741 *
2742 * @param {string} name
2743 * @returns {number} degrees on the colour wheel, or GROUP_GREY
2744 */
2745function groupColor(name) {
2746 const chosen = chosenColor(name);
2747 if (chosen !== null) return chosen;
2748 if (name === defaultSession) return GROUP_GREY;
2749 let h = 0;
2750 for (let i = 0; i < name.length; i++) h = (Math.imul(h, 31) + name.charCodeAt(i)) >>> 0;
2751 return GROUP_HUES[h % GROUP_HUES.length].hue;
2752}
2753
2754/**
2755 * Hand the colour to tmux and let the next status frame bring it back. No
2756 * optimistic repaint: the server is the only copy, and drawing what we hope it
2757 * will say invents a second one for the second it takes to answer.
2758 *
2759 * By id, not by name — a rename between the click and the command would
2760 * otherwise paint whichever session inherited the name.
2761 *
2762 * @param {string} name
2763 * @param {number | null} hue null clears it, back to automatic
2764 */
2765function setGroupColor(name, hue) {
2766 const id = lastSessions.find((s) => s.name === name)?.id;
2767 if (!id) return;
2768 tmuxCommand({
2769 cmd: "set-session-color",
2770 session: id,
2771 ...(hue === null ? {} : { color: String(hue) }),
2772 });
2773}
2774
2775/**
2776 * What a session may be renamed to. The same shape the daemon accepts, which is
2777 * the same shape the sidebar's session field has always accepted — tmux forbids
2778 * `.` and `:`, and the name is quoted into a command line to a live server, so
2779 * everything outside this is refused here rather than sent to be refused there.
2780 *
2781 * Checking it in the panel as well as in the daemon is not belt-and-braces: it
2782 * is what lets the field say *now* that a name will not do, instead of the
2783 * request vanishing silently.
2784 */
2785const SESSION_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
2786
2787/** @param {string} name */
2788function validSessionName(name) {
2789 const s = name.trim();
2790 return SESSION_NAME_RE.test(s) && !s.startsWith("-");
2791}
2792
2793/**
2794 * Rename a session, and bring this panel's own name-keyed state along.
2795 *
2796 * The colour needs no help — it lives on the session in tmux, so it follows the
2797 * rename by itself. Pins and the folded flag do not: they are panel
2798 * preferences, stored against the name because that is what the frames and the
2799 * storage have in common. Left alone, a rename would silently unfold a group
2800 * and unpin its tabs, which reads as the panel forgetting rather than as a
2801 * rename.
2802 *
2803 * The rename itself is sent by id — a second panel renaming the same session
2804 * between this menu opening and the click would otherwise redirect ours onto
2805 * whatever inherited the name. Nothing is repainted optimistically: tmux is the
2806 * only copy of the name, and the next status frame brings it back.
2807 *
2808 * @param {string} from the current name
2809 * @param {string} to
2810 */
2811function renameSession(from, to) {
2812 const next = to.trim();
2813 const id = lastSessions.find((s) => s.name === from)?.id;
2814 if (!id || next === from || !validSessionName(next)) return;
2815
2816 if (pins[from]) {
2817 pins[next] = pins[from];
2818 delete pins[from];
2819 storage.set({ pins });
2820 }
2821 if (foldedGroups.has(from)) {
2822 foldedGroups.delete(from);
2823 foldedGroups.add(next);
2824 storage.set({ foldedGroups: [...foldedGroups] });
2825 }
2826
2827 tmuxCommand({ cmd: "rename-session", session: id, name: next });
2828 log(`renamed ${from} to ${next}`);
2829}
2830
2831/**
2832 * Session names whose windows are folded away behind their chip. A panel
2833 * preference, saved as a list because a Set does not survive storage.
2834 * @type {Set<string>}
2835 */
2836let foldedGroups = new Set();
2837
2838/** @param {string} name */
2839function toggleGroup(name) {
2840 if (foldedGroups.has(name)) foldedGroups.delete(name);
2841 else foldedGroups.add(name);
2842 storage.set({ foldedGroups: [...foldedGroups] });
2843 repaintTabs();
2844}
2845
2846/**
2847 * @param {TbSessionInfo[]} unordered as the daemon sent them
2848 * @param {string | null | undefined} current the session this panel is on
2849 * @param {TbAgent[]} agents server-wide
2850 */
2851function renderGroups(unordered, current, agents) {
2852 const sessions = orderSessions(unordered);
2853 const strip = $("tabs");
2854 // A repaint mid-drag would tear the tab out from under the pointer; the move
2855 // it commits to brings a fresh frame of its own a moment later.
2856 if (dragging && !strip.hidden) return;
2857 const show = connected && tmuxMode && sessions.length > 0;
2858 $("tab-new").hidden = !(connected && tmuxMode && sessionName);
2859 // The one "+" left in this row, so it says which of the two things it is —
2860 // the ambiguity was the whole complaint about having a second one beside it.
2861 // It names the session it actually adds to, which is not always the one you
2862 // are on and not always the home session either.
2863 $("tab-new").title = newTabTitle(newWindowTarget() ?? defaultSession);
2864 strip.hidden = !show;
2865 // A chip carries the session name, so the status text has nothing left to
2866 // say — the same trade the session row makes in the nested layout.
2867 document.body.classList.toggle("has-session", show);
2868 document.body.classList.toggle("has-windows", show);
2869 if (!show) {
2870 strip.textContent = "";
2871 strip.dataset.sig = "";
2872 hideSessionInput();
2873 syncSpinner();
2874 return;
2875 }
2876
2877 const claude = agentByWindow(agents);
2878 const loudest = agentBySession(agents);
2879
2880 /** @type {{ session: TbSessionInfo, folded: boolean, windows: TbWindowInfo[], pinned: Set<string> }[]} */
2881 const groups = sessions.map((s) => {
2882 const { ordered, pinned } = orderWindows(s.name, s.windows);
2883 return { session: s, folded: foldedGroups.has(s.name), windows: ordered, pinned };
2884 });
2885
2886 // One session, the home one, never given a colour of its own: there is no
2887 // grouping for a chip to express, so it is left off and the row reads as a
2888 // plain strip of tabs. Chrome does the same with ungrouped tabs. Anything
2889 // that makes the grouping real — a second session, a rename, a colour picked
2890 // for this one — brings the chip back, and with it the fold, so folding is
2891 // ignored while it is gone.
2892 const bare =
2893 groups.length === 1 && groups[0].session.name === defaultSession && groupColor(defaultSession) === GROUP_GREY;
2894 if (bare) groups[0].folded = false;
2895
2896 const sig = JSON.stringify(
2897 groups.map((g) => [
2898 bare,
2899 g.session.name,
2900 g.session.name === current,
2901 g.session.attached,
2902 g.folded,
2903 // The colour lives on the server now, so it can change without anything
2904 // else in this frame moving — a second panel picked one.
2905 groupColor(g.session.name),
2906 // A folded group shows no tabs, but it still shows a count and the
2907 // loudest thing Claude is doing behind it, so both belong in the
2908 // signature whether or not the windows do.
2909 g.session.windows.length,
2910 loudest[g.session.name]?.state,
2911 agentLabel(loudest[g.session.name]),
2912 g.folded
2913 ? null
2914 : g.windows.map((w) => {
2915 const a = claude[w.id];
2916 return [w.id, w.index, w.name, w.active, w.activity, a?.state, agentLabel(a), g.pinned.has(w.id)];
2917 }),
2918 ]),
2919 );
2920 if (strip.dataset.sig !== sig) {
2921 strip.dataset.sig = sig;
2922 strip.textContent = "";
2923 for (const g of groups) {
2924 const name = g.session.name;
2925 if (!bare) strip.appendChild(groupChip(g.session, name === current, g.folded, loudest[name]));
2926 if (g.folded) continue;
2927 // Per group *and* per row: closing a group's last window closes the
2928 // group, which is what closing a group's last tab does in a browser too.
2929 // Only the very last window on the server is withheld.
2930 const closable = windowClosable(g.windows.length, groups.length);
2931 for (const w of g.windows) {
2932 strip.appendChild(
2933 windowTab(w, {
2934 claude: claude[w.id],
2935 closable,
2936 pinned: g.pinned.has(w.id),
2937 lastInSession: g.windows.length === 1,
2938 owner: name,
2939 current: w.active && name === current,
2940 }),
2941 );
2942 }
2943 }
2944 }
2945
2946 syncSpinner();
2947
2948 // The omnibar says where the client is, and the client moves — a switch made
2949 // from a tab, from the omnibar, or from the terminal itself all land here.
2950 syncOmniHere();
2951 refreshSessionInfo();
2952 // The list is drawn from the same frame the tabs are, so a window that just
2953 // closed has to leave it too — and a Claude that just started waiting has to
2954 // light up in it. Only while it is open: this arrives once a second.
2955 if (!$("omni-list").hidden) refreshOmni();
2956
2957 const active = strip.querySelector('[aria-selected="true"]');
2958 // The row is as long as the whole server now, so the window you are on is
2959 // further off screen than it ever was in the nested layout.
2960 if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
2961}
2962
2963/**
2964 * @param {TbSessionInfo} s
2965 * @param {boolean} isCurrent this panel's client is in this group
2966 * @param {boolean} folded
2967 * @param {TbAgent} [claude] the loudest agent anywhere in the session
2968 */
2969function groupChip(s, isCurrent, folded, claude) {
2970 // Grey by default for the home session — it is where windows go when nothing
2971 // said otherwise, so a hue would make it look like one more named group
2972 // rather than the plain one. Chrome's ungrouped tabs are the same idea with
2973 // the chip left off entirely; keeping the chip is what buys the fold. Picking
2974 // a colour for it overrides that, because an explicit choice outranks a
2975 // default about the same thing.
2976 const colour = groupColor(s.name);
2977 const state = claude?.state ?? "none";
2978 const linked = tabPinMark({ session: s.name, window: null });
2979 const chip = button(
2980 {
2981 class:
2982 `group-chip${isCurrent ? " current" : ""}${folded ? " folded" : ""}` +
2983 `${s.attached && !isCurrent ? " attached" : ""}${colour === GROUP_GREY ? " grey" : ""}` +
2984 `${linked ? " tab-linked" : ""}`,
2985 css: { "--group-h": String(colour) },
2986 data: { session: s.name },
2987 attrs: { "aria-expanded": !folded },
2988 title: tip(
2989 `session ${s.name}`,
2990 `${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`,
2991 isCurrent ? "you are here" : s.attached && "attached elsewhere",
2992 claude && `claude ${claude.state} — ${agentLabel(claude)}`,
2993 folded ? "folded — click to unfold" : "click to fold",
2994 linked && PIN_SOURCE_NOTE[linked],
2995 "right-click to rename or recolour",
2996 ),
2997 on: {
2998 click: () => {
2999 toggleGroup(s.name);
3000 term.focus();
3001 },
3002 /** @param {MouseEvent} e */
3003 contextmenu: (e) => {
3004 e.preventDefault();
3005 openGroupMenu(e, s, isCurrent, folded);
3006 },
3007 },
3008 },
3009 // Unlike Chrome, the group you are in folds too. Chrome forbids it because
3010 // folding away the active tab would leave you with no way back to it; here
3011 // the terminal underneath is still the window you are on, and the chip stays
3012 // marked as current, so there is nothing to lose track of.
3013 el("span", { class: "caret", attrs: { "aria-hidden": "true" } }),
3014 el("span", { class: "name", text: s.name }),
3015 // Folded, the count is the only thing saying how much is behind the chip, so
3016 // it is worth the room. Unfolded it is redundant with the tabs themselves.
3017 folded &&
3018 s.windows.length > 0 &&
3019 el("span", { class: "count", text: String(s.windows.length) }),
3020 // A folded group's windows have no tabs to wear their status, so the chip
3021 // wears the loudest of them — the same job the session tab does in the
3022 // nested layout, and the reason the daemon reports every session's agents.
3023 folded && state !== "none" && glyphSpan(state),
3024 );
3025
3026 // Dropping a tab on a chip moves that window into the session — the only way
3027 // to express the move when the group is folded and has no visible window to
3028 // land beside. Stopping the event keeps the strip's own dragover from
3029 // sliding the tab into a position it is not going to end up in.
3030 chip.addEventListener("dragover", (e) => {
3031 if (!dragging || dragging.parentElement !== $("tabs")) return;
3032 e.preventDefault();
3033 e.stopPropagation();
3034 chip.classList.add("drop");
3035 });
3036 chip.addEventListener("dragleave", () => chip.classList.remove("drop"));
3037 chip.addEventListener("drop", (e) => {
3038 chip.classList.remove("drop");
3039 if (!dragging) return;
3040 e.preventDefault();
3041 e.stopPropagation();
3042 const moved = windowIdOf(dragging);
3043 if (moved && sessionOf(dragging) !== s.name) {
3044 tmuxCommand({ cmd: "move-window-to-session", window: moved, session: s.name });
3045 }
3046 term.focus();
3047 });
3048
3049 return chip;
3050}
3051
3052/**
3053 * The palette, as a row of swatches. Grey leads it, then the eight hues in
3054 * wheel order so the row reads as a spectrum rather than as a list of names.
3055 *
3056 * The swatch showing now is ringed whichever way it got there — chosen or
3057 * hashed — so the row always says what the group looks like. Clicking the one
3058 * already showing clears the choice back to automatic, which is how you undo a
3059 * colour without a tenth control for it.
3060 *
3061 * @param {string} name the session
3062 */
3063function colourRow(name) {
3064 const live = groupColor(name);
3065 const chosen = chosenColor(name);
3066
3067 /** @param {number} hue @param {string} label */
3068 const swatch = (hue, label) => {
3069 const title = chosen !== null && hue === live ? `${label} — click for automatic` : label;
3070 return button({
3071 class: `swatch${hue === GROUP_GREY ? " grey" : ""}${hue === live ? " on" : ""}`,
3072 css: { "--group-h": String(hue) },
3073 title,
3074 attrs: { "aria-label": title, "aria-pressed": hue === live },
3075 on: {
3076 click: () => {
3077 closeTabMenu();
3078 setGroupColor(name, hue === live && chosen !== null ? null : hue);
3079 },
3080 },
3081 });
3082 };
3083
3084 return el(
3085 "div",
3086 { class: "colours" },
3087 swatch(GROUP_GREY, "Grey"),
3088 ...GROUP_HUES.map((c) => swatch(c.hue, c.name)),
3089 );
3090}
3091
3092/**
3093 * The name, as a field at the top of the group's menu — the same place and the
3094 * same gesture a browser gives a tab group's name, because it is the same
3095 * thing being named.
3096 *
3097 * A field rather than a "Rename" item that opens something: an item would have
3098 * to open a prompt, and a modal in this panel blocks the socket's message
3099 * handler for as long as it is up, so the terminal underneath would stop
3100 * moving while the box was open. Editing in place costs nothing and the panel
3101 * keeps running behind it.
3102 *
3103 * Enter commits, Escape leaves the name alone, and blur commits too — a click
3104 * on anything else in the menu is a click on a menu whose field you had already
3105 * finished with. An empty or malformed name commits nothing and says so.
3106 *
3107 * @param {TbSessionInfo} s
3108 */
3109function renameRow(s) {
3110 const row = el("div", { class: "rename" });
3111
3112 const input = el("input", {
3113 title: "Session name — letters, digits, - and _",
3114 attrs: {
3115 type: "text",
3116 maxlength: 64,
3117 spellcheck: "false",
3118 "aria-label": `Rename session ${s.name}`,
3119 },
3120 });
3121 // Not an attribute: what the field holds is state, and the attribute only ever
3122 // sets what it *started* with.
3123 input.value = s.name;
3124
3125 // Live rather than only on Enter: the field is small and the rule is not
3126 // guessable, so the moment a character breaks it is the moment to say so.
3127 const check = () => {
3128 const v = input.value.trim();
3129 row.classList.toggle("bad", v !== "" && v !== s.name && !validSessionName(v));
3130 };
3131 input.addEventListener("input", check);
3132
3133 /** @param {boolean} commit */
3134 const finish = (commit) => {
3135 // Whichever way it ends, it ends once: the blur that Enter causes, and the
3136 // menu closing behind it, would otherwise each commit the same name again.
3137 input.removeEventListener("blur", onBlur);
3138 pendingRename = null;
3139 if (commit) renameSession(s.name, input.value);
3140 };
3141 const onBlur = () => finish(true);
3142 input.addEventListener("blur", onBlur);
3143 // Dismissing the menu removes the field, and a removed element gets no blur
3144 // event — so the close path commits it instead. Typing a name and clicking
3145 // away should rename, not throw the typing out.
3146 pendingRename = () => finish(true);
3147
3148 input.addEventListener("keydown", (ev) => {
3149 if (ev.key === "Enter") {
3150 ev.preventDefault();
3151 finish(true);
3152 closeTabMenu();
3153 term.focus();
3154 } else if (ev.key === "Escape") {
3155 ev.preventDefault();
3156 finish(false);
3157 closeTabMenu();
3158 term.focus();
3159 }
3160 // Everything else stays in the box. Without this the panel's own shortcuts
3161 // would read the typing as commands aimed at the terminal.
3162 ev.stopPropagation();
3163 });
3164
3165 row.appendChild(input);
3166 return row;
3167}
3168
3169/**
3170 * @param {MouseEvent} e
3171 * @param {TbSessionInfo} s
3172 * @param {boolean} isCurrent
3173 * @param {boolean} folded
3174 */
3175function openGroupMenu(e, s, isCurrent, folded) {
3176 closeTabMenu();
3177 const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
3178 /** @param {string} label @param {() => void} run */
3179 const item = (label, run) => menuItem(menu, label, run);
3180
3181 // Name then colours across the top, then the actions — a browser's tab group
3182 // menu in the same order, and for the same reason: these two are what the
3183 // group *is*, and the rest is what to do with it.
3184 const rename = renameRow(s);
3185 menu.appendChild(rename);
3186 menu.appendChild(colourRow(s.name));
3187
3188 item(folded ? "Unfold" : "Fold", () => toggleGroup(s.name));
3189
3190 if (!isCurrent) {
3191 item("Switch to this session", () => {
3192 tmuxCommand({ cmd: "switch", session: s.name });
3193 term.focus();
3194 });
3195 }
3196
3197 // The row's "+" opens a window in the home session; this is how any other
3198 // group gets one without going there first.
3199 item("New window here", () => {
3200 newWindowIn(s.name);
3201 term.focus();
3202 });
3203
3204 item("New Claude here", () => {
3205 newClaudeIn(s.name);
3206 term.focus();
3207 });
3208
3209 pinMenuItems(menu, { session: s.name, window: null });
3210
3211 item(folded ? "Fold the others" : "Fold everything else", () => {
3212 foldedGroups = new Set(lastSessions.map((x) => x.name).filter((n) => n !== s.name));
3213 storage.set({ foldedGroups: [...foldedGroups] });
3214 repaintTabs();
3215 });
3216
3217 document.body.appendChild(menu);
3218 placeMenu(menu, e);
3219 // Selected rather than merely focused: the common rename replaces the name
3220 // outright, and the uncommon one is an arrow key away.
3221 const field = rename.querySelector("input");
3222 if (field instanceof HTMLInputElement) field.select();
3223}
3224
3225// --- the omnibar ------------------------------------------------------------
3226//
3227// Groups mode's second row, and the same bargain a browser's address bar makes:
3228// one box that searches what you already have and, failing that, offers to
3229// create the thing you typed. Here that is every window, every session and
3230// every pane running Claude on the tmux server — the same status frame the tabs
3231// are drawn from, so there is nothing extra to fetch and nothing that can be
3232// out of date with respect to the row above it.
3233//
3234// It exists because the flat row scrolls: with every window on the server in
3235// one strip, past a handful of sessions the one you want is off the end of it,
3236// and folding groups to find it defeats the point of having them all there.
3237//
3238// Everything it lists is a tmux name — a user or a shell script named it, and
3239// both can put anything in a name — so every one of them reaches the DOM
3240// through textContent, and the only thing sent back is an id tmux issued.
3241
3242/**
3243 * One row of the dropdown.
3244 * @typedef {object} TbOmniItem
3245 * @property {"window" | "session" | "pane" | "ssh" | "create" | "claude" | "run"
3246 * | "action" | "project" | "path"} kind
3247 * @property {string} label the name, matched against and shown first
3248 * @property {string} meta where it is — dimmed, after the label
3249 * @property {string} [mark] a character in front of the label, for the kinds
3250 * whose rows are not all alike — one action is not the next one
3251 * @property {TbAgentState} [state] an agent state, for the glyph
3252 * @property {number} score lower sorts first
3253 * @property {() => void} [run] absent on a hint, which is a row you cannot run
3254 * @property {boolean} [hint] the row is telling you something, not offering it
3255 * @property {boolean} [complete] running it fills the box in rather than going
3256 * anywhere, so the list stays up and the caret stays where it is
3257 * @property {boolean} [typed] the label is the query itself rather than a name
3258 * that was matched, so there is nothing in it to highlight
3259 */
3260
3261/** @type {TbOmniItem[]} */
3262let omniItems = [];
3263/** Index into `omniItems`, or -1 for "nothing chosen yet". */
3264let omniActive = -1;
3265
3266/**
3267 * Substring, case-insensitive, with a prefix bonus: `srv` finds "server"
3268 * ahead of "webserver", which is the order you meant by typing the start of a
3269 * name. Deliberately not fuzzy — a fuzzy match over a few hundred window names
3270 * puts noise at the top, and tmux names are short enough to type.
3271 *
3272 * @param {string} text
3273 * @param {string} q already lowercased and trimmed
3274 * @returns {number} lower is better, -1 for no match
3275 */
3276function omniScore(text, q) {
3277 if (!q) return 0;
3278 const i = text.toLowerCase().indexOf(q);
3279 if (i < 0) return -1;
3280 return i === 0 ? 0 : i + 1;
3281}
3282
3283/**
3284 * `omniScore`, plus a subsequence pass for what it rejects: `bld1` finds
3285 * `build-01`, and `cmini` finds `collin@mini`.
3286 *
3287 * Only hosts are scored this way, and the divergence is deliberate. Fuzzy
3288 * matching over a few hundred window names puts noise at the top, which is why
3289 * `omniScore` is not fuzzy — but the host list is a few dozen names you wrote
3290 * down yourself, and they are the ones with dots, dashes and a `user@` in front
3291 * that make a substring search miss what you obviously meant.
3292 *
3293 * A subsequence match always sorts below every substring match, and among
3294 * themselves the tighter one wins: the span the letters were found across is
3295 * the score, so a name that has them close together beats one that spreads them
3296 * over half its length.
3297 *
3298 * @param {string} text
3299 * @param {string} q already lowercased and trimmed
3300 * @returns {number} lower is better, -1 for no match
3301 */
3302function fuzzyScore(text, q) {
3303 const direct = omniScore(text, q);
3304 if (direct >= 0) return direct;
3305 const s = text.toLowerCase();
3306 let from = -1;
3307 let start = -1;
3308 for (const ch of q) {
3309 from = s.indexOf(ch, from + 1);
3310 if (from < 0) return -1;
3311 if (start < 0) start = from;
3312 }
3313 return 20 + (from - start);
3314}
3315
3316/**
3317 * The daemon's `valid_ssh_host`, mirrored for the same reason the validators
3318 * below are: a row offering to connect somewhere the daemon will refuse is
3319 * worse than no row.
3320 *
3321 * `[user@]host`, and no character that ssh could read as an option — a leading
3322 * dash is the one that matters, because `-oProxyCommand=` runs a shell. Kept in
3323 * step by hand with daemon/src/ssh.rs; the daemon validates regardless.
3324 *
3325 * @param {string} host
3326 */
3327function validSshHost(host) {
3328 const s = host.trim();
3329 if (!s || s.length > 128) return false;
3330 const at = s.indexOf("@");
3331 const parts = at < 0 ? [s] : [s.slice(0, at), s.slice(at + 1)];
3332 return parts.every((p) => p.length > 0 && !p.startsWith("-") && /^[A-Za-z0-9._-]+$/.test(p));
3333}
3334
3335/**
3336 * The daemon's `valid_session_name`, mirrored so the row that offers to create
3337 * a session only appears when the daemon would accept it — an offer that turns
3338 * into a silently ignored command is worse than no offer.
3339 *
3340 * Kept in step by hand with daemon/src/pty.rs. The daemon validates regardless;
3341 * this is about what to show, never about what is safe to send.
3342 *
3343 * @param {string} name
3344 */
3345function validSessionName(name) {
3346 const s = name.trim();
3347 return s.length > 0 && s.length <= 64 && !s.startsWith("-") && /^[A-Za-z0-9_-]+$/.test(s);
3348}
3349
3350/**
3351 * Ties break in this order, so a session beats a window whose name matches
3352 * equally well. The box holds a session name to begin with, so typing over it
3353 * is first of all a way to change session — that reading wins the tie, and a
3354 * window of the same name is one row below it.
3355 */
3356const OMNI_KIND_RANK = {
3357 session: 0,
3358 window: 1,
3359 pane: 2,
3360 ssh: 3,
3361 create: 4,
3362 claude: 5,
3363 run: 6,
3364 action: 7,
3365 // Both only ever appear on their own — a `~` in the box is a mode, and these
3366 // are the only rows in it — so their rank is a formality.
3367 project: 8,
3368 path: 9,
3369};
3370
3371/**
3372 * What a host row's score is pushed up by, so that anything already running on
3373 * the server outranks somewhere you could connect to.
3374 *
3375 * Above panes (+50) rather than below them, because a host matches on its name
3376 * — which is what you typed — where a pane matches on the prose of what Claude
3377 * happens to be doing in it.
3378 */
3379const OMNI_SSH_PENALTY = 40;
3380
3381/** How many matches the list shows before it stops. */
3382const OMNI_LIMIT = 8;
3383
3384/**
3385 * @param {string} query what is in the box
3386 * @returns {TbOmniItem[]}
3387 */
3388function omniSuggestions(query) {
3389 // `!` first, and on its own: a leading bang says the rest of the box is a
3390 // shell command, not a name, so nothing here is worth matching against the
3391 // server's names and offering to make a session called `!ls` would be noise.
3392 // The prefix is the shell's own — `!` is what a pager, an editor or a REPL
3393 // has always meant "and now run this" with.
3394 if (query.trim().startsWith("!")) {
3395 const cmd = query.trim().slice(1).trim();
3396 const here = lastSessions.find((s) => s.name === sessionName);
3397 if (!here) return [];
3398 const where = here.path ? `run · ${shortPath(here.path)}` : "run";
3399 // The bare `!` answers itself: the row appears the moment the prefix is
3400 // typed, saying what the box is now for and where the command will run, so
3401 // the mode is visible before there is anything to run. It is a label rather
3402 // than an offer — see `hint`, which is what keeps Enter from firing it.
3403 if (!cmd) {
3404 return [{ kind: "run", label: "type a command…", meta: where, score: 0, hint: true }];
3405 }
3406 if (!validCommand(cmd)) return [];
3407 return [
3408 {
3409 kind: "run",
3410 label: cmd,
3411 meta: where,
3412 score: 0,
3413 typed: true,
3414 run: () => tmuxCommand({ cmd: "run", session: here.name, command: cmd }),
3415 },
3416 ];
3417 }
3418
3419 // `~` next, and for the same reason `!` is first: a leading tilde says the
3420 // box holds a directory, and matching `~/Code/foo` against the server's
3421 // window names would find nothing while hiding the rows that can act on it.
3422 if (query.trimStart().startsWith("~")) return projectSuggestions(query);
3423
3424 const q = query.trim().toLowerCase();
3425 const claude = agentByWindow(lastAgents);
3426 const loudest = agentBySession(lastAgents);
3427 /** @type {TbOmniItem[]} */
3428 const out = [];
3429
3430 for (const s of lastSessions) {
3431 const score = omniScore(s.name, q);
3432 // An empty box is a starting point, not a dump of the server: it offers the
3433 // sessions, which is the short list, and holds the windows back until there
3434 // is something to narrow them by. The session you are on is left out of it
3435 // either way — going there is where you already are.
3436 if (score >= 0 && !(!q && s.name === sessionName)) {
3437 out.push({
3438 kind: "session",
3439 label: s.name,
3440 meta: `session · ${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`,
3441 state: loudest[s.name]?.state,
3442 score,
3443 run: () => tmuxCommand({ cmd: "switch", session: s.name }),
3444 });
3445 }
3446
3447 if (!q) continue;
3448 for (const w of s.windows) {
3449 const wScore = omniScore(w.name, q);
3450 if (wScore < 0) continue;
3451 const a = claude[w.id];
3452 out.push({
3453 kind: "window",
3454 label: w.name,
3455 meta: `${s.name} · window ${w.index}`,
3456 state: a?.state,
3457 // The window you are looking at right now is a worse answer than any
3458 // other equally good match: it is the one place you can already see.
3459 score: wScore + (w.active && s.name === sessionName ? 100 : 0),
3460 run: () => selectWindow(w, s.name),
3461 });
3462 }
3463 }
3464
3465 // Panes, but only the ones running Claude: those are the panes the daemon
3466 // knows an id for, and they are the ones worth addressing individually — the
3467 // rest of a window's panes are reached by going to the window.
3468 for (const a of lastAgents) {
3469 // What you would search for is what it is doing, not "pane %12".
3470 const text = [a.title, a.message, a.tool, a.name].filter(Boolean).join(" ");
3471 const score = omniScore(text, q);
3472 if (score < 0 || !q) continue;
3473 out.push({
3474 kind: "pane",
3475 label: a.title || agentLabel(a),
3476 meta: `${a.session} · ${a.window} · claude ${a.state}`,
3477 state: a.state,
3478 score: score + 50,
3479 run: () => tmuxCommand({ cmd: "focus", pane: a.pane }),
3480 });
3481 }
3482
3483 // Machines, from what ssh already knows about. Only with a query, for the
3484 // same reason the windows are: an empty box is a starting point rather than
3485 // an inventory, and the sessions are the short list it offers.
3486 //
3487 // The window this opens is a local one running ssh — see the daemon's
3488 // TmuxRequest::Ssh. So it needs the session the panel is on, exactly as the
3489 // Claude and `!` rows do, and it is offered only when there is one.
3490 const onSession = lastSessions.find((s) => s.name === sessionName);
3491 if (q && onSession) {
3492 for (const host of sshHosts) {
3493 const score = fuzzyScore(host, q);
3494 if (score < 0) continue;
3495 out.push({
3496 kind: "ssh",
3497 label: host,
3498 meta: "ssh",
3499 score: score + OMNI_SSH_PENALTY,
3500 run: () => tmuxCommand({ cmd: "ssh", session: onSession.name, host }),
3501 });
3502 }
3503 }
3504
3505 out.sort((x, y) => x.score - y.score || OMNI_KIND_RANK[x.kind] - OMNI_KIND_RANK[y.kind]);
3506 const items = out.slice(0, OMNI_LIMIT);
3507
3508 const typed = query.trim();
3509
3510 // A destination that is not in the list, read as one anyway: ssh reaches
3511 // machines no config or `known_hosts` mentions, and having to open a terminal
3512 // to connect to one of them would make the list a limit rather than a
3513 // shortcut.
3514 //
3515 // Only for text shaped like a destination, though — a `user@` or a dot.
3516 // A bare word is a session or a window name, and offering to ssh to `wor`
3517 // while you type `work` would put a connection under the cursor on the way
3518 // to somewhere you already have.
3519 if (
3520 typed &&
3521 onSession &&
3522 /[@.]/.test(typed) &&
3523 validSshHost(typed) &&
3524 !sshHosts.includes(typed)
3525 ) {
3526 items.push({
3527 kind: "ssh",
3528 label: typed,
3529 meta: "ssh",
3530 score: Infinity,
3531 typed: true,
3532 run: () => tmuxCommand({ cmd: "ssh", session: onSession.name, host: typed }),
3533 });
3534 }
3535
3536 // Last, always, and only when it would do something: an exact existing name
3537 // is a switch, which is already in the list above.
3538 if (typed && validSessionName(typed) && !lastSessions.some((s) => s.name === typed)) {
3539 items.push({
3540 kind: "create",
3541 typed: true,
3542 label: typed,
3543 meta: "create session",
3544 score: Infinity,
3545 run: () => tmuxCommand({ cmd: "create", session: typed }),
3546 });
3547 }
3548
3549 // Below everything, and last of all: whatever was typed, read as a question
3550 // rather than as a name. A sentence matches no window and is not a legal
3551 // session name, so for anything that isn't a name this is the only row in
3552 // the list — type the thing you want done, press Enter, and it opens in a
3553 // window of its own beside the one you are in.
3554 //
3555 // In the session's own directory, which is the whole reason to ask from here
3556 // rather than in a terminal somewhere else.
3557 if (typed && typed.length <= OMNI_PROMPT_MAX && onSession) {
3558 items.push({
3559 kind: "claude",
3560 label: typed,
3561 meta: onSession.path ? `send to claude · ${shortPath(onSession.path)}` : "send to claude",
3562 score: Infinity,
3563 typed: true,
3564 run: () => tmuxCommand({ cmd: "claude", session: onSession.name, prompt: typed }),
3565 });
3566 }
3567
3568 // With nothing typed the box is a menu rather than a search, so it ends with
3569 // the things you would otherwise have had to type to get: a Claude, and a
3570 // shell. Every other row here is reached by typing at least a letter, which
3571 // is the one thing a touch client has no cheap way to do — these are the rows
3572 // that make the list usable with a thumb.
3573 //
3574 // At the bottom, after the places, so Enter on an untouched box still means
3575 // the first session rather than starting something.
3576 if (!typed && onSession) items.push(...omniActions(onSession));
3577 return items;
3578}
3579
3580/**
3581 * The rows that make something in the session the panel is on. Both open a
3582 * window beside the current one, in the session's own directory — the same
3583 * window `send to claude` and `!` open, without the text.
3584 *
3585 * `claude` goes through `run` rather than through the daemon's Claude request:
3586 * that one exists to carry a prompt safely, and there is no prompt here. What
3587 * this is, is `!claude` with nothing to type.
3588 *
3589 * @param {TbSessionInfo} s
3590 * @returns {TbOmniItem[]}
3591 */
3592function omniActions(s) {
3593 const where = s.path ? shortPath(s.path) : s.name;
3594 return [
3595 {
3596 kind: "action",
3597 mark: "✻",
3598 label: "New Claude",
3599 meta: `new window · ${where}`,
3600 score: Infinity,
3601 typed: true,
3602 run: () => tmuxCommand({ cmd: "run", session: s.name, command: "claude" }),
3603 },
3604 {
3605 kind: "action",
3606 mark: "+",
3607 label: "New window",
3608 meta: `${s.name} · ${where}`,
3609 score: Infinity,
3610 typed: true,
3611 run: () => tmuxCommand({ cmd: "new-window", session: s.name }),
3612 },
3613 ];
3614}
3615
3616/**
3617 * The daemon's `MAX_PROMPT`, mirrored for the same reason `validSessionName`
3618 * mirrors its validator: an offer the daemon would drop is worse than none.
3619 * Kept in step by hand with daemon/src/pty.rs.
3620 */
3621const OMNI_PROMPT_MAX = 8192;
3622
3623/**
3624 * The daemon's `valid_command`, mirrored for the same reason again: a row that
3625 * offers to run something the daemon will drop is worse than no row.
3626 *
3627 * A command is one line — a newline in the box means a paste that meant to go
3628 * to the terminal itself. Kept in step by hand with daemon/src/pty.rs.
3629 *
3630 * @param {string} cmd
3631 */
3632function validCommand(cmd) {
3633 // eslint-disable-next-line no-control-regex
3634 return cmd.length > 0 && cmd.length <= 4096 && !/[\x00-\x1f\x7f]/.test(cmd);
3635}
3636
3637// --- the box as a place -----------------------------------------------------
3638//
3639// `~/Code/foo let's do this` — a directory to work in, and what to say to
3640// Claude once it is running there. A leading `~` is what puts the box in this
3641// mode: no tmux name starts with one, and neither does anything you would type
3642// looking for a window, so nothing else has to be given up for it.
3643//
3644// The rows are a question the panel cannot answer for itself. It has no
3645// filesystem — the directory is on the daemon's machine — so it asks about one
3646// directory at a time and works the rest out from the answer: whether the path
3647// exists decides between switching to it, starting a session in it, and making
3648// it first. One query per level typed, cached, rather than one per keystroke.
3649
3650/**
3651 * What the daemon said about a directory, keyed by the directory asked about.
3652 * @type {Map<string, TbPathFrame>}
3653 */
3654const pathAnswers = new Map();
3655/** Asked and not yet answered, so the same question is not asked twice.
3656 * @type {Set<string>} */
3657const pathAsking = new Set();
3658/** Cleared wholesale when it gets past this; the box asks again as you type. */
3659const PATH_CACHE_MAX = 64;
3660/** Long enough that a typed path is one query per `/`, short enough not to be
3661 * felt. The answer arriving repaints the list under the caret. */
3662const PATH_DEBOUNCE_MS = 70;
3663/** How many directories the list offers to complete to. */
3664const PATH_COMPLETIONS = 6;
3665
3666let pathTimer = 0;
3667/** The most recent directory `askPath` was asked for; the timer sends this. */
3668let pathWanted = "";
3669
3670/**
3671 * Ask the daemon about a directory, at most once, and not on every keystroke.
3672 *
3673 * The debounce holds one query rather than a queue: typing `~/Code/` fires for
3674 * `~/Code` and not for `~/Cod`, because the last thing wanted is the only thing
3675 * still worth asking by the time the timer runs.
3676 *
3677 * @param {string} dir a path in the box's own notation — `~/Code`, `/etc`
3678 */
3679function askPath(dir) {
3680 if (!connected || !ws || !dir || pathAnswers.has(dir) || pathAsking.has(dir)) return;
3681 pathWanted = dir;
3682 if (pathTimer) return;
3683 pathTimer = setTimeout(() => {
3684 pathTimer = 0;
3685 const q = pathWanted;
3686 if (!connected || !ws || !q || pathAnswers.has(q) || pathAsking.has(q)) return;
3687 pathAsking.add(q);
3688 ws.send(JSON.stringify({ type: "path", q }));
3689 }, PATH_DEBOUNCE_MS);
3690}
3691
3692/**
3693 * File an answer and, if the list is up, draw it — the rows that were waiting
3694 * on this are the reason it was asked for.
3695 *
3696 * @param {TbPathFrame} msg
3697 */
3698function takePathAnswer(msg) {
3699 if (typeof msg.q !== "string") return;
3700 pathAsking.delete(msg.q);
3701 // A cache, not a model of the filesystem: it is dropped whole rather than
3702 // aged, and anything still on screen is asked for again on the next keystroke.
3703 if (pathAnswers.size >= PATH_CACHE_MAX) pathAnswers.clear();
3704 pathAnswers.set(msg.q, msg);
3705 if (!$("omni-list").hidden) refreshOmni();
3706}
3707
3708/**
3709 * Forget what we know about a directory. Called when we have just asked for
3710 * something to be created inside it, because the listing we hold is now one
3711 * name short of the truth.
3712 *
3713 * @param {string} dir
3714 */
3715function forgetPath(dir) {
3716 pathAnswers.delete(dir);
3717 pathAsking.delete(dir);
3718}
3719
3720/**
3721 * A path split where the daemon has to be asked: the directory to list, and
3722 * what has been typed of the name inside it.
3723 *
3724 * @param {string} token
3725 * @returns {{ dir: string, prefix: string }}
3726 */
3727function splitPath(token) {
3728 const cut = token.lastIndexOf("/");
3729 if (cut < 0) return { dir: token, prefix: "" };
3730 // A single leading slash is the root, and slicing it away would leave "".
3731 if (cut === 0) return { dir: "/", prefix: token.slice(1) };
3732 return { dir: token.slice(0, cut), prefix: token.slice(cut + 1) };
3733}
3734
3735/**
3736 * A session name from a directory name — what the last component of the path
3737 * would be called if tmux would have it.
3738 *
3739 * `my.app` becomes `my-app`, because tmux rejects a dot in a session name. The
3740 * row says what the session will be called for exactly this reason: the name
3741 * and the directory are usually the same word, and when they are not, that is
3742 * worth seeing before pressing Enter rather than after.
3743 *
3744 * @param {string} path an absolute path
3745 */
3746function projectSlug(path) {
3747 const base = path.split("/").filter(Boolean).pop() ?? "";
3748 const slug = base
3749 .replace(/[^A-Za-z0-9_-]+/g, "-")
3750 .replace(/^-+|-+$/g, "")
3751 .slice(0, 64);
3752 return validSessionName(slug) ? slug : "";
3753}
3754
3755/**
3756 * `slug`, or the first `slug-2`, `slug-3` that no session has taken.
3757 *
3758 * Only reached when the directory is *not* one we already have a session on —
3759 * that case is a switch, not a second session. This is the other one: two
3760 * different directories whose last component happens to be the same word.
3761 *
3762 * @param {string} slug
3763 * @returns {string} empty when there is no free name, which is not a real case
3764 */
3765function freeSessionName(slug) {
3766 if (!slug) return "";
3767 const taken = (/** @type {string} */ name) => lastSessions.some((s) => s.name === name);
3768 if (!taken(slug)) return slug;
3769 for (let n = 2; n < 100; n++) {
3770 const candidate = `${slug}-${n}`.slice(0, 64);
3771 if (!taken(candidate)) return candidate;
3772 }
3773 return "";
3774}
3775
3776/**
3777 * The rows for a box that starts with `~`.
3778 *
3779 * @param {string} query
3780 * @returns {TbOmniItem[]}
3781 */
3782function projectSuggestions(query) {
3783 const s = query.trim();
3784 // The first whitespace ends the path and begins the prompt. A directory with
3785 // a space in its name cannot be typed here, which is the price of the prompt
3786 // needing no punctuation of its own — and the completion rows will still walk
3787 // you into one.
3788 const space = s.search(/\s/);
3789 const token = space < 0 ? s : s.slice(0, space);
3790 const typedPrompt = space < 0 ? "" : s.slice(space + 1).trim();
3791 const prompt = typedPrompt.length <= OMNI_PROMPT_MAX ? typedPrompt : "";
3792 const { dir, prefix } = splitPath(token);
3793
3794 /** @type {(label: string, meta: string) => TbOmniItem[]} */
3795 const hint = (label, meta) => [{ kind: "project", label, meta, score: 0, typed: true, hint: true }];
3796
3797 const answer = pathAnswers.get(dir);
3798 if (!answer) {
3799 askPath(dir);
3800 return hint(token, "looking…");
3801 }
3802 if (answer.kind === "invalid") return hint(token, "not a path");
3803 if (answer.kind === "denied") return hint(token, "cannot read that directory");
3804 if (answer.kind === "file") return hint(token, "not a directory");
3805
3806 const dirs = answer.dirs ?? [];
3807 const files = answer.files ?? [];
3808 const base = (answer.path || "").replace(/\/+$/, "");
3809 const target = prefix ? `${base}/${prefix}` : base;
3810
3811 // What is at the whole path, worked out from the one directory we asked
3812 // about. `creates` counts what `mkdir -p` would have to make, and the daemon
3813 // has already counted the part above this level.
3814 let state = /** @type {TbPathFrame["kind"]} */ (answer.kind);
3815 let creates = answer.creates ?? 0;
3816 if (prefix) {
3817 if (answer.kind !== "dir") {
3818 state = "missing";
3819 creates += 1;
3820 } else if (dirs.includes(prefix)) {
3821 state = "dir";
3822 creates = 0;
3823 } else if (files.includes(prefix)) {
3824 state = "file";
3825 } else {
3826 state = "missing";
3827 creates = 1;
3828 }
3829 }
3830
3831 /** @type {TbOmniItem[]} */
3832 const rows = [];
3833 const shown = tildePath(target);
3834 const loudest = agentBySession(lastAgents);
3835
3836 if (state === "file") {
3837 rows.push(...hint(shown, "not a directory"));
3838 } else if (state === "dir") {
3839 // The directory is already somebody's: go there rather than opening a
3840 // second session on the same tree, which is the mistake this row exists to
3841 // prevent. tmux reports a session's *current pane's* directory, so this is
3842 // "a session sitting in that project", which is the question being asked.
3843 const onIt = lastSessions.find((x) => x.path === target);
3844 if (onIt) {
3845 const here = onIt.name === sessionName;
3846 if (prompt) {
3847 rows.push({
3848 kind: "claude",
3849 label: prompt,
3850 meta: `send to claude · ${shortPath(target)}`,
3851 score: 0,
3852 typed: true,
3853 run: () => tmuxCommand({ cmd: "claude", session: onIt.name, prompt }),
3854 });
3855 }
3856 rows.push({
3857 kind: "session",
3858 label: onIt.name,
3859 meta: here ? `session · ${shown} · here` : `session · ${shown}`,
3860 state: loudest[onIt.name]?.state,
3861 score: 0,
3862 typed: true,
3863 // Switching to the session you are on does nothing, so it is said
3864 // rather than offered — the row is still worth drawing, because "you
3865 // already have this open" is the answer to what was typed.
3866 hint: here,
3867 run: here ? undefined : () => tmuxCommand({ cmd: "switch", session: onIt.name }),
3868 });
3869 } else {
3870 rows.push(...projectRow({ token, dir, target, prompt, creates: 0, shown }));
3871 }
3872 } else {
3873 rows.push(...projectRow({ token, dir, target, prompt, creates, shown }));
3874 }
3875
3876 // Everything inside the directory that starts with what has been typed of the
3877 // next name. Below the row that acts, because the thing you typed in full is
3878 // a better answer than something it is a prefix of.
3879 if (answer.kind === "dir") {
3880 const q = prefix.toLowerCase();
3881 // Hidden directories only when you have said so with a leading dot: `~/`
3882 // otherwise offers a home directory's worth of dotfiles ahead of anything
3883 // you keep work in.
3884 const shownDirs = prefix.startsWith(".") ? dirs : dirs.filter((n) => !n.startsWith("."));
3885 const matches = shownDirs
3886 .map((name) => ({ name, score: omniScore(name, q) }))
3887 .filter((m) => m.score >= 0 && m.name !== prefix)
3888 .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
3889 .slice(0, PATH_COMPLETIONS);
3890 for (const { name, score } of matches) {
3891 const next = `${dir === "/" ? "" : dir}/${name}`;
3892 rows.push({
3893 kind: "path",
3894 label: name,
3895 meta: dir,
3896 score: 1 + score,
3897 typed: true,
3898 complete: true,
3899 run: () => completePath(next, prompt),
3900 });
3901 }
3902 }
3903
3904 return rows;
3905}
3906
3907/**
3908 * The row that makes the thing: a session in that directory, and the directory
3909 * itself when it is not there yet.
3910 *
3911 * A list rather than an item so a path with no usable session name in it —
3912 * `~/...` of nothing but punctuation — can answer with a hint instead.
3913 *
3914 * @param {{token: string, dir: string, target: string, prompt: string,
3915 * creates: number, shown: string}} spec
3916 * @returns {TbOmniItem[]}
3917 */
3918function projectRow({ token, dir, target, prompt, creates, shown }) {
3919 const name = freeSessionName(projectSlug(target));
3920 if (!name) {
3921 return [
3922 {
3923 kind: "project",
3924 label: shown,
3925 meta: "no session name in that path",
3926 score: 0,
3927 typed: true,
3928 hint: true,
3929 },
3930 ];
3931 }
3932 // What is about to happen, in the order it happens in. The count is there
3933 // because "make one directory" and "make four" are different answers to what
3934 // is usually a typo in the middle of a path.
3935 const made = creates === 0 ? "" : creates === 1 ? "mkdir · " : `creates ${creates} dirs · `;
3936 return [
3937 {
3938 kind: "project",
3939 // The one thing that distinguishes it from the row below it is what it is
3940 // about to start, so the mark comes with the item — see `omniActions`.
3941 mark: prompt ? "✻" : "+",
3942 label: shown,
3943 meta: `${made}new session ${name}${prompt ? " · claude" : ""}`,
3944 score: 0,
3945 typed: true,
3946 run: () => {
3947 // The listing we hold for the directory above this one is about to be
3948 // one name out of date.
3949 if (creates > 0) forgetPath(dir);
3950 tmuxCommand({
3951 cmd: "new-project",
3952 // The path as typed: `~` is the daemon's home, not the browser's, and
3953 // one expansion of it is the only way the row and the mkdir agree.
3954 path: token,
3955 name,
3956 ...(prompt ? { prompt } : {}),
3957 });
3958 },
3959 },
3960 ];
3961}
3962
3963/**
3964 * Take a completion: put the directory in the box with a trailing slash, which
3965 * both says "there is more to come" and is what asks about the next level.
3966 *
3967 * The prompt rides along, so completing a path halfway through a sentence does
3968 * not cost the sentence.
3969 *
3970 * @param {string} next
3971 * @param {string} prompt
3972 */
3973function completePath(next, prompt) {
3974 const input = $area("omni");
3975 input.value = prompt ? `${next}/ ${prompt}` : `${next}/`;
3976 omniDirty = true;
3977 input.focus();
3978 refreshOmni();
3979}
3980
3981/**
3982 * A path as a person refers to it. The row's dimmed half is a few characters
3983 * wide, so this is the tail of it — the last two components, which is the part
3984 * that says which project. The whole path is in the session info panel, which
3985 * is where one is worth reading in full.
3986 *
3987 * The panel never sees `$HOME`, so home is recognised by shape: `/home/x` and
3988 * `/Users/x`. Getting that wrong costs a `…` where a `~` would have read
3989 * better, and nothing else.
3990 *
3991 * @param {string} p
3992 */
3993function shortPath(p) {
3994 const parts = p.split("/").filter(Boolean);
3995 const home = parts.length >= 2 && (parts[0] === "home" || parts[0] === "Users");
3996 if (home && parts.length === 2) return "~";
3997 const rest = home ? parts.slice(2) : parts;
3998 const tail = rest.slice(-2).join("/");
3999 if (home) return rest.length <= 2 ? `~/${tail}` : `~/…/${tail}`;
4000 return rest.length <= 2 ? p : `…/${tail}`;
4001}
4002
4003/**
4004 * The whole path, with home written the way a shell writes it.
4005 *
4006 * Unlike {@link shortPath} nothing is dropped: this goes where the box's own
4007 * overflow decides what fits, and eliding in advance means a `…` in a gap wide
4008 * enough for the characters it replaced.
4009 *
4010 * @param {string} p
4011 */
4012function tildePath(p) {
4013 const parts = p.split("/").filter(Boolean);
4014 const home = parts.length >= 2 && (parts[0] === "home" || parts[0] === "Users");
4015 if (!home) return p;
4016 return parts.length === 2 ? "~" : `~/${parts.slice(2).join("/")}`;
4017}
4018
4019/**
4020 * True once the box holds a query rather than the location it was showing.
4021 *
4022 * An address bar's text is its value, not a label beside it: the session name
4023 * *is* what is in the box, focusing selects the whole of it, and typing
4024 * replaces it — so getting somewhere else is one shortcut and a few letters,
4025 * with no clearing step in between. The flag is what keeps the two states
4026 * apart, because "work" sitting in the box means "you are in work" until you
4027 * touch it and "find me something called work" afterwards.
4028 */
4029let omniDirty = false;
4030
4031/**
4032 * Put the location back in the box: the session this panel's client is on.
4033 *
4034 * Called on every status frame, so it has two things it must not walk over —
4035 * a query being typed, and the selection that focusing just made.
4036 */
4037/**
4038 * Size the box to its text: one line when there is one, taller as it wraps,
4039 * and no further than the cap in `sidebar.css` — past that it scrolls.
4040 *
4041 * A textarea has no intrinsic height, so this is the whole of "it grows". The
4042 * reset to `auto` first is what lets it shrink again: `scrollHeight` of a box
4043 * already taller than its content is that taller height, so measuring without
4044 * it makes the box a ratchet.
4045 *
4046 * The header is a flex column above a `flex: 1` terminal, so a taller pill
4047 * takes its pixels from the terminal, and the ResizeObserver on `#term` refits
4048 * tmux to the rows that are left. Nothing here has to say so.
4049 */
4050function growOmni() {
4051 const input = $area("omni");
4052 input.style.height = "auto";
4053 input.style.height = `${input.scrollHeight}px`;
4054}
4055
4056// Rewrapping is the panel's width changing, and that is the one thing that can
4057// change the line count without anyone touching the box.
4058window.addEventListener("resize", growOmni);
4059
4060function syncOmniHere() {
4061 const input = $area("omni");
4062 const here = connected && tmuxMode && sessionName ? sessionName : "";
4063
4064 // Shown only when there is a session for it to describe: an info button over
4065 // an empty box has nothing to open.
4066 $("omni-here").hidden = !here;
4067
4068 // The directory follows the same rule as the name: it describes where you
4069 // are, so it goes as soon as the box stops being a location and starts being
4070 // a query. The full path stays one click away in the info panel.
4071 const cwd = $("omni-cwd");
4072 const path = here ? lastSessions.find((s) => s.name === sessionName)?.path : null;
4073 const showCwd = !!path && !omniDirty && document.activeElement !== input;
4074 cwd.hidden = !showCwd;
4075 if (showCwd && path) {
4076 cwd.textContent = "";
4077 cwd.appendChild(el("span", { text: tildePath(path) }));
4078 cwd.title = path;
4079 }
4080
4081 if (omniDirty || document.activeElement === input) {
4082 growOmni();
4083 takePendingOmniFocus();
4084 return;
4085 }
4086 input.value = here;
4087 growOmni();
4088 input.title = here
4089 ? `${here} — type to jump to a window, session or Claude pane, ` +
4090 `name a session to create it, or ask a new Claude in this directory`
4091 : "Jump to a window, session or Claude pane";
4092 takePendingOmniFocus();
4093}
4094
4095// --- session info -----------------------------------------------------------
4096//
4097// What is behind the dot, and the same bargain a browser's padlock makes: the
4098// box has room for a name and nothing else, so the identity behind that name
4099// gets a panel of its own one click away. There it is the origin, its
4100// certificate and what the page is allowed to do; here it is the session — the
4101// working directory above all, which is the one thing about a session its name
4102// never tells you and the first thing you want to know before typing into it.
4103//
4104// Everything in it is a tmux string, so all of it reaches the DOM through
4105// textContent.
4106
4107/** Set while the panel is up, so a status frame redraws it in place. */
4108let infoOpen = false;
4109/**
4110 * Set when the outside-press guard closed the panel because the press landed on
4111 * the dot itself. The click that follows is the rest of that same press, so it
4112 * has to leave the panel closed rather than treat it as a fresh open.
4113 */
4114let infoToggledOff = false;
4115/** Reset whenever the panel opens: the copy button's label is a one-shot. */
4116let infoCopied = false;
4117
4118/**
4119 * Rough and one unit deep, which is all an age is read for here: whether this
4120 * session is from this morning or from last week.
4121 *
4122 * @param {number} created Unix seconds
4123 */
4124function sessionAge(created) {
4125 const secs = Math.max(0, Math.floor(Date.now() / 1000 - created));
4126 const units = /** @type {const} */ ([
4127 [86400, "d"],
4128 [3600, "h"],
4129 [60, "m"],
4130 ]);
4131 for (const [size, suffix] of units) {
4132 if (secs >= size) return `${Math.floor(secs / size)}${suffix} ago`;
4133 }
4134 return "just now";
4135}
4136
4137/**
4138 * One label-and-value line.
4139 *
4140 * @param {string} key
4141 * @param {string} value
4142 * @param {boolean} [path] a filesystem path, which is elided from the front
4143 */
4144function infoRow(key, value, path) {
4145 return el(
4146 "div",
4147 { class: "info-row" },
4148 el("span", { class: "k", text: key }),
4149 path
4150 ? // The `rtl` that puts the ellipsis on the left would also reorder the
4151 // path's own punctuation, so the text itself is wrapped back to `ltr`.
4152 el("span", { class: "v path", title: value }, el("span", { text: value }))
4153 : el("span", { class: "v", title: value, text: value }),
4154 );
4155}
4156
4157/**
4158 * Draw the panel's contents from the current frame. Called again on every
4159 * status frame while it is open, so a Claude that starts working, a window
4160 * that opens or a second client attaching all show up without reopening it.
4161 *
4162 * @param {HTMLElement} pop
4163 * @param {TbSessionInfo} s
4164 */
4165function fillSessionInfo(pop, s) {
4166 pop.textContent = "";
4167
4168 const colour = groupColor(s.name);
4169 pop.appendChild(
4170 el(
4171 "div",
4172 { class: "info-head" },
4173 el("span", {
4174 class: `dot${colour === GROUP_GREY ? " grey" : ""}`,
4175 css: { "--group-h": String(colour) },
4176 }),
4177 el("span", { class: "name", text: s.name }),
4178 // The id, because a rename changes the name and not this — and because it
4179 // is what a `tmux` command typed by hand wants.
4180 el("span", { class: "sub", text: s.id }),
4181 ),
4182 );
4183
4184 const panes = s.windows.reduce((n, w) => n + w.panes, 0);
4185 const here = s.windows.find((w) => w.active);
4186 pop.appendChild(infoRow("Directory", s.path || "unknown", true));
4187 if (here) pop.appendChild(infoRow("Window", `${here.index}: ${here.name}`));
4188 pop.appendChild(
4189 infoRow(
4190 "Contents",
4191 `${s.windows.length} window${s.windows.length === 1 ? "" : "s"} · ` +
4192 `${panes} pane${panes === 1 ? "" : "s"}`,
4193 ),
4194 );
4195 // Worth saying plainly: a second client on the same session is why what you
4196 // type here appears somewhere else too.
4197 const clients = s.clients ?? (s.attached ? 1 : 0);
4198 pop.appendChild(
4199 infoRow(
4200 "Attached",
4201 clients <= 1 ? "this panel only" : `${clients} clients — this panel and ${clients - 1} more`,
4202 ),
4203 );
4204 if (s.created) pop.appendChild(infoRow("Started", sessionAge(s.created)));
4205
4206 const mine = lastAgents.filter((a) => a.session === s.name);
4207 pop.appendChild(
4208 el(
4209 "div",
4210 { class: "info-agents" },
4211 mine.length === 0 && el("div", { class: "info-empty", text: "No Claude running here" }),
4212 ...mine.map((a) =>
4213 el(
4214 "div",
4215 { class: "info-agent" },
4216 glyphSpan(a.state),
4217 el("span", {
4218 class: "what",
4219 text: a.title || agentLabel(a),
4220 title: `claude ${a.state} — ${agentLabel(a)}`,
4221 }),
4222 el("span", { class: "where", text: a.window }),
4223 ),
4224 ),
4225 ),
4226 );
4227
4228 // The one thing here that is wanted somewhere else: a path is typed into
4229 // another shell, a file manager or an editor far more often than it is read.
4230 if (s.path) {
4231 const copy = button({
4232 class: "info-copy",
4233 text: infoCopied ? "Copied" : "Copy path",
4234 on: {
4235 click: () =>
4236 navigator.clipboard.writeText(s.path ?? "").then(
4237 () => {
4238 infoCopied = true;
4239 copy.textContent = "Copied";
4240 },
4241 () => {
4242 copy.textContent = "Couldn't copy";
4243 },
4244 ),
4245 },
4246 });
4247 pop.appendChild(copy);
4248 }
4249}
4250
4251/** Redraw an open panel from the frame that just arrived. */
4252function refreshSessionInfo() {
4253 if (!infoOpen || !openMenu) return;
4254 const s = lastSessions.find((x) => x.name === sessionName);
4255 // The session went away — closing the panel is the honest answer, and it is
4256 // what the omnibar above it is about to do with the name too.
4257 if (!s) return closeTabMenu();
4258 fillSessionInfo(openMenu, s);
4259}
4260
4261/**
4262 * Open it under the dot, the way a browser drops its site panel out of the
4263 * padlock. Shares the tab menu's machinery — one thing open at a time, Escape
4264 * and a click anywhere else close it.
4265 */
4266function openSessionInfo() {
4267 const wasOpen = infoOpen || infoToggledOff;
4268 infoToggledOff = false;
4269 closeTabMenu();
4270 // The dot is a toggle: clicking it again is how you put the panel away
4271 // without having to find somewhere neutral to click.
4272 if (wasOpen) return;
4273 const s = lastSessions.find((x) => x.name === sessionName);
4274 if (!s) return;
4275
4276 infoCopied = false;
4277 const pop = el("div", {
4278 class: "info-pop",
4279 attrs: { role: "dialog", "aria-label": `Session ${s.name}` },
4280 });
4281 fillSessionInfo(pop, s);
4282
4283 document.body.appendChild(pop);
4284 const anchor = $("omni-here").getBoundingClientRect();
4285 const r = pop.getBoundingClientRect();
4286 // Hung off the dot's left edge, and folded back inside when the panel is
4287 // narrower than the bubble wants to be.
4288 pop.style.left = `${Math.max(4, Math.min(anchor.left - 4, window.innerWidth - r.width - 4))}px`;
4289 pop.style.top = `${Math.min(anchor.bottom + 4, Math.max(0, window.innerHeight - r.height - 4))}px`;
4290 openMenu = pop;
4291 infoOpen = true;
4292 $("omni-here").setAttribute("aria-expanded", "true");
4293 setTimeout(() => {
4294 window.addEventListener("pointerdown", onDismiss, { once: true, capture: true });
4295 }, 0);
4296}
4297
4298// mousedown rather than click for the guard: the pill hands focus to the input
4299// on a press anywhere inside it, and the panel opening under a focused omnibar
4300// would sit over the list that focus drops down.
4301$("omni-here").addEventListener("mousedown", (e) => e.preventDefault());
4302$("omni-here").addEventListener("click", openSessionInfo);
4303
4304/** Drop whatever was typed and show the location again. */
4305function revertOmni() {
4306 omniDirty = false;
4307 const input = $area("omni");
4308 input.value = connected && tmuxMode && sessionName ? sessionName : "";
4309 input.select();
4310 refreshOmni();
4311}
4312
4313/** Rebuild the dropdown from whatever is in the box. */
4314function refreshOmni() {
4315 const input = $area("omni");
4316 const list = $("omni-list");
4317 syncOmniHere();
4318 if (tabMode !== "groups" || !connected || !tmuxMode) return closeOmni();
4319
4320 // The id of the row that was chosen, so a status frame arriving mid-type
4321 // does not move the selection out from under the next Enter.
4322 const chosen = omniItems[omniActive];
4323 // Untouched, the box is showing where you are, not asking for it: the list
4324 // that goes with that is the other places, the same way a browser drops down
4325 // suggestions rather than searching for the URL already in the bar.
4326 const query = omniDirty ? input.value : "";
4327 omniItems = omniSuggestions(query);
4328 omniActive = chosen
4329 ? omniItems.findIndex((i) => i.kind === chosen.kind && i.label === chosen.label)
4330 : -1;
4331
4332 list.textContent = "";
4333 if (!omniItems.length) return closeOmni();
4334
4335 const q = query.trim().toLowerCase();
4336 omniItems.forEach((item, i) => list.appendChild(omniRow(item, i, q)));
4337 list.hidden = false;
4338 // Anchored to the row it drops out of rather than to the panel, so it lines
4339 // up with the box whatever the density is doing to the header's height.
4340 const r = $("omni-strip").getBoundingClientRect();
4341 list.style.top = `${r.bottom}px`;
4342 syncOmniActive();
4343}
4344
4345/**
4346 * @param {TbOmniItem} item
4347 * @param {number} i
4348 * @param {string} q the matched substring, for the highlight
4349 */
4350function omniRow(item, i, q) {
4351 // Split around the match so the part you typed can be picked out. Three
4352 // textContent assignments, never markup — these are tmux's names.
4353 const at = q ? item.label.toLowerCase().indexOf(q) : -1;
4354 // The rows whose label is the query itself have nothing to highlight: every
4355 // character of them was typed.
4356 const label =
4357 at >= 0 && !item.typed
4358 ? el(
4359 "span",
4360 { class: "label" },
4361 el("span", { text: item.label.slice(0, at) }),
4362 el("b", { text: item.label.slice(at, at + q.length) }),
4363 el("span", { text: item.label.slice(at + q.length) }),
4364 )
4365 : el("span", { class: "label", text: item.label });
4366
4367 return el(
4368 "div",
4369 {
4370 class: `omni-row ${item.kind}${item.hint ? " hint" : ""}`,
4371 attrs: { id: `omni-row-${i}` },
4372 data: { index: String(i) },
4373 on: {
4374 // mousedown rather than click for the guard: the input would otherwise
4375 // blur before the click landed, and blur closes the list out from
4376 // under it.
4377 /** @param {MouseEvent} e */
4378 mousedown: (e) => e.preventDefault(),
4379 click: () => runOmni(i),
4380 mousemove: () => {
4381 if (omniActive === i) return;
4382 omniActive = i;
4383 syncOmniActive();
4384 },
4385 },
4386 },
4387 glyphSpan(item.state),
4388 // The kinds whose rows are all alike get their character from CSS. An action
4389 // row does not: what it is about to make is the whole of what distinguishes
4390 // it from the action below it, so the mark comes with the item.
4391 item.mark && el("span", { class: "mark", text: item.mark, attrs: { "aria-hidden": "true" } }),
4392 label,
4393 el("span", { class: "meta", text: item.meta }),
4394 );
4395}
4396
4397/** Paint the chosen row. */
4398function syncOmniActive() {
4399 const rows = [...$("omni-list").children];
4400 rows.forEach((row, i) => row.classList.toggle("active", i === omniActive));
4401 const active = rows[omniActive];
4402 if (active) active.scrollIntoView({ block: "nearest" });
4403}
4404
4405function closeOmni() {
4406 const list = $("omni-list");
4407 list.hidden = true;
4408 list.textContent = "";
4409 omniItems = [];
4410 omniActive = -1;
4411}
4412
4413/**
4414 * Run a row and get out of the way. The box goes back to being the location:
4415 * the command has been sent, and the status frame that answers it will put the
4416 * new session's name here a moment later — this just stops the query it was
4417 * holding from looking like where you are in the meantime.
4418 *
4419 * @param {number} i
4420 */
4421function runOmni(i) {
4422 const item = omniItems[i];
4423 if (!item) return;
4424 // A hint has nothing to run, and closing the list on Enter would take the
4425 // thing it is explaining off the screen. It stays put and the box keeps focus.
4426 if (!item.run) return;
4427 // A completion is not a destination: it puts a longer path in the box and
4428 // leaves you typing, so nothing here closes or hands focus back.
4429 if (item.complete) {
4430 item.run();
4431 return;
4432 }
4433 item.run();
4434 omniDirty = false;
4435 closeOmni();
4436 term.focus();
4437 syncOmniHere();
4438}
4439
4440/** @param {number} delta */
4441function moveOmni(delta) {
4442 if (!omniItems.length) return;
4443 // Wraps, and starts at the top going down / the bottom going up: with
4444 // nothing chosen there is no "next" that isn't the first one.
4445 const n = omniItems.length;
4446 omniActive = omniActive < 0 ? (delta > 0 ? 0 : n - 1) : (omniActive + delta + n) % n;
4447 syncOmniActive();
4448}
4449
4450/**
4451 * When the shortcut is what opened the panel, it arrives ahead of everything
4452 * the box is made of: the mode comes from storage, the name from the server,
4453 * and neither is here yet. Held as a time rather than a flag so a request that
4454 * never becomes answerable expires instead of ambushing a later frame — a mode
4455 * switch minutes on is not this shortcut still landing.
4456 */
4457let omniFocusAsked = 0;
4458const OMNI_FOCUS_WAIT_MS = 15_000;
4459
4460/** The first frame with a box to focus honours a request that came too early. */
4461function takePendingOmniFocus() {
4462 if (!omniFocusAsked) return;
4463 if (Date.now() - omniFocusAsked > OMNI_FOCUS_WAIT_MS) {
4464 omniFocusAsked = 0;
4465 return;
4466 }
4467 if (tabMode !== "groups" || !connected || !tmuxMode) return;
4468 omniFocusAsked = 0;
4469 focusOmni();
4470}
4471
4472/**
4473 * Put the caret in the box, from wherever focus was.
4474 *
4475 * Whether the keyboard follows is not this document's to decide. Chrome hands
4476 * the panel focus when it opens it and at no other time — there is no API to
4477 * focus a panel that is already up — so the caret and the selection made here
4478 * are real either way, but they only *look* like a selection when the panel is
4479 * the focused surface. The worker leans on that: a shortcut pressed while the
4480 * panel is closed becomes an open, which is the path that focuses.
4481 */
4482function focusOmni() {
4483 // Not ready to hold a caret yet. Remember the ask; the next frame that has a
4484 // box takes it.
4485 if (tabMode !== "groups" || !connected || !tmuxMode) {
4486 omniFocusAsked = Date.now();
4487 return;
4488 }
4489 omniFocusAsked = 0;
4490 const input = $area("omni");
4491
4492 // A panel coming up for the first time gets its focus somewhere in the next
4493 // few hundred milliseconds, and a selection made before that arrives is
4494 // collapsed back to a caret when it does. So this takes the caret and the
4495 // selection back across that window. Only two things end it early, and both
4496 // mean the box is already being used: text typed into it, or a click placing
4497 // the caret by hand.
4498 let live = true;
4499 const stop = () => {
4500 live = false;
4501 input.removeEventListener("input", stop);
4502 input.removeEventListener("mousedown", stop);
4503 window.removeEventListener("focus", reselect);
4504 };
4505 const reselect = () => {
4506 if (!live) return;
4507 input.focus();
4508 input.select();
4509 // The list belongs to the same gesture as the caret, and the same startup
4510 // churn that drops the selection can close it. Put it back too, but only
4511 // when it is gone: rebuilding an open list would move the chosen row out
4512 // from under an arrow key.
4513 if ($("omni-list").hidden) {
4514 omniDirty = false;
4515 refreshOmni();
4516 }
4517 };
4518 input.addEventListener("input", stop);
4519 input.addEventListener("mousedown", stop);
4520 window.addEventListener("focus", reselect);
4521
4522 reselect();
4523 for (const ms of [0, 16, 50, 120, 250, 400, 600]) setTimeout(reselect, ms);
4524 setTimeout(stop, 800);
4525
4526 // The list drops down on focus, and that is the focus event's doing — which
4527 // does not fire when the box already held the caret, and cannot be counted
4528 // on when the panel is still coming up around it. Asking for it here makes
4529 // the shortcut mean the same thing however it arrived: the box, its name
4530 // selected, and everywhere else already listed under it.
4531 omniDirty = false;
4532 refreshOmni();
4533}
4534
4535$area("omni").addEventListener("input", () => {
4536 const input = $area("omni");
4537 // The box wraps, but it still holds one line: Enter runs a row rather than
4538 // breaking the line, so the only way a newline gets in is a paste — and a
4539 // command with a hard newline in the middle of it is not what was pasted,
4540 // it is what the clipboard happened to be carrying. Each becomes a space,
4541 // and the caret keeps its place because the length does not change.
4542 if (input.value.includes("\n")) {
4543 const at = input.selectionStart;
4544 input.value = input.value.replace(/[\r\n]/g, " ");
4545 input.setSelectionRange(at, at);
4546 }
4547 // The location has been typed over, so it is a query from here on.
4548 omniDirty = true;
4549 refreshOmni();
4550});
4551
4552// Focus selects the whole name, so the first letter typed replaces it — the one
4553// behaviour that makes "the box holds where you are" and "the box is how you go
4554// somewhere else" the same box. Opening the list here rather than on the first
4555// keystroke: with nothing typed it is already the list of everywhere else.
4556$area("omni").addEventListener("focus", () => {
4557 omniDirty = false;
4558 $area("omni").select();
4559 refreshOmni();
4560});
4561
4562// Late enough for a row's own click to have run first. Leaving focus abandons
4563// whatever was typed, exactly as a browser's does — the box goes back to
4564// saying where you are.
4565$area("omni").addEventListener("blur", () =>
4566 setTimeout(() => {
4567 // A blur that leaves the caret where it was is the panel gaining or losing
4568 // the keyboard, not the box being left — and that happens under the box on
4569 // the way up, when the shortcut is what opened this panel. Closing on it
4570 // would take the list away from a box that is still focused.
4571 if (document.activeElement === $area("omni")) return;
4572 closeOmni();
4573 omniDirty = false;
4574 syncOmniHere();
4575 }, 0),
4576);
4577
4578$area("omni").addEventListener("keydown", (e) => {
4579 const ev = /** @type {KeyboardEvent} */ (e);
4580 const key = ev.key;
4581 // Ctrl+J / Ctrl+K move the selection too, but only while the list is up:
4582 // with nothing open they belong to the terminal, and Ctrl+K in particular is
4583 // a line-kill an emacs-keyed shell expects to get.
4584 if (ev.ctrlKey && !ev.altKey && !ev.metaKey && (key === "j" || key === "k") && omniItems.length) {
4585 e.preventDefault();
4586 moveOmni(key === "j" ? 1 : -1);
4587 } else if (key === "ArrowDown" || key === "ArrowUp") {
4588 e.preventDefault();
4589 moveOmni(key === "ArrowDown" ? 1 : -1);
4590 } else if (key === "Tab" && omniItems.some((i) => i.complete)) {
4591 // What Tab has meant in every box that has ever held a path: take the
4592 // completion. The chosen one if a row is chosen, the first otherwise, which
4593 // is the same rule Enter follows.
4594 e.preventDefault();
4595 const active = omniItems[omniActive];
4596 const item = active?.complete ? active : omniItems.find((i) => i.complete);
4597 item?.run?.();
4598 } else if (key === "Enter") {
4599 e.preventDefault();
4600 // Enter with nothing chosen takes the top row, which is what the list is
4601 // sorted for — you type three letters and press Enter without looking.
4602 runOmni(omniActive < 0 ? 0 : omniActive);
4603 } else if (key === "Escape") {
4604 e.preventDefault();
4605 // First Escape puts the location back, the second gives the terminal back
4606 // — the same two steps Escape takes in a browser's address bar.
4607 if (omniDirty) revertOmni();
4608 else {
4609 closeOmni();
4610 term.focus();
4611 }
4612 }
4613});
4614
4615// --- session tabs -----------------------------------------------------------
4616//
4617// The top row: every session on the server. Selecting one is a switch-client —
4618// the client this panel holds moves, so the pty underneath is never re-spawned
4619// and nothing running is disturbed.
4620//
4621// Session names come from tmux (a user or a shell script named them, and both
4622// can put anything in a name) and only ever reach the DOM through textContent.
4623//
4624// Order: the daemon sends them oldest first, so a session you just made is on
4625// the end rather than wherever its name sorts. Dragging a tab overrides that,
4626// and the override is this panel's own — tmux has no notion of session order to
4627// change, unlike windows, which are dragged with a real move-window.
4628/** @type {string[]} session names, in the order this panel shows them */
4629let sessionOrder = [];
4630
4631/**
4632 * Saved order first, in its own sequence; then everything it doesn't mention,
4633 * in the daemon's (creation) order. A session that comes back after a while
4634 * therefore returns to where you last put it, and a brand new one lands last.
4635 *
4636 * @param {TbSessionInfo[]} sessions
4637 * @returns {TbSessionInfo[]}
4638 */
4639function orderSessions(sessions) {
4640 const known = new Map(sessions.map((s) => [s.name, s]));
4641 /** @type {TbSessionInfo[]} */
4642 const out = [];
4643 for (const name of sessionOrder) {
4644 const s = known.get(name);
4645 if (s) {
4646 out.push(s);
4647 known.delete(name);
4648 }
4649 }
4650 return [...out, ...known.values()];
4651}
4652
4653/** @param {string[]} names the row's order, as dragged */
4654function saveSessionOrder(names) {
4655 sessionOrder = names;
4656 storage.set({ sessionOrder });
4657}
4658
4659/**
4660 * @param {TbSessionInfo[]} unordered as the daemon sent them
4661 * @param {string | null | undefined} current the session this panel is on
4662 * @param {TbAgent[]} agents server-wide, for the glyph on each tab
4663 */
4664function renderSessionTabs(unordered, current, agents) {
4665 const sessions = orderSessions(unordered);
4666 const strip = $("sessions");
4667 // A repaint mid-drag would tear the tab out from under the pointer, and the
4668 // frames arrive once a second whether or not anything moved.
4669 if (dragging && !strip.hidden) return;
4670 const show = connected && tmuxMode && sessions.length > 0;
4671 strip.hidden = !show;
4672 $("session-new").hidden = !show;
4673 // Two things cannot both hold the row: a tab carries the session name, so
4674 // the status text only speaks when there is no tab to speak for it.
4675 document.body.classList.toggle("has-session", show);
4676 if (!show) {
4677 strip.textContent = "";
4678 strip.dataset.sig = "";
4679 hideSessionInput();
4680 syncSpinner();
4681 return;
4682 }
4683
4684 const claude = agentBySession(agents);
4685 const sig = JSON.stringify(
4686 sessions.map((s) => {
4687 const a = claude[s.name];
4688 const selected = s.name === current;
4689 // The selected tab draws no glyph, so what its agent is doing cannot
4690 // change what it looks like — and must not be in here, or every state
4691 // change in the session you are *on* rebuilds the whole row and drops
4692 // whatever the pointer was hovering. The tooltip still names it, and a
4693 // tooltip is not worth a repaint.
4694 return [
4695 s.name,
4696 s.windows.length,
4697 s.attached,
4698 selected,
4699 selected ? null : a?.state,
4700 selected ? null : agentLabel(a),
4701 ];
4702 }),
4703 );
4704 if (strip.dataset.sig !== sig) {
4705 strip.dataset.sig = sig;
4706 strip.textContent = "";
4707 for (const s of sessions) {
4708 strip.appendChild(sessionTab(s, s.name === current, claude[s.name]));
4709 }
4710 }
4711
4712 syncSpinner();
4713
4714 const active = strip.querySelector('[aria-selected="true"]');
4715 // A narrow panel scrolls this row too, and the session you are on is the one
4716 // that has to stay in sight.
4717 if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
4718}
4719
4720/**
4721 * @param {TbAgent[]} agents
4722 * @returns {Record<string, TbAgent>} session name → the one worth reporting
4723 */
4724function agentBySession(agents) {
4725 /** @type {Record<string, TbAgent>} */
4726 const out = {};
4727 for (const a of agents) {
4728 const seen = out[a.session];
4729 if (!seen || AGENT_RANK.indexOf(a.state) < AGENT_RANK.indexOf(seen.state)) {
4730 out[a.session] = a;
4731 }
4732 }
4733 return out;
4734}
4735
4736/**
4737 * @param {TbSessionInfo} s
4738 * @param {boolean} selected
4739 * @param {TbAgent} [claude] the agent worth reporting anywhere in this session
4740 */
4741function sessionTab(s, selected, claude) {
4742 const linked = tabPinMark({ session: s.name, window: null });
4743 const tab = button(
4744 {
4745 class:
4746 `session-tab${s.attached && !selected ? " attached" : ""}` +
4747 `${linked ? " tab-linked" : ""}`,
4748 attrs: { role: "tab", "aria-selected": selected },
4749 data: { session: s.name },
4750 // No window count on the tab. The row below it *is* the count for the
4751 // session you are on, and for the others the number was never the thing
4752 // you were choosing by — the name is. It stays in the tooltip.
4753 title: tip(
4754 `session ${s.name}`,
4755 `${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`,
4756 s.attached && !selected && "attached elsewhere",
4757 claude && `claude ${claude.state} — ${agentLabel(claude)}`,
4758 linked && PIN_SOURCE_NOTE[linked],
4759 ),
4760 on: {
4761 click: () => {
4762 // Moves the existing client: no reconnect, no second pty, and whatever
4763 // is running in the session we leave keeps running.
4764 if (!selected) tmuxCommand({ cmd: "switch", session: s.name });
4765 term.focus();
4766 },
4767 // The nested layout has no group chip, so this is the only place a
4768 // session-level pin can be reached from in it.
4769 /** @param {MouseEvent} e */
4770 contextmenu: (e) => {
4771 e.preventDefault();
4772 closeTabMenu();
4773 const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
4774 pinMenuItems(menu, { session: s.name, window: null });
4775 // Nothing to offer for a tab with neither an origin nor an id — a
4776 // browser page, say. An empty menu is worse than none.
4777 if (!menu.childElementCount) return;
4778 document.body.appendChild(menu);
4779 placeMenu(menu, e);
4780 },
4781 },
4782 },
4783 // The glyph goes on a session you are not on and nowhere else. It reports the
4784 // loudest agent *anywhere* in the session, which is worth a light when the
4785 // windows it is summarising are out of sight — and is nothing but a second,
4786 // coarser copy of the window row when they are not. On the selected tab it
4787 // also sits an inch above a spinner saying the same thing about the same
4788 // Claude, animating out of step with it, which is the distracting part.
4789 !selected && glyphSpan(claude?.state),
4790 el("span", { class: "name", text: s.name }),
4791 );
4792 makeDraggable(tab);
4793 return tab;
4794}
4795
4796// --- new session ------------------------------------------------------------
4797//
4798// A window can be created without asking — tmux names it after the directory —
4799// but a session's name is its identity and the only handle you get on it from a
4800// terminal, so this one is worth a prompt. Inline, because a modal would block
4801// this page's message handler while the socket keeps delivering frames.
4802
4803function hideSessionInput() {
4804 const input = $input("session-name");
4805 input.hidden = true;
4806 input.value = "";
4807}
4808
4809/** The field, wherever `applyTabMode` has put it: the session row in the nested
4810 layout, the tab row in groups mode, where "+"'s menu is what opens it. */
4811function showSessionInput() {
4812 const input = $input("session-name");
4813 input.hidden = false;
4814 input.focus();
4815}
4816
4817$("session-new").addEventListener("click", showSessionInput);
4818
4819$input("session-name").addEventListener("keydown", (e) => {
4820 const key = /** @type {KeyboardEvent} */ (e).key;
4821 if (key === "Escape") {
4822 hideSessionInput();
4823 term.focus();
4824 return;
4825 }
4826 if (key !== "Enter") return;
4827 const name = $input("session-name").value.trim();
4828 hideSessionInput();
4829 // The daemon validates the name and ignores anything it doesn't like; `-A`
4830 // there means an existing name attaches rather than failing.
4831 if (name) tmuxCommand({ cmd: "create", session: name });
4832 term.focus();
4833});
4834
4835// Clicking away is a cancel: the input is only ever one keystroke from being
4836// re-opened, and a stray text box in the tab row is worse than a lost name.
4837$input("session-name").addEventListener("blur", hideSessionInput);
4838
4839/* --- the working spinner ---------------------------------------------------
4840 Claude Code's own asterisk cycle, so a tab that is thinking looks like the
4841 transcript that is thinking. The frames grow and shrink rather than spin:
4842 a dot swelling to a full asterisk and back, which reads as activity at
4843 10px where a rotating glyph would just shimmer. */
4844const SPINNER_FRAMES = ["·", "✢", "✳", "∗", "✻", "✽", "✻", "∗", "✳", "✢"];
4845/** What a tab shows when it is not mid-cycle. */
4846/** @type {Record<string, string>} */
4847// `ready` and `idle` are the same glyph on purpose: the shape says "Claude is
4848// at rest here", and only the colour says whether that rest is news to you.
4849const STATIC_GLYPH = { waiting: "✳", ready: "✻", idle: "✻", unknown: "·", none: "" };
4850const SPINNER_MS = 130;
4851
4852const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
4853let spinnerStep = 0;
4854/** @type {number | undefined} */
4855let spinnerTimer;
4856
4857// Reduced motion keeps the glyph — the tab still says "working" — and parks it
4858// on the frame the animation spends the most time looking like.
4859function spinnerGlyph() {
4860 return reducedMotion.matches ? "✻" : SPINNER_FRAMES[spinnerStep % SPINNER_FRAMES.length];
4861}
4862
4863/**
4864 * One timer for the whole strip, running only while something is working, so
4865 * an idle panel is not repainting four times a second forever. Repaints touch
4866 * textContent only: renderTabs owns the elements and skips its rebuild whenever
4867 * the signature is unchanged, so the spinner never fights it.
4868 */
4869function syncSpinner() {
4870 const working = document.querySelectorAll("header .glyph.working");
4871 if (!working.length || reducedMotion.matches) {
4872 clearInterval(spinnerTimer);
4873 spinnerTimer = undefined;
4874 return;
4875 }
4876 if (spinnerTimer !== undefined) return;
4877 spinnerTimer = setInterval(() => {
4878 spinnerStep++;
4879 const frame = spinnerGlyph();
4880 const live = document.querySelectorAll("header .glyph.working");
4881 if (!live.length) return syncSpinner();
4882 for (const el of live) el.textContent = frame;
4883 }, SPINNER_MS);
4884}
4885
4886// Turning the preference on mid-run has to stop the timer and settle the
4887// glyphs where they are, not leave them frozen on whatever frame was up.
4888reducedMotion.addEventListener("change", () => {
4889 const frame = spinnerGlyph();
4890 for (const el of document.querySelectorAll("header .glyph.working")) el.textContent = frame;
4891 syncSpinner();
4892});
4893
4894// A tooltip's worth of room, so show the most specific thing known: what it is
4895// blocked on, what tool it is running, else the mode.
4896//
4897// Every value here comes from Claude Code's hook payloads by way of the daemon
4898// — a user prompt, a tool name, a notification message — and is only ever
4899// assigned through textContent/title, never parsed as markup.
4900/** @param {TbAgent} [a] */
4901function agentLabel(a) {
4902 if (!a) return "";
4903 if (a.state === "waiting") return a.message || "waiting";
4904 if (a.state === "working") return a.tool || shortMode(a.mode) || "working";
4905 if (a.state === "ready") return "finished its turn";
4906 if (a.state === "unknown") return "no hook records — run: termbridge hooks";
4907 return shortMode(a.mode) || "idle";
4908}
4909
4910/* --- action required -------------------------------------------------------
4911 `ready` is an idle Claude in a pane that has not been on screen since it went
4912 idle — the daemon derives it (see `SEEN` in daemon/src/status.rs) and it
4913 arrives as a state like any other. It wears the same glyph as idle in a
4914 colour that is not grey, which is the smallest thing that reads as "come back
4915 to this" without inventing a second vocabulary.
4916
4917 It is the daemon's to know rather than this panel's because tmux is what
4918 knows which pane is in front of you, and because two panels on one server
4919 should not each keep a private opinion about the same window. */
4920
4921// permission_mode arrives camelCased, straight from Claude's hook payload.
4922/** @type {Record<string, string>} */
4923const MODE_SHORT = {
4924 default: "idle",
4925 acceptEdits: "accept edits",
4926 plan: "plan",
4927 bypassPermissions: "bypass",
4928};
4929
4930/** @param {string | null | undefined} mode */
4931function shortMode(mode) {
4932 if (!mode) return "";
4933 return MODE_SHORT[mode] ?? mode;
4934}
4935
4936/** @param {TbOkFrame} msg */
4937function renderSessions(msg) {
4938 if (!msg.tmux) return;
4939 const names = msg.sessions ?? [];
4940 const list = $("session-list");
4941 list.textContent = "";
4942 for (const name of names) list.appendChild(el("option", { attrs: { value: name } }));
4943 $input("session").placeholder = msg.defaultSession ?? defaultSession;
4944 if (names.length) log(`tmux sessions: ${names.join(", ")}`);
4945}
4946
4947$("session-apply").addEventListener("click", () => {
4948 const name = $input("session").value.trim();
4949 storage.set({ session: name });
4950 // Connected, this creates-or-attaches and moves the live client — the field
4951 // is how you reach a session that doesn't exist yet, which the header's
4952 // switcher (existing sessions only) can't do. Disconnected, it's the session
4953 // the next connection opens with.
4954 if (connected && name) {
4955 tmuxCommand({ cmd: "create", session: name });
4956 closeSettings();
4957 term.focus();
4958 return;
4959 }
4960 connect();
4961});
4962
4963// --- element picker ---------------------------------------------------------
4964//
4965// Everything the picker returns is page-controlled data. It is displayed, and
4966// it only reaches the terminal when the user explicitly clicks "insert" — and
4967// then only after Sanitize.forTerminal has stripped control characters and
4968// shell-quoted it.
4969
4970let picked = /** @type {TbPicked | null} */ (null);
4971
4972/** Why the panel is open, kept so switching format doesn't erase it. */
4973let pickedProblem = "";
4974
4975// XPath by default: it always exists, it addresses exactly one node, and it
4976// survives the class-name churn that a CSS selector built from a framework's
4977// generated class names does not.
4978let pickedFormat = /** @type {TbPickedFormat} */ ("xpath");
4979
4980// Ordered by how often they're the one you want.
4981/** @type {[TbPickedFormat, string][]} */
4982const FORMATS = [
4983 ["xpath", "XPath"],
4984 ["css", "CSS"],
4985 ["id", "id"],
4986 ["testid", "test id"],
4987 ["text", "text"],
4988 ["href", "href"],
4989];
4990
4991/**
4992 * The tabs a pick should run in. Normally one; in a split view, both halves,
4993 * because only one of the two visible tabs is ever `active` and the other is
4994 * just as clickable. See lib/split.js.
4995 *
4996 * @returns {Promise<{ tab: TbTab | undefined; targets: TbTab[] }>}
4997 */
4998async function pickTargets() {
4999 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
5000 if (!tab || SKIP_URL.test(tab.url ?? "")) return { tab, targets: [] };
5001 const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP_URL.test(t.url ?? ""));
5002 return { tab, targets };
5003}
5004
5005/**
5006 * `https://example.com/*` — the narrowest pattern that covers this page.
5007 * @param {string | undefined} url
5008 */
5009function originPattern(url) {
5010 if (!url) return null;
5011 try {
5012 return `${new URL(url).origin}/*`;
5013 } catch {
5014 return null;
5015 }
5016}
5017
5018/**
5019 * @param {string} text
5020 * @param {string} [bad] a warning to show alongside it
5021 */
5022function pickNote(text, bad) {
5023 // Loud enough to notice without opening the log: the picker failing silently
5024 // is the whole reason this feature felt broken.
5025 $("picked").hidden = false;
5026 $("picked-value").textContent = text;
5027 $("picked-warn").hidden = !bad;
5028 $("picked-warn").textContent = bad ?? "";
5029 $("picked-grant").hidden = true;
5030 log(text);
5031}
5032
5033/**
5034 * Offer a per-site grant rather than shipping a blanket <all_urls> permission.
5035 *
5036 * localhost is granted up front because it's your own machine; everything else
5037 * is opt-in, one origin at a time, via a prompt the browser shows.
5038 *
5039 * @param {string} pattern
5040 * @param {TbTab[]} targets the tabs the pick would run in
5041 */
5042function offerGrant(pattern, targets) {
5043 $("picked").hidden = false;
5044 $("picked-value").textContent = `No access to ${pattern}`;
5045 $("picked-warn").hidden = false;
5046 $("picked-warn").textContent =
5047 "Grant access to this site, or use Alt+Shift+P which needs no permission.";
5048 const btn = $("picked-grant");
5049 btn.hidden = false;
5050 btn.textContent = `Allow ${pattern}`;
5051 btn.onclick = async () => {
5052 // Must be called from a user gesture, which this click is.
5053 const granted = await api.permissions.request({ origins: [pattern] });
5054 if (granted) {
5055 btn.hidden = true;
5056 log(`granted ${pattern}`);
5057 runPick(targets);
5058 } else {
5059 log(`declined ${pattern}`);
5060 }
5061 };
5062}
5063
5064/**
5065 * The capture the picker wants is the one permission a per-origin grant cannot
5066 * buy. `tabs.captureVisibleTab` accepts exactly two things: the `activeTab`
5067 * grant a keyboard command mints, or a host permission set that contains the
5068 * literal `<all_urls>` pattern. A per-origin grant fails the check, and so does
5069 * the all-scheme-wildcard pattern in optional_host_permissions, which misses
5070 * `file:` and so isn't "all". That is why picking from the panel button used to
5071 * hand back a selector and no image on every site you'd approved.
5072 *
5073 * @param {string} reason the failure copyPickedShot reported
5074 */
5075function isCapturePermissionError(reason) {
5076 return /all_urls|activeTab/i.test(reason);
5077}
5078
5079/**
5080 * Offer the one grant that makes the panel button capture, alongside a pick
5081 * that already succeeded. Screenshots stay opt-in: nothing here is requested
5082 * until the button is pressed, and Alt+Shift+P keeps working without it.
5083 *
5084 * @param {string} reason
5085 */
5086function offerCaptureGrant(reason) {
5087 const btn = /** @type {HTMLButtonElement} */ ($("picked-grant"));
5088 btn.hidden = false;
5089 btn.textContent = "Allow screenshots on all sites";
5090 btn.onclick = async () => {
5091 // Must be called from a user gesture, which this click is.
5092 const granted = await api.permissions.request({ origins: ["<all_urls>"] });
5093 if (!granted) {
5094 log("declined <all_urls>");
5095 return;
5096 }
5097 btn.hidden = true;
5098 log("granted <all_urls>");
5099 // The shot that prompted this is long gone from the viewport's timeline;
5100 // re-picking is the honest way to get one, so say so rather than silently
5101 // leaving the old warning up.
5102 pickedProblem = "Screenshots are on. Pick again to get one.";
5103 refreshPicked();
5104 };
5105 log(`screenshot needs <all_urls>: ${reason}`);
5106}
5107
5108const SKIP_URL = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/;
5109
5110// The tabs a pick is currently running in — more than one in a split view.
5111// Empty means no pick is running, which is also the "is picking" flag.
5112let pickTabs = /** @type {number[]} */ ([]);
5113
5114/**
5115 * Cancel an in-flight pick, in every half it is running in.
5116 */
5117async function cancelPick() {
5118 if (!pickTabs.length) return;
5119 await Split.cancelPicks(api, pickTabs);
5120}
5121
5122/**
5123 * Run a pick across `targets`. Returns true on success, or a message explaining
5124 * why every injection failed.
5125 *
5126 * @param {TbTab[]} targets
5127 * @returns {Promise<true | string>}
5128 */
5129async function runPick(targets) {
5130 const btn = $("pick");
5131 pickTabs = [];
5132 for (const t of targets) if (t.id != null) pickTabs.push(t.id);
5133 btn.classList.add("active");
5134 setStatus("pending", "pick mode — click an element, Esc cancels");
5135 try {
5136 const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement);
5137 if (value) {
5138 const shot = await Shot.copyPickedShot(api, tabId, value);
5139 await deliverPick(value, shot);
5140 } else if (error) {
5141 return error;
5142 } else log("pick cancelled");
5143 return true;
5144 } catch (e) {
5145 return e instanceof Error ? e.message : String(e);
5146 } finally {
5147 pickTabs = [];
5148 btn.classList.remove("active");
5149 refreshStatus();
5150 }
5151}
5152
5153$("pick").addEventListener("click", async () => {
5154 // Second press toggles it back off rather than doing nothing.
5155 if (pickTabs.length) {
5156 await cancelPick();
5157 return;
5158 }
5159
5160 const { tab, targets } = await pickTargets();
5161 if (!targets.length) {
5162 pickNote(
5163 "Can't pick here.",
5164 "Browser-internal and extension pages are off limits to all extensions. Switch to a normal web page.",
5165 );
5166 return;
5167 }
5168
5169 setStatus("pending", "pick mode — click an element, Esc cancels");
5170 const outcome = await runPick(targets);
5171 if (outcome === true) return;
5172
5173 // The failure is nearly always a missing host permission for this origin.
5174 const pattern = originPattern(tab?.url);
5175 if (pattern && /permission|access/i.test(outcome)) {
5176 offerGrant(pattern, targets);
5177 } else {
5178 pickNote("Couldn't reach the page.", `Try Alt+Shift+P instead. [${outcome}]`);
5179 }
5180});
5181
5182// Escape from the sidebar too. The picker handles Escape itself, but only when
5183// the page has keyboard focus — if you started the pick from here, focus is
5184// still in the panel and the key never reaches the page.
5185window.addEventListener("keydown", (e) => {
5186 // These work anywhere in the panel, not just with the terminal focused.
5187 // xterm.js consumes its own keydowns before they reach here, so it has its
5188 // own handler for them too.
5189 if (handlePanelKey(e)) return;
5190 if (e.key !== "Escape") return;
5191 if (openMenu) {
5192 e.preventDefault();
5193 closeTabMenu();
5194 return;
5195 }
5196 // After the menus, before the pick: a popup is the nearest thing open.
5197 if (settingsOpen) {
5198 e.preventDefault();
5199 closeSettings();
5200 term.focus();
5201 return;
5202 }
5203 if (pickTabs.length) {
5204 e.preventDefault();
5205 cancelPick();
5206 return;
5207 }
5208 // A pick the keyboard shortcut started belongs to the worker, and this panel
5209 // has no record of it. Ask; the worker ignores it when nothing is picking.
5210 togglePort?.postMessage({ type: "cancel-pick" });
5211});
5212
5213// Results arriving from the background worker (the keyboard-shortcut path).
5214//
5215// Guarded: content scripts always have `sender.tab` set, so rejecting those
5216// leaves only our own extension pages and worker. This is the one inbound
5217// message path in the sidebar, and it exists solely because the shortcut has to
5218// be handled in the background.
5219api.runtime.onMessage.addListener((msg, sender) => {
5220 if (sender?.id !== api.runtime.id) return;
5221 if (sender?.tab) return;
5222 if (msg?.type === "picked" && msg.value) {
5223 // The worker owns the screenshot on this path: it holds the activeTab
5224 // grant the shortcut just minted, and the clipboard write has to happen
5225 // while the page it captured is still the focused one.
5226 deliverPick(msg.value, msg.shot === true ? true : msg.shot || "not captured");
5227 }
5228});
5229
5230// The shortcut half of the port described in sw.js: while this document is
5231// alive it stays connected and reports whether it holds focus, so the worker
5232// can decide between open, focus and close without asking first.
5233// Only the three commands below arrive here; nothing on this port touches the
5234// WebSocket.
5235/** @type {TbPort | null} */
5236let togglePort = null;
5237
5238function connectToggle() {
5239 api.windows.getCurrent().then((win) => {
5240 // The same answer the tab pins need: which browser window this panel is
5241 // one of. Set here rather than in a second getCurrent() call, because this
5242 // one already runs at startup and the pins are useless without it.
5243 //
5244 // First connection only. Chrome retires an idle service worker and this
5245 // runs again a second later, and re-running the whole follow on each of
5246 // those would keep re-deciding a question the tab has not re-asked.
5247 const first = panelWindowId == null;
5248 panelWindowId = win.id;
5249 if (first) syncTabPin();
5250 const port = api.runtime.connect({ name: "sidebar" });
5251 togglePort = port;
5252 port.postMessage({ type: "hello", windowId: win.id, focused: document.hasFocus() });
5253 port.onMessage.addListener((msg) => {
5254 if (msg?.type === "close") window.close();
5255 else if (msg?.type === "focus") term.focus();
5256 // A browser command rather than a key this page listens for, so the
5257 // binding is the browser's to own: it shows up in chrome://extensions/
5258 // shortcuts with the other two and can be rebound or cleared there. A
5259 // hardcoded keydown here would keep firing on the old key afterwards.
5260 else if (msg?.type === "omnibar") focusOmni();
5261 });
5262 port.onDisconnect.addListener(() => {
5263 // Chrome may retire an idle service worker under us. Nothing here is
5264 // urgent, so reconnect lazily rather than fighting for the port.
5265 if (togglePort === port) togglePort = null;
5266 setTimeout(connectToggle, 1000);
5267 });
5268 });
5269}
5270
5271const reportFocus = () => togglePort?.postMessage({ type: "focus", focused: document.hasFocus() });
5272window.addEventListener("focus", reportFocus);
5273window.addEventListener("blur", reportFocus);
5274connectToggle();
5275
5276// A pick made while the sidebar was closed is parked in storage. Nothing is
5277// typed for these: the terminal has moved on, and whatever screenshot went with
5278// it left the clipboard long ago. Show it and let the user decide.
5279storage.get("pendingPick").then((v) => {
5280 if (v.pendingPick) {
5281 showPicked(v.pendingPick);
5282 storage.remove("pendingPick");
5283 }
5284});
5285
5286function currentPickedRaw() {
5287 if (!picked) return "";
5288 return picked[pickedFormat] ?? "";
5289}
5290
5291/**
5292 * Open the panel on a pick. Only called when the pick needs the user's
5293 * attention — see deliverPick.
5294 *
5295 * @param {TbPicked} value everything in here is page-controlled
5296 * @param {string} [problem] why the panel is opening
5297 */
5298function showPicked(value, problem) {
5299 picked = value;
5300 $("picked-warn").hidden = true;
5301 $("picked-grant").hidden = true;
5302
5303 $("picked-tag").textContent = value.tag ? `<${value.tag}>` : "?";
5304 $("picked-meta").textContent = value.text || value.href || value.pageUrl || "";
5305 $("picked-meta").title = value.pageUrl || "";
5306
5307 // Only offer formats this element actually has — an empty tab is a dead end.
5308 const available = FORMATS.filter(([key]) => value[key]);
5309 if (!available.some(([key]) => key === pickedFormat)) {
5310 pickedFormat = available[0]?.[0] ?? "css";
5311 }
5312
5313 const bar = $("picked-formats");
5314 bar.textContent = "";
5315 for (const [key, label] of available) {
5316 const b = button({
5317 text: label,
5318 attrs: { role: "tab", "aria-selected": key === pickedFormat },
5319 on: {
5320 click: () => {
5321 pickedFormat = key;
5322 for (const other of bar.children) {
5323 other.setAttribute("aria-selected", String(other === b));
5324 }
5325 refreshPicked();
5326 },
5327 },
5328 });
5329 bar.appendChild(b);
5330 }
5331
5332 pickedProblem = problem ?? "";
5333 $("picked").hidden = false;
5334 refreshPicked();
5335}
5336
5337function refreshPicked() {
5338 const raw = currentPickedRaw();
5339 const el = $("picked-value");
5340 el.textContent = raw || "not present on this element";
5341 el.classList.toggle("empty", !raw);
5342
5343 const { removedControl, truncated } = Sanitize.forTerminal(raw);
5344 const notes = [];
5345 if (removedControl) notes.push("control characters removed");
5346 if (truncated) notes.push("truncated");
5347 const warn = $("picked-warn");
5348 const modified = notes.length ? `Modified before use: ${notes.join(", ")}.` : "";
5349 warn.textContent = [pickedProblem, modified].filter(Boolean).join(" ");
5350 warn.hidden = !warn.textContent;
5351
5352 /** @type {HTMLButtonElement} */ ($("picked-insert")).disabled = !raw;
5353}
5354
5355$("picked-close").addEventListener("click", () => {
5356 $("picked").hidden = true;
5357 picked = null;
5358});
5359
5360$("picked-copy").addEventListener("click", async () => {
5361 await navigator.clipboard.writeText(Sanitize.forClipboard(currentPickedRaw()));
5362 log("copied to clipboard");
5363});
5364
5365/**
5366 * Send the pick to the terminal: the screenshot first, then the selector.
5367 *
5368 * The screenshot is already on the system clipboard by the time this runs, so
5369 * "sending" it is a literal ^V. That byte is not a paste as far as this panel
5370 * is concerned — xterm.js never sees it — it goes down the wire, and an agent
5371 * on the other end that handles image paste (Claude Code does) reads the
5372 * clipboard itself and attaches the PNG. In a bare shell ^V is literal-next
5373 * instead, which is why it is only sent when there is an image to fetch.
5374 *
5375 * @param {boolean} withShot
5376 */
5377async function insertPicked(withShot) {
5378 if (!connected || !ws) {
5379 log("not connected");
5380 return;
5381 }
5382 const { text } = Sanitize.forTerminal(currentPickedRaw());
5383 if (withShot) {
5384 ws.send(enc.encode("\x16"));
5385 // Reading the clipboard is a round trip out to a helper process on the
5386 // agent's side. Text sent in the same breath can arrive first and end up
5387 // ahead of the attachment on the line.
5388 await new Promise((r) => setTimeout(r, 250));
5389 ws.send(enc.encode(" "));
5390 }
5391 // No trailing newline, ever. The user presses Enter themselves.
5392 ws.send(enc.encode(text));
5393 term.focus();
5394 log(withShot ? `inserted screenshot + ${text.length} chars` : `inserted ${text.length} chars`);
5395}
5396
5397/**
5398 * What a fresh pick does: type it into the terminal, and stay out of the way.
5399 *
5400 * The panel is deliberately *not* opened on the happy path. It shifts the
5401 * terminal down the moment you pick something, which is a poor trade when the
5402 * result has already been typed where you were looking. It opens only when
5403 * there is something to decide — no connection, or no screenshot — and then it
5404 * carries the reason.
5405 *
5406 * @param {TbPicked} value page-controlled, all of it
5407 * @param {true | string} shot true, or why there is no screenshot
5408 */
5409async function deliverPick(value, shot) {
5410 picked = value;
5411 log(`picked ${value.tag} on ${value.pageUrl}`);
5412 log(shot === true ? "screenshot on clipboard, sending ^V" : `no screenshot: ${shot}`);
5413
5414 const needsGrant = shot !== true && isCapturePermissionError(shot);
5415
5416 if (!connected || !ws) {
5417 showPicked(value, "Not connected — nothing was typed.");
5418 if (needsGrant) offerCaptureGrant(shot);
5419 return;
5420 }
5421 await insertPicked(shot === true);
5422 if (shot !== true) {
5423 showPicked(
5424 value,
5425 needsGrant
5426 ? "Selector only. Screenshots from this button need access to all sites; Alt+Shift+P needs none."
5427 : `Selector only, no screenshot: ${shot}`,
5428 );
5429 if (needsGrant) offerCaptureGrant(shot);
5430 }
5431}
5432
5433$("picked-insert").addEventListener("click", () => insertPicked(false));
5434
5435// Keystrokes out as binary, so nothing is lost to UTF-8 round-tripping.
5436term.onData((data) => {
5437 if (connected && ws) ws.send(enc.encode(data));
5438});
5439term.onBinary((data) => {
5440 if (!connected || !ws) return;
5441 const buf = new Uint8Array(data.length);
5442 for (let i = 0; i < data.length; i++) buf[i] = data.charCodeAt(i) & 255;
5443 ws.send(buf);
5444});
5445
5446new ResizeObserver(() => {
5447 fit.fit();
5448 sendSize();
5449}).observe($("term"));
5450
5451// --- settings / persistence -------------------------------------------------
5452
5453const themeSelect = $select("theme-select");
5454themeSelect.addEventListener("change", () => setTheme(themeSelect.value));
5455const densitySelect = $select("density-select");
5456densitySelect.addEventListener("change", () => setDensity(densitySelect.value));
5457const tabModeSelect = $select("tabmode-select");
5458tabModeSelect.addEventListener("change", () => setTabMode(tabModeSelect.value));
5459const newTabSelect = $select("newtab-select");
5460newTabSelect.addEventListener("change", () => setNewTabAction(newTabSelect.value));
5461
5462// The tab-pin switches. Pins are kept when the feature is turned off — you are
5463// silencing it, not throwing away what you told it — so nothing here touches
5464// `byTab` or `byOrigin`.
5465const followBox = $input("follow-tabs");
5466const detectBox = $input("detect-devport");
5467const leadBox = $input("lead-tabs");
5468
5469function applyTabPinSettings() {
5470 followBox.checked = tabPins.enabled;
5471 detectBox.checked = tabPins.detect;
5472 leadBox.checked = tabPins.reverse;
5473 // Both of these are subordinate clauses of the feature, and a live checkbox
5474 // that cannot do anything is a worse answer than a greyed-out one.
5475 detectBox.disabled = !tabPins.enabled;
5476 leadBox.disabled = !tabPins.enabled;
5477}
5478
5479followBox.addEventListener("change", () => {
5480 saveTabPins({ ...tabPins, enabled: followBox.checked });
5481 applyTabPinSettings();
5482});
5483detectBox.addEventListener("change", () => {
5484 saveTabPins({ ...tabPins, detect: detectBox.checked });
5485 applyTabPinSettings();
5486});
5487leadBox.addEventListener("change", () => {
5488 saveTabPins({ ...tabPins, reverse: leadBox.checked });
5489 applyTabPinSettings();
5490});
5491
5492$("font-smaller").addEventListener("click", () => setFontSize(fontSize - 1));
5493$("font-bigger").addEventListener("click", () => setFontSize(fontSize + 1));
5494$("font-reset").addEventListener("click", () => setFontSize(FONT_DEFAULT));
5495
5496// --- settings, as a popup ---------------------------------------------------
5497//
5498// A card hung off the chevron rather than a drawer at the foot of the panel:
5499// the same shape Chrome gives the popup on the other end of that same chevron.
5500//
5501// Out of flow, which is the substantive part. As a flex item the panel sized
5502// the terminal, so opening or closing it re-fit xterm.js and reflowed the
5503// scrollback — you lost your place to change a font size. Floating over the
5504// terminal, the terminal never moves.
5505//
5506// Not the tab menu's machinery, and not `openMenu`: those close on the first
5507// pointerdown anywhere, which is right for a menu of one-shot actions and wrong
5508// for a panel full of text fields you click into and drag across.
5509
5510/** Set while the popup is up, so the outside-click listener is only ever one. */
5511let settingsOpen = false;
5512
5513/** Put it under the chevron, folded back inside a panel too narrow for it. */
5514function placeSettings() {
5515 const pop = $("settings");
5516 const anchor = $("settings-toggle").getBoundingClientRect();
5517 const r = pop.getBoundingClientRect();
5518 pop.style.left = `${Math.max(4, Math.min(anchor.left, window.innerWidth - r.width - 4))}px`;
5519 pop.style.top = `${Math.min(anchor.bottom + 5, Math.max(4, window.innerHeight - r.height - 4))}px`;
5520}
5521
5522/** @param {PointerEvent | MouseEvent} e */
5523function onSettingsDismiss(e) {
5524 if (!(e.target instanceof Node)) return;
5525 // The chevron closes it through its own handler; swallowing the press here
5526 // would close and reopen it in the same click.
5527 if ($("settings").contains(e.target) || $("settings-toggle").contains(e.target)) return;
5528 closeSettings();
5529}
5530
5531function openSettings() {
5532 if (settingsOpen) return placeSettings();
5533 settingsOpen = true;
5534 $("settings").hidden = false;
5535 $("settings-toggle").setAttribute("aria-expanded", "true");
5536 placeSettings();
5537 // A resize here is the sidebar being dragged wider or the window changing —
5538 // either moves the chevron, and the card has to go with it.
5539 window.addEventListener("resize", placeSettings);
5540 // Deferred by a tick so the click that opened it does not also dismiss it.
5541 setTimeout(() => {
5542 if (settingsOpen) window.addEventListener("pointerdown", onSettingsDismiss, true);
5543 }, 0);
5544}
5545
5546function closeSettings() {
5547 if (!settingsOpen) return;
5548 settingsOpen = false;
5549 $("settings").hidden = true;
5550 $("settings-toggle").setAttribute("aria-expanded", "false");
5551 window.removeEventListener("resize", placeSettings);
5552 window.removeEventListener("pointerdown", onSettingsDismiss, true);
5553}
5554
5555$("settings-toggle").addEventListener("click", () => {
5556 if (settingsOpen) closeSettings();
5557 else openSettings();
5558});
5559$("settings-close").addEventListener("click", () => {
5560 closeSettings();
5561 term.focus();
5562});
5563$("reconnect").addEventListener("click", connect);
5564$("connect").addEventListener("click", connect);
5565$("offline-retry").addEventListener("click", connect);
5566$("offline-settings").addEventListener("click", openSettings);
5567$("trust-cert").addEventListener("click", () => {
5568 const u = new URL($input("url").value.trim());
5569 api.tabs.create({ url: `https://${u.host}/` });
5570});
5571$("copy-origin").addEventListener("click", () =>
5572 navigator.clipboard.writeText(`termbridge pair ${ORIGIN}`),
5573);
5574const tokenInput = $input("token");
5575const urlInput = $input("url");
5576const revealBox = $input("reveal");
5577revealBox.addEventListener("change", () => {
5578 tokenInput.type = revealBox.checked ? "text" : "password";
5579});
5580tokenInput.addEventListener("change", () =>
5581 storage.set({ token: tokenInput.value.trim() }),
5582);
5583urlInput.addEventListener("change", () => storage.set({ url: urlInput.value.trim() }));
5584
5585/** Everything this panel remembers between openings. */
5586const STORED_KEYS = [
5587 "token",
5588 "url",
5589 "session",
5590 "theme",
5591 "density",
5592 "fontSize",
5593 "pins",
5594 "sessionOrder",
5595 "tabMode",
5596 "newTabAction",
5597 "foldedGroups",
5598 Tabpin.KEY,
5599];
5600
5601storage.get(STORED_KEYS).then((v) => {
5602 if (v.pins && typeof v.pins === "object") pins = v.pins;
5603 tabPins = Tabpin.loadStore(v[Tabpin.KEY]);
5604 applyTabPinSettings();
5605 if (Array.isArray(v.foldedGroups)) {
5606 foldedGroups = new Set(v.foldedGroups.filter((n) => typeof n === "string"));
5607 }
5608 // Names, from storage this panel wrote — but storage is not a promise, so
5609 // anything that isn't a list of strings is dropped rather than trusted.
5610 if (Array.isArray(v.sessionOrder)) {
5611 sessionOrder = v.sessionOrder.filter((n) => typeof n === "string");
5612 }
5613 themePref = Themes.PREFERENCES.includes(v.theme) ? v.theme : "auto";
5614 density = DENSITIES[v.density] ? v.density : "normal";
5615 tabMode = TAB_MODES[v.tabMode] ? v.tabMode : "nested";
5616 newTabAction = NEW_TAB_ACTIONS[v.newTabAction] ? v.newTabAction : "claude";
5617 const stored = Number(v.fontSize);
5618 fontSize =
5619 Number.isFinite(stored) && stored >= FONT_MIN && stored <= FONT_MAX
5620 ? Math.round(stored)
5621 : FONT_DEFAULT;
5622 applyTheme();
5623 applyDensity();
5624 applyTabMode();
5625 applyNewTabAction();
5626 applyFontSize();
5627 if (v.token) $input("token").value = v.token;
5628 if (v.url) $input("url").value = v.url;
5629 if (v.session) $input("session").value = v.session;
5630 log(`origin: ${ORIGIN}`);
5631 if (v.token) {
5632 connect();
5633 } else {
5634 setStatus("off", "needs setup");
5635 openSettings();
5636 term.write(
5637 "\x1b[90m terminal\x1b[0m\r\n\r\n" +
5638 " Not configured yet. Open \x1b[1msettings\x1b[0m (top right)\r\n" +
5639 " to pair and paste your token.\r\n",
5640 );
5641 }
5642});