anvilsign in

collin/browser-terminal-extension · 233a77a4

Find browser tabs from the jump box

Collin Richards · 2026-08-19 06:36 UTC · 233a77a4ab332f41d5b62d9cb4406b22bfc76587 · parent 614e6bf8 · browse files

modifiedREADME.md+11 −0
⋯ 784 unchanged lines
785785 permission too, because the screenshot API takes nothing narrower, but only
786786 when a capture has already failed and only behind an explicit button. Still no
787787 declared content scripts and no `web_accessible_resources`.
788+- **`debugger` is the one standing, non-revocable permission**, Chrome only,
789+ and it exists for a single feature: the mobile device-emulation toggle next
790+ to the picker button. `chrome.debugger` plus the CDP `Emulation` domain is
791+ the only extension-facing way to override a tab's viewport, user agent and
792+ touch behavior together, and unlike host access it cannot be requested
793+ per-use through `optional_permissions` — Chrome requires it declared up
794+ front in `manifest.chrome.json`. Firefox has no equivalent API at all, so
795+ `manifest.firefox.json` never requests it and the button there just stays
796+ disabled. It attaches only to the tab you click the button on, and
797+ detaching (whether from a second click, the tab closing, or the user
798+ dismissing Chrome's own debugging infobar) reverts every override.
788799 - The picker returns its result as the `executeScript` **return value**, not via
789800 `runtime.sendMessage` — so the sidebar still has no inbound message listener
790801 that a content script could reach.
⋯ 104 unchanged lines
modifiedextension/manifest.chrome.json+3 −2
⋯ 9 unchanged lines
1010 "tabGroups",
1111 "activeTab",
1212 "scripting",
13- "clipboardWrite"
13+ "clipboardWrite",
14+ "debugger"
1415 ],
1516 "background": {
1617 "service_worker": "sw.js"
⋯ 31 unchanged lines
4849 },
4950 "focus-omnibar": {
5051 "suggested_key": {
51- "default": "Ctrl+Shift+L"
52+ "default": "Ctrl+Shift+P"
5253 },
5354 "description": "Jump to a window, session or pane"
5455 },
⋯ 23 unchanged lines
modifiedextension/manifest.firefox.json+1 −1
⋯ 44 unchanged lines
4545 },
4646 "focus-omnibar": {
4747 "suggested_key": {
48- "default": "Ctrl+Shift+L"
48+ "default": "Ctrl+Shift+P"
4949 },
5050 "description": "Jump to a window, session or pane"
5151 }
⋯ 22 unchanged lines
modifiedextension/sidebar.css+57 −22
⋯ 618 unchanged lines
619619 .tab-slot.elsewhere > button.tab { color: var(--fg); }
620620 .tab-slot.elsewhere > button.tab .name { font-weight: 600; }
621621
622-/* The row the browser tab on screen is pinned to.
623-
624- A dot rather than a border or a tint: the tab silhouette is built out of
625- masks and backgrounds (see the run above), and both of the obvious markings
626- would have to be drawn through that.
627-
628- ::after, and absolutely positioned, for two separate reasons. ::before on
629- these three is already the tab separator, and a flex item here would push the
630- name along and collide with the ✕ that overlays the trailing edge. Riding in
631- the leading padding instead costs no layout: every one of the three keeps at
632- least 6px there (7px on a pinned tab and on a chip, before its caret), and
633- the dot is 4px wide starting 1px in. Vertically centred, which is also the
634- one height at which a chip's 9px pill edge is straight.
635-
636- Small and in the accent, because this is a "why did it move" answer read once
637- and then ignored — the tooltip carries the sentence. */
638-.tab-slot.tab-linked > button.tab::after,
622+/* A session or a chip has no room for a second control — both are already a
623+ single button doing one job on click, and a pin icon nested inside would be
624+ a button inside a button. So this stays a mark rather than the real
625+ `button.tab-pin` a window's own tab gets: the same glyph (Google's
626+ "keep_off"), painted with `mask-image` rather than an inline `<svg>` since
627+ there is no element here to hang one off. ::after, absolutely positioned,
628+ rides in leading padding that only a linked chip reserves — costs no layout
629+ on the common, unlinked chip. */
630+button.session-tab.tab-linked,
631+button.group-chip.tab-linked { padding-left: 20px; }
639632 button.session-tab.tab-linked::after,
640633 button.group-chip.tab-linked::after {
641634 content: "";
642635 position: absolute; top: 50%; translate: 0 -50%;
643- left: 1px;
644- width: 4px; height: 4px;
645- border-radius: 50%;
646- background: var(--accent-hover);
636+ left: 5px;
637+ width: 10px; height: 10px;
638+ background: var(--fg-muted);
639+ mask: url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M17%203v2h-1v8.175L7.825%205L7%204.175V3zm-5%2020l-1-1v-6H6v-2l2-2v-1.15L1.4%204.2l1.4-1.4l18.4%2018.4l-1.45%201.4l-6.6-6.6H13v6z%22/%3E%3C/svg%3E') center / contain no-repeat;
640+ -webkit-mask: url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M17%203v2h-1v8.175L7.825%205L7%204.175V3zm-5%2020l-1-1v-6H6v-2l2-2v-1.15L1.4%204.2l1.4-1.4l18.4%2018.4l-1.45%201.4l-6.6-6.6H13v6z%22/%3E%3C/svg%3E') center / contain no-repeat;
647641 pointer-events: none;
648642 }
649643
644+/* A window's own tab has the room a chip does not: `.tab-slot` is a wrapper
645+ around `button.tab`, the same way `.closable` makes room for the ✕ that
646+ sits beside it. So the row the browser tab on screen is pinned to gets a
647+ real button here instead of a dot — the same one click unpins that the
648+ context menu's "Unpin from..." line always did, just without going
649+ looking for it. */
650+.tab-slot.tab-linked button.tab { padding-left: 20px; }
651+/* Full opacity on every tab that wears one, active or not — unlike the ✕ this
652+ is not destructive and not noise: it is the answer to "why did the panel
653+ just move on its own", and that answer is just as true on a background
654+ tab as on the one in front. */
655+button.tab-pin {
656+ position: absolute; left: calc(4px + var(--tab-foot)); top: 5px;
657+ width: 15px; height: 15px; padding: 0;
658+ display: inline-flex; align-items: center; justify-content: center;
659+ border: 0; border-radius: 4px;
660+ background: transparent; color: var(--fg-muted); cursor: pointer;
661+ transition: background 90ms linear, color 90ms linear;
662+}
663+button.tab-pin svg { width: 9px; height: 9px; display: block; }
664+button.tab-pin:hover { background: rgba(128, 128, 128, 0.16); color: var(--fg); }
665+button.tab-pin:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; }
666+
650667 /* --- the omnibar -----------------------------------------------------------
651668 Groups mode's second row: a browser's address bar, under the tabs, in the
652669 same place and doing the same job — search what you have, or create what you
⋯ 242 unchanged lines
895912 }
896913 .omni-row.ssh.active .label::before { color: var(--accent-fg); }
897914 .omni-row.ssh .label { font-family: ui-monospace, monospace; }
915+/* The row that is a browser tab rather than anything on the tmux server. Two
916+ overlapping panes — the one mark in the list that is not about tmux at all,
917+ because that is what has to be read before the name is: Enter here moves the
918+ browser, and every other row moves the terminal. The address is monospaced
919+ for the reason the ssh and path rows are, and because it is usually the half
920+ that was typed. */
921+.omni-row.tab .label::before {
922+ content: "⧉ ";
923+ color: var(--fg-muted);
924+ font-weight: 700;
925+}
926+.omni-row.tab.active .label::before { color: var(--accent-fg); }
927+.omni-row.tab .meta { font-family: ui-monospace, monospace; }
898928 /* A directory, in both of the rows that are about one: what the box holds, and
899929 the names it could be completed to. Monospaced for the reason the ssh and
900930 shell rows are — a path is read character by character, and the column the
⋯ 152 unchanged lines
10531083 color: var(--accent-fg);
10541084 }
10551085 #pick.active { background: var(--accent); color: var(--accent-fg); }
1086+#device-toggle.active { background: var(--accent); color: var(--accent-fg); }
10561087
10571088 /* Jump to the next pane an agent is waiting in. Tinted by the state it would
10581089 take you to, in the same three colours the tabs' glyphs use — the button is
⋯ 107 unchanged lines
11661197 body.compact button.tab { padding: 3px 6px 4px; }
11671198 body.compact button.session-tab { padding: 2px 6px 3px; max-width: calc(8rem + 2 * var(--tab-foot)); }
11681199 body.compact .tab-slot.closable button.tab { padding-right: 19px; }
1200+body.compact .tab-slot.tab-linked button.tab { padding-left: 19px; }
11691201 body.compact button.tab[aria-selected="true"] { padding-bottom: 9px; }
1170-body.compact button.tab-close { top: 4px; }
1202+body.compact button.tab-close,
1203+body.compact button.tab-pin { top: 4px; }
1204+body.compact button.session-tab.tab-linked,
1205+body.compact .group-chip.tab-linked { padding-left: 19px; }
11711206 body.compact .group-chip { padding: 1px 6px 2px; margin-left: 6px; font-size: 10px; }
11721207 /* The count is what a folded chip has instead of tabs, so unlike a session
11731208 tab's it survives compact. */
⋯ 233 unchanged lines
modifiedextension/sidebar.html+19 −3
⋯ 83 unchanged lines
8484 that wants you — so they sit together and away from the tabs. -->
8585 <button id="pick" class="icon" title="Pick an element on the page (Esc cancels)">
8686 <svg viewBox="0 0 16 16" aria-hidden="true">
87- <circle cx="8" cy="8" r="3.1" fill="none" stroke="currentColor" stroke-width="1.6"/>
88- <path d="M8 .8v2.6M8 12.6v2.6M.8 8h2.6M12.6 8h2.6" stroke="currentColor"
89- stroke-width="1.6" stroke-linecap="round"/>
87+ <path d="M2 1.5l4.6 12.2 1.7-4 4-1.7L2 1.5z" fill="none" stroke="currentColor"
88+ stroke-width="1.4" stroke-linejoin="round"/>
89+ <path d="M10.5 10.5l3.5 3.5" stroke="currentColor" stroke-width="1.4"
90+ stroke-linecap="round"/>
91+ </svg>
92+ </button>
93+
94+ <!-- Chrome only: emulates a mobile device on the active tab via CDP
95+ (chrome.debugger + the Emulation domain). Disabled outright on
96+ Firefox, which has no equivalent API — see syncDeviceToggle() in
97+ sidebar.js. Sits between pick and jump in every layout because it
98+ is, like the picker, a control that acts on the page rather than
99+ on tmux. -->
100+ <button id="device-toggle" class="icon" title="Emulate a mobile device on this tab">
101+ <svg viewBox="0 0 16 16" aria-hidden="true">
102+ <rect x="1" y="2.5" width="10.5" height="7" rx="1" fill="none" stroke="currentColor"
103+ stroke-width="1.4"/>
104+ <rect x="9" y="6.5" width="5.5" height="8" rx="1" fill="none" stroke="currentColor"
105+ stroke-width="1.4"/>
90106 </svg>
91107 </button>
92108
⋯ 342 unchanged lines
modifiedextension/sidebar.js+422 −23
⋯ 143 unchanged lines
144144 // you type into, where what it does is leave for somewhere else. Enter is
145145 // not in either row: it lives under the mic, in the corner beside every
146146 // row, and so never moves with the layout.
147- $("omni-strip").prepend($("pick"));
147+ $("omni-strip").prepend($("pick"), $("device-toggle"));
148148 $("omni-strip").append($("jump"));
149149 $("omni-strip").hidden = false;
150150 // The session row is gone, so what it was showing has to go with it: a
⋯ 12 unchanged lines
163163 top.append($("session-new"), $("session-name"));
164164 // Picker then jump lead the window row, in that order: both act on a pane
165165 // rather than on the tabs.
166- row.prepend($("pick"), $("jump"));
166+ row.prepend($("pick"), $("device-toggle"), $("jump"));
167167 row.append($("tab-new"));
168168 $("omni-strip").hidden = true;
169169 closeOmni();
⋯ 1167 unchanged lines
13371337 const elsewhere = w.active && !current;
13381338 // The browser tab on screen sends the terminal here. Worth a mark, because
13391339 // otherwise the panel moves on its own and nothing on it says why.
1340- const linked = tabPinMark({ session: owner ?? sessionName, window: w.id });
1340+ const pinTarget = { session: owner ?? sessionName, window: w.id };
1341+ const linked = tabPinMark(pinTarget);
13411342
13421343 const tab = button(
13431344 {
⋯ 60 unchanged lines
14041405 },
14051406 tab,
14061407 canClose && closeButton(w, lastInSession ? owner : undefined),
1408+ linked && unpinButton(pinTarget, linked),
14071409 );
14081410 // The slot and not the tab: the ✕ is its sibling, and a drag that started on
14091411 // the tab alone would leave the ✕ behind.
⋯ 293 unchanged lines
17031705 // The menu labels name the origin, and the indicator marks the row this
17041706 // tab points at. Both are stale the moment the tab changed.
17051707 repaintTabs();
1708+ syncDeviceToggle();
17061709 })
17071710 .catch(() => {});
17081711 }
⋯ 10 unchanged lines
17191722 .then((tab) => {
17201723 browserTab = { id: tab.id, url: tab.url };
17211724 repaintTabs();
1725+ syncDeviceToggle();
17221726 })
17231727 .catch(() => {});
17241728 return;
⋯ 9 unchanged lines
17341738 browserTab = { id: tabId, url: change.url };
17351739 scheduleTabPin();
17361740 repaintTabs();
1741+ syncDeviceToggle();
17371742 });
17381743
17391744 // Tab ids are never reissued, so a pin whose tab is gone can only ever be dead
⋯ 13 unchanged lines
17531758
17541759 /**
17551760 * Whether the showing browser tab points at this exact row, so the row can say
1756- * so. Windows match on their own id; a session-level pin marks the session and
1757- * every row of it stays unmarked.
1761+ * so. Windows match on their own id.
1762+ *
1763+ * A session's own row (chip or session-tab) also lights up for a pin that
1764+ * names one of its *windows*, not just a session-level pin — that window's own
1765+ * row carries the precise mark when it is on screen, but a folded group or a
1766+ * session you are not attached to never renders its windows at all, and the
1767+ * session row is the only thing left to say the pin is in here somewhere.
17581768 *
17591769 * @param {{ session?: string | null, window?: string | null }} row
17601770 * @returns {TbPinSource | null}
⋯ 2 unchanged lines
17631773 const at = pinNow;
17641774 if (!at) return null;
17651775 if (row.window) return at.window?.id === row.window ? at.source : null;
1766- return !at.window && at.session === row.session ? at.source : null;
1776+ return at.session === row.session ? at.source : null;
17671777 }
17681778
17691779 /** How a pin explains itself in a tooltip. */
⋯ 4 unchanged lines
17741784 };
17751785
17761786 /**
1787+ * Removes whichever pin currently links `target` to the browser tab on
1788+ * screen — shared by the context menu's "Unpin from..." line and the pin icon
1789+ * a linked window's tab wears, so there is one place that knows how each kind
1790+ * of pin comes off.
1791+ *
1792+ * Unpinning a detected match cannot just delete an entry — there is no entry,
1793+ * and the guess would be back before the click had landed. It writes the veto
1794+ * instead, at the level the guess was made: the origin.
1795+ *
1796+ * @param {{ session?: string | null, window?: string | null }} target
1797+ */
1798+function unpinHere(target) {
1799+ const here = tabPinMark({ session: target.session, window: target.window });
1800+ if (!here) return;
1801+ const origin = Tabpin.originOf(browserTab.url);
1802+ const tabKey = browserTab.id != null ? String(browserTab.id) : "";
1803+ if (here === "tab") saveTabPins(Tabpin.put(tabPins, "tab", tabKey, undefined));
1804+ else if (here === "origin") saveTabPins(Tabpin.put(tabPins, "origin", origin, undefined));
1805+ else saveTabPins(Tabpin.put(tabPins, "origin", origin, null));
1806+}
1807+
1808+/**
17771809 * The pin lines of a context menu, for a window (a window id) or a whole
17781810 * session (none). Both kinds of key are offered because they answer different
17791811 * questions — see the header of lib/tabpin.js — and neither is offered for a
⋯ 9 unchanged lines
17891821 const here = tabPinMark({ session: target.session, window: target.window });
17901822
17911823 if (here) {
1792- // Unpinning a detected match cannot just delete an entry — there is no
1793- // entry, and the guess would be back before the menu had closed. It writes
1794- // the veto instead, at the level the guess was made: the origin.
17951824 const label = here === "tab" ? "Unpin from this browser tab" : `Unpin from ${shortOrigin(origin)}`;
1796- menuItem(menu, label, () => {
1797- if (here === "tab") saveTabPins(Tabpin.put(tabPins, "tab", tabKey, undefined));
1798- else if (here === "origin") saveTabPins(Tabpin.put(tabPins, "origin", origin, undefined));
1799- else saveTabPins(Tabpin.put(tabPins, "origin", origin, null));
1800- });
1825+ menuItem(menu, label, () => unpinHere(target));
18011826 return;
18021827 }
18031828
⋯ 117 unchanged lines
19211946 });
19221947 }
19231948
1949+ // The same split the "+" drop target performs, as a line you can find without
1950+ // knowing the drag exists. Withheld for a session's last window, where the
1951+ // drag allows it — a drop needs a "+" that lights up for every tab, but a menu
1952+ // line can be selective, and here the split would only rename the session the
1953+ // window is already alone in.
1954+ if (!lastInSession) {
1955+ item("Move to new session", () => {
1956+ const name = sessionNameFor(
1957+ w.name,
1958+ lastSessions.map((s) => s.name),
1959+ );
1960+ tmuxCommand({ cmd: "new-session-with-window", window: w.id, name });
1961+ log(`new session ${name} from window ${w.index} (${w.name})`);
1962+ term.focus();
1963+ });
1964+ }
1965+
19241966 if (closable) {
19251967 const kill = item(lastInSession && owner ? `Close window and ${owner}` : "Close window", () => {
19261968 tmuxCommand({ cmd: "kill-window", window: w.id });
⋯ 72 unchanged lines
19992041 );
20002042 }
20012043
2044+/**
2045+ * The pin icon a window's tab wears once the browser tab on screen is linked
2046+ * to it. One click is the whole gesture — `unpinHere` already knows which
2047+ * kind of pin to take off, and the tooltip already said why it was there.
2048+ *
2049+ * @param {{ session?: string | null, window?: string | null }} target
2050+ * @param {TbPinSource} source
2051+ */
2052+function unpinButton(target, source) {
2053+ const label = `${PIN_SOURCE_NOTE[source]} — click to unpin`;
2054+ return button(
2055+ {
2056+ class: "tab-pin",
2057+ title: label,
2058+ attrs: { "aria-label": label },
2059+ on: {
2060+ /** @param {MouseEvent} e */
2061+ click: (e) => {
2062+ // The tab underneath would otherwise read this as "select me".
2063+ e.stopPropagation();
2064+ unpinHere(target);
2065+ term.focus();
2066+ },
2067+ },
2068+ },
2069+ pinIcon(),
2070+ );
2071+}
2072+
20022073 const SVG_NS = "http://www.w3.org/2000/svg";
20032074
20042075 /**
⋯ 16 unchanged lines
20212092 return svg;
20222093 }
20232094
2095+/** Google's own "keep_off" glyph — the pin-with-a-strike Chrome's Material
2096+ * Symbols icon set uses for exactly this — dropped in verbatim rather than
2097+ * redrawn by hand. */
2098+function pinIcon() {
2099+ const svg = document.createElementNS(SVG_NS, "svg");
2100+ svg.setAttribute("viewBox", "0 0 24 24");
2101+ svg.setAttribute("aria-hidden", "true");
2102+ const path = document.createElementNS(SVG_NS, "path");
2103+ path.setAttribute(
2104+ "d",
2105+ "M17 3v2h-1v8.175L7.825 5L7 4.175V3zm-5 20l-1-1v-6H6v-2l2-2v-1.15L1.4 4.2l1.4-1.4l18.4 18.4l-1.45 1.4l-6.6-6.6H13v6z",
2106+ );
2107+ path.setAttribute("fill", "currentColor");
2108+ svg.appendChild(path);
2109+ return svg;
2110+}
2111+
20242112 /**
20252113 * Open a window in a session and go to it.
20262114 *
⋯ 1258 unchanged lines
32853373 /**
32863374 * One row of the dropdown.
32873375 * @typedef {object} TbOmniItem
3288- * @property {"window" | "session" | "pane" | "ssh" | "create" | "claude" | "run"
3289- * | "action" | "project" | "path"} kind
3376+ * @property {"window" | "session" | "pane" | "tab" | "ssh" | "create" | "claude"
3377+ * | "run" | "action" | "project" | "path"} kind
32903378 * @property {string} label the name, matched against and shown first
32913379 * @property {string} meta where it is — dimmed, after the label
32923380 * @property {string} [mark] a character in front of the label, for the kinds
⋯ 107 unchanged lines
34003488 session: 0,
34013489 window: 1,
34023490 pane: 2,
3403- ssh: 3,
3404- create: 4,
3405- claude: 5,
3406- run: 6,
3407- action: 7,
3491+ tab: 3,
3492+ ssh: 4,
3493+ create: 5,
3494+ claude: 6,
3495+ run: 7,
3496+ action: 8,
34083497 // Both only ever appear on their own — a `~` in the box is a mode, and these
34093498 // are the only rows in it — so their rank is a formality.
3410- project: 8,
3411- path: 9,
3499+ project: 9,
3500+ path: 10,
34123501 };
34133502
34143503 /**
⋯ 9 unchanged lines
34243513 /** How many matches the list shows before it stops. */
34253514 const OMNI_LIMIT = 8;
34263515
3516+// --- the box as a tab switcher ----------------------------------------------
3517+//
3518+// The other half of what a panel this shape is looking at: the browser window
3519+// it is attached to. Typing `localhost:8000` finds the tab serving it and Enter
3520+// shows it, which is the thing the address bar cannot be asked for from in here
3521+// — Ctrl+L belongs to the terminal, and no extension API can focus the real one.
3522+//
3523+// Nothing here needs a permission that is not already granted: `tabs` is in
3524+// both manifests for the pins, and a tab's title and URL come with it.
3525+
3526+/**
3527+ * What a tab row's score is pushed up by, so that everything on the tmux server
3528+ * outranks a page in the browser.
3529+ *
3530+ * Below the panes and the hosts, which is the bottom of the list, and it costs
3531+ * the tab rows nothing they wanted: an address matches no session, window or
3532+ * host name, so the query that is looking for a tab finds only tabs anyway. The
3533+ * penalty decides the other case — a word that means both, a tab titled `build`
3534+ * while a window is called `build` — and there the window is what the box is
3535+ * for.
3536+ */
3537+const OMNI_TAB_PENALTY = 60;
3538+
3539+/**
3540+ * The window's tabs, as of the last time the box asked.
3541+ *
3542+ * Kept rather than queried per keystroke, because `omniSuggestions` is
3543+ * synchronous and `api.tabs.query` is not.
3544+ *
3545+ * @type {TbTab[]}
3546+ */
3547+let lastTabs = [];
3548+
34273549 /**
3550+ * Re-read the window's tabs, and repaint the list if it is up.
3551+ *
3552+ * Asked for when the box takes focus, and on every tab event while the list is
3553+ * showing — which is what keeps a row from offering a tab that closed under it.
3554+ * Not asked for otherwise: with the list down there is nothing to be stale.
3555+ */
3556+function syncOmniTabs() {
3557+ if (panelWindowId == null) return;
3558+ api.tabs
3559+ .query({ windowId: panelWindowId })
3560+ .then((tabs) => {
3561+ lastTabs = tabs;
3562+ if (!$("omni-list").hidden) refreshOmni();
3563+ })
3564+ .catch(() => {});
3565+}
3566+
3567+const repaintOmniTabs = () => {
3568+ if (!$("omni-list").hidden) syncOmniTabs();
3569+};
3570+api.tabs.onCreated.addListener(repaintOmniTabs);
3571+api.tabs.onRemoved.addListener(repaintOmniTabs);
3572+api.tabs.onUpdated.addListener(repaintOmniTabs);
3573+api.tabs.onActivated.addListener(repaintOmniTabs);
3574+
3575+/**
3576+ * A URL as the box should match and show it: the scheme, a leading `www.` and a
3577+ * trailing slash taken off, because none of the three is anything you would
3578+ * type to find the tab again. `https://localhost:8000/` becomes `localhost:8000`.
3579+ *
3580+ * Only http(s) is trimmed. A `chrome://`, `about:` or `file://` address is
3581+ * mostly scheme — take it off and what is left names nothing.
3582+ *
3583+ * @param {string} url
3584+ * @returns {string}
3585+ */
3586+function tabAddress(url) {
3587+ let u;
3588+ try {
3589+ u = new URL(url);
3590+ } catch {
3591+ return url;
3592+ }
3593+ if (u.protocol !== "http:" && u.protocol !== "https:") return url.replace(/\/+$/, "");
3594+ const rest = `${u.pathname}${u.search}`.replace(/\/$/, "");
3595+ return u.host.replace(/^www\./, "") + rest;
3596+}
3597+
3598+/**
3599+ * The better of two `omniScore`s, where -1 means "did not match" rather than a
3600+ * score and so cannot go into the comparison as one.
3601+ *
3602+ * @param {number} a
3603+ * @param {number} b
3604+ * @returns {number} lower is better, -1 when neither matched
3605+ */
3606+function bestScore(a, b) {
3607+ if (a < 0) return b;
3608+ if (b < 0) return a;
3609+ return Math.min(a, b);
3610+}
3611+
3612+/**
3613+ * The window's tabs, as rows.
3614+ *
3615+ * Matched on the title *and* on the address, best of the two, because which of
3616+ * them you know is not the same from tab to tab: a dev server is `localhost:8000`
3617+ * and its title is whatever the framework put there, while a doc page has a
3618+ * title you would recognise and a URL you would not.
3619+ *
3620+ * This window's tabs only. The panel belongs to one browser window — Chrome
3621+ * gives each window its own side panel — so a row that focused another window
3622+ * would leave the terminal behind in this one, which is a strange thing for the
3623+ * terminal's own box to offer.
3624+ *
3625+ * Titles and URLs are page-controlled, which is the same footing tmux names are
3626+ * on: both reach the DOM through textContent and nothing else.
3627+ *
3628+ * @param {string} q already lowercased and trimmed
3629+ * @returns {TbOmniItem[]}
3630+ */
3631+function tabSuggestions(q) {
3632+ /** @type {TbOmniItem[]} */
3633+ const out = [];
3634+ for (const tab of lastTabs) {
3635+ const id = tab.id;
3636+ // The showing tab is left out for the reason the current session is: going
3637+ // there is where you already are.
3638+ if (id == null || tab.active) continue;
3639+ const where = tabAddress(tab.url ?? "");
3640+ const score = bestScore(omniScore(tab.title ?? "", q), omniScore(where, q));
3641+ if (score < 0) continue;
3642+ out.push({
3643+ kind: "tab",
3644+ label: tab.title || where,
3645+ meta: where,
3646+ score: score + OMNI_TAB_PENALTY,
3647+ // Showing the tab is the whole of it. The keyboard stays where `runOmni`
3648+ // puts it — a panel cannot hand focus to the page — and if the tab is
3649+ // pinned, showing it moves the terminal too: the same forward pin that a
3650+ // click on the tab would have run.
3651+ run: () => api.tabs.update(id, { active: true }).catch(() => {}),
3652+ });
3653+ }
3654+ return out;
3655+}
3656+
3657+/**
34283658 * @param {string} query what is in the box
34293659 * @returns {TbOmniItem[]}
34303660 */
⋯ 93 unchanged lines
35243754 });
35253755 }
35263756
3757+ // The browser's own tabs. Only with a query, for the same reason the windows
3758+ // and the panes are: an empty box is the server's short list, and every tab
3759+ // in the window listed under it would bury the thing it opened for.
3760+ if (q) out.push(...tabSuggestions(q));
3761+
35273762 // Machines, from what ssh already knows about. Only with a query, for the
35283763 // same reason the windows are: an empty box is a starting point rather than
35293764 // an inventory, and the sessions are the short list it offers.
⋯ 1042 unchanged lines
45724807 // on when the panel is still coming up around it. Asking for it here makes
45734808 // the shortcut mean the same thing however it arrived: the box, its name
45744809 // selected, and everywhere else already listed under it.
4810+ // The tab rows come from a query, and the shortcut is the one path into the
4811+ // box that the focus listener below does not cover: pressed with the caret
4812+ // already here, no focus event fires and the list would be built from
4813+ // whatever the window's tabs were the last time it was open.
45754814 omniDirty = false;
4815+ syncOmniTabs();
45764816 refreshOmni();
45774817 }
45784818
⋯ 21 unchanged lines
46004840 $area("omni").addEventListener("focus", () => {
46014841 omniDirty = false;
46024842 $area("omni").select();
4843+ syncOmniTabs();
46034844 refreshOmni();
46044845 });
46054846
⋯ 631 unchanged lines
52375478 }
52385479 });
52395480
5481+// --- mobile device emulation --------------------------------------------
5482+//
5483+// DevTools' own device toolbar, reachable from here: chrome.debugger is the
5484+// only extension-facing way to drive the CDP Emulation domain, and there is
5485+// no Firefox equivalent, so api.debugger is both the permission gate and the
5486+// feature gate. See the "debugger" bullet in README's Security model for why
5487+// this is the one standing, non-revocable permission in this codebase.
5488+
5489+/** A DevTools-ish default: iPhone 12/13-class viewport, in each orientation. */
5490+const MOBILE_DEVICE_METRICS = {
5491+ portrait: {
5492+ width: 390,
5493+ height: 844,
5494+ deviceScaleFactor: 3,
5495+ mobile: true,
5496+ screenOrientation: { type: "portraitPrimary", angle: 0 },
5497+ },
5498+ landscape: {
5499+ width: 844,
5500+ height: 390,
5501+ deviceScaleFactor: 3,
5502+ mobile: true,
5503+ screenOrientation: { type: "landscapePrimary", angle: 90 },
5504+ },
5505+};
5506+const MOBILE_UA =
5507+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
5508+ "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
5509+
5510+/**
5511+ * Tabs currently under CDP device-metrics override, and which orientation
5512+ * each is showing. One button drives all three states: off -> portrait ->
5513+ * landscape -> off.
5514+ * @type {Map<number, "portrait" | "landscape">}
5515+ */
5516+const emulating = new Map();
5517+
5518+/**
5519+ * Apply a metrics override and force a repaint against it. Blink does not
5520+ * reliably repaint on a metrics change alone — without the synthetic resize
5521+ * below, a rotate can leave the page painted at the old size until something
5522+ * external forces a layout pass. DevTools' own device toolbar never hits
5523+ * this: resizing its panel is the forcing function this dispatches by hand
5524+ * instead.
5525+ * @param {number} tabId
5526+ * @param {typeof MOBILE_DEVICE_METRICS.portrait} metrics
5527+ */
5528+async function applyDeviceMetrics(tabId, metrics) {
5529+ if (!api.debugger) return;
5530+ await api.debugger.sendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", metrics);
5531+ await api.debugger
5532+ .sendCommand({ tabId }, "Runtime.evaluate", {
5533+ expression: "window.dispatchEvent(new Event('resize'))",
5534+ })
5535+ .catch(() => {});
5536+}
5537+
5538+/**
5539+ * Undo the on-sequence and drop the CDP session.
5540+ * @param {number} tabId
5541+ */
5542+async function stopEmulating(tabId) {
5543+ if (!api.debugger) return;
5544+ await api.debugger
5545+ .sendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride")
5546+ .catch(() => {});
5547+ await api.debugger
5548+ .sendCommand({ tabId }, "Emulation.setTouchEmulationEnabled", { enabled: false })
5549+ .catch(() => {});
5550+ await api.debugger
5551+ .sendCommand({ tabId }, "Runtime.evaluate", {
5552+ expression: "window.dispatchEvent(new Event('resize'))",
5553+ })
5554+ .catch(() => {});
5555+ await api.debugger.detach({ tabId }).catch(() => {});
5556+}
5557+
5558+/**
5559+ * Reflect the tracked tab's place in the off/portrait/landscape cycle, or
5560+ * that the feature does not exist at all here — Firefox has no api.debugger,
5561+ * so the button just stays off permanently rather than failing on click.
5562+ */
5563+function syncDeviceToggle() {
5564+ const btn = /** @type {HTMLButtonElement} */ ($("device-toggle"));
5565+ if (!api.debugger) {
5566+ btn.disabled = true;
5567+ btn.title = "Mobile emulation needs Chrome's debugger API — not available in Firefox";
5568+ return;
5569+ }
5570+ const orientation = browserTab.id != null ? emulating.get(browserTab.id) : undefined;
5571+ btn.classList.toggle("active", orientation != null);
5572+ btn.title =
5573+ orientation === "portrait"
5574+ ? "Rotate to landscape (click again to turn off)"
5575+ : orientation === "landscape"
5576+ ? "Turn off mobile emulation"
5577+ : "Emulate a mobile device on this tab";
5578+}
5579+
5580+$("device-toggle").addEventListener("click", async () => {
5581+ if (!api.debugger) return;
5582+ const tabId = browserTab.id;
5583+ if (tabId == null) return;
5584+
5585+ const orientation = emulating.get(tabId);
5586+ if (orientation === "landscape") {
5587+ await stopEmulating(tabId);
5588+ emulating.delete(tabId);
5589+ syncDeviceToggle();
5590+ return;
5591+ }
5592+ if (orientation === "portrait") {
5593+ await applyDeviceMetrics(tabId, MOBILE_DEVICE_METRICS.landscape);
5594+ emulating.set(tabId, "landscape");
5595+ syncDeviceToggle();
5596+ return;
5597+ }
5598+
5599+ if (SKIP_URL.test(browserTab.url ?? "")) {
5600+ log("Can't emulate here — browser-internal and extension pages are off limits.");
5601+ return;
5602+ }
5603+
5604+ try {
5605+ await api.debugger.attach({ tabId }, "1.3");
5606+ } catch (e) {
5607+ log(`Mobile toggle: ${e instanceof Error ? e.message : e}`);
5608+ return;
5609+ }
5610+ try {
5611+ await applyDeviceMetrics(tabId, MOBILE_DEVICE_METRICS.portrait);
5612+ // Deprecated in the CDP spec (setDeviceMetricsOverride's `mobile` flag now
5613+ // implies touch), but DevTools' own device toolbar still sends it, so kept
5614+ // for parity.
5615+ await api.debugger.sendCommand({ tabId }, "Emulation.setTouchEmulationEnabled", { enabled: true });
5616+ await api.debugger.sendCommand({ tabId }, "Emulation.setUserAgentOverride", { userAgent: MOBILE_UA });
5617+ } catch (e) {
5618+ // Never leave a half-configured session attached.
5619+ await stopEmulating(tabId);
5620+ log(`Mobile toggle: ${e instanceof Error ? e.message : e}`);
5621+ return;
5622+ }
5623+ emulating.set(tabId, "portrait");
5624+ syncDeviceToggle();
5625+});
5626+
5627+// Fires when the session ends without our own detach() call: the tab closed,
5628+// or the user dismissed Chrome's debugging infobar themselves. Either way the
5629+// button has gone stale and has to resync.
5630+api.debugger?.onDetach.addListener((source) => {
5631+ if (source.tabId == null) return;
5632+ emulating.delete(source.tabId);
5633+ syncDeviceToggle();
5634+});
5635+
52405636 // Escape from the sidebar too. The picker handles Escape itself, but only when
52415637 // the page has keyboard focus — if you started the pick from here, focus is
52425638 // still in the panel and the key never reaches the page.
⋯ 87 unchanged lines
53305726 window.addEventListener("focus", reportFocus);
53315727 window.addEventListener("blur", reportFocus);
53325728 connectToggle();
5729+// Sets the disabled state on Firefox immediately, ahead of the first tab
5730+// query resolving.
5731+syncDeviceToggle();
53335732
53345733 // A pick made while the sidebar was closed is parked in storage. Nothing is
53355734 // typed for these: the terminal has moved on, and whatever screenshot went with
⋯ 365 unchanged lines
modifiedextension/sw.js+40 −77
⋯ 78 unchanged lines
7979 /** @type {Map<number, { port: TbPort, focused: boolean }>} */
8080 const panels = new Map();
8181
82+// Windows whose panel is being closed by the toggle shortcut, Chrome only —
83+// see focusActiveTabContent below for why this waits for the disconnect.
84+/** @type {Set<number>} */
85+const closingForToggle = new Set();
86+
8287 api.runtime.onConnect.addListener((port) => {
8388 if (port.name !== "sidebar") return;
8489 // Content scripts can connect too. Ours is an extension page: no sender.tab.
⋯ 7 unchanged lines
9297 if (msg?.type === "hello" && typeof msg.windowId === "number") {
9398 windowId = /** @type {number} */ (msg.windowId);
9499 panels.set(windowId, { port, focused: !!msg.focused });
95- // The shortcut that opened this panel asked for the jump box, and there
96- // was nothing here to ask at the time. This connection is the panel
97- // arriving; the request is only good for the panel that open call was
98- // for, so it is spent whether or not it was this one.
99- const asked = pendingOmnibar.get(windowId);
100- pendingOmnibar.delete(windowId);
101- if (asked != null && Date.now() - asked < PENDING_OMNIBAR_MS) {
102- port.postMessage({ type: "omnibar" });
103- }
104100 } else if (msg?.type === "cancel-pick") {
105101 // Escape in the panel, for a pick the shortcut started here. Harmless
106102 // when nothing is picking: the list is empty and this does nothing.
⋯ 7 unchanged lines
114110 });
115111 port.onDisconnect.addListener(() => {
116112 if (windowId != null && panels.get(windowId)?.port === port) panels.delete(windowId);
113+ // Chrome only: the panel document is actually gone now, so this is the
114+ // first moment stealing focus back for the tab can win against whatever
115+ // default target Chrome just picked for itself.
116+ if (windowId != null && closingForToggle.delete(windowId)) focusActiveTabContent(windowId);
117117 });
118118 });
119119
⋯ 12 unchanged lines
132132 Promise.resolve(opened).catch((e) => console.warn("termbridge: cannot open panel", e));
133133 }
134134
135-// Chrome hands the panel the keyboard when it opens it and at no other time:
136-// there is no API to focus a panel that is already up, and `autofocus` does
137-// nothing in one. So the only way to point the keyboard at an open panel is to
138-// make it a panel Chrome is opening — take it away and put it back.
139-//
140-// The catch is the same gesture rule as above, from the other side: there is no
141-// sidePanel.close() to call, and closing it the only way there is — asking the
142-// panel to close itself — is a round trip that spends the gesture the reopen
143-// needs. Disabling the panel closes it without asking anyone, so all three
144-// calls are issued here, unawaited and in order, while the gesture is live.
145-//
146-// The panel document does not survive this, and that is the whole cost: the
147-// terminal is a tmux client, so a new one reattaches to the same session with
148-// its scrollback intact on the server. What the user sees is a flicker.
149-//
150-// Experimental — this leans on Chrome running the three in the order they were
151-// issued. Set false to go back to leaving an open panel where it is.
152-const REOPEN_TO_FOCUS = true;
153-
154135 /** @param {number} windowId */
155-function reopenPanelForFocus(windowId) {
156- const sidePanel = api.sidePanel;
157- if (!REOPEN_TO_FOCUS || !sidePanel?.setOptions) {
158- // Firefox reaches the same end by a shorter road: sidebarAction.open()
159- // focuses the sidebar whether or not it was already showing.
160- openPanel(windowId);
161- return;
162- }
163- const quiet = (/** @type {unknown} */ e) => console.warn("termbridge: panel reopen", e);
164- Promise.resolve(sidePanel.setOptions({ enabled: false })).catch(quiet);
165- Promise.resolve(sidePanel.setOptions({ enabled: true, path: "sidebar.html" })).catch(quiet);
166- openPanel(windowId);
136+function focusActiveTabContent(windowId) {
137+ if (!api.scripting?.executeScript) return;
138+ api.tabs
139+ .query({ active: true, windowId })
140+ .then(([tab]) => {
141+ if (!tab || typeof tab.id !== "number" || SKIP.test(tab.url ?? "")) return;
142+ return api.scripting.executeScript({ target: { tabId: tab.id }, func: () => window.focus() });
143+ })
144+ .catch(() => {});
167145 }
168146
169147 /** @param {number | undefined} windowId */
⋯ 12 unchanged lines
182160 }
183161
184162 if (entry.focused) {
185- // Firefox can close its own sidebar; Chrome has no close API, so the panel
186- // closes itself with window.close().
187- if (api.sidebarAction?.close) api.sidebarAction.close();
188- else entry.port.postMessage({ type: "close" });
163+ // Neither browser reliably hands keyboard focus back to the page once the
164+ // panel it was in disappears — it can land on the omnibox or nowhere.
165+ // window.focus(), run in the page itself, is the one thing that reclaims it.
166+ if (api.sidebarAction?.close) {
167+ // Firefox: the sidebar document just gets hidden, not unloaded, so it is
168+ // safe to steal focus back right away.
169+ api.sidebarAction.close();
170+ focusActiveTabContent(windowId);
171+ } else {
172+ // Chrome: the panel document is destroyed by window.close(), and Chrome
173+ // assigns its own new focus target the moment that happens — a
174+ // window.focus() issued before then just loses that race. Wait for the
175+ // port to disconnect, which fires once the panel is actually gone.
176+ closingForToggle.add(windowId);
177+ entry.port.postMessage({ type: "close" });
178+ }
189179 return;
190180 }
191181
⋯ 2 unchanged lines
194184 entry.port.postMessage({ type: "focus" });
195185 openPanel(windowId);
196186 }
197-
198-// A jump box asked for while the panel was closed, by window and when. The
199-// panel takes a moment to come up and connect, so the request has to outlive
200-// the shortcut that made it — but only just: a panel arriving much later is
201-// one the user opened themselves, and it should come up on the terminal like
202-// any other.
203-/** @type {Map<number, number>} */
204-const pendingOmnibar = new Map();
205-const PENDING_OMNIBAR_MS = 10_000;
206187
207188 // Put the caret in the panel's jump bar. Same port and the same reasoning as
208189 // the toggle: whether the panel is open is state only the panel can report.
190+//
191+// Deliberately does nothing unless the panel is already open and holding the
192+// keyboard: this is the panel's own jump-box key, not a way to open the panel
193+// or steal focus away from whatever page the user is on.
209194 /** @param {number | undefined} windowId */
210195 function focusOmnibar(windowId) {
211196 if (windowId == null) {
⋯ 1 unchanged line
213198 return;
214199 }
215200 const entry = panels.get(windowId);
216-
217- // Not open. The panel is the only thing that can put the caret anywhere, so
218- // the request is left here for it to pick up when it connects — and this is
219- // the one path where the caret reliably lands, because a panel Chrome is
220- // opening for the first time is a panel Chrome hands the keyboard to.
221- if (!entry) {
222- pendingOmnibar.set(windowId, Date.now());
223- openPanel(windowId);
224- return;
225- }
226-
227- // Open and already holding the keyboard: the panel it has is the one that
228- // takes the caret, and nothing has to move for it to be typed into.
229- if (entry.focused) {
230- entry.port.postMessage({ type: "omnibar" });
231- return;
232- }
233-
234- // Open, but the keyboard is on the page. Telling this panel to focus its box
235- // would put a caret in a window nothing is typing into, so the request is
236- // left for the panel that comes back and the panel is taken away and
237- // reopened — the one move that makes Chrome hand the keyboard over.
238- pendingOmnibar.set(windowId, Date.now());
239- reopenPanelForFocus(windowId);
201+ if (!entry?.focused) return;
202+ entry.port.postMessage({ type: "omnibar" });
240203 }
241204
242205 // --- handing a tab to Claude in Chrome ---------------------------------------
⋯ 94 unchanged lines
modifiedextension/types/globals.d.ts+18 −0
⋯ 131 unchanged lines
132132 onRemoved: {
133133 addListener(cb: (tabId: number) => void): void;
134134 };
135+ /** A tab appeared. Only the omnibar's tab rows read it: the list they are
136+ part of is the browser's, and one opened while the box is up belongs in
137+ it. */
138+ onCreated: {
139+ addListener(cb: (tab: TbTab) => void): void;
140+ };
135141 /** Only used to make a tab active, which needs no permission. captureVisibleTab
136142 has no tabId of its own, so a split-view pick has to be activated first. */
137143 update(tabId: number, props: { active: boolean }): Promise<TbTab>;
⋯ 16 unchanged lines
154160 permissions: {
155161 request(perms: { origins?: string[]; permissions?: string[] }): Promise<boolean>;
156162 };
163+ /** Chrome only — Firefox has no debugger API a normal extension can reach,
164+ so api.debugger is the feature gate for the mobile-emulation toggle. */
165+ debugger?: {
166+ attach(target: { tabId: number }, requiredVersion: string): Promise<void>;
167+ detach(target: { tabId: number }): Promise<void>;
168+ sendCommand(target: { tabId: number }, method: string, params?: object): Promise<any>;
169+ onDetach: {
170+ addListener(
171+ cb: (source: { tabId?: number }, reason: "target_closed" | "canceled_by_user") => void,
172+ ): void;
173+ };
174+ };
157175 scripting: {
158176 /**
159177 * `Awaited<T>`: a func that returns a promise is awaited by the browser,
⋯ 350 unchanged lines