chore: checkpoint current terminal improvements
This commit is contained in:
1 parent
74f19d8cbd
commit
33a1aae8e4
14 files changed
+765
-144
No files matched your search
@@ -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]"] },
|
||||
]
|
||||
@@ -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"] },
|
||||
]
|
||||
+109
-1
@@ -290,6 +290,13 @@ struct Manifest {
|
||||
rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
/// `_shared.toml`: rules merged into every manifest, with no agent identity.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SharedManifest {
|
||||
rules: Vec<Rule>,
|
||||
}
|
||||
|
||||
#[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<SystemTime>,
|
||||
) -> Result<CompiledManifest, String> {
|
||||
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<Rule> {
|
||||
static SHARED_RULES: OnceLock<Vec<Rule>> = 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<CompiledGate, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,58 +199,14 @@ pub(crate) fn parse_remote_envelope(bytes: &[u8], pane: Option<u64>) -> Option<A
|
||||
Some(event)
|
||||
}
|
||||
|
||||
/// 屏幕尾部的文本像不像「AI 正在等用户回答」。
|
||||
///
|
||||
/// codex 的 notify 只有一种事件——弹出交互式提问(选择框、批准确认)时它
|
||||
/// 发的也是 `agent-turn-complete`。光看事件流分不出"说完了"和"在等你",
|
||||
/// 只能看屏幕:回合结束的瞬间尾部还挂着提交/确认类提示,就是在等人,
|
||||
/// 蓝点该升级成手掌。
|
||||
///
|
||||
/// 特征串取各家问题框的**操作提示行**——那一行只在等待输入时才存在。刻意
|
||||
/// 不收 "esc to interrupt":它在整个回合运行期间都挂在状态栏上,收了它,
|
||||
/// 每次正常完成都会被误报成"在问你"。
|
||||
pub(crate) fn tail_looks_like_question(tail: &str) -> 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 {
|
||||
|
||||
@@ -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::<f32>() {
|
||||
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<CursorShape> {
|
||||
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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<std::time::Instant>,
|
||||
/// 上次跑进程树探测的时刻,用于节流。
|
||||
last_process_probe: Option<std::time::Instant>,
|
||||
active_run: Option<crate::runtime_api::RuntimePaneRun>,
|
||||
last_run: Option<crate::runtime_api::RuntimeRunOutcome>,
|
||||
/// 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;
|
||||
|
||||
@@ -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<Self>) {
|
||||
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<String> {
|
||||
let session = self.session.as_ref()?;
|
||||
let term = session.term.lock();
|
||||
|
||||
@@ -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<Wallpaper>,
|
||||
}
|
||||
|
||||
@@ -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::<VisualEffects>().map(|v| v.blur).unwrap_or(false);
|
||||
// 全局缺失时按"关"处理而不是缺省档:这条路径只在极早期或异常态走到,
|
||||
// 宁可少一层材质,也不要凭空给窗口开上模糊再被 refresh 纠正一次。
|
||||
let blur = cx.try_global::<VisualEffects>().map(|v| v.blur).unwrap_or(BlurModeName::None);
|
||||
if cx.try_global::<AppliedBlur>().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<Option<Arc<RenderImage>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// 生成(并缓存)噪点 tile。
|
||||
fn noise_tile() -> Arc<RenderImage> {
|
||||
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<Pixels>, window: &mut Window, cx: &App) {
|
||||
let Some(effects) = cx.try_global::<VisualEffects>() 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 调用,
|
||||
/// 覆盖整卡含内边距带)。
|
||||
///
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -139,10 +139,38 @@ impl NebulaWorkspace {
|
||||
}
|
||||
|
||||
pub(super) fn active_terminal_area(&self) -> Option<nebula_split::Rect> {
|
||||
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 };
|
||||
|
||||
@@ -154,6 +154,30 @@ pub fn busy_child(_root_pid: u32) -> Option<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::display_name;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Rgb8>,
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user