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