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
29// The tabs a shortcut-started pick is running in. Empty means none is.
30let pickTabs = /** @type {number[]} */ ([]);
31
32async 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 }>} */
80const panels = new Map();
81
82api.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 */
126function 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.
152const REOPEN_TO_FOCUS = true;
153
154/** @param {number} windowId */
155function 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 */
170function 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>} */
204const pendingOmnibar = new Map();
205const 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 */
210function 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
258const CLAUDE_GROUP_KEY = "claudeGroupId";
259
260/** @returns {Promise<TbTabGroup | null>} */
261async 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
282async 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
317async 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
330api.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});