anvilsign in

collin/browser-terminal-extension · a71dc952

Drop stale autorepeat bursts flushed into the terminal under CPU load

Collin Richards · 2026-08-17 22:21 UTC · a71dc9528f5d70932d60b380d72f96bed25332d9 · parent ea0845f8 · browse files

modifiedextension/sidebar.js+902 −134
⋯ 23 unchanged lines
2424
2525 /** @param {string} id @returns {HTMLInputElement} */
2626 const $input = (id) => /** @type {HTMLInputElement} */ ($(id));
27+/** @param {string} id @returns {HTMLTextAreaElement} */
28+const $area = (id) => /** @type {HTMLTextAreaElement} */ ($(id));
2729 /** @param {string} id @returns {HTMLSelectElement} */
2830 const $select = (id) => /** @type {HTMLSelectElement} */ ($(id));
2931 /** @param {string} id @returns {HTMLButtonElement} */
⋯ 106 unchanged lines
136138 // The name field comes with it: "+" holds the menu that makes a session
137139 // now, and the field it opens has to be in a row that is on screen.
138140 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"));
141+ // Picker and jump both follow the tabs into the row below — controls that
142+ // act on a pane rather than on the tabs — but they take opposite ends of
143+ // it: the picker leads, and jump sits past the box, at the end of the row
144+ // you type into, where what it does is leave for somewhere else. Enter is
145+ // not in either row: it lives under the mic, in the corner beside every
146+ // row, and so never moves with the layout.
147+ $("omni-strip").prepend($("pick"));
148+ $("omni-strip").append($("jump"));
145149 $("omni-strip").hidden = false;
146150 // The session row is gone, so what it was showing has to go with it: a
147151 // stale session tab left in a hidden strip still answers `querySelector`
⋯ 128 unchanged lines
276280 term.open($("term"));
277281 fit.fit();
278282
283+// Autorepeat under load. When the browser process is starved, the key's
284+// release sits in the queue behind it while the OS keeps generating repeats
285+// for a key it still believes is held; when the process catches up, the whole
286+// backlog is flushed into the pty at once and a single tap comes out as a row
287+// of characters. Nothing here can stop the OS repeating, but the burst is
288+// easy to tell from a real hold: genuine autorepeat arrives ~30ms apart with
289+// fresh timestamps, a flushed backlog arrives all in one turn with old ones.
290+// Only `repeat` events are ever dropped, so the first press of a key always
291+// goes through, and a deliberate hold still repeats, just never faster than
292+// the OS would have delivered it live.
293+const REPEAT_MIN_GAP_MS = 20;
294+const REPEAT_MAX_AGE_MS = 150;
295+let lastRepeatAt = 0;
296+/** @param {KeyboardEvent} e */
297+function staleRepeat(e) {
298+ if (!e.repeat) return false;
299+ const now = performance.now();
300+ const stale = now - e.timeStamp > REPEAT_MAX_AGE_MS || now - lastRepeatAt < REPEAT_MIN_GAP_MS;
301+ if (!stale) lastRepeatAt = now;
302+ return stale;
303+}
304+
279305 // Returning false keeps the keystroke out of the pty.
280306 term.attachCustomKeyEventHandler((e) => {
281307 if (e.type !== "keydown") return true;
308+ if (staleRepeat(e)) return false;
282309 return !handlePanelKey(e);
283310 });
284311
⋯ 200 unchanged lines
485512 *
486513 * @param {"on"|"off"|"pending"} state whether reconnect is worth offering
487514 * @param {string} text
515+ * @param {string} [detail] one sentence for the overlay card, when the caller
516+ * knows better than `offlineHint` what would fix it
488517 */
489-function setStatus(state, text) {
518+function setStatus(state, text, detail) {
490519 $("status-text").textContent = text;
491520 $("reconnect").hidden = state === "on";
521+ showOffline(state, text, detail);
522+}
523+
524+/**
525+ * The same state, in the middle of the panel. The header line is the whole
526+ * story while things work — a connected panel says so with its tabs — but a
527+ * dead socket leaves a scrollback that still looks alive above a word small
528+ * enough to miss, so it gets a card over the terminal until it is fixed.
529+ *
530+ * @param {"on"|"off"|"pending"} state
531+ * @param {string} text what the header says, which is also the card's heading
532+ * @param {string} [detail] what to do about it; derived from `text` if absent
533+ */
534+function showOffline(state, text, detail) {
535+ const box = $("offline");
536+ if (state === "on") {
537+ box.hidden = true;
538+ return;
539+ }
540+ box.dataset.state = state;
541+ $("offline-title").textContent = state === "pending" ? "connecting…" : text;
542+ $("offline-msg").textContent = state === "pending" ? "" : (detail ?? offlineHint(text));
543+ box.hidden = false;
544+}
545+
546+/**
547+ * One sentence naming the thing that would fix it. Two ways a socket stays
548+ * down — nothing is listening, or what is listening does not believe us — and
549+ * they are fixed in different places, so the card must not guess wrong.
550+ *
551+ * @param {string} reason the header's wording, which is the daemon's when it
552+ * rejected us and this panel's otherwise
553+ */
554+function offlineHint(reason) {
555+ if (/token|auth|setup/i.test(reason)) {
556+ return "Open settings and paste the token termbridge token prints.";
557+ }
558+ if (/origin|paired/i.test(reason)) {
559+ return "This extension is not paired yet. Settings has the command to run.";
560+ }
561+ const url = $input("url").value.trim() || "the daemon";
562+ return `Nothing is answering at ${url}. Start it with termbridge serve, then reconnect.`;
492563 }
493564
494565 // One place decides what the header says, so anything that repaints it (a
⋯ 135 unchanged lines
630701 // one is named.
631702 const current = msg.session ?? sessionName;
632703 renderHeader(sessions, current, agents);
704+ // The first frame is the first moment a pin can be acted on: until it
705+ // arrives there is no session list to resolve one against. Opening the
706+ // panel while sitting on a pinned tab lands on the right session
707+ // because of this, and only because of this.
708+ if (!pinAppliedOnConnect) {
709+ pinAppliedOnConnect = true;
710+ applyTabPin();
711+ }
712+ // And the other direction, after the render: this reads `lastSessions`,
713+ // which `renderHeader` is what sets.
714+ noteSpotChange();
633715 } else if (msg.type === "path") {
634716 // An answer to something the omnibar asked about a directory. Keyed by
635717 // the query, so one that arrives after the box has moved on is filed
⋯ 44 unchanged lines
680762 cancelTalkSubmit();
681763 sessionName = null;
682764 tmuxMode = false;
765+ // The next socket gets to place us again, and it is a new client on a
766+ // server whose sessions may have moved since.
767+ pinAppliedOnConnect = false;
768+ // Nothing to hand back: the client that was borrowed is gone, and the next
769+ // socket lands wherever the daemon puts it.
770+ pinOwed = null;
771+ // Same reasoning for the other direction: wherever the next socket lands is
772+ // a starting position, not somewhere the user steered to, so it must not
773+ // read as a move and rearrange the browser.
774+ lastSpot = null;
775+ forwardGoingTo = null;
776+ clearTimeout(reverseTimer);
683777 renderHeader([], null, []);
684778 refreshStatus();
685779 log(`closed code=${ev.code} ${ev.reason || ""}`);
⋯ 36 unchanged lines
722816 function renderHeader(sessions, current, agents) {
723817 lastSessions = sessions;
724818 lastAgents = agents;
819+ // Resolved once per frame rather than once per tab: every row asks whether it
820+ // is the pinned one, and the answer cannot change between two rows of the
821+ // same repaint.
822+ pinNow = currentPinTarget();
725823 // Before the early return below: the jump button is in whichever row exists,
726824 // and what it says comes from the agents rather than from either layout.
727825 syncJump();
⋯ 228 unchanged lines
9561054 */
9571055 function repaintTabs() {
9581056 $("tabs").dataset.sig = "";
959- if (tabMode === "groups") renderGroups(lastSessions, sessionName, lastAgents);
960- else renderTabs(lastWindows, lastAgents);
1057+ // The other entry point is renderHeader, and this one does not go through it:
1058+ // a repaint caused by a new pin, or by the browser tab changing under us,
1059+ // has to re-resolve before the rows ask which of them is marked.
1060+ pinNow = currentPinTarget();
1061+ if (tabMode === "groups") {
1062+ renderGroups(lastSessions, sessionName, lastAgents);
1063+ return;
1064+ }
1065+ // The nested layout keeps its two rows on separate signatures, and a
1066+ // session-level browser pin marks the top one. Its signature has to be
1067+ // cleared too, or the dot waits for a frame that changes something else.
1068+ $("sessions").dataset.sig = "";
1069+ renderSessionTabs(lastSessions, sessionName, lastAgents);
1070+ renderTabs(lastWindows, lastAgents);
9611071 }
9621072
9631073 /**
⋯ 12 unchanged lines
9761086 // Reset, not decoration: the groups layout points this same button at the
9771087 // home session and says so, and switching back would otherwise leave that
9781088 // 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;
1089+ $("tab-new").title = newTabTitle(sessionName);
9811090 strip.hidden = !show;
9821091 // The hairline between the two rows belongs to the session row, and only
9831092 // while there is a window row under it to be separated from.
⋯ 199 unchanged lines
11831292 // the way a session tab marks "attached elsewhere", but it is not the tab you
11841293 // are on and must not be drawn as one.
11851294 const elsewhere = w.active && !current;
1295+ // The browser tab on screen sends the terminal here. Worth a mark, because
1296+ // otherwise the panel moves on its own and nothing on it says why.
1297+ const linked = tabPinMark({ session: owner ?? sessionName, window: w.id });
11861298
11871299 const tab = button(
11881300 {
⋯ 15 unchanged lines
12041316 !w.active && w.activity && "activity",
12051317 elsewhere && "current window of that session",
12061318 isPinned && "pinned — right-click to unpin",
1319+ linked && PIN_SOURCE_NOTE[linked],
12071320 ),
12081321 on: {
12091322 click: () => {
⋯ 20 unchanged lines
12301343 {
12311344 class:
12321345 `tab-slot${current ? " active" : ""}${canClose ? " closable" : ""}` +
1233- `${isPinned ? " pinned" : ""}${elsewhere ? " elsewhere" : ""}`,
1346+ `${isPinned ? " pinned" : ""}${elsewhere ? " elsewhere" : ""}` +
1347+ `${linked ? " tab-linked" : ""}`,
12341348 on: {
12351349 /** @param {MouseEvent} e */
12361350 contextmenu: (e) => {
⋯ 38 unchanged lines
12751389 else if (!w.active) tmuxCommand({ cmd: "select-window", window: w.id });
12761390 }
12771391
1278-// --- tab context menu -------------------------------------------------------
1392+// --- pinning a session to a browser tab -------------------------------------
1393+//
1394+// The rules live in lib/tabpin.js, which is pure. This is the half that has to
1395+// touch the world: which browser tab is showing, what storage holds, and the
1396+// one tmux command a match turns into.
12791397 //
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.
1398+// The trigger is deliberately narrow. Only a tab in *this* browser window can
1399+// move this panel — a side panel belongs to one window, and a tab activated in
1400+// another window is another panel's business — and the panel only ever
1401+// activates a tab in that same window going the other way.
1402+
1403+/** @type {TbTabPinStore} */
1404+let tabPins = Tabpin.emptyStore();
1405+
1406+/** This panel's browser window. Null until `windows.getCurrent()` answers. */
1407+let panelWindowId = /** @type {number | null} */ (null);
1408+
1409+/** The tab currently showing in this window, as far as we have been told. */
1410+let browserTab = /** @type {{ id?: number, url?: string }} */ ({});
12841411
1285-let openMenu = /** @type {HTMLElement | null} */ (null);
1412+/**
1413+ * What the showing tab resolves to, as of the last repaint. Held rather than
1414+ * recomputed per row — see `renderHeader`.
1415+ * @type {{ session: string, window: TbWindowInfo | null, source: TbPinSource } | null}
1416+ */
1417+let pinNow = null;
12861418
12871419 /**
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}
1420+ * The excursion in progress: where a pin took the terminal from, and where it
1421+ * put it. `Tabpin.step` owns the rules; this is only where the answer is kept
1422+ * between two tab changes.
1423+ * @type {TbPinReturn | null}
12911424 */
1292-let pendingRename = null;
1425+let pinOwed = null;
12931426
1294-function 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-}
1427+/** Whether the pin has been applied since this socket came up. */
1428+let pinAppliedOnConnect = false;
13071429
13081430 /**
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.
1431+ * How long a tab has to stay on screen before the terminal follows it.
13121432 *
1313- * @param {HTMLElement} menu
1314- * @param {string} label
1315- * @param {() => void} run
1433+ * Ctrl-Tab through six tabs fires six activations, and without this the panel
1434+ * would drag tmux through every one of them — six switch-clients for a gesture
1435+ * that meant one. Short enough to be invisible when you land somewhere on
1436+ * purpose: the tmux switch is local and the status frame that redraws the
1437+ * header was up to a second away regardless.
13161438 */
1317-function 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;
1439+const PIN_SETTLE_MS = 140;
1440+
1441+/** @type {ReturnType<typeof setTimeout> | undefined} */
1442+let pinTimer;
1443+
1444+/** @param {TbTabPinStore} next */
1445+function saveTabPins(next) {
1446+ tabPins = next;
1447+ storage.set({ [Tabpin.KEY]: next });
1448+ repaintTabs();
1449+ applyTabPin();
13301450 }
13311451
13321452 /**
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]
1453+ * Where the tab on screen says the terminal belongs, already checked against
1454+ * the server — or null when nothing does.
1455+ *
1456+ * @returns {{ session: string, window: TbWindowInfo | null, source: TbPinSource } | null}
13411457 */
1342-function 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);
1458+function currentPinTarget() {
1459+ const hit = Tabpin.resolve(tabPins, browserTab, lastSessions, Devport);
1460+ if (!hit) return null;
1461+ const at = Tabpin.locate(hit.target, lastSessions);
1462+ return at ? { ...at, source: hit.source } : null;
1463+}
1464+
1465+/**
1466+ * Where the terminal is: the session this panel's client is on, and that
1467+ * session's active window. Null before the first status frame, which is the
1468+ * only honest answer — a return has to know where it is returning *from*, and
1469+ * guessing at that would move the terminal somewhere nobody was.
1470+ *
1471+ * @returns {TbSpot | null}
1472+ */
1473+function currentSpot() {
1474+ if (!sessionName) return null;
1475+ const s = lastSessions.find((x) => x.name === sessionName);
1476+ if (!s) return null;
1477+ return { session: sessionName, window: s.windows.find((w) => w.active)?.id ?? null };
1478+}
1479+
1480+/**
1481+ * Put the terminal somewhere. The counterpart of a tab click, minus the focus:
1482+ * the hands that caused this are on a web page, and the terminal moving
1483+ * underneath is the feature — the caret leaving the page for it is not.
1484+ *
1485+ * @param {TbSpot} spot
1486+ */
1487+function gotoSpot(spot) {
1488+ const s = lastSessions.find((x) => x.name === spot.session);
1489+ if (!s) return;
1490+ const w = spot.window ? s.windows.find((x) => x.id === spot.window) : null;
1491+ // A window that has closed since leaves the session as the whole answer,
1492+ // which is the same fallback `Tabpin.locate` makes for a pin.
1493+ if (w) selectWindow(w, spot.session);
1494+ else if (spot.session !== sessionName) tmuxCommand({ cmd: "switch", session: spot.session });
1495+}
1496+
1497+/**
1498+ * Act on the tab now showing: follow its pin, or hand the terminal back if an
1499+ * earlier pin borrowed it.
1500+ *
1501+ * Silent about every kind of miss, and they are all ordinary: no pin, a pin to
1502+ * a session that is not running, a pin to where we already are, a return that
1503+ * has been overtaken by a switch made by hand. None of them is a failure, and a
1504+ * panel that logged them would log on every tab switch.
1505+ */
1506+function applyTabPin() {
1507+ if (!connected) return;
1508+ const at = currentSpot();
1509+ if (!at) return;
1510+ const hit = currentPinTarget();
1511+ // The resolved *window* matters here, not the pin as written: a pin to a
1512+ // window that has closed is a pin to its session, and it must compare as one.
1513+ const target = hit ? { session: hit.session, window: hit.window?.id ?? null } : null;
1514+ const { go, owed } = Tabpin.step(pinOwed, target, at);
1515+ pinOwed = owed;
1516+ if (!go) return;
1517+ forwardGoingTo = go;
1518+ gotoSpot(go);
1519+}
1520+
1521+/**
1522+ * Follow the browser after a beat — see `PIN_SETTLE_MS`. Every path that can
1523+ * change which pin applies goes through here, so a burst of them collapses into
1524+ * one move rather than a queue of them.
1525+ */
1526+function scheduleTabPin() {
1527+ clearTimeout(pinTimer);
1528+ pinTimer = setTimeout(applyTabPin, PIN_SETTLE_MS);
1529+}
1530+
1531+// --- and the other way: the browser follows the terminal ---------------------
1532+//
1533+// Same pins, read backwards by `Tabpin.reverse`. The trigger is the terminal
1534+// arriving somewhere new, whatever moved it — a click on a session tab in this
1535+// panel, a `prefix n` typed into the pane, another client switching a session
1536+// this one is watching. All of those are the user going somewhere, and none of
1537+// them is distinguishable from the others by the time the status frame lands.
1538+//
1539+// What this will not do is as much of the design as what it will. It activates
1540+// a tab that is already open in this panel's window and stops there: it does
1541+// not create tabs, does not focus the browser, does not raise a window, and
1542+// does not touch a tab in another window. So the whole failure mode is showing
1543+// you a tab you already had open, which is recoverable with Ctrl-Tab.
1544+
1545+/** Where the terminal was as of the last frame, so a move can be told from a
1546+ * repaint. Null until the first frame names a session.
1547+ * @type {TbSpot | null} */
1548+let lastSpot = null;
1549+
1550+/**
1551+ * A tab activation this panel asked for, which must not come back around as a
1552+ * reason to move the terminal.
1553+ *
1554+ * `Tabpin.reverse` already refuses to move a browser that is showing a tab
1555+ * pointing where the terminal is, so the loop cannot run away regardless. This
1556+ * closes the narrower window underneath that: our own `tabs.update` fires
1557+ * `onActivated`, and the status frame that would have told `applyTabPin` where
1558+ * the terminal now is may not have arrived yet. Acting on the stale answer
1559+ * would send tmux a switch to the place it is already going.
1560+ *
1561+ * @type {number | null}
1562+ */
1563+let reverseGoingTo = null;
1564+
1565+/** @type {ReturnType<typeof setTimeout> | undefined} */
1566+let reverseTimer;
1567+
1568+/**
1569+ * A terminal move this panel's *forward* direction asked for, which must not
1570+ * come back around as a reason to move the browser.
1571+ *
1572+ * A pin arriving somewhere is already harmless — the tab that sent it there is
1573+ * showing, so `Tabpin.reverse` finds its work done. The case this exists for is
1574+ * the other half of the loan: handing the terminal *back* when you leave a
1575+ * pinned tab lands it wherever it was borrowed from, and if some third tab is
1576+ * pinned to that place, this would chase it and undo the tab switch the user
1577+ * just made by hand. A return is an undo of a move this code made, and it has
1578+ * no business rearranging the browser on the way.
1579+ *
1580+ * @type {TbSpot | null}
1581+ */
1582+let forwardGoingTo = null;
1583+
1584+/**
1585+ * Show the tab that points at wherever the terminal has landed, if one is open
1586+ * and is not already showing.
1587+ */
1588+function applyReverseTabPin() {
1589+ if (!connected || panelWindowId == null) return;
1590+ const at = currentSpot();
1591+ if (!at) return;
1592+ api.tabs
1593+ .query({ windowId: panelWindowId })
1594+ .then((tabs) => {
1595+ // Most recently used first, which is the tie-break `Tabpin.reverse`
1596+ // leans on: of two tabs on the pinned origin, the one you were reading.
1597+ // Chrome before 121 has no `lastAccessed`, and then the sort is a no-op
1598+ // and tab order decides — a worse answer, not a broken one.
1599+ const order = [...tabs].sort((a, b) => (b.lastAccessed ?? 0) - (a.lastAccessed ?? 0));
1600+ const hit = Tabpin.reverse(tabPins, at, order, lastSessions, Devport);
1601+ if (!hit) return;
1602+ reverseGoingTo = hit.tabId;
1603+ api.tabs.update(hit.tabId, { active: true }).catch(() => {
1604+ reverseGoingTo = null;
1605+ });
1606+ })
1607+ .catch(() => {});
1608+}
1609+
1610+/**
1611+ * Act on where the terminal is, after a beat.
1612+ *
1613+ * The beat is the same bargain as `PIN_SETTLE_MS` and for the same reason from
1614+ * the other end: holding prefix-n through six windows should move the browser
1615+ * once, at the end, rather than flicking it through five tabs on the way.
1616+ */
1617+function scheduleReverseTabPin() {
1618+ clearTimeout(reverseTimer);
1619+ reverseTimer = setTimeout(applyReverseTabPin, PIN_SETTLE_MS);
1620+}
1621+
1622+/**
1623+ * Notice the terminal moving. Called once per status frame, which is the only
1624+ * thing that can tell us — every mover of tmux is a frame by the time it gets
1625+ * here, including this panel's own commands.
1626+ *
1627+ * The first frame after a connect is a starting position rather than a move,
1628+ * and is skipped: opening the panel is not a reason to rearrange the browser,
1629+ * and the forward direction is meanwhile busy putting the terminal on the tab
1630+ * you already have up.
1631+ */
1632+function noteSpotChange() {
1633+ const at = currentSpot();
1634+ if (!at) return;
1635+ const was = lastSpot;
1636+ lastSpot = at;
1637+ if (!was || Tabpin.sameSpot(was, at)) return;
1638+ if (forwardGoingTo && Tabpin.sameSpot(forwardGoingTo, at)) {
1639+ // The browser moved the terminal; the terminal does not get to move it
1640+ // back. See `forwardGoingTo`.
1641+ forwardGoingTo = null;
1642+ return;
1643+ }
1644+ forwardGoingTo = null;
1645+ scheduleReverseTabPin();
1646+}
1647+
1648+/**
1649+ * Re-read the showing tab and act on it. Called for the events that can change
1650+ * which pin applies, and once at startup for the tab that was already there.
1651+ */
1652+function syncTabPin() {
1653+ if (panelWindowId == null) return;
1654+ api.tabs
1655+ .query({ active: true, windowId: panelWindowId })
1656+ .then(([tab]) => {
1657+ if (!tab) return;
1658+ browserTab = { id: tab.id, url: tab.url };
1659+ scheduleTabPin();
1660+ // The menu labels name the origin, and the indicator marks the row this
1661+ // tab points at. Both are stale the moment the tab changed.
1662+ repaintTabs();
1663+ })
1664+ .catch(() => {});
1665+}
1666+
1667+api.tabs.onActivated.addListener((info) => {
1668+ if (info.windowId !== panelWindowId) return;
1669+ if (info.tabId === reverseGoingTo) {
1670+ // Our own doing — see `reverseGoingTo`. The tab still has to be recorded
1671+ // and the header still has to redraw, since the indicator dot moves with
1672+ // it; what is skipped is treating it as news about where the user went.
1673+ reverseGoingTo = null;
1674+ api.tabs
1675+ .get(info.tabId)
1676+ .then((tab) => {
1677+ browserTab = { id: tab.id, url: tab.url };
1678+ repaintTabs();
1679+ })
1680+ .catch(() => {});
1681+ return;
1682+ }
1683+ syncTabPin();
1684+});
1685+
1686+// A tab that navigates is a different origin and so possibly a different pin.
1687+// Only the showing one matters: a background tab finishing a redirect is not a
1688+// reason to move the terminal.
1689+api.tabs.onUpdated.addListener((tabId, change, tab) => {
1690+ if (!change.url || tab.windowId !== panelWindowId || tabId !== browserTab.id) return;
1691+ browserTab = { id: tabId, url: change.url };
1692+ scheduleTabPin();
1693+ repaintTabs();
1694+});
1695+
1696+// Tab ids are never reissued, so a pin whose tab is gone can only ever be dead
1697+// weight in storage.
1698+api.tabs.onRemoved.addListener((tabId) => {
1699+ if (!(String(tabId) in tabPins.byTab)) return;
1700+ const next = Tabpin.put(tabPins, "tab", tabId, undefined);
1701+ tabPins = next;
1702+ storage.set({ [Tabpin.KEY]: next });
1703+});
1704+
1705+/** `http://localhost:26210` reads better as `localhost:26210` in a menu.
1706+ * @param {string} origin */
1707+function shortOrigin(origin) {
1708+ return origin.replace(/^https?:\/\//, "");
1709+}
1710+
1711+/**
1712+ * Whether the showing browser tab points at this exact row, so the row can say
1713+ * so. Windows match on their own id; a session-level pin marks the session and
1714+ * every row of it stays unmarked.
1715+ *
1716+ * @param {{ session?: string | null, window?: string | null }} row
1717+ * @returns {TbPinSource | null}
1718+ */
1719+function tabPinMark(row) {
1720+ const at = pinNow;
1721+ if (!at) return null;
1722+ if (row.window) return at.window?.id === row.window ? at.source : null;
1723+ return !at.window && at.session === row.session ? at.source : null;
1724+}
1725+
1726+/** How a pin explains itself in a tooltip. */
1727+const PIN_SOURCE_NOTE = {
1728+ tab: "pinned to this browser tab",
1729+ origin: "pinned to this site",
1730+ detect: "matched to this site by its devport port",
1731+};
13471732
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);
1733+/**
1734+ * The pin lines of a context menu, for a window (a window id) or a whole
1735+ * session (none). Both kinds of key are offered because they answer different
1736+ * questions — see the header of lib/tabpin.js — and neither is offered for a
1737+ * tab whose URL cannot carry a pin, which is every browser page and every
1738+ * `file:`.
1739+ *
1740+ * @param {HTMLElement} menu
1741+ * @param {TbPinTarget} target
1742+ */
1743+function pinMenuItems(menu, target) {
1744+ const origin = Tabpin.originOf(browserTab.url);
1745+ const tabKey = browserTab.id != null ? String(browserTab.id) : "";
1746+ const here = tabPinMark({ session: target.session, window: target.window });
1747+
1748+ if (here) {
1749+ // Unpinning a detected match cannot just delete an entry — there is no
1750+ // entry, and the guess would be back before the menu had closed. It writes
1751+ // the veto instead, at the level the guess was made: the origin.
1752+ const label = here === "tab" ? "Unpin from this browser tab" : `Unpin from ${shortOrigin(origin)}`;
1753+ menuItem(menu, label, () => {
1754+ if (here === "tab") saveTabPins(Tabpin.put(tabPins, "tab", tabKey, undefined));
1755+ else if (here === "origin") saveTabPins(Tabpin.put(tabPins, "origin", origin, undefined));
1756+ else saveTabPins(Tabpin.put(tabPins, "origin", origin, null));
1757+ });
1758+ return;
1759+ }
1760+
1761+ const what = target.window ? "this window" : target.session;
1762+ if (origin) {
1763+ menuItem(menu, `Pin ${what} to ${shortOrigin(origin)}`, () =>
1764+ saveTabPins(Tabpin.put(tabPins, "origin", origin, target)),
1765+ );
1766+ }
1767+ if (tabKey) {
1768+ menuItem(menu, `Pin ${what} to this browser tab`, () =>
1769+ saveTabPins(Tabpin.put(tabPins, "tab", tabKey, target)),
1770+ );
1771+ }
1772+}
1773+
1774+// --- tab context menu -------------------------------------------------------
1775+//
1776+// Built rather than native: an extension page gets the browser's own menu here,
1777+// which has nothing to say about tmux windows. Deliberately not a <dialog> or
1778+// anything modal — a modal in this panel would block the socket's message
1779+// handler while it is up.
1780+
1781+let openMenu = /** @type {HTMLElement | null} */ (null);
1782+
1783+/**
1784+ * Set while a group menu's name field is open, so closing the menu commits what
1785+ * was typed in it. See `renameRow`.
1786+ * @type {(() => void) | null}
1787+ */
1788+let pendingRename = null;
1789+
1790+function closeTabMenu() {
1791+ const commit = pendingRename;
1792+ pendingRename = null;
1793+ commit?.();
1794+ openMenu?.remove();
1795+ openMenu = null;
1796+ // The session info panel shares this machinery, and the dot it hangs off has
1797+ // to stop claiming it is open.
1798+ if (infoOpen) {
1799+ infoOpen = false;
1800+ $("omni-here").setAttribute("aria-expanded", "false");
1801+ }
1802+}
1803+
1804+/**
1805+ * One line of a menu. Every one of them does the same two things in the same
1806+ * order — dismiss the menu, then act — because a menu still standing over the
1807+ * thing it just changed is the wrong half of the gesture.
1808+ *
1809+ * @param {HTMLElement} menu
1810+ * @param {string} label
1811+ * @param {() => void} run
1812+ */
1813+function menuItem(menu, label, run) {
1814+ const b = button({
1815+ text: label,
1816+ attrs: { role: "menuitem" },
1817+ on: {
1818+ click: () => {
1819+ closeTabMenu();
1820+ run();
1821+ },
1822+ },
1823+ });
1824+ menu.appendChild(b);
1825+ return b;
1826+}
1827+
1828+/**
1829+ * @param {MouseEvent} e
1830+ * @param {TbWindowInfo} w
1831+ * @param {object} opts
1832+ * @param {boolean} opts.pinned
1833+ * @param {boolean} opts.closable
1834+ * @param {string} [opts.owner]
1835+ * @param {boolean} [opts.current]
1836+ * @param {boolean} [opts.lastInSession]
1837+ */
1838+function openTabMenu(e, w, { pinned: isPinned, closable, owner, current, lastInSession }) {
1839+ closeTabMenu();
1840+ const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
1841+ /** @param {string} label @param {() => void} run */
1842+ const item = (label, run) => menuItem(menu, label, run);
1843+
1844+ item(isPinned ? "Unpin" : "Pin", () => {
1845+ const ids = pinnedIds(owner);
1846+ if (isPinned) ids.delete(w.id);
1847+ else ids.add(w.id);
1848+ savePins(owner, ids);
13531849 repaintTabs();
13541850 });
13551851
⋯ 4 unchanged lines
13601856 });
13611857 }
13621858
1859+ // Both levels, from the one menu: the window you right-clicked, and the
1860+ // session it belongs to. They are genuinely different pins — "this browser
1861+ // tab brings up my dev server window" and "this browser tab brings up that
1862+ // project, wherever I left it" — and the second is the one most people want.
1863+ const pinTo = owner ?? sessionName;
1864+ if (pinTo) {
1865+ pinMenuItems(menu, { session: pinTo, window: w.id });
1866+ pinMenuItems(menu, { session: pinTo, window: null });
1867+ }
1868+
1869+ // A tab's own menu is the nearest thing to "open another one beside this",
1870+ // and beside this means in the session this tab belongs to — the owner in
1871+ // groups mode, where the row spans the server, and the panel's session
1872+ // otherwise.
1873+ const beside = owner ?? sessionName;
1874+ if (beside && lastSessions.some((s) => s.name === beside)) {
1875+ item("New Claude", () => {
1876+ newClaudeIn(beside);
1877+ term.focus();
1878+ });
1879+ }
1880+
13631881 if (closable) {
13641882 const kill = item(lastInSession && owner ? `Close window and ${owner}` : "Close window", () => {
13651883 tmuxCommand({ cmd: "kill-window", window: w.id });
⋯ 32 unchanged lines
13981916 /** @param {Event} e */
13991917 function onDismiss(e) {
14001918 if (openMenu && e.target instanceof Node && openMenu.contains(e.target)) return;
1919+ // A press on the dot is the first half of a click that means "put it away".
1920+ // This guard gets there first and would leave the panel closed, so the click
1921+ // behind it would read a closed panel and open it straight back up; the flag
1922+ // is how that click learns the press it belongs to did the closing.
1923+ infoToggledOff =
1924+ infoOpen && e.target instanceof Node && !!$("omni-here").contains(e.target);
14011925 closeTabMenu();
14021926 }
14031927
⋯ 18 unchanged lines
14221946 e.stopPropagation();
14231947 tmuxCommand({ cmd: "kill-window", window: w.id });
14241948 // 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- );
1949+ // so it at least leaves a record of what went.
1950+ log(`killed window ${w.index} (${w.name})`);
1951+ term.focus();
1952+ },
1953+ },
1954+ },
1955+ strokeIcon("M4 4l8 8M12 4l-8 8"),
1956+ );
1957+}
1958+
1959+const SVG_NS = "http://www.w3.org/2000/svg";
1960+
1961+/**
1962+ * The header's icons are inline SVG rather than unicode glyphs, which render at
1963+ * wildly different weights depending on the platform's fallback font. The ones
1964+ * built here are the same, just built rather than written out.
1965+ *
1966+ * @param {string} d
1967+ */
1968+function strokeIcon(d) {
1969+ const svg = document.createElementNS(SVG_NS, "svg");
1970+ svg.setAttribute("viewBox", "0 0 16 16");
1971+ svg.setAttribute("aria-hidden", "true");
1972+ const path = document.createElementNS(SVG_NS, "path");
1973+ path.setAttribute("d", d);
1974+ path.setAttribute("stroke", "currentColor");
1975+ path.setAttribute("stroke-width", "2");
1976+ path.setAttribute("stroke-linecap", "round");
1977+ svg.appendChild(path);
1978+ return svg;
1979+}
1980+
1981+/**
1982+ * Open a window in a session and go to it.
1983+ *
1984+ * A new window needs no name: tmux names it after whatever it runs, and renames
1985+ * it as you cd around. So this is one click and no prompt.
1986+ *
1987+ * A session that does not exist yet cannot be given a window — but making it
1988+ * *is* making the window, since `create` is `new-session -A`, which comes up
1989+ * with one. That is the case the home session hits on a fresh tmux server,
1990+ * where "+" is the first thing that ever names it.
1991+ *
1992+ * @param {string | null} session
1993+ */
1994+function newWindowIn(session) {
1995+ if (!session) return;
1996+ if (lastSessions.some((s) => s.name === session)) {
1997+ tmuxCommand({ cmd: "new-window", session });
1998+ } else {
1999+ tmuxCommand({ cmd: "create", session });
2000+ }
2001+}
2002+
2003+/**
2004+ * Open a window running Claude in a session and go to it.
2005+ *
2006+ * The same window `!claude` and the omnibar's "New Claude" row open — `run`
2007+ * rather than the daemon's Claude request, which exists to carry a prompt and
2008+ * there is no prompt here.
2009+ *
2010+ * Unlike `newWindowIn` this cannot make the session it lands in: the daemon's
2011+ * `run` is a `new-window` and nothing else, so a session that does not exist
2012+ * yet would take the command nowhere. Every caller offers the row only for a
2013+ * session the last frame named.
2014+ *
2015+ * @param {string | null} session
2016+ */
2017+function newClaudeIn(session) {
2018+ if (!session) return;
2019+ tmuxCommand({ cmd: "run", session, command: "claude" });
2020+}
2021+
2022+/* What a plain click on "+" makes.
2023+
2024+ The button opens a window either way; the question is what is running in it.
2025+ "claude" is the default because that is what the window is nearly always for
2026+ — a shell is one `exit` away inside it, and a Claude in a shell is a command
2027+ you had to type. "window" is the old behaviour, for anyone whose "+" means a
2028+ shell.
2029+
2030+ Only the plain click follows this. The hold menu still offers both, so
2031+ whichever is not the default is one gesture away rather than a settings trip,
2032+ and it lists the default first. */
2033+/** @type {Record<string, { verb: string, hint: string }>} */
2034+const NEW_TAB_ACTIONS = {
2035+ claude: {
2036+ verb: "New Claude in",
2037+ hint: '"+" opens a window running claude. Hold it for a plain shell.',
2038+ },
2039+ window: {
2040+ verb: "New window in",
2041+ hint: '"+" opens a window with a shell. Hold it for claude.',
2042+ },
2043+};
2044+let newTabAction = "claude";
2045+
2046+/** @returns {"claude" | "window"} what "+" does, as a key of NEW_TAB_ACTIONS. */
2047+function plainNewTab() {
2048+ return newTabAction === "window" ? "window" : "claude";
2049+}
2050+
2051+/**
2052+ * The pref only reaches two places — the tooltips, which `renderHeader` writes
2053+ * on every frame, and the settings card. So this repaints the header rather
2054+ * than touching the button itself.
2055+ */
2056+function applyNewTabAction() {
2057+ const a = NEW_TAB_ACTIONS[plainNewTab()];
2058+ $select("newtab-select").value = plainNewTab();
2059+ $("newtab-hint").textContent = a.hint;
2060+ renderHeader(lastSessions, sessionName, lastAgents);
14332061 }
14342062
1435-const SVG_NS = "http://www.w3.org/2000/svg";
2063+/** @param {string} value */
2064+function setNewTabAction(value) {
2065+ newTabAction = NEW_TAB_ACTIONS[value] ? value : "claude";
2066+ storage.set({ newTabAction });
2067+ applyNewTabAction();
2068+}
14362069
14372070 /**
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.
2071+ * What "+" promises, in the session it will actually land in. Both layouts word
2072+ * it the same way and only differ in which session that is.
14412073 *
1442- * @param {string} d
2074+ * @param {string | null | undefined} target
14432075 */
1444-function 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;
2076+function newTabTitle(target) {
2077+ const { verb } = NEW_TAB_ACTIONS[plainNewTab()];
2078+ return (target ? `${verb} ${target}` : verb.replace(/ in$/, "")) + SPLIT_HINT + HOLD_HINT;
14552079 }
14562080
14572081 /**
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.
2082+ * Do what a plain "+" click means, in whichever session it points at.
14672083 *
14682084 * @param {string | null} session
14692085 */
1470-function newWindowIn(session) {
1471- if (!session) return;
1472- if (lastSessions.some((s) => s.name === session)) {
1473- tmuxCommand({ cmd: "new-window", session });
2086+function newTabIn(session) {
2087+ // A session the last frame has not named cannot be given a Claude window (see
2088+ // `newClaudeIn`), and `newWindowIn` is the one path that can make it. So the
2089+ // first "+" on a fresh server opens a shell whatever the pref says — there is
2090+ // no session yet for anything else to run in.
2091+ if (plainNewTab() === "claude" && session && lastSessions.some((s) => s.name === session)) {
2092+ newClaudeIn(session);
14742093 } else {
1475- tmuxCommand({ cmd: "create", session });
2094+ newWindowIn(session);
14762095 }
14772096 }
14782097
⋯ 29 unchanged lines
15082127 // offers the other thing the strip can hold. Here that other thing is a tmux
15092128 // session — a tab group — and this is where it gets made, so the row never
15102129 // 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.
2130+// else. The plain click stays one gesture and no prompt: it opens a window
2131+// immediately, running whatever `NEW_TAB_ACTIONS` says — and the menu holds the
2132+// other kind, so that pref is a hold away rather than a settings trip.
15122133 //
15132134 // The menu is the same one every tab and chip in the row uses, so it dismisses
15142135 // the same way and only one of them is ever up.
⋯ 11 unchanged lines
15262147 closeTabMenu();
15272148 const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
15282149 const target = newWindowTarget() ?? defaultSession;
1529- menuItem(menu, `New window in ${target}`, () => {
1530- newWindowIn(target);
1531- term.focus();
1532- });
2150+ const window_ = () =>
2151+ menuItem(menu, `New window in ${target}`, () => {
2152+ newWindowIn(target);
2153+ term.focus();
2154+ });
2155+ // Only once the session exists: see `newClaudeIn`. On a fresh server "+"
2156+ // itself is what names the home session, and until it has, there is nowhere
2157+ // for a `run` to open a window.
2158+ const claude = () => {
2159+ if (!lastSessions.some((s) => s.name === target)) return;
2160+ menuItem(menu, `New Claude in ${target}`, () => {
2161+ newClaudeIn(target);
2162+ term.focus();
2163+ });
2164+ };
2165+ // The default first, so the menu reads in the order the button does: what a
2166+ // plain click would have done, then the other one.
2167+ if (plainNewTab() === "claude") {
2168+ claude();
2169+ window_();
2170+ } else {
2171+ window_();
2172+ claude();
2173+ }
15332174 // Named rather than immediate, unlike the window: a session's name is the
15342175 // only handle a terminal gives you on it. See "new session" below.
15352176 menuItem(menu, "New session…", showSessionInput);
⋯ 14 unchanged lines
15502191 newHeld = false;
15512192 return;
15522193 }
1553- newWindowIn(newWindowTarget());
2194+ newTabIn(newWindowTarget());
15542195 term.focus();
15552196 });
15562197
⋯ 331 unchanged lines
18882529 is already in the air, so the button is where it gets announced. */
18892530 const SPLIT_HINT = "\nDrop a window here to give it a session of its own";
18902531
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. */
1893-const HOLD_HINT = "\nHold or right-click for a new session";
2532+/** Third line of the window "+"'s tooltip: the menu holds the kind of tab the
2533+ plain click is not (see `NEW_TAB_ACTIONS`) and, in groups mode, is the only
2534+ place a session gets made — and a held button announces itself nowhere
2535+ else. */
2536+const HOLD_HINT = "\nHold or right-click for the other kind, or a new session";
18942537
18952538 /**
18962539 * A tmux session name made out of a window name. tmux windows are named after
⋯ 320 unchanged lines
22172860 // the ambiguity was the whole complaint about having a second one beside it.
22182861 // It names the session it actually adds to, which is not always the one you
22192862 // are on and not always the home session either.
2220- $("tab-new").title = `New window in ${newWindowTarget() ?? defaultSession}${SPLIT_HINT}${HOLD_HINT}`;
2863+ $("tab-new").title = newTabTitle(newWindowTarget() ?? defaultSession);
22212864 strip.hidden = !show;
22222865 // A chip carries the session name, so the status text has nothing left to
22232866 // say — the same trade the session row makes in the nested layout.
⋯ 108 unchanged lines
23322975 // default about the same thing.
23332976 const colour = groupColor(s.name);
23342977 const state = claude?.state ?? "none";
2978+ const linked = tabPinMark({ session: s.name, window: null });
23352979 const chip = button(
23362980 {
23372981 class:
23382982 `group-chip${isCurrent ? " current" : ""}${folded ? " folded" : ""}` +
2339- `${s.attached && !isCurrent ? " attached" : ""}${colour === GROUP_GREY ? " grey" : ""}`,
2983+ `${s.attached && !isCurrent ? " attached" : ""}${colour === GROUP_GREY ? " grey" : ""}` +
2984+ `${linked ? " tab-linked" : ""}`,
23402985 css: { "--group-h": String(colour) },
23412986 data: { session: s.name },
23422987 attrs: { "aria-expanded": !folded },
⋯ 3 unchanged lines
23462991 isCurrent ? "you are here" : s.attached && "attached elsewhere",
23472992 claude && `claude ${claude.state} — ${agentLabel(claude)}`,
23482993 folded ? "folded — click to unfold" : "click to fold",
2994+ linked && PIN_SOURCE_NOTE[linked],
23492995 "right-click to rename or recolour",
23502996 ),
23512997 on: {
⋯ 203 unchanged lines
25553201 term.focus();
25563202 });
25573203
3204+ item("New Claude here", () => {
3205+ newClaudeIn(s.name);
3206+ term.focus();
3207+ });
3208+
3209+ pinMenuItems(menu, { session: s.name, window: null });
3210+
25583211 item(folded ? "Fold the others" : "Fold everything else", () => {
25593212 foldedGroups = new Set(lastSessions.map((x) => x.name).filter((n) => n !== s.name));
25603213 storage.set({ foldedGroups: [...foldedGroups] });
⋯ 757 unchanged lines
33183971 * @param {string} prompt
33193972 */
33203973 function completePath(next, prompt) {
3321- const input = $input("omni");
3974+ const input = $area("omni");
33223975 input.value = prompt ? `${next}/ ${prompt}` : `${next}/`;
33233976 omniDirty = true;
33243977 input.focus();
⋯ 56 unchanged lines
33814034 * Called on every status frame, so it has two things it must not walk over —
33824035 * a query being typed, and the selection that focusing just made.
33834036 */
4037+/**
4038+ * Size the box to its text: one line when there is one, taller as it wraps,
4039+ * and no further than the cap in `sidebar.css` — past that it scrolls.
4040+ *
4041+ * A textarea has no intrinsic height, so this is the whole of "it grows". The
4042+ * reset to `auto` first is what lets it shrink again: `scrollHeight` of a box
4043+ * already taller than its content is that taller height, so measuring without
4044+ * it makes the box a ratchet.
4045+ *
4046+ * The header is a flex column above a `flex: 1` terminal, so a taller pill
4047+ * takes its pixels from the terminal, and the ResizeObserver on `#term` refits
4048+ * tmux to the rows that are left. Nothing here has to say so.
4049+ */
4050+function growOmni() {
4051+ const input = $area("omni");
4052+ input.style.height = "auto";
4053+ input.style.height = `${input.scrollHeight}px`;
4054+}
4055+
4056+// Rewrapping is the panel's width changing, and that is the one thing that can
4057+// change the line count without anyone touching the box.
4058+window.addEventListener("resize", growOmni);
4059+
33844060 function syncOmniHere() {
3385- const input = $input("omni");
4061+ const input = $area("omni");
33864062 const here = connected && tmuxMode && sessionName ? sessionName : "";
33874063
33884064 // Shown only when there is a session for it to describe: an info button over
⋯ 14 unchanged lines
34034079 }
34044080
34054081 if (omniDirty || document.activeElement === input) {
4082+ growOmni();
34064083 takePendingOmniFocus();
34074084 return;
34084085 }
34094086 input.value = here;
4087+ growOmni();
34104088 input.title = here
34114089 ? `${here} — type to jump to a window, session or Claude pane, ` +
34124090 `name a session to create it, or ask a new Claude in this directory`
⋯ 15 unchanged lines
34284106
34294107 /** Set while the panel is up, so a status frame redraws it in place. */
34304108 let infoOpen = false;
4109+/**
4110+ * Set when the outside-press guard closed the panel because the press landed on
4111+ * the dot itself. The click that follows is the rest of that same press, so it
4112+ * has to leave the panel closed rather than treat it as a fresh open.
4113+ */
4114+let infoToggledOff = false;
34314115 /** Reset whenever the panel opens: the copy button's label is a one-shot. */
34324116 let infoCopied = false;
34334117
⋯ 146 unchanged lines
35804264 * and a click anywhere else close it.
35814265 */
35824266 function openSessionInfo() {
3583- const wasOpen = infoOpen;
4267+ const wasOpen = infoOpen || infoToggledOff;
4268+ infoToggledOff = false;
35844269 closeTabMenu();
35854270 // The dot is a toggle: clicking it again is how you put the panel away
35864271 // without having to find somewhere neutral to click.
⋯ 32 unchanged lines
36194304 /** Drop whatever was typed and show the location again. */
36204305 function revertOmni() {
36214306 omniDirty = false;
3622- const input = $input("omni");
4307+ const input = $area("omni");
36234308 input.value = connected && tmuxMode && sessionName ? sessionName : "";
36244309 input.select();
36254310 refreshOmni();
⋯ 1 unchanged line
36274312
36284313 /** Rebuild the dropdown from whatever is in the box. */
36294314 function refreshOmni() {
3630- const input = $input("omni");
4315+ const input = $area("omni");
36314316 const list = $("omni-list");
36324317 syncOmniHere();
36334318 if (tabMode !== "groups" || !connected || !tmuxMode) return closeOmni();
⋯ 168 unchanged lines
38024487 return;
38034488 }
38044489 omniFocusAsked = 0;
3805- const input = $input("omni");
4490+ const input = $area("omni");
38064491
38074492 // A panel coming up for the first time gets its focus somewhere in the next
38084493 // few hundred milliseconds, and a selection made before that arrives is
⋯ 38 unchanged lines
38474532 refreshOmni();
38484533 }
38494534
3850-$input("omni").addEventListener("input", () => {
4535+$area("omni").addEventListener("input", () => {
4536+ const input = $area("omni");
4537+ // The box wraps, but it still holds one line: Enter runs a row rather than
4538+ // breaking the line, so the only way a newline gets in is a paste — and a
4539+ // command with a hard newline in the middle of it is not what was pasted,
4540+ // it is what the clipboard happened to be carrying. Each becomes a space,
4541+ // and the caret keeps its place because the length does not change.
4542+ if (input.value.includes("\n")) {
4543+ const at = input.selectionStart;
4544+ input.value = input.value.replace(/[\r\n]/g, " ");
4545+ input.setSelectionRange(at, at);
4546+ }
38514547 // The location has been typed over, so it is a query from here on.
38524548 omniDirty = true;
38534549 refreshOmni();
⋯ 3 unchanged lines
38574553 // behaviour that makes "the box holds where you are" and "the box is how you go
38584554 // somewhere else" the same box. Opening the list here rather than on the first
38594555 // keystroke: with nothing typed it is already the list of everywhere else.
3860-$input("omni").addEventListener("focus", () => {
4556+$area("omni").addEventListener("focus", () => {
38614557 omniDirty = false;
3862- $input("omni").select();
4558+ $area("omni").select();
38634559 refreshOmni();
38644560 });
38654561
38664562 // Late enough for a row's own click to have run first. Leaving focus abandons
38674563 // whatever was typed, exactly as a browser's does — the box goes back to
38684564 // saying where you are.
3869-$input("omni").addEventListener("blur", () =>
4565+$area("omni").addEventListener("blur", () =>
38704566 setTimeout(() => {
38714567 // A blur that leaves the caret where it was is the panel gaining or losing
38724568 // the keyboard, not the box being left — and that happens under the box on
38734569 // the way up, when the shortcut is what opened this panel. Closing on it
38744570 // would take the list away from a box that is still focused.
3875- if (document.activeElement === $input("omni")) return;
4571+ if (document.activeElement === $area("omni")) return;
38764572 closeOmni();
38774573 omniDirty = false;
38784574 syncOmniHere();
38794575 }, 0),
38804576 );
38814577
3882-$input("omni").addEventListener("keydown", (e) => {
4578+$area("omni").addEventListener("keydown", (e) => {
38834579 const ev = /** @type {KeyboardEvent} */ (e);
38844580 const key = ev.key;
38854581 // Ctrl+J / Ctrl+K move the selection too, but only while the list is up:
⋯ 157 unchanged lines
40434739 * @param {TbAgent} [claude] the agent worth reporting anywhere in this session
40444740 */
40454741 function sessionTab(s, selected, claude) {
4742+ const linked = tabPinMark({ session: s.name, window: null });
40464743 const tab = button(
40474744 {
4048- class: `session-tab${s.attached && !selected ? " attached" : ""}`,
4745+ class:
4746+ `session-tab${s.attached && !selected ? " attached" : ""}` +
4747+ `${linked ? " tab-linked" : ""}`,
40494748 attrs: { role: "tab", "aria-selected": selected },
40504749 data: { session: s.name },
40514750 // No window count on the tab. The row below it *is* the count for the
⋯ 4 unchanged lines
40564755 `${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`,
40574756 s.attached && !selected && "attached elsewhere",
40584757 claude && `claude ${claude.state} — ${agentLabel(claude)}`,
4758+ linked && PIN_SOURCE_NOTE[linked],
40594759 ),
40604760 on: {
40614761 click: () => {
⋯ 2 unchanged lines
40644764 if (!selected) tmuxCommand({ cmd: "switch", session: s.name });
40654765 term.focus();
40664766 },
4767+ // The nested layout has no group chip, so this is the only place a
4768+ // session-level pin can be reached from in it.
4769+ /** @param {MouseEvent} e */
4770+ contextmenu: (e) => {
4771+ e.preventDefault();
4772+ closeTabMenu();
4773+ const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
4774+ pinMenuItems(menu, { session: s.name, window: null });
4775+ // Nothing to offer for a tab with neither an origin nor an id — a
4776+ // browser page, say. An empty menu is worse than none.
4777+ if (!menu.childElementCount) return;
4778+ document.body.appendChild(menu);
4779+ placeMenu(menu, e);
4780+ },
40674781 },
40684782 },
40694783 // The glyph goes on a session you are not on and nowhere else. It reports the
⋯ 419 unchanged lines
44895203 if (pickTabs.length) {
44905204 e.preventDefault();
44915205 cancelPick();
5206+ return;
44925207 }
5208+ // A pick the keyboard shortcut started belongs to the worker, and this panel
5209+ // has no record of it. Ask; the worker ignores it when nothing is picking.
5210+ togglePort?.postMessage({ type: "cancel-pick" });
44935211 });
44945212
44955213 // Results arriving from the background worker (the keyboard-shortcut path).
⋯ 23 unchanged lines
45195237
45205238 function connectToggle() {
45215239 api.windows.getCurrent().then((win) => {
5240+ // The same answer the tab pins need: which browser window this panel is
5241+ // one of. Set here rather than in a second getCurrent() call, because this
5242+ // one already runs at startup and the pins are useless without it.
5243+ //
5244+ // First connection only. Chrome retires an idle service worker and this
5245+ // runs again a second later, and re-running the whole follow on each of
5246+ // those would keep re-deciding a question the tab has not re-asked.
5247+ const first = panelWindowId == null;
5248+ panelWindowId = win.id;
5249+ if (first) syncTabPin();
45225250 const port = api.runtime.connect({ name: "sidebar" });
45235251 togglePort = port;
45245252 port.postMessage({ type: "hello", windowId: win.id, focused: document.hasFocus() });
⋯ 203 unchanged lines
47285456 densitySelect.addEventListener("change", () => setDensity(densitySelect.value));
47295457 const tabModeSelect = $select("tabmode-select");
47305458 tabModeSelect.addEventListener("change", () => setTabMode(tabModeSelect.value));
5459+const newTabSelect = $select("newtab-select");
5460+newTabSelect.addEventListener("change", () => setNewTabAction(newTabSelect.value));
5461+
5462+// The tab-pin switches. Pins are kept when the feature is turned off — you are
5463+// silencing it, not throwing away what you told it — so nothing here touches
5464+// `byTab` or `byOrigin`.
5465+const followBox = $input("follow-tabs");
5466+const detectBox = $input("detect-devport");
5467+const leadBox = $input("lead-tabs");
5468+
5469+function applyTabPinSettings() {
5470+ followBox.checked = tabPins.enabled;
5471+ detectBox.checked = tabPins.detect;
5472+ leadBox.checked = tabPins.reverse;
5473+ // Both of these are subordinate clauses of the feature, and a live checkbox
5474+ // that cannot do anything is a worse answer than a greyed-out one.
5475+ detectBox.disabled = !tabPins.enabled;
5476+ leadBox.disabled = !tabPins.enabled;
5477+}
5478+
5479+followBox.addEventListener("change", () => {
5480+ saveTabPins({ ...tabPins, enabled: followBox.checked });
5481+ applyTabPinSettings();
5482+});
5483+detectBox.addEventListener("change", () => {
5484+ saveTabPins({ ...tabPins, detect: detectBox.checked });
5485+ applyTabPinSettings();
5486+});
5487+leadBox.addEventListener("change", () => {
5488+ saveTabPins({ ...tabPins, reverse: leadBox.checked });
5489+ applyTabPinSettings();
5490+});
47315491
47325492 $("font-smaller").addEventListener("click", () => setFontSize(fontSize - 1));
47335493 $("font-bigger").addEventListener("click", () => setFontSize(fontSize + 1));
⋯ 68 unchanged lines
48025562 });
48035563 $("reconnect").addEventListener("click", connect);
48045564 $("connect").addEventListener("click", connect);
5565+$("offline-retry").addEventListener("click", connect);
5566+$("offline-settings").addEventListener("click", openSettings);
48055567 $("trust-cert").addEventListener("click", () => {
48065568 const u = new URL($input("url").value.trim());
48075569 api.tabs.create({ url: `https://${u.host}/` });
⋯ 23 unchanged lines
48315593 "pins",
48325594 "sessionOrder",
48335595 "tabMode",
5596+ "newTabAction",
48345597 "foldedGroups",
5598+ Tabpin.KEY,
48355599 ];
48365600
48375601 storage.get(STORED_KEYS).then((v) => {
48385602 if (v.pins && typeof v.pins === "object") pins = v.pins;
5603+ tabPins = Tabpin.loadStore(v[Tabpin.KEY]);
5604+ applyTabPinSettings();
48395605 if (Array.isArray(v.foldedGroups)) {
48405606 foldedGroups = new Set(v.foldedGroups.filter((n) => typeof n === "string"));
48415607 }
⋯ 5 unchanged lines
48475613 themePref = Themes.PREFERENCES.includes(v.theme) ? v.theme : "auto";
48485614 density = DENSITIES[v.density] ? v.density : "normal";
48495615 tabMode = TAB_MODES[v.tabMode] ? v.tabMode : "nested";
5616+ newTabAction = NEW_TAB_ACTIONS[v.newTabAction] ? v.newTabAction : "claude";
48505617 const stored = Number(v.fontSize);
48515618 fontSize =
48525619 Number.isFinite(stored) && stored >= FONT_MIN && stored <= FONT_MAX
⋯ 2 unchanged lines
48555622 applyTheme();
48565623 applyDensity();
48575624 applyTabMode();
5625+ applyNewTabAction();
48585626 applyFontSize();
48595627 if (v.token) $input("token").value = v.token;
48605628 if (v.url) $input("url").value = v.url;
⋯ 14 unchanged lines