anvilsign in

collin/browser-terminal-extension · 81bf09bf

Add tab pinning, pane title publishing, and terminal URL linkification

Collin Richards · 2026-08-18 06:41 UTC · 81bf09bfd2499cba85f32e92061d75bf4d9e6232 · parent a71dc952 · browse files

modifiedREADME.md+171 −2
⋯ 245 unchanged lines
246246 window they point at closes. They are per-browser-profile, not shared with
247247 anyone else attached to the session.
248248
249+## Pinning a session to a browser tab
250+
251+Right-clicking a session or a window tab also offers to **pin** it to the
252+browser tab you are on. After that, switching to that browser tab switches the
253+terminal: the tab you keep the app in brings up the session you run the app
254+from, and the docs tab beside it brings back whatever you had there.
255+
256+This is a different thing from the **Pin** in the paragraph above, which is
257+about where a tab sits in the strip. This one is about which browser tab brings
258+it up. They are independent, and a window can have both.
259+
260+Nothing is focused when it does: the terminal moves underneath, and the caret
261+stays on the page.
262+
263+### It is a loan, not a move
264+
265+A pin *borrows* the terminal. Leaving for a browser tab with no pin of its own
266+puts it back where it was before the pinned tab took it, so flicking between a
267+pinned tab and an unpinned one flicks the terminal between two places rather
268+than stranding it on the pinned one. Without that, a single glance at a pinned
269+tab would relocate the terminal permanently.
270+
271+Three rules keep that from being annoying:
272+
273+- Only the first pin in a run records a return. Pinned tab to pinned tab to
274+ unpinned goes back to where the run *started*, not to the middle of it — the
275+ middle was never somewhere you chose to be.
276+- A pin that had nothing to do owes nothing. Landing on a tab pinned to where
277+ you already are records no return, so leaving it moves nothing.
278+- A terminal you have since moved by hand is left alone. The return is an undo
279+ of a move this code made; once you have steered somewhere yourself there is
280+ nothing to undo, and dragging you back would be the panel overruling you.
281+
282+Following also waits about a sixth of a second before it acts, so Ctrl-Tabbing
283+through six tabs is one move at the end rather than six on the way.
284+
285+A pin can key on two things:
286+
287+- **the site**, e.g. `localhost:26210`. Any tab on that origin matches, and the
288+ pin outlives the tab, the window and the browser, because an origin is a name.
289+ This is the one to reach for.
290+- **that exact browser tab**, by the id Chrome gave it. Survives nothing, and
291+ exists for what an origin cannot express: two tabs on the same site pointing
292+ at different sessions, or a page whose URL says nothing.
293+
294+A tab pin wins over a site pin, being the more specific statement.
295+
296+Pinning the **session** rather than one of its windows is usually what you want:
297+it means "this tab brings up that project, wherever I left it", and it keeps
298+working when you close and reopen the window the dev server was in. A window pin
299+whose window has since been killed falls back to its session rather than going
300+quietly dead.
301+
302+A small accent dot marks whichever row the tab on screen points at, so the panel
303+moving on its own always has a visible reason. The tooltip says which rule
304+answered.
305+
306+### And back the other way
307+
308+The same pin also reads backwards, so moving the terminal brings the browser
309+along: switch to that session and the tab you pinned to it comes up. Anything
310+that moves the terminal counts — a click on a session tab in the panel, a
311+`prefix n` typed into the pane, another client switching a session this one is
312+watching — because by the time it reaches the panel it is one status frame
313+either way.
314+
315+This is the half that touches the browser, so it is deliberately timid, and it
316+has its own checkbox (**and switch tabs back**) for turning off without giving
317+up the forward direction:
318+
319+- It only ever **activates a tab that is already open**, in the panel's own
320+ browser window. It does not create tabs, does not focus the browser, does not
321+ raise a window and does not reach into another window. The worst it can do is
322+ show you a tab you already had.
323+- It does nothing when the tab already showing satisfies the pin. That is also
324+ what stops the two directions chasing each other: the forward one will not
325+ move a terminal that is already where the tab points, and this one will not
326+ move a browser that is already on a tab pointing here, so whichever fires
327+ second finds its work done.
328+- Two tabs can answer to one pin — two tabs on the pinned origin — and the tie
329+ goes first to the more specific rule and then to the one you looked at more
330+ recently.
331+- It waits the same sixth of a second, so holding `prefix n` through six windows
332+ moves the browser once at the end rather than flicking it through five tabs on
333+ the way.
334+- The loan being handed back does not count as a move. Leaving a pinned tab puts
335+ the terminal back where it was borrowed from, and if some third tab happens to
336+ be pinned to *that* place this stays out of it rather than chasing it and
337+ undoing the tab switch you just made by hand.
338+
339+It reads the pins you already have rather than a second set of its own, which
340+means a veto keeps vetoing and a tab pin keeps outranking a site pin from this
341+end too. There is nothing extra to set up: pin a session to a tab and both
342+directions light up together.
343+
344+### Guessing from the port
345+
346+Under the explicit pins sits a guess, on by default and switchable in settings:
347+a tab on a `localhost` port that `devport` would hand to a project we have a
348+session in is treated as pinned to that session, without anyone saying so.
349+
350+devport gives a project a stable block of ten ports from a checksum of its
351+directory name, so nothing has to be registered anywhere — and that mapping is
352+reproducible offline. `extension/lib/devport.js` reimplements it (POSIX `cksum`
353+and all) and hashes the working directories tmux already reports, which is the
354+same answer `devport -r` gives without a subprocess, a filesystem walk, or
355+devport being installed.
356+
357+The guess is deliberately timid. Local hosts only, in-range ports only, and an
358+ambiguous match — two sessions in one block, which collisions make possible —
359+resolves to nothing rather than a coin toss. Unpinning a guessed match records a
360+veto against that origin, which is the only way to say "no, not this one" to a
361+rule that would otherwise keep re-deriving itself.
362+
363+Everything here is panel state: it lives in extension storage, it is
364+per-browser-profile, and none of it reaches the daemon, which has never heard of
365+a browser tab. It also only works while the panel is open — the panel is what
366+watches the tabs.
367+
249368 ## How the daemon talks to tmux
250369
251370 The interactive client in the pty is busy being a terminal, so the daemon
⋯ 153 unchanged lines
405524 several times a second; stepping on events costs nothing and moves the glyph
406525 exactly when Claude crosses a tool boundary.
407526
408-The only command this issues is `set-option -w @tb_claude`. It cannot rename a
409-window, change a layout, or send a key.
527+The only command this issues is `set-option -w @tb_claude`. It cannot change a
528+layout or send a key.
529+
530+### Titles from the last prompt
531+
532+Two panes running Claude in the same repo are indistinguishable by anything tmux
533+knows about them: `#{pane_current_command}` is `node` in both and the cwd is the
534+same. What tells them apart is what you asked each one to do, and
535+`UserPromptSubmit` hands the hook exactly that.
536+
537+So the hook flattens the prompt to one line — whitespace collapsed, control
538+characters dropped, cut to 80 characters at a word — stores it on the session's
539+record, and writes it to the pane title:
540+
541+```sh
542+tmux select-pane -T "fix the flaky pty test"
543+```
544+
545+Pane title rather than window name, deliberately. `rename-window` would turn
546+automatic renaming off for that window for good and overwrite a name you or your
547+shell chose; the pane title is a field almost nothing else uses, and a window
548+that wants to follow it opts in through `automatic-rename-format` — which also
549+means the *active* pane is what names the window, for free:
550+
551+```tmux
552+set -g automatic-rename-format \
553+ "#{?#{==:#{pane_title},#{host}},#{pane_current_command},#{=/40/…:pane_title}}"
554+```
410555
556+Both fallbacks are load bearing. tmux seeds a pane's title with the hostname, so
557+"has no title of its own" has to be tested for rather than assumed empty, and a
558+pane that never ran Claude keeps the name tmux would have given it anyway.
559+Substitute your own format for `#{pane_current_command}` if you had one.
560+
561+`select-pane -T` expands its argument as a tmux format, so `#` in a prompt is
562+doubled on the way in and a prompt containing `#{...}` or `#[fg=red]` is inert.
563+
564+The title is only ever set, never cleared: the last prompt is still the most
565+useful thing to say about a pane that just finished running it, and a shell that
566+emits an OSC title on each prompt takes the pane back on its own. The same
567+string is what the sidebar shows for a session that has no name yet — Claude
568+only names a session once it has thought of one, and until then "what you asked"
569+beats "idle".
570+
411571 ## Opening and focusing the terminal
412572
413573 **Alt+Shift+T** is one key for the whole cycle, and what it does depends on
⋯ 61 unchanged lines
475635 actually captured; if the capture fails, only the selector is inserted and the
476636 reason is logged.
477637
638+**Where the keyboard ends up.** In the page, where you just clicked — the
639+selector is typed into a terminal you have to click into before you can type
640+there yourself. Moving the keyboard into the panel was tried and does not work:
641+the only way to focus a side panel is for Chrome to *open* it, which means
642+closing and reopening it (see *Opening and focusing the terminal*), and even
643+driven from the pick click's own user activation Chrome reopens the panel
644+without handing it the keyboard. All that bought was a flicker, so the pick
645+leaves the focus where it finds it.
646+
478647 The panel still shows the element afterwards: CSS selector, XPath, `id`, test
479648 id, text, or `href`. **copy** puts the selected one on the clipboard, and
480649 **insert** re-types it (text only — the screenshot is already on the clipboard
⋯ 194 unchanged lines
modifiedTODO.md+14 −2
1+- [ ] be able to talk to claude about anything on the page
2+- [ ] remote host tmux session
13 - [ ] Idea: pull off a tab out side of the browser and it puts it within your default terminal emulator
24 - [ ] TODO: get working well on unconfigured tmux
35 - [ ] TODO: investigate chrome wterm and vercel wterm
4-- [ ] Idea: pinned tab / session mode
6+- [x] Idea: pinned tab / session mode
7+ - right-click a session or window tab to pin it to the browser tab you are
8+ on, by site or by that exact tab; localhost ports are matched to sessions
9+ by devport's hash when nothing is pinned. See README, "Pinning a session
10+ to a browser tab".
11+ - the same pins read backwards too: moving the terminal activates the tab
12+ pinned to where it landed. Own checkbox, "and switch tabs back".
513 - [ ] send to claude should auto name window using llm
614 - [ ] typing cd in omnibar should have a ui for selecting dir
715 - this will change the directory of the current tmux session
8-- [ ] be able to talk to claude about anything on the page
916 - [ ] 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
17+ - we kinda got it working with a werid workaround hack which closes then re-opens
18+ - this is okay, but janky
19+
20+- [ ] does neovim have a way to probe the terminal's background color
21+- [ ] use claude in chrome with a browser on a different machine
modifiedbuild.sh+1 −1
⋯ 6 unchanged lines
77 out="dist/$browser"
88 rm -rf "$out"; mkdir -p "$out"
99 cp extension/sidebar.html extension/sidebar.js extension/sidebar.css extension/picker.js "$out/"
10- mkdir -p "$out/lib" && cp extension/lib/sanitize.js extension/lib/theme.js extension/lib/shot.js extension/lib/split.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 extension/lib/devport.js extension/lib/tabpin.js "$out/lib/"
1111 cp -r extension/vendor extension/icons "$out/"
1212 cp "extension/manifest.$browser.json" "$out/manifest.json"
1313 cp extension/sw.js "$out/" # both browsers now run a background script
⋯ 11 unchanged lines
modifieddaemon/src/agents.rs+119 −0
⋯ 62 unchanged lines
6363 /// Session title, when Claude has one.
6464 #[serde(default, skip_serializing_if = "Option::is_none")]
6565 pub name: Option<String>,
66+ /// One line of the last thing the user asked, from `UserPromptSubmit`. It
67+ /// is what [`crate::pane_title`] writes to the pane title, and what the
68+ /// sidebar falls back to when a session has no name yet — which is most of
69+ /// them, since a name only arrives once Claude has thought of one.
70+ #[serde(default, skip_serializing_if = "Option::is_none")]
71+ pub prompt: Option<String>,
6672 /// Which asterisk frame this session's glyph is on. Advanced once per hook
6773 /// event so the tmux status line has something to animate with — see
6874 /// [`crate::window_status`]. The sidebar runs its own timer and ignores it.
⋯ 113 unchanged lines
182188 _ => None,
183189 },
184190 name: str_field("session_name").or_else(|| carry(|r| r.name.clone())),
191+ // Only `UserPromptSubmit` carries one; every other event keeps the one
192+ // already on disk, so the title stays put for the whole turn instead of
193+ // blinking out on the first tool call.
194+ prompt: str_field("prompt")
195+ .as_deref()
196+ .and_then(summarize)
197+ .or_else(|| carry(|r| r.prompt.clone())),
185198 // Wrapping, and the glyph indexes into a six-frame cycle: the absolute
186199 // count is never read, only its position in the loop.
187200 frame: previous.as_ref().map_or(0, |p| p.frame.wrapping_add(1)),
⋯ 15 unchanged lines
203216 fs::rename(&tmp, path)
204217 }
205218
219+/// How much of a prompt is kept. Long enough that a sentence usually survives
220+/// whole, short enough that a record, a pane title and a status line entry are
221+/// all bounded. Anything that has to be shorter still — a narrow status line,
222+/// a sidebar tab — trims what it renders rather than what is stored.
223+const PROMPT_MAX: usize = 80;
224+
225+/// A prompt as a single line of title.
226+///
227+/// Prompts are pasted stack traces and multi-paragraph essays as often as they
228+/// are one sentence, and they reach a pane title, so they are flattened to one
229+/// line, stripped of control characters, and cut. `None` for a prompt with
230+/// nothing printable in it — better to keep the previous title than to blank it.
231+pub fn summarize(prompt: &str) -> Option<String> {
232+ let mut out = String::new();
233+ let mut space = false;
234+ for c in prompt.chars() {
235+ if c.is_whitespace() {
236+ space = !out.is_empty();
237+ continue;
238+ }
239+ // Control characters, and the escape sequences built from them, would
240+ // be written straight into a terminal's title.
241+ if c.is_control() {
242+ continue;
243+ }
244+ if space {
245+ out.push(' ');
246+ space = false;
247+ }
248+ out.push(c);
249+ // One past the limit, so the caller below can tell "exactly full" from
250+ // "there was more".
251+ if out.chars().count() > PROMPT_MAX {
252+ break;
253+ }
254+ }
255+ if out.is_empty() {
256+ return None;
257+ }
258+ if out.chars().count() <= PROMPT_MAX {
259+ return Some(out);
260+ }
261+ let mut cut: String = out.chars().take(PROMPT_MAX).collect();
262+ // Prefer a word boundary, but only a late one: cutting "refactor the
263+ // authentication" back to "refactor" loses more than the ragged edge costs.
264+ let floor = cut.len() * 2 / 3;
265+ if let Some(i) = cut.rfind(' ').filter(|i| *i >= floor) {
266+ cut.truncate(i);
267+ }
268+ cut.push('…');
269+ Some(cut)
270+}
271+
206272 /// Session ids come from Claude, but they end up in a filename, so treat them
207273 /// as untrusted: no separators, no `..`, no surprises.
208274 fn sanitize_id(id: &str) -> String {
⋯ 147 unchanged lines
356422 });
357423 }
358424
425+ /// The title has to survive the whole turn: the prompt arrives once and
426+ /// every event after it is a tool call that knows nothing about it.
427+ #[test]
428+ fn the_prompt_outlives_the_event_that_carried_it() {
429+ with_temp_dir(|| {
430+ let r = event(serde_json::json!({
431+ "session_id": "a", "hook_event_name": "UserPromptSubmit",
432+ "cwd": "/tmp/p", "prompt": "fix the flaky pty test",
433+ }))
434+ .unwrap();
435+ assert_eq!(r.prompt.as_deref(), Some("fix the flaky pty test"));
436+
437+ let r = event(serde_json::json!({
438+ "session_id": "a", "hook_event_name": "PreToolUse", "tool_name": "Bash",
439+ }))
440+ .unwrap();
441+ assert_eq!(r.prompt.as_deref(), Some("fix the flaky pty test"));
442+ });
443+ }
444+
445+ #[test]
446+ fn a_prompt_becomes_one_line_of_title() {
447+ assert_eq!(
448+ summarize(" fix the\n\tflaky test ").as_deref(),
449+ Some("fix the flaky test")
450+ );
451+ // Escape sequences would otherwise reach a terminal's title.
452+ assert_eq!(
453+ summarize("red \x1b[31mtext").as_deref(),
454+ Some("red [31mtext")
455+ );
456+ assert_eq!(summarize(" \n ").as_deref(), None);
457+ }
458+
459+ #[test]
460+ fn a_long_prompt_is_cut_at_a_word() {
461+ let long = "please refactor the websocket handshake so that pairing happens \
462+ before the upgrade rather than after it";
463+ let cut = summarize(long).unwrap();
464+ assert!(cut.ends_with('…'), "{cut}");
465+ assert!(cut.chars().count() <= PROMPT_MAX + 1, "{cut}");
466+ assert!(!cut.contains(" "));
467+ // Cut at a boundary, so no half word before the ellipsis.
468+ let body = cut.trim_end_matches('…');
469+ assert!(long.starts_with(body), "{cut}");
470+ assert!(long[body.len()..].starts_with(' '), "{cut}");
471+
472+ // A single unbroken run has no boundary to prefer; it is cut anyway
473+ // rather than allowed through at full length.
474+ let wall = "x".repeat(200);
475+ assert_eq!(summarize(&wall).unwrap().chars().count(), PROMPT_MAX + 1);
476+ }
477+
359478 #[test]
360479 fn session_ids_cannot_escape_the_directory() {
361480 assert_eq!(sanitize_id("../../etc/passwd"), "etcpasswd");
⋯ 3 unchanged lines
modifieddaemon/src/lib.rs+1 −0
⋯ 2 unchanged lines
33 pub mod auth;
44 pub mod control;
55 pub mod install;
6+pub mod pane_title;
67 pub mod paths;
78 pub mod project;
89 pub mod pty;
⋯ 12 unchanged lines
modifieddaemon/src/main.rs+11 −1
⋯ 17 unchanged lines
1818 termbridge install [--port N] [--idle-timeout SECS] [--session NAME]
1919 start the daemon on demand, from now on
2020 termbridge uninstall stop doing that
21- termbridge reload restart the running daemon (after a rebuild)
21+ termbridge reload restart the running daemon, re-source tmux.conf
2222 termbridge token print the auth token (paste into extension)
2323 termbridge token --rotate generate a new token
2424 termbridge pair <origin> approve an extension origin
⋯ 74 unchanged lines
9999 // install under another name.
100100 Some("reload") => {
101101 termbridge::install::reload()?;
102+ // The other half of an edit loop. The formats in ~/.tmux.conf name
103+ // options this daemon's hooks set, so the two are changed together
104+ // often enough that reloading one and not the other is the state
105+ // you end up debugging.
106+ for file in termbridge::window_status::reload_conf() {
107+ println!("sourced {file}");
108+ }
102109 Ok(())
103110 }
104111 Some("token") => {
⋯ 55 unchanged lines
160167 // the event removed the record: that is what clears the glyph
161168 // from a window whose Claude has gone.
162169 termbridge::window_status::publish();
170+ // Same order and the same reason: the title is read back out of
171+ // the record that was just written.
172+ termbridge::pane_title::publish();
163173 }
164174 Ok(())
165175 }
⋯ 163 unchanged lines
addeddaemon/src/pane_title.rs+88 −0
1+//! What a pane's Claude is called, published as a tmux pane option.
2+//!
3+//! A pane running Claude is otherwise indistinguishable from any other pane:
4+//! `#{pane_current_command}` is `node` in all of them and the cwd is the same
5+//! repo in half of them. What tells two of them apart is the session's name, or
6+//! before Claude has thought of one, the last thing you asked it — and the hook
7+//! is handed both, `session_name` and `prompt`, as fields.
8+//!
9+//! They go into a pane option rather than the pane title. The title is a shared
10+//! channel: Claude Code writes its own name there over OSC, a shell writes the
11+//! cwd there, and a status line reading it back has to guess which of them it
12+//! got and what shape it is in. An `@`-prefixed option is ours alone, so
13+//! `#{@tb_title}` is either the name or empty, and no format has to take the
14+//! string apart to find out.
15+//!
16+//! Pane-scoped, because tmux resolves a pane option against the active pane of
17+//! the window being drawn: `#{@tb_title}` in `window-status-format` names the
18+//! window after whichever pane you are looking at, with nothing here having to
19+//! know which one that is — or to touch the window at all.
20+//!
21+//! Like [`crate::window_status`], this runs inside a Claude Code hook and
22+//! swallows every error: a stale name is not worth interrupting a session over.
23+
24+use std::process::{Command, Stdio};
25+
26+use crate::agents;
27+
28+/// The option the format reads. `@`-prefixed, so tmux treats it as a user
29+/// option and no future tmux release can collide with it.
30+pub const OPTION: &str = "@tb_title";
31+
32+/// Set the option on the pane the hook fired in, from that pane's record.
33+///
34+/// Unset when there is nothing to say — including when the record has just been
35+/// removed by `SessionEnd`, which is what takes the name off a pane that is
36+/// back to being an ordinary shell. That is safe here in a way clearing a pane
37+/// *title* would not be: the option has no other writer.
38+pub fn publish() {
39+ if std::env::var_os("TMUX").is_none() {
40+ return;
41+ }
42+ let Ok(pane) = std::env::var("TMUX_PANE") else {
43+ return;
44+ };
45+ // Claude's own name for the session first — it is a summary, and the prompt
46+ // is only ever the raw material for one. Before it exists, and it does not
47+ // exist for most of a session's life, what you asked beats nothing.
48+ let title = agents::by_pane()
49+ .get(&pane)
50+ .and_then(|r| r.name.clone().or_else(|| r.prompt.clone()));
51+
52+ match title {
53+ Some(t) => tmux(&["set-option", "-p", "-t", &pane, OPTION, &escape(&t)]),
54+ None => tmux(&["set-option", "-pu", "-t", &pane, OPTION]),
55+ };
56+}
57+
58+/// Text on its way into a place tmux will expand as a format — an option's
59+/// value is expanded every time the status line draws, so a prompt containing
60+/// `#{...}` or `#[fg=red]` would be interpolated or restyled. Doubling `#` is
61+/// tmux's own escape and the only one needed: every format construct starts
62+/// with one.
63+pub(crate) fn escape(title: &str) -> String {
64+ title.replace('#', "##")
65+}
66+
67+fn tmux(args: &[&str]) -> String {
68+ Command::new("tmux")
69+ .args(args)
70+ .stdin(Stdio::null())
71+ .stderr(Stdio::null())
72+ .output()
73+ .ok()
74+ .and_then(|o| String::from_utf8(o.stdout).ok())
75+ .unwrap_or_default()
76+}
77+
78+#[cfg(test)]
79+mod tests {
80+ use super::*;
81+
82+ #[test]
83+ fn format_constructs_in_a_prompt_are_inert() {
84+ assert_eq!(escape("count #{host} items"), "count ##{host} items");
85+ assert_eq!(escape("#[fg=red]"), "##[fg=red]");
86+ assert_eq!(escape("plain"), "plain");
87+ }
88+}
modifieddaemon/src/status.rs+5 −2
⋯ 58 unchanged lines
5959 pub tool: Option<String>,
6060 /// Why it is waiting on you.
6161 pub message: Option<String>,
62- /// Claude's session title, when it has one.
62+ /// Claude's session title, or — until it has one — the last thing the user
63+ /// asked it. The same string [`crate::pane_title`] gives to tmux, so a
64+ /// session reads the same in the sidebar and in the status line.
6365 pub title: Option<String>,
6466 }
6567
⋯ 367 unchanged lines
433435 mode: record.and_then(|r| r.mode.clone()),
434436 tool: record.and_then(|r| r.tool.clone()),
435437 message: record.and_then(|r| r.message.clone()),
436- title: record.and_then(|r| r.name.clone()),
438+ title: record.and_then(|r| r.name.clone().or_else(|| r.prompt.clone())),
437439 })
438440 })
439441 .collect();
⋯ 21 unchanged lines
461463 tool: None,
462464 message: None,
463465 name: None,
466+ prompt: None,
464467 frame: 0,
465468 updated,
466469 }
⋯ 48 unchanged lines
modifieddaemon/src/window_status.rs+40 −4
⋯ 97 unchanged lines
9898 let _ = tmux(&["set-option", "-wu", "-t", pane, OPTION]);
9999 }
100100
101+/// Re-source the running tmux server's own configuration, and report what was
102+/// sourced.
103+///
104+/// tmux has no "reload config" command, so the files have to be named — and
105+/// `#{config_files}` is tmux naming them itself, which is the only answer that
106+/// stays right for a user whose config lives somewhere this code has never
107+/// heard of. It is a list of candidates rather than of files that exist, hence
108+/// `-q`: `~/.tmux.conf` is on it for everyone, including everyone who keeps
109+/// their config in `~/.config/tmux` instead.
110+///
111+/// Empty when no tmux server is running, which is not a failure — there is
112+/// simply nothing holding stale settings.
113+pub fn reload_conf() -> Vec<String> {
114+ let files: Vec<String> = tmux(&["display-message", "-p", "#{config_files}"])
115+ .trim()
116+ .split(',')
117+ .filter(|f| !f.is_empty())
118+ .map(str::to_string)
119+ .collect();
120+ for file in &files {
121+ let _ = tmux(&["source-file", "-q", file]);
122+ }
123+ files
124+}
125+
101126 /// No `-L`/`-S`: the hook runs inside the pane, so `$TMUX` already names the
102127 /// server the user is looking at, and inheriting it is what makes this correct
103128 /// under multiple tmux servers.
⋯ 11 unchanged lines
115140 /// What `termbridge tmux` prints. Two formats, because tmux styles the current
116141 /// window with a separate one and a glyph that vanished on the window you were
117142 /// on would be the wrong half to lose.
143+///
144+/// The name in place of `#W` is [`crate::pane_title`]: a pane option, so tmux
145+/// resolves it against the active pane of the window it is drawing, and the
146+/// window reads as whichever pane you are looking at. It is empty rather than
147+/// oddly shaped when there is no Claude, so `#W` is the whole fallback — and
148+/// `#{=/24/…:}` because a name is a sentence where a window name was a word,
149+/// and four of them would otherwise push the clock off the status line.
118150 pub fn tmux_conf() -> String {
151+ let title = crate::pane_title::OPTION;
152+ let name = format!("#{{?#{{{title}}},#{{=/24/…:#{{{title}}}}},#W}}");
119153 format!(
120154 "# Claude Code status in the tmux status line, from termbridge's hooks.\n\
121- # The option is empty for a window with no Claude in it, so windows\n\
122- # that never see one look exactly as they do now.\n\
123- set -g window-status-format \"#{{{OPTION}}}#I:#W#F\"\n\
124- set -g window-status-current-format \"#{{{OPTION}}}#I:#W#F\"\n"
155+ # Both options are empty for a pane with no Claude in it, so windows\n\
156+ # that never see one look exactly as they do now: no glyph, and the\n\
157+ # window name tmux would have shown anyway.\n\
158+ set -g window-status-format \"#{{{OPTION}}}#I:{name}#F\"\n\
159+ set -g window-status-current-format \"#{{{OPTION}}}#I:{name}#F\"\n"
125160 )
126161 }
127162
⋯ 11 unchanged lines
139174 tool: None,
140175 message: None,
141176 name: None,
177+ prompt: None,
142178 frame,
143179 updated: 0,
144180 }
⋯ 23 unchanged lines
addedextension/lib/devport.js+112 −0
1+// The `devport` convention, reimplemented so the panel can apply it offline.
2+//
3+// devport(1) is a shell script that hands a local project a stable block of ten
4+// ports: `BASE + (cksum(name) % BLOCKS) * SLOT`, where the name is the git
5+// repo's directory name. Nothing registers anything — the hash *is* the
6+// registry — which is what makes it reproducible here.
7+//
8+// The panel wants the question the other way round: a browser tab is sitting on
9+// http://localhost:12345, and it wants to know which tmux session that port
10+// belongs to. devport answers that with `-r`, by walking ~/Code and hashing
11+// every directory in it. We do not need the walk: the daemon already tells us
12+// each session's working directory, so hashing those few names forward and
13+// comparing blocks gives the same answer without a subprocess, a filesystem, or
14+// devport being installed at all.
15+//
16+// The one thing that must not drift is the hash. `portof()` in devport is
17+// `printf '%s' "$name" | cksum`, and POSIX cksum is CRC-32/CKSUM: the ordinary
18+// CRC-32 polynomial, unreflected, with the message length appended to the
19+// message. Any faster-looking CRC-32 (zlib's, reflected, no length) computes a
20+// different number and would point at the wrong project.
21+
22+/** First assignable port. Below this is the crowd of framework defaults. */
23+const DEVPORT_BASE = 10240;
24+/** How many blocks exist: 2100 * 10 covers 10240-31239. */
25+const DEVPORT_BLOCKS = 2100;
26+/** Ports per project — app, db, cache, debugger, whatever. */
27+const DEVPORT_SLOT = 10;
28+
29+/** Lazily built, because this runs on every tab activation. @type {number[] | null} */
30+let table = null;
31+
32+/** The CRC-32/CKSUM byte table, poly 0x04C11DB7, unreflected. */
33+function crcTable() {
34+ if (table) return table;
35+ const t = new Array(256);
36+ for (let i = 0; i < 256; i++) {
37+ let c = i << 24;
38+ for (let k = 0; k < 8; k++) c = c & 0x80000000 ? (c << 1) ^ 0x04c11db7 : c << 1;
39+ t[i] = c >>> 0;
40+ }
41+ table = t;
42+ return t;
43+}
44+
45+/**
46+ * POSIX `cksum`, as a number.
47+ *
48+ * The trailing length loop is not decoration: cksum feeds the byte count in
49+ * after the data, low byte first, dropping the high zero bytes. Without it the
50+ * checksums differ from the shell's for every input.
51+ *
52+ * @param {string} s
53+ * @returns {number}
54+ */
55+function cksum(s) {
56+ const t = crcTable();
57+ const bytes = new TextEncoder().encode(s);
58+ let crc = 0;
59+ for (const b of bytes) crc = ((crc << 8) ^ t[((crc >>> 24) ^ b) & 0xff]) >>> 0;
60+ for (let n = bytes.length; n !== 0; n = Math.floor(n / 256)) {
61+ crc = ((crc << 8) ^ t[((crc >>> 24) ^ (n & 0xff)) & 0xff]) >>> 0;
62+ }
63+ return (crc ^ 0xffffffff) >>> 0;
64+}
65+
66+/**
67+ * The first port of the block a project name owns — what plain `devport` prints
68+ * in a directory of that name.
69+ *
70+ * @param {string} name
71+ * @returns {number}
72+ */
73+function portOf(name) {
74+ return DEVPORT_BASE + (cksum(name) % DEVPORT_BLOCKS) * DEVPORT_SLOT;
75+}
76+
77+/**
78+ * The block a port falls in, or null when it is outside the assignable range —
79+ * 3000, 5173, 8080 and every other hand-picked port land here, and they carry
80+ * no project in them to find.
81+ *
82+ * @param {number} port
83+ * @returns {number | null}
84+ */
85+function blockOf(port) {
86+ if (!Number.isInteger(port)) return null;
87+ if (port < DEVPORT_BASE || port >= DEVPORT_BASE + DEVPORT_BLOCKS * DEVPORT_SLOT) return null;
88+ return port - ((port - DEVPORT_BASE) % DEVPORT_SLOT);
89+}
90+
91+/**
92+ * Whether `port` is one of the ten this name owns.
93+ *
94+ * A block is ten ports wide and there are only 2100 blocks, so a match is
95+ * evidence and not proof — two projects in ~/Code can share one. That is why
96+ * this only ever *suggests* a pin rather than making one.
97+ *
98+ * @param {number} port
99+ * @param {string} name
100+ * @returns {boolean}
101+ */
102+function owns(port, name) {
103+ if (!name) return false;
104+ const block = blockOf(port);
105+ return block !== null && block === portOf(name);
106+}
107+
108+const Devport = { BASE: DEVPORT_BASE, BLOCKS: DEVPORT_BLOCKS, SLOT: DEVPORT_SLOT, cksum, portOf, blockOf, owns };
109+
110+if (typeof module !== "undefined" && module.exports) {
111+ module.exports = Devport;
112+}
addedextension/lib/devport.test.js+71 −0
1+// Run with: node --test "extension/lib/*.test.js"
2+//
3+// The whole value of this module is agreeing with the devport shell script, so
4+// most of what is checked here are ports taken from running it. If cksum is
5+// ever "optimised" into an ordinary zlib CRC-32 these are the tests that fail.
6+
7+const test = require("node:test");
8+const assert = require("node:assert");
9+
10+const Devport = require("./devport.js");
11+
12+// `devport -n <name>`, run for real.
13+const KNOWN = {
14+ "browser-terminal-extension": 26210,
15+ myapp: 26270,
16+ dotfiles: 26140,
17+ termbridge: 12820,
18+ Code: 25000,
19+ a: 30900,
20+};
21+
22+test("agrees with the devport script", () => {
23+ for (const [name, port] of Object.entries(KNOWN)) {
24+ assert.equal(Devport.portOf(name), port, name);
25+ }
26+});
27+
28+test("cksum matches the POSIX checksum", () => {
29+ // `printf '%s' "" | cksum` and `printf '%s' abc | cksum`.
30+ assert.equal(Devport.cksum(""), 4294967295);
31+ assert.equal(Devport.cksum("abc"), 1219131554);
32+});
33+
34+test("every port lands in the assignable range, on a block boundary", () => {
35+ const top = Devport.BASE + Devport.BLOCKS * Devport.SLOT;
36+ for (let i = 0; i < 500; i++) {
37+ const port = Devport.portOf(`project-${i}`);
38+ assert.ok(port >= Devport.BASE && port < top, `${port} out of range`);
39+ assert.equal((port - Devport.BASE) % Devport.SLOT, 0);
40+ }
41+});
42+
43+test("a block covers the ten ports that follow its base", () => {
44+ const base = Devport.portOf("myapp");
45+ for (let i = 0; i < Devport.SLOT; i++) {
46+ assert.equal(Devport.blockOf(base + i), base, `offset ${i}`);
47+ assert.ok(Devport.owns(base + i, "myapp"));
48+ }
49+ // One past the end belongs to the next block, not this one.
50+ assert.notEqual(Devport.blockOf(base + Devport.SLOT), base);
51+});
52+
53+test("ports outside the range belong to nobody", () => {
54+ for (const port of [80, 3000, 5173, 8080, 10239, 31240, 65535, -1, 1.5, NaN]) {
55+ assert.equal(Devport.blockOf(port), null, String(port));
56+ assert.equal(Devport.owns(port, "myapp"), false, String(port));
57+ }
58+});
59+
60+test("an empty name owns nothing", () => {
61+ // Not because it hashes badly — it hashes fine — but because a session with
62+ // no working directory would otherwise match one arbitrary block.
63+ assert.equal(Devport.owns(Devport.portOf(""), ""), false);
64+});
65+
66+test("unicode names hash by their bytes", () => {
67+ // The shell pipes bytes into cksum, so the name has to be UTF-8 encoded here
68+ // rather than iterated as UTF-16 code units.
69+ assert.equal(Devport.portOf("café"), Devport.portOf("café"));
70+ assert.notEqual(Devport.portOf("café"), Devport.portOf("cafe"));
71+});
addedextension/lib/tabpin.js+437 −0
1+// Pinning a tmux session (or one window of one) to a browser tab.
2+//
3+// The point is that moving between browser tabs moves the terminal with you:
4+// the tab you keep the app in brings up the session you run the app from, and
5+// the docs tab beside it brings up whatever you had there.
6+//
7+// The same pin also reads backwards — moving the terminal brings the browser
8+// along — and that half is the more delicate one, because the browser is where
9+// the user's hands are. Three things keep it from yanking anything:
10+//
11+// - It only ever *activates* a tab that is already open in the panel's own
12+// browser window. Nothing is created, nothing is focused, no window is
13+// raised. The worst it can do is show you a tab you already had.
14+// - It does nothing at all when the tab already showing satisfies the pin,
15+// which is what stops the two directions from chasing each other: forward
16+// will not move a terminal that is already where the tab points, and
17+// backward will not move a browser that is already on a tab that points
18+// here. Whichever fires second finds its work done.
19+// - It is off in one checkbox, separately from the forward direction, for
20+// anyone who wants the terminal to follow without the browser leading.
21+//
22+// Two kinds of pin, and the difference is what they key on:
23+//
24+// by origin http://localhost:26210 -> a session. Any tab on that origin
25+// matches, and the pin outlives the tab, the window and the
26+// browser itself, because an origin is a name and a tab id is a
27+// handle. This is the one to reach for.
28+// by tab this exact tab, by the id Chrome gave it. Survives nothing —
29+// tab ids are not reissued but they are not remembered across a
30+// restart either — and exists for the case an origin cannot
31+// express: two tabs on the same site pointing at different
32+// sessions, or a page whose URL says nothing (a file:, a blank
33+// tab, one of five identical Jira boards).
34+//
35+// A tab pin wins over an origin pin, being the more specific statement. Below
36+// both sits detection: a tab on a localhost port that devport would hand to a
37+// project we have a session in is treated as pinned to that session even though
38+// nobody said so. That guess is only ever a fallback — an explicit pin at
39+// either level overrides it, including the explicit *veto* that unpinning an
40+// auto-matched tab writes, which is the only way to say "no, not this one" to a
41+// rule that would otherwise keep re-deriving itself.
42+//
43+// Everything here is pure. Reading tabs, writing storage and telling tmux to
44+// move are all sidebar.js's; this module only answers "given what is stored and
45+// what is on the server, where should the terminal be?"
46+
47+/** Storage key. Deliberately not `pins`, which is this panel's *window* pins. */
48+const TABPIN_KEY = "tabPins";
49+
50+/**
51+ * An empty store. Shaped rather than `{}` so every caller can index into it
52+ * without a guard.
53+ * @returns {TbTabPinStore}
54+ */
55+function emptyStore() {
56+ return { enabled: true, detect: true, reverse: true, byTab: {}, byOrigin: {} };
57+}
58+
59+/**
60+ * Storage is not a promise: it holds whatever some earlier version of this
61+ * panel wrote, and a hostile page cannot reach it but a bug can. So anything
62+ * that is not the shape we expect is dropped rather than trusted.
63+ *
64+ * @param {unknown} raw
65+ * @returns {TbTabPinStore}
66+ */
67+function loadStore(raw) {
68+ const store = emptyStore();
69+ if (!raw || typeof raw !== "object") return store;
70+ const v = /** @type {Record<string, unknown>} */ (raw);
71+ if (v.enabled === false) store.enabled = false;
72+ if (v.detect === false) store.detect = false;
73+ if (v.reverse === false) store.reverse = false;
74+ store.byTab = cleanMap(v.byTab);
75+ store.byOrigin = cleanMap(v.byOrigin);
76+ return store;
77+}
78+
79+/** @param {unknown} raw @returns {Record<string, TbPinTarget | null>} */
80+function cleanMap(raw) {
81+ /** @type {Record<string, TbPinTarget | null>} */
82+ const out = {};
83+ if (!raw || typeof raw !== "object") return out;
84+ for (const [key, value] of Object.entries(raw)) {
85+ // null is a veto — "this tab is pinned to nothing, stop guessing" — and is
86+ // as meaningful a stored value as a target.
87+ if (value === null) {
88+ out[key] = null;
89+ continue;
90+ }
91+ if (!value || typeof value !== "object") continue;
92+ const t = /** @type {Record<string, unknown>} */ (value);
93+ if (typeof t.session !== "string" || !t.session) continue;
94+ out[key] = {
95+ session: t.session,
96+ window: typeof t.window === "string" && t.window ? t.window : null,
97+ };
98+ }
99+ return out;
100+}
101+
102+/**
103+ * The origin a pin would key on, or "" for a tab that cannot carry one.
104+ *
105+ * Only http and https. Everything else — the browser's own pages, extension
106+ * pages, `file:`, `about:blank` — either has no origin worth the name or is a
107+ * page this extension is not allowed to look at anyway, and a tab pin is the
108+ * answer for those.
109+ *
110+ * @param {string | undefined} url
111+ * @returns {string}
112+ */
113+function originOf(url) {
114+ if (!url) return "";
115+ try {
116+ const u = new URL(url);
117+ if (u.protocol !== "http:" && u.protocol !== "https:") return "";
118+ return u.origin;
119+ } catch {
120+ return "";
121+ }
122+}
123+
124+/**
125+ * The port a local development server would be on, or null.
126+ *
127+ * Local only, and deliberately so: devport's mapping says nothing about a port
128+ * on someone else's host, and a public site that happens to sit on :26210 is
129+ * not your project.
130+ *
131+ * @param {string} origin
132+ * @returns {number | null}
133+ */
134+function localPort(origin) {
135+ if (!origin) return null;
136+ try {
137+ const u = new URL(origin);
138+ const host = u.hostname;
139+ const local =
140+ host === "localhost" ||
141+ host === "127.0.0.1" ||
142+ host === "[::1]" ||
143+ host === "::1" ||
144+ host.endsWith(".localhost");
145+ if (!local || !u.port) return null;
146+ return Number(u.port);
147+ } catch {
148+ return null;
149+ }
150+}
151+
152+/**
153+ * The last path segment, which is the name devport hashes: it takes the git
154+ * repo's directory name, and a session's working directory is normally that
155+ * directory or something under it.
156+ *
157+ * @param {string | null | undefined} path
158+ * @returns {string}
159+ */
160+function baseName(path) {
161+ if (!path) return "";
162+ const parts = path.replace(/\/+$/, "").split("/");
163+ return parts[parts.length - 1] ?? "";
164+}
165+
166+/**
167+ * The session a localhost origin most likely belongs to, by devport's hash, or
168+ * null when nothing matches.
169+ *
170+ * Two candidate names per session, because either can be the one devport was
171+ * run under: the working directory's own name, and the session name (the
172+ * omnibar names sessions after the project directory, so it usually *is* the
173+ * name). The directory is checked first — it is what devport actually reads.
174+ *
175+ * Ambiguity resolves to nothing. Blocks collide by design, and a wrong
176+ * auto-switch is worse than no auto-switch: it moves a terminal you were
177+ * reading out from under you for a reason you cannot see.
178+ *
179+ * @param {string} origin
180+ * @param {TbSessionInfo[]} sessions
181+ * @param {typeof Devport} devport
182+ * @returns {TbPinTarget | null}
183+ */
184+function detectTarget(origin, sessions, devport) {
185+ const port = localPort(origin);
186+ if (port === null || devport.blockOf(port) === null) return null;
187+
188+ /** @type {TbSessionInfo[]} */
189+ const hits = [];
190+ for (const s of sessions) {
191+ if (devport.owns(port, baseName(s.path)) || devport.owns(port, s.name)) hits.push(s);
192+ }
193+ if (hits.length !== 1) return null;
194+ return { session: hits[0].name, window: null };
195+}
196+
197+/**
198+ * Where the terminal should go for this tab, and why — or null for "stay where
199+ * you are", which is the answer for every tab nobody has said anything about.
200+ *
201+ * @param {TbTabPinStore} store
202+ * @param {{ id?: number, url?: string }} tab
203+ * @param {TbSessionInfo[]} sessions
204+ * @param {typeof Devport} devport
205+ * @returns {{ target: TbPinTarget, source: TbPinSource } | null}
206+ */
207+function resolve(store, tab, sessions, devport) {
208+ if (!store.enabled) return null;
209+ const origin = originOf(tab.url);
210+
211+ // Presence is the statement, so `in` rather than a truthiness test: a stored
212+ // null is a veto and has to stop the search rather than fall through it.
213+ const byTab = tab.id != null ? String(tab.id) : "";
214+ if (byTab && byTab in store.byTab) {
215+ const t = store.byTab[byTab];
216+ return t ? { target: t, source: "tab" } : null;
217+ }
218+ if (origin && origin in store.byOrigin) {
219+ const t = store.byOrigin[origin];
220+ return t ? { target: t, source: "origin" } : null;
221+ }
222+ if (!store.detect || !origin) return null;
223+ const auto = detectTarget(origin, sessions, devport);
224+ return auto ? { target: auto, source: "detect" } : null;
225+}
226+
227+/**
228+ * How specific a rule is, for picking between two tabs that both point here.
229+ * The same order the forward direction searches in, which is the point: the
230+ * tab a pin would choose going one way is the tab it chooses coming back.
231+ */
232+const SOURCE_RANK = { tab: 0, origin: 1, detect: 2 };
233+
234+/**
235+ * The browser tab to show now that the terminal is at `spot` — or null for
236+ * "leave the browser alone", which is the answer far more often than not.
237+ *
238+ * Deliberately expressed as the forward rule run over every tab rather than as
239+ * a second set of rules read out of the store backwards. A pin, a site pin, a
240+ * veto and a devport guess already compose into one answer per tab; asking each
241+ * tab "would you bring the terminal here?" and keeping the ones that say yes
242+ * means the two directions cannot disagree about what a pin means, and that a
243+ * veto keeps vetoing when read from this end.
244+ *
245+ * Two tabs can answer yes — two tabs on the pinned origin, most obviously — so
246+ * ties go to the more specific rule, and then to whichever tab is earlier in
247+ * `tabs`. The caller sorts by last use, so that is the one you were reading.
248+ *
249+ * The tab already showing wins outright, by stopping the search: if it points
250+ * here there is nothing to do, and doing nothing is what keeps this from
251+ * fighting the forward direction.
252+ *
253+ * @param {TbTabPinStore} store
254+ * @param {TbSpot} spot where the terminal is now
255+ * @param {{ id?: number, url?: string, active?: boolean }[]} tabs the panel's
256+ * own browser window, most recently used first
257+ * @param {TbSessionInfo[]} sessions
258+ * @param {typeof Devport} devport
259+ * @returns {{ tabId: number, source: TbPinSource } | null}
260+ */
261+function reverse(store, spot, tabs, sessions, devport) {
262+ if (!store.enabled || !store.reverse) return null;
263+ /** @type {{ tabId: number, source: TbPinSource } | null} */
264+ let best = null;
265+ for (const tab of tabs) {
266+ if (tab.id == null) continue;
267+ const hit = resolve(store, tab, sessions, devport);
268+ if (!hit) continue;
269+ // Against the *resolved* pin, not the pin as written, for the reason
270+ // `applyTabPin` compares that way too: a pin to a window that has closed is
271+ // a pin to its session, and has to compare as one from either end.
272+ const at = locate(hit.target, sessions);
273+ if (!at) continue;
274+ if (!sameSpot({ session: at.session, window: at.window?.id ?? null }, spot)) continue;
275+ if (tab.active) return null;
276+ if (!best || SOURCE_RANK[hit.source] < SOURCE_RANK[best.source]) {
277+ best = { tabId: tab.id, source: hit.source };
278+ }
279+ }
280+ return best;
281+}
282+
283+/**
284+ * The pin as the terminal can actually act on it: the window if it is still
285+ * there, the session's own active window if it is not, and null when the whole
286+ * session has gone.
287+ *
288+ * A pin outliving its window is the normal case, not the broken one — you pin
289+ * the session you run the dev server in, then close and reopen the window it
290+ * was running in. Falling back to the session keeps that pin useful instead of
291+ * quietly dead.
292+ *
293+ * @param {TbPinTarget} target
294+ * @param {TbSessionInfo[]} sessions
295+ * @returns {{ session: string, window: TbWindowInfo | null } | null}
296+ */
297+function locate(target, sessions) {
298+ const s = sessions.find((x) => x.name === target.session);
299+ if (!s) return null;
300+ const w = target.window ? s.windows.find((x) => x.id === target.window) : null;
301+ return { session: s.name, window: w ?? null };
302+}
303+
304+/**
305+ * Set or clear one entry, returning a new store — the caller writes it to
306+ * storage, so nothing here mutates what is already live.
307+ *
308+ * `target` null writes the veto; undefined removes the entry entirely and lets
309+ * the layer below (an origin pin, then detection) answer again.
310+ *
311+ * @param {TbTabPinStore} store
312+ * @param {"tab" | "origin"} kind
313+ * @param {string | number} key
314+ * @param {TbPinTarget | null | undefined} target
315+ * @returns {TbTabPinStore}
316+ */
317+function put(store, kind, key, target) {
318+ const next = {
319+ ...store,
320+ byTab: { ...store.byTab },
321+ byOrigin: { ...store.byOrigin },
322+ };
323+ const map = kind === "tab" ? next.byTab : next.byOrigin;
324+ if (target === undefined) delete map[String(key)];
325+ else map[String(key)] = target;
326+ return next;
327+}
328+
329+/**
330+ * Drop tab pins for tabs that no longer exist.
331+ *
332+ * Tab ids are per-run, so without this the map grows by one entry every time a
333+ * pinned tab is closed and never shrinks. Origin pins are left alone: they are
334+ * names, and a name whose session is not running today may be running tomorrow.
335+ *
336+ * @param {TbTabPinStore} store
337+ * @param {number[]} liveTabIds
338+ * @returns {TbTabPinStore | null} null when nothing needed dropping
339+ */
340+function pruneTabs(store, liveTabIds) {
341+ const live = new Set(liveTabIds.map(String));
342+ const stale = Object.keys(store.byTab).filter((id) => !live.has(id));
343+ if (!stale.length) return null;
344+ const next = { ...store, byTab: { ...store.byTab }, byOrigin: { ...store.byOrigin } };
345+ for (const id of stale) delete next.byTab[id];
346+ return next;
347+}
348+
349+/**
350+ * Whether two places in tmux are the same place.
351+ *
352+ * A target with no window names a whole session, so the comparison stops at the
353+ * session name — "the session, wherever it is currently pointed" is satisfied by
354+ * any window of it.
355+ *
356+ * @param {TbSpot | null} a
357+ * @param {TbSpot | null} b
358+ */
359+function sameSpot(a, b) {
360+ if (!a || !b) return false;
361+ if (a.session !== b.session) return false;
362+ if (!a.window || !b.window) return true;
363+ return a.window === b.window;
364+}
365+
366+/**
367+ * The whole state machine for "where should the terminal be, and what do we owe
368+ * it afterwards" — one step per browser tab change.
369+ *
370+ * A pin moving the terminal is an excursion, not a relocation. Leaving for a tab
371+ * with no pin of its own puts it back where it was before the first pinned tab
372+ * took it, so flicking between a pinned tab and an unpinned one flicks the
373+ * terminal between the two places rather than stranding it on the pinned one.
374+ *
375+ * Three things make that safe to do automatically:
376+ *
377+ * - Only the *first* pin in a run records a return. Pinned tab to pinned tab
378+ * to unpinned goes back to where the run started, not to the middle of it,
379+ * because the middle was never somewhere the user chose to be.
380+ * - Nothing is owed when the pin had nothing to do. Landing on a tab pinned
381+ * to where you already are records no return, so leaving it moves nothing.
382+ * - A terminal that has been moved by hand since is left alone. The return is
383+ * an undo of a move this code made, and once that move is gone there is
384+ * nothing to undo — dragging the user back from somewhere they steered to
385+ * themselves would be the panel overruling them.
386+ *
387+ * @param {TbPinReturn | null} owed what an earlier pin move left outstanding
388+ * @param {TbSpot | null} target where the showing tab points, null if nowhere
389+ * @param {TbSpot} at where the terminal is now
390+ * @returns {{ go: TbSpot | null, owed: TbPinReturn | null }} where to move it,
391+ * and what is outstanding afterwards
392+ */
393+function step(owed, target, at) {
394+ if (!target) {
395+ // Unpinned. Hand the terminal back if the excursion is still standing.
396+ if (owed && sameSpot(at, owed.to)) return { go: owed.from, owed: null };
397+ return { go: null, owed: null };
398+ }
399+ if (sameSpot(at, target)) {
400+ // Already there. An outstanding return survives — this tab is part of the
401+ // same excursion — but a new one is not opened, because nothing moved.
402+ return { go: null, owed: owed ? { ...owed, to: target } : null };
403+ }
404+ return { go: target, owed: owed ? { ...owed, to: target } : { from: at, to: target } };
405+}
406+
407+/**
408+ * How the pin reads in a menu or a tooltip.
409+ *
410+ * @param {TbPinTarget} target
411+ * @returns {string}
412+ */
413+function describe(target) {
414+ return target.window ? `${target.session} (one window)` : target.session;
415+}
416+
417+const Tabpin = {
418+ KEY: TABPIN_KEY,
419+ emptyStore,
420+ loadStore,
421+ originOf,
422+ localPort,
423+ baseName,
424+ detectTarget,
425+ resolve,
426+ reverse,
427+ locate,
428+ put,
429+ pruneTabs,
430+ sameSpot,
431+ step,
432+ describe,
433+};
434+
435+if (typeof module !== "undefined" && module.exports) {
436+ module.exports = Tabpin;
437+}
addedextension/lib/tabpin.test.js+327 −0
1+// Run with: node --test "extension/lib/*.test.js"
2+//
3+// What matters here is precedence. Four rules can answer "where does this tab
4+// send the terminal" — a tab pin, an origin pin, a veto and a devport guess —
5+// and getting their order wrong means the panel moves somewhere the user did
6+// not ask for, which is the one failure this feature cannot afford.
7+
8+const test = require("node:test");
9+const assert = require("node:assert");
10+
11+const Tabpin = require("./tabpin.js");
12+const Devport = require("./devport.js");
13+
14+/** @param {string} name */
15+const session = (name, path, windows = []) => ({
16+ id: `$${name}`,
17+ name,
18+ attached: false,
19+ path,
20+ windows: windows.map((id, i) => ({
21+ id,
22+ index: i + 1,
23+ name: id,
24+ active: i === 0,
25+ panes: 1,
26+ activity: false,
27+ })),
28+});
29+
30+const SESSIONS = [
31+ session("web", "/home/u/Code/browser-terminal-extension", ["@1", "@2"]),
32+ session("dots", "/home/u/Code/dotfiles", ["@7"]),
33+];
34+
35+/** The port devport hands the first session's directory. */
36+const WEB_PORT = Devport.portOf("browser-terminal-extension");
37+
38+const store = (over = {}) => ({ ...Tabpin.emptyStore(), ...over });
39+const resolve = (s, tab, sessions = SESSIONS) => Tabpin.resolve(s, tab, sessions, Devport);
40+
41+test("an unknown tab moves nothing", () => {
42+ assert.equal(resolve(store(), { id: 1, url: "https://example.com/" }), null);
43+});
44+
45+test("an origin pin matches any tab on that origin", () => {
46+ const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
47+ // Path and query are not part of the key.
48+ const hit = resolve(s, { id: 9, url: "https://example.com/deep/page?q=1" });
49+ assert.deepEqual(hit, { target: { session: "web", window: null }, source: "origin" });
50+ // A different port is a different origin, and so not a match.
51+ assert.equal(resolve(s, { id: 9, url: "https://example.com:8443/" }), null);
52+});
53+
54+test("a tab pin outranks the origin pin under it", () => {
55+ const s = store({
56+ byTab: { 5: { session: "dots", window: "@7" } },
57+ byOrigin: { "https://example.com": { session: "web", window: null } },
58+ });
59+ assert.equal(resolve(s, { id: 5, url: "https://example.com/" }).source, "tab");
60+ assert.equal(resolve(s, { id: 6, url: "https://example.com/" }).source, "origin");
61+});
62+
63+test("a null entry is a veto and stops the search", () => {
64+ const s = store({
65+ byTab: { 5: null },
66+ byOrigin: { [`http://localhost:${WEB_PORT}`]: { session: "web", window: null } },
67+ });
68+ // The origin pin below it would have matched; the veto is why it does not.
69+ assert.equal(resolve(s, { id: 5, url: `http://localhost:${WEB_PORT}/` }), null);
70+ // And an origin veto is what turns a devport guess off for good.
71+ const vetoed = store({ byOrigin: { [`http://localhost:${WEB_PORT}`]: null } });
72+ assert.equal(resolve(vetoed, { id: 5, url: `http://localhost:${WEB_PORT}/` }), null);
73+});
74+
75+test("a localhost devport port finds its session with nothing stored", () => {
76+ const hit = resolve(store(), { id: 1, url: `http://localhost:${WEB_PORT}/app` });
77+ assert.deepEqual(hit, { target: { session: "web", window: null }, source: "detect" });
78+ // Every port in the block, not just the base one.
79+ assert.equal(resolve(store(), { id: 1, url: `http://127.0.0.1:${WEB_PORT + 3}/` }).source, "detect");
80+});
81+
82+test("detection is local, in-range and unambiguous or it does not fire", () => {
83+ const off = store({ detect: false });
84+ assert.equal(resolve(off, { id: 1, url: `http://localhost:${WEB_PORT}/` }), null);
85+ // A remote host on the same port says nothing about your projects.
86+ assert.equal(resolve(store(), { id: 1, url: `http://example.com:${WEB_PORT}/` }), null);
87+ // A hand-picked port is in nobody's block.
88+ assert.equal(resolve(store(), { id: 1, url: "http://localhost:3000/" }), null);
89+ // Two sessions in the same block is a collision, and a coin toss is worse
90+ // than doing nothing.
91+ const twins = [...SESSIONS, session("web2", "/elsewhere/browser-terminal-extension", ["@9"])];
92+ assert.equal(resolve(store(), { id: 1, url: `http://localhost:${WEB_PORT}/` }, twins), null);
93+});
94+
95+test("the session name is a candidate too, not just its directory", () => {
96+ const named = [session("myapp", "/home/u/somewhere/else", ["@1"])];
97+ const port = Devport.portOf("myapp");
98+ assert.equal(resolve(store(), { id: 1, url: `http://localhost:${port}/` }, named).source, "detect");
99+});
100+
101+test("the whole feature can be switched off without losing pins", () => {
102+ const s = store({
103+ enabled: false,
104+ byOrigin: { "https://example.com": { session: "web", window: null } },
105+ });
106+ assert.equal(resolve(s, { id: 1, url: "https://example.com/" }), null);
107+ assert.equal(resolve({ ...s, enabled: true }, { id: 1, url: "https://example.com/" }).source, "origin");
108+});
109+
110+test("only http and https can carry an origin pin", () => {
111+ for (const url of ["file:///tmp/x.html", "about:blank", "chrome://extensions", undefined]) {
112+ assert.equal(Tabpin.originOf(url), "");
113+ }
114+ assert.equal(Tabpin.originOf("http://localhost:26210/x"), "http://localhost:26210");
115+});
116+
117+test("locate falls back to the session when the window has closed", () => {
118+ assert.deepEqual(Tabpin.locate({ session: "web", window: "@2" }, SESSIONS).window.id, "@2");
119+ // Pinned to a window that has since been killed: still the right session.
120+ const gone = Tabpin.locate({ session: "web", window: "@99" }, SESSIONS);
121+ assert.deepEqual({ session: gone.session, window: gone.window }, { session: "web", window: null });
122+ // Session gone entirely: nothing to act on.
123+ assert.equal(Tabpin.locate({ session: "ghost", window: null }, SESSIONS), null);
124+});
125+
126+test("loadStore drops anything that is not a pin", () => {
127+ const s = Tabpin.loadStore({
128+ enabled: false,
129+ byTab: { 1: { session: "web", window: "@1" }, 2: { window: "@2" }, 3: "web", 4: null },
130+ byOrigin: "not a map",
131+ });
132+ assert.equal(s.enabled, false);
133+ // detect was absent, and absent is not off.
134+ assert.equal(s.detect, true);
135+ assert.deepEqual(s.byTab, { 1: { session: "web", window: "@1" }, 4: null });
136+ assert.deepEqual(s.byOrigin, {});
137+ // Junk in, empty out — never a throw, because this is storage a past version
138+ // of the panel wrote.
139+ assert.deepEqual(Tabpin.loadStore(null), Tabpin.emptyStore());
140+ assert.deepEqual(Tabpin.loadStore("x"), Tabpin.emptyStore());
141+});
142+
143+test("put does not mutate the store it was given", () => {
144+ const before = store();
145+ const after = Tabpin.put(before, "origin", "https://example.com", { session: "web" });
146+ assert.deepEqual(before.byOrigin, {});
147+ assert.deepEqual(after.byOrigin, { "https://example.com": { session: "web" } });
148+ assert.deepEqual(Tabpin.put(after, "origin", "https://example.com", undefined).byOrigin, {});
149+ assert.deepEqual(Tabpin.put(after, "origin", "https://example.com", null).byOrigin, {
150+ "https://example.com": null,
151+ });
152+});
153+
154+test("pruneTabs drops closed tabs and leaves origins alone", () => {
155+ const s = store({
156+ byTab: { 1: { session: "web" }, 2: { session: "dots" } },
157+ byOrigin: { "https://example.com": { session: "web" } },
158+ });
159+ assert.equal(Tabpin.pruneTabs(s, [1, 2]), null);
160+ const pruned = Tabpin.pruneTabs(s, [2]);
161+ assert.deepEqual(Object.keys(pruned.byTab), ["2"]);
162+ assert.deepEqual(pruned.byOrigin, s.byOrigin);
163+});
164+
165+// --- the excursion ----------------------------------------------------------
166+//
167+// A pin borrows the terminal; leaving the pinned tab gives it back. These are
168+// the rules that decide when it is owed and when the debt has lapsed.
169+
170+const AT = { session: "home", window: "@1" };
171+const PIN = { session: "web", window: "@2" };
172+
173+test("a pin borrows the terminal and an unpinned tab hands it back", () => {
174+ const first = Tabpin.step(null, PIN, AT);
175+ assert.deepEqual(first.go, PIN);
176+ assert.deepEqual(first.owed, { from: AT, to: PIN });
177+
178+ // Now sitting where the pin put us, and the browser moves to a plain tab.
179+ const back = Tabpin.step(first.owed, null, PIN);
180+ assert.deepEqual(back.go, AT);
181+ assert.equal(back.owed, null);
182+});
183+
184+test("flicking back and forth flicks the terminal with it", () => {
185+ let owed = null;
186+ let at = AT;
187+ for (let i = 0; i < 3; i++) {
188+ ({ go: at, owed } = trip(owed, PIN, at));
189+ assert.deepEqual(at, PIN, `to the pinned tab, round ${i}`);
190+ ({ go: at, owed } = trip(owed, null, at));
191+ assert.deepEqual(at, AT, `and back, round ${i}`);
192+ }
193+ // `go` null means "stay", so the walk needs the previous place carried over.
194+ function trip(o, target, from) {
195+ const r = Tabpin.step(o, target, from);
196+ return { go: r.go ?? from, owed: r.owed };
197+ }
198+});
199+
200+test("a run of pinned tabs returns to where the run started", () => {
201+ const one = Tabpin.step(null, PIN, AT);
202+ const other = { session: "dots", window: "@7" };
203+ const two = Tabpin.step(one.owed, other, PIN);
204+ assert.deepEqual(two.go, other);
205+ // The middle of the excursion is not somewhere the user chose to be.
206+ assert.deepEqual(two.owed, { from: AT, to: other });
207+ assert.deepEqual(Tabpin.step(two.owed, null, other).go, AT);
208+});
209+
210+test("a pin that had nothing to do owes nothing", () => {
211+ // Landing on a tab pinned to where you already are.
212+ const r = Tabpin.step(null, PIN, PIN);
213+ assert.equal(r.go, null);
214+ assert.equal(r.owed, null);
215+ // So leaving it moves nothing either.
216+ assert.deepEqual(Tabpin.step(r.owed, null, PIN), { go: null, owed: null });
217+});
218+
219+test("a switch made by hand cancels the return rather than being undone", () => {
220+ const { owed } = Tabpin.step(null, PIN, AT);
221+ // The user steered somewhere else while sitting on the pinned tab.
222+ const steered = { session: "web", window: "@9" };
223+ const r = Tabpin.step(owed, null, steered);
224+ assert.equal(r.go, null, "the panel does not overrule a by-hand switch");
225+ assert.equal(r.owed, null, "and the debt lapses rather than lingering");
226+});
227+
228+test("a session-level pin is satisfied by any window of that session", () => {
229+ const session = { session: "web", window: null };
230+ // Already in the session, on some window of it: nothing to do.
231+ assert.deepEqual(Tabpin.step(null, session, { session: "web", window: "@5" }), {
232+ go: null,
233+ owed: null,
234+ });
235+ // And the return still fires after moving around inside it.
236+ const { owed } = Tabpin.step(null, session, AT);
237+ assert.deepEqual(Tabpin.step(owed, null, { session: "web", window: "@5" }).go, AT);
238+});
239+
240+test("sameSpot compares by session, and by window only when both name one", () => {
241+ assert.ok(Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: "@1" }));
242+ assert.ok(Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: null }));
243+ assert.ok(!Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: "@2" }));
244+ assert.ok(!Tabpin.sameSpot({ session: "a" }, { session: "b" }));
245+ assert.ok(!Tabpin.sameSpot(null, { session: "a" }));
246+});
247+
248+test("baseName reads the directory devport would have hashed", () => {
249+ assert.equal(Tabpin.baseName("/home/u/Code/thing"), "thing");
250+ assert.equal(Tabpin.baseName("/home/u/Code/thing/"), "thing");
251+ assert.equal(Tabpin.baseName(null), "");
252+});
253+
254+// --- the other direction ----------------------------------------------------
255+//
256+// `reverse` is the same rules read from the far end, so what these check is
257+// mostly that it agrees with `resolve`: the tab a pin would have chosen going
258+// one way is the tab it chooses coming back, vetoes still veto, and the more
259+// specific rule still wins. Plus the one rule that is only its own — a browser
260+// already showing a tab that points here is left alone, which is what keeps the
261+// two directions from chasing each other.
262+
263+const rev = (s, spot, tabs, sessions = SESSIONS) => Tabpin.reverse(s, spot, tabs, sessions, Devport);
264+
265+test("reverse finds the tab an origin pin names", () => {
266+ const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
267+ const tabs = [
268+ { id: 1, url: "https://other.test/", active: true },
269+ { id: 2, url: "https://example.com/deep" },
270+ ];
271+ assert.deepEqual(rev(s, { session: "web", window: "@1" }, tabs), { tabId: 2, source: "origin" });
272+ // Nothing points at the other session, so nothing moves.
273+ assert.equal(rev(s, { session: "dots", window: "@7" }, tabs), null);
274+});
275+
276+test("reverse does nothing when the showing tab already points here", () => {
277+ const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
278+ const tabs = [
279+ { id: 2, url: "https://example.com/", active: true },
280+ { id: 3, url: "https://example.com/other" },
281+ ];
282+ // Two tabs match and one of them is showing: that one wins outright, so the
283+ // browser is left where it is rather than being dragged to the other.
284+ assert.equal(rev(s, { session: "web", window: "@1" }, tabs), null);
285+});
286+
287+test("reverse prefers the more specific rule, then the more recent tab", () => {
288+ const s = store({
289+ byTab: { 9: { session: "web", window: null } },
290+ byOrigin: { "https://example.com": { session: "web", window: null } },
291+ });
292+ const tabs = [
293+ { id: 4, url: "https://example.com/" },
294+ { id: 9, url: "https://elsewhere.test/" },
295+ ];
296+ // The tab pin outranks the origin pin even though its tab is later in the
297+ // list, exactly as it does going forward.
298+ assert.deepEqual(rev(s, { session: "web" }, tabs), { tabId: 9, source: "tab" });
299+ // Between two tabs answering by the same rule, the caller's order decides —
300+ // it sorts by last use, so this is the one you were reading.
301+ const origins = store({ byOrigin: { "https://example.com": { session: "web", window: null } } });
302+ assert.equal(rev(origins, { session: "web" }, tabs).tabId, 4);
303+});
304+
305+test("reverse honours a veto and the off switches", () => {
306+ const tabs = [{ id: 5, url: `http://localhost:${WEB_PORT}/` }];
307+ const spot = { session: "web", window: "@1" };
308+ // The devport guess would have matched this tab.
309+ assert.equal(rev(store(), spot, tabs).source, "detect");
310+ // An origin veto turns it off from this end too.
311+ assert.equal(rev(store({ byOrigin: { [`http://localhost:${WEB_PORT}`]: null } }), spot, tabs), null);
312+ // As does either switch.
313+ assert.equal(rev(store({ reverse: false }), spot, tabs), null);
314+ assert.equal(rev(store({ enabled: false }), spot, tabs), null);
315+ assert.equal(rev(store({ detect: false }), spot, tabs), null);
316+});
317+
318+test("reverse follows a window pin whose window has closed to its session", () => {
319+ const s = store({ byOrigin: { "https://example.com": { session: "web", window: "@404" } } });
320+ const tabs = [{ id: 2, url: "https://example.com/" }];
321+ // The pin names a window that is gone, so it stands for its session — the
322+ // same fallback `locate` makes going forward — and any window of it matches.
323+ assert.deepEqual(rev(s, { session: "web", window: "@2" }, tabs), { tabId: 2, source: "origin" });
324+ // A pin to a session that is not running at all still resolves to nothing.
325+ const dead = store({ byOrigin: { "https://example.com": { session: "gone", window: null } } });
326+ assert.equal(rev(dead, { session: "gone", window: null }, tabs), null);
327+});
modifiedextension/mock.html+10 −2
⋯ 17 unchanged lines
1818 <body>
1919 <script src="lib/theme.js"></script>
2020 <script>
21+ // ?light renders the light palette; the default is dark.
2122 const dark = matchMedia("(prefers-color-scheme: dark)").matches;
22- const t = Themes.resolveTheme("dark", dark);
23+ const t = Themes.resolveTheme(
24+ location.search.includes("light") ? "light" : "dark", dark);
2325 for (const [k, v] of Object.entries(t.ui)) document.documentElement.style.setProperty(k, v);
2426
2527 // ?full fills the row; the default is one of each, so both layouts can be
⋯ 160 unchanged lines
186188 const toggle = document.createElement("button");
187189 toggle.id = "mode";
188190 toggle.textContent = "switch to tab groups";
189- let mode = "nested";
191+ // ?groups starts in the other layout, so a screenshot can be taken of
192+ // it without a click.
193+ let mode = location.search.includes("groups") ? "groups" : "nested";
194+ if (mode === "groups") {
195+ toggle.textContent = "switch to nested tabs";
196+ setMode(mode);
197+ }
190198 toggle.addEventListener("click", () => {
191199 mode = mode === "nested" ? "groups" : "nested";
192200 toggle.textContent = `switch to ${mode === "nested" ? "tab groups" : "nested tabs"}`;
⋯ 7 unchanged lines
addedextension/page.js+198 −0
1+// Reading the current page as text, so it can be handed to an agent in the pty.
2+//
3+// Same contract as picker.js: this function is serialised and executed in the
4+// page's world via scripting.executeScript, so it must be entirely
5+// self-contained — no imports, no closures over extension state, no extension
6+// privileges. What it returns comes back as executeScript's *return value*,
7+// which is why the sidebar needs no inbound message listener a content script
8+// could reach.
9+//
10+// Everything it returns is page-controlled data. It is not text until
11+// Sanitize.forPasteBlock has been over it — see sidebar.js.
12+
13+/**
14+ * @param {number} max hard cap on how many characters of body text to return
15+ */
16+function tbReadPage(max) {
17+ // Elements that are never prose. SVG and CANVAS carry no readable text;
18+ // SCRIPT and STYLE carry text that is not prose; the rest are chrome.
19+ const SKIP = new Set([
20+ "SCRIPT",
21+ "STYLE",
22+ "NOSCRIPT",
23+ "TEMPLATE",
24+ "IFRAME",
25+ "SVG",
26+ "CANVAS",
27+ "AUDIO",
28+ "VIDEO",
29+ "SELECT",
30+ "OPTION",
31+ "TEXTAREA",
32+ ]);
33+
34+ // Page furniture: dropped only when we are reading the whole document, since
35+ // then they are the site's navigation rather than the article. Inside a
36+ // <main> or <article> they are the author's own structure and stay.
37+ const FURNITURE = new Set(["NAV", "HEADER", "FOOTER", "ASIDE"]);
38+
39+ // Anything that ends a line of prose. Used two ways: to know which elements
40+ // produce a block, and — as a selector — to know whether an element is a
41+ // container to recurse into or a leaf to read.
42+ const BLOCK =
43+ "p,h1,h2,h3,h4,h5,h6,li,pre,blockquote,figcaption,dt,dd,td,th,tr,table,ul,ol,article,section,main,div";
44+
45+ /** @param {string} s */
46+ const squash = (s) => s.replace(/\s+/g, " ").trim();
47+
48+ /**
49+ * Rendered, in the sense that matters here: it has boxes. Catches
50+ * `display: none`, `hidden`, and the zero-size wrappers a framework leaves
51+ * behind, without a getComputedStyle call per element.
52+ *
53+ * @param {Element} el
54+ */
55+ function rendered(el) {
56+ if (el.getClientRects().length) return true;
57+ // A <tr> in a table with `display: block` cells, and other layout oddities,
58+ // can report no rects while its contents are plainly visible. Fall back to
59+ // the cheap style read rather than dropping real text.
60+ const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
61+ return !cs || (cs.display !== "none" && cs.visibility !== "hidden");
62+ }
63+
64+ /** @type {string[]} */
65+ const lines = [];
66+ let chars = 0;
67+ let full = true;
68+
69+ /** @param {string} line */
70+ function emit(line) {
71+ const text = line.trimEnd();
72+ if (!text) return;
73+ // Site furniture repeats itself — a heading that is also the link that got
74+ // you here, a label rendered twice at two breakpoints. Consecutive
75+ // duplicates are always noise.
76+ if (lines[lines.length - 1] === text) return;
77+ if (chars + text.length > max) {
78+ full = false;
79+ return;
80+ }
81+ lines.push(text);
82+ chars += text.length + 1;
83+ }
84+
85+ /**
86+ * Text of an element, minus anything that would be read as its own block.
87+ * A list item's own words without its nested list, a cell's without a table
88+ * inside it.
89+ *
90+ * @param {Element} el
91+ */
92+ function ownText(el) {
93+ let out = "";
94+ for (const node of el.childNodes) {
95+ if (node.nodeType === 3) out += node.nodeValue ?? "";
96+ else if (node.nodeType === 1) {
97+ const child = /** @type {Element} */ (node);
98+ if (SKIP.has(child.tagName)) continue;
99+ if (child.matches("ul,ol,table,pre,p,li,h1,h2,h3,h4,h5,h6,blockquote")) continue;
100+ out += ownText(child);
101+ }
102+ }
103+ return squash(out);
104+ }
105+
106+ /**
107+ * @param {Element} el
108+ * @param {boolean} trimFurniture whether NAV/HEADER/FOOTER/ASIDE are chrome
109+ */
110+ function walk(el, trimFurniture) {
111+ if (!full) return;
112+ const tag = el.tagName;
113+ if (SKIP.has(tag)) return;
114+ if (trimFurniture && FURNITURE.has(tag)) return;
115+ if (el.getAttribute("aria-hidden") === "true") return;
116+ if (!rendered(el)) return;
117+
118+ if (/^H[1-6]$/.test(tag)) {
119+ emit(`${"#".repeat(Number(tag[1]))} ${squash(el.textContent ?? "")}`);
120+ return;
121+ }
122+ if (tag === "PRE") {
123+ const code = (el.textContent ?? "").replace(/\s+$/, "");
124+ if (code) emit(`\`\`\`\n${code}\n\`\`\``);
125+ return;
126+ }
127+ if (tag === "LI") {
128+ emit(`- ${ownText(el)}`);
129+ // The nested list is its own set of items, so it is walked rather than
130+ // folded into the line above.
131+ for (const child of el.children) {
132+ if (child.matches("ul,ol,table,pre")) walk(child, trimFurniture);
133+ }
134+ return;
135+ }
136+ if (tag === "TR") {
137+ /** @type {string[]} */
138+ const cells = [];
139+ for (const cell of el.children) cells.push(ownText(cell));
140+ if (cells.some(Boolean)) emit(cells.join(" | "));
141+ return;
142+ }
143+ if (tag === "P" || tag === "BLOCKQUOTE" || tag === "FIGCAPTION" || tag === "DT" || tag === "DD") {
144+ emit(ownText(el));
145+ return;
146+ }
147+
148+ // Not a block of its own: either a container of blocks, which is walked, or
149+ // a leaf that happens to hold the text — a <div> or <span> with words in it
150+ // and no <p> in sight, which is most of the modern web.
151+ if (el.querySelector(BLOCK)) {
152+ for (const child of el.children) walk(child, trimFurniture);
153+ // Text sitting directly inside a container, alongside its blocks, would
154+ // otherwise be dropped.
155+ const own = ownText(el);
156+ if (own) emit(own);
157+ return;
158+ }
159+ emit(squash(el.textContent ?? ""));
160+ }
161+
162+ /** @param {string} sel */
163+ const meta = (sel) => squash(document.querySelector(sel)?.getAttribute("content") ?? "");
164+
165+ const selection = squash(window.getSelection()?.toString() ?? "");
166+
167+ if (selection) {
168+ // A selection is the user saying which part they meant, so it is taken
169+ // verbatim — no extraction, no furniture trimming, no reordering.
170+ const truncated = selection.length > max;
171+ return {
172+ url: location.href,
173+ title: document.title || "",
174+ description: meta('meta[name="description"]') || meta('meta[property="og:description"]'),
175+ kind: /** @type {"selection"} */ ("selection"),
176+ body: truncated ? selection.slice(0, max) : selection,
177+ chars: selection.length,
178+ truncated,
179+ };
180+ }
181+
182+ // The narrowest thing that plausibly holds the article. Falling back to
183+ // <body> is the common case; the trim of nav/header/footer only applies then,
184+ // because inside a <main> those tags are the author's own.
185+ const main = document.querySelector("main, article, [role='main']");
186+ const root = main ?? document.body ?? document.documentElement;
187+ walk(root, !main);
188+
189+ return {
190+ url: location.href,
191+ title: document.title || "",
192+ description: meta('meta[name="description"]') || meta('meta[property="og:description"]'),
193+ kind: /** @type {"page"} */ ("page"),
194+ body: lines.join("\n"),
195+ chars,
196+ truncated: !full,
197+ };
198+}
modifiedextension/picker.js+39 −3
⋯ 43 unchanged lines
4444 boxShadow: "0 2px 8px rgba(0,0,0,.4)",
4545 });
4646
47- document.documentElement.append(box, label);
47+ // Escape only reaches a keydown listener here if this document holds the
48+ // keyboard focus. A page whose focus sits in a text field is fine, but one
49+ // that has handed focus to an iframe is not: key events go to the frame and
50+ // never surface in the top document. So the picker parks focus on a sink of
51+ // its own for as long as it is up, and puts it back on the way out.
52+ const sink = document.createElement("div");
53+ Object.assign(sink.style, {
54+ position: "fixed",
55+ top: "0",
56+ left: "0",
57+ width: "0",
58+ height: "0",
59+ opacity: "0",
60+ outline: "none",
61+ pointerEvents: "none",
62+ });
63+ sink.tabIndex = -1;
64+
65+ document.documentElement.append(box, label, sink);
66+
67+ const wasFocused = /** @type {Element | null} */ (document.activeElement);
68+ const grabFocus = () => {
69+ if (document.activeElement !== sink) sink.focus({ preventScroll: true });
70+ };
71+ grabFocus();
4872
4973 /** Element under the cursor, so a click resolves what the box is drawn on. */
5074 let current = /** @type {Element | null} */ (null);
⋯ 2 unchanged lines
5377 window[PREV] = null;
5478 box.remove();
5579 label.remove();
80+ sink.remove();
81+ // Removing the sink leaves the document itself focused; hand focus back
82+ // to whatever had it, so a pick doesn't cost the page its caret.
83+ if (wasFocused && wasFocused.isConnected && wasFocused instanceof HTMLElement) {
84+ wasFocused.focus({ preventScroll: true });
85+ }
5686 document.removeEventListener("mousemove", onMove, true);
5787 document.removeEventListener("click", onClick, true);
58- document.removeEventListener("keydown", onKey, true);
88+ window.removeEventListener("keydown", onKey, true);
5989 window.removeEventListener("scroll", onScroll, true);
6090 };
6191 window[PREV] = () => {
⋯ 18 unchanged lines
80110
81111 /** @param {MouseEvent} e */
82112 function onMove(e) {
113+ // A click inside a cross-origin iframe never reaches this document, but
114+ // it does move the focus there. Moving the mouse is the first thing that
115+ // tells us we are still up, so take the focus back then.
116+ grabFocus();
83117 const hit = /** @type {Element | null} */ (e.target);
84118 const el =
85119 hit && hit !== box && hit !== label
⋯ 34 unchanged lines
120154
121155 document.addEventListener("mousemove", onMove, true);
122156 document.addEventListener("click", onClick, true);
123- document.addEventListener("keydown", onKey, true);
157+ // On `window` rather than `document`: capture starts at the window, so this
158+ // runs before any handler the page installed, even a capturing one.
159+ window.addEventListener("keydown", onKey, true);
124160 window.addEventListener("scroll", onScroll, true);
125161
126162 // --- identifier construction ---------------------------------------------
⋯ 132 unchanged lines
modifiedextension/sidebar.css+154 −39
⋯ 60 unchanged lines
6161 corner of its own rather than inside any one of them.
6262
6363 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. */
64+ paints its own surface over it, edge to edge — including under the corner,
65+ which is laid over the end of the rows and paints nothing of its own. Beside
66+ each row it is that row: no fourth colour, and no seam. */
6767 header {
6868 display: flex; align-items: stretch;
6969 background: var(--bg); flex: none;
70+ /* The corner is positioned against this, not laid out beside the rows —
71+ see #corner. */
72+ position: relative;
73+ --corner-w: 56px;
7074 }
7175 #rows {
7276 display: flex; flex-direction: column;
⋯ 2 unchanged lines
7579 /* A column reserved across every row, holding the one control that types into
7680 the pane rather than steering the tabs: hold-to-talk, and nothing else — the
7781 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.
82+ Unpainted, and over the rows rather than beside them, so each row's own
83+ surface continues under it and the corner is that row's colour at every
84+ height — the rows are tmux and their surfaces are the nesting; a fourth
85+ colour beside them was one surface too many, and it was the one thing in the
86+ header with a hard vertical seam down it.
8387
8488 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.
89+ say in it — while its height is whatever the rows come to, since it is
90+ stretched between the header's top and bottom edges and the header is sized
91+ by the rows alone. Which is why the mic below is sized *from* that height
92+ rather than fixed: the header has one row or two depending on the mode, and
93+ the corner is as tall as either. Nothing here can push the header taller, so
94+ the mic's floor has to stay under the shortest the rows ever get.
9095
9196 `gap` and the bottom padding are the mic's own air, not a gutter between
9297 stacked controls: it is centred in the corner and the padding keeps its ring
9398 off the terminal's edge while it is held. */
9499 #corner {
95- flex: none; width: 56px;
100+ flex: none; width: var(--corner-w);
96101 display: flex; flex-direction: column;
97102 align-items: center; justify-content: center; gap: 3px;
98103 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- );
104+ /* Nothing painted here at all, and out of the flow: the corner is laid over
105+ the right end of the rows, which run the full width behind it and reserve
106+ its column in their own right padding. Padding is inside a background box,
107+ so each row's surface carries on under the corner and stops exactly where
108+ that row does.
109+
110+ That is the only way this corner meets both rows with the same edge. The
111+ rows are not one surface and their exact heights are not knowable in CSS,
112+ so a fill of its own — flat or graded — is a guess at where the boundary
113+ is, and a guess wrong by a few pixels is a step down the corner's left edge
114+ (in light, where the levels are 9 units apart rather than dark's 2, an
115+ obvious one). Letting the rows paint it means there is no boundary to
116+ guess: the top half is the top row because it *is* the top row, in either
117+ layout and at any density. */
118+ position: absolute; top: 0; right: 0; bottom: 0;
119+ background: none;
110120 }
111121 /* Stretch, not centre: the active window tab has to reach the bottom edge to
112122 sit on the seam. Everything else in the row re-centres itself. */
⋯ 3 unchanged lines
116126 so a gap on top of that reads as a hole between the controls and the tabs.
117127 The buttons sit against the tab strip the way a browser's do. */
118128 gap: 0;
119- padding: 4px 6px 0 4px;
129+ /* The right padding is the row's own 6px plus the corner's column: the rows
130+ run under the corner so their surfaces reach the panel's edge, and nothing
131+ in them may lay out there. */
132+ padding: 4px calc(var(--corner-w) + 6px) 0 4px;
120133 }
121134 /* The controls that aren't buttons still need their own air. */
122135 .strip > #status-text { margin-left: 6px; }
⋯ 483 unchanged lines
606619 .tab-slot.elsewhere > button.tab { color: var(--fg); }
607620 .tab-slot.elsewhere > button.tab .name { font-weight: 600; }
608621
622+/* The row the browser tab on screen is pinned to.
623+
624+ A dot rather than a border or a tint: the tab silhouette is built out of
625+ masks and backgrounds (see the run above), and both of the obvious markings
626+ would have to be drawn through that.
627+
628+ ::after, and absolutely positioned, for two separate reasons. ::before on
629+ these three is already the tab separator, and a flex item here would push the
630+ name along and collide with the ✕ that overlays the trailing edge. Riding in
631+ the leading padding instead costs no layout: every one of the three keeps at
632+ least 6px there (7px on a pinned tab and on a chip, before its caret), and
633+ the dot is 4px wide starting 1px in. Vertically centred, which is also the
634+ one height at which a chip's 9px pill edge is straight.
635+
636+ Small and in the accent, because this is a "why did it move" answer read once
637+ and then ignored — the tooltip carries the sentence. */
638+.tab-slot.tab-linked > button.tab::after,
639+button.session-tab.tab-linked::after,
640+button.group-chip.tab-linked::after {
641+ content: "";
642+ position: absolute; top: 50%; translate: 0 -50%;
643+ left: 1px;
644+ width: 4px; height: 4px;
645+ border-radius: 50%;
646+ background: var(--accent-hover);
647+ pointer-events: none;
648+}
649+
609650 /* --- the omnibar -----------------------------------------------------------
610651 Groups mode's second row: a browser's address bar, under the tabs, in the
611652 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. */
653+ named. The picker leads it and jump follows the box, the way a browser puts
654+ what acts on the page before the bar and what takes you onward after it. */
615655 #omni-strip {
616656 background: var(--bg);
617- padding: 3px 6px 4px 4px;
657+ /* Less on the right than the left now that a button ends the row rather than
658+ the pill: an icon's box is already wider than its glyph, so the same 6px
659+ past it reads as a bigger gap than the pill's edge did. */
660+ padding: 3px calc(var(--corner-w) + 2px) 4px 4px;
618661 align-items: center;
619662 gap: 2px;
620663 }
⋯ 1 unchanged line
622665 border and the focus ring have to go around both. */
623666 #omni-box {
624667 flex: 1 1 auto; min-width: 0;
625- display: flex; align-items: center; gap: 6px;
668+ /* Aligned to the top rather than centred, because the box is only sometimes
669+ one line tall: once the text wraps, the info button and the directory stay
670+ on the first line — beside the start of what you typed — instead of
671+ drifting to the middle of a paragraph. At one line the two are the same
672+ thing, since every child is a line-box tall. */
673+ display: flex; align-items: flex-start; gap: 6px;
626674 /* Nothing inside may paint past the pill: at the narrowest the directory is
627675 capped rather than shrunk, so it can want a few pixels more than are left. */
628676 overflow: hidden;
629- height: 24px;
630- padding: 0 9px;
677+ /* Height comes from the text now: one line is the old 24px (18px of line box
678+ plus this padding) and every wrapped line adds 18 more, up to the cap on
679+ #omni itself. */
680+ min-height: 24px;
681+ padding: 3px 9px;
631682 border: 1px solid var(--border); border-radius: 12px;
632683 background: var(--bg-input);
633684 }
⋯ 8 unchanged lines
642693 beside it shrinks first, because a session you cannot read the name of is
643694 worse than a path missing its front. */
644695 flex: 1 1 auto; min-width: 4rem;
645- height: 100%;
646- font: inherit; font-size: 11px;
696+ font: inherit; font-size: 11px; line-height: 18px;
647697 padding: 0; border: 0; background: none; color: var(--fg);
698+ /* A textarea's own affordances, all of them wrong here: it is a control in a
699+ chrome strip, not a form field, so it neither drags to resize nor keeps a
700+ scrollbar gutter it is not using. */
701+ resize: none;
702+ /* One line to start with; JS sets the height to the content's on every
703+ change, and this caps how far that can go — past about six lines the box
704+ is eating the terminal, so it scrolls inside itself instead. */
705+ height: 18px; max-height: 108px;
706+ overflow-y: auto;
707+ /* Wrap long words rather than let one run of characters — a path, a URL —
708+ decide the panel's minimum width. */
709+ overflow-wrap: anywhere;
648710 }
649711 #omni:focus, #omni:focus-visible { outline: none; }
650712 #omni::placeholder { color: var(--fg-muted); }
⋯ 9 unchanged lines
660722 an ellipsis. Fixed at its content width, the input absorbs the slack
661723 instead, and the only thing that can cut the path is the cap. */
662724 flex: 0 0 auto; min-width: 0; max-width: 72%;
663- font-size: 10px;
725+ /* The same line box as the text it sits beside, so it rides the first line
726+ of a wrapped query rather than a half-step off it. */
727+ font-size: 10px; line-height: 18px;
664728 color: var(--fg-muted);
665729 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
666730 direction: rtl; text-align: right;
⋯ 88 unchanged lines
755819 }
756820 .info-pop .info-copy:hover { color: var(--fg); border-color: var(--accent); }
757821
758-body.compact #omni-strip { padding: 2px 4px 3px 4px; }
822+body.compact #omni-strip { padding: 2px calc(var(--corner-w) + 4px) 3px 4px; }
759823 body.compact #omni-box { height: 21px; }
760824
761825 /* Over the terminal, not in the header: the header is a flex column that sizes
⋯ 325 unchanged lines
10871151 body.has-session > header #status-text { display: none; }
10881152
10891153 /* Compact: lose the status text, keep the targets clickable. */
1090-body.compact .strip { padding: 2px 4px 0; }
1154+body.compact .strip { padding: 2px calc(var(--corner-w) + 4px) 0 4px; }
10911155 body.compact #status-text { display: none; }
10921156 body.compact button.icon { width: 24px; height: 24px; }
10931157 /* The mic is a touch target rather than a glyph, so density trims it a little
10941158 and then stops: below the platforms' 44px floor it stops being one. */
1095-body.compact #corner { width: 50px; padding: 3px 3px 4px; }
1159+/* On the header, not the corner: it is the rows that reserve this column, and
1160+ they read it from there. */
1161+body.compact header { --corner-w: 50px; }
1162+body.compact #corner { padding: 3px 3px 4px; }
10961163 body.compact #talk.big { min-height: 38px; max-height: 44px; }
10971164 body.compact #talk.big svg { width: 19px; height: 19px; }
10981165 body.compact .tab-slot { max-width: calc(8rem + 2 * var(--tab-foot)); }
⋯ 190 unchanged lines
12891356 #font-reset { padding: 4px 8px; font-size: 11px; }
12901357 #theme-select, #density-select, #tabmode-select { width: auto; background: var(--bg-input); color: var(--fg);
12911358 border: 1px solid var(--border); border-radius: 4px; padding: 5px 7px; font-size: 11px; }
1359+
1360+/* --- offline overlay --------------------------------------------------------
1361+ The connection's state, said in the middle of the panel rather than in the
1362+ corner of the header. Fixed and full-bleed so the card lands in the optical
1363+ centre at any density, but the layer is click-through: the header underneath
1364+ it is still how you switch sessions, and a disconnect must not take that
1365+ away. z-index sits under the omnibar list (5) and the settings card (30), so
1366+ either of those covers it rather than fighting it. */
1367+#offline {
1368+ position: fixed; inset: 0; z-index: 4;
1369+ display: flex; align-items: center; justify-content: center;
1370+ padding: 12px;
1371+ pointer-events: none;
1372+}
1373+/* The one thing on the layer that takes the pointer. Opaque rather than a scrim
1374+ over the whole panel: dimming everything would dim the header too, and the
1375+ header is the half that still works. */
1376+.offline-card {
1377+ pointer-events: auto;
1378+ width: min(15rem, 100%);
1379+ padding: 14px 14px 12px;
1380+ text-align: center;
1381+ background: var(--bg-panel); color: var(--fg);
1382+ border: 1px solid var(--border); border-radius: 12px;
1383+ box-shadow: 0 10px 30px var(--shadow), 0 2px 6px var(--shadow);
1384+}
1385+#offline-icon { display: block; width: 26px; height: 26px; margin: 0 auto 6px;
1386+ color: var(--fg-muted); }
1387+#offline-title { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .01em; }
1388+#offline-msg { margin: 4px 0 0; font-size: 11px; line-height: 1.45; color: var(--fg-muted); }
1389+#offline-msg:empty { display: none; }
1390+#offline-msg code { font-size: 10px; }
1391+.offline-actions { display: flex; gap: 6px; justify-content: center; margin-top: 10px; }
1392+.offline-actions button { padding: 5px 12px; }
1393+
1394+/* While a connection is in flight there is nothing to press — pressing
1395+ Reconnect again would only replace the socket that is already dialling — so
1396+ the card swaps its buttons and its broken link for a ring that says "wait". */
1397+#offline-spinner { display: none; width: 22px; height: 22px; margin: 2px auto 8px;
1398+ border: 2px solid var(--border); border-top-color: var(--accent);
1399+ border-radius: 50%; animation: offline-spin 700ms linear infinite; }
1400+#offline[data-state="pending"] #offline-spinner { display: block; }
1401+#offline[data-state="pending"] #offline-icon,
1402+#offline[data-state="pending"] .offline-actions { display: none; }
1403+@keyframes offline-spin { to { transform: rotate(360deg); } }
1404+@media (prefers-reduced-motion: reduce) {
1405+ #offline-spinner { animation-duration: 2s; }
1406+}
modifiedextension/sidebar.html+83 −4
⋯ 160 unchanged lines
161161 <circle cx="10" cy="10.5" r="1.9" fill="none" stroke="currentColor" stroke-width="1.5"/>
162162 </svg>
163163 </button>
164- <!-- A plain text input. Not a combobox: it does not fill itself in and
164+ <!-- A plain text box. Not a combobox: it does not fill itself in and
165165 there is no value to commit — what it holds is a query, and the list
166166 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"
167+ completion. The list announces itself instead.
168+
169+ A textarea rather than an input, for one reason: what gets typed
170+ here is often a sentence — `!claude <a whole prompt>` — and an input
171+ answers a long one by scrolling its own text sideways, which hides
172+ the beginning of the thing you are still writing. This wraps and the
173+ pill grows down instead, up to a few lines. It is still one line of
174+ *value*: Enter runs the chosen row and newlines never get in (see
175+ the input handler), so the box behaves exactly as it reads. -->
176+ <textarea id="omni" rows="1" wrap="soft"
169177 aria-label="Jump to a window, name a new session, or run a command with !"
170178 placeholder="Jump, create or !run…"
171179 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" />
180+ spellcheck="false" autocomplete="off"></textarea>
173181 <!-- The other half of what an address bar says: not just which session,
174182 but where it is. The name alone never tells you that, and it is the
175183 thing you want to know before typing a command into the box below.
⋯ 73 unchanged lines
249257
250258 <div id="term"></div>
251259
260+ <!-- What a dead socket looks like. The word in the header is easy to miss —
261+ it sits in a row of controls, in the smallest type in the panel, while
262+ the scrollback below it still shows a session that is no longer there.
263+ So the state gets said over the terminal instead, where the eye already
264+ is, with the two things you would go looking for on it: retry, and the
265+ settings that hold the token and the URL.
266+
267+ Over the terminal rather than in the flex column, for the reason the
268+ omnibar list is: a card in the column would resize the terminal, and
269+ xterm.js would reflow the scrollback you are still reading. The layer
270+ itself is click-through (`pointer-events: none`) so the header keeps
271+ working while it is up; only the card takes the pointer. -->
272+ <div id="offline" hidden>
273+ <div class="offline-card" role="status" aria-live="polite">
274+ <!-- A broken link, which is what happened: the two halves are still
275+ there and no longer joined. Hidden while connecting — the spinner
276+ the card grows in its place says the same thing in motion. -->
277+ <svg id="offline-icon" viewBox="0 0 24 24" aria-hidden="true">
278+ <path d="M9.5 7.5H7a4.5 4.5 0 0 0 0 9h2.5M14.5 7.5H17a4.5 4.5 0 0 1 0 9h-2.5"
279+ fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
280+ <path d="M9.6 12h1.2M13.2 12h1.2" stroke="currentColor"
281+ stroke-width="1.7" stroke-linecap="round"/>
282+ </svg>
283+ <div id="offline-spinner" aria-hidden="true"></div>
284+ <p id="offline-title">disconnected</p>
285+ <p id="offline-msg"></p>
286+ <div class="offline-actions">
287+ <button id="offline-retry">Reconnect</button>
288+ <button id="offline-settings" class="ghost">Settings</button>
289+ </div>
290+ </div>
291+ </div>
292+
252293 <!-- A card hung off the chevron rather than a drawer at the foot of the
253294 panel — the shape Chrome gives the popup on the other end of that same
254295 chevron. Positioned out of flow deliberately: as a flex item it sized
⋯ 48 unchanged lines
303344 </div>
304345 <p class="hint" id="tabmode-hint"></p>
305346
347+ <!-- What a plain click on "+" opens. Both kinds are always in the button's
348+ hold menu; this only says which one the click itself is. -->
349+ <div class="row" id="newtab-row">
350+ <select id="newtab-select" title="What the &quot;+&quot; button opens">
351+ <option value="claude">"+" opens claude</option>
352+ <option value="window">"+" opens a shell</option>
353+ </select>
354+ </div>
355+ <p class="hint" id="newtab-hint"></p>
356+
357+ <!-- Following the browser. Right-click a session or a window to pin it to
358+ the tab you are on; these three only say whether pins are acted on at
359+ all, whether an unpinned localhost port may guess at one, and whether
360+ the same pins also read backwards. -->
361+ <div class="row">
362+ <label class="row"
363+ ><input id="follow-tabs" type="checkbox" /> follow browser tabs</label
364+ >
365+ </div>
366+ <div class="row">
367+ <label class="row"
368+ ><input id="detect-devport" type="checkbox" /> guess by devport port</label
369+ >
370+ </div>
371+ <div class="row">
372+ <label class="row"
373+ ><input id="lead-tabs" type="checkbox" /> and switch tabs back</label
374+ >
375+ </div>
376+ <p class="hint">
377+ A pinned browser tab brings its session up when you switch to it, and
378+ switching to the session brings the tab back up. Right-click a session
379+ or window tab to pin one.
380+ </p>
381+
306382 <div class="row" id="font-row">
307383 <span class="hint">Font size</span>
308384 <button id="font-smaller" class="icon" title="Smaller (Ctrl+Alt+- or Ctrl+wheel)">
⋯ 38 unchanged lines
347423 <script src="lib/sanitize.js"></script>
348424 <script src="lib/shot.js"></script>
349425 <script src="lib/split.js"></script>
426+ <script src="lib/devport.js"></script>
427+ <script src="lib/tabpin.js"></script>
350428 <script src="picker.js"></script>
351429 <script src="vendor/xterm.js"></script>
352430 <script src="vendor/addon-fit.js"></script>
431+ <script src="vendor/addon-web-links.js"></script>
353432 <script src="sidebar.js"></script>
354433 </body>
355434 </html>
modifiedextension/sidebar.js+8 −0
⋯ 276 unchanged lines
277277
278278 const fit = new FitAddon.FitAddon();
279279 term.loadAddon(fit);
280+// Default handler is `window.open`, which the side panel can't reliably pop
281+// as a real browser tab; route through the extension tabs API instead.
282+term.loadAddon(
283+ new WebLinksAddon.WebLinksAddon((event, uri) => {
284+ event.preventDefault();
285+ api.tabs.create({ url: uri });
286+ }),
287+);
280288 term.open($("term"));
281289 fit.fit();
282290
⋯ 5360 unchanged lines
modifiedextension/sw.js+14 −0
⋯ 25 unchanged lines
2626
2727 const SKIP = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/;
2828
29+// The tabs a shortcut-started pick is running in. Empty means none is.
30+let pickTabs = /** @type {number[]} */ ([]);
31+
2932 async function runPicker() {
3033 const [tab] = await api.tabs.query({ active: true, currentWindow: true });
3134 if (!tab || SKIP.test(tab.url ?? "")) {
⋯ 6 unchanged lines
3841 // reachable only where its origin was granted — Split.racePick treats that
3942 // refusal as one runner dropping out, not as the pick failing.
4043 const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP.test(t.url ?? ""));
44+ // Remembered so the panel can call the pick off: this path's Escape has to
45+ // work from the panel too, and the panel doesn't know this pick exists.
46+ pickTabs = [];
47+ for (const t of targets) if (t.id != null) pickTabs.push(t.id);
4148 const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement);
49+ pickTabs = [];
4250 if (error) console.error("termbridge: pick failed", error);
4351 if (!value) return;
4452
⋯ 48 unchanged lines
93101 if (asked != null && Date.now() - asked < PENDING_OMNIBAR_MS) {
94102 port.postMessage({ type: "omnibar" });
95103 }
104+ } else if (msg?.type === "cancel-pick") {
105+ // Escape in the panel, for a pick the shortcut started here. Harmless
106+ // when nothing is picking: the list is empty and this does nothing.
107+ const ids = pickTabs;
108+ pickTabs = [];
109+ if (ids.length) Split.cancelPicks(api, ids);
96110 } else if (msg?.type === "focus" && windowId != null) {
97111 const entry = panels.get(windowId);
98112 if (entry?.port === port) entry.focused = !!msg.focused;
⋯ 224 unchanged lines
modifiedextension/types/globals.d.ts+85 −0
⋯ 14 unchanged lines
1515 windowId?: number;
1616 url?: string;
1717 title?: string;
18+ /** Whether this is the showing tab of its window. Only the reverse pin reads
19+ it, to notice that the browser is already where it would have moved it. */
20+ active?: boolean;
21+ /** When the tab was last showing, as an epoch millisecond count. Chrome 121+
22+ and Firefox both set it; absent elsewhere, which only costs the reverse
23+ pin its tie-break between two tabs that point at the same session. */
24+ lastAccessed?: number;
1825 /** The split view this tab is half of, if any — Chrome 140+, and read-only:
1926 the API detects splits, it cannot create or dissolve them. Absent in
2027 Firefox, which has no split view at all. */
⋯ 75 unchanged lines
96103 query(info: {
97104 active?: boolean;
98105 currentWindow?: boolean;
106+ /** Both set by the tab-pin code, which cares about one browser window —
107+ its own — and about which tab in it is showing. */
108+ windowId?: number;
99109 /** Chrome throws on this key rather than ignoring it where it isn't
100110 supported, so every call that passes it is wrapped. */
101111 splitViewId?: number;
102112 }): Promise<TbTab[]>;
113+ /** One tab by id, for the pin code: a tab activation carries an id and
114+ nothing else, and the URL is what a pin is keyed on. */
115+ get(tabId: number): Promise<TbTab>;
103116 create(props: { url: string }): Promise<TbTab>;
117+ /** A tab became the showing one in its window. Fires for every window, so
118+ the sidebar filters on its own. */
119+ onActivated: {
120+ addListener(cb: (info: { tabId: number; windowId: number }) => void): void;
121+ };
122+ /** Navigation inside a tab that is already open — the same page becoming a
123+ different origin, which is a different pin. `url` is only present when
124+ it changed, and only with the "tabs" permission. */
125+ onUpdated: {
126+ addListener(
127+ cb: (tabId: number, change: { url?: string; status?: string }, tab: TbTab) => void,
128+ ): void;
129+ };
130+ /** Tab ids are per-run and never reissued, so a tab pin outlives its tab
131+ unless something drops it. This is that something. */
132+ onRemoved: {
133+ addListener(cb: (tabId: number) => void): void;
134+ };
104135 /** Only used to make a tab active, which needs no permission. captureVisibleTab
105136 has no tabId of its own, so a split-view pick has to be activated first. */
106137 update(tabId: number, props: { active: boolean }): Promise<TbTab>;
⋯ 112 unchanged lines
219250 activity: boolean;
220251 }
221252
253+// --- pinning a session to a browser tab -------------------------------------
254+//
255+// Panel state, not server state: none of this reaches the socket, and the
256+// daemon has never heard of a browser tab. See lib/tabpin.js.
257+
258+/** Where a pinned browser tab sends the terminal. A null window means "the
259+ session, wherever it is currently pointed". */
260+interface TbPinTarget {
261+ session: string;
262+ window?: string | null;
263+}
264+
265+/** Which rule answered — shown in the tooltip, because a switch nobody
266+ remembers asking for should say where it came from. */
267+type TbPinSource = "tab" | "origin" | "detect";
268+
269+/** A place in tmux. A null window means the session's own current window,
270+ which is what a session-level pin points at. */
271+interface TbSpot {
272+ session: string;
273+ window?: string | null;
274+}
275+
276+/** An excursion in progress: a pin moved the terminal `to` somewhere, and owes
277+ it back to `from` when the browser leaves for a tab with no pin. */
278+interface TbPinReturn {
279+ from: TbSpot;
280+ to: TbSpot;
281+}
282+
283+interface TbTabPinStore {
284+ /** The whole feature, off. Pins are kept while it is off. */
285+ enabled: boolean;
286+ /** The devport guess for localhost ports. Explicit pins work regardless. */
287+ detect: boolean;
288+ /** The other direction: moving the terminal activates the pinned tab. Reads
289+ the same pins as the forward direction, and is separately switchable
290+ because it is the half that touches the browser. */
291+ reverse: boolean;
292+ /** Keyed by browser tab id, as a string — storage round-trips JSON, and JSON
293+ object keys are strings. A null value is a veto. */
294+ byTab: Record<string, TbPinTarget | null>;
295+ /** Keyed by origin, e.g. `http://localhost:26210`. */
296+ byOrigin: Record<string, TbPinTarget | null>;
297+}
298+
222299 /**
223300 * What an agent is doing. The header's glyph slot takes this plus `"none"` — its
224301 * own name for "no agent here", which it needs because every row has the slot
⋯ 173 unchanged lines
398475 }
399476 }
400477
478+declare namespace WebLinksAddon {
479+ class WebLinksAddon {
480+ constructor(handler?: (event: MouseEvent, uri: string) => void);
481+ }
482+}
483+
401484 // --- our own files, as the browser sees them --------------------------------
402485 //
403486 // lib/theme.js and lib/sanitize.js are loaded as classic scripts here and
⋯ 7 unchanged lines
411494 declare const Sanitize: typeof import("../lib/sanitize.js");
412495 declare const Shot: typeof import("../lib/shot.js");
413496 declare const Split: typeof import("../lib/split.js");
497+declare const Devport: typeof import("../lib/devport.js");
498+declare const Tabpin: typeof import("../lib/tabpin.js");
414499
415500 /** Set by picker.js inside the *page*, not here — see cancelPick(). */
416501 interface Window {
⋯ 2 unchanged lines
addedextension/vendor/addon-web-links.js+2 −0
1+!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,i={}){this._terminal=e,this._regex=t,this._handler=n,this._options=i}provideLinks(e,t){const i=n.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:i}=e;this._options.hover(t,n,i)}},e)))}};class n{static computeLink(e,t,i,r){const o=new RegExp(t.source,(t.flags||"")+"g"),[s,a]=n._getWindowedLineStrings(e-1,i),c=s.join("");let d;const l=[];for(;d=o.exec(c);){const e=d[0];try{const t=new URL(e),n=decodeURI(t.toString());if(e!==n&&e+"/"!==n)continue}catch(e){continue}const[t,o]=n._mapStrIdx(i,a,0,d.index),[s,c]=n._mapStrIdx(i,t,o,e.length);if(-1===t||-1===o||-1===s||-1===c)continue;const p={start:{x:o+1,y:t+1},end:{x:c,y:s+1}};l.push({range:p,text:e,activate:r})}return l}static _getWindowedLineStrings(e,t){let n,i=e,r=e,o=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(o=0;(n=t.buffer.active.getLine(--i))&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),o=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,i]}static _mapStrIdx(e,t,n,i){const r=e.buffer.active,o=r.getNullCell();let s=n;for(;i;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,o);const s=o.getChars();if(o.getWidth()&&(i-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,o),2===o.getWidth()&&(i+=1))}if(i<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=n}},t={};function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,n),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function o(e,t){const n=window.open();if(n){try{n.opener=null}catch(e){}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=o,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,i=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,i,this._handler,n))}dispose(){var e;null===(e=this._linkProvider)||void 0===e||e.dispose()}}})(),i})()));
2+//# sourceMappingURL=xterm-addon-web-links.js.map