diff --git a/Cargo.lock b/Cargo.lock index 60c4628..5068d33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4600,6 +4600,7 @@ dependencies = [ "pollster 0.4.0", "pulldown-cmark", "pulldown-latex", + "regex", "rfd", "russh", "russh-sftp", @@ -6452,9 +6453,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -6475,9 +6476,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "renderdoc-sys" diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 1790fa4..743ecb5 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -13,6 +13,10 @@ Native mathematical formula support uses pulldown-latex 0.7.1 under the MIT License, ttf-parser 0.25.1 under the MIT OR Apache-2.0 License, and ab_glyph_rasterizer 0.1.10 under the Apache-2.0 License. +The bundled AI-CLI screen-state evidence in +nebula_app/src/agent_detection derives from Herdr +(github.com/AmrDeveloper/herdr), distributed under the Apache License 2.0. + The embedded Latin Modern Math font is distributed under the GUST Font License 1.0. Its complete license text is included as licenses/LICENSE-LATIN-MODERN-MATH. diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index eb77503..79cc4b7 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -112,6 +112,7 @@ global-hotkey = "0.8.0" getrandom = "0.3.3" pollster = { version = "0.4", optional = true } wgpu = { version = "25.0.2", optional = true } +regex = "1.13.1" [build-dependencies] gl_generator = "0.14.0" diff --git a/nebula_app/src/agent_detection/claude.toml b/nebula_app/src/agent_detection/claude.toml new file mode 100644 index 0000000..ae9b81f --- /dev/null +++ b/nebula_app/src/agent_detection/claude.toml @@ -0,0 +1,50 @@ +# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +id = "claude" +aliases = ["claude-code"] + +[[rules]] +id = "live_form_blocked" +state = "blocked" +priority = 900 +region = "after_last_horizontal_rule" +contains = ["esc to cancel"] +any = [ + { contains = ["enter to select"] }, + { contains = ["enter to confirm"] }, + { contains = ["do you want to proceed?"] }, + { contains = ["tab to amend"] }, +] + +[[rules]] +id = "yes_no_blocked" +state = "blocked" +priority = 700 +region = "bottom_non_empty_lines(12)" +any = [ + { contains = ["do you want to proceed?"] }, + { contains = ["waiting for permission"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, +] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 400 +region = "bottom_non_empty_lines(6)" +line_regex = ['^[\s]*[\x{2801}-\x{28FF}]\s+\S'] +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["esc to cancel"] }, +] + +[[rules]] +id = "prompt_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(4)" +line_regex = ['^\s*❯(?:\s|$)'] +not = [ + { contains = ["esc to cancel"] }, + { contains = ["enter to select"] }, +] diff --git a/nebula_app/src/agent_detection/codex.toml b/nebula_app/src/agent_detection/codex.toml new file mode 100644 index 0000000..bfd5975 --- /dev/null +++ b/nebula_app/src/agent_detection/codex.toml @@ -0,0 +1,41 @@ +# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +id = "codex" + +[[rules]] +id = "strong_blocker" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(12)" +any = [ + { contains = ["press enter to confirm or esc to cancel"] }, + { contains = ["enter to submit answer"] }, + { contains = ["enter to submit all"] }, + { contains = ["allow command?"] }, + { contains = ["tab to add notes", "enter to submit"] }, +] + +[[rules]] +id = "yes_no_blocker" +state = "blocked" +priority = 700 +region = "bottom_non_empty_lines(12)" +any = [ + { contains = ["[y/n]"] }, + { contains = ["(y/n)"] }, + { contains = ["yes (y)"] }, +] + +[[rules]] +id = "working_status" +state = "working" +priority = 400 +region = "bottom_non_empty_lines(4)" +line_regex = ['^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$'] + +[[rules]] +id = "prompt_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(4)" +line_regex = ['^\s*›(?:\s|$)'] +not = [{ contains = ["esc to interrupt"] }] diff --git a/nebula_app/src/agent_detection/copilot.toml b/nebula_app/src/agent_detection/copilot.toml new file mode 100644 index 0000000..a2ec0f3 --- /dev/null +++ b/nebula_app/src/agent_detection/copilot.toml @@ -0,0 +1,29 @@ +# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +id = "copilot" +aliases = ["github-copilot", "ghcs"] + +[[rules]] +id = "selection_blocked" +state = "blocked" +priority = 800 +region = "bottom_non_empty_lines(12)" +all = [ + { any = [{ contains = ["esc to cancel"] }, { contains = ["esc cancel"] }] }, + { any = [ + { contains = ["enter to select"] }, + { contains = ["enter to confirm"] }, + { contains = ["enter to submit"] }, + { contains = ["enter accept"] }, + ] }, +] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 300 +region = "bottom_non_empty_lines(8)" +any = [ + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + { contains = ["esc interrupt"] }, +] diff --git a/nebula_app/src/agent_detection/cursor.toml b/nebula_app/src/agent_detection/cursor.toml new file mode 100644 index 0000000..c11fa8c --- /dev/null +++ b/nebula_app/src/agent_detection/cursor.toml @@ -0,0 +1,29 @@ +# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +id = "cursor" +aliases = ["cursor-agent"] + +[[rules]] +id = "write_approval_blocked" +state = "blocked" +priority = 850 +region = "bottom_non_empty_lines(10)" +any = [ + { contains = ["write to this file?", "proceed (y)"] }, + { contains = ["waiting for approval", "run this command?"] }, + { contains = ["skip (esc or n)"] }, + { line_regex = ['(?i)^\s*allow .*\(y\)'] }, +] + +[[rules]] +id = "stop_hint_working" +state = "working" +priority = 300 +region = "bottom_non_empty_lines(8)" +contains = ["ctrl+c to stop"] + +[[rules]] +id = "background_tasks_working" +state = "working" +priority = 250 +region = "bottom_non_empty_lines(6)" +line_regex = ['(?i)\b[1-9][0-9]*\s+background\s+tasks?\b'] diff --git a/nebula_app/src/agent_detection/gemini.toml b/nebula_app/src/agent_detection/gemini.toml new file mode 100644 index 0000000..1d4a7fd --- /dev/null +++ b/nebula_app/src/agent_detection/gemini.toml @@ -0,0 +1,22 @@ +# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +id = "gemini" +aliases = ["gemini-cli"] + +[[rules]] +id = "approval_blocked" +state = "blocked" +priority = 800 +region = "bottom_non_empty_lines(12)" +any = [ + { contains = ["apply this change"] }, + { contains = ["allow execution"] }, + { contains = ["waiting for user confirmation"] }, + { contains = ["do you want to proceed?"] }, +] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 300 +region = "bottom_non_empty_lines(6)" +contains = ["esc to cancel"] diff --git a/nebula_app/src/agent_detection/grok.toml b/nebula_app/src/agent_detection/grok.toml new file mode 100644 index 0000000..05f4e55 --- /dev/null +++ b/nebula_app/src/agent_detection/grok.toml @@ -0,0 +1,40 @@ +# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +id = "grok" +aliases = ["grok-cli", "grok-build"] + +[[rules]] +id = "option_dialog_blocked" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +any = [ + { line_regex = ['^\s*┃\s+[0-9a-z]+\s+\([●○]\)\s'] }, + { contains = [":select", "ctrl+o:yolo", "ctrl+c:cancel"] }, + { contains = ["tab:scrollback", "shift+x:dismiss"] }, + { contains = ["yes, proceed", "no, reject"] }, +] + +[[rules]] +id = "spinner_working" +state = "working" +priority = 400 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$'] + +[[rules]] +id = "cancel_hint_working" +state = "working" +priority = 300 +region = "bottom_non_empty_lines(4)" +contains = ["esc:cancel"] + +[[rules]] +id = "prompt_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(3)" +contains = ["ctrl+.:shortcuts"] +not = [ + { contains = ["esc:cancel"] }, + { contains = ["ctrl+c:cancel"] }, +] diff --git a/nebula_app/src/agent_detection/opencode.toml b/nebula_app/src/agent_detection/opencode.toml new file mode 100644 index 0000000..f6aebd0 --- /dev/null +++ b/nebula_app/src/agent_detection/opencode.toml @@ -0,0 +1,32 @@ +# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +id = "opencode" +aliases = ["open-code"] + +[[rules]] +id = "permission_blocked" +state = "blocked" +priority = 800 +region = "bottom_non_empty_lines(12)" +any = [ + { contains = ["permission required"] }, + { contains = ["esc dismiss", "enter confirm"] }, + { contains = ["esc dismiss", "enter submit"] }, +] + +[[rules]] +id = "interrupt_hint_working" +state = "working" +priority = 300 +region = "bottom_non_empty_lines(8)" +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, + { contains = ["press esc to interrupt"] }, +] + +[[rules]] +id = "progress_working" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(8)" +regex = ['(■|⬝){4,}'] diff --git a/nebula_app/src/agent_detection/pi.toml b/nebula_app/src/agent_detection/pi.toml new file mode 100644 index 0000000..fc447ba --- /dev/null +++ b/nebula_app/src/agent_detection/pi.toml @@ -0,0 +1,9 @@ +# Screen-chrome evidence adapted from Herdr's Apache-2.0 detection manifest. +id = "pi" + +[[rules]] +id = "working_literal" +state = "working" +priority = 300 +region = "bottom_non_empty_lines(8)" +contains = ["Working..."] diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs new file mode 100644 index 0000000..5c0f515 --- /dev/null +++ b/nebula_app/src/ai_agents.rs @@ -0,0 +1,537 @@ +//! Shared AI-CLI identity, resumability and screen-state detection. +//! +//! Hooks remain the highest-confidence lifecycle source. This module fills the +//! gaps hooks cannot cover: wrapped process identity, agents without a hook +//! API, and interactive permission/question screens that a coarse "turn done" +//! callback cannot distinguish. +//! +//! Detection rules are declarative TOML. Bundled rules cover the popular +//! clients; `%APPDATA%\Nebula\agent-detection\.toml` (or the equivalent +//! [`nebula_settings::settings_dir`]) overrides one manifest. Overrides are +//! mtime-checked at most once every two seconds, so rules can be tuned while a +//! client is running without putting filesystem work on every frame. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{OnceLock, RwLock}; +use std::time::{Duration, Instant, SystemTime}; + +use regex::Regex; +use serde::Deserialize; + +/// AI clients Nebula can identify as a first-class terminal workload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AgentKind { + Claude, + Codex, + Gemini, + Aider, + Amp, + OpenCode, + Copilot, + Cursor, + Goose, + Droid, + Pi, + Auggie, + Hermes, + Vibe, + Antigravity, + Grok, + Qwen, + OhMyPi, +} + +impl AgentKind { + pub const ALL: [Self; 18] = [ + Self::Claude, + Self::Codex, + Self::Gemini, + Self::Aider, + Self::Amp, + Self::OpenCode, + Self::Copilot, + Self::Cursor, + Self::Goose, + Self::Droid, + Self::Pi, + Self::Auggie, + Self::Hermes, + Self::Vibe, + Self::Antigravity, + Self::Grok, + Self::Qwen, + Self::OhMyPi, + ]; + + pub fn slug(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Gemini => "gemini", + Self::Aider => "aider", + Self::Amp => "amp", + Self::OpenCode => "opencode", + Self::Copilot => "copilot", + Self::Cursor => "cursor", + Self::Goose => "goose", + Self::Droid => "droid", + Self::Pi => "pi", + Self::Auggie => "auggie", + Self::Hermes => "hermes", + Self::Vibe => "vibe", + Self::Antigravity => "antigravity", + Self::Grok => "grok", + Self::Qwen => "qwen", + Self::OhMyPi => "omp", + } + } + + fn aliases(self) -> &'static [&'static str] { + match self { + Self::Claude => &["claude", "claude-code"], + Self::Codex => &["codex", "codex-cli"], + Self::Gemini => &["gemini", "gemini-cli"], + Self::Aider => &["aider", "aider-chat"], + Self::Amp => &["amp", "amp-local"], + Self::OpenCode => &["opencode", "open-code"], + Self::Copilot => &["copilot", "github-copilot", "ghcs"], + Self::Cursor => &["cursor", "cursor-agent"], + Self::Goose => &["goose"], + Self::Droid => &["droid"], + Self::Pi => &["pi"], + Self::Auggie => &["auggie"], + Self::Hermes => &["hermes", "hermes-agent"], + Self::Vibe => &["vibe", "vibe-acp"], + Self::Antigravity => &["agy", "antigravity", "antigravity-cli"], + Self::Grok => &["grok", "grok-cli", "grok-build"], + Self::Qwen => &["qwen", "qwen-code"], + Self::OhMyPi => &["omp", "oh-my-pi"], + } + } + + /// Resolve an executable/program label to a canonical client identity. + pub fn parse(raw: &str) -> Option { + let raw = raw + .trim() + .trim_matches(['"', '\'']) + .rsplit(['/', '\\']) + .next() + .unwrap_or(raw) + .to_ascii_lowercase(); + let raw = [".exe", ".cmd", ".bat", ".ps1", ".com", ".js"] + .into_iter() + .find_map(|suffix| raw.strip_suffix(suffix)) + .unwrap_or(&raw); + Self::ALL.into_iter().find(|agent| agent.aliases().contains(&raw)) + } + + /// Shell-safe resume command. Session ids are untrusted hook/file input, + /// so an unsupported shape returns `None` instead of being quoted. + pub fn resume_command(self, session_id: &str) -> Option { + valid_session_id(session_id)?; + Some(match self { + Self::Claude => format!("claude --resume {session_id}"), + Self::Codex => format!("codex resume {session_id}"), + Self::Gemini => format!("gemini --resume {session_id}"), + Self::OpenCode => format!("opencode --session {session_id}"), + Self::Amp => format!("amp threads continue {session_id}"), + Self::Cursor => format!("cursor-agent --resume {session_id}"), + Self::Copilot => format!("copilot --resume {session_id}"), + Self::Grok => format!("grok --resume {session_id}"), + Self::Pi => format!("pi --session {session_id}"), + Self::OhMyPi => format!("omp --resume {session_id}"), + Self::Aider + | Self::Goose + | Self::Droid + | Self::Auggie + | Self::Hermes + | Self::Vibe + | Self::Antigravity + | Self::Qwen => return None, + }) + } + + /// Shell-safe fork command for clients with a verified fork syntax. + pub fn fork_command(self, session_id: &str) -> Option { + valid_session_id(session_id)?; + Some(match self { + Self::Claude => format!("claude --resume {session_id} --fork-session"), + Self::Codex => format!("codex fork {session_id}"), + Self::OpenCode => format!("opencode --session {session_id} --fork"), + Self::Grok => format!("grok --resume {session_id} --fork-session"), + Self::OhMyPi => format!("omp --fork {session_id}"), + _ => return None, + }) + } +} + +fn valid_session_id(id: &str) -> Option<()> { + (!id.is_empty() + && id.len() <= 64 + && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))) + .then_some(()) +} + +/// Semantic state inferred from live application chrome. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AgentStatus { + Idle, + Working, + Blocked, + Done, + #[default] + Unknown, +} + +/// Why the current pane status has its value. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AgentStatusSource { + Hook, + Screen, + Process, + #[default] + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Detection { + pub agent: AgentKind, + pub status: AgentStatus, + pub rule_id: String, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct Manifest { + id: String, + #[serde(default)] + aliases: Vec, + rules: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct Rule { + id: String, + state: RuleState, + #[serde(default)] + priority: i32, + #[serde(default = "whole_recent")] + region: String, + #[serde(flatten)] + gate: Gate, +} + +#[derive(Debug, Deserialize, Clone, Default)] +#[serde(deny_unknown_fields)] +struct Gate { + #[serde(default)] + contains: Vec, + #[serde(default)] + regex: Vec, + #[serde(default)] + line_regex: Vec, + #[serde(default)] + all: Vec, + #[serde(default)] + any: Vec, + #[serde(default, rename = "not")] + not_gate: Vec, +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum RuleState { + Idle, + Working, + Blocked, +} + +impl From for AgentStatus { + fn from(state: RuleState) -> Self { + match state { + RuleState::Idle => Self::Idle, + RuleState::Working => Self::Working, + RuleState::Blocked => Self::Blocked, + } + } +} + +#[derive(Debug)] +struct CompiledManifest { + manifest: Manifest, + rules: Vec, + override_mtime: Option, +} + +#[derive(Debug)] +struct CompiledGate { + contains: Vec, + regex: Vec, + line_regex: Vec, + all: Vec, + any: Vec, + not_gate: Vec, +} + +#[derive(Debug)] +struct Cache { + manifests: HashMap, + last_override_scan: Instant, +} + +const BUNDLED: &[(AgentKind, &str)] = &[ + (AgentKind::Claude, include_str!("agent_detection/claude.toml")), + (AgentKind::Codex, include_str!("agent_detection/codex.toml")), + (AgentKind::Gemini, include_str!("agent_detection/gemini.toml")), + (AgentKind::Cursor, include_str!("agent_detection/cursor.toml")), + (AgentKind::OpenCode, include_str!("agent_detection/opencode.toml")), + (AgentKind::Copilot, include_str!("agent_detection/copilot.toml")), + (AgentKind::Grok, include_str!("agent_detection/grok.toml")), + (AgentKind::Pi, include_str!("agent_detection/pi.toml")), +]; + +static CACHE: OnceLock> = OnceLock::new(); + +fn cache() -> &'static RwLock { + CACHE.get_or_init(|| { + RwLock::new(Cache { manifests: build_cache(), last_override_scan: Instant::now() }) + }) +} + +fn build_cache() -> HashMap { + BUNDLED + .iter() + .map(|(agent, source)| { + let path = override_path(*agent); + let disk_mtime = modified(&path); + let bundled = compile_manifest(source, disk_mtime).unwrap_or_else(|error| { + panic!("bundled {} agent rules are invalid: {error}", agent.slug()) + }); + let loaded = std::fs::read_to_string(&path) + .ok() + .and_then(|text| { + compile_manifest(&text, disk_mtime) + .map_err(|error| { + log::warn!("agent detection: ignored {}: {error}", path.display()); + }) + .ok() + }) + .filter(|loaded| { + let matches = manifest_matches(&loaded.manifest, *agent); + if !matches { + log::warn!( + "agent detection: ignored {} because id {} does not match {}", + path.display(), + loaded.manifest.id, + agent.slug() + ); + } + matches + }) + .unwrap_or(bundled); + (*agent, loaded) + }) + .collect() +} + +fn override_path(agent: AgentKind) -> PathBuf { + settings_dir().join("agent-detection").join(format!("{}.toml", agent.slug())) +} + +/// Same path contract as nebula-settings, repeated here because that crate is +/// optional in non-GPUI builds while agent semantics serve both shells. +fn settings_dir() -> PathBuf { + if let Some(path) = std::env::var_os("NEBULA_CONFIG_DIR").filter(|path| !path.is_empty()) { + return PathBuf::from(path); + } + std::env::var_os("APPDATA") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .unwrap_or_else(std::env::temp_dir) + .join("Nebula") +} + +fn modified(path: &std::path::Path) -> Option { + path.metadata().and_then(|meta| meta.modified()).ok() +} + +fn refresh_overrides_if_needed() { + let should_scan = cache() + .read() + .map(|guard| guard.last_override_scan.elapsed() >= Duration::from_secs(2)) + .unwrap_or(true); + if !should_scan { + return; + } + let Ok(mut guard) = cache().write() else { return }; + if guard.last_override_scan.elapsed() < Duration::from_secs(2) { + return; + } + guard.last_override_scan = Instant::now(); + let changed = BUNDLED.iter().any(|(agent, _)| { + let path = override_path(*agent); + let disk_mtime = modified(&path); + guard.manifests.get(agent).is_none_or(|loaded| loaded.override_mtime != disk_mtime) + }); + if changed { + guard.manifests = build_cache(); + } +} + +/// Match one live screen snapshot. No match is `None`: callers retain their +/// higher-confidence hook/process state rather than fabricating idle. +pub fn detect(program: &str, screen: &str) -> Option { + let agent = AgentKind::parse(program)?; + refresh_overrides_if_needed(); + let guard = cache().read().ok()?; + let loaded = guard.manifests.get(&agent)?; + let mut best: Option<(&Rule, &CompiledGate)> = None; + for (rule, gate) in loaded.manifest.rules.iter().zip(&loaded.rules) { + let text = region(screen, &rule.region); + if !gate.matches(text) { + continue; + } + if best.is_none_or(|(previous, _)| rule.priority > previous.priority) { + best = Some((rule, gate)); + } + } + best.map(|(rule, _)| Detection { agent, status: rule.state.into(), rule_id: rule.id.clone() }) +} + +fn compile_manifest( + source: &str, + override_mtime: Option, +) -> Result { + let manifest: Manifest = toml::from_str(source).map_err(|error| error.to_string())?; + if manifest.rules.is_empty() || manifest.rules.len() > 64 { + return Err("manifest must contain 1..=64 rules".to_owned()); + } + let rules = manifest + .rules + .iter() + .map(|rule| compile_gate(&rule.gate)) + .collect::, _>>()?; + Ok(CompiledManifest { manifest, rules, override_mtime }) +} + +fn compile_gate(gate: &Gate) -> Result { + let compile = |patterns: &[String]| { + patterns + .iter() + .map(|pattern| Regex::new(pattern).map_err(|error| error.to_string())) + .collect::, _>>() + }; + Ok(CompiledGate { + contains: gate.contains.iter().map(|value| value.to_lowercase()).collect(), + regex: compile(&gate.regex)?, + line_regex: compile(&gate.line_regex)?, + all: gate.all.iter().map(compile_gate).collect::>()?, + any: gate.any.iter().map(compile_gate).collect::>()?, + not_gate: gate.not_gate.iter().map(compile_gate).collect::>()?, + }) +} + +impl CompiledGate { + fn matches(&self, text: &str) -> bool { + let lower = text.to_lowercase(); + self.contains.iter().all(|needle| lower.contains(needle)) + && self.regex.iter().all(|regex| regex.is_match(text)) + && self.line_regex.iter().all(|regex| text.lines().any(|line| regex.is_match(line))) + && self.all.iter().all(|gate| gate.matches(text)) + && (self.any.is_empty() || self.any.iter().any(|gate| gate.matches(text))) + && !self.not_gate.iter().any(|gate| gate.matches(text)) + } +} + +fn manifest_matches(manifest: &Manifest, agent: AgentKind) -> bool { + manifest.id == agent.slug() + || manifest.aliases.iter().any(|alias| agent.aliases().contains(&alias.as_str())) +} + +fn whole_recent() -> String { + "whole_recent".to_owned() +} + +fn region<'a>(screen: &'a str, spec: &str) -> &'a str { + if spec == "whole_recent" { + return screen; + } + if let Some(count) = region_count(spec, "bottom_non_empty_lines") { + let lines: Vec<&str> = screen.lines().collect(); + let Some(start) = lines + .iter() + .enumerate() + .rev() + .filter(|(_, line)| !line.trim().is_empty()) + .take(count) + .last() + .map(|(index, _)| index) + else { + return ""; + }; + return slice_from_line(screen, &lines, start); + } + if spec == "after_last_horizontal_rule" { + let mut offset = 0; + let mut last = 0; + for line in screen.lines() { + offset = (offset + line.len() + 1).min(screen.len()); + let trimmed = line.trim(); + if trimmed.chars().filter(|character| *character == '─').count() >= 3 { + last = offset; + } + } + return &screen[last..]; + } + "" +} + +fn region_count(spec: &str, name: &str) -> Option { + spec.strip_prefix(name)?.strip_prefix('(')?.strip_suffix(')')?.parse().ok() +} + +fn slice_from_line<'a>(screen: &'a str, lines: &[&str], index: usize) -> &'a str { + let offset = lines[..index.min(lines.len())] + .iter() + .map(|line| line.len() + 1) + .sum::() + .min(screen.len()); + &screen[offset..] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aliases_resolve_to_canonical_agents() { + assert_eq!(AgentKind::parse(r"C:\tools\CLAUDE.EXE"), Some(AgentKind::Claude)); + assert_eq!(AgentKind::parse("cursor-agent"), Some(AgentKind::Cursor)); + assert_eq!(AgentKind::parse("grok-cli.cmd"), Some(AgentKind::Grok)); + assert_eq!(AgentKind::parse("cargo"), None); + } + + #[test] + fn commands_are_exact_and_injection_safe() { + assert_eq!( + AgentKind::Claude.resume_command("abc-123").as_deref(), + Some("claude --resume abc-123") + ); + assert_eq!(AgentKind::Codex.fork_command("abc-123").as_deref(), Some("codex fork abc-123")); + assert_eq!(AgentKind::Claude.resume_command("x; calc"), None); + assert_eq!(AgentKind::Aider.resume_command("abc"), None); + } + + #[test] + fn screen_rules_distinguish_working_blocked_and_idle() { + let blocked = + detect("claude", "Do you want to proceed?\n❯ 1. Yes\n 2. No\nEsc to cancel").unwrap(); + assert_eq!(blocked.status, AgentStatus::Blocked); + let working = detect("codex", "• Working (12s • esc to interrupt)").unwrap(); + assert_eq!(working.status, AgentStatus::Working); + let idle = detect("claude", "────────────────\n❯ ").unwrap(); + assert_eq!(idle.status, AgentStatus::Idle); + } +} diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 312bf44..5384076 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -72,18 +72,33 @@ pub const HOOK_EXE_ENV: &str = "NEBULA_HOOK_EXE"; /// helper's name so entries survive Nebula moving to a new absolute path. const HELPER_MARK: &str = "nebula-hook"; -/// Claude hook events we subscribe to. -const CLAUDE_EVENTS: [&str; 3] = ["UserPromptSubmit", "Stop", "Notification"]; +/// Claude hook events we subscribe to. Session boundaries carry the id needed +/// for resume/fork; PostToolUse lets a stale permission state return to working +/// before the whole turn completes. +const CLAUDE_EVENTS: [&str; 6] = [ + "SessionStart", + "UserPromptSubmit", + "Notification", + "PostToolUse", + "Stop", + "SessionEnd", +]; /// What a lifecycle event means for the pane's turn state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AiHookKind { + /// Agent process/session became live; usually the earliest session-id edge. + SessionStart, /// The user submitted a prompt: a turn is running. PromptSubmit, + /// A tool completed; clears a stale permission/question wait. + ToolComplete, /// The turn finished; the CLI waits for the next instruction. TurnDone, /// The CLI needs the user NOW (permission prompt, idle reminder). NeedsAttention, + /// Agent session shut down and no longer owns the pane. + SessionEnd, } /// A typed AI-CLI lifecycle event, parsed from one pipe connection. @@ -133,12 +148,17 @@ fn parse_envelope(bytes: &[u8]) -> Option { let session_id = payload .get("session_id") .or_else(|| payload.get("thread-id")) + .or_else(|| payload.get("sessionID")) + .or_else(|| payload.get("sessionId")) .and_then(Value::as_str) .map(str::to_owned); let (kind, message) = match source.as_str() { "claude" => match payload.get("hook_event_name").and_then(Value::as_str) { + Some("SessionStart") => (AiHookKind::SessionStart, None), Some("UserPromptSubmit") => (AiHookKind::PromptSubmit, None), + Some("PostToolUse") => (AiHookKind::ToolComplete, None), Some("Stop") => (AiHookKind::TurnDone, None), + Some("SessionEnd") => (AiHookKind::SessionEnd, None), Some("Notification") => ( AiHookKind::NeedsAttention, payload.get("message").and_then(Value::as_str).map(str::to_owned), @@ -161,8 +181,11 @@ fn parse_envelope(bytes: &[u8]) -> Option { // embedded plugin in `ensure_opencode_plugin`), so this side stays // decoupled from opencode's evolving SDK event schema. "opencode" | "pi" => match payload.get("kind").and_then(Value::as_str) { + Some("session-start") => (AiHookKind::SessionStart, None), Some("prompt") => (AiHookKind::PromptSubmit, None), + Some("tool-complete") => (AiHookKind::ToolComplete, None), Some("done") => (AiHookKind::TurnDone, None), + Some("session-end") => (AiHookKind::SessionEnd, None), Some("attention") => ( AiHookKind::NeedsAttention, payload.get("message").and_then(Value::as_str).map(|m| truncate(m, 300)), @@ -268,6 +291,14 @@ mod remote_tests { #[test] fn pi_extension_events_use_the_normalized_lifecycle_shape() { + let start = parse_remote_envelope( + b"nebula-hook/1 source=pi pane=3\n{\"kind\":\"session-start\",\"sessionId\":\"pi-42\"}", + Some(3), + ) + .unwrap(); + assert_eq!(start.kind, AiHookKind::SessionStart); + assert_eq!(start.session_id.as_deref(), Some("pi-42")); + let prompt = parse_remote_envelope( b"nebula-hook/1 source=pi pane=3\n{\"kind\":\"prompt\"}", Some(3), @@ -279,6 +310,14 @@ mod remote_tests { parse_remote_envelope(b"nebula-hook/1 source=pi pane=3\n{\"kind\":\"done\"}", Some(3)) .unwrap(); assert_eq!(done.kind, AiHookKind::TurnDone); + + for (kind, expected) in [ + ("tool-complete", AiHookKind::ToolComplete), + ("session-end", AiHookKind::SessionEnd), + ] { + let raw = format!("nebula-hook/1 source=pi pane=3\n{{\"kind\":\"{kind}\"}}"); + assert_eq!(parse_remote_envelope(raw.as_bytes(), Some(3)).unwrap().kind, expected); + } } } @@ -657,16 +696,29 @@ export const NebulaNotify = async ({ $ }) => { if (!hook) return {} let active = false let lastUser = "" + let sessionId = "" const send = (obj) => { // Fire-and-forget; never throw into opencode's event loop. - try { $`${hook} opencode ${JSON.stringify(obj)}`.quiet().nothrow().catch(() => {}) } + try { + if (sessionId) obj.session_id = sessionId + $`${hook} opencode ${JSON.stringify(obj)}`.quiet().nothrow().catch(() => {}) + } catch (_) {} } return { event: async ({ event }) => { const t = event && event.type + const props = (event && event.properties) || {} + const info = props.info || {} + // Root session only: subagents carry parentID and must not steal the + // pane's resume identity. + const candidate = props.sessionID || info.sessionID || (!info.parentID && info.id) + if (candidate) { + const first = !sessionId + sessionId = candidate + if (first) send({ kind: "session-start" }) + } if (t === "message.updated") { - const info = event.properties && event.properties.info if (info && info.role === "user" && info.id !== lastUser) { lastUser = info.id active = true @@ -678,7 +730,11 @@ export const NebulaNotify = async ({ $ }) => { if (active) { active = false; send({ kind: "done" }) } } else if (t === "permission.updated") { active = false - send({ kind: "attention", message: (event.properties && event.properties.title) || "" }) + send({ kind: "attention", message: props.title || "" }) + } else if (t === "tool.execute.after") { + send({ kind: "tool-complete" }) + } else if (t === "session.deleted") { + send({ kind: "session-end" }) } }, } @@ -694,11 +750,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; export default function (pi: ExtensionAPI) { let active = false; - const send = (kind: "prompt" | "done") => { + const send = (kind: "session-start" | "prompt" | "tool-complete" | "done" | "session-end", ctx?: any) => { const hook = process.env.NEBULA_HOOK_EXE; if (!hook) return; try { - spawn(hook, ["pi", JSON.stringify({ kind })], { + let session_id = ""; + try { session_id = ctx?.sessionManager?.getSessionId?.() || ""; } catch (_) {} + spawn(hook, ["pi", JSON.stringify({ kind, session_id })], { detached: true, stdio: "ignore", windowsHide: true, @@ -706,15 +764,18 @@ export default function (pi: ExtensionAPI) { } catch (_) {} }; - pi.on("agent_start", async () => { + pi.on("agent_start", async (_event, ctx) => { active = true; - send("prompt"); + send("prompt", ctx); }); - pi.on("agent_end", async () => { + pi.on("agent_end", async (_event, ctx) => { if (!active) return; active = false; - send("done"); + send("done", ctx); }); + try { pi.on("session_start", async (_event, ctx) => send("session-start", ctx)); } catch (_) {} + try { pi.on("tool_result", async (_event, ctx) => send("tool-complete", ctx)); } catch (_) {} + try { pi.on("session_shutdown", async (_event, ctx) => send("session-end", ctx)); } catch (_) {} } "#; diff --git a/nebula_app/src/ai_sessions.rs b/nebula_app/src/ai_sessions.rs index 9497ef8..7fe51a1 100644 --- a/nebula_app/src/ai_sessions.rs +++ b/nebula_app/src/ai_sessions.rs @@ -29,6 +29,13 @@ impl AiSessionSource { Self::Codex => "codex", } } + + fn agent(self) -> crate::ai_agents::AgentKind { + match self { + Self::Claude => crate::ai_agents::AgentKind::Claude, + Self::Codex => crate::ai_agents::AgentKind::Codex, + } + } } #[derive(Debug, Clone)] @@ -48,10 +55,15 @@ pub struct AiSession { impl AiSession { /// 在终端里敲下去就能恢复这个会话的命令行。 pub fn resume_command(&self) -> String { - match self.source { - AiSessionSource::Claude => format!("claude --resume {}", self.id), - AiSessionSource::Codex => format!("codex resume {}", self.id), - } + self.source + .agent() + .resume_command(&self.id) + .expect("filesystem-derived Claude/Codex ids are valid session tokens") + } + + /// 从这条历史会话创建独立分叉,不改变原会话。 + pub fn fork_command(&self) -> Option { + self.source.agent().fork_command(&self.id) } /// hint 里的位置词。codex 有真实 cwd,取末段目录名;claude 只有编码过的 @@ -431,8 +443,13 @@ mod tests { path: PathBuf::new(), }; assert_eq!(claude.resume_command(), "claude --resume abc-123"); + assert_eq!( + claude.fork_command().as_deref(), + Some("claude --resume abc-123 --fork-session") + ); let codex = AiSession { source: AiSessionSource::Codex, ..claude }; assert_eq!(codex.resume_command(), "codex resume abc-123"); + assert_eq!(codex.fork_command().as_deref(), Some("codex fork abc-123")); } #[test] diff --git a/nebula_app/src/display/state.rs b/nebula_app/src/display/state.rs index e31ab1b..5806872 100644 --- a/nebula_app/src/display/state.rs +++ b/nebula_app/src/display/state.rs @@ -221,6 +221,15 @@ pub struct NebulaPaneState { /// 与 `running_program` 同生命周期:133;D 命令收尾时一起清除,快照据此 /// 判断「关窗那一刻这个 pane 里还开着哪个对话」,冷恢复接续它。 pub ai_session: Option, + /// 统一 agent 语义状态。Hook 是权威边界;声明式屏幕规则补足无 hook + /// 客户端及“回合完成 vs 正在问人”这类事件流无法分辨的现场。 + pub agent_status: crate::ai_agents::AgentStatus, + pub agent_status_source: crate::ai_agents::AgentStatusSource, + /// 最后命中的声明式规则 id,供诊断日志/问题截图对账。 + pub agent_status_rule: Option, + /// 本次前台 agent 命令是否收到过 hook。屏幕规则的 idle 不得覆盖精确的 + /// hook done;可见 blocker/working 仍可纠正漏报或过时事件。 + pub agent_hook_seen: bool, pub last_committed: String, pub awaiting_input: bool, pub finished_unseen: bool, diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index da8ec93..73500d5 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -1272,6 +1272,9 @@ impl ApplicationHandler for Processor { }, (EventType::NebulaTick, Some(window_id)) => { if let Some(window_context) = self.windows.get_mut(window_id) { + // Agent screen semantics (blocked/working/idle) are a + // cheap 1 Hz fallback under exact lifecycle hooks. + window_context.refresh_agent_screen_states(); // Piggyback session persistence on the 1 Hz chrome clock. window_context.autosave_session(); // 渲染门控看门狗:被误报的遮挡/丢失的帧回调在这里解锁 @@ -3180,8 +3183,31 @@ impl input::Processor> { self.ctx.nebula_state.command_started = Some(Instant::now()); // Program identity for the sidebar tab icon, from the // line captured at Enter (buffers are cleared by now). - self.ctx.nebula_state.running_program = - crate::display::extract_program(&self.ctx.nebula_state.last_committed); + self.ctx.nebula_state.running_program = crate::display::extract_program( + &self.ctx.nebula_state.last_committed, + ) + .map(|program| { + crate::ai_agents::AgentKind::parse(&program) + .map(|agent| agent.slug().to_owned()) + .unwrap_or(program) + }); + self.ctx.nebula_state.agent_hook_seen = false; + self.ctx.nebula_state.agent_status_rule = None; + self.ctx.nebula_state.agent_status_source = + crate::ai_agents::AgentStatusSource::Process; + self.ctx.nebula_state.agent_status = + if self + .ctx + .nebula_state + .running_program + .as_deref() + .and_then(crate::ai_agents::AgentKind::parse) + .is_some() + { + crate::ai_agents::AgentStatus::Working + } else { + crate::ai_agents::AgentStatus::Unknown + }; // Arm the ssh host auto-save: when this command is an // interactive ssh login, hold its destination until a // remote NEBULA| title or a long-enough session @@ -3198,6 +3224,12 @@ impl input::Processor> { // CLI 退回提示符,对话不再是这个 pane 的前台事实; // 留着它,快照会把一个已经退出的会话当活的接续。 self.ctx.nebula_state.ai_session = None; + self.ctx.nebula_state.agent_hook_seen = false; + self.ctx.nebula_state.agent_status = + crate::ai_agents::AgentStatus::Unknown; + self.ctx.nebula_state.agent_status_source = + crate::ai_agents::AgentStatusSource::Unknown; + self.ctx.nebula_state.agent_status_rule = None; let pending_ssh = self.ctx.nebula_state.pending_ssh_host.take(); self.ctx.nebula_state.awaiting_input = false; // 助手错误恢复(spec 001):Nebula 集成上报的退出码 diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 1bdf40c..33c0e04 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -28,6 +28,7 @@ use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; use nebula_terminal::tty; mod ai_assistant; +mod ai_agents; mod ai_hook; mod ai_providers; mod ai_sessions; diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index f9efd53..797646a 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -83,30 +83,19 @@ impl AgentSession { /// 缺 id)时返回 `None`,宁可只恢复布局也不上屏可疑字节。命令形状与 /// `ai_sessions::AiSession::resume_command`(手动恢复面板)保持一致。 pub fn resume_command(&self) -> Option { - let id = match self.session_id.as_deref() { - Some(id) if valid_session_id(id) => Some(id), - // 有 id 但形状可疑:绝不把它敲进终端。 - Some(_) => return None, - None => None, - }; - match (self.source.as_str(), id) { - ("claude", Some(id)) => Some(format!("claude --resume {id}")), + let agent = crate::ai_agents::AgentKind::parse(&self.source)?; + match self.session_id.as_deref() { + Some(id) => agent.resume_command(id), // claude 无 id(hook 未装、OSC 认出的启动):`--continue` 恢复 // 当前目录最近一次对话——pane 的 cwd 已经先被恢复,语义正好。 - ("claude", None) => Some("claude --continue".to_owned()), - ("codex", Some(id)) => Some(format!("codex resume {id}")), + None if agent == crate::ai_agents::AgentKind::Claude => { + Some("claude --continue".to_owned()) + }, // codex 没有按目录 continue 的形式,缺 id 只能放弃接续。 _ => None, } } -} -/// 会话 id 只可能是 uuid 一族的字符集;其余一律拒绝,这行字符串会被敲进 -/// 用户的 shell,必须按不可信输入对待。 -fn valid_session_id(id: &str) -> bool { - !id.is_empty() - && id.len() <= 64 - && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') } /// A tab's pane tree. Leaves carry each pane's working directory; splits carry @@ -486,7 +475,11 @@ mod tests { Some("codex resume b5f6c1c2-1111-2222-3333-444455556666") ); assert_eq!(agent("codex", None).resume_command(), None); - assert_eq!(agent("gemini", Some("abc")).resume_command(), None, "无 resume 语法的来源"); + assert_eq!( + agent("gemini", Some("abc")).resume_command().as_deref(), + Some("gemini --resume abc") + ); + assert_eq!(agent("aider", Some("abc")).resume_command(), None, "无 resume 语法的来源"); // shell 元字符、空串、超长——全都不许上屏。 assert_eq!(agent("claude", Some("abc; rm -rf /")).resume_command(), None); assert_eq!(agent("claude", Some("")).resume_command(), None); diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index f5f4a16..80e0458 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -51,7 +51,7 @@ mod model; mod nebula_fetch_art; mod runtime; /// New-tab welcome page (Windows logo + fastfetch intro). Stateless helpers. -mod welcome; +pub(crate) mod welcome; use welcome::nebula_fastfetch_intro_command_for; use model::{DOC_PANE_ID, Layout, PaneId, TabEntry, TabLaunch}; @@ -2113,6 +2113,9 @@ impl WindowContext { { let state = &mut self.panes[idx].nebula_state; state.running_program = Some(ev.source.clone()); + state.agent_hook_seen = true; + state.agent_status_source = crate::ai_agents::AgentStatusSource::Hook; + state.agent_status_rule = None; // 会话身份跟着事件走:同一个 pane 里 /clear、重开会话都会带来 // 新 id,最后一次上报永远是权威。133;D(CLI 退回提示符)清除。 if let Some(id) = ev.session_id.as_deref() { @@ -2124,15 +2127,34 @@ impl WindowContext { } match ev.kind { + crate::ai_hook::AiHookKind::SessionStart => { + let state = &mut self.panes[idx].nebula_state; + state.agent_status = crate::ai_agents::AgentStatus::Idle; + state.awaiting_input = true; + state.needs_attention = false; + state.command_started.get_or_insert_with(Instant::now); + }, crate::ai_hook::AiHookKind::PromptSubmit => { // A turn started: spinner resumes, stale dot is consumed. let state = &mut self.panes[idx].nebula_state; + state.agent_status = crate::ai_agents::AgentStatus::Working; state.awaiting_input = false; + state.needs_attention = false; state.finished_unseen = false; // No shell integration = no OSC 133;C ever ran: give the // spinner a start mark so the turn still animates. state.command_started.get_or_insert_with(std::time::Instant::now); }, + crate::ai_hook::AiHookKind::ToolComplete => { + let state = &mut self.panes[idx].nebula_state; + // Tool completion is meaningful chiefly after a permission + // wait: the user answered and work resumed. + if state.agent_status == crate::ai_agents::AgentStatus::Blocked { + state.agent_status = crate::ai_agents::AgentStatus::Working; + state.awaiting_input = false; + state.needs_attention = false; + } + }, crate::ai_hook::AiHookKind::TurnDone | crate::ai_hook::AiHookKind::NeedsAttention => { // codex 的 notify 只有"回合完成"一种事件:弹出交互式提问时 // 它发的也是 turn-complete,事件流分不出"说完了"和"在等你 @@ -2151,6 +2173,12 @@ impl WindowContext { }; { let state = &mut self.panes[idx].nebula_state; + state.agent_status = + if ev.kind == crate::ai_hook::AiHookKind::NeedsAttention || screen_asks { + crate::ai_agents::AgentStatus::Blocked + } else { + crate::ai_agents::AgentStatus::Done + }; state.awaiting_input = true; state.finished_unseen = true; // 「等你批准」是比「回合完成」更强的状态:它不是通知你 @@ -2192,6 +2220,17 @@ impl WindowContext { ); } }, + crate::ai_hook::AiHookKind::SessionEnd => { + let state = &mut self.panes[idx].nebula_state; + state.agent_status = crate::ai_agents::AgentStatus::Unknown; + state.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown; + state.agent_status_rule = None; + state.agent_hook_seen = false; + state.ai_session = None; + state.running_program = None; + state.awaiting_input = false; + state.needs_attention = false; + }, } self.dirty = true; @@ -2199,34 +2238,101 @@ impl WindowContext { true } + /// 1 Hz 声明式屏幕检测。Hook 仍是精确边界;屏幕承担两类补位: + /// + /// 1. Gemini/Cursor/Copilot 等尚无 hook 桥接的客户端; + /// 2. 可见的权限/问题框(比“turn complete”事件更能证明正在等人)。 + /// + /// 只读底部 24 行,规则已预编译;普通 shell 或未知程序立即跳过。 + pub fn refresh_agent_screen_states(&mut self) { + for pane in &mut self.panes { + let Some(program) = pane.nebula_state.running_program.clone() else { + continue; + }; + if crate::ai_agents::AgentKind::parse(&program).is_none() { + continue; + } + let screen = { + let term = pane.terminal.lock(); + let lines = term.screen_lines(); + if lines == 0 || term.columns() == 0 { + continue; + } + let take = lines.min(24); + let start = Point::new(Line((lines - take) as i32), Column(0)); + let end = + Point::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); + term.bounds_to_string(start, end) + }; + let Some(detection) = crate::ai_agents::detect(&program, &screen) else { + continue; + }; + + let state = &mut pane.nebula_state; + // An exact hook-done must not be downgraded merely because an idle + // prompt is visible. Strong live blocker/working chrome may still + // correct a missed/new event. + if detection.status == crate::ai_agents::AgentStatus::Idle && state.agent_hook_seen { + continue; + } + let previous = state.agent_status; + if previous != detection.status + || state.agent_status_rule.as_deref() != Some(&detection.rule_id) + { + log::debug!( + "agent screen state: pane={} program={} {:?}->{:?} rule={}", + pane.id, + program, + previous, + detection.status, + detection.rule_id + ); + } + state.agent_status = detection.status; + state.agent_status_source = crate::ai_agents::AgentStatusSource::Screen; + state.agent_status_rule = Some(detection.rule_id); + + match detection.status { + crate::ai_agents::AgentStatus::Blocked => { + state.awaiting_input = true; + state.needs_attention = true; + state.finished_unseen = true; + }, + crate::ai_agents::AgentStatus::Working => { + state.awaiting_input = false; + state.needs_attention = false; + state.finished_unseen = false; + state.command_started.get_or_insert_with(Instant::now); + }, + crate::ai_agents::AgentStatus::Idle => { + state.awaiting_input = true; + state.needs_attention = false; + if previous == crate::ai_agents::AgentStatus::Working { + state.finished_unseen = true; + state.finished_at = Some(Instant::now()); + } + }, + crate::ai_agents::AgentStatus::Done + | crate::ai_agents::AgentStatus::Unknown => {}, + } + } + // Chrome/tray read the established fields; rebuilding their compact + // arrays here makes the detector visible in the same tick. + self.sync_chrome_tabs(); + } + /// 托盘菜单的数据面(T1-3):本窗口所有正在跑 AI CLI 的 pane。与侧栏 /// 徽章同一事实源(`running_program` + `needs_attention`),托盘因此 /// 永远和 pane 徽章一致,不另立第二份 agent 状态。 pub fn tray_agents(&self) -> Vec { - /// 托盘只列 agent 类 CLI(hook 直报的四家 + 图标表认识的其他家)。 - /// vim/cargo 这类长命令不进托盘——它们不会「停下来等你回答」。 - fn is_agent(program: &str) -> bool { - matches!( - program, - "claude" - | "codex" - | "opencode" - | "pi" - | "gemini" - | "copilot" - | "cursor-agent" - | "grok" - | "grok-cli" - | "aider" - | "goose" - | "crush" - ) - } self.panes .iter() .filter_map(|pane| { let state = &pane.nebula_state; - let program = state.running_program.as_deref().filter(|p| is_agent(p))?; + let program = state + .running_program + .as_deref() + .filter(|program| crate::ai_agents::AgentKind::parse(program).is_some())?; // 「哪个项目」比「哪个 pane id」有意义:取 cwd 尾段。 let place = std::path::Path::new(state.cwd.trim()) .file_name()