1use std::collections::HashMap;
30use std::sync::{Mutex as StdMutex, OnceLock};
31use std::time::{Duration, Instant, SystemTime};
32
33use regex::Regex;
34use serde::Deserialize;
35
36use crate::state::turn::TurnState;
37
38pub const ENGINE_VERSION: u32 = 3;
42
43#[derive(Debug, Clone, Copy)]
48pub struct DetectionInput<'a> {
49 pub screen: &'a str,
50 pub osc_title: &'a str,
51 pub osc_progress: &'a str,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct Verdict {
58 pub state: TurnState,
59 pub rule: Option<(&'static str, i32)>,
62}
63
64#[derive(Debug, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct RawManifest {
69 id: String,
70 #[allow(dead_code)]
71 version: Option<String>,
72 min_engine_version: Option<u32>,
73 #[serde(rename = "updated_at")]
74 _updated_at: Option<String>,
75 #[serde(default)]
76 aliases: Vec<String>,
77 #[serde(default)]
78 rules: Vec<RawRule>,
79}
80
81#[allow(clippy::struct_excessive_bools)]
84#[derive(Debug, Deserialize)]
85#[serde(deny_unknown_fields)]
86struct RawRule {
87 id: String,
88 state: Option<RawState>,
89 #[serde(default)]
90 priority: i32,
91 #[serde(default = "default_region")]
92 region: String,
93 #[serde(default)]
96 #[allow(dead_code)]
97 visible_idle: bool,
98 #[serde(default)]
99 #[allow(dead_code)]
100 visible_blocker: bool,
101 #[serde(default)]
102 #[allow(dead_code)]
103 visible_working: bool,
104 #[serde(default)]
105 skip_state_update: bool,
106 #[serde(default)]
107 all: Vec<RawGate>,
108 #[serde(default)]
109 any: Vec<RawGate>,
110 #[serde(default, rename = "not")]
111 not_gate: Vec<RawGate>,
112 #[serde(default)]
113 contains: Vec<String>,
114 #[serde(default)]
115 regex: Vec<String>,
116 #[serde(default)]
117 line_regex: Vec<String>,
118}
119
120#[derive(Debug, Deserialize, Clone)]
121#[serde(deny_unknown_fields)]
122struct RawGate {
123 #[serde(default)]
124 all: Vec<RawGate>,
125 #[serde(default)]
126 any: Vec<RawGate>,
127 #[serde(default, rename = "not")]
128 not_gate: Vec<RawGate>,
129 #[serde(default)]
130 contains: Vec<String>,
131 #[serde(default)]
132 regex: Vec<String>,
133 #[serde(default)]
134 line_regex: Vec<String>,
135}
136
137#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139enum RawState {
140 Idle,
141 Working,
142 Blocked,
143 Unknown,
144}
145
146impl From<RawState> for TurnState {
147 fn from(value: RawState) -> Self {
148 match value {
149 RawState::Idle => TurnState::AwaitingInput,
150 RawState::Working => TurnState::Busy,
151 RawState::Blocked => TurnState::AwaitingApproval,
152 RawState::Unknown => TurnState::Unknown,
153 }
154 }
155}
156
157fn default_region() -> String {
158 "whole_recent".to_string()
159}
160
161#[derive(Debug)]
165pub struct CompiledManifest {
166 id: String,
167 aliases: Vec<String>,
168 rules: Vec<CompiledRule>,
169}
170
171#[derive(Debug)]
172struct CompiledRule {
173 id: String,
174 priority: i32,
175 region: String,
176 state: TurnState,
177 gate: CompiledGate,
178}
179
180#[derive(Debug)]
181struct CompiledGate {
182 all: Vec<CompiledGate>,
183 any: Vec<CompiledGate>,
184 not_gate: Vec<CompiledGate>,
185 contains: Vec<String>,
187 regex: Vec<Regex>,
188 line_regex: Vec<Regex>,
189}
190
191impl CompiledManifest {
192 pub fn id(&self) -> &str {
194 &self.id
195 }
196
197 pub fn detect(&'static self, input: DetectionInput<'_>) -> Verdict {
200 let mut matched: Option<&'static CompiledRule> = None;
201 for rule in &self.rules {
202 let text = region(input, &rule.region);
203 if !gate_matches(&rule.gate, text, &text.to_lowercase()) {
204 continue;
205 }
206 match matched {
207 Some(previous) if previous.priority >= rule.priority => {}
208 _ => matched = Some(rule),
209 }
210 }
211 match matched {
212 Some(rule) => {
213 Verdict { state: rule.state, rule: Some((rule.id.as_str(), rule.priority)) }
214 }
215 None => Verdict { state: TurnState::Unknown, rule: None },
216 }
217 }
218}
219
220const BUNDLED_MANIFESTS: &[&str] = &[
225 include_str!("agent_manifests/amp.toml"),
226 include_str!("agent_manifests/antigravity.toml"),
227 include_str!("agent_manifests/claude.toml"),
228 include_str!("agent_manifests/cline.toml"),
229 include_str!("agent_manifests/codex.toml"),
230 include_str!("agent_manifests/cursor.toml"),
231 include_str!("agent_manifests/devin.toml"),
232 include_str!("agent_manifests/droid.toml"),
233 include_str!("agent_manifests/gemini.toml"),
234 include_str!("agent_manifests/github-copilot.toml"),
235 include_str!("agent_manifests/grok.toml"),
236 include_str!("agent_manifests/hermes.toml"),
237 include_str!("agent_manifests/kilo.toml"),
238 include_str!("agent_manifests/kimi.toml"),
239 include_str!("agent_manifests/kiro.toml"),
240 include_str!("agent_manifests/maki.toml"),
241 include_str!("agent_manifests/muse.toml"),
242 include_str!("agent_manifests/opencode.toml"),
243 include_str!("agent_manifests/pi.toml"),
244 include_str!("agent_manifests/qodercli.toml"),
245 include_str!("agent_manifests/qwen.toml"),
246];
247
248static MANIFESTS: OnceLock<Vec<CompiledManifest>> = OnceLock::new();
249
250pub fn manifests() -> &'static [CompiledManifest] {
256 MANIFESTS.get_or_init(|| {
257 BUNDLED_MANIFESTS
258 .iter()
259 .filter_map(|content| match compile_manifest(content) {
260 Ok(manifest) => Some(manifest),
261 Err(err) => {
262 tracing::error!(error = %err, "skipping invalid bundled agent manifest");
263 None
264 }
265 })
266 .collect()
267 })
268}
269
270pub fn manifest_for(hint: &str) -> Option<&'static CompiledManifest> {
277 static LOOKUP: OnceLock<HashMap<String, usize>> = OnceLock::new();
278 let manifests = manifests();
279 let lookup = LOOKUP.get_or_init(|| {
280 let mut map = HashMap::new();
281 for (index, manifest) in manifests.iter().enumerate() {
282 map.entry(manifest.id.to_lowercase()).or_insert(index);
283 for alias in &manifest.aliases {
284 map.entry(alias.to_lowercase()).or_insert(index);
285 }
286 }
287 map
288 });
289 let bundled =
290 lookup.get(&hint.trim().to_lowercase()).and_then(|&index| manifests.get(index))?;
291 Some(overridden_manifest(&bundled.id).unwrap_or(bundled))
292}
293
294const OVERRIDE_RECHECK_INTERVAL: Duration = Duration::from_secs(2);
300
301struct OverrideSlot {
303 last_checked: Option<Instant>,
304 mtime: Option<SystemTime>,
309 manifest: Option<&'static CompiledManifest>,
313}
314
315fn override_dir() -> Option<&'static std::path::Path> {
316 static DIR: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
317 DIR.get_or_init(|| std::env::var("WINX_AGENT_MANIFEST_DIR").ok().map(Into::into)).as_deref()
318}
319
320fn overridden_manifest(id: &str) -> Option<&'static CompiledManifest> {
323 static SLOTS: OnceLock<StdMutex<HashMap<String, OverrideSlot>>> = OnceLock::new();
324 let dir = override_dir()?;
325 let mut slots = SLOTS
326 .get_or_init(|| StdMutex::new(HashMap::new()))
327 .lock()
328 .unwrap_or_else(std::sync::PoisonError::into_inner);
329 let slot = slots.entry(id.to_string()).or_insert(OverrideSlot {
330 last_checked: None,
331 mtime: None,
332 manifest: None,
333 });
334 if slot.last_checked.is_none_or(|at| at.elapsed() >= OVERRIDE_RECHECK_INTERVAL) {
335 refresh_override_slot(slot, &dir.join(format!("{id}.toml")), id);
336 slot.last_checked = Some(Instant::now());
337 }
338 slot.manifest
339}
340
341fn refresh_override_slot(slot: &mut OverrideSlot, path: &std::path::Path, id: &str) {
346 let Ok(mtime) = std::fs::metadata(path).and_then(|metadata| metadata.modified()) else {
347 if slot.manifest.is_some() {
348 tracing::info!(id, path = %path.display(), "agent manifest override removed; bundled manifest active again");
349 }
350 slot.mtime = None;
351 slot.manifest = None;
352 return;
353 };
354 if Some(mtime) == slot.mtime {
355 return;
356 }
357 slot.mtime = Some(mtime);
358 let compiled = std::fs::read_to_string(path)
359 .map_err(|err| err.to_string())
360 .and_then(|content| compile_manifest(&content));
361 match compiled {
362 Ok(manifest) if manifest.id == id => {
363 tracing::info!(id, path = %path.display(), "loaded agent manifest override");
364 slot.manifest = Some(Box::leak(Box::new(manifest)));
365 }
366 Ok(manifest) => {
367 tracing::warn!(
368 id, override_id = %manifest.id, path = %path.display(),
369 "ignoring agent manifest override: id mismatch"
370 );
371 }
372 Err(err) => {
373 tracing::warn!(
374 id, path = %path.display(), error = %err,
375 "ignoring agent manifest override: failed to compile"
376 );
377 }
378 }
379}
380
381const MAX_RULES_PER_MANIFEST: usize = 128;
386const MAX_GATE_DEPTH: usize = 8;
387const MAX_TOTAL_GATES: usize = 512;
388const MAX_MATCHERS_PER_GATE: usize = 32;
389const MAX_TOTAL_MATCHERS: usize = 1024;
390const MAX_MATCHER_CHARS: usize = 512;
391
392fn compile_manifest(content: &str) -> Result<CompiledManifest, String> {
393 let raw = toml::from_str::<RawManifest>(content).map_err(|err| err.to_string())?;
394 validate_manifest(&raw)?;
395 let rules = raw
396 .rules
397 .iter()
398 .map(|rule| {
399 compile_gate(&gate_from_rule(rule))
400 .map(|gate| CompiledRule {
401 id: rule.id.clone(),
402 priority: rule.priority,
403 region: rule.region.trim().to_string(),
404 state: rule.state.map_or(TurnState::Unknown, TurnState::from),
405 gate,
406 })
407 .map_err(|err| format!("rule {} could not be compiled: {err}", rule.id))
408 })
409 .collect::<Result<Vec<_>, _>>()?;
410 Ok(CompiledManifest { id: raw.id, aliases: raw.aliases, rules })
411}
412
413fn validate_manifest(manifest: &RawManifest) -> Result<(), String> {
414 if manifest.id.trim().is_empty() {
415 return Err("manifest id must not be empty".to_string());
416 }
417 if let Some(version) = manifest.min_engine_version {
418 if version > ENGINE_VERSION {
419 return Err(format!(
420 "manifest requires engine {version}, this engine is {ENGINE_VERSION}"
421 ));
422 }
423 }
424 if manifest.rules.is_empty() {
425 return Err("manifest must contain at least one rule".to_string());
426 }
427 if manifest.rules.len() > MAX_RULES_PER_MANIFEST {
428 return Err(format!(
429 "manifest contains {} rules, max is {MAX_RULES_PER_MANIFEST}",
430 manifest.rules.len()
431 ));
432 }
433
434 let mut complexity = Complexity::default();
435 for rule in &manifest.rules {
436 if rule.id.trim().is_empty() {
437 return Err("manifest rule id must not be empty".to_string());
438 }
439 if rule.skip_state_update && rule.state != Some(RawState::Unknown) {
443 return Err(format!(
444 "rule {} uses skip_state_update without state = \"unknown\"",
445 rule.id
446 ));
447 }
448 validate_region_name(&rule.region)
449 .map_err(|err| format!("rule {} uses invalid region: {err}", rule.id))?;
450 validate_gate(&gate_from_rule(rule), "rule", 0, &mut complexity)
451 .map_err(|err| format!("rule {} has invalid matcher gates: {err}", rule.id))?;
452 }
453 Ok(())
454}
455
456#[derive(Default)]
457struct Complexity {
458 total_gates: usize,
459 total_matchers: usize,
460}
461
462fn validate_gate(
463 gate: &RawGate,
464 context: &str,
465 depth: usize,
466 complexity: &mut Complexity,
467) -> Result<(), String> {
468 if depth > MAX_GATE_DEPTH {
469 return Err(format!("{context} exceeds max gate depth {MAX_GATE_DEPTH}"));
470 }
471 complexity.total_gates += 1;
472 if complexity.total_gates > MAX_TOTAL_GATES {
473 return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}"));
474 }
475 validate_matcher_limits(gate, context, complexity)?;
476 if !gate_has_positive_matcher(gate) {
477 return Err(format!("{context} must contain a positive matcher"));
478 }
479 validate_regex_patterns(&gate.regex, context, "regex")?;
480 validate_regex_patterns(&gate.line_regex, context, "line_regex")?;
481 for nested in &gate.all {
482 validate_gate(nested, "all gate", depth + 1, complexity)?;
483 }
484 for nested in &gate.any {
485 validate_gate(nested, "any gate", depth + 1, complexity)?;
486 }
487 for nested in &gate.not_gate {
488 validate_not_gate(nested, depth + 1, complexity)?;
489 }
490 Ok(())
491}
492
493fn validate_not_gate(
494 gate: &RawGate,
495 depth: usize,
496 complexity: &mut Complexity,
497) -> Result<(), String> {
498 if depth > MAX_GATE_DEPTH {
499 return Err(format!("not gate exceeds max gate depth {MAX_GATE_DEPTH}"));
500 }
501 complexity.total_gates += 1;
502 if complexity.total_gates > MAX_TOTAL_GATES {
503 return Err(format!("manifest exceeds max gate count {MAX_TOTAL_GATES}"));
504 }
505 validate_matcher_limits(gate, "not gate", complexity)?;
506 if !gate_has_any_matcher(gate) {
507 return Err("not gate must contain a matcher".to_string());
508 }
509 validate_regex_patterns(&gate.regex, "not gate", "regex")?;
510 validate_regex_patterns(&gate.line_regex, "not gate", "line_regex")?;
511 for nested in &gate.all {
512 validate_gate(nested, "not all gate", depth + 1, complexity)?;
513 }
514 for nested in &gate.any {
515 validate_gate(nested, "not any gate", depth + 1, complexity)?;
516 }
517 for nested in &gate.not_gate {
518 validate_not_gate(nested, depth + 1, complexity)?;
519 }
520 Ok(())
521}
522
523fn validate_matcher_limits(
524 gate: &RawGate,
525 context: &str,
526 complexity: &mut Complexity,
527) -> Result<(), String> {
528 let matcher_count = gate.contains.len() + gate.regex.len() + gate.line_regex.len();
529 if matcher_count > MAX_MATCHERS_PER_GATE {
530 return Err(format!(
531 "{context} has {matcher_count} direct matchers, max is {MAX_MATCHERS_PER_GATE}"
532 ));
533 }
534 complexity.total_matchers += matcher_count;
535 if complexity.total_matchers > MAX_TOTAL_MATCHERS {
536 return Err(format!("manifest exceeds max matcher count {MAX_TOTAL_MATCHERS}"));
537 }
538 for value in gate.contains.iter().chain(gate.regex.iter()).chain(gate.line_regex.iter()) {
539 if value.chars().count() > MAX_MATCHER_CHARS {
540 return Err(format!("{context} matcher exceeds max length {MAX_MATCHER_CHARS}"));
541 }
542 }
543 Ok(())
544}
545
546fn validate_regex_patterns(patterns: &[String], context: &str, field: &str) -> Result<(), String> {
547 for pattern in patterns {
548 Regex::new(pattern).map_err(|err| {
549 format!("{context} contains invalid {field} pattern {pattern:?}: {err}")
550 })?;
551 }
552 Ok(())
553}
554
555fn gate_has_positive_matcher(gate: &RawGate) -> bool {
556 !gate.contains.is_empty()
557 || !gate.regex.is_empty()
558 || !gate.line_regex.is_empty()
559 || !gate.all.is_empty()
560 || !gate.any.is_empty()
561}
562
563fn gate_has_any_matcher(gate: &RawGate) -> bool {
564 gate_has_positive_matcher(gate) || !gate.not_gate.is_empty()
565}
566
567fn validate_region_name(spec: &str) -> Result<(), String> {
568 let trimmed = spec.trim();
569 match trimmed {
570 "whole_recent"
571 | "after_last_prompt_marker"
572 | "before_current_prompt_marker"
573 | "whole_recent_without_current_prompt_marker"
574 | "current_prompt_block_marker"
575 | "after_current_prompt_block_marker"
576 | "prompt_box_body"
577 | "above_prompt_box"
578 | "last_non_empty_above_prompt_box"
579 | "after_last_horizontal_rule"
580 | "osc_title"
581 | "osc_progress" => Ok(()),
582 _ if region_count(trimmed, "bottom_lines").is_some()
583 || region_count(trimmed, "bottom_non_empty_lines").is_some()
584 || top_region_count(trimmed).is_some() =>
585 {
586 Ok(())
587 }
588 _ => Err(trimmed.to_string()),
589 }
590}
591
592fn gate_from_rule(rule: &RawRule) -> RawGate {
593 RawGate {
594 all: rule.all.clone(),
595 any: rule.any.clone(),
596 not_gate: rule.not_gate.clone(),
597 contains: rule.contains.clone(),
598 regex: rule.regex.clone(),
599 line_regex: rule.line_regex.clone(),
600 }
601}
602
603fn compile_gate(gate: &RawGate) -> Result<CompiledGate, String> {
604 Ok(CompiledGate {
605 all: gate.all.iter().map(compile_gate).collect::<Result<_, _>>()?,
606 any: gate.any.iter().map(compile_gate).collect::<Result<_, _>>()?,
607 not_gate: gate.not_gate.iter().map(compile_gate).collect::<Result<_, _>>()?,
608 contains: gate.contains.iter().map(|needle| needle.to_lowercase()).collect(),
609 regex: gate
610 .regex
611 .iter()
612 .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
613 .collect::<Result<_, _>>()?,
614 line_regex: gate
615 .line_regex
616 .iter()
617 .map(|pattern| Regex::new(pattern).map_err(|err| err.to_string()))
618 .collect::<Result<_, _>>()?,
619 })
620}
621
622fn gate_matches(gate: &CompiledGate, text: &str, lower_text: &str) -> bool {
625 if !gate.contains.iter().all(|needle| lower_text.contains(needle)) {
626 return false;
627 }
628 if !gate.regex.iter().all(|regex| regex.is_match(text)) {
629 return false;
630 }
631 if !gate.line_regex.iter().all(|regex| text.lines().any(|line| regex.is_match(line))) {
632 return false;
633 }
634 if !gate.all.iter().all(|nested| gate_matches(nested, text, lower_text)) {
635 return false;
636 }
637 if !gate.any.is_empty() && !gate.any.iter().any(|nested| gate_matches(nested, text, lower_text))
638 {
639 return false;
640 }
641 if gate.not_gate.iter().any(|nested| gate_matches(nested, text, lower_text)) {
642 return false;
643 }
644 true
645}
646
647fn region<'a>(input: DetectionInput<'a>, spec: &str) -> &'a str {
650 match spec {
652 "osc_title" => return input.osc_title,
653 "osc_progress" => return input.osc_progress,
654 _ => {}
655 }
656 let content = input.screen;
657 match spec {
658 "whole_recent" => content,
659 "after_last_prompt_marker" => after_last_prompt_marker(content),
660 "before_current_prompt_marker" => before_current_prompt_marker(content),
661 "whole_recent_without_current_prompt_marker" => {
662 whole_recent_without_current_prompt_marker(content)
663 }
664 "current_prompt_block_marker" => current_prompt_block_marker(content).unwrap_or(""),
665 "after_current_prompt_block_marker" => {
666 after_current_prompt_block_marker(content).unwrap_or("")
667 }
668 "prompt_box_body" => prompt_box_body(content).unwrap_or(""),
669 "above_prompt_box" => above_prompt_box(content),
670 "last_non_empty_above_prompt_box" => last_non_empty_line(above_prompt_box(content)),
671 "after_last_horizontal_rule" => after_last_horizontal_rule(content),
672 _ => {
673 if let Some(count) = region_count(spec, "bottom_lines") {
674 return bottom_lines(content, count);
675 }
676 if let Some(count) = region_count(spec, "bottom_non_empty_lines") {
677 return bottom_non_empty_lines(content, count);
678 }
679 if let Some(count) = top_region_count(spec) {
680 return top_non_empty_lines(content, count);
681 }
682 ""
683 }
684 }
685}
686
687fn region_count(spec: &str, name: &str) -> Option<usize> {
688 spec.strip_prefix(name)
689 .and_then(|rest| rest.strip_prefix('('))
690 .and_then(|rest| rest.strip_suffix(')'))
691 .and_then(|count| count.parse::<usize>().ok())
692}
693
694const MAX_TOP_REGION_LINE_COUNT: usize = u16::MAX as usize;
695
696fn top_region_count(spec: &str) -> Option<usize> {
697 let count = spec.strip_prefix("top_non_empty_lines")?.strip_prefix('(')?.strip_suffix(')')?;
698 if count.starts_with('0') || !count.bytes().all(|byte| byte.is_ascii_digit()) {
699 return None;
700 }
701 count.parse::<usize>().ok().filter(|count| *count <= MAX_TOP_REGION_LINE_COUNT)
702}
703
704fn bottom_lines(content: &str, count: usize) -> &str {
705 let lines: Vec<&str> = content.lines().collect();
706 let start = lines.len().saturating_sub(count);
707 slice_from_line_index(content, &lines, start)
708}
709
710fn bottom_non_empty_lines(content: &str, count: usize) -> &str {
711 let lines: Vec<&str> = content.lines().collect();
712 let Some(start_index) = lines
713 .iter()
714 .enumerate()
715 .rev()
716 .filter(|(_, line)| !line.trim().is_empty())
717 .take(count)
718 .last()
719 .map(|(index, _)| index)
720 else {
721 return "";
722 };
723 slice_from_line_index(content, &lines, start_index)
724}
725
726fn top_non_empty_lines(content: &str, count: usize) -> &str {
727 let lines: Vec<&str> = content.lines().collect();
728 let Some(end_index) = lines
729 .iter()
730 .enumerate()
731 .filter(|(_, line)| !line.trim().is_empty())
732 .take(count)
733 .last()
734 .map(|(index, _)| index)
735 else {
736 return "";
737 };
738 let byte_offset = line_start_offset(content, &lines, end_index + 1);
739 &content[..byte_offset]
740}
741
742fn after_last_prompt_marker(content: &str) -> &str {
743 let lines: Vec<&str> = content.lines().collect();
744 let Some(index) = lines.iter().rposition(|line| codex_prompt_line(line)) else {
745 return content;
746 };
747 slice_from_line_index(content, &lines, index + 1)
748}
749
750fn before_current_prompt_marker(content: &str) -> &str {
751 let lines: Vec<&str> = content.lines().collect();
752 let Some(index) = current_codex_prompt_index(&lines) else {
753 return content;
754 };
755 let byte_offset = lines[..index].iter().map(|line| line.len() + 1).sum::<usize>();
756 &content[..byte_offset.min(content.len())]
757}
758
759fn whole_recent_without_current_prompt_marker(content: &str) -> &str {
760 let lines: Vec<&str> = content.lines().collect();
761 if current_codex_prompt_index(&lines).is_some() {
762 ""
763 } else {
764 content
765 }
766}
767
768fn current_prompt_block_marker(content: &str) -> Option<&str> {
769 let lines: Vec<&str> = content.lines().collect();
770 let prompt_index = current_codex_prompt_index(&lines)?;
771 lines[..prompt_index].iter().rev().find(|line| codex_block_marker_line(line)).copied()
772}
773
774fn after_current_prompt_block_marker(content: &str) -> Option<&str> {
775 let lines: Vec<&str> = content.lines().collect();
776 let prompt_index = current_codex_prompt_index(&lines)?;
777 let block_index =
778 lines[..prompt_index].iter().rposition(|line| codex_block_marker_line(line))?;
779 Some(slice_from_line_index(content, &lines, block_index))
780}
781
782fn current_codex_prompt_index(lines: &[&str]) -> Option<usize> {
783 let prompt_index = lines.iter().rposition(|line| codex_prompt_line(line))?;
784 if lines[prompt_index + 1..].iter().any(|line| codex_block_marker_line(line)) {
785 return None;
786 }
787 Some(prompt_index)
788}
789
790fn codex_prompt_line(line: &str) -> bool {
791 line == "›" || line.starts_with("› ")
792}
793
794fn codex_block_marker_line(line: &str) -> bool {
795 line.starts_with('•') || line.starts_with('■') || line.starts_with('✗') || line.starts_with('✓')
796}
797
798fn prompt_box_body(content: &str) -> Option<&str> {
799 let lines: Vec<&str> = content.lines().collect();
800 let top = prompt_box_top_border_index(&lines)?;
801 let start = line_start_offset(content, &lines, top + 1);
802 let end_index = lines[top + 1..]
803 .iter()
804 .position(|line| is_horizontal_rule(line))
805 .map_or(lines.len(), |relative| top + 1 + relative);
806 let end = line_start_offset(content, &lines, end_index);
807 Some(&content[start.min(content.len())..end.min(content.len())])
808}
809
810fn above_prompt_box(content: &str) -> &str {
811 let lines: Vec<&str> = content.lines().collect();
812 let Some(top) = prompt_box_top_border_index(&lines) else {
813 return content;
814 };
815 let end = line_start_offset(content, &lines, top);
816 &content[..end.min(content.len())]
817}
818
819fn after_last_horizontal_rule(content: &str) -> &str {
820 let mut last_rule_end = 0usize;
821 let mut offset = 0usize;
822 for line in content.lines() {
823 let next_offset = offset + line.len() + 1;
824 if is_horizontal_rule(line) {
825 last_rule_end = next_offset.min(content.len());
826 }
827 offset = next_offset;
828 }
829 &content[last_rule_end..]
830}
831
832fn last_non_empty_line(content: &str) -> &str {
833 content.lines().rev().find(|line| !line.trim().is_empty()).unwrap_or("")
834}
835
836fn prompt_box_top_border_index(lines: &[&str]) -> Option<usize> {
837 let mut border_count = 0;
838 for index in (0..lines.len()).rev() {
839 if is_horizontal_rule(lines[index]) {
840 border_count += 1;
841 if border_count == 2 {
842 return Some(index);
843 }
844 }
845 }
846 None
847}
848
849fn is_horizontal_rule(line: &str) -> bool {
850 let trimmed = line.trim();
851 if trimmed.is_empty() {
852 return false;
853 }
854 let rule_chars = trimmed.chars().take_while(|&ch| ch == '─').count();
855 if rule_chars == 0 {
856 return false;
857 }
858 let rule_bytes =
859 trimmed.char_indices().nth(rule_chars).map_or(trimmed.len(), |(index, _)| index);
860 let suffix = trimmed[rule_bytes..].trim_start();
861 suffix.is_empty() || rule_chars >= 3
862}
863
864fn slice_from_line_index<'a>(content: &'a str, lines: &[&str], index: usize) -> &'a str {
865 let byte_offset = line_start_offset(content, lines, index);
866 &content[byte_offset.min(content.len())..]
867}
868
869fn line_start_offset(content: &str, lines: &[&str], index: usize) -> usize {
870 lines[..index.min(lines.len())]
871 .iter()
872 .map(|line| line.len() + 1)
873 .sum::<usize>()
874 .min(content.len())
875}
876
877#[cfg(test)]
878#[allow(clippy::expect_used, clippy::panic)]
879mod tests {
880 use super::*;
881
882 fn input(screen: &str) -> DetectionInput<'_> {
883 DetectionInput { screen, osc_title: "", osc_progress: "" }
884 }
885
886 #[test]
887 fn every_bundled_manifest_compiles() {
888 for content in BUNDLED_MANIFESTS {
891 if let Err(err) = compile_manifest(content) {
892 let id = content.lines().next().unwrap_or("").to_string();
893 panic!("bundled manifest failed to compile ({id}): {err}");
894 }
895 }
896 assert_eq!(manifests().len(), BUNDLED_MANIFESTS.len());
897 }
898
899 #[test]
900 fn lookup_resolves_ids_and_aliases() {
901 assert_eq!(manifest_for("claude").map(CompiledManifest::id), Some("claude"));
902 assert_eq!(manifest_for("claude-code").map(CompiledManifest::id), Some("claude"));
903 assert_eq!(manifest_for("CODEX").map(CompiledManifest::id), Some("codex"));
904 assert_eq!(manifest_for("antigravity").map(CompiledManifest::id), Some("agy"));
905 assert_eq!(manifest_for("gemini").map(CompiledManifest::id), Some("gemini"));
906 assert_eq!(manifest_for("ghcs").map(CompiledManifest::id), Some("copilot"));
907 assert_eq!(manifest_for("cursor-agent").map(CompiledManifest::id), Some("cursor"));
908 assert!(manifest_for("not-an-agent").is_none());
909 }
910
911 #[test]
912 fn claude_busy_via_footer_and_idle_prompt_box() {
913 let claude = manifest_for("claude").expect("claude manifest");
914 let busy = "❯ prompt\n\n* Hatching…\n⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt";
916 let verdict = claude.detect(input(busy));
917 assert_eq!(verdict.state, TurnState::Busy);
918 assert_eq!(verdict.rule.map(|(id, _)| id), Some("live_turn_working"));
919
920 let idle = "●─PONG\n──────────\n❯ \n──────────\n ⏵⏵ bypass permissions on";
922 let verdict = claude.detect(input(idle));
923 assert_eq!(verdict.state, TurnState::AwaitingInput);
924 assert_eq!(verdict.rule.map(|(id, _)| id), Some("live_prompt_box"));
925 }
926
927 #[test]
928 fn claude_tool_permission_dialog_is_approval() {
929 let claude = manifest_for("claude").expect("claude manifest");
930 let screen = "⏺ Bash(rm -rf /tmp/x)\nDo you want to proceed?\n❯ 1. Yes\n 2. No, and tell Claude what to do differently";
931 assert_eq!(claude.detect(input(screen)).state, TurnState::AwaitingApproval);
932 }
933
934 #[test]
935 fn claude_transcript_viewer_defers_to_quiescence() {
936 let claude = manifest_for("claude").expect("claude manifest");
937 let screen = "old transcript\nShowing detailed transcript\nctrl+o to toggle";
938 let verdict = claude.detect(input(screen));
939 assert_eq!(verdict.state, TurnState::Unknown);
940 assert_eq!(verdict.rule.map(|(id, _)| id), Some("transcript_viewer"));
941 }
942
943 #[test]
944 fn codex_working_footer_beats_stale_prompt() {
945 let codex = manifest_for("codex").expect("codex manifest");
946 let screen = "› an earlier prompt\n• Working (3s • esc to interrupt)";
947 let verdict = codex.detect(input(screen));
948 assert_eq!(verdict.state, TurnState::Busy);
949 assert_eq!(verdict.rule.map(|(id, _)| id), Some("screen_working_fallback"));
950 }
951
952 #[test]
953 fn codex_approval_dialog_after_prompt_marker() {
954 let codex = manifest_for("codex").expect("codex manifest");
955 let screen = "› run the command\n\n $ touch /tmp/x.txt\n\n Press enter to confirm or esc to cancel";
956 assert_eq!(codex.detect(input(screen)).state, TurnState::AwaitingApproval);
957 }
958
959 #[test]
960 fn osc_title_spinner_marks_codex_busy_and_plain_title_idle() {
961 let codex = manifest_for("codex").expect("codex manifest");
962 let screen = "› stale prompt from history";
963 let busy = codex.detect(DetectionInput {
965 screen,
966 osc_title: "⠋ codex — thinking",
967 osc_progress: "",
968 });
969 assert_eq!(busy.state, TurnState::Busy);
970 assert_eq!(busy.rule.map(|(id, _)| id), Some("osc_title_working"));
971
972 let blocked = codex.detect(DetectionInput {
974 screen,
975 osc_title: "Action Required — codex",
976 osc_progress: "",
977 });
978 assert_eq!(blocked.state, TurnState::AwaitingApproval);
979
980 let idle = codex.detect(DetectionInput { screen, osc_title: "codex", osc_progress: "" });
982 assert_eq!(idle.state, TurnState::AwaitingInput);
983 assert_eq!(idle.rule.map(|(id, _)| id), Some("osc_title_idle"));
984 }
985
986 #[test]
987 fn claude_osc_progress_zero_reads_idle() {
988 let claude = manifest_for("claude").expect("claude manifest");
989 let verdict =
990 claude.detect(DetectionInput { screen: "", osc_title: "", osc_progress: "4;0" });
991 assert_eq!(verdict.state, TurnState::AwaitingInput);
992 assert_eq!(verdict.rule.map(|(id, _)| id), Some("osc_progress_idle"));
993 }
994
995 #[test]
996 fn no_match_returns_unknown_not_idle() {
997 let claude = manifest_for("claude").expect("claude manifest");
1000 let verdict = claude.detect(input("$ ls\nfile.txt\n$"));
1001 assert_eq!(verdict.state, TurnState::Unknown);
1002 assert_eq!(verdict.rule, None);
1003 }
1004
1005 #[test]
1006 fn higher_priority_rule_wins_and_first_match_kept_on_tie() {
1007 let manifest = compile_manifest(
1008 r#"
1009id = "test"
1010[[rules]]
1011id = "low"
1012state = "idle"
1013priority = 10
1014contains = ["marker"]
1015[[rules]]
1016id = "tie-first"
1017state = "working"
1018priority = 50
1019contains = ["marker"]
1020[[rules]]
1021id = "tie-second"
1022state = "blocked"
1023priority = 50
1024contains = ["marker"]
1025"#,
1026 )
1027 .expect("test manifest");
1028 let manifest: &'static CompiledManifest = Box::leak(Box::new(manifest));
1030 let verdict = manifest.detect(input("marker"));
1031 assert_eq!(verdict.state, TurnState::Busy);
1032 assert_eq!(verdict.rule.map(|(id, _)| id), Some("tie-first"));
1033 }
1034
1035 #[test]
1036 fn gate_semantics_all_any_not_and_line_regex() {
1037 let manifest = compile_manifest(
1038 r#"
1039id = "test"
1040[[rules]]
1041id = "gated"
1042state = "blocked"
1043priority = 1
1044contains = ["always"]
1045any = [ { contains = ["option a"] }, { contains = ["option b"] } ]
1046not = [ { contains = ["veto"] } ]
1047line_regex = ['^\s*❯']
1048"#,
1049 )
1050 .expect("test manifest");
1051 let manifest: &'static CompiledManifest = Box::leak(Box::new(manifest));
1052 let hit = "ALWAYS shown\noption b\n ❯ pick";
1053 assert_eq!(manifest.detect(input(hit)).state, TurnState::AwaitingApproval);
1054 let vetoed = format!("{hit}\nveto");
1056 assert_eq!(manifest.detect(input(&vetoed)).state, TurnState::Unknown);
1057 let no_option = "always\n ❯ pick";
1059 assert_eq!(manifest.detect(input(no_option)).state, TurnState::Unknown);
1060 let no_prompt_line = "always ❯ inline\noption a";
1062 assert_eq!(manifest.detect(input(no_prompt_line)).state, TurnState::Unknown);
1063 }
1064
1065 #[test]
1066 fn region_windows_slice_as_documented() {
1067 let content = "top\n\nmid\nlast1\n\nlast2";
1068 assert_eq!(bottom_non_empty_lines(content, 2), "last1\n\nlast2");
1069 assert_eq!(bottom_lines(content, 1), "last2");
1070 assert_eq!(top_non_empty_lines(content, 1), "top\n");
1071 assert_eq!(last_non_empty_line("a\nb\n\n"), "b");
1072 }
1073
1074 #[test]
1075 fn prompt_box_regions_find_the_boxed_prompt() {
1076 let content = "history\n──────────\n❯ type here\n──────────\nfooter";
1077 assert_eq!(prompt_box_body(content), Some("❯ type here\n"));
1078 assert_eq!(above_prompt_box(content), "history\n");
1079 assert_eq!(after_last_horizontal_rule(content), "footer");
1080 }
1081
1082 #[test]
1083 fn codex_prompt_marker_regions() {
1084 let content = "• block\n› current prompt\ntail";
1085 assert_eq!(after_last_prompt_marker(content), "tail");
1086 assert_eq!(before_current_prompt_marker(content), "• block\n");
1087 let stale = "› old prompt\n• Working";
1089 assert_eq!(whole_recent_without_current_prompt_marker(stale), stale);
1090 assert_eq!(whole_recent_without_current_prompt_marker("› live prompt"), "");
1091 }
1092
1093 #[test]
1094 fn override_slot_hot_reloads_edits_and_reverts_on_removal() {
1095 let dir = tempfile::tempdir().expect("temp dir");
1096 let path = dir.path().join("claude.toml");
1097 let mut slot = OverrideSlot { last_checked: None, mtime: None, manifest: None };
1098
1099 refresh_override_slot(&mut slot, &path, "claude");
1101 assert!(slot.manifest.is_none());
1102
1103 std::fs::write(
1105 &path,
1106 "id = \"claude\"\n[[rules]]\nid = \"v1\"\nstate = \"working\"\ncontains = [\"override marker v1\"]\n",
1107 )
1108 .expect("write v1");
1109 refresh_override_slot(&mut slot, &path, "claude");
1110 let first = slot.manifest.expect("override should activate");
1111 assert_eq!(first.detect(input("override marker v1")).state, TurnState::Busy);
1112
1113 refresh_override_slot(&mut slot, &path, "claude");
1115 assert!(std::ptr::eq(first, slot.manifest.expect("override still active")));
1116
1117 std::fs::write(
1119 &path,
1120 "id = \"claude\"\n[[rules]]\nid = \"v2\"\nstate = \"blocked\"\ncontains = [\"override marker v2\"]\n",
1121 )
1122 .expect("write v2");
1123 bump_mtime(&path);
1124 refresh_override_slot(&mut slot, &path, "claude");
1125 let second = slot.manifest.expect("edited override should reload");
1126 assert_eq!(second.detect(input("override marker v2")).state, TurnState::AwaitingApproval);
1127 assert_eq!(second.detect(input("override marker v1")).state, TurnState::Unknown);
1128
1129 std::fs::write(&path, "not toml at all").expect("write broken");
1131 bump_mtime(&path);
1132 refresh_override_slot(&mut slot, &path, "claude");
1133 assert!(std::ptr::eq(second, slot.manifest.expect("broken edit keeps previous")));
1134
1135 std::fs::write(
1137 &path,
1138 "id = \"codex\"\n[[rules]]\nid = \"x\"\nstate = \"idle\"\ncontains = [\"y\"]\n",
1139 )
1140 .expect("write mismatched");
1141 bump_mtime(&path);
1142 refresh_override_slot(&mut slot, &path, "claude");
1143 assert!(std::ptr::eq(second, slot.manifest.expect("id mismatch keeps previous")));
1144
1145 std::fs::remove_file(&path).expect("remove override");
1147 refresh_override_slot(&mut slot, &path, "claude");
1148 assert!(slot.manifest.is_none());
1149 }
1150
1151 fn bump_mtime(path: &std::path::Path) {
1154 let file = std::fs::OpenOptions::new().write(true).open(path).expect("open for mtime");
1155 file.set_modified(SystemTime::now() + Duration::from_secs(2)).expect("bump mtime");
1156 }
1157
1158 #[test]
1159 fn engine_version_gate_rejects_future_manifests() {
1160 let err = compile_manifest(
1161 r#"
1162id = "future"
1163min_engine_version = 99
1164[[rules]]
1165id = "r"
1166state = "idle"
1167contains = ["x"]
1168"#,
1169 )
1170 .expect_err("future manifest must be rejected");
1171 assert!(err.contains("requires engine 99"), "unexpected error: {err}");
1172 }
1173
1174 #[test]
1175 fn skip_state_update_requires_unknown_state() {
1176 let err = compile_manifest(
1177 r#"
1178id = "bad"
1179[[rules]]
1180id = "r"
1181state = "idle"
1182skip_state_update = true
1183contains = ["x"]
1184"#,
1185 )
1186 .expect_err("skip_state_update with non-unknown state must be rejected");
1187 assert!(err.contains("skip_state_update"), "unexpected error: {err}");
1188 }
1189}