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 // What you would search for is what it is doing, not "pane %12".
3478 const text = [a.title, a.message, a.tool, a.name].filter(Boolean).join(" ");
3479 const score = omniScore(text, q);
3480 if (score < 0 || !q) continue;
3481 out.push({
3482 kind: "pane",
3483 label: a.title || agentLabel(a),
3484 meta: `${a.session} · ${a.window} · claude ${a.state}`,
3485 state: a.state,
3486 score: score + 50,
3487 run: () => tmuxCommand({ cmd: "focus", pane: a.pane }),
3488 });
3489 }
3490
3491 // Machines, from what ssh already knows about. Only with a query, for the
3492 // same reason the windows are: an empty box is a starting point rather than
3493 // an inventory, and the sessions are the short list it offers.
3494 //
3495 // The window this opens is a local one running ssh — see the daemon's
3496 // TmuxRequest::Ssh. So it needs the session the panel is on, exactly as the
3497 // Claude and `!` rows do, and it is offered only when there is one.
3498 const onSession = lastSessions.find((s) => s.name === sessionName);
3499 if (q && onSession) {
3500 for (const host of sshHosts) {
3501 const score = fuzzyScore(host, q);
3502 if (score < 0) continue;
3503 out.push({
3504 kind: "ssh",
3505 label: host,
3506 meta: "ssh",
3507 score: score + OMNI_SSH_PENALTY,
3508 run: () => tmuxCommand({ cmd: "ssh", session: onSession.name, host }),
3509 });
3510 }
3511 }
3512
3513 out.sort((x, y) => x.score - y.score || OMNI_KIND_RANK[x.kind] - OMNI_KIND_RANK[y.kind]);
3514 const items = out.slice(0, OMNI_LIMIT);
3515
3516 const typed = query.trim();
3517
3518 // A destination that is not in the list, read as one anyway: ssh reaches
3519 // machines no config or `known_hosts` mentions, and having to open a terminal
3520 // to connect to one of them would make the list a limit rather than a
3521 // shortcut.
3522 //
3523 // Only for text shaped like a destination, though — a `user@` or a dot.
3524 // A bare word is a session or a window name, and offering to ssh to `wor`
3525 // while you type `work` would put a connection under the cursor on the way
3526 // to somewhere you already have.
3527 if (
3528 typed &&
3529 onSession &&
3530 /[@.]/.test(typed) &&
3531 validSshHost(typed) &&
3532 !sshHosts.includes(typed)
3533 ) {
3534 items.push({
3535 kind: "ssh",
3536 label: typed,
3537 meta: "ssh",
3538 score: Infinity,
3539 typed: true,
3540 run: () => tmuxCommand({ cmd: "ssh", session: onSession.name, host: typed }),
3541 });
3542 }
3543
3544 // Last, always, and only when it would do something: an exact existing name
3545 // is a switch, which is already in the list above.
3546 if (typed && validSessionName(typed) && !lastSessions.some((s) => s.name === typed)) {
3547 items.push({
3548 kind: "create",
3549 typed: true,
3550 label: typed,
3551 meta: "create session",
3552 score: Infinity,
3553 run: () => tmuxCommand({ cmd: "create", session: typed }),
3554 });
3555 }
3556
3557 // Below everything, and last of all: whatever was typed, read as a question
3558 // rather than as a name. A sentence matches no window and is not a legal
3559 // session name, so for anything that isn't a name this is the only row in
3560 // the list — type the thing you want done, press Enter, and it opens in a
3561 // window of its own beside the one you are in.
3562 //
3563 // In the session's own directory, which is the whole reason to ask from here
3564 // rather than in a terminal somewhere else.
3565 if (typed && typed.length <= OMNI_PROMPT_MAX && onSession) {
3566 items.push({
3567 kind: "claude",
3568 label: typed,
3569 meta: onSession.path ? `send to claude · ${shortPath(onSession.path)}` : "send to claude",
3570 score: Infinity,
3571 typed: true,
3572 run: () => tmuxCommand({ cmd: "claude", session: onSession.name, prompt: typed }),
3573 });
3574 }
3575
3576 // With nothing typed the box is a menu rather than a search, so it ends with
3577 // the things you would otherwise have had to type to get: a Claude, and a
3578 // shell. Every other row here is reached by typing at least a letter, which
3579 // is the one thing a touch client has no cheap way to do — these are the rows
3580 // that make the list usable with a thumb.
3581 //
3582 // At the bottom, after the places, so Enter on an untouched box still means
3583 // the first session rather than starting something.
3584 if (!typed && onSession) items.push(...omniActions(onSession));
3585 return items;
3586}
3587
3588/**
3589 * The rows that make something in the session the panel is on. Both open a
3590 * window beside the current one, in the session's own directory — the same
3591 * window `send to claude` and `!` open, without the text.
3592 *
3593 * `claude` goes through `run` rather than through the daemon's Claude request:
3594 * that one exists to carry a prompt safely, and there is no prompt here. What
3595 * this is, is `!claude` with nothing to type.
3596 *
3597 * @param {TbSessionInfo} s
3598 * @returns {TbOmniItem[]}
3599 */
3600function omniActions(s) {
3601 const where = s.path ? shortPath(s.path) : s.name;
3602 return [
3603 {
3604 kind: "action",
3605 mark: "✻",
3606 label: "New Claude",
3607 meta: `new window · ${where}`,
3608 score: Infinity,
3609 typed: true,
3610 run: () => tmuxCommand({ cmd: "run", session: s.name, command: "claude" }),
3611 },
3612 {
3613 kind: "action",
3614 mark: "+",
3615 label: "New window",
3616 meta: `${s.name} · ${where}`,
3617 score: Infinity,
3618 typed: true,
3619 run: () => tmuxCommand({ cmd: "new-window", session: s.name }),
3620 },
3621 ];
3622}
3623
3624/**
3625 * The daemon's `MAX_PROMPT`, mirrored for the same reason `validSessionName`
3626 * mirrors its validator: an offer the daemon would drop is worse than none.
3627 * Kept in step by hand with daemon/src/pty.rs.
3628 */
3629const OMNI_PROMPT_MAX = 8192;
3630
3631/**
3632 * The daemon's `valid_command`, mirrored for the same reason again: a row that
3633 * offers to run something the daemon will drop is worse than no row.
3634 *
3635 * A command is one line — a newline in the box means a paste that meant to go
3636 * to the terminal itself. Kept in step by hand with daemon/src/pty.rs.
3637 *
3638 * @param {string} cmd
3639 */
3640function validCommand(cmd) {
3641 // eslint-disable-next-line no-control-regex
3642 return cmd.length > 0 && cmd.length <= 4096 && !/[\x00-\x1f\x7f]/.test(cmd);
3643}
3644
3645// --- the box as a place -----------------------------------------------------
3646//
3647// `~/Code/foo let's do this` — a directory to work in, and what to say to
3648// Claude once it is running there. A leading `~` is what puts the box in this
3649// mode: no tmux name starts with one, and neither does anything you would type
3650// looking for a window, so nothing else has to be given up for it.
3651//
3652// The rows are a question the panel cannot answer for itself. It has no
3653// filesystem — the directory is on the daemon's machine — so it asks about one
3654// directory at a time and works the rest out from the answer: whether the path
3655// exists decides between switching to it, starting a session in it, and making
3656// it first. One query per level typed, cached, rather than one per keystroke.
3657
3658/**
3659 * What the daemon said about a directory, keyed by the directory asked about.
3660 * @type {Map<string, TbPathFrame>}
3661 */
3662const pathAnswers = new Map();
3663/** Asked and not yet answered, so the same question is not asked twice.
3664 * @type {Set<string>} */
3665const pathAsking = new Set();
3666/** Cleared wholesale when it gets past this; the box asks again as you type. */
3667const PATH_CACHE_MAX = 64;
3668/** Long enough that a typed path is one query per `/`, short enough not to be
3669 * felt. The answer arriving repaints the list under the caret. */
3670const PATH_DEBOUNCE_MS = 70;
3671/** How many directories the list offers to complete to. */
3672const PATH_COMPLETIONS = 6;
3673
3674let pathTimer = 0;
3675/** The most recent directory `askPath` was asked for; the timer sends this. */
3676let pathWanted = "";
3677
3678/**
3679 * Ask the daemon about a directory, at most once, and not on every keystroke.
3680 *
3681 * The debounce holds one query rather than a queue: typing `~/Code/` fires for
3682 * `~/Code` and not for `~/Cod`, because the last thing wanted is the only thing
3683 * still worth asking by the time the timer runs.
3684 *
3685 * @param {string} dir a path in the box's own notation — `~/Code`, `/etc`
3686 */
3687function askPath(dir) {
3688 if (!connected || !ws || !dir || pathAnswers.has(dir) || pathAsking.has(dir)) return;
3689 pathWanted = dir;
3690 if (pathTimer) return;
3691 pathTimer = setTimeout(() => {
3692 pathTimer = 0;
3693 const q = pathWanted;
3694 if (!connected || !ws || !q || pathAnswers.has(q) || pathAsking.has(q)) return;
3695 pathAsking.add(q);
3696 ws.send(JSON.stringify({ type: "path", q }));
3697 }, PATH_DEBOUNCE_MS);
3698}
3699
3700/**
3701 * File an answer and, if the list is up, draw it — the rows that were waiting
3702 * on this are the reason it was asked for.
3703 *
3704 * @param {TbPathFrame} msg
3705 */
3706function takePathAnswer(msg) {
3707 if (typeof msg.q !== "string") return;
3708 pathAsking.delete(msg.q);
3709 // A cache, not a model of the filesystem: it is dropped whole rather than
3710 // aged, and anything still on screen is asked for again on the next keystroke.
3711 if (pathAnswers.size >= PATH_CACHE_MAX) pathAnswers.clear();
3712 pathAnswers.set(msg.q, msg);
3713 if (!$("omni-list").hidden) refreshOmni();
3714}
3715
3716/**
3717 * Forget what we know about a directory. Called when we have just asked for
3718 * something to be created inside it, because the listing we hold is now one
3719 * name short of the truth.
3720 *
3721 * @param {string} dir
3722 */
3723function forgetPath(dir) {
3724 pathAnswers.delete(dir);
3725 pathAsking.delete(dir);
3726}
3727
3728/**
3729 * A path split where the daemon has to be asked: the directory to list, and
3730 * what has been typed of the name inside it.
3731 *
3732 * @param {string} token
3733 * @returns {{ dir: string, prefix: string }}
3734 */
3735function splitPath(token) {
3736 const cut = token.lastIndexOf("/");
3737 if (cut < 0) return { dir: token, prefix: "" };
3738 // A single leading slash is the root, and slicing it away would leave "".
3739 if (cut === 0) return { dir: "/", prefix: token.slice(1) };
3740 return { dir: token.slice(0, cut), prefix: token.slice(cut + 1) };
3741}
3742
3743/**
3744 * A session name from a directory name — what the last component of the path
3745 * would be called if tmux would have it.
3746 *
3747 * `my.app` becomes `my-app`, because tmux rejects a dot in a session name. The
3748 * row says what the session will be called for exactly this reason: the name
3749 * and the directory are usually the same word, and when they are not, that is
3750 * worth seeing before pressing Enter rather than after.
3751 *
3752 * @param {string} path an absolute path
3753 */
3754function projectSlug(path) {
3755 const base = path.split("/").filter(Boolean).pop() ?? "";
3756 const slug = base
3757 .replace(/[^A-Za-z0-9_-]+/g, "-")
3758 .replace(/^-+|-+$/g, "")
3759 .slice(0, 64);
3760 return validSessionName(slug) ? slug : "";
3761}
3762
3763/**
3764 * `slug`, or the first `slug-2`, `slug-3` that no session has taken.
3765 *
3766 * Only reached when the directory is *not* one we already have a session on —
3767 * that case is a switch, not a second session. This is the other one: two
3768 * different directories whose last component happens to be the same word.
3769 *
3770 * @param {string} slug
3771 * @returns {string} empty when there is no free name, which is not a real case
3772 */
3773function freeSessionName(slug) {
3774 if (!slug) return "";
3775 const taken = (/** @type {string} */ name) => lastSessions.some((s) => s.name === name);
3776 if (!taken(slug)) return slug;
3777 for (let n = 2; n < 100; n++) {
3778 const candidate = `${slug}-${n}`.slice(0, 64);
3779 if (!taken(candidate)) return candidate;
3780 }
3781 return "";
3782}
3783
3784/**
3785 * The rows for a box that starts with `~`.
3786 *
3787 * @param {string} query
3788 * @returns {TbOmniItem[]}
3789 */
3790function projectSuggestions(query) {
3791 const s = query.trim();
3792 // The first whitespace ends the path and begins the prompt. A directory with
3793 // a space in its name cannot be typed here, which is the price of the prompt
3794 // needing no punctuation of its own — and the completion rows will still walk
3795 // you into one.
3796 const space = s.search(/\s/);
3797 const token = space < 0 ? s : s.slice(0, space);
3798 const typedPrompt = space < 0 ? "" : s.slice(space + 1).trim();
3799 const prompt = typedPrompt.length <= OMNI_PROMPT_MAX ? typedPrompt : "";
3800 const { dir, prefix } = splitPath(token);
3801
3802 /** @type {(label: string, meta: string) => TbOmniItem[]} */
3803 const hint = (label, meta) => [{ kind: "project", label, meta, score: 0, typed: true, hint: true }];
3804
3805 const answer = pathAnswers.get(dir);
3806 if (!answer) {
3807 askPath(dir);
3808 return hint(token, "looking…");
3809 }
3810 if (answer.kind === "invalid") return hint(token, "not a path");
3811 if (answer.kind === "denied") return hint(token, "cannot read that directory");
3812 if (answer.kind === "file") return hint(token, "not a directory");
3813
3814 const dirs = answer.dirs ?? [];
3815 const files = answer.files ?? [];
3816 const base = (answer.path || "").replace(/\/+$/, "");
3817 const target = prefix ? `${base}/${prefix}` : base;
3818
3819 // What is at the whole path, worked out from the one directory we asked
3820 // about. `creates` counts what `mkdir -p` would have to make, and the daemon
3821 // has already counted the part above this level.
3822 let state = /** @type {TbPathFrame["kind"]} */ (answer.kind);
3823 let creates = answer.creates ?? 0;
3824 if (prefix) {
3825 if (answer.kind !== "dir") {
3826 state = "missing";
3827 creates += 1;
3828 } else if (dirs.includes(prefix)) {
3829 state = "dir";
3830 creates = 0;
3831 } else if (files.includes(prefix)) {
3832 state = "file";
3833 } else {
3834 state = "missing";
3835 creates = 1;
3836 }
3837 }
3838
3839 /** @type {TbOmniItem[]} */
3840 const rows = [];
3841 const shown = tildePath(target);
3842 const loudest = agentBySession(lastAgents);
3843
3844 if (state === "file") {
3845 rows.push(...hint(shown, "not a directory"));
3846 } else if (state === "dir") {
3847 // The directory is already somebody's: go there rather than opening a
3848 // second session on the same tree, which is the mistake this row exists to
3849 // prevent. tmux reports a session's *current pane's* directory, so this is
3850 // "a session sitting in that project", which is the question being asked.
3851 const onIt = lastSessions.find((x) => x.path === target);
3852 if (onIt) {
3853 const here = onIt.name === sessionName;
3854 if (prompt) {
3855 rows.push({
3856 kind: "claude",
3857 label: prompt,
3858 meta: `send to claude · ${shortPath(target)}`,
3859 score: 0,
3860 typed: true,
3861 run: () => tmuxCommand({ cmd: "claude", session: onIt.name, prompt }),
3862 });
3863 }
3864 rows.push({
3865 kind: "session",
3866 label: onIt.name,
3867 meta: here ? `session · ${shown} · here` : `session · ${shown}`,
3868 state: loudest[onIt.name]?.state,
3869 score: 0,
3870 typed: true,
3871 // Switching to the session you are on does nothing, so it is said
3872 // rather than offered — the row is still worth drawing, because "you
3873 // already have this open" is the answer to what was typed.
3874 hint: here,
3875 run: here ? undefined : () => tmuxCommand({ cmd: "switch", session: onIt.name }),
3876 });
3877 } else {
3878 rows.push(...projectRow({ token, dir, target, prompt, creates: 0, shown }));
3879 }
3880 } else {
3881 rows.push(...projectRow({ token, dir, target, prompt, creates, shown }));
3882 }
3883
3884 // Everything inside the directory that starts with what has been typed of the
3885 // next name. Below the row that acts, because the thing you typed in full is
3886 // a better answer than something it is a prefix of.
3887 if (answer.kind === "dir") {
3888 const q = prefix.toLowerCase();
3889 // Hidden directories only when you have said so with a leading dot: `~/`
3890 // otherwise offers a home directory's worth of dotfiles ahead of anything
3891 // you keep work in.
3892 const shownDirs = prefix.startsWith(".") ? dirs : dirs.filter((n) => !n.startsWith("."));
3893 const matches = shownDirs
3894 .map((name) => ({ name, score: omniScore(name, q) }))
3895 .filter((m) => m.score >= 0 && m.name !== prefix)
3896 .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
3897 .slice(0, PATH_COMPLETIONS);
3898 for (const { name, score } of matches) {
3899 const next = `${dir === "/" ? "" : dir}/${name}`;
3900 rows.push({
3901 kind: "path",
3902 label: name,
3903 meta: dir,
3904 score: 1 + score,
3905 typed: true,
3906 complete: true,
3907 run: () => completePath(next, prompt),
3908 });
3909 }
3910 }
3911
3912 return rows;
3913}
3914
3915/**
3916 * The row that makes the thing: a session in that directory, and the directory
3917 * itself when it is not there yet.
3918 *
3919 * A list rather than an item so a path with no usable session name in it —
3920 * `~/...` of nothing but punctuation — can answer with a hint instead.
3921 *
3922 * @param {{token: string, dir: string, target: string, prompt: string,
3923 * creates: number, shown: string}} spec
3924 * @returns {TbOmniItem[]}
3925 */
3926function projectRow({ token, dir, target, prompt, creates, shown }) {
3927 const name = freeSessionName(projectSlug(target));
3928 if (!name) {
3929 return [
3930 {
3931 kind: "project",
3932 label: shown,
3933 meta: "no session name in that path",
3934 score: 0,
3935 typed: true,
3936 hint: true,
3937 },
3938 ];
3939 }
3940 // What is about to happen, in the order it happens in. The count is there
3941 // because "make one directory" and "make four" are different answers to what
3942 // is usually a typo in the middle of a path.
3943 const made = creates === 0 ? "" : creates === 1 ? "mkdir · " : `creates ${creates} dirs · `;
3944 return [
3945 {
3946 kind: "project",
3947 // The one thing that distinguishes it from the row below it is what it is
3948 // about to start, so the mark comes with the item — see `omniActions`.
3949 mark: prompt ? "✻" : "+",
3950 label: shown,
3951 meta: `${made}new session ${name}${prompt ? " · claude" : ""}`,
3952 score: 0,
3953 typed: true,
3954 run: () => {
3955 // The listing we hold for the directory above this one is about to be
3956 // one name out of date.
3957 if (creates > 0) forgetPath(dir);
3958 tmuxCommand({
3959 cmd: "new-project",
3960 // The path as typed: `~` is the daemon's home, not the browser's, and
3961 // one expansion of it is the only way the row and the mkdir agree.
3962 path: token,
3963 name,
3964 ...(prompt ? { prompt } : {}),
3965 });
3966 },
3967 },
3968 ];
3969}
3970
3971/**
3972 * Take a completion: put the directory in the box with a trailing slash, which
3973 * both says "there is more to come" and is what asks about the next level.
3974 *
3975 * The prompt rides along, so completing a path halfway through a sentence does
3976 * not cost the sentence.
3977 *
3978 * @param {string} next
3979 * @param {string} prompt
3980 */
3981function completePath(next, prompt) {
3982 const input = $area("omni");
3983 input.value = prompt ? `${next}/ ${prompt}` : `${next}/`;
3984 omniDirty = true;
3985 input.focus();
3986 refreshOmni();
3987}
3988
3989/**
3990 * A path as a person refers to it. The row's dimmed half is a few characters
3991 * wide, so this is the tail of it — the last two components, which is the part
3992 * that says which project. The whole path is in the session info panel, which
3993 * is where one is worth reading in full.
3994 *
3995 * The panel never sees `$HOME`, so home is recognised by shape: `/home/x` and
3996 * `/Users/x`. Getting that wrong costs a `…` where a `~` would have read
3997 * better, and nothing else.
3998 *
3999 * @param {string} p
4000 */
4001function shortPath(p) {
4002 const parts = p.split("/").filter(Boolean);
4003 const home = parts.length >= 2 && (parts[0] === "home" || parts[0] === "Users");
4004 if (home && parts.length === 2) return "~";
4005 const rest = home ? parts.slice(2) : parts;
4006 const tail = rest.slice(-2).join("/");
4007 if (home) return rest.length <= 2 ? `~/${tail}` : `~/…/${tail}`;
4008 return rest.length <= 2 ? p : `…/${tail}`;
4009}
4010
4011/**
4012 * The whole path, with home written the way a shell writes it.
4013 *
4014 * Unlike {@link shortPath} nothing is dropped: this goes where the box's own
4015 * overflow decides what fits, and eliding in advance means a `…` in a gap wide
4016 * enough for the characters it replaced.
4017 *
4018 * @param {string} p
4019 */
4020function tildePath(p) {
4021 const parts = p.split("/").filter(Boolean);
4022 const home = parts.length >= 2 && (parts[0] === "home" || parts[0] === "Users");
4023 if (!home) return p;
4024 return parts.length === 2 ? "~" : `~/${parts.slice(2).join("/")}`;
4025}
4026
4027/**
4028 * True once the box holds a query rather than the location it was showing.
4029 *
4030 * An address bar's text is its value, not a label beside it: the session name
4031 * *is* what is in the box, focusing selects the whole of it, and typing
4032 * replaces it — so getting somewhere else is one shortcut and a few letters,
4033 * with no clearing step in between. The flag is what keeps the two states
4034 * apart, because "work" sitting in the box means "you are in work" until you
4035 * touch it and "find me something called work" afterwards.
4036 */
4037let omniDirty = false;
4038
4039/**
4040 * Put the location back in the box: the session this panel's client is on.
4041 *
4042 * Called on every status frame, so it has two things it must not walk over —
4043 * a query being typed, and the selection that focusing just made.
4044 */
4045/**
4046 * Size the box to its text: one line when there is one, taller as it wraps,
4047 * and no further than the cap in `sidebar.css` — past that it scrolls.
4048 *
4049 * A textarea has no intrinsic height, so this is the whole of "it grows". The
4050 * reset to `auto` first is what lets it shrink again: `scrollHeight` of a box
4051 * already taller than its content is that taller height, so measuring without
4052 * it makes the box a ratchet.
4053 *
4054 * The header is a flex column above a `flex: 1` terminal, so a taller pill
4055 * takes its pixels from the terminal, and the ResizeObserver on `#term` refits
4056 * tmux to the rows that are left. Nothing here has to say so.
4057 */
4058function growOmni() {
4059 const input = $area("omni");
4060 input.style.height = "auto";
4061 input.style.height = `${input.scrollHeight}px`;
4062}
4063
4064// Rewrapping is the panel's width changing, and that is the one thing that can
4065// change the line count without anyone touching the box.
4066window.addEventListener("resize", growOmni);
4067
4068function syncOmniHere() {
4069 const input = $area("omni");
4070 const here = connected && tmuxMode && sessionName ? sessionName : "";
4071
4072 // Shown only when there is a session for it to describe: an info button over
4073 // an empty box has nothing to open.
4074 $("omni-here").hidden = !here;
4075
4076 // The directory follows the same rule as the name: it describes where you
4077 // are, so it goes as soon as the box stops being a location and starts being
4078 // a query. The full path stays one click away in the info panel.
4079 const cwd = $("omni-cwd");
4080 const path = here ? lastSessions.find((s) => s.name === sessionName)?.path : null;
4081 const showCwd = !!path && !omniDirty && document.activeElement !== input;
4082 cwd.hidden = !showCwd;
4083 if (showCwd && path) {
4084 cwd.textContent = "";
4085 cwd.appendChild(el("span", { text: tildePath(path) }));
4086 cwd.title = path;
4087 }
4088
4089 if (omniDirty || document.activeElement === input) {
4090 growOmni();
4091 takePendingOmniFocus();
4092 return;
4093 }
4094 input.value = here;
4095 growOmni();
4096 input.title = here
4097 ? `${here} — type to jump to a window, session or Claude pane, ` +
4098 `name a session to create it, or ask a new Claude in this directory`
4099 : "Jump to a window, session or Claude pane";
4100 takePendingOmniFocus();
4101}
4102
4103// --- session info -----------------------------------------------------------
4104//
4105// What is behind the dot, and the same bargain a browser's padlock makes: the
4106// box has room for a name and nothing else, so the identity behind that name
4107// gets a panel of its own one click away. There it is the origin, its
4108// certificate and what the page is allowed to do; here it is the session — the
4109// working directory above all, which is the one thing about a session its name
4110// never tells you and the first thing you want to know before typing into it.
4111//
4112// Everything in it is a tmux string, so all of it reaches the DOM through
4113// textContent.
4114
4115/** Set while the panel is up, so a status frame redraws it in place. */
4116let infoOpen = false;
4117/**
4118 * Set when the outside-press guard closed the panel because the press landed on
4119 * the dot itself. The click that follows is the rest of that same press, so it
4120 * has to leave the panel closed rather than treat it as a fresh open.
4121 */
4122let infoToggledOff = false;
4123/** Reset whenever the panel opens: the copy button's label is a one-shot. */
4124let infoCopied = false;
4125
4126/**
4127 * Rough and one unit deep, which is all an age is read for here: whether this
4128 * session is from this morning or from last week.
4129 *
4130 * @param {number} created Unix seconds
4131 */
4132function sessionAge(created) {
4133 const secs = Math.max(0, Math.floor(Date.now() / 1000 - created));
4134 const units = /** @type {const} */ ([
4135 [86400, "d"],
4136 [3600, "h"],
4137 [60, "m"],
4138 ]);
4139 for (const [size, suffix] of units) {
4140 if (secs >= size) return `${Math.floor(secs / size)}${suffix} ago`;
4141 }
4142 return "just now";
4143}
4144
4145/**
4146 * One label-and-value line.
4147 *
4148 * @param {string} key
4149 * @param {string} value
4150 * @param {boolean} [path] a filesystem path, which is elided from the front
4151 */
4152function infoRow(key, value, path) {
4153 return el(
4154 "div",
4155 { class: "info-row" },
4156 el("span", { class: "k", text: key }),
4157 path
4158 ? // The `rtl` that puts the ellipsis on the left would also reorder the
4159 // path's own punctuation, so the text itself is wrapped back to `ltr`.
4160 el("span", { class: "v path", title: value }, el("span", { text: value }))
4161 : el("span", { class: "v", title: value, text: value }),
4162 );
4163}
4164
4165/**
4166 * Draw the panel's contents from the current frame. Called again on every
4167 * status frame while it is open, so a Claude that starts working, a window
4168 * that opens or a second client attaching all show up without reopening it.
4169 *
4170 * @param {HTMLElement} pop
4171 * @param {TbSessionInfo} s
4172 */
4173function fillSessionInfo(pop, s) {
4174 pop.textContent = "";
4175
4176 const colour = groupColor(s.name);
4177 pop.appendChild(
4178 el(
4179 "div",
4180 { class: "info-head" },
4181 el("span", {
4182 class: `dot${colour === GROUP_GREY ? " grey" : ""}`,
4183 css: { "--group-h": String(colour) },
4184 }),
4185 el("span", { class: "name", text: s.name }),
4186 // The id, because a rename changes the name and not this — and because it
4187 // is what a `tmux` command typed by hand wants.
4188 el("span", { class: "sub", text: s.id }),
4189 ),
4190 );
4191
4192 const panes = s.windows.reduce((n, w) => n + w.panes, 0);
4193 const here = s.windows.find((w) => w.active);
4194 pop.appendChild(infoRow("Directory", s.path || "unknown", true));
4195 if (here) pop.appendChild(infoRow("Window", `${here.index}: ${here.name}`));
4196 pop.appendChild(
4197 infoRow(
4198 "Contents",
4199 `${s.windows.length} window${s.windows.length === 1 ? "" : "s"} · ` +
4200 `${panes} pane${panes === 1 ? "" : "s"}`,
4201 ),
4202 );
4203 // Worth saying plainly: a second client on the same session is why what you
4204 // type here appears somewhere else too.
4205 const clients = s.clients ?? (s.attached ? 1 : 0);
4206 pop.appendChild(
4207 infoRow(
4208 "Attached",
4209 clients <= 1 ? "this panel only" : `${clients} clients — this panel and ${clients - 1} more`,
4210 ),
4211 );
4212 if (s.created) pop.appendChild(infoRow("Started", sessionAge(s.created)));
4213
4214 const mine = lastAgents.filter((a) => a.session === s.name);
4215 pop.appendChild(
4216 el(
4217 "div",
4218 { class: "info-agents" },
4219 mine.length === 0 && el("div", { class: "info-empty", text: "No Claude running here" }),
4220 ...mine.map((a) =>
4221 el(
4222 "div",
4223 { class: "info-agent" },
4224 glyphSpan(a.state),
4225 el("span", {
4226 class: "what",
4227 text: a.title || agentLabel(a),
4228 title: `claude ${a.state} — ${agentLabel(a)}`,
4229 }),
4230 el("span", { class: "where", text: a.window }),
4231 ),
4232 ),
4233 ),
4234 );
4235
4236 // The one thing here that is wanted somewhere else: a path is typed into
4237 // another shell, a file manager or an editor far more often than it is read.
4238 if (s.path) {
4239 const copy = button({
4240 class: "info-copy",
4241 text: infoCopied ? "Copied" : "Copy path",
4242 on: {
4243 click: () =>
4244 navigator.clipboard.writeText(s.path ?? "").then(
4245 () => {
4246 infoCopied = true;
4247 copy.textContent = "Copied";
4248 },
4249 () => {
4250 copy.textContent = "Couldn't copy";
4251 },
4252 ),
4253 },
4254 });
4255 pop.appendChild(copy);
4256 }
4257}
4258
4259/** Redraw an open panel from the frame that just arrived. */
4260function refreshSessionInfo() {
4261 if (!infoOpen || !openMenu) return;
4262 const s = lastSessions.find((x) => x.name === sessionName);
4263 // The session went away — closing the panel is the honest answer, and it is
4264 // what the omnibar above it is about to do with the name too.
4265 if (!s) return closeTabMenu();
4266 fillSessionInfo(openMenu, s);
4267}
4268
4269/**
4270 * Open it under the dot, the way a browser drops its site panel out of the
4271 * padlock. Shares the tab menu's machinery — one thing open at a time, Escape
4272 * and a click anywhere else close it.
4273 */
4274function openSessionInfo() {
4275 const wasOpen = infoOpen || infoToggledOff;
4276 infoToggledOff = false;
4277 closeTabMenu();
4278 // The dot is a toggle: clicking it again is how you put the panel away
4279 // without having to find somewhere neutral to click.
4280 if (wasOpen) return;
4281 const s = lastSessions.find((x) => x.name === sessionName);
4282 if (!s) return;
4283
4284 infoCopied = false;
4285 const pop = el("div", {
4286 class: "info-pop",
4287 attrs: { role: "dialog", "aria-label": `Session ${s.name}` },
4288 });
4289 fillSessionInfo(pop, s);
4290
4291 document.body.appendChild(pop);
4292 const anchor = $("omni-here").getBoundingClientRect();
4293 const r = pop.getBoundingClientRect();
4294 // Hung off the dot's left edge, and folded back inside when the panel is
4295 // narrower than the bubble wants to be.
4296 pop.style.left = `${Math.max(4, Math.min(anchor.left - 4, window.innerWidth - r.width - 4))}px`;
4297 pop.style.top = `${Math.min(anchor.bottom + 4, Math.max(0, window.innerHeight - r.height - 4))}px`;
4298 openMenu = pop;
4299 infoOpen = true;
4300 $("omni-here").setAttribute("aria-expanded", "true");
4301 setTimeout(() => {
4302 window.addEventListener("pointerdown", onDismiss, { once: true, capture: true });
4303 }, 0);
4304}
4305
4306// mousedown rather than click for the guard: the pill hands focus to the input
4307// on a press anywhere inside it, and the panel opening under a focused omnibar
4308// would sit over the list that focus drops down.
4309$("omni-here").addEventListener("mousedown", (e) => e.preventDefault());
4310$("omni-here").addEventListener("click", openSessionInfo);
4311
4312/** Drop whatever was typed and show the location again. */
4313function revertOmni() {
4314 omniDirty = false;
4315 const input = $area("omni");
4316 input.value = connected && tmuxMode && sessionName ? sessionName : "";
4317 input.select();
4318 refreshOmni();
4319}
4320
4321/** Rebuild the dropdown from whatever is in the box. */
4322function refreshOmni() {
4323 const input = $area("omni");
4324 const list = $("omni-list");
4325 syncOmniHere();
4326 if (tabMode !== "groups" || !connected || !tmuxMode) return closeOmni();
4327
4328 // The id of the row that was chosen, so a status frame arriving mid-type
4329 // does not move the selection out from under the next Enter.
4330 const chosen = omniItems[omniActive];
4331 // Untouched, the box is showing where you are, not asking for it: the list
4332 // that goes with that is the other places, the same way a browser drops down
4333 // suggestions rather than searching for the URL already in the bar.
4334 const query = omniDirty ? input.value : "";
4335 omniItems = omniSuggestions(query);
4336 omniActive = chosen
4337 ? omniItems.findIndex((i) => i.kind === chosen.kind && i.label === chosen.label)
4338 : -1;
4339
4340 list.textContent = "";
4341 if (!omniItems.length) return closeOmni();
4342
4343 const q = query.trim().toLowerCase();
4344 omniItems.forEach((item, i) => list.appendChild(omniRow(item, i, q)));
4345 list.hidden = false;
4346 // Anchored to the row it drops out of rather than to the panel, so it lines
4347 // up with the box whatever the density is doing to the header's height.
4348 const r = $("omni-strip").getBoundingClientRect();
4349 list.style.top = `${r.bottom}px`;
4350 syncOmniActive();
4351}
4352
4353/**
4354 * @param {TbOmniItem} item
4355 * @param {number} i
4356 * @param {string} q the matched substring, for the highlight
4357 */
4358function omniRow(item, i, q) {
4359 // Split around the match so the part you typed can be picked out. Three
4360 // textContent assignments, never markup — these are tmux's names.
4361 const at = q ? item.label.toLowerCase().indexOf(q) : -1;
4362 // The rows whose label is the query itself have nothing to highlight: every
4363 // character of them was typed.
4364 const label =
4365 at >= 0 && !item.typed
4366 ? el(
4367 "span",
4368 { class: "label" },
4369 el("span", { text: item.label.slice(0, at) }),
4370 el("b", { text: item.label.slice(at, at + q.length) }),
4371 el("span", { text: item.label.slice(at + q.length) }),
4372 )
4373 : el("span", { class: "label", text: item.label });
4374
4375 return el(
4376 "div",
4377 {
4378 class: `omni-row ${item.kind}${item.hint ? " hint" : ""}`,
4379 attrs: { id: `omni-row-${i}` },
4380 data: { index: String(i) },
4381 on: {
4382 // mousedown rather than click for the guard: the input would otherwise
4383 // blur before the click landed, and blur closes the list out from
4384 // under it.
4385 /** @param {MouseEvent} e */
4386 mousedown: (e) => e.preventDefault(),
4387 click: () => runOmni(i),
4388 mousemove: () => {
4389 if (omniActive === i) return;
4390 omniActive = i;
4391 syncOmniActive();
4392 },
4393 },
4394 },
4395 glyphSpan(item.state),
4396 // The kinds whose rows are all alike get their character from CSS. An action
4397 // row does not: what it is about to make is the whole of what distinguishes
4398 // it from the action below it, so the mark comes with the item.
4399 item.mark && el("span", { class: "mark", text: item.mark, attrs: { "aria-hidden": "true" } }),
4400 label,
4401 el("span", { class: "meta", text: item.meta }),
4402 );
4403}
4404
4405/** Paint the chosen row. */
4406function syncOmniActive() {
4407 const rows = [...$("omni-list").children];
4408 rows.forEach((row, i) => row.classList.toggle("active", i === omniActive));
4409 const active = rows[omniActive];
4410 if (active) active.scrollIntoView({ block: "nearest" });
4411}
4412
4413function closeOmni() {
4414 const list = $("omni-list");
4415 list.hidden = true;
4416 list.textContent = "";
4417 omniItems = [];
4418 omniActive = -1;
4419}
4420
4421/**
4422 * Run a row and get out of the way. The box goes back to being the location:
4423 * the command has been sent, and the status frame that answers it will put the
4424 * new session's name here a moment later — this just stops the query it was
4425 * holding from looking like where you are in the meantime.
4426 *
4427 * @param {number} i
4428 */
4429function runOmni(i) {
4430 const item = omniItems[i];
4431 if (!item) return;
4432 // A hint has nothing to run, and closing the list on Enter would take the
4433 // thing it is explaining off the screen. It stays put and the box keeps focus.
4434 if (!item.run) return;
4435 // A completion is not a destination: it puts a longer path in the box and
4436 // leaves you typing, so nothing here closes or hands focus back.
4437 if (item.complete) {
4438 item.run();
4439 return;
4440 }
4441 item.run();
4442 omniDirty = false;
4443 closeOmni();
4444 term.focus();
4445 syncOmniHere();
4446}
4447
4448/** @param {number} delta */
4449function moveOmni(delta) {
4450 if (!omniItems.length) return;
4451 // Wraps, and starts at the top going down / the bottom going up: with
4452 // nothing chosen there is no "next" that isn't the first one.
4453 const n = omniItems.length;
4454 omniActive = omniActive < 0 ? (delta > 0 ? 0 : n - 1) : (omniActive + delta + n) % n;
4455 syncOmniActive();
4456}
4457
4458/**
4459 * When the shortcut is what opened the panel, it arrives ahead of everything
4460 * the box is made of: the mode comes from storage, the name from the server,
4461 * and neither is here yet. Held as a time rather than a flag so a request that
4462 * never becomes answerable expires instead of ambushing a later frame — a mode
4463 * switch minutes on is not this shortcut still landing.
4464 */
4465let omniFocusAsked = 0;
4466const OMNI_FOCUS_WAIT_MS = 15_000;
4467
4468/** The first frame with a box to focus honours a request that came too early. */
4469function takePendingOmniFocus() {
4470 if (!omniFocusAsked) return;
4471 if (Date.now() - omniFocusAsked > OMNI_FOCUS_WAIT_MS) {
4472 omniFocusAsked = 0;
4473 return;
4474 }
4475 if (tabMode !== "groups" || !connected || !tmuxMode) return;
4476 omniFocusAsked = 0;
4477 focusOmni();
4478}
4479
4480/**
4481 * Put the caret in the box, from wherever focus was.
4482 *
4483 * Whether the keyboard follows is not this document's to decide. Chrome hands
4484 * the panel focus when it opens it and at no other time — there is no API to
4485 * focus a panel that is already up — so the caret and the selection made here
4486 * are real either way, but they only *look* like a selection when the panel is
4487 * the focused surface. The worker leans on that: a shortcut pressed while the
4488 * panel is closed becomes an open, which is the path that focuses.
4489 */
4490function focusOmni() {
4491 // Not ready to hold a caret yet. Remember the ask; the next frame that has a
4492 // box takes it.
4493 if (tabMode !== "groups" || !connected || !tmuxMode) {
4494 omniFocusAsked = Date.now();
4495 return;
4496 }
4497 omniFocusAsked = 0;
4498 const input = $area("omni");
4499
4500 // A panel coming up for the first time gets its focus somewhere in the next
4501 // few hundred milliseconds, and a selection made before that arrives is
4502 // collapsed back to a caret when it does. So this takes the caret and the
4503 // selection back across that window. Only two things end it early, and both
4504 // mean the box is already being used: text typed into it, or a click placing
4505 // the caret by hand.
4506 let live = true;
4507 const stop = () => {
4508 live = false;
4509 input.removeEventListener("input", stop);
4510 input.removeEventListener("mousedown", stop);
4511 window.removeEventListener("focus", reselect);
4512 };
4513 const reselect = () => {
4514 if (!live) return;
4515 input.focus();
4516 input.select();
4517 // The list belongs to the same gesture as the caret, and the same startup
4518 // churn that drops the selection can close it. Put it back too, but only
4519 // when it is gone: rebuilding an open list would move the chosen row out
4520 // from under an arrow key.
4521 if ($("omni-list").hidden) {
4522 omniDirty = false;
4523 refreshOmni();
4524 }
4525 };
4526 input.addEventListener("input", stop);
4527 input.addEventListener("mousedown", stop);
4528 window.addEventListener("focus", reselect);
4529
4530 reselect();
4531 for (const ms of [0, 16, 50, 120, 250, 400, 600]) setTimeout(reselect, ms);
4532 setTimeout(stop, 800);
4533
4534 // The list drops down on focus, and that is the focus event's doing — which
4535 // does not fire when the box already held the caret, and cannot be counted
4536 // on when the panel is still coming up around it. Asking for it here makes
4537 // the shortcut mean the same thing however it arrived: the box, its name
4538 // selected, and everywhere else already listed under it.
4539 omniDirty = false;
4540 refreshOmni();
4541}
4542
4543$area("omni").addEventListener("input", () => {
4544 const input = $area("omni");
4545 // The box wraps, but it still holds one line: Enter runs a row rather than
4546 // breaking the line, so the only way a newline gets in is a paste — and a
4547 // command with a hard newline in the middle of it is not what was pasted,
4548 // it is what the clipboard happened to be carrying. Each becomes a space,
4549 // and the caret keeps its place because the length does not change.
4550 if (input.value.includes("\n")) {
4551 const at = input.selectionStart;
4552 input.value = input.value.replace(/[\r\n]/g, " ");
4553 input.setSelectionRange(at, at);
4554 }
4555 // The location has been typed over, so it is a query from here on.
4556 omniDirty = true;
4557 refreshOmni();
4558});
4559
4560// Focus selects the whole name, so the first letter typed replaces it — the one
4561// behaviour that makes "the box holds where you are" and "the box is how you go
4562// somewhere else" the same box. Opening the list here rather than on the first
4563// keystroke: with nothing typed it is already the list of everywhere else.
4564$area("omni").addEventListener("focus", () => {
4565 omniDirty = false;
4566 $area("omni").select();
4567 refreshOmni();
4568});
4569
4570// Late enough for a row's own click to have run first. Leaving focus abandons
4571// whatever was typed, exactly as a browser's does — the box goes back to
4572// saying where you are.
4573$area("omni").addEventListener("blur", () =>
4574 setTimeout(() => {
4575 // A blur that leaves the caret where it was is the panel gaining or losing
4576 // the keyboard, not the box being left — and that happens under the box on
4577 // the way up, when the shortcut is what opened this panel. Closing on it
4578 // would take the list away from a box that is still focused.
4579 if (document.activeElement === $area("omni")) return;
4580 closeOmni();
4581 omniDirty = false;
4582 syncOmniHere();
4583 }, 0),
4584);
4585
4586$area("omni").addEventListener("keydown", (e) => {
4587 const ev = /** @type {KeyboardEvent} */ (e);
4588 const key = ev.key;
4589 // Ctrl+J / Ctrl+K move the selection too, but only while the list is up:
4590 // with nothing open they belong to the terminal, and Ctrl+K in particular is
4591 // a line-kill an emacs-keyed shell expects to get.
4592 if (ev.ctrlKey && !ev.altKey && !ev.metaKey && (key === "j" || key === "k") && omniItems.length) {
4593 e.preventDefault();
4594 moveOmni(key === "j" ? 1 : -1);
4595 } else if (key === "ArrowDown" || key === "ArrowUp") {
4596 e.preventDefault();
4597 moveOmni(key === "ArrowDown" ? 1 : -1);
4598 } else if (key === "Tab" && omniItems.some((i) => i.complete)) {
4599 // What Tab has meant in every box that has ever held a path: take the
4600 // completion. The chosen one if a row is chosen, the first otherwise, which
4601 // is the same rule Enter follows.
4602 e.preventDefault();
4603 const active = omniItems[omniActive];
4604 const item = active?.complete ? active : omniItems.find((i) => i.complete);
4605 item?.run?.();
4606 } else if (key === "Enter") {
4607 e.preventDefault();
4608 // Enter with nothing chosen takes the top row, which is what the list is
4609 // sorted for — you type three letters and press Enter without looking.
4610 runOmni(omniActive < 0 ? 0 : omniActive);
4611 } else if (key === "Escape") {
4612 e.preventDefault();
4613 // First Escape puts the location back, the second gives the terminal back
4614 // — the same two steps Escape takes in a browser's address bar.
4615 if (omniDirty) revertOmni();
4616 else {
4617 closeOmni();
4618 term.focus();
4619 }
4620 }
4621});
4622
4623// --- session tabs -----------------------------------------------------------
4624//
4625// The top row: every session on the server. Selecting one is a switch-client —
4626// the client this panel holds moves, so the pty underneath is never re-spawned
4627// and nothing running is disturbed.
4628//
4629// Session names come from tmux (a user or a shell script named them, and both
4630// can put anything in a name) and only ever reach the DOM through textContent.
4631//
4632// Order: the daemon sends them oldest first, so a session you just made is on
4633// the end rather than wherever its name sorts. Dragging a tab overrides that,
4634// and the override is this panel's own — tmux has no notion of session order to
4635// change, unlike windows, which are dragged with a real move-window.
4636/** @type {string[]} session names, in the order this panel shows them */
4637let sessionOrder = [];
4638
4639/**
4640 * Saved order first, in its own sequence; then everything it doesn't mention,
4641 * in the daemon's (creation) order. A session that comes back after a while
4642 * therefore returns to where you last put it, and a brand new one lands last.
4643 *
4644 * @param {TbSessionInfo[]} sessions
4645 * @returns {TbSessionInfo[]}
4646 */
4647function orderSessions(sessions) {
4648 const known = new Map(sessions.map((s) => [s.name, s]));
4649 /** @type {TbSessionInfo[]} */
4650 const out = [];
4651 for (const name of sessionOrder) {
4652 const s = known.get(name);
4653 if (s) {
4654 out.push(s);
4655 known.delete(name);
4656 }
4657 }
4658 return [...out, ...known.values()];
4659}
4660
4661/** @param {string[]} names the row's order, as dragged */
4662function saveSessionOrder(names) {
4663 sessionOrder = names;
4664 storage.set({ sessionOrder });
4665}
4666
4667/**
4668 * @param {TbSessionInfo[]} unordered as the daemon sent them
4669 * @param {string | null | undefined} current the session this panel is on
4670 * @param {TbAgent[]} agents server-wide, for the glyph on each tab
4671 */
4672function renderSessionTabs(unordered, current, agents) {
4673 const sessions = orderSessions(unordered);
4674 const strip = $("sessions");
4675 // A repaint mid-drag would tear the tab out from under the pointer, and the
4676 // frames arrive once a second whether or not anything moved.
4677 if (dragging && !strip.hidden) return;
4678 const show = connected && tmuxMode && sessions.length > 0;
4679 strip.hidden = !show;
4680 $("session-new").hidden = !show;
4681 // Two things cannot both hold the row: a tab carries the session name, so
4682 // the status text only speaks when there is no tab to speak for it.
4683 document.body.classList.toggle("has-session", show);
4684 if (!show) {
4685 strip.textContent = "";
4686 strip.dataset.sig = "";
4687 hideSessionInput();
4688 syncSpinner();
4689 return;
4690 }
4691
4692 const claude = agentBySession(agents);
4693 const sig = JSON.stringify(
4694 sessions.map((s) => {
4695 const a = claude[s.name];
4696 const selected = s.name === current;
4697 // The selected tab draws no glyph, so what its agent is doing cannot
4698 // change what it looks like — and must not be in here, or every state
4699 // change in the session you are *on* rebuilds the whole row and drops
4700 // whatever the pointer was hovering. The tooltip still names it, and a
4701 // tooltip is not worth a repaint.
4702 return [
4703 s.name,
4704 s.windows.length,
4705 s.attached,
4706 selected,
4707 selected ? null : a?.state,
4708 selected ? null : agentLabel(a),
4709 ];
4710 }),
4711 );
4712 if (strip.dataset.sig !== sig) {
4713 strip.dataset.sig = sig;
4714 strip.textContent = "";
4715 for (const s of sessions) {
4716 strip.appendChild(sessionTab(s, s.name === current, claude[s.name]));
4717 }
4718 }
4719
4720 syncSpinner();
4721
4722 const active = strip.querySelector('[aria-selected="true"]');
4723 // A narrow panel scrolls this row too, and the session you are on is the one
4724 // that has to stay in sight.
4725 if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
4726}
4727
4728/**
4729 * @param {TbAgent[]} agents
4730 * @returns {Record<string, TbAgent>} session name → the one worth reporting
4731 */
4732function agentBySession(agents) {
4733 /** @type {Record<string, TbAgent>} */
4734 const out = {};
4735 for (const a of agents) {
4736 const seen = out[a.session];
4737 if (!seen || AGENT_RANK.indexOf(a.state) < AGENT_RANK.indexOf(seen.state)) {
4738 out[a.session] = a;
4739 }
4740 }
4741 return out;
4742}
4743
4744/**
4745 * @param {TbSessionInfo} s
4746 * @param {boolean} selected
4747 * @param {TbAgent} [claude] the agent worth reporting anywhere in this session
4748 */
4749function sessionTab(s, selected, claude) {
4750 const linked = tabPinMark({ session: s.name, window: null });
4751 const tab = button(
4752 {
4753 class:
4754 `session-tab${s.attached && !selected ? " attached" : ""}` +
4755 `${linked ? " tab-linked" : ""}`,
4756 attrs: { role: "tab", "aria-selected": selected },
4757 data: { session: s.name },
4758 // No window count on the tab. The row below it *is* the count for the
4759 // session you are on, and for the others the number was never the thing
4760 // you were choosing by — the name is. It stays in the tooltip.
4761 title: tip(
4762 `session ${s.name}`,
4763 `${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`,
4764 s.attached && !selected && "attached elsewhere",
4765 claude && `claude ${claude.state} — ${agentLabel(claude)}`,
4766 linked && PIN_SOURCE_NOTE[linked],
4767 ),
4768 on: {
4769 click: () => {
4770 // Moves the existing client: no reconnect, no second pty, and whatever
4771 // is running in the session we leave keeps running.
4772 if (!selected) tmuxCommand({ cmd: "switch", session: s.name });
4773 term.focus();
4774 },
4775 // The nested layout has no group chip, so this is the only place a
4776 // session-level pin can be reached from in it.
4777 /** @param {MouseEvent} e */
4778 contextmenu: (e) => {
4779 e.preventDefault();
4780 closeTabMenu();
4781 const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
4782 pinMenuItems(menu, { session: s.name, window: null });
4783 // Nothing to offer for a tab with neither an origin nor an id — a
4784 // browser page, say. An empty menu is worse than none.
4785 if (!menu.childElementCount) return;
4786 document.body.appendChild(menu);
4787 placeMenu(menu, e);
4788 },
4789 },
4790 },
4791 // The glyph goes on a session you are not on and nowhere else. It reports the
4792 // loudest agent *anywhere* in the session, which is worth a light when the
4793 // windows it is summarising are out of sight — and is nothing but a second,
4794 // coarser copy of the window row when they are not. On the selected tab it
4795 // also sits an inch above a spinner saying the same thing about the same
4796 // Claude, animating out of step with it, which is the distracting part.
4797 !selected && glyphSpan(claude?.state),
4798 el("span", { class: "name", text: s.name }),
4799 );
4800 makeDraggable(tab);
4801 return tab;
4802}
4803
4804// --- new session ------------------------------------------------------------
4805//
4806// A window can be created without asking — tmux names it after the directory —
4807// but a session's name is its identity and the only handle you get on it from a
4808// terminal, so this one is worth a prompt. Inline, because a modal would block
4809// this page's message handler while the socket keeps delivering frames.
4810
4811function hideSessionInput() {
4812 const input = $input("session-name");
4813 input.hidden = true;
4814 input.value = "";
4815}
4816
4817/** The field, wherever `applyTabMode` has put it: the session row in the nested
4818 layout, the tab row in groups mode, where "+"'s menu is what opens it. */
4819function showSessionInput() {
4820 const input = $input("session-name");
4821 input.hidden = false;
4822 input.focus();
4823}
4824
4825$("session-new").addEventListener("click", showSessionInput);
4826
4827$input("session-name").addEventListener("keydown", (e) => {
4828 const key = /** @type {KeyboardEvent} */ (e).key;
4829 if (key === "Escape") {
4830 hideSessionInput();
4831 term.focus();
4832 return;
4833 }
4834 if (key !== "Enter") return;
4835 const name = $input("session-name").value.trim();
4836 hideSessionInput();
4837 // The daemon validates the name and ignores anything it doesn't like; `-A`
4838 // there means an existing name attaches rather than failing.
4839 if (name) tmuxCommand({ cmd: "create", session: name });
4840 term.focus();
4841});
4842
4843// Clicking away is a cancel: the input is only ever one keystroke from being
4844// re-opened, and a stray text box in the tab row is worse than a lost name.
4845$input("session-name").addEventListener("blur", hideSessionInput);
4846
4847/* --- the working spinner ---------------------------------------------------
4848 Claude Code's own asterisk cycle, so a tab that is thinking looks like the
4849 transcript that is thinking. The frames grow and shrink rather than spin:
4850 a dot swelling to a full asterisk and back, which reads as activity at
4851 10px where a rotating glyph would just shimmer. */
4852const SPINNER_FRAMES = ["·", "✢", "✳", "∗", "✻", "✽", "✻", "∗", "✳", "✢"];
4853/** What a tab shows when it is not mid-cycle. */
4854/** @type {Record<string, string>} */
4855// `ready` and `idle` are the same glyph on purpose: the shape says "Claude is
4856// at rest here", and only the colour says whether that rest is news to you.
4857const STATIC_GLYPH = { waiting: "✳", ready: "✻", idle: "✻", unknown: "·", none: "" };
4858const SPINNER_MS = 130;
4859
4860const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
4861let spinnerStep = 0;
4862/** @type {number | undefined} */
4863let spinnerTimer;
4864
4865// Reduced motion keeps the glyph — the tab still says "working" — and parks it
4866// on the frame the animation spends the most time looking like.
4867function spinnerGlyph() {
4868 return reducedMotion.matches ? "✻" : SPINNER_FRAMES[spinnerStep % SPINNER_FRAMES.length];
4869}
4870
4871/**
4872 * One timer for the whole strip, running only while something is working, so
4873 * an idle panel is not repainting four times a second forever. Repaints touch
4874 * textContent only: renderTabs owns the elements and skips its rebuild whenever
4875 * the signature is unchanged, so the spinner never fights it.
4876 */
4877function syncSpinner() {
4878 const working = document.querySelectorAll("header .glyph.working");
4879 if (!working.length || reducedMotion.matches) {
4880 clearInterval(spinnerTimer);
4881 spinnerTimer = undefined;
4882 return;
4883 }
4884 if (spinnerTimer !== undefined) return;
4885 spinnerTimer = setInterval(() => {
4886 spinnerStep++;
4887 const frame = spinnerGlyph();
4888 const live = document.querySelectorAll("header .glyph.working");
4889 if (!live.length) return syncSpinner();
4890 for (const el of live) el.textContent = frame;
4891 }, SPINNER_MS);
4892}
4893
4894// Turning the preference on mid-run has to stop the timer and settle the
4895// glyphs where they are, not leave them frozen on whatever frame was up.
4896reducedMotion.addEventListener("change", () => {
4897 const frame = spinnerGlyph();
4898 for (const el of document.querySelectorAll("header .glyph.working")) el.textContent = frame;
4899 syncSpinner();
4900});
4901
4902// A tooltip's worth of room, so show the most specific thing known: what it is
4903// blocked on, what tool it is running, else the mode.
4904//
4905// Every value here comes from Claude Code's hook payloads by way of the daemon
4906// — a user prompt, a tool name, a notification message — and is only ever
4907// assigned through textContent/title, never parsed as markup.
4908/** @param {TbAgent} [a] */
4909function agentLabel(a) {
4910 if (!a) return "";
4911 if (a.state === "waiting") return a.message || "waiting";
4912 if (a.state === "working") return a.tool || shortMode(a.mode) || "working";
4913 if (a.state === "ready") return "finished its turn";
4914 if (a.state === "unknown") return "no hook records — run: termbridge hooks";
4915 return shortMode(a.mode) || "idle";
4916}
4917
4918/* --- action required -------------------------------------------------------
4919 `ready` is an idle Claude in a pane that has not been on screen since it went
4920 idle — the daemon derives it (see `SEEN` in daemon/src/status.rs) and it
4921 arrives as a state like any other. It wears the same glyph as idle in a
4922 colour that is not grey, which is the smallest thing that reads as "come back
4923 to this" without inventing a second vocabulary.
4924
4925 It is the daemon's to know rather than this panel's because tmux is what
4926 knows which pane is in front of you, and because two panels on one server
4927 should not each keep a private opinion about the same window. */
4928
4929// permission_mode arrives camelCased, straight from Claude's hook payload.
4930/** @type {Record<string, string>} */
4931const MODE_SHORT = {
4932 default: "idle",
4933 acceptEdits: "accept edits",
4934 plan: "plan",
4935 bypassPermissions: "bypass",
4936};
4937
4938/** @param {string | null | undefined} mode */
4939function shortMode(mode) {
4940 if (!mode) return "";
4941 return MODE_SHORT[mode] ?? mode;
4942}
4943
4944/** @param {TbOkFrame} msg */
4945function renderSessions(msg) {
4946 if (!msg.tmux) return;
4947 const names = msg.sessions ?? [];
4948 const list = $("session-list");
4949 list.textContent = "";
4950 for (const name of names) list.appendChild(el("option", { attrs: { value: name } }));
4951 $input("session").placeholder = msg.defaultSession ?? defaultSession;
4952 if (names.length) log(`tmux sessions: ${names.join(", ")}`);
4953}
4954
4955$("session-apply").addEventListener("click", () => {
4956 const name = $input("session").value.trim();
4957 storage.set({ session: name });
4958 // Connected, this creates-or-attaches and moves the live client — the field
4959 // is how you reach a session that doesn't exist yet, which the header's
4960 // switcher (existing sessions only) can't do. Disconnected, it's the session
4961 // the next connection opens with.
4962 if (connected && name) {
4963 tmuxCommand({ cmd: "create", session: name });
4964 closeSettings();
4965 term.focus();
4966 return;
4967 }
4968 connect();
4969});
4970
4971// --- element picker ---------------------------------------------------------
4972//
4973// Everything the picker returns is page-controlled data. It is displayed, and
4974// it only reaches the terminal when the user explicitly clicks "insert" — and
4975// then only after Sanitize.forTerminal has stripped control characters and
4976// shell-quoted it.
4977
4978let picked = /** @type {TbPicked | null} */ (null);
4979
4980/** Why the panel is open, kept so switching format doesn't erase it. */
4981let pickedProblem = "";
4982
4983// XPath by default: it always exists, it addresses exactly one node, and it
4984// survives the class-name churn that a CSS selector built from a framework's
4985// generated class names does not.
4986let pickedFormat = /** @type {TbPickedFormat} */ ("xpath");
4987
4988// Ordered by how often they're the one you want.
4989/** @type {[TbPickedFormat, string][]} */
4990const FORMATS = [
4991 ["xpath", "XPath"],
4992 ["css", "CSS"],
4993 ["id", "id"],
4994 ["testid", "test id"],
4995 ["text", "text"],
4996 ["href", "href"],
4997];
4998
4999/**
5000 * The tabs a pick should run in. Normally one; in a split view, both halves,
5001 * because only one of the two visible tabs is ever `active` and the other is
5002 * just as clickable. See lib/split.js.
5003 *
5004 * @returns {Promise<{ tab: TbTab | undefined; targets: TbTab[] }>}
5005 */
5006async function pickTargets() {
5007 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
5008 if (!tab || SKIP_URL.test(tab.url ?? "")) return { tab, targets: [] };
5009 const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP_URL.test(t.url ?? ""));
5010 return { tab, targets };
5011}
5012
5013/**
5014 * `https://example.com/*` — the narrowest pattern that covers this page.
5015 * @param {string | undefined} url
5016 */
5017function originPattern(url) {
5018 if (!url) return null;
5019 try {
5020 return `${new URL(url).origin}/*`;
5021 } catch {
5022 return null;
5023 }
5024}
5025
5026/**
5027 * @param {string} text
5028 * @param {string} [bad] a warning to show alongside it
5029 */
5030function pickNote(text, bad) {
5031 // Loud enough to notice without opening the log: the picker failing silently
5032 // is the whole reason this feature felt broken.
5033 $("picked").hidden = false;
5034 $("picked-value").textContent = text;
5035 $("picked-warn").hidden = !bad;
5036 $("picked-warn").textContent = bad ?? "";
5037 $("picked-grant").hidden = true;
5038 log(text);
5039}
5040
5041/**
5042 * Offer a per-site grant rather than shipping a blanket <all_urls> permission.
5043 *
5044 * localhost is granted up front because it's your own machine; everything else
5045 * is opt-in, one origin at a time, via a prompt the browser shows.
5046 *
5047 * @param {string} pattern
5048 * @param {TbTab[]} targets the tabs the pick would run in
5049 */
5050function offerGrant(pattern, targets) {
5051 $("picked").hidden = false;
5052 $("picked-value").textContent = `No access to ${pattern}`;
5053 $("picked-warn").hidden = false;
5054 $("picked-warn").textContent =
5055 "Grant access to this site, or use Alt+Shift+P which needs no permission.";
5056 const btn = $("picked-grant");
5057 btn.hidden = false;
5058 btn.textContent = `Allow ${pattern}`;
5059 btn.onclick = async () => {
5060 // Must be called from a user gesture, which this click is.
5061 const granted = await api.permissions.request({ origins: [pattern] });
5062 if (granted) {
5063 btn.hidden = true;
5064 log(`granted ${pattern}`);
5065 runPick(targets);
5066 } else {
5067 log(`declined ${pattern}`);
5068 }
5069 };
5070}
5071
5072/**
5073 * The capture the picker wants is the one permission a per-origin grant cannot
5074 * buy. `tabs.captureVisibleTab` accepts exactly two things: the `activeTab`
5075 * grant a keyboard command mints, or a host permission set that contains the
5076 * literal `<all_urls>` pattern. A per-origin grant fails the check, and so does
5077 * the all-scheme-wildcard pattern in optional_host_permissions, which misses
5078 * `file:` and so isn't "all". That is why picking from the panel button used to
5079 * hand back a selector and no image on every site you'd approved.
5080 *
5081 * @param {string} reason the failure copyPickedShot reported
5082 */
5083function isCapturePermissionError(reason) {
5084 return /all_urls|activeTab/i.test(reason);
5085}
5086
5087/**
5088 * Offer the one grant that makes the panel button capture, alongside a pick
5089 * that already succeeded. Screenshots stay opt-in: nothing here is requested
5090 * until the button is pressed, and Alt+Shift+P keeps working without it.
5091 *
5092 * @param {string} reason
5093 */
5094function offerCaptureGrant(reason) {
5095 const btn = /** @type {HTMLButtonElement} */ ($("picked-grant"));
5096 btn.hidden = false;
5097 btn.textContent = "Allow screenshots on all sites";
5098 btn.onclick = async () => {
5099 // Must be called from a user gesture, which this click is.
5100 const granted = await api.permissions.request({ origins: ["<all_urls>"] });
5101 if (!granted) {
5102 log("declined <all_urls>");
5103 return;
5104 }
5105 btn.hidden = true;
5106 log("granted <all_urls>");
5107 // The shot that prompted this is long gone from the viewport's timeline;
5108 // re-picking is the honest way to get one, so say so rather than silently
5109 // leaving the old warning up.
5110 pickedProblem = "Screenshots are on. Pick again to get one.";
5111 refreshPicked();
5112 };
5113 log(`screenshot needs <all_urls>: ${reason}`);
5114}
5115
5116const SKIP_URL = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/;
5117
5118// The tabs a pick is currently running in — more than one in a split view.
5119// Empty means no pick is running, which is also the "is picking" flag.
5120let pickTabs = /** @type {number[]} */ ([]);
5121
5122/**
5123 * Cancel an in-flight pick, in every half it is running in.
5124 */
5125async function cancelPick() {
5126 if (!pickTabs.length) return;
5127 await Split.cancelPicks(api, pickTabs);
5128}
5129
5130/**
5131 * Run a pick across `targets`. Returns true on success, or a message explaining
5132 * why every injection failed.
5133 *
5134 * @param {TbTab[]} targets
5135 * @returns {Promise<true | string>}
5136 */
5137async function runPick(targets) {
5138 const btn = $("pick");
5139 pickTabs = [];
5140 for (const t of targets) if (t.id != null) pickTabs.push(t.id);
5141 btn.classList.add("active");
5142 setStatus("pending", "pick mode — click an element, Esc cancels");
5143 try {
5144 const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement);
5145 if (value) {
5146 const shot = await Shot.copyPickedShot(api, tabId, value);
5147 await deliverPick(value, shot);
5148 } else if (error) {
5149 return error;
5150 } else log("pick cancelled");
5151 return true;
5152 } catch (e) {
5153 return e instanceof Error ? e.message : String(e);
5154 } finally {
5155 pickTabs = [];
5156 btn.classList.remove("active");
5157 refreshStatus();
5158 }
5159}
5160
5161$("pick").addEventListener("click", async () => {
5162 // Second press toggles it back off rather than doing nothing.
5163 if (pickTabs.length) {
5164 await cancelPick();
5165 return;
5166 }
5167
5168 const { tab, targets } = await pickTargets();
5169 if (!targets.length) {
5170 pickNote(
5171 "Can't pick here.",
5172 "Browser-internal and extension pages are off limits to all extensions. Switch to a normal web page.",
5173 );
5174 return;
5175 }
5176
5177 setStatus("pending", "pick mode — click an element, Esc cancels");
5178 const outcome = await runPick(targets);
5179 if (outcome === true) return;
5180
5181 // The failure is nearly always a missing host permission for this origin.
5182 const pattern = originPattern(tab?.url);
5183 if (pattern && /permission|access/i.test(outcome)) {
5184 offerGrant(pattern, targets);
5185 } else {
5186 pickNote("Couldn't reach the page.", `Try Alt+Shift+P instead. [${outcome}]`);
5187 }
5188});
5189
5190// Escape from the sidebar too. The picker handles Escape itself, but only when
5191// the page has keyboard focus — if you started the pick from here, focus is
5192// still in the panel and the key never reaches the page.
5193window.addEventListener("keydown", (e) => {
5194 // These work anywhere in the panel, not just with the terminal focused.
5195 // xterm.js consumes its own keydowns before they reach here, so it has its
5196 // own handler for them too.
5197 if (handlePanelKey(e)) return;
5198 if (e.key !== "Escape") return;
5199 if (openMenu) {
5200 e.preventDefault();
5201 closeTabMenu();
5202 return;
5203 }
5204 // After the menus, before the pick: a popup is the nearest thing open.
5205 if (settingsOpen) {
5206 e.preventDefault();
5207 closeSettings();
5208 term.focus();
5209 return;
5210 }
5211 if (pickTabs.length) {
5212 e.preventDefault();
5213 cancelPick();
5214 return;
5215 }
5216 // A pick the keyboard shortcut started belongs to the worker, and this panel
5217 // has no record of it. Ask; the worker ignores it when nothing is picking.
5218 togglePort?.postMessage({ type: "cancel-pick" });
5219});
5220
5221// Results arriving from the background worker (the keyboard-shortcut path).
5222//
5223// Guarded: content scripts always have `sender.tab` set, so rejecting those
5224// leaves only our own extension pages and worker. This is the one inbound
5225// message path in the sidebar, and it exists solely because the shortcut has to
5226// be handled in the background.
5227api.runtime.onMessage.addListener((msg, sender) => {
5228 if (sender?.id !== api.runtime.id) return;
5229 if (sender?.tab) return;
5230 if (msg?.type === "picked" && msg.value) {
5231 // The worker owns the screenshot on this path: it holds the activeTab
5232 // grant the shortcut just minted, and the clipboard write has to happen
5233 // while the page it captured is still the focused one.
5234 deliverPick(msg.value, msg.shot === true ? true : msg.shot || "not captured");
5235 }
5236});
5237
5238// The shortcut half of the port described in sw.js: while this document is
5239// alive it stays connected and reports whether it holds focus, so the worker
5240// can decide between open, focus and close without asking first.
5241// Only the three commands below arrive here; nothing on this port touches the
5242// WebSocket.
5243/** @type {TbPort | null} */
5244let togglePort = null;
5245
5246function connectToggle() {
5247 api.windows.getCurrent().then((win) => {
5248 // The same answer the tab pins need: which browser window this panel is
5249 // one of. Set here rather than in a second getCurrent() call, because this
5250 // one already runs at startup and the pins are useless without it.
5251 //
5252 // First connection only. Chrome retires an idle service worker and this
5253 // runs again a second later, and re-running the whole follow on each of
5254 // those would keep re-deciding a question the tab has not re-asked.
5255 const first = panelWindowId == null;
5256 panelWindowId = win.id;
5257 if (first) syncTabPin();
5258 const port = api.runtime.connect({ name: "sidebar" });
5259 togglePort = port;
5260 port.postMessage({ type: "hello", windowId: win.id, focused: document.hasFocus() });
5261 port.onMessage.addListener((msg) => {
5262 if (msg?.type === "close") window.close();
5263 else if (msg?.type === "focus") term.focus();
5264 // A browser command rather than a key this page listens for, so the
5265 // binding is the browser's to own: it shows up in chrome://extensions/
5266 // shortcuts with the other two and can be rebound or cleared there. A
5267 // hardcoded keydown here would keep firing on the old key afterwards.
5268 else if (msg?.type === "omnibar") focusOmni();
5269 });
5270 port.onDisconnect.addListener(() => {
5271 // Chrome may retire an idle service worker under us. Nothing here is
5272 // urgent, so reconnect lazily rather than fighting for the port.
5273 if (togglePort === port) togglePort = null;
5274 setTimeout(connectToggle, 1000);
5275 });
5276 });
5277}
5278
5279const reportFocus = () => togglePort?.postMessage({ type: "focus", focused: document.hasFocus() });
5280window.addEventListener("focus", reportFocus);
5281window.addEventListener("blur", reportFocus);
5282connectToggle();
5283
5284// A pick made while the sidebar was closed is parked in storage. Nothing is
5285// typed for these: the terminal has moved on, and whatever screenshot went with
5286// it left the clipboard long ago. Show it and let the user decide.
5287storage.get("pendingPick").then((v) => {
5288 if (v.pendingPick) {
5289 showPicked(v.pendingPick);
5290 storage.remove("pendingPick");
5291 }
5292});
5293
5294function currentPickedRaw() {
5295 if (!picked) return "";
5296 return picked[pickedFormat] ?? "";
5297}
5298
5299/**
5300 * Open the panel on a pick. Only called when the pick needs the user's
5301 * attention — see deliverPick.
5302 *
5303 * @param {TbPicked} value everything in here is page-controlled
5304 * @param {string} [problem] why the panel is opening
5305 */
5306function showPicked(value, problem) {
5307 picked = value;
5308 $("picked-warn").hidden = true;
5309 $("picked-grant").hidden = true;
5310
5311 $("picked-tag").textContent = value.tag ? `<${value.tag}>` : "?";
5312 $("picked-meta").textContent = value.text || value.href || value.pageUrl || "";
5313 $("picked-meta").title = value.pageUrl || "";
5314
5315 // Only offer formats this element actually has — an empty tab is a dead end.
5316 const available = FORMATS.filter(([key]) => value[key]);
5317 if (!available.some(([key]) => key === pickedFormat)) {
5318 pickedFormat = available[0]?.[0] ?? "css";
5319 }
5320
5321 const bar = $("picked-formats");
5322 bar.textContent = "";
5323 for (const [key, label] of available) {
5324 const b = button({
5325 text: label,
5326 attrs: { role: "tab", "aria-selected": key === pickedFormat },
5327 on: {
5328 click: () => {
5329 pickedFormat = key;
5330 for (const other of bar.children) {
5331 other.setAttribute("aria-selected", String(other === b));
5332 }
5333 refreshPicked();
5334 },
5335 },
5336 });
5337 bar.appendChild(b);
5338 }
5339
5340 pickedProblem = problem ?? "";
5341 $("picked").hidden = false;
5342 refreshPicked();
5343}
5344
5345function refreshPicked() {
5346 const raw = currentPickedRaw();
5347 const el = $("picked-value");
5348 el.textContent = raw || "not present on this element";
5349 el.classList.toggle("empty", !raw);
5350
5351 const { removedControl, truncated } = Sanitize.forTerminal(raw);
5352 const notes = [];
5353 if (removedControl) notes.push("control characters removed");
5354 if (truncated) notes.push("truncated");
5355 const warn = $("picked-warn");
5356 const modified = notes.length ? `Modified before use: ${notes.join(", ")}.` : "";
5357 warn.textContent = [pickedProblem, modified].filter(Boolean).join(" ");
5358 warn.hidden = !warn.textContent;
5359
5360 /** @type {HTMLButtonElement} */ ($("picked-insert")).disabled = !raw;
5361}
5362
5363$("picked-close").addEventListener("click", () => {
5364 $("picked").hidden = true;
5365 picked = null;
5366});
5367
5368$("picked-copy").addEventListener("click", async () => {
5369 await navigator.clipboard.writeText(Sanitize.forClipboard(currentPickedRaw()));
5370 log("copied to clipboard");
5371});
5372
5373/**
5374 * Send the pick to the terminal: the screenshot first, then the selector.
5375 *
5376 * The screenshot is already on the system clipboard by the time this runs, so
5377 * "sending" it is a literal ^V. That byte is not a paste as far as this panel
5378 * is concerned — xterm.js never sees it — it goes down the wire, and an agent
5379 * on the other end that handles image paste (Claude Code does) reads the
5380 * clipboard itself and attaches the PNG. In a bare shell ^V is literal-next
5381 * instead, which is why it is only sent when there is an image to fetch.
5382 *
5383 * @param {boolean} withShot
5384 */
5385async function insertPicked(withShot) {
5386 if (!connected || !ws) {
5387 log("not connected");
5388 return;
5389 }
5390 const { text } = Sanitize.forTerminal(currentPickedRaw());
5391 if (withShot) {
5392 ws.send(enc.encode("\x16"));
5393 // Reading the clipboard is a round trip out to a helper process on the
5394 // agent's side. Text sent in the same breath can arrive first and end up
5395 // ahead of the attachment on the line.
5396 await new Promise((r) => setTimeout(r, 250));
5397 ws.send(enc.encode(" "));
5398 }
5399 // No trailing newline, ever. The user presses Enter themselves.
5400 ws.send(enc.encode(text));
5401 term.focus();
5402 log(withShot ? `inserted screenshot + ${text.length} chars` : `inserted ${text.length} chars`);
5403}
5404
5405/**
5406 * What a fresh pick does: type it into the terminal, and stay out of the way.
5407 *
5408 * The panel is deliberately *not* opened on the happy path. It shifts the
5409 * terminal down the moment you pick something, which is a poor trade when the
5410 * result has already been typed where you were looking. It opens only when
5411 * there is something to decide — no connection, or no screenshot — and then it
5412 * carries the reason.
5413 *
5414 * @param {TbPicked} value page-controlled, all of it
5415 * @param {true | string} shot true, or why there is no screenshot
5416 */
5417async function deliverPick(value, shot) {
5418 picked = value;
5419 log(`picked ${value.tag} on ${value.pageUrl}`);
5420 log(shot === true ? "screenshot on clipboard, sending ^V" : `no screenshot: ${shot}`);
5421
5422 const needsGrant = shot !== true && isCapturePermissionError(shot);
5423
5424 if (!connected || !ws) {
5425 showPicked(value, "Not connected — nothing was typed.");
5426 if (needsGrant) offerCaptureGrant(shot);
5427 return;
5428 }
5429 await insertPicked(shot === true);
5430 if (shot !== true) {
5431 showPicked(
5432 value,
5433 needsGrant
5434 ? "Selector only. Screenshots from this button need access to all sites; Alt+Shift+P needs none."
5435 : `Selector only, no screenshot: ${shot}`,
5436 );
5437 if (needsGrant) offerCaptureGrant(shot);
5438 }
5439}
5440
5441$("picked-insert").addEventListener("click", () => insertPicked(false));
5442
5443// Keystrokes out as binary, so nothing is lost to UTF-8 round-tripping.
5444term.onData((data) => {
5445 if (connected && ws) ws.send(enc.encode(data));
5446});
5447term.onBinary((data) => {
5448 if (!connected || !ws) return;
5449 const buf = new Uint8Array(data.length);
5450 for (let i = 0; i < data.length; i++) buf[i] = data.charCodeAt(i) & 255;
5451 ws.send(buf);
5452});
5453
5454new ResizeObserver(() => {
5455 fit.fit();
5456 sendSize();
5457}).observe($("term"));
5458
5459// --- settings / persistence -------------------------------------------------
5460
5461const themeSelect = $select("theme-select");
5462themeSelect.addEventListener("change", () => setTheme(themeSelect.value));
5463const densitySelect = $select("density-select");
5464densitySelect.addEventListener("change", () => setDensity(densitySelect.value));
5465const tabModeSelect = $select("tabmode-select");
5466tabModeSelect.addEventListener("change", () => setTabMode(tabModeSelect.value));
5467const newTabSelect = $select("newtab-select");
5468newTabSelect.addEventListener("change", () => setNewTabAction(newTabSelect.value));
5469
5470// The tab-pin switches. Pins are kept when the feature is turned off — you are
5471// silencing it, not throwing away what you told it — so nothing here touches
5472// `byTab` or `byOrigin`.
5473const followBox = $input("follow-tabs");
5474const detectBox = $input("detect-devport");
5475const leadBox = $input("lead-tabs");
5476
5477function applyTabPinSettings() {
5478 followBox.checked = tabPins.enabled;
5479 detectBox.checked = tabPins.detect;
5480 leadBox.checked = tabPins.reverse;
5481 // Both of these are subordinate clauses of the feature, and a live checkbox
5482 // that cannot do anything is a worse answer than a greyed-out one.
5483 detectBox.disabled = !tabPins.enabled;
5484 leadBox.disabled = !tabPins.enabled;
5485}
5486
5487followBox.addEventListener("change", () => {
5488 saveTabPins({ ...tabPins, enabled: followBox.checked });
5489 applyTabPinSettings();
5490});
5491detectBox.addEventListener("change", () => {
5492 saveTabPins({ ...tabPins, detect: detectBox.checked });
5493 applyTabPinSettings();
5494});
5495leadBox.addEventListener("change", () => {
5496 saveTabPins({ ...tabPins, reverse: leadBox.checked });
5497 applyTabPinSettings();
5498});
5499
5500$("font-smaller").addEventListener("click", () => setFontSize(fontSize - 1));
5501$("font-bigger").addEventListener("click", () => setFontSize(fontSize + 1));
5502$("font-reset").addEventListener("click", () => setFontSize(FONT_DEFAULT));
5503
5504// --- settings, as a popup ---------------------------------------------------
5505//
5506// A card hung off the chevron rather than a drawer at the foot of the panel:
5507// the same shape Chrome gives the popup on the other end of that same chevron.
5508//
5509// Out of flow, which is the substantive part. As a flex item the panel sized
5510// the terminal, so opening or closing it re-fit xterm.js and reflowed the
5511// scrollback — you lost your place to change a font size. Floating over the
5512// terminal, the terminal never moves.
5513//
5514// Not the tab menu's machinery, and not `openMenu`: those close on the first
5515// pointerdown anywhere, which is right for a menu of one-shot actions and wrong
5516// for a panel full of text fields you click into and drag across.
5517
5518/** Set while the popup is up, so the outside-click listener is only ever one. */
5519let settingsOpen = false;
5520
5521/** Put it under the chevron, folded back inside a panel too narrow for it. */
5522function placeSettings() {
5523 const pop = $("settings");
5524 const anchor = $("settings-toggle").getBoundingClientRect();
5525 const r = pop.getBoundingClientRect();
5526 pop.style.left = `${Math.max(4, Math.min(anchor.left, window.innerWidth - r.width - 4))}px`;
5527 pop.style.top = `${Math.min(anchor.bottom + 5, Math.max(4, window.innerHeight - r.height - 4))}px`;
5528}
5529
5530/** @param {PointerEvent | MouseEvent} e */
5531function onSettingsDismiss(e) {
5532 if (!(e.target instanceof Node)) return;
5533 // The chevron closes it through its own handler; swallowing the press here
5534 // would close and reopen it in the same click.
5535 if ($("settings").contains(e.target) || $("settings-toggle").contains(e.target)) return;
5536 closeSettings();
5537}
5538
5539function openSettings() {
5540 if (settingsOpen) return placeSettings();
5541 settingsOpen = true;
5542 $("settings").hidden = false;
5543 $("settings-toggle").setAttribute("aria-expanded", "true");
5544 placeSettings();
5545 // A resize here is the sidebar being dragged wider or the window changing —
5546 // either moves the chevron, and the card has to go with it.
5547 window.addEventListener("resize", placeSettings);
5548 // Deferred by a tick so the click that opened it does not also dismiss it.
5549 setTimeout(() => {
5550 if (settingsOpen) window.addEventListener("pointerdown", onSettingsDismiss, true);
5551 }, 0);
5552}
5553
5554function closeSettings() {
5555 if (!settingsOpen) return;
5556 settingsOpen = false;
5557 $("settings").hidden = true;
5558 $("settings-toggle").setAttribute("aria-expanded", "false");
5559 window.removeEventListener("resize", placeSettings);
5560 window.removeEventListener("pointerdown", onSettingsDismiss, true);
5561}
5562
5563$("settings-toggle").addEventListener("click", () => {
5564 if (settingsOpen) closeSettings();
5565 else openSettings();
5566});
5567$("settings-close").addEventListener("click", () => {
5568 closeSettings();
5569 term.focus();
5570});
5571$("reconnect").addEventListener("click", connect);
5572$("connect").addEventListener("click", connect);
5573$("offline-retry").addEventListener("click", connect);
5574$("offline-settings").addEventListener("click", openSettings);
5575$("trust-cert").addEventListener("click", () => {
5576 const u = new URL($input("url").value.trim());
5577 api.tabs.create({ url: `https://${u.host}/` });
5578});
5579$("copy-origin").addEventListener("click", () =>
5580 navigator.clipboard.writeText(`termbridge pair ${ORIGIN}`),
5581);
5582const tokenInput = $input("token");
5583const urlInput = $input("url");
5584const revealBox = $input("reveal");
5585revealBox.addEventListener("change", () => {
5586 tokenInput.type = revealBox.checked ? "text" : "password";
5587});
5588tokenInput.addEventListener("change", () =>
5589 storage.set({ token: tokenInput.value.trim() }),
5590);
5591urlInput.addEventListener("change", () => storage.set({ url: urlInput.value.trim() }));
5592
5593/** Everything this panel remembers between openings. */
5594const STORED_KEYS = [
5595 "token",
5596 "url",
5597 "session",
5598 "theme",
5599 "density",
5600 "fontSize",
5601 "pins",
5602 "sessionOrder",
5603 "tabMode",
5604 "newTabAction",
5605 "foldedGroups",
5606 Tabpin.KEY,
5607];
5608
5609storage.get(STORED_KEYS).then((v) => {
5610 if (v.pins && typeof v.pins === "object") pins = v.pins;
5611 tabPins = Tabpin.loadStore(v[Tabpin.KEY]);
5612 applyTabPinSettings();
5613 if (Array.isArray(v.foldedGroups)) {
5614 foldedGroups = new Set(v.foldedGroups.filter((n) => typeof n === "string"));
5615 }
5616 // Names, from storage this panel wrote — but storage is not a promise, so
5617 // anything that isn't a list of strings is dropped rather than trusted.
5618 if (Array.isArray(v.sessionOrder)) {
5619 sessionOrder = v.sessionOrder.filter((n) => typeof n === "string");
5620 }
5621 themePref = Themes.PREFERENCES.includes(v.theme) ? v.theme : "auto";
5622 density = DENSITIES[v.density] ? v.density : "normal";
5623 tabMode = TAB_MODES[v.tabMode] ? v.tabMode : "nested";
5624 newTabAction = NEW_TAB_ACTIONS[v.newTabAction] ? v.newTabAction : "claude";
5625 const stored = Number(v.fontSize);
5626 fontSize =
5627 Number.isFinite(stored) && stored >= FONT_MIN && stored <= FONT_MAX
5628 ? Math.round(stored)
5629 : FONT_DEFAULT;
5630 applyTheme();
5631 applyDensity();
5632 applyTabMode();
5633 applyNewTabAction();
5634 applyFontSize();
5635 if (v.token) $input("token").value = v.token;
5636 if (v.url) $input("url").value = v.url;
5637 if (v.session) $input("session").value = v.session;
5638 log(`origin: ${ORIGIN}`);
5639 if (v.token) {
5640 connect();
5641 } else {
5642 setStatus("off", "needs setup");
5643 openSettings();
5644 term.write(
5645 "\x1b[90m terminal\x1b[0m\r\n\r\n" +
5646 " Not configured yet. Open \x1b[1msettings\x1b[0m (top right)\r\n" +
5647 " to pair and paste your token.\r\n",
5648 );
5649 }
5650});