From 34651dd4cf231cd0331c32f606c92f0df0dad39e Mon Sep 17 00:00:00 2001 From: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:19:53 +0000 Subject: [PATCH] fix: source title activity glyphs from manifests refs #2707 --- scripts/agent_detection_manifest_check.py | 90 +++++++- .../test_agent_detection_manifest_check.py | 27 +++ src/app/actions.rs | 13 +- src/app/api.rs | 123 ++++++++++- src/app/terminal_titles.rs | 198 +++++++++++++++++- src/detect/manifest.rs | 45 ++++ src/detect/manifest/tests.rs | 84 ++++++++ src/detect/manifest_update.rs | 6 +- src/detect/manifests/claude.toml | 7 +- src/terminal/state.rs | 13 +- src/terminal/title.rs | 80 +++++-- 11 files changed, 650 insertions(+), 36 deletions(-) diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py index f180318b..59e7c0cc 100644 --- a/scripts/agent_detection_manifest_check.py +++ b/scripts/agent_detection_manifest_check.py @@ -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: diff --git a/scripts/test_agent_detection_manifest_check.py b/scripts/test_agent_detection_manifest_check.py index a9ec38d1..410f2442 100644 --- a/scripts/test_agent_detection_manifest_check.py +++ b/scripts/test_agent_detection_manifest_check.py @@ -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" diff --git a/src/app/actions.rs b/src/app/actions.rs index d6e264f5..870cc7d9 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -241,6 +241,7 @@ pub struct PaneStateUpdate { pub ws_idx: usize, pub previous_agent_label: Option, pub previous_known_agent: Option, + 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(), diff --git a/src/app/api.rs b/src/app/api.rs index 92d9596f..587a3248 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -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(); diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs index 0ed2a0f7..7c4d7d23 100644 --- a/src/app/terminal_titles.rs +++ b/src/app/terminal_titles.rs @@ -33,6 +33,63 @@ impl App { changes } + pub(crate) fn terminal_title_projection_snapshot( + &self, + agents: Option<&[crate::detect::Agent]>, + ) -> std::collections::HashMap> { + 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>, + ) -> 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, @@ -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(); diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index ed3de507..579c7889 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -123,6 +123,7 @@ pub struct RuleEvidence { #[derive(Debug, Clone)] struct LoadedManifest { manifest: AgentManifest, + terminal_title_activity_regex: Option, compiled_rules: Vec, source: ManifestSource, warning: Option, @@ -145,6 +146,7 @@ pub(crate) struct AgentManifest { _updated_at: Option, #[serde(default)] aliases: Vec, + terminal_title_activity_regex: Option, #[serde(default)] rules: Vec, } @@ -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 { 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, local_override_shadowing_remote: bool, ) -> Result { + 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()); } diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 265dfa69..0ee7fb87 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -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( diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs index 362dd906..5a60cd06 100644 --- a/src/detect/manifest_update.rs +++ b/src/detect/manifest_update.rs @@ -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 { - 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); diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml index 7e9a3163..2430f5b8 100644 --- a/src/detect/manifests/claude.toml +++ b/src/detect/manifests/claude.toml @@ -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" diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 4b1fdaef..185e0c8c 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -219,7 +219,18 @@ impl TerminalState { pub(crate) fn terminal_title_stripped(&self) -> Option { 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, + ) -> 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) -> TerminalTitleChange { diff --git a/src/terminal/title.rs b/src/terminal/title.rs index 6958be00..61219a2e 100644 --- a/src/terminal/title.rs +++ b/src/terminal/title.rs @@ -1,6 +1,7 @@ -const CLAUDE_ACTIVITY_GLYPHS: &str = "·✢✳✶✻✽"; - -pub(crate) fn stripped_terminal_title(title: &str) -> Option { +pub(crate) fn stripped_terminal_title( + title: &str, + agent: Option, +) -> Option { 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 { 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 { #[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); } }