anvilsign in

collin/browser-terminal-extension

1//! What the sidebar shows above the terminal: which tmux session we're on, what
2//! else is available, and what each Claude Code in the session is doing.
3//!
4//! Two sources, both authoritative, neither guessed:
5//!
6//! - tmux, asked over the [control channel](crate::control), for the session
7//! our client is attached to and the panes in it.
8//! - Claude Code's hook interface, via the records in [`crate::agents`].
9//!
10//! The join between them is the tmux pane id.
11//!
12//! Plus one thing neither of them knows on its own — whether a pane at rest has
13//! been on screen since it went quiet. That is [`SEEN`], and it is what turns
14//! "idle" into "done, and you haven't looked yet".
15
16use std::collections::{HashMap, HashSet};
17use std::sync::{Mutex, OnceLock};
18
19use serde::Serialize;
20
21use crate::agents::{Record, State};
22use crate::control::Control;
23
24/// Tab character, as tmux sees it. Single quotes make tmux take the byte
25/// literally, so the separator survives into the output and window names
26/// containing spaces or `|` stay intact.
27const T: char = '\t';
28
29/// One pane running Claude Code.
30#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
31pub struct Agent {
32 /// tmux pane id (`%12`), stable for the pane's lifetime.
33 pub pane: String,
34 /// Session the pane is in, by name. Agents are reported for the whole
35 /// server, so this is what tells the sidebar which session tab owns one.
36 pub session: String,
37 /// tmux window id (`@3`) — the join to [`WindowInfo`], so the sidebar can
38 /// mark the tab an agent is in without matching on a shifting index.
39 pub window_id: String,
40 pub window: String,
41 /// Window name — what the user sees in the tmux status line.
42 pub name: String,
43 /// `working` | `waiting` | `idle`, or `unknown` when the pane is running
44 /// Claude but no hook has ever reported for it.
45 ///
46 /// Plus `ready`, which no hook reports: an idle pane that has not been on
47 /// screen since it went idle. See [`SEEN`].
48 pub state: &'static str,
49 /// This pane is the one under the sidebar right now — our client's session,
50 /// its active window, its active pane. The same fact [`SEEN`] uses to turn
51 /// `ready` back into `idle`, said out loud, because the panel has no other
52 /// way to know where it is: it is told about panes, never about its own.
53 /// The jump button needs it so it can offer somewhere *else*.
54 pub here: bool,
55 /// Claude's `permission_mode`: `default`, `acceptEdits`, `plan`,
56 /// `bypassPermissions`.
57 pub mode: Option<String>,
58 /// Tool in flight, while working.
59 pub tool: Option<String>,
60 /// Why it is waiting on you.
61 pub message: Option<String>,
62 /// Claude's session title, when it has one.
63 pub title: Option<String>,
64}
65
66/// The tmux user option the sidebar keeps a session's colour in.
67///
68/// A user option (`@`-prefixed) is storage tmux itself holds, per session, for
69/// exactly this: state a tool wants to hang on a session without a place of its
70/// own to put it. Keeping the colour here rather than in the panel buys two
71/// things the panel could not have. It follows the session through a rename,
72/// because it is attached to the session and not to its name. And every panel
73/// on this server sees the same colour, rather than each browser profile
74/// keeping a private opinion about the same session.
75///
76/// It dies with the session, which is right — a colour for a session that no
77/// longer exists is not worth keeping.
78pub const COLOR_OPTION: &str = "@termbridge_color";
79
80/// One session on the server — one tab in the sidebar's top row, with its
81/// windows as the second row when it is the one selected.
82#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
83pub struct SessionInfo {
84 /// tmux session id (`$1`). Stable across renames, unlike the name.
85 pub id: String,
86 pub name: String,
87 /// Some tmux client, anywhere, is on this session.
88 pub attached: bool,
89 /// The sidebar's group colour, from [`COLOR_OPTION`]. `None` when unset,
90 /// which is what tells the panel to pick one itself.
91 pub color: Option<String>,
92 /// Working directory of the session's current pane — what `pwd` would say
93 /// in the shell you are looking at, which is the one thing about a session
94 /// its name never tells you.
95 pub path: Option<String>,
96 /// When tmux made it, in Unix seconds. The panel shows an age.
97 pub created: Option<u64>,
98 /// How many tmux clients are on it — a terminal elsewhere on the same
99 /// session is why what you type appears in two places.
100 pub clients: u32,
101 /// Its windows, in index order. Carried for every session and not just the
102 /// attached one: the sidebar draws the window row for whichever session
103 /// tab is selected, and switching must not wait for the next round trip.
104 pub windows: Vec<WindowInfo>,
105}
106
107/// One window of a session — one tab in the sidebar's second row.
108#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
109pub struct WindowInfo {
110 /// tmux window id (`@3`). Stable for the window's lifetime, unlike the
111 /// index, which shifts when a window before it closes — so this, not the
112 /// index, is what the sidebar sends back to select one.
113 pub id: String,
114 /// What the user sees in the status line and types as `prefix 2`.
115 pub index: u32,
116 pub name: String,
117 pub active: bool,
118 pub panes: u32,
119 /// Output arrived here since it was last looked at.
120 pub activity: bool,
121}
122
123/// The session a client is on, by both names tmux knows it by.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct ClientSession {
126 pub name: String,
127 /// tmux session id (`$1`). Unlike the name it cannot contain a quote or a
128 /// space, so it is the safe way to name the session in a command line.
129 pub id: String,
130}
131
132#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
133pub struct Snapshot {
134 /// Session our client is attached to *right now*, not the one we asked for.
135 pub session: Option<String>,
136 /// Same session as [`Snapshot::session`], by id — for command lines.
137 #[serde(skip)]
138 pub session_id: Option<String>,
139 /// Every session on the server, each with its own windows.
140 pub sessions: Vec<SessionInfo>,
141 /// Every agent on the server, in any session — so a session tab can report
142 /// a Claude waiting on you in a session you are not looking at.
143 pub agents: Vec<Agent>,
144}
145
146/// Ask tmux, then fold in the hook records.
147///
148/// The whole server, not just the attached session: the sidebar's two rows of
149/// tabs are sessions over their windows, and an agent anywhere is worth a light
150/// on the session that holds it. It costs no more round trips than one session
151/// did — `list-windows -a` and `list-panes -a` answer for all of them at once.
152pub async fn snapshot(control: &Control, tty: Option<&str>) -> Snapshot {
153 let current = current_session(control, tty).await;
154 let sessions = list_sessions(control).await;
155 let agents = agents_all(control, current.as_ref().map(|c| c.name.as_str())).await;
156 let (session, session_id) = match current {
157 Some(c) => (Some(c.name), Some(c.id)),
158 None => (None, None),
159 };
160 Snapshot {
161 session,
162 session_id,
163 sessions,
164 agents,
165 }
166}
167
168/// Which session the interactive client on `tty` is on.
169///
170/// tmux is the authority: the client can leave the session it was spawned with
171/// (`switch-client`, `choose-tree`, prefix-`(`/`)`), and the name we passed to
172/// `new-session` goes stale the moment it does.
173pub async fn current_session(control: &Control, tty: Option<&str>) -> Option<ClientSession> {
174 let tty = tty?;
175 let out = control
176 .run(format!(
177 "list-clients -F '#{{client_tty}}{T}#{{session_name}}{T}#{{session_id}}'"
178 ))
179 .await
180 .ok()?;
181 out.iter()
182 .filter_map(|l| {
183 let mut f = l.split(T);
184 Some((f.next()?, f.next()?, f.next()?))
185 })
186 .find(|(client_tty, _, _)| *client_tty == tty)
187 .map(|(_, name, id)| ClientSession {
188 name: name.trim().to_string(),
189 id: id.trim().to_string(),
190 })
191 .filter(|c| !c.name.is_empty() && !c.id.is_empty())
192}
193
194/// Every session, each carrying its own windows, oldest first.
195///
196/// tmux lists sessions alphabetically, which makes a new one appear wherever
197/// its name happens to sort — the tab row would reshuffle around a session you
198/// just made. Session ids come from a counter that only goes up, so ordering by
199/// id is creation order: existing tabs never move, and a new session is always
200/// the one on the end.
201async fn list_sessions(control: &Control) -> Vec<SessionInfo> {
202 // The colour comes back in the same format string rather than a
203 // `show-options` per session: it is one more field on a query we already
204 // make, so a server with twenty sessions costs exactly what it did.
205 // `pane_current_path` and the rest resolve against the session's current
206 // pane, which is what makes the cwd free: it rides the query the tab row
207 // already costs rather than a `display-message` per session.
208 let fmt = format!(
209 "#{{session_id}}{T}#{{session_attached}}{T}#{{{COLOR_OPTION}}}{T}\
210 #{{session_created}}{T}#{{pane_current_path}}{T}#{{session_name}}"
211 );
212 let Ok(out) = control.run(format!("list-sessions -F '{fmt}'")).await else {
213 return Vec::new();
214 };
215 let mut windows = list_windows(control).await;
216 let mut sessions: Vec<SessionInfo> = out
217 .iter()
218 .filter_map(|line| {
219 // Session names can hold a tab, so the name takes the rest of the
220 // line rather than a field of its own.
221 let mut f = line.splitn(6, T);
222 let id = f.next()?.to_string();
223 // The field is a client count, and any of them being on it is what
224 // "attached" means.
225 let clients: u32 = f.next()?.trim().parse().unwrap_or(0);
226 // Empty means the option is unset, which is not the same as a
227 // colour of zero — the sidebar picks its own when there is none.
228 let color = f.next()?.trim();
229 let color = (!color.is_empty()).then(|| color.to_string());
230 let created = f.next()?.trim().parse().ok();
231 // Absent when the session has no pane tmux will answer for, which
232 // it survives — the panel just has one less line to show.
233 let path = f.next()?.trim();
234 let path = (!path.is_empty()).then(|| path.to_string());
235 let name = f.next()?.to_string();
236 let windows = windows.remove(&id).unwrap_or_default();
237 Some(SessionInfo {
238 id,
239 name,
240 attached: clients > 0,
241 color,
242 path,
243 created,
244 clients,
245 windows,
246 })
247 })
248 .collect();
249 sessions.sort_by_key(|s| session_ordinal(&s.id));
250 sessions
251}
252
253/// The number in a `$12` session id. Ids tmux didn't issue sort last rather
254/// than first, so an unreadable one cannot jump the row it lands in.
255pub fn session_ordinal(id: &str) -> u64 {
256 id.strip_prefix('$')
257 .and_then(|n| n.parse().ok())
258 .unwrap_or(u64::MAX)
259}
260
261/// Every window on the server, grouped by session id and left in the order
262/// tmux lists them (by index).
263async fn list_windows(control: &Control) -> std::collections::HashMap<String, Vec<WindowInfo>> {
264 let fmt = format!(
265 "#{{session_id}}{T}#{{window_id}}{T}#{{window_index}}{T}\
266 #{{window_active}}{T}#{{window_panes}}{T}#{{window_activity_flag}}{T}#{{window_name}}"
267 );
268 let mut out_map: std::collections::HashMap<String, Vec<WindowInfo>> = Default::default();
269 let Ok(out) = control.run(format!("list-windows -a -F '{fmt}'")).await else {
270 return out_map;
271 };
272 for line in out {
273 // Window names are set by whatever is running in them, tabs included,
274 // so the name is last and gets everything that is left.
275 let mut f = line.splitn(7, T);
276 let Some(window) = (|| {
277 let session = f.next()?.to_string();
278 Some((
279 session,
280 WindowInfo {
281 id: f.next()?.to_string(),
282 index: f.next()?.parse().ok()?,
283 active: f.next()? != "0",
284 panes: f.next().and_then(|p| p.parse().ok()).unwrap_or(1),
285 activity: f.next()? != "0",
286 name: f.next()?.to_string(),
287 },
288 ))
289 })() else {
290 continue;
291 };
292 out_map.entry(window.0).or_default().push(window.1);
293 }
294 out_map
295}
296
297/// How long a hook record outlives the event that wrote it.
298///
299/// A record only disappears on `SessionEnd`, so a Claude that was killed — or
300/// whose terminal went away — leaves one behind forever, still saying
301/// `working`. tmux then hands that pane id to the next window, and a plain
302/// shell inherits a spinner that never stops. Ten minutes is longer than the
303/// gap between hook events in a live turn and short enough that a recycled
304/// pane goes quiet while you are still looking at it.
305const STALE_AFTER: u64 = 10 * 60;
306
307/// What to report for one pane: `None` when there is no agent in it at all,
308/// `Some(None)` when Claude is there but nothing credible is known about what
309/// it is doing (the `unknown` state).
310///
311/// `cmd` is the pane's foreground process, `record` the newest hook record
312/// filed against it.
313fn reported<'a>(cmd: &str, record: Option<&'a Record>) -> Option<Option<&'a Record>> {
314 let fresh = record.is_some_and(|r| r.age() < STALE_AFTER);
315 // A pane qualifies if Claude is the foreground process, or if a hook
316 // reported for it recently — Claude that shelled out to a long-running
317 // command shows that command as pane_current_command and would otherwise
318 // vanish from the sidebar mid-run. Old record, no Claude in the pane: an
319 // orphan, and the pane is somebody else's now.
320 if cmd != "claude" && !fresh {
321 return None;
322 }
323 // Claude is in the pane, so the entry stays either way; the question is
324 // whether its state is still true. `idle` and `waiting` are resting states
325 // and stay true for as long as nobody touches the keyboard. `working` is
326 // not: a session killed mid-turn leaves it behind, and it is the one state
327 // that animates.
328 Some(record.filter(|r| fresh || r.state != State::Working))
329}
330
331/// Which rest you have already seen: pane id → the `updated` stamp of the
332/// record that was on screen when you saw it.
333///
334/// The hook records say what a Claude is doing; they cannot say whether it has
335/// had your attention, because nothing in Claude's process knows which tmux
336/// pane is in front of a person. tmux knows, and this is where the two are put
337/// together: a pane that is the active pane of the active window of a session
338/// with a client on it is being looked at, and whatever it is doing right now
339/// is not news any more.
340///
341/// Stamps rather than a flag, so nothing has to be cleared. Every hook event
342/// bumps `updated`, so the mark a turn ago no longer matches the record a new
343/// turn wrote — the pane goes back to unseen by itself, and a mark that
344/// survives into a *recycled* pane cannot match the new Claude's record either.
345///
346/// Daemon-wide rather than per connection: two browser panels on one server are
347/// two views of the same tmux, and a window one of them showed you is not
348/// something the other should still be flagging. It lives for as long as the
349/// daemon does — long enough to outlast a panel reload, which is the point of
350/// keeping it here and not in the browser.
351static SEEN: OnceLock<Mutex<HashMap<String, u64>>> = OnceLock::new();
352
353fn seen() -> &'static Mutex<HashMap<String, u64>> {
354 SEEN.get_or_init(Default::default)
355}
356
357/// What to report for a pane, given whether it is on screen right now.
358///
359/// Takes the map rather than reaching for [`SEEN`] so the rule can be tested
360/// without a global; the caller holds the lock across a whole listing, which
361/// also keeps one snapshot from marking panes another is mid-way through
362/// reading.
363fn state_of(
364 seen: &mut HashMap<String, u64>,
365 pane: &str,
366 record: Option<&Record>,
367 visible: bool,
368) -> &'static str {
369 let Some(record) = record else {
370 // No credible record, so nothing to have seen — and nothing to mark,
371 // since there is no stamp to mark it with.
372 return "unknown";
373 };
374 if visible {
375 seen.insert(pane.to_string(), record.updated);
376 return record.state.as_str();
377 }
378 // Only rest is worth flagging. `working` reports itself and `waiting` is
379 // already the loudest thing the sidebar draws.
380 if record.state == State::Idle && seen.get(pane) != Some(&record.updated) {
381 return "ready";
382 }
383 record.state.as_str()
384}
385
386/// Agents in every session on the server.
387///
388/// `current` is the session this daemon's client is attached to — half of what
389/// makes a pane visible, the other half being tmux's own active window and
390/// active pane flags.
391async fn agents_all(control: &Control, current: Option<&str>) -> Vec<Agent> {
392 let fmt = format!(
393 "#{{pane_id}}{T}#{{window_id}}{T}#{{window_index}}{T}#{{window_active}}{T}\
394 #{{pane_active}}{T}#{{pane_current_command}}{T}#{{session_name}}{T}#{{window_name}}"
395 );
396 let Ok(out) = control.run(format!("list-panes -a -F '{fmt}'")).await else {
397 return Vec::new();
398 };
399 let records = crate::agents::by_pane();
400 let mut seen = seen().lock().unwrap_or_else(|e| e.into_inner());
401 let mut live: HashSet<String> = HashSet::new();
402
403 let agents: Vec<Agent> = out
404 .iter()
405 .filter_map(|line| {
406 // Window name last for the same reason as above; the session name
407 // before it is the one field that could also hold a tab, and a
408 // pane in a session named like that just loses its name here.
409 let mut f = line.splitn(8, T);
410 let pane = f.next()?.to_string();
411 let window_id = f.next()?.to_string();
412 let window = f.next()?.to_string();
413 let window_active = f.next()? != "0";
414 let pane_active = f.next()? != "0";
415 let cmd = f.next()?;
416 let session = f.next()?.to_string();
417 let name = f.next()?.to_string();
418 live.insert(pane.clone());
419 let record = reported(cmd, records.get(&pane))?;
420 // What the terminal under the sidebar is actually showing: our
421 // client's session, the window it is on, the pane that has the
422 // cursor. A tab in the strip is not a pane on screen.
423 let visible = window_active && pane_active && current == Some(session.as_str());
424 let state = state_of(&mut seen, &pane, record, visible);
425 Some(Agent {
426 pane,
427 session,
428 window_id,
429 window,
430 name,
431 state,
432 here: visible,
433 mode: record.and_then(|r| r.mode.clone()),
434 tool: record.and_then(|r| r.tool.clone()),
435 message: record.and_then(|r| r.message.clone()),
436 title: record.and_then(|r| r.name.clone()),
437 })
438 })
439 .collect();
440
441 // Panes that no longer exist. The listing is server-wide, so anything
442 // missing from it is gone — but only when tmux answered with something, or
443 // a failed query would look like an empty server and forget everything.
444 if !live.is_empty() {
445 seen.retain(|pane, _| live.contains(pane));
446 }
447 agents
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 fn record(state: State, updated: u64) -> Record {
455 Record {
456 session_id: "s".into(),
457 pane: Some("%1".into()),
458 cwd: "/tmp".into(),
459 state,
460 mode: None,
461 tool: None,
462 message: None,
463 name: None,
464 frame: 0,
465 updated,
466 }
467 }
468
469 /// The whole point: an idle pane you have not been shown reads differently
470 /// from one you have, and looking at it is what settles it.
471 #[test]
472 fn idle_is_ready_until_it_has_been_on_screen() {
473 let mut seen = HashMap::new();
474 let r = record(State::Idle, 100);
475 assert_eq!(state_of(&mut seen, "%1", Some(&r), false), "ready");
476 assert_eq!(state_of(&mut seen, "%1", Some(&r), true), "idle");
477 assert_eq!(state_of(&mut seen, "%1", Some(&r), false), "idle");
478 }
479
480 /// A mark is only good for the rest it was made against: the next turn
481 /// writes a new record, and that one has not been seen.
482 #[test]
483 fn a_new_turn_is_news_again() {
484 let mut seen = HashMap::new();
485 state_of(&mut seen, "%1", Some(&record(State::Idle, 100)), true);
486 let next = record(State::Idle, 200);
487 assert_eq!(state_of(&mut seen, "%1", Some(&next), false), "ready");
488 }
489
490 /// Working and waiting report themselves wherever they are — `ready` is
491 /// only ever a thing an idle pane becomes.
492 #[test]
493 fn only_rest_is_flagged() {
494 let mut seen = HashMap::new();
495 let working = record(State::Working, 100);
496 let waiting = record(State::Waiting, 100);
497 assert_eq!(state_of(&mut seen, "%1", Some(&working), false), "working");
498 assert_eq!(state_of(&mut seen, "%2", Some(&waiting), false), "waiting");
499 assert_eq!(state_of(&mut seen, "%3", None, false), "unknown");
500 assert_eq!(state_of(&mut seen, "%4", None, true), "unknown");
501 // Nothing was marked for the panes with no record to mark.
502 assert!(!seen.contains_key("%3") && !seen.contains_key("%4"));
503 }
504
505 /// tmux hands a closed pane's id to the next pane it opens. The stamp is
506 /// what keeps that from crediting a fresh Claude with having been seen.
507 #[test]
508 fn a_recycled_pane_starts_unseen() {
509 let mut seen = HashMap::new();
510 state_of(&mut seen, "%1", Some(&record(State::Idle, 100)), true);
511 let newcomer = record(State::Idle, 900);
512 assert_eq!(state_of(&mut seen, "%1", Some(&newcomer), false), "ready");
513 }
514}