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 1/5] 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 f180318bd1..59e7c0cc67 100644 --- a/scripts/agent_detection_manifest_check.py +++ b/scripts/agent_detection_manifest_check.py @@ -16,7 +16,15 @@ 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_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_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 a9ec38d138..410f2442ac 100644 --- a/scripts/test_agent_detection_manifest_check.py +++ b/scripts/test_agent_detection_manifest_check.py @@ -151,6 +151,33 @@ def test_rejects_manifest_requiring_newer_engine(self): 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 d6e264f56f..870cc7d928 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 92d9596fb7..587a32489d 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 0ed2a0f791..7c4d7d230b 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 ed3de50787..579c7889f4 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 265dfa69e5..0ee7fb873e 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 362dd9060c..5a60cd0611 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 7e9a316373..2430f5b89f 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 4b1fdaef4a..185e0c8c2b 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 6958be0040..61219a2e66 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); } } From ce6eb8e0632ef20f703ce861840e6348345ac112 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 12 Aug 2026 19:42:18 +0300 Subject: [PATCH 2/5] fix: simplify manifest-driven title stripping refs #2707 --- scripts/agent_detection_manifest_check.py | 79 +++++-------------- .../test_agent_detection_manifest_check.py | 19 ++--- src/app/actions.rs | 7 +- src/app/agent_resume.rs | 9 ++- src/app/api.rs | 35 ++++---- src/app/mod.rs | 3 + src/app/terminal_titles.rs | 39 ++++----- src/detect/manifest.rs | 41 +++++----- src/detect/manifest/tests.rs | 61 ++++---------- src/detect/manifests/claude.toml | 2 +- src/terminal/state.rs | 31 +++++--- src/terminal/title.rs | 53 ++++--------- 12 files changed, 140 insertions(+), 239 deletions(-) diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py index 59e7c0cc67..06ac8cba12 100644 --- a/scripts/agent_detection_manifest_check.py +++ b/scripts/agent_detection_manifest_check.py @@ -22,7 +22,7 @@ "min_engine_version", "updated_at", "aliases", - "terminal_title_activity_regex", + "terminal_title_activity_glyphs", "rules", } RULE_KEYS = { @@ -53,8 +53,6 @@ ) 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 @@ -158,23 +156,30 @@ 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: + title_activity_glyphs = manifest.get("terminal_title_activity_glyphs") + if title_activity_glyphs is not None: if min_engine < 4: raise CheckError( - f"{path}: terminal_title_activity_regex requires min_engine_version 4" + f"{path}: terminal_title_activity_glyphs 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: + if not isinstance(title_activity_glyphs, str): + raise CheckError(f"{path}: terminal_title_activity_glyphs must be a string") + if not title_activity_glyphs: + raise CheckError(f"{path}: terminal_title_activity_glyphs must not be empty") + if len(title_activity_glyphs) > MAX_MATCHER_CHARS: raise CheckError( - f"{path}: terminal_title_activity_regex exceeds max length {MAX_MATCHER_CHARS}" + f"{path}: terminal_title_activity_glyphs exceeds max length {MAX_MATCHER_CHARS}" ) - if not title_activity_regex.startswith("^") or not title_activity_regex.endswith("$"): + if any( + char.isalnum() + or char.isspace() + or ord(char) < 32 + or 0x7F <= ord(char) <= 0x9F + for char in title_activity_glyphs + ): raise CheckError( - f"{path}: terminal_title_activity_regex must be anchored with ^ and $" + f"{path}: terminal_title_activity_glyphs must contain only non-text glyphs" ) - validate_title_activity_regex(path, title_activity_regex) rules = manifest.get("rules") if not isinstance(rules, list) or not rules: @@ -193,54 +198,6 @@ 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") diff --git a/scripts/test_agent_detection_manifest_check.py b/scripts/test_agent_detection_manifest_check.py index 410f2442ac..0079136bc3 100644 --- a/scripts/test_agent_detection_manifest_check.py +++ b/scripts/test_agent_detection_manifest_check.py @@ -151,31 +151,22 @@ def test_rejects_manifest_requiring_newer_engine(self): 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): + def test_validates_title_activity_glyphs(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}]$'", + 'min_engine_version = 4\nterminal_title_activity_glyphs = "◐◓◑◒"', ) 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"), - ]: + for invalid in ("", "A◆", "◆ ◇", "\x7f"): with self.subTest(invalid=invalid): - manifest_path.write_text( - base.replace("^[\\x{25D0}-\\x{25D3}]$", invalid) - ) - with self.assertRaisesRegex(check.CheckError, error): + manifest_path.write_text(base.replace("◐◓◑◒", invalid)) + with self.assertRaises(check.CheckError): check.load_manifest_dir(bundled, engine_version=4) def test_rejects_top_non_empty_lines_below_engine_three(self): diff --git a/src/app/actions.rs b/src/app/actions.rs index 870cc7d928..4d268f3a3b 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2982,14 +2982,15 @@ impl AppState { ) = { 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 previous_known_agent = terminal.effective_known_agent(); 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 terminal_title_stripped_changed = - terminal.reconcile_terminal_title_projection(previous_stripped_title); + let known_agent = terminal.effective_known_agent(); + let terminal_title_stripped_changed = previous_known_agent != known_agent + && super::terminal_titles::reconcile_terminal_title_policy(terminal); let unchanged_change = (mutation.agent_released || agent_name_changed || terminal_title_stripped_changed) .then(|| terminal.unchanged_effective_state_change_at(now)); diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index 29c2c31768..954e522df0 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -256,8 +256,13 @@ impl App { err = %err, "failed to start shell for deferred agent resume" ); - if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { - terminal.clear_agent_runtime_identity_after_respawn(); + let title_changed = self.state.terminals.get_mut(&terminal_id).is_some_and( + crate::terminal::TerminalState::clear_agent_runtime_identity_after_respawn, + ); + if title_changed { + if let Some((ws_idx, _)) = self.find_pane(pane_id) { + self.emit_pane_updated(ws_idx, pane_id); + } } return false; } diff --git a/src/app/api.rs b/src/app/api.rs index 587a32489d..e092501c3c 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -305,23 +305,20 @@ impl App { } else { None }; - let manifest_title_snapshot = manifest_update_agents + let manifests_changed = 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()) - { + .is_some_and(|agents| !agents.is_empty()); + if manifests_changed { 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(agents) = manifest_update_agents.as_ref() { + self.reset_agent_detection_for_agents(agents); } - if let Some(previous) = manifest_title_snapshot { - self.reconcile_terminal_titles_after_manifest_reload(&previous); + if manifests_changed { + self.reconcile_terminal_titles_after_manifest_reload(); } if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { @@ -613,8 +610,11 @@ impl App { }; self.terminal_runtimes.insert(terminal_id.clone(), runtime); - if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { - terminal.clear_agent_runtime_identity_after_respawn(); + let title_changed = self.state.terminals.get_mut(&terminal_id).is_some_and( + crate::terminal::TerminalState::clear_agent_runtime_identity_after_respawn, + ); + if title_changed { + self.emit_pane_updated(ws_idx, pane_id); } self.state.focus_pane_in_workspace(ws_idx, pane_id); self.schedule_session_save(); @@ -995,12 +995,11 @@ 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); + self.reconcile_terminal_titles_after_manifest_reload(); SuccessResponse { id: request.id, result: ResponseResult::AgentManifestReload { @@ -1479,13 +1478,13 @@ mod tests { } #[tokio::test] - async fn manifest_update_event_activates_titles_on_the_app_thread() { + async fn manifest_update_event_reconciles_all_active_title_policies() { 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", + "app::api::tests::manifest_update_event_reconciles_all_active_title_policies", "--nocapture", ]) .env(CHILD_ENV, "1") @@ -1537,7 +1536,7 @@ id = "claude" version = "9999.01.01.1" min_engine_version = 4 updated_at = "9999-01-01T00:00:00Z" -terminal_title_activity_regex = '^◆$' +terminal_title_activity_glyphs = "◆" [[rules]] id = "idle" @@ -1549,7 +1548,7 @@ contains = ["remote-ready"] app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated { updated: vec![crate::detect::manifest_update::ManifestUpdateCommit { - agent: Agent::Claude, + agent: Agent::Codex, version: crate::detect::manifest_update::ManifestVersion::parse("9999.01.01.1") .unwrap(), }], diff --git a/src/app/mod.rs b/src/app/mod.rs index d3d7995442..c508fba102 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -700,6 +700,9 @@ impl App { }; state.terminals = restored_terminals; + for terminal in state.terminals.values_mut() { + terminal_titles::reconcile_terminal_title_policy(terminal); + } for ws_idx in 0..state.workspaces.len() { let cwd = state.workspaces[ws_idx] diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs index 7c4d7d230b..223b730e6a 100644 --- a/src/app/terminal_titles.rs +++ b/src/app/terminal_titles.rs @@ -3,6 +3,16 @@ use std::collections::HashSet; use super::App; use crate::layout::PaneId; +pub(crate) fn reconcile_terminal_title_policy( + terminal: &mut crate::terminal::TerminalState, +) -> bool { + let activity_glyphs = terminal + .effective_known_agent() + .map(crate::detect::manifest::terminal_title_activity_glyphs) + .unwrap_or_default(); + terminal.reconcile_terminal_title_projection(activity_glyphs) +} + #[derive(Debug, Default, PartialEq, Eq)] pub(crate) struct TerminalTitleChanges { pub(crate) raw_changed: bool, @@ -33,35 +43,13 @@ 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()) { + if reconcile_terminal_title_policy(terminal) { changes.stripped_changed = true; changed_terminals.insert(terminal_id.clone()); } @@ -158,6 +146,9 @@ mod tests { let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); terminal.detected_agent = Some(Agent::Claude); terminal.state = AgentState::Working; + terminal.reconcile_terminal_title_projection( + crate::detect::manifest::terminal_title_activity_glyphs(Agent::Claude), + ); let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); runtime.test_process_pty_bytes("\x1b]0;◐ 修复🙂标题\x07".as_bytes()); app.terminal_runtimes.insert(terminal_id.clone(), runtime); @@ -309,7 +300,7 @@ mod tests { r#" id = "claude" min_engine_version = 4 -terminal_title_activity_regex = '^◆$' +terminal_title_activity_glyphs = "◆" [[rules]] id = "idle" diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index 579c7889f4..2e5f1705b6 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -123,7 +123,6 @@ pub struct RuleEvidence { #[derive(Debug, Clone)] struct LoadedManifest { manifest: AgentManifest, - terminal_title_activity_regex: Option, compiled_rules: Vec, source: ManifestSource, warning: Option, @@ -146,7 +145,7 @@ pub(crate) struct AgentManifest { _updated_at: Option, #[serde(default)] aliases: Vec, - terminal_title_activity_regex: Option, + terminal_title_activity_glyphs: Option, #[serde(default)] rules: Vec, } @@ -359,7 +358,7 @@ 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 { +pub(crate) fn terminal_title_activity_glyphs(agent: Agent) -> String { let lock = manifest_cache(); let guard = match lock.read() { Ok(guard) => guard, @@ -370,8 +369,8 @@ pub(crate) fn terminal_title_activity_matches(agent: Agent, prefix: &str) -> boo .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)) + .and_then(|loaded| loaded.manifest.terminal_title_activity_glyphs.clone()) + .unwrap_or_default() } pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain { @@ -688,16 +687,9 @@ 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, @@ -916,24 +908,27 @@ 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 let Some(glyphs) = manifest.terminal_title_activity_glyphs.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}" + "terminal_title_activity_glyphs requires min_engine_version {TERMINAL_TITLE_ACTIVITY_ENGINE_VERSION}" )); } - if pattern.chars().count() > MAX_MATCHER_CHARS { + if glyphs.is_empty() { + return Err("terminal_title_activity_glyphs must not be empty".to_string()); + } + if glyphs.chars().count() > MAX_MATCHER_CHARS { return Err(format!( - "terminal_title_activity_regex exceeds max length {MAX_MATCHER_CHARS}" + "terminal_title_activity_glyphs 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 glyphs + .chars() + .any(|glyph| glyph.is_alphanumeric() || glyph.is_whitespace() || glyph.is_control()) + { + return Err( + "terminal_title_activity_glyphs must contain only non-text glyphs".to_string(), + ); } } if manifest.rules.is_empty() { diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 0ee7fb873e..14936470f1 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -29,7 +29,7 @@ contains = ["{contains}"] ) } -fn title_manifest(version: Option<&str>, glyph: char, contains: &str) -> String { +fn title_manifest(version: Option<&str>, glyphs: &str, contains: &str) -> String { let version = version .map(|version| format!("version = \"{version}\"\n")) .unwrap_or_default(); @@ -37,7 +37,7 @@ fn title_manifest(version: Option<&str>, glyph: char, contains: &str) -> String r#" id = "codex" {version}min_engine_version = 4 -terminal_title_activity_regex = '^{glyph}$' +terminal_title_activity_glyphs = "{glyphs}" [[rules]] id = "test" @@ -194,15 +194,13 @@ 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_remote_codex(&title_manifest(Some("9999.01.01.1"), "◆◇", "remote-ready")); + assert_eq!(terminal_title_activity_glyphs(Agent::Codex), "◆◇"); - write_local_codex(&title_manifest(None, '◇', "local-ready")); + 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, "◇")); + assert_eq!(terminal_title_activity_glyphs(Agent::Codex), "◈"); }); } @@ -393,12 +391,17 @@ fn devin_manifest_detects_idle_working_and_blocked_states() { } #[test] -fn manifest_accepts_agent_scoped_terminal_title_activity_regex() { +fn manifest_accepts_agent_scoped_terminal_title_activity_glyphs() { + assert!(parse_manifest(&title_manifest(None, "◆◇", "ready")).is_ok()); + + for glyphs in ["", "A◆", "◆ ◇"] { + assert!(parse_manifest(&title_manifest(None, glyphs, "ready")).is_err()); + } + assert!(parse_manifest( r#" id = "codex" -min_engine_version = 4 -terminal_title_activity_regex = '^[◆◇]$' +terminal_title_activity_glyphs = "◆" [[rules]] id = "idle" @@ -406,41 +409,7 @@ 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()); - } + .is_err()); } #[test] diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml index 2430f5b89f..66cd8d8bd4 100644 --- a/src/detect/manifests/claude.toml +++ b/src/detect/manifests/claude.toml @@ -3,7 +3,7 @@ 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}]$' +terminal_title_activity_glyphs = "·✢✳✶✻✽◐◓◑◒" [[rules]] id = "osc_title_working" diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 185e0c8c2b..d7ff81f711 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -129,6 +129,7 @@ pub struct TerminalState { pub metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option, pub terminal_title: Option, + terminal_title_activity_glyphs: String, pub manual_label: Option, pub agent_name: Option, agent_name_owner: Option, @@ -163,6 +164,7 @@ impl TerminalState { metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, terminal_title: None, + terminal_title_activity_glyphs: String::new(), manual_label: None, agent_name: None, agent_name_owner: None, @@ -217,15 +219,14 @@ impl TerminalState { } pub(crate) fn terminal_title_stripped(&self) -> Option { - self.terminal_title - .as_deref() - .and_then(|title| super::stripped_terminal_title(title, self.effective_known_agent())) + self.terminal_title.as_deref().and_then(|title| { + super::stripped_terminal_title(title, &self.terminal_title_activity_glyphs) + }) } - pub(crate) fn reconcile_terminal_title_projection( - &mut self, - previous_stripped: Option, - ) -> bool { + pub(crate) fn reconcile_terminal_title_projection(&mut self, activity_glyphs: String) -> bool { + let previous_stripped = self.terminal_title_stripped(); + self.terminal_title_activity_glyphs = activity_glyphs; if previous_stripped == self.terminal_title_stripped() { return false; } @@ -2052,7 +2053,7 @@ impl TerminalState { self.managed_agent = None; } - pub fn clear_agent_runtime_identity_after_respawn(&mut self) { + pub fn clear_agent_runtime_identity_after_respawn(&mut self) -> bool { self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; @@ -2070,7 +2071,10 @@ impl TerminalState { self.recent_agent_process_exit = None; self.agent_process_acquisition_pending = false; self.pending_agent_resume_plan = None; + let terminal_title_stripped_changed = + self.reconcile_terminal_title_projection(String::new()); self.clear_agent_name(); + terminal_title_stripped_changed } pub fn is_agent_terminal(&self) -> bool { @@ -5608,8 +5612,11 @@ mod tests { }); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); terminal.set_detected_agent_process_at(Agent::Codex, Instant::now()); + terminal.reconcile_terminal_title_projection("◆".into()); + terminal.set_terminal_title(Some("◆ task".into())); + let revision = terminal.revision; - terminal.clear_agent_runtime_identity_after_respawn(); + assert!(terminal.clear_agent_runtime_identity_after_respawn()); assert_eq!(terminal.state, AgentState::Unknown); assert!(terminal.detected_agent.is_none()); @@ -5617,6 +5624,12 @@ mod tests { assert!(terminal.persisted_agent_session.is_none()); assert!(!terminal.respawn_shell_on_exit); assert!(!terminal.finish_agent_process_acquisition()); + assert!(terminal.terminal_title_activity_glyphs.is_empty()); + assert_eq!( + terminal.terminal_title_stripped().as_deref(), + Some("◆ task") + ); + assert_eq!(terminal.revision, revision + 1); } #[test] diff --git a/src/terminal/title.rs b/src/terminal/title.rs index 61219a2e66..8c8cef780f 100644 --- a/src/terminal/title.rs +++ b/src/terminal/title.rs @@ -1,7 +1,4 @@ -pub(crate) fn stripped_terminal_title( - title: &str, - agent: Option, -) -> Option { +pub(crate) fn stripped_terminal_title(title: &str, activity_glyphs: &str) -> Option { let title = crate::platform::terminal_title_for_presentation(title).trim(); if title.is_empty() { return None; @@ -10,13 +7,7 @@ pub(crate) fn stripped_terminal_title( 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}') - || agent.is_some_and(|agent| { - crate::detect::manifest::terminal_title_activity_matches( - agent, - &title[..first.len_utf8()], - ) - }); + let recognized = matches!(first, '\u{2800}'..='\u{28ff}') || activity_glyphs.contains(first); let stripped = if recognized && (after_first.is_empty() || after_first.chars().next().is_some_and(char::is_whitespace)) { @@ -31,30 +22,21 @@ pub(crate) fn stripped_terminal_title( #[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(); + fn manifest_activity_glyph_is_scoped_to_the_resolved_policy() { assert_eq!( - stripped_terminal_title("◐ task", Some(Agent::Claude)).as_deref(), + stripped_terminal_title("◐ task", "◐◓◑◒").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(), + stripped_terminal_title("◐ task", "").as_deref(), Some("◐ task") ); } #[test] fn strips_one_recognized_leading_activity_glyph() { - let _guard = crate::config::test_config_env_lock().lock().unwrap(); - crate::detect::manifest::reload_manifests(); for title in [ "⠋ task", "✳ task", @@ -67,20 +49,18 @@ mod tests { "◒ task", ] { assert_eq!( - stripped_terminal_title(title, Some(Agent::Claude)).as_deref(), + stripped_terminal_title(title, "·✢✳✶✻✽◐◓◑◒").as_deref(), Some("task") ); } assert_eq!( - stripped_terminal_title("⠋ ⠙ task", None).as_deref(), + stripped_terminal_title("⠋ ⠙ task", "").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"), @@ -88,11 +68,12 @@ mod tests { ("✨ task", "✨ task"), ("☼ status", "☼ status"), ("@ task", "@ task"), + ("A task", "A task"), ("task ⠋ detail", "task ⠋ detail"), ("[prod] task", "[prod] task"), ] { assert_eq!( - stripped_terminal_title(title, Some(Agent::Claude)).as_deref(), + stripped_terminal_title(title, "◐◓◑◒").as_deref(), Some(expected) ); } @@ -100,29 +81,25 @@ mod tests { #[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(" ⠋ 修复🙂标题 ", None).as_deref(), + stripped_terminal_title(" ⠋ 修复🙂标题 ", "").as_deref(), Some("修复🙂标题") ); - assert_eq!(stripped_terminal_title(" ", None), None); - assert_eq!(stripped_terminal_title("⠋ ", None), None); + assert_eq!(stripped_terminal_title(" ", ""), None); + assert_eq!(stripped_terminal_title("⠋ ", ""), 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", None).as_deref(), + stripped_terminal_title("Administrator: ⠋ task", "").as_deref(), Some("task") ); assert_eq!( - stripped_terminal_title("Administrator: Administrator: task", None).as_deref(), + stripped_terminal_title("Administrator: Administrator: task", "").as_deref(), Some("Administrator: task") ); - assert_eq!(stripped_terminal_title("Administrator: ", None), None); + assert_eq!(stripped_terminal_title("Administrator: ", ""), None); } } From f8184ba4632aa21d10c12ce238174ab78d9d129e Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 12 Aug 2026 19:54:22 +0300 Subject: [PATCH 3/5] fix: reconcile handoff title policies refs #2707 --- scripts/test_agent_detection_manifest_check.py | 4 ++-- src/app/mod.rs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/test_agent_detection_manifest_check.py b/scripts/test_agent_detection_manifest_check.py index 0079136bc3..32699e6588 100644 --- a/scripts/test_agent_detection_manifest_check.py +++ b/scripts/test_agent_detection_manifest_check.py @@ -163,9 +163,9 @@ def test_validates_title_activity_glyphs(self): manifest_path.write_text(base) check.load_manifest_dir(bundled, engine_version=4) - for invalid in ("", "A◆", "◆ ◇", "\x7f"): + for invalid in ('', 'A◆', '◆ ◇', r'\u007f'): with self.subTest(invalid=invalid): - manifest_path.write_text(base.replace("◐◓◑◒", invalid)) + manifest_path.write_text(base.replace('"◐◓◑◒"', f'"{invalid}"')) with self.assertRaises(check.CheckError): check.load_manifest_dir(bundled, engine_version=4) diff --git a/src/app/mod.rs b/src/app/mod.rs index c508fba102..32d596f47b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -847,6 +847,9 @@ impl App { app.state.pane_id_aliases = pane_id_aliases; app.state.workspaces = workspaces; app.state.terminals = terminals; + for terminal in app.state.terminals.values_mut() { + terminal_titles::reconcile_terminal_title_policy(terminal); + } app.terminal_runtimes = runtimes.into(); app.state.active = snapshot .active From 2aaf172074d3a76f0f3d9c96e4224f58cf8ea5f8 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 12 Aug 2026 22:23:39 +0300 Subject: [PATCH 4/5] fix: strip claude title spinner frames refs #2707 --- scripts/agent_detection_manifest_check.py | 47 +---- .../test_agent_detection_manifest_check.py | 18 -- src/app/actions.rs | 14 +- src/app/agent_resume.rs | 9 +- src/app/api.rs | 130 +----------- src/app/mod.rs | 6 - src/app/terminal_titles.rs | 189 +----------------- src/detect/manifest.rs | 40 ---- src/detect/manifest/tests.rs | 68 ++----- src/detect/manifest_update.rs | 6 +- src/detect/manifests/claude.toml | 3 +- src/terminal/state.rs | 34 +--- src/terminal/title.rs | 45 ++--- website/agent-detection/claude.toml | 4 +- 14 files changed, 60 insertions(+), 553 deletions(-) diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py index 06ac8cba12..f180318bd1 100644 --- a/scripts/agent_detection_manifest_check.py +++ b/scripts/agent_detection_manifest_check.py @@ -16,15 +16,7 @@ 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", - "terminal_title_activity_glyphs", - "rules", -} +MANIFEST_KEYS = {"id", "version", "min_engine_version", "updated_at", "aliases", "rules"} RULE_KEYS = { "id", "state", @@ -61,15 +53,10 @@ MAX_TOTAL_MATCHERS = 1024 MAX_MATCHER_CHARS = 512 -# 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. +# 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. STAGED_WEBSITE_MANIFESTS = { - "claude": ( - "2026.08.12.2", - "2026.08.12.1", - "03efbec218b6dbde0b8b35ddbb2d495825651935da33cc78ad0c98a44f7aced3", - ), "grok": ( "2026.07.16.2", "2026.07.16.1", @@ -156,31 +143,6 @@ 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_glyphs = manifest.get("terminal_title_activity_glyphs") - if title_activity_glyphs is not None: - if min_engine < 4: - raise CheckError( - f"{path}: terminal_title_activity_glyphs requires min_engine_version 4" - ) - if not isinstance(title_activity_glyphs, str): - raise CheckError(f"{path}: terminal_title_activity_glyphs must be a string") - if not title_activity_glyphs: - raise CheckError(f"{path}: terminal_title_activity_glyphs must not be empty") - if len(title_activity_glyphs) > MAX_MATCHER_CHARS: - raise CheckError( - f"{path}: terminal_title_activity_glyphs exceeds max length {MAX_MATCHER_CHARS}" - ) - if any( - char.isalnum() - or char.isspace() - or ord(char) < 32 - or 0x7F <= ord(char) <= 0x9F - for char in title_activity_glyphs - ): - raise CheckError( - f"{path}: terminal_title_activity_glyphs must contain only non-text glyphs" - ) - rules = manifest.get("rules") if not isinstance(rules, list) or not rules: raise CheckError(f"{path}: rules must be a non-empty array") @@ -365,6 +327,7 @@ 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 32699e6588..a9ec38d138 100644 --- a/scripts/test_agent_detection_manifest_check.py +++ b/scripts/test_agent_detection_manifest_check.py @@ -151,24 +151,6 @@ def test_rejects_manifest_requiring_newer_engine(self): with self.assertRaisesRegex(check.CheckError, "exceeds engine"): check.load_manifest_dir(bundled, engine_version=1) - def test_validates_title_activity_glyphs(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_glyphs = "◐◓◑◒"', - ) - manifest_path = bundled / "codex.toml" - manifest_path.write_text(base) - check.load_manifest_dir(bundled, engine_version=4) - - for invalid in ('', 'A◆', '◆ ◇', r'\u007f'): - with self.subTest(invalid=invalid): - manifest_path.write_text(base.replace('"◐◓◑◒"', f'"{invalid}"')) - with self.assertRaises(check.CheckError): - 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 4d268f3a3b..d6e264f56f 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -241,7 +241,6 @@ 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, @@ -1047,7 +1046,6 @@ 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(), @@ -2975,30 +2973,23 @@ 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_known_agent = terminal.effective_known_agent(); 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 known_agent = terminal.effective_known_agent(); - let terminal_title_stripped_changed = previous_known_agent != known_agent - && super::terminal_titles::reconcile_terminal_title_policy(terminal); - let unchanged_change = - (mutation.agent_released || agent_name_changed || terminal_title_stripped_changed) - .then(|| terminal.unchanged_effective_state_change_at(now)); + let unchanged_change = (mutation.agent_released || agent_name_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, @@ -3023,7 +3014,6 @@ 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/agent_resume.rs b/src/app/agent_resume.rs index 954e522df0..29c2c31768 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -256,13 +256,8 @@ impl App { err = %err, "failed to start shell for deferred agent resume" ); - let title_changed = self.state.terminals.get_mut(&terminal_id).is_some_and( - crate::terminal::TerminalState::clear_agent_runtime_identity_after_respawn, - ); - if title_changed { - if let Some((ws_idx, _)) = self.find_pane(pane_id) { - self.emit_pane_updated(ws_idx, pane_id); - } + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.clear_agent_runtime_identity_after_respawn(); } return false; } diff --git a/src/app/api.rs b/src/app/api.rs index e092501c3c..92d9596fb7 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -305,20 +305,11 @@ impl App { } else { None }; - let manifests_changed = manifest_update_agents - .as_ref() - .is_some_and(|agents| !agents.is_empty()); - if manifests_changed { - 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.as_ref() { - self.reset_agent_detection_for_agents(agents); - } - if manifests_changed { - self.reconcile_terminal_titles_after_manifest_reload(); + if let Some(agents) = manifest_update_agents { + self.reset_agent_detection_for_agents(&agents); } if let Some((pane_id, agent)) = released_agent { if pane_updates.iter().any(|update| update.pane_id == pane_id) { @@ -610,11 +601,8 @@ impl App { }; self.terminal_runtimes.insert(terminal_id.clone(), runtime); - let title_changed = self.state.terminals.get_mut(&terminal_id).is_some_and( - crate::terminal::TerminalState::clear_agent_runtime_identity_after_respawn, - ); - if title_changed { - self.emit_pane_updated(ws_idx, pane_id); + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.clear_agent_runtime_identity_after_respawn(); } self.state.focus_pane_in_workspace(ws_idx, pane_id); self.schedule_session_save(); @@ -627,7 +615,7 @@ impl App { }; let workspace_id = self.public_workspace_id(update.ws_idx); - if update.agent_name_changed || update.terminal_title_stripped_changed { + if update.agent_name_changed { self.emit_pane_updated(update.ws_idx, update.pane_id); } @@ -999,7 +987,6 @@ impl App { 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(); SuccessResponse { id: request.id, result: ResponseResult::AgentManifestReload { @@ -1477,113 +1464,6 @@ mod tests { .expect("matching agent detection runtime should be reset"); } - #[tokio::test] - async fn manifest_update_event_reconciles_all_active_title_policies() { - 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_reconciles_all_active_title_policies", - "--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_glyphs = "◆" - -[[rules]] -id = "idle" -state = "idle" -contains = ["remote-ready"] -"#, - ) - .unwrap(); - - app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated { - updated: vec![crate::detect::manifest_update::ManifestUpdateCommit { - agent: Agent::Codex, - 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/mod.rs b/src/app/mod.rs index 32d596f47b..d3d7995442 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -700,9 +700,6 @@ impl App { }; state.terminals = restored_terminals; - for terminal in state.terminals.values_mut() { - terminal_titles::reconcile_terminal_title_policy(terminal); - } for ws_idx in 0..state.workspaces.len() { let cwd = state.workspaces[ws_idx] @@ -847,9 +844,6 @@ impl App { app.state.pane_id_aliases = pane_id_aliases; app.state.workspaces = workspaces; app.state.terminals = terminals; - for terminal in app.state.terminals.values_mut() { - terminal_titles::reconcile_terminal_title_policy(terminal); - } app.terminal_runtimes = runtimes.into(); app.state.active = snapshot .active diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs index 223b730e6a..0ed2a0f791 100644 --- a/src/app/terminal_titles.rs +++ b/src/app/terminal_titles.rs @@ -3,16 +3,6 @@ use std::collections::HashSet; use super::App; use crate::layout::PaneId; -pub(crate) fn reconcile_terminal_title_policy( - terminal: &mut crate::terminal::TerminalState, -) -> bool { - let activity_glyphs = terminal - .effective_known_agent() - .map(crate::detect::manifest::terminal_title_activity_glyphs) - .unwrap_or_default(); - terminal.reconcile_terminal_title_projection(activity_glyphs) -} - #[derive(Debug, Default, PartialEq, Eq)] pub(crate) struct TerminalTitleChanges { pub(crate) raw_changed: bool, @@ -43,41 +33,6 @@ impl App { changes } - pub(crate) fn reconcile_terminal_titles_after_manifest_reload( - &mut self, - ) -> TerminalTitleChanges { - let mut changes = TerminalTitleChanges::default(); - let mut changed_terminals = HashSet::new(); - for (terminal_id, terminal) in &mut self.state.terminals { - if reconcile_terminal_title_policy(terminal) { - 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, @@ -131,8 +86,6 @@ 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()); @@ -146,11 +99,8 @@ mod tests { let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); terminal.detected_agent = Some(Agent::Claude); terminal.state = AgentState::Working; - terminal.reconcile_terminal_title_projection( - crate::detect::manifest::terminal_title_activity_glyphs(Agent::Claude), - ); 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]); @@ -162,19 +112,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 { @@ -183,7 +133,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); @@ -207,135 +157,6 @@ 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_glyphs = "◆" - -[[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 2e5f1705b6..ed3de50787 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -145,7 +145,6 @@ pub(crate) struct AgentManifest { _updated_at: Option, #[serde(default)] aliases: Vec, - terminal_title_activity_glyphs: Option, #[serde(default)] rules: Vec, } @@ -268,7 +267,6 @@ 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 @@ -358,21 +356,6 @@ pub fn explain_with_input(agent: Agent, input: DetectionInput<'_>) -> DetectionE evaluate_loaded_manifest(agent, input, loaded, true) } -pub(crate) fn terminal_title_activity_glyphs(agent: Agent) -> String { - 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.manifest.terminal_title_activity_glyphs.clone()) - .unwrap_or_default() -} - pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain { let Some(agent) = parse_agent_label(agent_label) else { return DetectionExplain { @@ -908,29 +891,6 @@ pub(crate) fn parse_remote_manifest_for_agent( } fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> { - if let Some(glyphs) = manifest.terminal_title_activity_glyphs.as_deref() { - if manifest.min_engine_version.unwrap_or(0) < TERMINAL_TITLE_ACTIVITY_ENGINE_VERSION { - return Err(format!( - "terminal_title_activity_glyphs requires min_engine_version {TERMINAL_TITLE_ACTIVITY_ENGINE_VERSION}" - )); - } - if glyphs.is_empty() { - return Err("terminal_title_activity_glyphs must not be empty".to_string()); - } - if glyphs.chars().count() > MAX_MATCHER_CHARS { - return Err(format!( - "terminal_title_activity_glyphs exceeds max length {MAX_MATCHER_CHARS}" - )); - } - if glyphs - .chars() - .any(|glyph| glyph.is_alphanumeric() || glyph.is_whitespace() || glyph.is_control()) - { - return Err( - "terminal_title_activity_glyphs must contain only non-text glyphs".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 14936470f1..8d978e83f6 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -29,24 +29,6 @@ contains = ["{contains}"] ) } -fn title_manifest(version: Option<&str>, glyphs: &str, 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_glyphs = "{glyphs}" - -[[rules]] -id = "test" -state = "idle" -contains = ["{contains}"] -"# - ) -} - fn rules_manifest(rules: &str) -> String { format!( r#" @@ -191,19 +173,6 @@ 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_eq!(terminal_title_activity_glyphs(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_eq!(terminal_title_activity_glyphs(Agent::Codex), "◈"); - }); -} - #[test] fn fallback_explain_preserves_active_manifest_version() { with_manifest_dirs("fallback-version", || { @@ -390,28 +359,6 @@ fn devin_manifest_detects_idle_working_and_blocked_states() { assert!(permission_prompt.visible_blocker); } -#[test] -fn manifest_accepts_agent_scoped_terminal_title_activity_glyphs() { - assert!(parse_manifest(&title_manifest(None, "◆◇", "ready")).is_ok()); - - for glyphs in ["", "A◆", "◆ ◇"] { - assert!(parse_manifest(&title_manifest(None, glyphs, "ready")).is_err()); - } - - assert!(parse_manifest( - r#" -id = "codex" -terminal_title_activity_glyphs = "◆" - -[[rules]] -id = "idle" -state = "idle" -contains = ["ready"] -"# - ) - .is_err()); -} - #[test] fn manifest_validation_rejects_unknown_fields_empty_rules_invalid_regions_and_regexes() { assert!(parse_manifest( @@ -678,6 +625,21 @@ fn claude_osc_title_braille_prefix_is_working() { assert!(result.visible_working); } +#[test] +fn claude_osc_title_half_circle_frames_are_working() { + for frame in ['◐', '◓', '◑', '◒'] { + let title = format!("{frame} project"); + let result = osc_explain(Agent::Claude, "", &title, ""); + assert_eq!(result.state, AgentState::Working, "frame {frame}"); + assert_eq!( + result.matched_rule.as_ref().map(|rule| rule.id.as_str()), + Some("osc_title_working"), + "frame {frame}" + ); + assert!(result.visible_working, "frame {frame}"); + } +} + #[test] fn claude_osc_title_static_prefix_is_idle() { // "✳" is U+2733, static prefix when Claude is not working diff --git a/src/detect/manifest_update.rs b/src/detect/manifest_update.rs index 5a60cd0611..362dd9060c 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 = 4; +pub(crate) const MANIFEST_ENGINE_VERSION: u32 = 3; 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,6 +169,9 @@ 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, @@ -665,7 +668,6 @@ 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 66cd8d8bd4..bfc3b26679 100644 --- a/src/detect/manifests/claude.toml +++ b/src/detect/manifests/claude.toml @@ -1,9 +1,8 @@ id = "claude" version = "2026.08.12.2" -min_engine_version = 4 +min_engine_version = 2 updated_at = "2026-08-12T00:00:00Z" aliases = ["claude-code"] -terminal_title_activity_glyphs = "·✢✳✶✻✽◐◓◑◒" [[rules]] id = "osc_title_working" diff --git a/src/terminal/state.rs b/src/terminal/state.rs index d7ff81f711..4b1fdaef4a 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -129,7 +129,6 @@ pub struct TerminalState { pub metadata_tokens: crate::metadata_tokens::MetadataTokens, pub persisted_agent_session: Option, pub terminal_title: Option, - terminal_title_activity_glyphs: String, pub manual_label: Option, pub agent_name: Option, agent_name_owner: Option, @@ -164,7 +163,6 @@ impl TerminalState { metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), persisted_agent_session: None, terminal_title: None, - terminal_title_activity_glyphs: String::new(), manual_label: None, agent_name: None, agent_name_owner: None, @@ -219,19 +217,9 @@ impl TerminalState { } pub(crate) fn terminal_title_stripped(&self) -> Option { - self.terminal_title.as_deref().and_then(|title| { - super::stripped_terminal_title(title, &self.terminal_title_activity_glyphs) - }) - } - - pub(crate) fn reconcile_terminal_title_projection(&mut self, activity_glyphs: String) -> bool { - let previous_stripped = self.terminal_title_stripped(); - self.terminal_title_activity_glyphs = activity_glyphs; - if previous_stripped == self.terminal_title_stripped() { - return false; - } - self.revision = self.revision.wrapping_add(1); - true + self.terminal_title + .as_deref() + .and_then(super::stripped_terminal_title) } pub(crate) fn set_terminal_title(&mut self, title: Option) -> TerminalTitleChange { @@ -2053,7 +2041,7 @@ impl TerminalState { self.managed_agent = None; } - pub fn clear_agent_runtime_identity_after_respawn(&mut self) -> bool { + pub fn clear_agent_runtime_identity_after_respawn(&mut self) { self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; @@ -2071,10 +2059,7 @@ impl TerminalState { self.recent_agent_process_exit = None; self.agent_process_acquisition_pending = false; self.pending_agent_resume_plan = None; - let terminal_title_stripped_changed = - self.reconcile_terminal_title_projection(String::new()); self.clear_agent_name(); - terminal_title_stripped_changed } pub fn is_agent_terminal(&self) -> bool { @@ -5612,11 +5597,8 @@ mod tests { }); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); terminal.set_detected_agent_process_at(Agent::Codex, Instant::now()); - terminal.reconcile_terminal_title_projection("◆".into()); - terminal.set_terminal_title(Some("◆ task".into())); - let revision = terminal.revision; - assert!(terminal.clear_agent_runtime_identity_after_respawn()); + terminal.clear_agent_runtime_identity_after_respawn(); assert_eq!(terminal.state, AgentState::Unknown); assert!(terminal.detected_agent.is_none()); @@ -5624,12 +5606,6 @@ mod tests { assert!(terminal.persisted_agent_session.is_none()); assert!(!terminal.respawn_shell_on_exit); assert!(!terminal.finish_agent_process_acquisition()); - assert!(terminal.terminal_title_activity_glyphs.is_empty()); - assert_eq!( - terminal.terminal_title_stripped().as_deref(), - Some("◆ task") - ); - assert_eq!(terminal.revision, revision + 1); } #[test] diff --git a/src/terminal/title.rs b/src/terminal/title.rs index 8c8cef780f..89b4fed26a 100644 --- a/src/terminal/title.rs +++ b/src/terminal/title.rs @@ -1,4 +1,6 @@ -pub(crate) fn stripped_terminal_title(title: &str, activity_glyphs: &str) -> Option { +const CLAUDE_ACTIVITY_GLYPHS: &str = "·✢✳✶✻✽◐◓◑◒"; + +pub(crate) fn stripped_terminal_title(title: &str) -> Option { let title = crate::platform::terminal_title_for_presentation(title).trim(); if title.is_empty() { return None; @@ -7,7 +9,8 @@ pub(crate) fn stripped_terminal_title(title: &str, activity_glyphs: &str) -> Opt 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}') || activity_glyphs.contains(first); + let recognized = + matches!(first, '\u{2800}'..='\u{28ff}') || CLAUDE_ACTIVITY_GLYPHS.contains(first); let stripped = if recognized && (after_first.is_empty() || after_first.chars().next().is_some_and(char::is_whitespace)) { @@ -23,18 +26,6 @@ pub(crate) fn stripped_terminal_title(title: &str, activity_glyphs: &str) -> Opt mod tests { use super::stripped_terminal_title; - #[test] - fn manifest_activity_glyph_is_scoped_to_the_resolved_policy() { - assert_eq!( - stripped_terminal_title("◐ task", "◐◓◑◒").as_deref(), - Some("task") - ); - assert_eq!( - stripped_terminal_title("◐ task", "").as_deref(), - Some("◐ task") - ); - } - #[test] fn strips_one_recognized_leading_activity_glyph() { for title in [ @@ -48,13 +39,10 @@ mod tests { "◑ task", "◒ task", ] { - assert_eq!( - stripped_terminal_title(title, "·✢✳✶✻✽◐◓◑◒").as_deref(), - Some("task") - ); + assert_eq!(stripped_terminal_title(title).as_deref(), Some("task")); } assert_eq!( - stripped_terminal_title("⠋ ⠙ task", "").as_deref(), + stripped_terminal_title("⠋ ⠙ task").as_deref(), Some("⠙ task") ); } @@ -63,43 +51,38 @@ mod tests { fn preserves_unrecognized_or_unbounded_symbols() { for (title, expected) in [ ("★task", "★task"), - ("◐task", "◐task"), ("★ production", "★ production"), ("✨ task", "✨ task"), ("☼ status", "☼ status"), ("@ task", "@ task"), - ("A task", "A task"), ("task ⠋ detail", "task ⠋ detail"), ("[prod] task", "[prod] task"), ] { - assert_eq!( - stripped_terminal_title(title, "◐◓◑◒").as_deref(), - Some(expected) - ); + assert_eq!(stripped_terminal_title(title).as_deref(), Some(expected)); } } #[test] fn preserves_unicode_text_and_elides_empty_results() { assert_eq!( - stripped_terminal_title(" ⠋ 修复🙂标题 ", "").as_deref(), + stripped_terminal_title(" ⠋ 修复🙂标题 ").as_deref(), Some("修复🙂标题") ); - assert_eq!(stripped_terminal_title(" ", ""), None); - assert_eq!(stripped_terminal_title("⠋ ", ""), None); + assert_eq!(stripped_terminal_title(" "), None); + assert_eq!(stripped_terminal_title("⠋ "), None); } #[cfg(windows)] #[test] fn strips_one_windows_elevation_decoration_before_activity_glyph() { assert_eq!( - stripped_terminal_title("Administrator: ⠋ task", "").as_deref(), + stripped_terminal_title("Administrator: ⠋ task").as_deref(), Some("task") ); assert_eq!( - stripped_terminal_title("Administrator: Administrator: task", "").as_deref(), + stripped_terminal_title("Administrator: Administrator: task").as_deref(), Some("Administrator: task") ); - assert_eq!(stripped_terminal_title("Administrator: ", ""), None); + assert_eq!(stripped_terminal_title("Administrator: "), None); } } diff --git a/website/agent-detection/claude.toml b/website/agent-detection/claude.toml index 7e9a316373..bfc3b26679 100644 --- a/website/agent-detection/claude.toml +++ b/website/agent-detection/claude.toml @@ -1,5 +1,5 @@ id = "claude" -version = "2026.08.12.1" +version = "2026.08.12.2" min_engine_version = 2 updated_at = "2026-08-12T00:00:00Z" aliases = ["claude-code"] @@ -11,7 +11,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" From c9d29568d5eb2ddf2b55782d2a79ad52f6421d36 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 12 Aug 2026 22:39:37 +0300 Subject: [PATCH 5/5] fix: keep claude detection manifest unchanged --- src/detect/manifest/tests.rs | 15 --------------- src/detect/manifests/claude.toml | 4 ++-- website/agent-detection/claude.toml | 4 ++-- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 8d978e83f6..265dfa69e5 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -625,21 +625,6 @@ fn claude_osc_title_braille_prefix_is_working() { assert!(result.visible_working); } -#[test] -fn claude_osc_title_half_circle_frames_are_working() { - for frame in ['◐', '◓', '◑', '◒'] { - let title = format!("{frame} project"); - let result = osc_explain(Agent::Claude, "", &title, ""); - assert_eq!(result.state, AgentState::Working, "frame {frame}"); - assert_eq!( - result.matched_rule.as_ref().map(|rule| rule.id.as_str()), - Some("osc_title_working"), - "frame {frame}" - ); - assert!(result.visible_working, "frame {frame}"); - } -} - #[test] fn claude_osc_title_static_prefix_is_idle() { // "✳" is U+2733, static prefix when Claude is not working diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml index bfc3b26679..7e9a316373 100644 --- a/src/detect/manifests/claude.toml +++ b/src/detect/manifests/claude.toml @@ -1,5 +1,5 @@ id = "claude" -version = "2026.08.12.2" +version = "2026.08.12.1" min_engine_version = 2 updated_at = "2026-08-12T00:00:00Z" aliases = ["claude-code"] @@ -11,7 +11,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{25D3}] '] +regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D1}] '] [[rules]] id = "btw_overlay_working" diff --git a/website/agent-detection/claude.toml b/website/agent-detection/claude.toml index bfc3b26679..7e9a316373 100644 --- a/website/agent-detection/claude.toml +++ b/website/agent-detection/claude.toml @@ -1,5 +1,5 @@ id = "claude" -version = "2026.08.12.2" +version = "2026.08.12.1" min_engine_version = 2 updated_at = "2026-08-12T00:00:00Z" aliases = ["claude-code"] @@ -11,7 +11,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{25D3}] '] +regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D1}] '] [[rules]] id = "btw_overlay_working"