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