Merge pull request #865 from hhdebb/fix/strip-the-agent-status-mark

fix(tabs): take the agent's status mark off the title it writes (#847)
This commit is contained in:
l0ng-ai
2026-09-11 14:43:40 +08:00
committed by GitHub
3 changed files with 141 additions and 4 deletions
+4 -1
View File
@@ -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(
+135 -1
View File
@@ -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<TabView> {
#[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()),
+2 -2
View File
@@ -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());