anvilsign in

collin/browser-terminal-extension

1//! What a pane's Claude is called, published as a tmux pane option.
2//!
3//! A pane running Claude is otherwise indistinguishable from any other pane:
4//! `#{pane_current_command}` is `node` in all of them and the cwd is the same
5//! repo in half of them. What tells two of them apart is the session's name, or
6//! before Claude has thought of one, the last thing you asked it — and the hook
7//! is handed both, `session_name` and `prompt`, as fields.
8//!
9//! They go into a pane option rather than the pane title. The title is a shared
10//! channel: Claude Code writes its own name there over OSC, a shell writes the
11//! cwd there, and a status line reading it back has to guess which of them it
12//! got and what shape it is in. An `@`-prefixed option is ours alone, so
13//! `#{@tb_title}` is either the name or empty, and no format has to take the
14//! string apart to find out.
15//!
16//! Pane-scoped, because tmux resolves a pane option against the active pane of
17//! the window being drawn: `#{@tb_title}` in `window-status-format` names the
18//! window after whichever pane you are looking at, with nothing here having to
19//! know which one that is — or to touch the window at all.
20//!
21//! Like [`crate::window_status`], this runs inside a Claude Code hook and
22//! swallows every error: a stale name is not worth interrupting a session over.
23
24use std::process::{Command, Stdio};
25
26use crate::agents;
27
28/// The option the format reads. `@`-prefixed, so tmux treats it as a user
29/// option and no future tmux release can collide with it.
30pub const OPTION: &str = "@tb_title";
31
32/// Set the option on the pane the hook fired in, from that pane's record.
33///
34/// Unset when there is nothing to say — including when the record has just been
35/// removed by `SessionEnd`, which is what takes the name off a pane that is
36/// back to being an ordinary shell. That is safe here in a way clearing a pane
37/// *title* would not be: the option has no other writer.
38pub fn publish() {
39 if std::env::var_os("TMUX").is_none() {
40 return;
41 }
42 let Ok(pane) = std::env::var("TMUX_PANE") else {
43 return;
44 };
45 // Claude's own name for the session first — it is a summary, and the prompt
46 // is only ever the raw material for one. Before it exists, and it does not
47 // exist for most of a session's life, what you asked beats nothing.
48 let title = agents::by_pane()
49 .get(&pane)
50 .and_then(|r| r.name.clone().or_else(|| r.prompt.clone()));
51
52 match title {
53 Some(t) => tmux(&["set-option", "-p", "-t", &pane, OPTION, &escape(&t)]),
54 None => tmux(&["set-option", "-pu", "-t", &pane, OPTION]),
55 };
56}
57
58/// Text on its way into a place tmux will expand as a format — an option's
59/// value is expanded every time the status line draws, so a prompt containing
60/// `#{...}` or `#[fg=red]` would be interpolated or restyled. Doubling `#` is
61/// tmux's own escape and the only one needed: every format construct starts
62/// with one.
63pub(crate) fn escape(title: &str) -> String {
64 title.replace('#', "##")
65}
66
67fn tmux(args: &[&str]) -> String {
68 Command::new("tmux")
69 .args(args)
70 .stdin(Stdio::null())
71 .stderr(Stdio::null())
72 .output()
73 .ok()
74 .and_then(|o| String::from_utf8(o.stdout).ok())
75 .unwrap_or_default()
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 fn format_constructs_in_a_prompt_are_inert() {
84 assert_eq!(escape("count #{host} items"), "count ##{host} items");
85 assert_eq!(escape("#[fg=red]"), "##[fg=red]");
86 assert_eq!(escape("plain"), "plain");
87 }
88}