fix(gpui): restore CC Esc and default-shell plus button

Win32 input emits Esc down+up with uChar=27 so Claude Code sees 0x1b;
kitty flags encode CSI 27u instead of a bare escape. The TABS plus
button now creates the configured default shell; the dots keep the
profile picker. Plus/dots use the old 20px chrome size.

Checkpoint remaining GPUI parity work in the tree (settings rewrite,
math overlay, SSH settings, SVN status).
This commit is contained in:
kuddev committed 2026-08-17 18:30:03 +08:00
1 parent 4dee70beb2
commit 1ea860a2f7
39 files changed
+9984 -4769

No files matched your search

+104 -86
View File
@@ -1,30 +1,35 @@
target
# Local debug/release build bundles produced by packaging scripts
dist/
trellis/
# `package-release.ps1 -TargetDirectory` builds into its own cargo target dir
# so packaging never invalidates the incremental cache of the main one.
target-pack/
# Local/generated visual assets and scratch files.
docs/ui_mockups/
tmp/
# Window/UI probe captures (probe_*.png) and prototype pages (.tmp-*.html):
# scratch evidence for a single design round, never part of the product.
probe_*.png
.tmp-*.html
# Optional local ConPTY runtime bundle; the app falls back when absent.
assets/windows/conhost/
# Internal agent/session artifacts (review drafts, scratch notes — never publish)
.claude/
.tocodex/
tocodex-docs/
.codex/
*.internal.md
target
# Local debug/release build bundles produced by packaging scripts
dist/
trellis/
# `package-release.ps1 -TargetDirectory` builds into its own cargo target dir
# so packaging never invalidates the incremental cache of the main one.
target-pack/
# Local/generated visual assets and scratch files.
docs/ui_mockups/
tmp/
# Window/UI probe captures (probe_*.png) and prototype pages (.tmp-*.html):
# scratch evidence for a single design round, never part of the product.
probe_*.png
.tmp-*.html
# 所有 .tmp-/-tmp_ 前缀的草稿(探针 cfg 目录、截图、stdout 抓取、临时
# 笔记)与备用 cargo target 目录:一次性调试产物,永不入库。
.tmp-*
.tmp_*
.target-*/
# Optional local ConPTY runtime bundle; the app falls back when absent.
assets/windows/conhost/
# Internal agent/session artifacts (review drafts, scratch notes — never publish)
.claude/
.tocodex/
tocodex-docs/
.codex/
*.internal.md
# `docs/` is private by default. Public documentation must be reviewed and
# explicitly added to this file one path at a time; never allowlist another subtree.
/docs/**
@@ -38,63 +43,76 @@ tocodex-docs/
# Local interactive demos belong under the private docs tree.
/*.html
/TASKS.md
# Secrets / credentials / environment — must never be committed
.env
.env.*
*.pem
*.key
*.pfx
*.p12
*.keystore
id_rsa*
*.credentials
secrets.*
*_secret*
*.token
# Editor / IDE local settings
.vscode/
.idea/
*.swp
*.swo
*~
# OS-generated junk
.DS_Store
Thumbs.db
desktop.ini
# Build/debug scratch: parked-exe copies (Windows locked-file workaround),
# logs, crash dumps, profiling output
*.locked
*.log
*.tmp
*.orig
*.rej
*.stackdump
*.pdb.old
# Scratch notes and locally downloaded font archives
body.txt
JetBrainsMono-*.zip
# Local scratch checkouts, prototypes, and one-off repro scripts
.build-*/
target-aligned/
target-fix/
_nebula-front-stage/
message-queue-prototype.html
package.json
package-lock.json
node_modules/
scripts/drawer_repro.ps1
scripts/ghost_repro/
scripts/icon-build/
# Secrets / credentials / environment — must never be committed
.env
.env.*
*.pem
*.key
*.pfx
*.p12
*.keystore
id_rsa*
*.credentials
secrets.*
*_secret*
*.token
# Editor / IDE local settings
.vscode/
.idea/
*.swp
*.swo
*~
# OS-generated junk
.DS_Store
Thumbs.db
desktop.ini
# Build/debug scratch: parked-exe copies (Windows locked-file workaround),
# logs, crash dumps, profiling output
*.locked
*.log
*.tmp
*.orig
*.rej
*.stackdump
*.pdb.old
# Scratch notes and locally downloaded font archives
body.txt
JetBrainsMono-*.zip
# Local scratch checkouts, prototypes, and one-off repro scripts
.build-*/
target-aligned/
target-fix/
_nebula-front-stage/
message-queue-prototype.html
package.json
package-lock.json
node_modules/
scripts/drawer_repro.ps1
scripts/ghost_repro/
scripts/icon-build/
scripts/render_shell_icon.html
# 探针实例跑的是 debug 产物的副本(见 scripts/launch_probe_instance.ps1):
# 直接运行构建产物会让下一次 cargo build 以一个含义不明的 mingw 链接错误告终。
.probe/
.run/
# 探针实例跑的是 debug 产物的副本(见 scripts/launch_probe_instance.ps1):
# 直接运行构建产物会让下一次 cargo build 以一个含义不明的 mingw 链接错误告终。
.probe/
.run/
# 调试取证与旁路产物:PTY 录制(NEBULA_PTY_RECORD)、工具链输出目录、
# 参考用的外部项目工作拷贝。都不是 Nebula 源码,永不入库。
*.recording
output/
impeccable/
# Windows 下 shell 重定向事故的常见落地物:bash 里写 `2>nul` 会真的建出
# 名为 nul 的文件,`>false` 同理;PowerShell 在 HOME 未展开时把模块缓存
# 写进 cwd。见到就删,这里兜底防止误提交。
/nul
/false
/Microsoft/
Generated
+407 -7
View File
@@ -2319,6 +2319,18 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "1.9.0"
@@ -2453,6 +2465,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.12.2"
@@ -3090,7 +3108,6 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.5.1"
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
dependencies = [
"aho-corasick",
"anyhow",
@@ -3120,7 +3137,35 @@ dependencies = [
"smol",
"tracing",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-c",
"tree-sitter-c-sharp",
"tree-sitter-cmake",
"tree-sitter-cpp",
"tree-sitter-css",
"tree-sitter-diff",
"tree-sitter-elixir",
"tree-sitter-embedded-template",
"tree-sitter-go",
"tree-sitter-graphql",
"tree-sitter-html",
"tree-sitter-java",
"tree-sitter-javascript",
"tree-sitter-jsdoc",
"tree-sitter-json",
"tree-sitter-make",
"tree-sitter-md",
"tree-sitter-proto",
"tree-sitter-python",
"tree-sitter-ruby",
"tree-sitter-rust",
"tree-sitter-scala",
"tree-sitter-sequel",
"tree-sitter-swift",
"tree-sitter-toml-ng",
"tree-sitter-typescript",
"tree-sitter-yaml",
"tree-sitter-zig",
"unicode-segmentation",
"uuid",
"zed-sum-tree",
@@ -3129,7 +3174,6 @@ dependencies = [
[[package]]
name = "gpui-component-assets"
version = "0.5.1"
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
dependencies = [
"anyhow",
"gpui",
@@ -3139,7 +3183,6 @@ dependencies = [
[[package]]
name = "gpui-component-macros"
version = "0.5.1"
source = "git+https://github.com/Kuddev/gpui-component?rev=a59f704c497d6e6e628ec2d401466d825d4a51b2#a59f704c497d6e6e628ec2d401466d825d4a51b2"
dependencies = [
"proc-macro2",
"quote",
@@ -3229,14 +3272,35 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
@@ -3678,7 +3742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
dependencies = [
"equivalent",
"hashbrown 0.16.0",
"hashbrown 0.15.5",
"serde",
"serde_core",
]
@@ -4055,6 +4119,17 @@ dependencies = [
"redox_syscall 0.5.18",
]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -4603,12 +4678,14 @@ dependencies = [
"pulldown-latex",
"regex",
"rfd",
"rusqlite",
"russh",
"russh-sftp",
"serde",
"serde_json",
"serde_path_to_error",
"serde_yaml",
"sha1",
"sha2 0.11.0",
"signal-hook",
"tauri-winrt-notification",
@@ -6595,6 +6672,31 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.18",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "russh"
version = "0.62.2"
@@ -7567,6 +7669,18 @@ dependencies = [
"der",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "ssh-cipher"
version = "0.3.0"
@@ -8444,6 +8558,156 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-bash"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-c"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-c-sharp"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-cmake"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-cpp"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-css"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-diff"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-elixir"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-embedded-template"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-go"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-graphql"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-html"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-java"
version = "0.23.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-javascript"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-jsdoc"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-json"
version = "0.24.8"
@@ -8460,6 +8724,136 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
[[package]]
name = "tree-sitter-make"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-md"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-proto"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-python"
version = "0.23.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-ruby"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-rust"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-scala"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-sequel"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-swift"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-toml-ng"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-typescript"
version = "0.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-yaml"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-zig"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "triomphe"
version = "0.1.16"
@@ -8828,6 +9222,12 @@ dependencies = [
"sval_serde",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
+26 -2
View File
@@ -91,6 +91,30 @@ winit = { path = "third_party/winit-0.30.13" }
# 3. 新增的 text/inline_flow.rs:shape_line 前把 \n\r 换成空格(gpui
# shaper 遇换行 panic;替换保 byte 长度,run 偏移不失效)
# 本地 fork 工作拷贝在 ../gpui-component-fork(已挂 tty7 remote 可 diff)。
#
# 上游行为备忘(不是补丁,是调用侧要知道的事):`Scrollable`
# (scroll/scrollable.rs 的 overflow_*_scrollbar)不是就地加滚动条,而是把
# 被包装元素的 StyleRefinement 整个 clone 到它新建的外层 div,再把原元素
# 样式清空、只补 flex_1,然后塞进一层 `overflow_y_scroll` 的 scroll-area。
# 后果:真正承载 children 的那层退回 gpui 默认的 Display::Block 且宽度不
# 确定,children 上的 `w_full`(width:100%)无从解析、回落到 max-content,
# 于是每个子块按自己的内容宽度收缩,看起来就是"分组宽度参差、控件既不贴
# 左也不贴右"(2026-08-17 设置页实测 A/B 确认)。**调用侧约定:滚动区内
# 先放一层 `v_flex().w_full()`,内容写在它里面**——竖向 flex 的子项走交叉
# 轴 stretch 拿宽度,绕开百分比解析。settings_pane.rs 的设置正文即按此写。
#
# 补丁清单(nebula 分支,rebase 新基点时逐条核对):
# 1. popover 单层紧凑阴影(a59f704c,见上)。
# 2. TextView 数学公式钩子(本地 nebula-math 分支,待 push 后恢复 rev pin):
# markdown 解析启用 math 构造;新增 text/math.rs 的 set_math_renderer
# 全局注册点;InlineNode/Node 增加数学节点并在渲染时调钩子,未注册时
# 回退现状(inline=code 文本、block=CodeBlock)。TeX 引擎/栅格化/绘制
# 全部住在 nebula_app::gpui_shell::math_view,fork 只有接线层。上游
# 0.6 线出现 MarkdownPlugin/Custom block 后应迁过去并撤掉本补丁。
#
# ⚠ 临时状态:gpui-component 走本地 path patch(nebula-math 分支工作树),
# 便于数学补丁的本地迭代;验收后 push 到 Kuddev/gpui-component 并恢复
# `git + rev` 精确 pin(gpui 不受影响,仍是 rev pin)。
gpui = { git = "https://github.com/Kuddev/zed", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624" }
gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "a59f704c497d6e6e628ec2d401466d825d4a51b2" }
gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "a59f704c497d6e6e628ec2d401466d825d4a51b2" }
gpui-component = { path = "../gpui-component-fork/crates/ui" }
gpui-component-assets = { path = "../gpui-component-fork/crates/assets" }
+6 -2
View File
@@ -35,15 +35,18 @@ optional = true
[dependencies.gpui-component]
version = "=0.5.1"
optional = true
# 代码查看 tab 的 tree-sitter 语法高亮语言包(不开只有 JSON)。
features = ["tree-sitter-languages"]
[dependencies.gpui-component-assets]
version = "=0.5.1"
optional = true
# 运行时用户设置(nebula_settings.txt + 主题终端色表)的共享权威实现。
# 非可选:ai_hook 的 `ai_hooks` 持久开关(setup-ai --remove)在所有壳形态
# 下都要读写这份设置。
[dependencies.nebula-settings]
path = "../nebula_settings"
optional = true
# 分屏规则的共享权威实现(布局树/拖拽比例/方向导航);GPUI 壳消费。
[dependencies.nebula-split]
@@ -118,6 +121,8 @@ getrandom = "0.3.3"
pollster = { version = "0.4", optional = true }
wgpu = { version = "25.0.2", optional = true }
regex = "1.13.1"
rusqlite = { version = "0.40.2", features = ["bundled"] }
sha1 = "0.11.0"
[build-dependencies]
gl_generator = "0.14.0"
@@ -233,7 +238,6 @@ gpui-shell = [
"dep:gpui",
"dep:gpui-component",
"dep:gpui-component-assets",
"dep:nebula-settings",
"dep:nebula-split",
"dep:futures",
]
+165 -29
View File
@@ -482,6 +482,13 @@ mod win {
/// Boot entrypoint: install now, then keep installed (see module docs).
pub fn spawn_config_guard() {
// `setup-ai --remove` 落下的持久开关:用户明确断开过就不再自动
// 装回(#38 的自愈复发面 / #8 卸载后仍在 hook)。重新启用走
// `nebula setup-ai`。
if hooks_disabled() {
log::info!("ai_hook: ai_hooks=0 (setup-ai --remove); auto-install disabled");
return;
}
if let Err(err) =
std::thread::Builder::new().name("nebula-ai-setup".into()).spawn(config_guard)
{
@@ -489,6 +496,24 @@ mod win {
}
}
/// `nebula_settings.txt` 里 `ai_hooks=0`(由 `setup-ai --remove` 写入)。
fn hooks_disabled() -> bool {
nebula_settings::RawSettings::load().bool_on("ai_hooks") == Some(false)
}
/// 一轮完整自愈。每轮都重读开关:`setup-ai --remove` 可能发生在本进程
/// 存活期间,它触发的 config 变更事件会立刻打回这里——不重读就会在
/// 400ms 内把刚移除的接线原样装回(#38 实测的自愈复发路径)。
fn heal_all() {
if hooks_disabled() {
return;
}
ensure_claude_hooks();
ensure_codex_notify();
ensure_opencode_plugin();
ensure_pi_extension();
}
fn config_guard() {
use notify::{RecursiveMode, Watcher};
@@ -507,10 +532,7 @@ mod win {
std::thread::sleep(Duration::from_secs(300));
};
ensure_claude_hooks();
ensure_codex_notify();
ensure_opencode_plugin();
ensure_pi_extension();
heal_all();
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = match notify::recommended_watcher(move |res| {
@@ -551,10 +573,7 @@ mod win {
// Debounce the writer's burst, then heal. Our own atomic
// rename lands here once and heals to a no-op.
while rx.recv_timeout(Duration::from_millis(400)).is_ok() {}
ensure_claude_hooks();
ensure_codex_notify();
ensure_opencode_plugin();
ensure_pi_extension();
heal_all();
},
Err(_) => return, // channel closed: shutting down
}
@@ -565,10 +584,7 @@ mod win {
fn poll_guard() -> ! {
loop {
std::thread::sleep(Duration::from_secs(300));
ensure_claude_hooks();
ensure_codex_notify();
ensure_opencode_plugin();
ensure_pi_extension();
heal_all();
}
}
@@ -595,6 +611,53 @@ mod win {
Some(PathBuf::from(std::env::var_os("USERPROFILE")?).join(".codex"))
}
/// notify 序列化后的字节预算。正常接线只有几百字节;超过它的唯一已知
/// 途径是与其他 notify 包装器互相包装的指数膨胀(#38,最终 130 MB 撑爆
/// config.toml 让 codex 起不来)。宁可这一轮不接线,也不把病态值落盘。
const NOTIFY_BYTE_BUDGET: usize = 8 * 1024;
/// 由现有 notify argv 算出应写入的新 argv;`None` = 不动文件。
///
/// 与 codex-computer-use 的共存规则(#38):它重新注册时若认不出
/// notify\[0\] 是自己,会把整个旧数组 JSON 序列化进自己的
/// `--previous-notify` 参数。此时 nebula-hook 不在最外层,但仍在链中
/// ——事件会沿链回流。若这时再包一层,两个包装器互相包装、转义反斜杠
/// 每轮翻倍。所以:helper 标记出现在**任何位置**(含 JSON 字符串内部)
/// 都算已接线,只有最外层是自己时才做路径自愈。
fn desired_codex_notify(current: &[String], helper: &str) -> Option<Vec<String>> {
let desired: Vec<String> = match current.first() {
// Already ours: heal the helper path, keep any chain tail as-is.
Some(first) if first.contains(HELPER_MARK) => {
let mut argv = current.to_vec();
argv[0] = helper.to_owned();
argv
},
// 已在链中但不在最外层:保持现状,绝不再包(见上)。
Some(_) if current.iter().any(|arg| arg.contains(HELPER_MARK)) => return None,
// Occupied: wrap the existing notifier behind --chain.
Some(_) => {
let mut argv = vec![helper.to_owned(), "codex".to_owned(), "--chain".to_owned()];
argv.extend(current.iter().cloned());
argv
},
None => vec![helper.to_owned(), "codex".to_owned()],
};
if current == desired {
return None;
}
// 长度兜底:对任何形态的膨胀(不止 #38 这一种循环)一律拒写。
// +4 ≈ 每个元素的引号、逗号与空格开销。
let bytes: usize = desired.iter().map(|arg| arg.len() + 4).sum();
if bytes > NOTIFY_BYTE_BUDGET {
log::warn!(
"ai_hook: codex notify would serialize to {bytes} bytes (> {NOTIFY_BYTE_BUDGET}); \
refusing to write (wrapper loop guard, #38)"
);
return None;
}
Some(desired)
}
/// Wire codex's `notify` to nebula-hook. Codex has a SINGLE notify slot
/// which may already be taken (e.g. OpenAI's own computer-use notifier),
/// so an occupied slot is wrapped, not evicted: nebula-hook forwards to
@@ -618,24 +681,9 @@ mod win {
.map(|a| a.iter().filter_map(|i| i.as_str().map(str::to_owned)).collect())
.unwrap_or_default();
let desired: Vec<String> = match current.first() {
// Already ours: heal the helper path, keep any chain tail as-is.
Some(first) if first.contains(HELPER_MARK) => {
let mut argv = current.clone();
argv[0] = helper;
argv
},
// Occupied: wrap the existing notifier behind --chain.
Some(_) => {
let mut argv = vec![helper, "codex".to_owned(), "--chain".to_owned()];
argv.extend(current.iter().cloned());
argv
},
None => vec![helper, "codex".to_owned()],
};
if current == desired {
let Some(desired) = desired_codex_notify(&current, &helper) else {
return false;
}
};
let mut array = toml_edit::Array::new();
for arg in &desired {
@@ -980,6 +1028,17 @@ export default function (pi: ExtensionAPI) {
failed = true;
},
}
// 持久开关:不写它,下次 Nebula 启动(含开机自启)会把上面
// 刚清掉的四处原样装回——移除必须比自愈活得久(#8、#38)。
match nebula_settings::persist_keys(&[("ai_hooks", "0".to_owned())]) {
Ok(()) => println!(
"已写入 ai_hooks=0:Nebula 启动时不再自动接线(重新启用:nebula setup-ai)。"
),
Err(err) => {
eprintln!("警告:无法写入 ai_hooks=0({err}),下次启动仍会自动装回。");
failed = true;
},
}
// 卸载器必须尽最大努力清理所有集成,不能因一个损坏的用户配置
// 提前返回而让其他 Hook 永久指向即将被删除的程序目录。
return i32::from(failed);
@@ -991,6 +1050,11 @@ export default function (pi: ExtensionAPI) {
return 1;
},
}
// 显式安装即重新授权:清掉 --remove 落下的持久开关,守护线程下次
// 启动恢复自愈。
if let Err(err) = nebula_settings::persist_keys(&[("ai_hooks", "1".to_owned())]) {
eprintln!("警告:无法写入 ai_hooks=1({err});若之前执行过 --remove,自动接线仍是关闭状态。");
}
if dir.exists() {
if ensure_claude_hooks() {
println!("claude: 已写入 {}(首次改动备份 *.nebula-bak)。", path.display());
@@ -1213,6 +1277,78 @@ export default function (pi: ExtensionAPI) {
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod codex_notify_tests {
use super::desired_codex_notify;
const HELPER: &str = "C:/Program Files/Nebula/runtime/nebula-hook.exe";
fn argv(args: &[&str]) -> Vec<String> {
args.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn an_empty_slot_is_claimed_outright() {
assert_eq!(
desired_codex_notify(&[], HELPER),
Some(argv(&[HELPER, "codex"]))
);
}
#[test]
fn a_foreign_notifier_is_wrapped_behind_chain() {
let current = argv(&["C:/cua/codex-computer-use.exe", "turn-ended"]);
assert_eq!(
desired_codex_notify(&current, HELPER),
Some(argv(&[
HELPER,
"codex",
"--chain",
"C:/cua/codex-computer-use.exe",
"turn-ended",
]))
);
}
#[test]
fn our_stale_helper_path_heals_and_keeps_the_chain_tail() {
let current = argv(&["D:/old/nebula-hook.exe", "codex", "--chain", "C:/cua/cua.exe"]);
assert_eq!(
desired_codex_notify(&current, HELPER),
Some(argv(&[HELPER, "codex", "--chain", "C:/cua/cua.exe"]))
);
}
#[test]
fn an_up_to_date_wiring_is_left_alone() {
let current = argv(&[HELPER, "codex"]);
assert_eq!(desired_codex_notify(&current, HELPER), None);
}
// #38 的核心形态:codex-computer-use 重新注册时把我们的 chain JSON
// 编码进 --previous-notify。我们不在最外层,但已在链中——再包一层
// 就进入互相包装、反斜杠每轮翻倍的指数爆炸。
#[test]
fn a_notifier_that_swallowed_us_into_previous_notify_is_not_wrapped_again() {
let current = argv(&[
"C:/cua/codex-computer-use.exe",
"--previous-notify",
r#"["C:\\Program Files\\Nebula\\runtime\\nebula-hook.exe", "codex", "--chain", "C:\\cua\\cua.exe", "turn-ended"]"#,
"turn-ended",
]);
assert_eq!(desired_codex_notify(&current, HELPER), None);
}
// 兜底:即使标记检测失手(比如未来某个包装器改了我们的文件名),
// 病态膨胀也会被字节预算拦住,config.toml 不会被写到 codex 起不来。
#[test]
fn an_oversized_result_is_refused() {
let ballooned = "\\".repeat(64 * 1024);
let current = argv(&["C:/cua/codex-computer-use.exe", &ballooned]);
assert_eq!(desired_codex_notify(&current, HELPER), None);
}
}
#[cfg(test)]
mod runtime_asset_tests {
use super::helper_path_from_exe;
+1 -1
View File
@@ -112,7 +112,7 @@ impl LauncherGroup {
}
impl LauncherFilter {
fn label(self, language: super::UiLanguage) -> &'static str {
pub(crate) fn label(self, language: super::UiLanguage) -> &'static str {
match self {
Self::All => language.pick("全部", "All"),
Self::Ssh => "SSH",
+26 -8
View File
@@ -205,7 +205,7 @@ fn cursor_follows_theme(color: InvertedCellColors) -> bool {
color == NEBULA_DEFAULT_CURSOR
}
fn themed_cursor_style(
pub(crate) fn themed_cursor_style(
shape: CursorShape,
theme_is_light: bool,
theme_anchor: Rgb,
@@ -553,7 +553,16 @@ fn is_terminal_graphic(character: char) -> bool {
)
}
fn is_application_cursor_cell(
pub(crate) fn is_application_cursor_glyph(
character: char,
flags: Flags,
fixed_background: bool,
) -> bool {
matches!(character, '\u{2580}'..='\u{259f}')
|| (character == ' ' && (flags.contains(Flags::INVERSE) || fixed_background))
}
pub(crate) fn is_application_cursor_cell(
terminal_cursor_shape: CursorShape,
terminal_cursor_point: Point,
cell_point: Point,
@@ -563,14 +572,20 @@ fn is_application_cursor_cell(
) -> bool {
terminal_cursor_shape == CursorShape::Hidden
&& terminal_cursor_point == cell_point
&& (matches!(character, '\u{2580}'..='\u{259f}')
|| (character == ' ' && (flags.contains(Flags::INVERSE) || fixed_background)))
&& is_application_cursor_glyph(character, flags, fixed_background)
}
pub(crate) fn cell_background_is_fixed(
color: Color,
overrides: &nebula_terminal::term::color::Colors,
) -> bool {
is_fixed_color(color, overrides)
}
/// Alpha-compose a theme overlay over the cell's existing background. Keeping
/// this in the render model preserves explicit ANSI cell backgrounds while
/// still allowing the default transparent terminal surface/image to show.
fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f32) -> (Rgb, f32) {
pub(crate) fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f32) -> (Rgb, f32) {
let oa = overlay_alpha.clamp(0.0, 1.0);
let ba = base_alpha.clamp(0.0, 1.0);
let out_a = oa + ba * (1.0 - oa);
@@ -589,7 +604,7 @@ fn composite_overlay(overlay: Rgb, overlay_alpha: f32, base: Rgb, base_alpha: f3
)
}
fn mix_rgb(color: Rgb, neutral: Rgb, neutral_amount: f32) -> Rgb {
pub(crate) fn mix_rgb(color: Rgb, neutral: Rgb, neutral_amount: f32) -> Rgb {
let t = neutral_amount.clamp(0.0, 1.0);
let channel = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
Rgb::new(channel(color.r, neutral.r), channel(color.g, neutral.g), channel(color.b, neutral.b))
@@ -653,8 +668,8 @@ mod tests {
use nebula_terminal::vte::ansi::CursorShape;
use super::{
composite_overlay, cursor_follows_theme, is_application_cursor_cell, is_terminal_graphic,
mix_rgb, themed_cursor_style,
composite_overlay, cursor_follows_theme, is_application_cursor_cell,
is_application_cursor_glyph, is_terminal_graphic, mix_rgb, themed_cursor_style,
};
use crate::config::color::NEBULA_DEFAULT_CURSOR;
use crate::display::color::Rgb;
@@ -742,6 +757,9 @@ mod tests {
Flags::empty(),
false,
));
assert!(is_application_cursor_glyph('█', Flags::empty(), false));
assert!(is_application_cursor_glyph(' ', Flags::INVERSE, false));
assert!(!is_application_cursor_glyph(' ', Flags::empty(), false));
let (light_cursor, _) = composite_overlay(
Rgb::new(124, 90, 196),
+3
View File
@@ -457,6 +457,7 @@ pub(crate) fn capture_combo(key: &KeyEvent, mods: ModifiersState) -> CaptureOutc
/// - Shift+符号(gpui 直接给出变换后的字符并清掉 shift 位)只能按字符
/// 存储,旧壳 winit 层不会命中这类行——与旧壳编辑器「无法表示 → 等待」
/// 相比是多存了一条死绑定,可接受。
#[cfg(feature = "gpui-shell")]
pub(crate) fn capture_gpui(keystroke: &::gpui::Keystroke) -> CaptureOutcome {
let mods = gpui_modifiers(&keystroke.modifiers);
let key = keystroke.key.trim().to_lowercase();
@@ -494,6 +495,7 @@ pub(crate) fn capture_gpui(keystroke: &::gpui::Keystroke) -> CaptureOutcome {
}
/// gpui `Modifiers` → winit `ModifiersState`(位语义一致,直接搬运)。
#[cfg(feature = "gpui-shell")]
pub(crate) fn gpui_modifiers(modifiers: &::gpui::Modifiers) -> ModifiersState {
let mut mods = ModifiersState::empty();
if modifiers.control {
@@ -512,6 +514,7 @@ pub(crate) fn gpui_modifiers(modifiers: &::gpui::Modifiers) -> ModifiersState {
}
/// gpui `Modifiers` 的修饰键前缀回显(捕获态实时显示 "Ctrl+…")。
#[cfg(feature = "gpui-shell")]
pub(crate) fn gpui_mods_prefix(modifiers: &::gpui::Modifiers) -> String {
mods_prefix(gpui_modifiers(modifiers))
}
+1 -1
View File
@@ -86,7 +86,7 @@ mod state;
pub(crate) mod suggest_engine;
mod surface_opacity;
mod terminal_color;
mod terminal_math;
pub(crate) mod terminal_math;
mod toast;
/// Processor uses the same persisted value before the first window exists so
+139 -12
View File
@@ -69,6 +69,10 @@ pub struct GitInfo {
pub unstaged: Vec<(char, String)>,
/// Index changes ready to commit.
pub staged: Vec<(char, String)>,
/// 合并冲突(porcelain 的 U*/AA/DD;SVN 的 `C`)。冲突路径**同时**保留
/// 在 `staged`/`unstaged` 里:旧壳视图零改动照常显示,GPUI 壳按本列表
/// 单独分组并从另两组过滤(VS Code 的 Merge Changes 合同)。
pub conflicts: Vec<(char, String)>,
}
/// Result of hit-testing a pixel against the open drawer.
@@ -502,7 +506,22 @@ impl SidePanel {
let slot = std::sync::Arc::clone(&self.snapshot_slot);
std::thread::spawn(move || {
let rows = SidePanel::tree_rows(&root, &expanded);
let git = read_git(&git_root).or_else(|| read_svn(&git_root));
// 设置可强制只认 Git / SVN(混合仓库场景);Auto 保持既有探测:
// a checkout nested inside a Git tree must remain visible as SVN.
// Prefer SVN only when its metadata is in the current path's
// ancestor chain; ordinary Git directories keep the cheaper Git
// first path and only probe SVN as a fallback.
let git = match nebula_settings::RuntimeSettings::load().vcs_display {
nebula_settings::VcsDisplayName::Git => read_git(&git_root),
nebula_settings::VcsDisplayName::Svn => read_svn(&git_root),
nebula_settings::VcsDisplayName::Auto => {
if svn_working_copy_hint(&git_root) {
read_svn(&git_root).or_else(|| read_git(&git_root))
} else {
read_git(&git_root).or_else(|| read_svn(&git_root))
}
},
};
if let Ok(mut slot) = slot.lock() {
*slot = Some(PanelSnapshot { root, rows, git });
}
@@ -653,6 +672,33 @@ impl SidePanel {
}
}
/// `git add -- <path>`:单文件暂存(VS Code 行内 + 的合同)。
pub fn git_stage_path(&mut self, path: &str) {
if self.vcs() == Some(VcsKind::Git) && !self.op_running() {
self.spawn_git(vec!["add".into(), "--".into(), path.to_owned()]);
}
}
/// `git restore --staged -- <path>`:单文件取消暂存(VS Code 行内 −)。
pub fn git_unstage_path(&mut self, path: &str) {
if self.vcs() == Some(VcsKind::Git) && !self.op_running() {
self.spawn_git(vec![
"restore".into(),
"--staged".into(),
"--".into(),
path.to_owned(),
]);
}
}
/// `git restore -- <path>`:丢弃工作区改动(调用方负责确认交互;
/// untracked 文件不适用——restore 不删新文件,按钮层不对 `?` 提供)。
pub fn git_discard_path(&mut self, path: &str) {
if self.vcs() == Some(VcsKind::Git) && !self.op_running() {
self.spawn_git(vec!["restore".into(), "--".into(), path.to_owned()]);
}
}
/// Commit button: with staged changes, open the message input (Enter then
/// commits via [`Self::git_commit_submit`]).
pub fn git_begin_commit(&mut self) {
@@ -1222,6 +1268,12 @@ fn read_git(root: &Path) -> Option<GitInfo> {
info.unstaged.push(('?', path));
continue;
}
// Merge conflicts (VS Code's "Merge Changes" group). The path
// also stays in staged/unstaged below so the legacy view keeps
// rendering it untouched.
if x == 'U' || y == 'U' || (x == 'A' && y == 'A') || (x == 'D' && y == 'D') {
info.conflicts.push(('U', path.clone()));
}
// One file can be in BOTH lists (partially staged).
if x != ' ' {
info.staged.push((x, path.clone()));
@@ -1246,10 +1298,10 @@ fn read_git(root: &Path) -> Option<GitInfo> {
Some(info)
}
/// SVN fallback(`read_git` 判空后才尝试):工作副本检测走祖先链找 `.svn`
/// ——零子进程的廉价预检,svn 未安装或普通目录都在这里就地退出,不为
/// 每次快照付一次 CLI 启动开销。
fn svn_working_copy(root: &Path) -> bool {
/// Cheap preference hint for nested checkouts. `read_svn` still runs the
/// authoritative `svn info` command, so this hint cannot make detection
/// incorrect when metadata is represented differently by an SVN client.
fn svn_working_copy_hint(root: &Path) -> bool {
root.ancestors().any(|dir| dir.join(".svn").is_dir())
}
@@ -1270,15 +1322,71 @@ fn parse_svn_status(status: &str) -> Vec<(char, String)> {
.collect()
}
/// Snapshot SVN state for `root`: revision + working-copy changes. `None`
/// when `root` isn't inside a working copy or the `svn` CLI is missing.
/// 认证失败等错误同样落 `None` + debug 日志——面板留白,操作路径
/// (commit/update)的错误才经 `op_error` 呈现给用户。
/// Snapshot SVN state for `root`. The CLI(`svn info`/`svn status`)is tried
/// first for maximum fidelity; machines without a command-line client
/// (TortoiseSVN installs GUI only) fall back to reading `.svn/wc.db`
/// directly(`svn_status` 模块,TSVNCache 同款路线)。`None` means the path
/// is not a working copy at all.
fn read_svn(root: &Path) -> Option<GitInfo> {
use std::process::Command;
if !svn_working_copy(root) {
read_svn_cli(root).map(fill_svn_conflicts).or_else(|| read_svn_wc_db(root))
}
/// CLI 输出没有单独的冲突列表:从 `unstaged` 里把 `C` 行登记到
/// [`GitInfo::conflicts`](路径保留原位,合同见字段注释)。
fn fill_svn_conflicts(mut info: GitInfo) -> GitInfo {
info.conflicts = info
.unstaged
.iter()
.filter(|(state, _)| *state == 'C')
.map(|(_, path)| ('C', path.clone()))
.collect();
info
}
/// 零外部依赖的 SVN 快照:`svn_status` 读 `.svn/wc.db` 推导状态字母表,
/// 修订号取 NODES 根行。路径统一转成相对 `root` 的正斜杠形式,与 CLI
/// `svn status` 的展示合同一致(只显示 `root` 之下的条目)。
fn read_svn_wc_db(root: &Path) -> Option<GitInfo> {
let crate::svn_status::SvnDirKind::WorkingCopy(wc_root) = crate::svn_status::classify_dir(root)
else {
return None;
};
let changes = crate::svn_status::working_copy_status(&wc_root).ok()?;
let revision = crate::svn_status::working_copy_revision(&wc_root);
let mut info = GitInfo {
vcs: VcsKind::Svn,
branch: revision.map(|value| format!("r{value}")).unwrap_or_else(|| "svn".to_owned()),
..GitInfo::default()
};
let prefix = root.strip_prefix(&wc_root).ok().map(|relative| {
let mut text = relative.to_string_lossy().replace('\\', "/");
if !text.is_empty() && !text.ends_with('/') {
text.push('/');
}
text
});
for change in changes {
let shown = match prefix.as_deref() {
None | Some("") => change.rel_path.clone(),
Some(prefix) => match change.rel_path.strip_prefix(prefix) {
Some(inside) => inside.to_owned(),
// `root` 子目录之外的变更不在本视图的展示合同内。
None => continue,
},
};
let state = change.state.letter().chars().next().unwrap_or('M');
if change.state == crate::svn_status::SvnState::Conflicted {
info.conflicts.push(('C', shown.clone()));
}
// SVN 无暂存区:全部归 unstaged(GPUI 会把冲突条目过滤出去单独分组)。
info.unstaged.push((if state == 'U' { '?' } else { state }, shown));
}
Some(info)
}
/// CLI 路线(现代客户端的权威路径)。
fn read_svn_cli(root: &Path) -> Option<GitInfo> {
use std::process::Command;
let run = |args: &[&str]| -> Option<String> {
let mut cmd = Command::new("svn");
// 交互式认证提示会把无头子进程挂死;快照必须是非交互的。
@@ -1298,7 +1406,11 @@ fn read_svn(root: &Path) -> Option<GitInfo> {
Some(String::from_utf8_lossy(&out.stdout).into_owned())
};
let revision = run(&["info", "--show-item", "revision"])?;
// `--show-item` is available in modern SVN. Fall back to regular
// `svn info` so older clients still produce a revision number.
let revision = run(&["info", "--show-item", "revision"])
.and_then(|value| (!value.trim().is_empty()).then_some(value))
.or_else(|| run(&["info"]).and_then(|value| parse_svn_revision(&value)))?;
let status = run(&["status"])?;
Some(GitInfo {
vcs: VcsKind::Svn,
@@ -1308,6 +1420,13 @@ fn read_svn(root: &Path) -> Option<GitInfo> {
})
}
fn parse_svn_revision(info: &str) -> Option<String> {
info.lines().find_map(|line| {
let value = line.strip_prefix("Revision:")?.trim();
(!value.is_empty()).then_some(value.to_owned())
})
}
// ---- rendering (mirrors the `settings.rs` split: the parent `display::mod`
// hands in a snapshot + renderer; this module owns the drawer's pixels) ----
@@ -2443,6 +2562,7 @@ mod tests {
ahead: 0,
unstaged: vec![('?', "one.txt".into()), ('M', "two.txt".into())],
staged: vec![('A', "three.txt".into())],
conflicts: Vec::new(),
});
assert!(!panel.git_row_is_file(0), "未暂存标题");
@@ -2565,6 +2685,13 @@ mod tests {
);
}
#[test]
fn svn_info_revision_parser_accepts_standard_output() {
let info = "Path: .\r\nWorking Copy Root Path: D:\\checkout\r\nRevision: 42\r\nNode Kind: directory\r\n";
assert_eq!(parse_svn_revision(info).as_deref(), Some("42"));
assert_eq!(parse_svn_revision("Path: .\nNode Kind: directory\n"), None);
}
#[test]
fn svn_snapshot_disables_stage_and_push_semantics() {
// SVN 没有暂存区、没有 push:快照层用 staged 恒空 + ahead 恒 0 编码,
+3
View File
@@ -233,6 +233,9 @@ pub struct NebulaPaneState {
/// 本次前台 agent 命令是否收到过 hook。屏幕规则的 idle 不得覆盖精确的
/// hook done;可见 blocker/working 仍可纠正漏报或过时事件。
pub agent_hook_seen: bool,
/// 屏幕检测连续看到空闲提示符的拍数。Working 要连续两拍空闲才降级
/// (单拍可能是重绘间隙);任何非 idle 检测都会清零。
pub idle_screen_streak: u8,
pub last_committed: String,
pub awaiting_input: bool,
pub finished_unseen: bool,
+160 -80
View File
@@ -61,7 +61,7 @@ const HEIGHT_OVERRUN_TOLERANCE: f32 = 0.12;
/// Per-pane state. Layout data is intentionally discarded when a pane state is
/// cloned: cloned UI metadata can outlive a renderer/font scale, while layouts
/// are cheap to rebuild and remain bounded by `MathLayoutCache` afterwards.
pub(super) struct TerminalMathState {
pub(crate) struct TerminalMathState {
cache: MathLayoutCache,
projection: LineProjection,
ai_cli_seen: bool,
@@ -110,15 +110,15 @@ impl fmt::Debug for TerminalMathState {
}
impl TerminalMathState {
pub(super) fn observe_program(&mut self, program: Option<&str>) {
pub(crate) fn observe_program(&mut self, program: Option<&str>) {
self.ai_cli_seen |= program.is_some_and(is_ai_cli);
}
pub(super) fn inline_dollar_enabled(&self) -> bool {
pub(crate) fn inline_dollar_enabled(&self) -> bool {
self.ai_cli_seen
}
pub(super) fn update_projection(
pub(crate) fn update_projection(
&mut self,
overlays: &[FormulaOverlay],
prepared: &[Option<PreparedFormula>],
@@ -126,7 +126,7 @@ impl TerminalMathState {
self.projection.rebuild(overlays, prepared);
}
pub(super) fn project_cell(&self, point: Point<usize>, columns: usize) -> Option<Point<usize>> {
pub(crate) fn project_cell(&self, point: Point<usize>, columns: usize) -> Option<Point<usize>> {
self.projection.project_cell(point, columns)
}
@@ -137,7 +137,7 @@ impl TerminalMathState {
/// `viewport_origin` is the renderer's viewport top row: projection spans
/// live in rendered-viewport coordinates, which can be cropped relative to
/// the grid while a resize commit is pending.
pub(super) fn source_point(
pub(crate) fn source_point(
&self,
point: Point,
side: Side,
@@ -580,14 +580,14 @@ struct GridPosition {
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct RowSpan {
pub(crate) struct RowSpan {
row: i32,
start: usize,
end: usize,
}
#[derive(Clone, Debug)]
pub(super) struct FormulaOverlay {
pub(crate) struct FormulaOverlay {
source: Arc<str>,
display: bool,
formula_id: u64,
@@ -615,6 +615,21 @@ pub(super) struct FormulaOverlay {
}
impl FormulaOverlay {
/// GPUI 壳的只读视图:该公式覆盖的 (行, 起列, 止列) 序列。行可为负
/// (部分滚出视口的持久公式)。用于围栏背景过滤与源格跳过。
pub(crate) fn covered_ranges(&self) -> impl Iterator<Item = (i32, usize, usize)> + '_ {
self.spans.iter().map(|span| (span.row, span.start, span.end))
}
/// 归一化后的 TeX 源(GPUI 壳绘制/缓存键用)。
pub(crate) fn source_arc(&self) -> Arc<str> {
Arc::clone(&self.source)
}
pub(crate) fn formula_id(&self) -> u64 {
self.formula_id
}
fn contains(&self, point: Point<usize>) -> bool {
self.spans.iter().any(|span| {
usize::try_from(span.row) == Ok(point.line)
@@ -981,7 +996,7 @@ impl Ord for GridPosition {
/// Scan the visible grid and attach renderer-resolved colors/fallback cells.
#[allow(clippy::too_many_arguments)]
pub(super) fn scan_visible<T>(
pub(crate) fn scan_visible<T>(
state: &mut TerminalMathState,
terminal: &Term<T>,
size: &SizeInfo,
@@ -1452,7 +1467,7 @@ fn make_overlay(
/// Every geometric decision is taken here, so the fit and the clip can never
/// disagree about how much room the formula was given.
#[derive(Clone, Copy)]
pub(super) struct PreparedFormula {
pub(crate) struct PreparedFormula {
fitted_pixel_size: f32,
/// Typesetting style actually used. A block formula that does not fit its
/// row falls back to text style before it gives up any size: readers judge
@@ -1656,7 +1671,7 @@ fn ink_columns(
/// Pre-compile every overlay before the cell pass so the renderer knows which
/// source cells to skip. Returns one entry per overlay; `None` keeps the raw
/// source visible (layout failure, or the fitted size would be unreadable).
pub(super) fn prepare_overlays(
pub(crate) fn prepare_overlays(
state: &mut TerminalMathState,
overlays: &[FormulaOverlay],
size: &SizeInfo,
@@ -1803,15 +1818,19 @@ pub(super) fn prepare_overlays(
/// pass can skip those glyphs. Painting them and covering the area with an
/// opaque patch afterwards is what produced the black/white formula slabs on
/// transparent and background-image windows.
pub(super) struct CoverageMask {
#[derive(Default)]
pub(crate) struct CoverageMask {
rows: BTreeMap<usize, Vec<(usize, usize)>>,
}
impl CoverageMask {
pub(super) fn build(overlays: &[FormulaOverlay], prepared: &[Option<PreparedFormula>]) -> Self {
/// `gates` 与 overlays 一一对应;`None` 的公式保留原文(其源格不进
/// 掩码)。旧壳传 `Option<PreparedFormula>`,GPUI 壳传
/// `Option<OverlayDrawPlan>`——覆盖判定与各自的回退路径保持一致。
pub(crate) fn build<T>(overlays: &[FormulaOverlay], gates: &[Option<T>]) -> Self {
let mut rows: BTreeMap<usize, Vec<(usize, usize)>> = BTreeMap::new();
for (overlay, prepared) in overlays.iter().zip(prepared) {
if prepared.is_none() {
for (overlay, gate) in overlays.iter().zip(gates) {
if gate.is_none() {
continue;
}
for span in &overlay.spans {
@@ -1822,22 +1841,123 @@ impl CoverageMask {
Self { rows }
}
pub(super) fn covers(&self, point: Point<usize>) -> bool {
pub(crate) fn covers(&self, point: Point<usize>) -> bool {
self.rows.get(&point.line).is_some_and(|spans| {
spans.iter().any(|&(start, end)| (start..end).contains(&point.column.0))
})
}
pub(super) fn is_empty(&self) -> bool {
pub(crate) fn is_empty(&self) -> bool {
self.rows.is_empty()
}
}
/// 一个 overlay 的后端无关绘制计划:几何决策(fit/居中/bleed/裁剪)的
/// 单一出口,OpenGL(旧壳 [`draw_overlays`])与 GPUI 壳共同消费,保证两壳
/// 的公式落点与裁剪像素级同源。坐标系与 [`SizeInfo`] 一致。
#[derive(Clone, Copy, Debug)]
pub(crate) struct OverlayDrawPlan {
pub(crate) fitted_pixel_size: f32,
pub(crate) display_style: bool,
pub(crate) origin_x: f32,
pub(crate) baseline_y: f32,
pub(crate) clip_left: f32,
pub(crate) clip_top: f32,
pub(crate) clip_right: f32,
pub(crate) clip_bottom: f32,
pub(crate) foreground: Rgb,
}
/// 计算一个已通过预检的 overlay 的绘制几何。`None` = 本帧回退原文
/// (布局失败或裁剪退化),调用方自行决定回退方式(旧壳补画源格,GPUI
/// 壳保留源格不跳过)。
pub(crate) fn plan_overlay_draw(
state: &mut TerminalMathState,
overlay: &FormulaOverlay,
prepared: &PreparedFormula,
size: &SizeInfo,
pixels_per_point: f32,
) -> Option<OverlayDrawPlan> {
let mut bounds = overlay.bounds(size)?;
let shift_columns = match overlay.spans.as_slice() {
[span] => usize::try_from(span.row)
.ok()
.map_or(0, |row| state.projection.shift_before(row, span.start)),
_ => 0,
};
let shift_pixels = shift_columns as f32 * size.cell_width();
bounds.left += shift_pixels;
bounds.right += shift_pixels;
let projected_box_right = prepared.box_right + shift_pixels;
// Pre-flight succeeded, so failure here is unreachable in practice.
let metrics = state
.layout(
overlay.formula_id,
&overlay.source,
prepared.fitted_pixel_size,
pixels_per_point,
prepared.display_style,
)
.ok()?
.metrics;
let total_height = metrics.height + metrics.depth;
let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width();
let viewport_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height();
let box_right = projected_box_right;
let box_width = box_right - bounds.left;
let origin_x = if prepared.centered {
bounds.left + (box_width - metrics.width) / 2.0
} else {
bounds.left + FORMULA_INSET
};
// Centre inside the source rows while the ink fits them. What does not
// fit is split between the sides in proportion to what each side can
// actually lend: with a blank row above and prose below, the overflow
// goes up, and the formula keeps the full line gap between itself and
// the text underneath instead of splitting the crowding evenly.
let slack = bounds.height() - total_height;
let room = prepared.bleed_top + prepared.bleed_bottom;
let top = if slack >= 0.0 {
bounds.top + slack / 2.0
} else if room > 0.0 {
bounds.top + slack * (prepared.bleed_top / room)
} else {
bounds.top + slack / 2.0
};
let baseline_y = (top + metrics.height)
.max(bounds.top - prepared.bleed_top + metrics.height)
.min(bounds.bottom + prepared.bleed_bottom - metrics.depth);
// The clip follows the bleed budget, never the ink: whatever the fit
// could not absorb gets cropped instead of landing on neighbouring
// prose. (Following the ink is what painted formulas over adjacent
// rows.)
let clip_left = bounds.left.max(size.padding_x());
let clip_top = (bounds.top - prepared.bleed_top).max(size.padding_y());
let clip_right = box_right.min(viewport_right);
let clip_bottom = (bounds.bottom + prepared.bleed_bottom).min(viewport_bottom);
if clip_right <= clip_left || clip_bottom <= clip_top {
return None;
}
Some(OverlayDrawPlan {
fitted_pixel_size: prepared.fitted_pixel_size,
display_style: prepared.display_style,
origin_x,
baseline_y,
clip_left,
clip_top,
clip_right,
clip_bottom,
foreground: overlay.foreground,
})
}
/// Draw prepared overlays after terminal rectangles. The source cells were
/// already skipped during the grid pass, so the formula renders directly on
/// the window background — no cover quad, transparency and wallpaper intact.
#[allow(clippy::too_many_arguments)]
pub(super) fn draw_overlays(
pub(crate) fn draw_overlays(
renderer: &mut Renderer,
glyph_cache: &mut GlyphCache,
state: &mut TerminalMathState,
@@ -1850,78 +1970,34 @@ pub(super) fn draw_overlays(
let Some(prepared) = prepared else {
continue;
};
let Some(mut bounds) = overlay.bounds(size) else {
let Some(plan) = plan_overlay_draw(state, overlay, prepared, size, pixels_per_point)
else {
// Repaint the skipped source cells rather than leave a hole.
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
continue;
};
let shift_columns = match overlay.spans.as_slice() {
[span] => usize::try_from(span.row)
.ok()
.map_or(0, |row| state.projection.shift_before(row, span.start)),
_ => 0,
};
let shift_pixels = shift_columns as f32 * size.cell_width();
bounds.left += shift_pixels;
bounds.right += shift_pixels;
let projected_box_right = prepared.box_right + shift_pixels;
let layout = match state.layout(
overlay.formula_id,
&overlay.source,
prepared.fitted_pixel_size,
plan.fitted_pixel_size,
pixels_per_point,
prepared.display_style,
plan.display_style,
) {
Ok(layout) => layout,
Err(_) => {
// Pre-flight succeeded, so this is unreachable in practice;
// repaint the skipped source cells rather than leave a hole.
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
continue;
},
};
let total_height = layout.metrics.height + layout.metrics.depth;
let viewport_right = size.padding_x() + size.columns() as f32 * size.cell_width();
let viewport_bottom = size.padding_y() + size.screen_lines() as f32 * size.cell_height();
let box_right = projected_box_right;
let box_width = box_right - bounds.left;
let origin_x = if prepared.centered {
bounds.left + (box_width - layout.metrics.width) / 2.0
} else {
bounds.left + FORMULA_INSET
};
// Centre inside the source rows while the ink fits them. What does not
// fit is split between the sides in proportion to what each side can
// actually lend: with a blank row above and prose below, the overflow
// goes up, and the formula keeps the full line gap between itself and
// the text underneath instead of splitting the crowding evenly.
let slack = bounds.height() - total_height;
let room = prepared.bleed_top + prepared.bleed_bottom;
let top = if slack >= 0.0 {
bounds.top + slack / 2.0
} else if room > 0.0 {
bounds.top + slack * (prepared.bleed_top / room)
} else {
bounds.top + slack / 2.0
};
let baseline_y = (top + layout.metrics.height)
.max(bounds.top - prepared.bleed_top + layout.metrics.height)
.min(bounds.bottom + prepared.bleed_bottom - layout.metrics.depth);
// The clip follows the bleed budget, never the ink: whatever the fit
// could not absorb gets cropped instead of landing on neighbouring
// prose. (Following the ink is what painted formulas over adjacent
// rows.)
let clip = MathClip {
left: bounds.left.max(size.padding_x()),
top: (bounds.top - prepared.bleed_top).max(size.padding_y()),
right: box_right.min(viewport_right),
bottom: (bounds.bottom + prepared.bleed_bottom).min(viewport_bottom),
left: plan.clip_left,
top: plan.clip_top,
right: plan.clip_right,
bottom: plan.clip_bottom,
};
if clip.right <= clip.left || clip.bottom <= clip.top {
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
continue;
}
if renderer.draw_math(size, layout, origin_x, baseline_y, overlay.foreground, clip).is_err()
if renderer
.draw_math(size, layout, plan.origin_x, plan.baseline_y, plan.foreground, clip)
.is_err()
{
renderer.draw_cells(size, glyph_cache, overlay.fallback.iter().cloned());
continue;
@@ -1929,12 +2005,16 @@ pub(super) fn draw_overlays(
let base_ascent = (size.cell_height() + glyph_cache.font_metrics().descent).max(1.0);
for operation in &layout.text {
let scale = operation.pixel_size / prepared.fitted_pixel_size;
let x = origin_x + operation.x;
let y = baseline_y + operation.baseline_y - base_ascent * scale;
let scale = operation.pixel_size / plan.fitted_pixel_size;
let x = plan.origin_x + operation.x;
let y = plan.baseline_y + operation.baseline_y - base_ascent * scale;
let width = size.cell_width() * scale;
let height = size.cell_height() * scale;
if x < clip.left || x + width > clip.right || y < clip.top || y + height > clip.bottom {
if x < plan.clip_left
|| x + width > plan.clip_right
|| y < plan.clip_top
|| y + height > plan.clip_bottom
{
continue;
}
let mut text = [0u8; 4];
@@ -1943,7 +2023,7 @@ pub(super) fn draw_overlays(
x,
y,
scale,
overlay.foreground,
plan.foreground,
Flags::empty(),
operation.character.encode_utf8(&mut text),
glyph_cache,
+170
View File
@@ -0,0 +1,170 @@
//! 代码查看 Tab:tree-sitter 语法高亮 + 行级虚拟化的只读查看器。
//!
//! 渲染走组件库 `InputState::code_editor`——它按可视行布局/绘制(约 5 万行
//! 量级依旧流畅),正好满足"超大代码只渲染可视范围"的性能合同;Markdown
//! 文档继续走 `TextView`(根级块虚拟化),两条管线各管各的文件类型。
//!
//! 只读策略:这是查看器不是编辑器——文件树双击的诉求是"看一眼源码",
//! 写盘语义(保存/冲突/编码回写)超出本 tab 的合同。
use std::path::{Path, PathBuf};
use gpui::prelude::FluentBuilder as _;
use gpui::{
AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, Window, div, px,
};
use crate::gpui_shell::prelude::*;
/// 一次性读入的上限。行级虚拟化解决的是渲染成本,解析/塑形仍随内容量
/// 增长;8MB 已覆盖常见源码文件,超限截断并在文首说明。
const MAX_CODE_BYTES: usize = 8 * 1024 * 1024;
/// 扩展名 → tree-sitter 语言 id(组件库 highlighter 的注册名)。
/// 未命中的扩展仍可打开(纯文本、无高亮)。
fn language_for_extension(extension: &str) -> Option<&'static str> {
Some(match extension.to_ascii_lowercase().as_str() {
"rs" => "rust",
"py" | "pyi" => "python",
"js" | "mjs" | "cjs" => "javascript",
"ts" | "mts" | "cts" => "typescript",
"tsx" => "tsx",
"jsx" => "jsx",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp",
"cs" => "csharp",
"go" => "go",
"java" => "java",
"rb" => "ruby",
"php" => "php",
"swift" => "swift",
"kt" | "kts" => "kotlin",
"lua" => "lua",
"sh" | "bash" | "zsh" => "bash",
"ps1" | "psm1" | "psd1" => "powershell",
"toml" => "toml",
"yaml" | "yml" => "yaml",
"html" | "htm" => "html",
"css" => "css",
"scss" => "scss",
"sql" => "sql",
"zig" => "zig",
"cmake" => "cmake",
"dockerfile" => "dockerfile",
"ex" | "exs" => "elixir",
"erl" => "erlang",
"hs" => "haskell",
"ini" | "cfg" | "conf" => "ini",
"vim" => "vim",
"xml" => "xml",
_ => return None,
})
}
/// 文件树双击是否进代码查看 tab(与 markdown/图片路由并列;三者都不认的
/// 交系统处理器)。
pub fn viewable_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| language_for_extension(extension).is_some())
}
pub struct CodeTabView {
pub path: PathBuf,
pub title: String,
input: Entity<InputState>,
notice: Option<String>,
lines: usize,
}
impl CodeTabView {
pub fn new(path: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
let title = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
let language = path
.extension()
.and_then(|extension| extension.to_str())
.and_then(language_for_extension)
.unwrap_or("text");
let input = cx.new(|cx| {
InputState::new(window, cx)
.code_editor(language)
.line_number(true)
.indent_guides(true)
.soft_wrap(false)
});
let mut this =
Self { path, title, input, notice: None, lines: 0 };
this.reload(window, cx);
this
}
/// 重新读盘(文件树再次双击同一路径时宿主调用,与 doc tab 同义)。
pub fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let (text, notice) = match std::fs::read(&self.path) {
Ok(bytes) => {
let truncated = bytes.len() > MAX_CODE_BYTES;
let slice = if truncated { &bytes[..MAX_CODE_BYTES] } else { &bytes[..] };
let text = String::from_utf8_lossy(slice).into_owned();
let notice = truncated
.then(|| format!("文件超过 {} MB,仅显示开头部分", MAX_CODE_BYTES / 1024 / 1024));
(text, notice)
},
Err(error) => {
(String::new(), Some(format!("无法读取 {}: {error}", self.path.display())))
},
};
self.lines = text.lines().count();
self.notice = notice;
self.input.update(cx, |input, cx| input.set_value(text, window, cx));
cx.notify();
}
}
impl Render for CodeTabView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
let muted = theme.muted_foreground;
let path_label: SharedString = self.path.display().to_string().into();
let meta: SharedString = format!("{} 行", self.lines).into();
v_flex()
.size_full()
.p_3()
.gap_2()
.child(
h_flex()
.h(px(28.0))
.items_center()
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.text_xs()
.text_color(muted)
.overflow_hidden()
.whitespace_nowrap()
.child(path_label),
)
.child(div().text_xs().text_color(muted).child(meta))
.child(
Button::new("code-reload")
.icon(IconName::Redo2)
.ghost()
.xsmall()
.tooltip("重新读取文件")
.on_click(cx.listener(|this, _, window, cx| {
this.reload(window, cx);
})),
),
)
.when_some(self.notice.clone(), |root, notice| {
root.child(div().text_xs().text_color(theme.warning).child(notice))
})
.child(div().flex_1().min_h_0().child(Input::new(&self.input).h_full()))
}
}
+4
View File
@@ -52,6 +52,9 @@ pub struct Settings {
pub completion_style: crate::display::CompletionStyle,
/// 单元格宽度取整方式;同一窗口宽度下必须与旧壳得到相同列数。
pub cell_width_mode: nebula_settings::CellWidthModeName,
/// 全宽字形(CJK 等)的 bold run 用 Regular 字形栅格(粗体只提亮不加粗,
/// 旧壳 `glyph_cache.wide_bold_use_regular` 同义)。
pub cjk_bold_regular: bool,
/// 默认 shell 的稳定 id(`nebula_settings.txt` 的 `shell=`,如
/// "pwsh" / "cmd" / "wsl:Ubuntu")。None = 引擎默认。
pub shell_id: Option<String>,
@@ -138,6 +141,7 @@ impl Settings {
_ => crate::display::CompletionStyle::Inline,
},
cell_width_mode: runtime.cell_width_mode,
cjk_bold_regular: runtime.cjk_bold_regular,
shell_id: runtime.shell.clone(),
font_family: normal_family,
source_path: path,
+3 -2
View File
@@ -26,11 +26,12 @@ use crate::display::image_viewer::ImageView;
use crate::gpui_shell::prelude::*;
use gpui_component::text::TextView;
/// 双击路由与旧壳 `input/chrome.rs` 同合同:应用内能读的开 tab,其余交
/// 系统处理器。
/// 双击路由:应用内能读的开 tab(图片/文档/源码),其余交系统处理器。
/// 源码查看是 GPUI 壳新增能力,旧壳合同(`input/chrome.rs`)之上的超集。
pub fn openable_in_app(path: &Path) -> bool {
crate::display::image_viewer::viewable_file(path)
|| crate::display::markdown_view::viewable_file(path)
|| crate::gpui_shell::code_tab::viewable_file(path)
}
/// 文档一次性读入的上限。组件 TextView 是全量解析(没有旧壳 DocView 的
+562
View File
@@ -0,0 +1,562 @@
//! GPUI 壳的公式渲染:旧壳数学管线接入组件库 TextView。
//!
//! 管线与旧壳三层同源:`compile_formula` 产出后端无关绘制指令 →
//! `MathGlyphRasterizer`(与旧壳 GL 图集同一支栅格化器)按物理像素把整条
//! 公式合成一张 BGRA 位图 → `window.paint_image` 上屏;数学字体缺字的
//! 字符(旧笔记里的中文等)用 gpui 文本系统按基线叠加补画,对应旧壳
//! `draw_math` 之后的 `draw_doc_text` 回补。
//!
//! 为什么走位图而不是 gpui 字形管线:数学排版要按字形 ID 绘制(拉伸变体、
//! 装配件都不经 cmap),而 gpui 的 `GlyphId` 构造器是 crate 私有的;位图
//! 路线反而让两条壳共享唯一一支栅格化器,公式像素级同源。
//!
//! 失败合同与旧壳一致:编译失败/超预算/缩到 [`MIN_READABLE_MATH_PX`] 之下
//! 的公式回退为源码文本(组件库侧代码样式),不撑破阅读列。
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use gpui::{
App, AvailableSpace, Bounds, Corners, Element, ElementId, GlobalElementId, InspectorElementId,
IntoElement, LayoutId, Pixels, Rgba, ShapedLine, SharedString, Size, Style, Window, point, px,
size,
};
use image::Frame;
use crate::math::layout::MathLayout;
use crate::math::rasterizer::MathGlyphRasterizer;
use crate::math::{DEFAULT_LIMITS, MIN_READABLE_MATH_PX, compile_formula};
/// 探针编译字号:注册的渲染闭包用它判定"这条公式能否编译",失败即让
/// 组件库走源码文本回退。解析/预算类失败与字号无关,任意正值等价。
const PROBE_PX: f32 = 16.0;
/// 布局缓存条数上限。键含字号与 DPI,同一公式 fit 前后各占一条;超限
/// 整体清空,单条重建是亚毫秒级 compile,不值得上 LRU。
const MAX_LAYOUTS: usize = 1024;
/// 公式位图缓存的字节预算;超限整体清空,可见公式下一帧按需重建。
const IMAGE_BUDGET_BYTES: usize = 32 * 1024 * 1024;
/// 单条公式位图的物理边长/字节上限;超限回退源码文本,与旧壳"fit 不进
/// 列宽即回退"同一精神,防止病态长公式吃掉显存。
const MAX_IMAGE_EDGE_PX: u32 = 8192;
const MAX_IMAGE_BYTES: usize = 24 * 1024 * 1024;
/// 位图四周的出血余量(物理 px):字形位图的 bearing 可以越出公式度量
/// 包围盒 1–2px(`GLYPH_PADDING` + 抗锯齿),出血兜住后仍越界的按行裁剪。
const CANVAS_PAD: u32 = 2;
/// 与旧壳 `fit_math_run` 相同的收缩余量:布局对字号线性,留 2% 吸收
/// 取整误差,保证最右侧抗锯齿像素不越出阅读列。
const FIT_MARGIN: f32 = 0.98;
/// 注册 TextView 的公式渲染器;`gpui_shell::init` 调用一次。
pub fn register(cx: &mut App) {
cx.set_global(MathAssets::new());
gpui_component::text::set_math_renderer(cx, |spec, _, cx| {
let assets = cx.global_mut::<MathAssets>();
if assets.rasterizer.is_none() {
return None;
}
// 探针编译:失败的公式仍是文档文本,交回组件库按代码样式排版。
assets.layout(&spec.source, spec.display, PROBE_PX, 1.0)?;
Some(
MathView { source: spec.source.clone(), display: spec.display }.into_any_element(),
)
});
}
// ---- 全局缓存 ----
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct LayoutKey {
source: SharedString,
display: bool,
pixel_size_bits: u32,
pixels_per_point_bits: u32,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct ImageKey {
source: SharedString,
display: bool,
pixel_size_bits: u32,
raster_scale_bits: u32,
color: u32,
}
/// 位图内的定位信息(物理 px):paint 时把位图基线吸附到元素基线。
#[derive(Clone, Copy, Debug)]
struct ImageGeometry {
/// 位图顶边到公式基线的距离。
baseline: u32,
/// 位图左边相对公式盒左缘的外扩(出血)。
pad: u32,
width: u32,
height: u32,
}
pub(crate) struct MathAssets {
/// 字体解析失败时为 None:探针直接失败,所有公式回退源码文本。
rasterizer: Option<MathGlyphRasterizer>,
layouts: HashMap<LayoutKey, Option<Arc<MathLayout>>>,
images: HashMap<ImageKey, Option<(Arc<gpui::RenderImage>, ImageGeometry)>>,
image_bytes: usize,
}
impl gpui::Global for MathAssets {}
impl MathAssets {
fn new() -> Self {
Self {
rasterizer: MathGlyphRasterizer::new().ok(),
layouts: HashMap::new(),
images: HashMap::new(),
image_bytes: 0,
}
}
/// 编排(含负缓存:失败公式不逐帧重试)。
pub(crate) fn layout(
&mut self,
source: &SharedString,
display: bool,
pixel_size: f32,
pixels_per_point: f32,
) -> Option<Arc<MathLayout>> {
let key = LayoutKey {
source: source.clone(),
display,
pixel_size_bits: pixel_size.to_bits(),
pixels_per_point_bits: pixels_per_point.to_bits(),
};
if !self.layouts.contains_key(&key) {
if self.layouts.len() >= MAX_LAYOUTS {
self.layouts.clear();
}
let compiled =
compile_formula(source.as_ref(), display, pixel_size, pixels_per_point, DEFAULT_LIMITS)
.ok()
.map(Arc::new);
self.layouts.insert(key.clone(), compiled);
}
self.layouts.get(&key).and_then(Clone::clone)
}
/// 旧壳 `fit_math_run` 的等价物:超宽公式按线性比例缩字号,缩到
/// [`MIN_READABLE_MATH_PX`] 之下就放弃(调用方回退源码文本)。
fn fit(
&mut self,
source: &SharedString,
display: bool,
pixel_size: f32,
pixels_per_point: f32,
max_width: f32,
) -> Option<(Arc<MathLayout>, f32)> {
let base = self.layout(source, display, pixel_size, pixels_per_point)?;
if base.metrics.width <= max_width {
return Some((base, pixel_size));
}
let fitted_size = pixel_size * (max_width / base.metrics.width) * FIT_MARGIN;
if fitted_size < MIN_READABLE_MATH_PX {
return None;
}
let fitted = self.layout(source, display, fitted_size, pixels_per_point)?;
(fitted.metrics.width <= max_width).then_some((fitted, fitted_size))
}
/// 公式位图(含负缓存:超限公式不逐帧重试合成)。
pub(crate) fn image(
&mut self,
source: &SharedString,
display: bool,
pixel_size: f32,
pixels_per_point: f32,
raster_scale: f32,
color: Rgba,
) -> Option<(Arc<gpui::RenderImage>, ImageGeometry)> {
let color_bits = ((color.r * 255.0) as u32) << 16
| ((color.g * 255.0) as u32) << 8
| (color.b * 255.0) as u32;
let key = ImageKey {
source: source.clone(),
display,
pixel_size_bits: pixel_size.to_bits(),
raster_scale_bits: raster_scale.to_bits(),
color: color_bits,
};
if let Some(cached) = self.images.get(&key) {
return cached.clone();
}
let layout = self.layout(source, display, pixel_size, pixels_per_point)?;
let composed = self
.rasterizer
.as_ref()
.and_then(|rasterizer| compose_image(rasterizer, &layout, raster_scale, color));
if let Some((_, geometry)) = &composed {
let bytes = geometry.width as usize * geometry.height as usize * 4;
if self.image_bytes.saturating_add(bytes) > IMAGE_BUDGET_BYTES {
self.images.clear();
self.image_bytes = 0;
}
self.image_bytes += bytes;
}
self.images.insert(key, composed.clone());
composed
}
}
/// 把整条公式(字形 + 分数线等矩形)按物理像素合成为一张直通 alpha 的
/// BGRA 位图。字形位图原点吸附整数物理像素(旧壳同款),advance 保持
/// 全精度。
fn compose_image(
rasterizer: &MathGlyphRasterizer,
layout: &MathLayout,
raster_scale: f32,
color: Rgba,
) -> Option<(Arc<gpui::RenderImage>, ImageGeometry)> {
let content_width = (layout.metrics.width * raster_scale).ceil().max(1.0) as u32;
let ascent = (layout.metrics.height * raster_scale).ceil().max(0.0) as u32;
let descent = (layout.metrics.depth * raster_scale).ceil().max(0.0) as u32;
let width = content_width.checked_add(CANVAS_PAD * 2)?;
let height = ascent.checked_add(descent)?.checked_add(CANVAS_PAD * 2)?.max(1);
if width > MAX_IMAGE_EDGE_PX || height > MAX_IMAGE_EDGE_PX {
return None;
}
let bytes = width as usize * height as usize * 4;
if bytes > MAX_IMAGE_BYTES {
return None;
}
let baseline = (ascent + CANVAS_PAD) as i64;
// 先在 alpha 平面上合成(Porter-Duff over),最后一次性染色。
let mut coverage = vec![0u8; width as usize * height as usize];
for op in &layout.glyphs {
let glyph = rasterizer.rasterize(op.glyph_id, op.pixel_size * raster_scale).ok()?;
if glyph.width == 0 || glyph.height == 0 {
continue;
}
let origin_x = (op.x * raster_scale).round() as i64 + glyph.left as i64 + CANVAS_PAD as i64;
let origin_y = baseline + (op.baseline_y * raster_scale).round() as i64 - glyph.top as i64;
blit_over(
&mut coverage,
width,
height,
origin_x,
origin_y,
glyph.width as u32,
glyph.height as u32,
|x, y| glyph.rgba[(y * glyph.width as usize + x) * 4 + 3],
);
}
for rule in &layout.rules {
let x0 = (rule.x * raster_scale).round() as i64 + CANVAS_PAD as i64;
let y0 = baseline + (rule.y * raster_scale).round() as i64;
let w = (rule.width * raster_scale).round().max(1.0) as u32;
let h = (rule.height * raster_scale).round().max(1.0) as u32;
blit_over(&mut coverage, width, height, x0, y0, w, h, |_, _| u8::MAX);
}
// gpui 图像管线吃直通 alpha 的 BGRA(与 DirectWrite 字形同一约定)。
let mut bgra = vec![0u8; bytes];
let (r, g, b) = (
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
);
for (pixel, alpha) in bgra.chunks_exact_mut(4).zip(&coverage) {
pixel[0] = b;
pixel[1] = g;
pixel[2] = r;
pixel[3] = *alpha;
}
let buffer = image::RgbaImage::from_raw(width, height, bgra)?;
let geometry =
ImageGeometry { baseline: baseline as u32, pad: CANVAS_PAD, width, height };
Some((Arc::new(gpui::RenderImage::new([Frame::new(buffer)])), geometry))
}
/// alpha 平面上的 Porter-Duff over 合成;目标越界的像素按行列裁剪。
#[allow(clippy::too_many_arguments)]
fn blit_over(
canvas: &mut [u8],
canvas_width: u32,
canvas_height: u32,
origin_x: i64,
origin_y: i64,
width: u32,
height: u32,
sample: impl Fn(usize, usize) -> u8,
) {
for row in 0..height as i64 {
let y = origin_y + row;
if y < 0 || y >= canvas_height as i64 {
continue;
}
for column in 0..width as i64 {
let x = origin_x + column;
if x < 0 || x >= canvas_width as i64 {
continue;
}
let source = sample(column as usize, row as usize) as u32;
if source == 0 {
continue;
}
let target = &mut canvas[y as usize * canvas_width as usize + x as usize];
let existing = *target as u32;
*target = (existing + source * (255 - existing) / 255).min(255) as u8;
}
}
}
// ---- 公式元素 ----
/// 一条公式的 GPUI 元素。行内公式由组件库放进换行 flex 行(底边对齐),
/// 自身用底部留白把公式基线补齐到文本基线;块级公式由组件库水平居中。
struct MathView {
source: SharedString,
display: bool,
}
/// measure 闭包与 paint 之间的当帧交接。taffy 可能以不同可用宽度多次
/// 调用 measure,槽里保存最后一次结果;paint 用实际 bounds 核对,不符
/// (罕见)就按 bounds 宽度重新 fit,宁可略小也不越界。
#[derive(Default)]
enum FitSlot {
#[default]
Pending,
Math {
layout: Arc<MathLayout>,
pixel_size: f32,
/// 元素底边到公式基线的距离(行内基线补偿;块级为 depth)。
baseline_from_bottom: f32,
},
/// fit 放弃后的退化形态:单行源码文本(探针已保证可编译,只有
/// 极窄列会走到这里)。
Text(ShapedLine),
}
impl IntoElement for MathView {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for MathView {
type RequestLayoutState = Rc<RefCell<FitSlot>>;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
_: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let slot: Rc<RefCell<FitSlot>> = Rc::default();
let text_style = window.text_style();
let rem_size = window.rem_size();
let font_size = text_style.font_size.to_pixels(rem_size);
let line_height = f32::from(text_style.line_height_in_pixels(rem_size));
let run = text_style.to_run(self.source.len());
// 文本行盒里基线到底边的距离 = descent + 半行距;行内公式用它把
// 自己的基线抬到与同行文本一致(旧壳 push_with_math 的基线合同)。
let probe = window.text_system().shape_line("M".into(), font_size, &[text_style.to_run(1)], None);
let text_baseline_from_bottom = f32::from(probe.descent)
+ (line_height - f32::from(probe.ascent) - f32::from(probe.descent)).max(0.0) / 2.0;
let source = self.source.clone();
let display = self.display;
let nominal_px = f32::from(font_size);
let pixels_per_point = window.scale_factor();
let measure_slot = slot.clone();
let style = Style { flex_shrink: 0.0, ..Style::default() };
let layout_id = window.request_measured_layout(style, move |_, available, window, cx| {
let max_width = match available.width {
AvailableSpace::Definite(width) => f32::from(width).max(8.0),
AvailableSpace::MinContent | AvailableSpace::MaxContent => f32::INFINITY,
};
let assets = cx.global_mut::<MathAssets>();
match assets.fit(&source, display, nominal_px, pixels_per_point, max_width) {
Some((layout, pixel_size)) => {
let bottom_pad = if display {
0.0
} else {
(text_baseline_from_bottom - layout.metrics.depth).max(0.0)
};
let width = layout.metrics.width;
let height = layout.metrics.height + layout.metrics.depth + bottom_pad;
let baseline_from_bottom = layout.metrics.depth + bottom_pad;
*measure_slot.borrow_mut() =
FitSlot::Math { layout, pixel_size, baseline_from_bottom };
size(px(width), px(height))
},
None => {
let line = window.text_system().shape_line(
source.clone(),
px(nominal_px),
std::slice::from_ref(&run),
None,
);
let width = f32::from(line.width).min(max_width.max(8.0));
*measure_slot.borrow_mut() = FitSlot::Text(line);
size(px(width), px(line_height))
},
}
});
(layout_id, slot)
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Window,
_: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
slot: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let text_style = window.text_style();
let line_height = text_style.line_height_in_pixels(window.rem_size());
match &*slot.borrow() {
FitSlot::Pending => {},
FitSlot::Text(line) => {
let _ = line.paint(bounds.origin, line_height, window, cx);
},
FitSlot::Math { layout, pixel_size, baseline_from_bottom } => {
let bounds_width = f32::from(bounds.size.width);
// taffy 最终宽度与最后一次 measure 不一致(罕见):按实际
// bounds 重新 fit,保证不越界绘制。
if layout.metrics.width > bounds_width + 0.5 {
let nominal = f32::from(text_style.font_size.to_pixels(window.rem_size()));
let scale_factor = window.scale_factor();
let assets = cx.global_mut::<MathAssets>();
let Some((layout, pixel_size)) = assets.fit(
&self.source,
self.display,
nominal,
scale_factor,
bounds_width,
) else {
return;
};
let baseline = bounds.bottom() - px(layout.metrics.depth);
self.paint_math(layout.as_ref(), pixel_size, baseline, bounds, window, cx);
return;
}
let baseline = bounds.bottom() - px(*baseline_from_bottom);
let layout = layout.clone();
self.paint_math(layout.as_ref(), *pixel_size, baseline, bounds, window, cx);
},
}
}
}
impl MathView {
/// 位图贴到基线上(物理像素吸附),数学字体缺字的字符用 gpui 文本
/// 按各自字号补画在同一条基线上。
fn paint_math(
&self,
layout: &MathLayout,
pixel_size: f32,
baseline: Pixels,
bounds: Bounds<Pixels>,
window: &mut Window,
cx: &mut App,
) {
let text_style = window.text_style();
let color = Rgba::from(text_style.color);
paint_formula_image(
&self.source,
self.display,
pixel_size,
window.scale_factor(),
bounds.left(),
baseline,
color,
window,
cx,
);
for op in &layout.text {
let mut buffer = [0u8; 4];
let text: SharedString = op.character.encode_utf8(&mut buffer).to_string().into();
let run = text_style.to_run(text.len());
let line =
window.text_system().shape_line(text, px(op.pixel_size), std::slice::from_ref(&run), None);
let origin = point(
bounds.left() + px(op.x),
baseline + px(op.baseline_y) - line.ascent,
);
let _ = line.paint(origin, line.ascent + line.descent, window, cx);
}
}
}
/// 把一条公式的位图贴到指定基线(物理像素吸附),文档元素与终端覆盖层
/// 共用。位图缓存/合成走 [`MathAssets`];数学字体缺字的 text ops 由调用
/// 方按各自的裁剪合同补画。
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_formula_image(
source: &SharedString,
display: bool,
pixel_size: f32,
pixels_per_point: f32,
left: Pixels,
baseline: Pixels,
color: Rgba,
window: &mut Window,
cx: &mut App,
) {
let raster_scale = window.scale_factor();
let assets = cx.global_mut::<MathAssets>();
let Some((render_image, geometry)) =
assets.image(source, display, pixel_size, pixels_per_point, raster_scale, color)
else {
return;
};
// 位图物理像素与屏幕 1:1:原点在物理网格上取整后再折回逻辑坐标。
let left_physical = (f32::from(left) * raster_scale).round();
let baseline_physical = (f32::from(baseline) * raster_scale).round();
let origin = point(
px((left_physical - geometry.pad as f32) / raster_scale),
px((baseline_physical - geometry.baseline as f32) / raster_scale),
);
let image_size = size(
px(geometry.width as f32 / raster_scale),
px(geometry.height as f32 / raster_scale),
);
let _ = window.paint_image(
Bounds::new(origin, image_size),
Corners::default(),
render_image,
0,
false,
);
}
+15
View File
@@ -10,16 +10,20 @@
//! - `NEBULA_GPUI_SHELL=1`(需 gpui-shell 构建):spike 形态——GPUI 跑在
//! 专用线程,与 winit 旧壳同进程并存,仅用于双运行时验证,P3 完成后移除。
pub mod code_tab;
pub mod config;
pub mod doc_tabs;
pub mod math_view;
pub mod prelude;
pub mod session_restore;
pub mod settings_pane;
pub mod ssh_hosts;
pub mod ssh_settings;
pub mod terminal;
pub mod theme;
pub mod toast;
pub mod wallpaper;
pub mod widgets;
pub mod workspace;
use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size};
@@ -55,6 +59,9 @@ fn init(cx: &mut App) {
// 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。
gpui_component::init(cx);
// TextView 的公式渲染钩子:旧壳数学管线(compile → 栅格化)接入组件库
// 的 markdown 渲染;不注册时公式回退为源码文本。
math_view::register(cx);
theme::apply_chrome_theme(cx);
terminal::init(cx);
workspace::init(cx);
@@ -124,6 +131,14 @@ fn open_main_window(
cx.open_window(options, move |window, cx| {
let workspace = cx.new(|cx| NebulaWorkspace::new(window, ai_events, cx));
// 调试/验收后门:启动即打开指定文档(见 open_document_at_startup)。
if let Ok(path) = std::env::var("NEBULA_GPUI_OPEN_DOC")
&& !path.is_empty()
{
workspace.update(cx, |workspace, cx| {
workspace.open_document_at_startup(path.into(), window, cx);
});
}
cx.new(|cx| Root::new(workspace, window, cx))
})
.expect("failed to open Nebula GPUI window");
+15
View File
@@ -30,3 +30,18 @@ pub use gpui_component::{
pub use gpui_component::IndexPath;
pub use gpui_component::WindowExt as _;
use gpui::{Window, px};
use gpui_component::PixelsExt as _;
/// 旧壳 `draw_confirm_modal`:`(win_w - box_w)/2`、`(win_h - box_h)/2`。
/// 组件库 Dialog 水平已经居中,垂直默认贴在 `height/10`。不改 gpui-component,
/// 只把 `margin_top` 调到窗口中线。关闭/粘贴确认约 200px 高;入场动画结束
/// 时还会把 top 再加 30px,所以这里先减去这段位移,落点才是正中。
pub fn center_confirm_dialog(dialog: Dialog, window: &Window) -> Dialog {
let height = window.viewport_size().height.as_f32();
const DIALOG_H: f32 = 200.0;
const SLIDE: f32 = 30.0;
let top = ((height - DIALOG_H) / 2.0 - SLIDE).max(16.0);
dialog.margin_top(px(top))
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+243 -19
View File
@@ -22,7 +22,7 @@ use nebula_terminal::grid::Dimensions as _;
use nebula_terminal::render::{RenderSnapshot, SnapshotConfig, boxdraw};
#[cfg(windows)]
use nebula_terminal::term::TermMode;
use nebula_terminal::vte::ansi::CursorShape;
use nebula_terminal::vte::ansi::{Color, CursorShape};
use super::view::TerminalView;
@@ -187,6 +187,71 @@ impl Element for TerminalElement {
view.refresh_suggestion_from_snapshot(prompt_line, suggest_anchor);
});
let overrides = snap.color_overrides;
let (theme_anchor, theme_is_light) = themed_anchor(&theme, cx);
let host_cursor_follows_theme = is_default_host_cursor(&theme);
let app_cursor = snap.cursor.as_ref().filter(|cursor| {
cursor.shape == CursorShape::Hidden
&& crate::display::content::is_application_cursor_glyph(
cursor.cell_ch,
cursor.cell_flags,
crate::display::content::cell_background_is_fixed(cursor.cell_bg, &overrides),
)
});
let themed_block = host_cursor_follows_theme
&& snap
.cursor
.as_ref()
.is_some_and(|cursor| matches!(cursor.shape, CursorShape::Block));
// 公式覆盖层:与本帧快照同一份网格状态(term 锁内扫描 + 计划),
// 探测/fit/几何合同全部由共享的 display::terminal_math 裁定。
let scale_factor = window.scale_factor();
let math_frame = {
let cell_w = layout.cell_width.as_f32();
let line_h = layout.line_height.as_f32();
let font_px = f32::from(self.view.read(cx).font_size);
let fg = self.view.read(cx).palette.foreground;
let foreground = crate::display::color::Rgb::new(
(fg.r * 255.0).round() as u8,
(fg.g * 255.0).round() as u8,
(fg.b * 255.0).round() as u8,
);
let cursor = snap.cursor.as_ref().map(|c| (c.row as usize, c.col as usize));
let has_selection = !snap.selection_runs.is_empty();
let bg_runs = &snap.bg_runs;
self.view.update(cx, |view, _| {
let Some(term) = view.session.as_ref().map(|session| session.term.clone()) else {
return super::math_overlay::MathFrame::default();
};
view.math.observe_program(view.running_program.as_deref());
let size_info = crate::display::SizeInfo::new(
cell_w * layout.cols as f32,
line_h * layout.rows as f32,
cell_w,
line_h,
0.0,
0.0,
false,
);
let term = term.lock();
view.math.plan_frame(
&term,
&size_info,
has_selection,
cursor,
foreground,
font_px,
scale_factor,
|row, start, end| {
bg_runs.iter().any(|run| {
run.row as usize == row
&& (run.start as usize) < end
&& start < (run.end as usize)
})
},
)
})
};
let cell_rect = |row: usize, start: usize, count: usize| -> Bounds<Pixels> {
Bounds::new(
@@ -199,29 +264,94 @@ impl Element for TerminalElement {
};
for run in &snap.bg_runs {
let color = theme.resolve(run.color, &overrides, false);
window.paint_quad(fill(
cell_rect(run.row as usize, run.start as usize, (run.end - run.start) as usize),
color,
));
let mut paint = |start: u16, end: u16, color: Color| {
if start >= end {
return;
}
let color = theme.resolve(color, &overrides, false);
window.paint_quad(fill(
cell_rect(run.row as usize, start as usize, (end - start) as usize),
color,
));
};
if let Some(cursor) = app_cursor {
if run.row == cursor.row {
let skip_end = cursor.col.saturating_add(if cursor.wide { 2 } else { 1 });
if run.start < skip_end && cursor.col < run.end {
paint(run.start, cursor.col, run.color);
paint(skip_end, run.end, run.color);
continue;
}
}
}
paint(run.start, run.end, run.color);
}
let selection_fill = if is_default_selection(&theme) {
let alpha = if theme_is_light {
crate::display::ui::tokens::terminal_feedback::SELECTION_ALPHA_LIGHT
} else {
crate::display::ui::tokens::terminal_feedback::SELECTION_ALPHA_DARK
};
rgba_rgb(theme_anchor, alpha)
} else {
theme.selection
};
for run in &snap.selection_runs {
window.paint_quad(fill(
cell_rect(run.row as usize, run.start as usize, (run.end - run.start) as usize),
theme.selection,
selection_fill,
));
}
// 聚焦时的块状光标垫在文字下面,让字形以反色盖在上面。
// 聚焦时的块状光标:旧壳默认主题走半透明 theme_anchor(浅色 0.20),
// 叠在格子/壁纸上,不反色文字;用户显式配置光标才用实心色。
if let Some(cursor) = &snap.cursor {
if focused && cursor_visible && matches!(cursor.shape, CursorShape::Block) {
let width = if cursor.wide { 2 } else { 1 };
let fill_color = if host_cursor_follows_theme {
let alpha = if theme_is_light {
crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_LIGHT
} else {
crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_DARK
};
rgba_rgb(theme_anchor, alpha)
} else {
theme.cursor
};
window.paint_quad(fill(
cell_rect(cursor.row as usize, cursor.col as usize, width),
theme.cursor,
fill_color,
));
}
}
// CC/Codex:DECSCUSR Hidden 后自己画反色空格。旧壳把那格从反色黑
// 改成叠在主题底上的 theme_anchor;块元素则只换前景,保留字形。
let app_cursor_color = app_cursor.map(|cursor| {
let alpha = if theme_is_light {
crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_LIGHT
} else {
crate::display::ui::tokens::terminal_feedback::BLOCK_CURSOR_ALPHA_DARK
};
let base = if cursor.cell_ch == ' ' {
rgb_from_rgba(theme.background)
} else {
rgb_from_rgba(theme.resolve(cursor.cell_bg, &overrides, false))
};
let (color, _) =
crate::display::content::composite_overlay(theme_anchor, alpha, base, 1.0);
rgba_rgb(color, 1.0)
});
if let Some(cursor) = app_cursor {
if cursor.cell_ch == ' ' {
if let Some(color) = app_cursor_color {
let width = if cursor.wide { 2 } else { 1 };
window.paint_quad(fill(
cell_rect(cursor.row as usize, cursor.col as usize, width),
color,
));
}
}
}
let (font, bold_font, italic_font, bold_italic_font, font_size) = {
let view = self.view.read(cx);
@@ -233,16 +363,28 @@ impl Element for TerminalElement {
view.font_size,
)
};
let pick_font = |bold: bool, italic: bool| match (bold, italic) {
(false, false) => font.clone(),
(true, false) => bold_font.clone(),
(false, true) => italic_font.clone(),
(true, true) => bold_italic_font.clone(),
// 全宽(CJK 等)bold run 的字形策略(设置 `cjk_bold_regular`,默认
// 开):bold 只提亮颜色(resolve 已做),字形保持 Regular——旧壳
// `wide_bold_use_regular` 的同义移植。CJK 假粗体在小字号糊成一团,
// 这是旧壳早已裁定过的取舍。
let cjk_bold_regular = cx
.try_global::<crate::gpui_shell::config::Settings>()
.map(|settings| settings.cjk_bold_regular)
.unwrap_or(true);
let pick_font = |bold: bool, italic: bool, wide: bool| {
let bold = bold && !(wide && cjk_bold_regular);
match (bold, italic) {
(false, false) => font.clone(),
(true, false) => bold_font.clone(),
(false, true) => italic_font.clone(),
(true, true) => bold_italic_font.clone(),
}
};
// 聚焦块光标下的字形反色(合同保证该格是独立段)。
let cursor_inverts = |row: u16, col: u16| {
focused
&& cursor_visible
&& !themed_block
&& snap.cursor.as_ref().is_some_and(|c| {
matches!(c.shape, CursorShape::Block) && c.row == row && c.col == col
})
@@ -253,7 +395,11 @@ impl Element for TerminalElement {
// —— CJK 字体下的框线错位就此根治。
let scale = window.scale_factor();
for glyph in &snap.box_glyphs {
let fg = if cursor_inverts(glyph.row, glyph.col) {
let fg = if app_cursor
.is_some_and(|cursor| cursor.row == glyph.row && cursor.col == glyph.col)
{
app_cursor_color.unwrap_or_else(|| theme.resolve(glyph.fg, &overrides, glyph.bold))
} else if cursor_inverts(glyph.row, glyph.col) {
theme.background
} else {
theme.resolve(glyph.fg, &overrides, glyph.bold)
@@ -302,6 +448,11 @@ impl Element for TerminalElement {
// 光标反色在这里只是换色,不影响任何字形位置。
for seg in &snap.segments {
for cell in &seg.cells {
// 被公式覆盖的源格不画原文(公式直接落在卡底上,与旧壳
// CoverageMask 合同一致;计划失败的公式不进掩码、原文保留)。
if math_frame.covers(seg.row as usize, cell.col as usize) {
continue;
}
let fg: Hsla = if cursor_inverts(seg.row, cell.col) {
theme.background.into()
} else {
@@ -317,7 +468,7 @@ impl Element for TerminalElement {
.then(|| gpui::StrikethroughStyle { thickness: px(1.0), color: Some(fg) });
let run = TextRun {
len: cell.text.len(),
font: pick_font(cell.bold, cell.italic),
font: pick_font(cell.bold, cell.italic, seg.wide),
color: fg,
background_color: None,
underline,
@@ -339,6 +490,19 @@ impl Element for TerminalElement {
}
}
// 公式位图画在格子文本之后、装饰(ghost/光标/滚动条)之前,
// 与旧壳 draw_rects → draw_overlays 的次序一致。
if !math_frame.is_empty() {
super::math_overlay::paint_frame(
&math_frame,
bounds.origin,
(layout.cell_width.as_f32(), layout.line_height.as_f32()),
scale_factor,
window,
cx,
);
}
if let Some(cursor) = &snap.cursor {
// 内联 ghost 余量与弹窗补全:结果由 view 在本帧 render 时算好
// (引擎与旧壳共享),元素只负责画。IME 组合中让位给 preedit,
@@ -401,6 +565,16 @@ impl Element for TerminalElement {
// ghost 从光标单元格起笔;GPUI 的字形抗锯齿可能盖住同一位置的
// beam/underline 边缘,所以非块状光标的前景必须在 ghost 后补画。
// 聚焦块状光标仍在文字层下方填充,保持旧壳的反色合同。
let stroke = if host_cursor_follows_theme {
let alpha = if theme_is_light {
crate::display::ui::tokens::terminal_feedback::STROKE_CURSOR_ALPHA_LIGHT
} else {
crate::display::ui::tokens::terminal_feedback::STROKE_CURSOR_ALPHA_DARK
};
rgba_rgb(theme_anchor, alpha)
} else {
theme.cursor
};
let width = if cursor.wide { 2 } else { 1 };
let rect = cell_rect(cursor.row as usize, cursor.col as usize, width);
if !focused || cursor_visible {
@@ -408,12 +582,12 @@ impl Element for TerminalElement {
(true, CursorShape::Block) => {}, // 已在文字层下方填充
(false, CursorShape::Block | CursorShape::HollowBlock)
| (true, CursorShape::HollowBlock) => {
window.paint_quad(outline(rect, theme.cursor, gpui::BorderStyle::Solid));
window.paint_quad(outline(rect, stroke, gpui::BorderStyle::Solid));
},
(_, CursorShape::Beam) => {
window.paint_quad(fill(
Bounds::new(rect.origin, size(px(2.0), layout.line_height)),
theme.cursor,
stroke,
));
},
(_, CursorShape::Underline) => {
@@ -422,7 +596,7 @@ impl Element for TerminalElement {
point(rect.origin.x, rect.origin.y + layout.line_height - px(2.0)),
size(rect.size.width, px(2.0)),
),
theme.cursor,
stroke,
));
},
(_, CursorShape::Hidden) => {},
@@ -761,6 +935,56 @@ fn paint_completion_popup(
}
}
fn rgba_channels(color: Rgba) -> (u8, u8, u8) {
(
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
)
}
fn is_default_host_cursor(palette: &super::colors::Palette) -> bool {
rgba_channels(palette.cursor) == default_cursor_rgb()
}
fn is_default_selection(palette: &super::colors::Palette) -> bool {
rgba_channels(palette.selection) == default_cursor_rgb()
&& (palette.selection.a - 0.60).abs() < 0.05
}
fn default_cursor_rgb() -> (u8, u8, u8) {
match crate::config::color::NEBULA_DEFAULT_CURSOR.background {
crate::display::color::CellRgb::Rgb(rgb) => (rgb.r, rgb.g, rgb.b),
_ => (0x49, 0x4d, 0x72),
}
}
fn rgb_from_rgba(color: Rgba) -> crate::display::color::Rgb {
let (r, g, b) = rgba_channels(color);
crate::display::color::Rgb::new(r, g, b)
}
fn rgba_rgb(color: crate::display::color::Rgb, alpha: f32) -> Rgba {
Rgba {
r: f32::from(color.r) / 255.0,
g: f32::from(color.g) / 255.0,
b: f32::from(color.b) / 255.0,
a: alpha,
}
}
fn themed_anchor(palette: &super::colors::Palette, cx: &App) -> (crate::display::color::Rgb, bool) {
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
// ANSI magenta = index 5;旧壳 `display.colors[NamedColor::Magenta]`。
let magenta = rgb_from_rgba(palette.ansi[5]);
let mix = if sk.is_light {
crate::display::ui::tokens::terminal_feedback::ANCHOR_NEUTRAL_MIX_LIGHT
} else {
crate::display::ui::tokens::terminal_feedback::ANCHOR_NEUTRAL_MIX_DARK
};
(crate::display::content::mix_rgb(magenta, sk.ink_dim, mix), sk.is_light)
}
#[cfg(test)]
mod tests {
use super::completion_popup_layout;
@@ -81,10 +81,179 @@ fn ctrl_char(c: char) -> Option<u8> {
}
}
/// ConPTY Win32 input mode 是否当家。
///
/// 子进程请求的 Win32 记录是**回落**协议,不是与 kitty 并列的第二编码器:
/// 一旦子进程要过 kitty 键盘标志,那份标志就是线上合同,压过 DECSET 9001
/// (口径逐字同旧壳 `input::terminal_input::use_win32_input_mode`)。
fn use_win32_input_mode(mode: &TermMode) -> bool {
mode.contains(TermMode::WIN32_INPUT_MODE)
&& !mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL)
}
/// GPUI 键名 → Win32 虚拟键码。
///
/// 旧壳从 winit fork 的 `RawKeyEventInfo` 直接拿到系统报的 VK;GPUI 的
/// `Keystroke` 只有键名,所以这里按名字反查。表只覆盖**编码器会处理的键**
/// (控制键、方向、功能键)——可打印字符在 GPUI 走 IME 管道,不经这里。
#[cfg(windows)]
fn virtual_key_of(key: &str) -> Option<u16> {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LEFT,
VK_NEXT, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP,
};
let vk = match key {
"escape" => VK_ESCAPE,
"enter" => VK_RETURN,
"tab" => VK_TAB,
"backspace" => VK_BACK,
"space" => VK_SPACE,
"up" => VK_UP,
"down" => VK_DOWN,
"left" => VK_LEFT,
"right" => VK_RIGHT,
"home" => VK_HOME,
"end" => VK_END,
"insert" => VK_INSERT,
"delete" => VK_DELETE,
"pageup" => VK_PRIOR,
"pagedown" => VK_NEXT,
// F1..F24 在 VK 表里连号。
key if key.starts_with('f') => {
let index: u16 = key[1..].parse().ok()?;
if !(1..=24).contains(&index) {
return None;
}
VK_F1 + index - 1
},
// 单个字符(Ctrl+C 一族):ASCII 字母数字的 VK 就是它的大写码点。
key => {
let mut chars = key.chars();
let (Some(c), None) = (chars.next(), chars.next()) else { return None };
if !c.is_ascii_alphanumeric() {
return None;
}
c.to_ascii_uppercase() as u16
},
};
Some(vk)
}
/// 控制键必须携带真实 `KEY_EVENT_RECORD` 的字符值(Esc=0x1B、Enter=0x0D、
/// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0
/// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与
/// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。
#[cfg(windows)]
fn unicode_char_of(ks: &Keystroke) -> u16 {
// 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。
// `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。
if let Some(text) = ks.key_char.as_deref() {
let mut units = text.encode_utf16();
if let (Some(first), None) = (units.next(), units.next()) {
if first != 0 {
return first;
}
}
}
match ks.key.as_str() {
"escape" => 0x1b,
"enter" => b'\r' as u16,
"tab" => b'\t' as u16,
"backspace" => 0x08,
"space" => b' ' as u16,
_ => 0,
}
}
/// 一条 ConPTY Win32 input 记录:`CSI Vk;Sc;Uc;Kd;Cs;Rc_`。
///
/// 认不出 VK 的键返回 `None`,调用方回落到传统 VT 编码——宁可少一条记录,
/// 也不要编一个 Vk=0 的假记录,那会让子进程读到一个不存在的键。
#[cfg(windows)]
fn win32_input_record(ks: &Keystroke, key_down: bool) -> Option<Vec<u8>> {
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW};
const SHIFT_PRESSED: u32 = 0x0010;
const LEFT_ALT_PRESSED: u32 = 0x0002;
const LEFT_CTRL_PRESSED: u32 = 0x0008;
let vk = virtual_key_of(ks.key.as_str())?;
// 扫描码问系统要,不硬编码:非 US 布局与笔记本键盘上这张表并不通用。
let scan_code = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC) };
let mut control_key_state = 0u32;
if ks.modifiers.shift {
control_key_state |= SHIFT_PRESSED;
}
if ks.modifiers.alt {
control_key_state |= LEFT_ALT_PRESSED;
}
if ks.modifiers.control {
control_key_state |= LEFT_CTRL_PRESSED;
}
let key_down = u8::from(key_down);
Some(
format!(
"\x1b[{};{};{};{key_down};{};1_",
vk,
scan_code,
unicode_char_of(ks),
control_key_state
)
.into_bytes(),
)
}
/// GPUI 的终端视图只接到 key-down。旧壳对 9001 会再写一条 Kd=0 的抬起
/// (`keyboard.rs` 的 `key_release` + `escape_carries_its_control_character_both_directions`)。
/// 真实键盘也是 down+up:Codex 按 VK 看按下就够了,Claude Code / Ink 吃的是
/// OpenConsole 翻译出的字节流,缺抬起时 Esc 常常一个字节都到不了。
#[cfg(windows)]
fn win32_press_and_release(ks: &Keystroke) -> Option<Vec<u8>> {
let mut sequence = win32_input_record(ks, true)?;
sequence.extend(win32_input_record(ks, false)?);
Some(sequence)
}
/// 子进程要过 kitty 键盘标志时,Esc 必须是 `CSI 27u`,不是裸 `\x1b`。
/// 口径同旧壳 `kitty_disambiguate_escape_is_csi_27_u`。
fn kitty_escape(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
let kitty = mode.intersects(
TermMode::REPORT_ALL_KEYS_AS_ESC
| TermMode::DISAMBIGUATE_ESC_CODES
| TermMode::REPORT_EVENT_TYPES,
);
if !kitty || ks.key.as_str() != "escape" {
return None;
}
let param = modifier_param(ks);
Some(if param == 1 {
b"\x1b[27u".to_vec()
} else {
format!("\x1b[27;{param}u").into_bytes()
})
}
/// 返回 `None` 表示这次按键不由编码器处理(交给 IME/文本输入路径)。
pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
let mods = &ks.modifiers;
// ConPTY 是带 Win32 input mode 标志创建的(见 `tty::windows::conpty`),
// 子进程一发 DECSET 9001 就切到这份记录格式。旧壳在 `build_sequence`
// 开头做同样的分流;GPUI 此前一直只发传统 VT,于是裸 `\x1b` 要由
// OpenConsole 的翻译层反译成 KEY_EVENT_RECORD,而它会丢掉 uChar=0 的
// VK_ESCAPE——读字节流的应用(Claude Code)因此收不到 Esc。
#[cfg(windows)]
if use_win32_input_mode(mode) {
if let Some(record) = win32_press_and_release(ks) {
return Some(record);
}
}
if let Some(bytes) = kitty_escape(ks, mode) {
return Some(bytes);
}
match ks.key.as_str() {
"enter" => {
return Some(if mods.alt { b"\x1b\r".to_vec() } else { b"\r".to_vec() });
@@ -159,3 +328,74 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option<Vec<u8>> {
None
}
#[cfg(test)]
mod tests {
use super::*;
fn keystroke(key: &str) -> Keystroke {
Keystroke { modifiers: gpui::Modifiers::default(), key: key.to_owned(), key_char: None }
}
/// 传统 VT 路径(子进程没要过 DECSET 9001):Esc 就是裸 `\x1b`。
#[test]
fn escape_stays_a_bare_byte_on_the_legacy_vt_path() {
let mode = TermMode::default();
assert_eq!(encode(&keystroke("escape"), &mode), Some(b"\x1b".to_vec()));
}
/// Win32 input mode 下 Esc 必须走 `CSI Vk;Sc;Uc;Kd;Cs;Rc_`,且 Uc 是
/// 真实的 0x1B——填 0 会被 OpenConsole 1.22 的翻译层丢掉,读字节流的
/// 应用(Claude Code)就永远收不到 Esc。GPUI 没有 key-up 通道,所以
/// 一次按键要成对写出 down+up,形状对齐旧壳真实键盘。
#[cfg(windows)]
#[test]
fn escape_becomes_a_win32_record_carrying_its_real_char() {
let bytes = encode(&keystroke("escape"), &TermMode::WIN32_INPUT_MODE)
.expect("win32 模式下 Esc 必须编码");
let text = String::from_utf8(bytes).expect("记录是 ASCII");
let records: Vec<&str> = text.split_inclusive('_').filter(|s| !s.is_empty()).collect();
assert_eq!(records.len(), 2, "一次 Esc 必须是 down+up 两条记录:{text:?}");
let parse = |record: &str| -> Vec<String> {
let body = record
.strip_prefix("\x1b[")
.and_then(|s| s.strip_suffix('_'))
.expect("CSI … _");
body.split(';').map(str::to_owned).collect()
};
let down = parse(records[0]);
let up = parse(records[1]);
assert_eq!(down.len(), 6, "字段数固定为 Vk;Sc;Uc;Kd;Cs;Rc:{text:?}");
assert_eq!(down[0], "27", "VK_ESCAPE");
// 扫描码问系统要(布局相关),只断言它不是 0——0 意味着查询失败。
assert_ne!(down[1], "0", "扫描码不能为 0:{text:?}");
assert_eq!(down[2], "27", "uChar 必须是真实的 0x1B,不是 0");
assert_eq!(down[3], "1", "按下");
assert_eq!(down[4], "0", "无修饰");
assert_eq!(down[5], "1", "重复次数");
assert_eq!(&up[..3], &down[..3], "抬起与按下的 Vk/Sc/Uc 必须一致");
assert_eq!(up[3], "0", "抬起");
assert_eq!(up[4], down[4]);
assert_eq!(up[5], "1");
}
/// kitty 键盘标志是子进程明确要过的线上合同,压过 DECSET 9001
/// (口径同旧壳 `use_win32_input_mode` / `kitty_disambiguate_escape_is_csi_27_u`)。
#[test]
fn kitty_flags_take_precedence_over_win32_records() {
let mode = TermMode::WIN32_INPUT_MODE | TermMode::DISAMBIGUATE_ESC_CODES;
assert!(!use_win32_input_mode(&mode));
assert_eq!(encode(&keystroke("escape"), &mode), Some(b"\x1b[27u".to_vec()));
}
/// 认不出 VK 的键回落传统 VT,而不是编一条 Vk=0 的假记录。
#[cfg(windows)]
#[test]
fn unknown_keys_fall_back_instead_of_forging_a_record() {
assert_eq!(virtual_key_of("f99"), None);
assert_eq!(virtual_key_of("capslock"), None);
// 方向键在 win32 模式下仍要出记录(它们有确定的 VK)。
assert!(win32_input_record(&keystroke("up"), true).is_some());
}
}
@@ -0,0 +1,222 @@
//! GPUI 壳终端的公式覆盖层。
//!
//! 探测/持久化/fit/几何全部复用旧壳 `display::terminal_math`(单一权威,
//! 两壳像素级同源);本文件只做三件事:在 term 锁内组织一帧的扫描与
//! 绘制计划、把覆盖掩码交给格子绘制循环跳过源格、按计划用
//! `math_view` 的位图管线上屏。
//!
//! 与旧壳 `draw_pane` 相同的门控:alt screen / vi 模式 / 存在选区时整帧
//! 不覆盖(源码可正常选择复制);光标所在逻辑行的排除在 `scan_visible`
//! 内部完成。旧壳按 `RenderableCell` 背景 alpha 过滤围栏代码块,本壳的
//! 等价数据源是快照的 `bg_runs`(默认背景不产生 run)。
use std::sync::Arc;
use gpui::{App, Bounds, Pixels, Rgba, SharedString, Window, point, px, size};
use nebula_terminal::Term;
use nebula_terminal::event::EventListener;
use nebula_terminal::index::{Column, Point};
use nebula_terminal::term::TermMode;
use crate::display::SizeInfo;
use crate::display::color::Rgb;
use crate::display::terminal_math::{
self, CoverageMask, OverlayDrawPlan, TerminalMathState,
};
use crate::gpui_shell::math_view;
/// 每 pane 一份的覆盖层状态(`TerminalView` 持有)。内部的持久锚点/缓存
/// 语义(滚动、reflow、清屏失效)全部由共享的 [`TerminalMathState`] 决定。
#[derive(Default)]
pub struct MathOverlay {
state: TerminalMathState,
}
/// 一条通过几何计划的公式:源码 + 绘制计划(坐标系 = 网格局部逻辑 px)。
pub struct PlannedFormula {
source: Arc<str>,
plan: OverlayDrawPlan,
}
/// 一帧的覆盖产物:绘制清单 + 源格跳过掩码。
#[derive(Default)]
pub struct MathFrame {
formulas: Vec<PlannedFormula>,
coverage: CoverageMask,
}
impl MathFrame {
/// 该格是否被某条公式覆盖(覆盖格不画原文)。
pub fn covers(&self, row: usize, column: usize) -> bool {
!self.coverage.is_empty() && self.coverage.covers(Point::new(row, Column(column)))
}
pub fn is_empty(&self) -> bool {
self.formulas.is_empty()
}
}
impl MathOverlay {
/// shell 集成上报的前台程序(AI CLI 名单解锁行内 `$…$`,与旧壳同门)。
pub fn observe_program(&mut self, program: Option<&str>) {
self.state.observe_program(program);
}
/// 每帧的扫描 + 预编 + 几何计划。必须在 term 锁内调用。
///
/// `size` 以网格左上角为原点(padding = 0);`bg_covered` 回答"该行的
/// 列区间内是否存在非默认背景"(围栏代码过滤)。
#[allow(clippy::too_many_arguments)]
pub fn plan_frame<T: EventListener>(
&mut self,
term: &Term<T>,
size: &SizeInfo,
has_selection: bool,
cursor: Option<(usize, usize)>,
default_foreground: Rgb,
font_pixel_size: f32,
pixels_per_point: f32,
bg_covered: impl Fn(usize, usize, usize) -> bool,
) -> MathFrame {
// 旧壳 draw_pane 的整帧门控(搜索态本壳尚不存在,故不在此列)。
if term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI) || has_selection {
return MathFrame::default();
}
let allow_inline_dollar = self.state.inline_dollar_enabled();
let cursor = cursor.map(|(row, column)| Point::new(row, Column(column)));
let mut overlays = terminal_math::scan_visible(
&mut self.state,
term,
size,
// 本壳不走 RenderableCell 管线:fallback 重画与围栏过滤各有
// 等价实现(覆盖失败不跳格 / bg_runs 过滤)。
&[],
allow_inline_dollar,
cursor,
default_foreground,
);
// 围栏代码里的公式源码保持原文(旧壳按单元格背景 alpha 判定)。
overlays.retain(|overlay| {
!overlay.covered_ranges().any(|(row, start, end)| {
usize::try_from(row).is_ok_and(|row| bg_covered(row, start, end))
})
});
let prepared = terminal_math::prepare_overlays(
&mut self.state,
&overlays,
size,
font_pixel_size,
pixels_per_point,
);
let mut plans: Vec<Option<OverlayDrawPlan>> = Vec::with_capacity(overlays.len());
for (overlay, prepared) in overlays.iter().zip(&prepared) {
plans.push(prepared.as_ref().and_then(|prepared| {
terminal_math::plan_overlay_draw(
&mut self.state,
overlay,
prepared,
size,
pixels_per_point,
)
}));
}
// 掩码按"真的会画"的公式构建:计划失败的公式保留原文,不留洞。
let coverage = CoverageMask::build(&overlays, &plans);
let formulas = overlays
.iter()
.zip(&plans)
.filter_map(|(overlay, plan)| {
plan.map(|plan| PlannedFormula { source: overlay.source_arc(), plan })
})
.collect();
MathFrame { formulas, coverage }
}
}
/// 按计划绘制一帧的公式(在格子文本之后调用)。`origin` 是网格左上角的
/// 窗口坐标;`cell` 是 (宽, 高),text ops 的"整字符可见才画"合同用它。
pub fn paint_frame(
frame: &MathFrame,
origin: gpui::Point<Pixels>,
cell: (f32, f32),
pixels_per_point: f32,
window: &mut Window,
cx: &mut App,
) {
for formula in &frame.formulas {
let plan = &formula.plan;
let color = Rgba {
r: plan.foreground.r as f32 / 255.0,
g: plan.foreground.g as f32 / 255.0,
b: plan.foreground.b as f32 / 255.0,
a: 1.0,
};
let source: SharedString = formula.source.to_string().into();
// 裁剪跟随 bleed 预算(与旧壳 MathClip 同一矩形),交给 GPUI 的
// content mask 执行像素级裁剪。
let clip = Bounds::new(
point(origin.x + px(plan.clip_left), origin.y + px(plan.clip_top)),
size(
px((plan.clip_right - plan.clip_left).max(0.0)),
px((plan.clip_bottom - plan.clip_top).max(0.0)),
),
);
let left = origin.x + px(plan.origin_x);
let baseline = origin.y + px(plan.baseline_y);
window.with_content_mask(Some(gpui::ContentMask { bounds: clip }), |window| {
math_view::paint_formula_image(
&source,
plan.display_style,
plan.fitted_pixel_size,
pixels_per_point,
left,
baseline,
color,
window,
cx,
);
// 数学字体缺字的字符(公式里的中文等):旧壳合同是整字符落在
// 裁剪窗内才画,避免半个汉字。
let layout = cx.global_mut::<math_view::MathAssets>().layout(
&source,
plan.display_style,
plan.fitted_pixel_size,
pixels_per_point,
);
let Some(layout) = layout else { return };
let text_style = window.text_style();
for op in &layout.text {
let scale = op.pixel_size / plan.fitted_pixel_size.max(f32::EPSILON);
let x = plan.origin_x + op.x;
let width = cell.0 * scale;
let height = cell.1 * scale;
let top = plan.baseline_y + op.baseline_y - height * 0.8;
if x < plan.clip_left
|| x + width > plan.clip_right
|| top < plan.clip_top
|| top + height > plan.clip_bottom
{
continue;
}
let mut buffer = [0u8; 4];
let text: SharedString = op.character.encode_utf8(&mut buffer).to_string().into();
let run = text_style.to_run(text.len());
let line = window.text_system().shape_line(
text,
px(op.pixel_size),
std::slice::from_ref(&run),
None,
);
let target = point(
origin.x + px(x),
origin.y + px(plan.baseline_y + op.baseline_y) - line.ascent,
);
let _ = line.paint(target, line.ascent + line.descent, window, cx);
}
});
}
}
@@ -6,6 +6,7 @@
pub mod colors;
pub mod element;
pub mod keymap;
pub mod math_overlay;
pub mod mouse_protocol;
pub mod session;
pub mod suggest;
@@ -9,13 +9,12 @@
//! - 等宽 cell 度量(`SizeInfo`)→ UI 基准字号(同一设置源)
use gpui::{
AnyElement, Bounds, Context, Hsla, InteractiveElement as _, StatefulInteractiveElement as _,
IntoElement, MouseButton, ParentElement as _, SharedString,
Styled as _, canvas, div, fill, hsla, point,
px, size,
canvas, div, fill, hsla, point, px, size, AnyElement, Bounds, Context, Hsla,
InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, SharedString,
StatefulInteractiveElement as _, Styled as _,
};
use crate::display::ssh_connect::{self, SshConnectState, stage_labels, stage_message};
use crate::display::ssh_connect::{self, stage_labels, stage_message, SshConnectState};
use crate::gpui_shell::prelude::*;
/// UiLanguage 由设置折算(两壳同源)。
@@ -48,12 +47,7 @@ fn hsla_from_rgba(r: u8, g: u8, b: u8) -> Hsla {
fn lerp_hsla(a: Hsla, b: Hsla, k: f32) -> Hsla {
let k = k.clamp(0.0, 1.0);
hsla(
a.h + (b.h - a.h) * k,
a.s + (b.s - a.s) * k,
a.l + (b.l - a.l) * k,
1.0,
)
hsla(a.h + (b.h - a.h) * k, a.s + (b.s - a.s) * k, a.l + (b.l - a.l) * k, 1.0)
}
/// 品牌紫→青(旧壳 palette.edge_l/edge_r;浅色主题压暗 0.28)。
@@ -243,9 +237,10 @@ pub(super) fn overlay(
let theme = cx.theme();
let lang = language();
// chrome 文本锚定配置字号(旧壳 ui_font 合同),不跟终端缩放。
let ui_px = cx
.try_global::<crate::gpui_shell::config::Settings>()
.map(|settings| settings.font_size_px)
.map(|settings| settings.base_font_size_px)
.unwrap_or(15.0);
let family: SharedString = cx
.try_global::<crate::gpui_shell::config::Settings>()
@@ -261,12 +256,7 @@ pub(super) fn overlay(
let card_bg = theme.group_box;
let hairline = theme.border;
let dark = theme.is_dark();
let elevation = gpui::hsla(
0.0,
0.0,
0.0,
if dark { 56.0 / 255.0 } else { 26.0 / 255.0 },
);
let elevation = gpui::hsla(0.0, 0.0, 0.0, if dark { 56.0 / 255.0 } else { 26.0 / 255.0 });
// 遮罩沿用终端 pane 的真实底色;直接铺 popover 会把空网格区域
// 换成另一层面板色,尤其在浅色主题下会显得整块发白。
let backdrop = crate::gpui_shell::theme::card_content_bg(cx);
@@ -304,14 +294,7 @@ pub(super) fn overlay(
rack_panel,
);
let d = 2.4;
quad(
bx + stroke + 2.2,
by + bh * 0.5 - d * 0.5,
d,
d,
d * 0.5,
rack_ink,
);
quad(bx + stroke + 2.2, by + bh * 0.5 - d * 0.5, d, d, d * 0.5, rack_ink);
}
},
)
@@ -323,13 +306,10 @@ pub(super) fn overlay(
let logs_target = cx.entity().downgrade();
let logs_open = state.is_logs_open();
let logs_hover = theme.list_hover;
let logs_chevron = Icon::new(if logs_open {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.xsmall()
.text_color(ink_dim);
let logs_chevron =
Icon::new(if logs_open { IconName::ChevronUp } else { IconName::ChevronDown })
.xsmall()
.text_color(ink_dim);
let logs_button = h_flex()
.id("ssh-connect-logs")
.h(px(30.0))
@@ -410,12 +390,8 @@ pub(super) fn overlay(
let labels = stage_labels(lang);
let active = state.stage_index();
let caption_h = ui_px * 0.75;
let rail_block = v_flex()
.w_full()
.gap(px(8.0))
.mt(px(24.0))
.child(rail_canvas(state, cx, &theme))
.child(
let rail_block =
v_flex().w_full().gap(px(8.0)).mt(px(24.0)).child(rail_canvas(state, cx, &theme)).child(
div().relative().h(px(caption_h)).children(
labels
.iter()
@@ -472,11 +448,7 @@ pub(super) fn overlay(
.mt(px(24.0))
.justify_between()
.child(
div()
.font_family(family.clone())
.text_size(px(ui_px))
.text_color(msg_ink)
.child(msg),
div().font_family(family.clone()).text_size(px(ui_px)).text_color(msg_ink).child(msg),
)
.when(!state.failed(), |row| {
row.child(
@@ -491,22 +463,19 @@ pub(super) fn overlay(
// ── 失败详情(两行,超出收省略号)──
let per_line = 44usize;
let detail = state.failure().map(|reason| {
v_flex()
.mt(px(8.0))
.gap(px(ui_px * 0.28))
.children(
ssh_connect::wrap(reason, per_line)
.into_iter()
.take(2)
.map(|line| {
div()
.font_family(family.clone())
.text_size(px(ui_px * 0.80))
.text_color(ink_dim)
.child(ssh_connect::truncate_cols(&line, per_line))
})
.collect::<Vec<_>>(),
)
v_flex().mt(px(8.0)).gap(px(ui_px * 0.28)).children(
ssh_connect::wrap(reason, per_line)
.into_iter()
.take(2)
.map(|line| {
div()
.font_family(family.clone())
.text_size(px(ui_px * 0.80))
.text_color(ink_dim)
.child(ssh_connect::truncate_cols(&line, per_line))
})
.collect::<Vec<_>>(),
)
});
// ── Logs 区(末尾 6 行,最新在最下)──
@@ -546,27 +515,22 @@ pub(super) fn overlay(
};
let failed = state.failed();
let action_target = cx.entity().downgrade();
let buttons = h_flex()
.w_full()
.mt(px(24.0))
.justify_end()
.child(
Button::new("ssh-connect-action")
.label(action_label)
.small()
.map(|button| if failed { button.primary() } else { button.outline() })
.on_click(move |_, _, cx| {
if let Some(view) = action_target.upgrade() {
view.update(cx, |this, cx| {
// 取消与关闭是同一个动作(旧裁定):这个 pane
// 除了这条连接没有别的内容。
this.ssh_connect = None;
cx.emit(super::view::TerminalViewEvent::RequestClose);
cx.notify();
});
}
}),
);
let buttons = h_flex().w_full().mt(px(24.0)).justify_end().child({
let button =
crate::gpui_shell::widgets::NebulaButton::new("ssh-connect-action").label(action_label);
let button = if failed { button.primary() } else { button.outline() };
button.on_click(move |_, _, cx| {
if let Some(view) = action_target.upgrade() {
view.update(cx, |this, cx| {
// 取消与关闭是同一个动作(旧裁定):这个 pane
// 除了这条连接没有别的内容。
this.ssh_connect = None;
cx.emit(super::view::TerminalViewEvent::RequestClose);
cx.notify();
});
}
})
});
// ── 组装 ──
div()
+181 -17
View File
@@ -27,7 +27,7 @@ use super::session::{self, TerminalSession};
use super::suggest;
use super::{KEY_CONTEXT, TerminalBackTab, TerminalTab};
use crate::gpui_shell::config::Settings;
use crate::gpui_shell::prelude::DialogButtonProps;
use crate::gpui_shell::prelude::{DialogButtonProps, center_confirm_dialog};
use futures::StreamExt as _;
@@ -111,11 +111,17 @@ pub enum TerminalLaunch {
Ssh { destination: String },
}
/// 侧边栏只消费稳定的三态,不直接窥探终端/SSH 的内部错误字段。
/// 侧边栏只消费稳定的枚举态,不直接窥探终端/SSH 的内部错误字段。
/// `Done`/`Attention` 是旧壳 `AgentStatus::Done`/`Blocked` 的呈现位:
/// 回合结束≠会话结束,转圈必须停,但要留下「有结果没看」的痕迹。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarActivity {
Idle,
Running,
/// Agent 回合完成,等待下一条指令(旧壳蓝点语义)。
Done,
/// Agent 停在授权/提问上,需要用户表态(旧壳手掌语义)。
Attention,
Failed,
}
@@ -123,6 +129,8 @@ pub struct TerminalView {
pub pane_id: u64,
pub session: Option<TerminalSession>,
pub focus_handle: FocusHandle,
/// 公式覆盖层(探测/持久化状态复用旧壳 `terminal_math`,每 pane 一份)。
pub math: super::math_overlay::MathOverlay,
pub font: Font,
pub font_bold: Font,
pub font_italic: Font,
@@ -148,6 +156,16 @@ pub struct TerminalView {
/// `NEBULA|` 标题报 `running_program`,装了 OSC133 集成的 bash/zsh/nu 只
/// 发这一对语义标记——两条路都要能点亮侧栏的「运行中」。
command_running: bool,
/// Hook 事件驱动的 agent 回合状态(旧壳 `nebula_state.agent_status` 的
/// 边沿触发版)。`Unknown` = 本 pane 没有 agent 参与,spinner 完全由
/// `command_running`/`running_program` 决定。
agent_status: crate::ai_agents::AgentStatus,
/// 本次前台 agent 会话是否收到过 hook(旧壳同名字段同语义):屏幕
/// 检测的空闲提示符不得降级 hook 报出的 Done/Blocked 精确终态。
agent_hook_seen: bool,
/// 屏幕检测连续看到空闲提示符的拍数;Working 连续两拍空闲才降级
/// (单拍可能是重绘间隙),非 idle 检测与任何 hook 边沿都清零。
idle_screen_streak: u8,
/// OSC 9 程序通知,攒到 render(那里才有 `Window` 判聚焦)再呈现。
pending_notify: Vec<String>,
/// SSH 直连目的地(`user@host[:port]`);本地会话为 None。
@@ -558,6 +576,7 @@ impl TerminalView {
pane_id,
session,
focus_handle: cx.focus_handle(),
math: super::math_overlay::MathOverlay::default(),
font: mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal),
font_bold: mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal),
font_italic: mono_font(&families[2], FontWeight::NORMAL, FontStyle::Italic),
@@ -574,6 +593,9 @@ impl TerminalView {
branch: String::new(),
running_program: None,
command_running: false,
agent_status: crate::ai_agents::AgentStatus::Unknown,
agent_hook_seen: false,
idle_screen_streak: 0,
pending_notify: Vec::new(),
ssh_destination,
ssh_stage: None,
@@ -713,6 +735,9 @@ impl TerminalView {
cx.emit(TerminalViewEvent::TitleChanged);
}
self.command_running = false;
self.agent_status = crate::ai_agents::AgentStatus::Unknown;
self.agent_hook_seen = false;
self.idle_screen_streak = 0;
cx.notify();
},
TermEvent::Notify(body) => {
@@ -775,7 +800,15 @@ impl TerminalView {
}
/// Apply one lifecycle event already routed to this pane by the workspace.
///
/// 旧壳 `WindowContext::handle_ai_hook` 状态机的忠实移植:此前这里把
/// 所有非 SessionEnd 事件压平成「点亮 running_program」,于是回合结束
/// (TurnDone)后 spinner 也永远转下去——「CLI 都答完了侧栏还在转圈」
/// 的病根就是这次压平。
pub fn handle_ai_hook(&mut self, event: &crate::ai_hook::AiHookEvent, cx: &mut Context<Self>) {
use crate::ai_agents::AgentStatus;
use crate::ai_hook::AiHookKind;
// SessionEnd 可能是第一次携带最终权威 id 的事件;必须先落盘再清理
// pane 现场,否则 CLI 正常退出后反而无法冷恢复。
if let Some(id) = event.session_id.as_deref() {
@@ -785,28 +818,149 @@ impl TerminalView {
log::warn!("agent session index: could not record {} {id}: {error}", event.source);
}
}
if event.kind == crate::ai_hook::AiHookKind::SessionEnd {
self.ai_session = None;
self.running_program = None;
} else {
self.running_program = Some(event.source.clone());
if let Some(id) = event.session_id.as_deref() {
self.ai_session = Some(crate::display::AiSessionIdentity {
source: event.source.clone(),
session_id: id.to_owned(),
});
}
match event.kind {
AiHookKind::SessionEnd => {
self.ai_session = None;
self.running_program = None;
self.agent_status = AgentStatus::Unknown;
self.agent_hook_seen = false;
self.idle_screen_streak = 0;
},
kind => {
self.running_program = Some(event.source.clone());
self.agent_hook_seen = true;
// 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的
// 拍数不能把新回合的第一个空闲闪现立即降级)。
self.idle_screen_streak = 0;
if let Some(id) = event.session_id.as_deref() {
self.ai_session = Some(crate::display::AiSessionIdentity {
source: event.source.clone(),
session_id: id.to_owned(),
});
}
match kind {
// 会话起来了但还没干活:亮图标,不转圈。
AiHookKind::SessionStart => self.agent_status = AgentStatus::Idle,
AiHookKind::PromptSubmit => self.agent_status = AgentStatus::Working,
// 一个工具刚跑完 = agent 正在干活,这是无条件事实(回合
// 可能经不发 PromptSubmit 的路径继续——授权点头、队列
// 消息——状态却还挂在 Done:「还在执行却显示完成」)。
AiHookKind::ToolComplete => self.agent_status = AgentStatus::Working,
AiHookKind::TurnDone | AiHookKind::NeedsAttention => {
// codex 的 notify 只有「回合完成」一种事件:弹交互式
// 提问时发的也是 turn-complete。回合结束的瞬间看一眼
// 屏幕尾部——还挂着选择框/确认提示,就按「等你批准」
// 处理(旧壳同款嗅探)。
let screen_asks =
event.kind == AiHookKind::TurnDone && self.screen_tail_asks();
self.agent_status =
if event.kind == AiHookKind::NeedsAttention || screen_asks {
AgentStatus::Blocked
} else {
AgentStatus::Done
};
},
AiHookKind::SessionEnd => unreachable!("handled above"),
}
},
}
cx.emit(TerminalViewEvent::TitleChanged);
cx.notify();
}
/// 旧壳 TurnDone 分支的屏尾提问嗅探:取可视区最后 ≤15 行喂
/// `tail_looks_like_question`。
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))
}
/// 1 Hz 声明式屏幕看门狗(旧壳 `refresh_agent_screen_states` 的单 pane
/// 版)。Hook 是精确边沿,但边会丢:打断的回合没有 Stop 事件、codex
/// 只有 turn-complete 一种事件。屏幕负责两件事——给无 hook 客户端补位,
/// 以及纠正与可见现场矛盾的僵尸状态:
/// - 可见的 working/blocked chrome 永远可以纠正(现场比旧事件可信);
/// - 空闲提示符降级分档:hook 报过的 Done/Blocked 精确终态不动;
/// Working 连续两拍空闲才按「完成未读」收场(单拍可能是重绘间隙)。
pub fn refresh_agent_screen_state(&mut self, cx: &mut Context<Self>) {
use crate::ai_agents::AgentStatus;
let Some(program) = self.running_program.clone() else {
self.idle_screen_streak = 0;
return;
};
if crate::ai_agents::AgentKind::parse(&program).is_none() {
return;
}
let Some(session) = &self.session else { return };
let screen = {
let term = session.term.lock();
let lines = term.screen_lines();
if lines == 0 || term.columns() == 0 {
return;
}
let take = lines.min(24);
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)));
term.bounds_to_string(start, end)
};
let Some(detection) = crate::ai_agents::detect(&program, &screen) else { return };
let next = match detection.status {
AgentStatus::Idle => {
if self.agent_hook_seen
&& matches!(self.agent_status, AgentStatus::Done | AgentStatus::Blocked)
{
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;
}
// 连续空闲 = TurnDone 丢了:按「回合完成未读」收场
// (蓝点),而不是无痕回 Idle——用户没盯着屏幕时这个
// 结果仍然值得一个标记。
AgentStatus::Done
} else {
AgentStatus::Idle
}
},
status @ (AgentStatus::Working | AgentStatus::Blocked) => {
self.idle_screen_streak = 0;
status
},
AgentStatus::Done | AgentStatus::Unknown => {
self.idle_screen_streak = 0;
return;
},
};
if next != self.agent_status {
log::debug!(
"agent screen state: pane={} program={} {:?}->{next:?} rule={}",
self.pane_id,
program,
self.agent_status,
detection.rule_id
);
self.agent_status = next;
cx.emit(TerminalViewEvent::TitleChanged);
cx.notify();
}
}
pub fn ai_fork_command(&self) -> Option<String> {
let identity = self.ai_session.as_ref()?;
crate::ai_agents::AgentKind::parse(&identity.source)?.fork_command(&identity.session_id)
}
pub fn sidebar_activity(&self) -> SidebarActivity {
use crate::ai_agents::AgentStatus;
if self.error.is_some()
|| self.exited.is_some()
|| matches!(
@@ -814,8 +968,18 @@ impl TerminalView {
Some(crate::ssh_session::SshStage::Failed(_))
)
{
SidebarActivity::Failed
} else if self.command_running
return SidebarActivity::Failed;
}
// Agent 会话在场时回合状态是权威:TurnDone 之后 spinner 必须停,
// 不能因为 `running_program` 还亮着(图标要继续显示)就一直转。
match self.agent_status {
AgentStatus::Working => return SidebarActivity::Running,
AgentStatus::Blocked => return SidebarActivity::Attention,
AgentStatus::Done => return SidebarActivity::Done,
AgentStatus::Idle => return SidebarActivity::Idle,
AgentStatus::Unknown => {},
}
if self.command_running
|| self.running_program.is_some()
|| self.ssh_stage.as_ref().is_some_and(|stage| {
!matches!(stage, crate::ssh_session::SshStage::Ready)
@@ -1209,10 +1373,10 @@ impl TerminalView {
// pane」的 view 释放不掉。
let text = Arc::new(text);
let view = cx.entity().downgrade();
window.open_dialog(cx, move |dialog, _window, _cx| {
window.open_dialog(cx, move |dialog, window, _cx| {
let text = text.clone();
let view = view.clone();
dialog
center_confirm_dialog(dialog, window)
.title(title.clone())
.confirm()
.button_props(
+72 -8
View File
@@ -41,19 +41,50 @@ pub(crate) fn chrome_theme_resolved(cx: &App) -> NebulaTheme {
chrome_theme(effective_theme_name(cx))
}
/// 当前 OS 外观是否为浅色。跟随系统时与旧壳 `WinitTheme::Light` 同一判定。
pub(crate) fn system_is_light(cx: &App) -> bool {
matches!(
cx.window_appearance(),
gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight
)
}
/// 把用户点选的主题家族折算成此刻该显示的成员。
/// `follow_system` 关闭时原样返回 preference;开启时走
/// [`NebulaTheme::for_system_appearance`]。
pub(crate) fn resolve_theme_name(
preference: ThemeName,
follow_system: bool,
system_is_light: bool,
) -> ThemeName {
if !follow_system {
return preference;
}
settings_theme_name(chrome_theme(preference).for_system_appearance(system_is_light))
}
/// 生效主题:`follow_system_theme` 开启时按系统外观折算到用户主题家族的
/// 亮/暗成员(规则与旧壳 `NebulaTheme::for_system_appearance` 同一来源)。
/// chrome 令牌与终端 palette 都必须走这里,两层才不会分家。
pub fn effective_theme_name(cx: &App) -> ThemeName {
let rt = nebula_settings::RuntimeSettings::load();
if !rt.follow_system_theme {
return rt.theme;
}
let is_light = matches!(
cx.window_appearance(),
gpui::WindowAppearance::Light | gpui::WindowAppearance::VibrantLight
);
settings_theme_name(chrome_theme(rt.theme).for_system_appearance(is_light))
resolve_theme_name(rt.theme, rt.follow_system_theme, system_is_light(cx))
}
/// 点选一张主题卡时要写盘的键:对齐旧壳 `select_nebula_theme`
/// (关掉跟随系统)+ `apply_nebula_theme`(底色换成该主题 `term_bg`)。
pub(crate) fn theme_card_persist_updates(name: ThemeName) -> [(&'static str, String); 3] {
[
("theme", name.prompt_name().to_owned()),
("follow_system_theme", "0".to_owned()),
("background", nebula_settings::format_hex_rgb(name.term_theme().background)),
]
}
/// 当前生效主题的终端底色(给「跟随主题」取色器同步用,不是壳色)。
pub(crate) fn theme_term_background(cx: &App) -> Hsla {
let c = chrome_theme(effective_theme_name(cx)).palette().term_bg;
to_hsla(c.r, c.g, c.b)
}
fn to_hsla(r: u8, g: u8, b: u8) -> Hsla {
@@ -425,3 +456,36 @@ fn apply_skin_tokens(chrome: NebulaTheme, cx: &mut App) {
theme.radius = px(crate::display::UI_CORNER_RADIUS_LOGICAL);
theme.radius_lg = px(12.0);
}
#[cfg(test)]
mod tests {
use super::{resolve_theme_name, theme_card_persist_updates};
use nebula_settings::ThemeName;
#[test]
fn follow_system_remaps_theme_family_and_manual_mode_keeps_preference() {
assert_eq!(
resolve_theme_name(ThemeName::SilverLight, true, false),
ThemeName::SteelDark
);
assert_eq!(
resolve_theme_name(ThemeName::Nebula, true, true),
ThemeName::SilverLight
);
assert_eq!(
resolve_theme_name(ThemeName::SilverLight, false, false),
ThemeName::SilverLight
);
}
#[test]
fn picking_a_theme_card_disables_follow_system_and_writes_that_theme_background() {
let updates = theme_card_persist_updates(ThemeName::LinenLight);
assert_eq!(updates[0], ("theme", "LinenLight".to_owned()));
assert_eq!(updates[1], ("follow_system_theme", "0".to_owned()));
assert_eq!(
updates[2],
("background", nebula_settings::format_hex_rgb(ThemeName::LinenLight.term_theme().background))
);
}
}
+448
View File
@@ -0,0 +1,448 @@
//! 旧壳设置控件的 GPUI 形态。
//!
//! - [`NebulaSwitch`]:液态胶囊开关。几何、配色与动画通道逐项对照旧壳
//! `display/ui/widgets.rs::push_toggle`(HTML 参考的
//! `transition: all 0.3s ease`)。组件库 `Switch` 是 0.15s 线性、蓝底白点
//! 的另一套语言,与旧壳中性胶囊在同一页面上会打架。
//! - [`NebulaButton`]:设置行动作按钮。几何对照 `row_action_rect` / HTML
//! `.bt`,hover 色过渡对照 `.bt { transition: all .13s }`。组件库 `Button`
//! 的 `.hover()` / `.active()` 是瞬时换色,没有这段动效。
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, App, ClickEvent, ElementId, InteractiveElement as _,
IntoElement, ParentElement as _, RenderImage, RenderOnce, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, div, ease_in_out, px,
};
use image::Frame;
use crate::renderer::ui::Rgba;
/// Shell 的彩色品牌图标(`extra/shell-icons` 的 PNG)预缩放成与物理像素
/// 一一对应的纹理。设置页的默认 Shell 下拉与新建终端的 Shell 选择弹窗
/// 共用同一份资产和同一条缩放路径——把 128px 原图交给 GPUI 每帧缩小会糊
/// 边,两处各写一遍缩放又迟早缩出两种观感。
///
/// `logical_size` 是控件里图标的逻辑边长,`scale_factor` 是窗口 DPI 缩放。
/// 返回 `None` 表示该 id 没有品牌资产,调用方保留自己的字形回落。
pub fn shell_brand_image(
id: &str,
logical_size: f32,
scale_factor: f32,
) -> Option<Arc<RenderImage>> {
let bytes = crate::shell_detect::color_icon_png(id)?;
let source = image::load_from_memory(bytes).ok()?.into_rgba8();
let (width, height) = source.dimensions();
let target_size = (logical_size * scale_factor).round().max(1.0) as u32;
let (mut rgba, width, height) =
crate::display::prepare_ai_logo_texture(source.as_raw(), width, height, target_size);
// GPUI RenderImage 使用 BGRA;预缩放必须在 RGBA 上完成,换序放在最后,
// 否则 Lanczos 会把红蓝通道按错误语义混合。
for pixel in rgba.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
let rgba = image::RgbaImage::from_raw(width, height, rgba)?;
Some(Arc::new(RenderImage::new([Frame::new(rgba)])))
}
const TRACK_W: f32 = 48.0;
const TRACK_H: f32 = 26.0;
const KNOB: f32 = 20.0;
const INSET: f32 = 2.0;
const TRAVEL: f32 = 24.0;
const STRETCH: f32 = 8.0;
const DURATION: Duration = Duration::from_millis(300);
/// 旧壳设置行的动画开关(液态胶囊)。
///
/// - 胶囊 48×26(圆角 13),knob 20×20、inset 2,行程 24px;
/// - 按住时 knob 拉伸 +8px(开态从右缘回弹,旧壳「liquid recoil」);
/// - 状态切换 = 0.3s ease 同步过渡位置与颜色(border/track/knob 三通道)。
#[derive(IntoElement)]
pub struct NebulaSwitch {
key: SharedString,
checked: bool,
disabled: bool,
on_click: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
}
impl NebulaSwitch {
pub fn new(key: impl Into<SharedString>) -> Self {
Self { key: key.into(), checked: false, disabled: false, on_click: None }
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
#[allow(dead_code)]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&bool, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Rc::new(handler));
self
}
}
/// u8 通道混色(旧壳 `widgets::mix_color` 的语义),返回 gpui 颜色。
fn mix(start: Rgba, end: Rgba, t: f32) -> gpui::Rgba {
let t = t.clamp(0.0, 1.0);
let channel = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t) / 255.0;
gpui::Rgba {
r: channel(start.r, end.r),
g: channel(start.g, end.g),
b: channel(start.b, end.b),
a: channel(start.a, end.a),
}
}
/// `overlay`(带 alpha)盖到 `base`(不透明)上(旧壳 `surface::over` 语义)。
fn over(overlay: Rgba, base: gpui::Rgba) -> gpui::Rgba {
let alpha = f32::from(overlay.a) / 255.0;
let channel = |o: u8, b: f32| b + (f32::from(o) / 255.0 - b) * alpha;
gpui::Rgba {
r: channel(overlay.r, base.r),
g: channel(overlay.g, base.g),
b: channel(overlay.b, base.b),
a: base.a,
}
}
fn over_rgba(overlay: Rgba, base: Rgba) -> Rgba {
let alpha = f32::from(overlay.a) / 255.0;
let channel = |o: u8, b: u8| {
(f32::from(b) + (f32::from(o) - f32::from(b)) * alpha).round().clamp(0.0, 255.0) as u8
};
Rgba::new(channel(overlay.r, base.r), channel(overlay.g, base.g), channel(overlay.b, base.b), base.a)
}
impl RenderOnce for NebulaSwitch {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
let checked = self.checked;
let disabled = self.disabled;
// 动画驱动(组件库 Switch 同款结构):keyed state 记录「上一次画的
// 状态」,与本次 checked 不同才播 0.3s 过渡,结束后把状态落下来。
let settled_id = ElementId::Name(format!("nebula-switch-settled-{}", self.key).into());
let settled = window.use_keyed_state(settled_id, cx, |_, _| checked);
let animate = !disabled && *settled.read(cx) != checked;
if animate {
let settled = settled.clone();
cx.spawn(async move |cx| {
cx.background_executor().timer(DURATION).await;
let _ = settled.update(cx, |state, _| *state = checked);
})
.detach();
}
// 按住拉伸:knob +8px;开态按住时从右缘回弹(x 左移补偿)。
let pressed_id = ElementId::Name(format!("nebula-switch-pressed-{}", self.key).into());
let pressed = window.use_keyed_state(pressed_id, cx, |_, _| false);
let is_pressed = !disabled && *pressed.read(cx);
let hover_ink = Rgba::new(sk.icon_hover.r, sk.icon_hover.g, sk.icon_hover.b, 12);
let track_of = move |t: f32| mix(sk.toggle_track_off, sk.toggle_track_on, t);
let border_of = move |t: f32| mix(sk.toggle_border_off, sk.toggle_border_on, t);
let knob_color_of = move |t: f32| mix(sk.knob_off, sk.knob_on, t);
let knob_w = if is_pressed { KNOB + STRETCH } else { KNOB };
let knob_x_of = move |t: f32| {
let x = INSET + TRAVEL * t;
// 开态按住:宽度向左生长,右缘保持贴住(旧壳 recoil 语义)。
if is_pressed && t > 0.5 { x - STRETCH } else { x }
};
let knob = div()
.absolute()
.top(px((TRACK_H - 2.0 * INSET - KNOB) / 2.0))
.w(px(knob_w))
.h(px(KNOB))
.rounded(px(KNOB / 2.0));
let knob = if animate {
knob.with_animation(
ElementId::NamedInteger(
format!("nebula-switch-knob-{}", self.key).into(),
checked as u64,
),
Animation::new(DURATION).with_easing(ease_in_out),
move |knob, delta| {
let t = if checked { delta } else { 1.0 - delta };
knob.left(px(knob_x_of(t))).bg(knob_color_of(t))
},
)
.into_any_element()
} else {
let t = if checked { 1.0 } else { 0.0 };
knob.left(px(knob_x_of(t))).bg(knob_color_of(t)).into_any_element()
};
// 胶囊本体:动画帧由 with_animation 重染 bg/border;静止帧直接给
// settled 色。命中与手势挂在外层 stateful div 上,动画期间照常可点。
let track = div()
.relative()
.w(px(TRACK_W))
.h(px(TRACK_H))
.rounded(px(TRACK_H / 2.0))
.border_1()
.child(knob);
let track = if animate {
track
.with_animation(
ElementId::NamedInteger(
format!("nebula-switch-track-{}", self.key).into(),
checked as u64,
),
Animation::new(DURATION).with_easing(ease_in_out),
move |track, delta| {
let t = if checked { delta } else { 1.0 - delta };
track.bg(track_of(t)).border_color(border_of(t))
},
)
.into_any_element()
} else {
let t = if checked { 1.0 } else { 0.0 };
track
.bg(track_of(t))
.border_color(border_of(t))
// hover 水洗(旧壳 border 提亮 + track 12/255 水洗的简化)。
.hover(move |style| style.bg(over(hover_ink, track_of(t))))
.into_any_element()
};
div()
.id(ElementId::Name(format!("nebula-switch-{}", self.key).into()))
.flex_shrink_0()
.rounded(px(TRACK_H / 2.0))
.cursor_pointer()
.child(track)
.when(!disabled, |wrapper| {
let pressed_down = pressed.clone();
let pressed_up = pressed;
wrapper
.on_mouse_down(gpui::MouseButton::Left, move |_, _, cx| {
cx.stop_propagation();
let _ = pressed_down.update(cx, |state, _| *state = true);
})
.on_mouse_up(gpui::MouseButton::Left, move |_, _, cx| {
let _ = pressed_up.update(cx, |state, _| *state = false);
})
.when_some(self.on_click, |wrapper, on_click| {
wrapper.on_click(move |_, window, cx| {
cx.stop_propagation();
on_click(&!checked, window, cx);
})
})
})
}
}
const BUTTON_H: f32 = 30.0;
const BUTTON_PX: f32 = 13.0;
const BUTTON_DURATION: Duration = Duration::from_millis(130);
/// 旧壳设置行的文字按钮。
///
/// 对照两处权威,不对照组件库 `Button`:
/// - 几何:旧壳 `row_action_rect`(高 30)+ HTML `.bt`(`padding: 0 13px`,
/// 圆角走 chrome `UI_CORNER_RADIUS_LOGICAL`);
/// - 动效:HTML `.bt { transition: all .13s }`,hover 只插值底/边/字色,
/// 不改尺寸(旧壳 `push_button_frame`:「hover 只改亮度,不改尺寸」);
/// - 配色:默认态 = `action_button` 的 `sk.surface` → `sk.hover`;描边态 =
/// `push_outline_button` 的发丝 + panel;主按钮 = accent 实心再按亮度抬。
#[derive(IntoElement)]
pub struct NebulaButton {
key: SharedString,
label: SharedString,
kind: NebulaButtonKind,
disabled: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum NebulaButtonKind {
Default,
Primary,
Outline,
Danger,
}
impl NebulaButton {
pub fn new(key: impl Into<SharedString>) -> Self {
Self {
key: key.into(),
label: SharedString::default(),
kind: NebulaButtonKind::Default,
disabled: false,
on_click: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = label.into();
self
}
pub fn primary(mut self) -> Self {
self.kind = NebulaButtonKind::Primary;
self
}
pub fn outline(mut self) -> Self {
self.kind = NebulaButtonKind::Outline;
self
}
pub fn danger(mut self) -> Self {
self.kind = NebulaButtonKind::Danger;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Rc::new(handler));
self
}
}
fn rgb_rgba(c: crate::display::color::Rgb) -> Rgba {
Rgba::new(c.r, c.g, c.b, 255)
}
fn mix_rgba(start: Rgba, end: Rgba, t: f32) -> Rgba {
let t = t.clamp(0.0, 1.0);
let channel = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8;
Rgba::new(
channel(start.r, end.r),
channel(start.g, end.g),
channel(start.b, end.b),
channel(start.a, end.a),
)
}
fn button_palette(
kind: NebulaButtonKind,
sk: &crate::display::ui::theme::Skin,
) -> (Rgba, Rgba, Rgba, Rgba, Rgba, Rgba) {
// (bg0, bg1, border0, border1, fg0, fg1)
let ink = rgb_rgba(sk.ink);
let ink_dim = rgb_rgba(sk.ink_dim);
let transparent = Rgba::new(0, 0, 0, 0);
let hover_line = mix_rgba(sk.hairline, rgb_rgba(sk.icon_hover), 0.14);
match kind {
NebulaButtonKind::Default => (sk.surface, sk.hover, sk.hairline, hover_line, ink_dim, ink),
NebulaButtonKind::Outline => (sk.panel, sk.hover, sk.hairline, hover_line, ink_dim, ink),
NebulaButtonKind::Primary => {
let rest = Rgba::opaque(sk.accent);
let wash =
if sk.is_light { Rgba::new(0, 0, 0, 20) } else { Rgba::new(255, 255, 255, 26) };
let hover = over_rgba(wash, rest);
let on_accent = rgb_rgba(sk.ink_on_accent);
(rest, hover, transparent, transparent, on_accent, on_accent)
}
NebulaButtonKind::Danger => {
let wash = Rgba::new(sk.danger.r, sk.danger.g, sk.danger.b, 28);
let line = Rgba::new(sk.danger.r, sk.danger.g, sk.danger.b, 90);
(transparent, wash, transparent, line, sk.danger, sk.danger)
}
}
}
impl RenderOnce for NebulaButton {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let sk = crate::gpui_shell::theme::chrome_theme_resolved(cx).skin();
let disabled = self.disabled;
let kind = self.kind;
let (bg0, bg1, border0, border1, fg0, fg1) = button_palette(kind, &sk);
let stroked = !matches!(kind, NebulaButtonKind::Primary);
let hover_id = ElementId::Name(format!("nebula-btn-hover-{}", self.key).into());
let hover = window.use_keyed_state(hover_id, cx, |_, _| false);
let hovered = !disabled && *hover.read(cx);
let settled_id = ElementId::Name(format!("nebula-btn-settled-{}", self.key).into());
let settled = window.use_keyed_state(settled_id, cx, |_, _| false);
let animate = !disabled && *settled.read(cx) != hovered;
if animate {
let settled = settled.clone();
cx.spawn(async move |cx| {
cx.background_executor().timer(BUTTON_DURATION).await;
let _ = settled.update(cx, |state, _| *state = hovered);
})
.detach();
}
let bg_of = move |t: f32| mix(bg0, bg1, t);
let border_of = move |t: f32| mix(border0, border1, t);
let fg_of = move |t: f32| mix(fg0, fg1, t);
let face = div()
.h(px(BUTTON_H))
.px(px(BUTTON_PX))
.flex()
.items_center()
.justify_center()
.flex_shrink_0()
.rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL))
.when(stroked, |face| face.border_1())
.child(self.label);
let face = if animate {
face.with_animation(
ElementId::NamedInteger(
format!("nebula-btn-face-{}", self.key).into(),
hovered as u64,
),
Animation::new(BUTTON_DURATION).with_easing(ease_in_out),
move |face, delta| {
let t = if hovered { delta } else { 1.0 - delta };
face.bg(bg_of(t)).border_color(border_of(t)).text_color(fg_of(t))
},
)
.into_any_element()
} else {
let t = if hovered { 1.0 } else { 0.0 };
face.bg(bg_of(t))
.border_color(border_of(t))
.text_color(fg_of(t))
.into_any_element()
};
div()
.id(ElementId::Name(format!("nebula-btn-{}", self.key).into()))
.flex_shrink_0()
.rounded(px(crate::display::UI_CORNER_RADIUS_LOGICAL))
.when(disabled, |wrapper| wrapper.opacity(0.45).cursor_default())
.when(!disabled, |wrapper| wrapper.cursor_pointer())
.child(face)
.when(!disabled, |wrapper| {
let hover_enter = hover.clone();
wrapper
.on_hover(move |hovered, _, cx| {
let _ = hover_enter.update(cx, |state, _| *state = *hovered);
})
.when_some(self.on_click, |wrapper, on_click| {
wrapper.on_click(move |event, window, cx| {
cx.stop_propagation();
on_click(event, window, cx);
})
})
})
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -74,6 +74,7 @@ mod session;
mod shell_detect;
#[cfg(windows)]
mod ssh;
mod svn_status;
#[cfg(windows)]
mod ssh_credentials;
#[cfg(windows)]
-6
View File
@@ -34,12 +34,6 @@ pub(crate) const OPTICAL_SCALE: f32 = 1.21;
/// 同类公式的字号不会因此分裂。
///
/// 这是刻意偏离 LaTeX/KaTeX 的一处:那两者排的是版面充裕的文档,我们排的
/// 是终端里 AI 输出的一行文字。
/// 上下标 / 分子分母的最小相对字号。Latin Modern 的 MATH 表给 70%,是为
/// 纸面阅读字号调的;终端正文本来就小,0.7 em 的分子在 20px 字号下只有
/// 14px,用户看到的就是"公式比正文小一圈"。
///
/// 这是刻意偏离 LaTeX/KaTeX 的一处:那两者排的是版面充裕的文档,我们排的
/// 是终端里 AI 输出的一行文字。上限卡在 0.8:再往上(实测 0.85)根号里的
/// 上标会把 `\sqrt` 顶过字形变体的阈值,高度从 30px 跳到 45px,那条公式
/// 就得缩——同类公式一样大比多两个百分点重要。
+31 -3
View File
@@ -405,9 +405,19 @@ pub fn shell_short_tag(name_or_id: &str) -> String {
return distro.trim().to_ascii_lowercase();
}
let lower = raw.to_ascii_lowercase();
// 两个 PowerShell 各有稳定 id,先精确匹配 id 再看显示名:同一台 shell
// 经 `TabLaunch::Default`(喂 id)和 `TabLaunch::Shell`(喂菜单名)两条
// 管道进来,必须标成同一个词,否则「新建的 tab」和「恢复出来的 tab」
// 明明是同一个 shell 却挂着两个短标。
match lower.as_str() {
"pwsh" => return "pwsh".into(),
"powershell" | "ps" => return "ps".into(),
_ => {},
}
if lower.contains("powershell") {
// 用户口头就用 pwsh 区分 7 和老 5.1,只有 Windows PowerShell 保留全名。
return if lower.contains("windows") { "powershell".into() } else { "pwsh".into() };
// 用户口头就用 pwsh 指 7、ps 指系统自带的 5.1;两者都是缩写,且
// 同时装着两版时在侧栏一眼分得开。
return if lower.contains("windows") { "ps".into() } else { "pwsh".into() };
}
if lower.contains("bash") {
return "bash".into();
@@ -559,12 +569,30 @@ mod tests {
assert_eq!(shell_short_tag("WSL · Debian"), "debian");
assert_eq!(shell_short_tag("PowerShell 7"), "pwsh");
assert_eq!(shell_short_tag("pwsh"), "pwsh");
assert_eq!(shell_short_tag("Windows PowerShell"), "powershell");
assert_eq!(shell_short_tag("Windows PowerShell"), "ps");
assert_eq!(shell_short_tag("Git Bash"), "bash");
assert_eq!(shell_short_tag("Nushell"), "nu");
assert_eq!(shell_short_tag("CMD"), "cmd");
}
/// 同一台 shell 的 settings id 与菜单显示名必须给出同一个短标。
/// 回归锁:`powershell`(id) 曾标成 `pwsh` 而「Windows PowerShell」(名)
/// 标成 `powershell`——同一台机器因为 tab 的启动方式不同挂了两个词,
/// 而其中一个还不是缩写。
#[test]
fn shell_short_tags_agree_across_id_and_display_name() {
use super::{display_name_for_id, shell_short_tag};
for id in ["pwsh", "powershell", "cmd", "bash", "nu", "wsl:Ubuntu"] {
assert_eq!(
shell_short_tag(id),
shell_short_tag(&display_name_for_id(id)),
"id `{id}` 与它的显示名短标不一致"
);
}
// 两个 PowerShell 仍然分得开。
assert_ne!(shell_short_tag("powershell"), shell_short_tag("pwsh"));
}
use super::*;
#[test]
+460
View File
@@ -0,0 +1,460 @@
//! SVN 工作拷贝状态:直接读 `.svn/wc.db`(SQLite),零外部进程依赖。
//!
//! 为什么不调 svn.exe:这台产品的目标机器未必装命令行客户端(TortoiseSVN
//! 默认只装 GUI),而且逐次 spawn 进程拉状态既慢又抖。SVN 1.7+ 的工作拷贝
//! 元数据是稳定的 SQLite 库(`NODES` / `ACTUAL_NODE` 表),TortoiseSVN 的
//! TSVNCache 走的就是这条路:只读打开、快速路径按 `recorded_size` /
//! `recorded_time` 判嫌疑、嫌疑文件再做 SHA-1 精判——本模块同一合同。
//!
//! 范围(v1):
//! - 状态子集:Added / Replaced / Deleted / Modified / Missing / Conflicted /
//! Unversioned;`svn:ignore` 属性尚未解析(未忽略项会以 Unversioned 出现)。
//! - `svnadmin create` 出来的**服务端仓库**(conf/db/hooks/format)不是
//! 工作拷贝,[`classify_dir`] 会识别并明确报告,不做 FSFS 解析。
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use sha1::{Digest as _, Sha1};
/// 修改嫌疑文件做 SHA-1 精判的尺寸上限;超过就直接按 Modified 报告,
/// 避免状态刷新读几百 MB 大文件(TSVNCache 同款取舍)。
const SHA1_VERIFY_LIMIT: u64 = 4 * 1024 * 1024;
/// 一个目录相对 SVN 的身份。
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SvnDirKind {
/// 位于某个工作拷贝内;携带工作拷贝根(含 `.svn/` 的目录)。
WorkingCopy(PathBuf),
/// `svnadmin create` 生成的服务端仓库:只能被 svnserve/httpd 提供服务
/// 或用 `file://` 协议 checkout,本身没有"文件状态"可言。
Repository,
/// 与 SVN 无关。
Plain,
}
/// 判定 `dir` 的 SVN 身份:向上找 `.svn/wc.db`(工作拷贝),否则按
/// `format` + `conf/db/hooks` 特征识别服务端仓库。
pub fn classify_dir(dir: &Path) -> SvnDirKind {
let mut probe = Some(dir);
while let Some(current) = probe {
if current.join(".svn").join("wc.db").is_file() {
return SvnDirKind::WorkingCopy(current.to_owned());
}
probe = current.parent();
}
let is_repository = dir.join("format").is_file()
&& dir.join("conf").is_dir()
&& dir.join("db").is_dir()
&& dir.join("hooks").is_dir();
if is_repository { SvnDirKind::Repository } else { SvnDirKind::Plain }
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum SvnState {
Added,
Replaced,
Deleted,
Modified,
Missing,
Conflicted,
Unversioned,
}
impl SvnState {
/// 单字母角标(与 `svn status` 输出的第一列同字符集)。
pub fn letter(self) -> &'static str {
match self {
Self::Added => "A",
Self::Replaced => "R",
Self::Deleted => "D",
Self::Modified => "M",
Self::Missing => "!",
Self::Conflicted => "C",
Self::Unversioned => "?",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SvnChange {
/// 相对工作拷贝根的正斜杠路径。
pub rel_path: String,
pub state: SvnState,
}
/// 读出整个工作拷贝的变更清单(按路径排序)。
///
/// 干净判定的快速路径:`recorded_size` 不同 → Modified;大小相同而
/// `recorded_time`(µs)不同 → ≤ [`SHA1_VERIFY_LIMIT`] 时做 SHA-1 精判,
/// 只有内容真的不同才报 Modified(touch 过的文件不误报)。
pub fn working_copy_status(root: &Path) -> Result<Vec<SvnChange>, String> {
let db_path = root.join(".svn").join("wc.db");
let connection = rusqlite::Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.map_err(|error| format!("无法打开 {}: {error}", db_path.display()))?;
// svn 客户端可能正持有写锁;等一小会而不是立刻失败。
connection.busy_timeout(std::time::Duration::from_millis(300)).ok();
#[derive(Debug)]
struct NodeRow {
op_depth: i64,
presence: String,
kind: String,
checksum: Option<String>,
recorded_size: Option<i64>,
recorded_time: Option<i64>,
has_base: bool,
}
// 每个 relpath 取 op_depth 最大的行(当前操作层),并记录是否存在
// base 层(Added vs Replaced 的分界)。
let mut nodes: BTreeMap<String, NodeRow> = BTreeMap::new();
{
let mut statement = connection
.prepare(
"SELECT local_relpath, op_depth, presence, kind, checksum,
recorded_size, recorded_time
FROM nodes ORDER BY local_relpath, op_depth",
)
.map_err(|error| format!("wc.db 结构不符合预期: {error}"))?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
NodeRow {
op_depth: row.get(1)?,
presence: row.get(2)?,
kind: row.get(3)?,
checksum: row.get(4)?,
recorded_size: row.get(5)?,
recorded_time: row.get(6)?,
has_base: false,
},
))
})
.map_err(|error| format!("读取 NODES 失败: {error}"))?;
for row in rows {
let (relpath, node) = row.map_err(|error| format!("读取 NODES 失败: {error}"))?;
match nodes.get_mut(&relpath) {
// 行按 op_depth 升序到达:后到的层覆盖前面的,base 层的
// 存在性单独记账。
Some(existing) => {
let has_base = existing.has_base || existing.op_depth == 0;
if node.op_depth >= existing.op_depth {
*existing = NodeRow { has_base, ..node };
} else {
existing.has_base = has_base || node.op_depth == 0;
}
},
None => {
let has_base = node.op_depth == 0;
nodes.insert(relpath, NodeRow { has_base, ..node });
},
}
}
}
// 冲突从 ACTUAL_NODE 来(树冲突/文本冲突统一报 Conflicted)。
let mut conflicted: HashSet<String> = HashSet::new();
if let Ok(mut statement) = connection
.prepare("SELECT local_relpath FROM actual_node WHERE conflict_data IS NOT NULL")
{
if let Ok(rows) = statement.query_map([], |row| row.get::<_, String>(0)) {
for relpath in rows.flatten() {
conflicted.insert(relpath);
}
}
}
let mut changes = Vec::new();
let mut versioned_dirs: Vec<String> = Vec::new();
for (relpath, node) in &nodes {
if relpath.is_empty() {
versioned_dirs.push(String::new());
continue;
}
if conflicted.contains(relpath) {
changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Conflicted });
continue;
}
let disk = root.join(relpath.replace('/', std::path::MAIN_SEPARATOR_STR));
match node.presence.as_str() {
"base-deleted" => {
changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Deleted });
},
"normal" if node.op_depth > 0 => {
let state = if node.has_base { SvnState::Replaced } else { SvnState::Added };
changes.push(SvnChange { rel_path: relpath.clone(), state });
if node.kind == "dir" {
versioned_dirs.push(relpath.clone());
}
},
"normal" => {
if node.kind == "dir" {
versioned_dirs.push(relpath.clone());
continue;
}
let Ok(metadata) = std::fs::metadata(&disk) else {
changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Missing });
continue;
};
if is_modified(&disk, &metadata, node.recorded_size, node.recorded_time, node.checksum.as_deref())
{
changes.push(SvnChange { rel_path: relpath.clone(), state: SvnState::Modified });
}
},
// excluded/server-excluded/not-present 等:本地无实体,不报告。
_ => {},
}
}
// 未版本化:已版本化目录的磁盘直接子项里,不在 NODES 且不是 .svn 的。
for dir in versioned_dirs {
let disk_dir =
if dir.is_empty() { root.to_owned() } else { root.join(dir.replace('/', std::path::MAIN_SEPARATOR_STR)) };
let Ok(entries) = std::fs::read_dir(&disk_dir) else { continue };
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name == ".svn" {
continue;
}
let relpath =
if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") };
if !nodes.contains_key(&relpath) {
changes.push(SvnChange { rel_path: relpath, state: SvnState::Unversioned });
}
}
}
changes.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
Ok(changes)
}
/// 工作拷贝根的当前修订号(`NODES` 根行的 `revision` 列)。
pub fn working_copy_revision(root: &Path) -> Option<i64> {
let connection = rusqlite::Connection::open_with_flags(
root.join(".svn").join("wc.db"),
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.ok()?;
connection
.query_row(
"SELECT revision FROM nodes WHERE local_relpath = '' AND op_depth = 0",
[],
|row| row.get::<_, i64>(0),
)
.ok()
}
/// 干净判定:大小 → 时间戳 → SHA-1 三级快速路径。
fn is_modified(
path: &Path,
metadata: &std::fs::Metadata,
recorded_size: Option<i64>,
recorded_time: Option<i64>,
checksum: Option<&str>,
) -> bool {
if let Some(size) = recorded_size {
if metadata.len() != size as u64 {
return true;
}
}
let disk_micros = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_micros() as i64);
if let (Some(disk), Some(recorded)) = (disk_micros, recorded_time) {
if disk == recorded {
return false;
}
}
// 时间戳变了(或缺记录):尺寸相同的小文件做内容精判,防 touch 误报。
let Some(expected) = checksum.and_then(|value| value.strip_prefix("$sha1$")) else {
return true;
};
if metadata.len() > SHA1_VERIFY_LIMIT {
return true;
}
let Ok(bytes) = std::fs::read(path) else {
return true;
};
let digest = Sha1::digest(&bytes);
let mut actual = String::with_capacity(40);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(actual, "{byte:02x}");
}
actual != expected
}
#[cfg(test)]
mod tests {
use super::*;
/// 手工构造最小 wc.db:只建状态推导用到的列,与真实 schema 的子集
/// 保持同名同义。
fn fake_working_copy(dir: &Path) -> rusqlite::Connection {
std::fs::create_dir_all(dir.join(".svn")).unwrap();
let connection = rusqlite::Connection::open(dir.join(".svn").join("wc.db")).unwrap();
connection
.execute_batch(
"CREATE TABLE nodes (
local_relpath TEXT, op_depth INTEGER, presence TEXT, kind TEXT,
checksum TEXT, recorded_size INTEGER, recorded_time INTEGER
);
CREATE TABLE actual_node (local_relpath TEXT, conflict_data BLOB);",
)
.unwrap();
connection
}
fn insert_node(
connection: &rusqlite::Connection,
relpath: &str,
op_depth: i64,
presence: &str,
kind: &str,
checksum: Option<String>,
size: Option<i64>,
time: Option<i64>,
) {
connection
.execute(
"INSERT INTO nodes VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![relpath, op_depth, presence, kind, checksum, size, time],
)
.unwrap();
}
fn sha1_of(content: &str) -> String {
let digest = Sha1::digest(content.as_bytes());
let mut hex = String::new();
for byte in digest {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
format!("$sha1${hex}")
}
fn state_of<'a>(changes: &'a [SvnChange], path: &str) -> Option<&'a SvnChange> {
changes.iter().find(|change| change.rel_path == path)
}
#[test]
fn classifies_repository_and_working_copy() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
for sub in ["conf", "db", "hooks"] {
std::fs::create_dir_all(repo.join(sub)).unwrap();
}
std::fs::write(repo.join("format"), "8\n").unwrap();
assert_eq!(classify_dir(&repo), SvnDirKind::Repository);
let wc = temp.path().join("wc");
fake_working_copy(&wc);
let inner = wc.join("src");
std::fs::create_dir_all(&inner).unwrap();
assert_eq!(classify_dir(&inner), SvnDirKind::WorkingCopy(wc.clone()));
assert_eq!(classify_dir(temp.path()), SvnDirKind::Plain);
}
#[test]
fn derives_the_full_state_alphabet_from_wc_db() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let connection = fake_working_copy(root);
insert_node(&connection, "", 0, "normal", "dir", None, None, None);
// 干净文件:大小/时间都与记录一致。
std::fs::write(root.join("clean.txt"), "clean").unwrap();
let clean_meta = std::fs::metadata(root.join("clean.txt")).unwrap();
let clean_micros = clean_meta
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros() as i64;
insert_node(
&connection,
"clean.txt",
0,
"normal",
"file",
Some(sha1_of("clean")),
Some(5),
Some(clean_micros),
);
// touch 过但内容相同:时间戳不同 + SHA-1 相同 → 不报告。
std::fs::write(root.join("touched.txt"), "same!").unwrap();
insert_node(
&connection,
"touched.txt",
0,
"normal",
"file",
Some(sha1_of("same!")),
Some(5),
Some(1),
);
// 真改过:大小不同。
std::fs::write(root.join("changed.txt"), "different content").unwrap();
insert_node(
&connection,
"changed.txt",
0,
"normal",
"file",
Some(sha1_of("old")),
Some(3),
Some(1),
);
// 新增 / 替换 / 删除 / 丢失。
std::fs::write(root.join("added.txt"), "new").unwrap();
insert_node(&connection, "added.txt", 1, "normal", "file", None, None, None);
std::fs::write(root.join("replaced.txt"), "re-added").unwrap();
insert_node(&connection, "replaced.txt", 0, "normal", "file", None, Some(1), Some(1));
insert_node(&connection, "replaced.txt", 1, "normal", "file", None, None, None);
insert_node(&connection, "deleted.txt", 0, "normal", "file", None, Some(1), Some(1));
insert_node(&connection, "deleted.txt", 1, "base-deleted", "file", None, None, None);
insert_node(
&connection,
"missing.txt",
0,
"normal",
"file",
None,
Some(1),
Some(1),
);
// 冲突覆盖其他状态。
std::fs::write(root.join("conflict.txt"), "x").unwrap();
insert_node(&connection, "conflict.txt", 0, "normal", "file", None, Some(1), Some(1));
connection
.execute(
"INSERT INTO actual_node VALUES ('conflict.txt', x'00')",
[],
)
.unwrap();
// 未版本化的散文件。
std::fs::write(root.join("stray.txt"), "?").unwrap();
drop(connection);
let changes = working_copy_status(root).unwrap();
assert!(state_of(&changes, "clean.txt").is_none(), "clean file must stay silent");
assert!(state_of(&changes, "touched.txt").is_none(), "touch without edit must stay silent");
assert_eq!(state_of(&changes, "changed.txt").unwrap().state, SvnState::Modified);
assert_eq!(state_of(&changes, "added.txt").unwrap().state, SvnState::Added);
assert_eq!(state_of(&changes, "replaced.txt").unwrap().state, SvnState::Replaced);
assert_eq!(state_of(&changes, "deleted.txt").unwrap().state, SvnState::Deleted);
assert_eq!(state_of(&changes, "missing.txt").unwrap().state, SvnState::Missing);
assert_eq!(state_of(&changes, "conflict.txt").unwrap().state, SvnState::Conflicted);
assert_eq!(state_of(&changes, "stray.txt").unwrap().state, SvnState::Unversioned);
}
}
+45 -8
View File
@@ -15,7 +15,16 @@ use crate::event::{Event, EventType};
use crate::message_bar::{Message, MessageType};
const RELEASES_API: &str = "https://api.github.com/repos/Kuddev/nebula/releases/latest";
const RELEASES_PAGE: &str = "https://github.com/Kuddev/nebula/releases";
pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/nebula/releases";
/// 设置主页手动检查所需的完整结果。启动检查与手动检查共用这一份版本
/// 比较,避免两处对预发布后缀或分段数字产生不同结论。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateCheckResult {
pub current: String,
pub latest: String,
pub update_available: bool,
}
/// Kick off the once-per-process background check. The result (if any)
/// arrives as a regular [`EventType::Message`] banner, which the message bar
@@ -28,7 +37,13 @@ pub fn spawn_once(proxy: EventLoopProxy<Event>) {
let spawned = std::thread::Builder::new().name("update-check".into()).spawn(move || {
// 等窗口与首个会话安顿好再查,别和启动抢磁盘/网络。
std::thread::sleep(Duration::from_secs(12));
let Some(latest) = fetch_latest_version() else { return };
let latest = match fetch_latest_version() {
Ok(latest) => latest,
Err(error) => {
log::debug!("update-check: {error}");
return;
},
};
let current = env!("CARGO_PKG_VERSION");
if !is_newer(&latest, current) {
log::debug!("update-check: v{current} is current (latest v{latest})");
@@ -45,7 +60,15 @@ pub fn spawn_once(proxy: EventLoopProxy<Event>) {
}
}
fn fetch_latest_version() -> Option<String> {
/// 立即检查 GitHub 最新 release;调用方必须把它放到后台执行器,避免
/// `curl` 的网络等待阻塞 UI 线程。
pub fn check_now() -> Result<UpdateCheckResult, String> {
let latest = fetch_latest_version()?;
let current = env!("CARGO_PKG_VERSION").to_owned();
Ok(UpdateCheckResult { update_available: is_newer(&latest, &current), current, latest })
}
fn fetch_latest_version() -> Result<String, String> {
let mut command = Command::new("curl");
command.args([
"-fsSL",
@@ -63,14 +86,28 @@ fn fetch_latest_version() -> Option<String> {
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
let output = command.output().ok()?;
let output = command.output().map_err(|error| format!("无法启动 curl:{error}"))?;
if !output.status.success() {
return None;
let detail = String::from_utf8_lossy(&output.stderr);
let detail = detail.trim();
return Err(if detail.is_empty() {
format!("GitHub 请求失败(curl {})", output.status)
} else {
format!("GitHub 请求失败:{detail}")
});
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
let tag = json.get("tag_name")?.as_str()?;
let json: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|error| format!("GitHub 返回了无效数据:{error}"))?;
let tag = json
.get("tag_name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "GitHub release 缺少 tag_name".to_owned())?;
let version = tag.trim().trim_start_matches(['v', 'V']);
(!version.is_empty()).then(|| version.to_owned())
if version.is_empty() {
Err("GitHub release 的版本号为空".to_owned())
} else {
Ok(version.to_owned())
}
}
/// Compare dotted numeric prefixes ("0.7.10" > "0.7.9"); anything after the
+35 -12
View File
@@ -2177,6 +2177,9 @@ impl WindowContext {
state.agent_hook_seen = true;
state.agent_status_source = crate::ai_agents::AgentStatusSource::Hook;
state.agent_status_rule = None;
// 精确边沿抵达 = 屏幕检测的空闲计数作废(上一回合攒下的拍数
// 不能把新回合的第一个空闲闪现立即降级)。
state.idle_screen_streak = 0;
// 会话身份跟着事件走:同一个 pane 里 /clear、重开会话都会带来
// 新 id,最后一次上报永远是权威。133;D(CLI 退回提示符)清除。
if let Some(id) = ev.session_id.as_deref() {
@@ -2216,13 +2219,15 @@ impl WindowContext {
},
crate::ai_hook::AiHookKind::ToolComplete => {
let state = &mut self.panes[idx].nebula_state;
// Tool completion is meaningful chiefly after a permission
// wait: the user answered and work resumed.
if state.agent_status == crate::ai_agents::AgentStatus::Blocked {
state.agent_status = crate::ai_agents::AgentStatus::Working;
state.awaiting_input = false;
state.needs_attention = false;
}
// 一个工具刚跑完 = agent 正在干活,这是无条件事实。此前只
// 认 Blocked→Working,漏掉了「回合经不发 PromptSubmit 的路径
// 继续(授权点头、队列消息)后状态还挂在 Done」的场景——
// 也就是用户看到的「还在执行却已经显示完成蓝点」。
state.agent_status = crate::ai_agents::AgentStatus::Working;
state.awaiting_input = false;
state.needs_attention = false;
state.finished_unseen = false;
state.command_started.get_or_insert_with(Instant::now);
},
crate::ai_hook::AiHookKind::TurnDone | crate::ai_hook::AiHookKind::NeedsAttention => {
// codex 的 notify 只有"回合完成"一种事件:弹出交互式提问时
@@ -2338,11 +2343,29 @@ impl WindowContext {
};
let state = &mut pane.nebula_state;
// An exact hook-done must not be downgraded merely because an idle
// prompt is visible. Strong live blocker/working chrome may still
// correct a missed/new event.
if detection.status == crate::ai_agents::AgentStatus::Idle && state.agent_hook_seen {
continue;
// 空闲提示符降级要分三档(#「转圈不停」的根修):
// - hook 报过的 Done/Blocked 是精确终态,不被提示符降级;
// - Working 可能是丢了 TurnDone 的僵尸态(打断的回合没有 Stop
// 事件),但单拍空闲可能只是重绘间隙——连续两拍才收场;
// - 其余状态照常应用。
if detection.status == crate::ai_agents::AgentStatus::Idle {
if state.agent_hook_seen
&& matches!(
state.agent_status,
crate::ai_agents::AgentStatus::Done
| crate::ai_agents::AgentStatus::Blocked
)
{
continue;
}
if state.agent_status == crate::ai_agents::AgentStatus::Working {
state.idle_screen_streak = state.idle_screen_streak.saturating_add(1);
if state.idle_screen_streak < 2 {
continue;
}
}
} else {
state.idle_screen_streak = 0;
}
let previous = state.agent_status;
if previous != detection.status
+122
View File
@@ -0,0 +1,122 @@
//! 文件行数预算守护(docs/project-constraints.md 第 1 条)。
//!
//! 单个 `.rs` 文件硬上限 2000 行;存量超标文件在 [`GRANDFATHERED`] 登记,
//! 登记值只允许下调。测试失败的正确动作是拆文件,不是上调预算。
use std::fs;
use std::path::{Path, PathBuf};
/// 新代码红线:超过它就必须拆(docs/project-constraints.md)。
const MAX_LINES: usize = 2000;
/// 存量豁免清单(相对仓库根,正斜杠路径 → 行数上限)。
///
/// 登记值 = 收录当天的行数加不足百行的呼吸空间:小修小补不触线,成规模
/// 新增必须伴随拆分。拆小后同步下调或删除对应条目。
const GRANDFATHERED: &[(&str, usize)] = &[
("nebula_app/src/display/mod.rs", 12000),
("nebula_app/src/display/settings.rs", 8650),
// 2026-08-17 上调(4850 → 5150):git 树 VS Code 化 + 源码 tab 接线;
// 下一步应把 VCS 面板拆成独立文件后下调。
("nebula_app/src/gpui_shell/workspace.rs", 5150),
// 2026-08-17 下调(4600 → 3600):SSH 区拆到 ssh_settings.rs 的成果锁定。
("nebula_app/src/gpui_shell/settings_pane.rs", 3600),
("nebula_app/src/display/command_palette.rs", 3980),
("nebula_app/src/window_context.rs", 3800),
("nebula_app/src/event.rs", 3780),
("nebula_terminal/src/term/mod.rs", 3650),
// 2026-08-17 上调(2900 → 2970):draw_overlays 几何抽成两壳共享的 plan。
("nebula_app/src/display/terminal_math.rs", 2970),
// 2026-08-17 上调(2680 → 2760):SVN wc.db 回退 + 单文件 stage/discard。
("nebula_app/src/display/side_panel.rs", 2760),
("nebula_app/src/gpui_shell/terminal/view.rs", 2540),
("nebula_app/src/display/chrome.rs", 2550),
("nebula_app/src/display/markdown_view.rs", 2150),
];
/// 这些目录不受预算约束:构建产物、vendored 第三方代码、版本库内部。
fn excluded(component: &str) -> bool {
component == ".git"
|| component == "third_party"
|| component == "target"
|| component.starts_with(".target")
}
fn collect_rust_files(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
// 隐藏项(.git、.tmp_* 草稿等)不是源码,不受预算约束。
if name.starts_with('.') {
continue;
}
if path.is_dir() {
if !excluded(&name) {
collect_rust_files(&path, out);
}
} else if path.extension().is_some_and(|extension| extension == "rs") {
out.push(path);
}
}
}
#[test]
fn every_source_file_respects_its_line_budget() {
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("nebula_app sits directly under the repo root")
.to_path_buf();
let mut files = Vec::new();
collect_rust_files(&repo_root, &mut files);
assert!(
files.len() > 100,
"行数守护扫到的文件数异常({}),排除规则可能吞掉了源码目录",
files.len()
);
let mut violations = Vec::new();
let mut stale_grants = Vec::new();
for path in files {
let relative = path
.strip_prefix(&repo_root)
.expect("collected files live under the repo root")
.to_string_lossy()
.replace('\\', "/");
let Ok(content) = fs::read(&path) else {
panic!("无法读取 {relative}");
};
// 按换行字节计数:与编码无关,空文件为 0 行。
let lines = content.iter().filter(|byte| **byte == b'\n').count()
+ usize::from(!content.is_empty() && content.last() != Some(&b'\n'));
let grant = GRANDFATHERED
.iter()
.find(|(name, _)| *name == relative)
.map(|(_, budget)| *budget);
let budget = grant.unwrap_or(MAX_LINES);
if lines > budget {
violations.push(format!("{relative}: {lines} 行 > 预算 {budget}"));
}
// 豁免文件瘦身到红线以内后,条目必须删除,防止预算复胖。
if let Some(budget) = grant
&& lines <= MAX_LINES
{
stale_grants.push(format!("{relative}: 已降到 {lines} 行(≤{MAX_LINES}),豁免条目({budget})应删除"));
}
}
assert!(
violations.is_empty(),
"以下文件超出行数预算(docs/project-constraints.md 第 1 条,正确动作是拆文件):\n{}",
violations.join("\n")
);
assert!(
stale_grants.is_empty(),
"以下豁免条目已过期:\n{}",
stale_grants.join("\n")
);
}
+35
View File
@@ -550,6 +550,35 @@ impl NewTabPositionName {
}
}
/// 侧栏版本控制视图的数据源:自动探测(就近的 .svn 提示 + git 优先),
/// 或强制只认 Git / SVN(混合仓库、或想屏蔽其中一种时用)。
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum VcsDisplayName {
#[default]
Auto,
Git,
Svn,
}
impl VcsDisplayName {
pub fn from_settings(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Self::Auto),
"git" => Some(Self::Git),
"svn" => Some(Self::Svn),
_ => None,
}
}
pub fn settings_value(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Git => "git",
Self::Svn => "svn",
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum CellWidthModeName {
#[default]
@@ -631,6 +660,8 @@ pub struct RuntimeSettings {
pub density: DensityName,
pub new_tab_position: NewTabPositionName,
pub cell_width_mode: CellWidthModeName,
/// 侧栏版本控制视图的数据源(auto/git/svn)。
pub vcs_display: VcsDisplayName,
/// 新会话欢迎屏 fastfetch(默认关:启动速度优先于观感,旧壳裁定)。
pub fetch: bool,
pub keep_session: bool,
@@ -707,6 +738,10 @@ impl RuntimeSettings {
.value("cell_width_mode")
.and_then(CellWidthModeName::from_settings)
.unwrap_or_default(),
vcs_display: raw
.value("vcs_display")
.and_then(VcsDisplayName::from_settings)
.unwrap_or_default(),
fetch: raw.bool_on("fetch").unwrap_or(false),
keep_session: raw.bool_on("keep_session").unwrap_or(false),
restore_session: raw.bool_on("restore_session").unwrap_or(true),
+46 -3
View File
@@ -210,6 +210,12 @@ pub struct CursorSnapshot {
pub shape: CursorShape,
/// Cursor sits on a wide char: draw it two cells wide.
pub wide: bool,
/// Glyph under the cursor. Claude Code / Codex hide DECSCUSR and draw a
/// reverse-video space or block element; the frontend needs the cell to
/// recolor that fake cursor (old shell `is_application_cursor_cell`).
pub cell_ch: char,
pub cell_flags: Flags,
pub cell_bg: Color,
}
/// A cell rendered by built-in geometry ([`boxdraw`]) instead of a font:
@@ -389,13 +395,16 @@ impl RenderSnapshot {
flush(&mut snap.segments, &mut open);
if let Some(vp) = cursor_vp {
if vp.line < rows && vp.column.0 < cols && cursor_shape != CursorShape::Hidden {
let wide = term.grid()[content.cursor.point].flags.contains(Flags::WIDE_CHAR);
if vp.line < rows && vp.column.0 < cols {
let cell = &term.grid()[content.cursor.point];
snap.cursor = Some(CursorSnapshot {
row: vp.line as u16,
col: vp.column.0 as u16,
shape: cursor_shape,
wide,
wide: cell.flags.contains(Flags::WIDE_CHAR),
cell_ch: cell.c,
cell_flags: cell.flags,
cell_bg: cell.bg,
});
}
}
@@ -553,6 +562,40 @@ mod tests {
assert!(snap.cursor.expect("cursor").wide);
}
#[test]
fn capture_keeps_a_hidden_cursor_and_its_cell() {
use crate::vte::ansi::{Handler, NamedPrivateMode};
let mut term = term_with(&[" "]);
term.grid_mut().cursor.point = crate::index::Point::new(Line(0), Column(0));
term.grid_mut()[Line(0)][Column(0)].flags.insert(Flags::INVERSE);
term.unset_private_mode(NamedPrivateMode::ShowCursor.into());
let snap = RenderSnapshot::capture(&term, &cfg(4, 8));
let cursor = snap.cursor.expect("hidden cursor still has a cell");
assert_eq!(cursor.shape, CursorShape::Hidden);
assert_eq!((cursor.row, cursor.col), (0, 0));
assert!(cursor.cell_flags.contains(Flags::INVERSE));
assert_eq!(cursor.cell_ch, ' ');
assert!(
snap.bg_runs.iter().any(|run| run.row == 0 && run.start <= 0 && 0 < run.end),
"inverse space still emits a bg run so the frontend can skip the black cell"
);
}
#[test]
fn capture_keeps_decscusr_hidden_block_glyph() {
use crate::vte::ansi::Handler;
let mut term = term_with(&["█"]);
term.grid_mut().cursor.point = crate::index::Point::new(Line(0), Column(0));
term.set_cursor_shape(CursorShape::Hidden);
let snap = RenderSnapshot::capture(&term, &cfg(4, 8));
let cursor = snap.cursor.expect("DECSCUSR hidden cursor still has a cell");
assert_eq!(cursor.shape, CursorShape::Hidden);
assert_eq!(cursor.cell_ch, '█');
assert_eq!(snap.box_glyphs.iter().map(|g| g.ch).collect::<Vec<_>>(), vec!['█']);
}
#[test]
fn capture_routes_builtin_glyphs_to_geometry() {
let term = term_with(&["a─█b"]);
+39 -3
View File
@@ -24,6 +24,32 @@ public class W {
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags);
[DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
public delegate bool EnumProc(IntPtr h, IntPtr lparam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lparam);
// Process.MainWindowHandle picks whatever top-level window answers first --
// for nebula that is a 6x6 helper window (and a minimized one reports
// -32000,-32000). Enumerate instead and keep the largest visible window
// owned by the pid: that is the real shell window in both shells.
public static IntPtr FindMainWindow(int pid) {
IntPtr best = IntPtr.Zero;
long bestArea = 0;
EnumWindows(delegate(IntPtr h, IntPtr l) {
uint wpid;
GetWindowThreadProcessId(h, out wpid);
if (wpid != (uint)pid) return true;
if (!IsWindowVisible(h)) return true;
RECT r;
GetWindowRect(h, out r);
long area = (long)(r.R - r.L) * (long)(r.B - r.T);
// Minimized windows sit at -32000; never let one win the comparison.
if (r.L <= -30000 || r.T <= -30000) return true;
if (area > bestArea) { bestArea = area; best = h; }
return true;
}, IntPtr.Zero);
return best;
}
public struct RECT { public int L, T, R, B; }
}
'@
@@ -45,8 +71,14 @@ if ($ProcId -eq 0) {
$proc = Get-Process -Id $ProcId -ErrorAction SilentlyContinue
if (-not $proc) { Write-Output "PROCESS $ProcId GONE"; exit 1 }
if ($Kill) { $proc.Kill(); Write-Output "killed $ProcId"; exit 0 }
$h = $proc.MainWindowHandle
if ($h -eq 0) { Write-Output "NO WINDOW for $ProcId"; exit 1 }
$h = [W]::FindMainWindow($ProcId)
if ($h -eq [IntPtr]::Zero) {
# Fall back to the (unreliable) handle so a minimized-only state still
# reports something actionable instead of looking like a dead process.
$h = $proc.MainWindowHandle
if ($h -eq 0) { Write-Output "NO WINDOW for $ProcId"; exit 1 }
Write-Output "WARN: no visible top-level window; falling back to MainWindowHandle"
}
$r = New-Object W+RECT
[void][W]::GetWindowRect($h, [ref]$r)
Write-Output "pid=$ProcId rect=($($r.L),$($r.T),$($r.R),$($r.B)) size=$($r.R-$r.L)x$($r.B-$r.T) dpi=$([W]::GetDpiForWindow($h))"
@@ -80,7 +112,11 @@ if ($RightClick -ne "") {
if ($Scroll -ne 0) {
$sx = [int]($r.L + ($r.R - $r.L) * 0.6); $sy = [int]($r.T + ($r.B - $r.T) * 0.5)
[void][W]::SetCursorPos($sx, $sy); Start-Sleep -Milliseconds 150
[W]::mouse_event(0x0800, 0, 0, [uint32]($Scroll * 120), [UIntPtr]::Zero)
# WHEEL_DELTA is signed (negative scrolls down) but the P/Invoke signature
# declares dwData as uint: casting a negative int straight to [uint32]
# throws InvalidCast. Mask to the two's-complement bit pattern instead.
$wheel = [uint32]([int64]($Scroll * 120) -band 0xFFFFFFFFL)
[W]::mouse_event(0x0800, 0, 0, $wheel, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 500
}
if ($CtrlScroll -ne 0) {