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 /** The split view this tab is half of, if any — Chrome 140+, and read-only:
19 the API detects splits, it cannot create or dissolve them. Absent in
20 Firefox, which has no split view at all. */
21 splitViewId?: number;
22 /** The tab group this tab belongs to, or -1 for none. Chrome only. This is
23 the whole gate on whether Claude in Chrome can see the tab. */
24 groupId?: number;
25}
26
27/** A Chrome tab group. Carries no stable identity beyond its id — the title is
28 whatever the user (or another extension) last set, and is often empty, so it
29 is not something to match on. */
30interface TbTabGroup {
31 id: number;
32 windowId: number;
33 title?: string;
34}
35
36interface TbInjectionResult<T = unknown> {
37 result?: T;
38 frameId?: number;
39}
40
41interface TbMessageSender {
42 id?: string;
43 /** Set for content scripts and only for content scripts — the check that
44 keeps a hostile page from reaching the sidebar's message handler. */
45 tab?: TbTab;
46}
47
48/** A long-lived connection. Only the toggle shortcut uses one — sidebar.js and
49 sw.js are the two ends, and it carries three fixed command shapes. */
50interface TbPort {
51 name: string;
52 /** Set on the receiving end only, and set for content scripts exactly as
53 TbMessageSender.tab is. */
54 sender?: TbMessageSender;
55 postMessage(message: any): void;
56 disconnect(): void;
57 onMessage: { addListener(cb: (message: any) => void): void };
58 onDisconnect: { addListener(cb: () => void): void };
59}
60
61interface TbWindow {
62 id: number;
63 /** Screen position of the window's left edge. Only the split-view crop reads
64 it, to work out which half of the window a pick came from. */
65 left?: number;
66}
67
68interface TbStorageArea {
69 get(keys?: string | string[] | null): Promise<Record<string, any>>;
70 set(items: Record<string, any>): Promise<void>;
71 remove(keys: string | string[]): Promise<void>;
72}
73
74interface TbExtensionApi {
75 runtime: {
76 id: string;
77 sendMessage(message: any): Promise<any>;
78 onMessage: {
79 addListener(
80 cb: (message: any, sender: TbMessageSender) => void | boolean | Promise<any>,
81 ): void;
82 };
83 connect(info: { name: string }): TbPort;
84 onConnect: { addListener(cb: (port: TbPort) => void): void };
85 };
86 storage: { local: TbStorageArea };
87 windows: {
88 /** The window the calling document lives in — the sidebar's own. */
89 getCurrent(): Promise<TbWindow>;
90 /** The worker has no window of its own, so the shortcut acts on this one. */
91 getLastFocused(): Promise<TbWindow>;
92 /** Only used to raise the window a handed-over tab just landed in. */
93 update(windowId: number, props: { focused: boolean }): Promise<TbWindow>;
94 };
95 tabs: {
96 query(info: {
97 active?: boolean;
98 currentWindow?: boolean;
99 /** Chrome throws on this key rather than ignoring it where it isn't
100 supported, so every call that passes it is wrapped. */
101 splitViewId?: number;
102 }): Promise<TbTab[]>;
103 create(props: { url: string }): Promise<TbTab>;
104 /** Only used to make a tab active, which needs no permission. captureVisibleTab
105 has no tabId of its own, so a split-view pick has to be activated first. */
106 update(tabId: number, props: { active: boolean }): Promise<TbTab>;
107 /** The visible area of the active tab, as a PNG data URL. Needs a host
108 permission for the page, exactly as the picker's injection does. */
109 captureVisibleTab(options: { format: "png" }): Promise<string>;
110 /** Chrome only, and needs "tabGroups". Adds tabs to an existing group; the
111 tabs must already be in that group's window. */
112 group?(options: { groupId: number; tabIds: number[] }): Promise<number>;
113 /** Only used to carry a tab into the window Claude's group lives in, since
114 group() will not reach across windows. */
115 move(tabId: number, props: { windowId: number; index: number }): Promise<TbTab | TbTab[]>;
116 };
117 /** Chrome only — Firefox has no tab group API at all, which is what makes the
118 handoff a Chrome-only feature. */
119 tabGroups?: {
120 get(groupId: number): Promise<TbTabGroup>;
121 query(info: { windowId?: number }): Promise<TbTabGroup[]>;
122 };
123 permissions: {
124 request(perms: { origins?: string[]; permissions?: string[] }): Promise<boolean>;
125 };
126 scripting: {
127 /**
128 * `Awaited<T>`: a func that returns a promise is awaited by the browser,
129 * and `result` is what it resolved to — which is the whole reason the
130 * picker can hand its value back as a return value.
131 */
132 executeScript<T, A extends any[] = []>(injection: {
133 target: { tabId: number | undefined };
134 func: (...args: A) => T;
135 /** Structured-cloned into the page. Keep it small — a screenshot goes
136 through here as base64. */
137 args?: A;
138 }): Promise<TbInjectionResult<Awaited<T>>[]>;
139 };
140 /** Firefox only: writes an image to the clipboard without a focused
141 document, which the async clipboard API refuses to do. */
142 clipboard?: {
143 setImageData(image: ArrayBuffer, type: "png" | "jpeg"): Promise<void>;
144 };
145 /** Absent in Firefox, and absent in Chrome until the browser is new enough. */
146 sidePanel?: {
147 setPanelBehavior?(behavior: {
148 openPanelOnActionClick: boolean;
149 }): Promise<void>;
150 /** Chrome only, and only during a user gesture. There is no close(): the
151 panel closes itself with window.close(). */
152 open?(options: { windowId: number }): Promise<void>;
153 /** Disabling is the only way to close a panel from here — the panel it
154 applies to goes away without being asked. Without a tabId it is the
155 default for every tab, which is how this extension declares it. */
156 setOptions?(options: { tabId?: number; path?: string; enabled?: boolean }): Promise<void>;
157 };
158 /** Firefox only — the counterpart to Chrome's sidePanel. Unlike Chrome's, it
159 can close the sidebar as well as open it. */
160 sidebarAction?: {
161 open(): Promise<void>;
162 close(): Promise<void>;
163 };
164 /** Absent when no keyboard shortcuts are declared in the manifest. */
165 commands?: {
166 /** The tab is the active one at the time of the shortcut. Chrome always
167 passes it; Firefox only since 106, so treat it as optional. It is the
168 only way to learn the window id without an `await`, which on Chrome
169 would spend the gesture sidePanel.open() needs. */
170 onCommand: {
171 addListener(cb: (command: string, tab?: TbTab) => void): void;
172 };
173 };
174}
175
176declare var browser: TbExtensionApi | undefined;
177declare var chrome: TbExtensionApi | undefined;
178
179// --- the daemon's wire protocol ---------------------------------------------
180//
181// The other half of these shapes is Rust: `Snapshot`, `SessionInfo` and `Agent`
182// in daemon/src/status.rs, and the `ok` frame built in daemon/src/server.rs.
183// Change one side and this side has to move with it — which is most of why
184// they are written down at all.
185//
186// Everything here arrives over a socket. It is *claimed* structure, not
187// guaranteed structure, and it is only ever assigned through textContent.
188
189/** One tmux session — one tab in the top row. */
190interface TbSessionInfo {
191 /** tmux session id (`$1`). Empty on the `ok` frame, which has names only. */
192 id: string;
193 name: string;
194 /** Some tmux client, anywhere, is on this session. */
195 attached: boolean;
196 /** The group colour, from the `@termbridge_color` tmux user option: a hue in
197 degrees as a string, or "-1" for grey. Absent when nobody has chosen one,
198 which is what tells the panel to pick. */
199 color?: string | null;
200 /** Working directory of the session's current pane. Absent on the `ok` frame
201 and whenever tmux has no pane to answer for. */
202 path?: string | null;
203 /** When tmux made the session, in Unix seconds. */
204 created?: number | null;
205 /** How many tmux clients are attached to it. */
206 clients?: number;
207 /** Its windows, in index order. Empty until the first status frame. */
208 windows: TbWindowInfo[];
209}
210
211/** One window of a session — one tab in the second row. */
212interface TbWindowInfo {
213 /** tmux window id (`@3`). Stable; the index is not. */
214 id: string;
215 index: number;
216 name: string;
217 active: boolean;
218 panes: number;
219 activity: boolean;
220}
221
222/**
223 * What an agent is doing. The header's glyph slot takes this plus `"none"` — its
224 * own name for "no agent here", which it needs because every row has the slot
225 * whether or not anything is in it.
226 */
227type TbAgentState = "working" | "waiting" | "ready" | "idle" | "unknown";
228
229interface TbAgent {
230 /** tmux pane id (`%12`), stable for the pane's lifetime. */
231 pane: string;
232 /** Session name — the join to TbSessionInfo, since agents are server-wide. */
233 session: string;
234 /** tmux window id (`@3`) — the join to TbWindowInfo. */
235 window_id: string;
236 window: string;
237 name: string;
238 /**
239 * `ready` is the one no hook reports: an idle agent whose pane has not been
240 * on screen since it went idle. The daemon derives it — see `SEEN` in
241 * daemon/src/status.rs.
242 */
243 state: TbAgentState;
244 /**
245 * This pane is the one on screen under the sidebar — our client's session,
246 * active window, active pane. The panel is told about panes and never about
247 * its own, so this is the only "you are here" it gets.
248 */
249 here?: boolean;
250 mode?: string | null;
251 tool?: string | null;
252 message?: string | null;
253 title?: string | null;
254}
255
256/**
257 * First frame after a successful auth. It carries session *names* only — the
258 * window list needs the control channel, which is up a moment later.
259 */
260interface TbOkFrame {
261 type: "ok";
262 profile: string;
263 tmux: boolean;
264 defaultSession?: string;
265 /** Names only — window counts arrive with the first status frame. */
266 sessions?: string[];
267 /**
268 * Machines to offer in the omnibar, from the daemon's `~/.ssh/config` and
269 * `~/.ssh/known_hosts`. Here rather than on the status frames because it
270 * comes from files that do not change while a panel is open.
271 */
272 hosts?: string[];
273}
274
275/**
276 * Pushed whenever tmux says something changed. It describes the whole server:
277 * every session with its own windows, and every agent in any of them.
278 */
279interface TbStatusFrame {
280 type: "status";
281 /** The session this panel's client is on right now. */
282 session?: string;
283 sessions?: TbSessionInfo[];
284 agents?: TbAgent[];
285}
286
287/**
288 * One directory, in answer to `{type:"path", q}` — the only frame the panel
289 * asks for rather than being told. Built in daemon/src/server.rs from
290 * `project::Listing`.
291 */
292interface TbPathFrame {
293 type: "path";
294 /** The query, echoed back: replies can land after the box has moved on. */
295 q: string;
296 /** The expanded, absolute path. Empty when the query was not one. */
297 path: string;
298 /** "invalid" is a query the daemon would not expand — relative, `..`,
299 someone else's `~`. */
300 kind: "dir" | "file" | "missing" | "denied" | "invalid";
301 /** Directories `mkdir -p` would have to create to make this path. */
302 creates: number;
303 /** Directory names inside it, sorted, visible ones before hidden. */
304 dirs: string[];
305 /** The other names. Only used to tell a file apart from a path to create. */
306 files: string[];
307}
308
309type TbFrame =
310 | TbOkFrame
311 | TbStatusFrame
312 | TbPathFrame
313 | { type: "exit"; code: number }
314 | { type: "error"; reason: string }
315 | { type: "tmux-error"; reason: string };
316
317/** The element's box in CSS pixels, relative to the viewport. */
318/** Where a pick's viewport sits in its window, which is how the crop tells the
319 two halves of a split view apart. Both in screen coordinates. */
320interface TbPane {
321 screenX: number;
322 winLeft: number;
323}
324
325interface TbPickedRect {
326 x: number;
327 y: number;
328 width: number;
329 height: number;
330}
331
332/** The identifier flavours the panel can show — the string-valued keys of
333 TbPicked, and the only ones that may reach the terminal. */
334type TbPickedFormat = "css" | "xpath" | "id" | "testid" | "text" | "href";
335
336/** What picker.js's describe() returns — page-controlled, all of it. */
337interface TbPicked {
338 /** Where to crop the tab screenshot. Absent on picks made before 0.0.2. */
339 rect?: TbPickedRect;
340 /** Viewport size at pick time, which fixes the screenshot's scale factor. */
341 viewport?: { w: number; h: number };
342 /** Screen position of the viewport's left edge. Absent on picks made before
343 0.0.2; only a split view needs it. */
344 screenX?: number;
345 css: string;
346 xpath: string;
347 id: string | null;
348 testid: string | null;
349 tag: string;
350 text: string | null;
351 href: string | null;
352 pageUrl: string;
353}
354
355// --- vendored xterm.js ------------------------------------------------------
356//
357// The vendored build ships no types. Only the handful of members used here are
358// declared; anything else is a deliberate error rather than a silent `any`.
359
360interface TbTerminalTheme {
361 [color: string]: string;
362}
363
364interface TbTerminalOptions {
365 fontFamily?: string;
366 fontSize?: number;
367 lineHeight?: number;
368 cursorBlink?: boolean;
369 scrollback?: number;
370 allowProposedApi?: boolean;
371 convertEol?: boolean;
372 macOptionIsMeta?: boolean;
373 theme?: TbTerminalTheme;
374}
375
376declare class Terminal {
377 constructor(options?: TbTerminalOptions);
378 readonly cols: number;
379 readonly rows: number;
380 readonly element?: HTMLElement;
381 options: TbTerminalOptions;
382 open(parent: HTMLElement): void;
383 write(data: string | Uint8Array): void;
384 focus(): void;
385 clear(): void;
386 loadAddon(addon: unknown): void;
387 attachCustomKeyEventHandler(handler: (e: KeyboardEvent) => boolean): void;
388 onData(cb: (data: string) => void): { dispose(): void };
389 /** Bytes xterm could not represent as UTF-16 — each char is one byte. */
390 onBinary(cb: (data: string) => void): { dispose(): void };
391 onResize(cb: (size: { cols: number; rows: number }) => void): { dispose(): void };
392}
393
394declare namespace FitAddon {
395 class FitAddon {
396 fit(): void;
397 proposeDimensions(): { cols: number; rows: number } | undefined;
398 }
399}
400
401// --- our own files, as the browser sees them --------------------------------
402//
403// lib/theme.js and lib/sanitize.js are loaded as classic scripts here and
404// require()d by their node --test files, so each one ends with a CommonJS tail
405// guarded on `typeof module`. That tail is enough to make TypeScript treat them
406// as modules, so their globals have to be re-stated — from the files
407// themselves, so the shapes cannot drift.
408declare var module: { exports: any } | undefined;
409
410declare const Themes: typeof import("../lib/theme.js");
411declare const Sanitize: typeof import("../lib/sanitize.js");
412declare const Shot: typeof import("../lib/shot.js");
413declare const Split: typeof import("../lib/split.js");
414
415/** Set by picker.js inside the *page*, not here — see cancelPick(). */
416interface Window {
417 __tbPickerActive?: (() => void) | null;
418}