| 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 | // 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 | |
| 87 | api.runtime.onConnect.addListener((port) => { |
| 88 | if (port.name !== "sidebar") return; |
| 89 | // Content scripts can connect too. Ours is an extension page: no sender.tab. |
| 90 | if (port.sender?.tab || port.sender?.id !== api.runtime.id) { |
| 91 | port.disconnect(); |
| 92 | return; |
| 93 | } |
| 94 | /** @type {number | null} */ |
| 95 | let windowId = null; |
| 96 | port.onMessage.addListener((msg) => { |
| 97 | if (msg?.type === "hello" && typeof msg.windowId === "number") { |
| 98 | windowId = /** @type {number} */ (msg.windowId); |
| 99 | panels.set(windowId, { port, focused: !!msg.focused }); |
| 100 | } else if (msg?.type === "cancel-pick") { |
| 101 | // Escape in the panel, for a pick the shortcut started here. Harmless |
| 102 | // when nothing is picking: the list is empty and this does nothing. |
| 103 | const ids = pickTabs; |
| 104 | pickTabs = []; |
| 105 | if (ids.length) Split.cancelPicks(api, ids); |
| 106 | } else if (msg?.type === "focus" && windowId != null) { |
| 107 | const entry = panels.get(windowId); |
| 108 | if (entry?.port === port) entry.focused = !!msg.focused; |
| 109 | } |
| 110 | }); |
| 111 | port.onDisconnect.addListener(() => { |
| 112 | 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); |
| 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 | /** @param {number} 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(() => {}); |
| 145 | } |
| 146 | |
| 147 | /** @param {number | undefined} windowId */ |
| 148 | function toggleSidebar(windowId) { |
| 149 | // Firefox only started passing the tab to command listeners in 106; without |
| 150 | // it the gesture is lost, but sidebarAction has no such restriction. |
| 151 | if (windowId == null) { |
| 152 | api.windows.getLastFocused().then((w) => toggleSidebar(w.id)); |
| 153 | return; |
| 154 | } |
| 155 | const entry = panels.get(windowId); |
| 156 | |
| 157 | if (!entry) { |
| 158 | openPanel(windowId); |
| 159 | return; |
| 160 | } |
| 161 | |
| 162 | if (entry.focused) { |
| 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 | } |
| 179 | return; |
| 180 | } |
| 181 | |
| 182 | // Open but focus is elsewhere — put the caret back in the terminal. The |
| 183 | // panel is already showing, so the open call is only there to hand it focus. |
| 184 | entry.port.postMessage({ type: "focus" }); |
| 185 | openPanel(windowId); |
| 186 | } |
| 187 | |
| 188 | // Put the caret in the panel's jump bar. Same port and the same reasoning as |
| 189 | // 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. |
| 194 | /** @param {number | undefined} windowId */ |
| 195 | function focusOmnibar(windowId) { |
| 196 | if (windowId == null) { |
| 197 | api.windows.getLastFocused().then((w) => focusOmnibar(w.id)); |
| 198 | return; |
| 199 | } |
| 200 | const entry = panels.get(windowId); |
| 201 | if (!entry?.focused) return; |
| 202 | entry.port.postMessage({ type: "omnibar" }); |
| 203 | } |
| 204 | |
| 205 | // --- handing a tab to Claude in Chrome --------------------------------------- |
| 206 | // |
| 207 | // Not the Claude the rest of termbridge means: that one is Claude Code in a |
| 208 | // tmux pane, reached through the daemon. This is the browser extension, and it |
| 209 | // only drives tabs that sit inside its own tab group — a tab outside that group |
| 210 | // is invisible to it. Membership is the entire gate, so handing a page over is |
| 211 | // a tabs.group() call and nothing else. |
| 212 | // |
| 213 | // Which group is Claude's is a question only the user can answer. The group |
| 214 | // carries no title to match on — Claude's comes back untitled — so |
| 215 | // `set-claude-group` records the active tab's group id, and until that has |
| 216 | // happened a single group in the browser is taken to be the one. |
| 217 | // |
| 218 | // Chrome only: Firefox has no tab group API, so api.tabGroups is undefined |
| 219 | // there and both commands no-op. |
| 220 | |
| 221 | const CLAUDE_GROUP_KEY = "claudeGroupId"; |
| 222 | |
| 223 | /** @returns {Promise<TbTabGroup | null>} */ |
| 224 | async function resolveClaudeGroup() { |
| 225 | const tabGroups = api.tabGroups; |
| 226 | if (!tabGroups) return null; |
| 227 | |
| 228 | const stored = (await api.storage.local.get(CLAUDE_GROUP_KEY))[CLAUDE_GROUP_KEY]; |
| 229 | if (typeof stored === "number") { |
| 230 | try { |
| 231 | return await tabGroups.get(stored); |
| 232 | } catch { |
| 233 | // Recorded group has been closed since. Fall through and rediscover. |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | const groups = await tabGroups.query({}); |
| 238 | if (groups.length === 1) { |
| 239 | await api.storage.local.set({ [CLAUDE_GROUP_KEY]: groups[0].id }); |
| 240 | return groups[0]; |
| 241 | } |
| 242 | return null; |
| 243 | } |
| 244 | |
| 245 | async function handTabToClaude() { |
| 246 | const groupTabs = api.tabs.group; |
| 247 | if (!groupTabs) return; |
| 248 | |
| 249 | const [tab] = await api.tabs.query({ active: true, currentWindow: true }); |
| 250 | if (!tab || typeof tab.id !== "number") return; |
| 251 | // The same pages the picker cannot touch are pages Claude cannot drive. |
| 252 | if (SKIP.test(tab.url ?? "")) { |
| 253 | console.warn("termbridge: Claude cannot drive this page", tab.url); |
| 254 | return; |
| 255 | } |
| 256 | |
| 257 | const group = await resolveClaudeGroup(); |
| 258 | if (!group) { |
| 259 | console.warn( |
| 260 | "termbridge: no Claude group recorded — put a tab in Claude's group and run set-claude-group", |
| 261 | ); |
| 262 | return; |
| 263 | } |
| 264 | if (tab.groupId === group.id) return; |
| 265 | |
| 266 | try { |
| 267 | // group() will not reach across windows, and Claude's group usually sits in |
| 268 | // one of its own, so the tab has to travel first. |
| 269 | if (tab.windowId !== group.windowId) { |
| 270 | await api.tabs.move(tab.id, { windowId: group.windowId, index: -1 }); |
| 271 | } |
| 272 | await groupTabs({ groupId: group.id, tabIds: [tab.id] }); |
| 273 | await api.tabs.update(tab.id, { active: true }); |
| 274 | await api.windows.update(group.windowId, { focused: true }); |
| 275 | } catch (e) { |
| 276 | console.error("termbridge: could not hand the tab over", e); |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | async function setClaudeGroup() { |
| 281 | if (!api.tabGroups) return; |
| 282 | |
| 283 | const [tab] = await api.tabs.query({ active: true, currentWindow: true }); |
| 284 | // Chrome reports -1 for a tab that is in no group. |
| 285 | if (!tab || typeof tab.groupId !== "number" || tab.groupId < 0) { |
| 286 | console.warn("termbridge: this tab is in no group — drag it into Claude's group first"); |
| 287 | return; |
| 288 | } |
| 289 | await api.storage.local.set({ [CLAUDE_GROUP_KEY]: tab.groupId }); |
| 290 | console.info("termbridge: Claude group set to", tab.groupId); |
| 291 | } |
| 292 | |
| 293 | api.commands?.onCommand.addListener((command, tab) => { |
| 294 | if (command === "pick-element") runPicker(); |
| 295 | if (command === "toggle-terminal") toggleSidebar(tab?.windowId); |
| 296 | if (command === "focus-omnibar") focusOmnibar(tab?.windowId); |
| 297 | if (command === "hand-tab-to-claude") handTabToClaude(); |
| 298 | if (command === "set-claude-group") setClaudeGroup(); |
| 299 | }); |