anvilsign in

collin/browser-terminal-extension

RenderedSource

1# terminal
2
3A tmux sidebar for Chrome and Firefox.
4
5The extension is called **terminal**; the daemon it talks to is `termbridge`.
6They are deliberately separate names — the daemon is a browser-neutral CLI with
7its own config directory, and keeping it stable means renaming the extension
8never touches `~/.config/termbridge/`, the token, or the certificate.
9
10```
11sidebar (xterm.js) daemon (Rust)
12 │ │
13 │ ws://127.0.0.1:7681 │
14 │ ─── {"type":"auth","token":"…"} ──────▶ │ origin + host + token
15 │ ◀── {"type":"ok"} ───────────────────── │
16 │ ─── {"type":"open","cols":80,…} ──────▶ │ spawn pty
17 │ ═══ binary frames (raw bytes) ════════▶ │ ──▶ tmux new-session -A -s default
18 │ ◀══ binary frames (raw bytes) ═════════ │
19 │ ─── {"type":"resize","cols":…} ───────▶ │ TIOCSWINSZ
20```
21
22The daemon runs a PTY. tmux inside it does all multiplexing and, crucially, all
23persistence — close the sidebar, restart the browser, reattach and everything is
24where you left it.
25
26## Setup
27
28```sh
29cd daemon && cargo build --release
30./build.sh # produces dist/chrome and dist/firefox
31```
32
33Load the extension:
34
35| | |
36|---|---|
37| Chrome | `chrome://extensions` → Developer mode → Load unpacked → `dist/chrome` |
38| Firefox | `about:debugging#/runtime/this-firefox` → Load Temporary Add-on → `dist/firefox/manifest.json` |
39
40Open the sidebar, hit ⚙, and it shows you the command to run. Then:
41
42```sh
43termbridge pair chrome-extension://<the id it showed you>
44termbridge token # paste this into the sidebar
45termbridge serve
46```
47
48**Firefox needs one extra step.** HTTPS-Only Mode silently rewrites `ws://` to
49`wss://`, so the daemon serves TLS and plaintext on the same port, choosing per
50connection by sniffing the first byte. The certificate is self-signed, so trust
51it once: open `https://127.0.0.1:7681/` and accept the warning (the sidebar has a
52button for this). Chrome uses plaintext and skips the step entirely.
53
54Verify you're trusting the right certificate — `termbridge cert` prints the
55SHA-256 the browser will show you.
56
57Pairing is per-browser. Firefox's `moz-extension://` origin is a random UUID
58regenerated on each temporary install, so you'll re-pair each time until the
59add-on is signed.
60
61## Starting the daemon on demand
62
63Remembering to run `termbridge serve` before opening the sidebar is the worst
64part of the setup above. Hand it to the service manager instead:
65
66```sh
67termbridge install # then never think about it again
68```
69
70On Linux that is socket activation, not an always-on service. systemd binds
71`127.0.0.1:7681` at login and holds it; the daemon is only exec'd when the
72sidebar actually connects, inheriting the already-bound socket as fd 3. Fifteen
73minutes after the last client disconnects the daemon exits again, and the next
74connection starts a fresh one. So at rest there is no process, only a socket.
75
76That is safe *only* because tmux, not the daemon, is the persistence layer.
77Exiting drops no session state, which is the same property that lets you close
78the sidebar and reattach later. If tmux is unavailable and the daemon falls back
79to a plain shell, do not install with an idle timeout — the fallback shell dies
80with the daemon.
81
82```sh
83termbridge install --idle-timeout 0 # stay resident once started
84termbridge install --port 7999 --session work
85termbridge reload # restart the daemon after a rebuild
86termbridge uninstall
87systemctl --user status termbridge.service
88journalctl --user -u termbridge.service -f
89```
90
91### Updating the daemon
92
93The unit points at the binary by absolute path, so a rebuild is picked up by the
94next activation — but only once the daemon holding the old code goes away:
95
96```sh
97cargo build --release
98termbridge reload # socket keeps listening
99```
100
101The next sidebar connection starts the new binary. Nothing else is needed, and
102if you can wait out the idle timeout you don't even need the reload.
103
104`reload` is `systemctl --user daemon-reload`, `stop termbridge.service`,
105`restart termbridge.socket` and an `import-environment PATH`, in that order, and
106nothing else — it never rewrites a unit, so a port, idle timeout or pinned
107session you set at install time survives it. On macOS it is `launchctl kickstart
108-k` against the login agent. Either way tmux is not involved: the sessions and
109everything running in them belong to the tmux server, so all a reload costs is
110the moment the sidebar takes to reconnect.
111
112If you changed anything the *unit* encodes (port, idle timeout, session, or
113where the binary lives), re-run `termbridge install` instead. It rewrites both
114units, stops the running daemon, and restarts the socket, in that order. The
115order is the whole trick: `enable --now` is a no-op on an already-active socket,
116so a reinstall without the restart leaves the old configuration listening while
117systemd logs "Unit configuration changed while unit was running ... Unit not
118functional until restarted" and the port quietly stops accepting. And the socket
119cannot be restarted before the daemon is stopped, because the daemon is still
120holding the port.
121
122`systemctl stop` prints "Stopping termbridge.service, but its triggering units
123are still active" every time. That is systemd describing socket activation back
124to you, not a problem.
125
126The units land in `~/.config/systemd/user/`, and re-running `install` overwrites
127them, so edit freely and expect to lose it on upgrade. `install` also runs
128`systemctl --user import-environment PATH`, because a user service otherwise
129inherits the manager's PATH rather than your shell's, and the usual symptom is
130the daemon reporting "tmux not found" while `tmux` works fine in every terminal
131you have open.
132
133On macOS `install` writes a launchd agent that runs at login and stays resident.
134launchd can do socket activation too, but only through
135`launch_activate_socket(3)`, so the on-demand half is Linux-only for now.
136
137Nothing about this changes the protocol: `termbridge serve` by hand still works
138and still binds its own socket. `--systemd-socket` is what switches it to the
139inherited one, and it's an error rather than a fallback if no socket arrives —
140binding a second port would leave the sidebar talking to a daemon nobody dialed.
141
142## Theme
143
144The **◐** button in the header cycles *follow system → light → dark*, and the ⚙
145panel has the same setting. `auto` tracks `prefers-color-scheme` live, so it
146follows the OS without a reconnect.
147
148Both palettes live in `extension/lib/theme.js` as the single source of truth:
149the `ui` block becomes CSS custom properties on `:root`, the `xterm` block goes
150to `term.options.theme`. Chrome and terminal cannot drift apart.
151
152## Header size
153
154⚙ → Appearance has a header density control:
155
156| | |
157|---|---|
158| Normal | Status text plus icons |
159| Compact | Icons only, tighter padding — about one extra terminal row |
160| Hide header | No header; hover the top edge of the panel to bring it back |
161
162Changing it refits the terminal and sends the new size to the pty, so tmux
163reflows immediately.
164
165Note this only covers *our* header. The bar above it — extension name, close ✕,
166panel switcher — is browser chrome. Chrome's side panel and Firefox's sidebar
167both render it and neither exposes any way for an extension to remove or restyle
168it.
169
170## Choosing a tmux session
171
172By default the sidebar joins the tmux session you already have running, when
173there is exactly one — no point starting a second session beside the only one
174you are using. With none, or with several, it attaches to a session called
175`default` and creates it if needed. The check happens per connection, so it
176reflects what tmux holds when the sidebar connects, not when the daemon
177started.
178
179To pin a specific session and skip that guessing entirely:
180
181```sh
182termbridge serve --session my-existing-work
183```
184
185Or pick it live: the header's top row is one tab per session on the server.
186Clicking one runs `switch-client` on the sidebar's own tmux client — the
187WebSocket stays up, no second pty is spawned, and whatever is running in the
188session you left keeps running. **+** names a new one inline (`new-session -A`,
189so an existing name attaches instead of failing), and the ⚙ panel's **tmux
190session** field does the same from settings.
191
192A session tab carries the number of windows behind it, and in its favicon slot
193the loudest thing Claude Code is doing anywhere inside it — a Claude waiting on
194you in a session you are not looking at still gets a light. That is what the
195daemon's whole-server status frames are for.
196
197The selected tab is the session you are *actually* on, not the one you asked
198for, so a `switch-client`, `choose-tree` or prefix-`(`/`)` typed in the terminal
199moves it too.
200
201Both names are validated server-side — see the security notes below.
202
203## Window tabs
204
205The header's second row is a browser-style tab bar nested under the session
206tabs: one tab per **window** of the selected session — the same windows
207`prefix 2` selects and the tmux status line lists. Clicking one runs
208`select-window`, and **+** runs `new-window`. Neither touches the connection:
209the pty, the session and everything running in it stay exactly as they were.
210
211Selecting a window deliberately moves *every* client watching that session, not
212just the sidebar — a window belongs to the session, so this behaves the same as
213pressing prefix-2 in your terminal, and the tab bar tracks what you do there.
214
215The active tab is drawn in the terminal's own background so the two read as one
216surface. The dot in its favicon slot is what Claude Code is doing in that
217window — amber and cycling for working, blue for waiting on you, green for
218finished with something you have not read — and stays
219empty for a window that is just a shell, rather than lighting up a status
220indicator with no status to report. A background window that has produced
221output since you last looked wears tmux's activity flag as a bolder name.
222
223A new window needs no name (tmux names it after what it runs), so **+** is one
224click with nothing to fill in.
225
226The **✕** closes a window (`kill-window`) on the first click, like a browser
227tab. Unlike a browser tab there is no undo — it kills whatever was running in
228that window — so it goes red under the pointer, and the log records what went.
229
230It only appears on the window you are on and the one you are pointing at, and
231never on a session's *last* window: that would take the session and the
232sidebar's own client with it, which is not a tab close.
233
234Right-clicking a tab offers **Pin**, **Select** and **Close window**.
235
236Pinning works like a browser's: the tab moves to the head of the strip and
237shrinks to its dot and index, and loses its ✕ so it can't be closed by a
238mis-aimed click. What it does *not* do is touch tmux. Nothing is renumbered, no
239`move-window` is sent, and your terminal's status line doesn't change — the
240window keeps its real index, which is why the index is the thing a pinned tab
241keeps showing: `prefix 3` still selects it, pinned or not. The gain is purely
242that a window you care about stays visible when the strip overflows, at about a
243quarter of the width.
244
245Pins live in extension storage, keyed by session name, and are dropped when the
246window they point at closes. They are per-browser-profile, not shared with
247anyone else attached to the session.
248
249## Pinning a session to a browser tab
250
251Right-clicking a session or a window tab also offers to **pin** it to the
252browser tab you are on. After that, switching to that browser tab switches the
253terminal: the tab you keep the app in brings up the session you run the app
254from, and the docs tab beside it brings back whatever you had there.
255
256This is a different thing from the **Pin** in the paragraph above, which is
257about where a tab sits in the strip. This one is about which browser tab brings
258it up. They are independent, and a window can have both.
259
260Nothing is focused when it does: the terminal moves underneath, and the caret
261stays on the page.
262
263### It is a loan, not a move
264
265A pin *borrows* the terminal. Leaving for a browser tab with no pin of its own
266puts it back where it was before the pinned tab took it, so flicking between a
267pinned tab and an unpinned one flicks the terminal between two places rather
268than stranding it on the pinned one. Without that, a single glance at a pinned
269tab would relocate the terminal permanently.
270
271Three rules keep that from being annoying:
272
273- Only the first pin in a run records a return. Pinned tab to pinned tab to
274 unpinned goes back to where the run *started*, not to the middle of it — the
275 middle was never somewhere you chose to be.
276- A pin that had nothing to do owes nothing. Landing on a tab pinned to where
277 you already are records no return, so leaving it moves nothing.
278- A terminal you have since moved by hand is left alone. The return is an undo
279 of a move this code made; once you have steered somewhere yourself there is
280 nothing to undo, and dragging you back would be the panel overruling you.
281
282Following also waits about a sixth of a second before it acts, so Ctrl-Tabbing
283through six tabs is one move at the end rather than six on the way.
284
285A pin can key on two things:
286
287- **the site**, e.g. `https://mdlab.localhost`. Any tab on that origin matches,
288 and the pin outlives the tab, the window and the browser, because an origin is
289 a name. This is the one to reach for.
290- **that exact browser tab**, by the id Chrome gave it. Survives nothing, and
291 exists for what an origin cannot express: two tabs on the same site pointing
292 at different sessions, or a page whose URL says nothing.
293
294A tab pin wins over a site pin, being the more specific statement.
295
296Pinning the **session** rather than one of its windows is usually what you want:
297it means "this tab brings up that project, wherever I left it", and it keeps
298working when you close and reopen the window the dev server was in. A window pin
299whose window has since been killed falls back to its session rather than going
300quietly dead.
301
302A small accent dot marks whichever row the tab on screen points at, so the panel
303moving on its own always has a visible reason. The tooltip says which rule
304answered.
305
306### And back the other way
307
308The same pin also reads backwards, so moving the terminal brings the browser
309along: switch to that session and the tab you pinned to it comes up. Anything
310that moves the terminal counts — a click on a session tab in the panel, a
311`prefix n` typed into the pane, another client switching a session this one is
312watching — because by the time it reaches the panel it is one status frame
313either way.
314
315This is the half that touches the browser, so it is deliberately timid, and it
316has its own checkbox (**and switch tabs back**) for turning off without giving
317up the forward direction:
318
319- It only ever **activates a tab that is already open**, in the panel's own
320 browser window. It does not create tabs, does not focus the browser, does not
321 raise a window and does not reach into another window. The worst it can do is
322 show you a tab you already had.
323- It does nothing when the tab already showing satisfies the pin. That is also
324 what stops the two directions chasing each other: the forward one will not
325 move a terminal that is already where the tab points, and this one will not
326 move a browser that is already on a tab pointing here, so whichever fires
327 second finds its work done.
328- Two tabs can answer to one pin — two tabs on the pinned origin — and the tie
329 goes first to the more specific rule and then to the one you looked at more
330 recently.
331- It waits the same sixth of a second, so holding `prefix n` through six windows
332 moves the browser once at the end rather than flicking it through five tabs on
333 the way.
334- The loan being handed back does not count as a move. Leaving a pinned tab puts
335 the terminal back where it was borrowed from, and if some third tab happens to
336 be pinned to *that* place this stays out of it rather than chasing it and
337 undoing the tab switch you just made by hand.
338
339It reads the pins you already have rather than a second set of its own, which
340means a veto keeps vetoing and a tab pin keeps outranking a site pin from this
341end too. There is nothing extra to set up: pin a session to a tab and both
342directions light up together.
343
344### Guessing from the name
345
346Under the explicit pins sits a guess, on by default and switchable in settings:
347a tab on a `.localhost` name that [portless][portless] would hand to a project
348we have a session in is treated as pinned to that session, without anyone saying
349so.
350
351portless takes the port out of the URL — a dev server started under it gets a
352random port and a stable `https://<name>.localhost`, where the name comes from
353`package.json`'s `name`, else the git root's directory name, else the working
354directory's, lowercased into a DNS label. In a git worktree the branch goes in
355front: `https://<branch>.<project>.localhost`.
356
357`extension/lib/portless.js` reimplements that naming and applies it to the
358working directories tmux already reports, so `https://mdlab.localhost` finds the
359session sitting in `~/Code/mdlab` without a subprocess, without reading
360`~/.portless/routes.json`, and without portless being installed or running. The
361price of matching names rather than looking them up is that a project whose
362`package.json` name is not its directory name is invisible to the guess — the
363panel cannot read `package.json`, and an explicit pin is the answer for those.
364
365The guess is deliberately timid. `.localhost` only, which is reserved for this
366machine, and an ambiguous match — two sessions answering to one name — resolves
367to nothing rather than a coin toss. A worktree host wants the worktree's own
368session (a directory called `<branch>`, `<project>-<branch>` or
369`<branch>-<project>`) and will not settle for the checkout it forked from.
370Unpinning a guessed match records a veto against that origin, which is the only
371way to say "no, not this one" to a rule that would otherwise keep re-deriving
372itself.
373
374[portless]: https://www.npmjs.com/package/portless
375
376Everything here is panel state: it lives in extension storage, it is
377per-browser-profile, and none of it reaches the daemon, which has never heard of
378a browser tab. It also only works while the panel is open — the panel is what
379watches the tabs.
380
381## How the daemon talks to tmux
382
383The interactive client in the pty is busy being a terminal, so the daemon
384attaches a *second* client in [control
385mode](https://github.com/tmux/tmux/wiki/Control-Mode) to use as a query and
386event channel:
387
388```
389tmux -C attach -t <session> -f read-only,ignore-size,no-output
390```
391
392Each flag is load-bearing. `read-only` means the channel can never send
393keystrokes to a pane. `ignore-size` stops an 80x24 control client from shrinking
394your windows to fit itself. `no-output` stops tmux streaming every byte every
395pane produces to a client with no use for it.
396
397tmux pushes `%client-session-changed`, `%sessions-changed`, `%window-renamed`
398and friends, so the sidebar updates when something happens rather than on a
399timer, and no `tmux` process is spawned per refresh.
400
401`read-only` governs keys, not commands, so the same channel carries the
402sidebar's requests. Those are a closed allowlist — switch to a session,
403create-and-switch, focus a pane, select a window, go to a window in another
404session, open a window, move a window, close a window, rename a session, set a
405session's colour —
406expressed as an enum, not a command string. The wire protocol cannot name a
407tmux command, and every argument is validated (`valid_session_name`,
408`valid_pane_id`, `valid_window_id`, `valid_group_color`) before it is quoted
409into a command line.
410
411The colour is the one piece of the panel's own state kept on the server rather
412than in the browser. It goes in a tmux user option, `@termbridge_color`, set on
413the session and read back as one more field of the `list-sessions` format the
414status frame is already built from — so it costs no extra round trip. Keeping it
415there rather than in extension storage means it follows a session through a
416rename, every panel on the server agrees on it, and it dies with the session.
417The value is a hue in degrees or `-1` for grey, and nothing else parses.
418
419The channel also sets one option on each session the sidebar's client lands on.
420tmux's default is `detach-on-destroy on`: exit the last shell of a session and
421every client attached to it is detached too. For a terminal emulator that just
422closes the window, but here it is EOF on the pty, so the socket closes and the
423whole panel goes dead even though other sessions are still running. The daemon
424switches it to `off`, which moves the client to another session instead and only
425falls back to detaching when there is nothing left to show. Only tmux's own
426default is overridden — `no-detached` and `previous` are deliberate choices with
427the same effect, and are left alone.
428
429Exactly one of them destroys anything, `kill-window`, and it can only ever name
430one window: a window id is `@` plus digits, so `-a` (which would kill every
431window *but* the target) and `session:` targets do not parse. There is no
432kill-session and no kill-pane.
433
434## Claude Code status
435
436The glyph on each window tab is what Claude Code is doing in that window. It is
437Claude Code's own asterisk spinner, so a window that is thinking looks in the
438tab strip the way it looks in the pane:
439
440| Glyph | Meaning |
441|---|---|
442| amber, cycling `· ✢ ✳ ∗ ✻ ✽` | working |
443| blue `✳`, pulsing | waiting on you |
444| green `✻`, pulsing | done, and you haven't looked yet |
445| grey `✻` | idle at the prompt |
446| faded `·` | Claude is there, but no hooks are installed for it |
447| nothing | no Claude in this window |
448
449One timer drives the whole strip and only runs while something is working, so
450an idle panel is not repainting forever. `prefers-reduced-motion` drops the
451pulse and parks the spinner on a single frame rather than dropping the
452indicator; the colours, which are what carry the meaning, stay.
453
454Joined on the tmux window id, so a window shows the loudest state in it —
455waiting beats done, and both beat working. The tab's tooltip carries the detail
456the dot can't: the tool in flight, or what Claude is blocked on.
457
458### Action required
459
460Green and grey are the same thing to Claude Code — a session sitting at its
461prompt. What separates them is whether that pane has been in front of you since
462it went quiet, which no hook can report: nothing in Claude's process knows which
463tmux pane a person is looking at. tmux does, so the daemon is where the two are
464put together.
465
466The rules, in full:
467
468- **Lit** for any Claude at rest in a pane that is not on screen. On screen
469 means the active pane, of the active window, of the session this daemon's
470 client is attached to — a tab in the strip is not a pane on screen.
471- **Cleared by looking.** Selecting the window clears it within the second, and
472 a pane you are already on never lights up in the first place.
473- **Re-armed by the next turn.** Sending a prompt puts the pane back to
474 `working`, and the rest after *that* is news again.
475
476It is a stamp per pane, not a flag: what is remembered is the `updated` time of
477the record you were shown, so any later hook event stops matching it and the
478pane goes back to unseen on its own. A pane id tmux recycles into a new pane
479can't inherit a stale mark for the same reason.
480
481The state lives in the daemon, so it is shared by every panel on that tmux
482server and survives a panel reload — but not a daemon restart, after which
483everything at rest reads as ready once. The tmux status line (below) doesn't
484take part: it runs as its own short-lived process with no view of the daemon's
485memory, and shows plain `idle`.
486
487There used to be a second row of per-pane chips under the header saying the
488same thing at more length. It was costing a terminal line to repeat what the
489tabs already show, so it's gone; the trade is that a window whose tab is
490scrolled out of a narrow panel no longer announces itself.
491
492This comes from [Claude Code's hook
493interface](https://code.claude.com/docs/en/hooks), not from reading the screen:
494Claude runs `termbridge hook` on `UserPromptSubmit`, `PreToolUse`,
495`PostToolUse`, `Notification`, `Stop`, `SessionStart` and `SessionEnd`, and each
496event's JSON tells us the state directly. `Notification` even distinguishes
497`permission_prompt` (blocked on you) from `idle_prompt` (just quiet).
498
499Install it once:
500
501```sh
502termbridge hooks # prints the block to merge into ~/.claude/settings.json
503```
504
505Each session's state lands in `~/.config/termbridge/agents/<session_id>.json`.
506The pane a session belongs to comes from `$TMUX_PANE`, which Claude's process
507inherits and passes to the hook — that is the join between a Claude session and
508a tmux pane. `permission_mode` is carried forward across the events that omit it
509(`Notification` is the one that matters: the mode must not blink out exactly
510when you are being asked to approve something).
511
512### The same glyph in tmux's own status line
513
514The sidebar reads its state over the daemon's control channel, which exists
515only while a browser is attached — exactly the case where you are not looking
516at the browser. So the terminal gets it from the other end: the hook already
517runs inside the pane it is reporting on, with `$TMUX` naming the right server,
518and it sets a window user option on the way out.
519
520```sh
521termbridge tmux # prints the ~/.tmux.conf lines
522```
523
524```tmux
525set -g window-status-format "#{@tb_claude}#I:#W#F"
526set -g window-status-current-format "#{@tb_claude}#I:#W#F"
527```
528
529The option holds a styled glyph and a space, and is unset — expanding to
530nothing — for a window with no Claude in it, so windows that never see one look
531exactly as they do now. A window with several Claudes in it shows the loudest,
532the same rule the tabs use: `list-panes` on the hook's own pane is the join.
533
534It advances one frame per hook event rather than on a timer. tmux only redraws
535its status when an option changes or `status-interval` elapses, so a true
536spinner would mean forcing a full status repaint on every attached client
537several times a second; stepping on events costs nothing and moves the glyph
538exactly when Claude crosses a tool boundary.
539
540The only command this issues is `set-option -w @tb_claude`. It cannot change a
541layout or send a key.
542
543### Titles from the last prompt
544
545Two panes running Claude in the same repo are indistinguishable by anything tmux
546knows about them: `#{pane_current_command}` is `node` in both and the cwd is the
547same. What tells them apart is what you asked each one to do, and
548`UserPromptSubmit` hands the hook exactly that.
549
550So the hook flattens the prompt to one line — whitespace collapsed, control
551characters dropped, cut to 80 characters at a word — stores it on the session's
552record, and writes it to the pane title:
553
554```sh
555tmux select-pane -T "fix the flaky pty test"
556```
557
558Pane title rather than window name, deliberately. `rename-window` would turn
559automatic renaming off for that window for good and overwrite a name you or your
560shell chose; the pane title is a field almost nothing else uses, and a window
561that wants to follow it opts in through `automatic-rename-format` — which also
562means the *active* pane is what names the window, for free:
563
564```tmux
565set -g automatic-rename-format \
566 "#{?#{==:#{pane_title},#{host}},#{pane_current_command},#{=/40/…:pane_title}}"
567```
568
569Both fallbacks are load bearing. tmux seeds a pane's title with the hostname, so
570"has no title of its own" has to be tested for rather than assumed empty, and a
571pane that never ran Claude keeps the name tmux would have given it anyway.
572Substitute your own format for `#{pane_current_command}` if you had one.
573
574`select-pane -T` expands its argument as a tmux format, so `#` in a prompt is
575doubled on the way in and a prompt containing `#{...}` or `#[fg=red]` is inert.
576
577The title is only ever set, never cleared: the last prompt is still the most
578useful thing to say about a pane that just finished running it, and a shell that
579emits an OSC title on each prompt takes the pane back on its own. The same
580string is what the sidebar shows for a session that has no name yet — Claude
581only names a session once it has thought of one, and until then "what you asked"
582beats "idle".
583
584## Opening and focusing the terminal
585
586**Alt+Shift+T** is one key for the whole cycle, and what it does depends on
587where the keyboard is:
588
589| Sidebar state | What the key does |
590|---|---|
591| Closed | Opens it |
592| Open, focus is on the page | Focuses the terminal |
593| Open, focus is in the terminal | Closes it |
594
595So it is hold-to-glance from the page and press-twice to dismiss, without ever
596reaching for the mouse. Rebindable in the same place as the picker shortcut:
597`chrome://extensions/shortcuts`, or `about:addons` → gear → Manage Extension
598Shortcuts.
599
600The sidebar has to be open for the browser to know its own state, which is why
601the panel keeps a connection to the background worker while it lives. That
602connection carries three commands (open, focus, close) and nothing else — no
603terminal traffic goes through it, for the reason in the header of `sidebar.js`.
604
605## Keys the browser takes first
606
607Most browser shortcuts are cancelable: the page sees the keydown, calls
608`preventDefault()`, and the browser drops it. That is why Ctrl+P, Ctrl+F,
609Ctrl+S and friends reach the pty like any other key.
610
611Three do not. **Ctrl+N**, **Ctrl+T** and **Ctrl+W** are *reserved*: the browser
612process acts on them before the keystroke is handed to the renderer, so the
613panel never receives an event and has nothing to cancel. Extension commands
614cannot claim them either. `chrome://extensions/shortcuts` refuses reserved
615combinations for the same reason. Nothing inside the extension can win them
616back.
617
618So they are offered one modifier over, and only while the terminal has focus:
619
620| Press | Sent to the pty |
621|---|---|
622| **Ctrl+Alt+N** | `^N` |
623| **Ctrl+Alt+T** | `^T` |
624| **Ctrl+Alt+W** | `^W` |
625
626The bytes are written straight to the socket, so tmux and readline see exactly
627what a real Ctrl+N would have produced.
628
629**If you want the real keycap.** The browser cannot be talked out of Ctrl+N,
630but a remapper below it can make sure the browser never sees the key. Bind
631Ctrl+N to Ctrl+Alt+N and the panel picks it up as above:
632
633- [`xremap`](https://github.com/xremap/xremap) can do it per-application, so
634 Ctrl+N still opens a window everywhere else. On GNOME/Wayland it needs the
635 companion shell extension to know which window is focused; on X11 it does
636 not.
637- [`keyd`](https://github.com/rvaiya/keyd) sits at evdev and is simpler to run,
638 but it is system-wide: Ctrl+N stops meaning "new" in every other app too.
639
640Both are outside this repo, and neither is required. Ctrl+Alt+N works on its
641own.
642
643## Picking elements off the page
644
645Two ways to start it, and the difference matters:
646
647| | |
648|---|---|
649| **Alt+Shift+P** | Always works. Rebindable at `chrome://extensions/shortcuts` or `about:addons` → gear → Manage Extension Shortcuts |
650| Crosshair button in the header | Convenient, but may fail — see below |
651
652Then: hover highlights the element under the cursor with its tag and size, click
653selects. Clicks are swallowed, so picking a link or a submit button doesn't
654navigate or submit.
655
656To get out: **Esc** (from either the page or the sidebar), or press the
657crosshair again — it toggles. Esc in one half of a split view ends the whole
658pick, not just that half's overlay.
659
660**Split view.** Chrome puts two tabs side by side but marks only one of them
661`active`, so aiming at "the active tab" always lands in whichever half last had
662focus — the other half looks dead. The picker instead runs in *both* halves at
663once (found via `splitViewId`, Chrome 140+) and takes the first click; the loser
664is torn down. The half you clicked is made active before the screenshot,
665because `tabs.captureVisibleTab` takes no tab id and shoots whatever is active.
666One caveat on the **Alt+Shift+P** path: the `activeTab` grant the shortcut mints
667covers the active half only, so a pick in the *other* half needs that origin
668granted (see below) — otherwise that half sits out and the active one still
669works. Firefox has no split view and no `splitViewId`; it takes the single-tab
670path.
671
672Picking sends the result straight to the terminal, screenshot first:
673
6741. The element's box is cropped out of a screenshot of the tab and put on the
675 system clipboard as a PNG.
6762. A literal **^V** goes down the wire. That byte is not a paste in the panel —
677 xterm.js never sees it — it reaches the program in the pty, and an agent that
678 handles image paste (Claude Code does) reads the clipboard and attaches the
679 PNG itself.
6803. The selector follows, control-character stripped and single-quoted, with no
681 trailing newline. You press Enter yourself.
682
683This works because the daemon runs on the same machine as the browser, so the
684clipboard the extension writes is the clipboard the agent reads. In a bare shell
685^V is literal-next-character instead, so it is only sent when a screenshot was
686actually captured; if the capture fails, only the selector is inserted and the
687reason is logged.
688
689**Where the keyboard ends up.** In the page, where you just clicked — the
690selector is typed into a terminal you have to click into before you can type
691there yourself. Moving the keyboard into the panel was tried and does not work:
692the only way to focus a side panel is for Chrome to *open* it, which means
693closing and reopening it (see *Opening and focusing the terminal*), and even
694driven from the pick click's own user activation Chrome reopens the panel
695without handing it the keyboard. All that bought was a flicker, so the pick
696leaves the focus where it finds it.
697
698The panel still shows the element afterwards: CSS selector, XPath, `id`, test
699id, text, or `href`. **copy** puts the selected one on the clipboard, and
700**insert** re-types it (text only — the screenshot is already on the clipboard
701if you want it again).
702
703**Why the button can fail.** Touching a page needs `activeTab`, which the
704browser grants only on certain user gestures — a toolbar click, a context menu,
705or a keyboard command. A click inside the side panel is not one of them, so the
706button falls back to standing host permissions. Those are:
707
708- **localhost is granted up front** — `localhost`, `*.localhost` and `127.0.0.1`
709 on both schemes. Match patterns carry no port, so `:5173`, `:3000` and
710 everything else are covered.
711- **Every other site is opt-in, one origin at a time.** When the button hits a
712 site it can't reach, it offers an *Allow https://example.com/\** button that
713 triggers the browser's own permission prompt. Nothing is granted until you say
714 so.
715
716The shortcut needs none of this — it runs in the background worker, which is a
717gesture the browser does honour, and works on any page.
718
719**Why the button can pick but not screenshot.** `tabs.captureVisibleTab` accepts
720exactly two things: the `activeTab` grant a keyboard command mints, or a host
721permission set containing the literal `<all_urls>` pattern. A per-origin grant is
722enough to read the element but not to capture it, so the button hands back a
723selector and no image even on a site you approved. When that happens the panel
724offers an *Allow screenshots on all sites* button, which requests `<all_urls>`;
725it is optional and revocable in the extension's settings, and Alt+Shift+P keeps
726capturing without it.
727
728Picks made while the sidebar is closed are parked in `storage.local` and appear
729when you next open it.
730
731Nothing is inserted automatically — see below for why that matters.
732
733## Security model
734
735This daemon hands out shell access. It is `sshd` with a smaller feature set, and
736is treated that way.
737
738**Threats it stops**
739
740| Attacker | Defense |
741|---|---|
742| Any webpage you visit (WebSocket has no CORS — every page can reach 127.0.0.1) | Origin allowlist; `Origin` is browser-set and unforgeable from page JS |
743| `evil.com` rebound to 127.0.0.1 | `Host` header must be loopback |
744| Another user on the machine | Token in a `0600` file, `0700` dir; refuses to load if the mode loosens |
745| The network | Binds `127.0.0.1` only, not configurable |
746| Token brute-force | Constant-time compare, lockout after repeated failures |
747| Firefox HTTPS-Only Mode breaking the connection | Serves TLS and plaintext on one port; no browser setting has to be weakened |
748| A page injecting shell commands via the element picker | Picked text is control-character stripped and single-quoted before it can reach the pty |
749| A page reading the clipboard after the picker writes a screenshot to it | The write happens in the extension's isolated world; the page could already read its own clipboard, and the image is a picture of the page itself |
750
751**Threats it does not stop**
752
753A process running as *you* can already read `~/.ssh`, patch `~/.bashrc`, and
754ptrace your browser. Same-uid isolation is not a thing, and pretending otherwise
755would be theatre.
756
757**Design rules**
758
759- Token travels in a post-upgrade frame, never the URL — query strings leak into
760 logs, crash dumps, and devtools history.
761- Pairing is explicit. No trust-on-first-use, no blanket `chrome-extension://`
762 prefix match (that would admit every other extension you have installed).
763- The extension requests `storage` and `sidePanel`. No host permissions, no
764 content scripts, no `web_accessible_resources`, no `externally_connectable` —
765 so there is no bridge from page content to the socket.
766- The sidebar owns the WebSocket directly. Terminal data never passes through
767 `runtime.sendMessage`, which content scripts can reach.
768- The client cannot choose what runs. The protocol has no argv field, so an auth
769 bypass yields the configured profile rather than arbitrary exec.
770- TLS changes no policy: origin, host and token are enforced identically over
771 `wss://`. The private key is `0600` and refused if the mode loosens, and the
772 certificate is a leaf with `CA:FALSE` — trusting it cannot be leveraged to
773 vouch for any other host.
774- **The element picker treats the page as hostile.** A selector is built from
775 attributes the page chose, so an `id` of `x'; rm -rf ~; '` or an `aria-label`
776 containing a newline is arbitrary code execution — a newline typed at a
777 terminal is a pressed Enter. Two independent defenses, both required: strip
778 every C0 control character, then POSIX single-quote. `insert` never appends a
779 newline; you press Enter yourself. The UI says so when a value had to be
780 modified.
781- The picker's standing page access is limited to loopback hosts, which are your
782 own machine. Everything else is `optional_host_permissions`, granted per-origin
783 through the browser's prompt and revocable in the extension's settings — never
784 a blanket `<all_urls>` at install time. `<all_urls>` is offered as an optional
785 permission too, because the screenshot API takes nothing narrower, but only
786 when a capture has already failed and only behind an explicit button. Still no
787 declared content scripts and no `web_accessible_resources`.
788- The picker returns its result as the `executeScript` **return value**, not via
789 `runtime.sendMessage` — so the sidebar still has no inbound message listener
790 that a content script could reach.
791- tmux session names are client-selectable but validated: no leading dash (tmux
792 would read it as a flag), and `[A-Za-z0-9_-]` only. They reach `execvp` as a
793 separate argv element, never a shell. An invalid name falls back to the
794 default rather than erroring.
795- The HTTPS landing page exists only so the certificate-trust visit is
796 comprehensible. It serves no data and, unlike the implementation we looked at,
797 there is no endpoint anywhere that hands out the auth token.
798
799Every one of those is covered by a test, and the suite is mutation-checked:
800reverting the origin check to "allow any origin" turns 5 tests red.
801
802```sh
803cd daemon && cargo test # 46
804npm test # 25
805npm run check # types, see below
806```
807
808The sanitizer tests don't just assert on strings — they hand the quoted output
809to a real `/bin/sh` and check it comes back as one literal argument, including
810for `x'; rm -rf ~; echo '` and `harmless\nid`.
811
812The theme tests compute WCAG contrast ratios for all 16 ANSI slots against their
813own background, plus body text, muted text, buttons and selection. They already
814earned their keep: `brightBlack` landed at exactly 3.00:1 on the dark background
815— the slot most tools use for comments — and was corrected.
816
817## Types without a build step
818
819The extension is plain `.js` that the browser loads exactly as it sits on disk
820— no bundler, no transpile, `build.sh` is a `cp`. That stays true. What was
821added is a type *checker* over it: JSDoc annotations plus `npm run check`,
822which runs `tsc --noEmit` and emits nothing. Editors pick the same config up
823automatically and give completion on the daemon's frames.
824
825```sh
826npm install # one dev dependency: typescript
827npm run check
828```
829
830Two projects, because the manifest creates two global scopes and both declare
831`const api` at the top level:
832
833| Config | Realm | Files |
834|---|---|---|
835| `extension/jsconfig.json` | sidebar document | `sidebar.js`, `picker.js`, `lib/` |
836| `extension/jsconfig.sw.json` | service worker | `sw.js`, `picker.js`, `lib/shot.js`, `lib/split.js` |
837
838`extension/types/globals.d.ts` is hand-written rather than pulled from
839`@types/chrome`, so it doubles as the inventory of extension API surface this
840extension touches — adding a call means adding it there first, which is the
841same conversation as growing the manifest's permission list. It also carries
842the daemon's wire frames (`TbOkFrame`, `TbStatusFrame`, `TbSessionInfo`,
843`TbAgent`); the other half of those shapes is `daemon/src/status.rs`, and the
844two have to move together.
845
846Nothing in `node_modules/` reaches `dist/`.
847
848## Known gotchas
849
850**tmux resizes for everyone.** Default `window-size` is `latest`, so attaching a
851narrow sidebar shrinks the same session in your real terminal. Verified: a
852100x30 window became 40x20 on sidebar attach. Either give the sidebar its own
853session, or:
854
855```sh
856setw -g window-size manual
857```
858
859**Firefox temporary add-ons** get a new UUID per install, so re-pair after each
860reload.
861
862## Layout
863
864```
865daemon/src/paths.rs token + paired-origin files, permission enforcement
866daemon/src/auth.rs origin / host / token checks
867daemon/src/server.rs listener, handshake, session pump
868daemon/src/pty.rs portable-pty backend
869daemon/src/tls.rs self-signed cert generation, rustls config
870daemon/src/rewind.rs replayable stream, so we can inspect the request head
871daemon/src/activation.rs taking the listening socket systemd passed us
872daemon/src/install.rs the systemd units / launchd agent `install` writes
873daemon/tests/ security.rs (28), tls.rs (10), pty_e2e.rs (8),
874 activation.rs (5)
875extension/picker.js injected element picker (no privileges, runs in page)
876extension/lib/ sanitize.js (page-text-to-shell boundary), theme.js
877 (light/dark palettes), split.js (running the picker in
878 both halves of a split view) — all three with tests;
879 shot.js (element screenshot crop, clipboard write)
880extension/types/ hand-written ambients: extension API surface, the
881 daemon's wire frames, the vendored xterm build
882extension/ sidebar, two manifests, vendored xterm.js
883```
884
885## Not done yet
886
887- Firefox reaches the daemon (confirmed: HTTPS-Only Mode was rewriting the
888 scheme, which is why TLS exists). Not yet confirmed end-to-end after trusting
889 the certificate.
890- Chrome has not been loaded at all; whether MV3 needs anything in
891 `host_permissions` or CSP `connect-src` is still unverified.
892- No packaging: there's no distributable build, though `termbridge install` now
893 covers starting the daemon (socket activation on Linux, a login agent on
894 macOS).