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 Logo +

+ +

Codirigent

+ +

+ 一个可并行运行多个 AI Coding CLI 的终端工作空间 +

+ +

+ 项目状态 + 版本 + 许可证 + Rust 版本 +

+ +

+ 官网 · + 下载 · + 报告问题 · + 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。每个会话都会显示实时状态指示器: + +| 状态 | | 含义 | +|--------|---|---------| +| 空闲 | ![gray](https://img.shields.io/badge/●-gray) | Shell 空闲,没有 agent 活动 | +| 工作中 | ![amber](https://img.shields.io/badge/●-f59e0b) | Agent 正在生成回复 | +| 需要关注 | ![rose](https://img.shields.io/badge/●-f43f5e) | Agent 正在等待用户输入或授权 | +| 就绪 | ![green](https://img.shields.io/badge/●-22c55e) | 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"