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/// No `-L`/`-S`: the hook runs inside the pane, so `$TMUX` already names the
102/// server the user is looking at, and inheriting it is what makes this correct
103/// under multiple tmux servers.
104fn tmux(args: &[&str]) -> String {
105 Command::new("tmux")
106 .args(args)
107 .stdin(Stdio::null())
108 .stderr(Stdio::null())
109 .output()
110 .ok()
111 .and_then(|o| String::from_utf8(o.stdout).ok())
112 .unwrap_or_default()
113}
114
115/// What `termbridge tmux` prints. Two formats, because tmux styles the current
116/// window with a separate one and a glyph that vanished on the window you were
117/// on would be the wrong half to lose.
118pub fn tmux_conf() -> String {
119 format!(
120 "# Claude Code status in the tmux status line, from termbridge's hooks.\n\
121 # The option is empty for a window with no Claude in it, so windows\n\
122 # that never see one look exactly as they do now.\n\
123 set -g window-status-format \"#{{{OPTION}}}#I:#W#F\"\n\
124 set -g window-status-current-format \"#{{{OPTION}}}#I:#W#F\"\n"
125 )
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn record(state: State, frame: u8) -> Record {
133 Record {
134 session_id: "s".into(),
135 pane: Some("%1".into()),
136 cwd: "/tmp".into(),
137 state,
138 mode: None,
139 tool: None,
140 message: None,
141 name: None,
142 frame,
143 updated: 0,
144 }
145 }
146
147 #[test]
148 fn working_steps_through_the_cycle() {
149 let frames: Vec<String> = (0..7).map(|f| glyph(&record(State::Working, f))).collect();
150 assert!(frames[0].contains('·'));
151 assert!(frames[4].contains('✻'));
152 // Wraps rather than running off the end of the array.
153 assert_eq!(frames[0], frames[6]);
154 assert!(frames.iter().all(|f| f.ends_with("#[default] ")));
155 }
156
157 #[test]
158 fn waiting_outranks_working_outranks_idle() {
159 assert!(rank(State::Waiting) < rank(State::Working));
160 assert!(rank(State::Working) < rank(State::Idle));
161 }
162
163 #[test]
164 fn the_conf_names_the_option_the_hook_sets() {
165 assert!(tmux_conf().contains(&format!("#{{{OPTION}}}")));
166 }
167}