| 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 | /** 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. */ |
| 37 | interface TbTabGroup { |
| 38 | id: number; |
| 39 | windowId: number; |
| 40 | title?: string; |
| 41 | } |
| 42 | |
| 43 | interface TbInjectionResult<T = unknown> { |
| 44 | result?: T; |
| 45 | frameId?: number; |
| 46 | } |
| 47 | |
| 48 | interface 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. */ |
| 57 | interface 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 | |
| 68 | interface 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 | |
| 75 | interface 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 | |
| 81 | interface 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 | |
| 207 | declare var browser: TbExtensionApi | undefined; |
| 208 | declare 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. */ |
| 221 | interface 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. */ |
| 243 | interface 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". */ |
| 260 | interface 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. */ |
| 267 | type 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. */ |
| 271 | interface 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. */ |
| 278 | interface TbPinReturn { |
| 279 | from: TbSpot; |
| 280 | to: TbSpot; |
| 281 | } |
| 282 | |
| 283 | interface 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 | */ |
| 304 | type TbAgentState = "working" | "waiting" | "ready" | "idle" | "unknown"; |
| 305 | |
| 306 | interface 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 | */ |
| 343 | interface 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 | */ |
| 362 | interface 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 | */ |
| 375 | interface 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 | |
| 392 | type 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. */ |
| 403 | interface TbPane { |
| 404 | screenX: number; |
| 405 | winLeft: number; |
| 406 | } |
| 407 | |
| 408 | interface 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. */ |
| 417 | type TbPickedFormat = "css" | "xpath" | "id" | "testid" | "text" | "href"; |
| 418 | |
| 419 | /** What picker.js's describe() returns — page-controlled, all of it. */ |
| 420 | interface 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 | |
| 443 | interface TbTerminalTheme { |
| 444 | [color: string]: string; |
| 445 | } |
| 446 | |
| 447 | interface 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 | |
| 459 | declare 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 | |
| 477 | declare namespace FitAddon { |
| 478 | class FitAddon { |
| 479 | fit(): void; |
| 480 | proposeDimensions(): { cols: number; rows: number } | undefined; |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | declare 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. |
| 497 | declare var module: { exports: any } | undefined; |
| 498 | |
| 499 | declare const Themes: typeof import("../lib/theme.js"); |
| 500 | declare const Sanitize: typeof import("../lib/sanitize.js"); |
| 501 | declare const Shot: typeof import("../lib/shot.js"); |
| 502 | declare const Split: typeof import("../lib/split.js"); |
| 503 | declare const Devport: typeof import("../lib/devport.js"); |
| 504 | declare const Tabpin: typeof import("../lib/tabpin.js"); |
| 505 | |
| 506 | /** Set by picker.js inside the *page*, not here — see cancelPick(). */ |
| 507 | interface Window { |
| 508 | __tbPickerActive?: (() => void) | null; |
| 509 | } |