anvilsign in

collin/browser-terminal-extension

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.
12const 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.
16if (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.
21if (api.sidePanel?.setPanelBehavior) {
22 api.sidePanel
23 .setPanelBehavior({ openPanelOnActionClick: true })
24 .catch((e) => console.error(e));
25}
26
27const SKIP = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/;
28
29async function runPicker() {
30 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
31 if (!tab || SKIP.test(tab.url ?? "")) {
32 console.warn("termbridge: cannot pick on this page", tab?.url);
33 return;
34 }
35
36 // Both halves of a split view, when this is one. The activeTab grant the
37 // command just minted covers only the active half, so the partner half is
38 // reachable only where its origin was granted — Split.racePick treats that
39 // refusal as one runner dropping out, not as the pick failing.
40 const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP.test(t.url ?? ""));
41 const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement);
42 if (error) console.error("termbridge: pick failed", error);
43 if (!value) return;
44
45 // Screenshot here rather than in the sidebar: this path's page access comes
46 // from the activeTab grant the keyboard command just minted, and that grant
47 // belongs to the worker.
48 const shot = await Shot.copyPickedShot(api, tabId, value);
49 if (shot !== true) console.warn("termbridge: no screenshot", shot);
50
51 // The sidebar may not be open. Try to hand it over directly, and fall back to
52 // storage so the pick isn't lost — the sidebar drains it when it next opens.
53 try {
54 await api.runtime.sendMessage({ type: "picked", value, shot: shot === true });
55 } catch {
56 await api.storage.local.set({ pendingPick: value });
57 }
58}
59
60// --- the toggle shortcut -----------------------------------------------------
61//
62// Neither browser will tell us whether the panel is open, so the panel tells
63// us: it holds a port open for as long as it is alive and reports its own
64// focus over it. That state has to be here rather than fetched on demand,
65// because opening a side panel is only allowed during a user gesture, and a
66// round trip to the panel and back outlives the gesture the shortcut minted.
67//
68// The port carries three fixed commands and no data. It is deliberately not a
69// second route into the terminal — see the note at the top of sidebar.js.
70
71/** @type {Map<number, { port: TbPort, focused: boolean }>} */
72const panels = new Map();
73
74api.runtime.onConnect.addListener((port) => {
75 if (port.name !== "sidebar") return;
76 // Content scripts can connect too. Ours is an extension page: no sender.tab.
77 if (port.sender?.tab || port.sender?.id !== api.runtime.id) {
78 port.disconnect();
79 return;
80 }
81 /** @type {number | null} */
82 let windowId = null;
83 port.onMessage.addListener((msg) => {
84 if (msg?.type === "hello" && typeof msg.windowId === "number") {
85 windowId = /** @type {number} */ (msg.windowId);
86 panels.set(windowId, { port, focused: !!msg.focused });
87 // The shortcut that opened this panel asked for the jump box, and there
88 // was nothing here to ask at the time. This connection is the panel
89 // arriving; the request is only good for the panel that open call was
90 // for, so it is spent whether or not it was this one.
91 const asked = pendingOmnibar.get(windowId);
92 pendingOmnibar.delete(windowId);
93 if (asked != null && Date.now() - asked < PENDING_OMNIBAR_MS) {
94 port.postMessage({ type: "omnibar" });
95 }
96 } else if (msg?.type === "focus" && windowId != null) {
97 const entry = panels.get(windowId);
98 if (entry?.port === port) entry.focused = !!msg.focused;
99 }
100 });
101 port.onDisconnect.addListener(() => {
102 if (windowId != null && panels.get(windowId)?.port === port) panels.delete(windowId);
103 });
104});
105
106// Chrome only allows opening the panel while the gesture that the command
107// minted is still live, and a single `await` — even one that resolves
108// immediately — spends it. So everything from the command listener down to the
109// open call is synchronous, and the window id comes from the tab the listener
110// hands us rather than from a windows.getLastFocused() round trip.
111/** @param {number} windowId */
112function openPanel(windowId) {
113 const opened = api.sidePanel?.open
114 ? api.sidePanel.open({ windowId })
115 : api.sidebarAction?.open();
116 // Chrome still rejects if the gesture was somehow already spent; that is not
117 // worth an unhandled rejection in the worker's console.
118 Promise.resolve(opened).catch((e) => console.warn("termbridge: cannot open panel", e));
119}
120
121// Chrome hands the panel the keyboard when it opens it and at no other time:
122// there is no API to focus a panel that is already up, and `autofocus` does
123// nothing in one. So the only way to point the keyboard at an open panel is to
124// make it a panel Chrome is opening — take it away and put it back.
125//
126// The catch is the same gesture rule as above, from the other side: there is no
127// sidePanel.close() to call, and closing it the only way there is — asking the
128// panel to close itself — is a round trip that spends the gesture the reopen
129// needs. Disabling the panel closes it without asking anyone, so all three
130// calls are issued here, unawaited and in order, while the gesture is live.
131//
132// The panel document does not survive this, and that is the whole cost: the
133// terminal is a tmux client, so a new one reattaches to the same session with
134// its scrollback intact on the server. What the user sees is a flicker.
135//
136// Experimental — this leans on Chrome running the three in the order they were
137// issued. Set false to go back to leaving an open panel where it is.
138const REOPEN_TO_FOCUS = true;
139
140/** @param {number} windowId */
141function reopenPanelForFocus(windowId) {
142 const sidePanel = api.sidePanel;
143 if (!REOPEN_TO_FOCUS || !sidePanel?.setOptions) {
144 // Firefox reaches the same end by a shorter road: sidebarAction.open()
145 // focuses the sidebar whether or not it was already showing.
146 openPanel(windowId);
147 return;
148 }
149 const quiet = (/** @type {unknown} */ e) => console.warn("termbridge: panel reopen", e);
150 Promise.resolve(sidePanel.setOptions({ enabled: false })).catch(quiet);
151 Promise.resolve(sidePanel.setOptions({ enabled: true, path: "sidebar.html" })).catch(quiet);
152 openPanel(windowId);
153}
154
155/** @param {number | undefined} windowId */
156function toggleSidebar(windowId) {
157 // Firefox only started passing the tab to command listeners in 106; without
158 // it the gesture is lost, but sidebarAction has no such restriction.
159 if (windowId == null) {
160 api.windows.getLastFocused().then((w) => toggleSidebar(w.id));
161 return;
162 }
163 const entry = panels.get(windowId);
164
165 if (!entry) {
166 openPanel(windowId);
167 return;
168 }
169
170 if (entry.focused) {
171 // Firefox can close its own sidebar; Chrome has no close API, so the panel
172 // closes itself with window.close().
173 if (api.sidebarAction?.close) api.sidebarAction.close();
174 else entry.port.postMessage({ type: "close" });
175 return;
176 }
177
178 // Open but focus is elsewhere — put the caret back in the terminal. The
179 // panel is already showing, so the open call is only there to hand it focus.
180 entry.port.postMessage({ type: "focus" });
181 openPanel(windowId);
182}
183
184// A jump box asked for while the panel was closed, by window and when. The
185// panel takes a moment to come up and connect, so the request has to outlive
186// the shortcut that made it — but only just: a panel arriving much later is
187// one the user opened themselves, and it should come up on the terminal like
188// any other.
189/** @type {Map<number, number>} */
190const pendingOmnibar = new Map();
191const PENDING_OMNIBAR_MS = 10_000;
192
193// Put the caret in the panel's jump bar. Same port and the same reasoning as
194// the toggle: whether the panel is open is state only the panel can report.
195/** @param {number | undefined} windowId */
196function focusOmnibar(windowId) {
197 if (windowId == null) {
198 api.windows.getLastFocused().then((w) => focusOmnibar(w.id));
199 return;
200 }
201 const entry = panels.get(windowId);
202
203 // Not open. The panel is the only thing that can put the caret anywhere, so
204 // the request is left here for it to pick up when it connects — and this is
205 // the one path where the caret reliably lands, because a panel Chrome is
206 // opening for the first time is a panel Chrome hands the keyboard to.
207 if (!entry) {
208 pendingOmnibar.set(windowId, Date.now());
209 openPanel(windowId);
210 return;
211 }
212
213 // Open and already holding the keyboard: the panel it has is the one that
214 // takes the caret, and nothing has to move for it to be typed into.
215 if (entry.focused) {
216 entry.port.postMessage({ type: "omnibar" });
217 return;
218 }
219
220 // Open, but the keyboard is on the page. Telling this panel to focus its box
221 // would put a caret in a window nothing is typing into, so the request is
222 // left for the panel that comes back and the panel is taken away and
223 // reopened — the one move that makes Chrome hand the keyboard over.
224 pendingOmnibar.set(windowId, Date.now());
225 reopenPanelForFocus(windowId);
226}
227
228// --- handing a tab to Claude in Chrome ---------------------------------------
229//
230// Not the Claude the rest of termbridge means: that one is Claude Code in a
231// tmux pane, reached through the daemon. This is the browser extension, and it
232// only drives tabs that sit inside its own tab group — a tab outside that group
233// is invisible to it. Membership is the entire gate, so handing a page over is
234// a tabs.group() call and nothing else.
235//
236// Which group is Claude's is a question only the user can answer. The group
237// carries no title to match on — Claude's comes back untitled — so
238// `set-claude-group` records the active tab's group id, and until that has
239// happened a single group in the browser is taken to be the one.
240//
241// Chrome only: Firefox has no tab group API, so api.tabGroups is undefined
242// there and both commands no-op.
243
244const CLAUDE_GROUP_KEY = "claudeGroupId";
245
246/** @returns {Promise<TbTabGroup | null>} */
247async function resolveClaudeGroup() {
248 const tabGroups = api.tabGroups;
249 if (!tabGroups) return null;
250
251 const stored = (await api.storage.local.get(CLAUDE_GROUP_KEY))[CLAUDE_GROUP_KEY];
252 if (typeof stored === "number") {
253 try {
254 return await tabGroups.get(stored);
255 } catch {
256 // Recorded group has been closed since. Fall through and rediscover.
257 }
258 }
259
260 const groups = await tabGroups.query({});
261 if (groups.length === 1) {
262 await api.storage.local.set({ [CLAUDE_GROUP_KEY]: groups[0].id });
263 return groups[0];
264 }
265 return null;
266}
267
268async function handTabToClaude() {
269 const groupTabs = api.tabs.group;
270 if (!groupTabs) return;
271
272 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
273 if (!tab || typeof tab.id !== "number") return;
274 // The same pages the picker cannot touch are pages Claude cannot drive.
275 if (SKIP.test(tab.url ?? "")) {
276 console.warn("termbridge: Claude cannot drive this page", tab.url);
277 return;
278 }
279
280 const group = await resolveClaudeGroup();
281 if (!group) {
282 console.warn(
283 "termbridge: no Claude group recorded — put a tab in Claude's group and run set-claude-group",
284 );
285 return;
286 }
287 if (tab.groupId === group.id) return;
288
289 try {
290 // group() will not reach across windows, and Claude's group usually sits in
291 // one of its own, so the tab has to travel first.
292 if (tab.windowId !== group.windowId) {
293 await api.tabs.move(tab.id, { windowId: group.windowId, index: -1 });
294 }
295 await groupTabs({ groupId: group.id, tabIds: [tab.id] });
296 await api.tabs.update(tab.id, { active: true });
297 await api.windows.update(group.windowId, { focused: true });
298 } catch (e) {
299 console.error("termbridge: could not hand the tab over", e);
300 }
301}
302
303async function setClaudeGroup() {
304 if (!api.tabGroups) return;
305
306 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
307 // Chrome reports -1 for a tab that is in no group.
308 if (!tab || typeof tab.groupId !== "number" || tab.groupId < 0) {
309 console.warn("termbridge: this tab is in no group — drag it into Claude's group first");
310 return;
311 }
312 await api.storage.local.set({ [CLAUDE_GROUP_KEY]: tab.groupId });
313 console.info("termbridge: Claude group set to", tab.groupId);
314}
315
316api.commands?.onCommand.addListener((command, tab) => {
317 if (command === "pick-element") runPicker();
318 if (command === "toggle-terminal") toggleSidebar(tab?.windowId);
319 if (command === "focus-omnibar") focusOmnibar(tab?.windowId);
320 if (command === "hand-tab-to-claude") handTabToClaude();
321 if (command === "set-claude-group") setClaudeGroup();
322});