From 7ab047ffbc8391ca7d8785380d060025ebc6e15d Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 13 Aug 2026 23:48:30 +0800 Subject: [PATCH] feat(agent): add semantic detection and resumable sessions Use declarative screen evidence beneath exact hooks so more AI clients surface working and blocked states, while safe resume and fork commands preserve agent sessions across restarts. --- Cargo.lock | 9 +- THIRD-PARTY-NOTICES | 4 + nebula_app/Cargo.toml | 1 + nebula_app/src/agent_detection/claude.toml | 50 ++ nebula_app/src/agent_detection/codex.toml | 41 ++ nebula_app/src/agent_detection/copilot.toml | 29 + nebula_app/src/agent_detection/cursor.toml | 29 + nebula_app/src/agent_detection/gemini.toml | 22 + nebula_app/src/agent_detection/grok.toml | 40 ++ nebula_app/src/agent_detection/opencode.toml | 32 ++ nebula_app/src/agent_detection/pi.toml | 9 + nebula_app/src/ai_agents.rs | 537 +++++++++++++++++++ nebula_app/src/ai_hook.rs | 83 ++- nebula_app/src/ai_sessions.rs | 25 +- nebula_app/src/display/state.rs | 9 + nebula_app/src/event.rs | 36 +- nebula_app/src/main.rs | 1 + nebula_app/src/session.rs | 29 +- nebula_app/src/window_context.rs | 148 ++++- 19 files changed, 1074 insertions(+), 60 deletions(-) create mode 100644 nebula_app/src/agent_detection/claude.toml create mode 100644 nebula_app/src/agent_detection/codex.toml create mode 100644 nebula_app/src/agent_detection/copilot.toml create mode 100644 nebula_app/src/agent_detection/cursor.toml create mode 100644 nebula_app/src/agent_detection/gemini.toml create mode 100644 nebula_app/src/agent_detection/grok.toml create mode 100644 nebula_app/src/agent_detection/opencode.toml create mode 100644 nebula_app/src/agent_detection/pi.toml create mode 100644 nebula_app/src/ai_agents.rs 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()