| 1 | // Background worker. |
| 2 | // |
| 3 | // Its one real job is the element picker's keyboard shortcut. `activeTab` — the |
| 4 | // permission that lets us touch a page without asking for <all_urls> — is only |
| 5 | // granted on specific user gestures: a toolbar-action click, a context menu |
| 6 | // item, or a keyboard command. A click inside the side panel is not one of |
| 7 | // them, so the in-panel button can fail while the shortcut always works. Both |
| 8 | // paths exist; this is the reliable one. |
| 9 | |
| 10 | // One of the two always exists — this file only ever runs as an extension |
| 11 | // background script — but neither is declared unconditionally, so say which. |
| 12 | const api = /** @type {TbExtensionApi} */ (globalThis.browser ?? globalThis.chrome); |
| 13 | |
| 14 | // Chrome's service worker starts empty; Firefox loads picker.js alongside this |
| 15 | // file via background.scripts, so there it is already defined. |
| 16 | if (typeof tbPickElement === "undefined" && typeof importScripts === "function") { |
| 17 | importScripts("picker.js", "lib/shot.js", "lib/split.js"); |
| 18 | } |
| 19 | |
| 20 | // Chrome only: make the toolbar button open the side panel. |
| 21 | if (api.sidePanel?.setPanelBehavior) { |
| 22 | api.sidePanel |
| 23 | .setPanelBehavior({ openPanelOnActionClick: true }) |
| 24 | .catch((e) => console.error(e)); |
| 25 | } |
| 26 | |
| 27 | const SKIP = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/; |
| 28 | |
| 29 | // The tabs a shortcut-started pick is running in. Empty means none is. |
| 30 | let pickTabs = /** @type {number[]} */ ([]); |
| 31 | |
| 32 | async function runPicker() { |
| 33 | const [tab] = await api.tabs.query({ active: true, currentWindow: true }); |
| 34 | if (!tab || SKIP.test(tab.url ?? "")) { |
| 35 | console.warn("termbridge: cannot pick on this page", tab?.url); |
| 36 | return; |
| 37 | } |
| 38 | |
| 39 | // Both halves of a split view, when this is one. The activeTab grant the |
| 40 | // command just minted covers only the active half, so the partner half is |
| 41 | // reachable only where its origin was granted — Split.racePick treats that |
| 42 | // refusal as one runner dropping out, not as the pick failing. |
| 43 | const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP.test(t.url ?? "")); |
| 44 | // Remembered so the panel can call the pick off: this path's Escape has to |
| 45 | // work from the panel too, and the panel doesn't know this pick exists. |
| 46 | pickTabs = []; |
| 47 | for (const t of targets) if (t.id != null) pickTabs.push(t.id); |
| 48 | const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement); |
| 49 | pickTabs = []; |
| 50 | if (error) console.error("termbridge: pick failed", error); |
| 51 | if (!value) return; |
| 52 | |
| 53 | // Screenshot here rather than in the sidebar: this path's page access comes |
| 54 | // from the activeTab grant the keyboard command just minted, and that grant |
| 55 | // belongs to the worker. |
| 56 | const shot = await Shot.copyPickedShot(api, tabId, value); |
| 57 | if (shot !== true) console.warn("termbridge: no screenshot", shot); |
| 58 | |
| 59 | // The sidebar may not be open. Try to hand it over directly, and fall back to |
| 60 | // storage so the pick isn't lost — the sidebar drains it when it next opens. |
| 61 | try { |
| 62 | await api.runtime.sendMessage({ type: "picked", value, shot: shot === true }); |
| 63 | } catch { |
| 64 | await api.storage.local.set({ pendingPick: value }); |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // --- the toggle shortcut ----------------------------------------------------- |
| 69 | // |
| 70 | // Neither browser will tell us whether the panel is open, so the panel tells |
| 71 | // us: it holds a port open for as long as it is alive and reports its own |
| 72 | // focus over it. That state has to be here rather than fetched on demand, |
| 73 | // because opening a side panel is only allowed during a user gesture, and a |
| 74 | // round trip to the panel and back outlives the gesture the shortcut minted. |
| 75 | // |
| 76 | // The port carries three fixed commands and no data. It is deliberately not a |
| 77 | // second route into the terminal — see the note at the top of sidebar.js. |
| 78 | |
| 79 | /** @type {Map<number, { port: TbPort, focused: boolean }>} */ |
| 80 | const panels = new Map(); |
| 81 | |
| 82 | api.runtime.onConnect.addListener((port) => { |
| 83 | if (port.name !== "sidebar") return; |
| 84 | // Content scripts can connect too. Ours is an extension page: no sender.tab. |
| 85 | if (port.sender?.tab || port.sender?.id !== api.runtime.id) { |
| 86 | port.disconnect(); |
| 87 | return; |
| 88 | } |
| 89 | /** @type {number | null} */ |
| 90 | let windowId = null; |
| 91 | port.onMessage.addListener((msg) => { |
| 92 | if (msg?.type === "hello" && typeof msg.windowId === "number") { |
| 93 | windowId = /** @type {number} */ (msg.windowId); |
| 94 | 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 | } |
| 104 | } else if (msg?.type === "cancel-pick") { |
| 105 | // Escape in the panel, for a pick the shortcut started here. Harmless |
| 106 | // when nothing is picking: the list is empty and this does nothing. |
| 107 | const ids = pickTabs; |
| 108 | pickTabs = []; |
| 109 | if (ids.length) Split.cancelPicks(api, ids); |
| 110 | } else if (msg?.type === "focus" && windowId != null) { |
| 111 | const entry = panels.get(windowId); |
| 112 | if (entry?.port === port) entry.focused = !!msg.focused; |
| 113 | } |
| 114 | }); |
| 115 | port.onDisconnect.addListener(() => { |
| 116 | if (windowId != null && panels.get(windowId)?.port === port) panels.delete(windowId); |
| 117 | }); |
| 118 | }); |
| 119 | |
| 120 | // Chrome only allows opening the panel while the gesture that the command |
| 121 | // minted is still live, and a single `await` — even one that resolves |
| 122 | // immediately — spends it. So everything from the command listener down to the |
| 123 | // open call is synchronous, and the window id comes from the tab the listener |
| 124 | // hands us rather than from a windows.getLastFocused() round trip. |
| 125 | /** @param {number} windowId */ |
| 126 | function openPanel(windowId) { |
| 127 | const opened = api.sidePanel?.open |
| 128 | ? api.sidePanel.open({ windowId }) |
| 129 | : api.sidebarAction?.open(); |
| 130 | // Chrome still rejects if the gesture was somehow already spent; that is not |
| 131 | // worth an unhandled rejection in the worker's console. |
| 132 | Promise.resolve(opened).catch((e) => console.warn("termbridge: cannot open panel", e)); |
| 133 | } |
| 134 | |
| 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 | |
| 154 | /** @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); |
| 167 | } |
| 168 | |
| 169 | /** @param {number | undefined} windowId */ |
| 170 | function toggleSidebar(windowId) { |
| 171 | // Firefox only started passing the tab to command listeners in 106; without |
| 172 | // it the gesture is lost, but sidebarAction has no such restriction. |
| 173 | if (windowId == null) { |
| 174 | api.windows.getLastFocused().then((w) => toggleSidebar(w.id)); |
| 175 | return; |
| 176 | } |
| 177 | const entry = panels.get(windowId); |
| 178 | |
| 179 | if (!entry) { |
| 180 | openPanel(windowId); |
| 181 | return; |
| 182 | } |
| 183 | |
| 184 | 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" }); |
| 189 | return; |
| 190 | } |
| 191 | |
| 192 | // Open but focus is elsewhere — put the caret back in the terminal. The |
| 193 | // panel is already showing, so the open call is only there to hand it focus. |
| 194 | entry.port.postMessage({ type: "focus" }); |
| 195 | openPanel(windowId); |
| 196 | } |
| 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; |
| 206 | |
| 207 | // Put the caret in the panel's jump bar. Same port and the same reasoning as |
| 208 | // the toggle: whether the panel is open is state only the panel can report. |
| 209 | /** @param {number | undefined} windowId */ |
| 210 | function focusOmnibar(windowId) { |
| 211 | if (windowId == null) { |
| 212 | api.windows.getLastFocused().then((w) => focusOmnibar(w.id)); |
| 213 | return; |
| 214 | } |
| 215 | 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); |
| 240 | } |
| 241 | |
| 242 | // --- handing a tab to Claude in Chrome --------------------------------------- |
| 243 | // |
| 244 | // Not the Claude the rest of termbridge means: that one is Claude Code in a |
| 245 | // tmux pane, reached through the daemon. This is the browser extension, and it |
| 246 | // only drives tabs that sit inside its own tab group — a tab outside that group |
| 247 | // is invisible to it. Membership is the entire gate, so handing a page over is |
| 248 | // a tabs.group() call and nothing else. |
| 249 | // |
| 250 | // Which group is Claude's is a question only the user can answer. The group |
| 251 | // carries no title to match on — Claude's comes back untitled — so |
| 252 | // `set-claude-group` records the active tab's group id, and until that has |
| 253 | // happened a single group in the browser is taken to be the one. |
| 254 | // |
| 255 | // Chrome only: Firefox has no tab group API, so api.tabGroups is undefined |
| 256 | // there and both commands no-op. |
| 257 | |
| 258 | const CLAUDE_GROUP_KEY = "claudeGroupId"; |
| 259 | |
| 260 | /** @returns {Promise<TbTabGroup | null>} */ |
| 261 | async function resolveClaudeGroup() { |
| 262 | const tabGroups = api.tabGroups; |
| 263 | if (!tabGroups) return null; |
| 264 | |
| 265 | const stored = (await api.storage.local.get(CLAUDE_GROUP_KEY))[CLAUDE_GROUP_KEY]; |
| 266 | if (typeof stored === "number") { |
| 267 | try { |
| 268 | return await tabGroups.get(stored); |
| 269 | } catch { |
| 270 | // Recorded group has been closed since. Fall through and rediscover. |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | const groups = await tabGroups.query({}); |
| 275 | if (groups.length === 1) { |
| 276 | await api.storage.local.set({ [CLAUDE_GROUP_KEY]: groups[0].id }); |
| 277 | return groups[0]; |
| 278 | } |
| 279 | return null; |
| 280 | } |
| 281 | |
| 282 | async function handTabToClaude() { |
| 283 | const groupTabs = api.tabs.group; |
| 284 | if (!groupTabs) return; |
| 285 | |
| 286 | const [tab] = await api.tabs.query({ active: true, currentWindow: true }); |
| 287 | if (!tab || typeof tab.id !== "number") return; |
| 288 | // The same pages the picker cannot touch are pages Claude cannot drive. |
| 289 | if (SKIP.test(tab.url ?? "")) { |
| 290 | console.warn("termbridge: Claude cannot drive this page", tab.url); |
| 291 | return; |
| 292 | } |
| 293 | |
| 294 | const group = await resolveClaudeGroup(); |
| 295 | if (!group) { |
| 296 | console.warn( |
| 297 | "termbridge: no Claude group recorded — put a tab in Claude's group and run set-claude-group", |
| 298 | ); |
| 299 | return; |
| 300 | } |
| 301 | if (tab.groupId === group.id) return; |
| 302 | |
| 303 | try { |
| 304 | // group() will not reach across windows, and Claude's group usually sits in |
| 305 | // one of its own, so the tab has to travel first. |
| 306 | if (tab.windowId !== group.windowId) { |
| 307 | await api.tabs.move(tab.id, { windowId: group.windowId, index: -1 }); |
| 308 | } |
| 309 | await groupTabs({ groupId: group.id, tabIds: [tab.id] }); |
| 310 | await api.tabs.update(tab.id, { active: true }); |
| 311 | await api.windows.update(group.windowId, { focused: true }); |
| 312 | } catch (e) { |
| 313 | console.error("termbridge: could not hand the tab over", e); |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | async function setClaudeGroup() { |
| 318 | if (!api.tabGroups) return; |
| 319 | |
| 320 | const [tab] = await api.tabs.query({ active: true, currentWindow: true }); |
| 321 | // Chrome reports -1 for a tab that is in no group. |
| 322 | if (!tab || typeof tab.groupId !== "number" || tab.groupId < 0) { |
| 323 | console.warn("termbridge: this tab is in no group — drag it into Claude's group first"); |
| 324 | return; |
| 325 | } |
| 326 | await api.storage.local.set({ [CLAUDE_GROUP_KEY]: tab.groupId }); |
| 327 | console.info("termbridge: Claude group set to", tab.groupId); |
| 328 | } |
| 329 | |
| 330 | api.commands?.onCommand.addListener((command, tab) => { |
| 331 | if (command === "pick-element") runPicker(); |
| 332 | if (command === "toggle-terminal") toggleSidebar(tab?.windowId); |
| 333 | if (command === "focus-omnibar") focusOmnibar(tab?.windowId); |
| 334 | if (command === "hand-tab-to-claude") handTabToClaude(); |
| 335 | if (command === "set-claude-group") setClaudeGroup(); |
| 336 | }); |