| 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 | |
| 13 | interface 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. */ |
| 30 | interface TbTabGroup { |
| 31 | id: number; |
| 32 | windowId: number; |
| 33 | title?: string; |
| 34 | } |
| 35 | |
| 36 | interface TbInjectionResult<T = unknown> { |
| 37 | result?: T; |
| 38 | frameId?: number; |
| 39 | } |
| 40 | |
| 41 | interface 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. */ |
| 50 | interface 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 | |
| 61 | interface 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 | |
| 68 | interface 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 | |
| 74 | interface 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 | |
| 176 | declare var browser: TbExtensionApi | undefined; |
| 177 | declare 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. */ |
| 190 | interface 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. */ |
| 212 | interface 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 | */ |
| 227 | type TbAgentState = "working" | "waiting" | "ready" | "idle" | "unknown"; |
| 228 | |
| 229 | interface 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 | */ |
| 260 | interface 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 | */ |
| 279 | interface 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 | */ |
| 292 | interface 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 | |
| 309 | type 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. */ |
| 320 | interface TbPane { |
| 321 | screenX: number; |
| 322 | winLeft: number; |
| 323 | } |
| 324 | |
| 325 | interface 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. */ |
| 334 | type TbPickedFormat = "css" | "xpath" | "id" | "testid" | "text" | "href"; |
| 335 | |
| 336 | /** What picker.js's describe() returns — page-controlled, all of it. */ |
| 337 | interface 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 | |
| 360 | interface TbTerminalTheme { |
| 361 | [color: string]: string; |
| 362 | } |
| 363 | |
| 364 | interface 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 | |
| 376 | declare 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 | |
| 394 | declare 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. |
| 408 | declare var module: { exports: any } | undefined; |
| 409 | |
| 410 | declare const Themes: typeof import("../lib/theme.js"); |
| 411 | declare const Sanitize: typeof import("../lib/sanitize.js"); |
| 412 | declare const Shot: typeof import("../lib/shot.js"); |
| 413 | declare const Split: typeof import("../lib/split.js"); |
| 414 | |
| 415 | /** Set by picker.js inside the *page*, not here — see cancelPick(). */ |
| 416 | interface Window { |
| 417 | __tbPickerActive?: (() => void) | null; |
| 418 | } |