anvilsign in

collin/browser-terminal-extension

1// Ambient declarations for the globals these scripts run against.
2//
3// Hand-written rather than pulled from @types/chrome on purpose: this file is
4// the *inventory* of extension API surface termbridge touches. Adding a call
5// means adding it here first, which is a small, useful speed bump — the
6// manifest's permission list has to grow with it. It is also why `npm run
7// check` needs exactly one dependency.
8//
9// Chrome and Firefox differ: `browser.*` is promise-based everywhere, and
10// Chrome's `chrome.*` has been promise-based for MV3. Everything below is typed
11// as promises because that is the only way it is called here.
12
13interface TbTab {
14 id?: number;
15 windowId?: number;
16 url?: string;
17 title?: string;
18 /** Whether this is the showing tab of its window. Only the reverse pin reads
19 it, to notice that the browser is already where it would have moved it. */
20 active?: boolean;
21 /** When the tab was last showing, as an epoch millisecond count. Chrome 121+
22 and Firefox both set it; absent elsewhere, which only costs the reverse
23 pin its tie-break between two tabs that point at the same session. */
24 lastAccessed?: number;
25 /** The split view this tab is half of, if any — Chrome 140+, and read-only:
26 the API detects splits, it cannot create or dissolve them. Absent in
27 Firefox, which has no split view at all. */
28 splitViewId?: number;
29 /** The tab group this tab belongs to, or -1 for none. Chrome only. This is
30 the whole gate on whether Claude in Chrome can see the tab. */
31 groupId?: number;
32}
33
34/** A Chrome tab group. Carries no stable identity beyond its id — the title is
35 whatever the user (or another extension) last set, and is often empty, so it
36 is not something to match on. */
37interface TbTabGroup {
38 id: number;
39 windowId: number;
40 title?: string;
41}
42
43interface TbInjectionResult<T = unknown> {
44 result?: T;
45 frameId?: number;
46}
47
48interface TbMessageSender {
49 id?: string;
50 /** Set for content scripts and only for content scripts — the check that
51 keeps a hostile page from reaching the sidebar's message handler. */
52 tab?: TbTab;
53}
54
55/** A long-lived connection. Only the toggle shortcut uses one — sidebar.js and
56 sw.js are the two ends, and it carries three fixed command shapes. */
57interface TbPort {
58 name: string;
59 /** Set on the receiving end only, and set for content scripts exactly as
60 TbMessageSender.tab is. */
61 sender?: TbMessageSender;
62 postMessage(message: any): void;
63 disconnect(): void;
64 onMessage: { addListener(cb: (message: any) => void): void };
65 onDisconnect: { addListener(cb: () => void): void };
66}
67
68interface TbWindow {
69 id: number;
70 /** Screen position of the window's left edge. Only the split-view crop reads
71 it, to work out which half of the window a pick came from. */
72 left?: number;
73}
74
75interface TbStorageArea {
76 get(keys?: string | string[] | null): Promise<Record<string, any>>;
77 set(items: Record<string, any>): Promise<void>;
78 remove(keys: string | string[]): Promise<void>;
79}
80
81interface TbExtensionApi {
82 runtime: {
83 id: string;
84 sendMessage(message: any): Promise<any>;
85 onMessage: {
86 addListener(
87 cb: (message: any, sender: TbMessageSender) => void | boolean | Promise<any>,
88 ): void;
89 };
90 connect(info: { name: string }): TbPort;
91 onConnect: { addListener(cb: (port: TbPort) => void): void };
92 };
93 storage: { local: TbStorageArea };
94 windows: {
95 /** The window the calling document lives in — the sidebar's own. */
96 getCurrent(): Promise<TbWindow>;
97 /** The worker has no window of its own, so the shortcut acts on this one. */
98 getLastFocused(): Promise<TbWindow>;
99 /** Only used to raise the window a handed-over tab just landed in. */
100 update(windowId: number, props: { focused: boolean }): Promise<TbWindow>;
101 };
102 tabs: {
103 query(info: {
104 active?: boolean;
105 currentWindow?: boolean;
106 /** Both set by the tab-pin code, which cares about one browser window —
107 its own — and about which tab in it is showing. */
108 windowId?: number;
109 /** Chrome throws on this key rather than ignoring it where it isn't
110 supported, so every call that passes it is wrapped. */
111 splitViewId?: number;
112 }): Promise<TbTab[]>;
113 /** One tab by id, for the pin code: a tab activation carries an id and
114 nothing else, and the URL is what a pin is keyed on. */
115 get(tabId: number): Promise<TbTab>;
116 create(props: { url: string }): Promise<TbTab>;
117 /** A tab became the showing one in its window. Fires for every window, so
118 the sidebar filters on its own. */
119 onActivated: {
120 addListener(cb: (info: { tabId: number; windowId: number }) => void): void;
121 };
122 /** Navigation inside a tab that is already open — the same page becoming a
123 different origin, which is a different pin. `url` is only present when
124 it changed, and only with the "tabs" permission. */
125 onUpdated: {
126 addListener(
127 cb: (tabId: number, change: { url?: string; status?: string }, tab: TbTab) => void,
128 ): void;
129 };
130 /** Tab ids are per-run and never reissued, so a tab pin outlives its tab
131 unless something drops it. This is that something. */
132 onRemoved: {
133 addListener(cb: (tabId: number) => void): void;
134 };
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 };
141 /** Only used to make a tab active, which needs no permission. captureVisibleTab
142 has no tabId of its own, so a split-view pick has to be activated first. */
143 update(tabId: number, props: { active: boolean }): Promise<TbTab>;
144 /** The visible area of the active tab, as a PNG data URL. Needs a host
145 permission for the page, exactly as the picker's injection does. */
146 captureVisibleTab(options: { format: "png" }): Promise<string>;
147 /** Chrome only, and needs "tabGroups". Adds tabs to an existing group; the
148 tabs must already be in that group's window. */
149 group?(options: { groupId: number; tabIds: number[] }): Promise<number>;
150 /** Only used to carry a tab into the window Claude's group lives in, since
151 group() will not reach across windows. */
152 move(tabId: number, props: { windowId: number; index: number }): Promise<TbTab | TbTab[]>;
153 };
154 /** Chrome only — Firefox has no tab group API at all, which is what makes the
155 handoff a Chrome-only feature. */
156 tabGroups?: {
157 get(groupId: number): Promise<TbTabGroup>;
158 query(info: { windowId?: number }): Promise<TbTabGroup[]>;
159 };
160 permissions: {
161 request(perms: { origins?: string[]; permissions?: string[] }): Promise<boolean>;
162 };
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 };
175 scripting: {
176 /**
177 * `Awaited<T>`: a func that returns a promise is awaited by the browser,
178 * and `result` is what it resolved to — which is the whole reason the
179 * picker can hand its value back as a return value.
180 */
181 executeScript<T, A extends any[] = []>(injection: {
182 target: { tabId: number | undefined };
183 func: (...args: A) => T;
184 /** Structured-cloned into the page. Keep it small — a screenshot goes
185 through here as base64. */
186 args?: A;
187 }): Promise<TbInjectionResult<Awaited<T>>[]>;
188 };
189 /** Firefox only: writes an image to the clipboard without a focused
190 document, which the async clipboard API refuses to do. */
191 clipboard?: {
192 setImageData(image: ArrayBuffer, type: "png" | "jpeg"): Promise<void>;
193 };
194 /** Absent in Firefox, and absent in Chrome until the browser is new enough. */
195 sidePanel?: {
196 setPanelBehavior?(behavior: {
197 openPanelOnActionClick: boolean;
198 }): Promise<void>;
199 /** Chrome only, and only during a user gesture. There is no close(): the
200 panel closes itself with window.close(). */
201 open?(options: { windowId: number }): Promise<void>;
202 /** Disabling is the only way to close a panel from here — the panel it
203 applies to goes away without being asked. Without a tabId it is the
204 default for every tab, which is how this extension declares it. */
205 setOptions?(options: { tabId?: number; path?: string; enabled?: boolean }): Promise<void>;
206 };
207 /** Firefox only — the counterpart to Chrome's sidePanel. Unlike Chrome's, it
208 can close the sidebar as well as open it. */
209 sidebarAction?: {
210 open(): Promise<void>;
211 close(): Promise<void>;
212 };
213 /** Absent when no keyboard shortcuts are declared in the manifest. */
214 commands?: {
215 /** The tab is the active one at the time of the shortcut. Chrome always
216 passes it; Firefox only since 106, so treat it as optional. It is the
217 only way to learn the window id without an `await`, which on Chrome
218 would spend the gesture sidePanel.open() needs. */
219 onCommand: {
220 addListener(cb: (command: string, tab?: TbTab) => void): void;
221 };
222 };
223}
224
225declare var browser: TbExtensionApi | undefined;
226declare var chrome: TbExtensionApi | undefined;
227
228// --- the daemon's wire protocol ---------------------------------------------
229//
230// The other half of these shapes is Rust: `Snapshot`, `SessionInfo` and `Agent`
231// in daemon/src/status.rs, and the `ok` frame built in daemon/src/server.rs.
232// Change one side and this side has to move with it — which is most of why
233// they are written down at all.
234//
235// Everything here arrives over a socket. It is *claimed* structure, not
236// guaranteed structure, and it is only ever assigned through textContent.
237
238/** One tmux session — one tab in the top row. */
239interface TbSessionInfo {
240 /** tmux session id (`$1`). Empty on the `ok` frame, which has names only. */
241 id: string;
242 name: string;
243 /** Some tmux client, anywhere, is on this session. */
244 attached: boolean;
245 /** The group colour, from the `@termbridge_color` tmux user option: a hue in
246 degrees as a string, or "-1" for grey. Absent when nobody has chosen one,
247 which is what tells the panel to pick. */
248 color?: string | null;
249 /** Working directory of the session's current pane. Absent on the `ok` frame
250 and whenever tmux has no pane to answer for. */
251 path?: string | null;
252 /** When tmux made the session, in Unix seconds. */
253 created?: number | null;
254 /** How many tmux clients are attached to it. */
255 clients?: number;
256 /** Its windows, in index order. Empty until the first status frame. */
257 windows: TbWindowInfo[];
258}
259
260/** One window of a session — one tab in the second row. */
261interface TbWindowInfo {
262 /** tmux window id (`@3`). Stable; the index is not. */
263 id: string;
264 index: number;
265 name: string;
266 active: boolean;
267 panes: number;
268 activity: boolean;
269}
270
271// --- pinning a session to a browser tab -------------------------------------
272//
273// Panel state, not server state: none of this reaches the socket, and the
274// daemon has never heard of a browser tab. See lib/tabpin.js.
275
276/** Where a pinned browser tab sends the terminal. A null window means "the
277 session, wherever it is currently pointed". */
278interface TbPinTarget {
279 session: string;
280 window?: string | null;
281}
282
283/** Which rule answered — shown in the tooltip, because a switch nobody
284 remembers asking for should say where it came from. */
285type TbPinSource = "tab" | "origin" | "detect";
286
287/** A place in tmux. A null window means the session's own current window,
288 which is what a session-level pin points at. */
289interface TbSpot {
290 session: string;
291 window?: string | null;
292}
293
294/** An excursion in progress: a pin moved the terminal `to` somewhere, and owes
295 it back to `from` when the browser leaves for a tab with no pin. */
296interface TbPinReturn {
297 from: TbSpot;
298 to: TbSpot;
299}
300
301interface TbTabPinStore {
302 /** The whole feature, off. Pins are kept while it is off. */
303 enabled: boolean;
304 /** The portless guess for `.localhost` names. Explicit pins work regardless. */
305 detect: boolean;
306 /** The other direction: moving the terminal activates the pinned tab. Reads
307 the same pins as the forward direction, and is separately switchable
308 because it is the half that touches the browser. */
309 reverse: boolean;
310 /** Keyed by browser tab id, as a string — storage round-trips JSON, and JSON
311 object keys are strings. A null value is a veto. */
312 byTab: Record<string, TbPinTarget | null>;
313 /** Keyed by origin, e.g. `https://app.localhost`. */
314 byOrigin: Record<string, TbPinTarget | null>;
315}
316
317/**
318 * What an agent is doing. The header's glyph slot takes this plus `"none"` — its
319 * own name for "no agent here", which it needs because every row has the slot
320 * whether or not anything is in it.
321 */
322type TbAgentState = "working" | "waiting" | "ready" | "idle" | "unknown";
323
324interface TbAgent {
325 /** tmux pane id (`%12`), stable for the pane's lifetime. */
326 pane: string;
327 /** Session name — the join to TbSessionInfo, since agents are server-wide. */
328 session: string;
329 /** tmux window id (`@3`) — the join to TbWindowInfo. */
330 window_id: string;
331 window: string;
332 name: string;
333 /**
334 * `ready` is the one no hook reports: an idle agent whose pane has not been
335 * on screen since it went idle. The daemon derives it — see `SEEN` in
336 * daemon/src/status.rs.
337 */
338 state: TbAgentState;
339 /**
340 * This pane is the one on screen under the sidebar — our client's session,
341 * active window, active pane. The panel is told about panes and never about
342 * its own, so this is the only "you are here" it gets.
343 */
344 here?: boolean;
345 mode?: string | null;
346 tool?: string | null;
347 message?: string | null;
348 title?: string | null;
349 /**
350 * The model id, e.g. `claude-opus-4-1-20250805`. Only ever reported at
351 * `SessionStart`, so it can be stale after a mid-session model switch, and
352 * absent entirely for a session that started before this field existed.
353 */
354 model?: string | null;
355}
356
357/**
358 * First frame after a successful auth. It carries session *names* only — the
359 * window list needs the control channel, which is up a moment later.
360 */
361interface TbOkFrame {
362 type: "ok";
363 profile: string;
364 tmux: boolean;
365 defaultSession?: string;
366 /** Names only — window counts arrive with the first status frame. */
367 sessions?: string[];
368 /**
369 * Machines to offer in the omnibar, from the daemon's `~/.ssh/config` and
370 * `~/.ssh/known_hosts`. Here rather than on the status frames because it
371 * comes from files that do not change while a panel is open.
372 */
373 hosts?: string[];
374}
375
376/**
377 * Pushed whenever tmux says something changed. It describes the whole server:
378 * every session with its own windows, and every agent in any of them.
379 */
380interface TbStatusFrame {
381 type: "status";
382 /** The session this panel's client is on right now. */
383 session?: string;
384 sessions?: TbSessionInfo[];
385 agents?: TbAgent[];
386}
387
388/**
389 * One directory, in answer to `{type:"path", q}` — the only frame the panel
390 * asks for rather than being told. Built in daemon/src/server.rs from
391 * `project::Listing`.
392 */
393interface TbPathFrame {
394 type: "path";
395 /** The query, echoed back: replies can land after the box has moved on. */
396 q: string;
397 /** The expanded, absolute path. Empty when the query was not one. */
398 path: string;
399 /** "invalid" is a query the daemon would not expand — relative, `..`,
400 someone else's `~`. */
401 kind: "dir" | "file" | "missing" | "denied" | "invalid";
402 /** Directories `mkdir -p` would have to create to make this path. */
403 creates: number;
404 /** Directory names inside it, sorted, visible ones before hidden. */
405 dirs: string[];
406 /** The other names. Only used to tell a file apart from a path to create. */
407 files: string[];
408}
409
410type TbFrame =
411 | TbOkFrame
412 | TbStatusFrame
413 | TbPathFrame
414 | { type: "exit"; code: number }
415 | { type: "error"; reason: string }
416 | { type: "tmux-error"; reason: string };
417
418/** The element's box in CSS pixels, relative to the viewport. */
419/** Where a pick's viewport sits in its window, which is how the crop tells the
420 two halves of a split view apart. Both in screen coordinates. */
421interface TbPane {
422 screenX: number;
423 winLeft: number;
424}
425
426interface TbPickedRect {
427 x: number;
428 y: number;
429 width: number;
430 height: number;
431}
432
433/** The identifier flavours the panel can show — the string-valued keys of
434 TbPicked, and the only ones that may reach the terminal. */
435type TbPickedFormat = "css" | "xpath" | "id" | "testid" | "text" | "href";
436
437/** What picker.js's describe() returns — page-controlled, all of it. */
438interface TbPicked {
439 /** Where to crop the tab screenshot. Absent on picks made before 0.0.2. */
440 rect?: TbPickedRect;
441 /** Viewport size at pick time, which fixes the screenshot's scale factor. */
442 viewport?: { w: number; h: number };
443 /** Screen position of the viewport's left edge. Absent on picks made before
444 0.0.2; only a split view needs it. */
445 screenX?: number;
446 css: string;
447 xpath: string;
448 id: string | null;
449 testid: string | null;
450 tag: string;
451 text: string | null;
452 href: string | null;
453 pageUrl: string;
454}
455
456// --- vendored xterm.js ------------------------------------------------------
457//
458// The vendored build ships no types. Only the handful of members used here are
459// declared; anything else is a deliberate error rather than a silent `any`.
460
461interface TbTerminalTheme {
462 [color: string]: string;
463}
464
465interface TbTerminalOptions {
466 fontFamily?: string;
467 fontSize?: number;
468 lineHeight?: number;
469 cursorBlink?: boolean;
470 scrollback?: number;
471 allowProposedApi?: boolean;
472 convertEol?: boolean;
473 macOptionIsMeta?: boolean;
474 theme?: TbTerminalTheme;
475}
476
477declare class Terminal {
478 constructor(options?: TbTerminalOptions);
479 readonly cols: number;
480 readonly rows: number;
481 readonly element?: HTMLElement;
482 options: TbTerminalOptions;
483 open(parent: HTMLElement): void;
484 write(data: string | Uint8Array): void;
485 focus(): void;
486 clear(): void;
487 loadAddon(addon: unknown): void;
488 attachCustomKeyEventHandler(handler: (e: KeyboardEvent) => boolean): void;
489 onData(cb: (data: string) => void): { dispose(): void };
490 /** Bytes xterm could not represent as UTF-16 — each char is one byte. */
491 onBinary(cb: (data: string) => void): { dispose(): void };
492 onResize(cb: (size: { cols: number; rows: number }) => void): { dispose(): void };
493}
494
495declare namespace FitAddon {
496 class FitAddon {
497 fit(): void;
498 proposeDimensions(): { cols: number; rows: number } | undefined;
499 }
500}
501
502declare namespace WebLinksAddon {
503 class WebLinksAddon {
504 constructor(handler?: (event: MouseEvent, uri: string) => void);
505 }
506}
507
508// --- our own files, as the browser sees them --------------------------------
509//
510// lib/theme.js and lib/sanitize.js are loaded as classic scripts here and
511// require()d by their node --test files, so each one ends with a CommonJS tail
512// guarded on `typeof module`. That tail is enough to make TypeScript treat them
513// as modules, so their globals have to be re-stated — from the files
514// themselves, so the shapes cannot drift.
515declare var module: { exports: any } | undefined;
516
517declare const Themes: typeof import("../lib/theme.js");
518declare const Sanitize: typeof import("../lib/sanitize.js");
519declare const Shot: typeof import("../lib/shot.js");
520declare const Split: typeof import("../lib/split.js");
521declare const Portless: typeof import("../lib/portless.js");
522declare const Tabpin: typeof import("../lib/tabpin.js");
523
524/** Set by picker.js inside the *page*, not here — see cancelPick(). */
525interface Window {
526 __tbPickerActive?: (() => void) | null;
527}