diff --git a/nebula_app/src/agent_detection/_shared.toml b/nebula_app/src/agent_detection/_shared.toml new file mode 100644 index 0000000..c85dad0 --- /dev/null +++ b/nebula_app/src/agent_detection/_shared.toml @@ -0,0 +1,82 @@ +# 所有 AI CLI 共享的骨架规则,编译时自动并入每一份 per-agent manifest。 +# +# 立此文件的原因(2026-08-22,真机取证):per-agent manifest 各写各的 +# working 判据,写法五花八门且互相不知道对方踩过的坑——claude.toml 要求 +# 「盲文点阵 spinner 行 AND esc to interrupt」,而 Claude Code 的动画符号是 +# ✻✶✳·(U+2736 等)不是盲文,Gate 又是 AND 语义,于是那条规则从上线起 +# 从未命中过;漏判 working 之后屏幕只剩 idle 规则命中,回合进行中被连续 +# 两拍降级成「完成」蓝点。同类 AND 陷阱在 devin/kiro 里原样存在。 +# +# 归一化的依据是所有 agent CLI 共有的一件事:**正在干活时,它必须告诉用户 +# 怎么打断它**。中断提示的出现与消失,比 spinner 字形、动词表、计时格式都 +# 稳定得多——字形每次改版都在变,"how to stop me" 不会。 +# +# 优先级分层(per-agent 规则用 100/400/700/900 这几档): +# 900/700 per-agent 专有 blocked——最精确,永远优先 +# 800 shared blocked:给没写全 blocked 规则的 CLI 兜底 +# 400 per-agent 专有 working +# 350 shared working:中断提示,本文件的主力 +# 100 per-agent 专有 idle +# shared 不提供 idle 规则:空闲提示符字符(❯ › > ➜ $)差异大且与正文、引用 +# 撞脸,误判 idle 的代价(假完成)正是本次要修的 bug,不能由共享层引入。 + +# 停在等人表态的框上。per-agent 的 900/700 规则更精确时由它们取胜;这里 +# 只保证「装了新 CLI 但还没写 blocked 规则」时不至于把权限框当成工作中。 +[[rules]] +id = "shared_awaiting_answer" +state = "blocked" +priority = 800 +region = "bottom_non_empty_lines(12)" +any = [ + { contains = ["do you want to proceed"] }, + { contains = ["waiting for permission"] }, + { contains = ["requires approval"] }, + { contains = ["enter to confirm"] }, + { contains = ["enter to submit answer"] }, + { contains = ["enter to submit all"] }, + { contains = ["allow command?"] }, + { contains = ["tab to amend"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, +] + +# 工作中:屏幕上挂着「怎么中断我」的提示。 +# +# region 取 10 个非空行而不是 4~6:中断提示通常在最底部的模式栏里,与 +# spinner 行之间往往隔着 Tip、分隔线和输入框——pane 12 实测底部结构是 +# 「spinner / Tip / 续行 / ─── / ❯ / ─── / ⏵⏵ … esc to interrupt」,spinner +# 与中断提示相距 6 行,任何小 region 都会切掉其中一头。 +# +# not 段是必需的:权限确认框同样打印「Esc to cancel」(pane 11 实测 +# 「Esc to cancel · Tab to amend · ctrl+e to explain」),只看中断词会把 +# 「等你点头」误判成「正在干活」。blocked 的专有规则优先级更高本来就能 +# 赢,这里再排一次,保证没写 blocked 规则的 CLI 也不会错点转圈。 +[[rules]] +id = "shared_interrupt_hint_working" +state = "working" +priority = 350 +region = "bottom_non_empty_lines(10)" +any = [ + { contains = ["esc to interrupt"] }, + { contains = ["press esc to interrupt"] }, + { contains = ["ctrl+c to interrupt"] }, + { contains = ["esc interrupt"] }, + { contains = ["esc to cancel"] }, + { contains = ["esc cancel"] }, + { contains = ["esc:cancel"] }, + { contains = ["esc to stop"] }, + { contains = ["ctrl+c to stop"] }, + { contains = ["ctrl+c cancel"] }, + { contains = ["msg=interrupt"] }, +] +not = [ + { contains = ["do you want to proceed"] }, + { contains = ["requires approval"] }, + { contains = ["waiting for permission"] }, + { contains = ["enter to confirm"] }, + { contains = ["enter to select"] }, + { contains = ["enter to submit answer"] }, + { contains = ["tab to amend"] }, + { contains = ["(y/n)"] }, + { contains = ["[y/n]"] }, +] diff --git a/nebula_app/src/agent_detection/claude.toml b/nebula_app/src/agent_detection/claude.toml index 3a4b96b..da930fd 100644 --- a/nebula_app/src/agent_detection/claude.toml +++ b/nebula_app/src/agent_detection/claude.toml @@ -27,16 +27,21 @@ any = [ { contains = ["[y/n]"] }, ] +# 回合进行中的唯一可靠证据是底栏的「esc to interrupt」:Claude Code 只在 +# 真正可中断(即回合未结束)时打印它,回合一结束整行消失。 +# +# 2026-08-22:这条规则此前要求「盲文点阵 spinner 行」AND「esc to interrupt」 +# ——Gate 是 AND 语义(ai_agents.rs:528),而 Claude Code 的动画符号是 +# ·✢✳✶✻✽ 而非盲文点阵,第一个条件恒假,整条规则从未命中过。于是运行中的 +# 屏幕只剩 prompt_idle 命中,连续两拍把 Working 降级成 Done,侧栏在 Claude +# 正干活时显示「完成」蓝点。动画符号本身不能当证据:回合结束的总结行 +# 「✳ Baked for 14m 41s」用的是同一组符号。 [[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"] }, -] +region = "bottom_non_empty_lines(8)" +contains = ["esc to interrupt"] [[rules]] id = "prompt_idle" @@ -47,4 +52,8 @@ line_regex = ['^\s*❯(?:\s|$)'] not = [ { contains = ["esc to cancel"] }, { contains = ["enter to select"] }, + # 输入框在回合进行中同样可见(Claude Code 允许排队输入),所以「看见 ❯」 + # 本身不等于空闲。底栏离输入框足够近时这条 not 直接兜住;离得远时由 + # spinner_working 的更高优先级取胜。 + { contains = ["esc to interrupt"] }, ] diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs index af2cbf4..f26f300 100644 --- a/nebula_app/src/ai_agents.rs +++ b/nebula_app/src/ai_agents.rs @@ -290,6 +290,13 @@ struct Manifest { rules: Vec, } +/// `_shared.toml`: rules merged into every manifest, with no agent identity. +#[derive(Debug, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +struct SharedManifest { + rules: Vec, +} + #[derive(Debug, Deserialize, Clone)] #[serde(deny_unknown_fields)] struct Rule { @@ -361,6 +368,9 @@ struct Cache { last_override_scan: Instant, } +/// Skeleton rules merged into every manifest below (and into user overrides). +const SHARED: &str = include_str!("agent_detection/_shared.toml"); + const BUNDLED: &[(AgentKind, &str)] = &[ (AgentKind::Claude, include_str!("agent_detection/claude.toml")), (AgentKind::Codex, include_str!("agent_detection/codex.toml")), @@ -495,10 +505,14 @@ fn compile_manifest( source: &str, override_mtime: Option, ) -> Result { - let manifest: Manifest = toml::from_str(source).map_err(|error| error.to_string())?; + let mut 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()); } + // 共享骨架并入每一份 manifest——bundled 与用户 override 一视同仁,装了 + // 新 CLI 或改了本地规则都自动带上中断提示判据。理由见 _shared.toml 顶部: + // per-agent 的 working 规则各写各的,同一个 AND 陷阱在多份文件里重复出现。 + manifest.rules.extend(shared_rules().iter().cloned()); let rules = manifest .rules .iter() @@ -507,6 +521,16 @@ fn compile_manifest( Ok(CompiledManifest { manifest, rules, override_mtime }) } +/// Rules every agent manifest inherits. Parsed once; see `_shared.toml`. +fn shared_rules() -> &'static Vec { + static SHARED_RULES: OnceLock> = OnceLock::new(); + SHARED_RULES.get_or_init(|| { + let parsed: SharedManifest = toml::from_str(SHARED) + .unwrap_or_else(|error| panic!("bundled shared agent rules are invalid: {error}")); + parsed.rules + }) +} + fn compile_gate(gate: &Gate) -> Result { let compile = |patterns: &[String]| { patterns @@ -628,4 +652,88 @@ mod tests { let idle = detect("claude", "────────────────\n❯ ").unwrap(); assert_eq!(idle.status, AgentStatus::Idle); } + + /// 下面四段都是 2026-08-22 用 runtime `pane.read` 从真实窗口抓的原文, + /// 也就是判错的现场。规则改动必须对着它们回归,不能对着记忆里的格式写。 + #[test] + fn real_claude_working_chrome_reads_working() { + // 正在干活的 pane。spinner 行用 ✶(U+2736)而不是盲文点阵,且与底部 + // 中断提示相隔 6 行——旧规则要求「盲文行 AND 中断词」且 region 只有 + // 6 行,两个条件同时落空,回合进行中因此被判成 idle,再连续两拍降级 + // 成「完成」蓝点。 + let screen = " ⎿ Running…\n\ + ✶ Moseying… (8m 42s · ↓ 18.3k tokens)\n\ + \x20 ⎿ Tip: You haven't used the ui-ux-pro-max plugin in a while.\n\ + \x20 with /plugin\n\ + ────────────────────────────────────────\n\ + ❯ \n\ + ────────────────────────────────────────\n\ + \x20 ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← for agents"; + let detection = detect("claude", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Working, "rule={}", detection.rule_id); + } + + #[test] + fn real_claude_idle_chrome_reads_idle() { + // 回合已结束:没有中断提示,输入框空着。输入框可见本身不是判据—— + // 上面那段工作中的屏幕里 ❯ 同样在。 + let screen = "✻ Baked for 15m 24s\n\ + ────────────────────────────────────────\n\ + ❯ \n\ + ────────────────────────────────────────\n\ + \x20 ⏸ manual mode on · ? for shortcuts · ← for agents"; + let detection = detect("claude", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Idle, "rule={}", detection.rule_id); + } + + #[test] + fn real_claude_permission_form_reads_blocked() { + // 真的停在权限框上。这段同时含「Esc to cancel」,共享 working 规则 + // 必须被它的 not 段拦住,否则「等你点头」会被当成「正在干活」。 + let screen = " List dist artifacts and query GitHub release assets\n\ + \x20This command requires approval\n\ + \x20Do you want to proceed?\n\ + \x20❯ 1. Yes\n\ + \x20 2. Yes, and don't ask again for: gh release *\n\ + \x20 3. No\n\ + \x20Esc to cancel · Tab to amend · ctrl+e to explain"; + let detection = detect("claude", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Blocked, "rule={}", detection.rule_id); + } + + #[test] + fn real_codex_idle_chrome_reads_idle() { + // codex 早已答完,屏幕上就是空闲输入框。这块屏幕当初根本没人去匹: + // running_program 是 None,1 Hz 看门狗在入口就早退了,于是转圈长挂。 + let screen = " 代码级修复和带 gpui-shell 的编译、针对性测试已经通过。\n\ + › Improve documentation in @filename\n\ + \x20 gpt-5.6-sol xhigh · D:\\temp_build\\nebula"; + let detection = detect("codex", screen).unwrap(); + assert_eq!(detection.status, AgentStatus::Idle, "rule={}", detection.rule_id); + } + + #[test] + fn shared_rules_light_up_agents_without_their_own_working_rule() { + // 归一化的意义:中断提示对每个注册的 CLI 都点亮 working,新接的 CLI + // 不必从零再写一遍 spinner 正则(cline 至今就没有 working 规则)。 + let screen = "some output\n────────\n> \n esc to interrupt · ? for shortcuts"; + for agent in ["grok", "pi", "opencode", "gemini", "cline", "kilo"] { + let found = + detect(agent, screen).unwrap_or_else(|| panic!("{agent} produced no detection")); + assert_eq!(found.status, AgentStatus::Working, "{agent} rule={}", found.rule_id); + } + } + + #[test] + fn shared_blocked_rules_outrank_shared_working_on_confirmation_forms() { + // 确认框里几乎总有中断词;共享层必须自己把这一对张力解开,不能指望 + // 每个 CLI 都记得写 blocked 规则。 + let screen = "Run rm -rf /tmp/x ?\n Do you want to proceed?\n 1. Yes\n 2. No\n\ + esc to cancel · enter to confirm"; + for agent in ["grok", "pi", "cline", "kilo"] { + let found = + detect(agent, screen).unwrap_or_else(|| panic!("{agent} produced no detection")); + assert_eq!(found.status, AgentStatus::Blocked, "{agent} rule={}", found.rule_id); + } + } } diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 61d76df..66e5172 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -199,58 +199,14 @@ pub(crate) fn parse_remote_envelope(bytes: &[u8], pane: Option) -> Option bool { - let lower = tail.to_ascii_lowercase(); - [ - // codex 的问题框底栏。 - "enter to submit", - "tab to add notes", - // claude code 的选择框与权限确认。 - "enter to confirm", - "do you want to proceed", - // 通用 CLI 确认式。 - "(y/n)", - "[y/n]", - ] - .iter() - .any(|mark| lower.contains(mark)) -} - -#[cfg(test)] -mod question_tail_tests { - use super::tail_looks_like_question; - - #[test] - fn codex_question_footer_reads_as_asking() { - let tail = "Question 1/2 (2 unanswered)\n> 1. 深色主题 (Recommended)\n\ - tab to add notes | enter to submit answer | esc to interrupt"; - assert!(tail_looks_like_question(tail)); - } - - #[test] - fn yes_no_prompts_read_as_asking() { - assert!(tail_looks_like_question("Overwrite existing file? (y/N)")); - assert!(tail_looks_like_question("Do you want to proceed?\n> 1. Yes")); - } - - #[test] - fn a_running_status_bar_does_not_read_as_asking() { - // "esc to interrupt" 单独出现是**运行中**的状态栏;把它当特征收进 - // 去,每次正常完成都会误报成提问。 - assert!(!tail_looks_like_question("Working on it… esc to interrupt")); - assert!(!tail_looks_like_question("$ cargo build\n Compiling nebula v0.8.0")); - } -} +/// 「回合结束时 AI 是不是其实在等人回答」这个判断,2026-08-22 起统一走 +/// `ai_agents::detect` 的 blocked 规则(`agent_detection/*.toml` 与 +/// `_shared.toml`)。此处原有一份 `tail_looks_like_question` 裸关键词表, +/// 扫底部 15 行全文,只要 `(y/n)`、`do you want to proceed` 等字样落在正文 +/// 任何位置就算数——agent 打印的代码片段、上一轮答完还没滚走的旧框都会让 +/// 正常结束的回合挂上警告三角,而 Blocked 一旦点亮就再难落下。manifest 的 +/// 规则带 region 锚定(`after_last_horizontal_rule` / 底部 N 行),只认当前 +/// 活动框,两个壳现在共用它。 #[cfg(test)] mod remote_tests { diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index f580073..772a0e3 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -1058,7 +1058,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { if h > 0.0 { h.max(super::HOSTS_BAND_MIN) } else { 0.0 }; } }, - Some(("blur", v)) => settings.blur = parse_bool(v, true), + Some(("blur", v)) => settings.blur = parse_blur_enabled(v), Some(("opacity", v)) => { if let Ok(opacity) = v.trim().parse::() { settings.opacity = opacity.clamp(0.0, 1.0); @@ -1152,6 +1152,21 @@ fn parse_bool(value: &str, default: bool) -> bool { } } +/// `blur` 键与 GPUI 壳共用,但那边是三档枚举(`nebula_settings::BlurModeName`: +/// none / mica / acrylic),这边只有开关。 +/// +/// 不能直接套 [`parse_bool`]:它对认不出的值回落到 `default`,而这里的 +/// default 是 `true`——于是 GPUI 写下的 `blur=none` 会被旧壳读成"开",用户 +/// 关掉的模糊一开旧壳就自己回来了。这里显式认三个枚举名,档位信息丢掉但 +/// 开关语义保住。 +fn parse_blur_enabled(value: &str) -> bool { + match value.trim().to_ascii_lowercase().as_str() { + "none" => false, + "mica" | "acrylic" => true, + other => parse_bool(other, true), + } +} + fn parse_cursor_shape(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "block" => Some(CursorShape::Block), @@ -1274,7 +1289,10 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { settings.restore_session as u8, settings.resume_ai as u8, settings.tray as u8, - settings.blur as u8, + // 与 GPUI 壳共用 `blur` 键,写回枚举名而不是 0/1:写 0/1 会让那边 + // 把它当成旧布尔值走迁移分支,用户显式选的 acrylic 每次经旧壳存盘 + // 都会掉档。旧壳自己只有开关,开启一律落到性能安全的 mica。 + if settings.blur { "mica" } else { "none" }, settings.opacity, settings.background_image_opacity, settings.background_image_fit.settings_value(), diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index a21c9f1..6e444e3 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -569,6 +569,17 @@ impl SettingsPane { window, cx, ); + // 模糊三档的标签带上取舍提示:三者不是质量递进,而是三套成本模型 + // (见 `nebula_settings::BlurModeName`)。用户按性能预算选,不是按 + // "越靠后越好"选。 + add_select( + "blur", + &["无", "平衡(Mica)", "高质量(Acrylic)"], + &["none", "mica", "acrylic"], + runtime.blur.settings_value(), + window, + cx, + ); add_select( "accept", &["右方向键", "Tab", "Tab 或右方向键"], @@ -2518,7 +2529,7 @@ impl SettingsPane { .child(self.select_row("language", "语言", cx)) .child(self.select_row("density", "界面外观", cx)) .child(self.slider_row("终端正文不透明度", &self.opacity_slider, opacity)) - .child(self.switch_row("blur", "背景模糊", self.runtime.blur, cx)); + .child(self.select_row("blur", "背景模糊", cx)); let terminal = self .group("终端外观", cx) .child(self.stepper_row( diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index bb63f69..a38fdc5 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -204,6 +204,14 @@ pub struct TerminalView { /// `NEBULA|` 标题报 `running_program`,装了 OSC133 集成的 bash/zsh/nu 只 /// 发这一对语义标记——两条路都要能点亮侧栏的「运行中」。 command_running: bool, + /// 进程树给出的反证:`command_running` 为真、但 shell 树下只剩交互式 + /// shell 与 console plumbing 时置位,转圈据此熄灭(issue #42)。判据与 + /// 节流都在 `runtime::reconcile_shell_activity`,裁定理由见那里。 + command_running_disproved: bool, + /// `command_running` 置位的时刻,进程树探测的节流窗口从这里起算。 + command_started: Option, + /// 上次跑进程树探测的时刻,用于节流。 + last_process_probe: Option, active_run: Option, last_run: Option, /// Hook 事件驱动的 agent 回合状态(旧壳 `nebula_state.agent_status` 的 @@ -217,6 +225,11 @@ pub struct TerminalView { /// 本次前台 agent 会话是否收到过 hook(旧壳同名字段同语义):屏幕 /// 检测的空闲提示符不得降级 hook 报出的 Done/Blocked 精确终态。 agent_hook_seen: bool, + /// 本 pane 的 agent 在当前回合有过活动(hook 派活、屏幕判 working、或 + /// Runtime 提交)。屏幕回到空闲提示符时据此区分「干完了、你还没看」 + /// (Done,蓝点)与「从没开工」(Idle,只显示 shell 标签)——消费点在 + /// `runtime::refresh_agent_screen_state`。 + agent_turn_active: bool, /// 屏幕检测连续看到空闲提示符的拍数;Working 连续两拍空闲才降级 /// (单拍可能是重绘间隙),非 idle 检测与任何 hook 边沿都清零。 idle_screen_streak: u8, @@ -670,12 +683,16 @@ impl TerminalView { branch: String::new(), running_program: None, command_running: false, + command_running_disproved: false, + command_started: None, + last_process_probe: None, active_run: None, last_run: None, agent_status: crate::ai_agents::AgentStatus::Unknown, agent_status_source: crate::ai_agents::AgentStatusSource::Unknown, agent_status_rule: None, agent_hook_seen: false, + agent_turn_active: false, idle_screen_streak: 0, agent_runtime_submit_pending: false, pending_runtime_submit: None, @@ -842,7 +859,7 @@ impl TerminalView { self.agent_status_source = crate::ai_agents::AgentStatusSource::Process; self.agent_status_rule = None; } - self.command_running = true; + self.mark_command_running(); if let Some(run) = &mut self.active_run && run.phase == crate::runtime_api::RuntimeRunPhase::Submitted { @@ -865,6 +882,9 @@ impl TerminalView { cx.emit(TerminalViewEvent::TitleChanged); } self.command_running = false; + self.command_running_disproved = false; + self.command_started = None; + self.last_process_probe = None; if let Some(run) = self.active_run.take() { self.last_run = Some(crate::runtime_api::RuntimeRunOutcome::command_done(run, exit_code)); @@ -873,6 +893,7 @@ impl TerminalView { self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown; self.agent_status_rule = None; self.agent_hook_seen = false; + self.agent_turn_active = false; self.idle_screen_streak = 0; self.agent_runtime_submit_pending = false; self.pending_runtime_submit = None; diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index d8f40ae..6694002 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -29,7 +29,10 @@ impl TerminalView { AgentStatus::Blocked => unreachable!("handled above"), AgentStatus::Unknown => {}, } - if self.command_running + // `command_running` 是 OSC 133 的忠实记录,但 133;D 会因为宿主 shell + // 被 cmd/wsl/ssh 接管而永不到达;`command_running_disproved` 是进程树 + // 给出的反证,看门狗每 2 秒复核一次。 + if (self.command_running && !self.command_running_disproved) || self.running_program.is_some() || self .ssh_stage @@ -233,7 +236,7 @@ impl TerminalView { let run_id = run.run_id; self.active_run = Some(run); self.last_run = None; - self.command_running = true; + self.mark_command_running(); self.suggest.last_committed.clone_from(&command); self.write_input(bytes, cx); cx.emit(TerminalViewEvent::TitleChanged); @@ -304,12 +307,13 @@ impl TerminalView { // 本身先建立 Running 边沿,保证 prompt --wait 的 after_seq 不会 // 仍盯着提交前 Idle;真实 shell/hook 结束事件负责把它归位。 self.awaiting_input = false; - self.command_running = true; + self.mark_command_running(); if recognized_agent { self.agent_status = crate::ai_agents::AgentStatus::Working; self.agent_status_source = crate::ai_agents::AgentStatusSource::Process; self.agent_status_rule = None; self.agent_runtime_submit_pending = true; + self.agent_turn_active = true; self.idle_screen_streak = 0; } cx.emit(TerminalViewEvent::TitleChanged); @@ -390,9 +394,14 @@ impl TerminalView { }); } match kind { - AiHookKind::SessionStart => self.agent_status = AgentStatus::Idle, + AiHookKind::SessionStart => { + self.agent_status = AgentStatus::Idle; + // 新会话还没开工,此前那点未读痕迹不该带进来。 + self.agent_turn_active = false; + }, AiHookKind::PromptSubmit | AiHookKind::ToolComplete => { self.agent_status = AgentStatus::Working; + self.agent_turn_active = true; }, AiHookKind::TurnDone | AiHookKind::NeedsAttention => { let screen_asks = @@ -412,14 +421,19 @@ impl TerminalView { cx.notify(); } + /// TurnDone 到达时,屏幕上是否真的还挂着一个等人表态的框。 + /// + /// 2026-08-22:此前这里把底部 15 行整段丢给一张裸关键词表(`(y/n)` / + /// `do you want to proceed` / `enter to confirm` …),关键词落在**正文任何 + /// 位置**都算数——agent 打印的代码片段、上一轮已答完但还没滚走的权限框、 + /// 乃至讨论这套判据本身的聊天记录,都会让正常结束的回合挂上警告三角。 + /// 改走 per-agent manifest 的 blocked 规则:它们带 region 锚定 + /// (`after_last_horizontal_rule` / 底部 N 行),只认当前活动框。 fn screen_tail_asks(&self) -> bool { - let Some(session) = &self.session else { return false }; - let term = session.term.lock(); - let lines = term.screen_lines(); - let take = lines.min(15); - let start = TermPoint::new(Line((lines - take) as i32), Column(0)); - let end = TermPoint::new(Line(lines as i32 - 1), Column(term.columns().saturating_sub(1))); - crate::ai_hook::tail_looks_like_question(&term.bounds_to_string(start, end)) + let Some(program) = self.running_program.as_deref() else { return false }; + let Some(screen) = self.runtime_screen_snapshot() else { return false }; + crate::ai_agents::detect(program, &screen) + .is_some_and(|detection| detection.status == crate::ai_agents::AgentStatus::Blocked) } /// 1 Hz 屏幕看门狗:hook 提供精确边沿,屏幕补偿丢失边沿和无 hook 客户端。 @@ -429,6 +443,9 @@ impl TerminalView { // Wakeup 会被 synchronized-update 合并;1 Hz 看门狗提供可靠的 // Grid 后置检查,确保 Runtime 文本回显后一定能补发 Enter。 self.flush_pending_runtime_submit(cx); + // 进程树对账必须排在下面那道早退之前:身份认不出来就早退,等于让 + // 这个 pane 永远退出屏幕检测。 + self.reconcile_shell_activity(cx); let Some(program) = self.running_program.clone() else { self.idle_screen_streak = 0; return; @@ -461,23 +478,46 @@ impl TerminalView { let next = match detection.status { AgentStatus::Idle => { - if self.agent_hook_seen - && matches!(self.agent_status, AgentStatus::Done | AgentStatus::Blocked) - { + // hook 报出的 Done 是精确终态,屏幕不得改写。 + if self.agent_hook_seen && self.agent_status == AgentStatus::Done { return; } - if self.agent_status == AgentStatus::Working { - self.idle_screen_streak = self.idle_screen_streak.saturating_add(1); - if self.idle_screen_streak < 2 { - return; - } - AgentStatus::Done - } else { - AgentStatus::Idle + self.idle_screen_streak = self.idle_screen_streak.saturating_add(1); + match self.agent_status { + // Blocked 必须能自愈:问题框已经从屏幕上消失(用户答完了, + // 或者当初就是误判),继续挂警告三角就是假警报。此前它和 + // Done 一起被 hook_seen 挡在门外,于是三角一旦点亮就再也 + // 下不来,只能等下一个 hook 边沿——真实框还在时 blocked + // 规则会持续命中并走下面的分支,所以这里放行不会误灭。 + AgentStatus::Blocked => { + if self.idle_screen_streak < 2 { + return; + } + AgentStatus::Done + }, + // Working 降级更谨慎:hook 在场时 TurnDone 才是权威终态, + // 屏幕只在它迟迟不来时兜底,门槛拉到 5 拍;无 hook 的客户端 + // 屏幕是唯一证据,维持 2 拍。 + AgentStatus::Working => { + let threshold = if self.agent_hook_seen { 5 } else { 2 }; + if self.idle_screen_streak < threshold { + return; + } + AgentStatus::Done + }, + // 「干完了、你还没看」与「从没开工」必须分开:前者要留 + // 蓝点。此前两者都落到 Idle,于是 hook 掉线的 pane 干完 + // 整整一轮活也只显示 shell 标签,用户根本看不出哪个有结果 + // (实测三个 claude tab 全是这样)。 + _ if self.agent_turn_active => AgentStatus::Done, + _ => AgentStatus::Idle, } }, status @ (AgentStatus::Working | AgentStatus::Blocked) => { self.idle_screen_streak = 0; + if status == AgentStatus::Working { + self.agent_turn_active = true; + } status }, AgentStatus::Done | AgentStatus::Unknown => { @@ -501,6 +541,75 @@ impl TerminalView { } } + /// `command_running` 的统一置位口:进程树探测的节流窗口从这里起算, + /// 上一条命令的反证同时作废(新命令开始,「树里没活儿」不再成立)。 + pub(super) fn mark_command_running(&mut self) { + if !self.command_running { + self.command_started = Some(std::time::Instant::now()); + self.last_process_probe = None; + } + self.command_running = true; + self.command_running_disproved = false; + } + + /// 进程树对账:补 agent 身份、给 `command_running` 做反证。 + /// + /// 两件事都必须排在「`running_program` 为 None 就早退」之前。身份一旦 + /// 没认出来,看门狗就再也不看这个 pane 一眼——2026-08-22 实测的 codex + /// pane 正是死在这里:codex 早已答完,屏幕上就是空闲输入框 `›`(codex.toml + /// 的 prompt_idle 一匹就中),但 `running_program` 是 None,检测从未运行, + /// 转圈一直挂着,侧栏也没有图标。 + /// + /// 身份的第一来源是命令行首 token(`TermEvent::CommandStart` 那条路), + /// 那是脆弱推断:会话恢复、shell 别名、`npx codex` 这类间接启动都会让它 + /// 落空。进程树是客观事实,只是慢一拍——而慢一拍在 1 Hz 看门狗里无所谓。 + fn reconcile_shell_activity(&mut self, cx: &mut Context) { + if !self.command_running { + self.command_running_disproved = false; + return; + } + let Some(shell_pid) = + self.session.as_ref().map(|session| session.shell_pid).filter(|pid| *pid != 0) + else { + return; + }; + // 节流两道闸:命令先跑够 3 秒,且两次探测至少隔 2 秒。绝大多数命令 + // 活不到第一次探测,全机进程枚举因此不会落到 1 Hz 热路径上—— + // process_tree.rs 顶部专门告诫过不要那么做。 + let started = *self.command_started.get_or_insert_with(std::time::Instant::now); + if started.elapsed() < std::time::Duration::from_secs(3) { + return; + } + let probe_interval = std::time::Duration::from_secs(2); + if self.last_process_probe.is_some_and(|at| at.elapsed() < probe_interval) { + return; + } + self.last_process_probe = Some(std::time::Instant::now()); + + if self.running_program.is_none() + && let Some(agent) = crate::process_tree::agent_child(shell_pid) + { + log::debug!("agent identity from process tree: pane={} program={agent}", self.pane_id); + self.running_program = Some(agent); + cx.emit(TerminalViewEvent::TitleChanged); + cx.notify(); + return; + } + + // 反证:树里只剩交互式 shell 与 console plumbing,没有真在跑的活儿。 + // 判据与关闭确认共用 `STATELESS` 白名单——两处对「算不算在跑活儿」 + // 必须同口径,否则同一个 cmd 会话会一边说「随便关」一边说「忙着呢」。 + let disproved = crate::process_tree::busy_child(shell_pid).is_none(); + if disproved != self.command_running_disproved { + log::debug!( + "command_running disproved={disproved} by process tree: pane={}", + self.pane_id + ); + self.command_running_disproved = disproved; + cx.notify(); + } + } + fn runtime_screen_snapshot(&self) -> Option { let session = self.session.as_ref()?; let term = session.term.lock(); diff --git a/nebula_app/src/gpui_shell/wallpaper.rs b/nebula_app/src/gpui_shell/wallpaper.rs index 285b052..24a95cc 100644 --- a/nebula_app/src/gpui_shell/wallpaper.rs +++ b/nebula_app/src/gpui_shell/wallpaper.rs @@ -16,16 +16,18 @@ use std::sync::{Arc, Mutex}; use std::time::SystemTime; use gpui::{ - App, Bounds, ContentMask, Corners, Pixels, RenderImage, Window, WindowBackgroundAppearance, px, + App, Bounds, ContentMask, Corners, Hsla, Pixels, RenderImage, Window, + WindowBackgroundAppearance, fill, point, px, size, }; use image::{Frame, RgbaImage, imageops}; +use nebula_settings::BlurModeName; use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; /// 全局视效状态(App global)。 pub struct VisualEffects { pub opacity: f32, - pub blur: bool, + pub blur: BlurModeName, wallpaper: Option, } @@ -217,8 +219,15 @@ pub fn initial_background_appearance() -> WindowBackgroundAppearance { /// swapchain 固定预乘 alpha),`ACCENT_DISABLED` 下 0% 不透明度实测就能透 /// 出后方内容。`Transparent` 会额外挂 `ACCENT_ENABLE_TRANSPARENTGRADIENT` /// 的纯色渐变层,对我们没有收益。其他平台维持原有 `Transparent` 语义。 -fn background_appearance(blur: bool) -> WindowBackgroundAppearance { - if blur { +/// +/// # Mica 为什么也要 `Blurred` +/// +/// Windows 侧这个返回值只决定 GPUI 平台层预写哪套 AccentPolicy,随后 +/// [`apply_windows_accent_policy`] 会按档位覆写。Mica 需要的是「窗口保持可 +/// 透」这件事本身——材质在窗口内容**下方**,内容不透就看不见。两档模糊在这 +/// 一点上诉求相同,所以共用 `Blurred`,差异全部落在覆写那一步。 +fn background_appearance(blur: BlurModeName) -> WindowBackgroundAppearance { + if blur.enabled() { return WindowBackgroundAppearance::Blurred; } #[cfg(windows)] @@ -231,11 +240,11 @@ fn background_appearance(blur: bool) -> WindowBackgroundAppearance { } } -/// 已经真正落到窗口上的模糊态。拖不透明度滑块会每帧走一遍 [`refresh`],而 -/// 窗口级模糊是**跨进程**调用(`SetWindowCompositionAttribute` 两次 + +/// 已经真正落到窗口上的模糊档位。拖不透明度滑块会每帧走一遍 [`refresh`],而 +/// 窗口级材质是**跨进程**调用(`SetWindowCompositionAttribute` 两次 + /// `DwmSetWindowAttribute`)。不做门控就等于每帧和 DWM 往返三次,滑块直接 -/// 拖成幻灯片——2026-08-21 实测。模糊态没变时一次都不碰。 -struct AppliedBlur(bool); +/// 拖成幻灯片——2026-08-21 实测。档位没变时一次都不碰。 +struct AppliedBlur(BlurModeName); impl gpui::Global for AppliedBlur {} @@ -268,7 +277,9 @@ impl gpui::Global for AppliedBlur {} /// 短路是拖拽手感的必要条件,不是可选优化。重绘由调用链上的 `cx.notify()` /// 与主题重建负责。 fn apply_window_effects(cx: &mut App) { - let blur = cx.try_global::().map(|v| v.blur).unwrap_or(false); + // 全局缺失时按"关"处理而不是缺省档:这条路径只在极早期或异常态走到, + // 宁可少一层材质,也不要凭空给窗口开上模糊再被 refresh 纠正一次。 + let blur = cx.try_global::().map(|v| v.blur).unwrap_or(BlurModeName::None); if cx.try_global::().map(|applied| applied.0) == Some(blur) { return; } @@ -288,35 +299,48 @@ fn apply_window_effects(cx: &mut App) { }); } -/// 显式落下 Windows AccentPolicy,并把 system backdrop 钉死在 `DWMSBT_NONE`。 +/// 显式落下 Windows 材质属性:AccentPolicy 与 system backdrop 二选一。 /// -/// # 这段 FFI 保留的理由(不是因为 GPUI 关不掉) +/// # 三档各自写什么 /// -/// 2026-08-20 曾记录"GPUI 关闭时写 `state=0, flags=2`,DWM 会因此保留已有 -/// Acrylic"。**2026-08-21 同窗实测证伪**:在同一 HWND 上写 `state=0, flags=2` -/// 画面立刻恢复清晰,与全零 policy 无差别。也就是说 GPUI 上游两个方向本来都 -/// 正确(`Blurred` → state 4 且已把 tint alpha 0 修正成 1,`Opaque` → state 0)。 +/// | 档位 | AccentPolicy | SYSTEMBACKDROP | DWM 每帧成本 | +/// |---|---|---|---| +/// | `None` | 全零 | `DWMSBT_NONE` | 无 | +/// | `Mica` | 全零 | `DWMSBT_MAINWINDOW` | 无(只在窗口移动时重采样壁纸) | +/// | `Acrylic` | state 4 + 非零 alpha | `DWMSBT_NONE` | 整窗实时高斯模糊 | /// -/// 保留这段的真实理由只有两条: -/// 1. **钉死 backdrop**:`DWMSBT_AUTO` 会把决定权交回系统,而 system backdrop -/// 在本壳的 DComp 窗口上表现为不透明色板(见 [`background_appearance`])。 -/// 显式写 `DWMSBT_NONE` 保证不论系统默认如何,都不会冒出那块色板。 -/// 2. **对 pin 漂移的防御**:上游在 `Blurred` 该用 AccentPolicy 还是 DWM -/// backdrop 之间反复改过(PR #41842)。这里显式写下已实测生效的字段, -/// GPUI pin 升级后即使上游再摆回 backdrop,本壳行为也不变。 +/// 两条通道**必须互斥**:同时开 Acrylic 与 system backdrop 时 DWM 的行为未 +/// 定义(实测表现为 backdrop 赢,Acrylic 被吞)。所以每档都要把另一条显式 +/// 写回中性值,不能只写自己那条。 /// -/// 因此本函数的字段必须与实测生效值一致:关闭 = 全零,开启 = Acrylic(state 4) -/// + 非零 alpha。**绝不要在这里写 `DWMSBT_TRANSIENTWINDOW` 或任何非 NONE 的 -/// system backdrop**——那正是 2026-08-21 那轮把模糊改成灰色色板的原因。 +/// # `DWMSBT_MAINWINDOW`(Mica) 能用,`DWMSBT_TRANSIENTWINDOW`(Acrylic 材质) 不能 +/// +/// 2026-08-21 记录过 `DWMSBT_TRANSIENTWINDOW` 在本壳上变成不透明浅灰色板, +/// 当时归因于 GPUI 的 `WS_EX_NOREDIRECTIONBITMAP`(DComp 窗口没有重定向表面 +/// 给 system backdrop 挂载)。**2026-08-22 用户实测推翻了这个外推**:同一族的 +/// `DWMSBT_MAINWINDOW` 在本壳上工作正常,能正确显示模糊的桌面壁纸色调。 +/// +/// 也就是说那次失败是 `TRANSIENTWINDOW` 这一个值的问题,不是整个 system +/// backdrop 家族的问题。**不要**因为看到旧注释就把 Mica 一起判死;反过来也 +/// **不要**因为 Mica 能用就再去试 `TRANSIENTWINDOW`——那一条已经实测两次。 +/// +/// # Mica 天生没有玻璃感,靠壳侧自绘补 +/// +/// Acrylic 的观感由四层构成:实时背景采样 + 高斯模糊 + 噪点纹理 + 饱和度 +/// 提升。Mica 只有前两层的廉价版(采样桌面壁纸 + 强模糊 + 色调映射),刻意 +/// 不做噪点与饱和度——它的设计目标是"低调的材质底色"而非"毛玻璃"。 +/// +/// 所以 Mica 档的玻璃感由 [`paint_glass_overlay`] 在壳侧自绘补齐:噪点纹理与 +/// tint 恰好是 Acrylic 里最便宜的两层,自己画的成本接近零,还换来完全可调。 /// /// `SetWindowCompositionAttribute` 未进入公开 SDK,所以和 GPUI 上游一样动态取 -/// 函数地址;`DWMSBT_NONE` 与 `DwmFlush` 则用公开 DWM API。任一步失败都留日志, -/// 避免把 API 失败再次误判成“设置没有热应用”。 +/// 函数地址;backdrop 与 `DwmFlush` 则用公开 DWM API。任一步失败都留日志, +/// 避免把 API 失败再次误判成"设置没有热应用"。 #[cfg(windows)] -fn apply_windows_accent_policy(window: &Window, blur: bool) { +fn apply_windows_accent_policy(window: &Window, blur: BlurModeName) { use windows_sys::Win32::Foundation::{BOOL, HWND}; use windows_sys::Win32::Graphics::Dwm::{ - DWMSBT_NONE, DWMWA_SYSTEMBACKDROP_TYPE, DwmSetWindowAttribute, + DWMSBT_MAINWINDOW, DWMSBT_NONE, DWMWA_SYSTEMBACKDROP_TYPE, DwmSetWindowAttribute, }; use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; @@ -346,17 +370,23 @@ fn apply_windows_accent_policy(window: &Window, blur: bool) { return; }; let hwnd = handle.hwnd.get() as *mut core::ffi::c_void; - let backdrop: i32 = DWMSBT_NONE; - let mut accent = if blur { - AccentPolicy { + let backdrop: i32 = match blur { + BlurModeName::Mica => DWMSBT_MAINWINDOW, + BlurModeName::None | BlurModeName::Acrylic => DWMSBT_NONE, + }; + let mut accent = match blur { + BlurModeName::Acrylic => AccentPolicy { state: 4, // ACCENT_ENABLE_ACRYLICBLURBEHIND flags: 0, // alpha=0 会让部分 DWM 版本直接跳过 Acrylic。 gradient_color: 0x0100_0000, animation_id: 0, - } - } else { - AccentPolicy { state: 0, flags: 0, gradient_color: 0, animation_id: 0 } + }, + // Mica 走 backdrop 通道,AccentPolicy 必须让位——否则 Acrylic 残留会 + // 盖住 Mica,切档时看起来像"没生效"。 + BlurModeName::None | BlurModeName::Mica => { + AccentPolicy { state: 0, flags: 0, gradient_color: 0, animation_id: 0 } + }, }; let mut data = WindowCompositionAttributeData { attribute: 19, // WCA_ACCENT_POLICY @@ -395,7 +425,7 @@ fn apply_windows_accent_policy(window: &Window, blur: bool) { }; if backdrop_result < 0 { log::warn!( - "DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE=NONE) failed: HRESULT=0x{:08X}", + "DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE={backdrop}) failed: HRESULT=0x{:08X}", backdrop_result as u32 ); } @@ -434,6 +464,112 @@ fn decode( Some((image, source, width, height)) } +/// 噪点 tile 边长(物理像素)。越大平铺次数越少、内存越高:512 时 3K 屏约 +/// 28 次 `paint_image`,1MB 纹理——两头都便宜。 +const NOISE_TILE_PX: u32 = 512; +/// 噪点强度。Acrylic 自带的颗粒非常细微(目测 3~5%),高于 ~8% 会从"玻璃" +/// 变成"脏"。 +const NOISE_ALPHA: u8 = 14; +/// 白色 tint 浓度。暗色主题要更厚——Mica 的壁纸色调在暗色下偏沉,正是它 +/// "不够透亮"的主因;亮色主题本就够亮,加太多会过曝。 +const TINT_ALPHA_DARK: f32 = 0.08; +const TINT_ALPHA_LIGHT: f32 = 0.05; + +thread_local! { + /// 噪点 tile 只依赖上面几个常量,进程内生成一次即可。用 `thread_local` + /// 而不是 `OnceLock`:`RenderImage` 只在渲染线程用,不必为跨线程共享去 + /// 背 `Send + Sync` 的约束。 + static NOISE_TILE: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// 生成(并缓存)噪点 tile。 +fn noise_tile() -> Arc { + NOISE_TILE.with(|slot| { + if let Some(tile) = slot.borrow().as_ref() { + return tile.clone(); + } + let mut buffer = RgbaImage::new(NOISE_TILE_PX, NOISE_TILE_PX); + // LCG(数值出自 Numerical Recipes):确定性、零依赖。噪点只要"看起来 + // 随机",不需要统计学质量;确定性还让同一台机器每次启动的颗粒一致。 + let mut state: u32 = 0x9E37_79B9; + for pixel in buffer.chunks_exact_mut(4) { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + // 取高位:LCG 的低位周期极短,直接用会出现肉眼可见的条带。 + let luma = (state >> 24) as u8; + // GPUI 的图像帧是预乘 alpha,颜色必须先乘进去,否则半透明噪点 + // 整体偏亮。三通道同值,所以不必像 `decode` 那样 swap 成 BGRA。 + let premultiplied = ((u16::from(luma) * u16::from(NOISE_ALPHA)) / 255) as u8; + pixel[0] = premultiplied; + pixel[1] = premultiplied; + pixel[2] = premultiplied; + pixel[3] = NOISE_ALPHA; + } + let tile = Arc::new(RenderImage::new([Frame::new(buffer)])); + *slot.borrow_mut() = Some(tile.clone()); + tile + }) +} + +/// Mica 档的玻璃增强层:白 tint + 噪点颗粒,把 DWM 那层寡淡的壁纸色调补成 +/// 接近 Acrylic 的毛玻璃观感。整窗一层,画在 chrome 与壁纸**之下**。 +/// +/// # 为什么这层值得自己画 +/// +/// Acrylic 的观感由四层构成:实时背景采样 + 高斯模糊 + 噪点纹理 + 亮度/ +/// 饱和度提升。前两层是 DWM 每帧的重活,也正是 2026-08-22 实测里 `dwm.exe` +/// 均值 28% 的来源;后两层则是**纯静态叠加**——一张平铺小图加一个半透明 +/// 矩形,在我们自己的绘制层里成本接近零。 +/// +/// Mica 恰好只给前两层的廉价版(采样桌面壁纸 + 强模糊,且只在窗口移动时 +/// 更新),刻意省掉后两层。于是"拿 Mica 的成本 + 补 Acrylic 的观感"成立, +/// 这就是 2026-08-22 用户裁定的甜品区。 +/// +/// 调玻璃感只动本文件顶部那四个常量,不要改绘制顺序:tint 必须在噪点之下, +/// 否则颗粒会被 tint 冲淡到看不见。 +/// +/// Acrylic 档不叠这层——它自带噪点与亮度提升,再叠一遍就糊了。 +pub fn paint_glass_overlay(bounds: Bounds, window: &mut Window, cx: &App) { + let Some(effects) = cx.try_global::() else { return }; + if effects.blur != BlurModeName::Mica { + return; + } + + let is_light = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin().is_light; + let tint = if is_light { TINT_ALPHA_LIGHT } else { TINT_ALPHA_DARK }; + window.paint_quad(fill(bounds, Hsla { h: 0.0, s: 0.0, l: 1.0, a: tint })); + + // 平铺而不是"按窗口尺寸生成一张大图":后者在 3K 屏上是 25MB 纹理,且每次 + // resize 都要重新填充六百万像素——resize 是交互路径,不能挂这种活。 + // + // `paint_image` 内部会 `bounds.scale(scale_factor)`,即入参是**逻辑**像素。 + // tile 是按物理像素生成的,所以这里必须先除以 scale_factor 才能得到 1:1 + // 的落点——否则在 3K/200% 屏上每个噪点会被 GPU 放大成 2×2 像素块,颗粒 + // 糊成噪斑(旧壳"禁 GPU 拉伸"那条清晰度铁律同源)。 + let tile = noise_tile(); + let scale = window.scale_factor().max(0.5); + let step = px(NOISE_TILE_PX as f32 / scale); + let right = bounds.origin.x + bounds.size.width; + let bottom = bounds.origin.y + bounds.size.height; + window.with_content_mask(Some(ContentMask { bounds }), |window| { + let mut y = bounds.origin.y; + while y < bottom { + let mut x = bounds.origin.x; + while x < right { + let _ = window.paint_image( + Bounds::new(point(x, y), size(step, step)), + Corners::default(), + tile.clone(), + 0, + false, + ); + x += step; + } + y += step; + } + }); +} + /// 终端卡的壁纸层(卡底色之上、内容之下;由卡容器的 canvas 调用, /// 覆盖整卡含内边距带)。 /// diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index ac33b47..8dd70dd 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -3702,12 +3702,23 @@ impl NebulaWorkspace { return div().into_any_element(); }; if *zoomed || tree.is_leaf() { - let view = panes - .iter() - .find(|pane| pane.id == *focused) - .or_else(|| panes.first()) - .map(|pane| pane.view.clone()); - return div().size_full().children(view).into_any_element(); + let pane = panes.iter().find(|pane| pane.id == *focused).or_else(|| panes.first()); + let view = pane.map(|pane| pane.view.clone()); + let probe = pane.map(|pane| { + let pane_id = pane.id; + let store = self.pane_bounds.clone(); + // 普通单 Pane 也必须记录窗口坐标;dock 命中与旧壳一样依赖 + // 当前终端区域,不能只在进入分屏递归后才得到边界。 + gpui::canvas( + move |bounds, _, _| { + store.borrow_mut().insert(pane_id, bounds); + }, + |_, _, _, _| (), + ) + .absolute() + .inset_0() + }); + return div().size_full().relative().children(probe).children(view).into_any_element(); } let mut path = Vec::new(); self.render_split_node(tab_ix, tree, &mut path, panes, *focused, cx) @@ -4133,11 +4144,18 @@ impl Render for NebulaWorkspace { this.open_ai_session_palette(window, cx); })) .child( - // 壁纸"铺满整窗"底层(chrome 之下):卡外区域由这层负责, - // 卡内切片由终端元素在卡底色之上重画(旧壳同一层模型)。 + // 最底层壁纸(chrome 之下):卡外区域由这层负责,卡内切片由 + // 终端元素在卡底色之上重画(旧壳同一层模型)。Mica 的自绘 + // tint + 噪点增强层暂时停用,保留在下面的注释调用与 + // wallpaper.rs 实现中,待实机重新调参。 gpui::canvas( |_, _, _| (), |bounds, _, window, cx| { + // 暂停 Mica 自绘增强层:当前 tint + 噪点会盖住系统 Mica + // 自身的壁纸材质,尤其在 opacity=0 时仍留下约 13% 遮罩, + // 反而削弱背景层次。实现完整保留在 wallpaper.rs,等后续 + // 基于实机截图重新调参后再恢复调用。 + // crate::gpui_shell::wallpaper::paint_glass_overlay(bounds, window, cx); crate::gpui_shell::wallpaper::paint_wallpaper_under_chrome( bounds, window, cx, ); @@ -4331,9 +4349,11 @@ impl Render for NebulaWorkspace { .w(px(w)) .h(px(h)) .rounded(crate::gpui_shell::theme::card_radius()) - .border_2() - .border_color(cx.theme().primary) - .bg(cx.theme().primary.opacity(0.15)), + // 旧壳是低透明青色水洗,不是高饱和实线框;描边只 + // 提示落区边界,不能盖过终端内容成为视觉主体。 + .border_1() + .border_color(cx.theme().primary.opacity(0.28)) + .bg(cx.theme().primary.opacity(0.08)), ) }) .when(self.tab_drag.as_ref().is_some_and(|d| d.active), |root| { diff --git a/nebula_app/src/gpui_shell/workspace/tab_drag.rs b/nebula_app/src/gpui_shell/workspace/tab_drag.rs index 04854e5..23c342c 100644 --- a/nebula_app/src/gpui_shell/workspace/tab_drag.rs +++ b/nebula_app/src/gpui_shell/workspace/tab_drag.rs @@ -139,10 +139,38 @@ impl NebulaWorkspace { } pub(super) fn active_terminal_area(&self) -> Option { - let Some(WorkspaceTab::Terminal { panes, .. }) = self.tabs.get(self.active) else { + let Some(WorkspaceTab::Terminal { panes, tree, focused, zoomed }) = + self.tabs.get(self.active) + else { return None; }; + if !*zoomed && !tree.is_leaf() { + // 旧壳始终用当前 Tab 的完整终端视口做 dock 命中。分屏以后直接 + // 取递归布局的根节点,不能再靠多个 Pane 的边界间接拼接;否则 + // 第一次合并后几何源发生变化,后续 Tab 就无法稳定继续并入。 + if let Some(b) = self.split_bounds.borrow().get(&(self.active, Vec::new())).copied() { + let (w, h) = (f32::from(b.size.width), f32::from(b.size.height)); + if w > 0.0 && h > 0.0 { + return Some(nebula_split::Rect::new( + f32::from(b.origin.x), + f32::from(b.origin.y), + w, + h, + )); + } + } + } let bounds = self.pane_bounds.borrow(); + if *zoomed || tree.is_leaf() { + // 缩放态只认屏幕上实际可见的 Pane;其余 Pane 的上一帧边界可能 + // 仍在缓存里,不能把 dock 命中区扩到当前终端之外。 + let pane = panes.iter().find(|pane| pane.id == *focused).or_else(|| panes.first())?; + let b = bounds.get(&pane.id)?; + let (w, h) = (f32::from(b.size.width), f32::from(b.size.height)); + return (w > 0.0 && h > 0.0).then(|| { + nebula_split::Rect::new(f32::from(b.origin.x), f32::from(b.origin.y), w, h) + }); + } let mut acc: Option<(f32, f32, f32, f32)> = None; for pane in panes { let Some(b) = bounds.get(&pane.id) else { continue }; diff --git a/nebula_app/src/process_tree.rs b/nebula_app/src/process_tree.rs index ec11e49..71d2d64 100644 --- a/nebula_app/src/process_tree.rs +++ b/nebula_app/src/process_tree.rs @@ -154,6 +154,30 @@ pub fn busy_child(_root_pid: u32) -> Option { None } +/// First descendant that `AgentKind` recognizes as an AI CLI, as its slug. +/// +/// `busy_child` walks BFS level order, so the first non-stateless hit is always +/// the host interpreter rather than the agent: codex really runs as +/// `powershell → node.exe → codex.exe → node_repl.exe`, and taking the depth-1 +/// `node.exe` as the identity yields "node", which `AgentKind::parse` rejects — +/// the pane then has no logo and no semantic state at all. +/// +/// 2026-08-22 实测(pane 4,issue #42 同批):codex 已经答完,屏幕上就是空闲 +/// 输入框,但 `running_program` 是 None,于是 1 Hz 屏幕看门狗在入口处早退, +/// 没有任何机制去看那块屏幕,转圈一直挂着。命令行首 token 是脆弱推断(会话 +/// 恢复、别名、`npx codex` 都会落空),进程树才是客观事实。 +#[cfg(windows)] +pub fn agent_child(root_pid: u32) -> Option { + descendants(root_pid).ok()?.into_iter().skip(1).find_map(|process| { + crate::ai_agents::AgentKind::parse(&process.executable).map(|kind| kind.slug().to_owned()) + }) +} + +#[cfg(not(windows))] +pub fn agent_child(_root_pid: u32) -> Option { + None +} + #[cfg(test)] mod tests { use super::display_name; diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 98ba014..595a53f 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -2292,15 +2292,24 @@ impl WindowContext { // 回答"。回合结束的瞬间看一眼屏幕尾部——还挂着选择框或确认 // 提示,就按「等你批准」处理(蓝点升级成手掌)。 let screen_asks = ev.kind == crate::ai_hook::AiHookKind::TurnDone && { + // 与 GPUI 壳同判据:走 per-agent manifest 的 blocked 规则 + // (带 region 锚定,只认当前活动框),不再拿裸关键词扫底部 + // 15 行全文——正文里出现 (y/n)、do you want to proceed 之类 + // 的字样(agent 打印的代码、上一轮没滚走的旧框)就会让正常 + // 结束的回合挂上警告三角,而 Blocked 一旦点亮就再难落下。 let term = self.panes[idx].terminal.lock(); let lines = term.screen_lines(); - let take = lines.min(15); - 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)), - ); - crate::ai_hook::tail_looks_like_question(&term.bounds_to_string(start, end)) + lines > 0 && term.columns() > 0 && { + let start = Point::new(Line(0), Column(0)); + let end = Point::new( + Line(lines as i32 - 1), + Column(term.columns().saturating_sub(1)), + ); + let screen = term.bounds_to_string(start, end); + crate::ai_agents::detect(&ev.source, &screen).is_some_and(|detection| { + detection.status == crate::ai_agents::AgentStatus::Blocked + }) + } }; { let state = &mut self.panes[idx].nebula_state; diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 78ae286..64ece0d 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -644,6 +644,59 @@ impl BellModeName { } } +/// 窗口背景模糊材质:关 / Mica(平衡)/ Acrylic(高质量)。 +/// +/// 三档对应三套完全不同的 DWM 成本模型,落点在 +/// `gpui_shell::wallpaper::apply_windows_accent_policy`: +/// - `None`:AccentPolicy 全零 + `DWMSBT_NONE`,DWM 只贴一张不透明纹理。 +/// - `Mica`:`DWMSBT_MAINWINDOW`。DWM 采样**桌面壁纸**做色调,只在窗口移动 +/// 时更新,不逐帧模糊——这是"甜品区"的性能来源。代价是它天生没有玻璃 +/// 感(无噪点纹理、无饱和度提升),需要壳侧自绘玻璃层补齐。 +/// - `Acrylic`:`ACCENT_ENABLE_ACRYLICBLURBEHIND`。DWM 对窗口后方**实时 +/// 内容**逐帧高斯模糊 + 噪点 + 饱和度。观感最好,也是唯一能透出后方其他 +/// 窗口的档位;2026-08-22 实测这一档把 dwm.exe 顶到 28% 均值。 +/// +/// # 缺省为什么是 Mica 而不是 Acrylic +/// +/// 2026-08-22 用户实测:3K@165Hz 下 Acrylic 档 `nebula.exe` 22.7% + +/// `dwm.exe` 28.3%,关掉模糊后卡顿立刻好转。默认档必须是性能安全的那个, +/// 想要实时透视的用户显式选高质量。 +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum BlurModeName { + None, + #[default] + Mica, + Acrylic, +} + +impl BlurModeName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "none" | "off" | "0" | "false" | "no" => Some(Self::None), + "mica" => Some(Self::Mica), + "acrylic" => Some(Self::Acrylic), + // 旧壳的布尔开关:`blur=1` 只表达"我要模糊",并不表达要哪种材质。 + // 迁到 Mica 而非 Acrylic——本次改动的初衷就是降 GPU 占用,把存量 + // 用户留在最贵的那一档等于没修。想要实时透视的人显式改成 acrylic。 + "1" | "true" | "yes" | "on" => Some(Self::Mica), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::None => "none", + Self::Mica => "mica", + Self::Acrylic => "acrylic", + } + } + + /// 是否需要窗口内容保留透明像素(两档模糊都要,否则材质被自己盖住)。 + pub fn enabled(self) -> bool { + !matches!(self, Self::None) + } +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum CellWidthModeName { #[default] @@ -737,7 +790,7 @@ pub struct RuntimeSettings { pub resume_ai: bool, /// 常驻系统托盘图标。 pub tray: bool, - pub blur: bool, + pub blur: BlurModeName, pub opacity: f32, /// 终端背景覆盖色(设置页取色器写入,优先于主题背景)。 pub background: Option, @@ -820,7 +873,7 @@ impl RuntimeSettings { restore_session: raw.bool_on("restore_session").unwrap_or(true), resume_ai: raw.bool_on("resume_ai").unwrap_or(true), tray: raw.bool_on("tray").unwrap_or(true), - blur: raw.bool_on("blur").unwrap_or(true), + blur: raw.value("blur").and_then(BlurModeName::from_settings).unwrap_or_default(), opacity: raw.f32("opacity").map(|o| o.clamp(0.0, 1.0)).unwrap_or(1.0), background: raw.value("background").and_then(parse_hex_rgb), background_image: raw @@ -941,7 +994,7 @@ mod tests { assert!(!settings.restore_session); assert!(!settings.resume_ai); assert!(!settings.tray); - assert!(!settings.blur); + assert_eq!(settings.blur, BlurModeName::None); assert!((settings.opacity - 0.87).abs() < 1e-6); assert_eq!(settings.background, Some([0x10, 0x12, 0x16])); assert!(settings.panel_resize); @@ -976,7 +1029,7 @@ mod tests { assert!(settings.restore_session); assert!(settings.resume_ai); assert!(settings.tray); - assert!(settings.blur); + assert_eq!(settings.blur, BlurModeName::Mica); assert!((settings.opacity - 1.0).abs() < 1e-6); assert_eq!(settings.background, None); assert!(!settings.panel_resize); @@ -1000,6 +1053,43 @@ mod tests { ); } + #[test] + fn blur_mode_parses_three_tiers_and_migrates_legacy_bool() { + assert_eq!(BlurModeName::from_settings("none"), Some(BlurModeName::None)); + assert_eq!(BlurModeName::from_settings("off"), Some(BlurModeName::None)); + assert_eq!(BlurModeName::from_settings("mica"), Some(BlurModeName::Mica)); + assert_eq!(BlurModeName::from_settings("acrylic"), Some(BlurModeName::Acrylic)); + assert_eq!(BlurModeName::from_settings("frosted"), None); + // 大小写与空白由 from_settings 归一(设置文件是手改的)。 + assert_eq!(BlurModeName::from_settings(" Acrylic "), Some(BlurModeName::Acrylic)); + + // 旧壳布尔值迁移:关保持关,开落到 Mica(不是最贵的 Acrylic)。 + assert_eq!(BlurModeName::from_settings("0"), Some(BlurModeName::None)); + assert_eq!(BlurModeName::from_settings("false"), Some(BlurModeName::None)); + assert_eq!(BlurModeName::from_settings("1"), Some(BlurModeName::Mica)); + assert_eq!(BlurModeName::from_settings("true"), Some(BlurModeName::Mica)); + + // 认不出的值回落到缺省档,而不是把窗口留在无模糊状态。 + assert_eq!( + RuntimeSettings::from_raw(&RawSettings::from_text("blur=frosted\n")).blur, + BlurModeName::Mica + ); + assert_eq!( + RuntimeSettings::from_raw(&RawSettings::from_text("blur=acrylic\n")).blur, + BlurModeName::Acrylic + ); + + // settings_value 与 from_settings 必须互为逆运算,否则设置页存盘后 + // 重开会掉档。 + for mode in [BlurModeName::None, BlurModeName::Mica, BlurModeName::Acrylic] { + assert_eq!(BlurModeName::from_settings(mode.settings_value()), Some(mode)); + } + + assert!(!BlurModeName::None.enabled()); + assert!(BlurModeName::Mica.enabled()); + assert!(BlurModeName::Acrylic.enabled()); + } + #[test] fn tabs_position_parses_supported_values_and_defaults_to_sidebar() { assert_eq!(TabsPositionName::from_settings("sidebar"), Some(TabsPositionName::Sidebar));