diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8019c650..ce268ade 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -52,6 +52,25 @@ jobs:
cache-targets: "false"
save-if: ${{ github.ref == 'refs/heads/main' }}
+ - name: Set workspace version from tag
+ shell: bash
+ run: |
+ export VERSION="${GITHUB_REF_NAME#v}"
+ python3 -c "
+ import os, re, pathlib
+ v = os.environ['VERSION']
+ p = pathlib.Path('Cargo.toml')
+ txt = p.read_text()
+ txt = re.sub(
+ r'(\[workspace\.package\]\s*\nversion\s*=\s*)\"[^\"]*\"',
+ rf'\1\"{v}\"',
+ txt,
+ count=1,
+ )
+ p.write_text(txt)
+ "
+ cargo generate-lockfile
+
- name: Build binaries
run: |
cargo build --profile dist --features gpui-full --target ${{ matrix.target }} -p codirigent
@@ -248,7 +267,8 @@ jobs:
- name: Generate checksums
run: |
cd artifacts
- sha256sum * > checksums-sha256.txt
+ # Exclude MSI — it will be replaced by a locally signed version
+ sha256sum $(ls | grep -v '\.msi$') > checksums-sha256.txt
cat checksums-sha256.txt
- name: Create GitHub Release
@@ -256,7 +276,7 @@ jobs:
with:
artifacts: "artifacts/*"
token: ${{ secrets.GITHUB_TOKEN }}
- draft: false
+ draft: true
generateReleaseNotes: true
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
diff --git a/.gitignore b/.gitignore
index 4baa4c10..5828ef7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,9 +7,14 @@
# -----------------------------------------------------------------------------
+# Claude Code project instructions (local only)
+CLAUDE.md
+
# Development plans
plans/
docs/superpowers/
+docs/specs/
+Tasks.md
# Superpowers skill state
.superpowers/
@@ -139,4 +144,4 @@ coverage/
.worktrees/
worktrees/
vibehq-hub/
-.docx
\ No newline at end of file
+.docx
diff --git a/Cargo.lock b/Cargo.lock
index 886b7e59..582fedf1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 3
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -20,6 +29,19 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "const-random",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -137,6 +159,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
[[package]]
name = "as-slice"
version = "0.2.1"
@@ -146,6 +174,47 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "ash-window"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82"
+dependencies = [
+ "ash",
+ "raw-window-handle",
+ "raw-window-metal",
+]
+
+[[package]]
+name = "ashpd"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
+dependencies = [
+ "async-fs",
+ "async-net",
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand 0.9.2",
+ "serde",
+ "serde_repr",
+ "url",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols 0.32.10",
+ "zbus",
+]
+
[[package]]
name = "ashpd"
version = "0.12.1"
@@ -456,6 +525,21 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link 0.2.1",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -547,6 +631,64 @@ dependencies = [
"core2",
]
+[[package]]
+name = "blade-graphics"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e71cfb73b98eb9f58ee84048aa1bdf4e7497fd20c141b57523499fa066b48fed"
+dependencies = [
+ "ash",
+ "ash-window",
+ "bitflags 2.10.0",
+ "bytemuck",
+ "codespan-reporting",
+ "glow",
+ "gpu-alloc",
+ "gpu-alloc-ash",
+ "hidden-trait",
+ "js-sys",
+ "khronos-egl",
+ "libloading",
+ "log",
+ "mint",
+ "naga",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-metal",
+ "objc2-quartz-core",
+ "objc2-ui-kit",
+ "once_cell",
+ "raw-window-handle",
+ "slab",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "blade-macros"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "blade-util"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a6be3a82c001ba7a17b6f8e413ede5d1004e6047213f8efaf0ffc15b5c4904c"
+dependencies = [
+ "blade-graphics",
+ "bytemuck",
+ "log",
+ "profiling",
+]
+
[[package]]
name = "block"
version = "0.1.6"
@@ -620,6 +762,20 @@ name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
[[package]]
name = "byteorder"
@@ -653,6 +809,18 @@ dependencies = [
"thiserror 1.0.69",
]
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
[[package]]
name = "cast"
version = "0.3.0"
@@ -831,6 +999,22 @@ dependencies = [
"error-code",
]
+[[package]]
+name = "cocoa"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c"
+dependencies = [
+ "bitflags 1.3.2",
+ "block",
+ "cocoa-foundation 0.1.2",
+ "core-foundation 0.9.4",
+ "core-graphics 0.23.2",
+ "foreign-types",
+ "libc",
+ "objc",
+]
+
[[package]]
name = "cocoa"
version = "0.26.0"
@@ -839,14 +1023,28 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2"
dependencies = [
"bitflags 2.10.0",
"block",
- "cocoa-foundation",
+ "cocoa-foundation 0.2.0",
"core-foundation 0.10.1",
- "core-graphics",
+ "core-graphics 0.24.0",
"foreign-types",
"libc",
"objc",
]
+[[package]]
+name = "cocoa-foundation"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
+dependencies = [
+ "bitflags 1.3.2",
+ "block",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "libc",
+ "objc",
+]
+
[[package]]
name = "cocoa-foundation"
version = "0.2.0"
@@ -1099,6 +1297,26 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -1131,6 +1349,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "foreign-types",
+ "libc",
+]
+
[[package]]
name = "core-graphics"
version = "0.24.0"
@@ -1144,6 +1375,19 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core-graphics-helmer-fork"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5"
+dependencies = [
+ "bitflags 2.10.0",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "foreign-types",
+ "libc",
+]
+
[[package]]
name = "core-graphics-types"
version = "0.1.3"
@@ -1186,7 +1430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130"
dependencies = [
"core-foundation 0.10.1",
- "core-graphics",
+ "core-graphics 0.24.0",
"foreign-types",
"libc",
]
@@ -1223,6 +1467,29 @@ dependencies = [
"libm",
]
+[[package]]
+name = "cosmic-text"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8"
+dependencies = [
+ "bitflags 2.10.0",
+ "fontdb 0.16.2",
+ "log",
+ "rangemap",
+ "rustc-hash 1.1.0",
+ "rustybuzz 0.14.1",
+ "self_cell",
+ "smol_str",
+ "swash",
+ "sys-locale",
+ "ttf-parser 0.21.1",
+ "unicode-bidi",
+ "unicode-linebreak",
+ "unicode-script",
+ "unicode-segmentation",
+]
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1802,6 +2069,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "font-types"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
+dependencies = [
+ "bytemuck",
+]
+
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
@@ -1811,6 +2087,20 @@ dependencies = [
"roxmltree",
]
+[[package]]
+name = "fontdb"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
+dependencies = [
+ "fontconfig-parser",
+ "log",
+ "memmap2",
+ "slotmap",
+ "tinyvec",
+ "ttf-parser 0.20.0",
+]
+
[[package]]
name = "fontdb"
version = "0.23.0"
@@ -1822,7 +2112,7 @@ dependencies = [
"memmap2",
"slotmap",
"tinyvec",
- "ttf-parser",
+ "ttf-parser 0.25.1",
]
[[package]]
@@ -2009,6 +2299,16 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.3",
+ "windows-link 0.2.1",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2046,6 +2346,12 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
[[package]]
name = "git2"
version = "0.20.4"
@@ -2092,6 +2398,48 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "glow"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "gpu-alloc"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9"
+dependencies = [
+ "bitflags 2.10.0",
+ "gpu-alloc-types",
+]
+
+[[package]]
+name = "gpu-alloc-ash"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "643756f08ef6def813c776199e4766596395a4c6530373c9a4374a64de2f53a1"
+dependencies = [
+ "ash",
+ "gpu-alloc-types",
+ "tinyvec",
+]
+
+[[package]]
+name = "gpu-alloc-types"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
[[package]]
name = "gpui"
version = "0.2.1"
@@ -2099,21 +2447,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6cc8eeb8b75347d602fb1919e2c42f26c43c2a75d71b46200a8c24e8ba4c4c"
dependencies = [
"anyhow",
+ "as-raw-xcb-connection",
+ "ashpd 0.11.1",
"async-task",
+ "backtrace",
"bindgen 0.71.1",
+ "blade-graphics",
+ "blade-macros",
+ "blade-util",
"block",
+ "bytemuck",
"calloop",
+ "calloop-wayland-source",
"cbindgen",
- "cocoa",
+ "cocoa 0.26.0",
"core-foundation 0.10.1",
"core-foundation-sys",
- "core-graphics",
+ "core-graphics 0.24.0",
"core-text",
"core-video",
+ "cosmic-text",
"ctor",
"derive_more",
"embed-resource",
"etagere",
+ "filedescriptor",
"flume",
"foreign-types",
"futures",
@@ -2137,6 +2495,7 @@ dependencies = [
"num_cpus",
"objc",
"oo7",
+ "open",
"parking",
"parking_lot",
"pathfinder_geometry",
@@ -2159,12 +2518,22 @@ dependencies = [
"usvg",
"uuid",
"waker-fn",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-cursor",
+ "wayland-protocols 0.31.2",
+ "wayland-protocols-plasma",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-numerics 0.2.0",
"windows-registry 0.5.3",
"workspace-hack",
+ "x11-clipboard",
+ "x11rb",
+ "xkbcommon",
"zed-font-kit",
+ "zed-scap",
+ "zed-xim",
]
[[package]]
@@ -2298,12 +2667,15 @@ dependencies = [
"dunce",
"futures",
"futures-lite 1.13.0",
+ "git2",
"globset",
"gpui_collections",
+ "gpui_util_macros",
"itertools 0.14.0",
"libc",
"log",
"nix 0.29.0",
+ "rand 0.9.2",
"regex",
"rust-embed",
"schemars",
@@ -2370,10 +2742,16 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.15.5"
+version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
-dependencies = [
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
"foldhash",
]
@@ -2422,6 +2800,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+[[package]]
+name = "hidden-trait"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -2819,6 +3208,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
[[package]]
name = "is-terminal"
version = "0.4.17"
@@ -2830,6 +3228,16 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
[[package]]
name = "itertools"
version = "0.10.5"
@@ -2892,6 +3300,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+]
+
[[package]]
name = "kurbo"
version = "0.11.3"
@@ -3267,6 +3685,12 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "mint"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff"
+
[[package]]
name = "mio"
version = "1.1.1"
@@ -3316,6 +3740,7 @@ dependencies = [
"num-traits",
"once_cell",
"rustc-hash 1.1.0",
+ "spirv",
"strum 0.26.3",
"thiserror 2.0.18",
"unicode-ident",
@@ -3397,6 +3822,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3525,6 +3959,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
+ "objc_exception",
+]
+
+[[package]]
+name = "objc-foundation"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
+dependencies = [
+ "block",
+ "objc",
+ "objc_id",
]
[[package]]
@@ -3668,6 +4114,18 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -3676,7 +4134,40 @@ checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
"objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.10.0",
+ "objc2",
+ "objc2-core-foundation",
"objc2-foundation",
+ "objc2-quartz-core",
+]
+
+[[package]]
+name = "objc_exception"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc_id"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+dependencies = [
+ "objc",
]
[[package]]
@@ -3701,7 +4192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d"
dependencies = [
"aes",
- "ashpd",
+ "ashpd 0.12.1",
"async-fs",
"async-io",
"async-lock",
@@ -3735,6 +4226,16 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+[[package]]
+name = "open"
+version = "5.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
+dependencies = [
+ "is-wsl",
+ "libc",
+]
+
[[package]]
name = "openssl-probe"
version = "0.1.6"
@@ -4149,6 +4650,24 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+[[package]]
+name = "quick-xml"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -4278,6 +4797,12 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rangemap"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
+
[[package]]
name = "rav1e"
version = "0.8.1"
@@ -4334,6 +4859,18 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+[[package]]
+name = "raw-window-metal"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1"
+dependencies = [
+ "cocoa 0.25.0",
+ "core-graphics 0.23.2",
+ "objc",
+ "raw-window-handle",
+]
+
[[package]]
name = "rayon"
version = "1.11.0"
@@ -4354,6 +4891,16 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "read-fonts"
+version = "0.40.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e47cf772000d7aea67a6ea51de8a21b77674aaa538f3b523d89ae6514bf45bb4"
+dependencies = [
+ "bytemuck",
+ "font-types",
+]
+
[[package]]
name = "redox_syscall"
version = "0.2.16"
@@ -4560,6 +5107,12 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "rustc-demangle"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
+
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4680,6 +5233,23 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+[[package]]
+name = "rustybuzz"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c"
+dependencies = [
+ "bitflags 2.10.0",
+ "bytemuck",
+ "libm",
+ "smallvec",
+ "ttf-parser 0.21.1",
+ "unicode-bidi-mirroring 0.2.0",
+ "unicode-ccc 0.2.0",
+ "unicode-properties",
+ "unicode-script",
+]
+
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -4691,9 +5261,9 @@ dependencies = [
"core_maths",
"log",
"smallvec",
- "ttf-parser",
- "unicode-bidi-mirroring",
- "unicode-ccc",
+ "ttf-parser 0.25.1",
+ "unicode-bidi-mirroring 0.4.0",
+ "unicode-ccc 0.4.0",
"unicode-properties",
"unicode-script",
]
@@ -4757,12 +5327,41 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "screencapturekit"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e"
+dependencies = [
+ "screencapturekit-sys",
+]
+
+[[package]]
+name = "screencapturekit-sys"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60"
+dependencies = [
+ "block",
+ "dispatch",
+ "objc",
+ "objc-foundation",
+ "objc_id",
+ "once_cell",
+]
+
[[package]]
name = "sdd"
version = "3.0.10"
@@ -4798,6 +5397,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "self_cell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
+
[[package]]
name = "semver"
version = "1.0.27"
@@ -5061,6 +5666,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+[[package]]
+name = "skrifa"
+version = "0.43.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cbe997d0f2480442d727488fbe2150779114cbe480ecdbadef58b33e0318ffb"
+dependencies = [
+ "bytemuck",
+ "read-fonts",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -5099,6 +5714,12 @@ dependencies = [
"futures-lite 2.6.1",
]
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+
[[package]]
name = "socket2"
version = "0.6.2"
@@ -5118,6 +5739,15 @@ dependencies = [
"lock_api",
]
+[[package]]
+name = "spirv"
+version = "0.3.0+sdk-1.3.268.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -5337,6 +5967,17 @@ dependencies = [
"siphasher",
]
+[[package]]
+name = "swash"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e"
+dependencies = [
+ "skrifa",
+ "yazi",
+ "zeno",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -5359,6 +6000,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -5379,6 +6031,29 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "sysinfo"
+version = "0.31.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+ "memchr",
+ "ntapi",
+ "rayon",
+ "windows 0.57.0",
+]
+
[[package]]
name = "system-configuration"
version = "0.6.1"
@@ -5418,6 +6093,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb"
+[[package]]
+name = "tao-core-video-sys"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6"
+dependencies = [
+ "cfg-if",
+ "core-foundation-sys",
+ "libc",
+ "objc",
+]
+
[[package]]
name = "tempfile"
version = "3.24.0"
@@ -5514,6 +6201,15 @@ dependencies = [
"zune-jpeg 0.4.21",
]
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -5865,6 +6561,18 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "ttf-parser"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
+
+[[package]]
+name = "ttf-parser"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -5909,12 +6617,24 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+[[package]]
+name = "unicode-bidi-mirroring"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86"
+
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
+[[package]]
+name = "unicode-ccc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656"
+
[[package]]
name = "unicode-ccc"
version = "0.4.0"
@@ -5927,6 +6647,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -5985,13 +6711,13 @@ dependencies = [
"base64",
"data-url",
"flate2",
- "fontdb",
+ "fontdb 0.23.0",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
- "rustybuzz",
+ "rustybuzz 0.20.1",
"simplecss",
"siphasher",
"strict-num",
@@ -6239,6 +6965,103 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wayland-backend"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.3",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
+dependencies = [
+ "bitflags 2.10.0",
+ "rustix 1.1.3",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078"
+dependencies = [
+ "rustix 1.1.3",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols 0.31.2",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml 0.41.0",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
[[package]]
name = "web-sys"
version = "0.3.85"
@@ -6329,6 +7152,16 @@ dependencies = [
"windows_x86_64_msvc 0.24.0",
]
+[[package]]
+name = "windows"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
+dependencies = [
+ "windows-core 0.57.0",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows"
version = "0.61.3"
@@ -6354,6 +7187,19 @@ dependencies = [
"windows-numerics 0.3.1",
]
+[[package]]
+name = "windows-capture"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24"
+dependencies = [
+ "parking_lot",
+ "rayon",
+ "thiserror 2.0.18",
+ "windows 0.61.3",
+ "windows-future 0.2.1",
+]
+
[[package]]
name = "windows-collections"
version = "0.2.0"
@@ -6372,14 +7218,26 @@ dependencies = [
"windows-core 0.62.2",
]
+[[package]]
+name = "windows-core"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
+dependencies = [
+ "windows-implement 0.57.0",
+ "windows-interface 0.57.0",
+ "windows-result 0.1.2",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -6391,8 +7249,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -6420,6 +7278,17 @@ dependencies = [
"windows-threading 0.2.1",
]
+[[package]]
+name = "windows-implement"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -6431,6 +7300,17 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "windows-interface"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -6496,6 +7376,15 @@ dependencies = [
"windows-strings 0.4.2",
]
+[[package]]
+name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -6896,6 +7785,46 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-clipboard"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
+dependencies = [
+ "libc",
+ "x11rb",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "as-raw-xcb-connection",
+ "gethostname",
+ "libc",
+ "rustix 1.1.3",
+ "x11rb-protocol",
+ "xcursor",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
[[package]]
name = "xattr"
version = "0.2.3"
@@ -6905,6 +7834,60 @@ dependencies = [
"libc",
]
+[[package]]
+name = "xcb"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+ "quick-xml 0.30.0",
+ "x11",
+]
+
+[[package]]
+name = "xcursor"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
+
+[[package]]
+name = "xim-ctext"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "xim-parser"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
+[[package]]
+name = "xkbcommon"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
+dependencies = [
+ "as-raw-xcb-connection",
+ "libc",
+ "memmap2",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
[[package]]
name = "xml-rs"
version = "0.8.28"
@@ -6923,6 +7906,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
+[[package]]
+name = "yazi"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
+
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.0"
@@ -7041,7 +8030,7 @@ dependencies = [
"bitflags 2.10.0",
"byteorder",
"core-foundation 0.10.1",
- "core-graphics",
+ "core-graphics 0.24.0",
"core-text",
"dirs 5.0.1",
"dwrote",
@@ -7107,6 +8096,48 @@ dependencies = [
"windows-registry 0.4.0",
]
+[[package]]
+name = "zed-scap"
+version = "0.0.8-zed"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8"
+dependencies = [
+ "anyhow",
+ "cocoa 0.25.0",
+ "core-graphics-helmer-fork",
+ "log",
+ "objc",
+ "rand 0.8.5",
+ "screencapturekit",
+ "screencapturekit-sys",
+ "sysinfo",
+ "tao-core-video-sys",
+ "windows 0.61.3",
+ "windows-capture",
+ "x11",
+ "xcb",
+]
+
+[[package]]
+name = "zed-xim"
+version = "0.4.0-zed"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642"
+dependencies = [
+ "ahash",
+ "hashbrown 0.14.5",
+ "log",
+ "x11rb",
+ "xim-ctext",
+ "xim-parser",
+]
+
+[[package]]
+name = "zeno"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
+
[[package]]
name = "zerocopy"
version = "0.8.37"
diff --git a/README.md b/README.md
index 37c7f184..8b65bd02 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,9 @@
Website ·
Download ·
Report a Bug ·
- 繁體中文
+ 繁體中文 ·
+ 简体中文
+
---
@@ -71,7 +73,7 @@ Codirigent is a Tmux-style workspace built for this workflow. Open it once, and
Download the `.msi` installer from the [latest release](https://github.com/oso95/Codirigent/releases/latest).
-> **SmartScreen warning:** Windows may show "Windows protected your PC" since the app is not yet code-signed. Click **More info → Run anyway** to proceed.
+> **SmartScreen warning:** Windows may show "Windows protected your PC" on first install. The MSI is code-signed but SmartScreen requires reputation history. Click **More info → Run anyway** to proceed.
### macOS
@@ -127,5 +129,3 @@ Open an issue before major changes. PRs welcome.
## License
GPL-3.0 — see [LICENSE](LICENSE).
-
-Authored by ChengYuanWang
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 00000000..73e90bfb
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,130 @@
+
+
+
+
+Codirigent
+
+
+ 一个可并行运行多个 AI Coding CLI 的终端工作空间
+
+
+
+
+
+
+
+
+
+
+ 官网 ·
+ 下载 ·
+ 报告问题 ·
+ English ·
+ 繁體中文
+
+
+---
+
+https://github.com/user-attachments/assets/51b821fd-dfc3-40f0-b1f3-e8727045f474
+
+---
+
+如果你同时在多个项目中运行 Claude Code、Codex 或 Gemini,你一定知道那种痛苦:打开终端、在仓库之间 `cd` 来回切换、摆放窗口,还要记住哪个 agent 正在做什么。
+
+Codirigent 是一个为这种工作流打造的 Tmux 风格工作空间。只要打开一次,你的会话就会保持在上次离开时的状态,目录正确、布局正确、agent 也正确。
+
+## 功能特性
+
+**多个会话,一个视图** — 并排运行 Claude Code、Codex 和 Gemini。每个会话都会显示实时状态指示器:
+
+| 状态 | | 含义 |
+|--------|---|---------|
+| 空闲 |  | Shell 空闲,没有 agent 活动 |
+| 工作中 |  | Agent 正在生成回复 |
+| 需要关注 |  | Agent 正在等待用户输入或授权 |
+| 就绪 |  | Agent 已完成,回复正在未聚焦的会话中等待 |
+
+---
+
+**自定义布局** — 以任意网格方式排列会话并保存。拖放会话标题即可随时重新安排位置。
+
+---
+
+**同步文件树** — 文件浏览器始终反映当前聚焦的会话,因此你总能知道自己当前位于哪里。
+
+---
+
+**Git worktree 支持** — 在隔离分支上同时运行多个 agent,互不冲突。
+
+---
+
+**会话恢复** — Codirigent 会自动检测并恢复之前的 Claude Code 和 Codex 会话,让你可以从上次中断的地方继续。
+
+---
+
+**智能剪贴板** — 可将文本、文件或图片粘贴到任意会话中。文件路径会自动转换为目标 CLI 可直接使用的 shell 友好格式。
+
+## 下载
+
+> **早期 Alpha 版本** — 预计还会有不少粗糙之处。[欢迎反馈。](https://github.com/oso95/Codirigent/issues)
+
+### Windows
+
+从[最新版本](https://github.com/oso95/Codirigent/releases/latest)下载 `.msi` 安装程序。
+
+> **SmartScreen 警告:** 由于应用尚未进行代码签名,Windows 可能会显示“Windows 已保护你的电脑”。点击 **更多信息 → 仍要运行** 继续。
+
+### macOS
+
+从[最新版本](https://github.com/oso95/Codirigent/releases/latest)下载 `.dmg`。
+
+## Hook 设置(推荐)
+
+Codirigent 使用轻量级 hook 来实时跟踪 agent 状态,显示每个会话当前是 Working、Needs Attention 还是 Response Ready。如果 hook 不可用,Codirigent 会回退到 reader/detector 路径,但精度会稍差一些。
+
+**支持的 CLI 会在首次启动时自动安装 Hooks。** Codirigent 会将它的 `codirigent-hook` 二进制注册到各个 CLI 的配置中:
+
+| CLI | 配置文件 | 自动安装 |
+|-----|-------------|----------------|
+| Claude Code | `~/.claude/settings.json` | 是 |
+| Codex CLI | `~/.codex/config.toml` | 是 |
+| Gemini CLI | `~/.gemini/settings.json` | 是 |
+
+如需确认 hook 已安装,请检查你的 CLI 配置文件中是否出现了 `codirigent-hook`。如果你移动或重新安装了 Codirigent,只需重新启动一次,即可用更新后的二进制路径重新注册 hooks。
+
+## 从源码构建
+
+**前置要求:** Rust 1.75+、Windows 或 macOS
+
+```bash
+git clone https://github.com/oso95/Codirigent.git
+cd Codirigent
+cargo install --path . --all-features
+cargo install --path crates/codirigent-hook
+```
+
+这会将 `codirigent` 和 `codirigent-hook` 一起安装到 `~/.cargo/bin/`。hook 二进制是实时跟踪 agent 状态所必需的组件(见[Hook 设置](#hook-设置推荐))。
+
+如需在不安装的情况下运行:
+
+```bash
+cargo run --all-features
+```
+
+> Linux 支持尚未完成。
+
+## 开发
+
+```bash
+cargo test --all --all-targets # 运行测试
+cargo fmt --all # 格式化
+cargo clippy --all -- -D warnings # lint 检查
+```
+
+## 贡献
+
+进行重大更改前请先开 issue 讨论。欢迎提交 PR。
+
+## 许可证
+
+GPL-3.0 — 详见 [LICENSE](LICENSE)。
diff --git a/README.zh-TW.md b/README.zh-TW.md
index dca4bfda..e2683f55 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -19,7 +19,8 @@
官方網站 ·
下載 ·
回報問題 ·
- English
+ English ·
+ 简体中文
---
diff --git a/crates/codirigent-core/src/hook_installer.rs b/crates/codirigent-core/src/hook_installer.rs
index 08e19a88..91402f46 100644
--- a/crates/codirigent-core/src/hook_installer.rs
+++ b/crates/codirigent-core/src/hook_installer.rs
@@ -10,8 +10,8 @@
//! | Event | Matcher | Purpose |
//! |---|---|---|
//! | `UserPromptSubmit` | (all) | Mark session as "working" |
-//! | `Notification` | `idle_prompt\|permission_prompt` | Mark as "idle" or "needs_attention" |
-//! | `Stop` | (all) | Mark session as "idle" |
+//! | `Notification` | `idle_prompt\|permission_prompt` | Mark as "response_ready" or "needs_attention" |
+//! | `Stop` | (all) | Mark session as "response_ready" |
//!
//! # Signal files
//!
@@ -39,6 +39,7 @@ const HOOK_MARKER: &str = "codirigent-hook";
/// Safe to call on every launch - the function is idempotent.
/// Returns `Ok(true)` if the file was modified, `Ok(false)` if already up to date.
pub fn ensure_hooks_installed(hook_binary: &Path) -> Result {
+ validate_hook_binary(hook_binary)?;
let settings_path =
claude_settings_path().context("Could not determine ~/.claude/settings.json path")?;
@@ -61,6 +62,7 @@ pub fn ensure_hooks_installed(hook_binary: &Path) -> Result {
/// Gemini CLI uses the same JSON hook structure as Claude Code, so the merge is
/// additive and idempotent. Existing hooks from other tools are preserved.
pub fn ensure_gemini_hooks_installed(hook_binary: &Path) -> Result {
+ validate_hook_binary(hook_binary)?;
let settings_path =
gemini_settings_path().context("Could not determine ~/.gemini/settings.json path")?;
@@ -90,6 +92,7 @@ pub fn ensure_gemini_hooks_installed(hook_binary: &Path) -> Result {
/// Safe to call on every launch -- the function is idempotent.
/// Returns `Ok(true)` if the file was modified, `Ok(false)` if already up to date.
pub fn ensure_codex_hooks_installed(hook_binary: &Path) -> Result {
+ validate_hook_binary(hook_binary)?;
let config_path =
codex_config_path().context("Could not determine ~/.codex/config.toml path")?;
@@ -140,12 +143,35 @@ fn codex_config_path() -> Option {
home_dir().map(|home| home.join(".codex").join("config.toml"))
}
+fn validate_hook_binary(hook_binary: &Path) -> Result<()> {
+ if hook_binary.is_absolute() && !hook_binary.is_file() {
+ anyhow::bail!(
+ "Codirigent hook binary does not exist: {}",
+ hook_binary.display()
+ );
+ }
+ Ok(())
+}
+
fn shell_escaped_hook_command(hook_binary: &Path) -> String {
let raw = hook_binary.to_string_lossy().into_owned();
- if raw.contains(' ') {
- format!("\"{raw}\"")
- } else {
- raw
+
+ #[cfg(windows)]
+ {
+ // Claude and Gemini execute hooks through a POSIX-compatible shell on
+ // Windows. Unquoted backslashes are treated as escape characters, so
+ // normalize to the forward-slash form accepted by Windows executables.
+ let normalized = raw.replace('\\', "/");
+ format!("\"{normalized}\"")
+ }
+
+ #[cfg(not(windows))]
+ {
+ if raw.contains(' ') {
+ format!("\"{raw}\"")
+ } else {
+ raw
+ }
}
}
@@ -382,9 +408,9 @@ fn hook_definitions() -> &'static [(&'static str, &'static str, &'static str)] {
(
"Notification",
"idle_prompt|permission_prompt",
- "mark session as idle or needs_attention",
+ "mark session as response_ready or needs_attention",
),
- ("Stop", "", "mark session as idle on exit"),
+ ("Stop", "", "mark session as response_ready"),
]
}
@@ -446,7 +472,56 @@ mod tests {
const CMD: &str = "/usr/local/bin/codirigent-hook";
const CMD2: &str = "/opt/codirigent/codirigent-hook";
- const CMD_SPACES: &str = r#""C:\Program Files\Codirigent\codirigent-hook.exe""#;
+ const CMD_SPACES: &str = r#""C:/Program Files/Codirigent/codirigent-hook.exe""#;
+
+ #[test]
+ fn missing_absolute_hook_binary_is_rejected() {
+ let temp = tempfile::tempdir().expect("hook installer test should create temp dir");
+ let missing = temp.path().join("codirigent-hook-missing");
+ let error = validate_hook_binary(&missing)
+ .expect_err("an absolute path to a missing hook must be rejected");
+ assert!(error.to_string().contains("does not exist"));
+ }
+
+ #[test]
+ fn bare_hook_binary_name_is_allowed() {
+ validate_hook_binary(Path::new("codirigent-hook"))
+ .expect("a bare hook command may rely on PATH");
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn windows_hook_path_without_spaces_is_bash_safe() {
+ let command = shell_escaped_hook_command(Path::new(
+ r"D:\study\codirigent\target\debug\codirigent-hook.exe",
+ ));
+ assert_eq!(
+ command,
+ r#""D:/study/codirigent/target/debug/codirigent-hook.exe""#
+ );
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn windows_hook_path_with_spaces_is_bash_safe() {
+ let command = shell_escaped_hook_command(Path::new(
+ r"C:\Program Files\Codirigent\codirigent-hook.exe",
+ ));
+ assert_eq!(command, CMD_SPACES);
+ }
+
+ #[cfg(not(windows))]
+ #[test]
+ fn unix_hook_path_escaping_is_unchanged() {
+ assert_eq!(
+ shell_escaped_hook_command(Path::new(CMD)),
+ "/usr/local/bin/codirigent-hook"
+ );
+ assert_eq!(
+ shell_escaped_hook_command(Path::new("/opt/Codirigent App/codirigent-hook")),
+ r#""/opt/Codirigent App/codirigent-hook""#
+ );
+ }
#[test]
fn fresh_install_adds_three_hooks() {
diff --git a/crates/codirigent-core/src/types/status.rs b/crates/codirigent-core/src/types/status.rs
index e77560b0..226dcef1 100644
--- a/crates/codirigent-core/src/types/status.rs
+++ b/crates/codirigent-core/src/types/status.rs
@@ -15,7 +15,8 @@ pub enum SessionStatus {
Working,
/// Session needs user attention (input required or permission prompt).
NeedsAttention,
- /// Claude just finished responding (Stop hook) and the session is not focused.
+ /// An agent has finished responding and is waiting for the next user prompt.
+ /// Produced by hooks, structured logs, or terminal-screen semantic rules.
/// Cleared to Idle when the user switches to this session.
ResponseReady,
/// Error detected in output.
diff --git a/crates/codirigent-detector/src/detector.rs b/crates/codirigent-detector/src/detector.rs
index 2f8d0653..83d3cdef 100644
--- a/crates/codirigent-detector/src/detector.rs
+++ b/crates/codirigent-detector/src/detector.rs
@@ -34,7 +34,11 @@
//! let status = detector.get_status(SessionId(1));
//! ```
-use crate::patterns::{compile_patterns, find_matching_pattern_with_limit, get_default_patterns};
+use crate::patterns::{
+ compile_patterns, compile_status_rules, find_matching_pattern_with_limit,
+ find_matching_status_rule, get_default_patterns, get_default_status_rules, CompiledStatusRule,
+ StatusRule,
+};
use crate::platform::{NativeMonitor, PlatformMonitor, ProcessState};
use anyhow::Result;
use codirigent_core::{
@@ -71,6 +75,12 @@ pub struct DetectorConfig {
/// the default pattern set.
pub custom_patterns: Vec,
+ /// Additional semantic screen rules for unsupported agents.
+ ///
+ /// Unlike legacy `custom_patterns`, each rule declares the status it
+ /// produces and may require multiple visible-screen features.
+ pub status_rules: Vec,
+
/// Whether to send desktop notifications when input is required.
///
/// Default: true
@@ -95,6 +105,7 @@ impl Default for DetectorConfig {
poll_interval: Duration::from_millis(250),
idle_threshold: Duration::from_secs(2),
custom_patterns: Vec::new(),
+ status_rules: Vec::new(),
notifications_enabled: true,
max_buffer_size: 4096,
recent_lines_to_check: 5,
@@ -152,6 +163,8 @@ struct MonitoredSession {
current_status: SessionStatus,
/// Pattern that matched, if any.
pattern_matched: Option,
+ /// Status inferred from the latest raw prompt or visible terminal screen.
+ detected_status: Option,
/// Last known shell state from OSC 133 markers.
shell_state: Option,
}
@@ -166,6 +179,7 @@ impl MonitoredSession {
output_buffer: String::new(),
current_status: SessionStatus::Idle,
pattern_matched: None,
+ detected_status: None,
shell_state: None,
}
}
@@ -174,6 +188,7 @@ impl MonitoredSession {
fn clear_buffer(&mut self) {
self.output_buffer.clear();
self.pattern_matched = None;
+ self.detected_status = None;
}
}
@@ -190,6 +205,8 @@ pub struct InputDetector {
sessions: HashMap,
/// Compiled regex patterns.
compiled_patterns: Vec,
+ /// Compiled semantic rules for visible terminal screens.
+ compiled_status_rules: Vec,
/// Event bus for publishing status changes.
event_bus: Arc,
}
@@ -218,6 +235,9 @@ impl InputDetector {
all_patterns.extend(config.custom_patterns.clone());
let compiled_patterns = compile_patterns(&all_patterns);
+ let mut status_rules = get_default_status_rules();
+ status_rules.extend(config.status_rules.clone());
+ let compiled_status_rules = compile_status_rules(&status_rules);
debug!(
pattern_count = compiled_patterns.len(),
@@ -229,6 +249,7 @@ impl InputDetector {
platform_monitor: NativeMonitor::new(),
sessions: HashMap::new(),
compiled_patterns,
+ compiled_status_rules,
event_bus,
}
}
@@ -245,6 +266,10 @@ impl InputDetector {
pub fn process_output(&mut self, session_id: SessionId, data: &[u8]) {
let new_status = if let Some(session) = self.sessions.get_mut(&session_id) {
session.last_output_time = Instant::now();
+ // Any new output invalidates a resting state inferred from the
+ // previous screen. The fresh terminal snapshot will immediately
+ // establish a new semantic state when one is still visible.
+ session.detected_status = None;
// Append to buffer (keep limited size)
let text = String::from_utf8_lossy(data);
@@ -269,6 +294,10 @@ impl InputDetector {
&session.output_buffer,
self.config.recent_lines_to_check,
);
+ session.detected_status = session
+ .pattern_matched
+ .as_ref()
+ .map(|_| SessionStatus::NeedsAttention);
// Active output is enough to treat the session as working unless a
// prompt pattern or OSC 133 shell state says otherwise.
@@ -282,6 +311,61 @@ impl InputDetector {
}
}
+ /// Reclassify a session from its current visible terminal screen.
+ ///
+ /// The visible screen is authoritative for interactive TUI state: it
+ /// replaces the rolling raw-output buffer so a dismissed permission menu
+ /// cannot remain matched after the agent redraws the terminal.
+ pub fn process_visible_screen(&mut self, session_id: SessionId, screen: &str) {
+ let semantic_match = find_matching_status_rule(&self.compiled_status_rules, screen);
+ let legacy_match = if semantic_match.is_none() {
+ find_matching_pattern_with_limit(
+ &self.compiled_patterns,
+ screen,
+ self.config.recent_lines_to_check,
+ )
+ } else {
+ None
+ };
+
+ let new_status = if let Some(session) = self.sessions.get_mut(&session_id) {
+ session.output_buffer.clear();
+ if screen.len() <= self.config.max_buffer_size {
+ session.output_buffer.push_str(screen);
+ } else {
+ let target_start = screen.len() - self.config.max_buffer_size;
+ let start = screen
+ .char_indices()
+ .find(|(index, _)| *index >= target_start)
+ .map(|(index, _)| index)
+ .unwrap_or(screen.len());
+ session.output_buffer.push_str(&screen[start..]);
+ }
+
+ let (detected_status, detail) = match semantic_match {
+ Some((status, rule_name)) => (Some(status), Some(rule_name)),
+ None => match legacy_match {
+ Some(pattern) => (Some(SessionStatus::NeedsAttention), Some(pattern)),
+ None => (None, None),
+ },
+ };
+ session.detected_status = detected_status;
+ session.pattern_matched = if detected_status == Some(SessionStatus::NeedsAttention) {
+ detail
+ } else {
+ None
+ };
+
+ Some(Self::status_while_processing_output(session))
+ } else {
+ None
+ };
+
+ if let Some(new_status) = new_status {
+ self.apply_session_status(session_id, new_status);
+ }
+ }
+
/// Update status for a session.
///
/// Determines the new status based on process state and patterns,
@@ -306,8 +390,8 @@ impl InputDetector {
/// 3. Process state heuristic — fallback for shells without OSC 133
fn determine_status(&self, session: &MonitoredSession) -> SessionStatus {
// 1. Pattern match takes highest priority
- if session.pattern_matched.is_some() {
- return SessionStatus::NeedsAttention;
+ if let Some(status) = session.detected_status {
+ return status;
}
// 2. OSC 133 shell state — reliable, if available
@@ -388,8 +472,8 @@ impl InputDetector {
}
fn status_while_processing_output(session: &MonitoredSession) -> SessionStatus {
- if session.pattern_matched.is_some() {
- SessionStatus::NeedsAttention
+ if let Some(status) = session.detected_status {
+ status
} else if let Some(ref shell_state) = session.shell_state {
Self::status_from_shell_state(shell_state)
} else {
@@ -434,7 +518,7 @@ impl InputDetector {
let needs_tick = self
.sessions
.get(session_id)
- .map(|s| s.shell_state.is_none() || s.pattern_matched.is_some())
+ .map(|s| s.shell_state.is_none() || s.detected_status.is_some())
.unwrap_or(false);
if needs_tick {
let old_status = self.sessions.get(session_id).map(|s| s.current_status);
@@ -542,6 +626,7 @@ impl ProcessMonitor for InputDetector {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::patterns::StatusRule;
use codirigent_core::DefaultEventBus;
fn create_test_detector() -> InputDetector {
@@ -561,6 +646,7 @@ mod tests {
assert_eq!(config.poll_interval, Duration::from_millis(250));
assert_eq!(config.idle_threshold, Duration::from_secs(2));
assert!(config.custom_patterns.is_empty());
+ assert!(config.status_rules.is_empty());
assert!(config.notifications_enabled);
assert_eq!(config.max_buffer_size, 4096);
assert_eq!(config.recent_lines_to_check, 5);
@@ -1073,6 +1159,7 @@ mod tests {
assert!(session.output_buffer.is_empty());
assert_eq!(session.current_status, SessionStatus::Idle);
assert!(session.pattern_matched.is_none());
+ assert!(session.detected_status.is_none());
}
#[test]
@@ -1080,11 +1167,13 @@ mod tests {
let mut session = MonitoredSession::new(SessionId(1), 1234);
session.output_buffer = "some output".to_string();
session.pattern_matched = Some("pattern".to_string());
+ session.detected_status = Some(SessionStatus::NeedsAttention);
session.clear_buffer();
assert!(session.output_buffer.is_empty());
assert!(session.pattern_matched.is_none());
+ assert!(session.detected_status.is_none());
}
// Pattern detection edge cases
@@ -1256,6 +1345,179 @@ mod tests {
);
}
+ #[test]
+ fn test_kimi_command_approval_menu_sets_needs_attention() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+
+ detector.process_output(
+ SessionId(1),
+ concat!(
+ "Run this command?\n",
+ "cwd: D:\\study\\codirigent\\test\\gomoku\n",
+ "$ ls -R src && cat src/App.tsx\n",
+ "1. Approve once\n",
+ "2. Approve for this session\n",
+ "3. Reject\n",
+ "4. Reject with feedback\n",
+ "1/2/3/4 choose · confirm",
+ )
+ .as_bytes(),
+ );
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::NeedsAttention)
+ );
+ }
+
+ #[test]
+ fn test_unknown_agent_approval_menu_sets_needs_attention_from_visible_screen() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+
+ detector.process_visible_screen(
+ SessionId(1),
+ concat!(
+ "Tool wants to execute a command\n",
+ "1. Allow once\n",
+ "2. Allow for this workspace\n",
+ "3. Deny\n",
+ "Use arrows to select, then confirm",
+ ),
+ );
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::NeedsAttention)
+ );
+ }
+
+ #[test]
+ fn test_agent_prompt_and_chrome_sets_response_ready() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+
+ detector.process_visible_screen(
+ SessionId(1),
+ concat!(
+ "Implemented the requested change.\n",
+ ">\n",
+ "manual mode on · ? for shortcuts · + for agents",
+ ),
+ );
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::ResponseReady)
+ );
+ }
+
+ #[test]
+ fn test_explicit_ready_message_sets_response_ready_for_unknown_agent() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+
+ detector.process_visible_screen(SessionId(1), "Idle - Ready for next task");
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::ResponseReady)
+ );
+ }
+
+ #[test]
+ fn test_powershell_prompt_is_idle_not_response_ready() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::PromptStart);
+
+ detector.process_visible_screen(SessionId(1), r"PS D:\repo> ");
+
+ assert_eq!(detector.get_status(SessionId(1)), Some(SessionStatus::Idle));
+ }
+
+ #[test]
+ fn test_new_output_clears_stale_response_ready_screen_hint() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+ detector.process_visible_screen(SessionId(1), "Idle - Ready for next task");
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::ResponseReady)
+ );
+
+ detector.process_output(SessionId(1), b"Starting the next task...");
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::Working)
+ );
+ }
+
+ #[test]
+ fn test_visible_screen_replacement_clears_stale_approval_menu() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+ detector.process_visible_screen(
+ SessionId(1),
+ "Allow this action?\n1. Allow\n2. Deny\nSelect an option to confirm",
+ );
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::NeedsAttention)
+ );
+
+ detector.process_visible_screen(SessionId(1), "Running tests...\nworking");
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::Working)
+ );
+ }
+
+ #[test]
+ fn test_custom_status_rule_supports_unrecognized_agent() {
+ let mut config = DetectorConfig::default();
+ config.status_rules.push(StatusRule {
+ name: "acme-agent-ready".to_string(),
+ status: SessionStatus::ResponseReady,
+ required_patterns: vec![r"ACME_AGENT_AWAITING_INPUT".to_string()],
+ excluded_patterns: Vec::new(),
+ });
+ let mut detector = create_detector_with_config(config);
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+
+ detector.process_visible_screen(SessionId(1), "ACME_AGENT_AWAITING_INPUT");
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::ResponseReady)
+ );
+ }
+
#[test]
fn test_set_shell_state_nonexistent_session() {
let mut detector = create_test_detector();
diff --git a/crates/codirigent-detector/src/lib.rs b/crates/codirigent-detector/src/lib.rs
index f3384f0e..f8efa05d 100644
--- a/crates/codirigent-detector/src/lib.rs
+++ b/crates/codirigent-detector/src/lib.rs
@@ -3,8 +3,8 @@
//! Process monitoring, input detection, and status tracking for sessions.
//!
//! This crate provides the core detection functionality for Codirigent,
-//! determining when AI CLI sessions (Claude Code, Codex CLI, Gemini CLI)
-//! are waiting for user input.
+//! determining when known and previously unseen AI CLI sessions are working,
+//! waiting for approval, or ready for the next prompt.
//!
//! # Overview
//!
@@ -13,6 +13,7 @@
//! - **Platform-specific process monitoring** via `libproc` on macOS
//! and Win32 APIs on Windows
//! - **Output pattern matching** to detect common input prompts
+//! - **Visible-screen semantic rules** to classify interactive agent TUIs
//! - **Timing heuristics** to identify idle processes
//!
//! # Modules
@@ -66,6 +67,8 @@
//!
//! Custom patterns can be added via [`InputDetector::add_pattern`] or
//! through [`DetectorConfig::custom_patterns`].
+//! Unsupported agents can map terminal features to any status with
+//! [`StatusRule`] entries in [`DetectorConfig::status_rules`].
//!
//! # Notifications
//!
@@ -92,7 +95,7 @@ pub use notification::{
notify_error, notify_input_required, notify_task_completed, send_notification,
NotificationHandle, NotificationManager, NotificationType,
};
-pub use patterns::{DEFAULT_PATTERNS, DEFAULT_RECENT_LINES_TO_CHECK};
+pub use patterns::{StatusRule, DEFAULT_PATTERNS, DEFAULT_RECENT_LINES_TO_CHECK};
pub use platform::{NativeMonitor, PlatformMonitor, ProcessInfo, ProcessState};
// Re-export the factory function
diff --git a/crates/codirigent-detector/src/patterns.rs b/crates/codirigent-detector/src/patterns.rs
index 73a92af8..5acda718 100644
--- a/crates/codirigent-detector/src/patterns.rs
+++ b/crates/codirigent-detector/src/patterns.rs
@@ -18,9 +18,50 @@
//! }
//! ```
+use codirigent_core::context::strip_ansi_codes;
+use codirigent_core::SessionStatus;
use regex::Regex;
use tracing::warn;
+/// A semantic terminal-screen rule that maps visible features to a session status.
+///
+/// Every `required_patterns` regex must match and every `excluded_patterns`
+/// regex must not match. This lets integrations describe an unsupported agent
+/// without adding another hard-coded CLI enum variant.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StatusRule {
+ /// Stable diagnostic name for the rule.
+ pub name: String,
+ /// Status produced when the rule matches.
+ pub status: SessionStatus,
+ /// Regex features that must all occur in the visible terminal screen.
+ pub required_patterns: Vec,
+ /// Regex features that prevent the rule from matching.
+ pub excluded_patterns: Vec,
+}
+
+#[derive(Debug)]
+pub(crate) struct CompiledStatusRule {
+ name: String,
+ status: SessionStatus,
+ required_patterns: Vec,
+ excluded_patterns: Vec,
+}
+
+impl CompiledStatusRule {
+ fn matches(&self, screen: &str) -> bool {
+ !self.required_patterns.is_empty()
+ && self
+ .required_patterns
+ .iter()
+ .all(|pattern| pattern.is_match(screen))
+ && self
+ .excluded_patterns
+ .iter()
+ .all(|pattern| !pattern.is_match(screen))
+ }
+}
+
/// Default patterns for detecting input prompts.
///
/// These patterns cover common interactive prompts from CLI tools:
@@ -41,8 +82,91 @@ pub const DEFAULT_PATTERNS: &[&str] = &[
r"\(y/N\)",
r"password:",
r"Password:",
+ r"(?is)(approve|allow|permit|authorize|批准|允许).{0,512}(reject|deny|decline|cancel|拒绝|取消).{0,256}(choose|select|confirm|选择|确认)",
];
+/// Return the built-in brand-independent terminal semantic rules.
+pub(crate) fn get_default_status_rules() -> Vec {
+ vec![
+ StatusRule {
+ name: "interactive-approval-menu".to_string(),
+ status: SessionStatus::NeedsAttention,
+ required_patterns: vec![
+ r"(?i)(\bapprove\b|\ballow\b|\bpermit\b|\bauthorize\b|批准|允许)".to_string(),
+ r"(?i)(\breject\b|\bdeny\b|\bdecline\b|\bcancel\b|拒绝|取消)".to_string(),
+ r"(?i)(\bchoose\b|\bselect\b|\bconfirm\b|选择|确认)".to_string(),
+ ],
+ excluded_patterns: Vec::new(),
+ },
+ StatusRule {
+ name: "explicit-ready-for-next-input".to_string(),
+ status: SessionStatus::ResponseReady,
+ required_patterns: vec![
+ r"(?im)^\s*(?:idle\s*[-—:]\s*)?(?:ready|waiting)\s+for\s+(?:your\s+|the\s+)?(?:next\s+)?(?:task|prompt|request|instruction|input)\s*[.!]?\s*$"
+ .to_string(),
+ ],
+ excluded_patterns: Vec::new(),
+ },
+ StatusRule {
+ name: "agent-input-prompt".to_string(),
+ status: SessionStatus::ResponseReady,
+ required_patterns: vec![
+ r"(?i)(manual mode|for shortcuts|for agents|context\s*:?\s*\d+%|\bcontext\s+(?:left|remaining)\b|\btokens?\s*:|\bmodel\s*:|ctrl\+c to (?:stop|interrupt))"
+ .to_string(),
+ r"(?m)^\s*(?:>|›|❯|➜)\s*$".to_string(),
+ ],
+ excluded_patterns: vec![
+ r"(?i)(\bapprove\b|\ballow\b|\bpermit\b|\bauthorize\b|批准|允许).{0,512}(\breject\b|\bdeny\b|\bdecline\b|\bcancel\b|拒绝|取消)"
+ .to_string(),
+ ],
+ },
+ ]
+}
+
+pub(crate) fn compile_status_rules(rules: &[StatusRule]) -> Vec {
+ rules
+ .iter()
+ .filter_map(|rule| {
+ let required_patterns = compile_rule_patterns(&rule.name, &rule.required_patterns)?;
+ let excluded_patterns = compile_rule_patterns(&rule.name, &rule.excluded_patterns)?;
+ if required_patterns.is_empty() {
+ warn!(rule = %rule.name, "Ignoring status rule without required patterns");
+ return None;
+ }
+ Some(CompiledStatusRule {
+ name: rule.name.clone(),
+ status: rule.status,
+ required_patterns,
+ excluded_patterns,
+ })
+ })
+ .collect()
+}
+
+fn compile_rule_patterns(rule_name: &str, patterns: &[String]) -> Option> {
+ patterns
+ .iter()
+ .map(|pattern| match Regex::new(pattern) {
+ Ok(regex) => Some(regex),
+ Err(error) => {
+ warn!(rule = %rule_name, %pattern, %error, "Failed to compile status rule");
+ None
+ }
+ })
+ .collect()
+}
+
+pub(crate) fn find_matching_status_rule(
+ rules: &[CompiledStatusRule],
+ screen: &str,
+) -> Option<(SessionStatus, String)> {
+ let plain_screen = strip_ansi_codes(screen);
+ rules
+ .iter()
+ .find(|rule| rule.matches(&plain_screen))
+ .map(|rule| (rule.status, rule.name.clone()))
+}
+
/// Compile a list of pattern strings into regex objects.
///
/// Invalid patterns are skipped with a warning log message.
@@ -161,6 +285,14 @@ pub fn find_matching_pattern_with_limit(
.join("\n");
for pattern in patterns {
+ // The generic `> ` pattern is useful for REPLs and interactive tools,
+ // but it also matches PowerShell's normal `PS > ` shell prompt.
+ // A shell prompt means the session is idle, not waiting for an agent
+ // response. Ignore only this well-known prompt shape so other angle
+ // prompts continue to work.
+ if pattern.as_str() == r"> $" && ends_with_powershell_prompt(&recent_output) {
+ continue;
+ }
if pattern.is_match(&recent_output) {
return Some(pattern.as_str().to_string());
}
@@ -168,6 +300,15 @@ pub fn find_matching_pattern_with_limit(
None
}
+fn ends_with_powershell_prompt(output: &str) -> bool {
+ let plain_output = strip_ansi_codes(output);
+ plain_output
+ .lines()
+ .next_back()
+ .map(str::trim_end)
+ .is_some_and(|line| line.starts_with("PS ") && line.ends_with('>'))
+}
+
/// Check if any pattern matches the output (boolean version).
///
/// This is a convenience function when you only need to know if there's a match,
@@ -226,7 +367,7 @@ mod tests {
#[test]
fn test_default_patterns_count() {
- assert_eq!(DEFAULT_PATTERNS.len(), 10);
+ assert_eq!(DEFAULT_PATTERNS.len(), 11);
}
#[test]
@@ -296,6 +437,45 @@ mod tests {
assert!(result.is_some());
}
+ #[test]
+ fn test_powershell_prompt_is_not_an_attention_prompt() {
+ let patterns = compile_patterns(&[r"> $".to_string()]);
+
+ assert!(find_matching_pattern(&patterns, r"PS D:\repo> ").is_none());
+ }
+
+ #[test]
+ fn test_powershell_prompt_with_osc133_is_not_an_attention_prompt() {
+ let patterns = compile_patterns(&[r"> $".to_string()]);
+
+ assert!(find_matching_pattern(&patterns, "\x1b]133;A\x07PS D:\\repo> ").is_none());
+ }
+
+ #[test]
+ fn test_kimi_command_approval_menu_is_an_attention_prompt() {
+ let patterns = compile_patterns(&get_default_patterns());
+ let output = concat!(
+ "Run this command?\n",
+ "cwd: D:\\study\\codirigent\\test\\gomoku\n",
+ "$ ls -R src && cat src/App.tsx\n",
+ "\x1b[36m1. Approve once\x1b[0m\n",
+ "2. Approve for this session\n",
+ "3. Reject\n",
+ "4. Reject with feedback\n",
+ "1/2/3/4 choose · confirm",
+ );
+
+ assert!(find_matching_pattern(&patterns, output).is_some());
+ }
+
+ #[test]
+ fn test_approval_wording_in_prose_is_not_an_attention_prompt() {
+ let patterns = compile_patterns(&get_default_patterns());
+ let output = "The documentation explains when to choose Approve for this session.";
+
+ assert!(find_matching_pattern(&patterns, output).is_none());
+ }
+
#[test]
fn test_find_matching_pattern_press_enter() {
let patterns = compile_patterns(&[r"Press Enter".to_string()]);
@@ -413,6 +593,43 @@ mod tests {
}
}
+ #[test]
+ fn test_default_status_rules_compile() {
+ let rules = get_default_status_rules();
+ let compiled = compile_status_rules(&rules);
+
+ assert_eq!(compiled.len(), rules.len());
+ }
+
+ #[test]
+ fn test_status_rule_requires_all_features_in_any_order() {
+ let rules = compile_status_rules(&[StatusRule {
+ name: "approval".to_string(),
+ status: SessionStatus::NeedsAttention,
+ required_patterns: vec!["Allow".to_string(), "Deny".to_string()],
+ excluded_patterns: Vec::new(),
+ }]);
+
+ let matched = find_matching_status_rule(&rules, "2. Deny\n1. Allow");
+
+ assert_eq!(
+ matched,
+ Some((SessionStatus::NeedsAttention, "approval".to_string()))
+ );
+ }
+
+ #[test]
+ fn test_status_rule_exclusion_prevents_match() {
+ let rules = compile_status_rules(&[StatusRule {
+ name: "ready".to_string(),
+ status: SessionStatus::ResponseReady,
+ required_patterns: vec!["Ready".to_string()],
+ excluded_patterns: vec!["approval pending".to_string()],
+ }]);
+
+ assert!(find_matching_status_rule(&rules, "Ready - approval pending").is_none());
+ }
+
#[test]
fn test_compile_patterns_preserves_order() {
let patterns = vec![
diff --git a/crates/codirigent-hook/src/main.rs b/crates/codirigent-hook/src/main.rs
index 6618e0cc..ca8850d2 100644
--- a/crates/codirigent-hook/src/main.rs
+++ b/crates/codirigent-hook/src/main.rs
@@ -223,6 +223,7 @@ fn map_claude_status(hook_event: Option<&str>, notification_type: Option<&str>)
Some("Stop") => "response_ready",
Some("Notification") => match notification_type {
Some("permission_prompt") => "needs_attention",
+ Some("idle_prompt") => "response_ready",
_ => "idle",
},
_ => "idle",
@@ -380,7 +381,7 @@ mod tests {
}
#[test]
- fn map_status_notification_other_is_idle() {
+ fn map_status_notification_idle_prompt_is_response_ready() {
assert_eq!(
map_status(
Some("Notification"),
@@ -388,8 +389,12 @@ mod tests {
None,
CLI_TYPE_CLAUDE
),
- "idle"
+ "response_ready"
);
+ }
+
+ #[test]
+ fn map_status_notification_without_type_is_idle() {
assert_eq!(
map_status(Some("Notification"), None, None, CLI_TYPE_CLAUDE),
"idle"
diff --git a/crates/codirigent-session/src/pty.rs b/crates/codirigent-session/src/pty.rs
index 45ec93b1..f47db97f 100644
--- a/crates/codirigent-session/src/pty.rs
+++ b/crates/codirigent-session/src/pty.rs
@@ -1033,13 +1033,17 @@ mod tests {
.expect("timeout")
.expect("channel closed");
assert_eq!(&received, data);
- assert!(chunk_called.load(Ordering::SeqCst), "on_chunk should fire");
// Wait for channel to close (reader thread hit EOF and exited)
while rx.recv().await.is_some() {}
// Give the thread a moment to run on_exit after the loop
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+ // Check both flags after the thread has fully exited, since on_chunk
+ // is called *after* the channel send and may not have run yet when
+ // rx.recv() returns.
+ assert!(chunk_called.load(Ordering::SeqCst), "on_chunk should fire");
assert!(
exit_called.load(Ordering::SeqCst),
"on_exit should fire on EOF"
diff --git a/crates/codirigent-ui/Cargo.toml b/crates/codirigent-ui/Cargo.toml
index 20d5e72e..95306c5d 100644
--- a/crates/codirigent-ui/Cargo.toml
+++ b/crates/codirigent-ui/Cargo.toml
@@ -61,3 +61,4 @@ clipboard-win = "5.4"
[dev-dependencies]
tempfile.workspace = true
serial_test = "3.2"
+gpui = { workspace = true, features = ["test-support"] }
diff --git a/crates/codirigent-ui/src/app.rs b/crates/codirigent-ui/src/app.rs
index 61c6eeb9..234b3c2e 100644
--- a/crates/codirigent-ui/src/app.rs
+++ b/crates/codirigent-ui/src/app.rs
@@ -445,6 +445,10 @@ impl CodirigentApp {
let splash_duration = self.splash_duration;
Application::new().run(move |cx: &mut App| {
+ // Install platform-specific shutdown guard (macOS: adds
+ // applicationShouldTerminate: to GPUI's delegate class).
+ crate::platform::shutdown_guard::install();
+
// Register global actions
Self::register_actions(cx);
@@ -516,7 +520,8 @@ impl CodirigentApp {
}),
..Default::default()
},
- move |_window, cx| {
+ move |window, cx| {
+ Self::install_window_shutdown_guard(window);
cx.new(|cx| {
AppView::new_with_splash(
session_manager,
@@ -543,7 +548,8 @@ impl CodirigentApp {
}),
..Default::default()
},
- move |_window, cx| {
+ move |window, cx| {
+ Self::install_window_shutdown_guard(window);
cx.new(|cx| {
AppView::new_workspace(session_manager, detector, event_bus, theme, cx)
})
@@ -554,10 +560,35 @@ impl CodirigentApp {
});
}
+ /// Install the window-level shutdown guard (Windows only).
+ ///
+ /// On Windows, subclasses the HWND to intercept `WM_QUERYENDSESSION`.
+ /// On other platforms, this is a no-op.
+ #[allow(unused_variables)]
+ fn install_window_shutdown_guard(window: &Window) {
+ #[cfg(target_os = "windows")]
+ {
+ use raw_window_handle::HasWindowHandle;
+ if let Ok(handle) = HasWindowHandle::window_handle(window) {
+ if let raw_window_handle::RawWindowHandle::Win32(win32) = handle.as_raw() {
+ crate::platform::shutdown_guard::install_for_window(win32.hwnd.get());
+ }
+ }
+ }
+ }
+
/// Register global application actions.
fn register_actions(cx: &mut App) {
cx.on_action(|_: &Quit, cx| {
info!("Quit action triggered");
+ // On macOS, mark this as a user-initiated quit so the shutdown
+ // guard allows it through (vs. system shutdown/logout which
+ // should be blocked when sessions are active).
+ #[cfg(target_os = "macos")]
+ {
+ crate::platform::shutdown_guard::USER_QUIT_REQUESTED
+ .store(true, std::sync::atomic::Ordering::SeqCst);
+ }
cx.quit();
});
diff --git a/crates/codirigent-ui/src/clipboard.rs b/crates/codirigent-ui/src/clipboard.rs
index 3772b229..4741ec08 100644
--- a/crates/codirigent-ui/src/clipboard.rs
+++ b/crates/codirigent-ui/src/clipboard.rs
@@ -5,6 +5,7 @@
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
+use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::Term;
/// Copy selected text from terminal grid.
@@ -65,12 +66,16 @@ pub fn copy_selection(term: &Term, start: (i32, usize), end: (i32, usize))
let trimmed = line_text.trim_end();
text.push_str(trimmed);
- // Add newline between lines (but not after the last line)
- if line_idx < end_line && !trimmed.is_empty() {
- text.push('\n');
- } else if line_idx < end_line && trimmed.is_empty() {
- // Preserve empty lines in multi-line selections
- text.push('\n');
+ // Only add newline between lines if the current line does NOT wrap
+ // to the next row. Wrapped lines are continuations of the same
+ // logical line and should be joined without a newline, matching
+ // the behavior of standard terminal emulators.
+ if line_idx < end_line {
+ let last_col = Column(total_cols.saturating_sub(1));
+ let is_wrapped = line[last_col].flags.contains(Flags::WRAPLINE);
+ if !is_wrapped {
+ text.push('\n');
+ }
}
}
diff --git a/crates/codirigent-ui/src/input/keyboard.rs b/crates/codirigent-ui/src/input/keyboard.rs
index 297f17c4..78575635 100644
--- a/crates/codirigent-ui/src/input/keyboard.rs
+++ b/crates/codirigent-ui/src/input/keyboard.rs
@@ -513,6 +513,27 @@ mod tests {
);
}
+ #[test]
+ fn test_terminal_editing_control_sequences() {
+ let mode = TermMode::empty();
+ assert_eq!(
+ key_to_bytes(&make_keystroke("backspace", true, false, false), mode),
+ Some(vec![0x08])
+ );
+ assert_eq!(
+ key_to_bytes(&make_keystroke("w", true, false, false), mode),
+ Some(vec![0x17])
+ );
+ assert_eq!(
+ key_to_bytes(&make_keystroke("left", true, false, false), mode),
+ Some(b"\x1b[1;5D".to_vec())
+ );
+ assert_eq!(
+ key_to_bytes(&make_keystroke("delete", true, false, false), mode),
+ Some(b"\x1b[3;5~".to_vec())
+ );
+ }
+
#[test]
fn test_shift_tab() {
let mode = TermMode::empty();
diff --git a/crates/codirigent-ui/src/keybindings.rs b/crates/codirigent-ui/src/keybindings.rs
index 5570e43d..20761924 100644
--- a/crates/codirigent-ui/src/keybindings.rs
+++ b/crates/codirigent-ui/src/keybindings.rs
@@ -14,9 +14,9 @@
//! let manager = KeybindingManager::with_defaults();
//! // Platform modifier: Cmd on macOS, Ctrl elsewhere.
//! #[cfg(target_os = "macos")]
-//! let binding = KeybindingManager::parse_binding("Cmd+N").unwrap();
+//! let binding = KeybindingManager::parse_binding("Cmd+Shift+N").unwrap();
//! #[cfg(not(target_os = "macos"))]
-//! let binding = KeybindingManager::parse_binding("Ctrl+N").unwrap();
+//! let binding = KeybindingManager::parse_binding("Ctrl+Shift+N").unwrap();
//! assert_eq!(manager.get_action(&binding), Some(&Action::NewSession));
//! ```
diff --git a/crates/codirigent-ui/src/platform/mod.rs b/crates/codirigent-ui/src/platform/mod.rs
index edf8e4ef..123f2883 100644
--- a/crates/codirigent-ui/src/platform/mod.rs
+++ b/crates/codirigent-ui/src/platform/mod.rs
@@ -28,6 +28,15 @@
//! let clipboard = platform::StubSmartClipboard::new();
//! ```
+// Shutdown guard — prevents system shutdown/logout while sessions are active
+pub mod shutdown_guard;
+
+#[cfg(target_os = "macos")]
+mod shutdown_guard_macos;
+
+#[cfg(target_os = "windows")]
+mod shutdown_guard_windows;
+
#[cfg(target_os = "macos")]
mod clipboard_macos;
diff --git a/crates/codirigent-ui/src/platform/shutdown_guard.rs b/crates/codirigent-ui/src/platform/shutdown_guard.rs
new file mode 100644
index 00000000..244288dd
--- /dev/null
+++ b/crates/codirigent-ui/src/platform/shutdown_guard.rs
@@ -0,0 +1,88 @@
+//! Cross-platform shutdown guard.
+//!
+//! Prevents the operating system from terminating the application during
+//! logout or shutdown when sessions are actively running.
+//!
+//! ## Platform behavior
+//!
+//! - **macOS**: Adds `applicationShouldTerminate:` to GPUI's delegate,
+//! returning `NSTerminateCancel` when sessions are active. macOS shows
+//! "Your Mac hasn't logged out because Codirigent failed to quit."
+//!
+//! - **Windows**: Subclasses the GPUI window to intercept `WM_QUERYENDSESSION`,
+//! returning `FALSE` and setting a `ShutdownBlockReason` when sessions are active.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+
+/// Whether the application should block system shutdown/logout.
+///
+/// When `true`, the OS will be prevented from terminating the app during
+/// shutdown/logout, showing a system dialog to the user.
+static SHUTDOWN_BLOCKED: AtomicBool = AtomicBool::new(false);
+
+/// Whether the current quit was initiated by the user (Cmd+Q / Ctrl+Q).
+///
+/// On macOS, `[NSApp terminate:]` triggers `applicationShouldTerminate:` for
+/// both user-initiated and system-initiated quits. This flag distinguishes
+/// the two so that Cmd+Q always quits immediately.
+///
+/// Not needed on Windows — `WM_QUERYENDSESSION` is only sent during
+/// system shutdown, never during user-initiated quit.
+#[cfg(target_os = "macos")]
+pub(crate) static USER_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
+
+/// Update whether the app should block system shutdown.
+///
+/// Call this whenever the session count changes:
+/// - `true` when there are active sessions
+/// - `false` when all sessions are closed
+pub fn set_shutdown_blocked(blocked: bool) {
+ SHUTDOWN_BLOCKED.store(blocked, Ordering::SeqCst);
+}
+
+/// Check if shutdown is currently blocked.
+pub fn is_shutdown_blocked() -> bool {
+ SHUTDOWN_BLOCKED.load(Ordering::SeqCst)
+}
+
+/// Install the platform-specific shutdown guard.
+///
+/// On macOS, this adds `applicationShouldTerminate:` to GPUI's delegate class.
+/// Must be called inside `Application::new().run()` after GPUI has initialized.
+#[cfg(target_os = "macos")]
+pub fn install() {
+ super::shutdown_guard_macos::install_shutdown_guard();
+}
+
+/// No-op on non-macOS platforms. Windows uses `install_for_window` instead.
+#[cfg(not(target_os = "macos"))]
+pub fn install() {}
+
+/// Install the Windows shutdown guard by subclassing the given window.
+///
+/// Must be called after the GPUI window has been created, from within the
+/// `open_window` callback where the raw window handle is available.
+#[cfg(target_os = "windows")]
+pub fn install_for_window(hwnd: isize) {
+ super::shutdown_guard_windows::install_shutdown_guard(hwnd);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_shutdown_blocked_default_false() {
+ // Reset to known state (other tests may have changed it)
+ set_shutdown_blocked(false);
+ assert!(!is_shutdown_blocked());
+ }
+
+ #[test]
+ fn test_set_shutdown_blocked() {
+ set_shutdown_blocked(true);
+ assert!(is_shutdown_blocked());
+ set_shutdown_blocked(false);
+ assert!(!is_shutdown_blocked());
+ }
+}
diff --git a/crates/codirigent-ui/src/platform/shutdown_guard_macos.rs b/crates/codirigent-ui/src/platform/shutdown_guard_macos.rs
new file mode 100644
index 00000000..2d8bf1cf
--- /dev/null
+++ b/crates/codirigent-ui/src/platform/shutdown_guard_macos.rs
@@ -0,0 +1,98 @@
+//! macOS shutdown guard implementation.
+//!
+//! Adds `applicationShouldTerminate:` to GPUI's `GPUIApplicationDelegate`
+//! class via the ObjC runtime. When sessions are active and the system
+//! tries to shut down or log out, this returns `NSTerminateCancel`,
+//! causing macOS to show the "Your Mac hasn't logged out because
+//! Codirigent failed to quit" dialog.
+//!
+//! User-initiated quits (Cmd+Q) are always allowed through, distinguished
+//! by the `USER_QUIT_REQUESTED` flag set in the Quit action handler.
+
+use super::shutdown_guard::{is_shutdown_blocked, USER_QUIT_REQUESTED};
+use std::ffi::c_char;
+use std::sync::atomic::Ordering;
+use tracing::{info, warn};
+
+/// `NSTerminateCancel` — The app should not be terminated.
+const NS_TERMINATE_CANCEL: usize = 0;
+/// `NSTerminateNow` — It is OK to proceed with termination.
+const NS_TERMINATE_NOW: usize = 1;
+
+// ObjC runtime functions for adding a method to an existing class.
+extern "C" {
+ fn objc_getClass(name: *const c_char) -> *mut std::ffi::c_void;
+ fn sel_registerName(name: *const c_char) -> *const std::ffi::c_void;
+ fn class_addMethod(
+ cls: *mut std::ffi::c_void,
+ name: *const std::ffi::c_void,
+ imp: extern "C" fn(
+ *mut std::ffi::c_void,
+ *const std::ffi::c_void,
+ *mut std::ffi::c_void,
+ ) -> usize,
+ types: *const c_char,
+ ) -> i8;
+}
+
+/// Install the macOS shutdown guard.
+///
+/// Adds `applicationShouldTerminate:` to GPUI's `GPUIApplicationDelegate`
+/// class. This must be called after GPUI has initialized (inside
+/// `Application::new().run()`), but before the app enters the event loop.
+///
+/// # Safety
+///
+/// Uses the ObjC runtime to modify the `GPUIApplicationDelegate` class.
+/// The class must exist (GPUI creates it via `#[ctor]` before `main()`).
+pub fn install_shutdown_guard() {
+ unsafe {
+ let cls = objc_getClass(b"GPUIApplicationDelegate\0".as_ptr().cast::());
+ if cls.is_null() {
+ warn!("Could not find GPUIApplicationDelegate class; shutdown guard not installed");
+ return;
+ }
+
+ let sel = sel_registerName(b"applicationShouldTerminate:\0".as_ptr().cast::());
+
+ // Type encoding: return NSUInteger (Q), self (@), _cmd (:), sender (@)
+ let types = b"Q@:@\0".as_ptr().cast::();
+
+ let added = class_addMethod(cls, sel, application_should_terminate, types);
+
+ if added != 0 {
+ info!("Shutdown guard installed on GPUIApplicationDelegate");
+ } else {
+ warn!(
+ "applicationShouldTerminate: already exists on GPUIApplicationDelegate; \
+ shutdown guard not installed"
+ );
+ }
+ }
+}
+
+/// ObjC method implementation for `applicationShouldTerminate:`.
+///
+/// Called by macOS when the system attempts to terminate the app (shutdown,
+/// logout, or `[NSApp terminate:]`).
+extern "C" fn application_should_terminate(
+ _this: *mut std::ffi::c_void,
+ _sel: *const std::ffi::c_void,
+ _sender: *mut std::ffi::c_void,
+) -> usize {
+ // If the user explicitly requested quit (Cmd+Q), allow unconditionally.
+ // Reset the flag so subsequent system-initiated attempts are not affected.
+ if USER_QUIT_REQUESTED.swap(false, Ordering::SeqCst) {
+ info!("User-initiated quit: allowing termination");
+ return NS_TERMINATE_NOW;
+ }
+
+ // System-initiated (shutdown/logout): block if sessions are active.
+ if is_shutdown_blocked() {
+ info!("Blocking system shutdown: active sessions exist");
+ NS_TERMINATE_CANCEL
+ } else {
+ info!("No active sessions: allowing system termination");
+ NS_TERMINATE_NOW
+ }
+}
diff --git a/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs b/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
new file mode 100644
index 00000000..361256e9
--- /dev/null
+++ b/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
@@ -0,0 +1,90 @@
+//! Windows shutdown guard implementation.
+//!
+//! Subclasses the GPUI window to intercept `WM_QUERYENDSESSION`.
+//! When sessions are active and the system tries to shut down or
+//! log out, this returns `FALSE` and sets a `ShutdownBlockReason`,
+//! causing Windows to show a dialog telling the user that
+//! Codirigent is preventing shutdown.
+
+use super::shutdown_guard::is_shutdown_blocked;
+use std::sync::atomic::{AtomicIsize, Ordering};
+use tracing::{info, warn};
+
+const GWLP_WNDPROC: i32 = -4;
+const WM_QUERYENDSESSION: u32 = 0x0011;
+
+/// The original window procedure, saved when subclassing.
+static ORIGINAL_WNDPROC: AtomicIsize = AtomicIsize::new(0);
+
+// Win32 functions from user32.dll (linked automatically on Windows).
+extern "system" {
+ fn SetWindowLongPtrW(hwnd: isize, index: i32, new_long: isize) -> isize;
+ fn CallWindowProcW(prev: isize, hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> isize;
+ fn ShutdownBlockReasonCreate(hwnd: isize, reason: *const u16) -> i32;
+ fn ShutdownBlockReasonDestroy(hwnd: isize) -> i32;
+}
+
+/// Install the Windows shutdown guard by subclassing the given window.
+///
+/// Replaces the window procedure with one that intercepts `WM_QUERYENDSESSION`
+/// and blocks shutdown when sessions are active.
+///
+/// Must be called after the GPUI window has been created, from the main thread.
+pub fn install_shutdown_guard(hwnd: isize) {
+ if hwnd == 0 {
+ warn!("Invalid HWND (null); shutdown guard not installed");
+ return;
+ }
+
+ let prev = unsafe {
+ SetWindowLongPtrW(
+ hwnd,
+ GWLP_WNDPROC,
+ shutdown_guard_wndproc as *const () as isize,
+ )
+ };
+ if prev == 0 {
+ warn!("Failed to subclass window for shutdown guard");
+ return;
+ }
+
+ ORIGINAL_WNDPROC.store(prev, Ordering::SeqCst);
+ info!("Shutdown guard installed on window");
+}
+
+/// Subclassed window procedure that intercepts `WM_QUERYENDSESSION`.
+///
+/// When the system tries to shut down or log out:
+/// - If sessions are active: sets a `ShutdownBlockReason` and returns `FALSE` (0)
+/// to block shutdown. Windows shows the reason to the user.
+/// - If no sessions: cleans up any previous block reason and forwards to
+/// the original GPUI window procedure.
+///
+/// All other messages are forwarded to the original window procedure unchanged.
+unsafe extern "system" fn shutdown_guard_wndproc(
+ hwnd: isize,
+ msg: u32,
+ wparam: usize,
+ lparam: isize,
+) -> isize {
+ if msg == WM_QUERYENDSESSION {
+ if is_shutdown_blocked() {
+ info!("Blocking system shutdown: active sessions exist");
+
+ // Encode reason as null-terminated UTF-16 for the Windows API.
+ let reason: Vec = "Codirigent has active AI sessions"
+ .encode_utf16()
+ .chain(std::iter::once(0))
+ .collect();
+ unsafe { ShutdownBlockReasonCreate(hwnd, reason.as_ptr()) };
+
+ return 0; // FALSE — block shutdown
+ }
+
+ // Not blocking: clean up any stale block reason from a previous attempt.
+ unsafe { ShutdownBlockReasonDestroy(hwnd) };
+ }
+
+ let prev = ORIGINAL_WNDPROC.load(Ordering::SeqCst);
+ unsafe { CallWindowProcW(prev, hwnd, msg, wparam, lparam) }
+}
diff --git a/crates/codirigent-ui/src/platform_drag.rs b/crates/codirigent-ui/src/platform_drag.rs
index 4aa9eb06..812ae201 100644
--- a/crates/codirigent-ui/src/platform_drag.rs
+++ b/crates/codirigent-ui/src/platform_drag.rs
@@ -1,49 +1,217 @@
-//! Platform-specific window drag helpers.
+//! Platform-specific window drag and resize helpers.
//!
-//! On Windows, GPUI 0.2.x has a timing issue where `WindowControlArea::Drag`
-//! doesn't reliably initiate window moves (stale `mouse_hit_test` in
-//! WM_NCHITTEST). This module provides a direct Win32 workaround that sends
-//! `WM_NCLBUTTONDOWN` with `HTCAPTION` to the window, telling Windows to
-//! start a native title-bar drag.
+//! On Windows, GPUI 0.2.x has two issues with custom-chrome windows:
//!
-//! Remove this module after upgrading GPUI to a version that fixes the issue.
+//! 1. **Drag freeze**: `WindowControlArea::Drag` causes GPUI's `WM_NCHITTEST`
+//! to return `HTCAPTION`. When Windows enters the modal drag loop via
+//! `DefWindowProc`, it re-enters the message pump while GPUI's `RefCell`
+//! borrows are still held — causing a panic / freeze.
+//!
+//! 2. **NC event interception**: GPUI's `handle_nc_mouse_down_msg` re-dispatches
+//! `WM_NCLBUTTONDOWN` as a regular `MouseDownEvent` through the element tree.
+//! If an element handles it (e.g., the title bar's `on_mouse_down`),
+//! `DefWindowProc` never runs — breaking both title-bar drag and top-edge
+//! resize (because the drag region overlaps the top resize zone).
+//!
+//! This module installs a Win32 window subclass that intercepts these messages
+//! **before** GPUI's WndProc:
+//!
+//! - **Drag**: The `on_mouse_down` handler posts a custom `WM_APP` message.
+//! The subclass catches it and calls `DefWindowProc(WM_NCLBUTTONDOWN,
+//! HTCAPTION)` directly, starting the OS drag loop when no GPUI borrows
+//! are held.
+//!
+//! - **Resize**: `WM_NCLBUTTONDOWN` for resize hit-test areas (`HTTOP`,
+//! `HTLEFT`, …) is routed straight to `DefWindowProc`, bypassing GPUI's
+//! element dispatch that would otherwise eat the event.
+//!
+//! - **Buttons**: `WM_NCLBUTTONDOWN` for `HTMINBUTTON`/`HTMAXBUTTON`/`HTCLOSE`
+//! passes through to GPUI for normal button handling.
+//!
+//! Remove this module after upgrading GPUI to a version that fixes the issues.
-/// Begin a native title-bar drag on Windows.
+/// Install the window subclass for drag/resize handling (Windows only).
///
-/// Posts `ReleaseCapture` + `WM_NCLBUTTONDOWN(HTCAPTION)` so the OS
-/// takes over the drag loop. Uses `PostMessageW` (async) instead of
-/// `SendMessageW` (sync) to avoid reentrancy — `SendMessageW` starts a
-/// modal drag loop that pumps messages while GPUI's `RefCell` is still
-/// borrowed by the event callback, causing a panic.
+/// Must be called once per window. Safe to call multiple times — only the
+/// first call installs the subclass.
#[cfg(target_os = "windows")]
-pub fn begin_title_bar_drag(hwnd: isize) {
- use std::ffi::c_int;
-
- #[allow(clippy::upper_case_acronyms)]
- type HWND = isize;
- #[allow(clippy::upper_case_acronyms)]
- type WPARAM = usize;
- #[allow(clippy::upper_case_acronyms)]
- type LPARAM = isize;
- #[allow(clippy::upper_case_acronyms)]
- type BOOL = c_int;
-
- const WM_NCLBUTTONDOWN: u32 = 0x00A1;
- const HTCAPTION: WPARAM = 2;
-
- extern "system" {
- fn ReleaseCapture() -> BOOL;
- fn PostMessageW(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> BOOL;
+pub fn install_drag_subclass(hwnd: isize) {
+ use std::sync::atomic::{AtomicBool, Ordering};
+
+ static INSTALLED: AtomicBool = AtomicBool::new(false);
+ if INSTALLED
+ .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
+ .is_err()
+ {
+ return;
}
- // Safety: hwnd is obtained from raw_window_handle and the window is alive
- // during the mouse-down handler that calls this function.
+ // Safety: hwnd is obtained from raw_window_handle and the window is alive.
+ // SetWindowSubclass must be called from the thread that owns the window,
+ // which is the UI thread where render_title_bar runs.
+ unsafe {
+ SetWindowSubclass(hwnd, drag_subclass_proc, SUBCLASS_ID, 0);
+ }
+}
+
+/// Begin a native title-bar drag on Windows.
+///
+/// Posts a custom `WM_APP` message with the cursor's screen coordinates.
+/// The window subclass intercepts this and calls
+/// `DefWindowProc(WM_NCLBUTTONDOWN, HTCAPTION)` to start the OS drag loop.
+#[cfg(target_os = "windows")]
+pub fn begin_title_bar_drag(hwnd: isize) {
+ // Safety: GetCursorPos and PostMessageW are safe to call from any thread,
+ // and hwnd is valid during the mouse-down handler that calls this.
unsafe {
- ReleaseCapture();
- PostMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0);
+ let mut pt = POINT { x: 0, y: 0 };
+ GetCursorPos(&mut pt);
+ let lparam = pack_point(pt.x, pt.y);
+ PostMessageW(hwnd, WM_APP_DRAG_WINDOW, 0, lparam);
}
}
/// No-op on non-Windows platforms (drag is handled by GPUI natively).
#[cfg(not(target_os = "windows"))]
pub fn begin_title_bar_drag(_hwnd: isize) {}
+
+/// No-op on non-Windows platforms.
+#[cfg(not(target_os = "windows"))]
+pub fn install_drag_subclass(_hwnd: isize) {}
+
+// ---------------------------------------------------------------------------
+// Windows implementation
+// ---------------------------------------------------------------------------
+
+// Win32 type aliases (avoid pulling in the full `windows` crate).
+#[cfg(target_os = "windows")]
+#[allow(clippy::upper_case_acronyms, non_camel_case_types)]
+mod win32 {
+ pub type HWND = isize;
+ pub type WPARAM = usize;
+ pub type LPARAM = isize;
+ pub type LRESULT = isize;
+ pub type BOOL = std::ffi::c_int;
+
+ #[repr(C)]
+ pub struct POINT {
+ pub x: i32,
+ pub y: i32,
+ }
+
+ /// Signature expected by `SetWindowSubclass` / `RemoveWindowSubclass`.
+ pub type SUBCLASSPROC =
+ unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM, usize, usize) -> LRESULT;
+}
+
+#[cfg(target_os = "windows")]
+use win32::*;
+
+// Message and hit-test constants.
+#[cfg(target_os = "windows")]
+const WM_NCLBUTTONDOWN: u32 = 0x00A1;
+#[cfg(target_os = "windows")]
+const WM_NCLBUTTONDBLCLK: u32 = 0x00A3;
+#[cfg(target_os = "windows")]
+const WM_NCDESTROY: u32 = 0x0082;
+#[cfg(target_os = "windows")]
+const WM_APP_DRAG_WINDOW: u32 = 0x8000; // WM_APP
+#[cfg(target_os = "windows")]
+const HTMINBUTTON: u32 = 8;
+#[cfg(target_os = "windows")]
+const HTMAXBUTTON: u32 = 9;
+#[cfg(target_os = "windows")]
+const HTCLOSE: u32 = 20;
+#[cfg(target_os = "windows")]
+const HTCAPTION: usize = 2;
+#[cfg(target_os = "windows")]
+const VK_LBUTTON: i32 = 0x01;
+#[cfg(target_os = "windows")]
+const SUBCLASS_ID: usize = 0xC0D1; // Memorable constant for our subclass.
+
+// Imports from comctl32.dll (window subclass API).
+#[cfg(target_os = "windows")]
+#[link(name = "comctl32")]
+extern "system" {
+ fn SetWindowSubclass(
+ hwnd: HWND,
+ pfn_subclass: SUBCLASSPROC,
+ uid_subclass: usize,
+ dw_ref_data: usize,
+ ) -> BOOL;
+ fn RemoveWindowSubclass(hwnd: HWND, pfn_subclass: SUBCLASSPROC, uid_subclass: usize) -> BOOL;
+ fn DefSubclassProc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT;
+}
+
+// Imports from user32.dll.
+#[cfg(target_os = "windows")]
+extern "system" {
+ fn DefWindowProcW(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT;
+ fn ReleaseCapture() -> BOOL;
+ fn PostMessageW(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> BOOL;
+ fn GetCursorPos(point: *mut POINT) -> BOOL;
+ fn GetAsyncKeyState(vkey: i32) -> i16;
+}
+
+/// Pack screen coordinates as `MAKELPARAM(x, y)`.
+#[cfg(target_os = "windows")]
+fn pack_point(x: i32, y: i32) -> LPARAM {
+ (((y & 0xFFFF) as LPARAM) << 16) | ((x & 0xFFFF) as LPARAM)
+}
+
+/// Window subclass procedure — runs **before** GPUI's WndProc.
+///
+/// Routes NC mouse messages so the OS handles drag/resize directly,
+/// bypassing GPUI's element dispatch that would otherwise eat them.
+#[cfg(target_os = "windows")]
+unsafe extern "system" fn drag_subclass_proc(
+ hwnd: HWND,
+ msg: u32,
+ wparam: WPARAM,
+ lparam: LPARAM,
+ _uid_subclass: usize,
+ _ref_data: usize,
+) -> LRESULT {
+ match msg {
+ // Our custom drag message, posted by begin_title_bar_drag().
+ // At this point no GPUI RefCell borrows are held, so the modal
+ // drag loop started by DefWindowProc is safe.
+ //
+ // Guard: only start the drag if the left mouse button is still
+ // held. On a double-click the second click fires
+ // titlebar_double_click() synchronously, and by the time this
+ // stale message from the first click is dequeued the button may
+ // already be released — skip it to avoid a spurious drag on the
+ // now-maximised window.
+ WM_APP_DRAG_WINDOW => {
+ if GetAsyncKeyState(VK_LBUTTON) < 0 {
+ ReleaseCapture();
+ DefWindowProcW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, lparam)
+ } else {
+ 0
+ }
+ }
+
+ // NC mouse-down (single or double click).
+ WM_NCLBUTTONDOWN | WM_NCLBUTTONDBLCLK => match wparam as u32 {
+ // Window control buttons — let GPUI handle them via
+ // WindowControlArea::Min/Max/Close.
+ HTMINBUTTON | HTMAXBUTTON | HTCLOSE => DefSubclassProc(hwnd, msg, wparam, lparam),
+ // Everything else: resize edges (HTTOP, HTLEFT, …) or a
+ // stale HTCAPTION from GPUI's hit-test callback.
+ // Send straight to DefWindowProc so the OS starts the
+ // resize/drag loop — bypassing GPUI's element dispatch
+ // which would route it to on_mouse_down and eat the event.
+ _ => DefWindowProcW(hwnd, msg, wparam, lparam),
+ },
+
+ // Clean up the subclass when the window is destroyed.
+ WM_NCDESTROY => {
+ RemoveWindowSubclass(hwnd, drag_subclass_proc, SUBCLASS_ID);
+ DefSubclassProc(hwnd, msg, wparam, lparam)
+ }
+
+ // Everything else passes through to GPUI.
+ _ => DefSubclassProc(hwnd, msg, wparam, lparam),
+ }
+}
diff --git a/crates/codirigent-ui/src/terminal_runtime.rs b/crates/codirigent-ui/src/terminal_runtime.rs
index 6e349c80..bbd650f6 100644
--- a/crates/codirigent-ui/src/terminal_runtime.rs
+++ b/crates/codirigent-ui/src/terminal_runtime.rs
@@ -22,12 +22,39 @@ pub(crate) struct TerminalRenderSnapshot {
pub(crate) cached_rows: Vec,
pub(crate) dirty_rows: Option>,
pub(crate) cursor_viewport_cell: Option<(usize, usize)>,
+ pub(crate) software_cursor_viewport_cell: Option<(usize, usize)>,
+}
+
+impl TerminalRenderSnapshot {
+ /// Reconstruct the current visible terminal viewport as plain text.
+ pub(crate) fn visible_text(&self) -> String {
+ let mut lines = Vec::with_capacity(self.cached_rows.len());
+ for row in &self.cached_rows {
+ let mut runs = row.text_runs_hsla.iter().collect::>();
+ runs.sort_by_key(|(run, _)| run.start_col);
+ let mut line = String::new();
+ let mut cell_cursor = 0usize;
+ for (run, _) in runs {
+ if run.start_col > cell_cursor {
+ line.push_str(&" ".repeat(run.start_col - cell_cursor));
+ }
+ line.push_str(&run.text);
+ cell_cursor = run.start_col + run.cell_count;
+ }
+ lines.push(line.trim_end().to_string());
+ }
+ while lines.last().is_some_and(String::is_empty) {
+ lines.pop();
+ }
+ lines.join("\n")
+ }
}
struct TerminalRuntime {
terminal: Terminal,
theme: CodirigentTheme,
generation: u64,
+ last_snapshot_mode: TermMode,
cached_rows: Option>,
cached_search_snapshot: Option>,
}
@@ -44,10 +71,12 @@ impl TerminalRuntimeHandle {
initial_size: TerminalSize,
) -> (Self, TerminalRenderSnapshot) {
terminal.resize_with_cells(initial_size);
+ let last_snapshot_mode = terminal.mode();
let mut runtime = TerminalRuntime {
terminal,
theme,
generation: 0,
+ last_snapshot_mode,
cached_rows: None,
cached_search_snapshot: None,
};
@@ -199,10 +228,15 @@ impl TerminalRuntime {
fn snapshot_from_damage(&mut self) -> TerminalRenderSnapshot {
let rows = self.terminal.rows() as usize;
let cols = self.terminal.cols() as usize;
- let damage = if self
- .cached_rows
- .as_ref()
- .is_some_and(|cached_rows| cached_rows.len() == rows)
+ let scrolled_back = self.terminal.term().grid().display_offset() > 0;
+ let alternate_screen_changed = self.last_snapshot_mode.contains(TermMode::ALT_SCREEN)
+ != self.terminal.mode().contains(TermMode::ALT_SCREEN);
+ let damage = if !alternate_screen_changed
+ && !scrolled_back
+ && self
+ .cached_rows
+ .as_ref()
+ .is_some_and(|cached_rows| cached_rows.len() == rows)
{
let term = self.terminal.term_mut();
let damage = match term.damage() {
@@ -274,7 +308,16 @@ impl TerminalRuntime {
} else {
None
};
+ let software_cursor_viewport_cell = if mode.contains(TermMode::SHOW_CURSOR) {
+ None
+ } else {
+ closest_software_cursor_cell(
+ self.cached_rows.as_deref().unwrap_or_default(),
+ cursor_viewport_cell,
+ )
+ };
self.terminal.mark_clean();
+ self.last_snapshot_mode = mode;
TerminalRenderSnapshot {
generation: self.generation,
@@ -286,10 +329,37 @@ impl TerminalRuntime {
cached_rows: self.cached_rows.clone().unwrap_or_default(),
dirty_rows,
cursor_viewport_cell,
+ software_cursor_viewport_cell,
}
}
}
+fn closest_software_cursor_cell(
+ cached_rows: &[CachedTerminalRow],
+ hardware_cursor: Option<(usize, usize)>,
+) -> Option<(usize, usize)> {
+ let distance = |candidate: (usize, usize)| {
+ hardware_cursor.map_or((0, 0), |hardware| {
+ (
+ candidate.0.abs_diff(hardware.0),
+ candidate.1.abs_diff(hardware.1),
+ )
+ })
+ };
+
+ cached_rows
+ .iter()
+ .enumerate()
+ .flat_map(|(row, cached)| {
+ cached
+ .software_cursor_cols
+ .iter()
+ .copied()
+ .map(move |col| (row, col))
+ })
+ .min_by_key(|candidate| distance(*candidate))
+}
+
fn build_row_cache(
terminal: &Terminal,
theme: &CodirigentTheme,
@@ -302,12 +372,30 @@ fn build_row_cache(
let mut text_runs: Vec = Vec::new();
let mut background_rects: Vec<(usize, usize, usize, Rgba)> = Vec::new();
+ let mut software_cursor_cols = Vec::new();
let mut current_run: Option = None;
for col in 0..cols {
let cell = &grid[grid_line][Column(col)];
let c = cell.c;
+ if c == ' '
+ && cell.flags.contains(CellFlags::INVERSE)
+ && !cell.flags.contains(CellFlags::WIDE_CHAR_SPACER)
+ {
+ let inverse_to_left = col > 0
+ && grid[grid_line][Column(col - 1)]
+ .flags
+ .contains(CellFlags::INVERSE);
+ let inverse_to_right = col + 1 < cols
+ && grid[grid_line][Column(col + 1)]
+ .flags
+ .contains(CellFlags::INVERSE);
+ if !inverse_to_left && !inverse_to_right {
+ software_cursor_cols.push(col);
+ }
+ }
+
if cell.flags.contains(CellFlags::WIDE_CHAR_SPACER) {
let bg = convert_color(cell.bg, theme);
if bg != theme.terminal_background {
@@ -328,7 +416,14 @@ fn build_row_cache(
continue;
}
- if c == ' ' && cell.bg == TermColor::Named(NamedColor::Background) {
+ // Skip blank cells with the default background for efficiency. But a
+ // reverse-video blank (`\e[7m `) is how TUIs like claude's Ink draw their
+ // own caret; INVERSE swaps its background to the foreground color, so it
+ // must be kept and rendered as a background rect, not discarded.
+ if c == ' '
+ && cell.bg == TermColor::Named(NamedColor::Background)
+ && !cell.flags.contains(CellFlags::INVERSE)
+ {
continue;
}
@@ -412,6 +507,7 @@ fn build_row_cache(
})
.collect(),
),
+ software_cursor_cols: Arc::new(software_cursor_cols),
}
}
@@ -442,6 +538,42 @@ mod tests {
assert!(next.generation > initial.generation);
assert_eq!(next.rows, 4);
assert_eq!(next.cols, 8);
+ assert!(next.dirty_rows.is_some());
+ }
+
+ #[test]
+ fn runtime_alt_screen_exit_rebuilds_all_cached_rows() {
+ let runtime = create_runtime();
+ let entered = runtime
+ .apply_output(b"\x1b[?1049h\x1b[2J\x1b[4;1HKIMI")
+ .expect("alternate-screen snapshot");
+ assert!(entered.mode.contains(TermMode::ALT_SCREEN));
+
+ let exited = runtime
+ .apply_output(b"\x1b[?1049lPS> ")
+ .expect("primary-screen snapshot");
+ let visible_text = exited
+ .cached_rows
+ .iter()
+ .flat_map(|row| row.text_runs_hsla.iter())
+ .map(|(run, _)| run.text.as_str())
+ .collect::();
+
+ assert!(!exited.mode.contains(TermMode::ALT_SCREEN));
+ assert_eq!(exited.dirty_rows, None);
+ assert!(!visible_text.contains("KIMI"));
+ }
+
+ #[test]
+ fn snapshot_visible_text_preserves_row_and_column_spacing() {
+ let runtime = create_runtime();
+ let snapshot = runtime
+ .apply_output(b"left ok\r\nnext")
+ .expect("runtime output snapshot");
+
+ let visible_text = snapshot.visible_text();
+
+ assert!(visible_text.contains("left ok\nnext"));
}
#[test]
@@ -509,4 +641,51 @@ mod tests {
assert_eq!(snapshot.display_offset, snapshot.history_size);
}
+
+ #[test]
+ fn runtime_renders_reverse_video_blank_as_background_rect() {
+ let runtime = create_runtime();
+
+ // claude's Ink draws its caret as a reverse-video blank: \e[7m \e[27m.
+ // The blank cell keeps the default background but gains the INVERSE
+ // flag, so skipping it (the old behaviour) made claude's caret vanish.
+ let snapshot = runtime
+ .apply_output(b"\x1b[7m \x1b[27m")
+ .expect("runtime output snapshot");
+
+ let row = &snapshot.cached_rows[0];
+ let has_caret_rect = row
+ .bg_rects_hsla
+ .iter()
+ .any(|(r, start, end, _)| *r == 0 && *start == 0 && *end == 1);
+ assert!(
+ has_caret_rect,
+ "reverse-video blank must produce a background rect (the caret block)"
+ );
+ assert_eq!(snapshot.software_cursor_viewport_cell, None);
+ }
+
+ #[test]
+ fn runtime_finds_isolated_reverse_video_caret_when_hardware_cursor_is_hidden() {
+ let runtime = create_runtime();
+
+ let snapshot = runtime
+ .apply_output(b"\x1b[?25l> \x1b[7m \x1b[27m\x1b[4;8H")
+ .expect("runtime output snapshot");
+
+ assert!(!snapshot.mode.contains(TermMode::SHOW_CURSOR));
+ assert_eq!(snapshot.cursor_viewport_cell, Some((3, 7)));
+ assert_eq!(snapshot.software_cursor_viewport_cell, Some((0, 2)));
+ }
+
+ #[test]
+ fn runtime_does_not_treat_reverse_video_regions_as_software_carets() {
+ let runtime = create_runtime();
+
+ let snapshot = runtime
+ .apply_output(b"\x1b[?25l\x1b[7m \x1b[27m\x1b[4;8H")
+ .expect("runtime output snapshot");
+
+ assert_eq!(snapshot.software_cursor_viewport_cell, None);
+ }
}
diff --git a/crates/codirigent-ui/src/terminal_view.rs b/crates/codirigent-ui/src/terminal_view.rs
index d61e7ae5..96be369a 100644
--- a/crates/codirigent-ui/src/terminal_view.rs
+++ b/crates/codirigent-ui/src/terminal_view.rs
@@ -100,6 +100,10 @@ pub struct CachedTerminalContent {
pub(crate) struct CachedTerminalRow {
pub(crate) bg_rects_hsla: Arc>,
pub(crate) text_runs_hsla: Arc>,
+ /// Columns containing an isolated reverse-video blank. Terminal UIs built
+ /// on Ink and similar renderers use this cell pattern as a software caret
+ /// while the hardware cursor is hidden.
+ pub(crate) software_cursor_cols: Arc>,
}
type ShapedTerminalRow = Arc>;
@@ -326,6 +330,8 @@ pub struct TerminalView {
/// Updated alongside row caches in `ensure_row_caches()` so the render
/// pass never calls `renderable_content()` for cursor/IME positioning.
cached_cursor_viewport_pos: Option<(f32, f32)>,
+ /// Cached viewport-relative software cursor position in pixels.
+ cached_software_cursor_viewport_pos: Option<(f32, f32)>,
/// Scrollbar interaction state.
scrollbar: ScrollbarState,
/// Search overlay state.
@@ -379,6 +385,7 @@ impl TerminalView {
cached_terminal_bg,
cached_terminal_fg,
cached_cursor_viewport_pos: None,
+ cached_software_cursor_viewport_pos: None,
scrollbar: ScrollbarState::default(),
search: SearchState::default(),
};
@@ -415,7 +422,10 @@ impl TerminalView {
self.snapshot_generation = snapshot.generation;
self.cached_rows = snapshot.cached_rows;
self.cached_content = None;
- self.refresh_cursor_cache(snapshot.cursor_viewport_cell);
+ self.refresh_cursor_cache(
+ snapshot.cursor_viewport_cell,
+ snapshot.software_cursor_viewport_cell,
+ );
if display_offset_changed {
self.note_scroll_activity();
@@ -426,8 +436,18 @@ impl TerminalView {
self.cached_shaped_font_size = None;
self.cached_shaped_rows = None;
self.dirty_rows = None;
- } else {
- self.dirty_rows = snapshot.dirty_rows;
+ } else if self.cached_shaped_rows.is_none() {
+ // A full shaped rebuild is already pending, so partial row damage
+ // cannot narrow the work that still needs to be done.
+ self.dirty_rows = None;
+ } else if let Some(mut dirty_rows) = snapshot.dirty_rows {
+ if let Some(pending_rows) = self.dirty_rows.as_mut() {
+ pending_rows.append(&mut dirty_rows);
+ pending_rows.sort_unstable();
+ pending_rows.dedup();
+ } else {
+ self.dirty_rows = Some(dirty_rows);
+ }
}
true
@@ -475,7 +495,7 @@ impl TerminalView {
let _ = self.apply_snapshot(snapshot);
} else {
self.mark_dirty();
- self.refresh_cursor_cache(None);
+ self.refresh_cursor_cache(None, None);
}
}
@@ -686,11 +706,17 @@ impl TerminalView {
/// Returns the cached cursor (x, y) for IME preedit anchoring.
///
- /// Unlike `cursor_rect`, this ignores `\e[?25l` visibility so the
- /// preedit overlay tracks the real cursor location even during
- /// Claude Code / Ink redraw cycles.
+ /// When a TUI hides and parks the hardware cursor, prefer the isolated
+ /// reverse-video blank it uses as a software caret. Falling back to the
+ /// hardware position preserves compatibility with hidden-cursor programs
+ /// that do not expose a recognizable software caret.
pub fn ime_anchor_pos(&self) -> Option<(f32, f32)> {
- self.cached_cursor_viewport_pos
+ if self.mode.contains(TermMode::SHOW_CURSOR) {
+ self.cached_cursor_viewport_pos
+ } else {
+ self.cached_software_cursor_viewport_pos
+ .or(self.cached_cursor_viewport_pos)
+ }
}
/// Calculate pixel dimensions for the current terminal size.
@@ -1290,14 +1316,17 @@ impl TerminalView {
(start < end).then_some((start, end))
}
- /// Snapshot the cursor viewport position into `cached_cursor_viewport_pos`.
- fn refresh_cursor_cache(&mut self, cursor_viewport_cell: Option<(usize, usize)>) {
- if let Some((row, col)) = cursor_viewport_cell {
- self.cached_cursor_viewport_pos =
- Some((col as f32 * self.cell_width, row as f32 * self.cell_height));
- } else {
- self.cached_cursor_viewport_pos = None;
- }
+ /// Snapshot hardware and software cursor viewport positions into pixels.
+ fn refresh_cursor_cache(
+ &mut self,
+ cursor_viewport_cell: Option<(usize, usize)>,
+ software_cursor_viewport_cell: Option<(usize, usize)>,
+ ) {
+ let to_pixels = |(row, col): (usize, usize)| {
+ (col as f32 * self.cell_width, row as f32 * self.cell_height)
+ };
+ self.cached_cursor_viewport_pos = cursor_viewport_cell.map(to_pixels);
+ self.cached_software_cursor_viewport_pos = software_cursor_viewport_cell.map(to_pixels);
}
#[cfg(test)]
@@ -1845,6 +1874,26 @@ mod tests {
assert_eq!(cursor.unwrap().shape, CursorShape::Block);
}
+ #[test]
+ fn test_ime_anchor_uses_software_caret_when_hardware_cursor_is_hidden() {
+ let mut view = create_test_view();
+ view.apply_output_for_test(b"\x1b[?25l> \x1b[7m \x1b[27m\x1b[4;8H");
+
+ assert!(view.cursor_rect().is_none());
+ assert_eq!(view.ime_anchor_pos(), Some((2.0 * view.cell_width(), 0.0)));
+ }
+
+ #[test]
+ fn test_ime_anchor_uses_hardware_cursor_when_it_is_visible() {
+ let mut view = create_test_view();
+ view.apply_output_for_test(b"\x1b[2;4H");
+
+ assert_eq!(
+ view.ime_anchor_pos(),
+ Some((3.0 * view.cell_width(), view.cell_height()))
+ );
+ }
+
#[test]
fn test_cached_content_empty() {
let mut view = create_test_view();
@@ -2107,12 +2156,79 @@ mod tests {
cached_rows: Vec::new(),
dirty_rows: None,
cursor_viewport_cell: None,
+ software_cursor_viewport_cell: None,
};
assert!(!view.apply_snapshot(stale));
assert_eq!(view.rows(), current);
}
+ #[test]
+ fn test_apply_snapshot_accumulates_dirty_rows_until_rendered() {
+ let mut view = create_test_view();
+ view.cached_shaped_rows = Some(
+ (0..view.cached_rows.len())
+ .map(|_| Arc::new(Vec::new()))
+ .collect(),
+ );
+ let first = TerminalRenderSnapshot {
+ generation: view.snapshot_generation + 1,
+ rows: view.rows,
+ cols: view.cols,
+ mode: view.mode,
+ history_size: view.history_size,
+ display_offset: view.display_offset,
+ cached_rows: view.cached_rows.clone(),
+ dirty_rows: Some(vec![3]),
+ cursor_viewport_cell: None,
+ software_cursor_viewport_cell: None,
+ };
+ assert!(view.apply_snapshot(first));
+
+ let second = TerminalRenderSnapshot {
+ generation: view.snapshot_generation + 1,
+ rows: view.rows,
+ cols: view.cols,
+ mode: view.mode,
+ history_size: view.history_size,
+ display_offset: view.display_offset,
+ cached_rows: view.cached_rows.clone(),
+ dirty_rows: Some(vec![7, 3]),
+ cursor_viewport_cell: None,
+ software_cursor_viewport_cell: None,
+ };
+ assert!(view.apply_snapshot(second));
+
+ assert_eq!(view.dirty_rows, Some(vec![3, 7]));
+ }
+
+ #[test]
+ fn test_apply_snapshot_full_rebuild_supersedes_pending_dirty_rows() {
+ let mut view = create_test_view();
+ view.cached_shaped_rows = Some(
+ (0..view.cached_rows.len())
+ .map(|_| Arc::new(Vec::new()))
+ .collect(),
+ );
+ view.dirty_rows = Some(vec![3, 7]);
+ let full = TerminalRenderSnapshot {
+ generation: view.snapshot_generation + 1,
+ rows: view.rows,
+ cols: view.cols,
+ mode: view.mode,
+ history_size: view.history_size,
+ display_offset: view.display_offset,
+ cached_rows: view.cached_rows.clone(),
+ dirty_rows: None,
+ cursor_viewport_cell: None,
+ software_cursor_viewport_cell: None,
+ };
+
+ assert!(view.apply_snapshot(full));
+ assert!(view.cached_shaped_rows.is_none());
+ assert_eq!(view.dirty_rows, None);
+ }
+
#[test]
fn test_selection_rects_follow_scrollback() {
let mut view = create_test_view();
diff --git a/crates/codirigent-ui/src/workspace/drawer_render.rs b/crates/codirigent-ui/src/workspace/drawer_render.rs
index 17e1e43d..a9207bd9 100644
--- a/crates/codirigent-ui/src/workspace/drawer_render.rs
+++ b/crates/codirigent-ui/src/workspace/drawer_render.rs
@@ -1300,7 +1300,12 @@ impl WorkspaceView {
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
- this.open_session_menu(session_id, Some(event.position.y.into()), cx);
+ this.open_session_menu(
+ session_id,
+ Some(event.position.y.into()),
+ None,
+ cx,
+ );
cx.stop_propagation();
}),
)
diff --git a/crates/codirigent-ui/src/workspace/editor_detection.rs b/crates/codirigent-ui/src/workspace/editor_detection.rs
index a860b010..55e12dbd 100644
--- a/crates/codirigent-ui/src/workspace/editor_detection.rs
+++ b/crates/codirigent-ui/src/workspace/editor_detection.rs
@@ -184,6 +184,23 @@ pub(super) fn detect_monospace_fonts(text_system: &gpui::TextSystem) -> Vec Vec = all_names
+ .iter()
+ .filter(|name| {
+ let lower = name.to_lowercase();
+ lower.contains("nerd font") && !is_symbol_font(name)
+ })
+ .cloned()
+ .collect();
+ monospace.extend(nerd_fonts);
+
+ // Fallback heuristic when none of the above are available.
if monospace.is_empty() {
monospace = all_names
.iter()
diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs
index 1c43546a..ec543a63 100644
--- a/crates/codirigent-ui/src/workspace/gpui.rs
+++ b/crates/codirigent-ui/src/workspace/gpui.rs
@@ -283,6 +283,44 @@ impl WorkspaceView {
key.chars().count() == 1
}
+ /// Whether a Control-modified key is a terminal line-editing operation.
+ ///
+ /// Codirigent reserves a small set of unshifted Control shortcuts on
+ /// Windows/Linux. Other readline/TUI editing keys must reach the PTY.
+ pub(super) fn control_keystroke_is_terminal_editing(event: &KeyDownEvent) -> bool {
+ let modifiers = &event.keystroke.modifiers;
+ if !modifiers.control || modifiers.platform || modifiers.alt {
+ return false;
+ }
+
+ let key = event.keystroke.key.to_ascii_lowercase();
+ if matches!(
+ key.as_str(),
+ "backspace"
+ | "delete"
+ | "left"
+ | "right"
+ | "up"
+ | "down"
+ | "home"
+ | "end"
+ | "pageup"
+ | "pagedown"
+ ) {
+ return true;
+ }
+
+ // Shifted Control letters are application shortcuts (for example
+ // Ctrl+Shift+N/E/T/K/F/L). Unshifted terminal editing controls remain
+ // available except for Codirigent's explicit Ctrl+C/V/Q and pane keys.
+ !modifiers.shift
+ && key.chars().count() == 1
+ && !matches!(
+ key.as_str(),
+ "c" | "v" | "q" | "," | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
+ )
+ }
+
pub(super) fn set_session_codex_execution_mode(
&mut self,
session_id: SessionId,
@@ -1116,10 +1154,11 @@ impl WorkspaceView {
}
// On Windows/Linux, Ctrl sets modifiers.control (not modifiers.platform).
- // Guard here so Ctrl+ never reaches the PTY even if the GPUI action
- // system fails to match a secondary-* binding.
+ // Keep Codirigent shortcuts out of the PTY, but allow terminal-native
+ // editing controls such as Ctrl+Backspace, Ctrl+W and Ctrl+A/Ctrl+E.
#[cfg(not(target_os = "macos"))]
- if event.keystroke.modifiers.control {
+ if event.keystroke.modifiers.control && !Self::control_keystroke_is_terminal_editing(event)
+ {
return;
}
@@ -1545,6 +1584,16 @@ impl EntityInputHandler for WorkspaceView {
_window: &mut Window,
cx: &mut Context,
) {
+ let had_ime_overlay = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some();
+ if self.modals.task_creation.is_some() {
+ self.ime_marked_range = None;
+ self.ime_preedit_text = None;
+ let changed = self.insert_task_creation_text(text);
+ if changed || had_ime_overlay {
+ cx.notify();
+ }
+ return;
+ }
if self.has_blocking_modal() {
// Modal text fields are handled via key events; do not leak input to PTY.
return;
@@ -1564,7 +1613,6 @@ impl EntityInputHandler for WorkspaceView {
return;
}
- let had_ime_overlay = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some();
self.ime_marked_range = None;
self.ime_preedit_text = None;
let mut scrolled_to_bottom = false;
@@ -1595,6 +1643,23 @@ impl EntityInputHandler for WorkspaceView {
_window: &mut Window,
cx: &mut Context,
) {
+ if self.modals.task_creation.is_some() {
+ let previous_text = self.ime_preedit_text.clone();
+ let previous_range = self.ime_marked_range.clone();
+ let len = text.encode_utf16().count();
+ if len == 0 {
+ self.ime_marked_range = None;
+ self.ime_preedit_text = None;
+ } else {
+ self.ime_marked_range = Some(0..len);
+ self.ime_preedit_text = Some(text.to_string());
+ }
+
+ if self.ime_preedit_text != previous_text || self.ime_marked_range != previous_range {
+ cx.notify();
+ }
+ return;
+ }
if self.focused_search_session_id().is_some() {
self.ime_marked_range = None;
self.ime_preedit_text = None;
diff --git a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs
index 5ecb78ec..c8028405 100644
--- a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs
+++ b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs
@@ -228,6 +228,11 @@ impl WorkspaceView {
let task_titles = self.sync_task_board_state();
self.sync_all_session_headers(Some(&task_titles));
self.sync_empty_cells_state();
+
+ // Update shutdown guard: block system shutdown/logout while sessions exist.
+ crate::platform::shutdown_guard::set_shutdown_blocked(
+ !self.workspace.sessions().is_empty(),
+ );
}
/// Sync a single session's terminal header from workspace state.
diff --git a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs
index f4f4c139..be3c3d24 100644
--- a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs
+++ b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs
@@ -11,6 +11,10 @@ use std::hash::{Hash, Hasher};
use std::time::{Duration, Instant};
use tracing::warn;
+fn pty_resize_required(last: Option<(u16, u16)>, target: (u16, u16)) -> bool {
+ last != Some(target)
+}
+
impl WorkspaceView {
fn current_layout_mode_for_shortcuts(&self) -> LayoutMode {
if let Some(split_state) = self.workspace.layout_state().as_split_tree() {
@@ -179,6 +183,7 @@ impl WorkspaceView {
fn resize_terminals_to_grid(&mut self) -> bool {
// Layout constants from types.rs: HEADER_HEIGHT, TERMINAL_CONTENT_PADDING, CELL_BORDER_WIDTH
let mut resized_any = false;
+ let mut synchronized_pty_sessions = Vec::new();
for info in &self.cache.render_cell_info {
if let Some(terminal_view) = self.terminals.get_mut(&info.session_id) {
@@ -217,26 +222,37 @@ impl WorkspaceView {
if did_resize {
resized_any = true;
+ }
- // Propagate resize to actual PTY (ConPTY) so the shell
- // knows the correct terminal dimensions
- let rows = terminal_view.rows();
- let cols = terminal_view.cols();
- let last = self.cache.pty_sizes.get(&info.session_id);
- if last != Some(&(rows, cols)) {
- self.with_session_manager(|manager| {
- if let Err(e) = manager.resize(info.session_id, rows, cols) {
- warn!(
- "Failed to resize PTY for session {}: {}",
- info.session_id, e
- );
- }
- });
- self.cache.pty_sizes.insert(info.session_id, (rows, cols));
+ // Synchronize ConPTY independently of the emulator resize.
+ // A newly created PTY starts at 80x24, while a TerminalView may
+ // already have the target dimensions from a cached layout.
+ let rows = terminal_view.rows();
+ let cols = terminal_view.cols();
+ let last = self.cache.pty_sizes.get(&info.session_id).copied();
+ if pty_resize_required(last, (rows, cols)) {
+ let resize_result = self.with_session_manager(|manager| {
+ manager.resize(info.session_id, rows, cols)
+ });
+ match resize_result {
+ Ok(()) => {
+ self.cache.pty_sizes.insert(info.session_id, (rows, cols));
+ synchronized_pty_sessions.push(info.session_id);
+ }
+ Err(e) => warn!(
+ "Failed to resize PTY for session {}: {}",
+ info.session_id, e
+ ),
}
}
}
}
+
+ // If the shell became ready before the first layout pass, the queued
+ // Agent CLI command can now start at the real pane width.
+ for session_id in synchronized_pty_sessions {
+ self.dispatch_pending_resume_commands_for_session(session_id);
+ }
resized_any
}
@@ -361,6 +377,13 @@ mod tests {
));
}
+ #[test]
+ fn test_new_pty_is_synchronized_even_when_terminal_grid_already_matches() {
+ assert!(super::pty_resize_required(None, (40, 160)));
+ assert!(super::pty_resize_required(Some((24, 80)), (40, 160)));
+ assert!(!super::pty_resize_required(Some((40, 160)), (40, 160)));
+ }
+
#[test]
fn test_rendered_session_signature_changes_when_visible_sessions_change() {
let a = super::WorkspaceView::rendered_session_signature_for_ids(&[
diff --git a/crates/codirigent-ui/src/workspace/gpui/tests.rs b/crates/codirigent-ui/src/workspace/gpui/tests.rs
index 44c14847..cb072619 100644
--- a/crates/codirigent-ui/src/workspace/gpui/tests.rs
+++ b/crates/codirigent-ui/src/workspace/gpui/tests.rs
@@ -91,3 +91,62 @@ fn test_keystroke_is_not_text_input_for_named_terminal_key() {
assert!(!super::WorkspaceView::keystroke_is_text_input(&event));
}
+
+#[test]
+fn test_keystroke_is_text_input_for_task_modal_ascii_and_digits() {
+ for key in ["a", "7", "space"] {
+ let event = gpui::KeyDownEvent {
+ keystroke: gpui::Keystroke {
+ modifiers: gpui::Modifiers::default(),
+ key: key.to_string(),
+ key_char: None,
+ },
+ is_held: false,
+ };
+
+ assert!(
+ super::WorkspaceView::keystroke_is_text_input(&event),
+ "{key} should continue to the platform text-input handler"
+ );
+ }
+}
+
+fn control_event(key: &str, shift: bool) -> gpui::KeyDownEvent {
+ gpui::KeyDownEvent {
+ keystroke: gpui::Keystroke {
+ modifiers: gpui::Modifiers {
+ control: true,
+ shift,
+ ..gpui::Modifiers::default()
+ },
+ key: key.to_string(),
+ key_char: None,
+ },
+ is_held: false,
+ }
+}
+
+#[test]
+fn test_terminal_editing_control_keys_are_forwarded() {
+ for key in ["backspace", "delete", "left", "right", "a", "e", "w"] {
+ assert!(
+ super::WorkspaceView::control_keystroke_is_terminal_editing(&control_event(key, false)),
+ "Ctrl+{key} should reach the PTY"
+ );
+ }
+}
+
+#[test]
+fn test_workspace_control_shortcuts_remain_reserved() {
+ for key in ["c", "v", "q", ",", "1", "9"] {
+ assert!(
+ !super::WorkspaceView::control_keystroke_is_terminal_editing(&control_event(
+ key, false
+ )),
+ "Ctrl+{key} should remain a workspace shortcut"
+ );
+ }
+ assert!(
+ !super::WorkspaceView::control_keystroke_is_terminal_editing(&control_event("n", true))
+ );
+}
diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs
index e2b7e256..797361f3 100644
--- a/crates/codirigent-ui/src/workspace/grid_render.rs
+++ b/crates/codirigent-ui/src/workspace/grid_render.rs
@@ -225,6 +225,9 @@ impl WorkspaceView {
let fh = self.focus_handle(cx);
let is_focused = self.workspace.focused_session_id() == Some(session_id);
let input_enabled = !self.has_blocking_modal();
+ if let Some(terminal_view) = self.terminals.get_mut(&session_id) {
+ terminal_view.set_focused(is_focused && input_enabled);
+ }
let (terminal_content, canvas_origin) = self.render_terminal_content(
session_id,
theme,
@@ -316,18 +319,24 @@ impl WorkspaceView {
if let Some(tv) = this.terminals_mut().get_mut(&session_id) {
let cell_h: f32 = tv.cell_height();
let delta_y: f32 = event.delta.pixel_delta(px(cell_h)).y.into();
+ // Cap lines per event to avoid page-sized jumps from
+ // high-resolution touchpad momentum scrolling.
+ let max_lines: usize = tv.rows().max(1) as usize / 2;
+ let lines = (delta_y.abs() / cell_h)
+ .ceil()
+ .max(1.0)
+ .min(max_lines as f32)
+ as usize;
// Positive delta_y = scroll up = show older content (scrollback)
if delta_y > 0.0 {
- let lines = (delta_y / cell_h).ceil().max(1.0) as usize;
tv.scroll_up(lines);
} else if delta_y < 0.0 {
- let lines = (-delta_y / cell_h).ceil().max(1.0) as usize;
- // Snap to bottom when scrolling down within one viewport
- // of the live view. Without this, accidentally scrolling
- // up by even 1 line causes every new output line to push
- // the viewport further from the bottom, making it feel
- // like there is no bottom wall.
- if tv.display_offset() <= tv.rows() as usize + lines {
+ // Snap to bottom when scrolling down within a small
+ // margin of the live view. Without this, accidentally
+ // scrolling up by even 1 line causes every new output
+ // line to push the viewport further from the bottom.
+ let snap_margin = 3;
+ if tv.display_offset() <= snap_margin + lines {
tv.scroll_to_bottom();
} else {
tv.scroll_down(lines);
diff --git a/crates/codirigent-ui/src/workspace/impl_modals.rs b/crates/codirigent-ui/src/workspace/impl_modals.rs
index fdf6826e..193c4ead 100644
--- a/crates/codirigent-ui/src/workspace/impl_modals.rs
+++ b/crates/codirigent-ui/src/workspace/impl_modals.rs
@@ -34,10 +34,12 @@ impl WorkspaceView {
.unwrap_or_default(),
};
+ let cursor_position = input.chars().count();
self.modals.session_action = Some(SessionActionModal {
session_id,
kind,
input,
+ cursor_position,
error: None,
});
}
@@ -326,6 +328,7 @@ impl WorkspaceView {
let Some(modal) = self.modals.session_action.as_mut() else {
return false;
};
+ modal.cursor_position = modal.cursor_position.min(Self::char_count(&modal.input));
let key = event.keystroke.key.to_lowercase();
match key.as_str() {
@@ -339,13 +342,41 @@ impl WorkspaceView {
return true;
}
"backspace" => {
- modal.input.pop();
+ Self::backspace_at_cursor(&mut modal.input, &mut modal.cursor_position);
+ modal.error = None;
+ cx.notify();
+ return true;
+ }
+ "delete" => {
+ Self::delete_at_cursor(&mut modal.input, &mut modal.cursor_position);
+ modal.error = None;
+ cx.notify();
+ return true;
+ }
+ "left" | "arrowleft" => {
+ Self::move_cursor_left(&modal.input, &mut modal.cursor_position);
+ cx.notify();
+ return true;
+ }
+ "right" | "arrowright" => {
+ Self::move_cursor_right(&modal.input, &mut modal.cursor_position);
+ cx.notify();
+ return true;
+ }
+ "home" => {
+ Self::move_cursor_home(&mut modal.cursor_position);
+ cx.notify();
+ return true;
+ }
+ "end" => {
+ Self::move_cursor_end(&modal.input, &mut modal.cursor_position);
cx.notify();
return true;
}
"space" => {
// GPUI on Windows reports space as key="space" with key_char=None
- modal.input.push(' ');
+ Self::insert_at_cursor(&mut modal.input, &mut modal.cursor_position, " ");
+ modal.error = None;
cx.notify();
return true;
}
@@ -355,10 +386,25 @@ impl WorkspaceView {
// Ctrl+A selects all (clears input for easy replacement)
if (event.keystroke.modifiers.control || event.keystroke.modifiers.platform) && key == "a" {
modal.input.clear();
+ modal.cursor_position = 0;
cx.notify();
return true;
}
+ // Ctrl+V / Cmd+V — paste from system clipboard
+ if (event.keystroke.modifiers.control || event.keystroke.modifiers.platform) && key == "v" {
+ if let Ok(codirigent_core::ClipboardContent::Text(text)) =
+ self.clipboard.smart_clipboard.read_content()
+ {
+ if let Some(modal) = self.modals.session_action.as_mut() {
+ Self::insert_at_cursor(&mut modal.input, &mut modal.cursor_position, &text);
+ modal.error = None;
+ cx.notify();
+ }
+ }
+ return true;
+ }
+
// Ignore other modifier-based shortcuts inside the modal.
if event.keystroke.modifiers.control
|| event.keystroke.modifiers.alt
@@ -368,11 +414,10 @@ impl WorkspaceView {
}
if let Some(ref key_char) = event.keystroke.key_char {
- if let Some(ch) = key_char.chars().next() {
- if ch.is_ascii_graphic() || ch == ' ' {
- modal.input.push(ch);
- cx.notify();
- }
+ if !key_char.is_empty() {
+ Self::insert_at_cursor(&mut modal.input, &mut modal.cursor_position, key_char);
+ modal.error = None;
+ cx.notify();
}
}
@@ -511,6 +556,28 @@ impl WorkspaceView {
*cursor += text.chars().count();
}
+ fn insert_text_into_task_modal(modal: &mut TaskCreationModal, text: &str) -> bool {
+ if text.is_empty() {
+ return false;
+ }
+
+ Self::clamp_task_modal_cursor(modal);
+ if let Some((field, cursor)) = Self::focused_field_and_cursor_mut(modal) {
+ Self::insert_at_cursor(field, cursor, text);
+ modal.error = None;
+ true
+ } else {
+ false
+ }
+ }
+
+ pub(super) fn insert_task_creation_text(&mut self, text: &str) -> bool {
+ let Some(modal) = self.modals.task_creation.as_mut() else {
+ return false;
+ };
+ Self::insert_text_into_task_modal(modal, text)
+ }
+
fn backspace_at_cursor(field: &mut String, cursor: &mut usize) {
if *cursor == 0 {
return;
@@ -559,12 +626,20 @@ impl WorkspaceView {
event: &KeyDownEvent,
cx: &mut Context,
) -> bool {
+ let ime_composing = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some();
let Some(modal) = self.modals.task_creation.as_mut() else {
return false;
};
Self::clamp_task_modal_cursor(modal);
let key = event.keystroke.key.to_lowercase();
+ // While an IME composition is active, keys such as Space, Enter, arrows,
+ // and digits belong to the IME candidate UI. The committed text arrives
+ // through EntityInputHandler::replace_text_in_range().
+ if ime_composing {
+ return true;
+ }
+
match key.as_str() {
"escape" => {
self.close_task_creation_modal();
@@ -633,14 +708,6 @@ impl WorkspaceView {
cx.notify();
return true;
}
- "space" => {
- if let Some((field, cursor)) = Self::focused_field_and_cursor_mut(modal) {
- Self::insert_at_cursor(field, cursor, " ");
- }
- modal.error = None;
- cx.notify();
- return true;
- }
_ => {}
}
@@ -677,16 +744,74 @@ impl WorkspaceView {
return true;
}
- if let Some(ref key_char) = event.keystroke.key_char {
- if !key_char.is_empty() {
- if let Some((field, cursor)) = Self::focused_field_and_cursor_mut(modal) {
- Self::insert_at_cursor(field, cursor, key_char);
- modal.error = None;
- cx.notify();
- }
- }
+ // Printable characters (including plain Space) must keep propagating so
+ // the platform can deliver them through EntityInputHandler. The root
+ // keyboard handler already prevents these keys from reaching the PTY.
+ // During IME composition we returned early above, so candidate-selection
+ // digits and Space remain owned by the IME instead.
+ !Self::keystroke_is_text_input(event)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn task_modal(focused_field: usize) -> TaskCreationModal {
+ TaskCreationModal {
+ title: String::new(),
+ description: String::new(),
+ priority: codirigent_core::TaskPriority::Medium,
+ focused_field,
+ cursor_positions: [0, 0, 0],
+ error: Some("stale error".to_string()),
+ project_dir: None,
+ plan_file: String::new(),
+ editing_task_id: None,
}
+ }
- true
+ #[test]
+ fn committed_chinese_text_is_inserted_into_every_task_field() {
+ for focused_field in 0..3 {
+ let mut modal = task_modal(focused_field);
+
+ assert!(WorkspaceView::insert_text_into_task_modal(
+ &mut modal,
+ "中文输入"
+ ));
+
+ let values = [&modal.title, &modal.description, &modal.plan_file];
+ assert_eq!(values[focused_field], "中文输入");
+ assert_eq!(modal.cursor_positions[focused_field], 4);
+ assert!(modal.error.is_none());
+ }
+ }
+
+ #[test]
+ fn committed_text_uses_character_cursor_without_splitting_unicode() {
+ let mut modal = task_modal(0);
+ modal.title = "甲乙".to_string();
+ modal.cursor_positions[0] = 1;
+
+ assert!(WorkspaceView::insert_text_into_task_modal(
+ &mut modal, "任务"
+ ));
+
+ assert_eq!(modal.title, "甲任务乙");
+ assert_eq!(modal.cursor_positions[0], 3);
+ }
+
+ #[test]
+ fn empty_ime_commit_does_not_change_task_field() {
+ let mut modal = task_modal(1);
+ modal.description = "已有内容".to_string();
+ modal.cursor_positions[1] = 4;
+
+ assert!(!WorkspaceView::insert_text_into_task_modal(&mut modal, ""));
+
+ assert_eq!(modal.description, "已有内容");
+ assert_eq!(modal.cursor_positions[1], 4);
+ assert!(modal.error.is_some());
}
}
diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs
index 1c26ec0e..5cf336b8 100644
--- a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs
+++ b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs
@@ -366,6 +366,14 @@ impl WorkspaceView {
let _ = this.update(cx, |this, cx| {
this.polling.hook_signal_check_in_flight = false;
+ // Sort by timestamp so that the newest signal for each
+ // session is processed last and wins the cached status.
+ // Without this, non-deterministic read_dir order can let
+ // a stale "idle" signal overwrite a newer "working" signal
+ // when multiple signal files target the same session
+ // (e.g., parent + subagent Claude Code sessions).
+ let mut updates = updates;
+ updates.sort_by_key(|u| u.ts);
for update in updates {
this.apply_hook_signal_update(update, cx);
}
diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
index 9d44df50..e9a16e7b 100644
--- a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
+++ b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
@@ -60,6 +60,12 @@ impl WorkspaceView {
let had_output_activity = self.schedule_output_preparation(cx);
+ // Safety-net: dispatch resume commands that have been waiting longer
+ // than the fallback timeout (shell never produced output).
+ if !self.polling.pending_resume_commands.is_empty() {
+ self.dispatch_timed_out_resume_commands();
+ }
+
// Track output activity for adaptive polling
//
// Sessions that actually produced output are synchronized in
@@ -254,6 +260,9 @@ impl WorkspaceView {
let data = drained.data;
let bytes_drained = data.len();
let render_snapshot = runtime.apply_output(&data);
+ let visible_screen = render_snapshot
+ .as_ref()
+ .map(TerminalRenderSnapshot::visible_text);
let detected_cli_type = detect_cli_from_output(&data);
let shell_events = codirigent_session::extract_osc133_events(&data);
@@ -262,6 +271,9 @@ impl WorkspaceView {
{
let mut detector = detector.lock().ok()?;
detector.process_output(session_id, &data);
+ if let Some(screen) = visible_screen.as_deref() {
+ detector.process_visible_screen(session_id, screen);
+ }
for event in shell_events {
// DUAL-PATH: Emitted to channel for phase-2 event routing.
// Also applied directly below via set_shell_state() for correctness now.
@@ -361,6 +373,15 @@ impl WorkspaceView {
has_more,
"apply_prepared_session_output"
);
+
+ // Prompt-aware resume dispatch: the shell has produced real output,
+ // so it is alive and can accept input. Flush any queued resume
+ // commands for this session now.
+ if bytes_drained > 0 {
+ self.polling.resume_shell_ready.insert(session_id);
+ self.dispatch_pending_resume_commands_for_session(session_id);
+ }
+
let mut any_dirty = false;
if let Some(snapshot) = render_snapshot {
diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
index 83c177fa..bfb67926 100644
--- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
+++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
@@ -34,7 +34,6 @@ use tracing::{info, warn};
#[derive(Debug, Clone)]
struct RestoreSessionPlan {
original_session_id: SessionId,
- session_uuid: String,
session_name: String,
working_dir: PathBuf,
shell: Option,
@@ -77,6 +76,19 @@ struct CompletedRestoreBootstrap {
result: Result,
}
+/// Safety-net timeout for prompt-aware resume dispatch. If a session's shell
+/// has not produced any PTY output within this window, the resume command is
+/// sent anyway (the shell may not emit OSC 133 or may be very slow to start).
+const RESUME_COMMAND_FALLBACK_TIMEOUT: Duration = Duration::from_secs(3);
+
+fn resume_command_is_ready(shell_output_seen: bool, pty_size_synced: bool) -> bool {
+ shell_output_seen && pty_size_synced
+}
+
+fn timed_out_resume_can_dispatch(visible: bool, pty_size_synced: bool) -> bool {
+ pty_size_synced || !visible
+}
+
fn legacy_pane_stacks_from_groups(
saved_sessions: &[Session],
pane_tab_groups: &[PaneTabGroup],
@@ -656,7 +668,6 @@ mod tests {
fn restore_resume_commands_preserve_cli_order() {
let plan = RestoreSessionPlan {
original_session_id: SessionId(1),
- session_uuid: "session-uuid-1".to_string(),
session_name: "Session 1".to_string(),
working_dir: sample_working_dir(),
shell: None,
@@ -679,6 +690,21 @@ mod tests {
);
}
+ #[test]
+ fn restored_agent_waits_for_shell_output_and_real_pty_size() {
+ assert!(!resume_command_is_ready(false, false));
+ assert!(!resume_command_is_ready(true, false));
+ assert!(!resume_command_is_ready(false, true));
+ assert!(resume_command_is_ready(true, true));
+ }
+
+ #[test]
+ fn visible_timed_out_agent_still_waits_for_real_pty_size() {
+ assert!(!timed_out_resume_can_dispatch(true, false));
+ assert!(timed_out_resume_can_dispatch(true, true));
+ assert!(timed_out_resume_can_dispatch(false, false));
+ }
+
#[test]
fn bootstrap_session_returns_session_metadata() {
let session_manager = create_test_session_manager();
@@ -965,7 +991,6 @@ mod tests {
fn restore_plan_cli_type_prefers_known_resume_metadata() {
let base = RestoreSessionPlan {
original_session_id: SessionId(1),
- session_uuid: "session-uuid-1".to_string(),
session_name: "Session 1".to_string(),
working_dir: PathBuf::from("/tmp"),
shell: None,
@@ -997,7 +1022,6 @@ mod tests {
fn restore_resume_commands_empty_for_plan_with_no_cli_fields() {
let plan = RestoreSessionPlan {
original_session_id: SessionId(1),
- session_uuid: "uuid".to_string(),
session_name: "Session 1".to_string(),
working_dir: sample_working_dir(),
shell: None,
@@ -1255,6 +1279,9 @@ impl WorkspaceView {
}
fn create_terminal_view_for_session(&mut self, session_id: SessionId) {
+ // Session IDs can be reused after closing a pane. A new ConPTY always
+ // starts at its default 80x24 size and must be synchronized again.
+ self.cache.pty_sizes.remove(&session_id);
let (pty_tx, pty_rx) = tokio::sync::mpsc::unbounded_channel();
let terminal = Terminal::new(24, 80, session_id, pty_tx);
let theme = self.workspace.theme();
@@ -1443,9 +1470,6 @@ impl WorkspaceView {
let codex_started_at = plan.codex_started_at;
if let Ok(manager) = self.session_manager.lock() {
manager.with_session_state_mut(bootstrapped.session_id, |state| {
- // session_uuid is set only inside this restore_cli-gated block;
- // the local session struct receives the same guard at the assignment below.
- state.session.session_uuid = plan.session_uuid.clone();
state.session.codex_execution_mode = codex_execution_mode;
state.session.codex_started_at = codex_started_at;
});
@@ -1453,9 +1477,6 @@ impl WorkspaceView {
}
let mut session = bootstrapped.session;
- if restore_cli {
- session.session_uuid = plan.session_uuid.clone();
- }
session.shell = bootstrapped.request.requested_shell.clone();
session.group = plan.group.clone();
session.color = plan.color.clone();
@@ -1489,21 +1510,113 @@ impl WorkspaceView {
);
});
}
+ }
+
+ /// Enqueue resume commands to be dispatched when each session's shell
+ /// produces its first output (prompt-aware dispatch). The commands are
+ /// stored in `polling.pending_resume_commands` and flushed by
+ /// `dispatch_pending_resume_commands()` — either when the output pipeline
+ /// delivers the first real bytes for a session, or after a fallback timeout.
+ fn enqueue_restored_resume_commands(
+ &mut self,
+ pending_commands: Vec<(SessionId, Vec)>,
+ ) {
+ let now = std::time::Instant::now();
+ let mut enqueued_sessions = Vec::new();
+ for (session_id, commands) in pending_commands {
+ if !commands.is_empty() {
+ info!(
+ ?session_id,
+ command_count = commands.len(),
+ "Enqueued resume commands (prompt-aware dispatch)"
+ );
+ self.polling
+ .pending_resume_commands
+ .insert(session_id, (now, commands));
+ enqueued_sessions.push(session_id);
+ }
+ }
- if restore_cli {
- for command in restore_resume_commands(&plan) {
- if let Ok(manager) = self.session_manager.lock() {
- if let Err(error) =
- manager.send_input(bootstrapped.session_id, command.as_bytes())
- {
- warn!(
- ?bootstrapped.session_id,
- %error,
- "Failed to send resume command"
- );
- }
+ // Output or the first layout pass may have completed while the restore
+ // batch was still being assembled. Dispatch immediately when both
+ // readiness signals are already present.
+ for session_id in enqueued_sessions {
+ self.dispatch_pending_resume_commands_for_session(session_id);
+ }
+ }
+
+ /// Dispatch pending resume commands for a specific session.
+ ///
+ /// Called when the output pipeline delivers real bytes for this session or
+ /// after PTY sizing completes. Both readiness signals must be present.
+ pub(super) fn dispatch_pending_resume_commands_for_session(&mut self, session_id: SessionId) {
+ if !resume_command_is_ready(
+ self.polling.resume_shell_ready.contains(&session_id),
+ self.cache.pty_sizes.contains_key(&session_id),
+ ) {
+ return;
+ }
+
+ self.dispatch_pending_resume_commands_now(session_id);
+ }
+
+ fn dispatch_pending_resume_commands_now(&mut self, session_id: SessionId) {
+ let Some((_, commands)) = self.polling.pending_resume_commands.remove(&session_id) else {
+ return;
+ };
+ self.polling.resume_shell_ready.remove(&session_id);
+ info!(
+ ?session_id,
+ command_count = commands.len(),
+ "Dispatching resume commands (shell produced output)"
+ );
+ if let Ok(manager) = self.session_manager.lock() {
+ for command in commands {
+ if let Err(error) = manager.send_input(session_id, command.as_bytes()) {
+ warn!(?session_id, %error, "Failed to send resume command");
}
}
+ manager.mark_output_pending(session_id);
+ }
+ }
+
+ /// Check for timed-out pending resume commands and dispatch them.
+ ///
+ /// This is the safety-net path: if a shell does not produce any PTY output
+ /// within `RESUME_COMMAND_FALLBACK_TIMEOUT`, the resume command is sent
+ /// anyway so the session does not remain stuck.
+ pub(super) fn dispatch_timed_out_resume_commands(&mut self) {
+ let expired: Vec = self
+ .polling
+ .pending_resume_commands
+ .iter()
+ .filter(|(_, (enqueued_at, _))| {
+ enqueued_at.elapsed() >= RESUME_COMMAND_FALLBACK_TIMEOUT
+ })
+ .map(|(session_id, _)| *session_id)
+ .collect();
+ for session_id in expired {
+ let visible = self.workspace.visible_session_ids().contains(&session_id);
+ if !timed_out_resume_can_dispatch(
+ visible,
+ self.cache.pty_sizes.contains_key(&session_id),
+ ) {
+ info!(
+ ?session_id,
+ "Resume timeout reached, waiting for visible pane PTY size"
+ );
+ if let Some((enqueued_at, _)) =
+ self.polling.pending_resume_commands.get_mut(&session_id)
+ {
+ *enqueued_at = std::time::Instant::now();
+ }
+ continue;
+ }
+ info!(
+ ?session_id,
+ "Resume command fallback timeout — dispatching without prompt"
+ );
+ self.dispatch_pending_resume_commands_now(session_id);
}
}
@@ -1648,7 +1761,6 @@ impl WorkspaceView {
sessions.push(RestoreSessionPlan {
original_session_id: saved.id,
- session_uuid: saved.session_uuid.clone(),
session_name,
working_dir,
shell: saved.shell,
@@ -1714,6 +1826,7 @@ impl WorkspaceView {
let session_manager = self.session_manager.clone();
cx.spawn(async move |this: gpui::WeakEntity, cx| {
let mut restored_session_ids = std::collections::HashMap::new();
+ let mut pending_resume_commands: Vec<(SessionId, Vec)> = Vec::new();
let total_batches = restore_batches.len();
for (batch_index, batch) in restore_batches.into_iter().enumerate() {
let is_last_batch = batch_index + 1 == total_batches;
@@ -1739,6 +1852,20 @@ impl WorkspaceView {
Ok(bootstrapped) => {
let restored_session_id = bootstrapped.session_id;
let original_session_id = completion.plan.original_session_id;
+ let restore_cli = this
+ .effective_user_settings()
+ .general
+ .restore_cli_on_startup;
+ if restore_cli {
+ let commands = restore_resume_commands(&completion.plan)
+ .into_iter()
+ .map(str::to_owned)
+ .collect::>();
+ if !commands.is_empty() {
+ pending_resume_commands
+ .push((restored_session_id, commands));
+ }
+ }
this.finalize_restored_session_bootstrap(
bootstrapped,
completion.plan,
@@ -1768,6 +1895,9 @@ impl WorkspaceView {
this.sync_layout_derived_state();
this.sync_file_tree_to_focused_session(cx);
}
+ this.enqueue_restored_resume_commands(std::mem::take(
+ &mut pending_resume_commands,
+ ));
this.polling.restore_in_flight = false;
info!("Session restoration complete");
// Persist immediately so any session_uuids generated for
@@ -1954,8 +2084,11 @@ impl WorkspaceView {
readers.cached_status.remove(&id);
}
self.polling.shell_input_buffers.remove(&id);
+ self.polling.pending_resume_commands.remove(&id);
+ self.polling.resume_shell_ready.remove(&id);
self.cache.effective_shell_labels.remove(&id);
self.cache.restore_shell_fallbacks.remove(&id);
+ self.cache.pty_sizes.remove(&id);
// Remove from output dispatcher tracking (ready/in-flight sets)
self.output_dispatcher.remove_session(id);
diff --git a/crates/codirigent-ui/src/workspace/impl_ui_operations.rs b/crates/codirigent-ui/src/workspace/impl_ui_operations.rs
index 39ad2498..0d8c558a 100644
--- a/crates/codirigent-ui/src/workspace/impl_ui_operations.rs
+++ b/crates/codirigent-ui/src/workspace/impl_ui_operations.rs
@@ -100,15 +100,20 @@ impl WorkspaceView {
}
/// Open the session context menu for a specific session.
+ ///
+ /// When `anchor_x` is `Some`, the menu is positioned at that X coordinate
+ /// (e.g. for tab right-click). When `None`, default drawer-relative positioning is used.
pub fn open_session_menu(
&mut self,
session_id: SessionId,
anchor_y: Option,
+ anchor_x: Option,
cx: &mut Context,
) {
info!(?session_id, "Opening session menu");
self.selection.session_menu_open = Some(session_id);
self.selection.session_menu_anchor_y = anchor_y;
+ self.selection.session_menu_anchor_x = anchor_x;
cx.notify();
}
@@ -117,6 +122,7 @@ impl WorkspaceView {
info!("Closing session menu");
self.selection.session_menu_open = None;
self.selection.session_menu_anchor_y = None;
+ self.selection.session_menu_anchor_x = None;
cx.notify();
}
diff --git a/crates/codirigent-ui/src/workspace/modal_render.rs b/crates/codirigent-ui/src/workspace/modal_render.rs
index 3660d050..768a3805 100644
--- a/crates/codirigent-ui/src/workspace/modal_render.rs
+++ b/crates/codirigent-ui/src/workspace/modal_render.rs
@@ -761,11 +761,18 @@ impl WorkspaceView {
};
let input_value = if self.modals.cursor_blink_on {
- if modal.input.is_empty() {
- "|".to_string()
- } else {
- format!("{}|", modal.input)
- }
+ let cursor = modal.cursor_position.min(modal.input.chars().count());
+ let cursor_byte = modal
+ .input
+ .char_indices()
+ .nth(cursor)
+ .map(|(i, _)| i)
+ .unwrap_or(modal.input.len());
+ let mut out = String::with_capacity(modal.input.len() + 1);
+ out.push_str(&modal.input[..cursor_byte]);
+ out.push('|');
+ out.push_str(&modal.input[cursor_byte..]);
+ out
} else {
modal.input.clone()
};
diff --git a/crates/codirigent-ui/src/workspace/pane_header_render.rs b/crates/codirigent-ui/src/workspace/pane_header_render.rs
index 2b9de338..276aff73 100644
--- a/crates/codirigent-ui/src/workspace/pane_header_render.rs
+++ b/crates/codirigent-ui/src/workspace/pane_header_render.rs
@@ -239,7 +239,19 @@ impl WorkspaceView {
cx.notify();
}
}
- }));
+ }))
+ .on_mouse_down(
+ MouseButton::Right,
+ cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
+ this.open_session_menu(
+ tab_session_id,
+ Some(event.position.y.into()),
+ Some(event.position.x.into()),
+ cx,
+ );
+ cx.stop_propagation();
+ }),
+ );
// Apply glow border if present
if let Some(glow_border) = decoration.tab_border {
diff --git a/crates/codirigent-ui/src/workspace/render.rs b/crates/codirigent-ui/src/workspace/render.rs
index 016594a5..8796b53e 100644
--- a/crates/codirigent-ui/src/workspace/render.rs
+++ b/crates/codirigent-ui/src/workspace/render.rs
@@ -28,8 +28,8 @@ use tracing::info;
impl WorkspaceView {
/// Render the title bar with window controls (minimize, maximize, close).
///
- /// This is a 32px bar with the logo on the left and native window controls
- /// on the right. The entire bar is a drag region for moving the window.
+ /// This is a 32px bar with a dedicated drag region on the left and native
+ /// window controls on the right.
pub(super) fn render_title_bar(
&mut self,
window: &mut Window,
@@ -40,8 +40,7 @@ impl WorkspaceView {
let border_color: gpui::Hsla = theme.border.into();
let fg: gpui::Hsla = theme.foreground.into();
- // The entire bar is a drag region. Caption buttons use .occlude() +
- // their own WindowControlArea to carve out non-drag zones.
+ // The bar hosts a dedicated drag region plus caption buttons.
let mut bar = div()
.id("title-bar")
.h(px(self.title_bar.height()))
@@ -52,8 +51,7 @@ impl WorkspaceView {
.flex()
.items_center()
.px_3()
- .gap_2()
- .window_control_area(WindowControlArea::Drag);
+ .gap_2();
// macOS: Native traffic lights are rendered by the OS.
// Reserve left padding so content doesn't overlap them, and handle
@@ -67,34 +65,71 @@ impl WorkspaceView {
} else {
bar.pl(px(TRAFFIC_LIGHT_PADDING))
};
+ }
- bar = bar.on_click(|event: &ClickEvent, window, _cx| {
- if event.click_count() == 2 {
- window.titlebar_double_click();
- }
- });
+ // Drag region: how the user moves the window by clicking the title bar.
+ //
+ // macOS: Use GPUI's `WindowControlArea::Drag` — it returns HTCAPTION via
+ // the native hit-test and the OS handles drag + double-click-to-zoom.
+ //
+ // Windows: Do NOT use `WindowControlArea::Drag`. GPUI 0.2.x has two
+ // issues: (1) WM_NCHITTEST returns HTCAPTION while GPUI holds RefCell
+ // borrows, causing a freeze when DefWindowProc enters a modal drag
+ // loop; (2) GPUI re-dispatches WM_NCLBUTTONDOWN through its element
+ // tree, so on_mouse_down handlers eat resize events for the top edge
+ // (which overlaps the title bar).
+ //
+ // Fix: a Win32 window subclass (see `platform_drag.rs`) intercepts
+ // NC messages before GPUI. Drag uses a custom WM_APP message that the
+ // subclass routes to DefWindowProc(WM_NCLBUTTONDOWN, HTCAPTION).
+ // Resize edges go straight to DefWindowProc, bypassing GPUI.
+ let mut drag_region = div().flex().items_center().gap_2().flex_1().h_full();
+
+ #[cfg(target_os = "macos")]
+ {
+ drag_region = drag_region
+ .window_control_area(WindowControlArea::Drag)
+ .on_mouse_down(MouseButton::Left, |event: &MouseDownEvent, window, _cx| {
+ if event.click_count == 2 {
+ window.titlebar_double_click();
+ }
+ });
}
- // Windows: GPUI 0.2.1 has a stale mouse_hit_test issue in WM_NCHITTEST,
- // so WindowControlArea::Drag alone doesn't reliably initiate drags.
- // Work around by sending WM_NCLBUTTONDOWN(HTCAPTION) on mouse-down.
#[cfg(target_os = "windows")]
{
use raw_window_handle::HasWindowHandle;
- let raw_handle = window.window_handle().ok().map(|h| match h.as_raw() {
- raw_window_handle::RawWindowHandle::Win32(win32) => win32.hwnd.get(),
- _ => 0,
- });
+ let raw_handle =
+ HasWindowHandle::window_handle(window)
+ .ok()
+ .map(|h| match h.as_raw() {
+ raw_window_handle::RawWindowHandle::Win32(win32) => win32.hwnd.get(),
+ _ => 0,
+ });
if let Some(hwnd) = raw_handle {
- bar = bar.on_mouse_down(gpui::MouseButton::Left, move |_event, _window, _cx| {
- crate::platform_drag::begin_title_bar_drag(hwnd);
- });
+ // Install the window subclass that routes WM_NCLBUTTONDOWN
+ // for resize edges directly to DefWindowProc (bypassing
+ // GPUI's element dispatch) and handles our custom drag
+ // message. Safe to call every frame — only installs once.
+ crate::platform_drag::install_drag_subclass(hwnd);
+
+ drag_region = drag_region.on_mouse_down(
+ MouseButton::Left,
+ move |event: &MouseDownEvent, window, _cx| {
+ if event.click_count == 2 {
+ window.titlebar_double_click();
+ } else {
+ crate::platform_drag::begin_title_bar_drag(hwnd);
+ }
+ },
+ );
}
}
// Logo (3x3 grid matching logo-primary-dark.svg)
- bar = bar.child(div().flex_shrink_0().ml_2().child(self.render_logo_small()));
- bar = bar.child(
+ drag_region =
+ drag_region.child(div().flex_shrink_0().ml_2().child(self.render_logo_small()));
+ drag_region = drag_region.child(
div()
.text_sm()
.font_weight(FontWeight::BOLD)
@@ -103,8 +138,9 @@ impl WorkspaceView {
.child(TitleBar::LOGO_TEXT),
);
- // Spacer — fills remaining space so window controls stay on the right
- bar = bar.child(div().flex_1());
+ // Spacer - fills remaining space so window controls stay on the right.
+ drag_region = drag_region.child(div().flex_1());
+ bar = bar.child(drag_region);
// Window controls (Windows/Linux)
// Uses native Segoe icon fonts and WindowControlArea for OS-level handling.
@@ -329,6 +365,13 @@ impl WorkspaceView {
this.close_session_menu(cx);
cx.stop_propagation();
}),
+ )
+ .on_mouse_down(
+ MouseButton::Right,
+ cx.listener(|this, _: &MouseDownEvent, _window, cx| {
+ this.close_session_menu(cx);
+ cx.stop_propagation();
+ }),
);
// Build dropdown menu
@@ -460,8 +503,11 @@ impl WorkspaceView {
cx,
));
- // Position dropdown to the right of the drawer, aligned with the row
- let left_offset = crate::icon_rail::IconRail::WIDTH + self.drawer.width() - 8.0;
+ // Position dropdown: at click position (tab right-click) or next to the drawer (default).
+ let left_offset = self
+ .selection
+ .session_menu_anchor_x
+ .unwrap_or_else(|| crate::icon_rail::IconRail::WIDTH + self.drawer.width() - 8.0);
Some(
div()
diff --git a/crates/codirigent-ui/src/workspace/scrollbar_render.rs b/crates/codirigent-ui/src/workspace/scrollbar_render.rs
index 7a9271db..4ed26f03 100644
--- a/crates/codirigent-ui/src/workspace/scrollbar_render.rs
+++ b/crates/codirigent-ui/src/workspace/scrollbar_render.rs
@@ -20,7 +20,13 @@ impl WorkspaceView {
cx: &mut Context,
) -> Option {
let terminal_view = self.terminals.get(&session_id)?;
- let track_height = canvas_metrics.get().content_height;
+
+ // Approximate track height from terminal grid dimensions at render
+ // time. The Rc> canvas_metrics is only populated during
+ // prepaint (after the element tree is built), so reading it here
+ // would always yield 0. Mouse handlers read the Rc lazily (after
+ // prepaint) and prefer the exact content_height when available.
+ let track_height = terminal_view.rows() as f32 * terminal_view.cell_height();
if track_height <= 0.0 {
return None;
}
@@ -74,13 +80,19 @@ impl WorkspaceView {
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event: &MouseDownEvent, window, cx| {
- let origin_y = metrics_for_track.get().origin_y;
+ let metrics = metrics_for_track.get();
+ let effective_track = if metrics.content_height > 0.0 {
+ metrics.content_height
+ } else {
+ track_height
+ };
+ let origin_y = metrics.origin_y;
let pointer_y: f32 = event.position.y.into();
let relative_y = pointer_y - origin_y;
if let Some(terminal_view) = this.terminals.get_mut(&session_id) {
let target = terminal_view.scrollbar_offset_for_pointer(
relative_y,
- track_height,
+ effective_track,
None,
);
if target != terminal_view.display_offset() {
@@ -126,20 +138,26 @@ impl WorkspaceView {
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event: &MouseDownEvent, window, cx| {
- let origin_y = metrics_for_thumb.get().origin_y;
+ let metrics = metrics_for_thumb.get();
+ let effective_track = if metrics.content_height > 0.0 {
+ metrics.content_height
+ } else {
+ track_height
+ };
+ let origin_y = metrics.origin_y;
let pointer_y: f32 = event.position.y.into();
let relative_y = pointer_y - origin_y;
if let Some(terminal_view) = this.terminals.get_mut(&session_id) {
let (_, current_thumb_top) =
- terminal_view.scrollbar_thumb_metrics(track_height);
+ terminal_view.scrollbar_thumb_metrics(effective_track);
let thumb_offset = (relative_y - current_thumb_top).max(0.0);
terminal_view.start_scrollbar_drag(thumb_offset);
this.selection.terminal_scrollbar_drag =
Some(super::types::TerminalScrollbarDragState {
session_id,
track_top: origin_y,
- track_height,
+ track_height: effective_track,
});
this.select_session_with_cx(session_id, cx);
window.focus(&this.focus_handle(cx));
diff --git a/crates/codirigent-ui/src/workspace/settings_panels.rs b/crates/codirigent-ui/src/workspace/settings_panels.rs
index f20adb7c..6e23594a 100644
--- a/crates/codirigent-ui/src/workspace/settings_panels.rs
+++ b/crates/codirigent-ui/src/workspace/settings_panels.rs
@@ -1241,7 +1241,7 @@ impl super::gpui::WorkspaceView {
.text_color(ansi_hsla(12))
.child("Compiling ")
.child(div().text_color(fg).child("codirigent-ui"))
- .child(" v0.1.0"),
+ .child(format!(" v{}", env!("CARGO_PKG_VERSION"))),
)
.child(
div()
diff --git a/crates/codirigent-ui/src/workspace/task_board_render.rs b/crates/codirigent-ui/src/workspace/task_board_render.rs
index f5f1f3f0..e5add42f 100644
--- a/crates/codirigent-ui/src/workspace/task_board_render.rs
+++ b/crates/codirigent-ui/src/workspace/task_board_render.rs
@@ -10,9 +10,9 @@ use crate::workspace::gpui::WorkspaceView;
use crate::workspace::render::SessionMenuAction;
use codirigent_core::SessionId;
use gpui::{
- div, prelude::FluentBuilder, px, ClickEvent, Context, FontWeight, InteractiveElement,
- IntoElement, MouseButton, MouseDownEvent, ParentElement, SharedString,
- StatefulInteractiveElement, Styled,
+ canvas, div, prelude::FluentBuilder, px, ClickEvent, Context, ElementInputHandler, Focusable,
+ FontWeight, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement,
+ SharedString, StatefulInteractiveElement, Styled,
};
/// Priority indicator colors in Hsla (consistent with amber/green constants below).
@@ -81,6 +81,29 @@ const GREEN_BG_HOVER: gpui::Hsla = gpui::Hsla {
};
impl WorkspaceView {
+ fn task_modal_input_registration(
+ &self,
+ focused: bool,
+ cx: &mut Context,
+ ) -> impl IntoElement {
+ let entity = cx.entity();
+ let focus_handle = self.focus_handle(cx);
+ canvas(
+ |bounds, _window, _cx| bounds,
+ move |bounds, _prepaint, window, cx| {
+ if focused {
+ window.handle_input(
+ &focus_handle,
+ ElementInputHandler::new(bounds, entity),
+ cx,
+ );
+ }
+ },
+ )
+ .absolute()
+ .inset_0()
+ }
+
/// Convert core Task to UI TaskItem with status mapping.
pub(super) fn core_task_to_ui_item(
&self,
@@ -229,6 +252,7 @@ impl WorkspaceView {
let desc_focused = modal.focused_field == 1;
let plan_focused = modal.focused_field == 2;
let cursor_visible = self.modals.cursor_blink_on;
+ let ime_preedit_text = self.ime_preedit_text.as_deref().unwrap_or("");
let with_cursor =
|value: &str, focused: bool, cursor: usize, placeholder: &str| -> String {
@@ -240,7 +264,7 @@ impl WorkspaceView {
};
}
- if !cursor_visible {
+ if !cursor_visible && ime_preedit_text.is_empty() {
return value.to_string();
}
@@ -250,9 +274,12 @@ impl WorkspaceView {
.nth(cursor)
.map(|(i, _)| i)
.unwrap_or(value.len());
- let mut out = String::with_capacity(value.len() + 1);
+ let mut out = String::with_capacity(value.len() + ime_preedit_text.len() + 1);
out.push_str(&value[..cursor_byte]);
- out.push('|');
+ out.push_str(ime_preedit_text);
+ if cursor_visible {
+ out.push('|');
+ }
out.push_str(&value[cursor_byte..]);
out
};
@@ -282,6 +309,10 @@ impl WorkspaceView {
.map(|p| format!("Project: {}", p.display()))
.unwrap_or_else(|| "Project: (none)".to_string());
+ let title_input_registration = self.task_modal_input_registration(title_focused, cx);
+ let description_input_registration = self.task_modal_input_registration(desc_focused, cx);
+ let plan_input_registration = self.task_modal_input_registration(plan_focused, cx);
+
Some(
div()
.id("task-creation-overlay")
@@ -372,6 +403,7 @@ impl WorkspaceView {
modal.error.is_some(),
&input_style,
)
+ .relative()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _event, _window, cx| {
@@ -384,7 +416,8 @@ impl WorkspaceView {
}
cx.notify();
}),
- ),
+ )
+ .child(title_input_registration),
),
)
.child(
@@ -402,6 +435,7 @@ impl WorkspaceView {
)))
.child(
div()
+ .relative()
.h(px(120.0))
.w_full()
.p_3()
@@ -436,7 +470,8 @@ impl WorkspaceView {
cx.notify();
}),
)
- .child(description_value),
+ .child(description_value)
+ .child(description_input_registration),
),
)
// Priority selector
@@ -512,6 +547,7 @@ impl WorkspaceView {
false,
&input_style,
)
+ .relative()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _event, _window, cx| {
@@ -524,7 +560,8 @@ impl WorkspaceView {
}
cx.notify();
}),
- ),
+ )
+ .child(plan_input_registration),
),
)
.when_some(modal.error.clone(), |this, error| {
diff --git a/crates/codirigent-ui/src/workspace/terminal_render.rs b/crates/codirigent-ui/src/workspace/terminal_render.rs
index 2870aa75..0d1e427a 100644
--- a/crates/codirigent-ui/src/workspace/terminal_render.rs
+++ b/crates/codirigent-ui/src/workspace/terminal_render.rs
@@ -201,6 +201,11 @@ impl WorkspaceView {
}
}
+ // 0. Fill canvas with terminal background so empty rows (default-color cells
+ // that produce no bg_rects_hsla entries) don't show panel_bg below them.
+ // This fixes the visual "cut-off" after dragging to a larger pane.
+ window.paint_quad(gpui::fill(bounds, terminal_bg));
+
// 1. Paint background rectangles
for row in &cached_rows {
for (rect_row, start_col, end_col, bg_color) in row.bg_rects_hsla.iter() {
diff --git a/crates/codirigent-ui/src/workspace/toast_render.rs b/crates/codirigent-ui/src/workspace/toast_render.rs
index eed26133..722030f6 100644
--- a/crates/codirigent-ui/src/workspace/toast_render.rs
+++ b/crates/codirigent-ui/src/workspace/toast_render.rs
@@ -10,6 +10,11 @@ use gpui::{
SharedString, StatefulInteractiveElement, Styled,
};
+/// Keep pointer input inside an update toast from reaching the workspace underneath it.
+fn prevent_update_toast_pointer_passthrough(element: E) -> E {
+ element.occlude()
+}
+
impl WorkspaceView {
/// Render the auto-update toast notification.
///
@@ -41,22 +46,24 @@ impl WorkspaceView {
let muted: gpui::Hsla = theme.muted.into();
let primary: gpui::Hsla = theme.primary.into();
- let mut toast = div()
- .id("update-toast")
- .absolute()
- .bottom(px(16.0))
- .right(px(16.0))
- .bg(panel_bg)
- .border_1()
- .border_color(border_color)
- .rounded_lg()
- .shadow_lg()
- .p_3()
- .flex()
- .flex_col()
- .gap_2()
- .max_w(px(320.0))
- .min_w(px(240.0));
+ let mut toast = prevent_update_toast_pointer_passthrough(
+ div()
+ .id("update-toast")
+ .absolute()
+ .bottom(px(16.0))
+ .right(px(16.0))
+ .bg(panel_bg)
+ .border_1()
+ .border_color(border_color)
+ .rounded_lg()
+ .shadow_lg()
+ .p_3()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .max_w(px(320.0))
+ .min_w(px(240.0)),
+ );
match variant {
ToastVariant::UpdateAvailable { version } => {
@@ -294,3 +301,74 @@ enum ToastVariant {
Downloading { percent: u8 },
ReadyToApply { version: String },
}
+
+#[cfg(test)]
+mod tests {
+ use super::prevent_update_toast_pointer_passthrough;
+ use gpui::{
+ div, point, px, size, InteractiveElement, Modifiers, MouseButton, ParentElement, Styled,
+ TestAppContext,
+ };
+ use std::{cell::Cell, rc::Rc};
+
+ #[gpui::test]
+ fn update_toast_click_does_not_reach_content_behind_it(cx: &mut TestAppContext) {
+ let behind_mouse_downs = Rc::new(Cell::new(0));
+ let toast_mouse_downs = Rc::new(Cell::new(0));
+ let visual_cx = cx.add_empty_window();
+
+ visual_cx.draw(point(px(0.0), px(0.0)), size(px(100.0), px(100.0)), {
+ let behind_mouse_downs = behind_mouse_downs.clone();
+ let toast_mouse_downs = toast_mouse_downs.clone();
+
+ move |_, _| {
+ let behind_mouse_downs = behind_mouse_downs.clone();
+ let toast_mouse_downs = toast_mouse_downs.clone();
+
+ div()
+ .relative()
+ .size_full()
+ .child(div().absolute().inset_0().on_mouse_down(
+ MouseButton::Left,
+ move |_, _, _| {
+ behind_mouse_downs.set(behind_mouse_downs.get() + 1);
+ },
+ ))
+ .child(
+ prevent_update_toast_pointer_passthrough(
+ div()
+ .id("test-update-toast")
+ .debug_selector(|| "test-update-toast".to_string())
+ .absolute()
+ .left(px(20.0))
+ .top(px(20.0))
+ .w(px(60.0))
+ .h(px(60.0)),
+ )
+ .on_mouse_down(
+ MouseButton::Left,
+ move |_, _, _| {
+ toast_mouse_downs.set(toast_mouse_downs.get() + 1);
+ },
+ ),
+ )
+ }
+ });
+
+ let toast_bounds = visual_cx
+ .debug_bounds("test-update-toast")
+ .expect("test update toast should be rendered");
+ visual_cx.simulate_mouse_down(toast_bounds.center(), MouseButton::Left, Modifiers::none());
+
+ assert_eq!(
+ toast_mouse_downs.get(),
+ 1,
+ "toast should receive its own mouse input"
+ );
+ assert_eq!(
+ behind_mouse_downs.get(),
+ 0,
+ "toast click must not reach the content behind it"
+ );
+ }
+}
diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs
index 07059b73..77943ca4 100644
--- a/crates/codirigent-ui/src/workspace/types.rs
+++ b/crates/codirigent-ui/src/workspace/types.rs
@@ -196,6 +196,8 @@ pub(super) struct SessionActionModal {
pub(super) kind: SessionActionKind,
/// User input value.
pub(super) input: String,
+ /// Cursor position (char index) within `input`.
+ pub(super) cursor_position: usize,
/// Optional error message if validation fails.
pub(super) error: Option,
}
@@ -317,6 +319,10 @@ pub(super) struct SelectionState {
pub session_menu_open: Option,
/// Vertical anchor position for the session menu overlay, in window pixels.
pub session_menu_anchor_y: Option,
+ /// Horizontal anchor position for the session menu overlay, in window pixels.
+ /// When `Some`, the menu is positioned at this X coordinate (e.g. tab right-click).
+ /// When `None`, the menu uses the default drawer-relative positioning.
+ pub session_menu_anchor_x: Option,
/// Whether the user is actively dragging a text selection in a terminal.
pub is_selecting: bool,
/// Session ID that is currently being selected in (for mouse move events).
@@ -456,6 +462,7 @@ impl SelectionState {
selected_session_id: None,
session_menu_open: None,
session_menu_anchor_y: None,
+ session_menu_anchor_x: None,
is_selecting: false,
selecting_session_id: None,
file_tree_context_menu: None,
@@ -525,6 +532,14 @@ pub(super) struct PollingState {
pub last_legacy_fallback: Instant,
/// Best-effort shell command line capture per session while the shell is idle.
pub shell_input_buffers: HashMap,
+ /// CLI resume commands waiting for the shell to produce output before
+ /// being dispatched. Keyed by session ID; value is (enqueued_at, commands).
+ /// Commands are sent as soon as the first PTY output is received (the shell
+ /// is alive) or after `RESUME_COMMAND_FALLBACK_TIMEOUT` as a safety net.
+ pub pending_resume_commands: HashMap)>,
+ /// Restored sessions whose shell has produced output and is ready to
+ /// receive its resume command once the PTY has its rendered pane size.
+ pub resume_shell_ready: HashSet,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -561,6 +576,8 @@ impl PollingState {
pending_session_bootstrap_slots: HashSet::new(),
last_legacy_fallback: Instant::now(),
shell_input_buffers: HashMap::new(),
+ pending_resume_commands: HashMap::new(),
+ resume_shell_ready: HashSet::new(),
}
}
}
diff --git a/docs/architecture/workspace/output-polling.md b/docs/architecture/workspace/output-polling.md
index 0847a555..e5025f69 100644
--- a/docs/architecture/workspace/output-polling.md
+++ b/docs/architecture/workspace/output-polling.md
@@ -160,10 +160,19 @@ response or when post-command follow-up input timing is wrong.
Status is not driven by one source. The system combines multiple hints:
- detector state from `InputDetector`
+- semantic classification of the current visible terminal viewport
- hook-derived status for Claude Code
- JSONL-derived status for Codex/Gemini
- stale-cache handling rules
+The terminal viewport is reconstructed from `TerminalRenderSnapshot` after
+each PTY output batch and passed to `InputDetector::process_visible_screen`.
+This is important for full-screen Agent TUIs: screen redraws replace old
+permission menus, while raw PTY history would keep stale menu text. Semantic
+rules are status-bearing (`NeedsAttention`, `ResponseReady`, and so on) and
+match interaction concepts instead of requiring a known `CliType`. Legacy
+custom input regexes remain compatible and still mean `NeedsAttention`.
+
The actual arbitration happens in:
- `status_engine.rs`
diff --git a/docs/hook-and-status-system.md b/docs/features/hook-and-status-system.md
similarity index 92%
rename from docs/hook-and-status-system.md
rename to docs/features/hook-and-status-system.md
index c40acfc7..a1fb8e3a 100644
--- a/docs/hook-and-status-system.md
+++ b/docs/features/hook-and-status-system.md
@@ -95,10 +95,32 @@ hook_event_name notification_type → signal status
UserPromptSubmit (any) → "working"
Stop (any) → "response_ready"
Notification "permission_prompt" → "needs_attention"
+Notification "idle_prompt" → "response_ready"
Notification anything else → "idle"
(unknown event) (any) → "idle"
```
+Claude Code can emit `idle_prompt` immediately after `Stop`. Both events mean
+that Claude has finished and is waiting for the next user prompt, so they map
+to the same `response_ready` signal. This also prevents a later notification
+from overwriting an unread completion with `idle` before the next UI poll.
+
+### Generic Agent fallback
+
+Agents without a dedicated hook or JSONL reader use the terminal detector's
+visible-screen semantic rules. The detector reconstructs the current viewport
+after each PTY output batch and classifies interaction semantics such as:
+
+- a menu containing allow/approve, deny/reject, and select/confirm actions →
+ `needs_attention`
+- an Agent-style empty input prompt plus interaction chrome, or an explicit
+ “ready for next task” state → `response_ready`
+
+These rules do not require the Agent to have a `CliType` enum variant. A new
+integration with unique terminal wording can add a `StatusRule` declaring its
+target status and required/excluded regex features; old custom prompt regexes
+continue to map to `needs_attention`.
+
### Signal File
The hook writes a small JSON file to the signals directory:
diff --git a/docs/session-resume.md b/docs/features/session-resume.md
similarity index 100%
rename from docs/session-resume.md
rename to docs/features/session-resume.md
diff --git a/docs/ghostty-theme-registry-plan.md b/docs/ghostty-theme-registry-plan.md
deleted file mode 100644
index ab40bc2d..00000000
--- a/docs/ghostty-theme-registry-plan.md
+++ /dev/null
@@ -1,546 +0,0 @@
-# Ghostty-Style Theme Registry Plan
-
-Implementation plan for expanding Codirigent's theme system from a built-in
-`dark/light` toggle into a registry-backed theme model with custom theme files,
-runtime theme IDs, and terminal palette behavior that can scale toward a
-Ghostty-style theme experience.
-
-This document is intentionally written before code changes. It is the working
-plan for the branch `feat/ghostty-theme-registry`.
-
----
-
-## Execution Summary
-
-This branch is now complete. The implementation landed as the planned task
-series with small reviewable commits:
-
-1. `ced6530` `Add theme registry conversion backbone`
-2. `d2c0be0` `Apply saved theme IDs during settings load`
-3. `03d208f` `Make settings theme picker registry-driven`
-4. `f632676` `Load custom themes during settings startup`
-5. `4df2b36` `Apply selection text color in terminal rendering`
-6. `eb020b1` `Update lockfile for theme rendering dependency`
-
-Final outcome:
-
-- theme selection is registry-based instead of hardcoded `dark/light`
-- `appearance.theme` now round-trips as a durable theme ID
-- startup/settings load resolves and applies saved theme IDs through one path
-- custom themes load from the user config `themes/` directory off the UI thread
-- terminal fg/bg/cursor/selection/ANSI palette are all on the active theme path
-- terminal selection foreground is now actually rendered, not just stored in the schema
-
----
-
-## Purpose
-
-Codirigent already has the terminal rendering primitives needed for richer
-themes, but the application still behaves like a two-theme product:
-
-- the runtime UI uses `CodirigentTheme`
-- the settings UI only exposes `dark` and `light`
-- the saved setting is treated like a boolean mode rather than a durable
- registry theme ID
-- custom theme loading infrastructure exists separately but is not wired into
- app startup or live theme application
-
-The goal of this task series is to make themes a first-class product feature
-instead of a hardcoded toggle.
-
----
-
-## Problem Statement
-
-The current implementation has four structural gaps:
-
-1. **Two parallel theme models**
- - `crates/codirigent-ui/src/theme.rs` defines the runtime theme actually used
- by UI and terminal rendering.
- - `crates/codirigent-ui/src/theme_config.rs` and
- `crates/codirigent-ui/src/theme_manager.rs` define a serializable theme
- model and registry, but they are not the active runtime path.
-
-2. **Theme selection is hardcoded**
- - Settings only present `dark` and `light`.
- - Theme switching constructs `CodirigentTheme::dark()` or
- `CodirigentTheme::light()` directly.
-
-3. **Saved theme identity is not durable**
- - `appearance.theme` is stored as a `String`, but the settings page rebuild
- currently infers the value from current background lightness instead of
- preserving the active theme ID.
-
-4. **Load/apply path is incomplete**
- - User settings are loaded and cached, but the selected theme is not treated
- as a registry-resolved startup input.
-
----
-
-## Current Architecture Inventory
-
-### Runtime Theme Path
-
-- `crates/codirigent-ui/src/theme.rs`
- - owns `CodirigentTheme`
- - contains UI colors, terminal colors, typography, spacing
- - contains ANSI 16-color palette and 256-color indexed conversion
-
-- `crates/codirigent-ui/src/terminal_colors.rs`
- - maps terminal named/indexed/spec colors into runtime theme colors
-
-- `crates/codirigent-ui/src/terminal_view.rs`
- - caches terminal bg/fg from `CodirigentTheme`
- - updates terminal runtime when theme changes
-
-- `crates/codirigent-ui/src/workspace/core.rs`
- - stores the active `CodirigentTheme`
-
-### Settings and Persistence Path
-
-- `crates/codirigent-core/src/config.rs`
- - `AppearanceSettings.theme: String`
- - `TerminalSettings` stores font/cursor/line-height preferences
-
-- `crates/codirigent-ui/src/workspace/settings_panels.rs`
- - theme dropdown is currently `["dark", "light"]`
- - directly constructs built-in runtime themes
-
-- `crates/codirigent-ui/src/workspace/impl_settings.rs`
- - settings page rebuild overwrites `appearance.theme` based on background
- lightness
- - settings load path updates cached settings but does not appear to resolve
- and apply an arbitrary theme ID through a registry
-
-### Unused or Underused Theme Registry Path
-
-- `crates/codirigent-ui/src/theme_config.rs`
- - serializable `Theme`
- - `ThemeColors`, `TerminalColors`, typography, spacing
-
-- `crates/codirigent-ui/src/theme_manager.rs`
- - registry for built-in and JSON-loaded themes
- - theme loading from a directory or file
- - active theme switching by ID
-
----
-
-## Target End State
-
-After this work series:
-
-- Codirigent loads a theme registry on startup.
-- The active theme is identified by a durable theme ID.
-- `appearance.theme` means "selected theme ID", not "dark mode boolean".
-- Settings list all available themes, not just `dark/light`.
-- Built-in themes and custom JSON themes use the same application path.
-- Runtime theme application updates both UI and terminal state consistently.
-- The terminal palette model is structured so it can grow toward a
- Ghostty-style theme schema without another large refactor.
-
----
-
-## Non-Goals For The First Pass
-
-The first implementation pass should not try to do all theme features at once.
-These are explicitly out of scope unless they fall out naturally:
-
-- importing Ghostty theme files verbatim with full syntax compatibility
-- automatic OS appearance switching
-- a theme editor UI
-- remote theme downloads
-- dynamic generation of 256-color cube replacements on the first pass
-
-The first pass is about establishing the correct architecture and durable
-runtime behavior.
-
----
-
-## Design Principles
-
-1. **One runtime source of truth**
- - The app should resolve every selected theme into one runtime
- `CodirigentTheme`.
-
-2. **Theme ID is stable**
- - Any active theme must have a durable identifier that round-trips through
- settings and app restart.
-
-3. **Custom themes should not be a side path**
- - Built-in and file-loaded themes should use the same selection and apply
- flow.
-
-4. **Terminal fidelity matters**
- - ANSI palette, foreground/background, cursor, and selection colors must all
- switch with the active theme.
-
-5. **Incremental delivery**
- - The work should land as small, reviewable tasks following
- `docs/task-verification-workflow.md`.
-
----
-
-## Implementation Constraints
-
-These constraints apply to every task in this branch:
-
-1. **Do not load theme files on the UI thread**
- - theme discovery, directory scans, and file reads must happen on a
- background executor
- - the UI thread may receive resolved theme data and apply it, but must not
- block on filesystem traversal or JSON file IO
-
-2. **Keep files at manageable length**
- - do not keep expanding already-large files with unrelated theme logic
- - when a change starts to push a file into "grab bag" territory, extract a
- focused helper/module instead
- - prefer small, reviewable modules over one large integration file
-
-3. **Prefer reusable components over duplicated wiring**
- - shared theme resolution, fallback, conversion, and apply behavior should be
- centralized
- - avoid copy-pasting theme selection logic across startup, settings, and
- terminal update paths
-
-4. **Avoid magic numbers unless they are inherent to the domain**
- - filesystem polling delays, cache TTLs, directory limits, and fallback
- constants must be named
- - if a number is part of a terminal standard or palette definition, document
- why it is fixed
-
-5. **Separate IO, state, and presentation concerns**
- - file loading belongs in a theme loading/service layer
- - theme registry state belongs in app/workspace state
- - settings UI should only render options and trigger actions
-
----
-
-## Proposed Implementation Shape
-
-### 1. Introduce a Registry-to-Runtime Conversion Layer
-
-Create a conversion path from the serializable registry theme model into
-`CodirigentTheme`.
-
-Options:
-
-- add `impl TryFrom for CodirigentTheme`
-- or add `Theme::to_runtime_theme() -> Result`
-
-Expected result:
-
-- the registry model becomes useful without replacing the runtime renderer
-- theme parsing and theme application stop being separate systems
-
-### 2. Make Theme Selection Registry-Driven
-
-Replace direct `dark/light` branching with:
-
-1. resolve selected theme ID from settings
-2. look it up in the theme registry
-3. convert it into `CodirigentTheme`
-4. apply it to workspace and terminals
-
-Fallback behavior:
-
-- if the theme ID is missing or invalid, fall back to built-in `dark`
-- log the failure with enough detail to diagnose bad custom themes
-
-### 3. Preserve Theme IDs In Settings
-
-Remove the current behavior that reconstructs `appearance.theme` by inspecting
-background lightness.
-
-Instead:
-
-- track the current active theme ID in workspace settings state
-- persist and rebuild the settings page using that actual ID
-
-### 4. Load Custom Themes From A Well-Defined Directory
-
-Decide and document the custom theme directory. Likely candidate:
-
-- `%APPDATA%/codirigent/themes/` on Windows
-- `~/.config/codirigent/themes/` on Linux/macOS
-
-The initial implementation should:
-
-- load built-in themes first
-- then overlay custom themes from disk
-- allow custom themes to coexist with built-ins under unique IDs
-- perform file discovery and JSON loading off the UI thread
-
-### 5. Keep Runtime Theme Mutations Compatible
-
-Today the code mutates parts of the active runtime theme after applying a base
-theme, for example:
-
-- grid gap
-- UI font size
-- terminal font size
-- terminal font family
-- terminal line height
-
-The new registry-driven apply path must preserve those user overrides rather
-than resetting them when a theme changes.
-
-### 6. Prepare For Ghostty-Style Theme Growth
-
-The first pass does not need full Ghostty config syntax, but the schema should
-be able to expand toward these terminal concepts cleanly:
-
-- background
-- foreground
-- cursor color
-- cursor text color
-- selection background
-- selection foreground
-- ANSI 16 palette
-- optional split between light and dark variants
-
-If a schema change is needed, prefer a backward-compatible addition over a
-throwaway one-off field.
-
----
-
-## Task Series
-
-This branch should be executed as a small task series, not one large patch.
-
-### Task 1. Document and Wire The Runtime Registry Backbone
-
-Deliverables:
-
-- conversion path from serializable theme model to runtime `CodirigentTheme`
-- built-in themes exposed through the registry path
-- unit tests for conversion and fallback behavior
-
-Done when:
-
-- a theme ID can produce a runtime theme without `if theme == "light"`
-
-Status:
-
-- complete in `ced6530`
-
-### Task 2. Apply Saved Theme IDs During Settings Load / Startup
-
-Deliverables:
-
-- startup or settings load path resolves `appearance.theme`
-- invalid IDs fall back safely
-- active theme ID is retained in workspace state
-
-Done when:
-
-- restarting the app with a non-default theme keeps the same theme selected
-
-Status:
-
-- complete in `d2c0be0`
-
-### Task 3. Make The Settings Theme Picker Dynamic
-
-Deliverables:
-
-- settings theme dropdown is populated from the registry
-- selection applies by theme ID
-- settings rebuild preserves the active theme ID
-
-Done when:
-
-- custom or built-in registry themes are selectable from settings without
- hardcoded branching
-
-Status:
-
-- complete in `03d208f`
-
-### Task 4. Load Custom Theme Files From Disk
-
-Deliverables:
-
-- custom theme directory resolution
-- file loading on startup
-- invalid file handling with non-fatal logging
-- tests for loading valid and invalid theme files
-
-Done when:
-
-- dropping a valid theme JSON file into the theme directory makes it selectable
-
-Status:
-
-- complete in `f632676`
-
-### Task 5. Expand Terminal Theme Fidelity Where Needed
-
-Deliverables:
-
-- review the serializable theme schema against runtime terminal needs
-- add missing fields only if required for correct runtime parity
-- verify terminal fg/bg/cursor/selection/ANSI palette switch correctly
-
-Done when:
-
-- terminal behavior remains visually consistent after switching among themes
-
-Status:
-
-- complete in `4df2b36`
-
----
-
-## Risks And Review Focus
-
-### Risk 1. Theme Drift Between Models
-
-If `theme_config::Theme` cannot fully represent runtime needs, conversion logic
-may silently drop behavior.
-
-Review focus:
-
-- terminal fields
-- status colors
-- typography/spacings that are currently mutated at runtime
-
-### Risk 2. Settings Page Regressions
-
-The current settings page rebuild flow reconstructs display state from runtime
-theme values. That can easily wipe out the selected theme ID.
-
-Review focus:
-
-- open settings after switching themes
-- close and reopen settings
-- restart app and reopen settings
-
-### Risk 3. Startup Ordering
-
-If theme loading happens after UI creation or after terminal views are
-constructed, the app may flash the wrong theme or only partially update.
-
-Review focus:
-
-- initial workspace creation
-- settings background load path
-- terminal creation after theme application
-- background theme loading handoff back to UI state application
-
-### Risk 4. Overwriting User Overrides
-
-Applying a new base theme must not discard user font size, terminal font
-preferences, or grid gap choices.
-
-Review focus:
-
-- theme switch after changing font sizes
-- theme switch after changing terminal line height
-- theme switch after changing terminal font family
-
----
-
-## Verification Strategy
-
-This task series follows `docs/task-verification-workflow.md`.
-
-Per task, after implementation:
-
-```bash
-cargo clean
-cargo build --all-features
-cargo test --all --all-targets --all-features
-cargo test -p codirigent-ui --lib --features gpui-full
-cargo clippy --all --all-targets --all-features -- -D warnings
-cargo fmt --all --check
-bash scripts/audit-unwraps.sh
-```
-
-Required review pass after verification:
-
-- inspect the diff for dead theme paths and duplicate logic
-- review fallback behavior for invalid theme IDs and broken JSON files
-- review startup ordering and settings rebuild behavior
-- review terminal palette behavior, not just UI chrome colors
-- confirm file IO and theme discovery do not happen on the UI thread
-- confirm new constants are named and justified
-- confirm touched files remain at maintainable size
-
-Final verification executed on the completed branch:
-
-```bash
-cargo build --all-features
-cargo test --all --all-targets --all-features
-cargo test -p codirigent-ui --lib --features gpui-full
-cargo clippy --all --all-targets --all-features -- -D warnings
-cargo fmt --all --check
-bash scripts/audit-unwraps.sh
-```
-
-Result:
-
-- build passed
-- full test suite passed
-- `gpui-full` UI tests passed
-- clippy passed with `-D warnings`
-- formatting check passed
-- unwrap audit reported only the existing repository-wide baseline
-
----
-
-## Suggested File Touch Order
-
-To keep the series reviewable, prefer this order:
-
-1. `crates/codirigent-ui/src/theme.rs`
-2. `crates/codirigent-ui/src/theme_config.rs`
-3. `crates/codirigent-ui/src/theme_manager.rs`
-4. `crates/codirigent-ui/src/workspace/impl_settings.rs`
-5. `crates/codirigent-ui/src/workspace/settings_panels.rs`
-6. any startup/bootstrap files that need registry initialization
-7. tests
-8. follow-up docs updates if behavior changes materially
-
-This order keeps model changes ahead of UI wiring.
-
----
-
-## Open Questions Before Implementation
-
-1. Where should the registry live at runtime?
- - central app state
- - workspace state
- - settings state
-
-2. Should built-in themes remain defined in `theme.rs`, or should they be
- generated from `theme_config.rs` and then converted into runtime themes?
-
-3. Do we want the first pass to add richer terminal fields to
- `theme_config.rs`, or keep schema changes minimal and only fill the missing
- runtime wiring?
-
-4. Should custom theme discovery be automatic on every startup, or only when
- the settings panel opens?
-
-Recommended answers for the first pass:
-
-- keep the registry in app/workspace state
-- preserve `theme.rs` as the runtime authority initially
-- add only the schema fields required for parity
-- load custom themes on startup so the selected theme is valid before settings
- open
-
----
-
-## Completion Standard
-
-This plan is complete only when all of the following are true:
-
-- theme selection is registry-based
-- `appearance.theme` stores and preserves a real theme ID
-- startup and settings load paths apply the saved theme
-- custom themes can be loaded from disk
-- terminal colors switch consistently with the active theme
-- each task is verified and reviewed per `docs/task-verification-workflow.md`
-
-This completion standard is now satisfied for `feat/ghostty-theme-registry`.
diff --git a/docs/local-dmg-build.md b/docs/release/local-dmg-build.md
similarity index 100%
rename from docs/local-dmg-build.md
rename to docs/release/local-dmg-build.md
diff --git a/docs/release/windows-release-signing.md b/docs/release/windows-release-signing.md
new file mode 100644
index 00000000..bbd915e4
--- /dev/null
+++ b/docs/release/windows-release-signing.md
@@ -0,0 +1,189 @@
+# Windows Release Signing Workflow
+
+Required procedure for signing the Windows MSI installer after CI builds a
+release.
+
+---
+
+## Goal
+
+The Certum Open Source Code Signing certificate private key is non-exportable
+(SimplySign protected). CI cannot sign the MSI. After CI completes, the MSI
+must be signed locally on the machine where SimplySign Desktop is installed,
+then uploaded back to the GitHub Release with updated checksums.
+
+---
+
+## Prerequisites
+
+- SimplySign Desktop installed and logged in (system tray icon visible)
+- Windows SDK installed (`signtool.exe` available)
+- WiX Toolset available at `tools/wix/` in the repo
+- `gh` CLI authenticated with push access to the repo
+- Certum certificate visible in `certmgr.msc` under Personal > Certificates
+
+---
+
+## Automated Flow (CI Release)
+
+When a tag is pushed, CI handles everything except Windows signing:
+
+### 1. Push the Tag
+
+```bash
+git tag -a v0.X.Y -m "Release v0.X.Y"
+git push origin v0.X.Y
+```
+
+### 2. Wait for CI
+
+The GitHub Actions `Release` workflow builds:
+
+- Windows x64: `.msi` (unsigned) + `.zip`
+- macOS Apple Silicon: `.dmg` (signed + notarized) + `.tar.gz`
+
+`checksums-sha256.txt` intentionally excludes the MSI.
+
+### 3. Sign and Upload
+
+Run the signing script after CI completes:
+
+```powershell
+.\scripts\sign-and-upload.ps1 -Tag v0.X.Y
+```
+
+The script:
+
+1. downloads the unsigned MSI from the release
+2. finds the Certum certificate by issuer in the Windows cert store
+3. signs the MSI with SHA-256 + Certum timestamp server
+4. verifies the signature
+5. computes SHA-256 hash and appends to `checksums-sha256.txt`
+6. uploads signed MSI and updated checksums to the release
+
+### 4. Verify
+
+After the script completes, confirm:
+
+- [ ] MSI on the release page has a newer timestamp than the original
+- [ ] `checksums-sha256.txt` includes a line for the `.msi` file
+- [ ] Download the signed MSI and check Properties > Digital Signatures
+
+---
+
+## Local Build Flow (Testing)
+
+For testing installer changes before pushing a tag.
+
+### 1. Set the Version
+
+The workspace version in `Cargo.toml` must match the installer version.
+CI does this automatically from the tag. Locally, do it manually:
+
+```bash
+# Check current version
+grep '^version' Cargo.toml | head -1
+
+# If it needs updating (revert after testing):
+# Edit Cargo.toml line 16: version = "0.X.Y"
+```
+
+### 2. Build Binaries
+
+```bash
+export PATH="$PATH:/c/Program Files (x86)/Windows Kits/10/bin/10.0.19041.0/x64"
+cargo build --profile dist --features gpui-full -p codirigent -p codirigent-hook
+```
+
+Binaries output to `target/dist/`.
+
+### 3. Build MSI
+
+```bash
+WIX_BIN="tools/wix"
+"$WIX_BIN/candle.exe" \
+ -dBinaryPath="target/dist" \
+ -dVersion=0.X.Y.0 \
+ -dLicensePath=wix/License.rtf \
+ -arch x64 wix/main.wxs -o wix/main.wixobj
+
+"$WIX_BIN/light.exe" \
+ -ext "$WIX_BIN/WixUIExtension.dll" \
+ wix/main.wixobj -o dist/codirigent.msi
+```
+
+Note: WiX version format requires `x.x.x.x` (four integers, no prerelease
+suffix). Use `0.1.3.0` not `0.1.3-alpha.1`.
+
+### 4. Sign MSI
+
+Ensure SimplySign Desktop is running and logged in, then:
+
+```powershell
+$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" |
+ Sort-Object FullName -Descending | Select-Object -First 1).FullName
+
+$cert = Get-ChildItem Cert:\CurrentUser\My |
+ Where-Object { $_.Issuer -like "*Certum*" } |
+ Select-Object -First 1
+
+& $signtool sign `
+ /sha1 $cert.Thumbprint `
+ /fd sha256 `
+ /tr http://time.certum.pl `
+ /td sha256 `
+ /v dist\codirigent.msi
+```
+
+### 5. Verify Signature
+
+```powershell
+& $signtool verify /pa /v dist\codirigent.msi
+```
+
+Or: right-click `dist\codirigent.msi` > Properties > Digital Signatures.
+
+### 6. Test Install
+
+Double-click `dist\codirigent.msi` and verify:
+
+- [ ] Installer shows correct version
+- [ ] `codirigent.exe` runs and reports the correct version
+- [ ] `codirigent-hook.exe` is installed alongside
+- [ ] PATH is updated (new terminal session required)
+
+### 7. Revert Version (if changed)
+
+If you modified `Cargo.toml` for testing, revert it:
+
+```bash
+git checkout Cargo.toml Cargo.lock
+```
+
+---
+
+## Failure Handling
+
+- **SimplySign not running:** signtool returns "No certificates were found".
+ Launch SimplySign Desktop and log in.
+- **Certificate not found:** Run `certmgr.msc` and confirm the Certum cert is
+ under Personal > Certificates with the key icon.
+- **Timestamp server unreachable:** Retry. If `time.certum.pl` is down, try
+ `http://timestamp.digicert.com` as a fallback.
+- **WiX candle/light fails:** Ensure `tools/wix/` exists. If missing, download
+ WiX Toolset v3.14 binaries and extract to `tools/wix/`.
+- **Version mismatch (MSI vs binary):** The `-dVersion` passed to WiX controls
+ what Windows shows in Add/Remove Programs. The binary version comes from
+ `Cargo.toml`. Both must be updated for a consistent release.
+
+---
+
+## Completion Standard
+
+A Windows release is only complete when:
+
+- [ ] CI workflow finished successfully
+- [ ] MSI is signed with the Certum certificate
+- [ ] `checksums-sha256.txt` includes the signed MSI hash
+- [ ] Both files are uploaded to the GitHub Release
+- [ ] The release page shows the correct file sizes (signed MSI is larger)
diff --git a/docs/specs/2026-03-18-terminal-scrollbar-search-design.md b/docs/specs/2026-03-18-terminal-scrollbar-search-design.md
deleted file mode 100644
index b454b24b..00000000
--- a/docs/specs/2026-03-18-terminal-scrollbar-search-design.md
+++ /dev/null
@@ -1,188 +0,0 @@
-# Terminal Scrollbar & Search Design
-
-**Date:** 2026-03-18
-**Status:** Approved
-
-## Overview
-
-Add two features to the terminal pane:
-1. An interactive scrollbar with drag-to-scroll, click-to-jump, and auto-hide
-2. A find-in-terminal overlay (Cmd+F / Ctrl+F) with match highlighting, navigation, and scrollbar match markers
-
-## Prerequisite APIs
-
-### Total Scrollback Lines
-
-The scrollbar and search both need to know the total scrollback size. Alacritty's `Term` provides this via `grid().total_lines() - grid().screen_lines()` (history size) and `topmost_line().0.unsigned_abs()` (max scroll offset). Currently `TerminalSize::total_lines()` in `terminal.rs` returns only the visible row count.
-
-**Changes required:**
-- Add a `history_size: usize` field to `TerminalRenderSnapshot` in `terminal_runtime.rs`, populated from `term.topmost_line().0.unsigned_abs()` during snapshot generation
-- Add `total_scrollback_lines() -> usize` to `TerminalView`, reading from the snapshot
-- The scrollbar uses this value for thumb sizing and position math
-
-### Scroll-to-Absolute-Position
-
-The scrollbar drag and track-click need to set the viewport to an arbitrary position. Only relative scroll APIs exist today (`scroll_up`, `scroll_down`, `scroll_to_bottom`).
-
-**Approach:** Compute a delta from the current `display_offset` to the target offset using `i32` arithmetic to avoid underflow: `Scroll::Delta((target as i32) - (current_display_offset as i32))`. Alacritty clamps the result to `[0, history_size]`. Add a `scroll_to_offset(target: usize)` method on `TerminalRuntimeHandle` that performs this computation internally.
-
-### Search Grid Access
-
-The search engine needs to iterate the `Term` grid cells. The `Term` is owned by `TerminalRuntime` behind `Arc>`.
-
-**Approach:** Add a `search(query: &str) -> Vec` method on `TerminalRuntimeHandle` that acquires the mutex lock and runs the scan, consistent with how `get_selected_text()` already works. The debounce timer resets on each keystroke so only the final query triggers a scan. If large-scrollback performance becomes an issue, this can be moved to a background task later.
-
-## Feature 1: Interactive Scrollbar
-
-### Rendering
-
-Overlay div on the right edge of the terminal pane, rendered as a sibling of the terminal canvas inside `grid_render.rs`. Positioned absolute, sits on top of terminal content.
-
-- **Track:** Full-height div, transparent by default, semi-transparent on hover
-- **Thumb:** Colored div inside the track
- - Height: `max(30px, (visible_rows / total_lines) * track_height)`
- - Position: proportional to `display_offset / total_scrollback_lines`
-- **Width:** 8px default, expands to 12px on hover
-
-### Interaction
-
-- **Drag thumb:** `on_mouse_down` on thumb captures drag start offset. `on_mouse_move` on track converts pixel Y delta to proportional scrollback position. `on_mouse_up` releases.
-- **Click track:** Jump to proportional position — `(click_y / track_height) * total_scrollback_lines`
-- **Mouse wheel:** Existing handler unchanged. Thumb position updates reactively from `display_offset`.
-
-### Auto-Hide
-
-- Default opacity: 0 (hidden)
-- Fade in on: mouse enters terminal area, scroll wheel activity, scrollback position changes
-- Fade out after: 1.5s of no scroll activity AND mouse not hovering the scrollbar
-- While mouse hovers the scrollbar: stay visible, expand width
-- Timer: use `cx.spawn()` with `Timer::after(Duration::from_millis(1500))` to schedule fade-out; cancel and restart on any scroll activity or hover. Update opacity via `cx.notify()`.
-- The scrollbar track height accounts for `TERMINAL_CONTENT_PADDING` so the thumb range matches the visible content area.
-
-### State
-
-```rust
-struct ScrollbarState {
- /// Current opacity (0.0 = hidden, 1.0 = fully visible).
- opacity: f32,
- /// Mouse is hovering the scrollbar track or thumb.
- hovered: bool,
- /// Active drag: stores Y offset from thumb top at drag start.
- dragging: Option,
- /// Timestamp of last scroll activity (for auto-hide timer).
- last_scroll_activity: Instant,
-}
-```
-
-## Feature 2: Terminal Search
-
-### Search Overlay
-
-Floating bar at the top-right of the terminal pane, approximately 300px wide. Contains:
-- Text input field (focused on open)
-- Match count label: "3 of 47"
-- Prev/Next buttons (up/down arrow icons, or keyboard Enter/Shift+Enter)
-- Close button (X) or Escape to dismiss
-
-Rendered as an absolute-positioned div inside the terminal pane container.
-
-### Activation
-
-- **Open:** Cmd+F (macOS) / Ctrl+F (Windows/Linux) — registers a `SearchTerminal` GPUI action
-- **Close:** Escape key or click X — clears highlights and returns focus to terminal
-- **Input routing:** While search bar is open, keystrokes go to the search input, not the terminal PTY
-
-### Search Engine
-
-Location: `crates/codirigent-ui/src/terminal_search.rs`
-
-- Scans alacritty `Term` grid from bottom of scrollback to top (most recent content first)
-- Iterates cells row by row, concatenating characters into line strings
-- Handles wrapped lines as a single logical line
-- Case-insensitive matching
-- Returns match positions:
-
-```rust
-struct SearchMatch {
- /// Absolute grid line coordinate (matches alacritty's Line(i32) convention).
- /// Negative = scrollback history, 0 = top of visible screen, positive = below.
- /// This is independent of display_offset — the viewport maps absolute lines
- /// to screen rows. Scrollbar marker positions are computed from these absolute
- /// coordinates relative to the total scrollback range.
- grid_line: i32,
- /// Start column (inclusive).
- start_col: usize,
- /// End column (exclusive).
- end_col: usize,
-}
-```
-
-- Wrapped lines detected via alacritty's `WRAPLINE` cell flag — consecutive flagged rows are concatenated into a single logical line for matching, with column offsets adjusted accordingly
-- Debounce: 150ms, timer resets on each keystroke so only the final query triggers a scan; search runs synchronously under the `TerminalRuntimeHandle` mutex (consistent with `get_selected_text()`)
-- **Output during search:** When new terminal output arrives while search is active, matches are kept as-is (stale) until the user modifies the query. Match indices may shift due to new output; if the user navigates to a match whose text no longer matches the query at that position, skip to the next valid match. This avoids re-scanning on every output event.
-
-### Match Highlighting
-
-- All matches: colored background rects rendered during terminal paint phase (same layer as selection rects in `terminal_render.rs`)
-- Active/current match: brighter highlight color to distinguish from other matches
-- Only matches within the current viewport need rects computed — filter by visible row range during render
-
-### Navigation
-
-- Enter or Down arrow: jump to next match
-- Shift+Enter or Up arrow: jump to previous match
-- Jumping scrolls the viewport to center the match on screen
-- Match index wraps around (last match → first match)
-
-### Scrollbar Match Markers
-
-- While search is active, render small horizontal ticks on the scrollbar track
-- Each tick: 2px tall, full scrollbar width, positioned at proportional Y for the match's grid line
-- Uses the search highlight color
-- Only visible while search overlay is open
-- Marker Y position formula: `marker_y_fraction = (history_size + grid_line) / (history_size + screen_lines)` — this maps absolute grid line coordinates to the same proportional space used by the scrollbar thumb
-
-### Search State
-
-```rust
-struct SearchState {
- /// Whether the search overlay is open.
- active: bool,
- /// Current search query.
- query: String,
- /// All matches found in the terminal grid.
- matches: Vec,
- /// Index of the currently focused match (for navigation).
- current_match: Option,
-}
-```
-
-## File Organization
-
-### New Files
-
-| File | Purpose |
-|------|---------|
-| `crates/codirigent-ui/src/workspace/scrollbar_render.rs` | Scrollbar rendering helper called from within the session cell render path in `grid_render.rs`; not a standalone workspace component |
-| `crates/codirigent-ui/src/workspace/search_render.rs` | Search overlay: text input, match count, prev/next buttons |
-| `crates/codirigent-ui/src/terminal_search.rs` | Search engine: grid scanning, match collection, result types |
-
-### Modified Files
-
-| File | Change |
-|------|--------|
-| `terminal_view.rs` | Add `ScrollbarState`, `SearchState` fields; expose `total_scrollback_lines()` |
-| `grid_render.rs` | Compose scrollbar and search overlay into terminal pane div; wire Cmd+F |
-| `terminal_render.rs` | Render search match highlight rects during paint phase |
-| `app.rs` | Define `SearchTerminal` action struct and register keybinding (Cmd+F / Ctrl+F), alongside existing actions like `Copy`, `Paste` |
-| `workspace/mod.rs` | Declare new modules |
-
-### Modified (minimal)
-
-- `terminal_runtime.rs` — add `history_size` to snapshot, add `scroll_to_offset()` and `search()` methods on handle
-
-### Unchanged
-
-- `terminal.rs` — alacritty wrapper stays untouched
-- Session management, persistence, layout systems
-- Existing mouse scroll and text selection behavior (selection continues to work normally beneath the search overlay; search matches are purely visual and do not interact with the selection system)
diff --git a/docs/workflows/release-workflow.md b/docs/workflows/release-workflow.md
new file mode 100644
index 00000000..7b597e2f
--- /dev/null
+++ b/docs/workflows/release-workflow.md
@@ -0,0 +1,196 @@
+# Release Workflow
+
+Step-by-step procedure for creating a new Codirigent release with
+code-signed installers.
+
+---
+
+## Overview
+
+Releases use a two-phase process:
+
+1. **CI phase** — Push a git tag. GitHub Actions builds binaries, creates
+ unsigned installers (MSI + DMG), and publishes a **draft** release.
+2. **Local phase** — Sign the Windows MSI locally with the Certum
+ certificate via SimplySign, update checksums, upload, then publish.
+
+macOS DMG signing and notarization happen automatically in CI (Apple
+Developer certificate is stored in GitHub Secrets).
+
+---
+
+## Prerequisites
+
+- GitHub CLI (`gh`) authenticated
+- SimplySign Desktop running and authenticated (provides the Certum
+ code-signing certificate to the Windows certificate store)
+- Windows SDK installed (provides `signtool.exe`)
+- All changes committed and pushed to `main`
+
+---
+
+## Step 1: Create and Push the Tag
+
+```bash
+git tag -a v0.1.X -m "v0.1.X"
+git push origin v0.1.X
+```
+
+This triggers the `Release` workflow (`.github/workflows/release.yml`),
+which:
+
+- Builds release binaries for Windows x64 and macOS ARM64
+- Packages Windows `.zip` and `.msi` (unsigned)
+- Packages macOS `.tar.gz` and `.dmg` (signed + notarized in CI)
+- Generates `checksums-sha256.txt` (excludes `.msi` — it will be replaced)
+- Creates a **draft** GitHub Release with all artifacts attached
+
+### Monitor the workflow
+
+```bash
+gh run list --limit 3
+gh run watch
+```
+
+Wait for the workflow to complete successfully before proceeding.
+
+---
+
+## Step 2: Sign the Windows MSI
+
+Run the signing script from the repo root:
+
+```bash
+powershell -File scripts/sign-and-upload.ps1 -Tag v0.1.X
+```
+
+The script performs these steps automatically:
+
+1. Downloads the unsigned MSI from the draft release
+2. Locates `signtool.exe` from the Windows SDK
+3. Finds the Certum code-signing certificate (OID `1.3.6.1.5.5.7.3.3`)
+ in `Cert:\CurrentUser\My` (provided by SimplySign)
+4. Signs the MSI with SHA-256 and timestamps via `http://time.certum.pl`
+5. Verifies the signature
+6. Computes the SHA-256 checksum of the signed MSI
+7. Downloads `checksums-sha256.txt`, replaces the MSI line, re-uploads
+8. Uploads the signed MSI (overwrites the unsigned one)
+
+### Troubleshooting
+
+| Error | Cause | Fix |
+|-------|-------|-----|
+| "Certum code signing certificate not found" | SimplySign not running or not authenticated | Launch SimplySign Desktop and sign in |
+| "signtool.exe not found" | Windows SDK not installed | Install Windows 10/11 SDK |
+| "Failed to send Windows toast notification" | SimplySign session expired | Re-authenticate in SimplySign |
+
+---
+
+## Step 3: Verify the Draft Release
+
+Check the draft release before publishing:
+
+```bash
+gh release view v0.1.X
+```
+
+Confirm:
+
+- [ ] All expected assets are present (`.msi`, `.dmg`, `.zip`, `.tar.gz`,
+ `.wixpdb`, `checksums-sha256.txt`)
+- [ ] The MSI checksum in `checksums-sha256.txt` matches the signed file
+- [ ] The release is still in **draft** status
+
+---
+
+## Step 4: Edit Release Notes
+
+Update the draft release body with the changelog. Follow the format from
+previous releases:
+
+```
+## What's New
+
+### Feature Name
+- Description of changes
+
+---
+
+## Bug Fixes
+- Fix description (closes #N)
+
+---
+
+**Full Changelog**: https://github.com/oso95/Codirigent/compare/vPREV...vCURR
+```
+
+You can edit via the GitHub web UI or:
+
+```bash
+gh release edit v0.1.X --notes "$(cat release-notes.md)"
+```
+
+---
+
+## Step 5: Publish
+
+Once everything is verified, publish the draft:
+
+```bash
+gh release edit v0.1.X --draft=false
+```
+
+Or use the "Publish release" button in the GitHub web UI.
+
+---
+
+## Pre-release Tags
+
+Tags containing `alpha`, `beta`, or `rc` are automatically marked as
+pre-releases by the CI workflow. Use semantic naming:
+
+```
+v0.1.X-alpha.1 # early testing
+v0.1.X-beta.1 # feature complete, testing
+v0.1.X-rc.1 # release candidate
+v0.1.X # stable release
+```
+
+---
+
+## Recovery
+
+### If the workflow fails
+
+```bash
+# Delete the failed release (if created)
+gh release delete v0.1.X --yes
+
+# Delete the tag
+git tag -d v0.1.X
+git push origin :refs/tags/v0.1.X
+
+# Fix the issue, then re-tag and push
+git tag -a v0.1.X -m "v0.1.X"
+git push origin v0.1.X
+```
+
+### If you need to re-sign the MSI
+
+Just re-run the signing script — it uses `--clobber` to overwrite:
+
+```bash
+powershell -File scripts/sign-and-upload.ps1 -Tag v0.1.X
+```
+
+---
+
+## File Reference
+
+| File | Purpose |
+|------|---------|
+| `.github/workflows/release.yml` | CI workflow (build, package, draft release) |
+| `scripts/sign-and-upload.ps1` | Local MSI signing and upload |
+| `scripts/gen-license-rtf.ps1` | Generate `License.rtf` for WiX (used by CI) |
+| `wix/main.wxs` | WiX installer definition |
+| `tools/wix/` | Local WiX toolset (candle, light) |
diff --git a/docs/task-verification-workflow.md b/docs/workflows/task-verification-workflow.md
similarity index 100%
rename from docs/task-verification-workflow.md
rename to docs/workflows/task-verification-workflow.md
diff --git a/scripts/sign-and-upload.ps1 b/scripts/sign-and-upload.ps1
new file mode 100644
index 00000000..ac10c555
--- /dev/null
+++ b/scripts/sign-and-upload.ps1
@@ -0,0 +1,77 @@
+# Usage: .\scripts\sign-and-upload.ps1 -Tag v0.1.3-alpha3
+param(
+ [Parameter(Mandatory)]
+ [string]$Tag
+)
+
+$ErrorActionPreference = "Stop"
+
+$archiveName = "codirigent-$Tag-x86_64-pc-windows-msvc"
+$msiName = "$archiveName.msi"
+$tmpDir = Join-Path $env:TEMP "codirigent-sign-$Tag"
+
+New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
+
+# ── 1. Download unsigned MSI from the release ─────────────────────────────────
+Write-Host "Downloading $msiName from release $Tag..."
+gh release download $Tag --pattern $msiName --output "$tmpDir\$msiName" --clobber
+
+# ── 2. Find signtool ──────────────────────────────────────────────────────────
+$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" |
+ Sort-Object FullName -Descending | Select-Object -First 1).FullName
+
+if (-not $signtool) {
+ Write-Error "signtool.exe not found. Install Windows SDK."
+}
+
+# ── 3. Find the Certum certificate ───────────────────────────────────────────
+$cert = Get-ChildItem Cert:\CurrentUser\My |
+ Where-Object { $_.Issuer -like "*Certum*" -and $_.EnhancedKeyUsageList.FriendlyName -contains "Code Signing" } |
+ Select-Object -First 1
+
+if (-not $cert) {
+ Write-Error "Certum code signing certificate not found in CurrentUser\My store."
+}
+
+Write-Host "Using certificate: $($cert.Subject)"
+
+# ── 4. Sign the MSI ───────────────────────────────────────────────────────────
+Write-Host "Signing $msiName..."
+& $signtool sign `
+ /sha1 $cert.Thumbprint `
+ /fd sha256 `
+ /tr http://time.certum.pl `
+ /td sha256 `
+ /v "$tmpDir\$msiName"
+
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+# ── 5. Verify signature ───────────────────────────────────────────────────────
+Write-Host "Verifying signature..."
+& $signtool verify /pa /v "$tmpDir\$msiName"
+if ($LASTEXITCODE -ne 0) { Write-Warning "Signature verification failed!" }
+
+# ── 6. Compute new checksum ───────────────────────────────────────────────────
+Write-Host "Computing checksum..."
+$hash = (Get-FileHash "$tmpDir\$msiName" -Algorithm SHA256).Hash.ToLower()
+$checksumLine = "$hash $msiName"
+Write-Host "Checksum: $checksumLine"
+
+# ── 7. Download existing checksums-sha256.txt and append MSI checksum ─────────
+$checksumFile = "$tmpDir\checksums-sha256.txt"
+gh release download $Tag --pattern "checksums-sha256.txt" --output $checksumFile --clobber
+
+# Remove any stale MSI line (in case of re-run) then append new one
+$lines = Get-Content $checksumFile | Where-Object { $_ -notmatch '\.msi' }
+$lines + $checksumLine | Set-Content $checksumFile
+
+Write-Host "`nUpdated checksums-sha256.txt:"
+Get-Content $checksumFile
+
+# ── 8. Upload signed MSI and updated checksums ────────────────────────────────
+Write-Host "`nUploading signed MSI and updated checksums to release $Tag..."
+gh release upload $Tag "$tmpDir\$msiName" --clobber
+gh release upload $Tag $checksumFile --clobber
+
+Write-Host "`nDone! Signed MSI and checksums uploaded to release $Tag."
+Write-Host "Temp files at: $tmpDir"
|