fix: source title activity glyphs from manifests

refs #2707
This commit is contained in:
akbash-bot
2026-08-12 12:34:40 +00:00
parent 5600197f00
commit 34651dd4cf
11 changed files with 650 additions and 36 deletions
+85 -5
View File
@@ -16,7 +16,15 @@ DEFAULT_BUNDLED_DIR = PROJECT_ROOT / "src" / "detect" / "manifests"
DEFAULT_WEBSITE_DIR = PROJECT_ROOT / "website" / "agent-detection"
ENGINE_SOURCE = PROJECT_ROOT / "src" / "detect" / "manifest_update.rs"
MANIFEST_KEYS = {"id", "version", "min_engine_version", "updated_at", "aliases", "rules"}
MANIFEST_KEYS = {
"id",
"version",
"min_engine_version",
"updated_at",
"aliases",
"terminal_title_activity_regex",
"rules",
}
RULE_KEYS = {
"id",
"state",
@@ -45,6 +53,8 @@ REGION_RE = re.compile(
)
REGION_COUNT_RE = re.compile(r"\(([1-9][0-9]*)\)$")
VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)*$")
RUST_HEX_ESCAPE_RE = re.compile(r"\\x\{([0-9A-Fa-f]{1,6})\}")
TITLE_ACTIVITY_CLASS_SPECIALS = frozenset("\\[]^-&~")
MAX_TOP_REGION_LINE_COUNT = 65_535
MAX_RULES_PER_MANIFEST = 128
MAX_GATE_DEPTH = 8
@@ -53,10 +63,15 @@ MAX_MATCHERS_PER_GATE = 32
MAX_TOTAL_MATCHERS = 1024
MAX_MATCHER_CHARS = 512
# Keep engine-2 clients on the OSC-capable manifest until an engine-3 release
# can consume top_non_empty_lines. Remove this entry when the website publishes
# the bundled Grok manifest.
# Keep published manifests compatible with older engines while a bundled
# manifest stages a newer engine feature. Remove each entry after the website
# can publish that bundled version.
STAGED_WEBSITE_MANIFESTS = {
"claude": (
"2026.08.12.2",
"2026.08.12.1",
"03efbec218b6dbde0b8b35ddbb2d495825651935da33cc78ad0c98a44f7aced3",
),
"grok": (
"2026.07.16.2",
"2026.07.16.1",
@@ -143,6 +158,24 @@ def validate_manifest(path: Path, engine_version: int) -> dict:
if not isinstance(aliases, list) or not all(isinstance(item, str) for item in aliases):
raise CheckError(f"{path}: aliases must be an array of strings")
title_activity_regex = manifest.get("terminal_title_activity_regex")
if title_activity_regex is not None:
if min_engine < 4:
raise CheckError(
f"{path}: terminal_title_activity_regex requires min_engine_version 4"
)
if not isinstance(title_activity_regex, str):
raise CheckError(f"{path}: terminal_title_activity_regex must be a string")
if len(title_activity_regex) > MAX_MATCHER_CHARS:
raise CheckError(
f"{path}: terminal_title_activity_regex exceeds max length {MAX_MATCHER_CHARS}"
)
if not title_activity_regex.startswith("^") or not title_activity_regex.endswith("$"):
raise CheckError(
f"{path}: terminal_title_activity_regex must be anchored with ^ and $"
)
validate_title_activity_regex(path, title_activity_regex)
rules = manifest.get("rules")
if not isinstance(rules, list) or not rules:
raise CheckError(f"{path}: rules must be a non-empty array")
@@ -160,6 +193,54 @@ def validate_manifest(path: Path, engine_version: int) -> dict:
return manifest
def validate_title_activity_regex(path: Path, pattern: str) -> None:
def rust_scalar(match: re.Match[str]) -> str:
value = int(match.group(1), 16)
if value > 0x10FFFF or 0xD800 <= value <= 0xDFFF:
raise CheckError(
f"{path}: terminal_title_activity_regex contains an invalid Unicode scalar"
)
return chr(value)
translated = RUST_HEX_ESCAPE_RE.sub(rust_scalar, pattern)
if not translated.startswith("^[") or not translated.endswith("]$"):
raise CheckError(
f"{path}: terminal_title_activity_regex must be a one-scalar character class"
)
body = translated[2:-2]
if not body:
raise CheckError(f"{path}: terminal_title_activity_regex character class is empty")
index = 0
while index < len(body):
start = body[index]
if start in TITLE_ACTIVITY_CLASS_SPECIALS or 0xD800 <= ord(start) <= 0xDFFF:
raise CheckError(
f"{path}: terminal_title_activity_regex uses unsupported character-class syntax"
)
if index + 1 < len(body) and body[index + 1] == "-":
if index + 2 >= len(body):
raise CheckError(
f"{path}: terminal_title_activity_regex contains an incomplete range"
)
end = body[index + 2]
if end in TITLE_ACTIVITY_CLASS_SPECIALS or ord(start) > ord(end):
raise CheckError(
f"{path}: terminal_title_activity_regex contains an invalid range"
)
index += 3
else:
index += 1
try:
re.compile(translated)
except re.error as exc:
raise CheckError(
f"{path}: terminal_title_activity_regex is invalid: {exc}"
) from exc
def validate_rule(path: Path, index: int, rule: object, complexity: dict[str, int]) -> None:
if not isinstance(rule, dict):
raise CheckError(f"{path}: rule {index} must be a table")
@@ -327,7 +408,6 @@ def validate_catalog(
stages_new_engine_manifest = (
staged_manifest
== (bundled_manifest["version"], manifest["version"], website_digest)
and bundled_manifest["min_engine_version"] == engine_version
and manifest["min_engine_version"] < bundled_manifest["min_engine_version"]
)
if cmp < 0 and not stages_new_engine_manifest:
@@ -151,6 +151,33 @@ class AgentDetectionManifestCheckTests(unittest.TestCase):
with self.assertRaisesRegex(check.CheckError, "exceeds engine"):
check.load_manifest_dir(bundled, engine_version=1)
def test_validates_title_activity_regex_syntax_and_empty_matches(self):
with tempfile.TemporaryDirectory() as tmp:
bundled = Path(tmp) / "bundled"
bundled.mkdir()
base = manifest("codex", "2026.06.10.1").replace(
"min_engine_version = 1",
"min_engine_version = 4\nterminal_title_activity_regex = '^[\\x{25D0}-\\x{25D3}]$'",
)
manifest_path = bundled / "codex.toml"
manifest_path.write_text(base)
check.load_manifest_dir(bundled, engine_version=4)
for invalid, error in [
("^[a$", "one-scalar character class"),
("^a*$", "one-scalar character class"),
("^(?=x)x$", "one-scalar character class"),
("^[\\x{D800}]$", "invalid Unicode scalar"),
("^[\\x{110000}]$", "invalid Unicode scalar"),
("^[z-a]$", "invalid range"),
]:
with self.subTest(invalid=invalid):
manifest_path.write_text(
base.replace("^[\\x{25D0}-\\x{25D3}]$", invalid)
)
with self.assertRaisesRegex(check.CheckError, error):
check.load_manifest_dir(bundled, engine_version=4)
def test_rejects_top_non_empty_lines_below_engine_three(self):
with tempfile.TemporaryDirectory() as tmp:
bundled = Path(tmp) / "bundled"
+11 -2
View File
@@ -241,6 +241,7 @@ pub struct PaneStateUpdate {
pub ws_idx: usize,
pub previous_agent_label: Option<String>,
pub previous_known_agent: Option<Agent>,
pub terminal_title_stripped_changed: bool,
pub previous_state: AgentState,
pub previous_seen: bool,
pub previous_presentation: crate::terminal::EffectivePresentation,
@@ -1046,6 +1047,7 @@ impl AppState {
ws_idx,
previous_agent_label: change.previous_agent_label.clone(),
previous_known_agent: change.previous_known_agent,
terminal_title_stripped_changed: false,
previous_state: change.previous_state,
previous_seen,
previous_presentation: change.previous_presentation.clone(),
@@ -2973,23 +2975,29 @@ impl AppState {
mutation,
managed_changed,
agent_name_changed,
terminal_title_stripped_changed,
unchanged_change,
managed_launch_pending,
suppress_acquisition_completion,
) = {
let terminal = self.terminals.get_mut(&terminal_id)?;
let previous_agent_name = terminal.agent_name.clone();
let previous_stripped_title = terminal.terminal_title_stripped();
let managed_launch_pending = terminal.managed_agent_launch_pending();
let mutation = update(terminal)?;
let managed_changed = terminal.reconcile_managed_agent_at(now, false);
let suppress_acquisition_completion = terminal.finish_agent_process_acquisition();
let agent_name_changed = terminal.agent_name != previous_agent_name;
let unchanged_change = (mutation.agent_released || agent_name_changed)
.then(|| terminal.unchanged_effective_state_change_at(now));
let terminal_title_stripped_changed =
terminal.reconcile_terminal_title_projection(previous_stripped_title);
let unchanged_change =
(mutation.agent_released || agent_name_changed || terminal_title_stripped_changed)
.then(|| terminal.unchanged_effective_state_change_at(now));
(
mutation,
managed_changed,
agent_name_changed,
terminal_title_stripped_changed,
unchanged_change,
managed_launch_pending,
suppress_acquisition_completion,
@@ -3014,6 +3022,7 @@ impl AppState {
ws_idx,
previous_agent_label: change.previous_agent_label.clone(),
previous_known_agent: change.previous_known_agent,
terminal_title_stripped_changed,
previous_state: change.previous_state,
previous_seen,
previous_presentation: change.previous_presentation.clone(),
+122 -1
View File
@@ -305,12 +305,24 @@ impl App {
} else {
None
};
let manifest_title_snapshot = manifest_update_agents
.as_ref()
.map(|agents| self.terminal_title_projection_snapshot(Some(agents)));
if manifest_update_agents
.as_ref()
.is_some_and(|agents| !agents.is_empty())
{
crate::detect::manifest::reload_manifests();
}
let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
let previous_toast = self.state.toast.clone();
let pane_updates = self.state.handle_app_event(ev);
if let Some(agents) = manifest_update_agents {
self.reset_agent_detection_for_agents(&agents);
}
if let Some(previous) = manifest_title_snapshot {
self.reconcile_terminal_titles_after_manifest_reload(&previous);
}
if let Some((pane_id, agent)) = released_agent {
if pane_updates.iter().any(|update| update.pane_id == pane_id) {
if let Some((ws_idx, _)) = self.find_pane(pane_id) {
@@ -615,7 +627,7 @@ impl App {
};
let workspace_id = self.public_workspace_id(update.ws_idx);
if update.agent_name_changed {
if update.agent_name_changed || update.terminal_title_stripped_changed {
self.emit_pane_updated(update.ws_idx, update.pane_id);
}
@@ -983,10 +995,12 @@ impl App {
}
}
Method::ServerReloadAgentManifests(_) => {
let previous_titles = self.terminal_title_projection_snapshot(None);
let summaries = crate::detect::manifest::reload_manifests();
self.state.agent_manifest_summaries = summaries.clone();
let update_status = crate::detect::manifest_update::load_status();
self.reset_all_agent_detection_runtimes();
self.reconcile_terminal_titles_after_manifest_reload(&previous_titles);
SuccessResponse {
id: request.id,
result: ResponseResult::AgentManifestReload {
@@ -1464,6 +1478,113 @@ mod tests {
.expect("matching agent detection runtime should be reset");
}
#[tokio::test]
async fn manifest_update_event_activates_titles_on_the_app_thread() {
const CHILD_ENV: &str = "HERDR_TEST_TITLE_MANIFEST_UPDATE_CHILD";
if std::env::var_os(CHILD_ENV).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"app::api::tests::manifest_update_event_activates_titles_on_the_app_thread",
"--nocapture",
])
.env(CHILD_ENV, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"isolated manifest update test failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
return;
}
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let old_config = std::env::var_os("XDG_CONFIG_HOME");
let old_state = std::env::var_os("XDG_STATE_HOME");
let base =
std::env::temp_dir().join(format!("herdr-title-auto-manifest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::env::set_var("XDG_CONFIG_HOME", base.join("config"));
std::env::set_var("XDG_STATE_HOME", base.join("state"));
crate::detect::manifest::reload_manifests();
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
true,
None,
api_rx,
event_hub.clone(),
);
app.state.workspaces = vec![crate::workspace::Workspace::test_new("auto-title")];
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
terminal.detected_agent = Some(Agent::Claude);
terminal.set_terminal_title(Some("◆ task".into()));
let revision = terminal.revision;
let remote_path = crate::detect::manifest_update::remote_manifest_path(Agent::Claude);
std::fs::create_dir_all(remote_path.parent().unwrap()).unwrap();
std::fs::write(
remote_path,
r#"
id = "claude"
version = "9999.01.01.1"
min_engine_version = 4
updated_at = "9999-01-01T00:00:00Z"
terminal_title_activity_regex = '^◆$'
[[rules]]
id = "idle"
state = "idle"
contains = ["remote-ready"]
"#,
)
.unwrap();
app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated {
updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
agent: Agent::Claude,
version: crate::detect::manifest_update::ManifestVersion::parse("9999.01.01.1")
.unwrap(),
}],
status: crate::detect::manifest_update::ManifestUpdateStatus::default(),
});
assert!(matches!(
crate::detect::manifest::explain(Agent::Claude, "remote-ready").source,
Some(crate::detect::manifest::ManifestSource::Remote { .. })
));
let terminal = app.state.terminals.get(&terminal_id).unwrap();
assert_eq!(terminal.terminal_title.as_deref(), Some("◆ task"));
assert_eq!(terminal.terminal_title_stripped().as_deref(), Some("task"));
assert_eq!(terminal.revision, revision + 1);
assert_eq!(
event_hub
.events_after(0)
.iter()
.filter(|(_, event)| event.event == crate::api::schema::EventKind::PaneUpdated)
.count(),
1
);
match old_config {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match old_state {
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
None => std::env::remove_var("XDG_STATE_HOME"),
}
crate::detect::manifest::reload_manifests();
let _ = std::fs::remove_dir_all(base);
}
#[tokio::test]
async fn server_reload_agent_manifests_resets_detection_runtimes() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+193 -5
View File
@@ -33,6 +33,63 @@ impl App {
changes
}
pub(crate) fn terminal_title_projection_snapshot(
&self,
agents: Option<&[crate::detect::Agent]>,
) -> std::collections::HashMap<crate::terminal::TerminalId, Option<String>> {
self.state
.terminals
.iter()
.filter_map(|(terminal_id, terminal)| {
let included = agents.is_none_or(|agents| {
terminal
.effective_known_agent()
.is_some_and(|agent| agents.contains(&agent))
});
included.then(|| (terminal_id.clone(), terminal.terminal_title_stripped()))
})
.collect()
}
pub(crate) fn reconcile_terminal_titles_after_manifest_reload(
&mut self,
previous: &std::collections::HashMap<crate::terminal::TerminalId, Option<String>>,
) -> TerminalTitleChanges {
let mut changes = TerminalTitleChanges::default();
let mut changed_terminals = HashSet::new();
for (terminal_id, terminal) in &mut self.state.terminals {
let Some(previous_stripped) = previous.get(terminal_id) else {
continue;
};
if terminal.reconcile_terminal_title_projection(previous_stripped.clone()) {
changes.stripped_changed = true;
changed_terminals.insert(terminal_id.clone());
}
}
if changed_terminals.is_empty() {
return changes;
}
let mut publish = Vec::new();
for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
for tab in &workspace.tabs {
for (pane_id, pane) in &tab.panes {
if changed_terminals.contains(&pane.attached_terminal_id) {
publish.push((ws_idx, *pane_id));
}
}
}
}
for (ws_idx, pane_id) in publish {
self.emit_pane_updated(ws_idx, pane_id);
}
if self.terminal_title_sidebar_changed(&changes) {
self.render_dirty.request_generic();
self.render_notify.notify_one();
}
changes
}
pub(crate) fn sync_terminal_titles(
&mut self,
sources: &HashSet<PaneId>,
@@ -86,6 +143,8 @@ mod tests {
#[tokio::test]
async fn sync_keeps_latest_raw_title_and_emits_only_for_stripped_changes() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
crate::detect::manifest::reload_manifests();
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
@@ -100,7 +159,7 @@ mod tests {
terminal.detected_agent = Some(Agent::Claude);
terminal.state = AgentState::Working;
let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
runtime.test_process_pty_bytes("\x1b]0;⠋ 修复🙂标题\x07".as_bytes());
runtime.test_process_pty_bytes("\x1b]0;◐ 修复🙂标题\x07".as_bytes());
app.terminal_runtimes.insert(terminal_id.clone(), runtime);
let sources = HashSet::from([pane_id]);
@@ -112,19 +171,19 @@ mod tests {
}
);
let pane = app.pane_info(0, pane_id).unwrap();
assert_eq!(pane.terminal_title.as_deref(), Some("⠋ 修复🙂标题"));
assert_eq!(pane.terminal_title.as_deref(), Some("◐ 修复🙂标题"));
assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
assert_eq!(pane.title, None);
assert_eq!(pane.agent_status, crate::api::schema::AgentStatus::Working);
assert_eq!(pane.revision, 1);
let agent = app.collect_agent_infos().pop().unwrap();
assert_eq!(agent.terminal_title.as_deref(), Some("⠋ 修复🙂标题"));
assert_eq!(agent.terminal_title.as_deref(), Some("◐ 修复🙂标题"));
assert_eq!(agent.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
app.terminal_runtimes
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes("\x1b]2;⠙ 修复🙂标题\x1b\\".as_bytes());
.test_process_pty_bytes("\x1b]2;◓ 修复🙂标题\x1b\\".as_bytes());
assert_eq!(
app.sync_terminal_titles(&sources),
TerminalTitleChanges {
@@ -133,7 +192,7 @@ mod tests {
}
);
let pane = app.pane_info(0, pane_id).unwrap();
assert_eq!(pane.terminal_title.as_deref(), Some("⠙ 修复🙂标题"));
assert_eq!(pane.terminal_title.as_deref(), Some("◓ 修复🙂标题"));
assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
assert_eq!(pane.revision, 1);
assert_eq!(pane_updated_events(&event_hub), 1);
@@ -157,6 +216,135 @@ mod tests {
assert_eq!(pane_updated_events(&event_hub), 3);
}
#[tokio::test]
async fn agent_identity_reconciles_existing_title_projection() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
crate::detect::manifest::reload_manifests();
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
app.state.workspaces = vec![Workspace::test_new("one")];
app.state.active = Some(0);
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
terminal.set_terminal_title(Some("◐ task".into()));
let revision = terminal.revision;
assert_eq!(
terminal.terminal_title_stripped().as_deref(),
Some("◐ task")
);
app.handle_internal_event(crate::events::AppEvent::AgentProcessDetected {
pane_id,
agent: Agent::Claude,
observed_at: std::time::Instant::now(),
});
let terminal = app.state.terminals.get(&terminal_id).unwrap();
assert_eq!(terminal.terminal_title.as_deref(), Some("◐ task"));
assert_eq!(terminal.terminal_title_stripped().as_deref(), Some("task"));
assert_eq!(terminal.revision, revision + 1);
assert_eq!(pane_updated_events(&event_hub), 1);
}
#[tokio::test]
async fn manifest_reload_reconciles_existing_title_projection() {
const CHILD_ENV: &str = "HERDR_TEST_TITLE_MANIFEST_RELOAD_CHILD";
if std::env::var_os(CHILD_ENV).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"app::terminal_titles::tests::manifest_reload_reconciles_existing_title_projection",
"--nocapture",
])
.env(CHILD_ENV, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"isolated manifest reload test failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
return;
}
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let old_config = std::env::var_os("XDG_CONFIG_HOME");
let old_state = std::env::var_os("XDG_STATE_HOME");
let base = std::env::temp_dir().join(format!(
"herdr-title-manifest-reload-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&base);
std::env::set_var("XDG_CONFIG_HOME", base.join("config"));
std::env::set_var("XDG_STATE_HOME", base.join("state"));
crate::detect::manifest::reload_manifests();
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
app.state.workspaces = vec![Workspace::test_new("one")];
app.state.active = Some(0);
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
terminal.detected_agent = Some(Agent::Claude);
terminal.set_terminal_title(Some("◆ task".into()));
let revision = terminal.revision;
assert_eq!(
terminal.terminal_title_stripped().as_deref(),
Some("◆ task")
);
let override_path = base.join("config/herdr-dev/agent-detection/claude.toml");
std::fs::create_dir_all(override_path.parent().unwrap()).unwrap();
std::fs::write(
&override_path,
r#"
id = "claude"
min_engine_version = 4
terminal_title_activity_regex = '^◆$'
[[rules]]
id = "idle"
state = "idle"
contains = ["ready"]
"#,
)
.unwrap();
let response = app.handle_api_request(crate::api::schema::Request {
id: "reload-title-manifest".into(),
method: crate::api::schema::Method::ServerReloadAgentManifests(
crate::api::schema::EmptyParams::default(),
),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["result"]["type"], "agent_manifest_reload");
let terminal = app.state.terminals.get(&terminal_id).unwrap();
assert_eq!(terminal.terminal_title.as_deref(), Some("◆ task"));
assert_eq!(terminal.terminal_title_stripped().as_deref(), Some("task"));
assert_eq!(terminal.revision, revision + 1);
assert_eq!(pane_updated_events(&event_hub), 1);
match old_config {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match old_state {
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
None => std::env::remove_var("XDG_STATE_HOME"),
}
crate::detect::manifest::reload_manifests();
let _ = std::fs::remove_dir_all(base);
}
#[tokio::test]
async fn syncing_pending_titles_preserves_sidebar_render_impact() {
let event_hub = crate::api::EventHub::default();
+45
View File
@@ -123,6 +123,7 @@ pub struct RuleEvidence {
#[derive(Debug, Clone)]
struct LoadedManifest {
manifest: AgentManifest,
terminal_title_activity_regex: Option<Regex>,
compiled_rules: Vec<CompiledRule>,
source: ManifestSource,
warning: Option<String>,
@@ -145,6 +146,7 @@ pub(crate) struct AgentManifest {
_updated_at: Option<String>,
#[serde(default)]
aliases: Vec<String>,
terminal_title_activity_regex: Option<String>,
#[serde(default)]
rules: Vec<ManifestRule>,
}
@@ -267,6 +269,7 @@ const MAX_TOTAL_GATES: usize = 512;
const MAX_MATCHERS_PER_GATE: usize = 32;
const MAX_TOTAL_MATCHERS: usize = 1024;
const MAX_MATCHER_CHARS: usize = 512;
const TERMINAL_TITLE_ACTIVITY_ENGINE_VERSION: u32 = 4;
pub(crate) fn reload_manifests() -> Vec<AgentManifestSummary> {
let _reload_guard = MANIFEST_RELOAD_LOCK
@@ -356,6 +359,21 @@ pub fn explain_with_input(agent: Agent, input: DetectionInput<'_>) -> DetectionE
evaluate_loaded_manifest(agent, input, loaded, true)
}
pub(crate) fn terminal_title_activity_matches(agent: Agent, prefix: &str) -> bool {
let lock = manifest_cache();
let guard = match lock.read() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard
.manifests
.iter()
.find(|(cached_agent, _)| *cached_agent == agent)
.and_then(|(_, loaded)| loaded.as_ref())
.and_then(|loaded| loaded.terminal_title_activity_regex.as_ref())
.is_some_and(|regex| regex.is_match(prefix))
}
pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain {
let Some(agent) = parse_agent_label(agent_label) else {
return DetectionExplain {
@@ -670,9 +688,16 @@ fn loaded_manifest(
cached_remote_version: Option<String>,
local_override_shadowing_remote: bool,
) -> Result<LoadedManifest, String> {
let terminal_title_activity_regex = manifest
.terminal_title_activity_regex
.as_deref()
.map(Regex::new)
.transpose()
.map_err(|err| format!("terminal_title_activity_regex could not be compiled: {err}"))?;
let compiled_rules = compile_manifest(&manifest)?;
Ok(LoadedManifest {
manifest,
terminal_title_activity_regex,
compiled_rules,
source,
warning,
@@ -891,6 +916,26 @@ pub(crate) fn parse_remote_manifest_for_agent(
}
fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> {
if let Some(pattern) = manifest.terminal_title_activity_regex.as_deref() {
if manifest.min_engine_version.unwrap_or(0) < TERMINAL_TITLE_ACTIVITY_ENGINE_VERSION {
return Err(format!(
"terminal_title_activity_regex requires min_engine_version {TERMINAL_TITLE_ACTIVITY_ENGINE_VERSION}"
));
}
if pattern.chars().count() > MAX_MATCHER_CHARS {
return Err(format!(
"terminal_title_activity_regex exceeds max length {MAX_MATCHER_CHARS}"
));
}
if !pattern.starts_with('^') || !pattern.ends_with('$') {
return Err("terminal_title_activity_regex must be anchored with ^ and $".to_string());
}
let regex = Regex::new(pattern)
.map_err(|err| format!("terminal_title_activity_regex is invalid: {err}"))?;
if regex.is_match("") {
return Err("terminal_title_activity_regex must not match empty text".to_string());
}
}
if manifest.rules.is_empty() {
return Err("manifest must contain at least one rule".to_string());
}
+84
View File
@@ -29,6 +29,24 @@ contains = ["{contains}"]
)
}
fn title_manifest(version: Option<&str>, glyph: char, contains: &str) -> String {
let version = version
.map(|version| format!("version = \"{version}\"\n"))
.unwrap_or_default();
format!(
r#"
id = "codex"
{version}min_engine_version = 4
terminal_title_activity_regex = '^{glyph}$'
[[rules]]
id = "test"
state = "idle"
contains = ["{contains}"]
"#
)
}
fn rules_manifest(rules: &str) -> String {
format!(
r#"
@@ -173,6 +191,21 @@ fn remote_manifest_loads_between_local_override_and_bundled() {
});
}
#[test]
fn title_activity_uses_the_active_manifest_source() {
with_manifest_dirs("title-active-source", || {
write_remote_codex(&title_manifest(Some("9999.01.01.1"), '◆', "remote-ready"));
assert!(terminal_title_activity_matches(Agent::Codex, "◆"));
assert!(!terminal_title_activity_matches(Agent::Codex, "◇"));
write_local_codex(&title_manifest(None, '◇', "local-ready"));
let explain = explain(Agent::Codex, "local-ready");
assert!(matches!(explain.source, Some(ManifestSource::Override(_))));
assert!(!terminal_title_activity_matches(Agent::Codex, "◆"));
assert!(terminal_title_activity_matches(Agent::Codex, "◇"));
});
}
#[test]
fn fallback_explain_preserves_active_manifest_version() {
with_manifest_dirs("fallback-version", || {
@@ -359,6 +392,57 @@ fn devin_manifest_detects_idle_working_and_blocked_states() {
assert!(permission_prompt.visible_blocker);
}
#[test]
fn manifest_accepts_agent_scoped_terminal_title_activity_regex() {
assert!(parse_manifest(
r#"
id = "codex"
min_engine_version = 4
terminal_title_activity_regex = '^[◆◇]$'
[[rules]]
id = "idle"
state = "idle"
contains = ["ready"]
"#
)
.is_ok());
for manifest in [
r#"
id = "codex"
terminal_title_activity_regex = '^◆$'
[[rules]]
id = "idle"
state = "idle"
contains = ["ready"]
"#,
r#"
id = "codex"
min_engine_version = 4
terminal_title_activity_regex = '['
[[rules]]
id = "idle"
state = "idle"
contains = ["ready"]
"#,
r#"
id = "codex"
min_engine_version = 4
terminal_title_activity_regex = '^$'
[[rules]]
id = "idle"
state = "idle"
contains = ["ready"]
"#,
] {
assert!(parse_manifest(manifest).is_err());
}
}
#[test]
fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() {
assert!(parse_manifest(
+2 -4
View File
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use super::{agent_label, parse_agent_label, Agent};
pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 3;
pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 4;
const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml";
const CATALOG_URL_ENV: &str = "HERDR_AGENT_DETECTION_MANIFEST_CATALOG_URL";
const MAX_FETCH_BYTES: usize = 256 * 1024;
@@ -169,9 +169,6 @@ pub(crate) fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEv
let result = check_and_update();
let status = match result {
Ok(output) => {
if !output.updated.is_empty() {
super::manifest::reload_manifests();
}
let _ = events.blocking_send(crate::events::AppEvent::AgentDetectionManifestsUpdated {
updated: output.updated,
status: output.status,
@@ -668,6 +665,7 @@ path = "codex.toml"
else {
panic!("unexpected event");
};
crate::detect::manifest::reload_manifests();
assert_eq!(updated.len(), 1);
assert_eq!(updated[0].agent, Agent::Codex);
+4 -3
View File
@@ -1,8 +1,9 @@
id = "claude"
version = "2026.08.12.1"
min_engine_version = 2
version = "2026.08.12.2"
min_engine_version = 4
updated_at = "2026-08-12T00:00:00Z"
aliases = ["claude-code"]
terminal_title_activity_regex = '^[·✢✳✶✻✽\x{25D0}-\x{25D3}]$'
[[rules]]
id = "osc_title_working"
@@ -11,7 +12,7 @@ priority = 1100
region = "osc_title"
visible_working = true
# Braille covers <= 2.1.227; half-circles are the 2.1.228 busy spinner.
regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D1}] ']
regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D3}] ']
[[rules]]
id = "btw_overlay_working"
+12 -1
View File
@@ -219,7 +219,18 @@ impl TerminalState {
pub(crate) fn terminal_title_stripped(&self) -> Option<String> {
self.terminal_title
.as_deref()
.and_then(super::stripped_terminal_title)
.and_then(|title| super::stripped_terminal_title(title, self.effective_known_agent()))
}
pub(crate) fn reconcile_terminal_title_projection(
&mut self,
previous_stripped: Option<String>,
) -> bool {
if previous_stripped == self.terminal_title_stripped() {
return false;
}
self.revision = self.revision.wrapping_add(1);
true
}
pub(crate) fn set_terminal_title(&mut self, title: Option<String>) -> TerminalTitleChange {
+65 -15
View File
@@ -1,6 +1,7 @@
const CLAUDE_ACTIVITY_GLYPHS: &str = "·✢✳✶✻✽";
pub(crate) fn stripped_terminal_title(title: &str) -> Option<String> {
pub(crate) fn stripped_terminal_title(
title: &str,
agent: Option<crate::detect::Agent>,
) -> Option<String> {
let title = crate::platform::terminal_title_for_presentation(title).trim();
if title.is_empty() {
return None;
@@ -9,8 +10,13 @@ pub(crate) fn stripped_terminal_title(title: &str) -> Option<String> {
let mut chars = title.char_indices();
let (_, first) = chars.next()?;
let after_first = &title[first.len_utf8()..];
let recognized =
matches!(first, '\u{2800}'..='\u{28ff}') || CLAUDE_ACTIVITY_GLYPHS.contains(first);
let recognized = matches!(first, '\u{2800}'..='\u{28ff}')
|| agent.is_some_and(|agent| {
crate::detect::manifest::terminal_title_activity_matches(
agent,
&title[..first.len_utf8()],
)
});
let stripped = if recognized
&& (after_first.is_empty() || after_first.chars().next().is_some_and(char::is_whitespace))
{
@@ -25,22 +31,59 @@ pub(crate) fn stripped_terminal_title(title: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::stripped_terminal_title;
use crate::detect::Agent;
#[test]
fn manifest_activity_glyph_is_scoped_to_the_effective_agent() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
crate::detect::manifest::reload_manifests();
assert_eq!(
stripped_terminal_title("◐ task", Some(Agent::Claude)).as_deref(),
Some("task")
);
assert_eq!(
stripped_terminal_title("◐ task", Some(Agent::Codex)).as_deref(),
Some("◐ task")
);
assert_eq!(
stripped_terminal_title("◐ task", None).as_deref(),
Some("◐ task")
);
}
#[test]
fn strips_one_recognized_leading_activity_glyph() {
for title in ["⠋ task", "✳ task", " ⠙ task ", "✢ task", "✻ task"] {
assert_eq!(stripped_terminal_title(title).as_deref(), Some("task"));
let _guard = crate::config::test_config_env_lock().lock().unwrap();
crate::detect::manifest::reload_manifests();
for title in [
"⠋ task",
"✳ task",
" ⠙ task ",
"✢ task",
"✻ task",
"◐ task",
"◓ task",
"◑ task",
"◒ task",
] {
assert_eq!(
stripped_terminal_title(title, Some(Agent::Claude)).as_deref(),
Some("task")
);
}
assert_eq!(
stripped_terminal_title("⠋ ⠙ task").as_deref(),
stripped_terminal_title("⠋ ⠙ task", None).as_deref(),
Some("⠙ task")
);
}
#[test]
fn preserves_unrecognized_or_unbounded_symbols() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
crate::detect::manifest::reload_manifests();
for (title, expected) in [
("★task", "★task"),
("◐task", "◐task"),
("★ production", "★ production"),
("✨ task", "✨ task"),
("☼ status", "☼ status"),
@@ -48,31 +91,38 @@ mod tests {
("task ⠋ detail", "task ⠋ detail"),
("[prod] task", "[prod] task"),
] {
assert_eq!(stripped_terminal_title(title).as_deref(), Some(expected));
assert_eq!(
stripped_terminal_title(title, Some(Agent::Claude)).as_deref(),
Some(expected)
);
}
}
#[test]
fn preserves_unicode_text_and_elides_empty_results() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
crate::detect::manifest::reload_manifests();
assert_eq!(
stripped_terminal_title(" ⠋ 修复🙂标题 ").as_deref(),
stripped_terminal_title(" ⠋ 修复🙂标题 ", None).as_deref(),
Some("修复🙂标题")
);
assert_eq!(stripped_terminal_title(" "), None);
assert_eq!(stripped_terminal_title("⠋ "), None);
assert_eq!(stripped_terminal_title(" ", None), None);
assert_eq!(stripped_terminal_title("⠋ ", None), None);
}
#[cfg(windows)]
#[test]
fn strips_one_windows_elevation_decoration_before_activity_glyph() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
crate::detect::manifest::reload_manifests();
assert_eq!(
stripped_terminal_title("Administrator: ⠋ task").as_deref(),
stripped_terminal_title("Administrator: ⠋ task", None).as_deref(),
Some("task")
);
assert_eq!(
stripped_terminal_title("Administrator: Administrator: task").as_deref(),
stripped_terminal_title("Administrator: Administrator: task", None).as_deref(),
Some("Administrator: task")
);
assert_eq!(stripped_terminal_title("Administrator: "), None);
assert_eq!(stripped_terminal_title("Administrator: ", None), None);
}
}