anvilsign in

collin/browser-terminal-extension

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