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