anvilsign in

collin/browser-terminal-extension

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