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
12use serde::Serialize;
13
14use crate::control::Control;
15
16/// Tab character, as tmux sees it. Single quotes make tmux take the byte
17/// literally, so the separator survives into the output and window names
18/// containing spaces or `|` stay intact.
19const T: char = '\t';
20
21/// One pane running Claude Code.
22#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
23pub struct Agent {
24 /// tmux pane id (`%12`), stable for the pane's lifetime.
25 pub pane: String,
26 /// tmux window id (`@3`) — the join to [`WindowInfo`], so the sidebar can
27 /// mark the tab an agent is in without matching on a shifting index.
28 pub window_id: String,
29 pub window: String,
30 /// Window name — what the user sees in the tmux status line.
31 pub name: String,
32 /// `working` | `waiting` | `idle`, or `unknown` when the pane is running
33 /// Claude but no hook has ever reported for it.
34 pub state: &'static str,
35 /// Claude's `permission_mode`: `default`, `acceptEdits`, `plan`,
36 /// `bypassPermissions`.
37 pub mode: Option<String>,
38 /// Tool in flight, while working.
39 pub tool: Option<String>,
40 /// Why it is waiting on you.
41 pub message: Option<String>,
42 /// Claude's session title, when it has one.
43 pub title: Option<String>,
44}
45
46#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
47pub struct SessionInfo {
48 pub name: String,
49 pub windows: u32,
50 pub attached: bool,
51}
52
53/// One window in the attached session — one tab in the sidebar.
54#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
55pub struct WindowInfo {
56 /// tmux window id (`@3`). Stable for the window's lifetime, unlike the
57 /// index, which shifts when a window before it closes — so this, not the
58 /// index, is what the sidebar sends back to select one.
59 pub id: String,
60 /// What the user sees in the status line and types as `prefix 2`.
61 pub index: u32,
62 pub name: String,
63 pub active: bool,
64 pub panes: u32,
65 /// Output arrived here since it was last looked at.
66 pub activity: bool,
67}
68
69#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
70pub struct Snapshot {
71 /// Session our client is attached to *right now*, not the one we asked for.
72 pub session: Option<String>,
73 /// Every session on the server, for the sidebar's switcher.
74 pub sessions: Vec<SessionInfo>,
75 /// Windows of the attached session, in index order — the sidebar's tabs.
76 pub windows: Vec<WindowInfo>,
77 pub agents: Vec<Agent>,
78}
79
80/// Ask tmux, then fold in the hook records.
81pub async fn snapshot(control: &Control, tty: Option<&str>) -> Snapshot {
82 let session = current_session(control, tty).await;
83 let sessions = list_sessions(control).await;
84 let (windows, agents) = match &session {
85 Some(s) => (list_windows(control, s).await, agents_in(control, s).await),
86 None => (Vec::new(), Vec::new()),
87 };
88 Snapshot {
89 session,
90 sessions,
91 windows,
92 agents,
93 }
94}
95
96/// Which session the interactive client on `tty` is on.
97///
98/// tmux is the authority: the client can leave the session it was spawned with
99/// (`switch-client`, `choose-tree`, prefix-`(`/`)`), and the name we passed to
100/// `new-session` goes stale the moment it does.
101pub async fn current_session(control: &Control, tty: Option<&str>) -> Option<String> {
102 let tty = tty?;
103 let out = control
104 .run(format!("list-clients -F '#{{client_tty}}{T}#{{session_name}}'"))
105 .await
106 .ok()?;
107 out.iter()
108 .filter_map(|l| l.split_once(T))
109 .find(|(client_tty, _)| *client_tty == tty)
110 .map(|(_, name)| name.trim().to_string())
111 .filter(|name| !name.is_empty())
112}
113
114async fn list_sessions(control: &Control) -> Vec<SessionInfo> {
115 let fmt = format!("#{{session_name}}{T}#{{session_windows}}{T}#{{session_attached}}");
116 let Ok(out) = control.run(format!("list-sessions -F '{fmt}'")).await else {
117 return Vec::new();
118 };
119 out.iter()
120 .filter_map(|line| {
121 let mut f = line.split(T);
122 Some(SessionInfo {
123 name: f.next()?.to_string(),
124 windows: f.next()?.parse().unwrap_or(0),
125 attached: f.next().unwrap_or("0") != "0",
126 })
127 })
128 .collect()
129}
130
131/// Windows of `session`, in the order tmux lists them (by index).
132///
133/// `session` reaches here from tmux itself or from a name validated in
134/// [`crate::pty::valid_session_name`], and is single-quoted besides.
135async fn list_windows(control: &Control, session: &str) -> Vec<WindowInfo> {
136 let fmt = format!(
137 "#{{window_id}}{T}#{{window_index}}{T}#{{window_name}}{T}\
138 #{{window_active}}{T}#{{window_panes}}{T}#{{window_activity_flag}}"
139 );
140 let Ok(out) = control
141 .run(format!("list-windows -t '{session}' -F '{fmt}'"))
142 .await
143 else {
144 return Vec::new();
145 };
146 out.iter()
147 .filter_map(|line| {
148 let mut f = line.split(T);
149 Some(WindowInfo {
150 id: f.next()?.to_string(),
151 index: f.next()?.parse().ok()?,
152 name: f.next()?.to_string(),
153 active: f.next().unwrap_or("0") != "0",
154 panes: f.next().and_then(|p| p.parse().ok()).unwrap_or(1),
155 activity: f.next().unwrap_or("0") != "0",
156 })
157 })
158 .collect()
159}
160
161async fn agents_in(control: &Control, session: &str) -> Vec<Agent> {
162 let fmt = format!(
163 "#{{pane_id}}{T}#{{window_id}}{T}#{{window_index}}{T}\
164 #{{window_name}}{T}#{{pane_current_command}}"
165 );
166 // `session` reaches here from tmux itself or from a validated name, and is
167 // single-quoted besides.
168 let Ok(out) = control
169 .run(format!("list-panes -s -t '{session}' -F '{fmt}'"))
170 .await
171 else {
172 return Vec::new();
173 };
174 let records = crate::agents::by_pane();
175
176 out.iter()
177 .filter_map(|line| {
178 let mut f = line.split(T);
179 let pane = f.next()?.to_string();
180 let window_id = f.next()?.to_string();
181 let window = f.next()?.to_string();
182 let name = f.next()?.to_string();
183 let cmd = f.next().unwrap_or("");
184 let record = records.get(&pane);
185 // A pane qualifies if Claude is the foreground process, or if a
186 // hook has reported for it — Claude that shelled out to a
187 // long-running command shows that command as pane_current_command
188 // and would otherwise vanish from the sidebar mid-run.
189 if cmd != "claude" && record.is_none() {
190 return None;
191 }
192 Some(Agent {
193 pane,
194 window_id,
195 window,
196 name,
197 state: record.map(|r| r.state.as_str()).unwrap_or("unknown"),
198 mode: record.and_then(|r| r.mode.clone()),
199 tool: record.and_then(|r| r.tool.clone()),
200 message: record.and_then(|r| r.message.clone()),
201 title: record.and_then(|r| r.name.clone()),
202 })
203 })
204 .collect()
205}