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