From 56238bf3bb31375c6127c10aff5528006593d2ef Mon Sep 17 00:00:00 2001 From: hhdebb Date: Thu, 10 Sep 2026 21:32:43 +0800 Subject: [PATCH] fix(tabs): take the agent's status mark off the title it writes (#847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents animate in the terminal title while they work, and they do not agree on an alphabet: Claude Code cycles the quadrant circles and rests on an asterisk, others step through the braille frames, some write nothing at all. Rendered as they arrive, a column of tabs carries a mark in front of some rows and not others, in three vocabularies โ€” while the row already says what the agent is doing, in one, with its status dot. So the mark comes off, for everyone, with no setting. A switch would not settle this: nobody opens settings to decide how a spinner is drawn, and a default-off toggle buys two render paths to maintain forever in order to answer a question that has one right answer per person and no way for the app to know which. **A known alphabet, not a shape.** The obvious rule โ€” a leading character that is non-ASCII and above some code point, followed by a space โ€” matches by shape, and `๐Ÿ”ฅ build`, or `๐Ÿ“ ~/repo` written by somebody's shell integration, fits it exactly and quietly loses its first character with no way to ask for it back and no clue as to what took it. Matching marks we have actually seen costs the same and cannot do that: the braille block, the four quadrant circles, and Claude Code's resting asterisk. When an agent invents a mark that is not on the list, the failure is today's behaviour โ€” the mark stays โ€” which is the safe direction to fail in, and adding it is a line in the table. Two things the rule insists on, both to keep it from reaching past what it is for. A mark only counts with whitespace behind it, so `โœณfixing` is a word that starts with a character rather than a mark in front of one. And a title that is *only* a mark keeps it: taking it would leave an empty string, and an empty title is not a tab called nothing, it is a tab that falls back to its number โ€” less than the mark was saying. It happens in `TabView::label`, which is where a title becomes a label, so the strip, the sidebar, the switcher and the rename box's prefill all agree without being told separately โ€” and, because `label` reaches a given name before it reaches the title, a tab somebody deliberately called `โœณ release` keeps what they called it. That ordering is the only thing standing between a user's name and a rename behind their back, so there is a test on it rather than a comment. Three existing tests carried `โœณ` in their fixtures and now expect it gone. The one in `switcher.rs` was asserting that a tab in another window is named the way a local one would be, which is still exactly what it asserts; the one in `tty7-cli` is the table getting this for free, since `tab_label` reads `label` and so `tty7 ls` says what the tab strip says without either being told about the other. The daemon's fixtures keep their marks on purpose: a title is stored as the terminal wrote it, and only what turns one into a label takes anything off. This leaves the row with nothing moving in it, which is a real loss and is answered separately: `AgentStatus::dot_rgb` returns three flat colours, and a `Working` dot that breathes says the same thing in the vocabulary the row already speaks. --- crates/tty7-cli/src/output.rs | 5 +- crates/tty7-core/src/core/tab_view.rs | 136 +++++++++++++++++++++++++- src/ui/switcher.rs | 4 +- 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/crates/tty7-cli/src/output.rs b/crates/tty7-cli/src/output.rs index c44e3219..d92ab7ef 100644 --- a/crates/tty7-cli/src/output.rs +++ b/crates/tty7-cli/src/output.rs @@ -526,12 +526,15 @@ mod tests { ); // What the pane's own terminal says it is doing beats naming the agent // running it โ€” every tab of a workspace would otherwise read alike. + // The mark the agent writes in front of that title comes off here too: + // `tab_label` reads `TabView::label`, so the table says what the tab + // strip says without either being told about the other. assert_eq!( tab_label(&view(&|v| { v.osc_title = Some("โœณ fixing the switcher".into()); v.agent = Some(tty7_core::core::cli_agent::CLIAgent::Claude); })), - "โœณ fixing the switcher" + "fixing the switcher" ); assert_eq!( tab_label(&view( diff --git a/crates/tty7-core/src/core/tab_view.rs b/crates/tty7-core/src/core/tab_view.rs index 149c585f..bbed34aa 100644 --- a/crates/tty7-core/src/core/tab_view.rs +++ b/crates/tty7-core/src/core/tab_view.rs @@ -91,6 +91,73 @@ pub fn strip_host_prefix(raw: &str) -> &str { } } +/// The marks a coding agent writes in front of the title it sets while it +/// works, and which of them to take back off. +/// +/// Agents animate in the terminal title and do not agree on an alphabet: +/// Claude Code cycles the quadrant circles and rests on an asterisk, others +/// step through the braille frames, some write nothing. Rendered as they +/// arrive, a column of tabs carries a mark in front of some rows and not +/// others, in three vocabularies, while the row already says what the agent is +/// doing โ€” in one, with its status dot. +/// +/// **A known alphabet, not a shape.** The obvious rule โ€” a leading character +/// that is non-ASCII and above some code point, followed by a space โ€” matches +/// by shape, and a tab called `๐Ÿ”ฅ build`, or `๐Ÿ“ ~/repo` from somebody's shell +/// integration, fits it exactly and loses its first character with no way to +/// ask for it back and no clue as to what took it. Matching a list of marks we +/// have actually seen costs the same and cannot do that. When an agent invents +/// a mark that is not here yet the failure is today's behaviour โ€” the mark +/// stays โ€” which is the safe direction to fail in, and adding it is a line in +/// the table below. +const STATUS_MARKS: &[char] = &[ + // Claude Code: the quadrant circles while it works, the asterisk at rest. + '\u{25D0}', '\u{25D1}', '\u{25D2}', '\u{25D3}', '\u{2733}', +]; + +/// Whether `c` is one of the braille cells the common spinners are built from. +/// The whole block, because the frame sets differ between agents and every +/// cell in it is a spinner frame somewhere โ€” none is a character a human puts +/// at the front of a tab's name. +fn is_braille_frame(c: char) -> bool { + ('\u{2800}'..='\u{28FF}').contains(&c) +} + +/// `title` with any leading status marks taken off. +/// +/// A mark only counts with whitespace behind it, which is how every agent +/// writes one and is one more thing a title would have to do by accident. +/// Variation selectors and zero-width joiners ride along with the mark. +pub fn strip_status_mark(title: &str) -> &str { + let mut rest = title; + loop { + let mut chars = rest.chars(); + let Some(first) = chars.next() else { + return rest; + }; + if !STATUS_MARKS.contains(&first) && !is_braille_frame(first) { + return rest; + } + let after = chars + .as_str() + .trim_start_matches(|c: char| matches!(c, '\u{FE00}'..='\u{FE0F}' | '\u{200D}')); + let trimmed = after.trim_start(); + // Nothing between the mark and the rest of the title: a title that + // happens to start with the character, not a mark in front of one. + if trimmed.len() == after.len() { + return rest; + } + // A mark with nothing behind it is the whole title. Taking it would + // leave an empty string, and an empty title is not a tab called + // nothing โ€” it is a tab that falls back to its number, which is less + // than the mark was saying. + if trimmed.is_empty() { + return rest; + } + rest = trimmed; + } +} + impl TabView { pub fn label(&self) -> TabLabel<'_> { if let Some(name) = self @@ -105,6 +172,7 @@ impl TabView { .osc_title .as_deref() .map(str::trim) + .map(strip_status_mark) .filter(|t| !t.is_empty()) { return TabLabel::Osc(title); @@ -159,6 +227,72 @@ pub fn tab_views_of(ws: &Workspace, panes: &[PaneRecord]) -> Vec { #[cfg(test)] mod tests { + + /// The marks come off, whichever alphabet the agent picked. + #[test] + fn a_status_mark_comes_off_the_front_of_a_title() { + for raw in [ + "\u{2733} fixing the switcher", // Claude Code at rest + "\u{25D0} fixing the switcher", // and while it works + "\u{25D3} fixing the switcher", + "\u{280B} fixing the switcher", // a braille frame + "\u{28FF} fixing the switcher", // the far end of the block + "\u{2733}\u{FE0F} fixing the switcher", // with an emoji selector + "\u{2733} \u{280B} fixing the switcher", // two of them, both go + ] { + assert_eq!(strip_status_mark(raw), "fixing the switcher", "on {raw:?}"); + } + } + + /// The reason this matches an alphabet rather than a shape. Every one of + /// these fits "leading non-ASCII character above U+2000, then a space", + /// and every one of them is somebody's title rather than an agent's mark โ€” + /// a shape rule eats the first character of each, silently. + #[test] + fn a_title_that_merely_looks_like_one_is_left_alone() { + for raw in [ + "\u{1F525} build", // fire, a name somebody chose + "\u{1F4C1} ~/repo", // folder, from a shell integration + "\u{2192} deploy", // an arrow + "\u{2714} done", // a tick + "\u{2022} notes", // a bullet + "\u{4E2D}\u{6587} title", // a title in a script with no case + "\u{2733}fixing", // no space: part of the word + "fixing the switcher", // nothing to take + "", + ] { + assert_eq!(strip_status_mark(raw), raw, "on {raw:?}"); + } + } + + /// A name the user typed is theirs, mark or no mark. The strip is for the + /// title an agent writes, and `label` reaches the name first โ€” but that + /// ordering is the only thing keeping a tab someone deliberately called + /// `\u{2733} release` from being renamed behind their back, so it is worth + /// saying out loud. + #[test] + fn a_name_the_user_gave_is_never_stripped() { + let view = TabView { + id: TabId::new(), + name: Some("\u{2733} release".to_string()), + title: "zsh".to_string(), + osc_title: Some("\u{2733} fixing the switcher".to_string()), + cwd: None, + agent: None, + status: None, + live: true, + panes: 1, + }; + assert_eq!(view.label(), TabLabel::Named("\u{2733} release")); + } + + /// A title that is only a mark keeps it, rather than becoming empty and + /// falling through to the tab's number. + #[test] + fn a_mark_on_its_own_is_still_a_title() { + assert_eq!(strip_status_mark("\u{2733}"), "\u{2733}"); + assert_eq!(strip_status_mark("\u{2733} "), "\u{2733} "); + } use super::*; use crate::core::machine::{AgentFacts, Tab}; @@ -196,7 +330,7 @@ mod tests { cwd: Some("/work".into()), ..view() }; - assert_eq!(titled.label(), TabLabel::Osc("โœณ fixing the switcher")); + assert_eq!(titled.label(), TabLabel::Osc("fixing the switcher")); let blank_title = TabView { osc_title: Some(" ".into()), diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index a00b3943..1891a9df 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -3725,8 +3725,8 @@ mod tests { view.name = None; assert_eq!( tab_view_label(&view, 0, None), - "โœณ ไฟฎๅค workspace switcher", - "then the title the local strip would be showing, verbatim" + "ไฟฎๅค workspace switcher", + "then the title the local strip would be showing โ€” mark and all, which is to say without the mark" ); view.osc_title = Some("user@host:~/repo/025/tty7".to_string());