anvilsign in

collin/browser-terminal-extension · ea0845f8

Add install/activation, omnibar navigation, and touch scrolling

Collin Richards · 2026-08-17 14:03 UTC · ea0845f8f1bbbfee8d4d3888ce10ee78e7fbe73b · parent b9b5b120 · browse files

modified.gitignore+4 −0
11 node_modules/
22 dist/
3+
4+# Leftovers from patch/merge tools, never source.
5+*.orig
6+*.rej
addedCLAUDE.md+74 −0
1+# CLAUDE.md
2+
3+A tmux sidebar for Chrome and Firefox. Two halves that ship separately:
4+
5+- `extension/` is the browser extension (named **terminal**). Plain files, no
6+ bundler: the browser loads the `.js`/`.css`/`.html` on disk as they are.
7+- `daemon/` is `termbridge`, a Rust CLI that serves a WebSocket on
8+ `127.0.0.1:7681`, spawns a pty, and runs tmux inside it. tmux, not the daemon,
9+ is the persistence layer.
10+
11+See `README.md` for protocol, pairing, TLS, and the systemd socket activation
12+story. This file is the build and edit loop.
13+
14+## Build
15+
16+```sh
17+./build.sh # extension -> dist/chrome and dist/firefox
18+./run.sh # daemon: cargo build --release, then `termbridge serve`
19+```
20+
21+**`build.sh` copies files into `dist/`.** The browser loads `dist/chrome` (or
22+`dist/firefox`), never `extension/`. So an edit under `extension/` has no effect
23+until `./build.sh` runs and the sidebar (or the extension) is reloaded. This is
24+the most common reason a change "didn't work".
25+
26+`build.sh` copies an explicit list of files. A new file under `extension/` or
27+`extension/lib/` will not reach `dist/` until it is added to that list.
28+`extension/mock.html` is deliberately excluded: it renders the sidebar header in
29+a plain tab for layout work, outside the extension.
30+
31+## Checks
32+
33+```sh
34+npm test # node --test over extension/lib/*.test.js
35+npm run check # tsc over both jsconfig projects, noEmit
36+cd daemon && cargo test
37+```
38+
39+`npm run check` is type checking only, never a build step. The extension files
40+are classic scripts sharing one global scope (`sidebar.js` reaches `Themes`,
41+`Sanitize`, `tbPickElement` with no imports), so nothing under `extension/` may
42+introduce `import`/`export` syntax. There are two tsconfig projects because
43+there are two global scopes: `jsconfig.json` (sidebar document) and
44+`jsconfig.sw.json` (service worker), both extending `jsconfig.base.json`.
45+
46+Daemon tests use `/bin/sh` rather than tmux where they can, and skip themselves
47+when tmux is missing; the tmux ones use a private socket so they never touch
48+your real sessions.
49+
50+## Daemon edit loop
51+
52+If the daemon is installed as a user service (`termbridge install`), a rebuilt
53+binary is picked up on the next activation, but only once the running one exits:
54+
55+```sh
56+cd daemon && cargo build --release
57+termbridge reload
58+```
59+
60+Re-run `termbridge install` instead when the change affects what the unit
61+encodes (port, idle timeout, pinned session, binary path).
62+
63+## Conventions
64+
65+- Both manifests (`manifest.chrome.json`, `manifest.firefox.json`) are hand
66+ maintained. A permission or file change usually has to land in both.
67+- `extension/lib/theme.js` is the single source of truth for both palettes: the
68+ `ui` block becomes CSS custom properties, the `xterm` block goes to
69+ `term.options.theme`. Do not hardcode colors in `sidebar.css`; use the
70+ variables.
71+- `sidebar.css` carries long comments explaining *why* a rule exists (the tab
72+ silhouette, the separators, the seam the active tab covers). Keep that style
73+ when editing it, and read the surrounding comment before changing a value.
74+- `extension/vendor/` is vendored xterm.js. Do not edit it.
modifiedREADME.md+209 −30
⋯ 13 unchanged lines
1414 │ ─── {"type":"auth","token":"…"} ──────▶ │ origin + host + token
1515 │ ◀── {"type":"ok"} ───────────────────── │
1616 │ ─── {"type":"open","cols":80,…} ──────▶ │ spawn pty
17- │ ═══ binary frames (raw bytes) ════════▶ │ ──▶ tmux new-session -A -s browser
17+ │ ═══ binary frames (raw bytes) ════════▶ │ ──▶ tmux new-session -A -s default
1818 │ ◀══ binary frames (raw bytes) ═════════ │
1919 │ ─── {"type":"resize","cols":…} ───────▶ │ TIOCSWINSZ
2020 ```
⋯ 37 unchanged lines
5858 regenerated on each temporary install, so you'll re-pair each time until the
5959 add-on is signed.
6060
61+## Starting the daemon on demand
62+
63+Remembering to run `termbridge serve` before opening the sidebar is the worst
64+part of the setup above. Hand it to the service manager instead:
65+
66+```sh
67+termbridge install # then never think about it again
68+```
69+
70+On 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
72+sidebar actually connects, inheriting the already-bound socket as fd 3. Fifteen
73+minutes after the last client disconnects the daemon exits again, and the next
74+connection starts a fresh one. So at rest there is no process, only a socket.
75+
76+That is safe *only* because tmux, not the daemon, is the persistence layer.
77+Exiting drops no session state, which is the same property that lets you close
78+the sidebar and reattach later. If tmux is unavailable and the daemon falls back
79+to a plain shell, do not install with an idle timeout — the fallback shell dies
80+with the daemon.
81+
82+```sh
83+termbridge install --idle-timeout 0 # stay resident once started
84+termbridge install --port 7999 --session work
85+termbridge reload # restart the daemon after a rebuild
86+termbridge uninstall
87+systemctl --user status termbridge.service
88+journalctl --user -u termbridge.service -f
89+```
90+
91+### Updating the daemon
92+
93+The unit points at the binary by absolute path, so a rebuild is picked up by the
94+next activation — but only once the daemon holding the old code goes away:
95+
96+```sh
97+cargo build --release
98+termbridge reload # socket keeps listening
99+```
100+
101+The next sidebar connection starts the new binary. Nothing else is needed, and
102+if 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
106+nothing else — it never rewrites a unit, so a port, idle timeout or pinned
107+session 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
109+everything running in them belong to the tmux server, so all a reload costs is
110+the moment the sidebar takes to reconnect.
111+
112+If you changed anything the *unit* encodes (port, idle timeout, session, or
113+where the binary lives), re-run `termbridge install` instead. It rewrites both
114+units, stops the running daemon, and restarts the socket, in that order. The
115+order is the whole trick: `enable --now` is a no-op on an already-active socket,
116+so a reinstall without the restart leaves the old configuration listening while
117+systemd logs "Unit configuration changed while unit was running ... Unit not
118+functional until restarted" and the port quietly stops accepting. And the socket
119+cannot be restarted before the daemon is stopped, because the daemon is still
120+holding the port.
121+
122+`systemctl stop` prints "Stopping termbridge.service, but its triggering units
123+are still active" every time. That is systemd describing socket activation back
124+to you, not a problem.
125+
126+The units land in `~/.config/systemd/user/`, and re-running `install` overwrites
127+them, so edit freely and expect to lose it on upgrade. `install` also runs
128+`systemctl --user import-environment PATH`, because a user service otherwise
129+inherits the manager's PATH rather than your shell's, and the usual symptom is
130+the daemon reporting "tmux not found" while `tmux` works fine in every terminal
131+you have open.
132+
133+On macOS `install` writes a launchd agent that runs at login and stays resident.
134+launchd can do socket activation too, but only through
135+`launch_activate_socket(3)`, so the on-demand half is Linux-only for now.
136+
137+Nothing about this changes the protocol: `termbridge serve` by hand still works
138+and still binds its own socket. `--systemd-socket` is what switches it to the
139+inherited one, and it's an error rather than a fallback if no socket arrives —
140+binding a second port would leave the sidebar talking to a daemon nobody dialed.
141+
61142 ## Theme
62143
63144 The **◐** button in the header cycles *follow system → light → dark*, and the ⚙
⋯ 27 unchanged lines
91172 By default the sidebar joins the tmux session you already have running, when
92173 there is exactly one — no point starting a second session beside the only one
93174 you are using. With none, or with several, it attaches to a session called
94-`browser` and creates it if needed. The check happens per connection, so it
175+`default` and creates it if needed. The check happens per connection, so it
95176 reflects what tmux holds when the sidebar connects, not when the daemon
96177 started.
97178
⋯ 3 unchanged lines
101182 termbridge serve --session my-existing-work
102183 ```
103184
104-Or pick it live: the dropdown at the left of the header lists every session on
105-the server. Choosing one runs `switch-client` on the sidebar's own tmux client
106-— the WebSocket stays up, no second pty is spawned, and whatever is running in
107-the session you left keeps running. The ⚙ panel's **tmux session** field is the
108-way to reach a session that doesn't exist yet: it creates-or-attaches and
109-switches to it.
185+Or pick it live: the header's top row is one tab per session on the server.
186+Clicking one runs `switch-client` on the sidebar's own tmux client — the
187+WebSocket stays up, no second pty is spawned, and whatever is running in the
188+session you left keeps running. **+** names a new one inline (`new-session -A`,
189+so an existing name attaches instead of failing), and the ⚙ panel's **tmux
190+session** field does the same from settings.
191+
192+A session tab carries the number of windows behind it, and in its favicon slot
193+the loudest thing Claude Code is doing anywhere inside it — a Claude waiting on
194+you in a session you are not looking at still gets a light. That is what the
195+daemon's whole-server status frames are for.
110196
111-The dropdown shows the session you are *actually* on, not the one you asked
197+The selected tab is the session you are *actually* on, not the one you asked
112198 for, so a `switch-client`, `choose-tree` or prefix-`(`/`)` typed in the terminal
113-updates it too.
199+moves it too.
114200
115201 Both names are validated server-side — see the security notes below.
116202
117203 ## Window tabs
118204
119-The rest of the header is a browser-style tab bar, one tab per **window** in the
120-attached session — the same windows `prefix 2` selects and the tmux status line
121-lists. Clicking one runs `select-window`, and **+** runs `new-window`. Neither
122-touches the connection: the pty, the session and everything running in it stay
123-exactly as they were.
205+The header's second row is a browser-style tab bar nested under the session
206+tabs: 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:
209+the pty, the session and everything running in it stay exactly as they were.
124210
125211 Selecting a window deliberately moves *every* client watching that session, not
126212 just the sidebar — a window belongs to the session, so this behaves the same as
⋯ 1 unchanged line
128214
129215 The active tab is drawn in the terminal's own background so the two read as one
130216 surface. The dot in its favicon slot is what Claude Code is doing in that
131-window — amber and pulsing for working, blue for waiting on you — and stays
217+window — amber and cycling for working, blue for waiting on you, green for
218+finished with something you have not read — and stays
132219 empty for a window that is just a shell, rather than lighting up a status
133220 indicator with no status to report. A background window that has produced
134221 output since you last looked wears tmux's activity flag as a bolder name.
⋯ 46 unchanged lines
181268
182269 `read-only` governs keys, not commands, so the same channel carries the
183270 sidebar's requests. Those are a closed allowlist — switch to a session,
184-create-and-switch, focus a pane, select a window, open a window, close a window
185-— expressed as an enum, not a command string. The wire protocol cannot name a
271+create-and-switch, focus a pane, select a window, go to a window in another
272+session, open a window, move a window, close a window, rename a session, set a
273+session's colour —
274+expressed as an enum, not a command string. The wire protocol cannot name a
186275 tmux command, and every argument is validated (`valid_session_name`,
187-`valid_pane_id`, `valid_window_id`) before it is quoted into a command line.
276+`valid_pane_id`, `valid_window_id`, `valid_group_color`) before it is quoted
277+into a command line.
278+
279+The colour is the one piece of the panel's own state kept on the server rather
280+than in the browser. It goes in a tmux user option, `@termbridge_color`, set on
281+the session and read back as one more field of the `list-sessions` format the
282+status frame is already built from — so it costs no extra round trip. Keeping it
283+there rather than in extension storage means it follows a session through a
284+rename, every panel on the server agrees on it, and it dies with the session.
285+The value is a hue in degrees or `-1` for grey, and nothing else parses.
188286
287+The channel also sets one option on each session the sidebar's client lands on.
288+tmux's default is `detach-on-destroy on`: exit the last shell of a session and
289+every client attached to it is detached too. For a terminal emulator that just
290+closes the window, but here it is EOF on the pty, so the socket closes and the
291+whole panel goes dead even though other sessions are still running. The daemon
292+switches it to `off`, which moves the client to another session instead and only
293+falls back to detaching when there is nothing left to show. Only tmux's own
294+default is overridden — `no-detached` and `previous` are deliberate choices with
295+the same effect, and are left alone.
296+
189297 Exactly one of them destroys anything, `kill-window`, and it can only ever name
190298 one window: a window id is `@` plus digits, so `-a` (which would kill every
191299 window *but* the target) and `session:` targets do not parse. There is no
⋯ 8 unchanged lines
200308 | Glyph | Meaning |
201309 |---|---|
202310 | amber, cycling `· ✢ ✳ ∗ ✻ ✽` | working |
203-| blue `✳` | waiting on you |
311+| blue `✳`, pulsing | waiting on you |
312+| green `✻`, pulsing | done, and you haven't looked yet |
204313 | grey `✻` | idle at the prompt |
205314 | faded `·` | Claude is there, but no hooks are installed for it |
206315 | nothing | no Claude in this window |
207316
208317 One timer drives the whole strip and only runs while something is working, so
209-an idle panel is not repainting forever. `prefers-reduced-motion` parks the
210-glyph on a single frame rather than dropping the indicator.
318+an idle panel is not repainting forever. `prefers-reduced-motion` drops the
319+pulse and parks the spinner on a single frame rather than dropping the
320+indicator; the colours, which are what carry the meaning, stay.
211321
212322 Joined on the tmux window id, so a window shows the loudest state in it —
213-waiting beats working. The tab's tooltip carries the detail the dot can't: the
214-tool in flight, or what Claude is blocked on.
323+waiting beats done, and both beat working. The tab's tooltip carries the detail
324+the dot can't: the tool in flight, or what Claude is blocked on.
325+
326+### Action required
327+
328+Green and grey are the same thing to Claude Code — a session sitting at its
329+prompt. What separates them is whether that pane has been in front of you since
330+it went quiet, which no hook can report: nothing in Claude's process knows which
331+tmux pane a person is looking at. tmux does, so the daemon is where the two are
332+put together.
333+
334+The rules, in full:
215335
336+- **Lit** for any Claude at rest in a pane that is not on screen. On screen
337+ means the active pane, of the active window, of the session this daemon's
338+ client is attached to — a tab in the strip is not a pane on screen.
339+- **Cleared by looking.** Selecting the window clears it within the second, and
340+ a pane you are already on never lights up in the first place.
341+- **Re-armed by the next turn.** Sending a prompt puts the pane back to
342+ `working`, and the rest after *that* is news again.
343+
344+It is a stamp per pane, not a flag: what is remembered is the `updated` time of
345+the record you were shown, so any later hook event stops matching it and the
346+pane goes back to unseen on its own. A pane id tmux recycles into a new pane
347+can't inherit a stale mark for the same reason.
348+
349+The state lives in the daemon, so it is shared by every panel on that tmux
350+server and survives a panel reload — but not a daemon restart, after which
351+everything at rest reads as ready once. The tmux status line (below) doesn't
352+take part: it runs as its own short-lived process with no view of the daemon's
353+memory, and shows plain `idle`.
354+
216355 There used to be a second row of per-pane chips under the header saying the
217356 same thing at more length. It was costing a terminal line to repeat what the
218357 tabs already show, so it's gone; the trade is that a window whose tab is
⋯ 50 unchanged lines
269408 The only command this issues is `set-option -w @tb_claude`. It cannot rename a
270409 window, change a layout, or send a key.
271410
411+## Opening and focusing the terminal
412+
413+**Alt+Shift+T** is one key for the whole cycle, and what it does depends on
414+where the keyboard is:
415+
416+| Sidebar state | What the key does |
417+|---|---|
418+| Closed | Opens it |
419+| Open, focus is on the page | Focuses the terminal |
420+| Open, focus is in the terminal | Closes it |
421+
422+So it is hold-to-glance from the page and press-twice to dismiss, without ever
423+reaching for the mouse. Rebindable in the same place as the picker shortcut:
424+`chrome://extensions/shortcuts`, or `about:addons` → gear → Manage Extension
425+Shortcuts.
426+
427+The sidebar has to be open for the browser to know its own state, which is why
428+the panel keeps a connection to the background worker while it lives. That
429+connection carries three commands (open, focus, close) and nothing else — no
430+terminal traffic goes through it, for the reason in the header of `sidebar.js`.
431+
272432 ## Picking elements off the page
273433
274434 Two ways to start it, and the difference matters:
⋯ 8 unchanged lines
283443 navigate or submit.
284444
285445 To get out: **Esc** (from either the page or the sidebar), or press the
286-crosshair again — it toggles.
446+crosshair again — it toggles. Esc in one half of a split view ends the whole
447+pick, not just that half's overlay.
287448
449+**Split view.** Chrome puts two tabs side by side but marks only one of them
450+`active`, so aiming at "the active tab" always lands in whichever half last had
451+focus — the other half looks dead. The picker instead runs in *both* halves at
452+once (found via `splitViewId`, Chrome 140+) and takes the first click; the loser
453+is torn down. The half you clicked is made active before the screenshot,
454+because `tabs.captureVisibleTab` takes no tab id and shoots whatever is active.
455+One caveat on the **Alt+Shift+P** path: the `activeTab` grant the shortcut mints
456+covers the active half only, so a pick in the *other* half needs that origin
457+granted (see below) — otherwise that half sits out and the active one still
458+works. Firefox has no split view and no `splitViewId`; it takes the single-tab
459+path.
460+
288461 Picking sends the result straight to the terminal, screenshot first:
289462
290463 1. The element's box is cropped out of a screenshot of the tab and put on the
⋯ 149 unchanged lines
440613 | Config | Realm | Files |
441614 |---|---|---|
442615 | `extension/jsconfig.json` | sidebar document | `sidebar.js`, `picker.js`, `lib/` |
443-| `extension/jsconfig.sw.json` | service worker | `sw.js`, `picker.js`, `lib/shot.js` |
616+| `extension/jsconfig.sw.json` | service worker | `sw.js`, `picker.js`, `lib/shot.js`, `lib/split.js` |
444617
445618 `extension/types/globals.d.ts` is hand-written rather than pulled from
446619 `@types/chrome`, so it doubles as the inventory of extension API surface this
⋯ 28 unchanged lines
475648 daemon/src/pty.rs portable-pty backend
476649 daemon/src/tls.rs self-signed cert generation, rustls config
477650 daemon/src/rewind.rs replayable stream, so we can inspect the request head
478-daemon/tests/ security.rs (28), tls.rs (10), pty_e2e.rs (8)
651+daemon/src/activation.rs taking the listening socket systemd passed us
652+daemon/src/install.rs the systemd units / launchd agent `install` writes
653+daemon/tests/ security.rs (28), tls.rs (10), pty_e2e.rs (8),
654+ activation.rs (5)
479655 extension/picker.js injected element picker (no privileges, runs in page)
480656 extension/lib/ sanitize.js (page-text-to-shell boundary), theme.js
481- (light/dark palettes) — both with tests; shot.js
482- (element screenshot crop, clipboard write)
657+ (light/dark palettes), split.js (running the picker in
658+ both halves of a split view) — all three with tests;
659+ shot.js (element screenshot crop, clipboard write)
483660 extension/types/ hand-written ambients: extension API surface, the
484661 daemon's wire frames, the vendored xterm build
485662 extension/ sidebar, two manifests, vendored xterm.js
⋯ 6 unchanged lines
492669 the certificate.
493670 - Chrome has not been loaded at all; whether MV3 needs anything in
494671 `host_permissions` or CSP `connect-src` is still unverified.
495-- No packaging: the daemon is started by hand, and there's no systemd unit.
672+- No packaging: there's no distributable build, though `termbridge install` now
673+ covers starting the daemon (socket activation on Linux, a login agent on
674+ macOS).
modifiedTODO.md+9 −1
1-- when opening and disconnected in the browser extension, the extension should display to the user the command they need to run to start the service in their terminal
1+- [ ] Idea: pull off a tab out side of the browser and it puts it within your default terminal emulator
2+- [ ] TODO: get working well on unconfigured tmux
3+- [ ] TODO: investigate chrome wterm and vercel wterm
4+- [ ] Idea: pinned tab / session mode
5+- [ ] send to claude should auto name window using llm
6+- [ ] typing cd in omnibar should have a ui for selecting dir
7+ - this will change the directory of the current tmux session
8+- [ ] be able to talk to claude about anything on the page
9+- [ ] I notice that ctrl-shift-l focuses our omni bar properly when used within our extension, but when the page is focused, using ctrl-shift-l doesn't properly select the text in our omnibar
modifiedbuild.sh+1 −1
⋯ 6 unchanged lines
77 out="dist/$browser"
88 rm -rf "$out"; mkdir -p "$out"
99 cp extension/sidebar.html extension/sidebar.js extension/sidebar.css extension/picker.js "$out/"
10- mkdir -p "$out/lib" && cp extension/lib/sanitize.js extension/lib/theme.js extension/lib/shot.js "$out/lib/"
10+ mkdir -p "$out/lib" && cp extension/lib/sanitize.js extension/lib/theme.js extension/lib/shot.js extension/lib/split.js "$out/lib/"
1111 cp -r extension/vendor extension/icons "$out/"
1212 cp "extension/manifest.$browser.json" "$out/manifest.json"
1313 cp extension/sw.js "$out/" # both browsers now run a background script
⋯ 11 unchanged lines
modifieddaemon/Cargo.lock+44 −44
⋯ 19 unchanged lines
2020 "nom",
2121 "num-traits",
2222 "rusticata-macros",
23- "thiserror 2.0.19",
23+ "thiserror 2.0.20",
2424 "time",
2525 ]
2626
⋯ 70 unchanged lines
9797
9898 [[package]]
9999 name = "cc"
100-version = "1.4.0"
100+version = "1.4.2"
101101 source = "registry+https://github.com/rust-lang/crates.io-index"
102-checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
102+checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
103103 dependencies = [
104104 "find-msvc-tools",
105105 "shlex",
⋯ 48 unchanged lines
154154
155155 [[package]]
156156 name = "data-encoding"
157-version = "2.11.0"
157+version = "2.11.1"
158158 source = "registry+https://github.com/rust-lang/crates.io-index"
159-checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
159+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
160160
161161 [[package]]
162162 name = "der-parser"
⋯ 28 unchanged lines
191191
192192 [[package]]
193193 name = "displaydoc"
194-version = "0.2.6"
194+version = "0.2.7"
195195 source = "registry+https://github.com/rust-lang/crates.io-index"
196-checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
196+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
197197 dependencies = [
198198 "proc-macro2",
199199 "quote",
200- "syn 2.0.119",
200+ "syn 3.0.3",
201201 ]
202202
203203 [[package]]
⋯ 31 unchanged lines
235235
236236 [[package]]
237237 name = "find-msvc-tools"
238-version = "0.1.9"
238+version = "0.1.10"
239239 source = "registry+https://github.com/rust-lang/crates.io-index"
240-checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
240+checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
241241
242242 [[package]]
243243 name = "futures-core"
244-version = "0.3.33"
244+version = "0.3.34"
245245 source = "registry+https://github.com/rust-lang/crates.io-index"
246-checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
246+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
247247
248248 [[package]]
249249 name = "futures-sink"
250-version = "0.3.33"
250+version = "0.3.34"
251251 source = "registry+https://github.com/rust-lang/crates.io-index"
252-checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
252+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
253253
254254 [[package]]
255255 name = "futures-task"
256-version = "0.3.33"
256+version = "0.3.34"
257257 source = "registry+https://github.com/rust-lang/crates.io-index"
258-checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
258+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
259259
260260 [[package]]
261261 name = "futures-util"
262-version = "0.3.33"
262+version = "0.3.34"
263263 source = "registry+https://github.com/rust-lang/crates.io-index"
264-checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
264+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
265265 dependencies = [
266266 "futures-core",
267267 "futures-sink",
⋯ 27 unchanged lines
295295
296296 [[package]]
297297 name = "http"
298-version = "1.4.2"
298+version = "1.5.0"
299299 source = "registry+https://github.com/rust-lang/crates.io-index"
300-checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
300+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
301301 dependencies = [
302302 "bytes",
303303 "itoa",
⋯ 7 unchanged lines
311311
312312 [[package]]
313313 name = "hybrid-array"
314-version = "0.4.13"
314+version = "0.4.14"
315315 source = "registry+https://github.com/rust-lang/crates.io-index"
316-checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
316+checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
317317 dependencies = [
318318 "typenum",
319319 ]
⋯ 91 unchanged lines
411411
412412 [[package]]
413413 name = "num-integer"
414-version = "0.1.46"
414+version = "0.1.47"
415415 source = "registry+https://github.com/rust-lang/crates.io-index"
416-checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
416+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
417417 dependencies = [
418418 "num-traits",
419419 ]
⋯ 108 unchanged lines
528528
529529 [[package]]
530530 name = "rcgen"
531-version = "0.14.8"
531+version = "0.14.9"
532532 source = "registry+https://github.com/rust-lang/crates.io-index"
533-checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
533+checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
534534 dependencies = [
535535 "pem",
536536 "ring",
⋯ 41 unchanged lines
578578
579579 [[package]]
580580 name = "rustls"
581-version = "0.23.42"
581+version = "0.23.43"
582582 source = "registry+https://github.com/rust-lang/crates.io-index"
583-checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
583+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
584584 dependencies = [
585585 "log",
586586 "once_cell",
⋯ 15 unchanged lines
602602
603603 [[package]]
604604 name = "rustls-webpki"
605-version = "0.103.13"
605+version = "0.103.14"
606606 source = "registry+https://github.com/rust-lang/crates.io-index"
607-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
607+checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
608608 dependencies = [
609609 "ring",
610610 "rustls-pki-types",
⋯ 45 unchanged lines
656656
657657 [[package]]
658658 name = "serial2"
659-version = "0.2.37"
659+version = "0.2.38"
660660 source = "registry+https://github.com/rust-lang/crates.io-index"
661-checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1"
661+checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
662662 dependencies = [
663663 "cfg-if",
664664 "libc",
⋯ 151 unchanged lines
816816
817817 [[package]]
818818 name = "thiserror"
819-version = "2.0.19"
819+version = "2.0.20"
820820 source = "registry+https://github.com/rust-lang/crates.io-index"
821-checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
821+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
822822 dependencies = [
823- "thiserror-impl 2.0.19",
823+ "thiserror-impl 2.0.20",
824824 ]
825825
826826 [[package]]
⋯ 9 unchanged lines
836836
837837 [[package]]
838838 name = "thiserror-impl"
839-version = "2.0.19"
839+version = "2.0.20"
840840 source = "registry+https://github.com/rust-lang/crates.io-index"
841-checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
841+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
842842 dependencies = [
843843 "proc-macro2",
844844 "quote",
⋯ 2 unchanged lines
847847
848848 [[package]]
849849 name = "time"
850-version = "0.3.54"
850+version = "0.3.55"
851851 source = "registry+https://github.com/rust-lang/crates.io-index"
852-checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
852+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
853853 dependencies = [
854854 "deranged",
855855 "num-conv",
⋯ 37 unchanged lines
893893
894894 [[package]]
895895 name = "tokio-macros"
896-version = "2.7.1"
896+version = "2.7.2"
897897 source = "registry+https://github.com/rust-lang/crates.io-index"
898-checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
898+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
899899 dependencies = [
900900 "proc-macro2",
901901 "quote",
902- "syn 2.0.119",
902+ "syn 3.0.3",
903903 ]
904904
905905 [[package]]
⋯ 31 unchanged lines
937937 "log",
938938 "rand",
939939 "sha1",
940- "thiserror 2.0.19",
940+ "thiserror 2.0.20",
941941 ]
942942
943943 [[package]]
⋯ 153 unchanged lines
10971097 "oid-registry",
10981098 "ring",
10991099 "rusticata-macros",
1100- "thiserror 2.0.19",
1100+ "thiserror 2.0.20",
11011101 "time",
11021102 ]
11031103
⋯ 21 unchanged lines
modifieddaemon/Cargo.toml+2 −2
⋯ 3 unchanged lines
44 edition = "2024"
55
66 [dependencies]
7-futures-util = { version = "0.3.33", default-features = false, features = ["std", "sink"] }
7+futures-util = { version = "0.3.34", default-features = false, features = ["std", "sink"] }
88 getrandom = "0.4.3"
99 portable-pty = "0.9.0"
10-rcgen = { version = "0.14.8", default-features = false, features = ["pem", "ring"] }
10+rcgen = { version = "0.14.9", default-features = false, features = ["pem", "ring"] }
1111 serde = { version = "1.0.229", features = ["derive"] }
1212 serde_json = "1.0.151"
1313 sha2 = "0.11.0"
⋯ 7 unchanged lines
addeddaemon/src/activation.rs+128 −0
1+//! Picking up a listening socket that someone else already bound.
2+//!
3+//! systemd's socket activation lets the *socket* outlive the daemon: systemd
4+//! holds `127.0.0.1:7681` open from login, and only execs `termbridge serve`
5+//! when the sidebar actually connects. Combined with `--idle-timeout` the
6+//! daemon then exits once the last client goes away, and the next connection
7+//! starts a fresh one.
8+//!
9+//! That is only safe because tmux, not this process, is the persistence layer.
10+//! Sessions survive the daemon exiting, so "no clients" really is "nothing to
11+//! keep alive".
12+
13+use std::net::TcpListener;
14+
15+/// The first fd systemd passes. Defined by the protocol, not by us.
16+const LISTEN_FDS_START: i32 = 3;
17+
18+/// Take the listener systemd passed us, if this process was socket-activated.
19+///
20+/// `Ok(None)` means "started normally, bind your own socket". An `Err` means we
21+/// *were* activated but the handoff was wrong, which must not fall back to
22+/// binding: the port is already owned by systemd and the bind would fail (or,
23+/// worse, succeed on a different port and leave the sidebar talking to nobody).
24+pub fn systemd_listener() -> std::io::Result<Option<TcpListener>> {
25+ let Some(fds) = listen_fds()? else {
26+ return Ok(None);
27+ };
28+ if fds != 1 {
29+ return Err(err(format!(
30+ "systemd passed {fds} sockets, expected exactly 1 — check ListenStream in termbridge.socket"
31+ )));
32+ }
33+
34+ // Safe to own fd 3: the LISTEN_PID check below/above proved these variables
35+ // were meant for this process, and nothing else in the daemon touches it.
36+ let listener = unsafe {
37+ use std::os::fd::FromRawFd;
38+ TcpListener::from_raw_fd(LISTEN_FDS_START)
39+ };
40+
41+ // Same invariant the self-bound path asserts. A unit file with
42+ // `ListenStream=0.0.0.0:7681` would otherwise silently expose a shell to
43+ // the network, and the unit is a file the user can edit.
44+ let addr = listener.local_addr()?;
45+ if !addr.ip().is_loopback() {
46+ return Err(err(format!(
47+ "refusing the socket systemd passed: {addr} is not loopback"
48+ )));
49+ }
50+
51+ listener.set_nonblocking(true)?;
52+ Ok(Some(listener))
53+}
54+
55+/// `$LISTEN_FDS`, but only if `$LISTEN_PID` says the variables are ours.
56+///
57+/// The check matters because these variables are inherited by children. Without
58+/// it, a shell spawned inside the pty would look socket-activated to any
59+/// termbridge it ran.
60+fn listen_fds() -> std::io::Result<Option<usize>> {
61+ let Ok(pid) = std::env::var("LISTEN_PID") else {
62+ return Ok(None);
63+ };
64+ let fds = std::env::var("LISTEN_FDS").unwrap_or_default();
65+ // Clear before anything can fork: the pty spawns a shell, and these must
66+ // not be part of its environment. Called from startup, single-threaded,
67+ // before any other thread can be reading the environment.
68+ unsafe {
69+ std::env::remove_var("LISTEN_PID");
70+ std::env::remove_var("LISTEN_FDS");
71+ std::env::remove_var("LISTEN_FDNAMES");
72+ }
73+
74+ if pid.trim().parse::<u32>().ok() != Some(std::process::id()) {
75+ return Ok(None);
76+ }
77+ match fds.trim().parse::<usize>() {
78+ Ok(n) => Ok(Some(n)),
79+ Err(_) => Err(err(format!("LISTEN_PID is ours but LISTEN_FDS={fds:?}"))),
80+ }
81+}
82+
83+fn err(msg: String) -> std::io::Error {
84+ std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
85+}
86+
87+#[cfg(test)]
88+mod tests {
89+ use super::*;
90+
91+ // These mutate process-global environment, so they share one test to avoid
92+ // racing each other under the default multi-threaded harness.
93+ #[test]
94+ fn env_handshake() {
95+ // SAFETY (all of these): the harness runs this test alone in its
96+ // process for the same reason the assertions below are batched.
97+ unsafe { std::env::remove_var("LISTEN_PID") };
98+ assert!(
99+ listen_fds().unwrap().is_none(),
100+ "no LISTEN_PID: not activated"
101+ );
102+
103+ // Addressed to some other process: ignored, and consumed so it cannot
104+ // be inherited further.
105+ unsafe {
106+ std::env::set_var("LISTEN_PID", "1");
107+ std::env::set_var("LISTEN_FDS", "1");
108+ }
109+ assert!(
110+ listen_fds().unwrap().is_none(),
111+ "LISTEN_PID for another pid"
112+ );
113+ assert!(std::env::var("LISTEN_PID").is_err(), "consumed anyway");
114+ assert!(std::env::var("LISTEN_FDS").is_err(), "consumed anyway");
115+
116+ unsafe {
117+ std::env::set_var("LISTEN_PID", std::process::id().to_string());
118+ std::env::set_var("LISTEN_FDS", "2");
119+ }
120+ assert_eq!(listen_fds().unwrap(), Some(2));
121+
122+ unsafe {
123+ std::env::set_var("LISTEN_PID", std::process::id().to_string());
124+ std::env::set_var("LISTEN_FDS", "not-a-number");
125+ }
126+ assert!(listen_fds().is_err(), "ours but malformed is an error");
127+ }
128+}
modifieddaemon/src/agents.rs+18 −6
⋯ 72 unchanged lines
7373 pub updated: u64,
7474 }
7575
76+impl Record {
77+ /// Seconds since the last hook event. Saturating, so a record written
78+ /// before a clock adjustment reads as brand new rather than ancient.
79+ pub fn age(&self) -> u64 {
80+ now().saturating_sub(self.updated)
81+ }
82+}
83+
7684 pub fn dir() -> PathBuf {
7785 crate::paths::config_dir().join("agents")
7886 }
⋯ 26 unchanged lines
105113 map
106114 }
107115
108-fn now() -> u64 {
116+pub fn now() -> u64 {
109117 std::time::SystemTime::now()
110118 .duration_since(std::time::UNIX_EPOCH)
111119 .map(|d| d.as_secs())
⋯ 215 unchanged lines
327335 "session_id": "a", "hook_event_name": "SessionStart", "cwd": "/tmp/p",
328336 }));
329337 assert_eq!(read_all().len(), 1);
330- assert!(event(serde_json::json!({
331- "session_id": "a", "hook_event_name": "SessionEnd", "reason": "clear",
332- }))
333- .is_none());
338+ assert!(
339+ event(serde_json::json!({
340+ "session_id": "a", "hook_event_name": "SessionEnd", "reason": "clear",
341+ }))
342+ .is_none()
343+ );
334344 assert!(read_all().is_empty());
335345 });
336346 }
⋯ 2 unchanged lines
339349 fn unknown_events_and_ids_are_ignored() {
340350 with_temp_dir(|| {
341351 assert!(event(serde_json::json!({"hook_event_name": "Stop"})).is_none());
342- assert!(event(serde_json::json!({"session_id": "a", "hook_event_name": "Wat"})).is_none());
352+ assert!(
353+ event(serde_json::json!({"session_id": "a", "hook_event_name": "Wat"})).is_none()
354+ );
343355 assert!(read_all().is_empty());
344356 });
345357 }
⋯ 7 unchanged lines
modifieddaemon/src/control.rs+5 −2
⋯ 24 unchanged lines
2525 use std::sync::Arc;
2626
2727 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
28-use tokio::sync::{mpsc, oneshot, Mutex};
28+use tokio::sync::{Mutex, mpsc, oneshot};
2929
3030 /// A `%`-prefixed line tmux sent us unprompted, verbatim.
3131 pub type Notification = String;
⋯ 47 unchanged lines
7979 tokio::spawn(async move {
8080 while let Some((line, reply)) = jobs_rx.recv().await {
8181 queue.lock().await.push_back(reply);
82- if stdin.write_all(format!("{line}\n").as_bytes()).await.is_err()
82+ if stdin
83+ .write_all(format!("{line}\n").as_bytes())
84+ .await
85+ .is_err()
8386 || stdin.flush().await.is_err()
8487 {
8588 break;
⋯ 134 unchanged lines
addeddaemon/src/install.rs+446 −0
1+//! `termbridge install` — hand the daemon to the service manager so nobody has
2+//! to remember to start it.
3+//!
4+//! On Linux this is socket activation: systemd binds 127.0.0.1:7681 at login
5+//! and starts the daemon on the first connection, so the sidebar just works
6+//! from a cold boot with no process running at rest. See [`crate::activation`].
7+//!
8+//! macOS gets a plain login agent instead. launchd can do socket activation
9+//! too, but only through `launch_activate_socket(3)`, and shipping untested
10+//! FFI would be worse than an idle process.
11+
12+use std::io::Write;
13+use std::path::{Path, PathBuf};
14+use std::process::Command;
15+
16+/// Seconds of no clients before an activated daemon exits. Long enough that
17+/// closing the sidebar for a moment doesn't churn the process, short enough
18+/// that a forgotten browser tab doesn't pin a pty server all week.
19+const DEFAULT_IDLE: u64 = 900;
20+
21+pub struct Options {
22+ pub port: u16,
23+ pub idle_secs: u64,
24+ pub session: Option<String>,
25+}
26+
27+impl Default for Options {
28+ fn default() -> Self {
29+ Options {
30+ port: crate::DEFAULT_PORT,
31+ idle_secs: DEFAULT_IDLE,
32+ session: None,
33+ }
34+ }
35+}
36+
37+pub fn install(opts: &Options) -> std::io::Result<()> {
38+ let exe = exe_path()?;
39+ if cfg!(target_os = "macos") {
40+ launchd_install(&exe, opts)
41+ } else {
42+ systemd_install(&exe, opts)
43+ }
44+}
45+
46+pub fn uninstall() -> std::io::Result<()> {
47+ if cfg!(target_os = "macos") {
48+ launchd_uninstall()
49+ } else {
50+ systemd_uninstall()
51+ }
52+}
53+
54+/// `termbridge reload` — put the daemon that is running now out, so the next
55+/// connection starts the binary that is on disk now.
56+///
57+/// The update path after a rebuild. `install` already does this as a side
58+/// effect, but it also rewrites the unit files from the flags it was given, so
59+/// re-running it to pick up a new binary quietly resets an idle timeout or a
60+/// pinned session that was set once and forgotten. This touches no
61+/// configuration: same units, same options, new process.
62+///
63+/// What it does *not* restart is tmux. The sessions, their windows and
64+/// everything running in them belong to the tmux server, which this daemon only
65+/// attaches to — so a reload drops the sidebar's socket for as long as it takes
66+/// the browser to reconnect, and loses nothing else.
67+pub fn reload() -> std::io::Result<()> {
68+ if cfg!(target_os = "macos") {
69+ launchd_reload()
70+ } else {
71+ systemd_reload()
72+ }
73+}
74+
75+/// Resolved, absolute, and symlink-free: the unit file outlives this shell, so
76+/// a relative `./target/release/termbridge` in it would break the moment the
77+/// user cd'd somewhere else.
78+fn exe_path() -> std::io::Result<PathBuf> {
79+ let exe = std::env::current_exe()?;
80+ Ok(std::fs::canonicalize(&exe).unwrap_or(exe))
81+}
82+
83+// ---------------------------------------------------------------------------
84+// systemd
85+// ---------------------------------------------------------------------------
86+
87+pub const SOCKET_UNIT: &str = "termbridge.socket";
88+pub const SERVICE_UNIT: &str = "termbridge.service";
89+
90+fn home() -> PathBuf {
91+ std::env::var_os("HOME")
92+ .map(PathBuf::from)
93+ .unwrap_or_default()
94+}
95+
96+fn systemd_unit_dir() -> PathBuf {
97+ match std::env::var_os("XDG_CONFIG_HOME") {
98+ Some(x) if !x.is_empty() => PathBuf::from(x),
99+ _ => home().join(".config"),
100+ }
101+ .join("systemd/user")
102+}
103+
104+pub fn socket_unit(port: u16) -> String {
105+ format!(
106+ "# Written by `termbridge install`. Safe to edit; re-running overwrites it.\n\
107+ [Unit]\n\
108+ Description=termbridge terminal bridge socket\n\
109+ Documentation=https://github.com/collin/terminal\n\
110+ \n\
111+ [Socket]\n\
112+ # Loopback only. The daemon hands out shell access and refuses to serve\n\
113+ # anything else, so widening this just breaks startup.\n\
114+ ListenStream=127.0.0.1:{port}\n\
115+ # One daemon for all connections, not one process per connection.\n\
116+ Accept=no\n\
117+ \n\
118+ [Install]\n\
119+ WantedBy=sockets.target\n"
120+ )
121+}
122+
123+pub fn service_unit(exe: &Path, opts: &Options) -> String {
124+ let session = match &opts.session {
125+ Some(s) => format!(" --session {s}"),
126+ None => String::new(),
127+ };
128+ format!(
129+ "# Written by `termbridge install`. Safe to edit; re-running overwrites it.\n\
130+ [Unit]\n\
131+ Description=termbridge terminal bridge for the browser sidebar\n\
132+ Requires={SOCKET_UNIT}\n\
133+ After={SOCKET_UNIT}\n\
134+ \n\
135+ [Service]\n\
136+ ExecStart={exe} serve --systemd-socket --idle-timeout {idle}{session}\n\
137+ # The daemon exits on its own once no sidebar has been connected for\n\
138+ # --idle-timeout. tmux keeps the sessions, so that loses nothing, and\n\
139+ # the socket unit starts us again on the next connection.\n\
140+ Restart=no\n\
141+ SuccessExitStatus=0\n\
142+ # Stopping this unit must stop this daemon and nothing else. On a\n\
143+ # machine with no tmux server running, *we* are what starts one, and a\n\
144+ # process forked from here keeps this cgroup for life — reparenting to\n\
145+ # systemd when tmux daemonises does not move it out. So under the\n\
146+ # default KillMode=control-group, `termbridge reload` would signal the\n\
147+ # user's tmux server and every pane in it, which is the one thing this\n\
148+ # daemon promises never to touch.\n\
149+ KillMode=mixed\n",
150+ exe = exe.display(),
151+ idle = opts.idle_secs,
152+ )
153+}
154+
155+fn systemd_install(exe: &Path, opts: &Options) -> std::io::Result<()> {
156+ let dir = systemd_unit_dir();
157+ std::fs::create_dir_all(&dir)?;
158+ write_file(&dir.join(SOCKET_UNIT), &socket_unit(opts.port))?;
159+ write_file(&dir.join(SERVICE_UNIT), &service_unit(exe, opts))?;
160+ println!("wrote {}/{{{SOCKET_UNIT},{SERVICE_UNIT}}}", dir.display());
161+
162+ systemctl(&["daemon-reload"])?;
163+ // The service is deliberately not enabled: enabling the *socket* is what
164+ // makes the daemon on-demand. Starting the service directly would defeat
165+ // the point and, worse, race the socket for the port.
166+ systemctl(&["enable", SOCKET_UNIT])?;
167+
168+ // Re-running install has to work, because that is the update path: rebuild
169+ // the binary, run install, done. Two things make that awkward.
170+ //
171+ // `enable --now` is a no-op on an already-active socket, so it would leave
172+ // the old configuration listening while systemd logs "Unit configuration
173+ // changed while unit was running ... Unit not functional until restarted"
174+ // and quietly stops accepting. Only an explicit restart re-reads it.
175+ //
176+ // And the restart has to come *after* the daemon is gone, or the new socket
177+ // cannot bind the port the old daemon is still holding.
178+ let _ = systemctl(&["stop", SERVICE_UNIT]);
179+ systemctl(&["restart", SOCKET_UNIT])?;
180+
181+ // A systemd user service inherits the manager's environment, not a login
182+ // shell's. Without this, PATH can be missing whatever the user added in
183+ // their profile, and the daemon reports "tmux not found" while `tmux` works
184+ // fine in every terminal they have open.
185+ let _ = systemctl(&["import-environment", "PATH"]);
186+
187+ println!(
188+ "\nsocket-activated on 127.0.0.1:{port}. Nothing is running yet — the first\n\
189+ sidebar connection starts the daemon, and it exits {idle}s after the last\n\
190+ one disconnects. tmux keeps your sessions across that.\n",
191+ port = opts.port,
192+ idle = opts.idle_secs,
193+ );
194+ println!(" status: systemctl --user status {SERVICE_UNIT}");
195+ println!(" logs: journalctl --user -u {SERVICE_UNIT} -f");
196+ println!(" remove: termbridge uninstall");
197+ Ok(())
198+}
199+
200+fn systemd_uninstall() -> std::io::Result<()> {
201+ let _ = systemctl(&["disable", "--now", SOCKET_UNIT]);
202+ let _ = systemctl(&["stop", SERVICE_UNIT]);
203+ let dir = systemd_unit_dir();
204+ for unit in [SOCKET_UNIT, SERVICE_UNIT] {
205+ let path = dir.join(unit);
206+ match std::fs::remove_file(&path) {
207+ Ok(()) => println!("removed {}", path.display()),
208+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
209+ Err(e) => return Err(e),
210+ }
211+ }
212+ let _ = systemctl(&["daemon-reload"]);
213+ println!("\nthe daemon is no longer started automatically; `termbridge serve` still works.");
214+ Ok(())
215+}
216+
217+fn systemd_reload() -> std::io::Result<()> {
218+ let dir = systemd_unit_dir();
219+ if !dir.join(SOCKET_UNIT).exists() {
220+ return Err(std::io::Error::other(format!(
221+ "no {SOCKET_UNIT} in {} — run `termbridge install` first, or restart \
222+ your own `termbridge serve` by hand",
223+ dir.display()
224+ )));
225+ }
226+
227+ // A unit edited by hand since login is a unit systemd is still holding the
228+ // old text of, and the restart below would put that old text back.
229+ systemctl(&["daemon-reload"])?;
230+
231+ // Stop before restarting the socket, for the reason `install` documents:
232+ // the new listener cannot bind a port the old daemon is still holding.
233+ // Ignored rather than checked — "it was not running" is the normal case
234+ // here, and it is the state we wanted anyway.
235+ let _ = systemctl(&["stop", SERVICE_UNIT]);
236+ systemctl(&["restart", SOCKET_UNIT])?;
237+ let _ = systemctl(&["import-environment", "PATH"]);
238+
239+ println!(
240+ "daemon stopped and the socket is listening again. The next sidebar\n\
241+ connection starts the current binary; your tmux sessions are untouched."
242+ );
243+ Ok(())
244+}
245+
246+fn launchd_reload() -> std::io::Result<()> {
247+ let path = launchd_plist_path();
248+ if !path.exists() {
249+ return Err(std::io::Error::other(format!(
250+ "no {} — run `termbridge install` first, or restart your own \
251+ `termbridge serve` by hand",
252+ path.display()
253+ )));
254+ }
255+ // `kickstart -k` kills the running agent and starts it again from the plist
256+ // already loaded, which is what a reload is. No bootout/bootstrap pair: that
257+ // would re-read a plist this command has no business rewriting.
258+ let target = format!("gui/{}/{LAUNCHD_LABEL}", uid());
259+ let status = Command::new("launchctl")
260+ .args(["kickstart", "-k", &target])
261+ .status()?;
262+ if !status.success() {
263+ return Err(std::io::Error::other(format!(
264+ "launchctl kickstart -k {target} failed ({status})"
265+ )));
266+ }
267+ println!("daemon restarted; your tmux sessions are untouched.");
268+ Ok(())
269+}
270+
271+fn systemctl(args: &[&str]) -> std::io::Result<()> {
272+ let status = Command::new("systemctl")
273+ .arg("--user")
274+ .args(args)
275+ .status()
276+ .map_err(|e| {
277+ std::io::Error::new(
278+ e.kind(),
279+ format!("could not run systemctl --user {}: {e}", args.join(" ")),
280+ )
281+ })?;
282+ if !status.success() {
283+ return Err(std::io::Error::other(format!(
284+ "systemctl --user {} failed ({status})",
285+ args.join(" ")
286+ )));
287+ }
288+ Ok(())
289+}
290+
291+// ---------------------------------------------------------------------------
292+// launchd
293+// ---------------------------------------------------------------------------
294+
295+pub const LAUNCHD_LABEL: &str = "com.termbridge.daemon";
296+
297+fn launchd_plist_path() -> PathBuf {
298+ home().join(format!("Library/LaunchAgents/{LAUNCHD_LABEL}.plist"))
299+}
300+
301+pub fn launchd_plist(exe: &Path, opts: &Options) -> String {
302+ let mut args = vec![
303+ exe.display().to_string(),
304+ "serve".into(),
305+ "--port".into(),
306+ opts.port.to_string(),
307+ ];
308+ if let Some(s) = &opts.session {
309+ args.push("--session".into());
310+ args.push(s.clone());
311+ }
312+ let args: String = args
313+ .iter()
314+ .map(|a| format!(" <string>{}</string>\n", xml_escape(a)))
315+ .collect();
316+ let log = crate::paths::config_dir().join("daemon.log");
317+ format!(
318+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
319+ <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
320+ <plist version=\"1.0\"><dict>\n\
321+ \x20 <key>Label</key><string>{LAUNCHD_LABEL}</string>\n\
322+ \x20 <key>ProgramArguments</key><array>\n{args} </array>\n\
323+ \x20 <key>RunAtLoad</key><true/>\n\
324+ \x20 <key>KeepAlive</key><true/>\n\
325+ \x20 <key>StandardOutPath</key><string>{log}</string>\n\
326+ \x20 <key>StandardErrorPath</key><string>{log}</string>\n\
327+ </dict></plist>\n",
328+ log = xml_escape(&log.display().to_string()),
329+ )
330+}
331+
332+fn launchd_install(exe: &Path, opts: &Options) -> std::io::Result<()> {
333+ let path = launchd_plist_path();
334+ if let Some(parent) = path.parent() {
335+ std::fs::create_dir_all(parent)?;
336+ }
337+ write_file(&path, &launchd_plist(exe, opts))?;
338+ println!("wrote {}", path.display());
339+
340+ let target = format!("gui/{}", uid());
341+ let _ = Command::new("launchctl")
342+ .args(["bootout", &format!("{target}/{LAUNCHD_LABEL}")])
343+ .status();
344+ let status = Command::new("launchctl")
345+ .args(["bootstrap", &target])
346+ .arg(&path)
347+ .status()?;
348+ if !status.success() {
349+ return Err(std::io::Error::other(format!(
350+ "launchctl bootstrap failed ({status})"
351+ )));
352+ }
353+ println!(
354+ "\nrunning at login on 127.0.0.1:{port}.\n\
355+ Note: this is a plain login agent, so the daemon stays resident. On-demand\n\
356+ socket activation is Linux-only for now.\n",
357+ port = opts.port
358+ );
359+ println!(
360+ " logs: tail -f {}",
361+ crate::paths::config_dir().join("daemon.log").display()
362+ );
363+ println!(" remove: termbridge uninstall");
364+ Ok(())
365+}
366+
367+fn launchd_uninstall() -> std::io::Result<()> {
368+ let path = launchd_plist_path();
369+ let _ = Command::new("launchctl")
370+ .args(["bootout", &format!("gui/{}/{LAUNCHD_LABEL}", uid())])
371+ .status();
372+ match std::fs::remove_file(&path) {
373+ Ok(()) => println!("removed {}", path.display()),
374+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
375+ Err(e) => return Err(e),
376+ }
377+ println!("\nthe daemon is no longer started at login; `termbridge serve` still works.");
378+ Ok(())
379+}
380+
381+/// Shelling out to `id -u` rather than taking a libc dependency for one number
382+/// on one platform. Falls back to 501, macOS's first human user.
383+fn uid() -> String {
384+ Command::new("id")
385+ .arg("-u")
386+ .output()
387+ .ok()
388+ .filter(|o| o.status.success())
389+ .and_then(|o| String::from_utf8(o.stdout).ok())
390+ .map(|s| s.trim().to_string())
391+ .filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))
392+ .unwrap_or_else(|| "501".into())
393+}
394+
395+fn xml_escape(s: &str) -> String {
396+ s.replace('&', "&amp;")
397+ .replace('<', "&lt;")
398+ .replace('>', "&gt;")
399+}
400+
401+fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
402+ let mut f = std::fs::File::create(path)?;
403+ f.write_all(contents.as_bytes())?;
404+ Ok(())
405+}
406+
407+#[cfg(test)]
408+mod tests {
409+ use super::*;
410+
411+ #[test]
412+ fn socket_unit_is_loopback_only() {
413+ let unit = socket_unit(7681);
414+ assert!(unit.contains("ListenStream=127.0.0.1:7681"));
415+ assert!(unit.contains("WantedBy=sockets.target"));
416+ }
417+
418+ /// The service must ask for the passed socket. Without the flag it would
419+ /// try to bind the port systemd already owns and fail on every activation.
420+ #[test]
421+ fn service_unit_consumes_the_activated_socket() {
422+ let unit = service_unit(Path::new("/usr/local/bin/termbridge"), &Options::default());
423+ assert!(unit.contains("ExecStart=/usr/local/bin/termbridge serve --systemd-socket"));
424+ assert!(unit.contains(&format!("--idle-timeout {DEFAULT_IDLE}")));
425+ assert!(unit.contains(&format!("Requires={SOCKET_UNIT}")));
426+ assert!(!unit.contains("--port"), "the socket unit owns the port");
427+ }
428+
429+ #[test]
430+ fn service_unit_carries_a_pinned_session() {
431+ let opts = Options {
432+ session: Some("work".into()),
433+ ..Options::default()
434+ };
435+ let unit = service_unit(Path::new("/bin/termbridge"), &opts);
436+ assert!(unit.contains("--session work"));
437+ }
438+
439+ #[test]
440+ fn plist_escapes_paths() {
441+ let opts = Options::default();
442+ let plist = launchd_plist(Path::new("/tmp/a&b/termbridge"), &opts);
443+ assert!(plist.contains("<string>/tmp/a&amp;b/termbridge</string>"));
444+ assert!(plist.contains(LAUNCHD_LABEL));
445+ }
446+}
modifieddaemon/src/lib.rs+9 −1
1+pub mod activation;
12 pub mod agents;
23 pub mod auth;
34 pub mod control;
5+pub mod install;
46 pub mod paths;
7+pub mod project;
58 pub mod pty;
69 pub mod rewind;
10+pub mod server;
11+pub mod ssh;
712 pub mod status;
813 pub mod tls;
914 pub mod window_status;
10-pub mod server;
1115
1216 pub use server::{Config, Event, Server};
17+
18+/// The port the extension dials by default. Lives here because the unit file
19+/// `install` writes has to name the same one the daemon would have bound.
20+pub const DEFAULT_PORT: u16 = 7681;
modifieddaemon/src/main.rs+99 −21
⋯ 3 unchanged lines
44 use termbridge::pty::Profile;
55 use termbridge::{Config, Event, Server};
66
7-const DEFAULT_PORT: u16 = 7681;
7+use termbridge::DEFAULT_PORT;
88
99 fn usage() -> ! {
1010 eprintln!(
⋯ 2 unchanged lines
1313 USAGE:
1414 termbridge serve [--port N] [--session NAME]
1515 start the daemon
16+ termbridge serve --systemd-socket [--idle-timeout SECS]
17+ serve on the socket systemd passed us
18+ termbridge install [--port N] [--idle-timeout SECS] [--session NAME]
19+ start the daemon on demand, from now on
20+ termbridge uninstall stop doing that
21+ termbridge reload restart the running daemon (after a rebuild)
1622 termbridge token print the auth token (paste into extension)
1723 termbridge token --rotate generate a new token
1824 termbridge pair <origin> approve an extension origin
⋯ 17 unchanged lines
3642 async fn main() -> Result<(), Box<dyn std::error::Error>> {
3743 let args: Vec<String> = std::env::args().skip(1).collect();
3844 let dir = paths::config_dir();
45+ let flag = |name: &str| {
46+ args.iter()
47+ .position(|a| a == name)
48+ .and_then(|i| args.get(i + 1))
49+ .cloned()
50+ };
3951
4052 match args.first().map(String::as_str) {
4153 Some("serve") => {
42- let port = match args.iter().position(|a| a == "--port") {
43- Some(i) => args
44- .get(i + 1)
54+ let port = flag("--port")
55+ .and_then(|p| p.parse().ok())
56+ .unwrap_or(DEFAULT_PORT);
57+ let session = flag("--session");
58+ // 0 disables it, so a unit file can turn the timeout off without
59+ // being rewritten into a different shape.
60+ let idle = flag("--idle-timeout")
61+ .and_then(|s| s.parse::<u64>().ok())
62+ .filter(|s| *s > 0)
63+ .map(Duration::from_secs);
64+ let listener = if args.iter().any(|a| a == "--systemd-socket") {
65+ match termbridge::activation::systemd_listener()? {
66+ Some(l) => Some(l),
67+ None => {
68+ eprintln!(
69+ "--systemd-socket was passed but no socket arrived. Start this \
70+ through termbridge.socket (see `termbridge install`), not directly."
71+ );
72+ std::process::exit(2);
73+ }
74+ }
75+ } else {
76+ None
77+ };
78+ serve(port, session, idle, listener).await
79+ }
80+ Some("install") => {
81+ let opts = termbridge::install::Options {
82+ port: flag("--port")
4583 .and_then(|p| p.parse().ok())
4684 .unwrap_or(DEFAULT_PORT),
47- None => DEFAULT_PORT,
85+ idle_secs: flag("--idle-timeout")
86+ .and_then(|s| s.parse().ok())
87+ .unwrap_or(termbridge::install::Options::default().idle_secs),
88+ session: flag("--session"),
4889 };
49- let session = args
50- .iter()
51- .position(|a| a == "--session")
52- .and_then(|i| args.get(i + 1))
53- .cloned();
54- serve(port, session).await
90+ termbridge::install::install(&opts)?;
91+ Ok(())
92+ }
93+ Some("uninstall") => {
94+ termbridge::install::uninstall()?;
95+ Ok(())
96+ }
97+ // Deliberately takes no flags. Everything `install` accepts is written
98+ // into the unit file, and a reload that quietly rewrote it would be an
99+ // install under another name.
100+ Some("reload") => {
101+ termbridge::install::reload()?;
102+ Ok(())
55103 }
56104 Some("token") => {
57105 let token = if args.iter().any(|a| a == "--rotate") {
⋯ 29 unchanged lines
87135 } else {
88136 termbridge::tls::load_or_create(&dir)?
89137 };
90- println!("certificate: {}", dir.join(termbridge::tls::CERT_FILE).display());
91- println!("private key: {}", dir.join(termbridge::tls::KEY_FILE).display());
138+ println!(
139+ "certificate: {}",
140+ dir.join(termbridge::tls::CERT_FILE).display()
141+ );
142+ println!(
143+ "private key: {}",
144+ dir.join(termbridge::tls::KEY_FILE).display()
145+ );
92146 println!("SHA-256: {}", id.fingerprint);
93147 Ok(())
94148 }
⋯ 30 unchanged lines
125179 Ok(())
126180 }
127181 Some("tmux") => {
128- println!(
129- "Add this to ~/.tmux.conf, then `tmux source-file ~/.tmux.conf`:\n"
130- );
182+ println!("Add this to ~/.tmux.conf, then `tmux source-file ~/.tmux.conf`:\n");
131183 print!("{}", termbridge::window_status::tmux_conf());
132184 println!(
133185 "\nThe hooks are what set it, so install those first if you haven't:\n\
⋯ 19 unchanged lines
153205 }
154206 }
155207
156-async fn serve(port: u16, session: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
208+async fn serve(
209+ port: u16,
210+ session: Option<String>,
211+ idle_timeout: Option<Duration>,
212+ activated: Option<std::net::TcpListener>,
213+) -> Result<(), Box<dyn std::error::Error>> {
157214 let dir = paths::config_dir();
158215 let token = paths::load_or_create_token(&dir)?;
159216 let paired = paths::load_paired_origins(&dir);
⋯ 1 unchanged line
161218 let identity = termbridge::tls::load_or_create(&dir)?;
162219 let mut config = Config::new(token, paired.clone());
163220 config.auth_timeout = Duration::from_secs(3);
221+ config.idle_timeout = idle_timeout;
164222 config.tls = Some(termbridge::tls::acceptor(&identity)?);
165223 let pinned = session
166224 .as_deref()
⋯ 3 unchanged lines
170228 .unwrap_or_else(|| termbridge::pty::DEFAULT_SESSION.to_string());
171229 config.default_session = default_session.clone();
172230 // A pinned --session is a choice; without one, a lone existing session is
173- // a better guess than a fresh "browser" beside it.
231+ // a better guess than a fresh "default" beside it.
174232 config.adopt_sole_session = pinned.is_none();
175233 config.profile = if Profile::tmux_available() {
176234 Profile::tmux(&default_session)
⋯ 5 unchanged lines
182240 let profile = config.profile.clone();
183241 let config_adopts_sole = config.adopt_sole_session && profile.program == "tmux";
184242
185- let mut server = Server::start(config, port).await?;
186- println!("termbridge listening on wss://{a} (ws:// also accepted on the same port)", a = server.addr());
243+ let mut server = match activated {
244+ Some(listener) => Server::from_std(config, listener)?,
245+ None => Server::start(config, port).await?,
246+ };
247+ println!(
248+ "termbridge listening on wss://{a} (ws:// also accepted on the same port)",
249+ a = server.addr()
250+ );
187251 println!("running: {} {}", profile.program, profile.args.join(" "));
188252 let existing = termbridge::pty::list_sessions(&profile.tmux_global_args());
189253 if !existing.is_empty() {
⋯ 25 unchanged lines
215279 while let Some(event) = server.next_event().await {
216280 match event {
217281 Event::Accepted { origin } => println!("[accept] {origin}"),
218- Event::Rejected { origin, why, detail } => {
282+ // Under socket activation this is the normal way to stop: the
283+ // socket unit keeps listening, so the next sidebar connection
284+ // starts a fresh daemon and tmux hands it back the same sessions.
285+ Event::Idle => {
286+ println!(
287+ "[idle] no clients for {:?} — exiting",
288+ idle_timeout.unwrap_or_default()
289+ );
290+ break;
291+ }
292+ Event::Rejected {
293+ origin,
294+ why,
295+ detail,
296+ } => {
219297 let o = origin.unwrap_or_else(|| "<none>".into());
220298 println!("[reject] {o}: {}", why.reason());
221299 if let Some(d) = detail {
⋯ 29 unchanged lines
modifieddaemon/src/paths.rs+158 −4
⋯ 15 unchanged lines
1616 Io(io::Error),
1717 /// The token file is readable by group or other. Refuse rather than
1818 /// silently authenticate against a world-readable secret.
19- TooPermissive { path: PathBuf, mode: u32 },
19+ TooPermissive {
20+ path: PathBuf,
21+ mode: u32,
22+ },
2023 Malformed,
2124 }
2225
⋯ 29 unchanged lines
5255 let base = std::env::var_os("XDG_CONFIG_HOME")
5356 .map(PathBuf::from)
5457 .unwrap_or_else(|| {
55- let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
58+ let home = std::env::var_os("HOME")
59+ .map(PathBuf::from)
60+ .unwrap_or_default();
5661 home.join(".config")
5762 });
5863 base.join("termbridge")
⋯ 89 unchanged lines
148153 .truncate(true)
149154 .mode(0o600)
150155 .open(&path)?;
151- writeln!(f, "# Origins approved to connect to termbridge. One per line.")?;
156+ writeln!(
157+ f,
158+ "# Origins approved to connect to termbridge. One per line."
159+ )?;
152160 for o in &existing {
153161 writeln!(f, "{o}")?;
154162 }
⋯ 13 unchanged lines
168176 .truncate(true)
169177 .mode(0o600)
170178 .open(&path)?;
171- writeln!(f, "# Origins approved to connect to termbridge. One per line.")?;
179+ writeln!(
180+ f,
181+ "# Origins approved to connect to termbridge. One per line."
182+ )?;
172183 for o in existing.iter().filter(|o| *o != &origin) {
173184 writeln!(f, "{o}")?;
174185 }
175186 Ok(true)
176187 }
177188
189+/// Where the throwaway launcher scripts live: the per-user runtime dir when
190+/// there is one (0700 already, and cleared on logout), the temp dir otherwise.
191+/// Either way the subdirectory is made 0700, because on a shared box `/tmp` is
192+/// world-traversable and a prompt is the user's text.
193+fn script_dir() -> PathBuf {
194+ let base = std::env::var_os("XDG_RUNTIME_DIR")
195+ .map(PathBuf::from)
196+ .unwrap_or_else(std::env::temp_dir);
197+ base.join("termbridge")
198+}
199+
200+/// Write a script that runs `claude` on `prompt`, and return its path.
201+///
202+/// The prompt exists as a file rather than as part of a command line because it
203+/// is arbitrary user text and the command line it would otherwise land in is a
204+/// *tmux* one — tmux's single quotes have no escape, so there is no way to put
205+/// a quote through them, and its double quotes expand `#()`, which runs a
206+/// shell. A path this daemon generated is the only client text on that line,
207+/// and it is hex.
208+///
209+/// The script drops the shell it came from at the end rather than exiting: a
210+/// window that vanishes the moment Claude does takes the transcript with it.
211+pub fn write_prompt_script(prompt: &str) -> io::Result<PathBuf> {
212+ let dir = script_dir();
213+ fs::create_dir_all(&dir)?;
214+ fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
215+ let path = dir.join(format!("prompt-{}.sh", random_hex(8)));
216+ let mut f = fs::OpenOptions::new()
217+ .write(true)
218+ .create_new(true)
219+ .mode(0o700)
220+ .open(&path)?;
221+ // Single quotes, with the one escape sh allows: end the quote, an escaped
222+ // quote, start it again. Nothing else in the prompt is special inside them.
223+ let quoted = prompt.replace('\'', r"'\''");
224+ write!(
225+ f,
226+ "#!/bin/sh\n\
227+ prompt='{quoted}'\n\
228+ # Unlinked while the shell still holds it open, so this reads on.\n\
229+ rm -f -- \"$0\"\n\
230+ claude \"$prompt\"\n\
231+ exec \"${{SHELL:-/bin/sh}}\"\n"
232+ )?;
233+ Ok(path)
234+}
235+
236+/// Write a script that runs `command` in the user's shell, and return its path.
237+///
238+/// A file for the same reason [`write_prompt_script`] is one: the text is the
239+/// user's, and the line it would otherwise be spliced into is tmux's, which
240+/// cannot be escaped into safely.
241+///
242+/// The command runs under `$SHELL -c` rather than `/bin/sh -c` because the box
243+/// it was typed into looks like the shell in the pane beside it — the aliases,
244+/// functions and syntax that work there are what someone types here, and for a
245+/// fish user `sh` would reject half of them.
246+///
247+/// Then the shell is dropped into interactively rather than exited, which is
248+/// the whole point of running it here instead of in a scratch window: the
249+/// output stays on screen, in the directory the command ran in, and the pane is
250+/// a shell you can carry on in. A non-zero status is printed first, because the
251+/// prompt that replaces it is not going to say so.
252+pub fn write_command_script(command: &str) -> io::Result<PathBuf> {
253+ let dir = script_dir();
254+ fs::create_dir_all(&dir)?;
255+ fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
256+ let path = dir.join(format!("run-{}.sh", random_hex(8)));
257+ let mut f = fs::OpenOptions::new()
258+ .write(true)
259+ .create_new(true)
260+ .mode(0o700)
261+ .open(&path)?;
262+ // The same single-quote escape as the prompt script: end, escaped quote,
263+ // start again. Inside them nothing else in the command is special, so what
264+ // the shell below is handed is exactly what was typed.
265+ let quoted = command.replace('\'', r"'\''");
266+ write!(
267+ f,
268+ "#!/bin/sh\n\
269+ cmd='{quoted}'\n\
270+ # Unlinked while the shell still holds it open, so this reads on.\n\
271+ rm -f -- \"$0\"\n\
272+ \"${{SHELL:-/bin/sh}}\" -c \"$cmd\"\n\
273+ status=$?\n\
274+ [ \"$status\" -eq 0 ] || printf '\\n[exit %s]\\n' \"$status\"\n\
275+ exec \"${{SHELL:-/bin/sh}}\"\n"
276+ )?;
277+ Ok(path)
278+}
279+
280+/// Write a script that starts a session's first pane in `dir`, optionally with
281+/// Claude running on `prompt`, and return its path.
282+///
283+/// The directory goes in a file for the reason the prompt and the command do:
284+/// the line it would otherwise be spliced into is a *tmux* one, and a path is
285+/// allowed to contain a quote. `new-session -c` would be the direct way to say
286+/// this and there is no safe way to write it.
287+///
288+/// `cd` rather than anything cleverer, because what the *pane's* working
289+/// directory is is what tmux reports as the session's — so every window opened
290+/// in this session afterwards, by the panel's "+" or its `!`, inherits the
291+/// project directory without anything having to remember it.
292+///
293+/// A `cd` that fails does not close the window: it says so and hands over a
294+/// shell, which is the one state from which you can see what went wrong.
295+pub fn write_project_script(dir: &Path, prompt: Option<&str>) -> io::Result<PathBuf> {
296+ let dir = dir
297+ .to_str()
298+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path is not valid UTF-8"))?;
299+ let script_dir = script_dir();
300+ fs::create_dir_all(&script_dir)?;
301+ fs::set_permissions(&script_dir, fs::Permissions::from_mode(0o700))?;
302+ let path = script_dir.join(format!("project-{}.sh", random_hex(8)));
303+ let mut f = fs::OpenOptions::new()
304+ .write(true)
305+ .create_new(true)
306+ .mode(0o700)
307+ .open(&path)?;
308+ // The same single-quote escape the two scripts above use: end the quote, an
309+ // escaped quote, start it again.
310+ let quoted = |s: &str| s.replace('\'', r"'\''");
311+ write!(
312+ f,
313+ "#!/bin/sh\n\
314+ dir='{}'\n\
315+ # Unlinked while the shell still holds it open, so this reads on.\n\
316+ rm -f -- \"$0\"\n\
317+ cd \"$dir\" || printf '\\ncannot enter %s\\n' \"$dir\"\n",
318+ quoted(dir)
319+ )?;
320+ if let Some(prompt) = prompt {
321+ write!(
322+ f,
323+ "prompt='{}'\n\
324+ claude \"$prompt\"\n",
325+ quoted(prompt)
326+ )?;
327+ }
328+ writeln!(f, "exec \"${{SHELL:-/bin/sh}}\"")?;
329+ Ok(path)
330+}
331+
178332 fn random_hex(n: usize) -> String {
179333 let mut buf = vec![0u8; n];
180334 // Straight from the OS CSPRNG. Deliberately not a userspace PRNG — this is
⋯ 9 unchanged lines
addeddaemon/src/project.rs+286 −0
1+//! Directories, for the omnibar's path rows.
2+//!
3+//! The panel is a web page: it cannot stat a directory, and the one question it
4+//! has to answer before it can offer anything sensible is whether the path in
5+//! the box exists yet. So it asks, one directory at a time, and this module is
6+//! what answers — the names inside a directory and whether it is there at all.
7+//!
8+//! Deliberately narrow. Names only, never contents; one directory per question,
9+//! never a walk; and nothing outside the directory that was asked about. The
10+//! socket is authenticated and the thing on the other end of it is a terminal,
11+//! so this is not a privilege boundary — it is a ceiling on the shape of what
12+//! can leave the machine by accident.
13+
14+use std::path::{Path, PathBuf};
15+
16+/// The longest path the panel may ask about. Longer than any real one, short
17+/// enough that a query cannot be used to push work at the daemon.
18+pub const MAX_PATH: usize = 4096;
19+
20+/// The most names returned per directory, each of dirs and files. A source tree
21+/// with more entries than this exists; a *project directory* with more of them
22+/// that you would then pick one of by typing does not.
23+const MAX_ENTRIES: usize = 300;
24+
25+/// What is at a path.
26+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27+pub enum Kind {
28+ Dir,
29+ File,
30+ Missing,
31+ /// There, or possibly there, and not ours to look at. Deliberately one
32+ /// answer rather than two: the panel says "cannot read" either way, and
33+ /// distinguishing them would be reporting on a directory we were refused.
34+ Denied,
35+}
36+
37+impl Kind {
38+ pub fn as_str(self) -> &'static str {
39+ match self {
40+ Kind::Dir => "dir",
41+ Kind::File => "file",
42+ Kind::Missing => "missing",
43+ Kind::Denied => "denied",
44+ }
45+ }
46+}
47+
48+/// One directory, as the omnibar needs it.
49+#[derive(Debug, Clone, PartialEq, Eq)]
50+pub struct Listing {
51+ /// The expanded path. The panel matches this against the working
52+ /// directories tmux reports, which is how a path you already have a session
53+ /// on becomes a switch rather than a second session.
54+ pub path: PathBuf,
55+ pub kind: Kind,
56+ /// How many directories `mkdir -p` would have to create to make this one.
57+ /// Zero when it is already there. The panel says so before you press Enter,
58+ /// because "create one directory" and "create four" are different answers
59+ /// to a typo.
60+ pub creates: usize,
61+ /// Directory names inside it, sorted, visible ones first — the panel
62+ /// filters these by what you have typed of the next component.
63+ pub dirs: Vec<String>,
64+ /// The other names. Only ever used to say "that is a file": a path whose
65+ /// last component is one is not somewhere a session can start, and without
66+ /// this it would look like something to create.
67+ pub files: Vec<String>,
68+}
69+
70+/// `~/Code/foo` as an absolute path, or nothing.
71+///
72+/// The panel's own rule is that a leading `~` is what makes the box a path at
73+/// all, so this is deliberately strict about the rest: absolute or `~`-rooted,
74+/// no `.` or `..`, no `~user`. A relative path has no meaning here — there is
75+/// no directory for it to be relative *to* until a session exists, which is
76+/// the thing being asked for.
77+pub fn expand(raw: &str) -> Option<PathBuf> {
78+ expand_in(raw, home().as_deref())
79+}
80+
81+/// [`expand`] against a given home, which is the half of it worth testing:
82+/// `$HOME` is process-wide, and a test that set it would be setting it for
83+/// every other test running beside it.
84+fn expand_in(raw: &str, home: Option<&Path>) -> Option<PathBuf> {
85+ let s = raw.trim();
86+ if s.is_empty() || s.len() > MAX_PATH || s.chars().any(char::is_control) {
87+ return None;
88+ }
89+ let (mut path, rest) = match s.strip_prefix('~') {
90+ Some("") => (home?.to_path_buf(), ""),
91+ // `~user` is somebody else's home, which is not ours to guess at: what
92+ // follows the tilde has to be the separator.
93+ Some(rest) => (home?.to_path_buf(), rest.strip_prefix('/')?),
94+ None => (PathBuf::from("/"), s.strip_prefix('/')?),
95+ };
96+ for part in rest.split('/') {
97+ // Empty is a doubled or trailing slash, which is nothing.
98+ if part.is_empty() {
99+ continue;
100+ }
101+ if part == "." || part == ".." {
102+ return None;
103+ }
104+ path.push(part);
105+ }
106+ Some(path)
107+}
108+
109+fn home() -> Option<PathBuf> {
110+ let home = PathBuf::from(std::env::var_os("HOME")?);
111+ home.is_absolute().then_some(home)
112+}
113+
114+/// Answer one question about one directory.
115+///
116+/// `raw` is what the box holds up to the last `/`, so the entries are the
117+/// candidates for the component being typed. Whether the *full* path exists is
118+/// the panel's own arithmetic from `dirs` and `files` — one query per level
119+/// typed rather than one per keystroke.
120+pub fn list(raw: &str) -> Option<Listing> {
121+ let mut path = expand(raw)?;
122+ let (kind, mut dirs, mut files) = match std::fs::metadata(&path) {
123+ Ok(m) if m.is_dir() => match read_names(&path) {
124+ Some((d, f)) => (Kind::Dir, d, f),
125+ None => (Kind::Denied, Vec::new(), Vec::new()),
126+ },
127+ Ok(_) => (Kind::File, Vec::new(), Vec::new()),
128+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
129+ (Kind::Missing, Vec::new(), Vec::new())
130+ }
131+ Err(_) => (Kind::Denied, Vec::new(), Vec::new()),
132+ };
133+ dirs.truncate(MAX_ENTRIES);
134+ files.truncate(MAX_ENTRIES);
135+ // Symlinks resolved, but only for a directory that is there: this is the
136+ // string the panel matches against the working directories tmux reports,
137+ // and tmux reports a pane's real one. `~/code` being a link to `~/Code`
138+ // would otherwise be a session it could not see it already had.
139+ if kind == Kind::Dir {
140+ if let Ok(real) = std::fs::canonicalize(&path) {
141+ path = real;
142+ }
143+ }
144+ Some(Listing {
145+ creates: missing_ancestors(&path),
146+ path,
147+ kind,
148+ dirs,
149+ files,
150+ })
151+}
152+
153+/// Names in a directory, split into the ones you can descend into and the rest.
154+///
155+/// Symlinks are followed for the *classification only* — a link to a directory
156+/// is offered as one, because that is what typing it would land you in. Nothing
157+/// is followed to read through it, and an entry whose target cannot be stat'ed
158+/// is filed as a file rather than skipped, so a broken link is visible instead
159+/// of quietly missing.
160+///
161+/// Sorted, and hidden names last: a truncated listing should lose `.cache`
162+/// before it loses a project.
163+fn read_names(dir: &Path) -> Option<(Vec<String>, Vec<String>)> {
164+ let entries = std::fs::read_dir(dir).ok()?;
165+ let mut dirs = Vec::new();
166+ let mut files = Vec::new();
167+ for entry in entries.flatten() {
168+ // Not `to_string_lossy`: a name we cannot round-trip is a name the
169+ // panel cannot send back, and offering it would be offering a path that
170+ // does not exist.
171+ let Some(name) = entry.file_name().to_str().map(String::from) else {
172+ continue;
173+ };
174+ let is_dir = entry
175+ .file_type()
176+ .ok()
177+ .map(|t| {
178+ if t.is_symlink() {
179+ std::fs::metadata(entry.path())
180+ .map(|m| m.is_dir())
181+ .unwrap_or(false)
182+ } else {
183+ t.is_dir()
184+ }
185+ })
186+ .unwrap_or(false);
187+ if is_dir { &mut dirs } else { &mut files }.push(name);
188+ }
189+ for list in [&mut dirs, &mut files] {
190+ list.sort_by(|a, b| {
191+ let hidden = |s: &String| s.starts_with('.');
192+ hidden(a).cmp(&hidden(b)).then_with(|| a.cmp(b))
193+ });
194+ }
195+ Some((dirs, files))
196+}
197+
198+/// How many components of `path` do not exist yet.
199+fn missing_ancestors(path: &Path) -> usize {
200+ let mut n = 0;
201+ for a in path.ancestors() {
202+ // `exists()` is false for a path we are not allowed to stat, which
203+ // would overcount. It is a number in a row's subtitle, and the mkdir
204+ // that follows reports its own failure.
205+ if a.exists() {
206+ break;
207+ }
208+ n += 1;
209+ }
210+ n
211+}
212+
213+#[cfg(test)]
214+mod tests {
215+ use super::*;
216+
217+ const HOME: &str = "/home/someone";
218+
219+ fn home() -> Option<&'static Path> {
220+ Some(Path::new(HOME))
221+ }
222+
223+ #[test]
224+ fn expands_tilde_and_absolute_only() {
225+ let h = PathBuf::from(HOME);
226+ assert_eq!(expand_in("~", home()), Some(h.clone()));
227+ assert_eq!(expand_in("~/Code", home()), Some(h.join("Code")));
228+ assert_eq!(expand_in("~/Code/", home()), Some(h.join("Code")));
229+ assert_eq!(expand_in("~/Code//foo", home()), Some(h.join("Code/foo")));
230+ assert_eq!(
231+ expand_in("/etc/ssh", home()),
232+ Some(PathBuf::from("/etc/ssh"))
233+ );
234+ }
235+
236+ #[test]
237+ fn refuses_anything_that_is_not_a_rooted_path() {
238+ for bad in [
239+ "",
240+ "Code", // relative
241+ "./Code", // relative
242+ "~/Code/../../..", // traversal
243+ "~/..",
244+ "/etc/../root",
245+ "~root/x", // another user's home is not ours to expand
246+ "~/a\nb", // control characters
247+ ] {
248+ assert_eq!(expand_in(bad, home()), None, "{bad} should not expand");
249+ }
250+ assert_eq!(
251+ expand_in(&format!("~/{}", "a".repeat(MAX_PATH)), home()),
252+ None
253+ );
254+ // No home, no tilde. An absolute path is still fine.
255+ assert_eq!(expand_in("~/Code", None), None);
256+ assert!(expand_in("/tmp", None).is_some());
257+ }
258+
259+ #[test]
260+ fn lists_a_directory_and_says_what_is_missing() {
261+ let tmp = std::env::temp_dir().join(format!("tb-project-list-{}", std::process::id()));
262+ let _ = std::fs::remove_dir_all(&tmp);
263+ std::fs::create_dir_all(tmp.join("alpha")).unwrap();
264+ std::fs::create_dir_all(tmp.join(".hidden")).unwrap();
265+ std::fs::write(tmp.join("notes.txt"), "hi").unwrap();
266+ let at = |p: &Path| list(&p.to_string_lossy()).expect("absolute paths expand");
267+
268+ let l = at(&tmp);
269+ assert_eq!(l.kind, Kind::Dir);
270+ assert_eq!(l.creates, 0);
271+ // Visible first, hidden after it.
272+ assert_eq!(l.dirs, ["alpha", ".hidden"]);
273+ assert_eq!(l.files, ["notes.txt"]);
274+
275+ assert_eq!(at(&tmp.join("alpha")).kind, Kind::Dir);
276+ assert_eq!(at(&tmp.join("notes.txt")).kind, Kind::File);
277+
278+ // Three levels of nothing is three directories to make.
279+ let l = at(&tmp.join("a/b/c"));
280+ assert_eq!(l.kind, Kind::Missing);
281+ assert_eq!(l.creates, 3);
282+ assert!(l.dirs.is_empty());
283+
284+ let _ = std::fs::remove_dir_all(&tmp);
285+ }
286+}
modifieddaemon/src/pty.rs+87 −7
⋯ 30 unchanged lines
3131 }
3232 }
3333
34-pub const DEFAULT_SESSION: &str = "browser";
34+/// The session the sidebar lands in with nothing configured, and the one the
35+/// panel's "+" puts a new window in when it is not adding to a group.
36+///
37+/// Named for the role rather than for this client: it is where windows go when
38+/// nothing has said otherwise, which is what a user's other tmux sessions are
39+/// *not*. `--session` overrides it, and a lone pre-existing session is adopted
40+/// ahead of creating this one — see `adopt_sole_session`.
41+pub const DEFAULT_SESSION: &str = "default";
3542
3643 /// Session names reach `execvp` as a separate argv element, never a shell, so
3744 /// metacharacters cannot inject a command. The one real hazard is a leading
⋯ 17 unchanged lines
5562 Some(s.to_string())
5663 }
5764
65+/// The most text the omnibar may hand to a new Claude. Long enough for a
66+/// paragraph pasted into the box, short enough that nothing can push a
67+/// megabyte of it through the socket a keystroke at a time.
68+const MAX_PROMPT: usize = 8192;
69+
70+/// A prompt for "send to Claude": any text, minus the bytes that would make it
71+/// something other than text.
72+///
73+/// Deliberately permissive about punctuation, unlike the name validators above
74+/// — a prompt is a sentence, and quotes and semicolons belong in one. It is
75+/// safe to be, because the prompt never reaches a command line: it is written
76+/// to a file, single-quoted, by [`crate::paths::write_prompt_script`]. What is
77+/// rejected is control characters, which would arrive at a terminal as
78+/// something to obey rather than something to read.
79+pub fn valid_prompt(raw: &str) -> Option<String> {
80+ let s = raw.trim();
81+ if s.is_empty() || s.len() > MAX_PROMPT {
82+ return None;
83+ }
84+ if s.chars().any(|c| c.is_control() && c != '\n' && c != '\t') {
85+ return None;
86+ }
87+ Some(s.to_string())
88+}
89+
90+/// The most text the omnibar may hand to a shell. A command line is a line,
91+/// not a document; anything past this is a paste that meant to go somewhere
92+/// else.
93+const MAX_COMMAND: usize = 4096;
94+
95+/// A shell command for the omnibar's `!` — a line handed to `$SHELL -c`.
96+///
97+/// As permissive about punctuation as [`valid_prompt`], and for the same
98+/// reason: the text is shell source, so pipes, quotes and `&&` are the point of
99+/// it, and it never reaches a *tmux* command line — it is written to a file,
100+/// single-quoted, by [`crate::paths::write_command_script`].
101+///
102+/// Newlines are rejected where a prompt allows them. The box is one line, the
103+/// text is run as one, and a newline arriving here means something built the
104+/// request by hand.
105+pub fn valid_command(raw: &str) -> Option<String> {
106+ let s = raw.trim();
107+ if s.is_empty() || s.len() > MAX_COMMAND {
108+ return None;
109+ }
110+ if s.chars().any(|c| c.is_control()) {
111+ return None;
112+ }
113+ Some(s.to_string())
114+}
115+
58116 /// Existing tmux sessions, so the sidebar can offer them instead of guessing.
59117 ///
118+/// Oldest first, by session id, for the same reason the status frames are —
119+/// this list draws the first row of tabs, and it would otherwise be in tmux's
120+/// alphabetical order until the first status frame silently reshuffled it.
121+///
60122 /// `global_args` carries the server-selection flags from the profile; without
61123 /// them this asks the *default* tmux server, which is a different set of
62124 /// sessions than the one the sidebar is attached to.
63125 pub fn list_sessions(global_args: &[String]) -> Vec<String> {
64126 let Ok(out) = std::process::Command::new("tmux")
65127 .args(global_args)
66- .args(["list-sessions", "-F", "#{session_name}"])
128+ .args(["list-sessions", "-F", "#{session_id}\t#{session_name}"])
67129 .output()
68130 else {
69131 return Vec::new();
⋯ 1 unchanged line
71133 if !out.status.success() {
72134 return Vec::new(); // no server running yet
73135 }
74- String::from_utf8_lossy(&out.stdout)
136+ let text = String::from_utf8_lossy(&out.stdout);
137+ let mut sessions: Vec<(u64, String)> = text
75138 .lines()
76- .map(str::trim)
77- .filter(|l| !l.is_empty())
78- .map(str::to_string)
79- .collect()
139+ .filter_map(|line| {
140+ // Name last: a session name is allowed to contain a tab.
141+ let (id, name) = line.split_once('\t')?;
142+ let name = name.trim_end_matches(['\r', '\n']);
143+ (!name.is_empty()).then(|| (crate::status::session_ordinal(id), name.to_string()))
144+ })
145+ .collect();
146+ sessions.sort_by_key(|(ordinal, _)| *ordinal);
147+ sessions.into_iter().map(|(_, name)| name).collect()
80148 }
81149
82150 /// The one existing tmux session, when there is exactly one.
⋯ 35 unchanged lines
118186 Some(s.to_string())
119187 }
120188
189+/// A tmux session id (`$1`). Session *names* can hold quotes and spaces, so
190+/// when the daemon has to name a session in a command line of its own it uses
191+/// the id, which is `$` plus digits and nothing else.
192+pub fn valid_session_id(raw: &str) -> Option<String> {
193+ let s = raw.trim();
194+ let digits = s.strip_prefix('$')?;
195+ if digits.is_empty() || digits.len() > 12 || !digits.chars().all(|c| c.is_ascii_digit()) {
196+ return None;
197+ }
198+ Some(s.to_string())
199+}
200+
121201 /// Our own pty's slave path, on its way into `switch-client -c`. It comes from
122202 /// the kernel rather than a client, but it is still quoted into a command line,
123203 /// so it gets the same treatment.
⋯ 236 unchanged lines
modifieddaemon/src/server.rs+1309 −29
⋯ 5 unchanged lines
66
77 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
88 use std::sync::Arc;
9-use std::time::Duration;
9+use std::sync::atomic::{AtomicUsize, Ordering};
10+use std::time::{Duration, Instant};
1011
1112 use futures_util::{SinkExt, StreamExt};
1213 use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
1314 use tokio::net::{TcpListener, TcpStream};
14-use tokio::sync::{mpsc, Mutex};
15+use tokio::sync::{Mutex, mpsc};
16+use tokio_tungstenite::WebSocketStream;
17+use tokio_tungstenite::tungstenite::Message;
1518 use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
1619 use tokio_tungstenite::tungstenite::http;
17-use tokio_tungstenite::tungstenite::Message;
18-use tokio_tungstenite::WebSocketStream;
1920
2021 use crate::auth::{self, Denied};
2122
⋯ 20 unchanged lines
4243 /// TLS identity. When present the listener serves wss:// and ws:// on the
4344 /// same port, chosen per-connection by sniffing the first byte.
4445 pub tls: Option<tokio_rustls::TlsAcceptor>,
46+ /// Exit once no client has been connected for this long. `None` runs
47+ /// forever, which is what a hand-started daemon wants.
48+ ///
49+ /// Only sane because tmux holds the sessions: exiting drops no state, and
50+ /// under socket activation the next connection starts us again.
51+ pub idle_timeout: Option<Duration>,
4552 }
4653
4754 impl Config {
⋯ 11 unchanged lines
5966 adopt_sole_session: false,
6067 echo_only: false,
6168 tls: None,
69+ idle_timeout: None,
6270 }
6371 }
6472
⋯ 14 unchanged lines
7987
8088 #[derive(Debug, Clone, PartialEq, Eq)]
8189 pub enum Event {
82- Accepted { origin: String },
90+ Accepted {
91+ origin: String,
92+ },
93+ /// `idle_timeout` elapsed with nobody connected. The caller is expected to
94+ /// stop the daemon; the listener keeps running until it does.
95+ Idle,
8396 Rejected {
8497 origin: Option<String>,
8598 why: Denied,
⋯ 12 unchanged lines
98111 config: Config,
99112 failures: Mutex<Failures>,
100113 events: mpsc::UnboundedSender<Event>,
114+ /// Connections currently being served, and when the count last hit zero.
115+ /// Only read by the idle watchdog.
116+ live: AtomicUsize,
117+ quiet_since: std::sync::Mutex<Instant>,
118+}
119+
120+/// Holds the live-connection count up for as long as one connection is being
121+/// served. Dropping it restarts the idle clock, so the timeout measures time
122+/// since the *last* client left rather than since startup.
123+struct Live(Arc<State>);
124+
125+impl Live {
126+ fn new(state: &Arc<State>) -> Self {
127+ state.live.fetch_add(1, Ordering::SeqCst);
128+ Live(Arc::clone(state))
129+ }
130+}
131+
132+impl Drop for Live {
133+ fn drop(&mut self) {
134+ if self.0.live.fetch_sub(1, Ordering::SeqCst) == 1 {
135+ *self.0.quiet_since.lock().expect("not poisoned") = Instant::now();
136+ }
137+ }
101138 }
102139
103140 impl State {
⋯ 38 unchanged lines
142179 /// only purpose is to create a remotely exploitable configuration.
143180 pub async fn start(config: Config, port: u16) -> std::io::Result<Self> {
144181 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
145- let listener = TcpListener::bind(addr).await?;
182+ Self::start_on(config, TcpListener::bind(addr).await?)
183+ }
184+
185+ /// Serve on a socket someone else bound — in practice the one systemd
186+ /// handed us via socket activation (see [`crate::activation`]).
187+ ///
188+ /// The listener must already be non-blocking; `TcpListener::from_std`
189+ /// requires it.
190+ pub fn from_std(config: Config, listener: std::net::TcpListener) -> std::io::Result<Self> {
191+ Self::start_on(config, TcpListener::from_std(listener)?)
192+ }
193+
194+ fn start_on(config: Config, listener: TcpListener) -> std::io::Result<Self> {
146195 let addr = listener.local_addr()?;
147196
148197 // Belt and braces: if this ever regresses, fail loudly at startup
149- // rather than quietly listening on the network.
198+ // rather than quietly listening on the network. Also the last line of
199+ // defence for an activated socket, whose address came from a unit file.
150200 assert!(
151201 addr.ip().is_loopback(),
152202 "refusing to serve on non-loopback address {addr}"
153203 );
154204
155205 let (tx, rx) = mpsc::unbounded_channel();
206+ let idle_timeout = config.idle_timeout;
156207 let state = Arc::new(State {
157208 config,
158209 failures: Mutex::new(Failures {
⋯ 1 unchanged line
160211 locked_until: None,
161212 }),
162213 events: tx,
214+ live: AtomicUsize::new(0),
215+ quiet_since: std::sync::Mutex::new(Instant::now()),
163216 });
164217
218+ if let Some(timeout) = idle_timeout {
219+ tokio::spawn(watch_idle(Arc::clone(&state), timeout));
220+ }
221+
165222 let task = tokio::spawn(async move {
166223 loop {
167224 let Ok((stream, peer)) = listener.accept().await else {
⋯ 1 unchanged line
169226 };
170227 let state = Arc::clone(&state);
171228 tokio::spawn(async move {
229+ let _live = Live::new(&state);
172230 let _ = handle_conn(stream, peer, state).await;
173231 });
174232 }
⋯ 19 unchanged lines
194252 }
195253 }
196254
255+/// Emit [`Event::Idle`] once nothing has been connected for `timeout`.
256+///
257+/// Fires at most once: after that the caller is shutting down, and a second
258+/// notice would only race the exit.
259+async fn watch_idle(state: Arc<State>, timeout: Duration) {
260+ // Poll rather than wake on the transition: a client that connects and
261+ // leaves during the wait has to restart the clock, and a coarse tick keeps
262+ // that logic in one place. A second of overshoot on a five-minute timeout
263+ // costs nothing.
264+ let tick = (timeout / 10).clamp(Duration::from_millis(50), Duration::from_secs(5));
265+ loop {
266+ tokio::time::sleep(tick).await;
267+ if state.live.load(Ordering::SeqCst) > 0 {
268+ continue;
269+ }
270+ let quiet = state.quiet_since.lock().expect("not poisoned").elapsed();
271+ if quiet >= timeout {
272+ state.emit(Event::Idle);
273+ return;
274+ }
275+ }
276+}
277+
197278 fn deny(why: Denied) -> ErrorResponse {
198279 http::Response::builder()
199280 .status(why.status())
⋯ 57 unchanged lines
257338
258339 /// Serve one already-negotiated stream: either the certificate-trust landing
259340 /// page, or the WebSocket handshake.
260-async fn dispatch<S>(mut stream: S, state: Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
341+async fn dispatch<S>(
342+ mut stream: S,
343+ state: Arc<State>,
344+) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
261345 where
262346 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
263347 {
264348 // Look at the request head so a browser that navigated here to accept the
265349 // certificate gets an explanation instead of a protocol error.
266- let head = crate::rewind::read_head(&mut stream, 8192).await.unwrap_or_default();
350+ let head = crate::rewind::read_head(&mut stream, 8192)
351+ .await
352+ .unwrap_or_default();
267353 if !crate::rewind::is_websocket_upgrade(&head) {
268354 state.emit(Event::Rejected {
269355 origin: None,
⋯ 26 unchanged lines
296382 "other data, and in particular it never hands out the auth token.</p>"
297383 );
298384
299-async fn handshake<S>(stream: S, state: Arc<State>) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
385+async fn handshake<S>(
386+ stream: S,
387+ state: Arc<State>,
388+) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
300389 where
301390 S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
302391 {
⋯ 41 unchanged lines
344433 None => (Denied::NotAWebSocketUpgrade, Some(e.to_string())),
345434 };
346435 state.record_failure().await;
347- state.emit(Event::Rejected { origin, why, detail });
436+ state.emit(Event::Rejected {
437+ origin,
438+ why,
439+ detail,
440+ });
348441 return Ok(());
349442 }
350443 };
⋯ 43 unchanged lines
394487 // Enumerated server-side; the client picks from reality rather
395488 // than inventing names.
396489 "sessions": if is_tmux { crate::pty::list_sessions(&state.config.profile.tmux_global_args()) } else { Vec::new() },
490+ // Here rather than in the status frames, which are for what tmux
491+ // is doing right now: this list comes from files on disk that
492+ // change between sessions, not during one, and re-sending it every
493+ // tick would be a hundred names repeated for nothing. A reconnect
494+ // picks up an edited ssh config.
495+ "hosts": if is_tmux { crate::ssh::hosts() } else { Vec::new() },
397496 })
398497 .to_string()
399498 .into(),
⋯ 17 unchanged lines
417516 {
418517 // Wait for the client to tell us its size before spawning, so the shell's
419518 // first prompt is drawn at the right width.
420- let (cols, rows, requested) = match tokio::time::timeout(Duration::from_secs(5), ws.next()).await
421- {
422- Ok(Some(Ok(Message::Text(t)))) => parse_open(&t).unwrap_or((80, 24, None)),
423- _ => (80, 24, None),
424- };
519+ let (cols, rows, requested) =
520+ match tokio::time::timeout(Duration::from_secs(5), ws.next()).await {
521+ Ok(Some(Ok(Message::Text(t)))) => parse_open(&t).unwrap_or((80, 24, None)),
522+ _ => (80, 24, None),
523+ };
425524
426525 // The client may name a tmux session, but only a name that survives
427526 // validation, and only when we are actually running tmux. Anything else
⋯ 38 unchanged lines
466565 // watches for those changes and carries the sidebar's requests back.
467566 let (status_tx, mut status_rx) = mpsc::channel::<String>(8);
468567 let (tmux_tx, tmux_rx) = mpsc::channel::<TmuxRequest>(8);
568+ // The same channel, for the frames this loop answers itself rather than
569+ // getting from tmux — see the `path` query below.
570+ let frames_tx = status_tx.clone();
469571 if profile.program == "tmux" {
470572 // The session the interactive client was pointed at. Only used to give
471573 // the control client something to attach to; from then on tmux tells us
⋯ 53 unchanged lines
525627 // dropping the request beats stalling the terminal.
526628 let _ = tmux_tx.try_send(req);
527629 }
630+ if let Some(q) = parse_path_query(&t) {
631+ // On a thread of its own: a directory on a stalled
632+ // network mount would otherwise hold up the pty
633+ // this loop is also pumping. The answer comes back
634+ // through the frame channel like any other.
635+ let tx = frames_tx.clone();
636+ tokio::task::spawn_blocking(move || {
637+ let _ = tx.blocking_send(path_answer(&q));
638+ });
639+ }
528640 }
529641 Some(Ok(Message::Close(_))) | None => break,
530642 Some(Err(_)) => break,
⋯ 39 unchanged lines
570682 /// deliberately moves every client watching that session, exactly as
571683 /// pressing `prefix 2` in the terminal would.
572684 SelectWindow(String),
685+ /// Select a window *and* bring our client to its session — the sidebar's
686+ /// tab click in Tab Group mode, where the row holds windows from every
687+ /// session at once and clicking one has to cross the session boundary.
688+ ///
689+ /// `Focus` is the same move addressed by pane; this one is addressed by
690+ /// window, because a tab is a window and the pane it lands on is whichever
691+ /// one that window last had selected.
692+ GotoWindow(String),
573693 /// Open a window in a session and select it — the sidebar's "+".
574694 NewWindow(String),
695+ /// Open a window running Claude on a prompt — the omnibar's "send to
696+ /// Claude", which is what the box does with text that names nothing.
697+ ///
698+ /// The window starts in the session's own working directory rather than in
699+ /// `$HOME`: the point of asking from *this* session is that the answer is
700+ /// about the code this session is sitting in.
701+ ///
702+ /// The prompt is the one piece of free text the wire protocol carries. It
703+ /// never reaches a tmux command line — see [`command_lines`].
704+ Claude { session: String, prompt: String },
705+ /// Open a window running a shell command — the omnibar's `!`, which is what
706+ /// the box does with text that names nothing and was meant for a shell
707+ /// rather than for Claude.
708+ ///
709+ /// The same window as [`TmuxRequest::Claude`] in every other respect: the
710+ /// session's own working directory, and the command in a script rather than
711+ /// on the tmux command line. The window outlives the command — see
712+ /// [`crate::paths::write_command_script`].
713+ Run { session: String, command: String },
714+ /// Start a session in a directory, making the directory if it is not there
715+ /// yet — the omnibar's `~/Code/foo` rows.
716+ ///
717+ /// The one request that touches the filesystem before it touches tmux: the
718+ /// `mkdir -p` happens in [`prepare`], so a path that cannot be created is
719+ /// an error the panel is told about rather than a session standing in the
720+ /// wrong place. `path` is already expanded and absolute by the time it is
721+ /// in here — see [`crate::project::expand`].
722+ ///
723+ /// With a prompt, the first pane runs Claude on it. Without one it is a
724+ /// shell: the prompt is what says Claude was wanted.
725+ NewProject {
726+ path: String,
727+ name: String,
728+ prompt: Option<String>,
729+ },
730+ /// Open a window connected to another machine — the omnibar's ssh rows.
731+ ///
732+ /// Local in every way that matters to this daemon: an ordinary tmux window
733+ /// on this host, running ssh. The panel's tabs, drag, status frames and
734+ /// session model know nothing about it, which is what makes it cheap.
735+ ///
736+ /// What it is *not* is a remote session. A Claude running on the far end
737+ /// has no pane on this tmux server and its hooks cannot reach this
738+ /// daemon's socket, so it will not appear in the tab strip's agent glyphs
739+ /// — reaching that needs a daemon on the far end, not a window here.
740+ Ssh { session: String, host: String },
741+ /// Put one window next to another — the sidebar's tab drag.
742+ ///
743+ /// A real `move-window`, not a display order: the panel's tabs and the
744+ /// terminal's own status line show the same windows in the same order, and
745+ /// `prefix 2` still selects the second tab afterwards. Both ends are window
746+ /// ids, so the move is expressed relative to a window rather than to an
747+ /// index that may have shifted since the drag started.
748+ MoveWindow {
749+ window: String,
750+ /// The window to land beside.
751+ target: String,
752+ /// After `target` rather than before it.
753+ after: bool,
754+ },
755+ /// Move a window into another session — dropping a tab on a collapsed
756+ /// group's chip in Tab Group mode, where the group has no visible window
757+ /// to express the move against.
758+ MoveWindowToSession { window: String, session: String },
759+ /// Move a window into a session that does not exist yet — dragging a tab
760+ /// onto the row's "+", which is Chrome's "drag a tab out into a window of
761+ /// its own" written for tmux.
762+ ///
763+ /// tmux has no one command for this: `move-window` needs a session to move
764+ /// *to*, and `new-session` cannot adopt a window. So the session is made
765+ /// first, with a placeholder window nothing runs in, and that placeholder
766+ /// is killed once the real window is in. See [`command_lines`].
767+ NewSessionWithWindow { window: String, name: String },
768+ /// Set or clear a session's group colour, which the panel keeps in a tmux
769+ /// user option so it survives a rename and every panel agrees on it.
770+ ///
771+ /// The session is named by id rather than by name for the usual reason —
772+ /// an id cannot contain a quote or a space — and because a rename between
773+ /// the click and the command would otherwise send the colour to whichever
774+ /// session inherited the name.
775+ SetSessionColor {
776+ session: String,
777+ /// `None` unsets the option, which is how the panel goes back to
778+ /// picking a colour itself.
779+ color: Option<String>,
780+ },
781+ /// Rename a session — the group chip's context menu in Tab Group mode.
782+ ///
783+ /// By id for the same reason the colour is: the name is what is being
784+ /// changed, so naming the target by it would race with anyone else
785+ /// renaming the same session, and an id cannot carry a quote or a space.
786+ ///
787+ /// The new name goes through [`crate::pty::valid_session_name`], which is
788+ /// the same gate the sidebar's session field already passes — so a rename
789+ /// can only produce a name the panel could have created in the first place.
790+ RenameSession { session: String, name: String },
575791 /// Close a window and everything running in it — the sidebar's tab ✕.
576792 ///
577793 /// The one entry here that destroys anything, and tmux has no undo for it.
⋯ 3 unchanged lines
581797 KillWindow(String),
582798 }
583799
800+/// The name of the throwaway window a session is born with when it is being
801+/// made to hold a window that already exists. It lives for three commands and
802+/// nothing runs in it; the name only has to be one no real window will have,
803+/// and `valid_session_name` cannot produce it as a session name either.
804+const PLACEHOLDER_WINDOW: &str = "termbridge-placeholder";
805+
806+/// A group colour: a hue in degrees, or `-1` for grey.
807+///
808+/// Deliberately not "any short string". The value is written into a tmux
809+/// command line, and the sidebar only ever produces these, so anything else is
810+/// a client that is not our sidebar and gets nothing.
811+fn valid_group_color(raw: &str) -> Option<String> {
812+ let s = raw.trim();
813+ if s == "-1" {
814+ return Some(s.to_string());
815+ }
816+ let n: u16 = s.parse().ok()?;
817+ (n < 360).then(|| n.to_string())
818+}
819+
820+/// The omnibar asking about one directory: `{"type":"path","q":"~/Code"}`.
821+///
822+/// The only client frame that gets an answer rather than an effect. It is a
823+/// question about the daemon's own filesystem, so it is deliberately not a
824+/// `TmuxRequest` — nothing about it reaches tmux, and it must not be able to.
825+fn parse_path_query(text: &str) -> Option<String> {
826+ let v: serde_json::Value = serde_json::from_str(text).ok()?;
827+ if v.get("type")?.as_str()? != "path" {
828+ return None;
829+ }
830+ let q = v.get("q")?.as_str()?;
831+ (q.len() <= crate::project::MAX_PATH).then(|| q.to_string())
832+}
833+
834+/// The answer frame. `q` is echoed back so the panel can drop a reply that
835+/// arrives after the box has moved on — the queries are one per directory
836+/// typed, and they can land out of order.
837+fn path_answer(q: &str) -> String {
838+ match crate::project::list(q) {
839+ Some(l) => serde_json::json!({
840+ "type": "path",
841+ "q": q,
842+ "path": l.path.to_string_lossy(),
843+ "kind": l.kind.as_str(),
844+ "creates": l.creates,
845+ "dirs": l.dirs,
846+ "files": l.files,
847+ }),
848+ // Not a path we would expand — relative, `..`, `~someone-else`. Said
849+ // out loud rather than left unanswered, so the panel shows "not a path"
850+ // instead of waiting for a reply that is never coming.
851+ None => serde_json::json!({
852+ "type": "path", "q": q, "path": "", "kind": "invalid",
853+ "creates": 0, "dirs": [], "files": [],
854+ }),
855+ }
856+ .to_string()
857+}
858+
584859 fn parse_tmux_request(text: &str) -> Option<TmuxRequest> {
585860 let v: serde_json::Value = serde_json::from_str(text).ok()?;
586861 if v.get("type")?.as_str()? != "tmux" {
⋯ 7 unchanged lines
594869 "select-window" => {
595870 crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::SelectWindow)
596871 }
872+ "goto-window" => crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::GotoWindow),
597873 "new-window" => crate::pty::valid_session_name(arg("session")?).map(TmuxRequest::NewWindow),
874+ "claude" => Some(TmuxRequest::Claude {
875+ session: crate::pty::valid_session_name(arg("session")?)?,
876+ prompt: crate::pty::valid_prompt(arg("prompt")?)?,
877+ }),
878+ "run" => Some(TmuxRequest::Run {
879+ session: crate::pty::valid_session_name(arg("session")?)?,
880+ command: crate::pty::valid_command(arg("command")?)?,
881+ }),
882+ "new-project" => Some(TmuxRequest::NewProject {
883+ // Expanded here rather than in the panel: `~` is the daemon's home,
884+ // not the browser's, and one implementation of what a path means is
885+ // the only way the row and the mkdir can agree.
886+ path: crate::project::expand(arg("path")?)?
887+ .to_str()
888+ .map(String::from)?,
889+ name: crate::pty::valid_session_name(arg("name")?)?,
890+ // Absent is a shell; present has to survive the prompt validator,
891+ // because a request carrying an unusable prompt is one built by
892+ // something other than our panel.
893+ prompt: match arg("prompt") {
894+ None => None,
895+ Some(p) => Some(crate::pty::valid_prompt(p)?),
896+ },
897+ }),
898+ "ssh" => Some(TmuxRequest::Ssh {
899+ session: crate::pty::valid_session_name(arg("session")?)?,
900+ host: crate::ssh::valid_ssh_host(arg("host")?)?,
901+ }),
902+ "move-window-to-session" => Some(TmuxRequest::MoveWindowToSession {
903+ window: crate::pty::valid_window_id(arg("window")?)?,
904+ session: crate::pty::valid_session_name(arg("session")?)?,
905+ }),
906+ "new-session-with-window" => Some(TmuxRequest::NewSessionWithWindow {
907+ window: crate::pty::valid_window_id(arg("window")?)?,
908+ name: crate::pty::valid_session_name(arg("name")?)?,
909+ }),
910+ "move-window" => Some(TmuxRequest::MoveWindow {
911+ window: crate::pty::valid_window_id(arg("window")?)?,
912+ target: crate::pty::valid_window_id(arg("target")?)?,
913+ after: v.get("after").and_then(|x| x.as_bool()).unwrap_or(false),
914+ }),
915+ "rename-session" => Some(TmuxRequest::RenameSession {
916+ session: crate::pty::valid_session_id(arg("session")?)?,
917+ name: crate::pty::valid_session_name(arg("name")?)?,
918+ }),
598919 "kill-window" => crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::KillWindow),
920+ "set-session-color" => Some(TmuxRequest::SetSessionColor {
921+ session: crate::pty::valid_session_id(arg("session")?)?,
922+ // Absent means unset. Present means it has to be a colour we would
923+ // have produced ourselves — this string is quoted into a command
924+ // line to a live tmux server, so "it looks like a number" is the
925+ // whole of what may go through.
926+ color: match arg("color") {
927+ None => None,
928+ Some(c) => Some(valid_group_color(c)?),
929+ },
930+ }),
599931 _ => None,
600932 }
601933 }
602934
603935 /// Command lines for a request. `tty` identifies our interactive client, so the
604936 /// switch moves *it* rather than whichever client tmux would otherwise pick.
937+/// What to call the window a `!` command runs in: its first word, when that
938+/// word is a bare one.
939+///
940+/// The one place user text is allowed onto a tmux command line, and it is
941+/// allowed only after being narrowed to the characters a program name is made
942+/// of — no quote, no space, no `#`, so neither tmux's quoting nor its `#()`
943+/// expansion has anything to work with. Anything else is `run`, which costs a
944+/// worse window name and nothing more.
945+fn window_name_for(command: &str) -> &str {
946+ let word = command.split_whitespace().next().unwrap_or_default();
947+ let word = word.rsplit('/').next().unwrap_or(word);
948+ let plain = !word.is_empty()
949+ && word.len() <= 32
950+ && word
951+ .chars()
952+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
953+ && !word.starts_with('-');
954+ if plain { word } else { "run" }
955+}
956+
957+/// Whatever a request has to do outside tmux before tmux hears about it.
958+///
959+/// One request needs this and the rest are `Ok(())`: a new project's directory
960+/// has to exist before a session can start in it. It is here rather than inside
961+/// [`command_lines`] because it can fail in a way the person who asked needs to
962+/// hear about — a full disk, a read-only mount, a path under a file — and
963+/// `command_lines` has nowhere to say so. The error goes back as the same
964+/// `tmux-error` frame a rejected tmux command produces, which the panel already
965+/// shows.
966+fn prepare(req: &TmuxRequest) -> Result<(), String> {
967+ match req {
968+ TmuxRequest::NewProject { path, .. } => {
969+ std::fs::create_dir_all(path).map_err(|e| format!("{path}: {e}"))
970+ }
971+ _ => Ok(()),
972+ }
973+}
974+
605975 fn command_lines(req: &TmuxRequest, tty: Option<&str>) -> Vec<String> {
606976 let client = tty
607977 .and_then(crate::pty::valid_tty)
⋯ 13 unchanged lines
621991 // No `-c`: a window id already identifies its session, and selecting a
622992 // window is a property of the session rather than of our client.
623993 TmuxRequest::SelectWindow(id) => vec![format!("select-window -t '{id}'")],
994+ // Two halves that have to be in this order: select the window first, so
995+ // the client arrives at the session already looking at the right one
996+ // rather than at whatever was current and then jumping.
997+ TmuxRequest::GotoWindow(id) => vec![format!(
998+ "select-window -t '{id}' ; switch-client{client} -t '{id}'"
999+ )],
6241000 // `-a` inserts after the current window instead of claiming an index
6251001 // that may already be taken, which is an error rather than a shuffle.
6261002 // The trailing colon targets the session's current window.
627- TmuxRequest::NewWindow(name) => vec![format!("new-window -a -t '{name}:'")],
1003+ //
1004+ // Then the client follows it. `new-window` selects what it created
1005+ // inside its own session, but the session may not be the one this panel
1006+ // is on — the sidebar's "+" adds to the default session from anywhere,
1007+ // and a group's menu adds to that group. A new window you are not taken
1008+ // to is a worse answer than no new window, so the switch is part of the
1009+ // same request rather than something the sidebar has to chase with the
1010+ // id it does not have yet.
1011+ //
1012+ // `-c '#{pane_current_path}'` is a tmux format expanded against the
1013+ // target — the session's current pane — so the window starts in the
1014+ // directory the session is already in rather than in whatever the
1015+ // daemon's own working directory happens to be.
1016+ TmuxRequest::NewWindow(name) => vec![format!(
1017+ "new-window -a -t '{name}:' -c '#{{pane_current_path}}' ; \
1018+ switch-client{client} -t '{name}:'"
1019+ )],
1020+ // The same window as above, with two differences.
1021+ //
1022+ // `-c '#{pane_current_path}'` is a tmux format, expanded against the
1023+ // target — the session's current pane — so the daemon never has to
1024+ // carry a path across the socket and a path with a quote in it cannot
1025+ // become part of this line.
1026+ //
1027+ // The command is a script this daemon just wrote, holding the prompt,
1028+ // because the prompt cannot be quoted onto a tmux command line safely:
1029+ // tmux's single quotes admit no escape, and its double quotes expand
1030+ // `#()` — which runs a shell. The path is hex we generated.
1031+ TmuxRequest::Claude { session, prompt } => {
1032+ match crate::paths::write_prompt_script(prompt) {
1033+ Ok(script) => vec![format!(
1034+ "new-window -a -t '{session}:' -n claude -c '#{{pane_current_path}}' \
1035+ '{}' ; switch-client{client} -t '{session}:'",
1036+ script.display()
1037+ )],
1038+ // Nothing to run, so nothing is sent: a window that opened a
1039+ // bare shell would look like it worked.
1040+ Err(e) => {
1041+ eprintln!("send to claude: {e}");
1042+ Vec::new()
1043+ }
1044+ }
1045+ }
1046+ // The Claude window again, with a shell command in place of the prompt
1047+ // and the same reasoning behind every part of it — see above.
1048+ //
1049+ // The name is the command's first word when that word is plain enough
1050+ // to be one (see [`window_name_for`]), because `run` on every window
1051+ // tells you nothing when three of them are open. It is a name tmux
1052+ // would otherwise have picked for itself, had the pane not been running
1053+ // a generated script whose own name is hex.
1054+ TmuxRequest::Run { session, command } => {
1055+ match crate::paths::write_command_script(command) {
1056+ Ok(script) => vec![format!(
1057+ "new-window -a -t '{session}:' -n '{}' -c '#{{pane_current_path}}' \
1058+ '{}' ; switch-client{client} -t '{session}:'",
1059+ window_name_for(command),
1060+ script.display()
1061+ )],
1062+ Err(e) => {
1063+ eprintln!("run command: {e}");
1064+ Vec::new()
1065+ }
1066+ }
1067+ }
1068+ // A session rather than a window, and the only one of these that names
1069+ // a directory. The path is in the script for the reason the prompt is
1070+ // — see [`crate::paths::write_project_script`] — so what reaches this
1071+ // command line is a session name we validated and a path we generated.
1072+ //
1073+ // No `-A`: attaching to a session that happens to share the name would
1074+ // silently ignore both the directory and the prompt, which is the whole
1075+ // request. The panel picks a free name; a race that makes it unfree
1076+ // arrives here as a tmux error, which is the honest answer.
1077+ TmuxRequest::NewProject { path, name, prompt } => {
1078+ match crate::paths::write_project_script(std::path::Path::new(path), prompt.as_deref())
1079+ {
1080+ Ok(script) => vec![
1081+ format!(
1082+ "new-session -d -s '{name}' -n '{}' '{}'",
1083+ if prompt.is_some() { "claude" } else { name },
1084+ script.display()
1085+ ),
1086+ format!("switch-client{client} -t '{name}:'"),
1087+ ],
1088+ Err(e) => {
1089+ eprintln!("new project: {e}");
1090+ Vec::new()
1091+ }
1092+ }
1093+ }
1094+ // The `run` window with `ssh <host>` as its command, and the same
1095+ // script for the same two reasons it uses one: the window outlives the
1096+ // connection, so a refused or dropped one leaves its message on screen
1097+ // rather than closing over it, and what is left behind is a local
1098+ // shell you can reconnect from.
1099+ //
1100+ // The host would in fact survive tmux's single quotes — `valid_ssh_host`
1101+ // admits no quote to end them with — but going through the script keeps
1102+ // one path for "open a window on a command" rather than two.
1103+ //
1104+ // Named for the host, because `ssh` on all four of them tells you
1105+ // nothing about which is which.
1106+ TmuxRequest::Ssh { session, host } => {
1107+ match crate::paths::write_command_script(&format!("ssh {host}")) {
1108+ Ok(script) => vec![format!(
1109+ "new-window -a -t '{session}:' -n '{}' -c '#{{pane_current_path}}' \
1110+ '{}' ; switch-client{client} -t '{session}:'",
1111+ crate::ssh::window_name(host),
1112+ script.display()
1113+ )],
1114+ Err(e) => {
1115+ eprintln!("ssh: {e}");
1116+ Vec::new()
1117+ }
1118+ }
1119+ }
1120+ // `-a`/`-b` place the window after or before the target and renumber
1121+ // whatever has to move, so no free index has to be found first and no
1122+ // existing window is overwritten (which is what `-k` would risk).
1123+ TmuxRequest::MoveWindow {
1124+ window,
1125+ target,
1126+ after,
1127+ } => vec![format!(
1128+ "move-window {} -s '{window}' -t '{target}'",
1129+ if *after { "-a" } else { "-b" }
1130+ )],
1131+ // The trailing colon targets the session's current window, and `-a`
1132+ // lands after it — the same "no free index to find" reasoning as the
1133+ // reorder above, applied to a session with no window we can name.
1134+ TmuxRequest::MoveWindowToSession { window, session } => {
1135+ vec![format!("move-window -a -s '{window}' -t '{session}:'")]
1136+ }
1137+ // Four lines because tmux offers no one command that does it, and they
1138+ // are separate entries rather than a single `;` chain on purpose: the
1139+ // caller stops at the first failure, so a name that already exists
1140+ // fails at `new-session` and the window stays exactly where it was.
1141+ //
1142+ // The placeholder is the window `new-session` insists on making. It is
1143+ // killed by name and not by index, because the index it got depends on
1144+ // the server's `base-index`; the moved window lands after it with `-a`,
1145+ // and no window this panel can drag carries that name.
1146+ //
1147+ // Then the client follows the window — by window id rather than by the
1148+ // new session's name, which is the one thing here that a rename racing
1149+ // the drag could change out from under us.
1150+ TmuxRequest::NewSessionWithWindow { window, name } => vec![
1151+ format!("new-session -d -s '{name}' -n '{PLACEHOLDER_WINDOW}'"),
1152+ format!("move-window -a -s '{window}' -t '{name}:'"),
1153+ format!("kill-window -t '{name}:{PLACEHOLDER_WINDOW}'"),
1154+ format!("switch-client{client} -t '{window}'"),
1155+ ],
1156+ TmuxRequest::RenameSession { session, name } => {
1157+ vec![format!("rename-session -t '{session}' '{name}'")]
1158+ }
6281159 TmuxRequest::KillWindow(id) => vec![format!("kill-window -t '{id}'")],
1160+ // `-u` unsets rather than setting an empty string: an option set to ""
1161+ // still reads back as set, and the panel's "no colour chosen" test is
1162+ // exactly whether the option is there.
1163+ TmuxRequest::SetSessionColor { session, color } => vec![match color {
1164+ Some(c) => format!(
1165+ "set-option -t '{session}' {} '{c}'",
1166+ crate::status::COLOR_OPTION
1167+ ),
1168+ None => format!(
1169+ "set-option -t '{session}' -u {}",
1170+ crate::status::COLOR_OPTION
1171+ ),
1172+ }],
6291173 }
6301174 }
6311175
⋯ 14 unchanged lines
6461190 // small files; there is no process spawn on this path at all.
6471191 let mut ticker = tokio::time::interval(Duration::from_secs(1));
6481192 let mut last: Option<crate::status::Snapshot> = None;
1193+ // Sessions we have already checked `detach-on-destroy` on, by id.
1194+ let mut checked_detach: std::collections::HashSet<String> = std::collections::HashSet::new();
1195+ // The session browser.conf is currently applied to, by id.
1196+ let mut styled: Option<String> = None;
6491197
6501198 loop {
6511199 let snap = crate::status::snapshot(&control, tty.as_deref()).await;
1200+ // Every session the client lands on, not just the first: the sidebar's
1201+ // switcher moves it, and the option belongs to the session.
1202+ if let Some(id) = snap
1203+ .session_id
1204+ .as_deref()
1205+ .and_then(crate::pty::valid_session_id)
1206+ {
1207+ if checked_detach.insert(id.clone()) {
1208+ ensure_detach_on_destroy(&control, &id).await;
1209+ }
1210+ if styled.as_deref() != Some(id.as_str()) {
1211+ if let Some(previous) = styled.take() {
1212+ source_tmux_config(&global_args, &previous, BROWSER_RESET_CONF).await;
1213+ }
1214+ if source_tmux_config(&global_args, &id, BROWSER_CONF).await {
1215+ styled = Some(id);
1216+ }
1217+ }
1218+ }
6521219 if last.as_ref() != Some(&snap) {
6531220 let json = serde_json::json!({
6541221 "type": "status",
6551222 "session": snap.session,
6561223 "sessions": snap.sessions,
657- "windows": snap.windows,
6581224 "agents": snap.agents,
6591225 })
6601226 .to_string();
6611227 if status_tx.send(json).await.is_err() {
662- return; // Connection gone.
1228+ break; // Connection gone.
6631229 }
6641230 last = Some(snap);
6651231 }
⋯ 7 unchanged lines
6731239 // layout change nobody displays.
6741240 Some(n) if !crate::control::is_interesting(&n) => continue,
6751241 Some(_) => {}
676- None => return,
1242+ None => break,
6771243 }
6781244 }
6791245 req = requests.recv() => {
680- let Some(req) = req else { return };
1246+ let Some(req) = req else { break };
1247+ if let Err(reason) = prepare(&req) {
1248+ let json = serde_json::json!({
1249+ "type": "tmux-error", "reason": reason,
1250+ }).to_string();
1251+ let _ = status_tx.send(json).await;
1252+ continue;
1253+ }
6811254 for line in command_lines(&req, tty.as_deref()) {
6821255 if let Err(reason) = control.run(line).await {
6831256 let json = serde_json::json!({
⋯ 6 unchanged lines
6901263 }
6911264 }
6921265 }
1266+
1267+ // The panel is going away, so the session it was on goes back to the look
1268+ // the terminal clients expect. Reached on every exit from the loop above,
1269+ // which is why they all break rather than return.
1270+ if let Some(session) = styled {
1271+ source_tmux_config(&global_args, &session, BROWSER_RESET_CONF).await;
1272+ }
1273+}
1274+
1275+/// Overrides for sessions a browser client is looking at, relative to the
1276+/// user's tmux config dir. tmux options are server- and session-scoped, never
1277+/// per-client, so a browser client cannot simply be handed its own config: the
1278+/// only thing it can do is apply session options to the session it is on, and
1279+/// take them back off on the way out. Both files are optional; a user without
1280+/// them gets the same tmux the terminal gets.
1281+const BROWSER_CONF: &str = "browser.conf";
1282+const BROWSER_RESET_CONF: &str = "browser-reset.conf";
1283+
1284+/// `~/.config/tmux`, where tmux itself looks for its config.
1285+///
1286+/// `TERMBRIDGE_TMUX_CONFIG_DIR` overrides it, which is how the tests point at
1287+/// overrides of their own instead of the ones the user is running.
1288+fn tmux_config_dir() -> std::path::PathBuf {
1289+ if let Some(dir) = std::env::var_os("TERMBRIDGE_TMUX_CONFIG_DIR") {
1290+ return std::path::PathBuf::from(dir);
1291+ }
1292+ std::env::var_os("XDG_CONFIG_HOME")
1293+ .map(std::path::PathBuf::from)
1294+ .unwrap_or_else(|| {
1295+ let home = std::env::var_os("HOME")
1296+ .map(std::path::PathBuf::from)
1297+ .unwrap_or_default();
1298+ home.join(".config")
1299+ })
1300+ .join("tmux")
1301+}
1302+
1303+/// Source one of the files above into `session`, reporting whether it ran.
1304+///
1305+/// Deliberately a `tmux` process of its own rather than a line on the control
1306+/// client: in control mode `source-file` answers with *two* `%begin`/`%end`
1307+/// blocks — one for itself and one for the commands it runs — and the control
1308+/// client pairs replies to commands by order. The spare block would be taken
1309+/// for the next command's answer and every reply after it would be off by one,
1310+/// which the sidebar sees as `list-clients` output where its session list
1311+/// should be. A process per session switch is nothing; a shifted reply queue
1312+/// is everything the panel draws.
1313+///
1314+/// The path is checked here rather than left to tmux so that a missing file is
1315+/// silence rather than an error, and so that a session is only recorded as
1316+/// styled when there was something to apply. `session` must already be an id
1317+/// from [`crate::pty::valid_session_id`], and `global_args` must be the
1318+/// profile's server-selection flags or this addresses the wrong tmux server.
1319+async fn source_tmux_config(global_args: &[String], session: &str, name: &str) -> bool {
1320+ let path = tmux_config_dir().join(name);
1321+ if !path.is_file() {
1322+ return false;
1323+ }
1324+ // Arguments, not a command line: nothing here is quoted or split, so a
1325+ // path with a space or a quote in it is just a path.
1326+ tokio::process::Command::new("tmux")
1327+ .args(global_args)
1328+ .arg("source-file")
1329+ .arg("-t")
1330+ .arg(session)
1331+ .arg(path)
1332+ .stdin(std::process::Stdio::null())
1333+ .stdout(std::process::Stdio::null())
1334+ .stderr(std::process::Stdio::null())
1335+ .status()
1336+ .await
1337+ .is_ok_and(|s| s.success())
1338+}
1339+
1340+/// Keep the sidebar's client alive when a session is destroyed under it.
1341+///
1342+/// tmux's default is `detach-on-destroy on`: exiting the last shell of a
1343+/// session detaches every client attached to it. For a terminal emulator that
1344+/// is fine — the window closes. For the sidebar it means the pty hits EOF, the
1345+/// socket closes, and the whole panel goes dead even though other sessions are
1346+/// still running, which reads as a crash rather than as closing a pane. `off`
1347+/// moves the client to another session instead, and only falls back to
1348+/// detaching when there is genuinely nothing left to show.
1349+///
1350+/// Only the tmux default is overridden. `no-detached` and `previous` are
1351+/// deliberate choices with the same effect we want, so a user who set one keeps
1352+/// it. `session` must already be an id from [`crate::pty::valid_session_id`].
1353+async fn ensure_detach_on_destroy(control: &crate::control::Control, session: &str) {
1354+ // `-A` because the option is usually unset at the session level and
1355+ // inherited from the global one; without it the reply is empty and the
1356+ // effective value stays invisible.
1357+ let current = control
1358+ .run(format!(
1359+ "show-options -t '{session}' -v -A detach-on-destroy"
1360+ ))
1361+ .await;
1362+ let Ok(lines) = current else { return };
1363+ if lines.first().map(|l| l.trim()) != Some("on") {
1364+ return;
1365+ }
1366+ let _ = control
1367+ .run(format!("set-option -t '{session}' detach-on-destroy off"))
1368+ .await;
6931369 }
6941370
6951371 /// The interactive client creates the session, and we may get here first.
⋯ 39 unchanged lines
7351411 }
7361412
7371413 fn dims(v: &serde_json::Value) -> (u16, u16) {
738- let get = |k: &str, d: u64| v.get(k).and_then(|x| x.as_u64()).unwrap_or(d).clamp(1, 1000) as u16;
1414+ let get = |k: &str, d: u64| {
1415+ v.get(k)
1416+ .and_then(|x| x.as_u64())
1417+ .unwrap_or(d)
1418+ .clamp(1, 1000) as u16
1419+ };
7391420 (get("cols", 80), get("rows", 24))
7401421 }
7411422
⋯ 19 unchanged lines
7611442 use super::*;
7621443
7631444 /// A lone session is what the user is already working in, so a client that
764- /// names none should land there rather than in a fresh `browser` session.
1445+ /// names none should land there rather than in a fresh `default` session.
7651446 /// Two sessions is ambiguous, and the configured default wins again.
7661447 ///
7671448 /// Runs against a real tmux on a private socket; skipped where there is no
⋯ 24 unchanged lines
7921473 "new-session".into(),
7931474 "-A".into(),
7941475 "-s".into(),
795- "browser".into(),
1476+ crate::pty::DEFAULT_SESSION.into(),
7961477 ],
7971478 };
798- config.default_session = "browser".into();
1479+ config.default_session = crate::pty::DEFAULT_SESSION.into();
7991480 config.adopt_sole_session = true;
8001481
8011482 // No server running: nothing to adopt.
⋯ 14 unchanged lines
8161497 let _ = tmux(&["kill-server"]);
8171498 }
8181499
1500+ /// Ctrl-D in the last shell of a session must not take the sidebar's client
1501+ /// with it. Needs a real tmux; skipped where there isn't one.
1502+ #[tokio::test]
1503+ async fn the_default_detach_on_destroy_is_turned_off() {
1504+ if !crate::pty::Profile::tmux_available() {
1505+ eprintln!("skipping: no tmux on PATH");
1506+ return;
1507+ }
1508+ let socket = "termbridge-detach-test";
1509+ let tmux = |args: &[&str]| {
1510+ std::process::Command::new("tmux")
1511+ .args(["-L", socket, "-f", "/dev/null"])
1512+ .args(args)
1513+ .output()
1514+ };
1515+ let _ = tmux(&["kill-server"]);
1516+ let _ = tmux(&["new-session", "-d", "-s", "one"]);
1517+ let _ = tmux(&["new-session", "-d", "-s", "deliberate"]);
1518+
1519+ let global = ["-L", socket, "-f", "/dev/null"].map(String::from).to_vec();
1520+ let Ok((control, _notes)) = crate::control::Control::attach("one", &global).await else {
1521+ let _ = tmux(&["kill-server"]);
1522+ return;
1523+ };
1524+ let effective = |session: &str| {
1525+ let out = tmux(&[
1526+ "show-options",
1527+ "-t",
1528+ session,
1529+ "-v",
1530+ "-A",
1531+ "detach-on-destroy",
1532+ ]);
1533+ String::from_utf8_lossy(&out.expect("tmux ran").stdout)
1534+ .trim()
1535+ .to_string()
1536+ };
1537+
1538+ // tmux's default, so ours wins.
1539+ assert_eq!(effective("one"), "on");
1540+ ensure_detach_on_destroy(&control, "$0").await;
1541+ assert_eq!(effective("one"), "off");
1542+
1543+ // Deliberately set to something else with the same effect: left alone.
1544+ let _ = tmux(&[
1545+ "set-option",
1546+ "-t",
1547+ "deliberate",
1548+ "detach-on-destroy",
1549+ "previous",
1550+ ]);
1551+ ensure_detach_on_destroy(&control, "$1").await;
1552+ assert_eq!(effective("deliberate"), "previous");
1553+
1554+ let _ = tmux(&["kill-server"]);
1555+ }
1556+
1557+ #[test]
1558+ fn session_ids_are_dollar_and_digits() {
1559+ assert_eq!(crate::pty::valid_session_id("$0").as_deref(), Some("$0"));
1560+ assert_eq!(
1561+ crate::pty::valid_session_id(" $12 ").as_deref(),
1562+ Some("$12")
1563+ );
1564+ for bad in ["$", "1", "$1a", "$1 ; kill-server", "@1", "$-1", ""] {
1565+ assert_eq!(crate::pty::valid_session_id(bad), None, "{bad:?}");
1566+ }
1567+ }
1568+
8191569 /// The wire protocol must not be able to name a tmux command. Anything the
8201570 /// sidebar sends either maps to one of the allowlisted variants or is
8211571 /// dropped.
⋯ 13 unchanged lines
8351585 Some(TmuxRequest::SelectWindow("@3".into()))
8361586 );
8371587 assert_eq!(
1588+ req(r#"{"type":"tmux","cmd":"goto-window","window":"@3"}"#),
1589+ Some(TmuxRequest::GotoWindow("@3".into()))
1590+ );
1591+ assert_eq!(
8381592 req(r#"{"type":"tmux","cmd":"new-window","session":"work"}"#),
8391593 Some(TmuxRequest::NewWindow("work".into()))
8401594 );
8411595 assert_eq!(
1596+ req(r#"{"type":"tmux","cmd":"move-window-to-session","window":"@3","session":"work"}"#),
1597+ Some(TmuxRequest::MoveWindowToSession {
1598+ window: "@3".into(),
1599+ session: "work".into(),
1600+ })
1601+ );
1602+ assert_eq!(
8421603 req(r#"{"type":"tmux","cmd":"kill-window","window":"@3"}"#),
8431604 Some(TmuxRequest::KillWindow("@3".into()))
8441605 );
1606+ assert_eq!(
1607+ req(r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"210"}"#),
1608+ Some(TmuxRequest::SetSessionColor {
1609+ session: "$1".into(),
1610+ color: Some("210".into()),
1611+ })
1612+ );
1613+ // Grey, which is not a hue and so gets its own sentinel.
1614+ assert_eq!(
1615+ req(r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"-1"}"#),
1616+ Some(TmuxRequest::SetSessionColor {
1617+ session: "$1".into(),
1618+ color: Some("-1".into()),
1619+ })
1620+ );
1621+ // No colour at all is the "back to automatic" request, not a malformed
1622+ // one: it unsets the option.
1623+ assert_eq!(
1624+ req(r#"{"type":"tmux","cmd":"set-session-color","session":"$1"}"#),
1625+ Some(TmuxRequest::SetSessionColor {
1626+ session: "$1".into(),
1627+ color: None,
1628+ })
1629+ );
1630+ assert_eq!(
1631+ req(r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":"work"}"#),
1632+ Some(TmuxRequest::RenameSession {
1633+ session: "$1".into(),
1634+ name: "work".into(),
1635+ })
1636+ );
1637+ assert_eq!(
1638+ req(r#"{"type":"tmux","cmd":"ssh","session":"work","host":"collin@mini"}"#),
1639+ Some(TmuxRequest::Ssh {
1640+ session: "work".into(),
1641+ host: "collin@mini".into(),
1642+ })
1643+ );
1644+ assert_eq!(
1645+ req(r#"{"type":"tmux","cmd":"move-window","window":"@3","target":"@1","after":true}"#),
1646+ Some(TmuxRequest::MoveWindow {
1647+ window: "@3".into(),
1648+ target: "@1".into(),
1649+ after: true,
1650+ })
1651+ );
1652+ // Both ends are window ids, and a missing one is not a move at all.
1653+ assert_eq!(
1654+ req(r#"{"type":"tmux","cmd":"move-window","window":"@3"}"#),
1655+ None
1656+ );
1657+ assert_eq!(
1658+ req(r#"{"type":"tmux","cmd":"move-window","window":"@3","target":"work:1"}"#),
1659+ None
1660+ );
8451661 // Killing anything larger than a window is still not expressible.
846- assert_eq!(req(r#"{"type":"tmux","cmd":"kill-session","session":"work"}"#), None);
847- assert_eq!(req(r#"{"type":"tmux","cmd":"kill-pane","pane":"%3"}"#), None);
1662+ assert_eq!(
1663+ req(r#"{"type":"tmux","cmd":"kill-session","session":"work"}"#),
1664+ None
1665+ );
1666+ assert_eq!(
1667+ req(r#"{"type":"tmux","cmd":"kill-pane","pane":"%3"}"#),
1668+ None
1669+ );
8481670 assert_eq!(req(r#"{"type":"tmux","cmd":"kill-server"}"#), None);
849- assert_eq!(req(r#"{"type":"tmux","cmd":"run","command":"rm -rf /"}"#), None);
1671+ assert_eq!(
1672+ req(r#"{"type":"tmux","cmd":"run","command":"rm -rf /"}"#),
1673+ None
1674+ );
8501675 assert_eq!(req(r#"{"type":"resize","cols":80,"rows":24}"#), None);
8511676 }
8521677
⋯ 15 unchanged lines
8681693 // An index is not an id: `2` would be a bare target, and
8691694 // `session:2.0` carries syntax of its own.
8701695 r#"{"type":"tmux","cmd":"select-window","window":"2"}"#,
1696+ // Crossing sessions widens what a click can reach, not what it can
1697+ // say: the id goes through the same validator.
1698+ r#"{"type":"tmux","cmd":"goto-window","window":"@1 ; kill-server"}"#,
1699+ r#"{"type":"tmux","cmd":"goto-window","window":"work:1"}"#,
1700+ r#"{"type":"tmux","cmd":"goto-window","window":""}"#,
1701+ r#"{"type":"tmux","cmd":"move-window-to-session","window":"@1","session":"a' ; x ; '"}"#,
1702+ r#"{"type":"tmux","cmd":"move-window-to-session","window":"@1","session":"work:1"}"#,
1703+ r#"{"type":"tmux","cmd":"move-window-to-session","window":"-a","session":"work"}"#,
8711704 r#"{"type":"tmux","cmd":"new-window","session":"a' ; kill-server ; '"}"#,
8721705 r#"{"type":"tmux","cmd":"new-window","session":"work:1"}"#,
8731706 // The destructive one gets the same validator, and `-a` is the
⋯ 1 unchanged line
8751708 r#"{"type":"tmux","cmd":"kill-window","window":"@1 ; kill-server"}"#,
8761709 r#"{"type":"tmux","cmd":"kill-window","window":"-a"}"#,
8771710 r#"{"type":"tmux","cmd":"kill-window","window":""}"#,
1711+ // The colour is quoted into a command line, so it is a number this
1712+ // panel would have produced or it is nothing.
1713+ r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"red"}"#,
1714+ r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"210' ; kill-server ; '"}"#,
1715+ r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"360"}"#,
1716+ r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":"-2"}"#,
1717+ r#"{"type":"tmux","cmd":"set-session-color","session":"$1","color":""}"#,
1718+ // And the session is an id, never a name.
1719+ r#"{"type":"tmux","cmd":"set-session-color","session":"work","color":"210"}"#,
1720+ r#"{"type":"tmux","cmd":"set-session-color","session":"@1","color":"210"}"#,
1721+ // A rename names its target by id and its new name by the same
1722+ // gate the session field passes — both halves land in a command
1723+ // line, and neither may carry anything a shell word cannot.
1724+ r#"{"type":"tmux","cmd":"rename-session","session":"work","name":"other"}"#,
1725+ r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":"a' ; kill-server ; '"}"#,
1726+ r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":"work:1"}"#,
1727+ r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":"-C"}"#,
1728+ r#"{"type":"tmux","cmd":"rename-session","session":"$1","name":""}"#,
1729+ r#"{"type":"tmux","cmd":"rename-session","session":"$1"}"#,
1730+ // A destination is a name. The dangerous shape is not a shell
1731+ // metacharacter but an ssh option — `-oProxyCommand=` runs one.
1732+ r#"{"type":"tmux","cmd":"ssh","session":"work","host":"-oProxyCommand=sh"}"#,
1733+ r#"{"type":"tmux","cmd":"ssh","session":"work","host":"me@-oProxyCommand=sh"}"#,
1734+ r#"{"type":"tmux","cmd":"ssh","session":"work","host":"mini' ; kill-server ; '"}"#,
1735+ r#"{"type":"tmux","cmd":"ssh","session":"work","host":"mini -D 1080"}"#,
1736+ r#"{"type":"tmux","cmd":"ssh","session":"work","host":""}"#,
1737+ r#"{"type":"tmux","cmd":"ssh","session":"work"}"#,
1738+ r#"{"type":"tmux","cmd":"ssh","session":"work:1","host":"mini"}"#,
8781739 ] {
8791740 assert_eq!(req(hostile), None, "accepted {hostile}");
8801741 }
⋯ 7 unchanged lines
8881749 assert_eq!(lines, vec!["select-window -t '@3'"]);
8891750
8901751 // -a rather than an index, which could collide with an existing window.
1752+ // The client follows: "+" adds to the default session from wherever you
1753+ // are, so the window it makes may be in a session we are not on.
1754+ let lines = command_lines(&TmuxRequest::NewWindow("work".into()), Some("/dev/pts/7"));
1755+ assert_eq!(
1756+ lines,
1757+ vec![
1758+ "new-window -a -t 'work:' -c '#{pane_current_path}' ; \
1759+ switch-client -c '/dev/pts/7' -t 'work:'"
1760+ ]
1761+ );
8911762 let lines = command_lines(&TmuxRequest::NewWindow("work".into()), None);
892- assert_eq!(lines, vec!["new-window -a -t 'work:'"]);
1763+ assert_eq!(
1764+ lines,
1765+ vec!["new-window -a -t 'work:' -c '#{pane_current_path}' ; switch-client -t 'work:'"]
1766+ );
8931767
8941768 // One window, named by id. No -a, which would kill all *but* it.
8951769 let lines = command_lines(&TmuxRequest::KillWindow("@3".into()), Some("/dev/pts/7"));
8961770 assert_eq!(lines, vec!["kill-window -t '@3'"]);
1771+
1772+ // A drag lands the window beside another one, and tmux renumbers the
1773+ // rest — no index is named, so none can collide.
1774+ let drag = |after| {
1775+ command_lines(
1776+ &TmuxRequest::MoveWindow {
1777+ window: "@3".into(),
1778+ target: "@1".into(),
1779+ after,
1780+ },
1781+ Some("/dev/pts/7"),
1782+ )
1783+ };
1784+ assert_eq!(drag(true), vec!["move-window -a -s '@3' -t '@1'"]);
1785+ assert_eq!(drag(false), vec!["move-window -b -s '@3' -t '@1'"]);
1786+
1787+ // A collapsed group has no window to land beside, so the session is the
1788+ // target and tmux picks the index.
1789+ let lines = command_lines(
1790+ &TmuxRequest::MoveWindowToSession {
1791+ window: "@3".into(),
1792+ session: "work".into(),
1793+ },
1794+ Some("/dev/pts/7"),
1795+ );
1796+ assert_eq!(lines, vec!["move-window -a -s '@3' -t 'work:'"]);
1797+ }
1798+
1799+ /// Dragging a tab onto "+": the session has to exist before a window can be
1800+ /// moved into it, and the window `new-session` comes with has to go.
1801+ #[test]
1802+ fn a_window_dragged_onto_plus_gets_a_session_of_its_own() {
1803+ let lines = command_lines(
1804+ &TmuxRequest::NewSessionWithWindow {
1805+ window: "@3".into(),
1806+ name: "nvim".into(),
1807+ },
1808+ Some("/dev/pts/7"),
1809+ );
1810+ assert_eq!(
1811+ lines,
1812+ vec![
1813+ "new-session -d -s 'nvim' -n 'termbridge-placeholder'",
1814+ "move-window -a -s '@3' -t 'nvim:'",
1815+ "kill-window -t 'nvim:termbridge-placeholder'",
1816+ "switch-client -c '/dev/pts/7' -t '@3'",
1817+ ]
1818+ );
1819+
1820+ // Both halves are validated, and neither can carry a quote into the
1821+ // command lines above.
1822+ assert!(parse_tmux_request(
1823+ r#"{"type":"tmux","cmd":"new-session-with-window","window":"@3","name":"a' ; x ; '"}"#
1824+ )
1825+ .is_none());
1826+ assert!(
1827+ parse_tmux_request(
1828+ r#"{"type":"tmux","cmd":"new-session-with-window","window":"work","name":"nvim"}"#
1829+ )
1830+ .is_none()
1831+ );
1832+ assert_eq!(
1833+ parse_tmux_request(
1834+ r#"{"type":"tmux","cmd":"new-session-with-window","window":"@3","name":"nvim"}"#
1835+ ),
1836+ Some(TmuxRequest::NewSessionWithWindow {
1837+ window: "@3".into(),
1838+ name: "nvim".into(),
1839+ })
1840+ );
1841+ }
1842+
1843+ /// The colour goes into a tmux user option on the session, which is what
1844+ /// makes it survive a rename and reach every panel on the server.
1845+ #[test]
1846+ fn session_colour_sets_and_unsets_a_user_option() {
1847+ let set = command_lines(
1848+ &TmuxRequest::SetSessionColor {
1849+ session: "$1".into(),
1850+ color: Some("210".into()),
1851+ },
1852+ Some("/dev/pts/7"),
1853+ );
1854+ assert_eq!(set, vec!["set-option -t '$1' @termbridge_color '210'"]);
1855+
1856+ // `-u`, not an empty value: an option set to "" still reads back as
1857+ // set, and "nobody chose" is exactly the option being absent.
1858+ let clear = command_lines(
1859+ &TmuxRequest::SetSessionColor {
1860+ session: "$1".into(),
1861+ color: None,
1862+ },
1863+ None,
1864+ );
1865+ assert_eq!(clear, vec!["set-option -t '$1' -u @termbridge_color"]);
1866+ }
1867+
1868+ /// The rename targets an id, so it cannot land on whichever session
1869+ /// inherited the old name between the menu opening and the click.
1870+ #[test]
1871+ fn rename_session_targets_an_id() {
1872+ let lines = command_lines(
1873+ &TmuxRequest::RenameSession {
1874+ session: "$1".into(),
1875+ name: "work".into(),
1876+ },
1877+ Some("/dev/pts/7"),
1878+ );
1879+ assert_eq!(lines, vec!["rename-session -t '$1' 'work'"]);
1880+ }
1881+
1882+ /// Tab Group mode's click. Unlike SelectWindow this *does* take the client
1883+ /// with it, because the tab it came from may belong to a session the panel
1884+ /// is not on.
1885+ #[test]
1886+ fn goto_window_selects_then_brings_the_client() {
1887+ let lines = command_lines(&TmuxRequest::GotoWindow("@3".into()), Some("/dev/pts/7"));
1888+ assert_eq!(
1889+ lines,
1890+ vec!["select-window -t '@3' ; switch-client -c '/dev/pts/7' -t '@3'"]
1891+ );
1892+ // No tty: tmux picks a client, exactly as Switch degrades.
1893+ let lines = command_lines(&TmuxRequest::GotoWindow("@3".into()), None);
1894+ assert_eq!(lines, vec!["select-window -t '@3' ; switch-client -t '@3'"]);
8971895 }
8981896
8991897 #[test]
⋯ 9 unchanged lines
9091907 }
9101908
9111909 #[test]
1910+ fn claude_parses_only_with_a_session_and_a_prompt() {
1911+ let req = |s: &str| parse_tmux_request(s);
1912+ assert_eq!(
1913+ req(r#"{"type":"tmux","cmd":"claude","session":"work","prompt":"fix the parser"}"#),
1914+ Some(TmuxRequest::Claude {
1915+ session: "work".into(),
1916+ prompt: "fix the parser".into(),
1917+ })
1918+ );
1919+ // Punctuation belongs in a sentence, so it is allowed through — the
1920+ // prompt reaches a file, never a command line.
1921+ assert_eq!(
1922+ req(r#"{"type":"tmux","cmd":"claude","session":"work","prompt":"what's '; kill?"}"#),
1923+ Some(TmuxRequest::Claude {
1924+ session: "work".into(),
1925+ prompt: "what's '; kill?".into(),
1926+ })
1927+ );
1928+ // An escape a terminal would obey rather than print is not text.
1929+ assert_eq!(
1930+ req(r#"{"type":"tmux","cmd":"claude","session":"work","prompt":"a\u001b[2Jb"}"#),
1931+ None
1932+ );
1933+ assert_eq!(
1934+ req(r#"{"type":"tmux","cmd":"claude","session":"work","prompt":" "}"#),
1935+ None
1936+ );
1937+ assert_eq!(req(r#"{"type":"tmux","cmd":"claude","prompt":"hi"}"#), None);
1938+ }
1939+
1940+ /// The prompt is in the script, the script's path is in the command line,
1941+ /// and the cwd is a tmux format tmux expands for itself.
1942+ #[test]
1943+ fn claude_puts_the_prompt_in_a_script_and_not_on_the_command_line() {
1944+ let prompt = "why does 'this' break; really?";
1945+ let lines = command_lines(
1946+ &TmuxRequest::Claude {
1947+ session: "work".into(),
1948+ prompt: prompt.into(),
1949+ },
1950+ Some("/dev/pts/7"),
1951+ );
1952+ assert_eq!(lines.len(), 1);
1953+ let line = &lines[0];
1954+ assert!(!line.contains("really"), "prompt leaked into: {line}");
1955+ assert!(line.contains("-c '#{pane_current_path}'"), "{line}");
1956+ assert!(
1957+ line.ends_with("switch-client -c '/dev/pts/7' -t 'work:'"),
1958+ "{line}"
1959+ );
1960+
1961+ // And the script it named runs claude on exactly that text.
1962+ let script = line
1963+ .split('\'')
1964+ .find(|s| s.contains("prompt-"))
1965+ .expect("script path in the line");
1966+ let body = std::fs::read_to_string(script).expect("script written");
1967+ assert!(
1968+ body.contains(r"prompt='why does '\''this'\'' break; really?'"),
1969+ "{body}"
1970+ );
1971+ assert!(body.contains("claude \"$prompt\""), "{body}");
1972+ std::fs::remove_file(script).ok();
1973+ }
1974+
1975+ #[test]
1976+ fn run_parses_only_with_a_session_and_a_single_line_command() {
1977+ let req = |s: &str| parse_tmux_request(s);
1978+ assert_eq!(
1979+ req(r#"{"type":"tmux","cmd":"run","session":"work","command":"cargo test | less"}"#),
1980+ Some(TmuxRequest::Run {
1981+ session: "work".into(),
1982+ command: "cargo test | less".into(),
1983+ })
1984+ );
1985+ // Shell punctuation is the point of the feature, and it reaches a file.
1986+ assert_eq!(
1987+ req(r#"{"type":"tmux","cmd":"run","session":"work","command":"grep 'a; b' *.rs"}"#),
1988+ Some(TmuxRequest::Run {
1989+ session: "work".into(),
1990+ command: "grep 'a; b' *.rs".into(),
1991+ })
1992+ );
1993+ // A command is one line, unlike a prompt.
1994+ assert_eq!(
1995+ req(r#"{"type":"tmux","cmd":"run","session":"work","command":"ls\nrm -rf /"}"#),
1996+ None
1997+ );
1998+ assert_eq!(
1999+ req(r#"{"type":"tmux","cmd":"run","session":"work","command":" "}"#),
2000+ None
2001+ );
2002+ assert_eq!(req(r#"{"type":"tmux","cmd":"run","command":"ls"}"#), None);
2003+ }
2004+
2005+ /// The command is in the script; only the script's path and a window name
2006+ /// narrowed to a bare word reach the tmux command line.
2007+ #[test]
2008+ fn run_puts_the_command_in_a_script_and_not_on_the_command_line() {
2009+ let lines = command_lines(
2010+ &TmuxRequest::Run {
2011+ session: "work".into(),
2012+ command: "cargo test -- --nocapture 'it works'".into(),
2013+ },
2014+ Some("/dev/pts/7"),
2015+ );
2016+ assert_eq!(lines.len(), 1);
2017+ let line = &lines[0];
2018+ assert!(!line.contains("nocapture"), "command leaked into: {line}");
2019+ assert!(line.contains("-n 'cargo'"), "{line}");
2020+ assert!(line.contains("-c '#{pane_current_path}'"), "{line}");
2021+ assert!(
2022+ line.ends_with("switch-client -c '/dev/pts/7' -t 'work:'"),
2023+ "{line}"
2024+ );
2025+
2026+ let script = line
2027+ .split('\'')
2028+ .find(|s| s.contains("run-"))
2029+ .expect("script path in the line");
2030+ let body = std::fs::read_to_string(script).expect("script written");
2031+ assert!(
2032+ body.contains(r"cmd='cargo test -- --nocapture '\''it works'\'''"),
2033+ "{body}"
2034+ );
2035+ // The user's own shell runs it, and the pane outlives it.
2036+ assert!(body.contains(r#""${SHELL:-/bin/sh}" -c "$cmd""#), "{body}");
2037+ assert!(body.contains(r#"exec "${SHELL:-/bin/sh}""#), "{body}");
2038+ std::fs::remove_file(script).ok();
2039+ }
2040+
2041+ /// An ssh connection is the `run` window with `ssh <host>` in it, named
2042+ /// for the machine rather than for the login.
2043+ #[test]
2044+ fn ssh_opens_a_local_window_running_ssh() {
2045+ let lines = command_lines(
2046+ &TmuxRequest::Ssh {
2047+ session: "work".into(),
2048+ host: "collin@mini".into(),
2049+ },
2050+ Some("/dev/pts/7"),
2051+ );
2052+ assert_eq!(lines.len(), 1);
2053+ let line = &lines[0];
2054+ assert!(line.contains("-n 'mini'"), "{line}");
2055+ assert!(
2056+ line.ends_with("switch-client -c '/dev/pts/7' -t 'work:'"),
2057+ "{line}"
2058+ );
2059+
2060+ let script = line
2061+ .split('\'')
2062+ .find(|s| s.contains("run-"))
2063+ .expect("script path in the line");
2064+ let body = std::fs::read_to_string(script).expect("script written");
2065+ assert!(body.contains("cmd='ssh collin@mini'"), "{body}");
2066+ // The window outlives the connection, so a refusal stays on screen.
2067+ assert!(body.contains(r#"exec "${SHELL:-/bin/sh}""#), "{body}");
2068+ std::fs::remove_file(script).ok();
2069+ }
2070+
2071+ /// A first word that is not a bare word never reaches the command line.
2072+ #[test]
2073+ fn run_window_name_falls_back_when_the_command_is_not_a_plain_word() {
2074+ assert_eq!(window_name_for("cargo test"), "cargo");
2075+ assert_eq!(window_name_for("/usr/bin/env ls"), "env");
2076+ assert_eq!(window_name_for("FOO=1 ls"), "run");
2077+ assert_eq!(window_name_for("'weird thing'"), "run");
2078+ assert_eq!(window_name_for("#(whoami)"), "run");
2079+ assert_eq!(window_name_for("-x"), "run");
2080+ assert_eq!(window_name_for(""), "run");
2081+ }
2082+
2083+ #[test]
2084+ fn new_project_takes_a_rooted_path_and_nothing_else() {
2085+ let req = |s: &str| parse_tmux_request(s);
2086+ assert_eq!(
2087+ req(r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"foo"}"#),
2088+ Some(TmuxRequest::NewProject {
2089+ path: "/srv/foo".into(),
2090+ name: "foo".into(),
2091+ prompt: None,
2092+ })
2093+ );
2094+ assert_eq!(
2095+ req(
2096+ r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"foo","prompt":"go"}"#
2097+ ),
2098+ Some(TmuxRequest::NewProject {
2099+ path: "/srv/foo".into(),
2100+ name: "foo".into(),
2101+ prompt: Some("go".into()),
2102+ })
2103+ );
2104+ for bad in [
2105+ // Relative, and traversal: neither is a path this daemon expands.
2106+ r#"{"type":"tmux","cmd":"new-project","path":"foo","name":"foo"}"#,
2107+ r#"{"type":"tmux","cmd":"new-project","path":"/srv/../etc","name":"foo"}"#,
2108+ // A session name that would reach tmux as a flag, or as two words.
2109+ r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"-C"}"#,
2110+ r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"a b"}"#,
2111+ r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"a' ; kill-server ; '"}"#,
2112+ // Present but unusable is a rejection, not a shell.
2113+ r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo","name":"foo","prompt":""}"#,
2114+ r#"{"type":"tmux","cmd":"new-project","name":"foo"}"#,
2115+ r#"{"type":"tmux","cmd":"new-project","path":"/srv/foo"}"#,
2116+ ] {
2117+ assert_eq!(req(bad), None, "should be refused: {bad}");
2118+ }
2119+ }
2120+
2121+ /// The directory is in the script, not on the tmux command line — a path is
2122+ /// allowed to contain the quote that line is held together with.
2123+ #[test]
2124+ fn new_project_puts_the_directory_in_a_script() {
2125+ let dir = std::env::temp_dir().join("tb-it's-here");
2126+ let lines = command_lines(
2127+ &TmuxRequest::NewProject {
2128+ path: dir.to_string_lossy().into(),
2129+ name: "here".into(),
2130+ prompt: Some("do the thing".into()),
2131+ },
2132+ Some("/dev/pts/7"),
2133+ );
2134+ assert_eq!(lines.len(), 2);
2135+ assert!(!lines[0].contains("it's"), "path leaked into: {}", lines[0]);
2136+ assert!(!lines[0].contains("do the thing"), "{}", lines[0]);
2137+ assert!(lines[0].starts_with("new-session -d -s 'here' -n 'claude' '"));
2138+ assert_eq!(lines[1], "switch-client -c '/dev/pts/7' -t 'here:'");
2139+
2140+ let script = lines[0]
2141+ .split('\'')
2142+ .find(|s| s.contains("project-"))
2143+ .expect("script path in the line");
2144+ let body = std::fs::read_to_string(script).expect("script written");
2145+ assert!(body.contains(r"dir='/tmp/tb-it'\''s-here'"), "{body}");
2146+ assert!(body.contains("prompt='do the thing'"), "{body}");
2147+ assert!(body.contains("claude \"$prompt\""), "{body}");
2148+ let _ = std::fs::remove_file(script);
2149+
2150+ // Without a prompt it is a shell, and the window is named for the
2151+ // session rather than for Claude.
2152+ let lines = command_lines(
2153+ &TmuxRequest::NewProject {
2154+ path: "/srv/foo".into(),
2155+ name: "foo".into(),
2156+ prompt: None,
2157+ },
2158+ None,
2159+ );
2160+ assert!(lines[0].starts_with("new-session -d -s 'foo' -n 'foo' '"));
2161+ let script = lines[0]
2162+ .split('\'')
2163+ .find(|s| s.contains("project-"))
2164+ .expect("script path in the line");
2165+ let body = std::fs::read_to_string(script).expect("script written");
2166+ assert!(!body.contains("claude"), "{body}");
2167+ let _ = std::fs::remove_file(script);
2168+ }
2169+
2170+ /// A path query is answered, and it can never become a tmux command.
2171+ #[test]
2172+ fn path_queries_are_their_own_frame() {
2173+ assert_eq!(
2174+ parse_path_query(r#"{"type":"path","q":"~/Code"}"#),
2175+ Some("~/Code".into())
2176+ );
2177+ assert_eq!(parse_path_query(r#"{"type":"tmux","cmd":"switch"}"#), None);
2178+ assert_eq!(parse_tmux_request(r#"{"type":"path","q":"~/Code"}"#), None);
2179+ assert_eq!(
2180+ parse_path_query(&format!(r#"{{"type":"path","q":"{}"}}"#, "a".repeat(9000))),
2181+ None
2182+ );
2183+
2184+ let answer = path_answer("../etc");
2185+ assert!(answer.contains(r#""kind":"invalid""#), "{answer}");
2186+ let tmp = std::env::temp_dir();
2187+ let answer = path_answer(&tmp.to_string_lossy());
2188+ assert!(answer.contains(r#""kind":"dir""#), "{answer}");
2189+ }
2190+
2191+ #[test]
9122192 fn create_attaches_rather_than_failing_on_an_existing_name() {
9132193 let lines = command_lines(&TmuxRequest::Create("scratch".into()), None);
9142194 assert_eq!(lines[0], "new-session -d -A -s 'scratch'");
⋯ 3 unchanged lines
addeddaemon/src/ssh.rs+344 −0
1+//! Hosts the omnibar can offer to connect to, and the gate on the one it sends
2+//! back.
3+//!
4+//! The daemon runs tmux on *this* machine, and that does not change here: an
5+//! ssh connection is an ordinary local window that happens to be running ssh.
6+//! Nothing about the session model, the tabs or the status frames knows the
7+//! difference, which is the whole reason this is cheap.
8+//!
9+//! What the daemon contributes is the list. The names worth offering are the
10+//! ones ssh itself already knows — `~/.ssh/config` and, failing that,
11+//! `~/.ssh/known_hosts` — so the panel offers hosts you have actually
12+//! connected to rather than asking you to remember them a second time.
13+
14+use std::collections::HashSet;
15+use std::path::{Path, PathBuf};
16+
17+/// The most hosts the panel is told about.
18+///
19+/// `known_hosts` on an old account runs to hundreds of lines, and the omnibar
20+/// shows eight rows. The cap is about not sending a list nobody will scroll,
21+/// not about safety — every name in it went through [`valid_ssh_host`].
22+const MAX_HOSTS: usize = 100;
23+
24+/// The most an ssh destination may be. Longer than any real `user@host` and
25+/// far short of anything that would look like a command line.
26+const MAX_HOST: usize = 128;
27+
28+/// An ssh destination, as `[user@]host`.
29+///
30+/// The hazard here is not shell metacharacters — the destination reaches ssh
31+/// as one argv element, by way of a generated script — but *ssh's own options*.
32+/// `ssh -oProxyCommand=…` runs a shell, and ssh accepts an option anywhere on
33+/// its command line, so a destination beginning with a dash is the one thing
34+/// that turns "connect somewhere" into "run something". Both halves are checked
35+/// for it, and the charset on top of that leaves nothing that could become a
36+/// second argument.
37+///
38+/// Deliberately narrower than what ssh accepts: no `ssh://` URLs, no IPv6
39+/// brackets, no `:port`. Those are all reachable by typing `!ssh …` into the
40+/// same box, which is where the general case belongs. This is for names.
41+pub fn valid_ssh_host(raw: &str) -> Option<String> {
42+ let s = raw.trim();
43+ if s.is_empty() || s.len() > MAX_HOST {
44+ return None;
45+ }
46+ // At most one `@`, so a host cannot smuggle a second destination.
47+ let (user, host) = match s.split_once('@') {
48+ Some((u, h)) => {
49+ if h.contains('@') {
50+ return None;
51+ }
52+ (Some(u), h)
53+ }
54+ None => (None, s),
55+ };
56+ let plain = |part: &str| {
57+ !part.is_empty()
58+ && !part.starts_with('-')
59+ && part
60+ .chars()
61+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
62+ };
63+ if !plain(host) {
64+ return None;
65+ }
66+ if user.is_some_and(|u| !plain(u)) {
67+ return None;
68+ }
69+ Some(s.to_string())
70+}
71+
72+/// The tmux window name for a connection: the host, without the user.
73+///
74+/// Three windows on the same box are told apart by what is in them, not by the
75+/// login, and `collin@mini` in a tab is mostly the part that is the same every
76+/// time. Falls back to the whole destination, which [`valid_ssh_host`] has
77+/// already restricted to characters a tmux window name is happy with.
78+pub fn window_name(host: &str) -> &str {
79+ match host.split_once('@') {
80+ Some((_, h)) if !h.is_empty() => h,
81+ _ => host,
82+ }
83+}
84+
85+/// Every host worth offering, best source first and deduplicated.
86+///
87+/// Config aliases lead because they are what a person types and what they
88+/// named themselves; `known_hosts` fills in behind them for the machines
89+/// reached without ever writing a config entry.
90+pub fn hosts() -> Vec<String> {
91+ let Some(dir) = ssh_dir() else {
92+ return Vec::new();
93+ };
94+ let mut out = Vec::new();
95+ let mut seen = HashSet::new();
96+ let mut push = |name: String| {
97+ if out.len() < MAX_HOSTS && seen.insert(name.clone()) {
98+ out.push(name);
99+ }
100+ };
101+
102+ let (names, includes) = parse_config(&read(dir.join("config")));
103+ for name in names {
104+ push(name);
105+ }
106+ // One level. Included files may include further files, and following that
107+ // is a graph walk with a cycle check for the sake of a list of suggestions
108+ // — the first level is where every real config puts its hosts.
109+ for include in includes {
110+ for path in expand_include(&include, &dir) {
111+ let (names, _) = parse_config(&read(path));
112+ for name in names {
113+ push(name);
114+ }
115+ }
116+ }
117+ for name in parse_known_hosts(&read(dir.join("known_hosts"))) {
118+ push(name);
119+ }
120+ out
121+}
122+
123+fn ssh_dir() -> Option<PathBuf> {
124+ let home = std::env::var_os("HOME")?;
125+ Some(PathBuf::from(home).join(".ssh"))
126+}
127+
128+/// Missing and unreadable are the same answer: no hosts from here. An
129+/// unreadable ssh config is not this daemon's problem to report.
130+fn read(path: impl AsRef<Path>) -> String {
131+ std::fs::read_to_string(path).unwrap_or_default()
132+}
133+
134+/// Host aliases and `Include` paths from an ssh config.
135+///
136+/// Aliases, not `HostName`s: the alias is the thing you type at ssh, and the
137+/// whole point of having written one is that it is shorter and more memorable
138+/// than the address behind it.
139+///
140+/// Patterns are skipped. `Host *` is settings for everything rather than a
141+/// machine, and offering it as somewhere to connect would be offering ssh's
142+/// own defaults block as a destination. Negations (`!host`) are skipped for
143+/// the same reason: they name where a block does *not* apply.
144+pub fn parse_config(text: &str) -> (Vec<String>, Vec<String>) {
145+ let mut hosts = Vec::new();
146+ let mut includes = Vec::new();
147+ for line in text.lines() {
148+ let line = line.trim();
149+ if line.is_empty() || line.starts_with('#') {
150+ continue;
151+ }
152+ // ssh accepts `Host foo` and `Host=foo` alike, and is case-insensitive
153+ // about the keyword.
154+ let mut parts = line.split(|c: char| c.is_whitespace() || c == '=');
155+ let Some(keyword) = parts.next() else {
156+ continue;
157+ };
158+ let values = parts.filter(|v| !v.is_empty());
159+ if keyword.eq_ignore_ascii_case("host") {
160+ hosts.extend(
161+ values
162+ .filter(|v| !v.contains(['*', '?', '!']))
163+ .filter_map(valid_ssh_host),
164+ );
165+ } else if keyword.eq_ignore_ascii_case("include") {
166+ includes.extend(values.map(str::to_string));
167+ }
168+ }
169+ (hosts, includes)
170+}
171+
172+/// Hostnames from a `known_hosts` file.
173+///
174+/// Most of what is in one is unusable here and is dropped: hashed entries
175+/// (`|1|…`, which is the default on many distributions) cannot be reversed,
176+/// addresses are not names anybody types, and `[host]:port` entries belong to
177+/// the general case that `!ssh` covers. What is left is the plain hostnames,
178+/// which is exactly the set that is useful when there is no config file.
179+pub fn parse_known_hosts(text: &str) -> Vec<String> {
180+ let mut out = Vec::new();
181+ for line in text.lines() {
182+ let line = line.trim();
183+ if line.is_empty() || line.starts_with('#') || line.starts_with('|') {
184+ continue;
185+ }
186+ // A marker line (`@cert-authority`, `@revoked`) shifts every field
187+ // along by one.
188+ let mut fields = line.split_whitespace();
189+ let Some(first) = fields.next() else { continue };
190+ let patterns = if first.starts_with('@') {
191+ match fields.next() {
192+ Some(p) => p,
193+ None => continue,
194+ }
195+ } else {
196+ first
197+ };
198+ // One entry can list a name and its address, comma separated.
199+ for name in patterns.split(',') {
200+ // An address is not a name worth suggesting: you would not have
201+ // typed it, and if you would, `!ssh` takes it.
202+ if name.chars().all(|c| c.is_ascii_digit() || c == '.') {
203+ continue;
204+ }
205+ if let Some(host) = valid_ssh_host(name) {
206+ out.push(host);
207+ }
208+ }
209+ }
210+ out
211+}
212+
213+/// The files an `Include` names.
214+///
215+/// Relative paths are relative to `~/.ssh`, per ssh_config(5). The only glob
216+/// handled is a `*` in the final component, which is the shape every real
217+/// config uses (`Include config.d/*`) — anything more elaborate silently
218+/// contributes nothing, which costs a suggestion and no more.
219+fn expand_include(pattern: &str, ssh_dir: &Path) -> Vec<PathBuf> {
220+ let expanded = match pattern.strip_prefix("~/") {
221+ Some(rest) => match std::env::var_os("HOME") {
222+ Some(home) => PathBuf::from(home).join(rest),
223+ None => return Vec::new(),
224+ },
225+ None => {
226+ let p = Path::new(pattern);
227+ if p.is_absolute() {
228+ p.to_path_buf()
229+ } else {
230+ ssh_dir.join(p)
231+ }
232+ }
233+ };
234+ let Some(name) = expanded.file_name().and_then(|n| n.to_str()) else {
235+ return Vec::new();
236+ };
237+ if !name.contains('*') {
238+ return vec![expanded];
239+ }
240+ let (prefix, suffix) = name.split_once('*').unwrap_or((name, ""));
241+ // A second `*` would need real glob matching; the prefix and suffix of the
242+ // first one are as far as this goes.
243+ if suffix.contains('*') {
244+ return Vec::new();
245+ }
246+ let dir = expanded.parent().unwrap_or(ssh_dir).to_path_buf();
247+ let Ok(entries) = std::fs::read_dir(&dir) else {
248+ return Vec::new();
249+ };
250+ let mut out: Vec<PathBuf> = entries
251+ .flatten()
252+ .filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
253+ .filter(|e| match e.file_name().to_str() {
254+ Some(n) => {
255+ n.starts_with(prefix)
256+ && n.ends_with(suffix)
257+ && n.len() >= prefix.len() + suffix.len()
258+ }
259+ None => false,
260+ })
261+ .map(|e| e.path())
262+ .collect();
263+ // Directory order is whatever the filesystem feels like, and the panel
264+ // shows the first few of whatever it is sent.
265+ out.sort();
266+ out
267+}
268+
269+#[cfg(test)]
270+mod tests {
271+ use super::*;
272+
273+ #[test]
274+ fn accepts_plain_destinations() {
275+ assert_eq!(valid_ssh_host("mini").as_deref(), Some("mini"));
276+ assert_eq!(
277+ valid_ssh_host(" collin@mini ").as_deref(),
278+ Some("collin@mini")
279+ );
280+ assert_eq!(
281+ valid_ssh_host("build-01.example.com").as_deref(),
282+ Some("build-01.example.com")
283+ );
284+ }
285+
286+ #[test]
287+ fn rejects_anything_ssh_would_read_as_an_option() {
288+ // The one that matters: a leading dash is an ssh flag, and
289+ // `-oProxyCommand=` runs a shell.
290+ assert_eq!(valid_ssh_host("-oProxyCommand=sh"), None);
291+ assert_eq!(valid_ssh_host("root@-oProxyCommand=sh"), None);
292+ assert_eq!(valid_ssh_host("-mini"), None);
293+ }
294+
295+ #[test]
296+ fn rejects_anything_that_is_not_one_name() {
297+ assert_eq!(valid_ssh_host(""), None);
298+ assert_eq!(valid_ssh_host(" "), None);
299+ assert_eq!(valid_ssh_host("mini ; kill-server"), None);
300+ assert_eq!(valid_ssh_host("mini' ; kill-server ; '"), None);
301+ assert_eq!(valid_ssh_host("a@b@c"), None);
302+ assert_eq!(valid_ssh_host("@mini"), None);
303+ assert_eq!(valid_ssh_host("collin@"), None);
304+ assert_eq!(valid_ssh_host("ssh://mini"), None);
305+ assert_eq!(valid_ssh_host("mini:22"), None);
306+ assert_eq!(valid_ssh_host(&"a".repeat(MAX_HOST + 1)), None);
307+ }
308+
309+ #[test]
310+ fn window_name_drops_the_user() {
311+ assert_eq!(window_name("collin@mini"), "mini");
312+ assert_eq!(window_name("mini"), "mini");
313+ }
314+
315+ #[test]
316+ fn config_yields_aliases_and_includes() {
317+ let (hosts, includes) = parse_config(
318+ "# comment\n\
319+ Include ~/.ssh/config.d/*\n\
320+ Host mini nas\n\
321+ \tHostName 10.0.0.4\n\
322+ Host=work\n\
323+ Host *\n\
324+ Host *.internal\n\
325+ Host !badhost\n\
326+ Match host anything\n",
327+ );
328+ assert_eq!(hosts, ["mini", "nas", "work"]);
329+ assert_eq!(includes, ["~/.ssh/config.d/*"]);
330+ }
331+
332+ #[test]
333+ fn known_hosts_drops_what_cannot_be_typed() {
334+ let hosts = parse_known_hosts(
335+ "|1|aGFzaGVk|aGFzaGVk ssh-ed25519 AAAA\n\
336+ mini,10.0.0.4 ssh-ed25519 AAAA\n\
337+ [mini]:2222 ssh-ed25519 AAAA\n\
338+ @cert-authority anvil.example.com ssh-rsa AAAA\n\
339+ 192.168.1.9 ssh-rsa AAAA\n\
340+ # comment\n",
341+ );
342+ assert_eq!(hosts, ["mini", "anvil.example.com"]);
343+ }
344+}
modifieddaemon/src/status.rs+379 −70
⋯ 7 unchanged lines
88 //! - Claude Code's hook interface, via the records in [`crate::agents`].
99 //!
1010 //! The join between them is the tmux pane id.
11+//!
12+//! Plus one thing neither of them knows on its own — whether a pane at rest has
13+//! been on screen since it went quiet. That is [`SEEN`], and it is what turns
14+//! "idle" into "done, and you haven't looked yet".
1115
16+use std::collections::{HashMap, HashSet};
17+use std::sync::{Mutex, OnceLock};
18+
1219 use serde::Serialize;
1320
21+use crate::agents::{Record, State};
1422 use crate::control::Control;
1523
1624 /// Tab character, as tmux sees it. Single quotes make tmux take the byte
⋯ 6 unchanged lines
2331 pub struct Agent {
2432 /// tmux pane id (`%12`), stable for the pane's lifetime.
2533 pub pane: String,
34+ /// Session the pane is in, by name. Agents are reported for the whole
35+ /// server, so this is what tells the sidebar which session tab owns one.
36+ pub session: String,
2637 /// tmux window id (`@3`) — the join to [`WindowInfo`], so the sidebar can
2738 /// mark the tab an agent is in without matching on a shifting index.
2839 pub window_id: String,
⋯ 2 unchanged lines
3142 pub name: String,
3243 /// `working` | `waiting` | `idle`, or `unknown` when the pane is running
3344 /// Claude but no hook has ever reported for it.
45+ ///
46+ /// Plus `ready`, which no hook reports: an idle pane that has not been on
47+ /// screen since it went idle. See [`SEEN`].
3448 pub state: &'static str,
49+ /// This pane is the one under the sidebar right now — our client's session,
50+ /// its active window, its active pane. The same fact [`SEEN`] uses to turn
51+ /// `ready` back into `idle`, said out loud, because the panel has no other
52+ /// way to know where it is: it is told about panes, never about its own.
53+ /// The jump button needs it so it can offer somewhere *else*.
54+ pub here: bool,
3555 /// Claude's `permission_mode`: `default`, `acceptEdits`, `plan`,
3656 /// `bypassPermissions`.
3757 pub mode: Option<String>,
⋯ 5 unchanged lines
4363 pub title: Option<String>,
4464 }
4565
66+/// The tmux user option the sidebar keeps a session's colour in.
67+///
68+/// A user option (`@`-prefixed) is storage tmux itself holds, per session, for
69+/// exactly this: state a tool wants to hang on a session without a place of its
70+/// own to put it. Keeping the colour here rather than in the panel buys two
71+/// things the panel could not have. It follows the session through a rename,
72+/// because it is attached to the session and not to its name. And every panel
73+/// on this server sees the same colour, rather than each browser profile
74+/// keeping a private opinion about the same session.
75+///
76+/// It dies with the session, which is right — a colour for a session that no
77+/// longer exists is not worth keeping.
78+pub const COLOR_OPTION: &str = "@termbridge_color";
79+
80+/// One session on the server — one tab in the sidebar's top row, with its
81+/// windows as the second row when it is the one selected.
4682 #[derive(Serialize, Debug, Clone, PartialEq, Eq)]
4783 pub struct SessionInfo {
84+ /// tmux session id (`$1`). Stable across renames, unlike the name.
85+ pub id: String,
4886 pub name: String,
49- pub windows: u32,
87+ /// Some tmux client, anywhere, is on this session.
5088 pub attached: bool,
89+ /// The sidebar's group colour, from [`COLOR_OPTION`]. `None` when unset,
90+ /// which is what tells the panel to pick one itself.
91+ pub color: Option<String>,
92+ /// Working directory of the session's current pane — what `pwd` would say
93+ /// in the shell you are looking at, which is the one thing about a session
94+ /// its name never tells you.
95+ pub path: Option<String>,
96+ /// When tmux made it, in Unix seconds. The panel shows an age.
97+ pub created: Option<u64>,
98+ /// How many tmux clients are on it — a terminal elsewhere on the same
99+ /// session is why what you type appears in two places.
100+ pub clients: u32,
101+ /// Its windows, in index order. Carried for every session and not just the
102+ /// attached one: the sidebar draws the window row for whichever session
103+ /// tab is selected, and switching must not wait for the next round trip.
104+ pub windows: Vec<WindowInfo>,
51105 }
52106
53-/// One window in the attached session — one tab in the sidebar.
107+/// One window of a session — one tab in the sidebar's second row.
54108 #[derive(Serialize, Debug, Clone, PartialEq, Eq)]
55109 pub struct WindowInfo {
56110 /// tmux window id (`@3`). Stable for the window's lifetime, unlike the
⋯ 9 unchanged lines
66120 pub activity: bool,
67121 }
68122
123+/// The session a client is on, by both names tmux knows it by.
124+#[derive(Debug, Clone, PartialEq, Eq)]
125+pub struct ClientSession {
126+ pub name: String,
127+ /// tmux session id (`$1`). Unlike the name it cannot contain a quote or a
128+ /// space, so it is the safe way to name the session in a command line.
129+ pub id: String,
130+}
131+
69132 #[derive(Serialize, Debug, Clone, PartialEq, Eq)]
70133 pub struct Snapshot {
71134 /// Session our client is attached to *right now*, not the one we asked for.
72135 pub session: Option<String>,
73- /// Every session on the server, for the sidebar's switcher.
136+ /// Same session as [`Snapshot::session`], by id — for command lines.
137+ #[serde(skip)]
138+ pub session_id: Option<String>,
139+ /// Every session on the server, each with its own windows.
74140 pub sessions: Vec<SessionInfo>,
75- /// Windows of the attached session, in index order — the sidebar's tabs.
76- pub windows: Vec<WindowInfo>,
141+ /// Every agent on the server, in any session — so a session tab can report
142+ /// a Claude waiting on you in a session you are not looking at.
77143 pub agents: Vec<Agent>,
78144 }
79145
80146 /// Ask tmux, then fold in the hook records.
147+///
148+/// The whole server, not just the attached session: the sidebar's two rows of
149+/// tabs are sessions over their windows, and an agent anywhere is worth a light
150+/// on the session that holds it. It costs no more round trips than one session
151+/// did — `list-windows -a` and `list-panes -a` answer for all of them at once.
81152 pub async fn snapshot(control: &Control, tty: Option<&str>) -> Snapshot {
82- let session = current_session(control, tty).await;
153+ let current = current_session(control, tty).await;
83154 let sessions = list_sessions(control).await;
84- let (windows, agents) = match &session {
85- Some(s) => (list_windows(control, s).await, agents_in(control, s).await),
86- None => (Vec::new(), Vec::new()),
155+ let agents = agents_all(control, current.as_ref().map(|c| c.name.as_str())).await;
156+ let (session, session_id) = match current {
157+ Some(c) => (Some(c.name), Some(c.id)),
158+ None => (None, None),
87159 };
88160 Snapshot {
89161 session,
162+ session_id,
90163 sessions,
91- windows,
92164 agents,
93165 }
94166 }
⋯ 3 unchanged lines
98170 /// tmux is the authority: the client can leave the session it was spawned with
99171 /// (`switch-client`, `choose-tree`, prefix-`(`/`)`), and the name we passed to
100172 /// `new-session` goes stale the moment it does.
101-pub async fn current_session(control: &Control, tty: Option<&str>) -> Option<String> {
173+pub async fn current_session(control: &Control, tty: Option<&str>) -> Option<ClientSession> {
102174 let tty = tty?;
103175 let out = control
104- .run(format!("list-clients -F '#{{client_tty}}{T}#{{session_name}}'"))
176+ .run(format!(
177+ "list-clients -F '#{{client_tty}}{T}#{{session_name}}{T}#{{session_id}}'"
178+ ))
105179 .await
106180 .ok()?;
107181 out.iter()
108- .filter_map(|l| l.split_once(T))
109- .find(|(client_tty, _)| *client_tty == tty)
110- .map(|(_, name)| name.trim().to_string())
111- .filter(|name| !name.is_empty())
182+ .filter_map(|l| {
183+ let mut f = l.split(T);
184+ Some((f.next()?, f.next()?, f.next()?))
185+ })
186+ .find(|(client_tty, _, _)| *client_tty == tty)
187+ .map(|(_, name, id)| ClientSession {
188+ name: name.trim().to_string(),
189+ id: id.trim().to_string(),
190+ })
191+ .filter(|c| !c.name.is_empty() && !c.id.is_empty())
112192 }
113193
194+/// Every session, each carrying its own windows, oldest first.
195+///
196+/// tmux lists sessions alphabetically, which makes a new one appear wherever
197+/// its name happens to sort — the tab row would reshuffle around a session you
198+/// just made. Session ids come from a counter that only goes up, so ordering by
199+/// id is creation order: existing tabs never move, and a new session is always
200+/// the one on the end.
114201 async fn list_sessions(control: &Control) -> Vec<SessionInfo> {
115- let fmt = format!("#{{session_name}}{T}#{{session_windows}}{T}#{{session_attached}}");
202+ // The colour comes back in the same format string rather than a
203+ // `show-options` per session: it is one more field on a query we already
204+ // make, so a server with twenty sessions costs exactly what it did.
205+ // `pane_current_path` and the rest resolve against the session's current
206+ // pane, which is what makes the cwd free: it rides the query the tab row
207+ // already costs rather than a `display-message` per session.
208+ let fmt = format!(
209+ "#{{session_id}}{T}#{{session_attached}}{T}#{{{COLOR_OPTION}}}{T}\
210+ #{{session_created}}{T}#{{pane_current_path}}{T}#{{session_name}}"
211+ );
116212 let Ok(out) = control.run(format!("list-sessions -F '{fmt}'")).await else {
117213 return Vec::new();
118214 };
119- out.iter()
215+ let mut windows = list_windows(control).await;
216+ let mut sessions: Vec<SessionInfo> = out
217+ .iter()
120218 .filter_map(|line| {
121- let mut f = line.split(T);
219+ // Session names can hold a tab, so the name takes the rest of the
220+ // line rather than a field of its own.
221+ let mut f = line.splitn(6, T);
222+ let id = f.next()?.to_string();
223+ // The field is a client count, and any of them being on it is what
224+ // "attached" means.
225+ let clients: u32 = f.next()?.trim().parse().unwrap_or(0);
226+ // Empty means the option is unset, which is not the same as a
227+ // colour of zero — the sidebar picks its own when there is none.
228+ let color = f.next()?.trim();
229+ let color = (!color.is_empty()).then(|| color.to_string());
230+ let created = f.next()?.trim().parse().ok();
231+ // Absent when the session has no pane tmux will answer for, which
232+ // it survives — the panel just has one less line to show.
233+ let path = f.next()?.trim();
234+ let path = (!path.is_empty()).then(|| path.to_string());
235+ let name = f.next()?.to_string();
236+ let windows = windows.remove(&id).unwrap_or_default();
122237 Some(SessionInfo {
123- name: f.next()?.to_string(),
124- windows: f.next()?.parse().unwrap_or(0),
125- attached: f.next().unwrap_or("0") != "0",
238+ id,
239+ name,
240+ attached: clients > 0,
241+ color,
242+ path,
243+ created,
244+ clients,
245+ windows,
126246 })
127247 })
128- .collect()
248+ .collect();
249+ sessions.sort_by_key(|s| session_ordinal(&s.id));
250+ sessions
251+}
252+
253+/// The number in a `$12` session id. Ids tmux didn't issue sort last rather
254+/// than first, so an unreadable one cannot jump the row it lands in.
255+pub fn session_ordinal(id: &str) -> u64 {
256+ id.strip_prefix('$')
257+ .and_then(|n| n.parse().ok())
258+ .unwrap_or(u64::MAX)
129259 }
130260
131-/// Windows of `session`, in the order tmux lists them (by index).
132-///
133-/// `session` reaches here from tmux itself or from a name validated in
134-/// [`crate::pty::valid_session_name`], and is single-quoted besides.
135-async fn list_windows(control: &Control, session: &str) -> Vec<WindowInfo> {
261+/// Every window on the server, grouped by session id and left in the order
262+/// tmux lists them (by index).
263+async fn list_windows(control: &Control) -> std::collections::HashMap<String, Vec<WindowInfo>> {
136264 let fmt = format!(
137- "#{{window_id}}{T}#{{window_index}}{T}#{{window_name}}{T}\
138- #{{window_active}}{T}#{{window_panes}}{T}#{{window_activity_flag}}"
265+ "#{{session_id}}{T}#{{window_id}}{T}#{{window_index}}{T}\
266+ #{{window_active}}{T}#{{window_panes}}{T}#{{window_activity_flag}}{T}#{{window_name}}"
139267 );
140- let Ok(out) = control
141- .run(format!("list-windows -t '{session}' -F '{fmt}'"))
142- .await
143- else {
144- return Vec::new();
268+ let mut out_map: std::collections::HashMap<String, Vec<WindowInfo>> = Default::default();
269+ let Ok(out) = control.run(format!("list-windows -a -F '{fmt}'")).await else {
270+ return out_map;
145271 };
146- out.iter()
147- .filter_map(|line| {
148- let mut f = line.split(T);
149- Some(WindowInfo {
150- id: f.next()?.to_string(),
151- index: f.next()?.parse().ok()?,
152- name: f.next()?.to_string(),
153- active: f.next().unwrap_or("0") != "0",
154- panes: f.next().and_then(|p| p.parse().ok()).unwrap_or(1),
155- activity: f.next().unwrap_or("0") != "0",
156- })
157- })
158- .collect()
272+ for line in out {
273+ // Window names are set by whatever is running in them, tabs included,
274+ // so the name is last and gets everything that is left.
275+ let mut f = line.splitn(7, T);
276+ let Some(window) = (|| {
277+ let session = f.next()?.to_string();
278+ Some((
279+ session,
280+ WindowInfo {
281+ id: f.next()?.to_string(),
282+ index: f.next()?.parse().ok()?,
283+ active: f.next()? != "0",
284+ panes: f.next().and_then(|p| p.parse().ok()).unwrap_or(1),
285+ activity: f.next()? != "0",
286+ name: f.next()?.to_string(),
287+ },
288+ ))
289+ })() else {
290+ continue;
291+ };
292+ out_map.entry(window.0).or_default().push(window.1);
293+ }
294+ out_map
295+}
296+
297+/// How long a hook record outlives the event that wrote it.
298+///
299+/// A record only disappears on `SessionEnd`, so a Claude that was killed — or
300+/// whose terminal went away — leaves one behind forever, still saying
301+/// `working`. tmux then hands that pane id to the next window, and a plain
302+/// shell inherits a spinner that never stops. Ten minutes is longer than the
303+/// gap between hook events in a live turn and short enough that a recycled
304+/// pane goes quiet while you are still looking at it.
305+const STALE_AFTER: u64 = 10 * 60;
306+
307+/// What to report for one pane: `None` when there is no agent in it at all,
308+/// `Some(None)` when Claude is there but nothing credible is known about what
309+/// it is doing (the `unknown` state).
310+///
311+/// `cmd` is the pane's foreground process, `record` the newest hook record
312+/// filed against it.
313+fn reported<'a>(cmd: &str, record: Option<&'a Record>) -> Option<Option<&'a Record>> {
314+ let fresh = record.is_some_and(|r| r.age() < STALE_AFTER);
315+ // A pane qualifies if Claude is the foreground process, or if a hook
316+ // reported for it recently — Claude that shelled out to a long-running
317+ // command shows that command as pane_current_command and would otherwise
318+ // vanish from the sidebar mid-run. Old record, no Claude in the pane: an
319+ // orphan, and the pane is somebody else's now.
320+ if cmd != "claude" && !fresh {
321+ return None;
322+ }
323+ // Claude is in the pane, so the entry stays either way; the question is
324+ // whether its state is still true. `idle` and `waiting` are resting states
325+ // and stay true for as long as nobody touches the keyboard. `working` is
326+ // not: a session killed mid-turn leaves it behind, and it is the one state
327+ // that animates.
328+ Some(record.filter(|r| fresh || r.state != State::Working))
329+}
330+
331+/// Which rest you have already seen: pane id → the `updated` stamp of the
332+/// record that was on screen when you saw it.
333+///
334+/// The hook records say what a Claude is doing; they cannot say whether it has
335+/// had your attention, because nothing in Claude's process knows which tmux
336+/// pane is in front of a person. tmux knows, and this is where the two are put
337+/// together: a pane that is the active pane of the active window of a session
338+/// with a client on it is being looked at, and whatever it is doing right now
339+/// is not news any more.
340+///
341+/// Stamps rather than a flag, so nothing has to be cleared. Every hook event
342+/// bumps `updated`, so the mark a turn ago no longer matches the record a new
343+/// turn wrote — the pane goes back to unseen by itself, and a mark that
344+/// survives into a *recycled* pane cannot match the new Claude's record either.
345+///
346+/// Daemon-wide rather than per connection: two browser panels on one server are
347+/// two views of the same tmux, and a window one of them showed you is not
348+/// something the other should still be flagging. It lives for as long as the
349+/// daemon does — long enough to outlast a panel reload, which is the point of
350+/// keeping it here and not in the browser.
351+static SEEN: OnceLock<Mutex<HashMap<String, u64>>> = OnceLock::new();
352+
353+fn seen() -> &'static Mutex<HashMap<String, u64>> {
354+ SEEN.get_or_init(Default::default)
355+}
356+
357+/// What to report for a pane, given whether it is on screen right now.
358+///
359+/// Takes the map rather than reaching for [`SEEN`] so the rule can be tested
360+/// without a global; the caller holds the lock across a whole listing, which
361+/// also keeps one snapshot from marking panes another is mid-way through
362+/// reading.
363+fn state_of(
364+ seen: &mut HashMap<String, u64>,
365+ pane: &str,
366+ record: Option<&Record>,
367+ visible: bool,
368+) -> &'static str {
369+ let Some(record) = record else {
370+ // No credible record, so nothing to have seen — and nothing to mark,
371+ // since there is no stamp to mark it with.
372+ return "unknown";
373+ };
374+ if visible {
375+ seen.insert(pane.to_string(), record.updated);
376+ return record.state.as_str();
377+ }
378+ // Only rest is worth flagging. `working` reports itself and `waiting` is
379+ // already the loudest thing the sidebar draws.
380+ if record.state == State::Idle && seen.get(pane) != Some(&record.updated) {
381+ return "ready";
382+ }
383+ record.state.as_str()
159384 }
160385
161-async fn agents_in(control: &Control, session: &str) -> Vec<Agent> {
386+/// Agents in every session on the server.
387+///
388+/// `current` is the session this daemon's client is attached to — half of what
389+/// makes a pane visible, the other half being tmux's own active window and
390+/// active pane flags.
391+async fn agents_all(control: &Control, current: Option<&str>) -> Vec<Agent> {
162392 let fmt = format!(
163- "#{{pane_id}}{T}#{{window_id}}{T}#{{window_index}}{T}\
164- #{{window_name}}{T}#{{pane_current_command}}"
393+ "#{{pane_id}}{T}#{{window_id}}{T}#{{window_index}}{T}#{{window_active}}{T}\
394+ #{{pane_active}}{T}#{{pane_current_command}}{T}#{{session_name}}{T}#{{window_name}}"
165395 );
166- // `session` reaches here from tmux itself or from a validated name, and is
167- // single-quoted besides.
168- let Ok(out) = control
169- .run(format!("list-panes -s -t '{session}' -F '{fmt}'"))
170- .await
171- else {
396+ let Ok(out) = control.run(format!("list-panes -a -F '{fmt}'")).await else {
172397 return Vec::new();
173398 };
174399 let records = crate::agents::by_pane();
400+ let mut seen = seen().lock().unwrap_or_else(|e| e.into_inner());
401+ let mut live: HashSet<String> = HashSet::new();
175402
176- out.iter()
403+ let agents: Vec<Agent> = out
404+ .iter()
177405 .filter_map(|line| {
178- let mut f = line.split(T);
406+ // Window name last for the same reason as above; the session name
407+ // before it is the one field that could also hold a tab, and a
408+ // pane in a session named like that just loses its name here.
409+ let mut f = line.splitn(8, T);
179410 let pane = f.next()?.to_string();
180411 let window_id = f.next()?.to_string();
181412 let window = f.next()?.to_string();
413+ let window_active = f.next()? != "0";
414+ let pane_active = f.next()? != "0";
415+ let cmd = f.next()?;
416+ let session = f.next()?.to_string();
182417 let name = f.next()?.to_string();
183- let cmd = f.next().unwrap_or("");
184- let record = records.get(&pane);
185- // A pane qualifies if Claude is the foreground process, or if a
186- // hook has reported for it — Claude that shelled out to a
187- // long-running command shows that command as pane_current_command
188- // and would otherwise vanish from the sidebar mid-run.
189- if cmd != "claude" && record.is_none() {
190- return None;
191- }
418+ live.insert(pane.clone());
419+ let record = reported(cmd, records.get(&pane))?;
420+ // What the terminal under the sidebar is actually showing: our
421+ // client's session, the window it is on, the pane that has the
422+ // cursor. A tab in the strip is not a pane on screen.
423+ let visible = window_active && pane_active && current == Some(session.as_str());
424+ let state = state_of(&mut seen, &pane, record, visible);
192425 Some(Agent {
193426 pane,
427+ session,
194428 window_id,
195429 window,
196430 name,
197- state: record.map(|r| r.state.as_str()).unwrap_or("unknown"),
431+ state,
432+ here: visible,
198433 mode: record.and_then(|r| r.mode.clone()),
199434 tool: record.and_then(|r| r.tool.clone()),
200435 message: record.and_then(|r| r.message.clone()),
201436 title: record.and_then(|r| r.name.clone()),
202437 })
203438 })
204- .collect()
439+ .collect();
440+
441+ // Panes that no longer exist. The listing is server-wide, so anything
442+ // missing from it is gone — but only when tmux answered with something, or
443+ // a failed query would look like an empty server and forget everything.
444+ if !live.is_empty() {
445+ seen.retain(|pane, _| live.contains(pane));
446+ }
447+ agents
448+}
449+
450+#[cfg(test)]
451+mod tests {
452+ use super::*;
453+
454+ fn record(state: State, updated: u64) -> Record {
455+ Record {
456+ session_id: "s".into(),
457+ pane: Some("%1".into()),
458+ cwd: "/tmp".into(),
459+ state,
460+ mode: None,
461+ tool: None,
462+ message: None,
463+ name: None,
464+ frame: 0,
465+ updated,
466+ }
467+ }
468+
469+ /// The whole point: an idle pane you have not been shown reads differently
470+ /// from one you have, and looking at it is what settles it.
471+ #[test]
472+ fn idle_is_ready_until_it_has_been_on_screen() {
473+ let mut seen = HashMap::new();
474+ let r = record(State::Idle, 100);
475+ assert_eq!(state_of(&mut seen, "%1", Some(&r), false), "ready");
476+ assert_eq!(state_of(&mut seen, "%1", Some(&r), true), "idle");
477+ assert_eq!(state_of(&mut seen, "%1", Some(&r), false), "idle");
478+ }
479+
480+ /// A mark is only good for the rest it was made against: the next turn
481+ /// writes a new record, and that one has not been seen.
482+ #[test]
483+ fn a_new_turn_is_news_again() {
484+ let mut seen = HashMap::new();
485+ state_of(&mut seen, "%1", Some(&record(State::Idle, 100)), true);
486+ let next = record(State::Idle, 200);
487+ assert_eq!(state_of(&mut seen, "%1", Some(&next), false), "ready");
488+ }
489+
490+ /// Working and waiting report themselves wherever they are — `ready` is
491+ /// only ever a thing an idle pane becomes.
492+ #[test]
493+ fn only_rest_is_flagged() {
494+ let mut seen = HashMap::new();
495+ let working = record(State::Working, 100);
496+ let waiting = record(State::Waiting, 100);
497+ assert_eq!(state_of(&mut seen, "%1", Some(&working), false), "working");
498+ assert_eq!(state_of(&mut seen, "%2", Some(&waiting), false), "waiting");
499+ assert_eq!(state_of(&mut seen, "%3", None, false), "unknown");
500+ assert_eq!(state_of(&mut seen, "%4", None, true), "unknown");
501+ // Nothing was marked for the panes with no record to mark.
502+ assert!(!seen.contains_key("%3") && !seen.contains_key("%4"));
503+ }
504+
505+ /// tmux hands a closed pane's id to the next pane it opens. The stamp is
506+ /// what keeps that from crediting a fresh Claude with having been seen.
507+ #[test]
508+ fn a_recycled_pane_starts_unseen() {
509+ let mut seen = HashMap::new();
510+ state_of(&mut seen, "%1", Some(&record(State::Idle, 100)), true);
511+ let newcomer = record(State::Idle, 900);
512+ assert_eq!(state_of(&mut seen, "%1", Some(&newcomer), false), "ready");
513+ }
205514 }
modifieddaemon/src/tls.rs+11 −7
⋯ 13 unchanged lines
1414 use std::path::Path;
1515 use std::sync::Arc;
1616
17-use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
18-use tokio_rustls::rustls::ServerConfig;
1917 use tokio_rustls::TlsAcceptor;
18+use tokio_rustls::rustls::ServerConfig;
19+use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
2020
2121 pub const CERT_FILE: &str = "cert.pem";
2222 pub const KEY_FILE: &str = "key.pem";
⋯ 175 unchanged lines
198198 fn parse_private_key(pem: &str) -> std::io::Result<PrivateKeyDer<'static>> {
199199 for (begin, end) in [
200200 ("-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"),
201- ("-----BEGIN EC PRIVATE KEY-----", "-----END EC PRIVATE KEY-----"),
202- ("-----BEGIN RSA PRIVATE KEY-----", "-----END RSA PRIVATE KEY-----"),
201+ (
202+ "-----BEGIN EC PRIVATE KEY-----",
203+ "-----END EC PRIVATE KEY-----",
204+ ),
205+ (
206+ "-----BEGIN RSA PRIVATE KEY-----",
207+ "-----END RSA PRIVATE KEY-----",
208+ ),
203209 ] {
204210 if let Some(rest) = pem.split(begin).nth(1) {
205211 if let Some(body) = rest.split(end).next() {
206212 let b64: String = body.chars().filter(|c| !c.is_whitespace()).collect();
207213 let der = base64_decode(&b64)?;
208214 return Ok(match begin {
209- "-----BEGIN EC PRIVATE KEY-----" => {
210- PrivateKeyDer::Sec1(der.into())
211- }
215+ "-----BEGIN EC PRIVATE KEY-----" => PrivateKeyDer::Sec1(der.into()),
212216 "-----BEGIN RSA PRIVATE KEY-----" => PrivateKeyDer::Pkcs1(der.into()),
213217 _ => PrivateKeyDer::Pkcs8(der.into()),
214218 });
⋯ 5 unchanged lines
addeddaemon/tests/activation.rs+130 −0
1+//! Serving a socket we did not bind, and going away when nobody is using it.
2+//!
3+//! Together these are what make the daemon start itself: systemd owns the port
4+//! and execs us on the first connection, and we exit again once the sidebar is
5+//! gone. Both halves have to hold or the user is back to starting it by hand —
6+//! or, worse, ends up with a daemon that exits while a terminal is open.
7+
8+use std::time::Duration;
9+
10+use futures_util::{SinkExt, StreamExt};
11+use tokio_tungstenite::connect_async;
12+use tokio_tungstenite::tungstenite::Message;
13+use tokio_tungstenite::tungstenite::client::IntoClientRequest;
14+
15+use termbridge::{Config, Event, Server};
16+
17+const GOOD_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
18+const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
19+
20+fn test_config() -> Config {
21+ let mut c = Config::new(GOOD_TOKEN, vec![PAIRED.to_string()]);
22+ c.auth_timeout = Duration::from_millis(300);
23+ c.echo_only = true;
24+ c
25+}
26+
27+async fn authed(
28+ server: &Server,
29+) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
30+ let mut req = server.url().into_client_request().unwrap();
31+ req.headers_mut().insert("origin", PAIRED.parse().unwrap());
32+ let (mut ws, _) = connect_async(req).await.expect("connect");
33+ ws.send(Message::Text(
34+ format!(r#"{{"type":"auth","token":"{GOOD_TOKEN}"}}"#).into(),
35+ ))
36+ .await
37+ .unwrap();
38+ let reply = ws.next().await.unwrap().unwrap();
39+ assert!(reply.to_text().unwrap().contains("ok"), "auth: {reply:?}");
40+ ws
41+}
42+
43+/// A socket bound by somebody else is served exactly like one we bound. This is
44+/// the systemd handoff minus the environment variables, which `activation`'s
45+/// own tests cover.
46+#[tokio::test]
47+async fn serves_a_socket_it_did_not_bind() {
48+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
49+ let addr = listener.local_addr().unwrap();
50+ listener.set_nonblocking(true).unwrap();
51+
52+ let server = Server::from_std(test_config(), listener).expect("adopt listener");
53+ assert_eq!(server.addr(), addr, "kept the port systemd chose");
54+
55+ let mut ws = authed(&server).await;
56+ ws.send(Message::Binary(b"hi".to_vec().into()))
57+ .await
58+ .unwrap();
59+ assert_eq!(
60+ ws.next().await.unwrap().unwrap(),
61+ Message::Binary(b"hi".to_vec().into())
62+ );
63+}
64+
65+/// The whole point of the idle timeout: a daemon nobody is talking to should
66+/// stop, so the next connection gets a fresh one from the socket unit.
67+#[tokio::test]
68+async fn idle_daemon_asks_to_exit() {
69+ let mut config = test_config();
70+ config.idle_timeout = Some(Duration::from_millis(200));
71+ let mut server = Server::start(config, 0).await.unwrap();
72+
73+ let event = tokio::time::timeout(Duration::from_secs(5), server.next_event())
74+ .await
75+ .expect("idle event within 5s");
76+ assert_eq!(event, Some(Event::Idle));
77+}
78+
79+/// And the failure that would actually hurt: killing the daemon out from under
80+/// an open terminal. A connected client must hold it open indefinitely, however
81+/// quiet the user is being.
82+#[tokio::test]
83+async fn a_connected_client_is_not_idle() {
84+ let mut config = test_config();
85+ config.idle_timeout = Some(Duration::from_millis(200));
86+ let mut server = Server::start(config, 0).await.unwrap();
87+
88+ let _ws = authed(&server).await;
89+ // Accepted, then nothing. Well past several timeouts.
90+ assert_eq!(
91+ server.next_event().await,
92+ Some(Event::Accepted {
93+ origin: PAIRED.into()
94+ })
95+ );
96+ let quiet = tokio::time::timeout(Duration::from_millis(1500), server.next_event()).await;
97+ assert!(quiet.is_err(), "exited under an open client: {quiet:?}");
98+}
99+
100+/// The clock has to measure time since the last client *left*, not since
101+/// startup — otherwise the first disconnect after a long session triggers an
102+/// immediate exit, and the sidebar's reconnect races the shutdown.
103+#[tokio::test]
104+async fn the_clock_restarts_when_a_client_leaves() {
105+ let mut config = test_config();
106+ config.idle_timeout = Some(Duration::from_millis(600));
107+ let mut server = Server::start(config, 0).await.unwrap();
108+
109+ let ws = authed(&server).await;
110+ assert_eq!(
111+ server.next_event().await,
112+ Some(Event::Accepted {
113+ origin: PAIRED.into()
114+ })
115+ );
116+ tokio::time::sleep(Duration::from_millis(700)).await;
117+ drop(ws);
118+
119+ let event = tokio::time::timeout(Duration::from_secs(5), server.next_event())
120+ .await
121+ .expect("idle event after the client left");
122+ assert_eq!(event, Some(Event::Idle));
123+}
124+
125+/// Left off by default. A hand-started `termbridge serve` that vanished a few
126+/// minutes after the user closed one tab would be a surprising regression.
127+#[tokio::test]
128+async fn no_timeout_by_default() {
129+ assert!(Config::new(GOOD_TOKEN, vec![]).idle_timeout.is_none());
130+}
modifieddaemon/tests/pty_e2e.rs+328 −11
⋯ 5 unchanged lines
66 use std::time::Duration;
77
88 use futures_util::{SinkExt, StreamExt};
9+use tokio_tungstenite::tungstenite::Message;
910 use tokio_tungstenite::tungstenite::client::IntoClientRequest;
10-use tokio_tungstenite::tungstenite::Message;
1111
1212 use termbridge::pty::Profile;
1313 use termbridge::{Config, Server};
⋯ 10 unchanged lines
2424
2525 async fn connect_authed(
2626 server: &Server,
27-) -> tokio_tungstenite::WebSocketStream<
28- tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
29-> {
27+) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
3028 let mut req = server.url().into_client_request().unwrap();
3129 req.headers_mut().insert("origin", ORIGIN.parse().unwrap());
3230 let (mut ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
⋯ 68 unchanged lines
10199 "shell never became ready; saw:\n{}",
102100 printable(&String::from_utf8_lossy(&acc))
103101 );
104- ws.send(Message::Binary(format!("echo {probe}\n").into_bytes().into()))
105- .await
106- .unwrap();
102+ ws.send(Message::Binary(
103+ format!("echo {probe}\n").into_bytes().into(),
104+ ))
105+ .await
106+ .unwrap();
107107
108108 let until = tokio::time::Instant::now() + Duration::from_millis(700);
109109 loop {
⋯ 261 unchanged lines
371371 let _ = tmux(&["kill-server"]);
372372 }
373373
374+/// The omnibar's ssh rows, end to end: a request over the socket has to reach
375+/// tmux as a real window, named for the machine.
376+///
377+/// The host is `.invalid`, which RFC 2606 reserves and no resolver will answer
378+/// for — the test is about the window the daemon opens, and it must not depend
379+/// on anything being reachable. ssh failing in it is the expected outcome; the
380+/// window survives that, which is the other half of what is being checked.
381+#[tokio::test]
382+async fn ssh_request_opens_a_window_named_for_the_host() {
383+ if !Profile::tmux_available() {
384+ eprintln!("skipping: tmux not installed");
385+ return;
386+ }
387+ let sock = "termbridge-test-ssh";
388+ let sess = "termbridge-e2e-ssh";
389+ let tmux = |args: &[&str]| {
390+ std::process::Command::new("tmux")
391+ .args(["-L", sock])
392+ .args(args)
393+ .output()
394+ };
395+ let _ = tmux(&["kill-server"]);
396+
397+ let profile = Profile {
398+ program: "tmux".into(),
399+ args: ["-L", sock, "new-session", "-A", "-s", sess, "/bin/sh"]
400+ .iter()
401+ .map(|s| s.to_string())
402+ .collect(),
403+ };
404+ let server = start(profile).await;
405+ let mut ws = connect_authed(&server).await;
406+ ws.send(Message::Text(
407+ serde_json::json!({"type": "open", "cols": 80, "rows": 24})
408+ .to_string()
409+ .into(),
410+ ))
411+ .await
412+ .unwrap();
413+ wait_until_ready(&mut ws, "boot-ssh").await;
414+
415+ ws.send(Message::Text(
416+ serde_json::json!({
417+ "type": "tmux", "cmd": "ssh",
418+ "session": sess, "host": "collin@nowhere.invalid",
419+ })
420+ .to_string()
421+ .into(),
422+ ))
423+ .await
424+ .unwrap();
425+
426+ // Named for the machine, not for the login: `collin@` is the part that is
427+ // the same on every one of them.
428+ let mut names = String::new();
429+ for _ in 0..50 {
430+ tokio::time::sleep(Duration::from_millis(100)).await;
431+ let out = tmux(&["list-windows", "-a", "-F", "#{window_name}"]).unwrap();
432+ names = String::from_utf8_lossy(&out.stdout).into_owned();
433+ if names.lines().any(|n| n == "nowhere.invalid") {
434+ break;
435+ }
436+ }
437+ assert!(
438+ names.lines().any(|n| n == "nowhere.invalid"),
439+ "no window for the host; windows were: {names:?}"
440+ );
441+
442+ ws.close(None).await.unwrap();
443+ let _ = tmux(&["kill-server"]);
444+}
445+
374446 // --- tmux session selection -------------------------------------------------
375447
376448 /// Session names reach `execvp` as a separate argv element, so shell
⋯ 10 unchanged lines
387459 for bad in [
388460 "",
389461 " ",
390- "-C", // tmux would read this as a flag
462+ "-C", // tmux would read this as a flag
391463 "--help",
392- "a b", // tmux disallows
393- "a.b", // tmux disallows '.'
394- "a:b", // tmux disallows ':'
464+ "a b", // tmux disallows
465+ "a.b", // tmux disallows '.'
466+ "a:b", // tmux disallows ':'
395467 "a/b",
396468 "a;id",
397469 "a$(id)",
⋯ 65 unchanged lines
463535 .output();
464536 }
465537
538+/// A status frame describes the whole server, not just the session we are on:
539+/// the sidebar draws sessions over their windows, so the windows of a session
540+/// nobody is attached to have to be in the frame before it is selected.
541+#[tokio::test]
542+async fn status_frames_carry_the_windows_of_every_session() {
543+ if !Profile::tmux_available() {
544+ eprintln!("skipping: tmux not installed");
545+ return;
546+ }
547+ let sock = "termbridge-nested-test";
548+ let (here, elsewhere) = ("tb-nested-one", "tb-nested-two");
549+ let tmux = |args: &[&str]| {
550+ std::process::Command::new("tmux")
551+ .args(["-L", sock])
552+ .args(args)
553+ .output()
554+ };
555+ let _ = tmux(&["kill-server"]);
556+ let _ = tmux(&["new-session", "-d", "-s", elsewhere, "/bin/sh"]);
557+ let _ = tmux(&[
558+ "new-window",
559+ "-t",
560+ elsewhere,
561+ "-n",
562+ "second-window",
563+ "/bin/sh",
564+ ]);
565+ // A group colour, set the way the sidebar sets one, so the frame has to
566+ // carry it back. The session that has none must come back with none rather
567+ // than with an empty string, which is what tells the panel to pick.
568+ let _ = tmux(&["set-option", "-t", elsewhere, "@termbridge_color", "275"]);
569+
570+ let profile = Profile {
571+ program: "tmux".into(),
572+ args: ["-L", sock, "new-session", "-A", "-s", here, "/bin/sh"]
573+ .iter()
574+ .map(|s| s.to_string())
575+ .collect(),
576+ };
577+ let server = start(profile).await;
578+ let mut ws = connect_authed(&server).await;
579+ ws.send(Message::Text(
580+ serde_json::json!({"type": "open", "cols": 80, "rows": 24})
581+ .to_string()
582+ .into(),
583+ ))
584+ .await
585+ .unwrap();
586+
587+ let status = next_status(&mut ws, here).await;
588+ let sessions = status["sessions"].as_array().unwrap().clone();
589+ let find = |name: &str| {
590+ sessions
591+ .iter()
592+ .find(|s| s["name"].as_str() == Some(name))
593+ .unwrap_or_else(|| panic!("{name} missing from {sessions:?}"))
594+ .clone()
595+ };
596+
597+ // The session we are not attached to, with both of its windows and the
598+ // name given to the second one.
599+ let other = find(elsewhere);
600+ let windows = other["windows"].as_array().unwrap();
601+ assert_eq!(windows.len(), 2, "windows of {elsewhere}: {other:?}");
602+ assert!(
603+ windows
604+ .iter()
605+ .any(|w| w["name"].as_str() == Some("second-window")),
606+ "window names should survive: {windows:?}"
607+ );
608+ assert!(other["id"].as_str().is_some_and(|id| id.starts_with('$')));
609+ assert_eq!(other["color"], serde_json::json!("275"), "{other:?}");
610+
611+ let ours = find(here);
612+ assert_eq!(ours["windows"].as_array().unwrap().len(), 1);
613+ assert_eq!(ours["attached"], serde_json::json!(true));
614+ // Unset, not empty: the panel tests for a colour by its absence.
615+ assert_eq!(ours["color"], serde_json::Value::Null, "{ours:?}");
616+
617+ let _ = tmux(&["kill-server"]);
618+}
619+
466620 /// The status channel, end to end: the daemon's control-mode client reports
467621 /// which session we're on, lists the others, and moves the live client when the
468622 /// sidebar asks — all on a private tmux server so the user's own is untouched.
⋯ 57 unchanged lines
526680 let _ = tmux(&["kill-server"]);
527681 }
528682
683+/// Ctrl-D on the last shell of a session closes that session, not the sidebar.
684+///
685+/// tmux's default (`detach-on-destroy on`) would detach our client along with
686+/// the session, which reaches the sidebar as EOF on the pty and a dead panel.
687+/// `-f /dev/null` keeps the user's own tmux.conf out of it, so this tests the
688+/// daemon's doing rather than their configuration.
689+#[tokio::test]
690+async fn ctrl_d_closes_the_session_without_dropping_the_client() {
691+ if !Profile::tmux_available() {
692+ eprintln!("skipping: tmux not installed");
693+ return;
694+ }
695+ let sock = "termbridge-detach-e2e";
696+ let (going, staying) = ("tb-detach-going", "tb-detach-staying");
697+ let tmux = |args: &[&str]| {
698+ std::process::Command::new("tmux")
699+ .args(["-L", sock, "-f", "/dev/null"])
700+ .args(args)
701+ .output()
702+ };
703+ let _ = tmux(&["kill-server"]);
704+ let _ = tmux(&["new-session", "-d", "-s", staying, "/bin/sh"]);
705+
706+ let profile = Profile {
707+ program: "tmux".into(),
708+ args: [
709+ "-L",
710+ sock,
711+ "-f",
712+ "/dev/null",
713+ "new-session",
714+ "-A",
715+ "-s",
716+ going,
717+ "/bin/sh",
718+ ]
719+ .iter()
720+ .map(|s| s.to_string())
721+ .collect(),
722+ };
723+ let server = start(profile).await;
724+ let mut ws = connect_authed(&server).await;
725+ ws.send(Message::Text(
726+ serde_json::json!({"type": "open", "cols": 80, "rows": 24})
727+ .to_string()
728+ .into(),
729+ ))
730+ .await
731+ .unwrap();
732+
733+ // The status frame is sent after the daemon has fixed the option, so
734+ // seeing one for this session is what makes the ctrl-D below deterministic.
735+ next_status(&mut ws, going).await;
736+ wait_until_ready(&mut ws, "ready-to-exit").await;
737+
738+ // Ctrl-D: the shell exits, its pane goes, and with it the session.
739+ ws.send(Message::Binary(b"\x04".to_vec().into()))
740+ .await
741+ .unwrap();
742+
743+ // The client lands on the other session instead of the socket closing.
744+ next_status(&mut ws, staying).await;
745+
746+ let _ = tmux(&["kill-server"]);
747+}
748+
529749 /// Read frames until a `status` frame reports `session`, ignoring terminal
530750 /// output and the intermediate states tmux passes through.
531751 async fn next_status(
⋯ 25 unchanged lines
557777
558778 found.unwrap_or_else(|| panic!("never saw a status frame for {session:?}; saw {seen:?}"))
559779 }
780+
781+/// Every status frame is well formed, including the ones that land while the
782+/// client is moving between sessions.
783+///
784+/// The regression: sourcing the sidebar's tmux overrides used to go over the
785+/// control client, and in control mode `source-file` answers with two
786+/// `%begin`/`%end` blocks rather than one. The control client pairs replies to
787+/// commands by order, so the spare block was taken for the next command's
788+/// answer and every reply after it was off by one — the sidebar was handed
789+/// `list-clients` output as its session list, and drew session ids where names
790+/// belong. It lasted a frame or two, which is what a switch looked like: a
791+/// flash. So this asserts the *shape* of every frame, not just the last one.
792+#[tokio::test]
793+async fn frames_stay_well_formed_across_a_switch() {
794+ if !Profile::tmux_available() {
795+ eprintln!("skipping: tmux not installed");
796+ return;
797+ }
798+ // Overrides of our own, so the test doesn't depend on the user having any
799+ // — and doesn't touch theirs. Sourcing has to actually happen here: with no
800+ // file to source there is no second block and nothing to regress.
801+ let conf = tempfile::tempdir().unwrap();
802+ std::fs::write(conf.path().join("browser.conf"), "set status off\n").unwrap();
803+ std::fs::write(conf.path().join("browser-reset.conf"), "set -u status\n").unwrap();
804+ // SAFETY: single-threaded setup before the server starts, and no other test
805+ // reads this variable.
806+ unsafe { std::env::set_var("TERMBRIDGE_TMUX_CONFIG_DIR", conf.path()) };
807+
808+ let sock = "termbridge-switch-shape";
809+ let (here, there) = ("shape-one", "shape-two");
810+ let tmux = |args: &[&str]| {
811+ std::process::Command::new("tmux")
812+ .args(["-L", sock])
813+ .args(args)
814+ .output()
815+ };
816+ let _ = tmux(&["kill-server"]);
817+ let _ = tmux(&["new-session", "-d", "-s", there, "/bin/sh"]);
818+
819+ let profile = Profile {
820+ program: "tmux".into(),
821+ args: ["-L", sock, "new-session", "-A", "-s", here, "/bin/sh"]
822+ .iter()
823+ .map(|s| s.to_string())
824+ .collect(),
825+ };
826+ let server = start(profile).await;
827+ let mut ws = connect_authed(&server).await;
828+ ws.send(Message::Text(
829+ serde_json::json!({"type": "open", "cols": 80, "rows": 24})
830+ .to_string()
831+ .into(),
832+ ))
833+ .await
834+ .unwrap();
835+
836+ next_status(&mut ws, here).await;
837+ ws.send(Message::Text(
838+ serde_json::json!({"type": "tmux", "cmd": "switch", "session": there})
839+ .to_string()
840+ .into(),
841+ ))
842+ .await
843+ .unwrap();
844+
845+ // Everything the sidebar would have drawn for the next few seconds.
846+ let mut frames = 0;
847+ let _ = tokio::time::timeout(Duration::from_secs(4), async {
848+ while let Some(Ok(msg)) = ws.next().await {
849+ let Message::Text(t) = msg else { continue };
850+ let Ok(v) = serde_json::from_str::<serde_json::Value>(&t) else {
851+ continue;
852+ };
853+ if v["type"] != "status" {
854+ continue;
855+ }
856+ frames += 1;
857+ for s in v["sessions"].as_array().unwrap_or(&Vec::new()) {
858+ let name = s["name"].as_str().unwrap_or_default();
859+ let id = s["id"].as_str().unwrap_or_default();
860+ assert!(
861+ name == here || name == there,
862+ "session name is not a session name: {s} in {v}"
863+ );
864+ assert!(id.starts_with('$'), "session id is not an id: {s} in {v}");
865+ assert!(
866+ !s["windows"].as_array().unwrap_or(&Vec::new()).is_empty(),
867+ "a session with no windows does not exist: {s} in {v}"
868+ );
869+ }
870+ }
871+ })
872+ .await;
873+ assert!(frames > 0, "no status frames arrived at all");
874+
875+ let _ = tmux(&["kill-server"]);
876+}
modifieddaemon/tests/security.rs+52 −15
⋯ 16 unchanged lines
1717 use tokio_tungstenite::tungstenite::client::IntoClientRequest;
1818 use tokio_tungstenite::tungstenite::http::StatusCode;
1919 use tokio_tungstenite::tungstenite::{Error as WsError, Message};
20-use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
20+use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
2121
2222 use termbridge::auth::{self, Denied};
23-use termbridge::{paths, Config, Server};
23+use termbridge::{Config, Server, paths};
2424
2525 const GOOD_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2626 const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
⋯ 38 unchanged lines
6565
6666 /// A client that got through the handshake and sent a valid auth frame.
6767 async fn authed(server: &Server) -> Client {
68- let mut ws = connect(server, Some(PAIRED), None, "").await.expect("handshake");
68+ let mut ws = connect(server, Some(PAIRED), None, "")
69+ .await
70+ .expect("handshake");
6971 ws.send(Message::Text(
7072 serde_json::json!({"type": "auth", "token": GOOD_TOKEN})
7173 .to_string()
⋯ 119 unchanged lines
191193
192194 #[test]
193195 fn host_header_parsing() {
194- for good in ["127.0.0.1", "127.0.0.1:7681", "localhost", "localhost:7681", "[::1]:7681"] {
196+ for good in [
197+ "127.0.0.1",
198+ "127.0.0.1:7681",
199+ "localhost",
200+ "localhost:7681",
201+ "[::1]:7681",
202+ ] {
195203 assert!(auth::host_is_loopback(good), "{good} should be loopback");
196204 }
197205 for bad in [
⋯ 24 unchanged lines
222230 .await
223231 .unwrap();
224232 let reply = ws.next().await.unwrap().unwrap();
225- assert!(reply.to_text().unwrap().contains("invalid token"), "{reply:?}");
233+ assert!(
234+ reply.to_text().unwrap().contains("invalid token"),
235+ "{reply:?}"
236+ );
226237 }
227238
228239 #[tokio::test]
⋯ 37 unchanged lines
266277 #[tokio::test]
267278 async fn query_string_token_does_not_authenticate() {
268279 let server = start(test_config()).await;
269- let mut ws = connect(&server, Some(PAIRED), None, &format!("/?token={GOOD_TOKEN}"))
270- .await
271- .unwrap();
280+ let mut ws = connect(
281+ &server,
282+ Some(PAIRED),
283+ None,
284+ &format!("/?token={GOOD_TOKEN}"),
285+ )
286+ .await
287+ .unwrap();
272288 let outcome = tokio::time::timeout(Duration::from_secs(2), async {
273289 while let Some(Ok(m)) = ws.next().await {
274290 if let Message::Text(t) = &m {
⋯ 32 unchanged lines
307323 let dir = tempfile::tempdir().unwrap();
308324 let token = paths::generate_token(dir.path()).unwrap();
309325
310- assert_eq!(token.len(), paths::TOKEN_BYTES * 2, "expected 32 random bytes as hex");
326+ assert_eq!(
327+ token.len(),
328+ paths::TOKEN_BYTES * 2,
329+ "expected 32 random bytes as hex"
330+ );
311331
312332 let mode = std::fs::metadata(dir.path().join("token"))
313333 .unwrap()
⋯ 3 unchanged lines
317337 assert_eq!(mode, 0o600, "token file mode is {mode:04o}, must be 0600");
318338
319339 let dir_mode = std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
320- assert_eq!(dir_mode, 0o700, "config dir mode is {dir_mode:04o}, must be 0700");
340+ assert_eq!(
341+ dir_mode, 0o700,
342+ "config dir mode is {dir_mode:04o}, must be 0700"
343+ );
321344 }
322345
323346 /// If the mode ever loosens, refuse rather than authenticate against a secret
⋯ 44 unchanged lines
368391 let dir = tempfile::tempdir().unwrap();
369392 assert!(paths::load_paired_origins(dir.path()).is_empty());
370393 // Pairing only ever happens through the explicit CLI path.
371- assert!(!auth::origin_is_paired(PAIRED, &paths::load_paired_origins(dir.path())));
394+ assert!(!auth::origin_is_paired(
395+ PAIRED,
396+ &paths::load_paired_origins(dir.path())
397+ ));
372398 }
373399
374400 #[test]
375401 fn pair_and_unpair_round_trip() {
376402 let dir = tempfile::tempdir().unwrap();
377403 assert!(paths::pair_origin(dir.path(), PAIRED).unwrap());
378- assert!(!paths::pair_origin(dir.path(), PAIRED).unwrap(), "idempotent");
379- assert_eq!(paths::load_paired_origins(dir.path()), vec![PAIRED.to_string()]);
404+ assert!(
405+ !paths::pair_origin(dir.path(), PAIRED).unwrap(),
406+ "idempotent"
407+ );
408+ assert_eq!(
409+ paths::load_paired_origins(dir.path()),
410+ vec![PAIRED.to_string()]
411+ );
380412
381413 assert!(paths::unpair_origin(dir.path(), PAIRED).unwrap());
382414 assert!(paths::load_paired_origins(dir.path()).is_empty());
⋯ 8 unchanged lines
391423 paths::pair_origin(dir.path(), ff).unwrap();
392424 let paired = paths::load_paired_origins(dir.path());
393425 assert!(auth::origin_is_paired(ff, &paired));
394- assert!(auth::origin_is_paired(&ff.to_uppercase(), &paired), "case-insensitive");
426+ assert!(
427+ auth::origin_is_paired(&ff.to_uppercase(), &paired),
428+ "case-insensitive"
429+ );
395430 assert!(!auth::origin_is_paired(
396431 "moz-extension://99999999-2222-3333-4444-555555555555",
397432 &paired
⋯ 114 unchanged lines
512547
513548 let mut sock = tokio::net::TcpStream::connect(server.addr()).await.unwrap();
514549 // Opening bytes of a TLS 1.x ClientHello record.
515- sock.write_all(&[0x16, 0x03, 0x01, 0x00, 0x2f, 0x01]).await.unwrap();
550+ sock.write_all(&[0x16, 0x03, 0x01, 0x00, 0x2f, 0x01])
551+ .await
552+ .unwrap();
516553
517554 let ev = tokio::time::timeout(Duration::from_secs(3), server.next_event())
518555 .await
⋯ 30 unchanged lines
modifieddaemon/tests/tls.rs+24 −10
⋯ 8 unchanged lines
99
1010 use futures_util::{SinkExt, StreamExt};
1111 use tokio_rustls::rustls::pki_types::ServerName;
12+use tokio_tungstenite::tungstenite::Message;
1213 use tokio_tungstenite::tungstenite::client::IntoClientRequest;
13-use tokio_tungstenite::tungstenite::Message;
1414
15-use termbridge::{tls, Config, Server};
15+use termbridge::{Config, Server, tls};
1616
1717 const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1818 const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
⋯ 53 unchanged lines
7272 id: &tls::Identity,
7373 origin: Option<&str>,
7474 ) -> Result<
75- tokio_tungstenite::WebSocketStream<
76- tokio_rustls::client::TlsStream<tokio::net::TcpStream>,
77- >,
75+ tokio_tungstenite::WebSocketStream<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>,
7876 tokio_tungstenite::tungstenite::Error,
7977 > {
8078 let connector = tokio_rustls::TlsConnector::from(client_config(id));
⋯ 87 unchanged lines
168166 .await
169167 .unwrap();
170168 let reply = ws.next().await.unwrap().unwrap();
171- assert!(reply.to_text().unwrap().contains("invalid token"), "{reply:?}");
169+ assert!(
170+ reply.to_text().unwrap().contains("invalid token"),
171+ "{reply:?}"
172+ );
172173 }
173174
174175 #[tokio::test]
⋯ 74 unchanged lines
249250 let dir = tempfile::tempdir().unwrap();
250251 let a = tls::load_or_create(dir.path()).unwrap();
251252 let b = tls::load_or_create(dir.path()).unwrap();
252- assert_eq!(a.fingerprint, b.fingerprint, "cert must not churn per start");
253+ assert_eq!(
254+ a.fingerprint, b.fingerprint,
255+ "cert must not churn per start"
256+ );
253257 assert_eq!(a.fingerprint.len(), 32 * 3 - 1, "SHA-256 hex with colons");
254258
255259 let c = tls::generate(dir.path()).unwrap();
256- assert_ne!(a.fingerprint, c.fingerprint, "--regenerate must make a new one");
260+ assert_ne!(
261+ a.fingerprint, c.fingerprint,
262+ "--regenerate must make a new one"
263+ );
257264 }
258265
259266 /// The landing page exists so the certificate-trust visit is comprehensible.
⋯ 7 unchanged lines
267274
268275 let mut sock = tokio::net::TcpStream::connect(server.addr()).await.unwrap();
269276 sock.write_all(
270- format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n\r\n", server.addr().port()).as_bytes(),
277+ format!(
278+ "GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n\r\n",
279+ server.addr().port()
280+ )
281+ .as_bytes(),
271282 )
272283 .await
273284 .unwrap();
⋯ 2 unchanged lines
276287 let _ = tokio::time::timeout(Duration::from_secs(2), sock.read_to_end(&mut body)).await;
277288 let text = String::from_utf8_lossy(&body);
278289
279- assert!(text.contains("200 OK"), "expected the landing page, got:\n{text}");
290+ assert!(
291+ text.contains("200 OK"),
292+ "expected the landing page, got:\n{text}"
293+ );
280294 assert!(!text.contains(TOKEN), "landing page leaked the auth token");
281295 assert!(
282296 !text.to_lowercase().contains("\"token\""),
⋯ 3 unchanged lines
modifiedextension/jsconfig.sw.json+8 −1
⋯ 8 unchanged lines
99 "compilerOptions": {
1010 "lib": ["ES2022", "DOM", "DOM.Iterable"]
1111 },
12- "include": ["sw.js", "picker.js", "lib/shot.js", "types/globals.d.ts", "types/worker.d.ts"]
12+ "include": [
13+ "sw.js",
14+ "picker.js",
15+ "lib/shot.js",
16+ "lib/split.js",
17+ "types/globals.d.ts",
18+ "types/worker.d.ts"
19+ ]
1320 }
modifiedextension/lib/shot.js+50 −6
⋯ 46 unchanged lines
4747 /**
4848 * Cut the element's box out of a full-viewport screenshot.
4949 *
50- * The scale factor comes from the captured image's own width rather than
50+ * The scale factor comes from the captured image's own size rather than
5151 * devicePixelRatio: browser zoom, HiDPI and Chrome's own capture downscaling
52- * all move the two apart, and the ratio the image reports is the true one.
52+ * all move the two apart, and the ratio the image reports is the true one. It
53+ * is read off the *height*, because a split view can only disagree about width
54+ * — the two panes sit side by side and share the window's height.
5355 *
5456 * @param {string} dataUrl PNG data URL of the visible tab
5557 * @param {TbPickedRect} rect element box in CSS pixels, viewport-relative
5658 * @param {{ w: number; h: number }} viewport CSS pixel size of the viewport
59+ * @param {TbPane} [pane] where the viewport sits in the window, for split views
5760 * @returns {Promise<Uint8Array>} PNG bytes
5861 */
59-async function cropShot(dataUrl, rect, viewport) {
62+async function cropShot(dataUrl, rect, viewport, pane) {
6063 const bmp = await createImageBitmap(new Blob([dataUrlBytes(dataUrl)], { type: "image/png" }));
61- const scale = viewport.w > 0 ? bmp.width / viewport.w : 1;
64+ const scale = viewport.h > 0 ? bmp.height / viewport.h : 1;
65+
66+ // A capture wider than the viewport is the whole window with the other half
67+ // of a split view in it. The pick's pane is flush against one edge of that
68+ // image, and its distance from the window's own left edge says which — the
69+ // left pane starts at ~0, the right one hundreds of pixels in, so the
70+ // half-width threshold holds even though zoom scales screenX and not `left`.
71+ let offsetX = 0;
72+ if (bmp.width - viewport.w * scale > 2 && pane?.screenX != null && pane.winLeft != null) {
73+ const fromLeft = pane.screenX - pane.winLeft;
74+ if (fromLeft > bmp.width / scale / 2) offsetX = bmp.width - viewport.w * scale;
75+ }
6276
6377 // Clamp to the viewport: an element can hang off the edge, and drawImage
6478 // silently produces transparent pixels for the part that isn't there.
⋯ 2 unchanged lines
6781 const right = Math.min(viewport.w, rect.x + rect.width + SHOT_PAD);
6882 const bottom = Math.min(viewport.h, rect.y + rect.height + SHOT_PAD);
6983
70- const sx = Math.round(left * scale);
84+ const sx = Math.round(offsetX + left * scale);
7185 const sy = Math.round(top * scale);
7286 const sw = Math.round((right - left) * scale);
7387 const sh = Math.round((bottom - top) * scale);
⋯ 37 unchanged lines
111125 }
112126
113127 /**
128+ * Pair the pick's on-screen position with its window's, so cropShot can tell
129+ * the two halves of a split view apart. Best-effort: without it the crop just
130+ * assumes the capture covers the pane alone, which is what it does today.
131+ *
132+ * @param {TbExtensionApi} api
133+ * @param {TbPicked} picked
134+ * @returns {Promise<TbPane | undefined>}
135+ */
136+async function paneOf(api, picked) {
137+ if (picked.screenX == null) return undefined;
138+ try {
139+ // The worker has no window of its own; the shortcut acted on the focused
140+ // one, which is where the pick came from.
141+ const win = typeof document !== "undefined"
142+ ? await api.windows.getCurrent()
143+ : await api.windows.getLastFocused();
144+ if (win?.left == null) return undefined;
145+ return { screenX: picked.screenX, winLeft: win.left };
146+ } catch {
147+ return undefined;
148+ }
149+}
150+
151+/**
114152 * Screenshot the picked element and leave it on the system clipboard.
115153 *
116154 * Three routes, in order of how little they can go wrong:
⋯ 11 unchanged lines
128166 if (!picked.rect || !picked.viewport) return "no element geometry";
129167 let png;
130168 try {
169+ // captureVisibleTab takes no tab: it shoots whatever is *active*. In a
170+ // split view the pick may well have landed in the other half, so activate
171+ // the tab it came from first. Outside a split this is the tab that is
172+ // already active and the call does nothing — except help the clipboard
173+ // write below, which needs the page focused.
174+ if (tabId != null) await api.tabs.update(tabId, { active: true });
131175 const dataUrl = await api.tabs.captureVisibleTab({ format: "png" });
132- png = await cropShot(dataUrl, picked.rect, picked.viewport);
176+ png = await cropShot(dataUrl, picked.rect, picked.viewport, await paneOf(api, picked));
133177 } catch (e) {
134178 return e instanceof Error ? e.message : String(e);
135179 }
⋯ 40 unchanged lines
addedextension/lib/split.js+127 −0
1+// Running the picker across Chrome's split view.
2+//
3+// A split view is two tabs side by side in one window. Both are rendered and
4+// both are clickable, but only one is `active` — Chrome marks a single tab
5+// active even while its partner is fully visible. So the obvious
6+// `tabs.query({active: true})` always resolves to whichever half last had
7+// focus, and a pick started from the other half highlights nothing.
8+//
9+// The fix is to inject into every visible half at once and take the first
10+// answer. `splitViewId` (Chrome 140+) is what makes the set knowable; it is
11+// read-only detection data, so nothing here creates or dissolves a split.
12+// Firefox has neither the property nor the feature, and falls through to the
13+// single-tab path.
14+//
15+// Loaded by both the sidebar (a document) and the background worker (no DOM),
16+// so nothing here may touch `document`.
17+
18+/**
19+ * Every tab a pick should run in: the active one, plus the other half of its
20+ * split view when there is one. Active first — its failure is the one worth
21+ * reporting, because it is the half the user was looking at.
22+ *
23+ * @param {TbExtensionApi} api
24+ * @param {TbTab} tab the active tab
25+ * @returns {Promise<TbTab[]>}
26+ */
27+async function pickTargets(api, tab) {
28+ if (!tab || tab.id == null) return [];
29+ if (tab.splitViewId == null) return [tab];
30+ try {
31+ // Chrome throws on an unrecognised query key rather than ignoring it, so a
32+ // browser that reports splitViewId but won't query on it lands in the catch.
33+ const tabs = await api.tabs.query({ splitViewId: tab.splitViewId, currentWindow: true });
34+ const out = [tab];
35+ for (const t of tabs) if (t.id != null && t.id !== tab.id) out.push(t);
36+ return out;
37+ } catch {
38+ return [tab];
39+ }
40+}
41+
42+/**
43+ * Tear down a picker overlay from outside the page.
44+ *
45+ * picker.js parks its cleanup on `window.__tbPickerActive`; calling it resolves
46+ * that pick's promise with null, exactly as an Escape would.
47+ */
48+function tbCancelPicker() {
49+ if (window.__tbPickerActive) window.__tbPickerActive();
50+}
51+
52+/**
53+ * Cancel picks running in `tabIds`. Failures are ignored: a tab that closed or
54+ * navigated took its picker with it.
55+ *
56+ * @param {TbExtensionApi} api
57+ * @param {number[]} tabIds
58+ */
59+async function cancelPicks(api, tabIds) {
60+ await Promise.all(
61+ tabIds.map((tabId) =>
62+ api.scripting.executeScript({ target: { tabId }, func: tbCancelPicker }).catch(() => {}),
63+ ),
64+ );
65+}
66+
67+/**
68+ * Run the picker in every candidate tab at once and take the first real answer.
69+ *
70+ * The first half to settle wins, and the losers are torn down — including on a
71+ * cancel, so an Escape in one pane doesn't leave an overlay live in the other.
72+ * An injection that *fails* is not a settle: on the keyboard-shortcut path the
73+ * `activeTab` grant covers only the active half, so the partner half usually
74+ * refuses unless its origin was granted, and that must not end a pick the
75+ * active half is still serving.
76+ *
77+ * @template T
78+ * @param {TbExtensionApi} api
79+ * @param {TbTab[]} tabs
80+ * @param {() => Promise<T | null>} func the picker, serialised into each page
81+ * @returns {Promise<{ tabId: number | undefined; value: T | null; error: string }>}
82+ */
83+function racePick(api, tabs, func) {
84+ /** @type {number[]} */
85+ const ids = [];
86+ for (const t of tabs) if (t?.id != null) ids.push(t.id);
87+ if (!ids.length) return Promise.resolve({ tabId: undefined, value: null, error: "no page to pick in" });
88+
89+ return new Promise((resolve) => {
90+ let finished = false;
91+ let pending = ids.length;
92+ let firstError = "";
93+
94+ for (const tabId of ids) {
95+ api.scripting
96+ .executeScript({ target: { tabId }, func })
97+ // `undefined` means the injection itself failed; `null` means the pick
98+ // was cancelled. Only the second one ends the race.
99+ .then((results) => results?.[0]?.result ?? null)
100+ .catch((e) => {
101+ if (!firstError) firstError = e instanceof Error ? e.message : String(e);
102+ return undefined;
103+ })
104+ .then((value) => {
105+ if (finished) return;
106+ if (value === undefined) {
107+ if (--pending === 0) {
108+ resolve({ tabId: ids[0], value: null, error: firstError || "injection failed" });
109+ }
110+ return;
111+ }
112+ finished = true;
113+ cancelPicks(
114+ api,
115+ ids.filter((other) => other !== tabId),
116+ );
117+ resolve({ tabId, value: /** @type {T | null} */ (value), error: "" });
118+ });
119+ }
120+ });
121+}
122+
123+const Split = { pickTargets, racePick, cancelPicks };
124+
125+if (typeof module !== "undefined" && module.exports) {
126+ module.exports = Split;
127+}
addedextension/lib/split.test.js+128 −0
1+// Run with: node --test "extension/lib/*.test.js"
2+//
3+// Chrome's split view is two visible tabs and one `active` flag, so the picker
4+// runs in both halves at once. What is checked here is the race that follows:
5+// who wins, who gets torn down, and which failures are allowed to end it.
6+
7+const test = require("node:test");
8+const assert = require("node:assert");
9+
10+const Split = require("./split.js");
11+
12+/** Tabs whose injected picker is still open, keyed by tab id. */
13+const pending = new Map();
14+
15+/**
16+ * A stand-in for the scripting API. `pick` decides what each tab's injected
17+ * picker does: a value resolves the pick, null cancels it, an Error rejects the
18+ * injection, and a promise is awaited so a test can control who wins the race.
19+ */
20+function fakeApi(pick, log = []) {
21+ return {
22+ api: {
23+ scripting: {
24+ executeScript({ target, func }) {
25+ // The cancel injection is a different function than the picker; it is
26+ // the only one that takes no part in the race.
27+ if (func.name === "tbCancelPicker") {
28+ log.push(`cancel:${target.tabId}`);
29+ const cancelled = pending.get(target.tabId);
30+ if (cancelled) cancelled(null);
31+ return Promise.resolve([{ result: undefined }]);
32+ }
33+ log.push(`pick:${target.tabId}`);
34+ const outcome = pick(target.tabId);
35+ if (outcome instanceof Error) return Promise.reject(outcome);
36+ if (outcome === "wait") {
37+ return new Promise((resolve) => {
38+ pending.set(target.tabId, (v) => resolve([{ result: v }]));
39+ });
40+ }
41+ return Promise.resolve([{ result: outcome }]);
42+ },
43+ },
44+ tabs: {
45+ query: () => Promise.resolve([]),
46+ },
47+ },
48+ log,
49+ };
50+}
51+
52+test("a pick in the non-active half of a split wins the race", async () => {
53+ pending.clear();
54+ const { api, log } = fakeApi((id) => (id === 2 ? { xpath: "/html/body" } : "wait"));
55+ const out = await Split.racePick(api, [{ id: 1 }, { id: 2 }], function tbPickElement() {});
56+ assert.equal(out.tabId, 2);
57+ assert.deepEqual(out.value, { xpath: "/html/body" });
58+ assert.equal(out.error, "");
59+ // The loser's overlay is torn down rather than left live in the other pane.
60+ assert.ok(log.includes("cancel:1"));
61+});
62+
63+test("an injection that fails does not end a pick the other half is serving", async () => {
64+ pending.clear();
65+ const { api } = fakeApi((id) =>
66+ id === 1 ? new Error("Cannot access contents of the page") : { xpath: "/html" },
67+ );
68+ const out = await Split.racePick(api, [{ id: 1 }, { id: 2 }], function tbPickElement() {});
69+ assert.equal(out.tabId, 2);
70+ assert.deepEqual(out.value, { xpath: "/html" });
71+});
72+
73+test("every injection failing reports the first reason against the active tab", async () => {
74+ pending.clear();
75+ const { api } = fakeApi(() => new Error("Cannot access contents of the page"));
76+ const out = await Split.racePick(api, [{ id: 1 }, { id: 2 }], function tbPickElement() {});
77+ assert.equal(out.tabId, 1);
78+ assert.equal(out.value, null);
79+ assert.match(out.error, /Cannot access/);
80+});
81+
82+test("a cancel in one half cancels the other", async () => {
83+ pending.clear();
84+ const { api, log } = fakeApi((id) => (id === 1 ? null : "wait"));
85+ const out = await Split.racePick(api, [{ id: 1 }, { id: 2 }], function tbPickElement() {});
86+ assert.equal(out.value, null);
87+ assert.equal(out.error, "");
88+ assert.ok(log.includes("cancel:2"));
89+});
90+
91+test("no pickable tab is a message, not a throw", async () => {
92+ const { api } = fakeApi(() => null);
93+ const out = await Split.racePick(api, [], function tbPickElement() {});
94+ assert.equal(out.value, null);
95+ assert.match(out.error, /no page/);
96+});
97+
98+test("a tab outside a split is the only target", async () => {
99+ const { api } = fakeApi(() => null);
100+ const targets = await Split.pickTargets(api, { id: 7, url: "https://example.com/" });
101+ assert.deepEqual(targets, [{ id: 7, url: "https://example.com/" }]);
102+});
103+
104+test("a split's other half comes back too, with the active tab first", async () => {
105+ const api = {
106+ scripting: { executeScript: () => Promise.resolve([]) },
107+ tabs: { query: () => Promise.resolve([{ id: 3 }, { id: 9 }]) },
108+ };
109+ const targets = await Split.pickTargets(api, { id: 9, splitViewId: 4 });
110+ assert.deepEqual(
111+ targets.map((t) => t.id),
112+ [9, 3],
113+ );
114+});
115+
116+test("a browser that rejects the splitViewId query falls back to the active tab", async () => {
117+ const api = {
118+ scripting: { executeScript: () => Promise.resolve([]) },
119+ tabs: {
120+ query: () => Promise.reject(new Error("Unexpected property: 'splitViewId'")),
121+ },
122+ };
123+ const targets = await Split.pickTargets(api, { id: 9, splitViewId: 4 });
124+ assert.deepEqual(
125+ targets.map((t) => t.id),
126+ [9],
127+ );
128+});
modifiedextension/lib/theme.js+2 −0
⋯ 14 unchanged lines
1515 ui: {
1616 "--bg": "#14161a",
1717 "--bg-panel": "#1c1f25",
18+ "--bg-strip": "#191b21",
1819 "--bg-input": "#14161a",
1920 "--border": "#2d3138",
2021 "--fg": "#d7dae0",
⋯ 40 unchanged lines
6162 ui: {
6263 "--bg": "#ffffff",
6364 "--bg-panel": "#f6f8fa",
65+ "--bg-strip": "#e6e9ed",
6466 "--bg-input": "#ffffff",
6567 "--border": "#d0d7de",
6668 "--fg": "#1f2328",
⋯ 61 unchanged lines
modifiedextension/manifest.chrome.json+22 −0
⋯ 6 unchanged lines
77 "storage",
88 "sidePanel",
99 "tabs",
10+ "tabGroups",
1011 "activeTab",
1112 "scripting",
1213 "clipboardWrite"
⋯ 25 unchanged lines
3839 "default": "Alt+Shift+P"
3940 },
4041 "description": "Pick an element on the page"
42+ },
43+ "toggle-terminal": {
44+ "suggested_key": {
45+ "default": "Alt+Shift+T"
46+ },
47+ "description": "Open the terminal, focus it, or close it"
48+ },
49+ "focus-omnibar": {
50+ "suggested_key": {
51+ "default": "Ctrl+Shift+L"
52+ },
53+ "description": "Jump to a window, session or pane"
54+ },
55+ "hand-tab-to-claude": {
56+ "suggested_key": {
57+ "default": "Alt+Shift+K"
58+ },
59+ "description": "Move this tab into Claude in Chrome's tab group"
60+ },
61+ "set-claude-group": {
62+ "description": "Mark this tab's group as Claude in Chrome's"
4163 }
4264 },
4365 "host_permissions": [
⋯ 12 unchanged lines
modifiedextension/manifest.firefox.json+13 −0
⋯ 35 unchanged lines
3636 "default": "Alt+Shift+P"
3737 },
3838 "description": "Pick an element on the page"
39+ },
40+ "toggle-terminal": {
41+ "suggested_key": {
42+ "default": "Alt+Shift+T"
43+ },
44+ "description": "Open the terminal, focus it, or close it"
45+ },
46+ "focus-omnibar": {
47+ "suggested_key": {
48+ "default": "Ctrl+Shift+L"
49+ },
50+ "description": "Jump to a window, session or pane"
3951 }
4052 },
4153 "background": {
4254 "scripts": [
4355 "picker.js",
4456 "lib/shot.js",
57+ "lib/split.js",
4558 "sw.js"
4659 ]
4760 },
⋯ 13 unchanged lines
addedextension/mock.html+199 −0
1+<!doctype html>
2+<!-- Throwaway: the sidebar's header, rendered outside the extension so the
3+ layout can be looked at in a plain tab. Not shipped by build.sh. -->
4+<html>
5+ <head>
6+ <meta charset="utf-8" />
7+ <title>header mock</title>
8+ <link rel="stylesheet" href="sidebar.css" />
9+ <style>
10+ body { margin: 0; width: 380px; }
11+ #term { height: 220px; background: var(--bg); }
12+ #mode {
13+ position: fixed; left: 390px; top: 8px;
14+ font: 12px system-ui; color: #888;
15+ }
16+ </style>
17+ </head>
18+ <body>
19+ <script src="lib/theme.js"></script>
20+ <script>
21+ const dark = matchMedia("(prefers-color-scheme: dark)").matches;
22+ const t = Themes.resolveTheme("dark", dark);
23+ for (const [k, v] of Object.entries(t.ui)) document.documentElement.style.setProperty(k, v);
24+
25+ // ?full fills the row; the default is one of each, so both layouts can be
26+ // seen with slack left in the strip.
27+ const FULL = location.search.includes("full");
28+ const SESSIONS = FULL
29+ ? [
30+ { name: "browser", selected: true },
31+ { name: "anvil", state: "ready" },
32+ { name: "dotfiles", state: "working" },
33+ ]
34+ : [{ name: "browser", selected: true }];
35+ const WINDOWS = FULL
36+ ? [
37+ { index: 1, name: "termbridge", active: true, state: "working" },
38+ { index: 2, name: "sidebar", state: "waiting" },
39+ { index: 3, name: "notes" },
40+ ]
41+ : [{ index: 1, name: "termbridge", active: true, state: "working" }];
42+ const GLYPH = { working: "✳", waiting: "●", ready: "●", idle: "●", none: "" };
43+
44+ (async () => {
45+ const html = await (await fetch("sidebar.html")).text();
46+ const doc = new DOMParser().parseFromString(html, "text/html");
47+ const header = doc.querySelector("header");
48+ document.body.prepend(header);
49+ const term = document.createElement("div");
50+ term.id = "term";
51+ document.body.appendChild(term);
52+
53+ document.body.classList.add("has-session");
54+ const sessions = document.getElementById("sessions");
55+ sessions.hidden = false;
56+ document.getElementById("session-new").hidden = false;
57+ for (const s of SESSIONS) {
58+ const b = document.createElement("button");
59+ b.className = `session-tab${s.attached ? " attached" : ""}`;
60+ b.setAttribute("role", "tab");
61+ b.setAttribute("aria-selected", String(!!s.selected));
62+ if (!s.selected) {
63+ const g = document.createElement("span");
64+ g.className = `glyph ${s.state ?? "none"}`;
65+ g.textContent = GLYPH[s.state ?? "none"];
66+ b.appendChild(g);
67+ }
68+ const n = document.createElement("span");
69+ n.className = "name";
70+ n.textContent = s.name;
71+ b.appendChild(n);
72+ sessions.appendChild(b);
73+ }
74+
75+ const tabs = document.getElementById("tabs");
76+ tabs.hidden = false;
77+ document.getElementById("tab-new").hidden = false;
78+ for (const w of WINDOWS) {
79+ const slot = document.createElement("div");
80+ slot.className = `tab-slot closable${w.active ? " active" : ""}`;
81+ const b = document.createElement("button");
82+ b.className = "tab";
83+ b.setAttribute("role", "tab");
84+ b.setAttribute("aria-selected", String(!!w.active));
85+ const g = document.createElement("span");
86+ g.className = `glyph ${w.state ?? "none"}`;
87+ g.textContent = GLYPH[w.state ?? "none"];
88+ b.appendChild(g);
89+ const n = document.createElement("span");
90+ n.className = "name";
91+ n.textContent = w.name;
92+ b.appendChild(n);
93+ slot.appendChild(b);
94+ tabs.appendChild(slot);
95+ }
96+
97+ // What syncJump would say with a pane waiting on you.
98+ const jump = document.getElementById("jump");
99+ jump.disabled = false;
100+ jump.dataset.state = "waiting";
101+
102+ // The same DOM moves applyTabMode makes, so the groups layout can be
103+ // looked at too. Everything below mirrors sidebar.js — if the two
104+ // disagree, sidebar.js is right and this is stale.
105+ const $ = (id) => document.getElementById(id);
106+ const HUES = { browser: -1, anvil: 210, dotfiles: 25 };
107+ function setMode(mode) {
108+ const top = $("session-strip");
109+ const row = $("window-strip");
110+ document.body.classList.toggle("groups", mode === "groups");
111+ if (mode === "groups") {
112+ row.prepend($("reconnect"), $("settings-toggle"));
113+ row.insertBefore($("status-text"), $("tabs"));
114+ row.append($("tab-new"));
115+ $("omni-strip").prepend($("pick"), $("jump"));
116+ $("omni-strip").hidden = false;
117+ $("omni-here").hidden = false;
118+ $("omni").value = "";
119+ $("omni-cwd").hidden = false;
120+ $("omni-cwd").textContent = "~/Code/browser-terminal-extension";
121+ top.hidden = true;
122+ document.body.classList.add("has-windows");
123+ drawGroups();
124+ } else {
125+ top.hidden = false;
126+ top.prepend($("reconnect"), $("settings-toggle"));
127+ top.insertBefore($("status-text"), $("sessions"));
128+ top.append($("session-new"), $("session-name"));
129+ row.prepend($("pick"), $("jump"));
130+ row.append($("tab-new"));
131+ $("omni-strip").hidden = true;
132+ document.body.classList.remove("has-windows");
133+ drawWindows();
134+ }
135+ }
136+
137+ function drawGroups() {
138+ const strip = $("tabs");
139+ strip.textContent = "";
140+ for (const s of SESSIONS) {
141+ const hue = HUES[s.name];
142+ const chip = document.createElement("button");
143+ chip.className =
144+ `group-chip${s.selected ? " current" : ""}${hue < 0 ? " grey" : ""}`;
145+ chip.style.setProperty("--group-h", String(hue));
146+ chip.setAttribute("aria-expanded", "true");
147+ const caret = document.createElement("span");
148+ caret.className = "caret";
149+ chip.appendChild(caret);
150+ const n = document.createElement("span");
151+ n.className = "name";
152+ n.textContent = s.name;
153+ chip.appendChild(n);
154+ strip.appendChild(chip);
155+ const windows = s.selected ? WINDOWS : [{ index: 1, name: "shell", active: true }];
156+ for (const w of windows) strip.appendChild(windowTab(w, s.selected && w.active));
157+ }
158+ }
159+
160+ function drawWindows() {
161+ const strip = $("tabs");
162+ strip.textContent = "";
163+ for (const w of WINDOWS) strip.appendChild(windowTab(w, w.active));
164+ }
165+
166+ function windowTab(w, current) {
167+ const slot = document.createElement("div");
168+ slot.className = `tab-slot closable${current ? " active" : ""}` +
169+ `${w.active && !current ? " elsewhere" : ""}`;
170+ const b = document.createElement("button");
171+ b.className = "tab";
172+ b.setAttribute("role", "tab");
173+ b.setAttribute("aria-selected", String(!!current));
174+ const g = document.createElement("span");
175+ g.className = `glyph ${w.state ?? "none"}`;
176+ g.textContent = GLYPH[w.state ?? "none"];
177+ b.appendChild(g);
178+ const n = document.createElement("span");
179+ n.className = "name";
180+ n.textContent = w.name;
181+ b.appendChild(n);
182+ slot.appendChild(b);
183+ return slot;
184+ }
185+
186+ const toggle = document.createElement("button");
187+ toggle.id = "mode";
188+ toggle.textContent = "switch to tab groups";
189+ let mode = "nested";
190+ toggle.addEventListener("click", () => {
191+ mode = mode === "nested" ? "groups" : "nested";
192+ toggle.textContent = `switch to ${mode === "nested" ? "tab groups" : "nested tabs"}`;
193+ setMode(mode);
194+ });
195+ document.body.appendChild(toggle);
196+ })();
197+ </script>
198+ </body>
199+</html>
modifiedextension/picker.js+3 −0
⋯ 238 unchanged lines
239239 return {
240240 rect: { x: r.left, y: r.top, width: r.width, height: r.height },
241241 viewport: { w: window.innerWidth, h: window.innerHeight },
242+ // Where this viewport sits on the screen. Only the split view needs it:
243+ // it says which half of the window the pick came from.
244+ screenX: window.screenX,
242245 css: cssSelector(el),
243246 xpath: xpath(el),
244247 id: el.id || null,
⋯ 11 unchanged lines
modifiedextension/sidebar.css+976 −99
⋯ 17 unchanged lines
1818 --warn-bg: #241f16;
1919 --warn-border: #4a3a22;
2020 --shadow: rgba(0, 0, 0, 0.4);
21+ --bg-strip: #191b21;
22+ /* The window row's surface, between the session row's and the terminal's.
23+ Named because two things have to agree on it exactly: the row itself, and
24+ the selected session tab that merges into it. A tab a shade off the row it
25+ sits on shows the join as a visible edge. */
26+ --bg-row: color-mix(in srgb, var(--bg-panel) 55%, var(--bg-strip));
27+ /* The tab silhouette's two radii, in Chromium's proportions: it draws a 28px
28+ tab with a 12px bottom endcap and a 10px top corner (TabStyle::
29+ GetBottomCornerRadius / GetTopCornerRadius), so a ~25px tab gets 10 and 8.
30+ --tab-foot is also how far the tab's body is inset from its own box: the
31+ feet are part of the tab, not something it hangs into the row. */
32+ --tab-foot: 10px;
33+ --tab-top: 8px;
34+ /* 2 * --tab-foot less the slot the separator lives in (Chromium:
35+ `2 * GetBottomCornerRadius() - total_separator_width`). */
36+ --tab-slot-overlap: 14px;
37+ /* What is left between two tab bodies once they overlap: the separator's
38+ lane. */
39+ --tab-gap: calc(2 * var(--tab-foot) - var(--tab-slot-overlap));
2140 }
2241 * { box-sizing: border-box; }
2342 /* Author `display` beats the UA rule for [hidden] no matter the specificity, so
⋯ 9 unchanged lines
3352 }
3453 /* Vertical space is scarce in a sidebar, but not so scarce that controls
3554 should become unclickable. 26px is about the smallest comfortable pointer
36- target; the header stays short by having only two of them. */
37-/* Stretch, not centre: the active tab has to reach the bottom edge to sit on
38- the seam. Everything else in the row re-centres itself. */
55+ target; the header stays short by having few of them, spread over its two
56+ rows rather than crowding either.
57+
58+ Two rows: sessions, then the windows of the selected one. #rows is the
59+ column; each strip lays out the way the single row used to. The header itself
60+ is a *row*, because one control — push to talk — sits beside all of them in a
61+ corner of its own rather than inside any one of them.
62+
63+ The header's own background is the terminal's, not the panel's: every strip
64+ paints its own surface over it, and what is left uncovered is the corner —
65+ which should read as a continuation of the row it sits beside rather than as
66+ a slab of a fourth colour laid over the end of them. */
3967 header {
40- display: flex; align-items: stretch; gap: 4px;
41- padding: 4px 6px 0; background: var(--bg-panel);
42- border-bottom: 1px solid var(--border); flex: none;
68+ display: flex; align-items: stretch;
69+ background: var(--bg); flex: none;
70+}
71+#rows {
72+ display: flex; flex-direction: column;
73+ flex: 1; min-width: 0;
74+}
75+/* A column reserved across every row, holding the one control that types into
76+ the pane rather than steering the tabs: hold-to-talk, and nothing else — the
77+ hold sends its own Return, so there is no second button to stack under it.
78+ Unpainted, so it takes the header's background and reads as an extension of
79+ the bottom row instead of a slab of its own — the rows are tmux and their
80+ surfaces are the nesting; a fourth colour beside them was one surface too
81+ many, and it was the one thing in the header with a hard vertical seam down
82+ it.
83+
84+ Its width is its own — a square is what the mic wants, and the rows have no
85+ say in it — while its height is whatever the rows come to. Not the other way
86+ round: sizing the button off the rows' height and the corner off the button
87+ is a loop, and the browser breaks it by shrinking the strips. Which is why
88+ the mic below is sized *from* that height rather than fixed: the header has
89+ one row or two depending on the mode, and the corner is as tall as either.
90+
91+ `gap` and the bottom padding are the mic's own air, not a gutter between
92+ stacked controls: it is centred in the corner and the padding keeps its ring
93+ off the terminal's edge while it is held. */
94+#corner {
95+ flex: none; width: 56px;
96+ display: flex; flex-direction: column;
97+ align-items: center; justify-content: center; gap: 3px;
98+ padding: 3px 4px 4px;
99+ /* Not a flat fill: beside the top row the header's surface is the strips',
100+ and by the bottom it is the terminal's, so the corner falls from one to the
101+ other instead of meeting both with the same edge. The stops are soft on
102+ purpose — the exact row heights are not knowable here, and a gradient that
103+ tried to line up with them would be wrong in the mode that has one row. */
104+ background: linear-gradient(
105+ to bottom,
106+ var(--bg-row),
107+ color-mix(in srgb, var(--bg-row) 35%, var(--bg)) 55%,
108+ var(--bg) 85%
109+ );
110+}
111+/* Stretch, not centre: the active window tab has to reach the bottom edge to
112+ sit on the seam. Everything else in the row re-centres itself. */
113+.strip {
114+ display: flex; align-items: stretch; min-width: 0;
115+ /* No gap: an icon button already carries its own padding around a 15px glyph,
116+ so a gap on top of that reads as a hole between the controls and the tabs.
117+ The buttons sit against the tab strip the way a browser's do. */
118+ gap: 0;
119+ padding: 4px 6px 0 4px;
43120 }
121+/* The controls that aren't buttons still need their own air. */
122+.strip > #status-text { margin-left: 6px; }
123+.strip > #session-name { margin-left: 4px; }
124+/* Three surfaces, deepest at the top: the session row is recessed behind the
125+ window row, which is recessed behind the terminal. That is what makes the
126+ nesting readable without a single dividing line — the selected tab of each
127+ row is painted in the surface of the row *below* it, so it merges into what
128+ it contains, the way a browser's active tab merges into its toolbar.
129+ How deep that recession reads is a theme's call, not a formula's: mixing it
130+ from --bg-panel put dark's three surfaces within a few units of each other
131+ while light's stayed obvious, so it is a token both themes name. */
132+#session-strip { background: var(--bg-strip); }
133+#window-strip { background: var(--bg-row); }
44134 #status-text {
45135 flex: 1; font-size: 11px; color: var(--fg-muted); min-width: 0;
46136 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
47137 }
48-.dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
49138
50-/* The tabs are the only thing in this row that reaches the seam; everything
51- else is centred on the same 22px band, so the dot, the session name and the
139+/* The tabs are the only thing in a strip that reaches its bottom edge;
140+ everything else is centred on the same 22px band, so the status text and the
52141 icons sit on one line rather than each on its own accidental one. The 4px
53- bottom margin is the tabs' own gap above the seam, matched here. */
54-header > #session-chip,
55-header > #status-text,
56-header > button.icon {
142+ bottom margin is the tabs' own gap above that edge, matched. */
143+.strip > #status-text,
144+.strip > #session-name,
145+.strip > button.icon {
57146 align-self: center;
58147 margin-bottom: 4px;
59148 }
60-header > #status-text,
61-header > #session-chip { height: 22px; }
62-header > #status-text { line-height: 20px; }
149+.strip > #status-text { height: 22px; line-height: 20px; }
150+
151+/* Naming a new session, inline in the row the tab will appear in. A prompt or
152+ a dialog would block the socket's message handler for as long as it is up. */
153+#session-name {
154+ flex: 0 1 9rem; min-width: 4rem; height: 22px;
155+ font: inherit; font-size: 11px;
156+ padding: 0 6px; border: 1px solid var(--accent); border-radius: 6px;
157+ background: var(--bg-input); color: var(--fg);
158+}
159+#session-name:focus-visible { outline: none; }
63160
64-/* The session chip. Without a session there is nothing to switch to, so it is
65- just the status dot sitting where it always did; with one it closes up into a
66- recessed pill. The grey is mixed from the row rather than taken from
67- --bg-input, which in the light theme is the same white as the terminal — the
68- chip would then read as a lifted tab, which is the whole thing being avoided.
69- The extra right margin is the gap that says the tabs are a different group. */
70-#session-chip {
71- display: inline-flex; align-items: center; gap: 5px;
161+/* --- session tabs ----------------------------------------------------------
162+ A tab is a whole tmux session. Clicking one is a switch-client: it moves the
163+ client this panel already holds, so nothing is spawned and nothing is lost.
164+ The selected one lifts onto the window row below it, which is the same move
165+ the window row makes onto the terminal — one gesture, applied twice, so the
166+ nesting is legible from the shapes alone. */
167+#sessions {
168+ display: flex; align-items: stretch; gap: 0;
72169 flex: 0 1 auto; min-width: 0;
73- margin-left: 2px;
74- border: 1px solid transparent; border-radius: 999px;
170+ overflow-x: auto; scrollbar-width: none;
75171 }
76-body.has-session #session-chip {
77- padding: 0 5px 0 7px; margin-right: 9px;
78- background: rgba(128, 128, 128, 0.13);
79- border-color: var(--border);
172+#sessions::-webkit-scrollbar { display: none; }
173+/* Sized to its tabs the way the window row's "+" is: the row gives up width to
174+ scrolling before it gives up the button. */
175+#session-new { margin-right: auto; }
176+
177+button.session-tab {
178+ position: relative;
179+ margin-inline: calc(-0.5 * var(--tab-slot-overlap));
180+ flex: 0 1 auto; min-width: 0; max-width: calc(10rem + 2 * var(--tab-foot));
181+ display: inline-flex; align-items: center; gap: 5px;
182+ padding: 3px 8px 4px; margin-bottom: 4px;
183+ border: 0 solid transparent; border-width: 0 var(--tab-foot);
184+ border-radius: var(--tab-top) var(--tab-top) 0 0;
185+ background: transparent; color: var(--fg-muted);
186+ font: inherit; font-size: 11px; line-height: 1.4;
187+ white-space: nowrap; cursor: pointer; text-align: left;
80188 }
81-body.has-session #session-chip:hover { background: rgba(128, 128, 128, 0.2); }
82-#session-chip:focus-within { border-color: var(--accent); }
83-
84-/* Shrinkable: in a narrow panel the tabs are what the row is for, and the
85- session name is already in the select's own tooltip. The select draws no box
86- of its own — the chip around it is the box. */
87-#session-switch {
88- flex: 0 1 auto; min-width: 3rem; max-width: 9rem;
89- font: inherit; font-size: 11px; font-weight: 500; line-height: 20px;
90- padding: 0; border: 0; border-radius: 0;
91- background: transparent; color: var(--fg);
92- text-overflow: ellipsis;
189+button.session-tab .name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
190+button.session-tab:hover { background: rgba(128, 128, 128, 0.16); color: var(--fg); }
191+button.session-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
192+/* Attached by some other client: a terminal somewhere is on this session too. */
193+button.session-tab.attached { color: var(--fg); }
194+/* The selected session is drawn in the window row's own surface and reaches
195+ down to meet it, so the windows below read as this session's contents. */
196+button.session-tab[aria-selected="true"] {
197+ --surface: var(--bg-row);
198+ background: var(--surface); color: var(--fg);
199+ margin-bottom: 0; padding-bottom: 8px;
200+ cursor: default;
93201 }
94-#session-switch:focus-visible { outline: none; }
202+button.session-tab[aria-selected="true"]:hover { background: var(--surface); }
203+button.session-tab[aria-selected="true"] .name { font-weight: 600; }
95204
96205 /* --- window tabs -----------------------------------------------------------
97206 A tab is a window of the attached session — what `prefix 2` selects. Clicking
⋯ 12 unchanged lines
110219 tabs' own bottom edge back where it was, and the 1px between the two is room
111220 the overhang is allowed to paint in. */
112221 #tabs {
113- display: flex; align-items: stretch; gap: 2px;
222+ display: flex; align-items: stretch; gap: 0;
114223 flex: 0 1 auto; min-width: 0;
115224 margin-bottom: -1px; padding-bottom: 1px;
116225 overflow-x: auto; scrollbar-width: none;
⋯ 3 unchanged lines
120229 away from the last one. */
121230 #tab-new { margin-right: auto; }
122231
123-/* The panel's controls are one group and the session is another; the row's 4px
124- gap is not enough to say so. */
125-header > #settings-toggle { margin-right: 5px; }
232+/* No extra separation between the last control and the tab strip: the strip's
233+ own edge padding — the room the first tab's foot needs — already reads as the
234+ gap between the two groups, and adding to it pushes the tabs visibly away
235+ from the button for no reason. */
126236
127237 /* The tab and its close ✕ are siblings — a button cannot contain a button —
128238 so the slot is what the strip lays out, and the ✕ is positioned over the
⋯ 1 unchanged line
130240
131241 Past the min width a name is unreadable, so the strip scrolls instead of
132242 shrinking further: the same trade browsers make when the bar fills up. */
243+/* Tabs overlap, as Chromium's do: it lays tab n+1's edge `2R - separator_width`
244+ left of tab n's, so the slot left between two bodies is exactly the width the
245+ separator is centred in. Ours is 6px, from 20px of feet less 14px of overlap.
246+ The outermost edges stay flush, or the row would clip the first tab's foot. */
133247 .tab-slot {
134248 position: relative; display: flex; align-items: stretch;
135- flex: 0 1 auto; min-width: 5.5rem; max-width: 11rem;
249+ margin-inline: calc(-0.5 * var(--tab-slot-overlap));
250+ flex: 0 1 auto;
251+ min-width: calc(5.5rem + 2 * var(--tab-foot));
252+ max-width: calc(11rem + 2 * var(--tab-foot));
136253 }
137254 /* The window you are on gives up its characters last. */
138-.tab-slot.active { min-width: 7rem; }
255+.tab-slot.active { min-width: calc(7rem + 2 * var(--tab-foot)); }
256+
257+/* Room for the ✕ is reserved rather than made on hover, so a name does not
258+ re-ellipsis under the pointer. */
259+.tab-slot.closable button.tab { padding-right: 20px; }
260+
261+/* Pinned: down to the dot and the index, sized to its content and sitting at
262+ the head of the strip. The index is what stays visible rather than the name,
263+ because the index is the thing you can type — `prefix 3` still selects it,
264+ pinned or not. */
265+.tab-slot.pinned { flex: 0 0 auto; min-width: 0; max-width: none; }
266+.tab-slot.pinned button.tab { padding-left: 7px; padding-right: 7px; gap: 4px; }
267+.tab-slot.pinned button.tab .index { opacity: 1; font-size: 11px; }
268+/* A hairline against the unpinned run, the way a browser separates them. */
269+.tab-slot.pinned + .tab-slot:not(.pinned) { margin-left: 5px; }
270+.tab-slot.pinned + .tab-slot:not(.pinned)::before {
271+ content: ""; position: absolute; left: -3px; top: 6px; bottom: 6px;
272+ border-left: 1px solid var(--border);
273+}
139274
140275 button.tab {
276+ position: relative;
141277 flex: 1 1 auto; min-width: 0;
142278 display: inline-flex; align-items: center; gap: 5px;
143279 padding: 4px 8px 5px; margin-bottom: 4px;
144- border: 0; border-radius: 6px 6px 0 0;
280+ border: 0 solid transparent; border-width: 0 var(--tab-foot);
281+ border-radius: var(--tab-top) var(--tab-top) 0 0;
145282 background: transparent; color: var(--fg-muted);
146283 font: inherit; font-size: 11px; line-height: 1.4;
147284 white-space: nowrap; cursor: pointer; text-align: left;
⋯ 1 unchanged line
149286 button.tab:hover { background: rgba(128, 128, 128, 0.16); color: var(--fg); }
150287 button.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
151288 button.tab .name { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
289+/* Only a pinned tab carries one — it is what a tab that has given up its name
290+ has left to be told apart by. An unpinned tab shows the name instead: in a
291+ sidebar the number was spending room on something the tooltip and tmux's own
292+ status line both already say. */
152293 button.tab .index {
153- font-size: 9.5px; opacity: .6; font-variant-numeric: tabular-nums; flex: none;
294+ font-size: 10.5px; opacity: .75; font-variant-numeric: tabular-nums; flex: none;
154295 }
155296 /* tmux's own "something happened here while you were away". */
156297 button.tab.activity { color: var(--fg); }
⋯ 6 unchanged lines
163304 The glyph is Claude Code's own asterisk spinner, so a working window in the
164305 strip and the transcript in the pane are saying the same thing the same way.
165306 The box is sized and centred once, for the widest frame, so the name beside
166- it does not step sideways as the animation cycles. */
167-button.tab .glyph {
307+ it does not step sideways as the animation cycles — the frames come from
308+ whatever font the platform falls back to for each one, and their advance
309+ widths differ by several pixels.
310+
311+ An *unselected* session tab wears the same glyph for the loudest agent
312+ anywhere in the session, so one rule covers both rows: a waiting Claude looks
313+ the same whether you can see its window or only the session that holds it.
314+ The selected tab has no glyph to style — the windows it summarises are the
315+ row underneath, already saying it one at a time. */
316+button.tab .glyph,
317+button.session-tab .glyph {
168318 flex: none;
169319 width: 10px; height: 11px;
170320 display: inline-flex; align-items: center; justify-content: center;
171321 font-size: 10px; line-height: 1;
172322 color: var(--fg-muted); opacity: .5;
173323 }
174-button.tab .glyph.none { display: none; }
175-button.tab .glyph.working { color: var(--warn); opacity: 1; }
176-button.tab .glyph.waiting { color: var(--accent-hover); opacity: 1; animation: pulse 1.4s ease-in-out infinite; }
324+button.tab .glyph.none,
325+button.session-tab .glyph.none { display: none; }
326+button.tab .glyph.working,
327+button.session-tab .glyph.working { color: var(--warn); opacity: 1; }
328+button.tab .glyph.waiting,
329+button.session-tab .glyph.waiting { color: var(--accent-hover); opacity: 1; animation: pulse 1.4s ease-in-out infinite; }
330+/* Claude has stopped with something for you in a window you have not been
331+ shown since. Same glyph as idle, lit: grey is "nothing to do here", and the
332+ only thing that changed is that there now is. Slower than the waiting pulse
333+ and a different hue, because it is the softer of the two asks — nothing is
334+ blocked on it, it is just done. */
335+button.tab .glyph.ready,
336+button.session-tab .glyph.ready { color: var(--ok); opacity: 1; animation: pulse 2.2s ease-in-out infinite; }
177337
178-/* The lift: the terminal's background, shoulders drawn with an inset border,
179- and a negative bottom margin that punches through the header's seam so the
180- tab and the terminal read as one surface. */
338+/* The lift: the terminal's background, and a negative bottom margin that
339+ punches through the header's seam so the tab and the terminal read as one
340+ surface. */
341+/* Clicking the tab you are already on does nothing, so it does not offer the
342+ hand — the same thing a browser's own active tab does. The ✕ is a sibling
343+ button and keeps its own pointer. */
181344 button.tab[aria-selected="true"] {
182- background: var(--bg); color: var(--fg);
345+ --surface: var(--bg);
346+ background: var(--surface); color: var(--fg);
183347 margin-bottom: -1px; padding-bottom: 10px;
184- box-shadow: inset 1px 1px 0 0 var(--border), inset -1px 0 0 0 var(--border);
348+ cursor: default;
185349 }
186-button.tab[aria-selected="true"]:hover { background: var(--bg); }
350+button.tab[aria-selected="true"]:hover { background: var(--surface); }
187351
188-/* Room for the ✕ is reserved rather than made on hover, so a name does not
189- re-ellipsis under the pointer. */
190-.tab-slot.closable button.tab { padding-right: 20px; }
352+/* --- the tab silhouette ----------------------------------------------------
353+ A browser tab is not a rounded rectangle. Chromium walks the outline in
354+ TabStyleViews::GetPath: up from the floor through a *concave* quarter circle
355+ (the endcap, radius R), straight up the body's side, over a *convex* top
356+ corner (radius r), across, and back down the mirror image. Two consequences
357+ worth naming, because they are what makes the shape read as a tab:
191358
192-/* Pinned: down to the dot and the index, sized to its content and sitting at
193- the head of the strip. The index is what stays visible rather than the name,
194- because the index is the thing you can type — `prefix 3` still selects it,
195- pinned or not. */
196-.tab-slot.pinned { flex: 0 0 auto; min-width: 0; max-width: none; }
197-.tab-slot.pinned button.tab { padding-left: 7px; padding-right: 7px; gap: 4px; }
198-.tab-slot.pinned button.tab .index { opacity: 1; font-size: 11px; }
199-/* A hairline against the unpinned run, the way a browser separates them. */
200-.tab-slot.pinned + .tab-slot:not(.pinned) { margin-left: 5px; }
201-.tab-slot.pinned + .tab-slot:not(.pinned)::before {
202- content: ""; position: absolute; left: -3px; top: 6px; bottom: 6px;
203- border-left: 1px solid var(--border);
359+ - the body is inset from the tab's own width by R on each side, so the top
360+ is narrower than the bottom (`tab_left = left + extension`, where the
361+ extension is exactly the bottom corner radius);
362+ - each foot is tangent to both the floor and the body's side — its arc is
363+ centred on the body's edge line, R above the floor — so the tab leaves
364+ the floor horizontally rather than meeting it at a corner.
365+
366+ Drawn as one additive mask over the tab's own box, which the transparent
367+ side borders above make R wider than the body on each side. Not as
368+ pseudo-elements hanging past the tab's edges: the rows are scroll containers
369+ and clip at their padding box, so hanging feet only survive if the row
370+ reserves a foot's width before its first tab, and that reserve is dead space
371+ in the row whenever that tab isn't the selected one.
372+
373+ Six layers, every one of them overlapping its neighbours, all unioned. An
374+ earlier version tiled layers edge to edge and intersected them; the joins
375+ landed on fractional device pixels and the surface behind the tab showed
376+ through as hairlines, right where the tab is meant to be merging with it.
377+ Overlapping unions have no join to miss. The half-pixel colour stops are
378+ there for the same reason: a hard stop gives an unantialiased, stair-stepped
379+ arc. */
380+button.session-tab[aria-selected="true"],
381+button.tab[aria-selected="true"] {
382+ /* The flare: an R square of surface with a quarter disc bitten out of its
383+ outer top corner, which is the concave arc. 1px wider than the foot so it
384+ laps under the body rather than abutting it. */
385+ --foot-l: radial-gradient(circle var(--tab-foot) at top left,
386+ #0000 calc(var(--tab-foot) - 0.5px), #000 calc(var(--tab-foot) + 0.5px));
387+ --foot-r: radial-gradient(circle var(--tab-foot) at top right,
388+ #0000 calc(var(--tab-foot) - 0.5px), #000 calc(var(--tab-foot) + 0.5px));
389+ --fill: linear-gradient(#000, #000);
390+ /* The body's rounded shoulders, as discs the straight parts lap onto. */
391+ --top-l: radial-gradient(circle var(--tab-top) at
392+ calc(var(--tab-foot) + var(--tab-top)) var(--tab-top),
393+ #000 calc(var(--tab-top) - 0.5px), #0000 calc(var(--tab-top) + 0.5px));
394+ --top-r: radial-gradient(circle var(--tab-top) at
395+ calc(100% - var(--tab-foot) - var(--tab-top)) var(--tab-top),
396+ #000 calc(var(--tab-top) - 0.5px), #0000 calc(var(--tab-top) + 0.5px));
397+ --mask-layers:
398+ var(--foot-l) left bottom / calc(var(--tab-foot) + 1px) var(--tab-foot) no-repeat,
399+ var(--foot-r) right bottom / calc(var(--tab-foot) + 1px) var(--tab-foot) no-repeat,
400+ var(--top-l) 0 0 / 100% 100% no-repeat,
401+ var(--top-r) 0 0 / 100% 100% no-repeat,
402+ /* the body below its shoulders, and the strip between them */
403+ var(--fill) center bottom / calc(100% - 2 * var(--tab-foot))
404+ calc(100% - var(--tab-top)) no-repeat,
405+ var(--fill) center top /
406+ calc(100% - 2 * var(--tab-foot) - 2 * var(--tab-top)) 100% no-repeat;
407+ -webkit-mask: var(--mask-layers);
408+ mask: var(--mask-layers);
409+}
410+
411+/* --- separators ------------------------------------------------------------
412+ Chromium draws a 2x16 pill (radius 1, so it is a stadium and not a hairline)
413+ centred in the slot between two tab bodies, and hides it whenever either
414+ neighbour is active, selected or hovered — `has_visible_background` in
415+ TabStyleViews::GetSeparatorOpacity — crossfading over the hover animation so
416+ that scrubbing the pointer along the row doesn't flicker.
417+
418+ One per boundary, drawn on the right-hand tab of each pair: Chromium computes
419+ it on both facing tabs and relies on them landing on the same coordinate,
420+ which in CSS would just double-paint. Absolutely positioned, never a border,
421+ or hovering a tab would shift every tab after it by a pixel.
422+
423+ The colour is --border rather than Chromium's "sliver of the toolbar showing
424+ through": that trick needs the surface below to contrast with the strip, and
425+ in the dark theme ours are two units apart. */
426+.tab-slot + .tab-slot > button.tab::before,
427+button.session-tab + button.session-tab::before,
428+#tab-new::before,
429+#session-new::before {
430+ content: "";
431+ position: absolute; top: 50%; translate: 0 -50%;
432+ /* Chromium's is 2 DIP on a 34 DIP tab; ours is a hairline on a 26px one,
433+ which at this size reads as a divider rather than as a bar. */
434+ width: 1px; height: 11px;
435+ border-radius: 1px;
436+ background: var(--border);
437+ pointer-events: none;
438+ transition: opacity 160ms ease-out;
439+}
440+/* Centred in the lane between the two bodies. Offsets on an absolutely
441+ positioned child are measured from its parent's *padding* box, which here
442+ starts a whole foot inside the tab's own edge — so this counts left from the
443+ body's leading edge rather than right from the tab's. */
444+.tab-slot + .tab-slot > button.tab::before,
445+button.session-tab + button.session-tab::before {
446+ left: calc(-0.5 * var(--tab-gap) - 0.5px);
447+}
448+/* The trailing one, between the last tab and "+", is the same boundary seen
449+ from the other side. Chromium keeps this one and drops the leading one
450+ outright — `leading ? 0.0f` in GetSeparatorOpacity — so there is nothing
451+ between the controls ahead of the strip and the first tab. */
452+/* Between the last body's edge (a foot to the button's left) and the button's
453+ own glyph (about 5.5px inside it) — centred in what the eye sees as the gap,
454+ which is wider here than the lane between two tabs. */
455+#tab-new::before, #session-new::before { left: -2.5px; }
456+
457+/* Hidden next to anything with a background of its own: the tab itself, and
458+ the boundary on its far side. */
459+.tab-slot.active > button.tab::before,
460+.tab-slot:hover > button.tab::before,
461+.tab-slot.active + .tab-slot > button.tab::before,
462+.tab-slot:hover + .tab-slot > button.tab::before,
463+button.session-tab[aria-selected="true"]::before,
464+button.session-tab:hover::before,
465+button.session-tab[aria-selected="true"] + button.session-tab::before,
466+button.session-tab:hover + button.session-tab::before { opacity: 0; }
467+/* Same rule for the "+" boundary, which needs :has() to look backwards. */
468+#tabs:has(.tab-slot:last-child:is(.active, :hover)) ~ #tab-new::before,
469+#sessions:has(button.session-tab:last-child:is([aria-selected="true"], :hover)) ~ #session-new::before {
470+ opacity: 0;
471+}
472+/* The selected tab paints over its neighbours, feet included. */
473+.tab-slot.active, button.session-tab[aria-selected="true"] { z-index: 1; }
474+/* The "+" buttons carry a separator, so they contain it. */
475+#tab-new, #session-new { position: relative; }
476+
477+/* The overlap is between tabs, not around the run of them: the outermost edges
478+ pull back to flush, or half a foot hangs outside the scroll container and is
479+ clipped away — the row's ends are exactly where a foot needs its room. */
480+#tabs > .tab-slot:first-child,
481+#sessions > button.session-tab:first-child { margin-left: 0; }
482+#tabs > .tab-slot:last-child,
483+#sessions > button.session-tab:last-child { margin-right: 0; }
484+
485+/* The run as a whole sits a little further left than the control before it,
486+ so the first tab's foot laps over that button's padding rather than leaving
487+ a notch beside it. On the run and not on the first tab: pulling the tab
488+ alone would take its outer foot past the scroll container's edge, and the
489+ container would clip exactly the part of the curve this is about. */
490+#tabs, #sessions { margin-left: -4px; }
491+
492+/* Being dragged: the tab keeps its place in the row (it is the element being
493+ moved through it), so the only cue needed is that it is the one in hand. */
494+.session-tab.dragging, .tab-slot.dragging { opacity: .45; }
495+.tab-slot.dragging button.tab-close { opacity: 0; pointer-events: none; }
496+
497+/* --- tab groups ------------------------------------------------------------
498+ The single-row layout. One strip holds every window on the server, and each
499+ session's run of them is introduced by a chip.
500+
501+ With the session row gone there are two surfaces rather than three, so the
502+ remaining one takes the deepest of them and the selected tab still lifts all
503+ the way to the terminal — the same gesture, one level shallower. */
504+body.groups #window-strip { background: var(--bg-strip); }
505+/* Two "+"s in one row now — a window in the session we are on, then a session.
506+ Only the trailing one takes the row's leftover width, or the pair would be
507+ pushed apart by it and read as belonging to different things. */
508+body.groups #tab-new { margin-right: 0; }
509+/* The run starts with a chip, which has no foot to lap over the control before
510+ it, so it does not get the pull the tabs need. */
511+body.groups #tabs { margin-left: 0; }
512+
513+/* The chip is not a tab and is deliberately not shaped like one: no feet, no
514+ top corners, nothing that reaches the strip's bottom edge. It is a label with
515+ a fold, sitting on the row rather than rising out of it, which is what keeps
516+ a row of mixed chips and tabs readable as "groups of tabs" rather than as two
517+ kinds of tab.
518+
519+ --group-h is set per chip in JS, hashed from the session name: one hue for
520+ that session everywhere, with nothing stored and nothing to assign. Both the
521+ text and the fill are mixed *towards the theme's own colours* rather than
522+ given fixed lightnesses, so one rule covers light and dark — the same hue
523+ darkens on white and lightens on the dark strip. */
524+.group-chip {
525+ --group-c: hsl(var(--group-h, 210) 60% 50%);
526+ position: relative;
527+ flex: none;
528+ align-self: center;
529+ display: inline-flex; align-items: center; gap: 4px;
530+ margin: 0 2px 4px 8px;
531+ padding: 2px 7px 3px;
532+ border: 0; border-radius: 9px;
533+ background: color-mix(in srgb, var(--group-c) 15%, transparent);
534+ color: color-mix(in srgb, var(--group-c) 60%, var(--fg));
535+ font: inherit; font-size: 10.5px; line-height: 1.4; font-weight: 600;
536+ white-space: nowrap; cursor: pointer;
537+}
538+/* Nothing precedes the first chip, so it takes the same flush edge the first
539+ tab does. */
540+#tabs > .group-chip:first-child { margin-left: 0; }
541+/* Grey: the home session's default, and a colour you can pick outright.
542+ Overriding the one custom property is enough; every rule below is written
543+ against it. */
544+.group-chip.grey { --group-c: var(--fg-muted); }
545+.group-chip:hover { background: color-mix(in srgb, var(--group-c) 26%, transparent); }
546+.group-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
547+/* The group this panel's client is in. A ring rather than more fill: the fill
548+ is already carrying the identity, and a second use of it would say "louder
549+ session" instead of "you are here". */
550+.group-chip.current {
551+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--group-c) 70%, transparent);
552+ color: color-mix(in srgb, var(--group-c) 45%, var(--fg));
553+}
554+/* Some other tmux client is on this session — the same thing the nested
555+ layout's session tab says by going full-strength. */
556+.group-chip.attached .name { text-decoration: underline; text-underline-offset: 2px; }
557+/* Mid-drag, with a window hovering over it: the drop moves that window into
558+ this session. */
559+.group-chip.drop {
560+ background: color-mix(in srgb, var(--group-c) 45%, transparent);
561+ color: var(--fg);
562+}
563+
564+/* A triangle rather than a chevron glyph: at 4px a font's chevron is at the
565+ mercy of whichever fallback the platform picks, and this one is drawn. */
566+.group-chip .caret {
567+ flex: none;
568+ width: 0; height: 0;
569+ border: 3.5px solid transparent;
570+ border-top: 4px solid currentColor;
571+ /* Borders build the triangle from the box's centre, so the bottom half of it
572+ is empty space; this puts the visible half back on the text's midline. */
573+ margin-top: 2px;
574+}
575+.group-chip.folded .caret {
576+ border: 3.5px solid transparent;
577+ border-left: 4px solid currentColor;
578+ margin-top: 0; margin-left: 1px;
579+}
580+.group-chip .count {
581+ font-size: 9.5px; opacity: .7; font-variant-numeric: tabular-nums; flex: none;
582+ font-weight: 400;
583+}
584+/* A folded group's windows have no tabs to wear their status, so the chip wears
585+ the loudest of them. Same sizes as a tab's favicon slot. */
586+.group-chip .glyph {
587+ flex: none;
588+ width: 10px; height: 11px;
589+ display: inline-flex; align-items: center; justify-content: center;
590+ font-size: 10px; line-height: 1;
591+ opacity: .8;
592+}
593+.group-chip .glyph.working { color: var(--warn); opacity: 1; }
594+.group-chip .glyph.waiting { color: var(--accent-hover); opacity: 1; animation: pulse 1.4s ease-in-out infinite; }
595+.group-chip .glyph.ready { color: var(--ok); opacity: 1; animation: pulse 2.2s ease-in-out infinite; }
596+
597+/* The run's ends are flush against the chips either side of it, for the same
598+ reason the strip's own ends are: half a foot hanging under a chip reads as a
599+ tab starting behind it. */
600+#tabs > .group-chip + .tab-slot { margin-left: 0; }
601+#tabs > .tab-slot:has(+ .group-chip) { margin-right: 0; }
602+
603+/* The current window of a session this panel is *not* on. tmux marks one per
604+ session, and in this row they are all visible at once — worth saying, but it
605+ is not the tab you are looking at and must not be drawn as one. */
606+.tab-slot.elsewhere > button.tab { color: var(--fg); }
607+.tab-slot.elsewhere > button.tab .name { font-weight: 600; }
608+
609+/* --- the omnibar -----------------------------------------------------------
610+ Groups mode's second row: a browser's address bar, under the tabs, in the
611+ same place and doing the same job — search what you have, or create what you
612+ named. The picker and the jump button lead it, in that order, and Enter and
613+ push-to-talk end it — the same pair, in the same order, that leads the window
614+ row in the nested layout. */
615+#omni-strip {
616+ background: var(--bg);
617+ padding: 3px 6px 4px 4px;
618+ align-items: center;
619+ gap: 2px;
620+}
621+/* The pill is the wrapper, not the input: the location sits inside it, so the
622+ border and the focus ring have to go around both. */
623+#omni-box {
624+ flex: 1 1 auto; min-width: 0;
625+ display: flex; align-items: center; gap: 6px;
626+ /* Nothing inside may paint past the pill: at the narrowest the directory is
627+ capped rather than shrunk, so it can want a few pixels more than are left. */
628+ overflow: hidden;
629+ height: 24px;
630+ padding: 0 9px;
631+ border: 1px solid var(--border); border-radius: 12px;
632+ background: var(--bg-input);
633+}
634+#omni-box:focus-within {
635+ border-color: var(--accent);
636+ /* The ring is the border thickening inwards, so the row's height does not
637+ change when the box takes focus. */
638+ box-shadow: inset 0 0 0 1px var(--accent);
639+}
640+#omni {
641+ /* Enough left for a few characters of the name and the caret: the directory
642+ beside it shrinks first, because a session you cannot read the name of is
643+ worse than a path missing its front. */
644+ flex: 1 1 auto; min-width: 4rem;
645+ height: 100%;
646+ font: inherit; font-size: 11px;
647+ padding: 0; border: 0; background: none; color: var(--fg);
648+}
649+#omni:focus, #omni:focus-visible { outline: none; }
650+#omni::placeholder { color: var(--fg-muted); }
651+
652+/* The directory, at the far end of the box. Dimmed and never wider than half
653+ of it: it is context for the name, not a second thing competing with it, and
654+ a deep path must not push the name it belongs to out of view. Read from its
655+ end like every path here, so what goes when it does not fit is the front. */
656+#omni-cwd {
657+ /* It neither grows nor shrinks: a text input's flex base size is far wider
658+ than the text in it, so a shrinkable directory beside one is squeezed
659+ against a phantom — the box reads as half empty and the path still ends in
660+ an ellipsis. Fixed at its content width, the input absorbs the slack
661+ instead, and the only thing that can cut the path is the cap. */
662+ flex: 0 0 auto; min-width: 0; max-width: 72%;
663+ font-size: 10px;
664+ color: var(--fg-muted);
665+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
666+ direction: rtl; text-align: right;
667+}
668+#omni-cwd > span { direction: ltr; unicode-bidi: embed; }
669+
670+/* The slot a browser keeps its site-info button in, doing the same job for the
671+ session. The hit area is a finger's worth of button and the glyph inside it
672+ stays small — muted until you go for it, the way a chrome control that is not
673+ asking for anything should sit. */
674+#omni-here {
675+ flex: none;
676+ display: flex; align-items: center; justify-content: center;
677+ width: 18px; height: 18px; margin-left: -4px;
678+ padding: 0; border: 0; border-radius: 50%;
679+ background: none; color: var(--fg-muted); cursor: pointer;
680+}
681+#omni-here svg { width: 13px; height: 13px; display: block; }
682+#omni-here:hover { background: var(--bg-panel); color: var(--fg); }
683+#omni-here[aria-expanded="true"] { background: var(--bg-panel); color: var(--fg); }
684+#omni-here:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
685+
686+/* --- session info ----------------------------------------------------------
687+ The panel behind the dot. A browser's site-info bubble in the same place and
688+ with the same shape: rows of label and value, the ones you might want to
689+ carry elsewhere copyable, and nothing modal about it. */
690+.info-pop {
691+ position: fixed; z-index: 20;
692+ min-width: 15rem; max-width: min(22rem, calc(100vw - 12px));
693+ display: flex; flex-direction: column; gap: 5px;
694+ padding: 8px 10px;
695+ font-size: 11px; line-height: 1.45;
696+ background: var(--bg-panel); color: var(--fg);
697+ border: 1px solid var(--border); border-radius: 8px;
698+ box-shadow: 0 6px 20px var(--shadow);
699+}
700+.info-pop .info-head {
701+ display: flex; align-items: center; gap: 6px;
702+ padding-bottom: 5px;
703+ border-bottom: 1px solid var(--border);
704+}
705+.info-pop .info-head .dot {
706+ flex: none; width: 8px; height: 8px; border-radius: 50%;
707+ background: hsl(var(--group-h, 210) 60% 50%);
708+}
709+.info-pop .info-head .dot.grey { background: var(--fg-muted); }
710+.info-pop .info-head .name {
711+ font-weight: 600;
712+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
713+}
714+.info-pop .info-head .sub { color: var(--fg-muted); font-size: 10px; }
715+.info-row { display: flex; align-items: baseline; gap: 8px; }
716+.info-row .k { flex: none; width: 4.5rem; color: var(--fg-muted); }
717+.info-row .v {
718+ flex: 1 1 auto; min-width: 0;
719+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
720+}
721+/* A path is read from its end — the leaf is the part that identifies it — so
722+ when it does not fit, the front is what goes. */
723+.info-row .v.path {
724+ font-family: ui-monospace, monospace; direction: rtl; text-align: left;
725+}
726+.info-row .v.path > span { direction: ltr; unicode-bidi: embed; }
727+.info-pop .info-agents {
728+ display: flex; flex-direction: column; gap: 3px;
729+ padding-top: 5px; border-top: 1px solid var(--border);
730+}
731+.info-pop .info-agent { display: flex; align-items: baseline; gap: 6px; }
732+.info-pop .info-agent .glyph {
733+ flex: none; width: 10px; text-align: center;
734+ color: var(--fg-muted); opacity: .6;
735+}
736+.info-pop .info-agent .glyph.working { color: var(--warn); opacity: 1; }
737+.info-pop .info-agent .glyph.waiting { color: var(--accent-hover); opacity: 1; }
738+/* No pulse in the panel or the omnibox: both are lists you are already reading
739+ top to bottom, and a light that breathes in a list you are scanning is the
740+ kind of motion the strip earns by being glanced at and these do not. */
741+.info-pop .info-agent .glyph.ready { color: var(--ok); opacity: 1; }
742+.info-pop .info-agent .what {
743+ flex: 1 1 auto; min-width: 0;
744+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
745+}
746+.info-pop .info-agent .where { flex: none; color: var(--fg-muted); font-size: 10px; }
747+.info-pop .info-empty { color: var(--fg-muted); }
748+.info-pop .info-copy {
749+ align-self: flex-start;
750+ padding: 2px 6px;
751+ font: inherit; font-size: 10px;
752+ color: var(--fg-muted); background: none;
753+ border: 1px solid var(--border); border-radius: 5px;
754+ cursor: pointer;
755+}
756+.info-pop .info-copy:hover { color: var(--fg); border-color: var(--accent); }
757+
758+body.compact #omni-strip { padding: 2px 4px 3px 4px; }
759+body.compact #omni-box { height: 21px; }
760+
761+/* Over the terminal, not in the header: the header is a flex column that sizes
762+ the terminal, so a list that grew inside it would reflow the terminal — and
763+ re-fit xterm.js — on every keystroke. `top` is set in JS from the row's own
764+ box, so it stays under the box at any density. */
765+#omni-list {
766+ position: fixed; left: 0; right: 0;
767+ z-index: 5;
768+ max-height: min(52vh, 320px);
769+ overflow-y: auto;
770+ padding: 3px 0;
771+ background: var(--bg-panel);
772+ border-bottom: 1px solid var(--border);
773+ box-shadow: 0 6px 16px var(--shadow);
774+}
775+.omni-row {
776+ display: flex; align-items: baseline; gap: 6px;
777+ padding: 4px 10px;
778+ font-size: 11px; line-height: 1.5;
779+ color: var(--fg-muted);
780+ cursor: pointer;
781+}
782+.omni-row .label {
783+ color: var(--fg);
784+ flex: 0 1 auto; min-width: 0;
785+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
786+}
787+/* The part you typed. The rest of the name is what tells you it is not the one
788+ you meant, so the emphasis goes on the match rather than on the difference. */
789+.omni-row .label b { color: var(--accent-hover); font-weight: 700; }
790+.omni-row .meta {
791+ flex: 1 1 auto; min-width: 0;
792+ text-align: right;
793+ font-size: 10px;
794+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
795+}
796+/* Keyboard and pointer land on the same highlight — the row under the cursor
797+ *is* the chosen one, so Enter and a click can never disagree. */
798+.omni-row.active { background: var(--accent); }
799+.omni-row.active .label,
800+.omni-row.active .meta { color: var(--accent-fg); }
801+.omni-row.active .label b { color: var(--accent-fg); }
802+/* The one row that makes something rather than going somewhere. */
803+.omni-row.create .label::before {
804+ content: "+ ";
805+ color: var(--ok);
806+ font-weight: 700;
807+}
808+/* The row that reads the box as a question. Claude's own asterisk, the same
809+ one the agent glyphs use, so the row is recognisable before it is read. */
810+.omni-row.claude .label::before {
811+ content: "✻ ";
812+ color: var(--accent-hover);
813+ font-weight: 700;
814+}
815+.omni-row.claude.active .label::before { color: var(--accent-fg); }
816+/* The row that reads the box as a shell command. A prompt's own mark, because
817+ that is what the pane it opens will be showing. */
818+.omni-row.run .label::before {
819+ content: "$ ";
820+ color: var(--ok);
821+ font-weight: 700;
822+}
823+.omni-row.run .label { font-family: ui-monospace, monospace; }
824+/* The row that goes to another machine. An arrow leaving, because that is the
825+ one thing about it that is not like every other row: the window it opens is
826+ here, but what you end up talking to is somewhere else. */
827+.omni-row.ssh .label::before {
828+ content: "⇢ ";
829+ color: var(--accent-hover);
830+ font-weight: 700;
831+}
832+.omni-row.ssh.active .label::before { color: var(--accent-fg); }
833+.omni-row.ssh .label { font-family: ui-monospace, monospace; }
834+/* A directory, in both of the rows that are about one: what the box holds, and
835+ the names it could be completed to. Monospaced for the reason the ssh and
836+ shell rows are — a path is read character by character, and the column the
837+ slashes line up in is how you read it. The completion's mark is a slash
838+ because that is the key it stands in for. */
839+.omni-row.project .label { font-family: ui-monospace, monospace; }
840+.omni-row.path .label { font-family: ui-monospace, monospace; }
841+.omni-row.path .label::before {
842+ content: "/ ";
843+ color: var(--fg-muted);
844+ font-weight: 700;
845+}
846+.omni-row.path.active .label::before { color: var(--accent-fg); }
847+/* The rows that make something in this session rather than going anywhere. The
848+ mark is per-row rather than per-kind — see `omniActions`. */
849+.omni-row .mark {
850+ flex: none;
851+ color: var(--accent-hover);
852+ font-weight: 700;
853+}
854+.omni-row.active .mark { color: var(--accent-fg); }
855+/* A row that is a label rather than an offer: the bare `!`, before there is a
856+ command to run. Dimmed, and not styled as clickable, because it isn't. */
857+.omni-row.hint { cursor: default; }
858+.omni-row.hint .label { color: var(--fg-muted); font-style: italic; }
859+.omni-row.run.active .label::before { color: var(--accent-fg); }
860+.omni-row .glyph {
861+ flex: none;
862+ width: 10px; height: 11px;
863+ display: inline-flex; align-items: center; justify-content: center;
864+ font-size: 10px; line-height: 1;
865+ color: var(--fg-muted); opacity: .5;
866+ align-self: center;
867+}
868+/* Unlike a tab's, this slot is always there even when it is empty: a list is
869+ read down its left edge, and a glyph that collapses steps every row without
870+ an agent out of line with the rows that have one. */
871+.omni-row .glyph.working { color: var(--warn); opacity: 1; }
872+.omni-row .glyph.waiting { color: var(--accent-hover); opacity: 1; }
873+.omni-row .glyph.ready { color: var(--ok); opacity: 1; }
874+.omni-row.active .glyph { color: var(--accent-fg); opacity: 1; }
875+/* A finger is not a cursor: an 11px row is a comfortable read and a bad target,
876+ so where the pointer is coarse the rows grow to something you can hit without
877+ aiming. Nothing else about the list changes — the same rows, further apart. */
878+@media (pointer: coarse) {
879+ .omni-row { padding: 9px 10px; }
880+ #omni-list { max-height: min(60vh, 420px); }
204881 }
205882
206883 /* Right-click menu. A plain positioned element: an extension page would
⋯ 15 unchanged lines
222899 .tab-menu button.danger:hover { background: var(--err); color: var(--accent-fg); }
223900 .tab-menu button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
224901
902+/* The group's name, edited in place at the top of its menu — the same place a
903+ browser puts a tab group's name. No separator under it: it and the palette
904+ below are one block of "what this group is", divided from the actions. */
905+.tab-menu .rename {
906+ padding: 6px 8px 2px;
907+}
908+.tab-menu .rename input {
909+ width: 100%; box-sizing: border-box;
910+ padding: 4px 6px;
911+ background: var(--bg); color: var(--fg);
912+ border: 1px solid var(--border); border-radius: 4px;
913+ font: inherit; font-size: 11px;
914+}
915+.tab-menu .rename input:focus { outline: none; border-color: var(--accent); }
916+/* A name tmux will not take. Said while it is being typed rather than after it
917+ is sent, since the daemon's answer to one is silence. */
918+.tab-menu .rename.bad input,
919+.tab-menu .rename.bad input:focus { border-color: var(--err); }
920+
921+/* The group palette, across the top of a group's menu. A row rather than nine
922+ more menu items: these are a palette, and the eye finds a colour in a palette
923+ faster than a colour name in a list. */
924+.tab-menu .colours {
925+ display: flex; gap: 5px;
926+ padding: 7px 8px;
927+ border-bottom: 1px solid var(--border);
928+ margin-bottom: 3px;
929+}
930+.tab-menu .swatch {
931+ flex: none;
932+ width: 15px; height: 15px; padding: 0;
933+ border-radius: 50%; border: 0;
934+ background: hsl(var(--group-h, 210) 60% 50%);
935+ cursor: pointer;
936+}
937+.tab-menu .swatch.grey { background: var(--fg-muted); }
938+.tab-menu .swatch:hover { transform: scale(1.15); }
939+/* The one showing now, ringed in the panel's own background so the ring reads
940+ as a gap around the colour rather than as a second colour on top of it. */
941+.tab-menu .swatch.on {
942+ box-shadow: 0 0 0 2px var(--bg-panel), 0 0 0 3.5px var(--fg-muted);
943+}
944+.tab-menu .swatch:focus-visible {
945+ outline: 2px solid var(--accent); outline-offset: 2px;
946+}
947+
225948 button.tab-close {
226- position: absolute; right: 4px; top: 5px;
949+ position: absolute; right: calc(4px + var(--tab-foot)); top: 5px;
227950 width: 15px; height: 15px; padding: 0;
228951 display: inline-flex; align-items: center; justify-content: center;
229952 border: 0; border-radius: 4px;
230953 background: transparent; color: var(--fg-muted); cursor: pointer;
231- opacity: 0; pointer-events: none; transition: opacity 90ms linear;
954+ opacity: .45; transition: opacity 90ms linear;
232955 }
233956 button.tab-close svg { width: 9px; height: 9px; display: block; }
234-/* On the tab you are on and the one you are pointing at — a full strip of ✕ is
235- noise, and every one of them is destructive. */
957+/* On every tab that has one, not just the active tab and the one under the
958+ pointer: a ✕ you have to go looking for is a ✕ you do not know is there, and
959+ the room for it is reserved either way — `.tab-slot.closable` pads for it
960+ whether or not it is painted, so showing it costs no layout.
961+ Dim until the tab is the one you are on or pointing at, which is what keeps a
962+ full strip of them from reading as noise. Every one of them is destructive,
963+ so the quiet default matters more here than in a browser. */
236964 .tab-slot.active button.tab-close,
237965 .tab-slot:hover button.tab-close,
238-.tab-slot:focus-within button.tab-close { opacity: 1; pointer-events: auto; }
966+.tab-slot:focus-within button.tab-close { opacity: 1; }
239967 /* Red on hover rather than the usual grey: this is the one control in the
240968 panel that destroys something, and it takes effect on the first click. */
241969 button.tab-close:hover { background: var(--err); color: var(--accent-fg); }
⋯ 11 unchanged lines
253981 button.icon:hover { background: var(--border); color: var(--fg); }
254982 button.icon:active { transform: translateY(0.5px); }
255983 button.icon:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
984+/* A window tab is hovering over "+", and letting go gives it a session of its
985+ own. Lit rather than outlined, because the pointer is over the button and a
986+ ring under a dragged tab reads as a gap. */
987+button.icon.drop {
988+ background: var(--accent);
989+ color: var(--accent-fg);
990+}
256991 #pick.active { background: var(--accent); color: var(--accent-fg); }
257992
258-/* The session name lives in the switcher once there is one, so the status text
993+/* Jump to the next pane an agent is waiting in. Tinted by the state it would
994+ take you to, in the same three colours the tabs' glyphs use — the button is
995+ the one place in the header that says what is loudest *anywhere* on the
996+ server, so it has to answer "is there anything for me" before it is pressed
997+ and without being read as a fourth vocabulary.
998+
999+ No pulse: the tab whose glyph is pulsing is already saying that, and a second
1000+ thing moving in the same row for the same reason is noise. Dimmed and
1001+ unpressable when no pane has an agent in it — there is nowhere to go, and the
1002+ button that goes nowhere should not look like it does. */
1003+#jump[data-state="waiting"] { color: var(--accent-hover); }
1004+#jump[data-state="ready"] { color: var(--ok); }
1005+#jump[data-state="working"] { color: var(--warn); }
1006+button.icon:disabled { opacity: 0.4; cursor: default; }
1007+button.icon:disabled:hover { background: transparent; color: var(--fg-muted); }
1008+button.icon:disabled:active { transform: none; }
1009+
1010+/* Push to talk, while held. Red rather than the accent: this one is putting
1011+ bytes into the pane for as long as it looks like this, and it needs to be
1012+ obvious at a glance that it is still down — a key stuck open in a terminal is
1013+ not a small mistake. The ring is what carries that at 26px, where a fill
1014+ alone reads much like the picker's.
1015+
1016+ `:active` is deliberately not the hook. The hold survives the pointer sliding
1017+ off the button (it is captured), and :active does not. */
1018+/* Much bigger than its neighbours, and round. Every other icon here is a tap,
1019+ which forgives a near miss; this one is held for the length of a sentence, and
1020+ sliding off it mid-word is the failure that matters. The size is the fix, and
1021+ the circle is what says "hold" rather than "click" at a glance.
1022+
1023+ It takes most of the corner, which is what the corner is for: a row can only
1024+ ever give an icon its own 26px, and a thumb wants more than that. It is sized
1025+ from the height it is given rather than from the corner's width, because the
1026+ corner shares that height with the Enter key under it and the header is a row
1027+ taller in one mode than the other — a circle fixed at the full width would
1028+ push the header taller in the mode that cannot spare it. The floor is 40px
1029+ and the ceiling 48; both platforms' touch guidance names 44 and the band
1030+ straddles it.
1031+
1032+ A lifted surface rather than the recessed one an input gets: this is the one
1033+ control in the panel you aim a thumb at, and on the header's own background
1034+ a well would read as a hole rather than a button. */
1035+#talk.big {
1036+ flex: 1 1 auto;
1037+ width: auto; height: auto;
1038+ min-height: 40px; max-height: 48px;
1039+ aspect-ratio: 1;
1040+ border-radius: 50%;
1041+ border: 1px solid var(--border);
1042+ background: var(--bg-panel);
1043+ box-shadow: 0 1px 2px var(--shadow);
1044+}
1045+#talk.big:hover {
1046+ background: color-mix(in srgb, var(--fg) 8%, var(--bg-panel));
1047+ color: var(--fg);
1048+}
1049+#talk.big svg { width: 21px; height: 21px; }
1050+
1051+#talk.talking {
1052+ background: var(--err);
1053+ color: var(--accent-fg);
1054+ border-color: var(--err);
1055+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 30%, transparent);
1056+}
1057+#talk.talking svg { animation: talk-pulse 1.1s ease-in-out infinite; }
1058+@keyframes talk-pulse {
1059+ 50% { opacity: .45; }
1060+}
1061+@media (prefers-reduced-motion: reduce) {
1062+ #talk.talking svg { animation: none; }
1063+}
1064+/* Let go and the Return is queued, not sent: for the length of that window the
1065+ button says so, by fading the hold's own colour out over exactly as long as
1066+ you have to press it again. Nothing is sent while the ring is visible, so the
1067+ cue and the thing it reports on end together. The duration comes from JS so
1068+ there is one number for it; the fallback matches TALK_SUBMIT_MS. */
1069+#talk.pending {
1070+ animation: talk-arm var(--talk-submit, 1s) linear forwards;
1071+}
1072+@keyframes talk-arm {
1073+ from { box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 30%, transparent); }
1074+ to { box-shadow: 0 0 0 3px transparent; }
1075+}
1076+@media (prefers-reduced-motion: reduce) {
1077+ #talk.pending { animation: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--err) 15%, transparent); }
1078+}
1079+
1080+/* Held with a finger: the browser's own long-press behaviours — text selection,
1081+ the callout menu — would otherwise interrupt the gesture. */
1082+#talk { touch-action: none; -webkit-user-select: none; user-select: none; }
1083+
1084+/* The session name is a tab of its own once there is one, so the status text
2591085 stands down rather than saying it twice. The dot stays: it is the only thing
2601086 reporting on the connection itself. */
261-body.has-session > header > #status-text { display: none; }
1087+body.has-session > header #status-text { display: none; }
2621088
2631089 /* Compact: lose the status text, keep the targets clickable. */
264-body.compact header { padding: 2px 4px 0; }
1090+body.compact .strip { padding: 2px 4px 0; }
2651091 body.compact #status-text { display: none; }
2661092 body.compact button.icon { width: 24px; height: 24px; }
267-body.compact > header > #session-chip { height: 20px; }
268-body.compact #session-switch { line-height: 18px; }
269-body.compact .tab-slot { max-width: 8rem; }
1093+/* The mic is a touch target rather than a glyph, so density trims it a little
1094+ and then stops: below the platforms' 44px floor it stops being one. */
1095+body.compact #corner { width: 50px; padding: 3px 3px 4px; }
1096+body.compact #talk.big { min-height: 38px; max-height: 44px; }
1097+body.compact #talk.big svg { width: 19px; height: 19px; }
1098+body.compact .tab-slot { max-width: calc(8rem + 2 * var(--tab-foot)); }
2701099 body.compact button.tab { padding: 3px 6px 4px; }
1100+body.compact button.session-tab { padding: 2px 6px 3px; max-width: calc(8rem + 2 * var(--tab-foot)); }
2711101 body.compact .tab-slot.closable button.tab { padding-right: 19px; }
2721102 body.compact button.tab[aria-selected="true"] { padding-bottom: 9px; }
273-body.compact button.tab .index { display: none; }
2741103 body.compact button.tab-close { top: 4px; }
1104+body.compact .group-chip { padding: 1px 6px 2px; margin-left: 6px; font-size: 10px; }
1105+/* The count is what a folded chip has instead of tabs, so unlike a session
1106+ tab's it survives compact. */
2751107 body.compact #term { padding: 2px; }
2761108
2771109 /* Hidden: no header at all. A 4px strip along the top brings it back on
⋯ 10 unchanged lines
2881120 body.chromeless header:hover { opacity: 1; pointer-events: auto; }
2891121 body.chromeless #term { padding: 2px; }
2901122 #reveal-strip { display: none; }
291-
292-.dot.on { background: var(--ok); }
293-.dot.off { background: var(--err); }
294-.dot.pending { background: var(--warn); }
2951123
2961124 @keyframes pulse { 50% { opacity: .25; } }
2971125 /* The spinner is driven from script, which parks it on a single frame when
298- motion is reduced; this is only the waiting glyph's own breathing. */
1126+ motion is reduced; this is only the resting glyphs' own breathing. They keep
1127+ their colour, which is what carries the meaning — the pulse only ever made it
1128+ easier to catch out of the corner of an eye. */
2991129 @media (prefers-reduced-motion: reduce) {
300- button.tab .glyph.waiting { animation: none; }
1130+ button.tab .glyph.waiting,
1131+ button.session-tab .glyph.waiting,
1132+ .group-chip .glyph.waiting,
1133+ button.tab .glyph.ready,
1134+ button.session-tab .glyph.ready,
1135+ .group-chip .glyph.ready { animation: none; }
1136+}
1137+/* `touch-action: none` because the terminal scrolls itself. Without it the
1138+ browser claims a vertical drag as a pan of the panel, and having claimed it,
1139+ stops sending the touchmove events sidebar.js turns into wheel events — the
1140+ gesture would be swallowed before the terminal ever heard about it. A tap is
1141+ untouched by this and still becomes a click. */
1142+#term {
1143+ flex: 1; min-height: 0; padding: 4px; overflow: hidden;
1144+ touch-action: none;
3011145 }
302-#term { flex: 1; min-height: 0; padding: 4px; overflow: hidden; }
3031146 #term .xterm { height: 100%; }
1147+/* --- settings, as a popup ---------------------------------------------------
1148+ A floating card under the chevron, the way Chrome hangs its tab-search popup
1149+ off the chevron in the same corner: rounded, lifted off the page by a shadow
1150+ rather than joined to an edge, and scrolling inside itself so the terminal
1151+ underneath never moves. `left` and `top` are set in JS from the chevron's own
1152+ box, so it follows the button at any density. */
3041153 aside {
305- flex: none; max-height: 55%; overflow-y: auto;
306- padding: 10px; background: var(--bg-panel); border-top: 1px solid var(--border);
1154+ position: fixed; z-index: 30;
1155+ width: min(21rem, calc(100vw - 12px));
1156+ max-height: min(70vh, 34rem); overflow-y: auto; overscroll-behavior: contain;
1157+ padding: 10px 12px 12px;
1158+ background: var(--bg-panel); color: var(--fg);
1159+ border: 1px solid var(--border); border-radius: 12px;
1160+ box-shadow: 0 10px 30px var(--shadow), 0 2px 6px var(--shadow);
1161+}
1162+/* The card's own title bar, and a ✕ in it: a popup that floats has no edge to
1163+ read as "this is a panel", so it says what it is and how to put it away. */
1164+.settings-head {
1165+ position: sticky; top: -10px; z-index: 1;
1166+ display: flex; align-items: center; gap: 6px;
1167+ margin: -10px -12px 8px; padding: 8px 8px 8px 12px;
1168+ background: var(--bg-panel);
1169+ border-bottom: 1px solid var(--border);
1170+ border-radius: 12px 12px 0 0;
1171+}
1172+.settings-head button.icon { flex: none; width: 22px; height: 22px; }
1173+.settings-head button.icon svg { width: 12px; height: 12px; }
1174+.settings-head .title {
1175+ flex: 1 1 auto;
1176+ font-size: 11px; font-weight: 600; letter-spacing: .02em;
3071177 }
3081178 h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
3091179 color: var(--fg-muted); margin: 12px 0 4px; font-weight: 600; }
3101180 h2:first-child { margin-top: 0; }
1181+/* The chevron points at what it opened, and turns over while it is open. */
1182+#settings-toggle svg { transition: transform 120ms ease; }
1183+#settings-toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
1184+#settings-toggle[aria-expanded="true"] { background: var(--border); color: var(--fg); }
1185+@media (prefers-reduced-motion: reduce) {
1186+ #settings-toggle svg { transition: none; }
1187+}
3111188 .hint { color: var(--fg-muted); margin: 0 0 6px; font-size: 11px; }
3121189 .row { display: flex; gap: 6px; align-items: center; }
3131190 input[type=text], input[type=password], input:not([type]), #url {
⋯ 96 unchanged lines
4101287 #font-size { font-variant-numeric: tabular-nums; font-size: 11px; min-width: 2ch;
4111288 text-align: center; color: var(--fg); }
4121289 #font-reset { padding: 4px 8px; font-size: 11px; }
413-#theme-select, #density-select { width: auto; background: var(--bg-input); color: var(--fg);
1290+#theme-select, #density-select, #tabmode-select { width: auto; background: var(--bg-input); color: var(--fg);
4141291 border: 1px solid var(--border); border-radius: 4px; padding: 5px 7px; font-size: 11px; }
modifiedextension/sidebar.html+197 −26
⋯ 8 unchanged lines
99 <body>
1010 <div id="reveal-strip"></div>
1111
12+ <!-- Two rows of tabs, nested the way tmux itself nests: sessions on top,
13+ and under them the windows of whichever session is selected. The
14+ session row is where you are; the window row is where your keystrokes
15+ go. Both are populated from the daemon's status frames, which describe
16+ the whole tmux server and not just the session we are attached to. -->
1217 <header>
18+ <!-- The rows, and beside them a corner column that spans all of them. The
19+ rows are the tmux part of the header; the corner is the panel's own
20+ hand-sized control, kept out of every row so it can be as big as a
21+ thumb needs rather than as tall as a row allows. -->
22+ <div id="rows">
23+ <div class="strip" id="session-strip">
1324 <!-- The panel's own controls, held together at the left edge so the tab
1425 strip owns the rest of the row and grows rightwards into it.
1526 Reconnect appears only when it is actually actionable; theme and
⋯ 9 unchanged lines
2536 </svg>
2637 </button>
2738
39+ <!-- A chevron rather than a menu glyph, in the same corner and with the
40+ same shape as the one Chrome hangs its tab-search popup off: what it
41+ opens is a card that floats over the terminal, so the icon says which
42+ way it goes and flips over while it is down. -->
43+ <button id="settings-toggle" class="icon" title="Settings"
44+ aria-haspopup="dialog" aria-expanded="false" aria-controls="settings">
45+ <svg viewBox="0 0 16 16" aria-hidden="true">
46+ <path d="M4 6.5l4 4 4-4" fill="none" stroke="currentColor"
47+ stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
48+ </svg>
49+ </button>
50+
51+ <!-- The socket's state in words, and only while there is no session tab
52+ to stand in for it: tabs that are live *are* the "connected" light,
53+ and a disconnect empties them and puts this back. The ↻ button beside
54+ it appears on the same condition. -->
55+ <span id="status-text">disconnected</span>
56+
57+ <!-- One tab per tmux session. Clicking one runs switch-client, which
58+ moves the client this panel already has: the terminal below stays
59+ connected and nothing is re-spawned. The count is its window count,
60+ and the glyph is the loudest thing Claude Code is doing anywhere in
61+ it — which is the reason the daemon reports every session and not
62+ only the attached one. -->
63+ <div id="sessions" role="tablist" aria-label="tmux sessions" hidden></div>
64+
65+ <button id="session-new" class="icon" hidden
66+ title="New session&#10;Drop a window here to give it a session of its own">
67+ <svg viewBox="0 0 16 16" aria-hidden="true">
68+ <path d="M3.5 8h9M8 3.5v9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
69+ </svg>
70+ </button>
71+
72+ <!-- Named inline instead of in a dialog: a modal in this panel would
73+ block the socket's message handler for as long as it is up. -->
74+ <input id="session-name" type="text" placeholder="new session" spellcheck="false"
75+ autocomplete="off" hidden />
76+
77+ </div>
78+
79+ <div class="strip" id="window-strip">
80+ <!-- The one control in the header that acts on the page rather than on
81+ anything tmux owns, and it leads the window row, immediately left of
82+ jump: the two are the pair you reach for while working in a pane —
83+ one brings the page into the pane, the other takes you to the pane
84+ that wants you — so they sit together and away from the tabs. -->
2885 <button id="pick" class="icon" title="Pick an element on the page (Esc cancels)">
2986 <svg viewBox="0 0 16 16" aria-hidden="true">
3087 <circle cx="8" cy="8" r="3.1" fill="none" stroke="currentColor" stroke-width="1.6"/>
⋯ 2 unchanged lines
3390 </svg>
3491 </button>
3592
36- <button id="settings-toggle" class="icon" title="Settings">
93+ <!-- Second in the window row, right of the picker: it walks the panes an
94+ agent is waiting in, loudest first and never the one it last sent you
95+ to. Tinted by the state it would take you to, so the button says
96+ whether there is anywhere to go before you press it. -->
97+ <button id="jump" class="icon" title="No Claude panes" disabled>
3798 <svg viewBox="0 0 16 16" aria-hidden="true">
38- <path d="M2 4.5h12M2 8h12M2 11.5h12" stroke="currentColor"
39- stroke-width="1.6" stroke-linecap="round"/>
40- <circle cx="5.5" cy="4.5" r="1.9" fill="currentColor"/>
41- <circle cx="10.5" cy="11.5" r="1.9" fill="currentColor"/>
99+ <path d="M3.4 3.6l4.2 4.4-4.2 4.4M8.4 3.6l4.2 4.4-4.2 4.4"
100+ fill="none" stroke="currentColor" stroke-width="1.6"
101+ stroke-linecap="round" stroke-linejoin="round"/>
42102 </svg>
43103 </button>
44104
45- <!-- The session the tabs belong to. Switching here moves the live tmux
46- client (switch-client), so the terminal below stays connected and
47- nothing is re-spawned. It also carries the session name that the
48- status text used to, since the tabs push that out of the row.
49-
50- Dot and select are one chip so the session reads as a control and not
51- as another tab: the chip is a recessed pill with a full border, where
52- a tab is borderless and the selected one is *lifted* onto the
53- terminal's background. Same row, opposite direction. -->
54- <div id="session-chip">
55- <span id="status" class="dot off" title="disconnected"></span>
56- <select id="session-switch" title="tmux session" hidden></select>
57- </div>
58-
59- <!-- Only while there is no session name to stand in for it. -->
60- <span id="status-text">disconnected</span>
61-
62- <!-- One tab per window in the attached session, browser-style: the active
105+ <!-- One tab per window in the selected session, browser-style: the active
63106 tab is lifted to the terminal's own background so the two read as one
64107 surface, and the dot in its favicon slot is what Claude Code is doing
65108 in that window. Clicking a tab runs select-window — the same thing
66109 `prefix 2` does in the terminal, and it moves every client watching
67- the session, because a window belongs to the session and not to us.
68- Populated from the daemon's status frames. -->
110+ the session, because a window belongs to the session and not to us. -->
69111 <div id="tabs" role="tablist" aria-label="tmux windows" hidden></div>
70112
71113 <button id="tab-new" class="icon" title="New window" hidden>
⋯ 2 unchanged lines
74116 </svg>
75117 </button>
76118
119+ </div>
120+
121+ <!-- Tab group mode only. With every window on the server in one row, the
122+ row is the wrong place to look something up — it scrolls, and past a
123+ handful of sessions what you want is off the end of it. So the row
124+ gets an address bar under it, the way a browser does: type, and the
125+ same set the tabs are drawn from is searched by name.
126+
127+ It is also where a session is made, which is why there is no second
128+ "+" beside the window one: a browser has no "new profile" button next
129+ to "new tab" either — you type the thing you want and it takes you
130+ there, or offers to create it. -->
131+ <div class="strip" id="omni-strip" hidden>
132+ <!-- An address bar, and the same two jobs: it says where you are, and it
133+ is how you go somewhere else. Where you are is the session name, and
134+ it is the box's own text rather than a label beside it — focusing
135+ selects the whole of it, so typing replaces it and Enter takes you
136+ to whatever you typed, or offers to create it.
137+
138+ Left of the caret is the button a browser puts in that same spot —
139+ what was the padlock and is now a pair of sliders — doing the same
140+ job: what the box says in a word, said properly. There it is the
141+ origin and its certificate; here it is the session, its working
142+ directory, and what is running in it.
143+
144+ The glyph is Chrome's, deliberately: this is the one control in the
145+ panel whose position is borrowed outright, and borrowing the position
146+ without the mark leaves people hunting for a padlock. The session's
147+ colour is not on it — the tab row above already says which group we
148+ are in, and a second copy of that hue in the box competes with it for
149+ the same glance. It appears again inside the panel, where it names
150+ what you have just opened. -->
151+ <div id="omni-box">
152+ <button id="omni-here" type="button" hidden
153+ aria-haspopup="dialog" aria-expanded="false"
154+ aria-label="Session info" title="Session info">
155+ <svg viewBox="0 0 16 16" aria-hidden="true">
156+ <path d="M2.2 5.5h1.6M8.2 5.5h5.6" stroke="currentColor"
157+ stroke-width="1.5" stroke-linecap="round"/>
158+ <circle cx="6" cy="5.5" r="1.9" fill="none" stroke="currentColor" stroke-width="1.5"/>
159+ <path d="M2.2 10.5h5.6M12.2 10.5h1.6" stroke="currentColor"
160+ stroke-width="1.5" stroke-linecap="round"/>
161+ <circle cx="10" cy="10.5" r="1.9" fill="none" stroke="currentColor" stroke-width="1.5"/>
162+ </svg>
163+ </button>
164+ <!-- A plain text input. Not a combobox: it does not fill itself in and
165+ there is no value to commit — what it holds is a query, and the list
166+ under it is results, so Enter runs a result rather than accepting a
167+ completion. The list announces itself instead. -->
168+ <input id="omni" type="text"
169+ aria-label="Jump to a window, name a new session, or run a command with !"
170+ placeholder="Jump, create or !run…"
171+ title="Jump to a window, session or Claude pane — type a name to create a session, or !command to run one in a new window"
172+ spellcheck="false" autocomplete="off" />
173+ <!-- The other half of what an address bar says: not just which session,
174+ but where it is. The name alone never tells you that, and it is the
175+ thing you want to know before typing a command into the box below.
176+ It rides on the right, dimmed, and steps out of the way the moment
177+ the box turns into a query — the query needs the width more than a
178+ directory you are about to leave does. -->
179+ <span id="omni-cwd" hidden></span>
180+ </div>
181+ </div>
182+ </div>
183+
184+ <!-- The corner, spanning every row there is. One control lives here, and
185+ it is the only one in the panel that is *held* rather than tapped: a
186+ finger stays on it for the length of a sentence, and sliding off it
187+ mid-word is the failure that matters. So it gets a square of its own
188+ out of the rows' reach and fills it, which is the only way a touch
189+ target in this header gets past a row's height.
190+
191+ Held, not clicked: it holds the space bar down in the pane for as long
192+ as your finger is on it, which is what Claude Code's voice mode listens
193+ for. A sidebar has no key to hold — the terminal is the thing with
194+ focus — so this is the panel's own. -->
195+ <div id="corner">
196+ <button id="talk" class="icon big" aria-pressed="false"
197+ title="Hold to talk — holds space down in the pane">
198+ <svg viewBox="0 0 16 16" aria-hidden="true">
199+ <rect x="5.7" y="1.6" width="4.6" height="8" rx="2.3"
200+ fill="none" stroke="currentColor" stroke-width="1.5"/>
201+ <path d="M3.4 7.4a4.6 4.6 0 0 0 9.2 0" fill="none" stroke="currentColor"
202+ stroke-width="1.5" stroke-linecap="round"/>
203+ <path d="M8 12.1v2.3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
204+ </svg>
205+ </button>
206+
207+ <!-- No Enter button beside the mic: letting go of it is the send. The
208+ button used to be here because a voice message that cannot be sent is
209+ not worth dictating, and the keyboard is exactly what your hand is off
210+ when it is on the mic — but the hold now queues the Return itself, so
211+ the second button was pressing a key that had already been pressed. -->
212+ </div>
77213 </header>
78214
215+ <!-- Outside the header and positioned over the terminal: the header is a
216+ flex column that sizes the terminal, so a list that grew inside it
217+ would reflow the terminal on every keystroke. Deliberately not a
218+ <dialog> and nothing modal — a modal in this panel would block the
219+ socket's message handler for as long as it is up. -->
220+ <div id="omni-list" aria-live="polite" aria-label="Matches" hidden></div>
221+
79222 <div id="picked" hidden>
80223 <div class="picked-top">
81224 <span id="picked-tag" class="chip"></span>
⋯ 24 unchanged lines
106249
107250 <div id="term"></div>
108251
109- <aside id="settings" hidden>
252+ <!-- A card hung off the chevron rather than a drawer at the foot of the
253+ panel — the shape Chrome gives the popup on the other end of that same
254+ chevron. Positioned out of flow deliberately: as a flex item it sized
255+ the terminal, so every open and close re-fit xterm.js and reflowed the
256+ scrollback. Nothing modal about it, for the reason a modal here can't
257+ be: it would block the socket's message handler while it is up. -->
258+ <aside id="settings" role="dialog" aria-label="Settings" hidden>
259+ <div class="settings-head">
260+ <span class="title">Settings</span>
261+ <button id="settings-close" class="icon" title="Close" aria-label="Close settings">
262+ <svg viewBox="0 0 16 16" aria-hidden="true">
263+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor"
264+ stroke-width="1.6" stroke-linecap="round"/>
265+ </svg>
266+ </button>
267+ </div>
268+
110269 <h2>Pair this extension</h2>
111270 <p class="hint">Run this once, then restart the daemon:</p>
112271 <pre id="pair-cmd">loading…</pre>
⋯ 20 unchanged lines
133292 </div>
134293 <p class="hint" id="density-hint"></p>
135294
295+ <!-- Two ways to lay out the same tmux server. Nested keeps the two rows
296+ this panel was built around; groups folds them into one, the way
297+ Chrome folds many tabs into a coloured group chip. -->
298+ <div class="row" id="tabmode-row">
299+ <select id="tabmode-select" title="How sessions and windows are laid out">
300+ <option value="nested">Nested tabs</option>
301+ <option value="groups">Tab groups</option>
302+ </select>
303+ </div>
304+ <p class="hint" id="tabmode-hint"></p>
305+
136306 <div class="row" id="font-row">
137307 <span class="hint">Font size</span>
138308 <button id="font-smaller" class="icon" title="Smaller (Ctrl+Alt+- or Ctrl+wheel)">
⋯ 37 unchanged lines
176346 <script src="lib/theme.js"></script>
177347 <script src="lib/sanitize.js"></script>
178348 <script src="lib/shot.js"></script>
349+ <script src="lib/split.js"></script>
179350 <script src="picker.js"></script>
180351 <script src="vendor/xterm.js"></script>
181352 <script src="vendor/addon-fit.js"></script>
⋯ 3 unchanged lines
modifiedextension/sidebar.js+3936 −445
⋯ 2 unchanged lines
33 // Owns the WebSocket directly. There is deliberately no relay through the
44 // service worker: `runtime.sendMessage` is reachable from content scripts, so
55 // routing terminal data through it would create exactly the bridge that lets a
6-// hostile page reach the shell. This document has no inbound message listeners.
6+// hostile page reach the shell. What does arrive from the worker — a finished
7+// pick, and the toggle shortcut's two window commands — is checked to have come
8+// from the extension itself, and none of it reaches the socket.
79
810 // One of the two always exists — this file only ever runs as sidebar.html — but
911 // neither is declared unconditionally, so say which.
⋯ 14 unchanged lines
2426 const $input = (id) => /** @type {HTMLInputElement} */ ($(id));
2527 /** @param {string} id @returns {HTMLSelectElement} */
2628 const $select = (id) => /** @type {HTMLSelectElement} */ ($(id));
29+/** @param {string} id @returns {HTMLButtonElement} */
30+const $button = (id) => /** @type {HTMLButtonElement} */ ($(id));
2731
2832 const logEl = $("log");
2933 /** @param {string} m */
⋯ 55 unchanged lines
8589 applyDensity();
8690 }
8791
92+/* --- tab modes --------------------------------------------------------------
93+ Two ways to lay out one tmux server, and the same status frame feeds both.
94+
95+ "nested" is the panel's original shape and tmux's own: sessions on top, the
96+ selected session's windows underneath. You always see one session's worth of
97+ windows, and the two rows say which is which.
98+
99+ "groups" folds that into a single row, the way Chrome folds tabs into a
100+ group: every window on the server is in the row, each session's run of them
101+ introduced by a coloured chip, and a chip you are not using collapses to
102+ nothing but itself. It buys back a row of terminal and puts a window in
103+ another session one click away instead of two — at the cost of a row that is
104+ as long as the whole server rather than as long as one session.
105+
106+ The mode is this panel's, not the server's: nothing here is sent to tmux, and
107+ two panels on the same server can be in different modes. */
108+/** @type {Record<string, { cls: string, hint: string }>} */
109+const TAB_MODES = {
110+ nested: { cls: "", hint: "Sessions on top, the selected session's windows below." },
111+ groups: { cls: "groups", hint: "One row: every window, grouped by session. Click a chip to fold a group away." },
112+};
113+let tabMode = "nested";
114+
115+/**
116+ * The header's controls belong to whichever row exists, so switching modes
117+ * moves them rather than duplicating them: in groups mode there is only one
118+ * row, and everything the session row was holding has to land in it.
119+ */
120+function applyTabMode() {
121+ const m = TAB_MODES[tabMode] ?? TAB_MODES.nested;
122+ document.body.classList.toggle("groups", tabMode === "groups");
123+ $select("tabmode-select").value = tabMode;
124+ $("tabmode-hint").textContent = m.hint;
125+
126+ const top = $("session-strip");
127+ const row = $("window-strip");
128+ if (tabMode === "groups") {
129+ // One "+" in the tab row, and it means what the only "+" in a browser's tab
130+ // strip means: another window, here. Making a *session* is a different kind
131+ // of thing and it moves to the omnibar, where it is a named act rather than
132+ // a second identical button beside the first — which is the whole reason
133+ // there were two icons and no way to tell them apart.
134+ row.prepend($("reconnect"), $("settings-toggle"));
135+ row.insertBefore($("status-text"), $("tabs"));
136+ // The name field comes with it: "+" holds the menu that makes a session
137+ // now, and the field it opens has to be in a row that is on screen.
138+ row.append($("tab-new"), $("session-name"));
139+ // Picker and jump stay the pair they are in the nested layout, and follow
140+ // jump into the row below — controls that act on a pane rather than on the
141+ // tabs, at the start of the row you type into. Enter is not in either row:
142+ // it lives under the mic, in the corner beside every row, and so never
143+ // moves with the layout.
144+ $("omni-strip").prepend($("pick"), $("jump"));
145+ $("omni-strip").hidden = false;
146+ // The session row is gone, so what it was showing has to go with it: a
147+ // stale session tab left in a hidden strip still answers `querySelector`
148+ // and would keep the spinner's timer alive on windows nobody can see.
149+ $("sessions").textContent = "";
150+ $("sessions").dataset.sig = "";
151+ // Both of these stay behind in the hidden strip; the omnibar is how a
152+ // session gets named now.
153+ hideSessionInput();
154+ top.hidden = true;
155+ } else {
156+ top.hidden = false;
157+ top.prepend($("reconnect"), $("settings-toggle"));
158+ top.insertBefore($("status-text"), $("sessions"));
159+ top.append($("session-new"), $("session-name"));
160+ // Picker then jump lead the window row, in that order: both act on a pane
161+ // rather than on the tabs.
162+ row.prepend($("pick"), $("jump"));
163+ row.append($("tab-new"));
164+ $("omni-strip").hidden = true;
165+ closeOmni();
166+ // The dot goes with the row, so anything hanging off it has to go too.
167+ if (infoOpen) closeTabMenu();
168+ }
169+
170+ // Neither row's signature describes the other's contents, so both have to be
171+ // told that what they are holding is not what they should be holding.
172+ $("tabs").dataset.sig = "";
173+ $("sessions").dataset.sig = "";
174+ renderHeader(lastSessions, sessionName, lastAgents);
175+ // renderHeader only reaches the omnibar through the groups branch, and this
176+ // has to be right in the frame the mode changes rather than a second later.
177+ syncOmniHere();
178+
179+ // One row instead of two: the terminal has a different number of rows now.
180+ if (termReady) {
181+ fit.fit();
182+ sendSize();
183+ }
184+}
185+
186+/** @param {string} value */
187+function setTabMode(value) {
188+ tabMode = TAB_MODES[value] ? value : "nested";
189+ storage.set({ tabMode });
190+ applyTabMode();
191+}
192+
88193 // Below ~8px xterm.js's glyph atlas stops being legible; above ~32 a sidebar
89194 // holds too few columns to be a terminal.
90195 const FONT_DEFAULT = 12;
⋯ 1 unchanged line
92197 const FONT_MAX = 32;
93198 let fontSize = FONT_DEFAULT;
94199
200+// The Terminal's own `lineHeight`, named because touch scrolling needs it too:
201+// `fontSize * LINE_HEIGHT` is a row's height in CSS pixels, which is how far a
202+// finger travels per line of scroll.
203+const LINE_HEIGHT = 1.2;
204+
95205 function applyFontSize() {
96206 $("font-size").textContent = String(fontSize);
97207 if (!termReady) return;
⋯ 15 unchanged lines
113223 const FONT_STEP = { "=": 1, "+": 1, "-": -1, _: -1 };
114224
115225 /**
116- * Ctrl+Alt rather than plain Ctrl: Ctrl+= / Ctrl+- / Ctrl+0 are browser zoom
117- * accelerators, which pages cannot cancel, so binding them would zoom the whole
118- * panel instead. Ctrl+wheel *is* cancelable, so that gesture works as expected.
226+ * The panel's own Ctrl+Alt keys: the font size, and the omnibar.
119227 *
120- * Returns true if the event was a font-size command and has been handled.
228+ * Ctrl+Alt rather than plain Ctrl throughout. Ctrl+= / Ctrl+- / Ctrl+0 are
229+ * browser zoom accelerators, which pages cannot cancel, so binding them would
230+ * zoom the whole panel instead; Ctrl+L is the browser's address bar and, in a
231+ * terminal, clear-screen. Ctrl+wheel *is* cancelable, so that gesture works as
232+ * expected.
233+ *
234+ * Returns true if the event was one of ours and has been handled.
121235 *
122236 * @param {KeyboardEvent} e
123237 */
124-function handleFontKey(e) {
238+function handlePanelKey(e) {
125239 if (!e.ctrlKey || !e.altKey || e.metaKey) return false;
126240 const delta = FONT_STEP[e.key];
127241 if (delta) setFontSize(fontSize + delta);
⋯ 20 unchanged lines
148262 fontFamily:
149263 'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace',
150264 fontSize: FONT_DEFAULT,
151- lineHeight: 1.2,
265+ lineHeight: LINE_HEIGHT,
152266 cursorBlink: true,
153267 scrollback: 5000,
154268 theme: Themes.resolveTheme("auto", darkQuery.matches).xterm,
⋯ 10 unchanged lines
165279 // Returning false keeps the keystroke out of the pty.
166280 term.attachCustomKeyEventHandler((e) => {
167281 if (e.type !== "keydown") return true;
168- return !handleFontKey(e);
282+ return !handlePanelKey(e);
169283 });
170284
171285 $("term").addEventListener(
⋯ 6 unchanged lines
178292 { passive: false },
179293 );
180294
295+// --- touch scrolling --------------------------------------------------------
296+//
297+// xterm.js has no touch handling of its own: its mouse layer binds `mousedown`,
298+// `mousemove`, `mouseup` and `wheel`, and the browser only synthesises those
299+// from a *tap*. A drag is claimed as a pan gesture and never reaches the
300+// terminal at all. The one thing that would have scrolled without any help —
301+// `.xterm-viewport` is an `overflow-y: scroll` box — is always empty here,
302+// because tmux sits on the alternate screen, repaints in place, and keeps the
303+// scrollback on its own side.
304+//
305+// So a drag is turned back into wheel events rather than into escape sequences.
306+// A wheel event is the signal every layer downstream already agrees on: tmux
307+// with `mouse on` encodes it into copy-mode, an inner vim or less that turned
308+// mouse reporting on gets the report it expects, and xterm.js's own fallback
309+// still converts it to cursor keys when nothing asked for either. Writing SGR
310+// reports here instead would mean tracking mouse mode ourselves and being wrong
311+// about it every time an application changed it behind our back.
312+//
313+// Scrolling is all a touch does. Selection stays on the mouse: a drag has to
314+// mean one or the other, and on a panel this narrow scrolling is the gesture
315+// worth having.
316+
317+/** How far a finger may wander before a tap counts as a drag, in CSS pixels. */
318+const TOUCH_SLOP = 8;
319+
320+/** The finger being followed, or null when no drag is in progress. */
321+let touchId = /** @type {number | null} */ (null);
322+let touchStartY = 0;
323+/** Last position charged to `touchRest`. */
324+let touchY = 0;
325+/** Travel not yet worth a whole line, kept so a slow drag still scrolls. */
326+let touchRest = 0;
327+/** Whether the finger has passed TOUCH_SLOP and owns the gesture. */
328+let touchScrolling = false;
329+
330+/**
331+ * Hand xterm.js a wheel event it cannot tell from a real one.
332+ *
333+ * The mouse protocol emits one report per event and reads the cell under the
334+ * pointer out of `clientX`/`clientY`, so the finger's own position is carried
335+ * through and a multi-line step is sent a line at a time. `DOM_DELTA_LINE` with
336+ * a delta of one is xterm's own definition of a single line, which keeps this
337+ * clear of its pixel-mode accumulator.
338+ *
339+ * The position is clamped into `.xterm-screen` first. xterm resolves a report's
340+ * cell against that element and reports nothing at all when the point falls
341+ * outside it, and a finger can easily sit on `#term`'s padding or over the
342+ * scrollbar column — close enough to be scrolling, far enough to report
343+ * nothing. Clamping costs a cell of accuracy at the very edge and buys a
344+ * gesture that works everywhere on the panel.
345+ *
346+ * @param {Touch} t
347+ * @param {number} lines signed, in terminal rows
348+ */
349+function touchWheel(t, lines) {
350+ const screen = term.element?.querySelector(".xterm-screen");
351+ // Both of xterm's wheel listeners live on the `.xterm` root, so dispatching
352+ // on the viewport underneath it reaches them by bubbling and would also reach
353+ // a listener the viewport grew later.
354+ const target = term.element?.querySelector(".xterm-viewport") ?? term.element;
355+ if (!screen || !target) return;
356+
357+ const box = screen.getBoundingClientRect();
358+ const clientX = Math.min(box.right - 1, Math.max(box.left, t.clientX));
359+ const clientY = Math.min(box.bottom - 1, Math.max(box.top, t.clientY));
360+
361+ const step = Math.sign(lines);
362+ for (let i = 0; i < Math.abs(lines); i++) {
363+ target.dispatchEvent(
364+ new WheelEvent("wheel", {
365+ deltaY: step,
366+ deltaMode: WheelEvent.DOM_DELTA_LINE,
367+ clientX,
368+ clientY,
369+ bubbles: true,
370+ cancelable: true,
371+ }),
372+ );
373+ }
374+}
375+
376+$("term").addEventListener(
377+ "touchstart",
378+ (e) => {
379+ // A second finger cancels rather than joins: a pinch is not a scroll, and
380+ // the font size is still Ctrl+wheel's.
381+ if (e.touches.length !== 1) {
382+ touchId = null;
383+ return;
384+ }
385+ const t = e.touches[0];
386+ touchId = t.identifier;
387+ touchStartY = t.clientY;
388+ touchY = t.clientY;
389+ touchRest = 0;
390+ touchScrolling = false;
391+ // Deliberately not prevented: the tap still has to become a click, which is
392+ // what focuses the terminal and what an application in mouse mode reports.
393+ },
394+ { passive: true },
395+);
396+
397+$("term").addEventListener(
398+ "touchmove",
399+ (e) => {
400+ if (touchId === null) return;
401+ let t = /** @type {Touch | null} */ (null);
402+ for (const c of e.changedTouches) if (c.identifier === touchId) t = c;
403+ if (!t) return;
404+
405+ if (!touchScrolling) {
406+ if (Math.abs(t.clientY - touchStartY) < TOUCH_SLOP) return;
407+ // Start the accounting from here, so the slop is spent rather than
408+ // scrolled: the text should not jump by TOUCH_SLOP the moment it engages.
409+ touchScrolling = true;
410+ touchY = t.clientY;
411+ }
412+
413+ // Dragging the content, not the viewport. A finger moving up drags the text
414+ // up, which shows what comes after it — a positive wheel delta.
415+ touchRest += touchY - t.clientY;
416+ touchY = t.clientY;
417+ e.preventDefault();
418+
419+ // One line per row of travel, so the text keeps up with the finger.
420+ const cell = fontSize * LINE_HEIGHT;
421+ const lines = Math.trunc(touchRest / cell);
422+ if (!lines) return;
423+ touchRest -= lines * cell;
424+ touchWheel(t, lines);
425+ },
426+ { passive: false },
427+);
428+
429+for (const type of ["touchend", "touchcancel"]) {
430+ $("term").addEventListener(type, () => {
431+ touchId = null;
432+ touchScrolling = false;
433+ });
434+}
435+
181436 // Set only once term *and* fit are usable — applyTheme/applyDensity both touch
182437 // them, and a flag that runs ahead of the objects it guards is worse than none.
183438 termReady = true;
⋯ 12 unchanged lines
196451 // in tabs, and "+" would have nothing to create.
197452 let tmuxMode = false;
198453
454+// The session the daemon falls back to when nothing names one — `default`
455+// unless it was started with --session. This panel treats it as the home
456+// session: it is where "+" puts a window when it is not adding to a particular
457+// group, and it is drawn without a colour of its own, the way Chrome leaves
458+// ungrouped tabs plain. The daemon names it on the ok frame; the fallback here
459+// only covers the moment before that arrives.
460+//
461+// It is not otherwise privileged: tmux has no notion of a special session, so
462+// it can be renamed, killed or dragged like any other, and if it does not exist
463+// the first "+" creates it.
464+let defaultSession = "default";
465+
466+/**
467+ * Machines the omnibar can offer to connect to, from the daemon's reading of
468+ * `~/.ssh/config` and `~/.ssh/known_hosts`.
469+ *
470+ * On the ok frame rather than the status frames because it is a fact about
471+ * files on disk, not about what tmux is doing: it does not change while a panel
472+ * is open, and a reconnect is what picks up an edited config.
473+ *
474+ * A connection is an ordinary window running ssh on the daemon's machine, so
475+ * nothing else in this panel treats these differently from anywhere else —
476+ * they are a source of names for one kind of row, and that is all.
477+ * @type {string[]}
478+ */
479+let sshHosts = [];
480+
199481 /**
200- * @param {"on"|"off"|"pending"} state drives the dot's colour class
482+ * The header says what the socket is doing only when it is doing something
483+ * other than working: a connected panel has session tabs, which report the
484+ * connection by existing, and there is nothing a green light adds to that.
485+ *
486+ * @param {"on"|"off"|"pending"} state whether reconnect is worth offering
201487 * @param {string} text
202488 */
203489 function setStatus(state, text) {
204- $("status").className = `dot ${state}`;
205- $("status").title = text;
206490 $("status-text").textContent = text;
207491 $("reconnect").hidden = state === "on";
208492 }
⋯ 24 unchanged lines
233517 function tokenProblem(text) {
234518 $("token-hint").textContent = `${text} ${TOKEN_HELP}`;
235519 $("token-hint").hidden = false;
236- $("settings").hidden = false;
520+ openSettings();
237521 term.write(
238522 `\r\n\x1b[33m ${text}\x1b[0m\r\n` +
239523 " Open \x1b[1msettings\x1b[0m (top right) and set the Token.\r\n" +
⋯ 57 unchanged lines
297581 const want = $input("session").value.trim() || undefined;
298582 // Optimistic; the daemon's first status frame replaces this with what
299583 // tmux actually attached us to, usually within a second.
584+ if (msg.defaultSession) defaultSession = msg.defaultSession;
585+ sshHosts = msg.hosts ?? [];
586+ // Directories the daemon described to a previous connection. Cheap to
587+ // ask again, and a reconnect is the one moment we know nothing about
588+ // what has happened on that machine in the meantime.
589+ pathAnswers.clear();
590+ pathAsking.clear();
300591 sessionName = want ?? msg.defaultSession ?? msg.profile;
301592 refreshStatus();
302- // Session names only at this point — the window list needs the control
593+ // Session names only at this point — the window lists need the control
303594 // channel, which the first status frame brings a moment later.
304- renderSessionSwitch(
305- (msg.sessions ?? []).map((name) => ({ name, windows: 0, attached: false })),
595+ renderHeader(
596+ (msg.sessions ?? []).map((name) => ({ id: "", name, attached: false, windows: [] })),
306597 sessionName,
598+ [],
307599 );
308600 log(`authenticated, running ${msg.profile}`);
309601 fit.fit();
⋯ 7 unchanged lines
317609 ...(want ? { session: want } : {}),
318610 }),
319611 );
320- $("settings").hidden = true;
612+ closeSettings();
321613 term.focus();
322614 } else if (msg.type === "status") {
323615 if (msg.session && msg.session !== sessionName) {
324616 sessionName = msg.session;
325617 refreshStatus();
326618 }
327- renderSessionSwitch(msg.sessions ?? [], msg.session);
328- renderTabs(msg.windows ?? [], msg.agents ?? []);
619+ // One frame describes the whole server: sessions for the top row, the
620+ // selected session's own windows for the second, and every agent in
621+ // any of them — a session tab reports the Claude in a session you
622+ // cannot see, which is the point of knowing about all of them.
623+ const sessions = msg.sessions ?? [];
624+ const agents = msg.agents ?? [];
625+ // A frame that cannot say which session we are on — the moment mid
626+ // switch-client, before tmux has answered for our client — is not news
627+ // that we are on none. Falling for that empties the window row, which
628+ // takes a row of height with it and reflows the terminal underneath:
629+ // the flash. The last session we were told about stands until another
630+ // one is named.
631+ const current = msg.session ?? sessionName;
632+ renderHeader(sessions, current, agents);
633+ } else if (msg.type === "path") {
634+ // An answer to something the omnibar asked about a directory. Keyed by
635+ // the query, so one that arrives after the box has moved on is filed
636+ // rather than acted on — see `askPath`.
637+ takePathAnswer(msg);
329638 } else if (msg.type === "tmux-error") {
330639 // tmux refused the command (session gone, pane closed). The next status
331640 // frame already shows the truth, so this only needs to explain itself.
⋯ 7 unchanged lines
339648 if (/token|auth/i.test(msg.reason)) {
340649 tokenProblem("The daemon rejected this token.");
341650 } else if (/origin/i.test(msg.reason)) {
342- $("settings").hidden = false;
651+ openSettings();
343652 term.write(
344653 `\r\n\x1b[33m ${msg.reason}\x1b[0m\r\n` +
345654 " Open \x1b[1msettings\x1b[0m and run the pairing command shown there.\r\n",
⋯ 4 unchanged lines
350659 }
351660 // Raw terminal bytes. xterm.js takes Uint8Array directly, so nothing is
352661 // decoded, re-encoded, or mangled on the way in.
353- term.write(new Uint8Array(ev.data));
354- };
662+ term.write(new Uint8Array(ev.data));
663+ };
664+
665+ sock.onerror = () => {
666+ // The browser hides the reason from JS by design. The daemon's stdout has
667+ // the real one (unpaired origin / bad token / rate limited).
668+ log("socket error — check the daemon's output for the reason");
669+ // Firefox's HTTPS-Only Mode silently rewrites ws:// to wss://, so the most
670+ // likely cause here is that our self-signed certificate isn't trusted yet.
671+ $("trust-hint").hidden = false;
672+ };
673+
674+ sock.onclose = (ev) => {
675+ connected = false;
676+ // The socket that was carrying the held key is gone, so the hold is over
677+ // whether or not the pointer knows it yet — and the Return it queued has
678+ // nowhere to land, so it goes too rather than arriving on the next socket.
679+ stopTalk();
680+ cancelTalkSubmit();
681+ sessionName = null;
682+ tmuxMode = false;
683+ renderHeader([], null, []);
684+ refreshStatus();
685+ log(`closed code=${ev.code} ${ev.reason || ""}`);
686+ };
687+}
688+
689+// Ask the daemon to move tmux. The daemon accepts three commands and validates
690+// every argument; the sidebar cannot name a tmux command of its own.
691+/**
692+ * @param {{ cmd: "switch"|"create"|"focus"|"select-window"|"goto-window"|"new-window"
693+ * |"kill-window"|"move-window"|"move-window-to-session"|"new-session-with-window"
694+ * |"set-session-color"|"rename-session"|"claude"|"run"|"ssh"
695+ * |"new-project" }
696+ * & Record<string, string | boolean>} body
697+ */
698+function tmuxCommand(body) {
699+ if (!connected || !ws) return;
700+ ws.send(JSON.stringify({ type: "tmux", ...body }));
701+}
702+
703+// --- the header -------------------------------------------------------------
704+//
705+// One entry point for both layouts, because one status frame feeds both and
706+// which of them is on screen is a preference rather than anything the frame
707+// says. Everything below this line renders from the same three arguments.
708+
709+/**
710+ * The last frame's sessions, kept for the repaints nothing on the wire causes:
711+ * folding a group, pinning a tab, switching mode. Windows and agents have the
712+ * same reason to be kept — see `lastWindows`.
713+ * @type {TbSessionInfo[]}
714+ */
715+let lastSessions = [];
716+
717+/**
718+ * @param {TbSessionInfo[]} sessions every session on the server
719+ * @param {string | null | undefined} current the one this panel's client is on
720+ * @param {TbAgent[]} agents server-wide
721+ */
722+function renderHeader(sessions, current, agents) {
723+ lastSessions = sessions;
724+ lastAgents = agents;
725+ // Before the early return below: the jump button is in whichever row exists,
726+ // and what it says comes from the agents rather than from either layout.
727+ syncJump();
728+ if (tabMode === "groups") {
729+ renderGroups(sessions, current, agents);
730+ return;
731+ }
732+ renderSessionTabs(sessions, current, agents);
733+ // A frame that names no session leaves the window row on what it had — see
734+ // the note at the call site about the flash.
735+ const here = sessions.find((s) => s.name === current);
736+ renderTabs(here?.windows ?? lastWindows, agents);
737+}
738+
739+// --- building elements ------------------------------------------------------
740+//
741+// Everything the header draws is built rather than written out, and by hand it
742+// is five lines of `createElement`, `className`, `textContent`, `setAttribute`
743+// and `appendChild` for every span. `el` is those five lines, and the reason it
744+// is a function here rather than a template library is the same reason the
745+// extension has no bundler: it ships as plain files, and nothing in
746+// node_modules reaches dist/.
747+//
748+// It is deliberately not a renderer. There is no diffing, no keys and no state
749+// — the callers below build a fresh subtree and swap it in, exactly as they did
750+// before, and the signature checks in renderTabs/renderSessionTabs are still
751+// what decides whether that happens at all.
752+//
753+// One rule it enforces by having no way around it: text arrives as `text` or as
754+// a child string, both of which end up in `textContent`. Nothing here accepts
755+// markup, because most of what it draws is a tmux name — a window title a shell
756+// sets from whatever it is running, which is as page-influenced as any other
757+// terminal output.
758+
759+/**
760+ * @typedef {Node | string | null | undefined | false} TbChild A child to append,
761+ * or a falsy value to skip — so a conditional part of a subtree can be an
762+ * expression rather than an `if` with an `appendChild` in it.
763+ */
764+
765+/**
766+ * @typedef {object} TbElOpts
767+ * @property {string} [class] Written as `class` rather than `className`: these
768+ * read as markup at the call sites, and every one of them builds the string
769+ * with the same conditional template.
770+ * @property {string} [text] textContent. Never markup — see above.
771+ * @property {string} [title] The tooltip. `tip()` is what builds most of them.
772+ * @property {Record<string, string | number | boolean>} [attrs] Set with
773+ * setAttribute, so `role`, `aria-*` and `type` are written the way the DOM
774+ * spells them. Values are stringified, which is all `aria-selected` ever
775+ * wanted from `String(...)`.
776+ * @property {Record<string, string>} [data] dataset entries.
777+ * @property {Record<string, string>} [css] Custom properties, set with
778+ * setProperty — `--group-h` and nothing else so far.
779+ * @property {Record<string, (e: any) => void>} [on] Listeners by event name.
780+ */
781+
782+/**
783+ * @template {keyof HTMLElementTagNameMap} K
784+ * @param {K} tag
785+ * @param {TbElOpts} [opts]
786+ * @param {...TbChild} children
787+ * @returns {HTMLElementTagNameMap[K]}
788+ */
789+function el(tag, opts = {}, ...children) {
790+ const node = document.createElement(tag);
791+ if (opts.class) node.className = opts.class;
792+ if (opts.text != null) node.textContent = opts.text;
793+ if (opts.title != null) node.title = opts.title;
794+ for (const [k, v] of Object.entries(opts.attrs ?? {})) node.setAttribute(k, String(v));
795+ for (const [k, v] of Object.entries(opts.data ?? {})) node.dataset[k] = v;
796+ for (const [k, v] of Object.entries(opts.css ?? {})) node.style.setProperty(k, v);
797+ for (const [k, v] of Object.entries(opts.on ?? {})) node.addEventListener(k, v);
798+ for (const c of children) if (c) node.append(c);
799+ return node;
800+}
801+
802+/**
803+ * A button, which is every other element here. `type="button"` because the
804+ * default is `submit` and a stray Enter on one of these inside a form would
805+ * reload the panel.
806+ *
807+ * @param {TbElOpts} [opts]
808+ * @param {...TbChild} children
809+ */
810+function button(opts = {}, ...children) {
811+ return el("button", { ...opts, attrs: { type: "button", ...opts.attrs } }, ...children);
812+}
813+
814+/**
815+ * The favicon slot, and the one piece of markup that repeats across every kind
816+ * of row the panel draws: a tab, a chip, an omnibar row, a line in the session
817+ * panel. All of them report the same four states with the same glyphs, and the
818+ * working one has to be the spinner's current frame rather than a fixed
819+ * character — see the spinner section for why it is read at build time.
820+ *
821+ * Empty for "none", deliberately: a plain window of shell should not wear a
822+ * status light it has no state to report.
823+ *
824+ * @param {TbAgentState | "none" | undefined} state
825+ * @param {string} [cls] extra classes, for the rows that are not a favicon slot
826+ */
827+function glyphSpan(state, cls) {
828+ const s = state ?? "none";
829+ return el("span", {
830+ class: `glyph ${s}${cls ? ` ${cls}` : ""}`,
831+ text: s === "working" ? spinnerGlyph() : (STATIC_GLYPH[s] ?? ""),
832+ attrs: { "aria-hidden": "true" },
833+ });
834+}
835+
836+/**
837+ * A multi-line tooltip from parts, any of which may be missing. Everything in
838+ * the header has one: the rows are narrow enough that the detail — which tool
839+ * is in flight, how many panes, the name a pinned tab gave up — has nowhere
840+ * else to go.
841+ *
842+ * @param {...(string | false | null | undefined)} lines
843+ */
844+function tip(...lines) {
845+ return lines.filter(Boolean).join("\n");
846+}
847+
848+// --- window tabs ------------------------------------------------------------
849+//
850+// A tab is a window of the attached session — the thing `prefix 2` selects, not
851+// a session and not a pane. Window names come from tmux (a shell sets them from
852+// whatever it is running, so they are as page-influenced as any other terminal
853+// output) and only ever reach the DOM through textContent.
854+
855+// --- pinning ----------------------------------------------------------------
856+//
857+// A pin is a property of this panel, not of the session. tmux is never asked to
858+// renumber anything: the window keeps its real index, so `prefix 4` still goes
859+// where it always went, and nothing about your terminal's status line changes.
860+// All a pin does is move the tab to the front of *this* strip and shrink it to
861+// its dot and index, which is what buys the room in a narrow sidebar.
862+//
863+// Keyed by session name, because window ids are only meaningful inside the tmux
864+// server that issued them and only prunable against the session we can see.
865+/** @type {Record<string, string[]>} */
866+let pins = {};
867+
868+// The last frame's worth of windows, so a pin — which changes nothing on the
869+// wire and so produces no status frame — can repaint the strip on its own.
870+/** @type {TbWindowInfo[]} */
871+let lastWindows = [];
872+/** @type {TbAgent[]} */
873+let lastAgents = [];
874+
875+/**
876+ * Takes the session rather than reading `sessionName`: in groups mode the strip
877+ * holds windows from every session at once, so "which session's pins" is a
878+ * question each tab answers for itself.
879+ *
880+ * @param {string | null | undefined} session
881+ * @returns {Set<string>} pinned window ids in that session
882+ */
883+function pinnedIds(session) {
884+ return new Set((session && pins[session]) || []);
885+}
886+
887+/**
888+ * Whether a window may be offered a ✕.
889+ *
890+ * Killing a session's last window kills the session, and that used to take this
891+ * panel with it — so the ✕ was withheld for it. It no longer does: the daemon
892+ * sets `detach-on-destroy off` on every session the client lands on (see
893+ * `ensure_detach_on_destroy` in daemon/src/server.rs), and tmux answers a
894+ * destroyed session by moving the client to another one rather than detaching.
895+ *
896+ * What is left is the genuinely last thing on the server. With nothing to fall
897+ * back to tmux does detach, the pty hits EOF and the panel goes dead — so that
898+ * one window, and only it, still gets no ✕.
899+ *
900+ * The old rule was written when the window row only ever held one session's
901+ * windows, where "the last window here" and "the last window anywhere" looked
902+ * the same. Groups mode is what pulled them apart: it shows every session at
903+ * once, so a one-window session sat next to five others with no ✕ on it and no
904+ * reason the eye could see.
905+ *
906+ * @param {number} windowsInSession
907+ * @param {number} sessionsOnServer
908+ */
909+function windowClosable(windowsInSession, sessionsOnServer) {
910+ return windowsInSession > 1 || sessionsOnServer > 1;
911+}
912+
913+/** @param {string | null | undefined} session @param {Set<string>} ids */
914+function savePins(session, ids) {
915+ if (!session) return;
916+ if (ids.size) pins[session] = [...ids];
917+ else delete pins[session];
918+ storage.set({ pins });
919+}
920+
921+/**
922+ * Pinned first, each group still in tmux's own index order — nothing is
923+ * reordered on the server, so the indexes stay the ground truth they are.
924+ *
925+ * Also prunes: a window that closed takes its pin with it, and this is the one
926+ * moment we hold a session's full window list to notice that by.
927+ *
928+ * @param {string | null | undefined} session
929+ * @param {TbWindowInfo[]} windows that session's windows, in index order
930+ * @returns {{ ordered: TbWindowInfo[], pinned: Set<string> }}
931+ */
932+function orderWindows(session, windows) {
933+ const pinned = pinnedIds(session);
934+ const live = new Set(windows.map((w) => w.id));
935+ let dropped = false;
936+ for (const id of pinned) {
937+ if (!live.has(id)) {
938+ pinned.delete(id);
939+ dropped = true;
940+ }
941+ }
942+ if (dropped) savePins(session, pinned);
943+ return {
944+ ordered: [
945+ ...windows.filter((w) => pinned.has(w.id)),
946+ ...windows.filter((w) => !pinned.has(w.id)),
947+ ],
948+ pinned,
949+ };
950+}
951+
952+/**
953+ * Repaint the window strip after something only this panel knows about — a pin,
954+ * a folded group. Nothing changed on the wire, so no frame is coming to trigger
955+ * the rebuild, and the signature has to be cleared or it would skip it.
956+ */
957+function repaintTabs() {
958+ $("tabs").dataset.sig = "";
959+ if (tabMode === "groups") renderGroups(lastSessions, sessionName, lastAgents);
960+ else renderTabs(lastWindows, lastAgents);
961+}
962+
963+/**
964+ * @param {TbWindowInfo[]} windows in index order
965+ * @param {TbAgent[]} agents so a tab can say what Claude is doing in it
966+ */
967+function renderTabs(windows, agents) {
968+ lastWindows = windows;
969+ lastAgents = agents;
970+ const strip = $("tabs");
971+ // A repaint mid-drag would tear the tab out from under the pointer; the move
972+ // it commits to brings a fresh frame of its own a moment later.
973+ if (dragging && !strip.hidden) return;
974+ const show = connected && tmuxMode && windows.length > 0;
975+ $("tab-new").hidden = !(connected && tmuxMode && sessionName);
976+ // Reset, not decoration: the groups layout points this same button at the
977+ // home session and says so, and switching back would otherwise leave that
978+ // promise on a button that no longer keeps it.
979+ $("tab-new").title =
980+ (sessionName ? `New window in ${sessionName}` : "New window") + SPLIT_HINT + HOLD_HINT;
981+ strip.hidden = !show;
982+ // The hairline between the two rows belongs to the session row, and only
983+ // while there is a window row under it to be separated from.
984+ document.body.classList.toggle("has-windows", show);
985+ if (!show) {
986+ strip.textContent = "";
987+ strip.dataset.sig = "";
988+ syncSpinner();
989+ return;
990+ }
991+
992+ const { ordered, pinned } = orderWindows(sessionName, windows);
993+
994+ // Repainting drops hover and any focus ring; status frames arrive on every
995+ // tmux notification and once a second besides.
996+ const claude = agentByWindow(agents);
997+ const closable = windowClosable(windows.length, lastSessions.length);
998+ // `closable` is in the signature because it is the one thing drawn here that
999+ // does not come from this session's windows: killing the *other* session
1000+ // changes whether this session's last window may be closed, and nothing in
1001+ // the window list moves when that happens. Left out, the strip keeps a ✕ that
1002+ // would now take the whole server down with it.
1003+ const sig = JSON.stringify([
1004+ closable,
1005+ ordered.map((w) => {
1006+ const a = claude[w.id];
1007+ return [w.id, w.index, w.name, w.active, w.activity, a?.state, agentLabel(a), pinned.has(w.id)];
1008+ }),
1009+ ]);
1010+ if (strip.dataset.sig !== sig) {
1011+ strip.dataset.sig = sig;
1012+ strip.textContent = "";
1013+ for (const w of ordered) {
1014+ strip.appendChild(
1015+ windowTab(w, {
1016+ claude: claude[w.id],
1017+ closable,
1018+ pinned: pinned.has(w.id),
1019+ lastInSession: windows.length === 1,
1020+ owner: sessionName ?? "",
1021+ // One session's windows, and it is the one we are on, so tmux's own
1022+ // "active" is exactly the tab you are looking at.
1023+ current: w.active,
1024+ }),
1025+ );
1026+ }
1027+ }
1028+
1029+ syncSpinner();
1030+
1031+ const active = strip.querySelector('[aria-selected="true"]');
1032+ // Sidebars are narrow enough that the window you are on can be scrolled out
1033+ // of the strip entirely.
1034+ if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
1035+}
1036+
1037+// Loudest first: a window with something waiting on you outranks one that is
1038+// merely busy, and both outrank one that is only still. The two loud states are
1039+// the two that want you — blocked mid-turn, or done with one you have not seen
1040+// — so they sort above the two that don't. A window with no Claude in it gets
1041+// no entry at all.
1042+const AGENT_RANK = ["waiting", "ready", "working", "idle", "unknown"];
1043+
1044+/**
1045+ * @param {TbAgent[]} agents
1046+ * @returns {Record<string, TbAgent>} window id → the one worth reporting
1047+ */
1048+function agentByWindow(agents) {
1049+ /** @type {Record<string, TbAgent>} */
1050+ const out = {};
1051+ for (const a of agents) {
1052+ const seen = out[a.window_id];
1053+ if (!seen || AGENT_RANK.indexOf(a.state) < AGENT_RANK.indexOf(seen.state)) {
1054+ out[a.window_id] = a;
1055+ }
1056+ }
1057+ return out;
1058+}
1059+
1060+/* --- jump to the next pane that wants you ----------------------------------
1061+ One button that walks the panes an agent is in, so cycling between agents is
1062+ a repeated tap rather than a hunt through two rows of tabs. It is the tab
1063+ strip's loudness order with one change: `working` drops below `idle`. A pane
1064+ mid-turn is the one place there is nothing for you to do, and the whole point
1065+ of the button is to land somewhere you can act — so it is offered last, when
1066+ there is nothing else, rather than not at all.
1067+
1068+ Within a state the order is the tab rows' own — session, then window, then
1069+ pane — so pressing repeatedly walks the server the way you read it, and it
1070+ wraps.
1071+
1072+ The pane you are on is never a destination: the daemon marks it `here`, and it
1073+ drops out of the walk entirely. That is what makes the button work at the one
1074+ moment you most want it — the agent in front of you finishing. Its own turn
1075+ ending puts it at rest, at rest is the loudest thing on the server while
1076+ everything else is mid-turn, and cycling "the loudest tier" would hand you
1077+ back the pane you are already looking at. `lastJumped` still skips a step
1078+ inside a tier, because the frame that will mark the new pane `here` is up to a
1079+ second behind the press. */
1080+const JUMP_RANK = ["waiting", "ready", "idle", "unknown", "working"];
1081+
1082+// The pane this button last sent the client to, so a second press inside the
1083+// same tier goes somewhere new even before the frame that says `here` lands.
1084+let lastJumped = "";
1085+
1086+/**
1087+ * Every pane with an agent in it *other than the one on screen*, loudest first
1088+ * — see `JUMP_RANK`.
1089+ * @param {TbAgent[]} agents
1090+ * @returns {TbAgent[]}
1091+ */
1092+function jumpOrder(agents) {
1093+ // tmux's own window order, which is the index and not the name: the tab rows
1094+ // are drawn in it, so walking the panes in any other order would jump about
1095+ // the strip you are looking at. A window we have no frame for sorts last
1096+ // rather than first — an agent whose window has just gone is not where the
1097+ // next press should land.
1098+ /** @type {Record<string, number>} */
1099+ const at = {};
1100+ for (const s of lastSessions) for (const w of s.windows) at[w.id] = w.index;
1101+ return agents
1102+ .filter((a) => a.pane && !a.here)
1103+ .slice()
1104+ .sort(
1105+ (x, y) =>
1106+ JUMP_RANK.indexOf(x.state) - JUMP_RANK.indexOf(y.state) ||
1107+ x.session.localeCompare(y.session) ||
1108+ (at[x.window_id] ?? Infinity) - (at[y.window_id] ?? Infinity) ||
1109+ x.pane.localeCompare(y.pane, undefined, { numeric: true }),
1110+ );
1111+}
1112+
1113+/**
1114+ * The pane the jump button would go to next, or null when the only agent on the
1115+ * server is the one already on screen — or there is none at all. Only the
1116+ * loudest state is cycled: with something waiting on you somewhere, an idle pane
1117+ * is not the next stop.
1118+ *
1119+ * @param {TbAgent[]} agents
1120+ * @returns {TbAgent | null}
1121+ */
1122+function nextJump(agents) {
1123+ const order = jumpOrder(agents);
1124+ if (!order.length) return null;
1125+ const loudest = JUMP_RANK.indexOf(order[0].state);
1126+ const tier = order.filter((a) => JUMP_RANK.indexOf(a.state) === loudest);
1127+ // -1 — the last jump was to some other tier, or nowhere — starts at the top,
1128+ // which is what `+ 1` makes of it.
1129+ const at = tier.findIndex((a) => a.pane === lastJumped);
1130+ return tier[(at + 1) % tier.length];
1131+}
1132+
1133+/**
1134+ * Say what the button would do before it is pressed: the state it would take
1135+ * you to is its colour, and where that is is its tooltip. Disabled when there is
1136+ * nowhere else to go — a button that goes nowhere should not look pressable, and
1137+ * "the only Claude is this one" is a different nowhere from "there are none".
1138+ */
1139+function syncJump() {
1140+ const btn = $button("jump");
1141+ const next = nextJump(lastAgents);
1142+ btn.disabled = !next;
1143+ btn.dataset.state = next?.state ?? "";
1144+ btn.title = next
1145+ ? `Jump to ${next.session} · ${next.window} — claude ${next.state}\n${agentLabel(next)}`
1146+ : lastAgents.some((a) => a.pane)
1147+ ? "The only Claude pane is this one"
1148+ : "No Claude panes";
1149+}
1150+
1151+$("jump").addEventListener("click", () => {
1152+ const next = nextJump(lastAgents);
1153+ if (!next) return;
1154+ lastJumped = next.pane;
1155+ tmuxCommand({ cmd: "focus", pane: next.pane });
1156+ term.focus();
1157+ // The status frame that follows redraws the button, but the tooltip should
1158+ // already point at the pane *after* this one by the time the click ends.
1159+ syncJump();
1160+});
1161+
1162+/**
1163+ * A slot rather than a bare button, because the close ✕ has to be a sibling:
1164+ * a button inside a button is invalid markup and unreachable to a screen
1165+ * reader, and closing a window is not selecting it.
1166+ *
1167+ * @param {TbWindowInfo} w
1168+ * @param {object} opts
1169+ * @param {TbAgent} [opts.claude] the agent worth reporting in this window
1170+ * @param {boolean} [opts.closable]
1171+ * @param {boolean} [opts.pinned]
1172+ * @param {string} [opts.owner] the session this window belongs to
1173+ * @param {boolean} [opts.current] this is the window the panel is looking at
1174+ * @param {boolean} [opts.lastInSession] the only window its session has left
1175+ */
1176+function windowTab(w, { claude, closable, pinned: isPinned, owner, current, lastInSession } = {}) {
1177+ // A pinned tab drops its name and its ✕, the same two things Chrome takes
1178+ // away: it is down to a dot and a number, and it cannot be closed by a
1179+ // mis-aimed click. The name is still in the tooltip.
1180+ const canClose = closable && !isPinned;
1181+ // `w.active` is per session, so in groups mode every session contributes one
1182+ // — the current window *of a session you are not on*. That is worth marking,
1183+ // the way a session tab marks "attached elsewhere", but it is not the tab you
1184+ // are on and must not be drawn as one.
1185+ const elsewhere = w.active && !current;
1186+
1187+ const tab = button(
1188+ {
1189+ // Activity is tmux's own "something happened here while you were away",
1190+ // and it is the whole reason a background tab is worth looking at.
1191+ class: `tab${!w.active && w.activity ? " activity" : ""}`,
1192+ attrs: { role: "tab", "aria-selected": !!current },
1193+ // The session is read back by the drag commit, which has to know which
1194+ // group a dropped tab came out of, and by the click below.
1195+ data: { window: w.id, ...(owner ? { session: owner } : {}) },
1196+ // With no chip row left, the tooltip is where the detail lives: which tool
1197+ // is in flight, what Claude is blocked on, and — for a pinned tab — the
1198+ // name it gave up to fit.
1199+ title: tip(
1200+ owner && owner !== sessionName ? `${owner}: ${w.name}` : `window ${w.index}: ${w.name}`,
1201+ w.panes > 1 && `${w.panes} panes`,
1202+ claude && `claude ${claude.state} — ${agentLabel(claude)}`,
1203+ claude?.title,
1204+ !w.active && w.activity && "activity",
1205+ elsewhere && "current window of that session",
1206+ isPinned && "pinned — right-click to unpin",
1207+ ),
1208+ on: {
1209+ click: () => {
1210+ selectWindow(w, owner);
1211+ term.focus();
1212+ },
1213+ },
1214+ },
1215+ glyphSpan(claude?.state),
1216+ // The index is not on the tab. tmux's own status line has it, `prefix 2`
1217+ // needs it and nothing here does: a tab is clicked, not counted to, and in a
1218+ // sidebar the number was taking room from the one thing that identifies the
1219+ // window — its name. It is still in the tooltip.
1220+ //
1221+ // The exception is a pinned tab, which has given its name up to shrink: the
1222+ // index is what is left to tell two pinned tabs apart.
1223+ isPinned
1224+ ? el("span", { class: "index", text: String(w.index) })
1225+ : el("span", { class: "name", text: w.name }),
1226+ );
1227+
1228+ const slot = el(
1229+ "div",
1230+ {
1231+ class:
1232+ `tab-slot${current ? " active" : ""}${canClose ? " closable" : ""}` +
1233+ `${isPinned ? " pinned" : ""}${elsewhere ? " elsewhere" : ""}`,
1234+ on: {
1235+ /** @param {MouseEvent} e */
1236+ contextmenu: (e) => {
1237+ e.preventDefault();
1238+ openTabMenu(e, w, {
1239+ pinned: !!isPinned,
1240+ closable: !!closable,
1241+ lastInSession: !!lastInSession,
1242+ owner,
1243+ current: !!current,
1244+ });
1245+ },
1246+ },
1247+ },
1248+ tab,
1249+ canClose && closeButton(w, lastInSession ? owner : undefined),
1250+ );
1251+ // The slot and not the tab: the ✕ is its sibling, and a drag that started on
1252+ // the tab alone would leave the ✕ behind.
1253+ makeDraggable(slot);
1254+ return slot;
1255+}
1256+
1257+/**
1258+ * Two commands for one gesture, and which one it is depends on whether the tab
1259+ * is in the session this panel's client is on.
1260+ *
1261+ * Inside it, `select-window`: selecting a window is a property of the session,
1262+ * so it moves anyone else watching that session too — exactly as pressing
1263+ * prefix-2 in the terminal does, and deliberately so.
1264+ *
1265+ * Outside it — only reachable in groups mode, where the row spans the server —
1266+ * `goto-window`, which does that *and* brings our own client along. Without the
1267+ * second half the click would select a window in a session we are not looking
1268+ * at, and the terminal would not move at all.
1269+ *
1270+ * @param {TbWindowInfo} w
1271+ * @param {string} [owner] the session the window belongs to
1272+ */
1273+function selectWindow(w, owner) {
1274+ if (owner && owner !== sessionName) tmuxCommand({ cmd: "goto-window", window: w.id });
1275+ else if (!w.active) tmuxCommand({ cmd: "select-window", window: w.id });
1276+}
1277+
1278+// --- tab context menu -------------------------------------------------------
1279+//
1280+// Built rather than native: an extension page gets the browser's own menu here,
1281+// which has nothing to say about tmux windows. Deliberately not a <dialog> or
1282+// anything modal — a modal in this panel would block the socket's message
1283+// handler while it is up.
1284+
1285+let openMenu = /** @type {HTMLElement | null} */ (null);
1286+
1287+/**
1288+ * Set while a group menu's name field is open, so closing the menu commits what
1289+ * was typed in it. See `renameRow`.
1290+ * @type {(() => void) | null}
1291+ */
1292+let pendingRename = null;
1293+
1294+function closeTabMenu() {
1295+ const commit = pendingRename;
1296+ pendingRename = null;
1297+ commit?.();
1298+ openMenu?.remove();
1299+ openMenu = null;
1300+ // The session info panel shares this machinery, and the dot it hangs off has
1301+ // to stop claiming it is open.
1302+ if (infoOpen) {
1303+ infoOpen = false;
1304+ $("omni-here").setAttribute("aria-expanded", "false");
1305+ }
1306+}
1307+
1308+/**
1309+ * One line of a menu. Every one of them does the same two things in the same
1310+ * order — dismiss the menu, then act — because a menu still standing over the
1311+ * thing it just changed is the wrong half of the gesture.
1312+ *
1313+ * @param {HTMLElement} menu
1314+ * @param {string} label
1315+ * @param {() => void} run
1316+ */
1317+function menuItem(menu, label, run) {
1318+ const b = button({
1319+ text: label,
1320+ attrs: { role: "menuitem" },
1321+ on: {
1322+ click: () => {
1323+ closeTabMenu();
1324+ run();
1325+ },
1326+ },
1327+ });
1328+ menu.appendChild(b);
1329+ return b;
1330+}
1331+
1332+/**
1333+ * @param {MouseEvent} e
1334+ * @param {TbWindowInfo} w
1335+ * @param {object} opts
1336+ * @param {boolean} opts.pinned
1337+ * @param {boolean} opts.closable
1338+ * @param {string} [opts.owner]
1339+ * @param {boolean} [opts.current]
1340+ * @param {boolean} [opts.lastInSession]
1341+ */
1342+function openTabMenu(e, w, { pinned: isPinned, closable, owner, current, lastInSession }) {
1343+ closeTabMenu();
1344+ const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
1345+ /** @param {string} label @param {() => void} run */
1346+ const item = (label, run) => menuItem(menu, label, run);
1347+
1348+ item(isPinned ? "Unpin" : "Pin", () => {
1349+ const ids = pinnedIds(owner);
1350+ if (isPinned) ids.delete(w.id);
1351+ else ids.add(w.id);
1352+ savePins(owner, ids);
1353+ repaintTabs();
1354+ });
1355+
1356+ if (!current) {
1357+ item(owner && owner !== sessionName ? `Go to ${owner}` : "Select", () => {
1358+ selectWindow(w, owner);
1359+ term.focus();
1360+ });
1361+ }
1362+
1363+ if (closable) {
1364+ const kill = item(lastInSession && owner ? `Close window and ${owner}` : "Close window", () => {
1365+ tmuxCommand({ cmd: "kill-window", window: w.id });
1366+ log(`killed window ${w.index} (${w.name})`);
1367+ term.focus();
1368+ });
1369+ kill.className = "danger";
1370+ }
1371+
1372+ document.body.appendChild(menu);
1373+ placeMenu(menu, e);
1374+}
1375+
1376+/**
1377+ * Put a menu that is already in the document under the pointer, and make it the
1378+ * one open menu.
1379+ *
1380+ * Placed after measuring, so a menu opened near an edge folds back inside
1381+ * rather than off the panel.
1382+ *
1383+ * @param {HTMLElement} menu
1384+ * @param {{ clientX: number, clientY: number }} e
1385+ */
1386+function placeMenu(menu, e) {
1387+ const r = menu.getBoundingClientRect();
1388+ menu.style.left = `${Math.min(e.clientX, Math.max(0, window.innerWidth - r.width - 4))}px`;
1389+ menu.style.top = `${Math.min(e.clientY, Math.max(0, window.innerHeight - r.height - 4))}px`;
1390+ openMenu = menu;
1391+
1392+ // Any click that isn't on the menu, anywhere, dismisses it.
1393+ setTimeout(() => {
1394+ window.addEventListener("pointerdown", onDismiss, { once: true, capture: true });
1395+ }, 0);
1396+}
1397+
1398+/** @param {Event} e */
1399+function onDismiss(e) {
1400+ if (openMenu && e.target instanceof Node && openMenu.contains(e.target)) return;
1401+ closeTabMenu();
1402+}
1403+
1404+/**
1405+ * @param {TbWindowInfo} w
1406+ * @param {string} [lastOf] the session this is the only window of, if it is —
1407+ * closing it takes that session with it, which the label has to say
1408+ */
1409+function closeButton(w, lastOf) {
1410+ const label = lastOf
1411+ ? `Close window ${w.index}: ${w.name} — last one, so this closes ${lastOf} too`
1412+ : `Close window ${w.index}: ${w.name}`;
1413+ return button(
1414+ {
1415+ class: "tab-close",
1416+ title: label,
1417+ attrs: { "aria-label": label },
1418+ on: {
1419+ /** @param {MouseEvent} e */
1420+ click: (e) => {
1421+ // The tab underneath would otherwise read this as "select me".
1422+ e.stopPropagation();
1423+ tmuxCommand({ cmd: "kill-window", window: w.id });
1424+ // The one destructive thing in the panel, and tmux has no undo for it,
1425+ // so it at least leaves a record of what went.
1426+ log(`killed window ${w.index} (${w.name})`);
1427+ term.focus();
1428+ },
1429+ },
1430+ },
1431+ strokeIcon("M4 4l8 8M12 4l-8 8"),
1432+ );
1433+}
1434+
1435+const SVG_NS = "http://www.w3.org/2000/svg";
1436+
1437+/**
1438+ * The header's icons are inline SVG rather than unicode glyphs, which render at
1439+ * wildly different weights depending on the platform's fallback font. The ones
1440+ * built here are the same, just built rather than written out.
1441+ *
1442+ * @param {string} d
1443+ */
1444+function strokeIcon(d) {
1445+ const svg = document.createElementNS(SVG_NS, "svg");
1446+ svg.setAttribute("viewBox", "0 0 16 16");
1447+ svg.setAttribute("aria-hidden", "true");
1448+ const path = document.createElementNS(SVG_NS, "path");
1449+ path.setAttribute("d", d);
1450+ path.setAttribute("stroke", "currentColor");
1451+ path.setAttribute("stroke-width", "2");
1452+ path.setAttribute("stroke-linecap", "round");
1453+ svg.appendChild(path);
1454+ return svg;
1455+}
1456+
1457+/**
1458+ * Open a window in a session and go to it.
1459+ *
1460+ * A new window needs no name: tmux names it after whatever it runs, and renames
1461+ * it as you cd around. So this is one click and no prompt.
1462+ *
1463+ * A session that does not exist yet cannot be given a window — but making it
1464+ * *is* making the window, since `create` is `new-session -A`, which comes up
1465+ * with one. That is the case the home session hits on a fresh tmux server,
1466+ * where "+" is the first thing that ever names it.
1467+ *
1468+ * @param {string | null} session
1469+ */
1470+function newWindowIn(session) {
1471+ if (!session) return;
1472+ if (lastSessions.some((s) => s.name === session)) {
1473+ tmuxCommand({ cmd: "new-window", session });
1474+ } else {
1475+ tmuxCommand({ cmd: "create", session });
1476+ }
1477+}
1478+
1479+/**
1480+ * Which session the row's "+" adds to.
1481+ *
1482+ * Nested, the row *is* one session's windows, so a window added anywhere else
1483+ * would not appear in it — "+" adds here, as it always has.
1484+ *
1485+ * Groups, the row spans the server, so it can show a window wherever it lands.
1486+ * It goes to the home session, which is the split Chrome makes: its "+" opens
1487+ * an ungrouped tab rather than another tab in whichever group you happen to be
1488+ * reading, and a group's own menu is how you add to that group.
1489+ *
1490+ * Unless the server is holding exactly one session — then that one, whatever it
1491+ * is called. With a single group there is no ambiguity for "+" to resolve, and
1492+ * answering it with `default` would spend a second group on one window and
1493+ * split the work across two. It is the same judgement the daemon makes when it
1494+ * adopts a sole existing session rather than creating its own beside it (see
1495+ * `adopt_sole_session` in daemon/src/server.rs).
1496+ *
1497+ * @returns {string | null}
1498+ */
1499+function newWindowTarget() {
1500+ if (tabMode !== "groups") return sessionName;
1501+ if (lastSessions.length === 1) return lastSessions[0].name;
1502+ return defaultSession;
1503+}
1504+
1505+// --- "+" as more than a window ----------------------------------------------
1506+//
1507+// A click on a browser's "+" makes a tab; holding it, or right-clicking it,
1508+// offers the other thing the strip can hold. Here that other thing is a tmux
1509+// session — a tab group — and this is where it gets made, so the row never
1510+// needs a second icon beside the first that looks the same and does something
1511+// else. The plain click is untouched: it still opens a window, immediately.
1512+//
1513+// The menu is the same one every tab and chip in the row uses, so it dismisses
1514+// the same way and only one of them is ever up.
1515+
1516+/** How long "+" has to be held before the menu is what the press meant. */
1517+const NEW_HOLD_MS = 450;
1518+
1519+let newHold = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined);
1520+/** Set when a hold has opened the menu, so the click that ends the press
1521+ doesn't also open a window behind it. */
1522+let newHeld = false;
1523+
1524+/** @param {{ clientX: number, clientY: number }} e */
1525+function openNewMenu(e) {
1526+ closeTabMenu();
1527+ const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
1528+ const target = newWindowTarget() ?? defaultSession;
1529+ menuItem(menu, `New window in ${target}`, () => {
1530+ newWindowIn(target);
1531+ term.focus();
1532+ });
1533+ // Named rather than immediate, unlike the window: a session's name is the
1534+ // only handle a terminal gives you on it. See "new session" below.
1535+ menuItem(menu, "New session…", showSessionInput);
1536+ document.body.appendChild(menu);
1537+ placeMenu(menu, e);
1538+}
1539+
1540+function cancelNewHold() {
1541+ clearTimeout(newHold);
1542+ newHold = undefined;
1543+}
1544+
1545+{
1546+ const btn = $("tab-new");
1547+ btn.addEventListener("click", () => {
1548+ // The hold already answered this press.
1549+ if (newHeld) {
1550+ newHeld = false;
1551+ return;
1552+ }
1553+ newWindowIn(newWindowTarget());
1554+ term.focus();
1555+ });
1556+
1557+ btn.addEventListener("pointerdown", (e) => {
1558+ const ev = /** @type {PointerEvent} */ (e);
1559+ // Right button is the contextmenu event's, not the hold's.
1560+ if (ev.button !== 0) return;
1561+ newHeld = false;
1562+ cancelNewHold();
1563+ newHold = setTimeout(() => {
1564+ newHold = undefined;
1565+ newHeld = true;
1566+ openNewMenu(ev);
1567+ }, NEW_HOLD_MS);
1568+ });
1569+ // A press that ends, moves off the button, or is taken away by the browser
1570+ // (a touch turning into a scroll) is not a hold.
1571+ for (const type of ["pointerup", "pointerleave", "pointercancel"]) {
1572+ btn.addEventListener(type, cancelNewHold);
1573+ }
1574+
1575+ btn.addEventListener("contextmenu", (e) => {
1576+ e.preventDefault();
1577+ // On touch the browser fires this at the end of its own long press, by
1578+ // which point our hold has already put the menu up.
1579+ if (newHeld) return;
1580+ cancelNewHold();
1581+ openNewMenu(/** @type {MouseEvent} */ (e));
1582+ });
1583+}
1584+
1585+// --- push to talk -----------------------------------------------------------
1586+//
1587+// Claude Code's voice mode is push-to-talk: you hold space in the pane and it
1588+// records until you let go. There is no key to hold from here — the terminal is
1589+// what has focus, and a sidebar button is a click, not a hold — so this button
1590+// holds it on your behalf for as long as the pointer is down on it.
1591+//
1592+// What "holding a key" *is*, at the far end of a pty, is autorepeat: the press
1593+// puts one byte on the wire and the keyboard driver keeps putting the same byte
1594+// there, after a delay, at a steady rate, until the key comes up. A pty carries
1595+// no key-up event and no notion of a key being down, so reproducing the stream
1596+// is the whole of reproducing the hold. The two constants below are the X11
1597+// defaults, which is what a Linux terminal on the other end would have sent.
1598+//
1599+// If the agent on the other end turns out to want explicit press/release events
1600+// instead — the kitty keyboard protocol reports both, where plain mode cannot —
1601+// then TALK_PRESS/TALK_RELEASE are the only two things that change.
1602+const TALK_DELAY_MS = 500;
1603+const TALK_INTERVAL_MS = 33;
1604+
1605+// Letting go of the button ends the recording, and what you almost always want
1606+// next is to send it — but not always: often the thought is not finished and the
1607+// button goes down again. So the submit is deferred rather than immediate, and a
1608+// second press inside the window cancels it. The transcript stays one prompt
1609+// across as many holds as it takes, and the pause that ends it is the same
1610+// gesture as pausing before hitting Return.
1611+const TALK_SUBMIT_MS = 1000;
1612+
1613+/** Bytes for the press, each repeat, the release, and the deferred submit. */
1614+const TALK_PRESS = " ";
1615+const TALK_RELEASE = "";
1616+// Carriage return and not a newline: that is the byte a terminal's Return key
1617+// puts on the wire, and what line-editing readers — a shell, Claude Code's
1618+// prompt — are waiting for. `\n` would be Ctrl+J, which some of them treat as a
1619+// literal newline in the buffer instead of a submit.
1620+const TALK_SUBMIT = "\r";
1621+
1622+/** @type {number | undefined} */
1623+let talkDelay;
1624+/** @type {number | undefined} */
1625+let talkRepeat;
1626+/** @type {number | undefined} */
1627+let talkSubmit;
1628+let talking = false;
1629+
1630+/**
1631+ * Drop a submit that has not fired yet. Called when the button goes down again —
1632+ * there is more to say — and whenever the panel loses the connection the submit
1633+ * was going to travel over, so a reconnect does not inherit a stray Return.
1634+ */
1635+function cancelTalkSubmit() {
1636+ clearTimeout(talkSubmit);
1637+ talkSubmit = undefined;
1638+ $("talk").classList.remove("pending");
1639+}
1640+
1641+/** @param {string} bytes */
1642+function talkSend(bytes) {
1643+ if (!bytes || !connected || !ws) return false;
1644+ ws.send(enc.encode(bytes));
1645+ return true;
1646+}
1647+
1648+function startTalk() {
1649+ if (talking) return;
1650+ // Before the connection check: a press that cannot record still means "I am
1651+ // not done", and the queued Return would land after it.
1652+ cancelTalkSubmit();
1653+ if (!connected || !ws) {
1654+ log("not connected");
1655+ return;
1656+ }
1657+ talking = true;
1658+ const btn = $("talk");
1659+ btn.classList.add("talking");
1660+ btn.setAttribute("aria-pressed", "true");
1661+
1662+ talkSend(TALK_PRESS);
1663+ // The gap before autorepeat kicks in, then the repeat itself. A key held
1664+ // briefly sends exactly one byte, which is what makes a quick tap on this
1665+ // button a plain space rather than a burst of them.
1666+ talkDelay = setTimeout(() => {
1667+ talkRepeat = setInterval(() => {
1668+ if (!talkSend(TALK_PRESS)) stopTalk();
1669+ }, TALK_INTERVAL_MS);
1670+ }, TALK_DELAY_MS);
1671+}
1672+
1673+function stopTalk() {
1674+ if (!talking) return;
1675+ talking = false;
1676+ clearTimeout(talkDelay);
1677+ clearInterval(talkRepeat);
1678+ talkDelay = talkRepeat = undefined;
1679+ talkSend(TALK_RELEASE);
1680+ const btn = $("talk");
1681+ btn.classList.remove("talking");
1682+ btn.setAttribute("aria-pressed", "false");
1683+ btn.style.setProperty("--talk-submit", `${TALK_SUBMIT_MS}ms`);
1684+ // Off and on again, so a second hold inside the window restarts the fade
1685+ // rather than continuing the old one from wherever it had got to.
1686+ btn.classList.remove("pending");
1687+ void btn.offsetWidth;
1688+ btn.classList.add("pending");
1689+ talkSubmit = setTimeout(() => {
1690+ talkSubmit = undefined;
1691+ btn.classList.remove("pending");
1692+ talkSend(TALK_SUBMIT);
1693+ }, TALK_SUBMIT_MS);
1694+}
1695+
1696+{
1697+ const btn = $("talk");
1698+ btn.addEventListener("pointerdown", (e) => {
1699+ e.preventDefault();
1700+ // Capture, so a finger or cursor that slides off the button still ends the
1701+ // hold on *this* element. Without it the pointerup lands somewhere else and
1702+ // the key is held down forever, which in a terminal is not a small bug.
1703+ btn.setPointerCapture(/** @type {PointerEvent} */ (e).pointerId);
1704+ startTalk();
1705+ });
1706+ for (const type of ["pointerup", "pointercancel", "lostpointercapture"]) {
1707+ btn.addEventListener(type, stopTalk);
1708+ }
1709+ // A click is a hold that already ended; the pointer handlers own both edges.
1710+ btn.addEventListener("click", (e) => e.preventDefault());
1711+ btn.addEventListener("contextmenu", (e) => e.preventDefault());
1712+
1713+ // Keyboard: the same hold, from a focused button. `repeat` is the browser's
1714+ // own autorepeat on *our* key, which would restart nothing but is not a
1715+ // second press either.
1716+ btn.addEventListener("keydown", (e) => {
1717+ const ev = /** @type {KeyboardEvent} */ (e);
1718+ if (ev.repeat || (ev.key !== " " && ev.key !== "Enter")) return;
1719+ e.preventDefault();
1720+ startTalk();
1721+ });
1722+ btn.addEventListener("keyup", (e) => {
1723+ const ev = /** @type {KeyboardEvent} */ (e);
1724+ if (ev.key !== " " && ev.key !== "Enter") return;
1725+ e.preventDefault();
1726+ stopTalk();
1727+ });
1728+ btn.addEventListener("blur", stopTalk);
1729+}
1730+
1731+// Nothing may outlive the gesture: a panel that loses the window mid-hold has
1732+// no way to see the release, and a stuck key would keep typing into the pane.
1733+window.addEventListener("blur", stopTalk);
1734+document.addEventListener("visibilitychange", () => {
1735+ if (document.hidden) stopTalk();
1736+});
1737+
1738+// --- dragging tabs ----------------------------------------------------------
1739+//
1740+// Both rows reorder by drag, and the two commit to different places: a window
1741+// drag is a real `move-window` on the server, because tmux has an order for
1742+// windows and the terminal's own status line has to agree with ours. Sessions
1743+// have no such thing — tmux lists them alphabetically and offers nothing to
1744+// renumber — so that order is this panel's, saved in extension storage.
1745+//
1746+// The drop itself is the same either way: the dragged element moves through the
1747+// row live, and the commit reads the row's final DOM order.
1748+
1749+/** The element being dragged, while a drag is in flight. */
1750+let dragging = /** @type {HTMLElement | null} */ (null);
1751+
1752+/**
1753+ * @param {HTMLElement} el the element the pointer picks up
1754+ */
1755+function makeDraggable(el) {
1756+ el.draggable = true;
1757+ el.addEventListener("dragstart", (e) => {
1758+ dragging = el;
1759+ el.classList.add("dragging");
1760+ const dt = /** @type {DragEvent} */ (e).dataTransfer;
1761+ if (!dt) return;
1762+ dt.effectAllowed = "move";
1763+ // Firefox starts no drag at all without data on the transfer. Nothing
1764+ // reads it: the element being moved is `dragging`, and a drop from outside
1765+ // this row is ignored below.
1766+ dt.setData("text/plain", "");
1767+ });
1768+ el.addEventListener("dragend", () => {
1769+ el.classList.remove("dragging");
1770+ dragging = null;
1771+ });
1772+}
1773+
1774+/**
1775+ * Wire a row as a drop target. `commit` runs once, on drop, with the row's DOM
1776+ * already in the order the pointer left it in.
1777+ *
1778+ * @param {HTMLElement} strip
1779+ * @param {string} sel selector for that row's draggable items
1780+ * @param {(moved: HTMLElement) => void} commit
1781+ */
1782+function dropZone(strip, sel, commit) {
1783+ strip.addEventListener("dragover", (e) => {
1784+ // A drag that started somewhere else — the other row, or another page
1785+ // entirely — is not a reorder of this one.
1786+ if (!dragging || dragging.parentElement !== strip) return;
1787+ e.preventDefault();
1788+ const x = /** @type {DragEvent} */ (e).clientX;
1789+ // The first item whose midpoint is past the pointer is the one the dragged
1790+ // tab belongs in front of; none means the pointer is past them all.
1791+ const before =
1792+ [...strip.querySelectorAll(sel)]
1793+ .filter((el) => el !== dragging)
1794+ .find((el) => x < el.getBoundingClientRect().left + el.getBoundingClientRect().width / 2) ??
1795+ null;
1796+ if (before !== dragging.nextElementSibling) strip.insertBefore(dragging, before);
1797+ });
1798+ strip.addEventListener("drop", (e) => {
1799+ if (!dragging || dragging.parentElement !== strip) return;
1800+ e.preventDefault();
1801+ commit(dragging);
1802+ });
1803+}
1804+
1805+dropZone($("sessions"), ".session-tab", () => {
1806+ saveSessionOrder(
1807+ [...$("sessions").querySelectorAll(".session-tab")].map(
1808+ (el) => /** @type {HTMLElement} */ (el).dataset.session ?? "",
1809+ ),
1810+ );
1811+ term.focus();
1812+});
1813+
1814+dropZone($("tabs"), ".tab-slot", (slot) => {
1815+ // Expressed against a neighbour rather than an index: `move-window -a/-b`
1816+ // renumbers whatever has to move, so there is no free index to find and no
1817+ // window to overwrite. The next status frame brings the new indexes back.
1818+ //
1819+ // In groups mode the neighbours are found the same way, but the walk stops at
1820+ // a chip: the tab either side of a group boundary is in a different session,
1821+ // and landing "after" it would silently move the window out of the group the
1822+ // pointer left it in. Where the walk finds nothing — a group with no other
1823+ // window in it — the session itself is the target instead.
1824+ const moved = windowIdOf(slot);
1825+ if (!moved) return;
1826+ const grouped = tabMode === "groups";
1827+ const after = windowIdOf(neighbour(slot, "previousElementSibling"));
1828+ const before = windowIdOf(neighbour(slot, "nextElementSibling"));
1829+ if (after) tmuxCommand({ cmd: "move-window", window: moved, target: after, after: true });
1830+ else if (before) tmuxCommand({ cmd: "move-window", window: moved, target: before });
1831+ else if (grouped) {
1832+ const group = groupOf(slot);
1833+ if (group && group !== sessionOf(slot)) {
1834+ tmuxCommand({ cmd: "move-window-to-session", window: moved, session: group });
1835+ }
1836+ }
1837+ term.focus();
1838+});
1839+
1840+/**
1841+ * The tab next to this one, in the given direction, stopping at a group
1842+ * boundary. Chips only exist in groups mode, so in the nested layout this is
1843+ * just the adjacent sibling.
1844+ *
1845+ * @param {Element} slot
1846+ * @param {"previousElementSibling" | "nextElementSibling"} dir
1847+ * @returns {Element | null}
1848+ */
1849+function neighbour(slot, dir) {
1850+ for (let el = slot[dir]; el; el = el[dir]) {
1851+ if (el.classList.contains("group-chip")) return null;
1852+ if (el.classList.contains("tab-slot")) return el;
1853+ }
1854+ return null;
1855+}
1856+
1857+/**
1858+ * Which group a dropped tab landed in: the nearest chip above it in the row.
1859+ * @param {Element} slot
1860+ * @returns {string} session name, or "" if the row has no chips
1861+ */
1862+function groupOf(slot) {
1863+ for (let el = slot.previousElementSibling; el; el = el.previousElementSibling) {
1864+ if (el.classList.contains("group-chip")) {
1865+ return /** @type {HTMLElement} */ (el).dataset.session ?? "";
1866+ }
1867+ }
1868+ return "";
1869+}
1870+
1871+// --- dragging a tab onto "+" ------------------------------------------------
1872+//
1873+// Chrome's other tab gesture: drag a tab out of the strip and it becomes a
1874+// window of its own. The tmux answer is a session of its own, and "+" is where
1875+// it lands — the button that already means "another one of these", now also
1876+// meaning "another one of these, holding this".
1877+//
1878+// Both "+"s take it, because which one is on screen is a layout question: the
1879+// nested layout's session row has its own, and groups mode has a single "+" in
1880+// the tab row and no session row at all.
1881+//
1882+// The session is not prompted for. A drag is one gesture and a name field in
1883+// the middle of it would be a second one — so the window's own name becomes the
1884+// session's, deduped against what is already on the server, and the chip's menu
1885+// renames it after the fact like any other session.
1886+
1887+/** Second line of both "+" tooltips: the gesture has no affordance until a tab
1888+ is already in the air, so the button is where it gets announced. */
1889+const SPLIT_HINT = "\nDrop a window here to give it a session of its own";
1890+
1891+/** Third line of the window "+"'s tooltip: the menu is the only place a session
1892+ gets made in groups mode, and a held button announces itself nowhere else. */
1893+const HOLD_HINT = "\nHold or right-click for a new session";
1894+
1895+/**
1896+ * A tmux session name made out of a window name. tmux windows are named after
1897+ * whatever is running in them, so this is `nvim` or `fish` far more often than
1898+ * it is anything with a slash in it.
1899+ *
1900+ * The daemon validates the result and drops the request if it doesn't like it,
1901+ * so this has to land inside the same rules ([A-Za-z0-9_-], no leading dash) or
1902+ * the drag does nothing at all.
1903+ *
1904+ * @param {string} windowName
1905+ * @param {string[]} taken names already on the server
1906+ * @returns {string}
1907+ */
1908+function sessionNameFor(windowName, taken) {
1909+ const base =
1910+ windowName
1911+ .replace(/[^A-Za-z0-9_-]+/g, "-")
1912+ .replace(/^-+|-+$/g, "")
1913+ .slice(0, 60) || "session";
1914+ if (!taken.includes(base)) return base;
1915+ // The window name is the whole of what the user has to go on, so it stays and
1916+ // takes a suffix rather than being replaced by something generated.
1917+ for (let n = 2; n < 100; n++) {
1918+ if (!taken.includes(`${base}-${n}`)) return `${base}-${n}`;
1919+ }
1920+ return `${base}-${taken.length}`;
1921+}
1922+
1923+/**
1924+ * The window a dragged slot stands for, looked up in the last status frame —
1925+ * the slot itself carries an id and a session but not a name, and the name is
1926+ * what the new session is called.
1927+ *
1928+ * @param {string} id tmux window id
1929+ * @returns {{ window: TbWindowInfo, session: TbSessionInfo } | null}
1930+ */
1931+function windowById(id) {
1932+ for (const session of lastSessions) {
1933+ const window = session.windows.find((w) => w.id === id);
1934+ if (window) return { window, session };
1935+ }
1936+ return null;
1937+}
1938+
1939+/**
1940+ * Whether this drag can become a session, and what it would be called.
1941+ *
1942+ * The only thing asked of the drag is that it is a window tab: a session tab is
1943+ * already a session, and a window's own last window is *not* refused — tmux
1944+ * destroys the session it leaves behind, which is the same session arriving
1945+ * under a new name, and refusing it would mean a "+" that lights up for some
1946+ * tabs and not others with nothing on screen saying which.
1947+ *
1948+ * The name comes from the last status frame where it can, and from the tab's
1949+ * own label where it cannot: the frame is a lookup that can miss (a window
1950+ * created during the drag, a frame not in yet), and a drag that dies because of
1951+ * one would look exactly like a feature that does not work.
1952+ *
1953+ * @returns {{ window: string, name: string } | null}
1954+ */
1955+function pendingSessionSplit() {
1956+ if (!dragging || dragging.parentElement !== $("tabs")) return null;
1957+ const id = windowIdOf(dragging);
1958+ if (!id) return null;
1959+ const label = dragging.querySelector(".name")?.textContent ?? "";
1960+ return {
1961+ window: id,
1962+ name: sessionNameFor(
1963+ windowById(id)?.window.name || label,
1964+ lastSessions.map((s) => s.name),
1965+ ),
1966+ };
1967+}
1968+
1969+/**
1970+ * Wire a "+" as a drop target for window tabs.
1971+ *
1972+ * `dragenter` is cancelled as well as `dragover`: cancelling the latter is what
1973+ * makes the drop legal, and cancelling the former is what stops the browser
1974+ * deciding on the way in that this element is not a target at all. Stopping the
1975+ * events keeps the strip's own reorder from sliding the tab around while the
1976+ * pointer is parked on a button it is going to leave the row through.
1977+ *
1978+ * @param {HTMLElement} button
1979+ */
1980+function splitZone(button) {
1981+ const over = (/** @type {Event} */ e) => {
1982+ if (!pendingSessionSplit()) return;
1983+ e.preventDefault();
1984+ e.stopPropagation();
1985+ button.classList.add("drop");
1986+ };
1987+ button.addEventListener("dragenter", over);
1988+ button.addEventListener("dragover", over);
1989+ // The pointer crossing onto the "+"'s own <svg> is a dragleave on the button,
1990+ // and the dragover that follows immediately puts the class back. Clearing it
1991+ // on dragend as well is what covers the drag that ends somewhere else
1992+ // entirely, which fires no dragleave here at all.
1993+ button.addEventListener("dragleave", () => button.classList.remove("drop"));
1994+ document.addEventListener("dragend", () => button.classList.remove("drop"));
1995+ button.addEventListener("drop", (e) => {
1996+ button.classList.remove("drop");
1997+ const split = pendingSessionSplit();
1998+ if (!split) return;
1999+ e.preventDefault();
2000+ e.stopPropagation();
2001+ tmuxCommand({ cmd: "new-session-with-window", ...split });
2002+ // The one gesture here whose result arrives a frame later and somewhere
2003+ // else in the row: the log is what separates "the drop did nothing" from
2004+ // "the daemon refused it".
2005+ log(`new session ${split.name} from window ${split.window}`);
2006+ term.focus();
2007+ });
2008+}
2009+
2010+splitZone($("session-new"));
2011+splitZone($("tab-new"));
2012+
2013+/** @param {Element | null} slot @returns {string} the slot's window id, or "" */
2014+function windowIdOf(slot) {
2015+ const tab = slot?.querySelector(".tab");
2016+ return (tab && /** @type {HTMLElement} */ (tab).dataset.window) || "";
2017+}
2018+
2019+/** @param {Element | null} slot @returns {string} the session it belongs to */
2020+function sessionOf(slot) {
2021+ const tab = slot?.querySelector(".tab");
2022+ return (tab && /** @type {HTMLElement} */ (tab).dataset.session) || "";
2023+}
2024+
2025+// --- tab groups -------------------------------------------------------------
2026+//
2027+// Groups mode's single row. It renders into the same `#tabs` strip the nested
2028+// layout uses — the same scroll behaviour, the same drop zone, the same tab
2029+// silhouettes — but the strip now holds every window on the server, each
2030+// session's run of them introduced by a chip.
2031+//
2032+// The chip is the session, and it is the only thing in this row that is not a
2033+// window: it carries the name, the count, a colour that is the same colour
2034+// every time you see that session, and the fold. Folding is this panel's own —
2035+// tmux has no notion of a hidden session, and nothing about a folded group is
2036+// sent anywhere.
2037+
2038+/**
2039+ * Chrome's tab groups get a colour from a fixed short list rather than from
2040+ * anywhere in the tab, and so do these: a hue picked out of the name means a
2041+ * session is the same colour in every panel and after every restart, with no
2042+ * state to store and nothing to assign by hand.
2043+ *
2044+ * Eight hues, spaced to stay apart at chip size and chosen to skip the
2045+ * yellow-green band, which goes muddy against both themes' strip colours.
2046+ */
2047+const GROUP_HUES = [
2048+ { hue: 210, name: "Blue" },
2049+ { hue: 190, name: "Cyan" },
2050+ { hue: 145, name: "Green" },
2051+ { hue: 45, name: "Yellow" },
2052+ { hue: 25, name: "Orange" },
2053+ { hue: 0, name: "Red" },
2054+ { hue: 330, name: "Pink" },
2055+ { hue: 275, name: "Purple" },
2056+];
2057+
2058+/**
2059+ * Grey, which is not a hue and so cannot be one of the numbers above. It is the
2060+ * home session's default and a colour you can pick outright, the way Chrome
2061+ * offers grey alongside its eight.
2062+ */
2063+const GROUP_GREY = -1;
2064+
2065+/**
2066+ * The colour a session has been given, if any — read off the session itself.
2067+ *
2068+ * It lives in a tmux user option rather than in this panel's storage, which
2069+ * buys two things storage could not. It follows the session through a rename,
2070+ * because tmux hangs it on the session and not on its name. And every panel on
2071+ * the server sees the same colour, instead of each browser profile keeping a
2072+ * private opinion about the same session. It also dies with the session, which
2073+ * is right: a colour for a session that no longer exists is nothing.
2074+ *
2075+ * The value arrives as a string over a socket, so it is a claim rather than a
2076+ * number until this says otherwise.
2077+ *
2078+ * @param {string} name
2079+ * @returns {number | null}
2080+ */
2081+function chosenColor(name) {
2082+ const raw = lastSessions.find((s) => s.name === name)?.color;
2083+ if (typeof raw !== "string" || raw === "") return null;
2084+ const n = Number(raw);
2085+ if (!Number.isInteger(n)) return null;
2086+ return n === GROUP_GREY || (n >= 0 && n < 360) ? n : null;
2087+}
2088+
2089+/**
2090+ * What colour a group is drawn in: the one it was given, else grey for the home
2091+ * session, else a hue hashed from the name.
2092+ *
2093+ * The hash is what makes the automatic case worth having — a session is the
2094+ * same colour in every panel and after every restart, with nothing stored and
2095+ * nothing to assign by hand. Choosing one is for when the hash puts two
2096+ * sessions you use together on hues you cannot tell apart, which is the one
2097+ * thing hashing cannot fix by itself.
2098+ *
2099+ * @param {string} name
2100+ * @returns {number} degrees on the colour wheel, or GROUP_GREY
2101+ */
2102+function groupColor(name) {
2103+ const chosen = chosenColor(name);
2104+ if (chosen !== null) return chosen;
2105+ if (name === defaultSession) return GROUP_GREY;
2106+ let h = 0;
2107+ for (let i = 0; i < name.length; i++) h = (Math.imul(h, 31) + name.charCodeAt(i)) >>> 0;
2108+ return GROUP_HUES[h % GROUP_HUES.length].hue;
2109+}
2110+
2111+/**
2112+ * Hand the colour to tmux and let the next status frame bring it back. No
2113+ * optimistic repaint: the server is the only copy, and drawing what we hope it
2114+ * will say invents a second one for the second it takes to answer.
2115+ *
2116+ * By id, not by name — a rename between the click and the command would
2117+ * otherwise paint whichever session inherited the name.
2118+ *
2119+ * @param {string} name
2120+ * @param {number | null} hue null clears it, back to automatic
2121+ */
2122+function setGroupColor(name, hue) {
2123+ const id = lastSessions.find((s) => s.name === name)?.id;
2124+ if (!id) return;
2125+ tmuxCommand({
2126+ cmd: "set-session-color",
2127+ session: id,
2128+ ...(hue === null ? {} : { color: String(hue) }),
2129+ });
2130+}
2131+
2132+/**
2133+ * What a session may be renamed to. The same shape the daemon accepts, which is
2134+ * the same shape the sidebar's session field has always accepted — tmux forbids
2135+ * `.` and `:`, and the name is quoted into a command line to a live server, so
2136+ * everything outside this is refused here rather than sent to be refused there.
2137+ *
2138+ * Checking it in the panel as well as in the daemon is not belt-and-braces: it
2139+ * is what lets the field say *now* that a name will not do, instead of the
2140+ * request vanishing silently.
2141+ */
2142+const SESSION_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
2143+
2144+/** @param {string} name */
2145+function validSessionName(name) {
2146+ const s = name.trim();
2147+ return SESSION_NAME_RE.test(s) && !s.startsWith("-");
2148+}
2149+
2150+/**
2151+ * Rename a session, and bring this panel's own name-keyed state along.
2152+ *
2153+ * The colour needs no help — it lives on the session in tmux, so it follows the
2154+ * rename by itself. Pins and the folded flag do not: they are panel
2155+ * preferences, stored against the name because that is what the frames and the
2156+ * storage have in common. Left alone, a rename would silently unfold a group
2157+ * and unpin its tabs, which reads as the panel forgetting rather than as a
2158+ * rename.
2159+ *
2160+ * The rename itself is sent by id — a second panel renaming the same session
2161+ * between this menu opening and the click would otherwise redirect ours onto
2162+ * whatever inherited the name. Nothing is repainted optimistically: tmux is the
2163+ * only copy of the name, and the next status frame brings it back.
2164+ *
2165+ * @param {string} from the current name
2166+ * @param {string} to
2167+ */
2168+function renameSession(from, to) {
2169+ const next = to.trim();
2170+ const id = lastSessions.find((s) => s.name === from)?.id;
2171+ if (!id || next === from || !validSessionName(next)) return;
2172+
2173+ if (pins[from]) {
2174+ pins[next] = pins[from];
2175+ delete pins[from];
2176+ storage.set({ pins });
2177+ }
2178+ if (foldedGroups.has(from)) {
2179+ foldedGroups.delete(from);
2180+ foldedGroups.add(next);
2181+ storage.set({ foldedGroups: [...foldedGroups] });
2182+ }
2183+
2184+ tmuxCommand({ cmd: "rename-session", session: id, name: next });
2185+ log(`renamed ${from} to ${next}`);
2186+}
2187+
2188+/**
2189+ * Session names whose windows are folded away behind their chip. A panel
2190+ * preference, saved as a list because a Set does not survive storage.
2191+ * @type {Set<string>}
2192+ */
2193+let foldedGroups = new Set();
2194+
2195+/** @param {string} name */
2196+function toggleGroup(name) {
2197+ if (foldedGroups.has(name)) foldedGroups.delete(name);
2198+ else foldedGroups.add(name);
2199+ storage.set({ foldedGroups: [...foldedGroups] });
2200+ repaintTabs();
2201+}
2202+
2203+/**
2204+ * @param {TbSessionInfo[]} unordered as the daemon sent them
2205+ * @param {string | null | undefined} current the session this panel is on
2206+ * @param {TbAgent[]} agents server-wide
2207+ */
2208+function renderGroups(unordered, current, agents) {
2209+ const sessions = orderSessions(unordered);
2210+ const strip = $("tabs");
2211+ // A repaint mid-drag would tear the tab out from under the pointer; the move
2212+ // it commits to brings a fresh frame of its own a moment later.
2213+ if (dragging && !strip.hidden) return;
2214+ const show = connected && tmuxMode && sessions.length > 0;
2215+ $("tab-new").hidden = !(connected && tmuxMode && sessionName);
2216+ // The one "+" left in this row, so it says which of the two things it is —
2217+ // the ambiguity was the whole complaint about having a second one beside it.
2218+ // It names the session it actually adds to, which is not always the one you
2219+ // are on and not always the home session either.
2220+ $("tab-new").title = `New window in ${newWindowTarget() ?? defaultSession}${SPLIT_HINT}${HOLD_HINT}`;
2221+ strip.hidden = !show;
2222+ // A chip carries the session name, so the status text has nothing left to
2223+ // say — the same trade the session row makes in the nested layout.
2224+ document.body.classList.toggle("has-session", show);
2225+ document.body.classList.toggle("has-windows", show);
2226+ if (!show) {
2227+ strip.textContent = "";
2228+ strip.dataset.sig = "";
2229+ hideSessionInput();
2230+ syncSpinner();
2231+ return;
2232+ }
2233+
2234+ const claude = agentByWindow(agents);
2235+ const loudest = agentBySession(agents);
2236+
2237+ /** @type {{ session: TbSessionInfo, folded: boolean, windows: TbWindowInfo[], pinned: Set<string> }[]} */
2238+ const groups = sessions.map((s) => {
2239+ const { ordered, pinned } = orderWindows(s.name, s.windows);
2240+ return { session: s, folded: foldedGroups.has(s.name), windows: ordered, pinned };
2241+ });
2242+
2243+ // One session, the home one, never given a colour of its own: there is no
2244+ // grouping for a chip to express, so it is left off and the row reads as a
2245+ // plain strip of tabs. Chrome does the same with ungrouped tabs. Anything
2246+ // that makes the grouping real — a second session, a rename, a colour picked
2247+ // for this one — brings the chip back, and with it the fold, so folding is
2248+ // ignored while it is gone.
2249+ const bare =
2250+ groups.length === 1 && groups[0].session.name === defaultSession && groupColor(defaultSession) === GROUP_GREY;
2251+ if (bare) groups[0].folded = false;
2252+
2253+ const sig = JSON.stringify(
2254+ groups.map((g) => [
2255+ bare,
2256+ g.session.name,
2257+ g.session.name === current,
2258+ g.session.attached,
2259+ g.folded,
2260+ // The colour lives on the server now, so it can change without anything
2261+ // else in this frame moving — a second panel picked one.
2262+ groupColor(g.session.name),
2263+ // A folded group shows no tabs, but it still shows a count and the
2264+ // loudest thing Claude is doing behind it, so both belong in the
2265+ // signature whether or not the windows do.
2266+ g.session.windows.length,
2267+ loudest[g.session.name]?.state,
2268+ agentLabel(loudest[g.session.name]),
2269+ g.folded
2270+ ? null
2271+ : g.windows.map((w) => {
2272+ const a = claude[w.id];
2273+ return [w.id, w.index, w.name, w.active, w.activity, a?.state, agentLabel(a), g.pinned.has(w.id)];
2274+ }),
2275+ ]),
2276+ );
2277+ if (strip.dataset.sig !== sig) {
2278+ strip.dataset.sig = sig;
2279+ strip.textContent = "";
2280+ for (const g of groups) {
2281+ const name = g.session.name;
2282+ if (!bare) strip.appendChild(groupChip(g.session, name === current, g.folded, loudest[name]));
2283+ if (g.folded) continue;
2284+ // Per group *and* per row: closing a group's last window closes the
2285+ // group, which is what closing a group's last tab does in a browser too.
2286+ // Only the very last window on the server is withheld.
2287+ const closable = windowClosable(g.windows.length, groups.length);
2288+ for (const w of g.windows) {
2289+ strip.appendChild(
2290+ windowTab(w, {
2291+ claude: claude[w.id],
2292+ closable,
2293+ pinned: g.pinned.has(w.id),
2294+ lastInSession: g.windows.length === 1,
2295+ owner: name,
2296+ current: w.active && name === current,
2297+ }),
2298+ );
2299+ }
2300+ }
2301+ }
2302+
2303+ syncSpinner();
2304+
2305+ // The omnibar says where the client is, and the client moves — a switch made
2306+ // from a tab, from the omnibar, or from the terminal itself all land here.
2307+ syncOmniHere();
2308+ refreshSessionInfo();
2309+ // The list is drawn from the same frame the tabs are, so a window that just
2310+ // closed has to leave it too — and a Claude that just started waiting has to
2311+ // light up in it. Only while it is open: this arrives once a second.
2312+ if (!$("omni-list").hidden) refreshOmni();
2313+
2314+ const active = strip.querySelector('[aria-selected="true"]');
2315+ // The row is as long as the whole server now, so the window you are on is
2316+ // further off screen than it ever was in the nested layout.
2317+ if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
2318+}
2319+
2320+/**
2321+ * @param {TbSessionInfo} s
2322+ * @param {boolean} isCurrent this panel's client is in this group
2323+ * @param {boolean} folded
2324+ * @param {TbAgent} [claude] the loudest agent anywhere in the session
2325+ */
2326+function groupChip(s, isCurrent, folded, claude) {
2327+ // Grey by default for the home session — it is where windows go when nothing
2328+ // said otherwise, so a hue would make it look like one more named group
2329+ // rather than the plain one. Chrome's ungrouped tabs are the same idea with
2330+ // the chip left off entirely; keeping the chip is what buys the fold. Picking
2331+ // a colour for it overrides that, because an explicit choice outranks a
2332+ // default about the same thing.
2333+ const colour = groupColor(s.name);
2334+ const state = claude?.state ?? "none";
2335+ const chip = button(
2336+ {
2337+ class:
2338+ `group-chip${isCurrent ? " current" : ""}${folded ? " folded" : ""}` +
2339+ `${s.attached && !isCurrent ? " attached" : ""}${colour === GROUP_GREY ? " grey" : ""}`,
2340+ css: { "--group-h": String(colour) },
2341+ data: { session: s.name },
2342+ attrs: { "aria-expanded": !folded },
2343+ title: tip(
2344+ `session ${s.name}`,
2345+ `${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`,
2346+ isCurrent ? "you are here" : s.attached && "attached elsewhere",
2347+ claude && `claude ${claude.state} — ${agentLabel(claude)}`,
2348+ folded ? "folded — click to unfold" : "click to fold",
2349+ "right-click to rename or recolour",
2350+ ),
2351+ on: {
2352+ click: () => {
2353+ toggleGroup(s.name);
2354+ term.focus();
2355+ },
2356+ /** @param {MouseEvent} e */
2357+ contextmenu: (e) => {
2358+ e.preventDefault();
2359+ openGroupMenu(e, s, isCurrent, folded);
2360+ },
2361+ },
2362+ },
2363+ // Unlike Chrome, the group you are in folds too. Chrome forbids it because
2364+ // folding away the active tab would leave you with no way back to it; here
2365+ // the terminal underneath is still the window you are on, and the chip stays
2366+ // marked as current, so there is nothing to lose track of.
2367+ el("span", { class: "caret", attrs: { "aria-hidden": "true" } }),
2368+ el("span", { class: "name", text: s.name }),
2369+ // Folded, the count is the only thing saying how much is behind the chip, so
2370+ // it is worth the room. Unfolded it is redundant with the tabs themselves.
2371+ folded &&
2372+ s.windows.length > 0 &&
2373+ el("span", { class: "count", text: String(s.windows.length) }),
2374+ // A folded group's windows have no tabs to wear their status, so the chip
2375+ // wears the loudest of them — the same job the session tab does in the
2376+ // nested layout, and the reason the daemon reports every session's agents.
2377+ folded && state !== "none" && glyphSpan(state),
2378+ );
2379+
2380+ // Dropping a tab on a chip moves that window into the session — the only way
2381+ // to express the move when the group is folded and has no visible window to
2382+ // land beside. Stopping the event keeps the strip's own dragover from
2383+ // sliding the tab into a position it is not going to end up in.
2384+ chip.addEventListener("dragover", (e) => {
2385+ if (!dragging || dragging.parentElement !== $("tabs")) return;
2386+ e.preventDefault();
2387+ e.stopPropagation();
2388+ chip.classList.add("drop");
2389+ });
2390+ chip.addEventListener("dragleave", () => chip.classList.remove("drop"));
2391+ chip.addEventListener("drop", (e) => {
2392+ chip.classList.remove("drop");
2393+ if (!dragging) return;
2394+ e.preventDefault();
2395+ e.stopPropagation();
2396+ const moved = windowIdOf(dragging);
2397+ if (moved && sessionOf(dragging) !== s.name) {
2398+ tmuxCommand({ cmd: "move-window-to-session", window: moved, session: s.name });
2399+ }
2400+ term.focus();
2401+ });
2402+
2403+ return chip;
2404+}
2405+
2406+/**
2407+ * The palette, as a row of swatches. Grey leads it, then the eight hues in
2408+ * wheel order so the row reads as a spectrum rather than as a list of names.
2409+ *
2410+ * The swatch showing now is ringed whichever way it got there — chosen or
2411+ * hashed — so the row always says what the group looks like. Clicking the one
2412+ * already showing clears the choice back to automatic, which is how you undo a
2413+ * colour without a tenth control for it.
2414+ *
2415+ * @param {string} name the session
2416+ */
2417+function colourRow(name) {
2418+ const live = groupColor(name);
2419+ const chosen = chosenColor(name);
2420+
2421+ /** @param {number} hue @param {string} label */
2422+ const swatch = (hue, label) => {
2423+ const title = chosen !== null && hue === live ? `${label} — click for automatic` : label;
2424+ return button({
2425+ class: `swatch${hue === GROUP_GREY ? " grey" : ""}${hue === live ? " on" : ""}`,
2426+ css: { "--group-h": String(hue) },
2427+ title,
2428+ attrs: { "aria-label": title, "aria-pressed": hue === live },
2429+ on: {
2430+ click: () => {
2431+ closeTabMenu();
2432+ setGroupColor(name, hue === live && chosen !== null ? null : hue);
2433+ },
2434+ },
2435+ });
2436+ };
2437+
2438+ return el(
2439+ "div",
2440+ { class: "colours" },
2441+ swatch(GROUP_GREY, "Grey"),
2442+ ...GROUP_HUES.map((c) => swatch(c.hue, c.name)),
2443+ );
2444+}
2445+
2446+/**
2447+ * The name, as a field at the top of the group's menu — the same place and the
2448+ * same gesture a browser gives a tab group's name, because it is the same
2449+ * thing being named.
2450+ *
2451+ * A field rather than a "Rename" item that opens something: an item would have
2452+ * to open a prompt, and a modal in this panel blocks the socket's message
2453+ * handler for as long as it is up, so the terminal underneath would stop
2454+ * moving while the box was open. Editing in place costs nothing and the panel
2455+ * keeps running behind it.
2456+ *
2457+ * Enter commits, Escape leaves the name alone, and blur commits too — a click
2458+ * on anything else in the menu is a click on a menu whose field you had already
2459+ * finished with. An empty or malformed name commits nothing and says so.
2460+ *
2461+ * @param {TbSessionInfo} s
2462+ */
2463+function renameRow(s) {
2464+ const row = el("div", { class: "rename" });
2465+
2466+ const input = el("input", {
2467+ title: "Session name — letters, digits, - and _",
2468+ attrs: {
2469+ type: "text",
2470+ maxlength: 64,
2471+ spellcheck: "false",
2472+ "aria-label": `Rename session ${s.name}`,
2473+ },
2474+ });
2475+ // Not an attribute: what the field holds is state, and the attribute only ever
2476+ // sets what it *started* with.
2477+ input.value = s.name;
2478+
2479+ // Live rather than only on Enter: the field is small and the rule is not
2480+ // guessable, so the moment a character breaks it is the moment to say so.
2481+ const check = () => {
2482+ const v = input.value.trim();
2483+ row.classList.toggle("bad", v !== "" && v !== s.name && !validSessionName(v));
2484+ };
2485+ input.addEventListener("input", check);
2486+
2487+ /** @param {boolean} commit */
2488+ const finish = (commit) => {
2489+ // Whichever way it ends, it ends once: the blur that Enter causes, and the
2490+ // menu closing behind it, would otherwise each commit the same name again.
2491+ input.removeEventListener("blur", onBlur);
2492+ pendingRename = null;
2493+ if (commit) renameSession(s.name, input.value);
2494+ };
2495+ const onBlur = () => finish(true);
2496+ input.addEventListener("blur", onBlur);
2497+ // Dismissing the menu removes the field, and a removed element gets no blur
2498+ // event — so the close path commits it instead. Typing a name and clicking
2499+ // away should rename, not throw the typing out.
2500+ pendingRename = () => finish(true);
2501+
2502+ input.addEventListener("keydown", (ev) => {
2503+ if (ev.key === "Enter") {
2504+ ev.preventDefault();
2505+ finish(true);
2506+ closeTabMenu();
2507+ term.focus();
2508+ } else if (ev.key === "Escape") {
2509+ ev.preventDefault();
2510+ finish(false);
2511+ closeTabMenu();
2512+ term.focus();
2513+ }
2514+ // Everything else stays in the box. Without this the panel's own shortcuts
2515+ // would read the typing as commands aimed at the terminal.
2516+ ev.stopPropagation();
2517+ });
2518+
2519+ row.appendChild(input);
2520+ return row;
2521+}
2522+
2523+/**
2524+ * @param {MouseEvent} e
2525+ * @param {TbSessionInfo} s
2526+ * @param {boolean} isCurrent
2527+ * @param {boolean} folded
2528+ */
2529+function openGroupMenu(e, s, isCurrent, folded) {
2530+ closeTabMenu();
2531+ const menu = el("div", { class: "tab-menu", attrs: { role: "menu" } });
2532+ /** @param {string} label @param {() => void} run */
2533+ const item = (label, run) => menuItem(menu, label, run);
2534+
2535+ // Name then colours across the top, then the actions — a browser's tab group
2536+ // menu in the same order, and for the same reason: these two are what the
2537+ // group *is*, and the rest is what to do with it.
2538+ const rename = renameRow(s);
2539+ menu.appendChild(rename);
2540+ menu.appendChild(colourRow(s.name));
2541+
2542+ item(folded ? "Unfold" : "Fold", () => toggleGroup(s.name));
2543+
2544+ if (!isCurrent) {
2545+ item("Switch to this session", () => {
2546+ tmuxCommand({ cmd: "switch", session: s.name });
2547+ term.focus();
2548+ });
2549+ }
2550+
2551+ // The row's "+" opens a window in the home session; this is how any other
2552+ // group gets one without going there first.
2553+ item("New window here", () => {
2554+ newWindowIn(s.name);
2555+ term.focus();
2556+ });
2557+
2558+ item(folded ? "Fold the others" : "Fold everything else", () => {
2559+ foldedGroups = new Set(lastSessions.map((x) => x.name).filter((n) => n !== s.name));
2560+ storage.set({ foldedGroups: [...foldedGroups] });
2561+ repaintTabs();
2562+ });
2563+
2564+ document.body.appendChild(menu);
2565+ placeMenu(menu, e);
2566+ // Selected rather than merely focused: the common rename replaces the name
2567+ // outright, and the uncommon one is an arrow key away.
2568+ const field = rename.querySelector("input");
2569+ if (field instanceof HTMLInputElement) field.select();
2570+}
2571+
2572+// --- the omnibar ------------------------------------------------------------
2573+//
2574+// Groups mode's second row, and the same bargain a browser's address bar makes:
2575+// one box that searches what you already have and, failing that, offers to
2576+// create the thing you typed. Here that is every window, every session and
2577+// every pane running Claude on the tmux server — the same status frame the tabs
2578+// are drawn from, so there is nothing extra to fetch and nothing that can be
2579+// out of date with respect to the row above it.
2580+//
2581+// It exists because the flat row scrolls: with every window on the server in
2582+// one strip, past a handful of sessions the one you want is off the end of it,
2583+// and folding groups to find it defeats the point of having them all there.
2584+//
2585+// Everything it lists is a tmux name — a user or a shell script named it, and
2586+// both can put anything in a name — so every one of them reaches the DOM
2587+// through textContent, and the only thing sent back is an id tmux issued.
2588+
2589+/**
2590+ * One row of the dropdown.
2591+ * @typedef {object} TbOmniItem
2592+ * @property {"window" | "session" | "pane" | "ssh" | "create" | "claude" | "run"
2593+ * | "action" | "project" | "path"} kind
2594+ * @property {string} label the name, matched against and shown first
2595+ * @property {string} meta where it is — dimmed, after the label
2596+ * @property {string} [mark] a character in front of the label, for the kinds
2597+ * whose rows are not all alike — one action is not the next one
2598+ * @property {TbAgentState} [state] an agent state, for the glyph
2599+ * @property {number} score lower sorts first
2600+ * @property {() => void} [run] absent on a hint, which is a row you cannot run
2601+ * @property {boolean} [hint] the row is telling you something, not offering it
2602+ * @property {boolean} [complete] running it fills the box in rather than going
2603+ * anywhere, so the list stays up and the caret stays where it is
2604+ * @property {boolean} [typed] the label is the query itself rather than a name
2605+ * that was matched, so there is nothing in it to highlight
2606+ */
2607+
2608+/** @type {TbOmniItem[]} */
2609+let omniItems = [];
2610+/** Index into `omniItems`, or -1 for "nothing chosen yet". */
2611+let omniActive = -1;
2612+
2613+/**
2614+ * Substring, case-insensitive, with a prefix bonus: `srv` finds "server"
2615+ * ahead of "webserver", which is the order you meant by typing the start of a
2616+ * name. Deliberately not fuzzy — a fuzzy match over a few hundred window names
2617+ * puts noise at the top, and tmux names are short enough to type.
2618+ *
2619+ * @param {string} text
2620+ * @param {string} q already lowercased and trimmed
2621+ * @returns {number} lower is better, -1 for no match
2622+ */
2623+function omniScore(text, q) {
2624+ if (!q) return 0;
2625+ const i = text.toLowerCase().indexOf(q);
2626+ if (i < 0) return -1;
2627+ return i === 0 ? 0 : i + 1;
2628+}
2629+
2630+/**
2631+ * `omniScore`, plus a subsequence pass for what it rejects: `bld1` finds
2632+ * `build-01`, and `cmini` finds `collin@mini`.
2633+ *
2634+ * Only hosts are scored this way, and the divergence is deliberate. Fuzzy
2635+ * matching over a few hundred window names puts noise at the top, which is why
2636+ * `omniScore` is not fuzzy — but the host list is a few dozen names you wrote
2637+ * down yourself, and they are the ones with dots, dashes and a `user@` in front
2638+ * that make a substring search miss what you obviously meant.
2639+ *
2640+ * A subsequence match always sorts below every substring match, and among
2641+ * themselves the tighter one wins: the span the letters were found across is
2642+ * the score, so a name that has them close together beats one that spreads them
2643+ * over half its length.
2644+ *
2645+ * @param {string} text
2646+ * @param {string} q already lowercased and trimmed
2647+ * @returns {number} lower is better, -1 for no match
2648+ */
2649+function fuzzyScore(text, q) {
2650+ const direct = omniScore(text, q);
2651+ if (direct >= 0) return direct;
2652+ const s = text.toLowerCase();
2653+ let from = -1;
2654+ let start = -1;
2655+ for (const ch of q) {
2656+ from = s.indexOf(ch, from + 1);
2657+ if (from < 0) return -1;
2658+ if (start < 0) start = from;
2659+ }
2660+ return 20 + (from - start);
2661+}
2662+
2663+/**
2664+ * The daemon's `valid_ssh_host`, mirrored for the same reason the validators
2665+ * below are: a row offering to connect somewhere the daemon will refuse is
2666+ * worse than no row.
2667+ *
2668+ * `[user@]host`, and no character that ssh could read as an option — a leading
2669+ * dash is the one that matters, because `-oProxyCommand=` runs a shell. Kept in
2670+ * step by hand with daemon/src/ssh.rs; the daemon validates regardless.
2671+ *
2672+ * @param {string} host
2673+ */
2674+function validSshHost(host) {
2675+ const s = host.trim();
2676+ if (!s || s.length > 128) return false;
2677+ const at = s.indexOf("@");
2678+ const parts = at < 0 ? [s] : [s.slice(0, at), s.slice(at + 1)];
2679+ return parts.every((p) => p.length > 0 && !p.startsWith("-") && /^[A-Za-z0-9._-]+$/.test(p));
2680+}
2681+
2682+/**
2683+ * The daemon's `valid_session_name`, mirrored so the row that offers to create
2684+ * a session only appears when the daemon would accept it — an offer that turns
2685+ * into a silently ignored command is worse than no offer.
2686+ *
2687+ * Kept in step by hand with daemon/src/pty.rs. The daemon validates regardless;
2688+ * this is about what to show, never about what is safe to send.
2689+ *
2690+ * @param {string} name
2691+ */
2692+function validSessionName(name) {
2693+ const s = name.trim();
2694+ return s.length > 0 && s.length <= 64 && !s.startsWith("-") && /^[A-Za-z0-9_-]+$/.test(s);
2695+}
2696+
2697+/**
2698+ * Ties break in this order, so a session beats a window whose name matches
2699+ * equally well. The box holds a session name to begin with, so typing over it
2700+ * is first of all a way to change session — that reading wins the tie, and a
2701+ * window of the same name is one row below it.
2702+ */
2703+const OMNI_KIND_RANK = {
2704+ session: 0,
2705+ window: 1,
2706+ pane: 2,
2707+ ssh: 3,
2708+ create: 4,
2709+ claude: 5,
2710+ run: 6,
2711+ action: 7,
2712+ // Both only ever appear on their own — a `~` in the box is a mode, and these
2713+ // are the only rows in it — so their rank is a formality.
2714+ project: 8,
2715+ path: 9,
2716+};
2717+
2718+/**
2719+ * What a host row's score is pushed up by, so that anything already running on
2720+ * the server outranks somewhere you could connect to.
2721+ *
2722+ * Above panes (+50) rather than below them, because a host matches on its name
2723+ * — which is what you typed — where a pane matches on the prose of what Claude
2724+ * happens to be doing in it.
2725+ */
2726+const OMNI_SSH_PENALTY = 40;
2727+
2728+/** How many matches the list shows before it stops. */
2729+const OMNI_LIMIT = 8;
2730+
2731+/**
2732+ * @param {string} query what is in the box
2733+ * @returns {TbOmniItem[]}
2734+ */
2735+function omniSuggestions(query) {
2736+ // `!` first, and on its own: a leading bang says the rest of the box is a
2737+ // shell command, not a name, so nothing here is worth matching against the
2738+ // server's names and offering to make a session called `!ls` would be noise.
2739+ // The prefix is the shell's own — `!` is what a pager, an editor or a REPL
2740+ // has always meant "and now run this" with.
2741+ if (query.trim().startsWith("!")) {
2742+ const cmd = query.trim().slice(1).trim();
2743+ const here = lastSessions.find((s) => s.name === sessionName);
2744+ if (!here) return [];
2745+ const where = here.path ? `run · ${shortPath(here.path)}` : "run";
2746+ // The bare `!` answers itself: the row appears the moment the prefix is
2747+ // typed, saying what the box is now for and where the command will run, so
2748+ // the mode is visible before there is anything to run. It is a label rather
2749+ // than an offer — see `hint`, which is what keeps Enter from firing it.
2750+ if (!cmd) {
2751+ return [{ kind: "run", label: "type a command…", meta: where, score: 0, hint: true }];
2752+ }
2753+ if (!validCommand(cmd)) return [];
2754+ return [
2755+ {
2756+ kind: "run",
2757+ label: cmd,
2758+ meta: where,
2759+ score: 0,
2760+ typed: true,
2761+ run: () => tmuxCommand({ cmd: "run", session: here.name, command: cmd }),
2762+ },
2763+ ];
2764+ }
2765+
2766+ // `~` next, and for the same reason `!` is first: a leading tilde says the
2767+ // box holds a directory, and matching `~/Code/foo` against the server's
2768+ // window names would find nothing while hiding the rows that can act on it.
2769+ if (query.trimStart().startsWith("~")) return projectSuggestions(query);
2770+
2771+ const q = query.trim().toLowerCase();
2772+ const claude = agentByWindow(lastAgents);
2773+ const loudest = agentBySession(lastAgents);
2774+ /** @type {TbOmniItem[]} */
2775+ const out = [];
2776+
2777+ for (const s of lastSessions) {
2778+ const score = omniScore(s.name, q);
2779+ // An empty box is a starting point, not a dump of the server: it offers the
2780+ // sessions, which is the short list, and holds the windows back until there
2781+ // is something to narrow them by. The session you are on is left out of it
2782+ // either way — going there is where you already are.
2783+ if (score >= 0 && !(!q && s.name === sessionName)) {
2784+ out.push({
2785+ kind: "session",
2786+ label: s.name,
2787+ meta: `session · ${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`,
2788+ state: loudest[s.name]?.state,
2789+ score,
2790+ run: () => tmuxCommand({ cmd: "switch", session: s.name }),
2791+ });
2792+ }
2793+
2794+ if (!q) continue;
2795+ for (const w of s.windows) {
2796+ const wScore = omniScore(w.name, q);
2797+ if (wScore < 0) continue;
2798+ const a = claude[w.id];
2799+ out.push({
2800+ kind: "window",
2801+ label: w.name,
2802+ meta: `${s.name} · window ${w.index}`,
2803+ state: a?.state,
2804+ // The window you are looking at right now is a worse answer than any
2805+ // other equally good match: it is the one place you can already see.
2806+ score: wScore + (w.active && s.name === sessionName ? 100 : 0),
2807+ run: () => selectWindow(w, s.name),
2808+ });
2809+ }
2810+ }
2811+
2812+ // Panes, but only the ones running Claude: those are the panes the daemon
2813+ // knows an id for, and they are the ones worth addressing individually — the
2814+ // rest of a window's panes are reached by going to the window.
2815+ for (const a of lastAgents) {
2816+ // What you would search for is what it is doing, not "pane %12".
2817+ const text = [a.title, a.message, a.tool, a.name].filter(Boolean).join(" ");
2818+ const score = omniScore(text, q);
2819+ if (score < 0 || !q) continue;
2820+ out.push({
2821+ kind: "pane",
2822+ label: a.title || agentLabel(a),
2823+ meta: `${a.session} · ${a.window} · claude ${a.state}`,
2824+ state: a.state,
2825+ score: score + 50,
2826+ run: () => tmuxCommand({ cmd: "focus", pane: a.pane }),
2827+ });
2828+ }
2829+
2830+ // Machines, from what ssh already knows about. Only with a query, for the
2831+ // same reason the windows are: an empty box is a starting point rather than
2832+ // an inventory, and the sessions are the short list it offers.
2833+ //
2834+ // The window this opens is a local one running ssh — see the daemon's
2835+ // TmuxRequest::Ssh. So it needs the session the panel is on, exactly as the
2836+ // Claude and `!` rows do, and it is offered only when there is one.
2837+ const onSession = lastSessions.find((s) => s.name === sessionName);
2838+ if (q && onSession) {
2839+ for (const host of sshHosts) {
2840+ const score = fuzzyScore(host, q);
2841+ if (score < 0) continue;
2842+ out.push({
2843+ kind: "ssh",
2844+ label: host,
2845+ meta: "ssh",
2846+ score: score + OMNI_SSH_PENALTY,
2847+ run: () => tmuxCommand({ cmd: "ssh", session: onSession.name, host }),
2848+ });
2849+ }
2850+ }
2851+
2852+ out.sort((x, y) => x.score - y.score || OMNI_KIND_RANK[x.kind] - OMNI_KIND_RANK[y.kind]);
2853+ const items = out.slice(0, OMNI_LIMIT);
2854+
2855+ const typed = query.trim();
2856+
2857+ // A destination that is not in the list, read as one anyway: ssh reaches
2858+ // machines no config or `known_hosts` mentions, and having to open a terminal
2859+ // to connect to one of them would make the list a limit rather than a
2860+ // shortcut.
2861+ //
2862+ // Only for text shaped like a destination, though — a `user@` or a dot.
2863+ // A bare word is a session or a window name, and offering to ssh to `wor`
2864+ // while you type `work` would put a connection under the cursor on the way
2865+ // to somewhere you already have.
2866+ if (
2867+ typed &&
2868+ onSession &&
2869+ /[@.]/.test(typed) &&
2870+ validSshHost(typed) &&
2871+ !sshHosts.includes(typed)
2872+ ) {
2873+ items.push({
2874+ kind: "ssh",
2875+ label: typed,
2876+ meta: "ssh",
2877+ score: Infinity,
2878+ typed: true,
2879+ run: () => tmuxCommand({ cmd: "ssh", session: onSession.name, host: typed }),
2880+ });
2881+ }
2882+
2883+ // Last, always, and only when it would do something: an exact existing name
2884+ // is a switch, which is already in the list above.
2885+ if (typed && validSessionName(typed) && !lastSessions.some((s) => s.name === typed)) {
2886+ items.push({
2887+ kind: "create",
2888+ typed: true,
2889+ label: typed,
2890+ meta: "create session",
2891+ score: Infinity,
2892+ run: () => tmuxCommand({ cmd: "create", session: typed }),
2893+ });
2894+ }
2895+
2896+ // Below everything, and last of all: whatever was typed, read as a question
2897+ // rather than as a name. A sentence matches no window and is not a legal
2898+ // session name, so for anything that isn't a name this is the only row in
2899+ // the list — type the thing you want done, press Enter, and it opens in a
2900+ // window of its own beside the one you are in.
2901+ //
2902+ // In the session's own directory, which is the whole reason to ask from here
2903+ // rather than in a terminal somewhere else.
2904+ if (typed && typed.length <= OMNI_PROMPT_MAX && onSession) {
2905+ items.push({
2906+ kind: "claude",
2907+ label: typed,
2908+ meta: onSession.path ? `send to claude · ${shortPath(onSession.path)}` : "send to claude",
2909+ score: Infinity,
2910+ typed: true,
2911+ run: () => tmuxCommand({ cmd: "claude", session: onSession.name, prompt: typed }),
2912+ });
2913+ }
2914+
2915+ // With nothing typed the box is a menu rather than a search, so it ends with
2916+ // the things you would otherwise have had to type to get: a Claude, and a
2917+ // shell. Every other row here is reached by typing at least a letter, which
2918+ // is the one thing a touch client has no cheap way to do — these are the rows
2919+ // that make the list usable with a thumb.
2920+ //
2921+ // At the bottom, after the places, so Enter on an untouched box still means
2922+ // the first session rather than starting something.
2923+ if (!typed && onSession) items.push(...omniActions(onSession));
2924+ return items;
2925+}
2926+
2927+/**
2928+ * The rows that make something in the session the panel is on. Both open a
2929+ * window beside the current one, in the session's own directory — the same
2930+ * window `send to claude` and `!` open, without the text.
2931+ *
2932+ * `claude` goes through `run` rather than through the daemon's Claude request:
2933+ * that one exists to carry a prompt safely, and there is no prompt here. What
2934+ * this is, is `!claude` with nothing to type.
2935+ *
2936+ * @param {TbSessionInfo} s
2937+ * @returns {TbOmniItem[]}
2938+ */
2939+function omniActions(s) {
2940+ const where = s.path ? shortPath(s.path) : s.name;
2941+ return [
2942+ {
2943+ kind: "action",
2944+ mark: "✻",
2945+ label: "New Claude",
2946+ meta: `new window · ${where}`,
2947+ score: Infinity,
2948+ typed: true,
2949+ run: () => tmuxCommand({ cmd: "run", session: s.name, command: "claude" }),
2950+ },
2951+ {
2952+ kind: "action",
2953+ mark: "+",
2954+ label: "New window",
2955+ meta: `${s.name} · ${where}`,
2956+ score: Infinity,
2957+ typed: true,
2958+ run: () => tmuxCommand({ cmd: "new-window", session: s.name }),
2959+ },
2960+ ];
2961+}
2962+
2963+/**
2964+ * The daemon's `MAX_PROMPT`, mirrored for the same reason `validSessionName`
2965+ * mirrors its validator: an offer the daemon would drop is worse than none.
2966+ * Kept in step by hand with daemon/src/pty.rs.
2967+ */
2968+const OMNI_PROMPT_MAX = 8192;
2969+
2970+/**
2971+ * The daemon's `valid_command`, mirrored for the same reason again: a row that
2972+ * offers to run something the daemon will drop is worse than no row.
2973+ *
2974+ * A command is one line — a newline in the box means a paste that meant to go
2975+ * to the terminal itself. Kept in step by hand with daemon/src/pty.rs.
2976+ *
2977+ * @param {string} cmd
2978+ */
2979+function validCommand(cmd) {
2980+ // eslint-disable-next-line no-control-regex
2981+ return cmd.length > 0 && cmd.length <= 4096 && !/[\x00-\x1f\x7f]/.test(cmd);
2982+}
2983+
2984+// --- the box as a place -----------------------------------------------------
2985+//
2986+// `~/Code/foo let's do this` — a directory to work in, and what to say to
2987+// Claude once it is running there. A leading `~` is what puts the box in this
2988+// mode: no tmux name starts with one, and neither does anything you would type
2989+// looking for a window, so nothing else has to be given up for it.
2990+//
2991+// The rows are a question the panel cannot answer for itself. It has no
2992+// filesystem — the directory is on the daemon's machine — so it asks about one
2993+// directory at a time and works the rest out from the answer: whether the path
2994+// exists decides between switching to it, starting a session in it, and making
2995+// it first. One query per level typed, cached, rather than one per keystroke.
2996+
2997+/**
2998+ * What the daemon said about a directory, keyed by the directory asked about.
2999+ * @type {Map<string, TbPathFrame>}
3000+ */
3001+const pathAnswers = new Map();
3002+/** Asked and not yet answered, so the same question is not asked twice.
3003+ * @type {Set<string>} */
3004+const pathAsking = new Set();
3005+/** Cleared wholesale when it gets past this; the box asks again as you type. */
3006+const PATH_CACHE_MAX = 64;
3007+/** Long enough that a typed path is one query per `/`, short enough not to be
3008+ * felt. The answer arriving repaints the list under the caret. */
3009+const PATH_DEBOUNCE_MS = 70;
3010+/** How many directories the list offers to complete to. */
3011+const PATH_COMPLETIONS = 6;
3012+
3013+let pathTimer = 0;
3014+/** The most recent directory `askPath` was asked for; the timer sends this. */
3015+let pathWanted = "";
3016+
3017+/**
3018+ * Ask the daemon about a directory, at most once, and not on every keystroke.
3019+ *
3020+ * The debounce holds one query rather than a queue: typing `~/Code/` fires for
3021+ * `~/Code` and not for `~/Cod`, because the last thing wanted is the only thing
3022+ * still worth asking by the time the timer runs.
3023+ *
3024+ * @param {string} dir a path in the box's own notation — `~/Code`, `/etc`
3025+ */
3026+function askPath(dir) {
3027+ if (!connected || !ws || !dir || pathAnswers.has(dir) || pathAsking.has(dir)) return;
3028+ pathWanted = dir;
3029+ if (pathTimer) return;
3030+ pathTimer = setTimeout(() => {
3031+ pathTimer = 0;
3032+ const q = pathWanted;
3033+ if (!connected || !ws || !q || pathAnswers.has(q) || pathAsking.has(q)) return;
3034+ pathAsking.add(q);
3035+ ws.send(JSON.stringify({ type: "path", q }));
3036+ }, PATH_DEBOUNCE_MS);
3037+}
3038+
3039+/**
3040+ * File an answer and, if the list is up, draw it — the rows that were waiting
3041+ * on this are the reason it was asked for.
3042+ *
3043+ * @param {TbPathFrame} msg
3044+ */
3045+function takePathAnswer(msg) {
3046+ if (typeof msg.q !== "string") return;
3047+ pathAsking.delete(msg.q);
3048+ // A cache, not a model of the filesystem: it is dropped whole rather than
3049+ // aged, and anything still on screen is asked for again on the next keystroke.
3050+ if (pathAnswers.size >= PATH_CACHE_MAX) pathAnswers.clear();
3051+ pathAnswers.set(msg.q, msg);
3052+ if (!$("omni-list").hidden) refreshOmni();
3053+}
3054+
3055+/**
3056+ * Forget what we know about a directory. Called when we have just asked for
3057+ * something to be created inside it, because the listing we hold is now one
3058+ * name short of the truth.
3059+ *
3060+ * @param {string} dir
3061+ */
3062+function forgetPath(dir) {
3063+ pathAnswers.delete(dir);
3064+ pathAsking.delete(dir);
3065+}
3066+
3067+/**
3068+ * A path split where the daemon has to be asked: the directory to list, and
3069+ * what has been typed of the name inside it.
3070+ *
3071+ * @param {string} token
3072+ * @returns {{ dir: string, prefix: string }}
3073+ */
3074+function splitPath(token) {
3075+ const cut = token.lastIndexOf("/");
3076+ if (cut < 0) return { dir: token, prefix: "" };
3077+ // A single leading slash is the root, and slicing it away would leave "".
3078+ if (cut === 0) return { dir: "/", prefix: token.slice(1) };
3079+ return { dir: token.slice(0, cut), prefix: token.slice(cut + 1) };
3080+}
3081+
3082+/**
3083+ * A session name from a directory name — what the last component of the path
3084+ * would be called if tmux would have it.
3085+ *
3086+ * `my.app` becomes `my-app`, because tmux rejects a dot in a session name. The
3087+ * row says what the session will be called for exactly this reason: the name
3088+ * and the directory are usually the same word, and when they are not, that is
3089+ * worth seeing before pressing Enter rather than after.
3090+ *
3091+ * @param {string} path an absolute path
3092+ */
3093+function projectSlug(path) {
3094+ const base = path.split("/").filter(Boolean).pop() ?? "";
3095+ const slug = base
3096+ .replace(/[^A-Za-z0-9_-]+/g, "-")
3097+ .replace(/^-+|-+$/g, "")
3098+ .slice(0, 64);
3099+ return validSessionName(slug) ? slug : "";
3100+}
3101+
3102+/**
3103+ * `slug`, or the first `slug-2`, `slug-3` that no session has taken.
3104+ *
3105+ * Only reached when the directory is *not* one we already have a session on —
3106+ * that case is a switch, not a second session. This is the other one: two
3107+ * different directories whose last component happens to be the same word.
3108+ *
3109+ * @param {string} slug
3110+ * @returns {string} empty when there is no free name, which is not a real case
3111+ */
3112+function freeSessionName(slug) {
3113+ if (!slug) return "";
3114+ const taken = (/** @type {string} */ name) => lastSessions.some((s) => s.name === name);
3115+ if (!taken(slug)) return slug;
3116+ for (let n = 2; n < 100; n++) {
3117+ const candidate = `${slug}-${n}`.slice(0, 64);
3118+ if (!taken(candidate)) return candidate;
3119+ }
3120+ return "";
3121+}
3122+
3123+/**
3124+ * The rows for a box that starts with `~`.
3125+ *
3126+ * @param {string} query
3127+ * @returns {TbOmniItem[]}
3128+ */
3129+function projectSuggestions(query) {
3130+ const s = query.trim();
3131+ // The first whitespace ends the path and begins the prompt. A directory with
3132+ // a space in its name cannot be typed here, which is the price of the prompt
3133+ // needing no punctuation of its own — and the completion rows will still walk
3134+ // you into one.
3135+ const space = s.search(/\s/);
3136+ const token = space < 0 ? s : s.slice(0, space);
3137+ const typedPrompt = space < 0 ? "" : s.slice(space + 1).trim();
3138+ const prompt = typedPrompt.length <= OMNI_PROMPT_MAX ? typedPrompt : "";
3139+ const { dir, prefix } = splitPath(token);
3140+
3141+ /** @type {(label: string, meta: string) => TbOmniItem[]} */
3142+ const hint = (label, meta) => [{ kind: "project", label, meta, score: 0, typed: true, hint: true }];
3143+
3144+ const answer = pathAnswers.get(dir);
3145+ if (!answer) {
3146+ askPath(dir);
3147+ return hint(token, "looking…");
3148+ }
3149+ if (answer.kind === "invalid") return hint(token, "not a path");
3150+ if (answer.kind === "denied") return hint(token, "cannot read that directory");
3151+ if (answer.kind === "file") return hint(token, "not a directory");
3152+
3153+ const dirs = answer.dirs ?? [];
3154+ const files = answer.files ?? [];
3155+ const base = (answer.path || "").replace(/\/+$/, "");
3156+ const target = prefix ? `${base}/${prefix}` : base;
3157+
3158+ // What is at the whole path, worked out from the one directory we asked
3159+ // about. `creates` counts what `mkdir -p` would have to make, and the daemon
3160+ // has already counted the part above this level.
3161+ let state = /** @type {TbPathFrame["kind"]} */ (answer.kind);
3162+ let creates = answer.creates ?? 0;
3163+ if (prefix) {
3164+ if (answer.kind !== "dir") {
3165+ state = "missing";
3166+ creates += 1;
3167+ } else if (dirs.includes(prefix)) {
3168+ state = "dir";
3169+ creates = 0;
3170+ } else if (files.includes(prefix)) {
3171+ state = "file";
3172+ } else {
3173+ state = "missing";
3174+ creates = 1;
3175+ }
3176+ }
3177+
3178+ /** @type {TbOmniItem[]} */
3179+ const rows = [];
3180+ const shown = tildePath(target);
3181+ const loudest = agentBySession(lastAgents);
3182+
3183+ if (state === "file") {
3184+ rows.push(...hint(shown, "not a directory"));
3185+ } else if (state === "dir") {
3186+ // The directory is already somebody's: go there rather than opening a
3187+ // second session on the same tree, which is the mistake this row exists to
3188+ // prevent. tmux reports a session's *current pane's* directory, so this is
3189+ // "a session sitting in that project", which is the question being asked.
3190+ const onIt = lastSessions.find((x) => x.path === target);
3191+ if (onIt) {
3192+ const here = onIt.name === sessionName;
3193+ if (prompt) {
3194+ rows.push({
3195+ kind: "claude",
3196+ label: prompt,
3197+ meta: `send to claude · ${shortPath(target)}`,
3198+ score: 0,
3199+ typed: true,
3200+ run: () => tmuxCommand({ cmd: "claude", session: onIt.name, prompt }),
3201+ });
3202+ }
3203+ rows.push({
3204+ kind: "session",
3205+ label: onIt.name,
3206+ meta: here ? `session · ${shown} · here` : `session · ${shown}`,
3207+ state: loudest[onIt.name]?.state,
3208+ score: 0,
3209+ typed: true,
3210+ // Switching to the session you are on does nothing, so it is said
3211+ // rather than offered — the row is still worth drawing, because "you
3212+ // already have this open" is the answer to what was typed.
3213+ hint: here,
3214+ run: here ? undefined : () => tmuxCommand({ cmd: "switch", session: onIt.name }),
3215+ });
3216+ } else {
3217+ rows.push(...projectRow({ token, dir, target, prompt, creates: 0, shown }));
3218+ }
3219+ } else {
3220+ rows.push(...projectRow({ token, dir, target, prompt, creates, shown }));
3221+ }
3222+
3223+ // Everything inside the directory that starts with what has been typed of the
3224+ // next name. Below the row that acts, because the thing you typed in full is
3225+ // a better answer than something it is a prefix of.
3226+ if (answer.kind === "dir") {
3227+ const q = prefix.toLowerCase();
3228+ // Hidden directories only when you have said so with a leading dot: `~/`
3229+ // otherwise offers a home directory's worth of dotfiles ahead of anything
3230+ // you keep work in.
3231+ const shownDirs = prefix.startsWith(".") ? dirs : dirs.filter((n) => !n.startsWith("."));
3232+ const matches = shownDirs
3233+ .map((name) => ({ name, score: omniScore(name, q) }))
3234+ .filter((m) => m.score >= 0 && m.name !== prefix)
3235+ .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
3236+ .slice(0, PATH_COMPLETIONS);
3237+ for (const { name, score } of matches) {
3238+ const next = `${dir === "/" ? "" : dir}/${name}`;
3239+ rows.push({
3240+ kind: "path",
3241+ label: name,
3242+ meta: dir,
3243+ score: 1 + score,
3244+ typed: true,
3245+ complete: true,
3246+ run: () => completePath(next, prompt),
3247+ });
3248+ }
3249+ }
3250+
3251+ return rows;
3252+}
3253+
3254+/**
3255+ * The row that makes the thing: a session in that directory, and the directory
3256+ * itself when it is not there yet.
3257+ *
3258+ * A list rather than an item so a path with no usable session name in it —
3259+ * `~/...` of nothing but punctuation — can answer with a hint instead.
3260+ *
3261+ * @param {{token: string, dir: string, target: string, prompt: string,
3262+ * creates: number, shown: string}} spec
3263+ * @returns {TbOmniItem[]}
3264+ */
3265+function projectRow({ token, dir, target, prompt, creates, shown }) {
3266+ const name = freeSessionName(projectSlug(target));
3267+ if (!name) {
3268+ return [
3269+ {
3270+ kind: "project",
3271+ label: shown,
3272+ meta: "no session name in that path",
3273+ score: 0,
3274+ typed: true,
3275+ hint: true,
3276+ },
3277+ ];
3278+ }
3279+ // What is about to happen, in the order it happens in. The count is there
3280+ // because "make one directory" and "make four" are different answers to what
3281+ // is usually a typo in the middle of a path.
3282+ const made = creates === 0 ? "" : creates === 1 ? "mkdir · " : `creates ${creates} dirs · `;
3283+ return [
3284+ {
3285+ kind: "project",
3286+ // The one thing that distinguishes it from the row below it is what it is
3287+ // about to start, so the mark comes with the item — see `omniActions`.
3288+ mark: prompt ? "✻" : "+",
3289+ label: shown,
3290+ meta: `${made}new session ${name}${prompt ? " · claude" : ""}`,
3291+ score: 0,
3292+ typed: true,
3293+ run: () => {
3294+ // The listing we hold for the directory above this one is about to be
3295+ // one name out of date.
3296+ if (creates > 0) forgetPath(dir);
3297+ tmuxCommand({
3298+ cmd: "new-project",
3299+ // The path as typed: `~` is the daemon's home, not the browser's, and
3300+ // one expansion of it is the only way the row and the mkdir agree.
3301+ path: token,
3302+ name,
3303+ ...(prompt ? { prompt } : {}),
3304+ });
3305+ },
3306+ },
3307+ ];
3308+}
3309+
3310+/**
3311+ * Take a completion: put the directory in the box with a trailing slash, which
3312+ * both says "there is more to come" and is what asks about the next level.
3313+ *
3314+ * The prompt rides along, so completing a path halfway through a sentence does
3315+ * not cost the sentence.
3316+ *
3317+ * @param {string} next
3318+ * @param {string} prompt
3319+ */
3320+function completePath(next, prompt) {
3321+ const input = $input("omni");
3322+ input.value = prompt ? `${next}/ ${prompt}` : `${next}/`;
3323+ omniDirty = true;
3324+ input.focus();
3325+ refreshOmni();
3326+}
3327+
3328+/**
3329+ * A path as a person refers to it. The row's dimmed half is a few characters
3330+ * wide, so this is the tail of it — the last two components, which is the part
3331+ * that says which project. The whole path is in the session info panel, which
3332+ * is where one is worth reading in full.
3333+ *
3334+ * The panel never sees `$HOME`, so home is recognised by shape: `/home/x` and
3335+ * `/Users/x`. Getting that wrong costs a `…` where a `~` would have read
3336+ * better, and nothing else.
3337+ *
3338+ * @param {string} p
3339+ */
3340+function shortPath(p) {
3341+ const parts = p.split("/").filter(Boolean);
3342+ const home = parts.length >= 2 && (parts[0] === "home" || parts[0] === "Users");
3343+ if (home && parts.length === 2) return "~";
3344+ const rest = home ? parts.slice(2) : parts;
3345+ const tail = rest.slice(-2).join("/");
3346+ if (home) return rest.length <= 2 ? `~/${tail}` : `~/…/${tail}`;
3347+ return rest.length <= 2 ? p : `…/${tail}`;
3348+}
3349+
3350+/**
3351+ * The whole path, with home written the way a shell writes it.
3352+ *
3353+ * Unlike {@link shortPath} nothing is dropped: this goes where the box's own
3354+ * overflow decides what fits, and eliding in advance means a `…` in a gap wide
3355+ * enough for the characters it replaced.
3356+ *
3357+ * @param {string} p
3358+ */
3359+function tildePath(p) {
3360+ const parts = p.split("/").filter(Boolean);
3361+ const home = parts.length >= 2 && (parts[0] === "home" || parts[0] === "Users");
3362+ if (!home) return p;
3363+ return parts.length === 2 ? "~" : `~/${parts.slice(2).join("/")}`;
3364+}
3365+
3366+/**
3367+ * True once the box holds a query rather than the location it was showing.
3368+ *
3369+ * An address bar's text is its value, not a label beside it: the session name
3370+ * *is* what is in the box, focusing selects the whole of it, and typing
3371+ * replaces it — so getting somewhere else is one shortcut and a few letters,
3372+ * with no clearing step in between. The flag is what keeps the two states
3373+ * apart, because "work" sitting in the box means "you are in work" until you
3374+ * touch it and "find me something called work" afterwards.
3375+ */
3376+let omniDirty = false;
3377+
3378+/**
3379+ * Put the location back in the box: the session this panel's client is on.
3380+ *
3381+ * Called on every status frame, so it has two things it must not walk over —
3382+ * a query being typed, and the selection that focusing just made.
3383+ */
3384+function syncOmniHere() {
3385+ const input = $input("omni");
3386+ const here = connected && tmuxMode && sessionName ? sessionName : "";
3387+
3388+ // Shown only when there is a session for it to describe: an info button over
3389+ // an empty box has nothing to open.
3390+ $("omni-here").hidden = !here;
3391+
3392+ // The directory follows the same rule as the name: it describes where you
3393+ // are, so it goes as soon as the box stops being a location and starts being
3394+ // a query. The full path stays one click away in the info panel.
3395+ const cwd = $("omni-cwd");
3396+ const path = here ? lastSessions.find((s) => s.name === sessionName)?.path : null;
3397+ const showCwd = !!path && !omniDirty && document.activeElement !== input;
3398+ cwd.hidden = !showCwd;
3399+ if (showCwd && path) {
3400+ cwd.textContent = "";
3401+ cwd.appendChild(el("span", { text: tildePath(path) }));
3402+ cwd.title = path;
3403+ }
3404+
3405+ if (omniDirty || document.activeElement === input) {
3406+ takePendingOmniFocus();
3407+ return;
3408+ }
3409+ input.value = here;
3410+ input.title = here
3411+ ? `${here} — type to jump to a window, session or Claude pane, ` +
3412+ `name a session to create it, or ask a new Claude in this directory`
3413+ : "Jump to a window, session or Claude pane";
3414+ takePendingOmniFocus();
3415+}
3416+
3417+// --- session info -----------------------------------------------------------
3418+//
3419+// What is behind the dot, and the same bargain a browser's padlock makes: the
3420+// box has room for a name and nothing else, so the identity behind that name
3421+// gets a panel of its own one click away. There it is the origin, its
3422+// certificate and what the page is allowed to do; here it is the session — the
3423+// working directory above all, which is the one thing about a session its name
3424+// never tells you and the first thing you want to know before typing into it.
3425+//
3426+// Everything in it is a tmux string, so all of it reaches the DOM through
3427+// textContent.
3428+
3429+/** Set while the panel is up, so a status frame redraws it in place. */
3430+let infoOpen = false;
3431+/** Reset whenever the panel opens: the copy button's label is a one-shot. */
3432+let infoCopied = false;
3433+
3434+/**
3435+ * Rough and one unit deep, which is all an age is read for here: whether this
3436+ * session is from this morning or from last week.
3437+ *
3438+ * @param {number} created Unix seconds
3439+ */
3440+function sessionAge(created) {
3441+ const secs = Math.max(0, Math.floor(Date.now() / 1000 - created));
3442+ const units = /** @type {const} */ ([
3443+ [86400, "d"],
3444+ [3600, "h"],
3445+ [60, "m"],
3446+ ]);
3447+ for (const [size, suffix] of units) {
3448+ if (secs >= size) return `${Math.floor(secs / size)}${suffix} ago`;
3449+ }
3450+ return "just now";
3451+}
3452+
3453+/**
3454+ * One label-and-value line.
3455+ *
3456+ * @param {string} key
3457+ * @param {string} value
3458+ * @param {boolean} [path] a filesystem path, which is elided from the front
3459+ */
3460+function infoRow(key, value, path) {
3461+ return el(
3462+ "div",
3463+ { class: "info-row" },
3464+ el("span", { class: "k", text: key }),
3465+ path
3466+ ? // The `rtl` that puts the ellipsis on the left would also reorder the
3467+ // path's own punctuation, so the text itself is wrapped back to `ltr`.
3468+ el("span", { class: "v path", title: value }, el("span", { text: value }))
3469+ : el("span", { class: "v", title: value, text: value }),
3470+ );
3471+}
3472+
3473+/**
3474+ * Draw the panel's contents from the current frame. Called again on every
3475+ * status frame while it is open, so a Claude that starts working, a window
3476+ * that opens or a second client attaching all show up without reopening it.
3477+ *
3478+ * @param {HTMLElement} pop
3479+ * @param {TbSessionInfo} s
3480+ */
3481+function fillSessionInfo(pop, s) {
3482+ pop.textContent = "";
3483+
3484+ const colour = groupColor(s.name);
3485+ pop.appendChild(
3486+ el(
3487+ "div",
3488+ { class: "info-head" },
3489+ el("span", {
3490+ class: `dot${colour === GROUP_GREY ? " grey" : ""}`,
3491+ css: { "--group-h": String(colour) },
3492+ }),
3493+ el("span", { class: "name", text: s.name }),
3494+ // The id, because a rename changes the name and not this — and because it
3495+ // is what a `tmux` command typed by hand wants.
3496+ el("span", { class: "sub", text: s.id }),
3497+ ),
3498+ );
3499+
3500+ const panes = s.windows.reduce((n, w) => n + w.panes, 0);
3501+ const here = s.windows.find((w) => w.active);
3502+ pop.appendChild(infoRow("Directory", s.path || "unknown", true));
3503+ if (here) pop.appendChild(infoRow("Window", `${here.index}: ${here.name}`));
3504+ pop.appendChild(
3505+ infoRow(
3506+ "Contents",
3507+ `${s.windows.length} window${s.windows.length === 1 ? "" : "s"} · ` +
3508+ `${panes} pane${panes === 1 ? "" : "s"}`,
3509+ ),
3510+ );
3511+ // Worth saying plainly: a second client on the same session is why what you
3512+ // type here appears somewhere else too.
3513+ const clients = s.clients ?? (s.attached ? 1 : 0);
3514+ pop.appendChild(
3515+ infoRow(
3516+ "Attached",
3517+ clients <= 1 ? "this panel only" : `${clients} clients — this panel and ${clients - 1} more`,
3518+ ),
3519+ );
3520+ if (s.created) pop.appendChild(infoRow("Started", sessionAge(s.created)));
3521+
3522+ const mine = lastAgents.filter((a) => a.session === s.name);
3523+ pop.appendChild(
3524+ el(
3525+ "div",
3526+ { class: "info-agents" },
3527+ mine.length === 0 && el("div", { class: "info-empty", text: "No Claude running here" }),
3528+ ...mine.map((a) =>
3529+ el(
3530+ "div",
3531+ { class: "info-agent" },
3532+ glyphSpan(a.state),
3533+ el("span", {
3534+ class: "what",
3535+ text: a.title || agentLabel(a),
3536+ title: `claude ${a.state} — ${agentLabel(a)}`,
3537+ }),
3538+ el("span", { class: "where", text: a.window }),
3539+ ),
3540+ ),
3541+ ),
3542+ );
3543+
3544+ // The one thing here that is wanted somewhere else: a path is typed into
3545+ // another shell, a file manager or an editor far more often than it is read.
3546+ if (s.path) {
3547+ const copy = button({
3548+ class: "info-copy",
3549+ text: infoCopied ? "Copied" : "Copy path",
3550+ on: {
3551+ click: () =>
3552+ navigator.clipboard.writeText(s.path ?? "").then(
3553+ () => {
3554+ infoCopied = true;
3555+ copy.textContent = "Copied";
3556+ },
3557+ () => {
3558+ copy.textContent = "Couldn't copy";
3559+ },
3560+ ),
3561+ },
3562+ });
3563+ pop.appendChild(copy);
3564+ }
3565+}
3566+
3567+/** Redraw an open panel from the frame that just arrived. */
3568+function refreshSessionInfo() {
3569+ if (!infoOpen || !openMenu) return;
3570+ const s = lastSessions.find((x) => x.name === sessionName);
3571+ // The session went away — closing the panel is the honest answer, and it is
3572+ // what the omnibar above it is about to do with the name too.
3573+ if (!s) return closeTabMenu();
3574+ fillSessionInfo(openMenu, s);
3575+}
3576+
3577+/**
3578+ * Open it under the dot, the way a browser drops its site panel out of the
3579+ * padlock. Shares the tab menu's machinery — one thing open at a time, Escape
3580+ * and a click anywhere else close it.
3581+ */
3582+function openSessionInfo() {
3583+ const wasOpen = infoOpen;
3584+ closeTabMenu();
3585+ // The dot is a toggle: clicking it again is how you put the panel away
3586+ // without having to find somewhere neutral to click.
3587+ if (wasOpen) return;
3588+ const s = lastSessions.find((x) => x.name === sessionName);
3589+ if (!s) return;
3590+
3591+ infoCopied = false;
3592+ const pop = el("div", {
3593+ class: "info-pop",
3594+ attrs: { role: "dialog", "aria-label": `Session ${s.name}` },
3595+ });
3596+ fillSessionInfo(pop, s);
3597+
3598+ document.body.appendChild(pop);
3599+ const anchor = $("omni-here").getBoundingClientRect();
3600+ const r = pop.getBoundingClientRect();
3601+ // Hung off the dot's left edge, and folded back inside when the panel is
3602+ // narrower than the bubble wants to be.
3603+ pop.style.left = `${Math.max(4, Math.min(anchor.left - 4, window.innerWidth - r.width - 4))}px`;
3604+ pop.style.top = `${Math.min(anchor.bottom + 4, Math.max(0, window.innerHeight - r.height - 4))}px`;
3605+ openMenu = pop;
3606+ infoOpen = true;
3607+ $("omni-here").setAttribute("aria-expanded", "true");
3608+ setTimeout(() => {
3609+ window.addEventListener("pointerdown", onDismiss, { once: true, capture: true });
3610+ }, 0);
3611+}
3612+
3613+// mousedown rather than click for the guard: the pill hands focus to the input
3614+// on a press anywhere inside it, and the panel opening under a focused omnibar
3615+// would sit over the list that focus drops down.
3616+$("omni-here").addEventListener("mousedown", (e) => e.preventDefault());
3617+$("omni-here").addEventListener("click", openSessionInfo);
3618+
3619+/** Drop whatever was typed and show the location again. */
3620+function revertOmni() {
3621+ omniDirty = false;
3622+ const input = $input("omni");
3623+ input.value = connected && tmuxMode && sessionName ? sessionName : "";
3624+ input.select();
3625+ refreshOmni();
3626+}
3627+
3628+/** Rebuild the dropdown from whatever is in the box. */
3629+function refreshOmni() {
3630+ const input = $input("omni");
3631+ const list = $("omni-list");
3632+ syncOmniHere();
3633+ if (tabMode !== "groups" || !connected || !tmuxMode) return closeOmni();
3634+
3635+ // The id of the row that was chosen, so a status frame arriving mid-type
3636+ // does not move the selection out from under the next Enter.
3637+ const chosen = omniItems[omniActive];
3638+ // Untouched, the box is showing where you are, not asking for it: the list
3639+ // that goes with that is the other places, the same way a browser drops down
3640+ // suggestions rather than searching for the URL already in the bar.
3641+ const query = omniDirty ? input.value : "";
3642+ omniItems = omniSuggestions(query);
3643+ omniActive = chosen
3644+ ? omniItems.findIndex((i) => i.kind === chosen.kind && i.label === chosen.label)
3645+ : -1;
3646+
3647+ list.textContent = "";
3648+ if (!omniItems.length) return closeOmni();
3649+
3650+ const q = query.trim().toLowerCase();
3651+ omniItems.forEach((item, i) => list.appendChild(omniRow(item, i, q)));
3652+ list.hidden = false;
3653+ // Anchored to the row it drops out of rather than to the panel, so it lines
3654+ // up with the box whatever the density is doing to the header's height.
3655+ const r = $("omni-strip").getBoundingClientRect();
3656+ list.style.top = `${r.bottom}px`;
3657+ syncOmniActive();
3658+}
3659+
3660+/**
3661+ * @param {TbOmniItem} item
3662+ * @param {number} i
3663+ * @param {string} q the matched substring, for the highlight
3664+ */
3665+function omniRow(item, i, q) {
3666+ // Split around the match so the part you typed can be picked out. Three
3667+ // textContent assignments, never markup — these are tmux's names.
3668+ const at = q ? item.label.toLowerCase().indexOf(q) : -1;
3669+ // The rows whose label is the query itself have nothing to highlight: every
3670+ // character of them was typed.
3671+ const label =
3672+ at >= 0 && !item.typed
3673+ ? el(
3674+ "span",
3675+ { class: "label" },
3676+ el("span", { text: item.label.slice(0, at) }),
3677+ el("b", { text: item.label.slice(at, at + q.length) }),
3678+ el("span", { text: item.label.slice(at + q.length) }),
3679+ )
3680+ : el("span", { class: "label", text: item.label });
3681+
3682+ return el(
3683+ "div",
3684+ {
3685+ class: `omni-row ${item.kind}${item.hint ? " hint" : ""}`,
3686+ attrs: { id: `omni-row-${i}` },
3687+ data: { index: String(i) },
3688+ on: {
3689+ // mousedown rather than click for the guard: the input would otherwise
3690+ // blur before the click landed, and blur closes the list out from
3691+ // under it.
3692+ /** @param {MouseEvent} e */
3693+ mousedown: (e) => e.preventDefault(),
3694+ click: () => runOmni(i),
3695+ mousemove: () => {
3696+ if (omniActive === i) return;
3697+ omniActive = i;
3698+ syncOmniActive();
3699+ },
3700+ },
3701+ },
3702+ glyphSpan(item.state),
3703+ // The kinds whose rows are all alike get their character from CSS. An action
3704+ // row does not: what it is about to make is the whole of what distinguishes
3705+ // it from the action below it, so the mark comes with the item.
3706+ item.mark && el("span", { class: "mark", text: item.mark, attrs: { "aria-hidden": "true" } }),
3707+ label,
3708+ el("span", { class: "meta", text: item.meta }),
3709+ );
3710+}
3711+
3712+/** Paint the chosen row. */
3713+function syncOmniActive() {
3714+ const rows = [...$("omni-list").children];
3715+ rows.forEach((row, i) => row.classList.toggle("active", i === omniActive));
3716+ const active = rows[omniActive];
3717+ if (active) active.scrollIntoView({ block: "nearest" });
3718+}
3719+
3720+function closeOmni() {
3721+ const list = $("omni-list");
3722+ list.hidden = true;
3723+ list.textContent = "";
3724+ omniItems = [];
3725+ omniActive = -1;
3726+}
3727+
3728+/**
3729+ * Run a row and get out of the way. The box goes back to being the location:
3730+ * the command has been sent, and the status frame that answers it will put the
3731+ * new session's name here a moment later — this just stops the query it was
3732+ * holding from looking like where you are in the meantime.
3733+ *
3734+ * @param {number} i
3735+ */
3736+function runOmni(i) {
3737+ const item = omniItems[i];
3738+ if (!item) return;
3739+ // A hint has nothing to run, and closing the list on Enter would take the
3740+ // thing it is explaining off the screen. It stays put and the box keeps focus.
3741+ if (!item.run) return;
3742+ // A completion is not a destination: it puts a longer path in the box and
3743+ // leaves you typing, so nothing here closes or hands focus back.
3744+ if (item.complete) {
3745+ item.run();
3746+ return;
3747+ }
3748+ item.run();
3749+ omniDirty = false;
3750+ closeOmni();
3751+ term.focus();
3752+ syncOmniHere();
3753+}
3553754
356- sock.onerror = () => {
357- // The browser hides the reason from JS by design. The daemon's stdout has
358- // the real one (unpaired origin / bad token / rate limited).
359- log("socket error — check the daemon's output for the reason");
360- // Firefox's HTTPS-Only Mode silently rewrites ws:// to wss://, so the most
361- // likely cause here is that our self-signed certificate isn't trusted yet.
362- $("trust-hint").hidden = false;
363- };
3755+/** @param {number} delta */
3756+function moveOmni(delta) {
3757+ if (!omniItems.length) return;
3758+ // Wraps, and starts at the top going down / the bottom going up: with
3759+ // nothing chosen there is no "next" that isn't the first one.
3760+ const n = omniItems.length;
3761+ omniActive = omniActive < 0 ? (delta > 0 ? 0 : n - 1) : (omniActive + delta + n) % n;
3762+ syncOmniActive();
3763+}
3643764
365- sock.onclose = (ev) => {
366- connected = false;
367- sessionName = null;
368- tmuxMode = false;
369- renderTabs([], []);
370- renderSessionSwitch([], null);
371- refreshStatus();
372- log(`closed code=${ev.code} ${ev.reason || ""}`);
373- };
3765+/**
3766+ * When the shortcut is what opened the panel, it arrives ahead of everything
3767+ * the box is made of: the mode comes from storage, the name from the server,
3768+ * and neither is here yet. Held as a time rather than a flag so a request that
3769+ * never becomes answerable expires instead of ambushing a later frame — a mode
3770+ * switch minutes on is not this shortcut still landing.
3771+ */
3772+let omniFocusAsked = 0;
3773+const OMNI_FOCUS_WAIT_MS = 15_000;
3774+
3775+/** The first frame with a box to focus honours a request that came too early. */
3776+function takePendingOmniFocus() {
3777+ if (!omniFocusAsked) return;
3778+ if (Date.now() - omniFocusAsked > OMNI_FOCUS_WAIT_MS) {
3779+ omniFocusAsked = 0;
3780+ return;
3781+ }
3782+ if (tabMode !== "groups" || !connected || !tmuxMode) return;
3783+ omniFocusAsked = 0;
3784+ focusOmni();
3743785 }
3753786
376-// Ask the daemon to move tmux. The daemon accepts three commands and validates
377-// every argument; the sidebar cannot name a tmux command of its own.
3783787 /**
379- * @param {{ cmd: "switch"|"create"|"focus"|"select-window"|"new-window"|"kill-window" }
380- * & Record<string, string>} body
3788+ * Put the caret in the box, from wherever focus was.
3789+ *
3790+ * Whether the keyboard follows is not this document's to decide. Chrome hands
3791+ * the panel focus when it opens it and at no other time — there is no API to
3792+ * focus a panel that is already up — so the caret and the selection made here
3793+ * are real either way, but they only *look* like a selection when the panel is
3794+ * the focused surface. The worker leans on that: a shortcut pressed while the
3795+ * panel is closed becomes an open, which is the path that focuses.
3813796 */
382-function tmuxCommand(body) {
383- if (!connected || !ws) return;
384- ws.send(JSON.stringify({ type: "tmux", ...body }));
3797+function focusOmni() {
3798+ // Not ready to hold a caret yet. Remember the ask; the next frame that has a
3799+ // box takes it.
3800+ if (tabMode !== "groups" || !connected || !tmuxMode) {
3801+ omniFocusAsked = Date.now();
3802+ return;
3803+ }
3804+ omniFocusAsked = 0;
3805+ const input = $input("omni");
3806+
3807+ // A panel coming up for the first time gets its focus somewhere in the next
3808+ // few hundred milliseconds, and a selection made before that arrives is
3809+ // collapsed back to a caret when it does. So this takes the caret and the
3810+ // selection back across that window. Only two things end it early, and both
3811+ // mean the box is already being used: text typed into it, or a click placing
3812+ // the caret by hand.
3813+ let live = true;
3814+ const stop = () => {
3815+ live = false;
3816+ input.removeEventListener("input", stop);
3817+ input.removeEventListener("mousedown", stop);
3818+ window.removeEventListener("focus", reselect);
3819+ };
3820+ const reselect = () => {
3821+ if (!live) return;
3822+ input.focus();
3823+ input.select();
3824+ // The list belongs to the same gesture as the caret, and the same startup
3825+ // churn that drops the selection can close it. Put it back too, but only
3826+ // when it is gone: rebuilding an open list would move the chosen row out
3827+ // from under an arrow key.
3828+ if ($("omni-list").hidden) {
3829+ omniDirty = false;
3830+ refreshOmni();
3831+ }
3832+ };
3833+ input.addEventListener("input", stop);
3834+ input.addEventListener("mousedown", stop);
3835+ window.addEventListener("focus", reselect);
3836+
3837+ reselect();
3838+ for (const ms of [0, 16, 50, 120, 250, 400, 600]) setTimeout(reselect, ms);
3839+ setTimeout(stop, 800);
3840+
3841+ // The list drops down on focus, and that is the focus event's doing — which
3842+ // does not fire when the box already held the caret, and cannot be counted
3843+ // on when the panel is still coming up around it. Asking for it here makes
3844+ // the shortcut mean the same thing however it arrived: the box, its name
3845+ // selected, and everywhere else already listed under it.
3846+ omniDirty = false;
3847+ refreshOmni();
3853848 }
3863849
387-// --- window tabs ------------------------------------------------------------
3850+$input("omni").addEventListener("input", () => {
3851+ // The location has been typed over, so it is a query from here on.
3852+ omniDirty = true;
3853+ refreshOmni();
3854+});
3855+
3856+// Focus selects the whole name, so the first letter typed replaces it — the one
3857+// behaviour that makes "the box holds where you are" and "the box is how you go
3858+// somewhere else" the same box. Opening the list here rather than on the first
3859+// keystroke: with nothing typed it is already the list of everywhere else.
3860+$input("omni").addEventListener("focus", () => {
3861+ omniDirty = false;
3862+ $input("omni").select();
3863+ refreshOmni();
3864+});
3865+
3866+// Late enough for a row's own click to have run first. Leaving focus abandons
3867+// whatever was typed, exactly as a browser's does — the box goes back to
3868+// saying where you are.
3869+$input("omni").addEventListener("blur", () =>
3870+ setTimeout(() => {
3871+ // A blur that leaves the caret where it was is the panel gaining or losing
3872+ // the keyboard, not the box being left — and that happens under the box on
3873+ // the way up, when the shortcut is what opened this panel. Closing on it
3874+ // would take the list away from a box that is still focused.
3875+ if (document.activeElement === $input("omni")) return;
3876+ closeOmni();
3877+ omniDirty = false;
3878+ syncOmniHere();
3879+ }, 0),
3880+);
3881+
3882+$input("omni").addEventListener("keydown", (e) => {
3883+ const ev = /** @type {KeyboardEvent} */ (e);
3884+ const key = ev.key;
3885+ // Ctrl+J / Ctrl+K move the selection too, but only while the list is up:
3886+ // with nothing open they belong to the terminal, and Ctrl+K in particular is
3887+ // a line-kill an emacs-keyed shell expects to get.
3888+ if (ev.ctrlKey && !ev.altKey && !ev.metaKey && (key === "j" || key === "k") && omniItems.length) {
3889+ e.preventDefault();
3890+ moveOmni(key === "j" ? 1 : -1);
3891+ } else if (key === "ArrowDown" || key === "ArrowUp") {
3892+ e.preventDefault();
3893+ moveOmni(key === "ArrowDown" ? 1 : -1);
3894+ } else if (key === "Tab" && omniItems.some((i) => i.complete)) {
3895+ // What Tab has meant in every box that has ever held a path: take the
3896+ // completion. The chosen one if a row is chosen, the first otherwise, which
3897+ // is the same rule Enter follows.
3898+ e.preventDefault();
3899+ const active = omniItems[omniActive];
3900+ const item = active?.complete ? active : omniItems.find((i) => i.complete);
3901+ item?.run?.();
3902+ } else if (key === "Enter") {
3903+ e.preventDefault();
3904+ // Enter with nothing chosen takes the top row, which is what the list is
3905+ // sorted for — you type three letters and press Enter without looking.
3906+ runOmni(omniActive < 0 ? 0 : omniActive);
3907+ } else if (key === "Escape") {
3908+ e.preventDefault();
3909+ // First Escape puts the location back, the second gives the terminal back
3910+ // — the same two steps Escape takes in a browser's address bar.
3911+ if (omniDirty) revertOmni();
3912+ else {
3913+ closeOmni();
3914+ term.focus();
3915+ }
3916+ }
3917+});
3918+
3919+// --- session tabs -----------------------------------------------------------
3883920 //
389-// A tab is a window of the attached session — the thing `prefix 2` selects, not
390-// a session and not a pane. Window names come from tmux (a shell sets them from
391-// whatever it is running, so they are as page-influenced as any other terminal
392-// output) and only ever reach the DOM through textContent.
393-
394-// --- pinning ----------------------------------------------------------------
3921+// The top row: every session on the server. Selecting one is a switch-client —
3922+// the client this panel holds moves, so the pty underneath is never re-spawned
3923+// and nothing running is disturbed.
3953924 //
396-// A pin is a property of this panel, not of the session. tmux is never asked to
397-// renumber anything: the window keeps its real index, so `prefix 4` still goes
398-// where it always went, and nothing about your terminal's status line changes.
399-// All a pin does is move the tab to the front of *this* strip and shrink it to
400-// its dot and index, which is what buys the room in a narrow sidebar.
3925+// Session names come from tmux (a user or a shell script named them, and both
3926+// can put anything in a name) and only ever reach the DOM through textContent.
4013927 //
402-// Keyed by session name, because window ids are only meaningful inside the tmux
403-// server that issued them and only prunable against the session we can see.
404-/** @type {Record<string, string[]>} */
405-let pins = {};
3928+// Order: the daemon sends them oldest first, so a session you just made is on
3929+// the end rather than wherever its name sorts. Dragging a tab overrides that,
3930+// and the override is this panel's own — tmux has no notion of session order to
3931+// change, unlike windows, which are dragged with a real move-window.
3932+/** @type {string[]} session names, in the order this panel shows them */
3933+let sessionOrder = [];
4063934
407-// The last frame's worth of windows, so a pin — which changes nothing on the
408-// wire and so produces no status frame — can repaint the strip on its own.
409-/** @type {TbWindowInfo[]} */
410-let lastWindows = [];
411-/** @type {TbAgent[]} */
412-let lastAgents = [];
413-
414-/** @returns {Set<string>} pinned window ids in the attached session */
415-function pinnedIds() {
416- return new Set((sessionName && pins[sessionName]) || []);
3935+/**
3936+ * Saved order first, in its own sequence; then everything it doesn't mention,
3937+ * in the daemon's (creation) order. A session that comes back after a while
3938+ * therefore returns to where you last put it, and a brand new one lands last.
3939+ *
3940+ * @param {TbSessionInfo[]} sessions
3941+ * @returns {TbSessionInfo[]}
3942+ */
3943+function orderSessions(sessions) {
3944+ const known = new Map(sessions.map((s) => [s.name, s]));
3945+ /** @type {TbSessionInfo[]} */
3946+ const out = [];
3947+ for (const name of sessionOrder) {
3948+ const s = known.get(name);
3949+ if (s) {
3950+ out.push(s);
3951+ known.delete(name);
3952+ }
3953+ }
3954+ return [...out, ...known.values()];
4173955 }
4183956
419-/** @param {Set<string>} ids */
420-function savePins(ids) {
421- if (!sessionName) return;
422- if (ids.size) pins[sessionName] = [...ids];
423- else delete pins[sessionName];
424- storage.set({ pins });
3957+/** @param {string[]} names the row's order, as dragged */
3958+function saveSessionOrder(names) {
3959+ sessionOrder = names;
3960+ storage.set({ sessionOrder });
4253961 }
4263962
4273963 /**
428- * @param {TbWindowInfo[]} windows in index order
429- * @param {TbAgent[]} agents so a tab can say what Claude is doing in it
3964+ * @param {TbSessionInfo[]} unordered as the daemon sent them
3965+ * @param {string | null | undefined} current the session this panel is on
3966+ * @param {TbAgent[]} agents server-wide, for the glyph on each tab
4303967 */
431-function renderTabs(windows, agents) {
432- lastWindows = windows;
433- lastAgents = agents;
434- const strip = $("tabs");
435- const show = connected && tmuxMode && windows.length > 0;
436- $("tab-new").hidden = !(connected && tmuxMode && sessionName);
3968+function renderSessionTabs(unordered, current, agents) {
3969+ const sessions = orderSessions(unordered);
3970+ const strip = $("sessions");
3971+ // A repaint mid-drag would tear the tab out from under the pointer, and the
3972+ // frames arrive once a second whether or not anything moved.
3973+ if (dragging && !strip.hidden) return;
3974+ const show = connected && tmuxMode && sessions.length > 0;
4373975 strip.hidden = !show;
3976+ $("session-new").hidden = !show;
3977+ // Two things cannot both hold the row: a tab carries the session name, so
3978+ // the status text only speaks when there is no tab to speak for it.
3979+ document.body.classList.toggle("has-session", show);
4383980 if (!show) {
4393981 strip.textContent = "";
4403982 strip.dataset.sig = "";
3983+ hideSessionInput();
4413984 syncSpinner();
4423985 return;
4433986 }
4443987
445- // A window that closed takes its pin with it. Pruning is safe here and only
446- // here: this is the one moment we hold the full window list for a session.
447- const pinned = pinnedIds();
448- const live = new Set(windows.map((w) => w.id));
449- for (const id of pinned) {
450- if (!live.has(id)) pinned.delete(id);
451- }
452- if (pinned.size !== ((sessionName && pins[sessionName]) || []).length) savePins(pinned);
453-
454- // Pinned first, each group still in tmux's own index order — nothing is
455- // reordered on the server, so the indexes stay the ground truth they are.
456- const ordered = [
457- ...windows.filter((w) => pinned.has(w.id)),
458- ...windows.filter((w) => !pinned.has(w.id)),
459- ];
460-
461- // Repainting drops hover and any focus ring; status frames arrive on every
462- // tmux notification and once a second besides.
463- const claude = agentByWindow(agents);
3988+ const claude = agentBySession(agents);
4643989 const sig = JSON.stringify(
465- ordered.map((w) => {
466- const a = claude[w.id];
467- return [w.id, w.index, w.name, w.active, w.activity, a?.state, agentLabel(a), pinned.has(w.id)];
3990+ sessions.map((s) => {
3991+ const a = claude[s.name];
3992+ const selected = s.name === current;
3993+ // The selected tab draws no glyph, so what its agent is doing cannot
3994+ // change what it looks like — and must not be in here, or every state
3995+ // change in the session you are *on* rebuilds the whole row and drops
3996+ // whatever the pointer was hovering. The tooltip still names it, and a
3997+ // tooltip is not worth a repaint.
3998+ return [
3999+ s.name,
4000+ s.windows.length,
4001+ s.attached,
4002+ selected,
4003+ selected ? null : a?.state,
4004+ selected ? null : agentLabel(a),
4005+ ];
4684006 }),
4694007 );
4704008 if (strip.dataset.sig !== sig) {
4714009 strip.dataset.sig = sig;
4724010 strip.textContent = "";
473- // Killing a session's last window kills the session, and with it the client
474- // this panel is looking through. That is not a tab close, so it isn't
475- // offered as one.
476- const closable = windows.length > 1;
477- for (const w of ordered) {
478- strip.appendChild(windowTab(w, claude[w.id], closable, pinned.has(w.id)));
4011+ for (const s of sessions) {
4012+ strip.appendChild(sessionTab(s, s.name === current, claude[s.name]));
4794013 }
4804014 }
4814015
4824016 syncSpinner();
4834017
4844018 const active = strip.querySelector('[aria-selected="true"]');
485- // Sidebars are narrow enough that the window you are on can be scrolled out
486- // of the strip entirely.
4019+ // A narrow panel scrolls this row too, and the session you are on is the one
4020+ // that has to stay in sight.
4874021 if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
4884022 }
4894023
490-// Loudest first: a window with something waiting on you outranks one that is
491-// merely busy. A window with no Claude in it gets no entry at all.
492-const AGENT_RANK = ["waiting", "working", "idle", "unknown"];
493-
4944024 /**
4954025 * @param {TbAgent[]} agents
496- * @returns {Record<string, TbAgent>} window id → the one worth reporting
4026+ * @returns {Record<string, TbAgent>} session name → the one worth reporting
4974027 */
498-function agentByWindow(agents) {
4028+function agentBySession(agents) {
4994029 /** @type {Record<string, TbAgent>} */
5004030 const out = {};
5014031 for (const a of agents) {
502- const seen = out[a.window_id];
4032+ const seen = out[a.session];
5034033 if (!seen || AGENT_RANK.indexOf(a.state) < AGENT_RANK.indexOf(seen.state)) {
504- out[a.window_id] = a;
4034+ out[a.session] = a;
5054035 }
5064036 }
5074037 return out;
5084038 }
5094039
5104040 /**
511- * A slot rather than a bare button, because the close ✕ has to be a sibling:
512- * a button inside a button is invalid markup and unreachable to a screen
513- * reader, and closing a window is not selecting it.
514- *
515- * @param {TbWindowInfo} w
516- * @param {TbAgent} [claude] the agent worth reporting in this window, if any
517- * @param {boolean} [closable]
518- * @param {boolean} [isPinned]
519- */
520-function windowTab(w, claude, closable, isPinned) {
521- // A pinned tab drops its name and its ✕, the same two things Chrome takes
522- // away: it is down to a dot and a number, and it cannot be closed by a
523- // mis-aimed click. The name is still in the tooltip.
524- const canClose = closable && !isPinned;
525- const slot = document.createElement("div");
526- slot.className =
527- `tab-slot${w.active ? " active" : ""}${canClose ? " closable" : ""}` +
528- `${isPinned ? " pinned" : ""}`;
529-
530- const tab = document.createElement("button");
531- tab.type = "button";
532- tab.className = "tab";
533- // Activity is tmux's own "something happened here while you were away", and
534- // it is the whole reason a background tab is worth looking at.
535- if (!w.active && w.activity) tab.classList.add("activity");
536- tab.setAttribute("role", "tab");
537- tab.setAttribute("aria-selected", String(w.active));
538- tab.dataset.window = w.id;
539-
540- const glyph = document.createElement("span");
541- // The favicon slot. Empty unless Claude is in there, so a plain window of
542- // shell doesn't wear a status light it has no state to report.
543- const state = claude?.state ?? "none";
544- glyph.className = `glyph ${state}`;
545- glyph.textContent = state === "working" ? spinnerGlyph() : STATIC_GLYPH[state] ?? "";
546- glyph.setAttribute("aria-hidden", "true");
547- tab.appendChild(glyph);
548-
549- const index = document.createElement("span");
550- index.className = "index";
551- index.textContent = String(w.index);
552- tab.appendChild(index);
553-
554- if (!isPinned) {
555- const name = document.createElement("span");
556- name.className = "name";
557- name.textContent = w.name;
558- tab.appendChild(name);
559- }
560-
561- // With no chip row left, the tooltip is where the detail lives: which tool is
562- // in flight, what Claude is blocked on, and — for a pinned tab — the name it
563- // gave up to fit.
564- tab.title = [
565- `window ${w.index}: ${w.name}`,
566- w.panes > 1 ? `${w.panes} panes` : "",
567- claude ? `claude ${claude.state} — ${agentLabel(claude)}` : "",
568- claude?.title ?? "",
569- !w.active && w.activity ? "activity" : "",
570- isPinned ? "pinned — right-click to unpin" : "",
571- ]
572- .filter(Boolean)
573- .join("\n");
574-
575- tab.addEventListener("click", () => {
576- // Selecting a window is a property of the session, so this moves anyone
577- // else watching it too — exactly as pressing prefix-2 in the terminal does.
578- if (!w.active) tmuxCommand({ cmd: "select-window", window: w.id });
579- term.focus();
580- });
581- slot.appendChild(tab);
582-
583- if (canClose) slot.appendChild(closeButton(w));
584-
585- slot.addEventListener("contextmenu", (e) => {
586- e.preventDefault();
587- openTabMenu(e, w, !!isPinned, !!closable);
588- });
589- return slot;
4041+ * @param {TbSessionInfo} s
4042+ * @param {boolean} selected
4043+ * @param {TbAgent} [claude] the agent worth reporting anywhere in this session
4044+ */
4045+function sessionTab(s, selected, claude) {
4046+ const tab = button(
4047+ {
4048+ class: `session-tab${s.attached && !selected ? " attached" : ""}`,
4049+ attrs: { role: "tab", "aria-selected": selected },
4050+ data: { session: s.name },
4051+ // No window count on the tab. The row below it *is* the count for the
4052+ // session you are on, and for the others the number was never the thing
4053+ // you were choosing by — the name is. It stays in the tooltip.
4054+ title: tip(
4055+ `session ${s.name}`,
4056+ `${s.windows.length} window${s.windows.length === 1 ? "" : "s"}`,
4057+ s.attached && !selected && "attached elsewhere",
4058+ claude && `claude ${claude.state} — ${agentLabel(claude)}`,
4059+ ),
4060+ on: {
4061+ click: () => {
4062+ // Moves the existing client: no reconnect, no second pty, and whatever
4063+ // is running in the session we leave keeps running.
4064+ if (!selected) tmuxCommand({ cmd: "switch", session: s.name });
4065+ term.focus();
4066+ },
4067+ },
4068+ },
4069+ // The glyph goes on a session you are not on and nowhere else. It reports the
4070+ // loudest agent *anywhere* in the session, which is worth a light when the
4071+ // windows it is summarising are out of sight — and is nothing but a second,
4072+ // coarser copy of the window row when they are not. On the selected tab it
4073+ // also sits an inch above a spinner saying the same thing about the same
4074+ // Claude, animating out of step with it, which is the distracting part.
4075+ !selected && glyphSpan(claude?.state),
4076+ el("span", { class: "name", text: s.name }),
4077+ );
4078+ makeDraggable(tab);
4079+ return tab;
5904080 }
5914081
592-// --- tab context menu -------------------------------------------------------
4082+// --- new session ------------------------------------------------------------
5934083 //
594-// Built rather than native: an extension page gets the browser's own menu here,
595-// which has nothing to say about tmux windows. Deliberately not a <dialog> or
596-// anything modal — a modal in this panel would block the socket's message
597-// handler while it is up.
598-
599-let openMenu = /** @type {HTMLElement | null} */ (null);
600-
601-function closeTabMenu() {
602- openMenu?.remove();
603- openMenu = null;
4084+// A window can be created without asking — tmux names it after the directory —
4085+// but a session's name is its identity and the only handle you get on it from a
4086+// terminal, so this one is worth a prompt. Inline, because a modal would block
4087+// this page's message handler while the socket keeps delivering frames.
4088+
4089+function hideSessionInput() {
4090+ const input = $input("session-name");
4091+ input.hidden = true;
4092+ input.value = "";
6044093 }
6054094
606-/**
607- * @param {MouseEvent} e
608- * @param {TbWindowInfo} w
609- * @param {boolean} isPinned
610- * @param {boolean} closable
611- */
612-function openTabMenu(e, w, isPinned, closable) {
613- closeTabMenu();
614- const menu = document.createElement("div");
615- menu.className = "tab-menu";
616- menu.setAttribute("role", "menu");
617-
618- /**
619- * @param {string} label
620- * @param {() => void} run
621- */
622- const item = (label, run) => {
623- const b = document.createElement("button");
624- b.type = "button";
625- b.setAttribute("role", "menuitem");
626- b.textContent = label;
627- b.addEventListener("click", () => {
628- closeTabMenu();
629- run();
630- });
631- menu.appendChild(b);
632- return b;
633- };
634-
635- item(isPinned ? "Unpin" : "Pin", () => {
636- const ids = pinnedIds();
637- if (isPinned) ids.delete(w.id);
638- else ids.add(w.id);
639- savePins(ids);
640- // Nothing on the wire changed, so nothing will arrive to trigger a repaint.
641- $("tabs").dataset.sig = "";
642- renderTabs(lastWindows, lastAgents);
643- });
644-
645- if (!w.active) {
646- item("Select", () => {
647- tmuxCommand({ cmd: "select-window", window: w.id });
648- term.focus();
649- });
650- }
651-
652- if (closable) {
653- const kill = item("Close window", () => {
654- tmuxCommand({ cmd: "kill-window", window: w.id });
655- log(`killed window ${w.index} (${w.name})`);
656- term.focus();
657- });
658- kill.className = "danger";
659- }
660-
661- document.body.appendChild(menu);
662- // Placed after measuring, so a menu opened near an edge folds back inside
663- // rather than off the panel.
664- const r = menu.getBoundingClientRect();
665- const x = Math.min(e.clientX, Math.max(0, window.innerWidth - r.width - 4));
666- const y = Math.min(e.clientY, Math.max(0, window.innerHeight - r.height - 4));
667- menu.style.left = `${x}px`;
668- menu.style.top = `${y}px`;
669- openMenu = menu;
670-
671- // Any click that isn't on the menu, anywhere, dismisses it.
672- setTimeout(() => {
673- window.addEventListener("pointerdown", onDismiss, { once: true, capture: true });
674- }, 0);
4095+/** The field, wherever `applyTabMode` has put it: the session row in the nested
4096+ layout, the tab row in groups mode, where "+"'s menu is what opens it. */
4097+function showSessionInput() {
4098+ const input = $input("session-name");
4099+ input.hidden = false;
4100+ input.focus();
6754101 }
6764102
677-/** @param {Event} e */
678-function onDismiss(e) {
679- if (openMenu && e.target instanceof Node && openMenu.contains(e.target)) return;
680- closeTabMenu();
681-}
4103+$("session-new").addEventListener("click", showSessionInput);
6824104
683-/** @param {TbWindowInfo} w */
684-function closeButton(w) {
685- const btn = document.createElement("button");
686- btn.type = "button";
687- btn.className = "tab-close";
688- const label = `Close window ${w.index}: ${w.name}`;
689- btn.title = label;
690- btn.setAttribute("aria-label", label);
691- btn.appendChild(strokeIcon("M4 4l8 8M12 4l-8 8"));
692-
693- btn.addEventListener("click", (e) => {
694- // The tab underneath would otherwise read this as "select me".
695- e.stopPropagation();
696- tmuxCommand({ cmd: "kill-window", window: w.id });
697- // The one destructive thing in the panel, and tmux has no undo for it, so
698- // it at least leaves a record of what went.
699- log(`killed window ${w.index} (${w.name})`);
4105+$input("session-name").addEventListener("keydown", (e) => {
4106+ const key = /** @type {KeyboardEvent} */ (e).key;
4107+ if (key === "Escape") {
4108+ hideSessionInput();
7004109 term.focus();
701- });
702- return btn;
703-}
704-
705-const SVG_NS = "http://www.w3.org/2000/svg";
706-
707-/**
708- * The header's icons are inline SVG rather than unicode glyphs, which render at
709- * wildly different weights depending on the platform's fallback font. The ones
710- * built here are the same, just built rather than written out.
711- *
712- * @param {string} d
713- */
714-function strokeIcon(d) {
715- const svg = document.createElementNS(SVG_NS, "svg");
716- svg.setAttribute("viewBox", "0 0 16 16");
717- svg.setAttribute("aria-hidden", "true");
718- const path = document.createElementNS(SVG_NS, "path");
719- path.setAttribute("d", d);
720- path.setAttribute("stroke", "currentColor");
721- path.setAttribute("stroke-width", "2");
722- path.setAttribute("stroke-linecap", "round");
723- svg.appendChild(path);
724- return svg;
725-}
726-
727-// A new window needs no name: tmux names it after whatever it runs, and the
728-// terminal renames it as you cd around. So this is one click, no prompt.
729-$("tab-new").addEventListener("click", () => {
730- if (sessionName) tmuxCommand({ cmd: "new-window", session: sessionName });
4110+ return;
4111+ }
4112+ if (key !== "Enter") return;
4113+ const name = $input("session-name").value.trim();
4114+ hideSessionInput();
4115+ // The daemon validates the name and ignores anything it doesn't like; `-A`
4116+ // there means an existing name attaches rather than failing.
4117+ if (name) tmuxCommand({ cmd: "create", session: name });
7314118 term.focus();
7324119 });
7334120
734-// The session the tabs belong to. One session is not a choice, but the control
735-// still earns its place: it is where the session name is shown now that the
736-// status text has given up the row.
737-/**
738- * @param {TbSessionInfo[]} sessions
739- * @param {string | null | undefined} current
740- */
741-function renderSessionSwitch(sessions, current) {
742- const select = $select("session-switch");
743- // Rebuilding while the menu is open would close it under the user's cursor;
744- // the next status frame repaints it anyway.
745- if (document.activeElement === select) return;
746-
747- const names = sessions.map((s) => s.name);
748- const same = select.dataset.names === names.join(" ") && select.value === current;
749- if (same) return;
750-
751- select.textContent = "";
752- for (const s of sessions) {
753- const o = document.createElement("option");
754- o.value = s.name;
755- o.textContent = s.name;
756- select.appendChild(o);
757- }
758- select.dataset.names = names.join(" ");
759- if (current) select.value = current;
760- select.hidden = sessions.length === 0;
761- // Two things cannot both hold the row: the select shows the session name, so
762- // the status text only speaks when there is no select to speak for it.
763- document.body.classList.toggle("has-session", !select.hidden);
764-}
765-
766-$select("session-switch").addEventListener("change", (e) => {
767- // Moves the existing client: no reconnect, no second pty, and whatever is
768- // running in the session we leave keeps running.
769- tmuxCommand({ cmd: "switch", session: /** @type {HTMLSelectElement} */ (e.target).value });
770- term.focus();
771-});
4121+// Clicking away is a cancel: the input is only ever one keystroke from being
4122+// re-opened, and a stray text box in the tab row is worse than a lost name.
4123+$input("session-name").addEventListener("blur", hideSessionInput);
7724124
7734125 /* --- the working spinner ---------------------------------------------------
7744126 Claude Code's own asterisk cycle, so a tab that is thinking looks like the
⋯ 3 unchanged lines
7784130 const SPINNER_FRAMES = ["·", "✢", "✳", "∗", "✻", "✽", "✻", "∗", "✳", "✢"];
7794131 /** What a tab shows when it is not mid-cycle. */
7804132 /** @type {Record<string, string>} */
781-const STATIC_GLYPH = { waiting: "✳", idle: "✻", unknown: "·", none: "" };
4133+// `ready` and `idle` are the same glyph on purpose: the shape says "Claude is
4134+// at rest here", and only the colour says whether that rest is news to you.
4135+const STATIC_GLYPH = { waiting: "✳", ready: "✻", idle: "✻", unknown: "·", none: "" };
7824136 const SPINNER_MS = 130;
7834137
7844138 const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
⋯ 14 unchanged lines
7994153 * the signature is unchanged, so the spinner never fights it.
8004154 */
8014155 function syncSpinner() {
802- const working = document.querySelectorAll("#tabs .glyph.working");
4156+ const working = document.querySelectorAll("header .glyph.working");
8034157 if (!working.length || reducedMotion.matches) {
8044158 clearInterval(spinnerTimer);
8054159 spinnerTimer = undefined;
⋯ 3 unchanged lines
8094163 spinnerTimer = setInterval(() => {
8104164 spinnerStep++;
8114165 const frame = spinnerGlyph();
812- const live = document.querySelectorAll("#tabs .glyph.working");
4166+ const live = document.querySelectorAll("header .glyph.working");
8134167 if (!live.length) return syncSpinner();
8144168 for (const el of live) el.textContent = frame;
8154169 }, SPINNER_MS);
⋯ 3 unchanged lines
8194173 // glyphs where they are, not leave them frozen on whatever frame was up.
8204174 reducedMotion.addEventListener("change", () => {
8214175 const frame = spinnerGlyph();
822- for (const el of document.querySelectorAll("#tabs .glyph.working")) el.textContent = frame;
4176+ for (const el of document.querySelectorAll("header .glyph.working")) el.textContent = frame;
8234177 syncSpinner();
8244178 });
8254179
⋯ 8 unchanged lines
8344188 if (!a) return "";
8354189 if (a.state === "waiting") return a.message || "waiting";
8364190 if (a.state === "working") return a.tool || shortMode(a.mode) || "working";
4191+ if (a.state === "ready") return "finished its turn";
8374192 if (a.state === "unknown") return "no hook records — run: termbridge hooks";
8384193 return shortMode(a.mode) || "idle";
8394194 }
8404195
4196+/* --- action required -------------------------------------------------------
4197+ `ready` is an idle Claude in a pane that has not been on screen since it went
4198+ idle — the daemon derives it (see `SEEN` in daemon/src/status.rs) and it
4199+ arrives as a state like any other. It wears the same glyph as idle in a
4200+ colour that is not grey, which is the smallest thing that reads as "come back
4201+ to this" without inventing a second vocabulary.
4202+
4203+ It is the daemon's to know rather than this panel's because tmux is what
4204+ knows which pane is in front of you, and because two panels on one server
4205+ should not each keep a private opinion about the same window. */
4206+
8414207 // permission_mode arrives camelCased, straight from Claude's hook payload.
8424208 /** @type {Record<string, string>} */
8434209 const MODE_SHORT = {
⋯ 14 unchanged lines
8584224 if (!msg.tmux) return;
8594225 const names = msg.sessions ?? [];
8604226 const list = $("session-list");
861- list.innerHTML = "";
862- for (const name of names) {
863- const o = document.createElement("option");
864- o.value = name;
865- list.appendChild(o);
866- }
867- $input("session").placeholder = msg.defaultSession ?? "browser";
4227+ list.textContent = "";
4228+ for (const name of names) list.appendChild(el("option", { attrs: { value: name } }));
4229+ $input("session").placeholder = msg.defaultSession ?? defaultSession;
8684230 if (names.length) log(`tmux sessions: ${names.join(", ")}`);
8694231 }
8704232
⋯ 6 unchanged lines
8774239 // the next connection opens with.
8784240 if (connected && name) {
8794241 tmuxCommand({ cmd: "create", session: name });
880- $("settings").hidden = true;
4242+ closeSettings();
8814243 term.focus();
8824244 return;
8834245 }
⋯ 28 unchanged lines
9124274 ["href", "href"],
9134275 ];
9144276
915-async function activePickTab() {
4277+/**
4278+ * The tabs a pick should run in. Normally one; in a split view, both halves,
4279+ * because only one of the two visible tabs is ever `active` and the other is
4280+ * just as clickable. See lib/split.js.
4281+ *
4282+ * @returns {Promise<{ tab: TbTab | undefined; targets: TbTab[] }>}
4283+ */
4284+async function pickTargets() {
9164285 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
917- return tab;
4286+ if (!tab || SKIP_URL.test(tab.url ?? "")) return { tab, targets: [] };
4287+ const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP_URL.test(t.url ?? ""));
4288+ return { tab, targets };
9184289 }
9194290
9204291 /**
⋯ 31 unchanged lines
9524323 * is opt-in, one origin at a time, via a prompt the browser shows.
9534324 *
9544325 * @param {string} pattern
955- * @param {number | undefined} tabId
4326+ * @param {TbTab[]} targets the tabs the pick would run in
9564327 */
957-function offerGrant(pattern, tabId) {
4328+function offerGrant(pattern, targets) {
9584329 $("picked").hidden = false;
9594330 $("picked-value").textContent = `No access to ${pattern}`;
9604331 $("picked-warn").hidden = false;
⋯ 8 unchanged lines
9694340 if (granted) {
9704341 btn.hidden = true;
9714342 log(`granted ${pattern}`);
972- runPick(tabId);
4343+ runPick(targets);
9734344 } else {
9744345 log(`declined ${pattern}`);
9754346 }
⋯ 46 unchanged lines
10224393
10234394 const SKIP_URL = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/;
10244395
1025-// The tab a pick is currently running in, or null. Also the "is picking" flag.
1026-let pickTabId = /** @type {number | null | undefined} */ (null);
4396+// The tabs a pick is currently running in — more than one in a split view.
4397+// Empty means no pick is running, which is also the "is picking" flag.
4398+let pickTabs = /** @type {number[]} */ ([]);
10274399
10284400 /**
1029- * Cancel an in-flight pick.
1030- *
1031- * picker.js parks its cleanup function on `window.__tbPickerActive`, so we
1032- * re-enter the page and call it. That resolves the original promise with null,
1033- * which unwinds runPick exactly as a user Escape would.
4401+ * Cancel an in-flight pick, in every half it is running in.
10344402 */
10354403 async function cancelPick() {
1036- const tabId = pickTabId;
1037- if (tabId == null) return;
1038- try {
1039- await api.scripting.executeScript({
1040- target: { tabId },
1041- func: () => {
1042- if (window.__tbPickerActive) window.__tbPickerActive();
1043- },
1044- });
1045- } catch {
1046- // Tab closed or navigated; the picker went with it.
1047- }
4404+ if (!pickTabs.length) return;
4405+ await Split.cancelPicks(api, pickTabs);
10484406 }
10494407
10504408 /**
1051- * Run a pick in `tabId`. Returns true on success, or a message explaining why
1052- * the injection failed.
4409+ * Run a pick across `targets`. Returns true on success, or a message explaining
4410+ * why every injection failed.
10534411 *
1054- * @param {number | undefined} tabId
4412+ * @param {TbTab[]} targets
10554413 * @returns {Promise<true | string>}
10564414 */
1057-async function runPick(tabId) {
4415+async function runPick(targets) {
10584416 const btn = $("pick");
1059- pickTabId = tabId;
4417+ pickTabs = [];
4418+ for (const t of targets) if (t.id != null) pickTabs.push(t.id);
10604419 btn.classList.add("active");
10614420 setStatus("pending", "pick mode — click an element, Esc cancels");
10624421 try {
1063- const results = await api.scripting.executeScript({
1064- target: { tabId },
1065- func: tbPickElement,
1066- });
1067- const value = results?.[0]?.result ?? null;
4422+ const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement);
10684423 if (value) {
10694424 const shot = await Shot.copyPickedShot(api, tabId, value);
10704425 await deliverPick(value, shot);
4426+ } else if (error) {
4427+ return error;
10714428 } else log("pick cancelled");
10724429 return true;
10734430 } catch (e) {
10744431 return e instanceof Error ? e.message : String(e);
10754432 } finally {
1076- pickTabId = null;
4433+ pickTabs = [];
10774434 btn.classList.remove("active");
10784435 refreshStatus();
10794436 }
⋯ 1 unchanged line
10814438
10824439 $("pick").addEventListener("click", async () => {
10834440 // Second press toggles it back off rather than doing nothing.
1084- if (pickTabId != null) {
4441+ if (pickTabs.length) {
10854442 await cancelPick();
10864443 return;
10874444 }
10884445
1089- const [tab] = await api.tabs.query({ active: true, currentWindow: true });
1090- if (!tab || SKIP_URL.test(tab.url ?? "")) {
4446+ const { tab, targets } = await pickTargets();
4447+ if (!targets.length) {
10914448 pickNote(
10924449 "Can't pick here.",
10934450 "Browser-internal and extension pages are off limits to all extensions. Switch to a normal web page.",
⋯ 2 unchanged lines
10964453 }
10974454
10984455 setStatus("pending", "pick mode — click an element, Esc cancels");
1099- const outcome = await runPick(tab.id);
4456+ const outcome = await runPick(targets);
11004457 if (outcome === true) return;
11014458
11024459 // The failure is nearly always a missing host permission for this origin.
1103- const pattern = originPattern(tab.url);
4460+ const pattern = originPattern(tab?.url);
11044461 if (pattern && /permission|access/i.test(outcome)) {
1105- offerGrant(pattern, tab.id);
4462+ offerGrant(pattern, targets);
11064463 } else {
11074464 pickNote("Couldn't reach the page.", `Try Alt+Shift+P instead. [${outcome}]`);
11084465 }
⋯ 3 unchanged lines
11124469 // the page has keyboard focus — if you started the pick from here, focus is
11134470 // still in the panel and the key never reaches the page.
11144471 window.addEventListener("keydown", (e) => {
1115- // Font shortcuts work anywhere in the panel, not just with the terminal
1116- // focused. xterm.js consumes its own keydowns before they reach here, so it
1117- // has its own handler for these.
1118- if (handleFontKey(e)) return;
4472+ // These work anywhere in the panel, not just with the terminal focused.
4473+ // xterm.js consumes its own keydowns before they reach here, so it has its
4474+ // own handler for them too.
4475+ if (handlePanelKey(e)) return;
11194476 if (e.key !== "Escape") return;
11204477 if (openMenu) {
11214478 e.preventDefault();
11224479 closeTabMenu();
11234480 return;
11244481 }
1125- if (pickTabId != null) {
4482+ // After the menus, before the pick: a popup is the nearest thing open.
4483+ if (settingsOpen) {
4484+ e.preventDefault();
4485+ closeSettings();
4486+ term.focus();
4487+ return;
4488+ }
4489+ if (pickTabs.length) {
11264490 e.preventDefault();
11274491 cancelPick();
11284492 }
⋯ 16 unchanged lines
11454509 }
11464510 });
11474511
4512+// The shortcut half of the port described in sw.js: while this document is
4513+// alive it stays connected and reports whether it holds focus, so the worker
4514+// can decide between open, focus and close without asking first.
4515+// Only the three commands below arrive here; nothing on this port touches the
4516+// WebSocket.
4517+/** @type {TbPort | null} */
4518+let togglePort = null;
4519+
4520+function connectToggle() {
4521+ api.windows.getCurrent().then((win) => {
4522+ const port = api.runtime.connect({ name: "sidebar" });
4523+ togglePort = port;
4524+ port.postMessage({ type: "hello", windowId: win.id, focused: document.hasFocus() });
4525+ port.onMessage.addListener((msg) => {
4526+ if (msg?.type === "close") window.close();
4527+ else if (msg?.type === "focus") term.focus();
4528+ // A browser command rather than a key this page listens for, so the
4529+ // binding is the browser's to own: it shows up in chrome://extensions/
4530+ // shortcuts with the other two and can be rebound or cleared there. A
4531+ // hardcoded keydown here would keep firing on the old key afterwards.
4532+ else if (msg?.type === "omnibar") focusOmni();
4533+ });
4534+ port.onDisconnect.addListener(() => {
4535+ // Chrome may retire an idle service worker under us. Nothing here is
4536+ // urgent, so reconnect lazily rather than fighting for the port.
4537+ if (togglePort === port) togglePort = null;
4538+ setTimeout(connectToggle, 1000);
4539+ });
4540+ });
4541+}
4542+
4543+const reportFocus = () => togglePort?.postMessage({ type: "focus", focused: document.hasFocus() });
4544+window.addEventListener("focus", reportFocus);
4545+window.addEventListener("blur", reportFocus);
4546+connectToggle();
4547+
11484548 // A pick made while the sidebar was closed is parked in storage. Nothing is
11494549 // typed for these: the terminal has moved on, and whatever screenshot went with
11504550 // it left the clipboard long ago. Show it and let the user decide.
⋯ 32 unchanged lines
11834583 }
11844584
11854585 const bar = $("picked-formats");
1186- bar.innerHTML = "";
4586+ bar.textContent = "";
11874587 for (const [key, label] of available) {
1188- const b = document.createElement("button");
1189- b.type = "button";
1190- b.textContent = label;
1191- b.setAttribute("role", "tab");
1192- b.setAttribute("aria-selected", String(key === pickedFormat));
1193- b.addEventListener("click", () => {
1194- pickedFormat = key;
1195- for (const other of bar.children) {
1196- other.setAttribute("aria-selected", String(other === b));
1197- }
1198- refreshPicked();
4588+ const b = button({
4589+ text: label,
4590+ attrs: { role: "tab", "aria-selected": key === pickedFormat },
4591+ on: {
4592+ click: () => {
4593+ pickedFormat = key;
4594+ for (const other of bar.children) {
4595+ other.setAttribute("aria-selected", String(other === b));
4596+ }
4597+ refreshPicked();
4598+ },
4599+ },
11994600 });
12004601 bar.appendChild(b);
12014602 }
⋯ 123 unchanged lines
13254726 themeSelect.addEventListener("change", () => setTheme(themeSelect.value));
13264727 const densitySelect = $select("density-select");
13274728 densitySelect.addEventListener("change", () => setDensity(densitySelect.value));
4729+const tabModeSelect = $select("tabmode-select");
4730+tabModeSelect.addEventListener("change", () => setTabMode(tabModeSelect.value));
13284731
13294732 $("font-smaller").addEventListener("click", () => setFontSize(fontSize - 1));
13304733 $("font-bigger").addEventListener("click", () => setFontSize(fontSize + 1));
13314734 $("font-reset").addEventListener("click", () => setFontSize(FONT_DEFAULT));
13324735
4736+// --- settings, as a popup ---------------------------------------------------
4737+//
4738+// A card hung off the chevron rather than a drawer at the foot of the panel:
4739+// the same shape Chrome gives the popup on the other end of that same chevron.
4740+//
4741+// Out of flow, which is the substantive part. As a flex item the panel sized
4742+// the terminal, so opening or closing it re-fit xterm.js and reflowed the
4743+// scrollback — you lost your place to change a font size. Floating over the
4744+// terminal, the terminal never moves.
4745+//
4746+// Not the tab menu's machinery, and not `openMenu`: those close on the first
4747+// pointerdown anywhere, which is right for a menu of one-shot actions and wrong
4748+// for a panel full of text fields you click into and drag across.
4749+
4750+/** Set while the popup is up, so the outside-click listener is only ever one. */
4751+let settingsOpen = false;
4752+
4753+/** Put it under the chevron, folded back inside a panel too narrow for it. */
4754+function placeSettings() {
4755+ const pop = $("settings");
4756+ const anchor = $("settings-toggle").getBoundingClientRect();
4757+ const r = pop.getBoundingClientRect();
4758+ pop.style.left = `${Math.max(4, Math.min(anchor.left, window.innerWidth - r.width - 4))}px`;
4759+ pop.style.top = `${Math.min(anchor.bottom + 5, Math.max(4, window.innerHeight - r.height - 4))}px`;
4760+}
4761+
4762+/** @param {PointerEvent | MouseEvent} e */
4763+function onSettingsDismiss(e) {
4764+ if (!(e.target instanceof Node)) return;
4765+ // The chevron closes it through its own handler; swallowing the press here
4766+ // would close and reopen it in the same click.
4767+ if ($("settings").contains(e.target) || $("settings-toggle").contains(e.target)) return;
4768+ closeSettings();
4769+}
4770+
4771+function openSettings() {
4772+ if (settingsOpen) return placeSettings();
4773+ settingsOpen = true;
4774+ $("settings").hidden = false;
4775+ $("settings-toggle").setAttribute("aria-expanded", "true");
4776+ placeSettings();
4777+ // A resize here is the sidebar being dragged wider or the window changing —
4778+ // either moves the chevron, and the card has to go with it.
4779+ window.addEventListener("resize", placeSettings);
4780+ // Deferred by a tick so the click that opened it does not also dismiss it.
4781+ setTimeout(() => {
4782+ if (settingsOpen) window.addEventListener("pointerdown", onSettingsDismiss, true);
4783+ }, 0);
4784+}
4785+
4786+function closeSettings() {
4787+ if (!settingsOpen) return;
4788+ settingsOpen = false;
4789+ $("settings").hidden = true;
4790+ $("settings-toggle").setAttribute("aria-expanded", "false");
4791+ window.removeEventListener("resize", placeSettings);
4792+ window.removeEventListener("pointerdown", onSettingsDismiss, true);
4793+}
4794+
13334795 $("settings-toggle").addEventListener("click", () => {
1334- $("settings").hidden = !$("settings").hidden;
4796+ if (settingsOpen) closeSettings();
4797+ else openSettings();
4798+});
4799+$("settings-close").addEventListener("click", () => {
4800+ closeSettings();
4801+ term.focus();
13354802 });
13364803 $("reconnect").addEventListener("click", connect);
13374804 $("connect").addEventListener("click", connect);
⋯ 15 unchanged lines
13534820 );
13544821 urlInput.addEventListener("change", () => storage.set({ url: urlInput.value.trim() }));
13554822
1356-storage.get(["token", "url", "session", "theme", "density", "fontSize", "pins"]).then((v) => {
4823+/** Everything this panel remembers between openings. */
4824+const STORED_KEYS = [
4825+ "token",
4826+ "url",
4827+ "session",
4828+ "theme",
4829+ "density",
4830+ "fontSize",
4831+ "pins",
4832+ "sessionOrder",
4833+ "tabMode",
4834+ "foldedGroups",
4835+];
4836+
4837+storage.get(STORED_KEYS).then((v) => {
13574838 if (v.pins && typeof v.pins === "object") pins = v.pins;
4839+ if (Array.isArray(v.foldedGroups)) {
4840+ foldedGroups = new Set(v.foldedGroups.filter((n) => typeof n === "string"));
4841+ }
4842+ // Names, from storage this panel wrote — but storage is not a promise, so
4843+ // anything that isn't a list of strings is dropped rather than trusted.
4844+ if (Array.isArray(v.sessionOrder)) {
4845+ sessionOrder = v.sessionOrder.filter((n) => typeof n === "string");
4846+ }
13584847 themePref = Themes.PREFERENCES.includes(v.theme) ? v.theme : "auto";
13594848 density = DENSITIES[v.density] ? v.density : "normal";
4849+ tabMode = TAB_MODES[v.tabMode] ? v.tabMode : "nested";
13604850 const stored = Number(v.fontSize);
13614851 fontSize =
13624852 Number.isFinite(stored) && stored >= FONT_MIN && stored <= FONT_MAX
⋯ 1 unchanged line
13644854 : FONT_DEFAULT;
13654855 applyTheme();
13664856 applyDensity();
4857+ applyTabMode();
13674858 applyFontSize();
13684859 if (v.token) $input("token").value = v.token;
13694860 if (v.url) $input("url").value = v.url;
⋯ 3 unchanged lines
13734864 connect();
13744865 } else {
13754866 setStatus("off", "needs setup");
1376- $("settings").hidden = false;
4867+ openSettings();
13774868 term.write(
13784869 "\x1b[90m terminal\x1b[0m\r\n\r\n" +
13794870 " Not configured yet. Open \x1b[1msettings\x1b[0m (top right)\r\n" +
⋯ 4 unchanged lines
modifiedextension/sw.js+270 −14
⋯ 13 unchanged lines
1414 // Chrome's service worker starts empty; Firefox loads picker.js alongside this
1515 // file via background.scripts, so there it is already defined.
1616 if (typeof tbPickElement === "undefined" && typeof importScripts === "function") {
17- importScripts("picker.js", "lib/shot.js");
17+ importScripts("picker.js", "lib/shot.js", "lib/split.js");
1818 }
1919
2020 // Chrome only: make the toolbar button open the side panel.
⋯ 12 unchanged lines
3333 return;
3434 }
3535
36- let value = null;
37- try {
38- const results = await api.scripting.executeScript({
39- target: { tabId: tab.id },
40- func: tbPickElement,
41- });
42- value = results?.[0]?.result ?? null;
43- } catch (e) {
44- console.error("termbridge: pick failed", e);
45- return;
46- }
36+ // Both halves of a split view, when this is one. The activeTab grant the
37+ // command just minted covers only the active half, so the partner half is
38+ // reachable only where its origin was granted — Split.racePick treats that
39+ // refusal as one runner dropping out, not as the pick failing.
40+ const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP.test(t.url ?? ""));
41+ const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement);
42+ if (error) console.error("termbridge: pick failed", error);
4743 if (!value) return;
4844
4945 // Screenshot here rather than in the sidebar: this path's page access comes
5046 // from the activeTab grant the keyboard command just minted, and that grant
5147 // belongs to the worker.
52- const shot = await Shot.copyPickedShot(api, tab.id, value);
48+ const shot = await Shot.copyPickedShot(api, tabId, value);
5349 if (shot !== true) console.warn("termbridge: no screenshot", shot);
5450
5551 // The sidebar may not be open. Try to hand it over directly, and fall back to
⋯ 5 unchanged lines
6157 }
6258 }
6359
64-api.commands?.onCommand.addListener((command) => {
60+// --- the toggle shortcut -----------------------------------------------------
61+//
62+// Neither browser will tell us whether the panel is open, so the panel tells
63+// us: it holds a port open for as long as it is alive and reports its own
64+// focus over it. That state has to be here rather than fetched on demand,
65+// because opening a side panel is only allowed during a user gesture, and a
66+// round trip to the panel and back outlives the gesture the shortcut minted.
67+//
68+// The port carries three fixed commands and no data. It is deliberately not a
69+// second route into the terminal — see the note at the top of sidebar.js.
70+
71+/** @type {Map<number, { port: TbPort, focused: boolean }>} */
72+const panels = new Map();
73+
74+api.runtime.onConnect.addListener((port) => {
75+ if (port.name !== "sidebar") return;
76+ // Content scripts can connect too. Ours is an extension page: no sender.tab.
77+ if (port.sender?.tab || port.sender?.id !== api.runtime.id) {
78+ port.disconnect();
79+ return;
80+ }
81+ /** @type {number | null} */
82+ let windowId = null;
83+ port.onMessage.addListener((msg) => {
84+ if (msg?.type === "hello" && typeof msg.windowId === "number") {
85+ windowId = /** @type {number} */ (msg.windowId);
86+ panels.set(windowId, { port, focused: !!msg.focused });
87+ // The shortcut that opened this panel asked for the jump box, and there
88+ // was nothing here to ask at the time. This connection is the panel
89+ // arriving; the request is only good for the panel that open call was
90+ // for, so it is spent whether or not it was this one.
91+ const asked = pendingOmnibar.get(windowId);
92+ pendingOmnibar.delete(windowId);
93+ if (asked != null && Date.now() - asked < PENDING_OMNIBAR_MS) {
94+ port.postMessage({ type: "omnibar" });
95+ }
96+ } else if (msg?.type === "focus" && windowId != null) {
97+ const entry = panels.get(windowId);
98+ if (entry?.port === port) entry.focused = !!msg.focused;
99+ }
100+ });
101+ port.onDisconnect.addListener(() => {
102+ if (windowId != null && panels.get(windowId)?.port === port) panels.delete(windowId);
103+ });
104+});
105+
106+// Chrome only allows opening the panel while the gesture that the command
107+// minted is still live, and a single `await` — even one that resolves
108+// immediately — spends it. So everything from the command listener down to the
109+// open call is synchronous, and the window id comes from the tab the listener
110+// hands us rather than from a windows.getLastFocused() round trip.
111+/** @param {number} windowId */
112+function openPanel(windowId) {
113+ const opened = api.sidePanel?.open
114+ ? api.sidePanel.open({ windowId })
115+ : api.sidebarAction?.open();
116+ // Chrome still rejects if the gesture was somehow already spent; that is not
117+ // worth an unhandled rejection in the worker's console.
118+ Promise.resolve(opened).catch((e) => console.warn("termbridge: cannot open panel", e));
119+}
120+
121+// Chrome hands the panel the keyboard when it opens it and at no other time:
122+// there is no API to focus a panel that is already up, and `autofocus` does
123+// nothing in one. So the only way to point the keyboard at an open panel is to
124+// make it a panel Chrome is opening — take it away and put it back.
125+//
126+// The catch is the same gesture rule as above, from the other side: there is no
127+// sidePanel.close() to call, and closing it the only way there is — asking the
128+// panel to close itself — is a round trip that spends the gesture the reopen
129+// needs. Disabling the panel closes it without asking anyone, so all three
130+// calls are issued here, unawaited and in order, while the gesture is live.
131+//
132+// The panel document does not survive this, and that is the whole cost: the
133+// terminal is a tmux client, so a new one reattaches to the same session with
134+// its scrollback intact on the server. What the user sees is a flicker.
135+//
136+// Experimental — this leans on Chrome running the three in the order they were
137+// issued. Set false to go back to leaving an open panel where it is.
138+const REOPEN_TO_FOCUS = true;
139+
140+/** @param {number} windowId */
141+function reopenPanelForFocus(windowId) {
142+ const sidePanel = api.sidePanel;
143+ if (!REOPEN_TO_FOCUS || !sidePanel?.setOptions) {
144+ // Firefox reaches the same end by a shorter road: sidebarAction.open()
145+ // focuses the sidebar whether or not it was already showing.
146+ openPanel(windowId);
147+ return;
148+ }
149+ const quiet = (/** @type {unknown} */ e) => console.warn("termbridge: panel reopen", e);
150+ Promise.resolve(sidePanel.setOptions({ enabled: false })).catch(quiet);
151+ Promise.resolve(sidePanel.setOptions({ enabled: true, path: "sidebar.html" })).catch(quiet);
152+ openPanel(windowId);
153+}
154+
155+/** @param {number | undefined} windowId */
156+function toggleSidebar(windowId) {
157+ // Firefox only started passing the tab to command listeners in 106; without
158+ // it the gesture is lost, but sidebarAction has no such restriction.
159+ if (windowId == null) {
160+ api.windows.getLastFocused().then((w) => toggleSidebar(w.id));
161+ return;
162+ }
163+ const entry = panels.get(windowId);
164+
165+ if (!entry) {
166+ openPanel(windowId);
167+ return;
168+ }
169+
170+ if (entry.focused) {
171+ // Firefox can close its own sidebar; Chrome has no close API, so the panel
172+ // closes itself with window.close().
173+ if (api.sidebarAction?.close) api.sidebarAction.close();
174+ else entry.port.postMessage({ type: "close" });
175+ return;
176+ }
177+
178+ // Open but focus is elsewhere — put the caret back in the terminal. The
179+ // panel is already showing, so the open call is only there to hand it focus.
180+ entry.port.postMessage({ type: "focus" });
181+ openPanel(windowId);
182+}
183+
184+// A jump box asked for while the panel was closed, by window and when. The
185+// panel takes a moment to come up and connect, so the request has to outlive
186+// the shortcut that made it — but only just: a panel arriving much later is
187+// one the user opened themselves, and it should come up on the terminal like
188+// any other.
189+/** @type {Map<number, number>} */
190+const pendingOmnibar = new Map();
191+const PENDING_OMNIBAR_MS = 10_000;
192+
193+// Put the caret in the panel's jump bar. Same port and the same reasoning as
194+// the toggle: whether the panel is open is state only the panel can report.
195+/** @param {number | undefined} windowId */
196+function focusOmnibar(windowId) {
197+ if (windowId == null) {
198+ api.windows.getLastFocused().then((w) => focusOmnibar(w.id));
199+ return;
200+ }
201+ const entry = panels.get(windowId);
202+
203+ // Not open. The panel is the only thing that can put the caret anywhere, so
204+ // the request is left here for it to pick up when it connects — and this is
205+ // the one path where the caret reliably lands, because a panel Chrome is
206+ // opening for the first time is a panel Chrome hands the keyboard to.
207+ if (!entry) {
208+ pendingOmnibar.set(windowId, Date.now());
209+ openPanel(windowId);
210+ return;
211+ }
212+
213+ // Open and already holding the keyboard: the panel it has is the one that
214+ // takes the caret, and nothing has to move for it to be typed into.
215+ if (entry.focused) {
216+ entry.port.postMessage({ type: "omnibar" });
217+ return;
218+ }
219+
220+ // Open, but the keyboard is on the page. Telling this panel to focus its box
221+ // would put a caret in a window nothing is typing into, so the request is
222+ // left for the panel that comes back and the panel is taken away and
223+ // reopened — the one move that makes Chrome hand the keyboard over.
224+ pendingOmnibar.set(windowId, Date.now());
225+ reopenPanelForFocus(windowId);
226+}
227+
228+// --- handing a tab to Claude in Chrome ---------------------------------------
229+//
230+// Not the Claude the rest of termbridge means: that one is Claude Code in a
231+// tmux pane, reached through the daemon. This is the browser extension, and it
232+// only drives tabs that sit inside its own tab group — a tab outside that group
233+// is invisible to it. Membership is the entire gate, so handing a page over is
234+// a tabs.group() call and nothing else.
235+//
236+// Which group is Claude's is a question only the user can answer. The group
237+// carries no title to match on — Claude's comes back untitled — so
238+// `set-claude-group` records the active tab's group id, and until that has
239+// happened a single group in the browser is taken to be the one.
240+//
241+// Chrome only: Firefox has no tab group API, so api.tabGroups is undefined
242+// there and both commands no-op.
243+
244+const CLAUDE_GROUP_KEY = "claudeGroupId";
245+
246+/** @returns {Promise<TbTabGroup | null>} */
247+async function resolveClaudeGroup() {
248+ const tabGroups = api.tabGroups;
249+ if (!tabGroups) return null;
250+
251+ const stored = (await api.storage.local.get(CLAUDE_GROUP_KEY))[CLAUDE_GROUP_KEY];
252+ if (typeof stored === "number") {
253+ try {
254+ return await tabGroups.get(stored);
255+ } catch {
256+ // Recorded group has been closed since. Fall through and rediscover.
257+ }
258+ }
259+
260+ const groups = await tabGroups.query({});
261+ if (groups.length === 1) {
262+ await api.storage.local.set({ [CLAUDE_GROUP_KEY]: groups[0].id });
263+ return groups[0];
264+ }
265+ return null;
266+}
267+
268+async function handTabToClaude() {
269+ const groupTabs = api.tabs.group;
270+ if (!groupTabs) return;
271+
272+ const [tab] = await api.tabs.query({ active: true, currentWindow: true });
273+ if (!tab || typeof tab.id !== "number") return;
274+ // The same pages the picker cannot touch are pages Claude cannot drive.
275+ if (SKIP.test(tab.url ?? "")) {
276+ console.warn("termbridge: Claude cannot drive this page", tab.url);
277+ return;
278+ }
279+
280+ const group = await resolveClaudeGroup();
281+ if (!group) {
282+ console.warn(
283+ "termbridge: no Claude group recorded — put a tab in Claude's group and run set-claude-group",
284+ );
285+ return;
286+ }
287+ if (tab.groupId === group.id) return;
288+
289+ try {
290+ // group() will not reach across windows, and Claude's group usually sits in
291+ // one of its own, so the tab has to travel first.
292+ if (tab.windowId !== group.windowId) {
293+ await api.tabs.move(tab.id, { windowId: group.windowId, index: -1 });
294+ }
295+ await groupTabs({ groupId: group.id, tabIds: [tab.id] });
296+ await api.tabs.update(tab.id, { active: true });
297+ await api.windows.update(group.windowId, { focused: true });
298+ } catch (e) {
299+ console.error("termbridge: could not hand the tab over", e);
300+ }
301+}
302+
303+async function setClaudeGroup() {
304+ if (!api.tabGroups) return;
305+
306+ const [tab] = await api.tabs.query({ active: true, currentWindow: true });
307+ // Chrome reports -1 for a tab that is in no group.
308+ if (!tab || typeof tab.groupId !== "number" || tab.groupId < 0) {
309+ console.warn("termbridge: this tab is in no group — drag it into Claude's group first");
310+ return;
311+ }
312+ await api.storage.local.set({ [CLAUDE_GROUP_KEY]: tab.groupId });
313+ console.info("termbridge: Claude group set to", tab.groupId);
314+}
315+
316+api.commands?.onCommand.addListener((command, tab) => {
65317 if (command === "pick-element") runPicker();
318+ if (command === "toggle-terminal") toggleSidebar(tab?.windowId);
319+ if (command === "focus-omnibar") focusOmnibar(tab?.windowId);
320+ if (command === "hand-tab-to-claude") handTabToClaude();
321+ if (command === "set-claude-group") setClaudeGroup();
66322 });
modifiedextension/types/globals.d.ts+172 −9
⋯ 11 unchanged lines
1212
1313 interface TbTab {
1414 id?: number;
15+ windowId?: number;
1516 url?: string;
1617 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;
1725 }
1826
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+
1936 interface TbInjectionResult<T = unknown> {
2037 result?: T;
2138 frameId?: number;
⋯ 6 unchanged lines
2845 tab?: TbTab;
2946 }
3047
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+
3168 interface TbStorageArea {
3269 get(keys?: string | string[] | null): Promise<Record<string, any>>;
3370 set(items: Record<string, any>): Promise<void>;
⋯ 9 unchanged lines
4380 cb: (message: any, sender: TbMessageSender) => void | boolean | Promise<any>,
4481 ): void;
4582 };
83+ connect(info: { name: string }): TbPort;
84+ onConnect: { addListener(cb: (port: TbPort) => void): void };
4685 };
4786 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+ };
4895 tabs: {
49- query(info: { active?: boolean; currentWindow?: boolean }): Promise<TbTab[]>;
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[]>;
50103 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>;
51107 /** The visible area of the active tab, as a PNG data URL. Needs a host
52108 permission for the page, exactly as the picker's injection does. */
53109 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[]>;
54116 };
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+ };
55123 permissions: {
56124 request(perms: { origins?: string[]; permissions?: string[] }): Promise<boolean>;
57125 };
⋯ 21 unchanged lines
79147 setPanelBehavior?(behavior: {
80148 openPanelOnActionClick: boolean;
81149 }): 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>;
82163 };
83164 /** Absent when no keyboard shortcuts are declared in the manifest. */
84165 commands?: {
85- onCommand: { addListener(cb: (command: string) => void): void };
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+ };
86173 };
87174 }
88175
⋯ 10 unchanged lines
99186 // Everything here arrives over a socket. It is *claimed* structure, not
100187 // guaranteed structure, and it is only ever assigned through textContent.
101188
189+/** One tmux session — one tab in the top row. */
102190 interface TbSessionInfo {
191+ /** tmux session id (`$1`). Empty on the `ok` frame, which has names only. */
192+ id: string;
103193 name: string;
104- /** Window count. 0 when it isn't known yet — the `ok` frame sends names only. */
105- windows: number;
106194 /** Some tmux client, anywhere, is on this session. */
107195 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[];
108209 }
109210
110-/** One window of the attached session — one tab. */
211+/** One window of a session — one tab in the second row. */
111212 interface TbWindowInfo {
112213 /** tmux window id (`@3`). Stable; the index is not. */
113214 id: string;
⋯ 4 unchanged lines
118219 activity: boolean;
119220 }
120221
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+
121229 interface TbAgent {
122230 /** tmux pane id (`%12`), stable for the pane's lifetime. */
123231 pane: string;
232+ /** Session name — the join to TbSessionInfo, since agents are server-wide. */
233+ session: string;
124234 /** tmux window id (`@3`) — the join to TbWindowInfo. */
125235 window_id: string;
126236 window: string;
127237 name: string;
128- state: "working" | "waiting" | "idle" | "unknown";
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;
129250 mode?: string | null;
130251 tool?: string | null;
131252 message?: string | null;
⋯ 11 unchanged lines
143264 defaultSession?: string;
144265 /** Names only — window counts arrive with the first status frame. */
145266 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[];
146273 }
147274
148-/** Pushed whenever tmux says something changed. */
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+ */
149279 interface TbStatusFrame {
150280 type: "status";
281+ /** The session this panel's client is on right now. */
151282 session?: string;
152283 sessions?: TbSessionInfo[];
153- /** Windows of `session`, in index order. */
154- windows?: TbWindowInfo[];
155284 agents?: TbAgent[];
156285 }
157286
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+
158309 type TbFrame =
159310 | TbOkFrame
160311 | TbStatusFrame
312+ | TbPathFrame
161313 | { type: "exit"; code: number }
162314 | { type: "error"; reason: string }
163315 | { type: "tmux-error"; reason: string };
164316
165317 /** 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+
166325 interface TbPickedRect {
167326 x: number;
168327 y: number;
⋯ 11 unchanged lines
180339 rect?: TbPickedRect;
181340 /** Viewport size at pick time, which fixes the screenshot's scale factor. */
182341 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;
183345 css: string;
184346 xpath: string;
185347 id: string | null;
⋯ 62 unchanged lines
248410 declare const Themes: typeof import("../lib/theme.js");
249411 declare const Sanitize: typeof import("../lib/sanitize.js");
250412 declare const Shot: typeof import("../lib/shot.js");
413+declare const Split: typeof import("../lib/split.js");
251414
252415 /** Set by picker.js inside the *page*, not here — see cancelPick(). */
253416 interface Window {
⋯ 2 unchanged lines