aboutsummaryrefslogtreecommitdiff
path: root/crates/detect-core/src/manifest.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/detect-core/src/manifest.rs')
-rw-r--r--crates/detect-core/src/manifest.rs1546
1 files changed, 1546 insertions, 0 deletions
diff --git a/crates/detect-core/src/manifest.rs b/crates/detect-core/src/manifest.rs
new file mode 100644
index 0000000..dba7520
--- /dev/null
+++ b/crates/detect-core/src/manifest.rs
@@ -0,0 +1,1546 @@
+use std::{
+ path::{Path, PathBuf},
+ sync::{Arc, Mutex, OnceLock, RwLock},
+};
+
+use regex::Regex;
+use serde::Deserialize;
+
+use super::{
+ agent_label, manifest_update::ManifestVersion, parse_agent_label, Agent, AgentDetection,
+ AgentState,
+};
+
+pub const DEFAULT_KNOWN_AGENT_IDLE_FALLBACK: &str = "default_known_agent_idle_fallback";
+
+/// Input to the detection engine, carrying the screen snapshot plus any
+/// OSC-derived strings captured from the terminal title / progress sequences.
+/// Pass empty strings for `osc_title` and `osc_progress` when the data is not
+/// available — behavior is identical to the pre-OSC engine in that case.
+#[derive(Debug, Clone, Copy)]
+pub struct DetectionInput<'a> {
+ pub screen: &'a str,
+ pub osc_title: &'a str,
+ pub osc_progress: &'a str,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DetectionExplain {
+ pub agent: Option<String>,
+ pub state: AgentState,
+ pub source: Option<ManifestSource>,
+ pub matched_rule: Option<MatchedRule>,
+ pub screen_detection_skipped: bool,
+ pub visible_idle: bool,
+ pub visible_blocker: bool,
+ pub visible_working: bool,
+ pub skip_state_update: bool,
+ pub skipped_update_reason: Option<String>,
+ pub fallback_reason: Option<String>,
+ pub evaluated_rules: Vec<EvaluatedRule>,
+ pub warning: Option<String>,
+ pub manifest_version: Option<String>,
+ pub cached_remote_version: Option<String>,
+ pub local_override_shadowing_remote: bool,
+ pub remote_update_status: Option<String>,
+ pub remote_update_error: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ManifestSource {
+ Bundled,
+ Remote { path: PathBuf, version: String },
+ Override(PathBuf),
+}
+
+impl ManifestSource {
+ pub fn label(&self) -> String {
+ match self {
+ Self::Bundled => "bundled".to_string(),
+ Self::Remote { path, .. } => format!("remote:{}", path.display()),
+ Self::Override(path) => path.display().to_string(),
+ }
+ }
+
+ pub fn kind(&self) -> &'static str {
+ match self {
+ Self::Bundled => "bundled",
+ Self::Remote { .. } => "remote",
+ Self::Override(_) => "local override",
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AgentManifestSummary {
+ pub agent: Agent,
+ pub active_source: ManifestSource,
+ pub active_version: Option<String>,
+ pub cached_remote_version: Option<String>,
+ pub local_override_shadowing_remote: bool,
+ pub warning: Option<String>,
+}
+
+pub fn manifest_summaries() -> Vec<AgentManifestSummary> {
+ let lock = manifest_cache();
+ let guard = match lock.read() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ manifest_summaries_from_cache(&guard)
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MatchedRule {
+ pub id: String,
+ pub priority: i32,
+ pub region: String,
+ pub state: AgentState,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EvaluatedRule {
+ pub id: String,
+ pub priority: i32,
+ pub region: String,
+ pub evidence: RuleEvidence,
+ pub state: AgentState,
+ pub matched: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RuleEvidence {
+ pub contains: Vec<String>,
+ pub regex: Vec<String>,
+ pub line_regex: Vec<String>,
+ pub all_count: usize,
+ pub any_count: usize,
+ pub not_count: usize,
+ pub region_bytes: usize,
+ pub region_preview: String,
+}
+
+#[derive(Debug, Clone)]
+struct LoadedManifest {
+ manifest: AgentManifest,
+ // Keep Regex search caches warm across manifest loads and pane polling.
+ compiled_rules: Arc<[CompiledRule]>,
+ source: ManifestSource,
+ warning: Option<String>,
+ cached_remote_version: Option<String>,
+ local_override_shadowing_remote: bool,
+}
+
+#[derive(Debug, Clone)]
+struct ManifestCache {
+ manifests: Vec<(Agent, Option<LoadedManifest>)>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct AgentManifest {
+ id: String,
+ version: Option<ManifestVersion>,
+ min_engine_version: Option<u32>,
+ #[serde(rename = "updated_at")]
+ _updated_at: Option<String>,
+ #[serde(default)]
+ aliases: Vec<String>,
+ #[serde(default)]
+ rules: Vec<ManifestRule>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+struct ManifestRule {
+ id: String,
+ state: Option<ManifestState>,
+ #[serde(default)]
+ priority: i32,
+ #[serde(default = "default_region")]
+ region: String,
+ #[serde(default)]
+ visible_idle: bool,
+ #[serde(default)]
+ visible_blocker: bool,
+ #[serde(default)]
+ visible_working: bool,
+ #[serde(default)]
+ skip_state_update: bool,
+ #[serde(default)]
+ all: Vec<ManifestGate>,
+ #[serde(default)]
+ any: Vec<ManifestGate>,
+ #[serde(default, rename = "not")]
+ not_gate: Vec<ManifestGate>,
+ #[serde(default)]
+ contains: Vec<String>,
+ #[serde(default)]
+ regex: Vec<String>,
+ #[serde(default)]
+ line_regex: Vec<String>,
+}
+
+#[derive(Debug, Deserialize, Clone)]
+#[serde(deny_unknown_fields)]
+struct ManifestGate {
+ #[serde(default)]
+ all: Vec<ManifestGate>,
+ #[serde(default)]
+ any: Vec<ManifestGate>,
+ #[serde(default, rename = "not")]
+ not_gate: Vec<ManifestGate>,
+ #[serde(default)]
+ contains: Vec<String>,
+ #[serde(default)]
+ regex: Vec<String>,
+ #[serde(default)]
+ line_regex: Vec<String>,
+}
+
+#[derive(Debug, Clone)]
+struct CompiledRule {
+ gate: CompiledGate,
+}
+
+#[derive(Debug, Clone)]
+struct CompiledGate {
+ all: Vec<CompiledGate>,
+ any: Vec<CompiledGate>,
+ not_gate: Vec<CompiledGate>,
+ contains: Vec<String>,
+ regex: Vec<Regex>,
+ line_regex: Vec<Regex>,
+}
+
+#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+enum ManifestState {
+ Idle,
+ Working,
+ Blocked,
+ Unknown,
+}
+
+impl From<ManifestState> for AgentState {
+ fn from(value: ManifestState) -> Self {
+ match value {
+ ManifestState::Idle => AgentState::Idle,
+ ManifestState::Working => AgentState::Working,
+ ManifestState::Blocked => AgentState::Blocked,
+ ManifestState::Unknown => AgentState::Unknown,
+ }
+ }
+}
+
+fn default_region() -> String {
+ "whole_recent".to_string()
+}
+
+const BUNDLED_MANIFESTS: &[(&str, &str)] = &[
+ ("amp", include_str!("manifests/amp.toml")),
+ ("agy", include_str!("manifests/antigravity.toml")),
+ ("claude", include_str!("manifests/claude.toml")),
+ ("cline", include_str!("manifests/cline.toml")),
+ ("codex", include_str!("manifests/codex.toml")),
+ ("cursor", include_str!("manifests/cursor.toml")),
+ ("devin", include_str!("manifests/devin.toml")),
+ ("droid", include_str!("manifests/droid.toml")),
+ ("gemini", include_str!("manifests/gemini.toml")),
+ ("grok", include_str!("manifests/grok.toml")),
+ ("hermes", include_str!("manifests/hermes.toml")),
+ ("kilo", include_str!("manifests/kilo.toml")),
+ ("kimi", include_str!("manifests/kimi.toml")),
+ ("kiro", include_str!("manifests/kiro.toml")),
+ ("letta", include_str!("manifests/letta.toml")),
+ ("maki", include_str!("manifests/maki.toml")),
+ ("muse", include_str!("manifests/muse.toml")),
+ ("opencode", include_str!("manifests/opencode.toml")),
+ ("pi", include_str!("manifests/pi.toml")),
+ ("qodercli", include_str!("manifests/qodercli.toml")),
+ ("qwen", include_str!("manifests/qwen.toml")),
+ ("copilot", include_str!("manifests/github-copilot.toml")),
+];
+
+static MANIFEST_CACHE: OnceLock<RwLock<ManifestCache>> = OnceLock::new();
+static MANIFEST_RELOAD_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+
+const MAX_RULES_PER_MANIFEST: usize = 128;
+const MAX_GATE_DEPTH: usize = 8;
+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;
+
+pub fn reload_manifests() -> Vec<AgentManifestSummary> {
+ let _reload_guard = MANIFEST_RELOAD_LOCK
+ .get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let cache = build_manifest_cache();
+ let summaries = manifest_summaries_from_cache(&cache);
+ let lock = MANIFEST_CACHE.get_or_init(|| RwLock::new(cache.clone()));
+ match lock.write() {
+ Ok(mut guard) => *guard = cache,
+ Err(poisoned) => *poisoned.into_inner() = cache,
+ }
+ summaries
+}
+
+#[allow(dead_code)] // parity with herdr: selective reload after remote manifest refresh
+pub(crate) fn reload_manifests_for_agents(agents: &[Agent]) {
+ if agents.is_empty() {
+ return;
+ }
+
+ let _reload_guard = MANIFEST_RELOAD_LOCK
+ .get_or_init(|| Mutex::new(()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let lock = manifest_cache();
+ let replacements = Agent::SCREEN_MANIFEST_AGENTS
+ .into_iter()
+ .filter(|agent| agents.contains(agent))
+ .map(|agent| (agent, load_manifest_uncached(agent)))
+ .collect::<Vec<_>>();
+ let mut cache = match lock.write() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ for (agent, replacement) in replacements {
+ if let Some((_, loaded)) = cache
+ .manifests
+ .iter_mut()
+ .find(|(cached_agent, _)| *cached_agent == agent)
+ {
+ *loaded = replacement;
+ }
+ }
+}
+
+fn manifest_cache() -> &'static RwLock<ManifestCache> {
+ MANIFEST_CACHE.get_or_init(|| RwLock::new(build_manifest_cache()))
+}
+
+fn build_manifest_cache() -> ManifestCache {
+ ManifestCache {
+ manifests: Agent::SCREEN_MANIFEST_AGENTS
+ .into_iter()
+ .map(|agent| (agent, load_manifest_uncached(agent)))
+ .collect(),
+ }
+}
+
+fn manifest_summaries_from_cache(cache: &ManifestCache) -> Vec<AgentManifestSummary> {
+ cache
+ .manifests
+ .iter()
+ .filter_map(|(agent, loaded)| {
+ loaded
+ .clone()
+ .map(|loaded| manifest_summary_from_loaded(*agent, loaded))
+ })
+ .collect()
+}
+
+fn manifest_summary_from_loaded(agent: Agent, loaded: LoadedManifest) -> AgentManifestSummary {
+ AgentManifestSummary {
+ agent,
+ active_version: loaded.manifest.version.as_ref().map(ToString::to_string),
+ active_source: loaded.source,
+ cached_remote_version: loaded.cached_remote_version,
+ local_override_shadowing_remote: loaded.local_override_shadowing_remote,
+ warning: loaded.warning,
+ }
+}
+
+#[allow(dead_code)] // shim for existing callers; detect_with_osc is the real path
+pub fn detect(agent: Agent, screen_content: &str) -> AgentDetection {
+ detect_with_osc(
+ agent,
+ DetectionInput {
+ screen: screen_content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ )
+}
+
+pub fn detect_with_osc(agent: Agent, input: DetectionInput<'_>) -> AgentDetection {
+ let Some(loaded) = load_manifest(agent) else {
+ return fallback_explain(Some(agent), None, false).into_detection();
+ };
+ evaluate_loaded_manifest(agent, input, loaded, false).into_detection()
+}
+
+pub fn explain(agent: Agent, screen_content: &str) -> DetectionExplain {
+ explain_with_input(
+ agent,
+ DetectionInput {
+ screen: screen_content,
+ osc_title: "",
+ osc_progress: "",
+ },
+ )
+}
+
+pub fn explain_with_input(agent: Agent, input: DetectionInput<'_>) -> DetectionExplain {
+ let Some(loaded) = load_manifest(agent) else {
+ return fallback_explain(Some(agent), None, true);
+ };
+ evaluate_loaded_manifest(agent, input, loaded, true)
+}
+
+pub fn explain_for_label(agent_label: &str, screen_content: &str) -> DetectionExplain {
+ let Some(agent) = parse_agent_label(agent_label) else {
+ return DetectionExplain {
+ agent: Some(agent_label.to_string()),
+ state: AgentState::Unknown,
+ source: None,
+ matched_rule: None,
+ screen_detection_skipped: false,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ skip_state_update: false,
+ skipped_update_reason: None,
+ fallback_reason: Some("unknown_agent".to_string()),
+ evaluated_rules: Vec::new(),
+ warning: None,
+ manifest_version: None,
+ cached_remote_version: None,
+ local_override_shadowing_remote: false,
+ remote_update_status: None,
+ remote_update_error: None,
+ };
+ };
+ explain(agent, screen_content)
+}
+
+impl DetectionExplain {
+ fn into_detection(self) -> AgentDetection {
+ AgentDetection {
+ state: self.state,
+ skip_state_update: self.skip_state_update,
+ visible_idle: self.visible_idle,
+ visible_blocker: self.visible_blocker,
+ visible_working: self.visible_working,
+ }
+ }
+}
+
+fn evaluate_loaded_manifest(
+ agent: Agent,
+ input: DetectionInput<'_>,
+ loaded: LoadedManifest,
+ include_update_status: bool,
+) -> DetectionExplain {
+ let mut matched: Option<(&ManifestRule, String)> = None;
+ let mut evaluated_rules = Vec::new();
+
+ for (rule, compiled_rule) in loaded
+ .manifest
+ .rules
+ .iter()
+ .zip(loaded.compiled_rules.iter())
+ {
+ let region_text = region(input, &rule.region);
+ let matched_rule = compiled_rule_matches(compiled_rule, region_text);
+ evaluated_rules.push(EvaluatedRule {
+ id: rule.id.clone(),
+ priority: rule.priority,
+ region: rule.region.clone(),
+ evidence: rule_evidence(rule, region_text),
+ state: rule
+ .state
+ .map(AgentState::from)
+ .unwrap_or(AgentState::Unknown),
+ matched: matched_rule,
+ });
+
+ if !matched_rule {
+ continue;
+ }
+
+ match matched {
+ Some((previous, _)) if previous.priority >= rule.priority => {}
+ _ => matched = Some((rule, rule.region.clone())),
+ }
+ }
+
+ let Some((rule, region_name)) = matched else {
+ return fallback_explain(
+ Some(agent),
+ Some((loaded, evaluated_rules)),
+ include_update_status,
+ );
+ };
+
+ let state = rule
+ .state
+ .map(AgentState::from)
+ .unwrap_or(AgentState::Unknown);
+ let skipped_update_reason = rule
+ .skip_state_update
+ .then(|| format!("matched_rule:{}", rule.id));
+
+ let remote_update_status = include_update_status
+ .then(|| remote_update_status(agent))
+ .flatten();
+
+ DetectionExplain {
+ agent: Some(agent_label(agent).to_string()),
+ state,
+ source: Some(loaded.source),
+ matched_rule: Some(MatchedRule {
+ id: rule.id.clone(),
+ priority: rule.priority,
+ region: region_name,
+ state,
+ }),
+ screen_detection_skipped: false,
+ visible_idle: rule.visible_idle && state == AgentState::Idle,
+ visible_blocker: rule.visible_blocker && state == AgentState::Blocked,
+ visible_working: rule.visible_working && state == AgentState::Working,
+ skip_state_update: rule.skip_state_update,
+ skipped_update_reason,
+ fallback_reason: None,
+ evaluated_rules,
+ warning: loaded.warning,
+ manifest_version: loaded.manifest.version.as_ref().map(ToString::to_string),
+ cached_remote_version: loaded.cached_remote_version,
+ local_override_shadowing_remote: loaded.local_override_shadowing_remote,
+ remote_update_status: remote_update_status
+ .as_ref()
+ .map(|status| status.last_result.clone()),
+ remote_update_error: remote_update_status.and_then(|status| status.last_error),
+ }
+}
+
+fn fallback_explain(
+ agent: Option<Agent>,
+ context: Option<(LoadedManifest, Vec<EvaluatedRule>)>,
+ include_update_status: bool,
+) -> DetectionExplain {
+ let (
+ source,
+ evaluated_rules,
+ warning,
+ manifest_version,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ ) = context
+ .map(|(loaded, evaluated)| {
+ (
+ Some(loaded.source),
+ evaluated,
+ loaded.warning,
+ loaded.manifest.version.as_ref().map(ToString::to_string),
+ loaded.cached_remote_version,
+ loaded.local_override_shadowing_remote,
+ )
+ })
+ .unwrap_or((None, Vec::new(), None, None, None, false));
+ let known_agent = agent.is_some();
+ let remote_update_status = include_update_status
+ .then(|| agent.and_then(remote_update_status))
+ .flatten();
+
+ DetectionExplain {
+ agent: agent.map(|agent| agent_label(agent).to_string()),
+ state: if known_agent {
+ AgentState::Idle
+ } else {
+ AgentState::Unknown
+ },
+ source,
+ matched_rule: None,
+ screen_detection_skipped: false,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ skip_state_update: false,
+ skipped_update_reason: None,
+ fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()),
+ evaluated_rules,
+ warning,
+ manifest_version,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ remote_update_status: remote_update_status
+ .as_ref()
+ .map(|status| status.last_result.clone()),
+ remote_update_error: remote_update_status.and_then(|status| status.last_error),
+ }
+}
+
+fn load_manifest(agent: Agent) -> Option<LoadedManifest> {
+ 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.clone())
+}
+
+fn load_manifest_uncached(agent: Agent) -> Option<LoadedManifest> {
+ let bundled = bundled_manifest(agent)?;
+ let mut remote = read_remote_manifest(agent, &bundled);
+ let cached_remote_version = remote.as_ref().and_then(|loaded| match &loaded.source {
+ _ if loaded.cached_remote_version.is_some() => loaded.cached_remote_version.clone(),
+ ManifestSource::Remote { version, .. } => Some(version.clone()),
+ _ => None,
+ });
+ let Some(path) = override_path(agent) else {
+ if let Some(loaded) = remote.as_mut() {
+ loaded.cached_remote_version = cached_remote_version.clone();
+ }
+ return Some(remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(agent, bundled, None, cached_remote_version, false)
+ }));
+ };
+ let local_override_shadowing_remote = path.exists() && cached_remote_version.is_some();
+ if let Some(loaded) = remote.as_mut() {
+ loaded.cached_remote_version = cached_remote_version.clone();
+ loaded.local_override_shadowing_remote = local_override_shadowing_remote;
+ }
+
+ if !path.exists() {
+ return Some(remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(
+ agent,
+ bundled,
+ None,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ }));
+ }
+
+ match read_override_manifest(&path) {
+ Ok(manifest) if manifest_matches_agent(&manifest, agent) => {
+ match loaded_manifest(
+ manifest,
+ ManifestSource::Override(path.clone()),
+ None,
+ cached_remote_version.clone(),
+ local_override_shadowing_remote,
+ ) {
+ Ok(loaded) => Some(loaded),
+ Err(err) => {
+ let mut loaded = remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(
+ agent,
+ bundled,
+ None,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ });
+ loaded.warning = Some(format!(
+ "ignored override {} because it could not be compiled: {err}",
+ path.display()
+ ));
+ Some(loaded)
+ }
+ }
+ }
+ Ok(manifest) => {
+ let mut loaded = remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(
+ agent,
+ bundled,
+ None,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ });
+ loaded.warning = Some(format!(
+ "ignored override {} because manifest id {} does not match {}",
+ path.display(),
+ manifest.id,
+ agent_label(agent)
+ ));
+ Some(loaded)
+ }
+ Err(err) => {
+ let mut loaded = remote.unwrap_or_else(|| {
+ bundled_loaded_manifest(
+ agent,
+ bundled,
+ None,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ });
+ loaded.warning = Some(format!(
+ "ignored override {} because it could not be loaded: {err}",
+ path.display()
+ ));
+ Some(loaded)
+ }
+ }
+}
+
+fn loaded_manifest(
+ manifest: AgentManifest,
+ source: ManifestSource,
+ warning: Option<String>,
+ cached_remote_version: Option<String>,
+ local_override_shadowing_remote: bool,
+) -> Result<LoadedManifest, String> {
+ let compiled_rules = compile_manifest(&manifest)?.into();
+ Ok(LoadedManifest {
+ manifest,
+ compiled_rules,
+ source,
+ warning,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ })
+}
+
+fn bundled_loaded_manifest(
+ agent: Agent,
+ manifest: AgentManifest,
+ warning: Option<String>,
+ cached_remote_version: Option<String>,
+ local_override_shadowing_remote: bool,
+) -> LoadedManifest {
+ loaded_manifest(
+ manifest,
+ ManifestSource::Bundled,
+ warning,
+ cached_remote_version,
+ local_override_shadowing_remote,
+ )
+ .unwrap_or_else(|err| {
+ panic!(
+ "bundled {} manifest could not be compiled: {err}",
+ agent_label(agent)
+ )
+ })
+}
+
+fn bundled_manifest(agent: Agent) -> Option<AgentManifest> {
+ let id = agent_label(agent);
+ BUNDLED_MANIFESTS
+ .iter()
+ .find(|(manifest_id, _)| *manifest_id == id)
+ .map(|(_, content)| {
+ parse_manifest(content)
+ .unwrap_or_else(|err| panic!("bundled {id} manifest is invalid: {err}"))
+ })
+}
+
+fn read_override_manifest(path: &Path) -> Result<AgentManifest, String> {
+ let content = std::fs::read_to_string(path).map_err(|err| err.to_string())?;
+ parse_manifest(&content)
+}
+
+fn read_remote_manifest(agent: Agent, bundled: &AgentManifest) -> Option<LoadedManifest> {
+ let path = super::manifest_update::remote_manifest_path(agent);
+ if !path.exists() {
+ return None;
+ }
+ match std::fs::read_to_string(&path)
+ .map_err(|err| err.to_string())
+ .and_then(|content| {
+ parse_remote_manifest_for_agent(agent, &content).map(|parsed| parsed.manifest)
+ }) {
+ Ok(manifest) => {
+ let version = manifest
+ .version
+ .as_ref()
+ .map(ToString::to_string)
+ .unwrap_or_else(|| "unknown".to_string());
+ if let (Some(remote_version), Some(bundled_version)) =
+ (manifest.version.as_ref(), bundled.version.as_ref())
+ {
+ if remote_version < bundled_version {
+ return Some(bundled_loaded_manifest(
+ agent,
+ bundled.clone(),
+ Some(format!(
+ "ignored remote manifest {} because cached version {remote_version} is older than bundled {bundled_version}",
+ path.display()
+ )),
+ Some(remote_version.to_string()),
+ false,
+ ));
+ }
+ }
+ match loaded_manifest(
+ manifest,
+ ManifestSource::Remote {
+ path: path.clone(),
+ version,
+ },
+ None,
+ None,
+ false,
+ ) {
+ Ok(loaded) => Some(loaded),
+ Err(err) => Some(bundled_loaded_manifest(
+ agent,
+ bundled.clone(),
+ Some(format!(
+ "ignored remote manifest {} because it could not be compiled: {err}",
+ path.display()
+ )),
+ None,
+ false,
+ )),
+ }
+ }
+ Err(err) => Some(bundled_loaded_manifest(
+ agent,
+ bundled.clone(),
+ Some(format!(
+ "ignored remote manifest {} because it could not be loaded: {err}",
+ path.display()
+ )),
+ None,
+ false,
+ )),
+ }
+}
+
+pub fn agent_state_label(state: AgentState) -> &'static str {
+ match state {
+ AgentState::Idle => "idle",
+ AgentState::Working => "working",
+ AgentState::Blocked => "blocked",
+ AgentState::Unknown => "unknown",
+ }
+}
+
+pub fn explain_to_json_value(explain: &DetectionExplain) -> serde_json::Value {
+ let matched_rule = explain.matched_rule.as_ref().map(|rule| {
+ serde_json::json!({
+ "id": rule.id,
+ "priority": rule.priority,
+ "region": rule.region,
+ "state": agent_state_label(rule.state),
+ })
+ });
+ let evaluated_rules: Vec<_> = explain
+ .evaluated_rules
+ .iter()
+ .map(|rule| {
+ serde_json::json!({
+ "id": rule.id,
+ "priority": rule.priority,
+ "region": rule.region,
+ "state": agent_state_label(rule.state),
+ "matched": rule.matched,
+ "evidence": {
+ "contains": &rule.evidence.contains,
+ "regex": &rule.evidence.regex,
+ "line_regex": &rule.evidence.line_regex,
+ "all_count": rule.evidence.all_count,
+ "any_count": rule.evidence.any_count,
+ "not_count": rule.evidence.not_count,
+ "region_bytes": rule.evidence.region_bytes,
+ "region_preview": &rule.evidence.region_preview,
+ },
+ })
+ })
+ .collect();
+
+ serde_json::json!({
+ "agent": explain.agent,
+ "state": agent_state_label(explain.state),
+ "manifest_source": explain.source.as_ref().map(|source| source.label()),
+ "manifest_version": &explain.manifest_version,
+ "cached_remote_version": &explain.cached_remote_version,
+ "local_override_shadowing_remote": explain.local_override_shadowing_remote,
+ "remote_update_status": &explain.remote_update_status,
+ "remote_update_error": &explain.remote_update_error,
+ "matched_rule": matched_rule,
+ "visible_idle": explain.visible_idle,
+ "visible_blocker": explain.visible_blocker,
+ "visible_working": explain.visible_working,
+ "screen_detection_skipped": explain.screen_detection_skipped,
+ "skip_state_update": explain.skip_state_update,
+ "skipped_update_reason": explain.skipped_update_reason,
+ "fallback_reason": explain.fallback_reason,
+ "warning": explain.warning,
+ "evaluated_rules": evaluated_rules,
+ })
+}
+
+pub(crate) struct ParsedRemoteManifest {
+ pub(crate) manifest: AgentManifest,
+ #[allow(dead_code)] // carried for parity with herdr manifest bookkeeping
+ pub(crate) version: ManifestVersion,
+}
+
+pub(crate) fn parse_manifest(content: &str) -> Result<AgentManifest, String> {
+ let manifest = toml::from_str::<AgentManifest>(content).map_err(|err| err.to_string())?;
+ validate_manifest(&manifest)?;
+ Ok(manifest)
+}
+
+pub(crate) fn parse_remote_manifest_for_agent(
+ agent: Agent,
+ content: &str,
+) -> Result<ParsedRemoteManifest, String> {
+ let manifest = parse_manifest(content)?;
+ if !manifest_matches_agent(&manifest, agent) {
+ return Err(format!(
+ "manifest id {} does not match {}",
+ manifest.id,
+ agent_label(agent)
+ ));
+ }
+ let version = manifest
+ .version
+ .clone()
+ .ok_or("remote manifest must include version")?;
+ let min_engine_version = manifest
+ .min_engine_version
+ .ok_or("remote manifest must include min_engine_version")?;
+ if min_engine_version > super::manifest_update::MANIFEST_ENGINE_VERSION {
+ return Err(format!(
+ "manifest requires engine {min_engine_version}, current engine is {}",
+ super::manifest_update::MANIFEST_ENGINE_VERSION
+ ));
+ }
+ Ok(ParsedRemoteManifest { manifest, version })
+}
+
+fn validate_manifest(manifest: &AgentManifest) -> Result<(), String> {
+ if manifest.rules.is_empty() {
+ return Err("manifest must contain at least one rule".to_string());
+ }
+ if manifest.rules.len() > MAX_RULES_PER_MANIFEST {
+ return Err(format!(
+ "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}",
+ manifest.rules.len()
+ ));
+ }
+
+ let mut complexity = ManifestComplexity::default();
+ for rule in &manifest.rules {
+ if rule.id.trim().is_empty() {
+ return Err("manifest rule id must not be empty".to_string());
+ }
+ if rule.skip_state_update {
+ if rule.state != Some(ManifestState::Unknown) {
+ return Err(format!(
+ "rule {} uses skip_state_update without state = \"unknown\"",
+ rule.id
+ ));
+ }
+ if rule.visible_idle || rule.visible_blocker || rule.visible_working {
+ return Err(format!(
+ "rule {} uses skip_state_update with visible state evidence",
+ rule.id
+ ));
+ }
+ }
+ validate_region_name(&rule.region)
+ .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?;
+ if rule.region.trim().starts_with("top_non_empty_lines(")
+ && manifest
+ .min_engine_version
+ .is_some_and(|version| version < TOP_NON_EMPTY_LINES_ENGINE_VERSION)
+ {
+ return Err(format!(
+ "rule {} uses top_non_empty_lines but min_engine_version is below {}",
+ rule.id, TOP_NON_EMPTY_LINES_ENGINE_VERSION
+ ));
+ }
+ validate_rule_gate(rule, &mut complexity)
+ .map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?;
+ }
+
+ Ok(())
+}
+
+#[derive(Default)]
+struct ManifestComplexity {
+ total_gates: usize,
+ total_matchers: usize,
+}
+
+fn validate_rule_gate(
+ rule: &ManifestRule,
+ complexity: &mut ManifestComplexity,
+) -> Result<(), String> {
+ validate_gate(&manifest_gate_from_rule(rule), "rule", 0, complexity)
+}
+
+fn validate_gate(
+ gate: &ManifestGate,
+ context: &str,
+ depth: usize,
+ complexity: &mut ManifestComplexity,
+) -> Result<(), String> {
+ if depth > MAX_GATE_DEPTH {
+ return Err(format!("{context} exceeds max gate depth {MAX_GATE_DEPTH}"));
+ }
+ complexity.total_gates += 1;
+ if complexity.total_gates > MAX_TOTAL_GATES {
+ return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}"));
+ }
+ validate_matcher_limits(gate, context, complexity)?;
+ if !gate_has_positive_matcher(gate) {
+ return Err(format!("{context} must contain a positive matcher"));
+ }
+ validate_regex_patterns(&gate.regex, context, "regex")?;
+ validate_regex_patterns(&gate.line_regex, context, "line_regex")?;
+ for nested in &gate.all {
+ validate_gate(nested, "all gate", depth + 1, complexity)?;
+ }
+ for nested in &gate.any {
+ validate_gate(nested, "any gate", depth + 1, complexity)?;
+ }
+ for nested in &gate.not_gate {
+ if !gate_has_any_matcher(nested) {
+ return Err(format!("{context} contains an empty not gate"));
+ }
+ validate_not_gate(nested, depth + 1, complexity)?;
+ }
+ Ok(())
+}
+
+fn validate_not_gate(
+ gate: &ManifestGate,
+ depth: usize,
+ complexity: &mut ManifestComplexity,
+) -> Result<(), String> {
+ if depth > MAX_GATE_DEPTH {
+ return Err(format!("not gate exceeds max gate depth {MAX_GATE_DEPTH}"));
+ }
+ complexity.total_gates += 1;
+ if complexity.total_gates > MAX_TOTAL_GATES {
+ return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}"));
+ }
+ validate_matcher_limits(gate, "not gate", complexity)?;
+ if !gate_has_any_matcher(gate) {
+ return Err("not gate must contain a matcher".to_string());
+ }
+ validate_regex_patterns(&gate.regex, "not gate", "regex")?;
+ validate_regex_patterns(&gate.line_regex, "not gate", "line_regex")?;
+ for nested in &gate.all {
+ validate_gate(nested, "not all gate", depth + 1, complexity)?;
+ }
+ for nested in &gate.any {
+ validate_gate(nested, "not any gate", depth + 1, complexity)?;
+ }
+ for nested in &gate.not_gate {
+ validate_not_gate(nested, depth + 1, complexity)?;
+ }
+ Ok(())
+}
+
+fn validate_matcher_limits(
+ gate: &ManifestGate,
+ context: &str,
+ complexity: &mut ManifestComplexity,
+) -> Result<(), String> {
+ let matcher_count = gate.contains.len() + gate.regex.len() + gate.line_regex.len();
+ if matcher_count > MAX_MATCHERS_PER_GATE {
+ return Err(format!(
+ "{context} has {matcher_count} direct matchers, max is {MAX_MATCHERS_PER_GATE}"
+ ));
+ }
+ complexity.total_matchers += matcher_count;
+ if complexity.total_matchers > MAX_TOTAL_MATCHERS {
+ return Err(format!(
+ "manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}"
+ ));
+ }
+ for value in gate
+ .contains
+ .iter()
+ .chain(gate.regex.iter())
+ .chain(gate.line_regex.iter())
+ {
+ if value.chars().count() > MAX_MATCHER_CHARS {
+ return Err(format!(
+ "{context} matcher exceeds max length {MAX_MATCHER_CHARS}"
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_regex_patterns(patterns: &[String], context: &str, field: &str) -> Result<(), String> {
+ for pattern in patterns {
+ Regex::new(pattern).map_err(|err| {
+ format!("{context} contains invalid {field} pattern {pattern:?}: {err}")
+ })?;
+ }
+ Ok(())
+}
+
+fn gate_has_positive_matcher(gate: &ManifestGate) -> bool {
+ !gate.contains.is_empty()
+ || !gate.regex.is_empty()
+ || !gate.line_regex.is_empty()
+ || !gate.all.is_empty()
+ || !gate.any.is_empty()
+}
+
+fn gate_has_any_matcher(gate: &ManifestGate) -> bool {
+ gate_has_positive_matcher(gate) || !gate.not_gate.is_empty()
+}
+
+fn validate_region_name(spec: &str) -> Result<(), String> {
+ let trimmed = spec.trim();
+ match trimmed {
+ "whole_recent"
+ | "after_last_prompt_marker"
+ | "before_current_prompt_marker"
+ | "whole_recent_without_current_prompt_marker"
+ | "current_prompt_block_marker"
+ | "after_current_prompt_block_marker"
+ | "prompt_box_body"
+ | "above_prompt_box"
+ | "last_non_empty_above_prompt_box"
+ | "after_last_horizontal_rule"
+ | "osc_title"
+ | "osc_progress" => Ok(()),
+ _ if region_count(trimmed, "bottom_lines").is_some()
+ || region_count(trimmed, "bottom_non_empty_lines").is_some()
+ || top_region_count(trimmed).is_some() =>
+ {
+ Ok(())
+ }
+ _ => Err(trimmed.to_string()),
+ }
+}
+
+fn override_path(agent: Agent) -> Option<PathBuf> {
+ Some(
+ crate::manifest_update::override_dir()
+ .join(format!("{}.toml", agent_label(agent))),
+ )
+}
+
+struct RemoteUpdateStatus {
+ last_result: String,
+ last_error: Option<String>,
+}
+
+/// dingger has no background remote-update machinery; the `dingger manifests
+/// update` CLI command stores refreshed manifests in the remote store
+/// directory, and `read_remote_manifest` picks them up on the next cache
+/// reload. There is no fetch status to report.
+fn remote_update_status(_agent: Agent) -> Option<RemoteUpdateStatus> {
+ None
+}
+
+fn manifest_matches_agent(manifest: &AgentManifest, agent: Agent) -> bool {
+ let id = agent_label(agent);
+ manifest.id == id
+ || manifest.aliases.iter().any(|alias| alias == id)
+ || parse_agent_label(&manifest.id) == Some(agent)
+ || manifest
+ .aliases
+ .iter()
+ .any(|alias| parse_agent_label(alias) == Some(agent))
+}
+
+fn manifest_gate_from_rule(rule: &ManifestRule) -> ManifestGate {
+ ManifestGate {
+ all: rule.all.clone(),
+ any: rule.any.clone(),
+ not_gate: rule.not_gate.clone(),
+ contains: rule.contains.clone(),
+ regex: rule.regex.clone(),
+ line_regex: rule.line_regex.clone(),
+ }
+}
+
+fn compile_manifest(manifest: &AgentManifest) -> Result<Vec<CompiledRule>, String> {
+ manifest
+ .rules
+ .iter()
+ .map(|rule| {
+ compile_gate(&manifest_gate_from_rule(rule))
+ .map(|gate| CompiledRule { gate })
+ .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id))
+ })
+ .collect()
+}
+
+fn compile_gate(gate: &ManifestGate) -> Result<CompiledGate, String> {
+ Ok(CompiledGate {
+ all: gate
+ .all
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ any: gate
+ .any
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ not_gate: gate
+ .not_gate
+ .iter()
+ .map(compile_gate)
+ .collect::<Result<_, _>>()?,
+ contains: gate
+ .contains
+ .iter()
+ .map(|needle| needle.to_lowercase())
+ .collect(),
+ regex: gate
+ .regex
+ .iter()
+ .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
+ .collect::<Result<_, _>>()?,
+ line_regex: gate
+ .line_regex
+ .iter()
+ .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
+ .collect::<Result<_, _>>()?,
+ })
+}
+
+fn compiled_rule_matches(rule: &CompiledRule, text: &str) -> bool {
+ let lower_text = text.to_lowercase();
+ compiled_gate_matches(&rule.gate, text, &lower_text)
+}
+
+fn rule_evidence(rule: &ManifestRule, region_text: &str) -> RuleEvidence {
+ RuleEvidence {
+ contains: rule.contains.clone(),
+ regex: rule.regex.clone(),
+ line_regex: rule.line_regex.clone(),
+ all_count: rule.all.len(),
+ any_count: rule.any.len(),
+ not_count: rule.not_gate.len(),
+ region_bytes: region_text.len(),
+ region_preview: bounded_preview(region_text),
+ }
+}
+
+fn bounded_preview(text: &str) -> String {
+ const MAX_CHARS: usize = 240;
+ let mut preview: String = text.chars().take(MAX_CHARS).collect();
+ if text.chars().count() > MAX_CHARS {
+ preview.push_str("...");
+ }
+ preview
+}
+
+fn compiled_gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool {
+ if !gate
+ .contains
+ .iter()
+ .all(|needle| lower_text.contains(needle))
+ {
+ return false;
+ }
+
+ if !gate.regex.iter().all(|regex| regex.is_match(text)) {
+ return false;
+ }
+
+ if !gate
+ .line_regex
+ .iter()
+ .all(|regex| text.lines().any(|line| regex.is_match(line)))
+ {
+ return false;
+ }
+
+ if !gate
+ .all
+ .iter()
+ .all(|nested| compiled_gate_matches(nested, text, lower_text))
+ {
+ return false;
+ }
+
+ if !gate.any.is_empty()
+ && !gate
+ .any
+ .iter()
+ .any(|nested| compiled_gate_matches(nested, text, lower_text))
+ {
+ return false;
+ }
+
+ if gate
+ .not_gate
+ .iter()
+ .any(|nested| compiled_gate_matches(nested, text, lower_text))
+ {
+ return false;
+ }
+
+ true
+}
+
+fn region<'a>(input: DetectionInput<'a>, spec: &str) -> &'a str {
+ let trimmed = spec.trim();
+ // OSC regions source from their dedicated fields, not the screen.
+ match trimmed {
+ "osc_title" => return input.osc_title,
+ "osc_progress" => return input.osc_progress,
+ _ => {}
+ }
+ // All other regions operate on the screen content as before.
+ let content = input.screen;
+ match trimmed {
+ "whole_recent" => content,
+ "after_last_prompt_marker" => after_last_prompt_marker(content),
+ "before_current_prompt_marker" => before_current_prompt_marker(content),
+ "whole_recent_without_current_prompt_marker" => {
+ whole_recent_without_current_prompt_marker(content)
+ }
+ "current_prompt_block_marker" => current_prompt_block_marker(content).unwrap_or(""),
+ "after_current_prompt_block_marker" => {
+ after_current_prompt_block_marker(content).unwrap_or("")
+ }
+ "prompt_box_body" => prompt_box_body(content).unwrap_or(""),
+ "above_prompt_box" => above_prompt_box(content),
+ "last_non_empty_above_prompt_box" => last_non_empty_line(above_prompt_box(content)),
+ "after_last_horizontal_rule" => after_last_horizontal_rule(content),
+ _ => {
+ if let Some(count) = region_count(trimmed, "bottom_lines") {
+ return bottom_lines(content, count);
+ }
+ if let Some(count) = region_count(trimmed, "bottom_non_empty_lines") {
+ return bottom_non_empty_lines(content, count);
+ }
+ if let Some(count) = top_region_count(trimmed) {
+ return top_non_empty_lines(content, count);
+ }
+ ""
+ }
+ }
+}
+
+fn region_count(spec: &str, name: &str) -> Option<usize> {
+ spec.strip_prefix(name)
+ .and_then(|rest| rest.strip_prefix('('))
+ .and_then(|rest| rest.strip_suffix(')'))
+ .and_then(|count| count.parse::<usize>().ok())
+}
+
+const TOP_NON_EMPTY_LINES_ENGINE_VERSION: u32 = 3;
+const MAX_TOP_REGION_LINE_COUNT: usize = u16::MAX as usize;
+
+fn top_region_count(spec: &str) -> Option<usize> {
+ let count = spec
+ .strip_prefix("top_non_empty_lines")?
+ .strip_prefix('(')?
+ .strip_suffix(')')?;
+ if count.starts_with('0') || !count.bytes().all(|byte| byte.is_ascii_digit()) {
+ return None;
+ }
+ count
+ .parse::<usize>()
+ .ok()
+ .filter(|count| *count <= MAX_TOP_REGION_LINE_COUNT)
+}
+
+fn bottom_lines(content: &str, count: usize) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let start = lines.len().saturating_sub(count);
+ slice_from_line_index(content, &lines, start)
+}
+
+fn bottom_non_empty_lines(content: &str, count: usize) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(start_index) = lines
+ .iter()
+ .enumerate()
+ .rev()
+ .filter(|(_, line)| !line.trim().is_empty())
+ .take(count)
+ .last()
+ .map(|(index, _)| index)
+ else {
+ return "";
+ };
+ slice_from_line_index(content, &lines, start_index)
+}
+
+fn top_non_empty_lines(content: &str, count: usize) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(end_index) = lines
+ .iter()
+ .enumerate()
+ .filter(|(_, line)| !line.trim().is_empty())
+ .take(count)
+ .last()
+ .map(|(index, _)| index)
+ else {
+ return "";
+ };
+ let byte_offset = line_start_offset(content, &lines, end_index + 1);
+ &content[..byte_offset]
+}
+
+fn after_last_prompt_marker(content: &str) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else {
+ return content;
+ };
+ slice_from_line_index(content, &lines, index + 1)
+}
+
+fn before_current_prompt_marker(content: &str) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(index) = current_codex_prompt_index(&lines) else {
+ return content;
+ };
+ let byte_offset = lines[..index]
+ .iter()
+ .map(|line| line.len() + 1)
+ .sum::<usize>();
+ &content[..byte_offset.min(content.len())]
+}
+
+fn whole_recent_without_current_prompt_marker(content: &str) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ if current_codex_prompt_index(&lines).is_some() {
+ ""
+ } else {
+ content
+ }
+}
+
+fn current_prompt_block_marker(content: &str) -> Option<&str> {
+ let lines: Vec<&str> = content.lines().collect();
+ let prompt_index = current_codex_prompt_index(&lines)?;
+ lines[..prompt_index]
+ .iter()
+ .rev()
+ .find(|line| codex_block_marker_line(line))
+ .copied()
+}
+
+fn after_current_prompt_block_marker(content: &str) -> Option<&str> {
+ let lines: Vec<&str> = content.lines().collect();
+ let prompt_index = current_codex_prompt_index(&lines)?;
+ let block_index = lines[..prompt_index]
+ .iter()
+ .rposition(|line| codex_block_marker_line(line))?;
+ Some(slice_from_line_index(content, &lines, block_index))
+}
+
+fn current_codex_prompt_index(lines: &[&str]) -> Option<usize> {
+ let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?;
+ if lines[prompt_index + 1..]
+ .iter()
+ .any(|line| codex_block_marker_line(line))
+ {
+ return None;
+ }
+ Some(prompt_index)
+}
+
+fn codex_prompt_line(line: &str) -> bool {
+ line == "›" || line.starts_with("› ")
+}
+
+fn codex_block_marker_line(line: &str) -> bool {
+ line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓')
+}
+
+fn prompt_box_body(content: &str) -> Option<&str> {
+ let lines: Vec<&str> = content.lines().collect();
+ let top = prompt_box_top_border_index(&lines)?;
+ let start = line_start_offset(content, &lines, top + 1);
+ let end_index = lines[top + 1..]
+ .iter()
+ .position(|line| is_horizontal_rule(line))
+ .map(|relative| top + 1 + relative)
+ .unwrap_or(lines.len());
+ let end = line_start_offset(content, &lines, end_index);
+ Some(&content[start.min(content.len())..end.min(content.len())])
+}
+
+fn above_prompt_box(content: &str) -> &str {
+ let lines: Vec<&str> = content.lines().collect();
+ let Some(top) = prompt_box_top_border_index(&lines) else {
+ return content;
+ };
+ let end = line_start_offset(content, &lines, top);
+ &content[..end.min(content.len())]
+}
+
+fn after_last_horizontal_rule(content: &str) -> &str {
+ let mut last_rule_end = 0usize;
+ let mut offset = 0usize;
+ for line in content.lines() {
+ let next_offset = offset + line.len() + 1;
+ if is_horizontal_rule(line) {
+ last_rule_end = next_offset.min(content.len());
+ }
+ offset = next_offset;
+ }
+ &content[last_rule_end..]
+}
+
+fn last_non_empty_line(content: &str) -> &str {
+ content
+ .lines()
+ .rev()
+ .find(|line| !line.trim().is_empty())
+ .unwrap_or("")
+}
+
+fn prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
+ let mut border_count = 0;
+ for index in (0..lines.len()).rev() {
+ if is_horizontal_rule(lines[index]) {
+ border_count += 1;
+ if border_count == 2 {
+ return Some(index);
+ }
+ }
+ }
+ None
+}
+
+fn is_horizontal_rule(line: &str) -> bool {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ return false;
+ }
+
+ let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count();
+ if rule_chars == 0 {
+ return false;
+ }
+
+ let rule_bytes = trimmed
+ .char_indices()
+ .nth(rule_chars)
+ .map(|(index, _)| index)
+ .unwrap_or(trimmed.len());
+ let suffix = trimmed[rule_bytes..].trim_start();
+
+ suffix.is_empty() || rule_chars >= 3
+}
+
+fn slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str {
+ let byte_offset = line_start_offset(content, lines, index);
+ &content[byte_offset.min(content.len())..]
+}
+
+fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize {
+ lines[..index.min(lines.len())]
+ .iter()
+ .map(|line| line.len() + 1)
+ .sum::<usize>()
+ .min(content.len())
+}
+
+#[cfg(test)]
+mod tests;