| 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 | |
| 16 | use std::collections::{HashMap, HashSet}; |
| 17 | use std::sync::{Mutex, OnceLock}; |
| 18 | |
| 19 | use serde::Serialize; |
| 20 | |
| 21 | use crate::agents::{Record, State}; |
| 22 | use 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. |
| 27 | const T: char = '\t'; |
| 28 | |
| 29 | /// One pane running Claude Code. |
| 30 | #[derive(Serialize, Debug, Clone, PartialEq, Eq)] |
| 31 | pub 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. |
| 80 | pub 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)] |
| 85 | pub 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)] |
| 111 | pub 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)] |
| 127 | pub 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)] |
| 135 | pub 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. |
| 154 | pub 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. |
| 175 | pub 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. |
| 203 | async 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. |
| 257 | pub 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). |
| 265 | async 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. |
| 307 | const 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. |
| 315 | fn 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. |
| 353 | static SEEN: OnceLock<Mutex<HashMap<String, u64>>> = OnceLock::new(); |
| 354 | |
| 355 | fn 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. |
| 365 | fn 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. |
| 393 | async 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)] |
| 453 | mod 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 | } |