| 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 | |
| 24 | use std::process::{Command, Stdio}; |
| 25 | |
| 26 | use 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. |
| 30 | pub 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. |
| 38 | pub 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. |
| 63 | pub(crate) fn escape(title: &str) -> String { |
| 64 | title.replace('#', "##") |
| 65 | } |
| 66 | |
| 67 | fn 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)] |
| 79 | mod 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 | } |