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
| 1 | 1 | node_modules/ | |
| 2 | 2 | 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 | |||
| 14 | 14 | │ ─── {"type":"auth","token":"…"} ──────▶ │ origin + host + token | |
| 15 | 15 | │ ◀── {"type":"ok"} ───────────────────── │ | |
| 16 | 16 | │ ─── {"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 | |
| 18 | 18 | │ ◀══ binary frames (raw bytes) ═════════ │ | |
| 19 | 19 | │ ─── {"type":"resize","cols":…} ───────▶ │ TIOCSWINSZ | |
| 20 | 20 | ``` | |
| ⋯ 37 unchanged lines | |||
| 58 | 58 | regenerated on each temporary install, so you'll re-pair each time until the | |
| 59 | 59 | add-on is signed. | |
| 60 | 60 | ||
| 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 | + | ||
| 61 | 142 | ## Theme | |
| 62 | 143 | ||
| 63 | 144 | The **◐** button in the header cycles *follow system → light → dark*, and the ⚙ | |
| ⋯ 27 unchanged lines | |||
| 91 | 172 | By default the sidebar joins the tmux session you already have running, when | |
| 92 | 173 | there is exactly one — no point starting a second session beside the only one | |
| 93 | 174 | 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 | |
| 95 | 176 | reflects what tmux holds when the sidebar connects, not when the daemon | |
| 96 | 177 | started. | |
| 97 | 178 | ||
| ⋯ 3 unchanged lines | |||
| 101 | 182 | termbridge serve --session my-existing-work | |
| 102 | 183 | ``` | |
| 103 | 184 | ||
| 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. | |
| 110 | 196 | ||
| 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 | |
| 112 | 198 | for, so a `switch-client`, `choose-tree` or prefix-`(`/`)` typed in the terminal | |
| 113 | - | updates it too. | |
| 199 | + | moves it too. | |
| 114 | 200 | ||
| 115 | 201 | Both names are validated server-side — see the security notes below. | |
| 116 | 202 | ||
| 117 | 203 | ## Window tabs | |
| 118 | 204 | ||
| 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. | |
| 124 | 210 | ||
| 125 | 211 | Selecting a window deliberately moves *every* client watching that session, not | |
| 126 | 212 | just the sidebar — a window belongs to the session, so this behaves the same as | |
| ⋯ 1 unchanged line | |||
| 128 | 214 | ||
| 129 | 215 | The active tab is drawn in the terminal's own background so the two read as one | |
| 130 | 216 | 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 | |
| 132 | 219 | empty for a window that is just a shell, rather than lighting up a status | |
| 133 | 220 | indicator with no status to report. A background window that has produced | |
| 134 | 221 | output since you last looked wears tmux's activity flag as a bolder name. | |
| ⋯ 46 unchanged lines | |||
| 181 | 268 | ||
| 182 | 269 | `read-only` governs keys, not commands, so the same channel carries the | |
| 183 | 270 | 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 | |
| 186 | 275 | 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. | |
| 188 | 286 | ||
| 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 | + | ||
| 189 | 297 | Exactly one of them destroys anything, `kill-window`, and it can only ever name | |
| 190 | 298 | one window: a window id is `@` plus digits, so `-a` (which would kill every | |
| 191 | 299 | window *but* the target) and `session:` targets do not parse. There is no | |
| ⋯ 8 unchanged lines | |||
| 200 | 308 | | Glyph | Meaning | | |
| 201 | 309 | |---|---| | |
| 202 | 310 | | amber, cycling `· ✢ ✳ ∗ ✻ ✽` | working | | |
| 203 | - | | blue `✳` | waiting on you | | |
| 311 | + | | blue `✳`, pulsing | waiting on you | | |
| 312 | + | | green `✻`, pulsing | done, and you haven't looked yet | | |
| 204 | 313 | | grey `✻` | idle at the prompt | | |
| 205 | 314 | | faded `·` | Claude is there, but no hooks are installed for it | | |
| 206 | 315 | | nothing | no Claude in this window | | |
| 207 | 316 | ||
| 208 | 317 | 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. | |
| 211 | 321 | ||
| 212 | 322 | 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: | |
| 215 | 335 | ||
| 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 | + | ||
| 216 | 355 | There used to be a second row of per-pane chips under the header saying the | |
| 217 | 356 | same thing at more length. It was costing a terminal line to repeat what the | |
| 218 | 357 | tabs already show, so it's gone; the trade is that a window whose tab is | |
| ⋯ 50 unchanged lines | |||
| 269 | 408 | The only command this issues is `set-option -w @tb_claude`. It cannot rename a | |
| 270 | 409 | window, change a layout, or send a key. | |
| 271 | 410 | ||
| 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 | + | ||
| 272 | 432 | ## Picking elements off the page | |
| 273 | 433 | ||
| 274 | 434 | Two ways to start it, and the difference matters: | |
| ⋯ 8 unchanged lines | |||
| 283 | 443 | navigate or submit. | |
| 284 | 444 | ||
| 285 | 445 | 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. | |
| 287 | 448 | ||
| 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 | + | ||
| 288 | 461 | Picking sends the result straight to the terminal, screenshot first: | |
| 289 | 462 | ||
| 290 | 463 | 1. The element's box is cropped out of a screenshot of the tab and put on the | |
| ⋯ 149 unchanged lines | |||
| 440 | 613 | | Config | Realm | Files | | |
| 441 | 614 | |---|---|---| | |
| 442 | 615 | | `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` | | |
| 444 | 617 | ||
| 445 | 618 | `extension/types/globals.d.ts` is hand-written rather than pulled from | |
| 446 | 619 | `@types/chrome`, so it doubles as the inventory of extension API surface this | |
| ⋯ 28 unchanged lines | |||
| 475 | 648 | daemon/src/pty.rs portable-pty backend | |
| 476 | 649 | daemon/src/tls.rs self-signed cert generation, rustls config | |
| 477 | 650 | 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) | |
| 479 | 655 | extension/picker.js injected element picker (no privileges, runs in page) | |
| 480 | 656 | 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) | |
| 483 | 660 | extension/types/ hand-written ambients: extension API surface, the | |
| 484 | 661 | daemon's wire frames, the vendored xterm build | |
| 485 | 662 | extension/ sidebar, two manifests, vendored xterm.js | |
| ⋯ 6 unchanged lines | |||
| 492 | 669 | the certificate. | |
| 493 | 670 | - Chrome has not been loaded at all; whether MV3 needs anything in | |
| 494 | 671 | `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 | |||
| 7 | 7 | out="dist/$browser" | |
| 8 | 8 | rm -rf "$out"; mkdir -p "$out" | |
| 9 | 9 | 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/" | |
| 11 | 11 | cp -r extension/vendor extension/icons "$out/" | |
| 12 | 12 | cp "extension/manifest.$browser.json" "$out/manifest.json" | |
| 13 | 13 | cp extension/sw.js "$out/" # both browsers now run a background script | |
| ⋯ 11 unchanged lines | |||
modifieddaemon/Cargo.lock+44 −44
| ⋯ 19 unchanged lines | |||
| 20 | 20 | "nom", | |
| 21 | 21 | "num-traits", | |
| 22 | 22 | "rusticata-macros", | |
| 23 | - | "thiserror 2.0.19", | |
| 23 | + | "thiserror 2.0.20", | |
| 24 | 24 | "time", | |
| 25 | 25 | ] | |
| 26 | 26 | ||
| ⋯ 70 unchanged lines | |||
| 97 | 97 | ||
| 98 | 98 | [[package]] | |
| 99 | 99 | name = "cc" | |
| 100 | - | version = "1.4.0" | |
| 100 | + | version = "1.4.2" | |
| 101 | 101 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 102 | - | checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" | |
| 102 | + | checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" | |
| 103 | 103 | dependencies = [ | |
| 104 | 104 | "find-msvc-tools", | |
| 105 | 105 | "shlex", | |
| ⋯ 48 unchanged lines | |||
| 154 | 154 | ||
| 155 | 155 | [[package]] | |
| 156 | 156 | name = "data-encoding" | |
| 157 | - | version = "2.11.0" | |
| 157 | + | version = "2.11.1" | |
| 158 | 158 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 159 | - | checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" | |
| 159 | + | checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" | |
| 160 | 160 | ||
| 161 | 161 | [[package]] | |
| 162 | 162 | name = "der-parser" | |
| ⋯ 28 unchanged lines | |||
| 191 | 191 | ||
| 192 | 192 | [[package]] | |
| 193 | 193 | name = "displaydoc" | |
| 194 | - | version = "0.2.6" | |
| 194 | + | version = "0.2.7" | |
| 195 | 195 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 196 | - | checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" | |
| 196 | + | checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" | |
| 197 | 197 | dependencies = [ | |
| 198 | 198 | "proc-macro2", | |
| 199 | 199 | "quote", | |
| 200 | - | "syn 2.0.119", | |
| 200 | + | "syn 3.0.3", | |
| 201 | 201 | ] | |
| 202 | 202 | ||
| 203 | 203 | [[package]] | |
| ⋯ 31 unchanged lines | |||
| 235 | 235 | ||
| 236 | 236 | [[package]] | |
| 237 | 237 | name = "find-msvc-tools" | |
| 238 | - | version = "0.1.9" | |
| 238 | + | version = "0.1.10" | |
| 239 | 239 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 240 | - | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" | |
| 240 | + | checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" | |
| 241 | 241 | ||
| 242 | 242 | [[package]] | |
| 243 | 243 | name = "futures-core" | |
| 244 | - | version = "0.3.33" | |
| 244 | + | version = "0.3.34" | |
| 245 | 245 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 246 | - | checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" | |
| 246 | + | checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" | |
| 247 | 247 | ||
| 248 | 248 | [[package]] | |
| 249 | 249 | name = "futures-sink" | |
| 250 | - | version = "0.3.33" | |
| 250 | + | version = "0.3.34" | |
| 251 | 251 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 252 | - | checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" | |
| 252 | + | checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" | |
| 253 | 253 | ||
| 254 | 254 | [[package]] | |
| 255 | 255 | name = "futures-task" | |
| 256 | - | version = "0.3.33" | |
| 256 | + | version = "0.3.34" | |
| 257 | 257 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 258 | - | checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" | |
| 258 | + | checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" | |
| 259 | 259 | ||
| 260 | 260 | [[package]] | |
| 261 | 261 | name = "futures-util" | |
| 262 | - | version = "0.3.33" | |
| 262 | + | version = "0.3.34" | |
| 263 | 263 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 264 | - | checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" | |
| 264 | + | checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" | |
| 265 | 265 | dependencies = [ | |
| 266 | 266 | "futures-core", | |
| 267 | 267 | "futures-sink", | |
| ⋯ 27 unchanged lines | |||
| 295 | 295 | ||
| 296 | 296 | [[package]] | |
| 297 | 297 | name = "http" | |
| 298 | - | version = "1.4.2" | |
| 298 | + | version = "1.5.0" | |
| 299 | 299 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 300 | - | checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" | |
| 300 | + | checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" | |
| 301 | 301 | dependencies = [ | |
| 302 | 302 | "bytes", | |
| 303 | 303 | "itoa", | |
| ⋯ 7 unchanged lines | |||
| 311 | 311 | ||
| 312 | 312 | [[package]] | |
| 313 | 313 | name = "hybrid-array" | |
| 314 | - | version = "0.4.13" | |
| 314 | + | version = "0.4.14" | |
| 315 | 315 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 316 | - | checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" | |
| 316 | + | checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" | |
| 317 | 317 | dependencies = [ | |
| 318 | 318 | "typenum", | |
| 319 | 319 | ] | |
| ⋯ 91 unchanged lines | |||
| 411 | 411 | ||
| 412 | 412 | [[package]] | |
| 413 | 413 | name = "num-integer" | |
| 414 | - | version = "0.1.46" | |
| 414 | + | version = "0.1.47" | |
| 415 | 415 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 416 | - | checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" | |
| 416 | + | checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" | |
| 417 | 417 | dependencies = [ | |
| 418 | 418 | "num-traits", | |
| 419 | 419 | ] | |
| ⋯ 108 unchanged lines | |||
| 528 | 528 | ||
| 529 | 529 | [[package]] | |
| 530 | 530 | name = "rcgen" | |
| 531 | - | version = "0.14.8" | |
| 531 | + | version = "0.14.9" | |
| 532 | 532 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 533 | - | checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" | |
| 533 | + | checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" | |
| 534 | 534 | dependencies = [ | |
| 535 | 535 | "pem", | |
| 536 | 536 | "ring", | |
| ⋯ 41 unchanged lines | |||
| 578 | 578 | ||
| 579 | 579 | [[package]] | |
| 580 | 580 | name = "rustls" | |
| 581 | - | version = "0.23.42" | |
| 581 | + | version = "0.23.43" | |
| 582 | 582 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 583 | - | checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" | |
| 583 | + | checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" | |
| 584 | 584 | dependencies = [ | |
| 585 | 585 | "log", | |
| 586 | 586 | "once_cell", | |
| ⋯ 15 unchanged lines | |||
| 602 | 602 | ||
| 603 | 603 | [[package]] | |
| 604 | 604 | name = "rustls-webpki" | |
| 605 | - | version = "0.103.13" | |
| 605 | + | version = "0.103.14" | |
| 606 | 606 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 607 | - | checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" | |
| 607 | + | checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" | |
| 608 | 608 | dependencies = [ | |
| 609 | 609 | "ring", | |
| 610 | 610 | "rustls-pki-types", | |
| ⋯ 45 unchanged lines | |||
| 656 | 656 | ||
| 657 | 657 | [[package]] | |
| 658 | 658 | name = "serial2" | |
| 659 | - | version = "0.2.37" | |
| 659 | + | version = "0.2.38" | |
| 660 | 660 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 661 | - | checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" | |
| 661 | + | checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" | |
| 662 | 662 | dependencies = [ | |
| 663 | 663 | "cfg-if", | |
| 664 | 664 | "libc", | |
| ⋯ 151 unchanged lines | |||
| 816 | 816 | ||
| 817 | 817 | [[package]] | |
| 818 | 818 | name = "thiserror" | |
| 819 | - | version = "2.0.19" | |
| 819 | + | version = "2.0.20" | |
| 820 | 820 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 821 | - | checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" | |
| 821 | + | checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" | |
| 822 | 822 | dependencies = [ | |
| 823 | - | "thiserror-impl 2.0.19", | |
| 823 | + | "thiserror-impl 2.0.20", | |
| 824 | 824 | ] | |
| 825 | 825 | ||
| 826 | 826 | [[package]] | |
| ⋯ 9 unchanged lines | |||
| 836 | 836 | ||
| 837 | 837 | [[package]] | |
| 838 | 838 | name = "thiserror-impl" | |
| 839 | - | version = "2.0.19" | |
| 839 | + | version = "2.0.20" | |
| 840 | 840 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 841 | - | checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" | |
| 841 | + | checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" | |
| 842 | 842 | dependencies = [ | |
| 843 | 843 | "proc-macro2", | |
| 844 | 844 | "quote", | |
| ⋯ 2 unchanged lines | |||
| 847 | 847 | ||
| 848 | 848 | [[package]] | |
| 849 | 849 | name = "time" | |
| 850 | - | version = "0.3.54" | |
| 850 | + | version = "0.3.55" | |
| 851 | 851 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 852 | - | checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" | |
| 852 | + | checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" | |
| 853 | 853 | dependencies = [ | |
| 854 | 854 | "deranged", | |
| 855 | 855 | "num-conv", | |
| ⋯ 37 unchanged lines | |||
| 893 | 893 | ||
| 894 | 894 | [[package]] | |
| 895 | 895 | name = "tokio-macros" | |
| 896 | - | version = "2.7.1" | |
| 896 | + | version = "2.7.2" | |
| 897 | 897 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 898 | - | checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" | |
| 898 | + | checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" | |
| 899 | 899 | dependencies = [ | |
| 900 | 900 | "proc-macro2", | |
| 901 | 901 | "quote", | |
| 902 | - | "syn 2.0.119", | |
| 902 | + | "syn 3.0.3", | |
| 903 | 903 | ] | |
| 904 | 904 | ||
| 905 | 905 | [[package]] | |
| ⋯ 31 unchanged lines | |||
| 937 | 937 | "log", | |
| 938 | 938 | "rand", | |
| 939 | 939 | "sha1", | |
| 940 | - | "thiserror 2.0.19", | |
| 940 | + | "thiserror 2.0.20", | |
| 941 | 941 | ] | |
| 942 | 942 | ||
| 943 | 943 | [[package]] | |
| ⋯ 153 unchanged lines | |||
| 1097 | 1097 | "oid-registry", | |
| 1098 | 1098 | "ring", | |
| 1099 | 1099 | "rusticata-macros", | |
| 1100 | - | "thiserror 2.0.19", | |
| 1100 | + | "thiserror 2.0.20", | |
| 1101 | 1101 | "time", | |
| 1102 | 1102 | ] | |
| 1103 | 1103 | ||
| ⋯ 21 unchanged lines | |||
modifieddaemon/Cargo.toml+2 −2
| ⋯ 3 unchanged lines | |||
| 4 | 4 | edition = "2024" | |
| 5 | 5 | ||
| 6 | 6 | [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"] } | |
| 8 | 8 | getrandom = "0.4.3" | |
| 9 | 9 | 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"] } | |
| 11 | 11 | serde = { version = "1.0.229", features = ["derive"] } | |
| 12 | 12 | serde_json = "1.0.151" | |
| 13 | 13 | 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 | |||
| 73 | 73 | pub updated: u64, | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 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 | + | ||
| 76 | 84 | pub fn dir() -> PathBuf { | |
| 77 | 85 | crate::paths::config_dir().join("agents") | |
| 78 | 86 | } | |
| ⋯ 26 unchanged lines | |||
| 105 | 113 | map | |
| 106 | 114 | } | |
| 107 | 115 | ||
| 108 | - | fn now() -> u64 { | |
| 116 | + | pub fn now() -> u64 { | |
| 109 | 117 | std::time::SystemTime::now() | |
| 110 | 118 | .duration_since(std::time::UNIX_EPOCH) | |
| 111 | 119 | .map(|d| d.as_secs()) | |
| ⋯ 215 unchanged lines | |||
| 327 | 335 | "session_id": "a", "hook_event_name": "SessionStart", "cwd": "/tmp/p", | |
| 328 | 336 | })); | |
| 329 | 337 | 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 | + | ); | |
| 334 | 344 | assert!(read_all().is_empty()); | |
| 335 | 345 | }); | |
| 336 | 346 | } | |
| ⋯ 2 unchanged lines | |||
| 339 | 349 | fn unknown_events_and_ids_are_ignored() { | |
| 340 | 350 | with_temp_dir(|| { | |
| 341 | 351 | 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 | + | ); | |
| 343 | 355 | assert!(read_all().is_empty()); | |
| 344 | 356 | }); | |
| 345 | 357 | } | |
| ⋯ 7 unchanged lines | |||
modifieddaemon/src/control.rs+5 −2
| ⋯ 24 unchanged lines | |||
| 25 | 25 | use std::sync::Arc; | |
| 26 | 26 | ||
| 27 | 27 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; | |
| 28 | - | use tokio::sync::{mpsc, oneshot, Mutex}; | |
| 28 | + | use tokio::sync::{Mutex, mpsc, oneshot}; | |
| 29 | 29 | ||
| 30 | 30 | /// A `%`-prefixed line tmux sent us unprompted, verbatim. | |
| 31 | 31 | pub type Notification = String; | |
| ⋯ 47 unchanged lines | |||
| 79 | 79 | tokio::spawn(async move { | |
| 80 | 80 | while let Some((line, reply)) = jobs_rx.recv().await { | |
| 81 | 81 | 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() | |
| 83 | 86 | || stdin.flush().await.is_err() | |
| 84 | 87 | { | |
| 85 | 88 | 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('&', "&") | |
| 397 | + | .replace('<', "<") | |
| 398 | + | .replace('>', ">") | |
| 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&b/termbridge</string>")); | |
| 444 | + | assert!(plist.contains(LAUNCHD_LABEL)); | |
| 445 | + | } | |
| 446 | + | } |
modifieddaemon/src/lib.rs+9 −1
| 1 | + | pub mod activation; | |
| 1 | 2 | pub mod agents; | |
| 2 | 3 | pub mod auth; | |
| 3 | 4 | pub mod control; | |
| 5 | + | pub mod install; | |
| 4 | 6 | pub mod paths; | |
| 7 | + | pub mod project; | |
| 5 | 8 | pub mod pty; | |
| 6 | 9 | pub mod rewind; | |
| 10 | + | pub mod server; | |
| 11 | + | pub mod ssh; | |
| 7 | 12 | pub mod status; | |
| 8 | 13 | pub mod tls; | |
| 9 | 14 | pub mod window_status; | |
| 10 | - | pub mod server; | |
| 11 | 15 | ||
| 12 | 16 | 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 | |||
| 4 | 4 | use termbridge::pty::Profile; | |
| 5 | 5 | use termbridge::{Config, Event, Server}; | |
| 6 | 6 | ||
| 7 | - | const DEFAULT_PORT: u16 = 7681; | |
| 7 | + | use termbridge::DEFAULT_PORT; | |
| 8 | 8 | ||
| 9 | 9 | fn usage() -> ! { | |
| 10 | 10 | eprintln!( | |
| ⋯ 2 unchanged lines | |||
| 13 | 13 | USAGE: | |
| 14 | 14 | termbridge serve [--port N] [--session NAME] | |
| 15 | 15 | 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) | |
| 16 | 22 | termbridge token print the auth token (paste into extension) | |
| 17 | 23 | termbridge token --rotate generate a new token | |
| 18 | 24 | termbridge pair <origin> approve an extension origin | |
| ⋯ 17 unchanged lines | |||
| 36 | 42 | async fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 37 | 43 | let args: Vec<String> = std::env::args().skip(1).collect(); | |
| 38 | 44 | 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 | + | }; | |
| 39 | 51 | ||
| 40 | 52 | match args.first().map(String::as_str) { | |
| 41 | 53 | 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") | |
| 45 | 83 | .and_then(|p| p.parse().ok()) | |
| 46 | 84 | .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"), | |
| 48 | 89 | }; | |
| 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(()) | |
| 55 | 103 | } | |
| 56 | 104 | Some("token") => { | |
| 57 | 105 | let token = if args.iter().any(|a| a == "--rotate") { | |
| ⋯ 29 unchanged lines | |||
| 87 | 135 | } else { | |
| 88 | 136 | termbridge::tls::load_or_create(&dir)? | |
| 89 | 137 | }; | |
| 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 | + | ); | |
| 92 | 146 | println!("SHA-256: {}", id.fingerprint); | |
| 93 | 147 | Ok(()) | |
| 94 | 148 | } | |
| ⋯ 30 unchanged lines | |||
| 125 | 179 | Ok(()) | |
| 126 | 180 | } | |
| 127 | 181 | 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"); | |
| 131 | 183 | print!("{}", termbridge::window_status::tmux_conf()); | |
| 132 | 184 | println!( | |
| 133 | 185 | "\nThe hooks are what set it, so install those first if you haven't:\n\ | |
| ⋯ 19 unchanged lines | |||
| 153 | 205 | } | |
| 154 | 206 | } | |
| 155 | 207 | ||
| 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>> { | |
| 157 | 214 | let dir = paths::config_dir(); | |
| 158 | 215 | let token = paths::load_or_create_token(&dir)?; | |
| 159 | 216 | let paired = paths::load_paired_origins(&dir); | |
| ⋯ 1 unchanged line | |||
| 161 | 218 | let identity = termbridge::tls::load_or_create(&dir)?; | |
| 162 | 219 | let mut config = Config::new(token, paired.clone()); | |
| 163 | 220 | config.auth_timeout = Duration::from_secs(3); | |
| 221 | + | config.idle_timeout = idle_timeout; | |
| 164 | 222 | config.tls = Some(termbridge::tls::acceptor(&identity)?); | |
| 165 | 223 | let pinned = session | |
| 166 | 224 | .as_deref() | |
| ⋯ 3 unchanged lines | |||
| 170 | 228 | .unwrap_or_else(|| termbridge::pty::DEFAULT_SESSION.to_string()); | |
| 171 | 229 | config.default_session = default_session.clone(); | |
| 172 | 230 | // 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. | |
| 174 | 232 | config.adopt_sole_session = pinned.is_none(); | |
| 175 | 233 | config.profile = if Profile::tmux_available() { | |
| 176 | 234 | Profile::tmux(&default_session) | |
| ⋯ 5 unchanged lines | |||
| 182 | 240 | let profile = config.profile.clone(); | |
| 183 | 241 | let config_adopts_sole = config.adopt_sole_session && profile.program == "tmux"; | |
| 184 | 242 | ||
| 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 | + | ); | |
| 187 | 251 | println!("running: {} {}", profile.program, profile.args.join(" ")); | |
| 188 | 252 | let existing = termbridge::pty::list_sessions(&profile.tmux_global_args()); | |
| 189 | 253 | if !existing.is_empty() { | |
| ⋯ 25 unchanged lines | |||
| 215 | 279 | while let Some(event) = server.next_event().await { | |
| 216 | 280 | match event { | |
| 217 | 281 | 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 | + | } => { | |
| 219 | 297 | let o = origin.unwrap_or_else(|| "<none>".into()); | |
| 220 | 298 | println!("[reject] {o}: {}", why.reason()); | |
| 221 | 299 | if let Some(d) = detail { | |
| ⋯ 29 unchanged lines | |||
modifieddaemon/src/paths.rs+158 −4
| ⋯ 15 unchanged lines | |||
| 16 | 16 | Io(io::Error), | |
| 17 | 17 | /// The token file is readable by group or other. Refuse rather than | |
| 18 | 18 | /// silently authenticate against a world-readable secret. | |
| 19 | - | TooPermissive { path: PathBuf, mode: u32 }, | |
| 19 | + | TooPermissive { | |
| 20 | + | path: PathBuf, | |
| 21 | + | mode: u32, | |
| 22 | + | }, | |
| 20 | 23 | Malformed, | |
| 21 | 24 | } | |
| 22 | 25 | ||
| ⋯ 29 unchanged lines | |||
| 52 | 55 | let base = std::env::var_os("XDG_CONFIG_HOME") | |
| 53 | 56 | .map(PathBuf::from) | |
| 54 | 57 | .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(); | |
| 56 | 61 | home.join(".config") | |
| 57 | 62 | }); | |
| 58 | 63 | base.join("termbridge") | |
| ⋯ 89 unchanged lines | |||
| 148 | 153 | .truncate(true) | |
| 149 | 154 | .mode(0o600) | |
| 150 | 155 | .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 | + | )?; | |
| 152 | 160 | for o in &existing { | |
| 153 | 161 | writeln!(f, "{o}")?; | |
| 154 | 162 | } | |
| ⋯ 13 unchanged lines | |||
| 168 | 176 | .truncate(true) | |
| 169 | 177 | .mode(0o600) | |
| 170 | 178 | .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 | + | )?; | |
| 172 | 183 | for o in existing.iter().filter(|o| *o != &origin) { | |
| 173 | 184 | writeln!(f, "{o}")?; | |
| 174 | 185 | } | |
| 175 | 186 | Ok(true) | |
| 176 | 187 | } | |
| 177 | 188 | ||
| 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 | + | ||
| 178 | 332 | fn random_hex(n: usize) -> String { | |
| 179 | 333 | let mut buf = vec![0u8; n]; | |
| 180 | 334 | // 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 | |||
| 31 | 31 | } | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 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"; | |
| 35 | 42 | ||
| 36 | 43 | /// Session names reach `execvp` as a separate argv element, never a shell, so | |
| 37 | 44 | /// metacharacters cannot inject a command. The one real hazard is a leading | |
| ⋯ 17 unchanged lines | |||
| 55 | 62 | Some(s.to_string()) | |
| 56 | 63 | } | |
| 57 | 64 | ||
| 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 | + | ||
| 58 | 116 | /// Existing tmux sessions, so the sidebar can offer them instead of guessing. | |
| 59 | 117 | /// | |
| 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 | + | /// | |
| 60 | 122 | /// `global_args` carries the server-selection flags from the profile; without | |
| 61 | 123 | /// them this asks the *default* tmux server, which is a different set of | |
| 62 | 124 | /// sessions than the one the sidebar is attached to. | |
| 63 | 125 | pub fn list_sessions(global_args: &[String]) -> Vec<String> { | |
| 64 | 126 | let Ok(out) = std::process::Command::new("tmux") | |
| 65 | 127 | .args(global_args) | |
| 66 | - | .args(["list-sessions", "-F", "#{session_name}"]) | |
| 128 | + | .args(["list-sessions", "-F", "#{session_id}\t#{session_name}"]) | |
| 67 | 129 | .output() | |
| 68 | 130 | else { | |
| 69 | 131 | return Vec::new(); | |
| ⋯ 1 unchanged line | |||
| 71 | 133 | if !out.status.success() { | |
| 72 | 134 | return Vec::new(); // no server running yet | |
| 73 | 135 | } | |
| 74 | - | String::from_utf8_lossy(&out.stdout) | |
| 136 | + | let text = String::from_utf8_lossy(&out.stdout); | |
| 137 | + | let mut sessions: Vec<(u64, String)> = text | |
| 75 | 138 | .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() | |
| 80 | 148 | } | |
| 81 | 149 | ||
| 82 | 150 | /// The one existing tmux session, when there is exactly one. | |
| ⋯ 35 unchanged lines | |||
| 118 | 186 | Some(s.to_string()) | |
| 119 | 187 | } | |
| 120 | 188 | ||
| 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 | + | ||
| 121 | 201 | /// Our own pty's slave path, on its way into `switch-client -c`. It comes from | |
| 122 | 202 | /// the kernel rather than a client, but it is still quoted into a command line, | |
| 123 | 203 | /// so it gets the same treatment. | |
| ⋯ 236 unchanged lines | |||
modifieddaemon/src/server.rs+1309 −29
| ⋯ 5 unchanged lines | |||
| 6 | 6 | ||
| 7 | 7 | use std::net::{IpAddr, Ipv4Addr, SocketAddr}; | |
| 8 | 8 | use std::sync::Arc; | |
| 9 | - | use std::time::Duration; | |
| 9 | + | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 10 | + | use std::time::{Duration, Instant}; | |
| 10 | 11 | ||
| 11 | 12 | use futures_util::{SinkExt, StreamExt}; | |
| 12 | 13 | use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; | |
| 13 | 14 | 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; | |
| 15 | 18 | use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response}; | |
| 16 | 19 | use tokio_tungstenite::tungstenite::http; | |
| 17 | - | use tokio_tungstenite::tungstenite::Message; | |
| 18 | - | use tokio_tungstenite::WebSocketStream; | |
| 19 | 20 | ||
| 20 | 21 | use crate::auth::{self, Denied}; | |
| 21 | 22 | ||
| ⋯ 20 unchanged lines | |||
| 42 | 43 | /// TLS identity. When present the listener serves wss:// and ws:// on the | |
| 43 | 44 | /// same port, chosen per-connection by sniffing the first byte. | |
| 44 | 45 | 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>, | |
| 45 | 52 | } | |
| 46 | 53 | ||
| 47 | 54 | impl Config { | |
| ⋯ 11 unchanged lines | |||
| 59 | 66 | adopt_sole_session: false, | |
| 60 | 67 | echo_only: false, | |
| 61 | 68 | tls: None, | |
| 69 | + | idle_timeout: None, | |
| 62 | 70 | } | |
| 63 | 71 | } | |
| 64 | 72 | ||
| ⋯ 14 unchanged lines | |||
| 79 | 87 | ||
| 80 | 88 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 81 | 89 | 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, | |
| 83 | 96 | Rejected { | |
| 84 | 97 | origin: Option<String>, | |
| 85 | 98 | why: Denied, | |
| ⋯ 12 unchanged lines | |||
| 98 | 111 | config: Config, | |
| 99 | 112 | failures: Mutex<Failures>, | |
| 100 | 113 | 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 | + | } | |
| 101 | 138 | } | |
| 102 | 139 | ||
| 103 | 140 | impl State { | |
| ⋯ 38 unchanged lines | |||
| 142 | 179 | /// only purpose is to create a remotely exploitable configuration. | |
| 143 | 180 | pub async fn start(config: Config, port: u16) -> std::io::Result<Self> { | |
| 144 | 181 | 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> { | |
| 146 | 195 | let addr = listener.local_addr()?; | |
| 147 | 196 | ||
| 148 | 197 | // 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. | |
| 150 | 200 | assert!( | |
| 151 | 201 | addr.ip().is_loopback(), | |
| 152 | 202 | "refusing to serve on non-loopback address {addr}" | |
| 153 | 203 | ); | |
| 154 | 204 | ||
| 155 | 205 | let (tx, rx) = mpsc::unbounded_channel(); | |
| 206 | + | let idle_timeout = config.idle_timeout; | |
| 156 | 207 | let state = Arc::new(State { | |
| 157 | 208 | config, | |
| 158 | 209 | failures: Mutex::new(Failures { | |
| ⋯ 1 unchanged line | |||
| 160 | 211 | locked_until: None, | |
| 161 | 212 | }), | |
| 162 | 213 | events: tx, | |
| 214 | + | live: AtomicUsize::new(0), | |
| 215 | + | quiet_since: std::sync::Mutex::new(Instant::now()), | |
| 163 | 216 | }); | |
| 164 | 217 | ||
| 218 | + | if let Some(timeout) = idle_timeout { | |
| 219 | + | tokio::spawn(watch_idle(Arc::clone(&state), timeout)); | |
| 220 | + | } | |
| 221 | + | ||
| 165 | 222 | let task = tokio::spawn(async move { | |
| 166 | 223 | loop { | |
| 167 | 224 | let Ok((stream, peer)) = listener.accept().await else { | |
| ⋯ 1 unchanged line | |||
| 169 | 226 | }; | |
| 170 | 227 | let state = Arc::clone(&state); | |
| 171 | 228 | tokio::spawn(async move { | |
| 229 | + | let _live = Live::new(&state); | |
| 172 | 230 | let _ = handle_conn(stream, peer, state).await; | |
| 173 | 231 | }); | |
| 174 | 232 | } | |
| ⋯ 19 unchanged lines | |||
| 194 | 252 | } | |
| 195 | 253 | } | |
| 196 | 254 | ||
| 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 | + | ||
| 197 | 278 | fn deny(why: Denied) -> ErrorResponse { | |
| 198 | 279 | http::Response::builder() | |
| 199 | 280 | .status(why.status()) | |
| ⋯ 57 unchanged lines | |||
| 257 | 338 | ||
| 258 | 339 | /// Serve one already-negotiated stream: either the certificate-trust landing | |
| 259 | 340 | /// 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>> | |
| 261 | 345 | where | |
| 262 | 346 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, | |
| 263 | 347 | { | |
| 264 | 348 | // Look at the request head so a browser that navigated here to accept the | |
| 265 | 349 | // 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(); | |
| 267 | 353 | if !crate::rewind::is_websocket_upgrade(&head) { | |
| 268 | 354 | state.emit(Event::Rejected { | |
| 269 | 355 | origin: None, | |
| ⋯ 26 unchanged lines | |||
| 296 | 382 | "other data, and in particular it never hands out the auth token.</p>" | |
| 297 | 383 | ); | |
| 298 | 384 | ||
| 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>> | |
| 300 | 389 | where | |
| 301 | 390 | S: AsyncRead + AsyncWrite + Unpin + Send + 'static, | |
| 302 | 391 | { | |
| ⋯ 41 unchanged lines | |||
| 344 | 433 | None => (Denied::NotAWebSocketUpgrade, Some(e.to_string())), | |
| 345 | 434 | }; | |
| 346 | 435 | state.record_failure().await; | |
| 347 | - | state.emit(Event::Rejected { origin, why, detail }); | |
| 436 | + | state.emit(Event::Rejected { | |
| 437 | + | origin, | |
| 438 | + | why, | |
| 439 | + | detail, | |
| 440 | + | }); | |
| 348 | 441 | return Ok(()); | |
| 349 | 442 | } | |
| 350 | 443 | }; | |
| ⋯ 43 unchanged lines | |||
| 394 | 487 | // Enumerated server-side; the client picks from reality rather | |
| 395 | 488 | // than inventing names. | |
| 396 | 489 | "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() }, | |
| 397 | 496 | }) | |
| 398 | 497 | .to_string() | |
| 399 | 498 | .into(), | |
| ⋯ 17 unchanged lines | |||
| 417 | 516 | { | |
| 418 | 517 | // Wait for the client to tell us its size before spawning, so the shell's | |
| 419 | 518 | // 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 | + | }; | |
| 425 | 524 | ||
| 426 | 525 | // The client may name a tmux session, but only a name that survives | |
| 427 | 526 | // validation, and only when we are actually running tmux. Anything else | |
| ⋯ 38 unchanged lines | |||
| 466 | 565 | // watches for those changes and carries the sidebar's requests back. | |
| 467 | 566 | let (status_tx, mut status_rx) = mpsc::channel::<String>(8); | |
| 468 | 567 | 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(); | |
| 469 | 571 | if profile.program == "tmux" { | |
| 470 | 572 | // The session the interactive client was pointed at. Only used to give | |
| 471 | 573 | // the control client something to attach to; from then on tmux tells us | |
| ⋯ 53 unchanged lines | |||
| 525 | 627 | // dropping the request beats stalling the terminal. | |
| 526 | 628 | let _ = tmux_tx.try_send(req); | |
| 527 | 629 | } | |
| 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 | + | } | |
| 528 | 640 | } | |
| 529 | 641 | Some(Ok(Message::Close(_))) | None => break, | |
| 530 | 642 | Some(Err(_)) => break, | |
| ⋯ 39 unchanged lines | |||
| 570 | 682 | /// deliberately moves every client watching that session, exactly as | |
| 571 | 683 | /// pressing `prefix 2` in the terminal would. | |
| 572 | 684 | 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), | |
| 573 | 693 | /// Open a window in a session and select it — the sidebar's "+". | |
| 574 | 694 | 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 }, | |
| 575 | 791 | /// Close a window and everything running in it — the sidebar's tab ✕. | |
| 576 | 792 | /// | |
| 577 | 793 | /// The one entry here that destroys anything, and tmux has no undo for it. | |
| ⋯ 3 unchanged lines | |||
| 581 | 797 | KillWindow(String), | |
| 582 | 798 | } | |
| 583 | 799 | ||
| 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 | + | ||
| 584 | 859 | fn parse_tmux_request(text: &str) -> Option<TmuxRequest> { | |
| 585 | 860 | let v: serde_json::Value = serde_json::from_str(text).ok()?; | |
| 586 | 861 | if v.get("type")?.as_str()? != "tmux" { | |
| ⋯ 7 unchanged lines | |||
| 594 | 869 | "select-window" => { | |
| 595 | 870 | crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::SelectWindow) | |
| 596 | 871 | } | |
| 872 | + | "goto-window" => crate::pty::valid_window_id(arg("window")?).map(TmuxRequest::GotoWindow), | |
| 597 | 873 | "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 | + | }), | |
| 598 | 919 | "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 | + | }), | |
| 599 | 931 | _ => None, | |
| 600 | 932 | } | |
| 601 | 933 | } | |
| 602 | 934 | ||
| 603 | 935 | /// Command lines for a request. `tty` identifies our interactive client, so the | |
| 604 | 936 | /// 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 | + | ||
| 605 | 975 | fn command_lines(req: &TmuxRequest, tty: Option<&str>) -> Vec<String> { | |
| 606 | 976 | let client = tty | |
| 607 | 977 | .and_then(crate::pty::valid_tty) | |
| ⋯ 13 unchanged lines | |||
| 621 | 991 | // No `-c`: a window id already identifies its session, and selecting a | |
| 622 | 992 | // window is a property of the session rather than of our client. | |
| 623 | 993 | 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 | + | )], | |
| 624 | 1000 | // `-a` inserts after the current window instead of claiming an index | |
| 625 | 1001 | // that may already be taken, which is an error rather than a shuffle. | |
| 626 | 1002 | // 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 | + | } | |
| 628 | 1159 | 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 | + | }], | |
| 629 | 1173 | } | |
| 630 | 1174 | } | |
| 631 | 1175 | ||
| ⋯ 14 unchanged lines | |||
| 646 | 1190 | // small files; there is no process spawn on this path at all. | |
| 647 | 1191 | let mut ticker = tokio::time::interval(Duration::from_secs(1)); | |
| 648 | 1192 | 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; | |
| 649 | 1197 | ||
| 650 | 1198 | loop { | |
| 651 | 1199 | 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 | + | } | |
| 652 | 1219 | if last.as_ref() != Some(&snap) { | |
| 653 | 1220 | let json = serde_json::json!({ | |
| 654 | 1221 | "type": "status", | |
| 655 | 1222 | "session": snap.session, | |
| 656 | 1223 | "sessions": snap.sessions, | |
| 657 | - | "windows": snap.windows, | |
| 658 | 1224 | "agents": snap.agents, | |
| 659 | 1225 | }) | |
| 660 | 1226 | .to_string(); | |
| 661 | 1227 | if status_tx.send(json).await.is_err() { | |
| 662 | - | return; // Connection gone. | |
| 1228 | + | break; // Connection gone. | |
| 663 | 1229 | } | |
| 664 | 1230 | last = Some(snap); | |
| 665 | 1231 | } | |
| ⋯ 7 unchanged lines | |||
| 673 | 1239 | // layout change nobody displays. | |
| 674 | 1240 | Some(n) if !crate::control::is_interesting(&n) => continue, | |
| 675 | 1241 | Some(_) => {} | |
| 676 | - | None => return, | |
| 1242 | + | None => break, | |
| 677 | 1243 | } | |
| 678 | 1244 | } | |
| 679 | 1245 | 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 | + | } | |
| 681 | 1254 | for line in command_lines(&req, tty.as_deref()) { | |
| 682 | 1255 | if let Err(reason) = control.run(line).await { | |
| 683 | 1256 | let json = serde_json::json!({ | |
| ⋯ 6 unchanged lines | |||
| 690 | 1263 | } | |
| 691 | 1264 | } | |
| 692 | 1265 | } | |
| 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; | |
| 693 | 1369 | } | |
| 694 | 1370 | ||
| 695 | 1371 | /// The interactive client creates the session, and we may get here first. | |
| ⋯ 39 unchanged lines | |||
| 735 | 1411 | } | |
| 736 | 1412 | ||
| 737 | 1413 | 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 | + | }; | |
| 739 | 1420 | (get("cols", 80), get("rows", 24)) | |
| 740 | 1421 | } | |
| 741 | 1422 | ||
| ⋯ 19 unchanged lines | |||
| 761 | 1442 | use super::*; | |
| 762 | 1443 | ||
| 763 | 1444 | /// 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. | |
| 765 | 1446 | /// Two sessions is ambiguous, and the configured default wins again. | |
| 766 | 1447 | /// | |
| 767 | 1448 | /// Runs against a real tmux on a private socket; skipped where there is no | |
| ⋯ 24 unchanged lines | |||
| 792 | 1473 | "new-session".into(), | |
| 793 | 1474 | "-A".into(), | |
| 794 | 1475 | "-s".into(), | |
| 795 | - | "browser".into(), | |
| 1476 | + | crate::pty::DEFAULT_SESSION.into(), | |
| 796 | 1477 | ], | |
| 797 | 1478 | }; | |
| 798 | - | config.default_session = "browser".into(); | |
| 1479 | + | config.default_session = crate::pty::DEFAULT_SESSION.into(); | |
| 799 | 1480 | config.adopt_sole_session = true; | |
| 800 | 1481 | ||
| 801 | 1482 | // No server running: nothing to adopt. | |
| ⋯ 14 unchanged lines | |||
| 816 | 1497 | let _ = tmux(&["kill-server"]); | |
| 817 | 1498 | } | |
| 818 | 1499 | ||
| 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 | + | ||
| 819 | 1569 | /// The wire protocol must not be able to name a tmux command. Anything the | |
| 820 | 1570 | /// sidebar sends either maps to one of the allowlisted variants or is | |
| 821 | 1571 | /// dropped. | |
| ⋯ 13 unchanged lines | |||
| 835 | 1585 | Some(TmuxRequest::SelectWindow("@3".into())) | |
| 836 | 1586 | ); | |
| 837 | 1587 | assert_eq!( | |
| 1588 | + | req(r#"{"type":"tmux","cmd":"goto-window","window":"@3"}"#), | |
| 1589 | + | Some(TmuxRequest::GotoWindow("@3".into())) | |
| 1590 | + | ); | |
| 1591 | + | assert_eq!( | |
| 838 | 1592 | req(r#"{"type":"tmux","cmd":"new-window","session":"work"}"#), | |
| 839 | 1593 | Some(TmuxRequest::NewWindow("work".into())) | |
| 840 | 1594 | ); | |
| 841 | 1595 | 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!( | |
| 842 | 1603 | req(r#"{"type":"tmux","cmd":"kill-window","window":"@3"}"#), | |
| 843 | 1604 | Some(TmuxRequest::KillWindow("@3".into())) | |
| 844 | 1605 | ); | |
| 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 | + | ); | |
| 845 | 1661 | // 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 | + | ); | |
| 848 | 1670 | 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 | + | ); | |
| 850 | 1675 | assert_eq!(req(r#"{"type":"resize","cols":80,"rows":24}"#), None); | |
| 851 | 1676 | } | |
| 852 | 1677 | ||
| ⋯ 15 unchanged lines | |||
| 868 | 1693 | // An index is not an id: `2` would be a bare target, and | |
| 869 | 1694 | // `session:2.0` carries syntax of its own. | |
| 870 | 1695 | 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"}"#, | |
| 871 | 1704 | r#"{"type":"tmux","cmd":"new-window","session":"a' ; kill-server ; '"}"#, | |
| 872 | 1705 | r#"{"type":"tmux","cmd":"new-window","session":"work:1"}"#, | |
| 873 | 1706 | // The destructive one gets the same validator, and `-a` is the | |
| ⋯ 1 unchanged line | |||
| 875 | 1708 | r#"{"type":"tmux","cmd":"kill-window","window":"@1 ; kill-server"}"#, | |
| 876 | 1709 | r#"{"type":"tmux","cmd":"kill-window","window":"-a"}"#, | |
| 877 | 1710 | 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"}"#, | |
| 878 | 1739 | ] { | |
| 879 | 1740 | assert_eq!(req(hostile), None, "accepted {hostile}"); | |
| 880 | 1741 | } | |
| ⋯ 7 unchanged lines | |||
| 888 | 1749 | assert_eq!(lines, vec!["select-window -t '@3'"]); | |
| 889 | 1750 | ||
| 890 | 1751 | // -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 | + | ); | |
| 891 | 1762 | 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 | + | ); | |
| 893 | 1767 | ||
| 894 | 1768 | // One window, named by id. No -a, which would kill all *but* it. | |
| 895 | 1769 | let lines = command_lines(&TmuxRequest::KillWindow("@3".into()), Some("/dev/pts/7")); | |
| 896 | 1770 | 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'"]); | |
| 897 | 1895 | } | |
| 898 | 1896 | ||
| 899 | 1897 | #[test] | |
| ⋯ 9 unchanged lines | |||
| 909 | 1907 | } | |
| 910 | 1908 | ||
| 911 | 1909 | #[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] | |
| 912 | 2192 | fn create_attaches_rather_than_failing_on_an_existing_name() { | |
| 913 | 2193 | let lines = command_lines(&TmuxRequest::Create("scratch".into()), None); | |
| 914 | 2194 | 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 | |||
| 8 | 8 | //! - Claude Code's hook interface, via the records in [`crate::agents`]. | |
| 9 | 9 | //! | |
| 10 | 10 | //! 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". | |
| 11 | 15 | ||
| 16 | + | use std::collections::{HashMap, HashSet}; | |
| 17 | + | use std::sync::{Mutex, OnceLock}; | |
| 18 | + | ||
| 12 | 19 | use serde::Serialize; | |
| 13 | 20 | ||
| 21 | + | use crate::agents::{Record, State}; | |
| 14 | 22 | use crate::control::Control; | |
| 15 | 23 | ||
| 16 | 24 | /// Tab character, as tmux sees it. Single quotes make tmux take the byte | |
| ⋯ 6 unchanged lines | |||
| 23 | 31 | pub struct Agent { | |
| 24 | 32 | /// tmux pane id (`%12`), stable for the pane's lifetime. | |
| 25 | 33 | 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, | |
| 26 | 37 | /// tmux window id (`@3`) — the join to [`WindowInfo`], so the sidebar can | |
| 27 | 38 | /// mark the tab an agent is in without matching on a shifting index. | |
| 28 | 39 | pub window_id: String, | |
| ⋯ 2 unchanged lines | |||
| 31 | 42 | pub name: String, | |
| 32 | 43 | /// `working` | `waiting` | `idle`, or `unknown` when the pane is running | |
| 33 | 44 | /// 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`]. | |
| 34 | 48 | 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, | |
| 35 | 55 | /// Claude's `permission_mode`: `default`, `acceptEdits`, `plan`, | |
| 36 | 56 | /// `bypassPermissions`. | |
| 37 | 57 | pub mode: Option<String>, | |
| ⋯ 5 unchanged lines | |||
| 43 | 63 | pub title: Option<String>, | |
| 44 | 64 | } | |
| 45 | 65 | ||
| 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. | |
| 46 | 82 | #[derive(Serialize, Debug, Clone, PartialEq, Eq)] | |
| 47 | 83 | pub struct SessionInfo { | |
| 84 | + | /// tmux session id (`$1`). Stable across renames, unlike the name. | |
| 85 | + | pub id: String, | |
| 48 | 86 | pub name: String, | |
| 49 | - | pub windows: u32, | |
| 87 | + | /// Some tmux client, anywhere, is on this session. | |
| 50 | 88 | 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>, | |
| 51 | 105 | } | |
| 52 | 106 | ||
| 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. | |
| 54 | 108 | #[derive(Serialize, Debug, Clone, PartialEq, Eq)] | |
| 55 | 109 | pub struct WindowInfo { | |
| 56 | 110 | /// tmux window id (`@3`). Stable for the window's lifetime, unlike the | |
| ⋯ 9 unchanged lines | |||
| 66 | 120 | pub activity: bool, | |
| 67 | 121 | } | |
| 68 | 122 | ||
| 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 | + | ||
| 69 | 132 | #[derive(Serialize, Debug, Clone, PartialEq, Eq)] | |
| 70 | 133 | pub struct Snapshot { | |
| 71 | 134 | /// Session our client is attached to *right now*, not the one we asked for. | |
| 72 | 135 | 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. | |
| 74 | 140 | 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. | |
| 77 | 143 | pub agents: Vec<Agent>, | |
| 78 | 144 | } | |
| 79 | 145 | ||
| 80 | 146 | /// 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. | |
| 81 | 152 | 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; | |
| 83 | 154 | 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), | |
| 87 | 159 | }; | |
| 88 | 160 | Snapshot { | |
| 89 | 161 | session, | |
| 162 | + | session_id, | |
| 90 | 163 | sessions, | |
| 91 | - | windows, | |
| 92 | 164 | agents, | |
| 93 | 165 | } | |
| 94 | 166 | } | |
| ⋯ 3 unchanged lines | |||
| 98 | 170 | /// tmux is the authority: the client can leave the session it was spawned with | |
| 99 | 171 | /// (`switch-client`, `choose-tree`, prefix-`(`/`)`), and the name we passed to | |
| 100 | 172 | /// `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> { | |
| 102 | 174 | let tty = tty?; | |
| 103 | 175 | 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 | + | )) | |
| 105 | 179 | .await | |
| 106 | 180 | .ok()?; | |
| 107 | 181 | 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()) | |
| 112 | 192 | } | |
| 113 | 193 | ||
| 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. | |
| 114 | 201 | 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 | + | ); | |
| 116 | 212 | let Ok(out) = control.run(format!("list-sessions -F '{fmt}'")).await else { | |
| 117 | 213 | return Vec::new(); | |
| 118 | 214 | }; | |
| 119 | - | out.iter() | |
| 215 | + | let mut windows = list_windows(control).await; | |
| 216 | + | let mut sessions: Vec<SessionInfo> = out | |
| 217 | + | .iter() | |
| 120 | 218 | .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(); | |
| 122 | 237 | 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, | |
| 126 | 246 | }) | |
| 127 | 247 | }) | |
| 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) | |
| 129 | 259 | } | |
| 130 | 260 | ||
| 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>> { | |
| 136 | 264 | 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}}" | |
| 139 | 267 | ); | |
| 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; | |
| 145 | 271 | }; | |
| 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() | |
| 159 | 384 | } | |
| 160 | 385 | ||
| 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> { | |
| 162 | 392 | 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}}" | |
| 165 | 395 | ); | |
| 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 { | |
| 172 | 397 | return Vec::new(); | |
| 173 | 398 | }; | |
| 174 | 399 | 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(); | |
| 175 | 402 | ||
| 176 | - | out.iter() | |
| 403 | + | let agents: Vec<Agent> = out | |
| 404 | + | .iter() | |
| 177 | 405 | .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); | |
| 179 | 410 | let pane = f.next()?.to_string(); | |
| 180 | 411 | let window_id = f.next()?.to_string(); | |
| 181 | 412 | 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(); | |
| 182 | 417 | 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); | |
| 192 | 425 | Some(Agent { | |
| 193 | 426 | pane, | |
| 427 | + | session, | |
| 194 | 428 | window_id, | |
| 195 | 429 | window, | |
| 196 | 430 | name, | |
| 197 | - | state: record.map(|r| r.state.as_str()).unwrap_or("unknown"), | |
| 431 | + | state, | |
| 432 | + | here: visible, | |
| 198 | 433 | mode: record.and_then(|r| r.mode.clone()), | |
| 199 | 434 | tool: record.and_then(|r| r.tool.clone()), | |
| 200 | 435 | message: record.and_then(|r| r.message.clone()), | |
| 201 | 436 | title: record.and_then(|r| r.name.clone()), | |
| 202 | 437 | }) | |
| 203 | 438 | }) | |
| 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 | + | } | |
| 205 | 514 | } | |
modifieddaemon/src/tls.rs+11 −7
| ⋯ 13 unchanged lines | |||
| 14 | 14 | use std::path::Path; | |
| 15 | 15 | use std::sync::Arc; | |
| 16 | 16 | ||
| 17 | - | use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; | |
| 18 | - | use tokio_rustls::rustls::ServerConfig; | |
| 19 | 17 | use tokio_rustls::TlsAcceptor; | |
| 18 | + | use tokio_rustls::rustls::ServerConfig; | |
| 19 | + | use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; | |
| 20 | 20 | ||
| 21 | 21 | pub const CERT_FILE: &str = "cert.pem"; | |
| 22 | 22 | pub const KEY_FILE: &str = "key.pem"; | |
| ⋯ 175 unchanged lines | |||
| 198 | 198 | fn parse_private_key(pem: &str) -> std::io::Result<PrivateKeyDer<'static>> { | |
| 199 | 199 | for (begin, end) in [ | |
| 200 | 200 | ("-----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 | + | ), | |
| 203 | 209 | ] { | |
| 204 | 210 | if let Some(rest) = pem.split(begin).nth(1) { | |
| 205 | 211 | if let Some(body) = rest.split(end).next() { | |
| 206 | 212 | let b64: String = body.chars().filter(|c| !c.is_whitespace()).collect(); | |
| 207 | 213 | let der = base64_decode(&b64)?; | |
| 208 | 214 | return Ok(match begin { | |
| 209 | - | "-----BEGIN EC PRIVATE KEY-----" => { | |
| 210 | - | PrivateKeyDer::Sec1(der.into()) | |
| 211 | - | } | |
| 215 | + | "-----BEGIN EC PRIVATE KEY-----" => PrivateKeyDer::Sec1(der.into()), | |
| 212 | 216 | "-----BEGIN RSA PRIVATE KEY-----" => PrivateKeyDer::Pkcs1(der.into()), | |
| 213 | 217 | _ => PrivateKeyDer::Pkcs8(der.into()), | |
| 214 | 218 | }); | |
| ⋯ 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 | |||
| 6 | 6 | use std::time::Duration; | |
| 7 | 7 | ||
| 8 | 8 | use futures_util::{SinkExt, StreamExt}; | |
| 9 | + | use tokio_tungstenite::tungstenite::Message; | |
| 9 | 10 | use tokio_tungstenite::tungstenite::client::IntoClientRequest; | |
| 10 | - | use tokio_tungstenite::tungstenite::Message; | |
| 11 | 11 | ||
| 12 | 12 | use termbridge::pty::Profile; | |
| 13 | 13 | use termbridge::{Config, Server}; | |
| ⋯ 10 unchanged lines | |||
| 24 | 24 | ||
| 25 | 25 | async fn connect_authed( | |
| 26 | 26 | server: &Server, | |
| 27 | - | ) -> tokio_tungstenite::WebSocketStream< | |
| 28 | - | tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, | |
| 29 | - | > { | |
| 27 | + | ) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> { | |
| 30 | 28 | let mut req = server.url().into_client_request().unwrap(); | |
| 31 | 29 | req.headers_mut().insert("origin", ORIGIN.parse().unwrap()); | |
| 32 | 30 | let (mut ws, _) = tokio_tungstenite::connect_async(req).await.unwrap(); | |
| ⋯ 68 unchanged lines | |||
| 101 | 99 | "shell never became ready; saw:\n{}", | |
| 102 | 100 | printable(&String::from_utf8_lossy(&acc)) | |
| 103 | 101 | ); | |
| 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(); | |
| 107 | 107 | ||
| 108 | 108 | let until = tokio::time::Instant::now() + Duration::from_millis(700); | |
| 109 | 109 | loop { | |
| ⋯ 261 unchanged lines | |||
| 371 | 371 | let _ = tmux(&["kill-server"]); | |
| 372 | 372 | } | |
| 373 | 373 | ||
| 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 | + | ||
| 374 | 446 | // --- tmux session selection ------------------------------------------------- | |
| 375 | 447 | ||
| 376 | 448 | /// Session names reach `execvp` as a separate argv element, so shell | |
| ⋯ 10 unchanged lines | |||
| 387 | 459 | for bad in [ | |
| 388 | 460 | "", | |
| 389 | 461 | " ", | |
| 390 | - | "-C", // tmux would read this as a flag | |
| 462 | + | "-C", // tmux would read this as a flag | |
| 391 | 463 | "--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 ':' | |
| 395 | 467 | "a/b", | |
| 396 | 468 | "a;id", | |
| 397 | 469 | "a$(id)", | |
| ⋯ 65 unchanged lines | |||
| 463 | 535 | .output(); | |
| 464 | 536 | } | |
| 465 | 537 | ||
| 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 | + | ||
| 466 | 620 | /// The status channel, end to end: the daemon's control-mode client reports | |
| 467 | 621 | /// which session we're on, lists the others, and moves the live client when the | |
| 468 | 622 | /// sidebar asks — all on a private tmux server so the user's own is untouched. | |
| ⋯ 57 unchanged lines | |||
| 526 | 680 | let _ = tmux(&["kill-server"]); | |
| 527 | 681 | } | |
| 528 | 682 | ||
| 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 | + | ||
| 529 | 749 | /// Read frames until a `status` frame reports `session`, ignoring terminal | |
| 530 | 750 | /// output and the intermediate states tmux passes through. | |
| 531 | 751 | async fn next_status( | |
| ⋯ 25 unchanged lines | |||
| 557 | 777 | ||
| 558 | 778 | found.unwrap_or_else(|| panic!("never saw a status frame for {session:?}; saw {seen:?}")) | |
| 559 | 779 | } | |
| 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 | |||
| 17 | 17 | use tokio_tungstenite::tungstenite::client::IntoClientRequest; | |
| 18 | 18 | use tokio_tungstenite::tungstenite::http::StatusCode; | |
| 19 | 19 | use tokio_tungstenite::tungstenite::{Error as WsError, Message}; | |
| 20 | - | use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; | |
| 20 | + | use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; | |
| 21 | 21 | ||
| 22 | 22 | use termbridge::auth::{self, Denied}; | |
| 23 | - | use termbridge::{paths, Config, Server}; | |
| 23 | + | use termbridge::{Config, Server, paths}; | |
| 24 | 24 | ||
| 25 | 25 | const GOOD_TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| 26 | 26 | const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; | |
| ⋯ 38 unchanged lines | |||
| 65 | 65 | ||
| 66 | 66 | /// A client that got through the handshake and sent a valid auth frame. | |
| 67 | 67 | 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"); | |
| 69 | 71 | ws.send(Message::Text( | |
| 70 | 72 | serde_json::json!({"type": "auth", "token": GOOD_TOKEN}) | |
| 71 | 73 | .to_string() | |
| ⋯ 119 unchanged lines | |||
| 191 | 193 | ||
| 192 | 194 | #[test] | |
| 193 | 195 | 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 | + | ] { | |
| 195 | 203 | assert!(auth::host_is_loopback(good), "{good} should be loopback"); | |
| 196 | 204 | } | |
| 197 | 205 | for bad in [ | |
| ⋯ 24 unchanged lines | |||
| 222 | 230 | .await | |
| 223 | 231 | .unwrap(); | |
| 224 | 232 | 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 | + | ); | |
| 226 | 237 | } | |
| 227 | 238 | ||
| 228 | 239 | #[tokio::test] | |
| ⋯ 37 unchanged lines | |||
| 266 | 277 | #[tokio::test] | |
| 267 | 278 | async fn query_string_token_does_not_authenticate() { | |
| 268 | 279 | 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(); | |
| 272 | 288 | let outcome = tokio::time::timeout(Duration::from_secs(2), async { | |
| 273 | 289 | while let Some(Ok(m)) = ws.next().await { | |
| 274 | 290 | if let Message::Text(t) = &m { | |
| ⋯ 32 unchanged lines | |||
| 307 | 323 | let dir = tempfile::tempdir().unwrap(); | |
| 308 | 324 | let token = paths::generate_token(dir.path()).unwrap(); | |
| 309 | 325 | ||
| 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 | + | ); | |
| 311 | 331 | ||
| 312 | 332 | let mode = std::fs::metadata(dir.path().join("token")) | |
| 313 | 333 | .unwrap() | |
| ⋯ 3 unchanged lines | |||
| 317 | 337 | assert_eq!(mode, 0o600, "token file mode is {mode:04o}, must be 0600"); | |
| 318 | 338 | ||
| 319 | 339 | 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 | + | ); | |
| 321 | 344 | } | |
| 322 | 345 | ||
| 323 | 346 | /// If the mode ever loosens, refuse rather than authenticate against a secret | |
| ⋯ 44 unchanged lines | |||
| 368 | 391 | let dir = tempfile::tempdir().unwrap(); | |
| 369 | 392 | assert!(paths::load_paired_origins(dir.path()).is_empty()); | |
| 370 | 393 | // 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 | + | )); | |
| 372 | 398 | } | |
| 373 | 399 | ||
| 374 | 400 | #[test] | |
| 375 | 401 | fn pair_and_unpair_round_trip() { | |
| 376 | 402 | let dir = tempfile::tempdir().unwrap(); | |
| 377 | 403 | 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 | + | ); | |
| 380 | 412 | ||
| 381 | 413 | assert!(paths::unpair_origin(dir.path(), PAIRED).unwrap()); | |
| 382 | 414 | assert!(paths::load_paired_origins(dir.path()).is_empty()); | |
| ⋯ 8 unchanged lines | |||
| 391 | 423 | paths::pair_origin(dir.path(), ff).unwrap(); | |
| 392 | 424 | let paired = paths::load_paired_origins(dir.path()); | |
| 393 | 425 | 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 | + | ); | |
| 395 | 430 | assert!(!auth::origin_is_paired( | |
| 396 | 431 | "moz-extension://99999999-2222-3333-4444-555555555555", | |
| 397 | 432 | &paired | |
| ⋯ 114 unchanged lines | |||
| 512 | 547 | ||
| 513 | 548 | let mut sock = tokio::net::TcpStream::connect(server.addr()).await.unwrap(); | |
| 514 | 549 | // 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(); | |
| 516 | 553 | ||
| 517 | 554 | let ev = tokio::time::timeout(Duration::from_secs(3), server.next_event()) | |
| 518 | 555 | .await | |
| ⋯ 30 unchanged lines | |||
modifieddaemon/tests/tls.rs+24 −10
| ⋯ 8 unchanged lines | |||
| 9 | 9 | ||
| 10 | 10 | use futures_util::{SinkExt, StreamExt}; | |
| 11 | 11 | use tokio_rustls::rustls::pki_types::ServerName; | |
| 12 | + | use tokio_tungstenite::tungstenite::Message; | |
| 12 | 13 | use tokio_tungstenite::tungstenite::client::IntoClientRequest; | |
| 13 | - | use tokio_tungstenite::tungstenite::Message; | |
| 14 | 14 | ||
| 15 | - | use termbridge::{tls, Config, Server}; | |
| 15 | + | use termbridge::{Config, Server, tls}; | |
| 16 | 16 | ||
| 17 | 17 | const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; | |
| 18 | 18 | const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; | |
| ⋯ 53 unchanged lines | |||
| 72 | 72 | id: &tls::Identity, | |
| 73 | 73 | origin: Option<&str>, | |
| 74 | 74 | ) -> 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>>, | |
| 78 | 76 | tokio_tungstenite::tungstenite::Error, | |
| 79 | 77 | > { | |
| 80 | 78 | let connector = tokio_rustls::TlsConnector::from(client_config(id)); | |
| ⋯ 87 unchanged lines | |||
| 168 | 166 | .await | |
| 169 | 167 | .unwrap(); | |
| 170 | 168 | 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 | + | ); | |
| 172 | 173 | } | |
| 173 | 174 | ||
| 174 | 175 | #[tokio::test] | |
| ⋯ 74 unchanged lines | |||
| 249 | 250 | let dir = tempfile::tempdir().unwrap(); | |
| 250 | 251 | let a = tls::load_or_create(dir.path()).unwrap(); | |
| 251 | 252 | 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 | + | ); | |
| 253 | 257 | assert_eq!(a.fingerprint.len(), 32 * 3 - 1, "SHA-256 hex with colons"); | |
| 254 | 258 | ||
| 255 | 259 | 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 | + | ); | |
| 257 | 264 | } | |
| 258 | 265 | ||
| 259 | 266 | /// The landing page exists so the certificate-trust visit is comprehensible. | |
| ⋯ 7 unchanged lines | |||
| 267 | 274 | ||
| 268 | 275 | let mut sock = tokio::net::TcpStream::connect(server.addr()).await.unwrap(); | |
| 269 | 276 | 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(), | |
| 271 | 282 | ) | |
| 272 | 283 | .await | |
| 273 | 284 | .unwrap(); | |
| ⋯ 2 unchanged lines | |||
| 276 | 287 | let _ = tokio::time::timeout(Duration::from_secs(2), sock.read_to_end(&mut body)).await; | |
| 277 | 288 | let text = String::from_utf8_lossy(&body); | |
| 278 | 289 | ||
| 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 | + | ); | |
| 280 | 294 | assert!(!text.contains(TOKEN), "landing page leaked the auth token"); | |
| 281 | 295 | assert!( | |
| 282 | 296 | !text.to_lowercase().contains("\"token\""), | |
| ⋯ 3 unchanged lines | |||
modifiedextension/jsconfig.sw.json+8 −1
| ⋯ 8 unchanged lines | |||
| 9 | 9 | "compilerOptions": { | |
| 10 | 10 | "lib": ["ES2022", "DOM", "DOM.Iterable"] | |
| 11 | 11 | }, | |
| 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 | + | ] | |
| 13 | 20 | } | |
modifiedextension/lib/shot.js+50 −6
| ⋯ 46 unchanged lines | |||
| 47 | 47 | /** | |
| 48 | 48 | * Cut the element's box out of a full-viewport screenshot. | |
| 49 | 49 | * | |
| 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 | |
| 51 | 51 | * 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. | |
| 53 | 55 | * | |
| 54 | 56 | * @param {string} dataUrl PNG data URL of the visible tab | |
| 55 | 57 | * @param {TbPickedRect} rect element box in CSS pixels, viewport-relative | |
| 56 | 58 | * @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 | |
| 57 | 60 | * @returns {Promise<Uint8Array>} PNG bytes | |
| 58 | 61 | */ | |
| 59 | - | async function cropShot(dataUrl, rect, viewport) { | |
| 62 | + | async function cropShot(dataUrl, rect, viewport, pane) { | |
| 60 | 63 | 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 | + | } | |
| 62 | 76 | ||
| 63 | 77 | // Clamp to the viewport: an element can hang off the edge, and drawImage | |
| 64 | 78 | // silently produces transparent pixels for the part that isn't there. | |
| ⋯ 2 unchanged lines | |||
| 67 | 81 | const right = Math.min(viewport.w, rect.x + rect.width + SHOT_PAD); | |
| 68 | 82 | const bottom = Math.min(viewport.h, rect.y + rect.height + SHOT_PAD); | |
| 69 | 83 | ||
| 70 | - | const sx = Math.round(left * scale); | |
| 84 | + | const sx = Math.round(offsetX + left * scale); | |
| 71 | 85 | const sy = Math.round(top * scale); | |
| 72 | 86 | const sw = Math.round((right - left) * scale); | |
| 73 | 87 | const sh = Math.round((bottom - top) * scale); | |
| ⋯ 37 unchanged lines | |||
| 111 | 125 | } | |
| 112 | 126 | ||
| 113 | 127 | /** | |
| 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 | + | /** | |
| 114 | 152 | * Screenshot the picked element and leave it on the system clipboard. | |
| 115 | 153 | * | |
| 116 | 154 | * Three routes, in order of how little they can go wrong: | |
| ⋯ 11 unchanged lines | |||
| 128 | 166 | if (!picked.rect || !picked.viewport) return "no element geometry"; | |
| 129 | 167 | let png; | |
| 130 | 168 | 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 }); | |
| 131 | 175 | 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)); | |
| 133 | 177 | } catch (e) { | |
| 134 | 178 | return e instanceof Error ? e.message : String(e); | |
| 135 | 179 | } | |
| ⋯ 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 | |||
| 15 | 15 | ui: { | |
| 16 | 16 | "--bg": "#14161a", | |
| 17 | 17 | "--bg-panel": "#1c1f25", | |
| 18 | + | "--bg-strip": "#191b21", | |
| 18 | 19 | "--bg-input": "#14161a", | |
| 19 | 20 | "--border": "#2d3138", | |
| 20 | 21 | "--fg": "#d7dae0", | |
| ⋯ 40 unchanged lines | |||
| 61 | 62 | ui: { | |
| 62 | 63 | "--bg": "#ffffff", | |
| 63 | 64 | "--bg-panel": "#f6f8fa", | |
| 65 | + | "--bg-strip": "#e6e9ed", | |
| 64 | 66 | "--bg-input": "#ffffff", | |
| 65 | 67 | "--border": "#d0d7de", | |
| 66 | 68 | "--fg": "#1f2328", | |
| ⋯ 61 unchanged lines | |||
modifiedextension/manifest.chrome.json+22 −0
| ⋯ 6 unchanged lines | |||
| 7 | 7 | "storage", | |
| 8 | 8 | "sidePanel", | |
| 9 | 9 | "tabs", | |
| 10 | + | "tabGroups", | |
| 10 | 11 | "activeTab", | |
| 11 | 12 | "scripting", | |
| 12 | 13 | "clipboardWrite" | |
| ⋯ 25 unchanged lines | |||
| 38 | 39 | "default": "Alt+Shift+P" | |
| 39 | 40 | }, | |
| 40 | 41 | "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" | |
| 41 | 63 | } | |
| 42 | 64 | }, | |
| 43 | 65 | "host_permissions": [ | |
| ⋯ 12 unchanged lines | |||
modifiedextension/manifest.firefox.json+13 −0
| ⋯ 35 unchanged lines | |||
| 36 | 36 | "default": "Alt+Shift+P" | |
| 37 | 37 | }, | |
| 38 | 38 | "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" | |
| 39 | 51 | } | |
| 40 | 52 | }, | |
| 41 | 53 | "background": { | |
| 42 | 54 | "scripts": [ | |
| 43 | 55 | "picker.js", | |
| 44 | 56 | "lib/shot.js", | |
| 57 | + | "lib/split.js", | |
| 45 | 58 | "sw.js" | |
| 46 | 59 | ] | |
| 47 | 60 | }, | |
| ⋯ 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 | |||
| 239 | 239 | return { | |
| 240 | 240 | rect: { x: r.left, y: r.top, width: r.width, height: r.height }, | |
| 241 | 241 | 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, | |
| 242 | 245 | css: cssSelector(el), | |
| 243 | 246 | xpath: xpath(el), | |
| 244 | 247 | id: el.id || null, | |
| ⋯ 11 unchanged lines | |||
modifiedextension/sidebar.css+976 −99
| ⋯ 17 unchanged lines | |||
| 18 | 18 | --warn-bg: #241f16; | |
| 19 | 19 | --warn-border: #4a3a22; | |
| 20 | 20 | --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)); | |
| 21 | 40 | } | |
| 22 | 41 | * { box-sizing: border-box; } | |
| 23 | 42 | /* Author `display` beats the UA rule for [hidden] no matter the specificity, so | |
| ⋯ 9 unchanged lines | |||
| 33 | 52 | } | |
| 34 | 53 | /* Vertical space is scarce in a sidebar, but not so scarce that controls | |
| 35 | 54 | 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. */ | |
| 39 | 67 | 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; | |
| 43 | 120 | } | |
| 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); } | |
| 44 | 134 | #status-text { | |
| 45 | 135 | flex: 1; font-size: 11px; color: var(--fg-muted); min-width: 0; | |
| 46 | 136 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| 47 | 137 | } | |
| 48 | - | .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } | |
| 49 | 138 | ||
| 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 | |
| 52 | 141 | 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 { | |
| 57 | 146 | align-self: center; | |
| 58 | 147 | margin-bottom: 4px; | |
| 59 | 148 | } | |
| 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; } | |
| 63 | 160 | ||
| 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; | |
| 72 | 169 | 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; | |
| 75 | 171 | } | |
| 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; | |
| 80 | 188 | } | |
| 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; | |
| 93 | 201 | } | |
| 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; } | |
| 95 | 204 | ||
| 96 | 205 | /* --- window tabs ----------------------------------------------------------- | |
| 97 | 206 | A tab is a window of the attached session — what `prefix 2` selects. Clicking | |
| ⋯ 12 unchanged lines | |||
| 110 | 219 | tabs' own bottom edge back where it was, and the 1px between the two is room | |
| 111 | 220 | the overhang is allowed to paint in. */ | |
| 112 | 221 | #tabs { | |
| 113 | - | display: flex; align-items: stretch; gap: 2px; | |
| 222 | + | display: flex; align-items: stretch; gap: 0; | |
| 114 | 223 | flex: 0 1 auto; min-width: 0; | |
| 115 | 224 | margin-bottom: -1px; padding-bottom: 1px; | |
| 116 | 225 | overflow-x: auto; scrollbar-width: none; | |
| ⋯ 3 unchanged lines | |||
| 120 | 229 | away from the last one. */ | |
| 121 | 230 | #tab-new { margin-right: auto; } | |
| 122 | 231 | ||
| 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. */ | |
| 126 | 236 | ||
| 127 | 237 | /* The tab and its close ✕ are siblings — a button cannot contain a button — | |
| 128 | 238 | so the slot is what the strip lays out, and the ✕ is positioned over the | |
| ⋯ 1 unchanged line | |||
| 130 | 240 | ||
| 131 | 241 | Past the min width a name is unreadable, so the strip scrolls instead of | |
| 132 | 242 | 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. */ | |
| 133 | 247 | .tab-slot { | |
| 134 | 248 | 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)); | |
| 136 | 253 | } | |
| 137 | 254 | /* 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 | + | } | |
| 139 | 274 | ||
| 140 | 275 | button.tab { | |
| 276 | + | position: relative; | |
| 141 | 277 | flex: 1 1 auto; min-width: 0; | |
| 142 | 278 | display: inline-flex; align-items: center; gap: 5px; | |
| 143 | 279 | 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; | |
| 145 | 282 | background: transparent; color: var(--fg-muted); | |
| 146 | 283 | font: inherit; font-size: 11px; line-height: 1.4; | |
| 147 | 284 | white-space: nowrap; cursor: pointer; text-align: left; | |
| ⋯ 1 unchanged line | |||
| 149 | 286 | button.tab:hover { background: rgba(128, 128, 128, 0.16); color: var(--fg); } | |
| 150 | 287 | button.tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } | |
| 151 | 288 | 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. */ | |
| 152 | 293 | 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; | |
| 154 | 295 | } | |
| 155 | 296 | /* tmux's own "something happened here while you were away". */ | |
| 156 | 297 | button.tab.activity { color: var(--fg); } | |
| ⋯ 6 unchanged lines | |||
| 163 | 304 | The glyph is Claude Code's own asterisk spinner, so a working window in the | |
| 164 | 305 | strip and the transcript in the pane are saying the same thing the same way. | |
| 165 | 306 | 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 { | |
| 168 | 318 | flex: none; | |
| 169 | 319 | width: 10px; height: 11px; | |
| 170 | 320 | display: inline-flex; align-items: center; justify-content: center; | |
| 171 | 321 | font-size: 10px; line-height: 1; | |
| 172 | 322 | color: var(--fg-muted); opacity: .5; | |
| 173 | 323 | } | |
| 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; } | |
| 177 | 337 | ||
| 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. */ | |
| 181 | 344 | button.tab[aria-selected="true"] { | |
| 182 | - | background: var(--bg); color: var(--fg); | |
| 345 | + | --surface: var(--bg); | |
| 346 | + | background: var(--surface); color: var(--fg); | |
| 183 | 347 | 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; | |
| 185 | 349 | } | |
| 186 | - | button.tab[aria-selected="true"]:hover { background: var(--bg); } | |
| 350 | + | button.tab[aria-selected="true"]:hover { background: var(--surface); } | |
| 187 | 351 | ||
| 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: | |
| 191 | 358 | ||
| 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); } | |
| 204 | 881 | } | |
| 205 | 882 | ||
| 206 | 883 | /* Right-click menu. A plain positioned element: an extension page would | |
| ⋯ 15 unchanged lines | |||
| 222 | 899 | .tab-menu button.danger:hover { background: var(--err); color: var(--accent-fg); } | |
| 223 | 900 | .tab-menu button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } | |
| 224 | 901 | ||
| 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 | + | ||
| 225 | 948 | button.tab-close { | |
| 226 | - | position: absolute; right: 4px; top: 5px; | |
| 949 | + | position: absolute; right: calc(4px + var(--tab-foot)); top: 5px; | |
| 227 | 950 | width: 15px; height: 15px; padding: 0; | |
| 228 | 951 | display: inline-flex; align-items: center; justify-content: center; | |
| 229 | 952 | border: 0; border-radius: 4px; | |
| 230 | 953 | 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; | |
| 232 | 955 | } | |
| 233 | 956 | 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. */ | |
| 236 | 964 | .tab-slot.active button.tab-close, | |
| 237 | 965 | .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; } | |
| 239 | 967 | /* Red on hover rather than the usual grey: this is the one control in the | |
| 240 | 968 | panel that destroys something, and it takes effect on the first click. */ | |
| 241 | 969 | button.tab-close:hover { background: var(--err); color: var(--accent-fg); } | |
| ⋯ 11 unchanged lines | |||
| 253 | 981 | button.icon:hover { background: var(--border); color: var(--fg); } | |
| 254 | 982 | button.icon:active { transform: translateY(0.5px); } | |
| 255 | 983 | 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 | + | } | |
| 256 | 991 | #pick.active { background: var(--accent); color: var(--accent-fg); } | |
| 257 | 992 | ||
| 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 | |
| 259 | 1085 | stands down rather than saying it twice. The dot stays: it is the only thing | |
| 260 | 1086 | reporting on the connection itself. */ | |
| 261 | - | body.has-session > header > #status-text { display: none; } | |
| 1087 | + | body.has-session > header #status-text { display: none; } | |
| 262 | 1088 | ||
| 263 | 1089 | /* 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; } | |
| 265 | 1091 | body.compact #status-text { display: none; } | |
| 266 | 1092 | 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)); } | |
| 270 | 1099 | 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)); } | |
| 271 | 1101 | body.compact .tab-slot.closable button.tab { padding-right: 19px; } | |
| 272 | 1102 | body.compact button.tab[aria-selected="true"] { padding-bottom: 9px; } | |
| 273 | - | body.compact button.tab .index { display: none; } | |
| 274 | 1103 | 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. */ | |
| 275 | 1107 | body.compact #term { padding: 2px; } | |
| 276 | 1108 | ||
| 277 | 1109 | /* Hidden: no header at all. A 4px strip along the top brings it back on | |
| ⋯ 10 unchanged lines | |||
| 288 | 1120 | body.chromeless header:hover { opacity: 1; pointer-events: auto; } | |
| 289 | 1121 | body.chromeless #term { padding: 2px; } | |
| 290 | 1122 | #reveal-strip { display: none; } | |
| 291 | - | ||
| 292 | - | .dot.on { background: var(--ok); } | |
| 293 | - | .dot.off { background: var(--err); } | |
| 294 | - | .dot.pending { background: var(--warn); } | |
| 295 | 1123 | ||
| 296 | 1124 | @keyframes pulse { 50% { opacity: .25; } } | |
| 297 | 1125 | /* 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. */ | |
| 299 | 1129 | @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; | |
| 301 | 1145 | } | |
| 302 | - | #term { flex: 1; min-height: 0; padding: 4px; overflow: hidden; } | |
| 303 | 1146 | #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. */ | |
| 304 | 1153 | 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; | |
| 307 | 1177 | } | |
| 308 | 1178 | h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; | |
| 309 | 1179 | color: var(--fg-muted); margin: 12px 0 4px; font-weight: 600; } | |
| 310 | 1180 | 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 | + | } | |
| 311 | 1188 | .hint { color: var(--fg-muted); margin: 0 0 6px; font-size: 11px; } | |
| 312 | 1189 | .row { display: flex; gap: 6px; align-items: center; } | |
| 313 | 1190 | input[type=text], input[type=password], input:not([type]), #url { | |
| ⋯ 96 unchanged lines | |||
| 410 | 1287 | #font-size { font-variant-numeric: tabular-nums; font-size: 11px; min-width: 2ch; | |
| 411 | 1288 | text-align: center; color: var(--fg); } | |
| 412 | 1289 | #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); | |
| 414 | 1291 | border: 1px solid var(--border); border-radius: 4px; padding: 5px 7px; font-size: 11px; } | |
modifiedextension/sidebar.html+197 −26
| ⋯ 8 unchanged lines | |||
| 9 | 9 | <body> | |
| 10 | 10 | <div id="reveal-strip"></div> | |
| 11 | 11 | ||
| 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. --> | |
| 12 | 17 | <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"> | |
| 13 | 24 | <!-- The panel's own controls, held together at the left edge so the tab | |
| 14 | 25 | strip owns the rest of the row and grows rightwards into it. | |
| 15 | 26 | Reconnect appears only when it is actually actionable; theme and | |
| ⋯ 9 unchanged lines | |||
| 25 | 36 | </svg> | |
| 26 | 37 | </button> | |
| 27 | 38 | ||
| 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 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. --> | |
| 28 | 85 | <button id="pick" class="icon" title="Pick an element on the page (Esc cancels)"> | |
| 29 | 86 | <svg viewBox="0 0 16 16" aria-hidden="true"> | |
| 30 | 87 | <circle cx="8" cy="8" r="3.1" fill="none" stroke="currentColor" stroke-width="1.6"/> | |
| ⋯ 2 unchanged lines | |||
| 33 | 90 | </svg> | |
| 34 | 91 | </button> | |
| 35 | 92 | ||
| 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> | |
| 37 | 98 | <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"/> | |
| 42 | 102 | </svg> | |
| 43 | 103 | </button> | |
| 44 | 104 | ||
| 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 | |
| 63 | 106 | tab is lifted to the terminal's own background so the two read as one | |
| 64 | 107 | surface, and the dot in its favicon slot is what Claude Code is doing | |
| 65 | 108 | in that window. Clicking a tab runs select-window — the same thing | |
| 66 | 109 | `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. --> | |
| 69 | 111 | <div id="tabs" role="tablist" aria-label="tmux windows" hidden></div> | |
| 70 | 112 | ||
| 71 | 113 | <button id="tab-new" class="icon" title="New window" hidden> | |
| ⋯ 2 unchanged lines | |||
| 74 | 116 | </svg> | |
| 75 | 117 | </button> | |
| 76 | 118 | ||
| 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> | |
| 77 | 213 | </header> | |
| 78 | 214 | ||
| 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 | + | ||
| 79 | 222 | <div id="picked" hidden> | |
| 80 | 223 | <div class="picked-top"> | |
| 81 | 224 | <span id="picked-tag" class="chip"></span> | |
| ⋯ 24 unchanged lines | |||
| 106 | 249 | ||
| 107 | 250 | <div id="term"></div> | |
| 108 | 251 | ||
| 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 | + | ||
| 110 | 269 | <h2>Pair this extension</h2> | |
| 111 | 270 | <p class="hint">Run this once, then restart the daemon:</p> | |
| 112 | 271 | <pre id="pair-cmd">loading…</pre> | |
| ⋯ 20 unchanged lines | |||
| 133 | 292 | </div> | |
| 134 | 293 | <p class="hint" id="density-hint"></p> | |
| 135 | 294 | ||
| 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 | + | ||
| 136 | 306 | <div class="row" id="font-row"> | |
| 137 | 307 | <span class="hint">Font size</span> | |
| 138 | 308 | <button id="font-smaller" class="icon" title="Smaller (Ctrl+Alt+- or Ctrl+wheel)"> | |
| ⋯ 37 unchanged lines | |||
| 176 | 346 | <script src="lib/theme.js"></script> | |
| 177 | 347 | <script src="lib/sanitize.js"></script> | |
| 178 | 348 | <script src="lib/shot.js"></script> | |
| 349 | + | <script src="lib/split.js"></script> | |
| 179 | 350 | <script src="picker.js"></script> | |
| 180 | 351 | <script src="vendor/xterm.js"></script> | |
| 181 | 352 | <script src="vendor/addon-fit.js"></script> | |
| ⋯ 3 unchanged lines | |||
modifiedextension/sidebar.js+3936 −445
| ⋯ 2 unchanged lines | |||
| 3 | 3 | // Owns the WebSocket directly. There is deliberately no relay through the | |
| 4 | 4 | // service worker: `runtime.sendMessage` is reachable from content scripts, so | |
| 5 | 5 | // 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. | |
| 7 | 9 | ||
| 8 | 10 | // One of the two always exists — this file only ever runs as sidebar.html — but | |
| 9 | 11 | // neither is declared unconditionally, so say which. | |
| ⋯ 14 unchanged lines | |||
| 24 | 26 | const $input = (id) => /** @type {HTMLInputElement} */ ($(id)); | |
| 25 | 27 | /** @param {string} id @returns {HTMLSelectElement} */ | |
| 26 | 28 | const $select = (id) => /** @type {HTMLSelectElement} */ ($(id)); | |
| 29 | + | /** @param {string} id @returns {HTMLButtonElement} */ | |
| 30 | + | const $button = (id) => /** @type {HTMLButtonElement} */ ($(id)); | |
| 27 | 31 | ||
| 28 | 32 | const logEl = $("log"); | |
| 29 | 33 | /** @param {string} m */ | |
| ⋯ 55 unchanged lines | |||
| 85 | 89 | applyDensity(); | |
| 86 | 90 | } | |
| 87 | 91 | ||
| 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 | + | ||
| 88 | 193 | // Below ~8px xterm.js's glyph atlas stops being legible; above ~32 a sidebar | |
| 89 | 194 | // holds too few columns to be a terminal. | |
| 90 | 195 | const FONT_DEFAULT = 12; | |
| ⋯ 1 unchanged line | |||
| 92 | 197 | const FONT_MAX = 32; | |
| 93 | 198 | let fontSize = FONT_DEFAULT; | |
| 94 | 199 | ||
| 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 | + | ||
| 95 | 205 | function applyFontSize() { | |
| 96 | 206 | $("font-size").textContent = String(fontSize); | |
| 97 | 207 | if (!termReady) return; | |
| ⋯ 15 unchanged lines | |||
| 113 | 223 | const FONT_STEP = { "=": 1, "+": 1, "-": -1, _: -1 }; | |
| 114 | 224 | ||
| 115 | 225 | /** | |
| 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. | |
| 119 | 227 | * | |
| 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. | |
| 121 | 235 | * | |
| 122 | 236 | * @param {KeyboardEvent} e | |
| 123 | 237 | */ | |
| 124 | - | function handleFontKey(e) { | |
| 238 | + | function handlePanelKey(e) { | |
| 125 | 239 | if (!e.ctrlKey || !e.altKey || e.metaKey) return false; | |
| 126 | 240 | const delta = FONT_STEP[e.key]; | |
| 127 | 241 | if (delta) setFontSize(fontSize + delta); | |
| ⋯ 20 unchanged lines | |||
| 148 | 262 | fontFamily: | |
| 149 | 263 | 'ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace', | |
| 150 | 264 | fontSize: FONT_DEFAULT, | |
| 151 | - | lineHeight: 1.2, | |
| 265 | + | lineHeight: LINE_HEIGHT, | |
| 152 | 266 | cursorBlink: true, | |
| 153 | 267 | scrollback: 5000, | |
| 154 | 268 | theme: Themes.resolveTheme("auto", darkQuery.matches).xterm, | |
| ⋯ 10 unchanged lines | |||
| 165 | 279 | // Returning false keeps the keystroke out of the pty. | |
| 166 | 280 | term.attachCustomKeyEventHandler((e) => { | |
| 167 | 281 | if (e.type !== "keydown") return true; | |
| 168 | - | return !handleFontKey(e); | |
| 282 | + | return !handlePanelKey(e); | |
| 169 | 283 | }); | |
| 170 | 284 | ||
| 171 | 285 | $("term").addEventListener( | |
| ⋯ 6 unchanged lines | |||
| 178 | 292 | { passive: false }, | |
| 179 | 293 | ); | |
| 180 | 294 | ||
| 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 | + | ||
| 181 | 436 | // Set only once term *and* fit are usable — applyTheme/applyDensity both touch | |
| 182 | 437 | // them, and a flag that runs ahead of the objects it guards is worse than none. | |
| 183 | 438 | termReady = true; | |
| ⋯ 12 unchanged lines | |||
| 196 | 451 | // in tabs, and "+" would have nothing to create. | |
| 197 | 452 | let tmuxMode = false; | |
| 198 | 453 | ||
| 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 | + | ||
| 199 | 481 | /** | |
| 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 | |
| 201 | 487 | * @param {string} text | |
| 202 | 488 | */ | |
| 203 | 489 | function setStatus(state, text) { | |
| 204 | - | $("status").className = `dot ${state}`; | |
| 205 | - | $("status").title = text; | |
| 206 | 490 | $("status-text").textContent = text; | |
| 207 | 491 | $("reconnect").hidden = state === "on"; | |
| 208 | 492 | } | |
| ⋯ 24 unchanged lines | |||
| 233 | 517 | function tokenProblem(text) { | |
| 234 | 518 | $("token-hint").textContent = `${text} ${TOKEN_HELP}`; | |
| 235 | 519 | $("token-hint").hidden = false; | |
| 236 | - | $("settings").hidden = false; | |
| 520 | + | openSettings(); | |
| 237 | 521 | term.write( | |
| 238 | 522 | `\r\n\x1b[33m ${text}\x1b[0m\r\n` + | |
| 239 | 523 | " Open \x1b[1msettings\x1b[0m (top right) and set the Token.\r\n" + | |
| ⋯ 57 unchanged lines | |||
| 297 | 581 | const want = $input("session").value.trim() || undefined; | |
| 298 | 582 | // Optimistic; the daemon's first status frame replaces this with what | |
| 299 | 583 | // 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(); | |
| 300 | 591 | sessionName = want ?? msg.defaultSession ?? msg.profile; | |
| 301 | 592 | 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 | |
| 303 | 594 | // 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: [] })), | |
| 306 | 597 | sessionName, | |
| 598 | + | [], | |
| 307 | 599 | ); | |
| 308 | 600 | log(`authenticated, running ${msg.profile}`); | |
| 309 | 601 | fit.fit(); | |
| ⋯ 7 unchanged lines | |||
| 317 | 609 | ...(want ? { session: want } : {}), | |
| 318 | 610 | }), | |
| 319 | 611 | ); | |
| 320 | - | $("settings").hidden = true; | |
| 612 | + | closeSettings(); | |
| 321 | 613 | term.focus(); | |
| 322 | 614 | } else if (msg.type === "status") { | |
| 323 | 615 | if (msg.session && msg.session !== sessionName) { | |
| 324 | 616 | sessionName = msg.session; | |
| 325 | 617 | refreshStatus(); | |
| 326 | 618 | } | |
| 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); | |
| 329 | 638 | } else if (msg.type === "tmux-error") { | |
| 330 | 639 | // tmux refused the command (session gone, pane closed). The next status | |
| 331 | 640 | // frame already shows the truth, so this only needs to explain itself. | |
| ⋯ 7 unchanged lines | |||
| 339 | 648 | if (/token|auth/i.test(msg.reason)) { | |
| 340 | 649 | tokenProblem("The daemon rejected this token."); | |
| 341 | 650 | } else if (/origin/i.test(msg.reason)) { | |
| 342 | - | $("settings").hidden = false; | |
| 651 | + | openSettings(); | |
| 343 | 652 | term.write( | |
| 344 | 653 | `\r\n\x1b[33m ${msg.reason}\x1b[0m\r\n` + | |
| 345 | 654 | " Open \x1b[1msettings\x1b[0m and run the pairing command shown there.\r\n", | |
| ⋯ 4 unchanged lines | |||
| 350 | 659 | } | |
| 351 | 660 | // Raw terminal bytes. xterm.js takes Uint8Array directly, so nothing is | |
| 352 | 661 | // 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 | + | } | |
| 355 | 3754 | ||
| 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 | + | } | |
| 364 | 3764 | ||
| 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(); | |
| 374 | 3785 | } | |
| 375 | 3786 | ||
| 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. | |
| 378 | 3787 | /** | |
| 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. | |
| 381 | 3796 | */ | |
| 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(); | |
| 385 | 3848 | } | |
| 386 | 3849 | ||
| 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 ----------------------------------------------------------- | |
| 388 | 3920 | // | |
| 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. | |
| 395 | 3924 | // | |
| 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. | |
| 401 | 3927 | // | |
| 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 = []; | |
| 406 | 3934 | ||
| 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()]; | |
| 417 | 3955 | } | |
| 418 | 3956 | ||
| 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 }); | |
| 425 | 3961 | } | |
| 426 | 3962 | ||
| 427 | 3963 | /** | |
| 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 | |
| 430 | 3967 | */ | |
| 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; | |
| 437 | 3975 | 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); | |
| 438 | 3980 | if (!show) { | |
| 439 | 3981 | strip.textContent = ""; | |
| 440 | 3982 | strip.dataset.sig = ""; | |
| 3983 | + | hideSessionInput(); | |
| 441 | 3984 | syncSpinner(); | |
| 442 | 3985 | return; | |
| 443 | 3986 | } | |
| 444 | 3987 | ||
| 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); | |
| 464 | 3989 | 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 | + | ]; | |
| 468 | 4006 | }), | |
| 469 | 4007 | ); | |
| 470 | 4008 | if (strip.dataset.sig !== sig) { | |
| 471 | 4009 | strip.dataset.sig = sig; | |
| 472 | 4010 | 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])); | |
| 479 | 4013 | } | |
| 480 | 4014 | } | |
| 481 | 4015 | ||
| 482 | 4016 | syncSpinner(); | |
| 483 | 4017 | ||
| 484 | 4018 | 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. | |
| 487 | 4021 | if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" }); | |
| 488 | 4022 | } | |
| 489 | 4023 | ||
| 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 | - | ||
| 494 | 4024 | /** | |
| 495 | 4025 | * @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 | |
| 497 | 4027 | */ | |
| 498 | - | function agentByWindow(agents) { | |
| 4028 | + | function agentBySession(agents) { | |
| 499 | 4029 | /** @type {Record<string, TbAgent>} */ | |
| 500 | 4030 | const out = {}; | |
| 501 | 4031 | for (const a of agents) { | |
| 502 | - | const seen = out[a.window_id]; | |
| 4032 | + | const seen = out[a.session]; | |
| 503 | 4033 | if (!seen || AGENT_RANK.indexOf(a.state) < AGENT_RANK.indexOf(seen.state)) { | |
| 504 | - | out[a.window_id] = a; | |
| 4034 | + | out[a.session] = a; | |
| 505 | 4035 | } | |
| 506 | 4036 | } | |
| 507 | 4037 | return out; | |
| 508 | 4038 | } | |
| 509 | 4039 | ||
| 510 | 4040 | /** | |
| 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; | |
| 590 | 4080 | } | |
| 591 | 4081 | ||
| 592 | - | // --- tab context menu ------------------------------------------------------- | |
| 4082 | + | // --- new session ------------------------------------------------------------ | |
| 593 | 4083 | // | |
| 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 = ""; | |
| 604 | 4093 | } | |
| 605 | 4094 | ||
| 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(); | |
| 675 | 4101 | } | |
| 676 | 4102 | ||
| 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); | |
| 682 | 4104 | ||
| 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(); | |
| 700 | 4109 | 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 }); | |
| 731 | 4118 | term.focus(); | |
| 732 | 4119 | }); | |
| 733 | 4120 | ||
| 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); | |
| 772 | 4124 | ||
| 773 | 4125 | /* --- the working spinner --------------------------------------------------- | |
| 774 | 4126 | Claude Code's own asterisk cycle, so a tab that is thinking looks like the | |
| ⋯ 3 unchanged lines | |||
| 778 | 4130 | const SPINNER_FRAMES = ["·", "✢", "✳", "∗", "✻", "✽", "✻", "∗", "✳", "✢"]; | |
| 779 | 4131 | /** What a tab shows when it is not mid-cycle. */ | |
| 780 | 4132 | /** @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: "" }; | |
| 782 | 4136 | const SPINNER_MS = 130; | |
| 783 | 4137 | ||
| 784 | 4138 | const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); | |
| ⋯ 14 unchanged lines | |||
| 799 | 4153 | * the signature is unchanged, so the spinner never fights it. | |
| 800 | 4154 | */ | |
| 801 | 4155 | function syncSpinner() { | |
| 802 | - | const working = document.querySelectorAll("#tabs .glyph.working"); | |
| 4156 | + | const working = document.querySelectorAll("header .glyph.working"); | |
| 803 | 4157 | if (!working.length || reducedMotion.matches) { | |
| 804 | 4158 | clearInterval(spinnerTimer); | |
| 805 | 4159 | spinnerTimer = undefined; | |
| ⋯ 3 unchanged lines | |||
| 809 | 4163 | spinnerTimer = setInterval(() => { | |
| 810 | 4164 | spinnerStep++; | |
| 811 | 4165 | const frame = spinnerGlyph(); | |
| 812 | - | const live = document.querySelectorAll("#tabs .glyph.working"); | |
| 4166 | + | const live = document.querySelectorAll("header .glyph.working"); | |
| 813 | 4167 | if (!live.length) return syncSpinner(); | |
| 814 | 4168 | for (const el of live) el.textContent = frame; | |
| 815 | 4169 | }, SPINNER_MS); | |
| ⋯ 3 unchanged lines | |||
| 819 | 4173 | // glyphs where they are, not leave them frozen on whatever frame was up. | |
| 820 | 4174 | reducedMotion.addEventListener("change", () => { | |
| 821 | 4175 | 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; | |
| 823 | 4177 | syncSpinner(); | |
| 824 | 4178 | }); | |
| 825 | 4179 | ||
| ⋯ 8 unchanged lines | |||
| 834 | 4188 | if (!a) return ""; | |
| 835 | 4189 | if (a.state === "waiting") return a.message || "waiting"; | |
| 836 | 4190 | if (a.state === "working") return a.tool || shortMode(a.mode) || "working"; | |
| 4191 | + | if (a.state === "ready") return "finished its turn"; | |
| 837 | 4192 | if (a.state === "unknown") return "no hook records — run: termbridge hooks"; | |
| 838 | 4193 | return shortMode(a.mode) || "idle"; | |
| 839 | 4194 | } | |
| 840 | 4195 | ||
| 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 | + | ||
| 841 | 4207 | // permission_mode arrives camelCased, straight from Claude's hook payload. | |
| 842 | 4208 | /** @type {Record<string, string>} */ | |
| 843 | 4209 | const MODE_SHORT = { | |
| ⋯ 14 unchanged lines | |||
| 858 | 4224 | if (!msg.tmux) return; | |
| 859 | 4225 | const names = msg.sessions ?? []; | |
| 860 | 4226 | 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; | |
| 868 | 4230 | if (names.length) log(`tmux sessions: ${names.join(", ")}`); | |
| 869 | 4231 | } | |
| 870 | 4232 | ||
| ⋯ 6 unchanged lines | |||
| 877 | 4239 | // the next connection opens with. | |
| 878 | 4240 | if (connected && name) { | |
| 879 | 4241 | tmuxCommand({ cmd: "create", session: name }); | |
| 880 | - | $("settings").hidden = true; | |
| 4242 | + | closeSettings(); | |
| 881 | 4243 | term.focus(); | |
| 882 | 4244 | return; | |
| 883 | 4245 | } | |
| ⋯ 28 unchanged lines | |||
| 912 | 4274 | ["href", "href"], | |
| 913 | 4275 | ]; | |
| 914 | 4276 | ||
| 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() { | |
| 916 | 4285 | 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 }; | |
| 918 | 4289 | } | |
| 919 | 4290 | ||
| 920 | 4291 | /** | |
| ⋯ 31 unchanged lines | |||
| 952 | 4323 | * is opt-in, one origin at a time, via a prompt the browser shows. | |
| 953 | 4324 | * | |
| 954 | 4325 | * @param {string} pattern | |
| 955 | - | * @param {number | undefined} tabId | |
| 4326 | + | * @param {TbTab[]} targets the tabs the pick would run in | |
| 956 | 4327 | */ | |
| 957 | - | function offerGrant(pattern, tabId) { | |
| 4328 | + | function offerGrant(pattern, targets) { | |
| 958 | 4329 | $("picked").hidden = false; | |
| 959 | 4330 | $("picked-value").textContent = `No access to ${pattern}`; | |
| 960 | 4331 | $("picked-warn").hidden = false; | |
| ⋯ 8 unchanged lines | |||
| 969 | 4340 | if (granted) { | |
| 970 | 4341 | btn.hidden = true; | |
| 971 | 4342 | log(`granted ${pattern}`); | |
| 972 | - | runPick(tabId); | |
| 4343 | + | runPick(targets); | |
| 973 | 4344 | } else { | |
| 974 | 4345 | log(`declined ${pattern}`); | |
| 975 | 4346 | } | |
| ⋯ 46 unchanged lines | |||
| 1022 | 4393 | ||
| 1023 | 4394 | const SKIP_URL = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/; | |
| 1024 | 4395 | ||
| 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[]} */ ([]); | |
| 1027 | 4399 | ||
| 1028 | 4400 | /** | |
| 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. | |
| 1034 | 4402 | */ | |
| 1035 | 4403 | 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); | |
| 1048 | 4406 | } | |
| 1049 | 4407 | ||
| 1050 | 4408 | /** | |
| 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. | |
| 1053 | 4411 | * | |
| 1054 | - | * @param {number | undefined} tabId | |
| 4412 | + | * @param {TbTab[]} targets | |
| 1055 | 4413 | * @returns {Promise<true | string>} | |
| 1056 | 4414 | */ | |
| 1057 | - | async function runPick(tabId) { | |
| 4415 | + | async function runPick(targets) { | |
| 1058 | 4416 | const btn = $("pick"); | |
| 1059 | - | pickTabId = tabId; | |
| 4417 | + | pickTabs = []; | |
| 4418 | + | for (const t of targets) if (t.id != null) pickTabs.push(t.id); | |
| 1060 | 4419 | btn.classList.add("active"); | |
| 1061 | 4420 | setStatus("pending", "pick mode — click an element, Esc cancels"); | |
| 1062 | 4421 | 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); | |
| 1068 | 4423 | if (value) { | |
| 1069 | 4424 | const shot = await Shot.copyPickedShot(api, tabId, value); | |
| 1070 | 4425 | await deliverPick(value, shot); | |
| 4426 | + | } else if (error) { | |
| 4427 | + | return error; | |
| 1071 | 4428 | } else log("pick cancelled"); | |
| 1072 | 4429 | return true; | |
| 1073 | 4430 | } catch (e) { | |
| 1074 | 4431 | return e instanceof Error ? e.message : String(e); | |
| 1075 | 4432 | } finally { | |
| 1076 | - | pickTabId = null; | |
| 4433 | + | pickTabs = []; | |
| 1077 | 4434 | btn.classList.remove("active"); | |
| 1078 | 4435 | refreshStatus(); | |
| 1079 | 4436 | } | |
| ⋯ 1 unchanged line | |||
| 1081 | 4438 | ||
| 1082 | 4439 | $("pick").addEventListener("click", async () => { | |
| 1083 | 4440 | // Second press toggles it back off rather than doing nothing. | |
| 1084 | - | if (pickTabId != null) { | |
| 4441 | + | if (pickTabs.length) { | |
| 1085 | 4442 | await cancelPick(); | |
| 1086 | 4443 | return; | |
| 1087 | 4444 | } | |
| 1088 | 4445 | ||
| 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) { | |
| 1091 | 4448 | pickNote( | |
| 1092 | 4449 | "Can't pick here.", | |
| 1093 | 4450 | "Browser-internal and extension pages are off limits to all extensions. Switch to a normal web page.", | |
| ⋯ 2 unchanged lines | |||
| 1096 | 4453 | } | |
| 1097 | 4454 | ||
| 1098 | 4455 | setStatus("pending", "pick mode — click an element, Esc cancels"); | |
| 1099 | - | const outcome = await runPick(tab.id); | |
| 4456 | + | const outcome = await runPick(targets); | |
| 1100 | 4457 | if (outcome === true) return; | |
| 1101 | 4458 | ||
| 1102 | 4459 | // The failure is nearly always a missing host permission for this origin. | |
| 1103 | - | const pattern = originPattern(tab.url); | |
| 4460 | + | const pattern = originPattern(tab?.url); | |
| 1104 | 4461 | if (pattern && /permission|access/i.test(outcome)) { | |
| 1105 | - | offerGrant(pattern, tab.id); | |
| 4462 | + | offerGrant(pattern, targets); | |
| 1106 | 4463 | } else { | |
| 1107 | 4464 | pickNote("Couldn't reach the page.", `Try Alt+Shift+P instead. [${outcome}]`); | |
| 1108 | 4465 | } | |
| ⋯ 3 unchanged lines | |||
| 1112 | 4469 | // the page has keyboard focus — if you started the pick from here, focus is | |
| 1113 | 4470 | // still in the panel and the key never reaches the page. | |
| 1114 | 4471 | 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; | |
| 1119 | 4476 | if (e.key !== "Escape") return; | |
| 1120 | 4477 | if (openMenu) { | |
| 1121 | 4478 | e.preventDefault(); | |
| 1122 | 4479 | closeTabMenu(); | |
| 1123 | 4480 | return; | |
| 1124 | 4481 | } | |
| 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) { | |
| 1126 | 4490 | e.preventDefault(); | |
| 1127 | 4491 | cancelPick(); | |
| 1128 | 4492 | } | |
| ⋯ 16 unchanged lines | |||
| 1145 | 4509 | } | |
| 1146 | 4510 | }); | |
| 1147 | 4511 | ||
| 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 | + | ||
| 1148 | 4548 | // A pick made while the sidebar was closed is parked in storage. Nothing is | |
| 1149 | 4549 | // typed for these: the terminal has moved on, and whatever screenshot went with | |
| 1150 | 4550 | // it left the clipboard long ago. Show it and let the user decide. | |
| ⋯ 32 unchanged lines | |||
| 1183 | 4583 | } | |
| 1184 | 4584 | ||
| 1185 | 4585 | const bar = $("picked-formats"); | |
| 1186 | - | bar.innerHTML = ""; | |
| 4586 | + | bar.textContent = ""; | |
| 1187 | 4587 | 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 | + | }, | |
| 1199 | 4600 | }); | |
| 1200 | 4601 | bar.appendChild(b); | |
| 1201 | 4602 | } | |
| ⋯ 123 unchanged lines | |||
| 1325 | 4726 | themeSelect.addEventListener("change", () => setTheme(themeSelect.value)); | |
| 1326 | 4727 | const densitySelect = $select("density-select"); | |
| 1327 | 4728 | densitySelect.addEventListener("change", () => setDensity(densitySelect.value)); | |
| 4729 | + | const tabModeSelect = $select("tabmode-select"); | |
| 4730 | + | tabModeSelect.addEventListener("change", () => setTabMode(tabModeSelect.value)); | |
| 1328 | 4731 | ||
| 1329 | 4732 | $("font-smaller").addEventListener("click", () => setFontSize(fontSize - 1)); | |
| 1330 | 4733 | $("font-bigger").addEventListener("click", () => setFontSize(fontSize + 1)); | |
| 1331 | 4734 | $("font-reset").addEventListener("click", () => setFontSize(FONT_DEFAULT)); | |
| 1332 | 4735 | ||
| 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 | + | ||
| 1333 | 4795 | $("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(); | |
| 1335 | 4802 | }); | |
| 1336 | 4803 | $("reconnect").addEventListener("click", connect); | |
| 1337 | 4804 | $("connect").addEventListener("click", connect); | |
| ⋯ 15 unchanged lines | |||
| 1353 | 4820 | ); | |
| 1354 | 4821 | urlInput.addEventListener("change", () => storage.set({ url: urlInput.value.trim() })); | |
| 1355 | 4822 | ||
| 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) => { | |
| 1357 | 4838 | 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 | + | } | |
| 1358 | 4847 | themePref = Themes.PREFERENCES.includes(v.theme) ? v.theme : "auto"; | |
| 1359 | 4848 | density = DENSITIES[v.density] ? v.density : "normal"; | |
| 4849 | + | tabMode = TAB_MODES[v.tabMode] ? v.tabMode : "nested"; | |
| 1360 | 4850 | const stored = Number(v.fontSize); | |
| 1361 | 4851 | fontSize = | |
| 1362 | 4852 | Number.isFinite(stored) && stored >= FONT_MIN && stored <= FONT_MAX | |
| ⋯ 1 unchanged line | |||
| 1364 | 4854 | : FONT_DEFAULT; | |
| 1365 | 4855 | applyTheme(); | |
| 1366 | 4856 | applyDensity(); | |
| 4857 | + | applyTabMode(); | |
| 1367 | 4858 | applyFontSize(); | |
| 1368 | 4859 | if (v.token) $input("token").value = v.token; | |
| 1369 | 4860 | if (v.url) $input("url").value = v.url; | |
| ⋯ 3 unchanged lines | |||
| 1373 | 4864 | connect(); | |
| 1374 | 4865 | } else { | |
| 1375 | 4866 | setStatus("off", "needs setup"); | |
| 1376 | - | $("settings").hidden = false; | |
| 4867 | + | openSettings(); | |
| 1377 | 4868 | term.write( | |
| 1378 | 4869 | "\x1b[90m terminal\x1b[0m\r\n\r\n" + | |
| 1379 | 4870 | " Not configured yet. Open \x1b[1msettings\x1b[0m (top right)\r\n" + | |
| ⋯ 4 unchanged lines | |||
modifiedextension/sw.js+270 −14
| ⋯ 13 unchanged lines | |||
| 14 | 14 | // Chrome's service worker starts empty; Firefox loads picker.js alongside this | |
| 15 | 15 | // file via background.scripts, so there it is already defined. | |
| 16 | 16 | if (typeof tbPickElement === "undefined" && typeof importScripts === "function") { | |
| 17 | - | importScripts("picker.js", "lib/shot.js"); | |
| 17 | + | importScripts("picker.js", "lib/shot.js", "lib/split.js"); | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | // Chrome only: make the toolbar button open the side panel. | |
| ⋯ 12 unchanged lines | |||
| 33 | 33 | return; | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 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); | |
| 47 | 43 | if (!value) return; | |
| 48 | 44 | ||
| 49 | 45 | // Screenshot here rather than in the sidebar: this path's page access comes | |
| 50 | 46 | // from the activeTab grant the keyboard command just minted, and that grant | |
| 51 | 47 | // belongs to the worker. | |
| 52 | - | const shot = await Shot.copyPickedShot(api, tab.id, value); | |
| 48 | + | const shot = await Shot.copyPickedShot(api, tabId, value); | |
| 53 | 49 | if (shot !== true) console.warn("termbridge: no screenshot", shot); | |
| 54 | 50 | ||
| 55 | 51 | // The sidebar may not be open. Try to hand it over directly, and fall back to | |
| ⋯ 5 unchanged lines | |||
| 61 | 57 | } | |
| 62 | 58 | } | |
| 63 | 59 | ||
| 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) => { | |
| 65 | 317 | 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(); | |
| 66 | 322 | }); | |
modifiedextension/types/globals.d.ts+172 −9
| ⋯ 11 unchanged lines | |||
| 12 | 12 | ||
| 13 | 13 | interface TbTab { | |
| 14 | 14 | id?: number; | |
| 15 | + | windowId?: number; | |
| 15 | 16 | url?: string; | |
| 16 | 17 | title?: string; | |
| 18 | + | /** The split view this tab is half of, if any — Chrome 140+, and read-only: | |
| 19 | + | the API detects splits, it cannot create or dissolve them. Absent in | |
| 20 | + | Firefox, which has no split view at all. */ | |
| 21 | + | splitViewId?: number; | |
| 22 | + | /** The tab group this tab belongs to, or -1 for none. Chrome only. This is | |
| 23 | + | the whole gate on whether Claude in Chrome can see the tab. */ | |
| 24 | + | groupId?: number; | |
| 17 | 25 | } | |
| 18 | 26 | ||
| 27 | + | /** A Chrome tab group. Carries no stable identity beyond its id — the title is | |
| 28 | + | whatever the user (or another extension) last set, and is often empty, so it | |
| 29 | + | is not something to match on. */ | |
| 30 | + | interface TbTabGroup { | |
| 31 | + | id: number; | |
| 32 | + | windowId: number; | |
| 33 | + | title?: string; | |
| 34 | + | } | |
| 35 | + | ||
| 19 | 36 | interface TbInjectionResult<T = unknown> { | |
| 20 | 37 | result?: T; | |
| 21 | 38 | frameId?: number; | |
| ⋯ 6 unchanged lines | |||
| 28 | 45 | tab?: TbTab; | |
| 29 | 46 | } | |
| 30 | 47 | ||
| 48 | + | /** A long-lived connection. Only the toggle shortcut uses one — sidebar.js and | |
| 49 | + | sw.js are the two ends, and it carries three fixed command shapes. */ | |
| 50 | + | interface TbPort { | |
| 51 | + | name: string; | |
| 52 | + | /** Set on the receiving end only, and set for content scripts exactly as | |
| 53 | + | TbMessageSender.tab is. */ | |
| 54 | + | sender?: TbMessageSender; | |
| 55 | + | postMessage(message: any): void; | |
| 56 | + | disconnect(): void; | |
| 57 | + | onMessage: { addListener(cb: (message: any) => void): void }; | |
| 58 | + | onDisconnect: { addListener(cb: () => void): void }; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | interface TbWindow { | |
| 62 | + | id: number; | |
| 63 | + | /** Screen position of the window's left edge. Only the split-view crop reads | |
| 64 | + | it, to work out which half of the window a pick came from. */ | |
| 65 | + | left?: number; | |
| 66 | + | } | |
| 67 | + | ||
| 31 | 68 | interface TbStorageArea { | |
| 32 | 69 | get(keys?: string | string[] | null): Promise<Record<string, any>>; | |
| 33 | 70 | set(items: Record<string, any>): Promise<void>; | |
| ⋯ 9 unchanged lines | |||
| 43 | 80 | cb: (message: any, sender: TbMessageSender) => void | boolean | Promise<any>, | |
| 44 | 81 | ): void; | |
| 45 | 82 | }; | |
| 83 | + | connect(info: { name: string }): TbPort; | |
| 84 | + | onConnect: { addListener(cb: (port: TbPort) => void): void }; | |
| 46 | 85 | }; | |
| 47 | 86 | storage: { local: TbStorageArea }; | |
| 87 | + | windows: { | |
| 88 | + | /** The window the calling document lives in — the sidebar's own. */ | |
| 89 | + | getCurrent(): Promise<TbWindow>; | |
| 90 | + | /** The worker has no window of its own, so the shortcut acts on this one. */ | |
| 91 | + | getLastFocused(): Promise<TbWindow>; | |
| 92 | + | /** Only used to raise the window a handed-over tab just landed in. */ | |
| 93 | + | update(windowId: number, props: { focused: boolean }): Promise<TbWindow>; | |
| 94 | + | }; | |
| 48 | 95 | 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[]>; | |
| 50 | 103 | create(props: { url: string }): Promise<TbTab>; | |
| 104 | + | /** Only used to make a tab active, which needs no permission. captureVisibleTab | |
| 105 | + | has no tabId of its own, so a split-view pick has to be activated first. */ | |
| 106 | + | update(tabId: number, props: { active: boolean }): Promise<TbTab>; | |
| 51 | 107 | /** The visible area of the active tab, as a PNG data URL. Needs a host | |
| 52 | 108 | permission for the page, exactly as the picker's injection does. */ | |
| 53 | 109 | captureVisibleTab(options: { format: "png" }): Promise<string>; | |
| 110 | + | /** Chrome only, and needs "tabGroups". Adds tabs to an existing group; the | |
| 111 | + | tabs must already be in that group's window. */ | |
| 112 | + | group?(options: { groupId: number; tabIds: number[] }): Promise<number>; | |
| 113 | + | /** Only used to carry a tab into the window Claude's group lives in, since | |
| 114 | + | group() will not reach across windows. */ | |
| 115 | + | move(tabId: number, props: { windowId: number; index: number }): Promise<TbTab | TbTab[]>; | |
| 54 | 116 | }; | |
| 117 | + | /** Chrome only — Firefox has no tab group API at all, which is what makes the | |
| 118 | + | handoff a Chrome-only feature. */ | |
| 119 | + | tabGroups?: { | |
| 120 | + | get(groupId: number): Promise<TbTabGroup>; | |
| 121 | + | query(info: { windowId?: number }): Promise<TbTabGroup[]>; | |
| 122 | + | }; | |
| 55 | 123 | permissions: { | |
| 56 | 124 | request(perms: { origins?: string[]; permissions?: string[] }): Promise<boolean>; | |
| 57 | 125 | }; | |
| ⋯ 21 unchanged lines | |||
| 79 | 147 | setPanelBehavior?(behavior: { | |
| 80 | 148 | openPanelOnActionClick: boolean; | |
| 81 | 149 | }): Promise<void>; | |
| 150 | + | /** Chrome only, and only during a user gesture. There is no close(): the | |
| 151 | + | panel closes itself with window.close(). */ | |
| 152 | + | open?(options: { windowId: number }): Promise<void>; | |
| 153 | + | /** Disabling is the only way to close a panel from here — the panel it | |
| 154 | + | applies to goes away without being asked. Without a tabId it is the | |
| 155 | + | default for every tab, which is how this extension declares it. */ | |
| 156 | + | setOptions?(options: { tabId?: number; path?: string; enabled?: boolean }): Promise<void>; | |
| 157 | + | }; | |
| 158 | + | /** Firefox only — the counterpart to Chrome's sidePanel. Unlike Chrome's, it | |
| 159 | + | can close the sidebar as well as open it. */ | |
| 160 | + | sidebarAction?: { | |
| 161 | + | open(): Promise<void>; | |
| 162 | + | close(): Promise<void>; | |
| 82 | 163 | }; | |
| 83 | 164 | /** Absent when no keyboard shortcuts are declared in the manifest. */ | |
| 84 | 165 | 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 | + | }; | |
| 86 | 173 | }; | |
| 87 | 174 | } | |
| 88 | 175 | ||
| ⋯ 10 unchanged lines | |||
| 99 | 186 | // Everything here arrives over a socket. It is *claimed* structure, not | |
| 100 | 187 | // guaranteed structure, and it is only ever assigned through textContent. | |
| 101 | 188 | ||
| 189 | + | /** One tmux session — one tab in the top row. */ | |
| 102 | 190 | interface TbSessionInfo { | |
| 191 | + | /** tmux session id (`$1`). Empty on the `ok` frame, which has names only. */ | |
| 192 | + | id: string; | |
| 103 | 193 | name: string; | |
| 104 | - | /** Window count. 0 when it isn't known yet — the `ok` frame sends names only. */ | |
| 105 | - | windows: number; | |
| 106 | 194 | /** Some tmux client, anywhere, is on this session. */ | |
| 107 | 195 | attached: boolean; | |
| 196 | + | /** The group colour, from the `@termbridge_color` tmux user option: a hue in | |
| 197 | + | degrees as a string, or "-1" for grey. Absent when nobody has chosen one, | |
| 198 | + | which is what tells the panel to pick. */ | |
| 199 | + | color?: string | null; | |
| 200 | + | /** Working directory of the session's current pane. Absent on the `ok` frame | |
| 201 | + | and whenever tmux has no pane to answer for. */ | |
| 202 | + | path?: string | null; | |
| 203 | + | /** When tmux made the session, in Unix seconds. */ | |
| 204 | + | created?: number | null; | |
| 205 | + | /** How many tmux clients are attached to it. */ | |
| 206 | + | clients?: number; | |
| 207 | + | /** Its windows, in index order. Empty until the first status frame. */ | |
| 208 | + | windows: TbWindowInfo[]; | |
| 108 | 209 | } | |
| 109 | 210 | ||
| 110 | - | /** One window of the attached session — one tab. */ | |
| 211 | + | /** One window of a session — one tab in the second row. */ | |
| 111 | 212 | interface TbWindowInfo { | |
| 112 | 213 | /** tmux window id (`@3`). Stable; the index is not. */ | |
| 113 | 214 | id: string; | |
| ⋯ 4 unchanged lines | |||
| 118 | 219 | activity: boolean; | |
| 119 | 220 | } | |
| 120 | 221 | ||
| 222 | + | /** | |
| 223 | + | * What an agent is doing. The header's glyph slot takes this plus `"none"` — its | |
| 224 | + | * own name for "no agent here", which it needs because every row has the slot | |
| 225 | + | * whether or not anything is in it. | |
| 226 | + | */ | |
| 227 | + | type TbAgentState = "working" | "waiting" | "ready" | "idle" | "unknown"; | |
| 228 | + | ||
| 121 | 229 | interface TbAgent { | |
| 122 | 230 | /** tmux pane id (`%12`), stable for the pane's lifetime. */ | |
| 123 | 231 | pane: string; | |
| 232 | + | /** Session name — the join to TbSessionInfo, since agents are server-wide. */ | |
| 233 | + | session: string; | |
| 124 | 234 | /** tmux window id (`@3`) — the join to TbWindowInfo. */ | |
| 125 | 235 | window_id: string; | |
| 126 | 236 | window: string; | |
| 127 | 237 | 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; | |
| 129 | 250 | mode?: string | null; | |
| 130 | 251 | tool?: string | null; | |
| 131 | 252 | message?: string | null; | |
| ⋯ 11 unchanged lines | |||
| 143 | 264 | defaultSession?: string; | |
| 144 | 265 | /** Names only — window counts arrive with the first status frame. */ | |
| 145 | 266 | sessions?: string[]; | |
| 267 | + | /** | |
| 268 | + | * Machines to offer in the omnibar, from the daemon's `~/.ssh/config` and | |
| 269 | + | * `~/.ssh/known_hosts`. Here rather than on the status frames because it | |
| 270 | + | * comes from files that do not change while a panel is open. | |
| 271 | + | */ | |
| 272 | + | hosts?: string[]; | |
| 146 | 273 | } | |
| 147 | 274 | ||
| 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 | + | */ | |
| 149 | 279 | interface TbStatusFrame { | |
| 150 | 280 | type: "status"; | |
| 281 | + | /** The session this panel's client is on right now. */ | |
| 151 | 282 | session?: string; | |
| 152 | 283 | sessions?: TbSessionInfo[]; | |
| 153 | - | /** Windows of `session`, in index order. */ | |
| 154 | - | windows?: TbWindowInfo[]; | |
| 155 | 284 | agents?: TbAgent[]; | |
| 156 | 285 | } | |
| 157 | 286 | ||
| 287 | + | /** | |
| 288 | + | * One directory, in answer to `{type:"path", q}` — the only frame the panel | |
| 289 | + | * asks for rather than being told. Built in daemon/src/server.rs from | |
| 290 | + | * `project::Listing`. | |
| 291 | + | */ | |
| 292 | + | interface TbPathFrame { | |
| 293 | + | type: "path"; | |
| 294 | + | /** The query, echoed back: replies can land after the box has moved on. */ | |
| 295 | + | q: string; | |
| 296 | + | /** The expanded, absolute path. Empty when the query was not one. */ | |
| 297 | + | path: string; | |
| 298 | + | /** "invalid" is a query the daemon would not expand — relative, `..`, | |
| 299 | + | someone else's `~`. */ | |
| 300 | + | kind: "dir" | "file" | "missing" | "denied" | "invalid"; | |
| 301 | + | /** Directories `mkdir -p` would have to create to make this path. */ | |
| 302 | + | creates: number; | |
| 303 | + | /** Directory names inside it, sorted, visible ones before hidden. */ | |
| 304 | + | dirs: string[]; | |
| 305 | + | /** The other names. Only used to tell a file apart from a path to create. */ | |
| 306 | + | files: string[]; | |
| 307 | + | } | |
| 308 | + | ||
| 158 | 309 | type TbFrame = | |
| 159 | 310 | | TbOkFrame | |
| 160 | 311 | | TbStatusFrame | |
| 312 | + | | TbPathFrame | |
| 161 | 313 | | { type: "exit"; code: number } | |
| 162 | 314 | | { type: "error"; reason: string } | |
| 163 | 315 | | { type: "tmux-error"; reason: string }; | |
| 164 | 316 | ||
| 165 | 317 | /** The element's box in CSS pixels, relative to the viewport. */ | |
| 318 | + | /** Where a pick's viewport sits in its window, which is how the crop tells the | |
| 319 | + | two halves of a split view apart. Both in screen coordinates. */ | |
| 320 | + | interface TbPane { | |
| 321 | + | screenX: number; | |
| 322 | + | winLeft: number; | |
| 323 | + | } | |
| 324 | + | ||
| 166 | 325 | interface TbPickedRect { | |
| 167 | 326 | x: number; | |
| 168 | 327 | y: number; | |
| ⋯ 11 unchanged lines | |||
| 180 | 339 | rect?: TbPickedRect; | |
| 181 | 340 | /** Viewport size at pick time, which fixes the screenshot's scale factor. */ | |
| 182 | 341 | viewport?: { w: number; h: number }; | |
| 342 | + | /** Screen position of the viewport's left edge. Absent on picks made before | |
| 343 | + | 0.0.2; only a split view needs it. */ | |
| 344 | + | screenX?: number; | |
| 183 | 345 | css: string; | |
| 184 | 346 | xpath: string; | |
| 185 | 347 | id: string | null; | |
| ⋯ 62 unchanged lines | |||
| 248 | 410 | declare const Themes: typeof import("../lib/theme.js"); | |
| 249 | 411 | declare const Sanitize: typeof import("../lib/sanitize.js"); | |
| 250 | 412 | declare const Shot: typeof import("../lib/shot.js"); | |
| 413 | + | declare const Split: typeof import("../lib/split.js"); | |
| 251 | 414 | ||
| 252 | 415 | /** Set by picker.js inside the *page*, not here — see cancelPick(). */ | |
| 253 | 416 | interface Window { | |
| ⋯ 2 unchanged lines | |||