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