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 /** Only used to make a tab active, which needs no permission. captureVisibleTab
136 has no tabId of its own, so a split-view pick has to be activated first. */
137 update(tabId: number, props: { active: boolean }): Promise<TbTab>;
138 /** The visible area of the active tab, as a PNG data URL. Needs a host
139 permission for the page, exactly as the picker's injection does. */
140 captureVisibleTab(options: { format: "png" }): Promise<string>;
141 /** Chrome only, and needs "tabGroups". Adds tabs to an existing group; the
142 tabs must already be in that group's window. */
143 group?(options: { groupId: number; tabIds: number[] }): Promise<number>;
144 /** Only used to carry a tab into the window Claude's group lives in, since
145 group() will not reach across windows. */
146 move(tabId: number, props: { windowId: number; index: number }): Promise<TbTab | TbTab[]>;
147 };
148 /** Chrome only — Firefox has no tab group API at all, which is what makes the
149 handoff a Chrome-only feature. */
150 tabGroups?: {
151 get(groupId: number): Promise<TbTabGroup>;
152 query(info: { windowId?: number }): Promise<TbTabGroup[]>;
153 };
154 permissions: {
155 request(perms: { origins?: string[]; permissions?: string[] }): Promise<boolean>;
156 };
157 scripting: {
158 /**
159 * `Awaited<T>`: a func that returns a promise is awaited by the browser,
160 * and `result` is what it resolved to — which is the whole reason the
161 * picker can hand its value back as a return value.
162 */
163 executeScript<T, A extends any[] = []>(injection: {
164 target: { tabId: number | undefined };
165 func: (...args: A) => T;
166 /** Structured-cloned into the page. Keep it small — a screenshot goes
167 through here as base64. */
168 args?: A;
169 }): Promise<TbInjectionResult<Awaited<T>>[]>;
170 };
171 /** Firefox only: writes an image to the clipboard without a focused
172 document, which the async clipboard API refuses to do. */
173 clipboard?: {
174 setImageData(image: ArrayBuffer, type: "png" | "jpeg"): Promise<void>;
175 };
176 /** Absent in Firefox, and absent in Chrome until the browser is new enough. */
177 sidePanel?: {
178 setPanelBehavior?(behavior: {
179 openPanelOnActionClick: boolean;
180 }): Promise<void>;
181 /** Chrome only, and only during a user gesture. There is no close(): the
182 panel closes itself with window.close(). */
183 open?(options: { windowId: number }): Promise<void>;
184 /** Disabling is the only way to close a panel from here — the panel it
185 applies to goes away without being asked. Without a tabId it is the
186 default for every tab, which is how this extension declares it. */
187 setOptions?(options: { tabId?: number; path?: string; enabled?: boolean }): Promise<void>;
188 };
189 /** Firefox only — the counterpart to Chrome's sidePanel. Unlike Chrome's, it
190 can close the sidebar as well as open it. */
191 sidebarAction?: {
192 open(): Promise<void>;
193 close(): Promise<void>;
194 };
195 /** Absent when no keyboard shortcuts are declared in the manifest. */
196 commands?: {
197 /** The tab is the active one at the time of the shortcut. Chrome always
198 passes it; Firefox only since 106, so treat it as optional. It is the
199 only way to learn the window id without an `await`, which on Chrome
200 would spend the gesture sidePanel.open() needs. */
201 onCommand: {
202 addListener(cb: (command: string, tab?: TbTab) => void): void;
203 };
204 };
205}
206
207declare var browser: TbExtensionApi | undefined;
208declare var chrome: TbExtensionApi | undefined;
209
210// --- the daemon's wire protocol ---------------------------------------------
211//
212// The other half of these shapes is Rust: `Snapshot`, `SessionInfo` and `Agent`
213// in daemon/src/status.rs, and the `ok` frame built in daemon/src/server.rs.
214// Change one side and this side has to move with it — which is most of why
215// they are written down at all.
216//
217// Everything here arrives over a socket. It is *claimed* structure, not
218// guaranteed structure, and it is only ever assigned through textContent.
219
220/** One tmux session — one tab in the top row. */
221interface TbSessionInfo {
222 /** tmux session id (`$1`). Empty on the `ok` frame, which has names only. */
223 id: string;
224 name: string;
225 /** Some tmux client, anywhere, is on this session. */
226 attached: boolean;
227 /** The group colour, from the `@termbridge_color` tmux user option: a hue in
228 degrees as a string, or "-1" for grey. Absent when nobody has chosen one,
229 which is what tells the panel to pick. */
230 color?: string | null;
231 /** Working directory of the session's current pane. Absent on the `ok` frame
232 and whenever tmux has no pane to answer for. */
233 path?: string | null;
234 /** When tmux made the session, in Unix seconds. */
235 created?: number | null;
236 /** How many tmux clients are attached to it. */
237 clients?: number;
238 /** Its windows, in index order. Empty until the first status frame. */
239 windows: TbWindowInfo[];
240}
241
242/** One window of a session — one tab in the second row. */
243interface TbWindowInfo {
244 /** tmux window id (`@3`). Stable; the index is not. */
245 id: string;
246 index: number;
247 name: string;
248 active: boolean;
249 panes: number;
250 activity: boolean;
251}
252
253// --- pinning a session to a browser tab -------------------------------------
254//
255// Panel state, not server state: none of this reaches the socket, and the
256// daemon has never heard of a browser tab. See lib/tabpin.js.
257
258/** Where a pinned browser tab sends the terminal. A null window means "the
259 session, wherever it is currently pointed". */
260interface TbPinTarget {
261 session: string;
262 window?: string | null;
263}
264
265/** Which rule answered — shown in the tooltip, because a switch nobody
266 remembers asking for should say where it came from. */
267type TbPinSource = "tab" | "origin" | "detect";
268
269/** A place in tmux. A null window means the session's own current window,
270 which is what a session-level pin points at. */
271interface TbSpot {
272 session: string;
273 window?: string | null;
274}
275
276/** An excursion in progress: a pin moved the terminal `to` somewhere, and owes
277 it back to `from` when the browser leaves for a tab with no pin. */
278interface TbPinReturn {
279 from: TbSpot;
280 to: TbSpot;
281}
282
283interface TbTabPinStore {
284 /** The whole feature, off. Pins are kept while it is off. */
285 enabled: boolean;
286 /** The devport guess for localhost ports. Explicit pins work regardless. */
287 detect: boolean;
288 /** The other direction: moving the terminal activates the pinned tab. Reads
289 the same pins as the forward direction, and is separately switchable
290 because it is the half that touches the browser. */
291 reverse: boolean;
292 /** Keyed by browser tab id, as a string — storage round-trips JSON, and JSON
293 object keys are strings. A null value is a veto. */
294 byTab: Record<string, TbPinTarget | null>;
295 /** Keyed by origin, e.g. `http://localhost:26210`. */
296 byOrigin: Record<string, TbPinTarget | null>;
297}
298
299/**
300 * What an agent is doing. The header's glyph slot takes this plus `"none"` — its
301 * own name for "no agent here", which it needs because every row has the slot
302 * whether or not anything is in it.
303 */
304type TbAgentState = "working" | "waiting" | "ready" | "idle" | "unknown";
305
306interface TbAgent {
307 /** tmux pane id (`%12`), stable for the pane's lifetime. */
308 pane: string;
309 /** Session name — the join to TbSessionInfo, since agents are server-wide. */
310 session: string;
311 /** tmux window id (`@3`) — the join to TbWindowInfo. */
312 window_id: string;
313 window: string;
314 name: string;
315 /**
316 * `ready` is the one no hook reports: an idle agent whose pane has not been
317 * on screen since it went idle. The daemon derives it — see `SEEN` in
318 * daemon/src/status.rs.
319 */
320 state: TbAgentState;
321 /**
322 * This pane is the one on screen under the sidebar — our client's session,
323 * active window, active pane. The panel is told about panes and never about
324 * its own, so this is the only "you are here" it gets.
325 */
326 here?: boolean;
327 mode?: string | null;
328 tool?: string | null;
329 message?: string | null;
330 title?: string | null;
331 /**
332 * The model id, e.g. `claude-opus-4-1-20250805`. Only ever reported at
333 * `SessionStart`, so it can be stale after a mid-session model switch, and
334 * absent entirely for a session that started before this field existed.
335 */
336 model?: string | null;
337}
338
339/**
340 * First frame after a successful auth. It carries session *names* only — the
341 * window list needs the control channel, which is up a moment later.
342 */
343interface TbOkFrame {
344 type: "ok";
345 profile: string;
346 tmux: boolean;
347 defaultSession?: string;
348 /** Names only — window counts arrive with the first status frame. */
349 sessions?: string[];
350 /**
351 * Machines to offer in the omnibar, from the daemon's `~/.ssh/config` and
352 * `~/.ssh/known_hosts`. Here rather than on the status frames because it
353 * comes from files that do not change while a panel is open.
354 */
355 hosts?: string[];
356}
357
358/**
359 * Pushed whenever tmux says something changed. It describes the whole server:
360 * every session with its own windows, and every agent in any of them.
361 */
362interface TbStatusFrame {
363 type: "status";
364 /** The session this panel's client is on right now. */
365 session?: string;
366 sessions?: TbSessionInfo[];
367 agents?: TbAgent[];
368}
369
370/**
371 * One directory, in answer to `{type:"path", q}` — the only frame the panel
372 * asks for rather than being told. Built in daemon/src/server.rs from
373 * `project::Listing`.
374 */
375interface TbPathFrame {
376 type: "path";
377 /** The query, echoed back: replies can land after the box has moved on. */
378 q: string;
379 /** The expanded, absolute path. Empty when the query was not one. */
380 path: string;
381 /** "invalid" is a query the daemon would not expand — relative, `..`,
382 someone else's `~`. */
383 kind: "dir" | "file" | "missing" | "denied" | "invalid";
384 /** Directories `mkdir -p` would have to create to make this path. */
385 creates: number;
386 /** Directory names inside it, sorted, visible ones before hidden. */
387 dirs: string[];
388 /** The other names. Only used to tell a file apart from a path to create. */
389 files: string[];
390}
391
392type TbFrame =
393 | TbOkFrame
394 | TbStatusFrame
395 | TbPathFrame
396 | { type: "exit"; code: number }
397 | { type: "error"; reason: string }
398 | { type: "tmux-error"; reason: string };
399
400/** The element's box in CSS pixels, relative to the viewport. */
401/** Where a pick's viewport sits in its window, which is how the crop tells the
402 two halves of a split view apart. Both in screen coordinates. */
403interface TbPane {
404 screenX: number;
405 winLeft: number;
406}
407
408interface TbPickedRect {
409 x: number;
410 y: number;
411 width: number;
412 height: number;
413}
414
415/** The identifier flavours the panel can show — the string-valued keys of
416 TbPicked, and the only ones that may reach the terminal. */
417type TbPickedFormat = "css" | "xpath" | "id" | "testid" | "text" | "href";
418
419/** What picker.js's describe() returns — page-controlled, all of it. */
420interface TbPicked {
421 /** Where to crop the tab screenshot. Absent on picks made before 0.0.2. */
422 rect?: TbPickedRect;
423 /** Viewport size at pick time, which fixes the screenshot's scale factor. */
424 viewport?: { w: number; h: number };
425 /** Screen position of the viewport's left edge. Absent on picks made before
426 0.0.2; only a split view needs it. */
427 screenX?: number;
428 css: string;
429 xpath: string;
430 id: string | null;
431 testid: string | null;
432 tag: string;
433 text: string | null;
434 href: string | null;
435 pageUrl: string;
436}
437
438// --- vendored xterm.js ------------------------------------------------------
439//
440// The vendored build ships no types. Only the handful of members used here are
441// declared; anything else is a deliberate error rather than a silent `any`.
442
443interface TbTerminalTheme {
444 [color: string]: string;
445}
446
447interface TbTerminalOptions {
448 fontFamily?: string;
449 fontSize?: number;
450 lineHeight?: number;
451 cursorBlink?: boolean;
452 scrollback?: number;
453 allowProposedApi?: boolean;
454 convertEol?: boolean;
455 macOptionIsMeta?: boolean;
456 theme?: TbTerminalTheme;
457}
458
459declare class Terminal {
460 constructor(options?: TbTerminalOptions);
461 readonly cols: number;
462 readonly rows: number;
463 readonly element?: HTMLElement;
464 options: TbTerminalOptions;
465 open(parent: HTMLElement): void;
466 write(data: string | Uint8Array): void;
467 focus(): void;
468 clear(): void;
469 loadAddon(addon: unknown): void;
470 attachCustomKeyEventHandler(handler: (e: KeyboardEvent) => boolean): void;
471 onData(cb: (data: string) => void): { dispose(): void };
472 /** Bytes xterm could not represent as UTF-16 — each char is one byte. */
473 onBinary(cb: (data: string) => void): { dispose(): void };
474 onResize(cb: (size: { cols: number; rows: number }) => void): { dispose(): void };
475}
476
477declare namespace FitAddon {
478 class FitAddon {
479 fit(): void;
480 proposeDimensions(): { cols: number; rows: number } | undefined;
481 }
482}
483
484declare namespace WebLinksAddon {
485 class WebLinksAddon {
486 constructor(handler?: (event: MouseEvent, uri: string) => void);
487 }
488}
489
490// --- our own files, as the browser sees them --------------------------------
491//
492// lib/theme.js and lib/sanitize.js are loaded as classic scripts here and
493// require()d by their node --test files, so each one ends with a CommonJS tail
494// guarded on `typeof module`. That tail is enough to make TypeScript treat them
495// as modules, so their globals have to be re-stated — from the files
496// themselves, so the shapes cannot drift.
497declare var module: { exports: any } | undefined;
498
499declare const Themes: typeof import("../lib/theme.js");
500declare const Sanitize: typeof import("../lib/sanitize.js");
501declare const Shot: typeof import("../lib/shot.js");
502declare const Split: typeof import("../lib/split.js");
503declare const Devport: typeof import("../lib/devport.js");
504declare const Tabpin: typeof import("../lib/tabpin.js");
505
506/** Set by picker.js inside the *page*, not here — see cancelPick(). */
507interface Window {
508 __tbPickerActive?: (() => void) | null;
509}