anvilsign in

collin/browser-terminal-extension

1//! The same Claude glyph the sidebar's tabs wear, published into tmux's own
2//! status line.
3//!
4//! The sidebar gets its state over the daemon's control channel, which only
5//! exists while a browser is attached — exactly the case where you are not
6//! looking at the terminal. So this takes the other route: `termbridge hook`
7//! already runs on every Claude Code event, inside the pane it is reporting on,
8//! with `$TMUX` pointing at the right server. It sets a window user option, and
9//! tmux redraws on the option change.
10//!
11//! The user opts in with one line in `~/.tmux.conf` (`termbridge tmux` prints
12//! it). Nothing here touches a window that has never had a Claude in it, and
13//! nothing here can change a window's name, layout or contents: the only
14//! command it issues is `set-option -w @tb_claude`.
15
16use std::process::{Command, Stdio};
17
18use crate::agents::{self, Record, State};
19
20/// The option the format reads. `@`-prefixed, so tmux treats it as a user
21/// option and no future tmux release can collide with it.
22const OPTION: &str = "@tb_claude";
23
24/// Claude Code's asterisk cycle. A working window steps one frame per hook
25/// event rather than on a timer: tmux only redraws its status when an option
26/// changes or `status-interval` elapses, so a real animation would mean
27/// forcing a full status repaint on every attached client several times a
28/// second. Stepping on events costs nothing and still reads as alive — the
29/// glyph moves exactly when Claude crosses a tool boundary.
30const FRAMES: [&str; 6] = ["·", "✢", "✳", "∗", "✻", "✽"];
31
32/// Recompute the glyph for the window the hook fired in, from every Claude
33/// record in that window.
34///
35/// Errors are swallowed all the way down. This runs inside a Claude Code hook,
36/// where the cost of failing loudly is an interrupted session and the cost of
37/// failing quietly is a stale symbol until the next event.
38pub fn publish() {
39 // Not in tmux, or not under a tmux that would show it: nothing to say.
40 if std::env::var_os("TMUX").is_none() {
41 return;
42 }
43 let Ok(pane) = std::env::var("TMUX_PANE") else {
44 return;
45 };
46 let panes = window_panes(&pane);
47 let by_pane = agents::by_pane();
48 // A window is one tab in the sidebar and one entry in the status line, so
49 // it reports the loudest thing in it — the same rule, and the same reason:
50 // a window with something waiting on you outranks one that is merely busy.
51 let winner = panes
52 .iter()
53 .filter_map(|p| by_pane.get(p))
54 .min_by_key(|r| rank(r.state));
55
56 match winner.map(glyph) {
57 Some(value) => set_option(&pane, &value),
58 None => unset_option(&pane),
59 }
60}
61
62fn rank(state: State) -> u8 {
63 match state {
64 State::Waiting => 0,
65 State::Working => 1,
66 State::Idle => 2,
67 }
68}
69
70/// Styled inline, because the option's value is expanded into the status format
71/// and tmux reads `#[...]` in the result. `#[default]` hands the style back to
72/// whatever the user's own format was using, so this cannot leak colour into
73/// the window name that follows it.
74fn glyph(r: &Record) -> String {
75 let (colour, sym) = match r.state {
76 State::Working => ("yellow", FRAMES[r.frame as usize % FRAMES.len()]),
77 State::Waiting => ("magenta", "✳"),
78 State::Idle => ("brightblack", "✻"),
79 };
80 format!("#[fg={colour}]{sym}#[default] ")
81}
82
83/// The panes of the window `pane` is in — one tmux call, and the only thing we
84/// need tmux itself to tell us. The hook records key on pane id, so this is the
85/// whole join.
86fn window_panes(pane: &str) -> Vec<String> {
87 let out = tmux(&["list-panes", "-t", pane, "-F", "#{pane_id}"]);
88 out.lines().map(str::to_string).collect()
89}
90
91/// `-w` with a pane target sets the option on that pane's window, so the pane
92/// id is the only handle needed.
93fn set_option(pane: &str, value: &str) {
94 let _ = tmux(&["set-option", "-w", "-t", pane, OPTION, value]);
95}
96
97fn unset_option(pane: &str) {
98 let _ = tmux(&["set-option", "-wu", "-t", pane, OPTION]);
99}
100
101/// Re-source the running tmux server's own configuration, and report what was
102/// sourced.
103///
104/// tmux has no "reload config" command, so the files have to be named — and
105/// `#{config_files}` is tmux naming them itself, which is the only answer that
106/// stays right for a user whose config lives somewhere this code has never
107/// heard of. It is a list of candidates rather than of files that exist, hence
108/// `-q`: `~/.tmux.conf` is on it for everyone, including everyone who keeps
109/// their config in `~/.config/tmux` instead.
110///
111/// Empty when no tmux server is running, which is not a failure — there is
112/// simply nothing holding stale settings.
113pub fn reload_conf() -> Vec<String> {
114 let files: Vec<String> = tmux(&["display-message", "-p", "#{config_files}"])
115 .trim()
116 .split(',')
117 .filter(|f| !f.is_empty())
118 .map(str::to_string)
119 .collect();
120 for file in &files {
121 let _ = tmux(&["source-file", "-q", file]);
122 }
123 files
124}
125
126/// No `-L`/`-S`: the hook runs inside the pane, so `$TMUX` already names the
127/// server the user is looking at, and inheriting it is what makes this correct
128/// under multiple tmux servers.
129fn tmux(args: &[&str]) -> String {
130 Command::new("tmux")
131 .args(args)
132 .stdin(Stdio::null())
133 .stderr(Stdio::null())
134 .output()
135 .ok()
136 .and_then(|o| String::from_utf8(o.stdout).ok())
137 .unwrap_or_default()
138}
139
140/// What `termbridge tmux` prints. Two formats, because tmux styles the current
141/// window with a separate one and a glyph that vanished on the window you were
142/// on would be the wrong half to lose.
143///
144/// The name in place of `#W` is [`crate::pane_title`]: a pane option, so tmux
145/// resolves it against the active pane of the window it is drawing, and the
146/// window reads as whichever pane you are looking at. It is empty rather than
147/// oddly shaped when there is no Claude, so `#W` is the whole fallback — and
148/// `#{=/24/…:}` because a name is a sentence where a window name was a word,
149/// and four of them would otherwise push the clock off the status line.
150pub fn tmux_conf() -> String {
151 let title = crate::pane_title::OPTION;
152 let name = format!("#{{?#{{{title}}},#{{=/24/…:#{{{title}}}}},#W}}");
153 format!(
154 "# Claude Code status in the tmux status line, from termbridge's hooks.\n\
155 # Both options are empty for a pane with no Claude in it, so windows\n\
156 # that never see one look exactly as they do now: no glyph, and the\n\
157 # window name tmux would have shown anyway.\n\
158 set -g window-status-format \"#{{{OPTION}}}#I:{name}#F\"\n\
159 set -g window-status-current-format \"#{{{OPTION}}}#I:{name}#F\"\n"
160 )
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn record(state: State, frame: u8) -> Record {
168 Record {
169 session_id: "s".into(),
170 pane: Some("%1".into()),
171 cwd: "/tmp".into(),
172 state,
173 mode: None,
174 tool: None,
175 message: None,
176 name: None,
177 model: None,
178 prompt: None,
179 frame,
180 updated: 0,
181 }
182 }
183
184 #[test]
185 fn working_steps_through_the_cycle() {
186 let frames: Vec<String> = (0..7).map(|f| glyph(&record(State::Working, f))).collect();
187 assert!(frames[0].contains('·'));
188 assert!(frames[4].contains('✻'));
189 // Wraps rather than running off the end of the array.
190 assert_eq!(frames[0], frames[6]);
191 assert!(frames.iter().all(|f| f.ends_with("#[default] ")));
192 }
193
194 #[test]
195 fn waiting_outranks_working_outranks_idle() {
196 assert!(rank(State::Waiting) < rank(State::Working));
197 assert!(rank(State::Working) < rank(State::Idle));
198 }
199
200 #[test]
201 fn the_conf_names_the_option_the_hook_sets() {
202 assert!(tmux_conf().contains(&format!("#{{{OPTION}}}")));
203 }
204}