diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 798c71c3..67d548b6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,10 +5,18 @@ updates: schedule: interval: monthly open-pull-requests-limit: 1 + ignore: + # 0.30.1+ diffs halfwidth kana + voiced mark as two columns, so the server's + # backend replay blanks the voiced mark. Lift once the render path handles it. + - dependency-name: ratatui + versions: [">0.30.0"] groups: cargo-dependencies: patterns: - "*" + update-types: + - minor + - patch - package-ecosystem: github-actions directory: / @@ -19,3 +27,6 @@ updates: github-actions: patterns: - "*" + update-types: + - minor + - patch diff --git a/CHANGELOG.md b/CHANGELOG.md index 410c6d46..2d0e6939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ Superherdr's release history. Superherdr is a fork of [Herdr](https://github.com/herdrdev/herdr); changes inherited from upstream are described in Herdr's releases and ported into the inherited sections at the end of this file. +## [0.9.5.1] - Unreleased + +Based on Herdr 0.9.5. Upstream's 0.9.4 and 0.9.5 release bodies are ported into the inherited sections at the end of this file. + +### Added +- `herdr plugin update` updates plugins installed from GitHub, all at once or by name. Running plugin panes keep working on their old files, and locally linked plugins are left alone. (#4245) + +### Changed +- Focus, Snooze, and the Top-level Agents view continue to work across the upstream input and sidebar overhaul, including the worktree-group rendering fix that keeps repository parents out of worktree children, merged with Superherdr's Focus and Snooze visibility rules. +- On Linux and macOS, closing the shell that started the server no longer stops the server. Scripts that stopped Herdr with `SIGHUP` should use `herdr server stop` or `SIGTERM`. (#4920, #4965) +- Closing a workspace's last pane now asks for confirmation. Disable it with `ui.confirm_close = false`. (#5143) +- With mouse capture on, plugins that handle a link get first pick when you click it. Turn capture off to use your terminal's own link shortcuts, like Command-click on macOS. (#4874, #5064) +- The Codex integration no longer installs turn hooks, and Codex status comes from screen detection again. Run `herdr integration install codex` to update. (#5246, #5278) + +### Fixed +- Prefix shortcuts work again when the terminal sends the next key as plain text. This affected Tern, Chinese, Japanese and Korean input methods on macOS, and Ghostty `text:` keybinds that send the prefix and a key together. Workspace navigation had the same problem. (#5255) +- Claude Code questions show as blocked in named sessions. (#4573, #5192) +- Restarts and shutdowns preserve agent sessions through pre-shutdown saves, and session saves are written to disk before replacing the old file, so an interrupted save cannot corrupt them. (#4414, #5150) +- Ctrl+Z suspend followed by `fg` keeps the agent name and the saved conversation. (#1647) +- Ctrl shortcuts work on non-Latin keyboard layouts such as Russian, Greek, and Hebrew. (#1079, #4961) +- Session restore stays responsive with slow or missing Git data, worktree groups survive unavailable checkouts, and nested duplicate workspaces no longer appear. (#4926, #4736, #4945) +- Out-of-range workspace numbers return `workspace_not_found` instead of crashing. (#5096) +- The server survives process and thread exhaustion instead of killing all panes. (#3324) +- Forwarded SSH agents are no longer polled every second, which broke SSH and Git auth over time. (#4931, #4962) +- Sidebar width, collapsed groups, and agent sorting survive reconnects. (#4364, #5019, #4536) +- Typing appears faster, notably in PowerShell, with lower CPU for busy agents and idle shells and less memory for hidden panes. (#5149, #4506, #5083) +- macOS `pane run` and `agent start` reject over-long commands during shell startup instead of silently truncating them. (#4950, #4988) + ## [0.9.3.1] - Unreleased Based on Herdr 0.9.3. Upstream's 0.9.2 and 0.9.3 release bodies are ported into the inherited sections at the end of this file. @@ -44,6 +72,70 @@ First Superherdr release, based on Herdr 0.9.0. Superherdr versions are the Herd - New worktrees default to `~/.herdr/worktrees`, as in Herdr. - Connecting to a remote host finds and installs `herdr` in the same places Herdr does. Superherdr does not download release binaries for remote hosts; set `HERDR_REMOTE_BINARY` or install herdr there. +### Inherited from Herdr 0.9.5 + + +This is a hotfix release for v0.9.4. See the v0.9.4 notes for the full feature release: https://github.com/herdrdev/herdr/releases/tag/v0.9.4 + +Fixed: +- Prefix shortcuts work again when the terminal sends the next key as plain text. This affected Tern, Chinese, Japanese and Korean input methods on macOS, and Ghostty `text:` keybinds that send the prefix and a key together. Workspace navigation had the same problem. (#5255) +- Codex sessions no longer start with a "clamping Interrupt hook timeout" warning. Herdr no longer installs Codex turn hooks, and Codex status comes from screen detection again. Run `herdr integration install codex` to update. (#5246, #5278) +- Claude Code questions show as blocked in named sessions. (#4573, #5192) +- On Windows, plugin panes start even when a folder on `PATH` has the same name as the plugin's program. (#5267, #5269, thanks @JJLiebig) +- On Windows, Claude Code session tracking works when the user folder name contains non-ASCII characters, such as Korean. (#5253, #5270, thanks @JJLiebig) +- Adding a Windows machine no longer prints raw PowerShell progress output. (#4639, #5282, thanks @JJLiebig) + +### Inherited from Herdr 0.9.4 + + +Added: +- `herdr plugin update` updates plugins installed from GitHub, all at once or by name. Running plugin panes keep working on their old files, and new runs pick up the update without a config reload. Locally linked plugins are left alone. (#4245, thanks @JJLiebig) +- Windows notifications stay in Notification Center. Clicking one jumps to its pane and brings the terminal window forward. (#4773, #4803, thanks @JJLiebig) + +Changed: +- An elevated Windows server now only accepts administrator clients by default. To let normal clients from the same account in, set `[server] allow_unelevated_clients = true` or start the server with `--allow-unelevated-clients`. Permission errors now say how to fix them, and sessions you can't reach show as unavailable instead of stopped. (#4717, #4769, #4777, #4779, thanks @JJLiebig) +- On Linux and macOS, closing the shell that started Herdr no longer stops the server. Scripts that stopped Herdr with `SIGHUP` should use `herdr server stop` or `SIGTERM`. The server log now says why the server stopped. (#4920, #4965) +- With mouse capture on, plugins that handle a link get first pick when you click it. Turn capture off to use your terminal's own link shortcuts, like Command-click on macOS. (#4874, #5064, thanks @JJLiebig) +- Antigravity and Grok count as idle once they are back at their prompt, even if their own background tasks or subagents are still running. (#3530, #4735, #4959) +- Ctrl+Shift+letter reaches plain shells as the Ctrl key, like in Alacritty and WezTerm. Super, Hyper and Meta shortcuts no longer type escape-code junk into apps that can't read them. (#4356, #5017) + +Fixed: +- Restarting a Mac or shutting down Windows keeps your agents' sessions. Herdr saves before the system starts closing apps, so agents come back where they were. (#4414, #5013, #5057, #5092, thanks @JJLiebig) +- Session saves are written to disk before they replace the old file, so a crash or power cut can't leave an unreadable save. (#5150, #5152, thanks @JJLiebig) +- The server keeps running when the machine runs out of processes or threads, instead of taking every pane down with it. (#3324, #4958) +- An out-of-range workspace number returns `workspace_not_found` instead of crashing the server. (#5096, #5171) +- Suspending an agent with Ctrl+Z and bringing it back with `fg` keeps its name and saved conversation. (#1647, #4964) +- Restoring a session stays responsive when a checkout's Git data is slow or missing. Worktree groups survive checkouts that are temporarily unavailable, and opening the same repository twice no longer nests one workspace inside the other. (#4926, #4736, #4945, #4770, #4772, thanks @minatoaquaMK2 and @JJLiebig) +- Closing a workspace's only pane asks first, like closing its last tab already does. Turn this off with `ui.confirm_close = false`. (#5143, #5173) +- Ctrl shortcuts like Ctrl+W work on non-Latin keyboard layouts such as Russian, Greek or Hebrew. Latin layouts and AltGr characters keep their normal meaning. (#1079, #4961) +- Accent keys and AltGr shortcuts work on Windows. (#4688, #5021, thanks @JJLiebig) +- Shift+Enter and other enhanced keys keep working after the outer terminal resets, for example after VS Code's Reload Window. (#4985, #5088) +- Quick Escape taps work on macOS with WezTerm's Kitty keyboard mode. (#1266, #5089) +- Kitty keyboard apps get key releases and F1, F2 and F4 correctly, and Backspace works when the terminal sends Ctrl+H for it. (#4184, #4403, #3244, #5017) +- Keyboard and terminal color replies no longer leak stray characters into pane input, and a half-finished color reply no longer freezes typing. (#4856, #4025, #5052, #5017, #5087) +- Fish no longer repeats prompt fragments while you resize a pane. The same problem with zsh and Powerlevel10k is still open, waiting on a Ghostty fix. (#3672, #4977) +- Apps that ask for pixel mouse coordinates get them, and clicks land in the right place on terminals that only report cells. (#4750, #3675, #5017, #4971) +- Turning off Kitty graphics makes panes stop advertising image support, so apps fall back to text. (#4978, #4979) +- Typing shows up faster, especially in PowerShell. (#4884, #5149, thanks @JJLiebig) +- Big sessions use less CPU on busy agents and idle shells, hidden panes use less memory, and Windows panes need fewer threads. (#4506, #5083, #4845, #4847, #4849, #4850, #4873, #5085, #5091, thanks @JJLiebig) +- New workspaces, tabs and splits start their shell at the right size instead of borrowing another pane's size. (#4419, #4873) +- Forwarded SSH agents are no longer contacted every second, which could eventually break SSH and Git authentication. (#4931, #4962) +- Sidebar width, collapsed groups and agent sorting on remote machines survive reconnects. Switching workspaces quickly while changing machines no longer gets canceled. (#4364, #5019, #4536, #5061, thanks @minatoaquaMK2 and @JJLiebig) +- The sidebar collapse button stays clickable when the agent list has a scrollbar. (#4692, #4734, thanks @minatoaquaMK2) +- The welcome screen shows your configured prefix. (#4745, #4747, thanks @JJLiebig) +- Ctrl+[ leaves workspace navigation. (#1431, #2340, thanks @qaz74107410) +- Keybindings that use `+` survive config reloads, and `/` opens Go To search on layouts where it needs Shift. (#4960, #4963, #4983, #5090, thanks @minatoaquaMK2) +- Pasting from WezTerm on Windows into remote panes no longer types encoded key data. Copying no longer adds duplicate clipboard history entries, and closing a pane no longer hangs on a pending Enter. (#4725, #4730, #4709, #4872, thanks @JJLiebig) +- Windows shells started through Scoop or 32-bit wrappers keep their working directory in new tabs and after restarts. (#4472, #4749, #5062, thanks @JJLiebig) +- Windows ARM64 machines can be used as remote hosts through the x64 build, on a best-effort basis. (#4752) +- Codex reports finished turns through its integration when started with `--no-daemon`, its startup update screen shows as blocked, and Codex and Pi status is recognized in more cases. (#4507, #4811, #4756, #5174, thanks @JJLiebig) +- Antigravity permission, question and workspace-trust dialogs show as blocked, and its thinking spinner shows as working. (#3871, #3419, #3530, #4959) +- Hermes started through its Python installer is recognized, and installing or removing its integration keeps your YAML plugin list intact. (#4910, #4909, #4911, #4984, thanks @minatoaquaMK2) +- On macOS, `pane run` and `agent start` refuse commands too long for a shell that is still starting, instead of silently cutting them off. (#4950, #4988, thanks @minatoaquaMK2) + +Removed: +- The experimental `HERDR_WINDOWS_INPUT_BACKEND=crossterm` setting is gone. Windows always uses its native console input. (#5017) + ### Inherited from Herdr 0.9.3 This is a hotfix release for v0.9.2. See the v0.9.2 notes for the full feature release: https://github.com/herdrdev/herdr/releases/tag/v0.9.2 diff --git a/Cargo.lock b/Cargo.lock index 28d96412..75079aa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,9 +169,9 @@ checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -202,18 +202,18 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstyle", "clap_lex", @@ -221,9 +221,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" dependencies = [ "clap", ] @@ -378,9 +378,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_more" @@ -624,9 +621,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -634,9 +631,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" @@ -651,9 +648,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -670,32 +667,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -833,9 +830,9 @@ dependencies = [ [[package]] name = "interprocess" -version = "2.4.2" +version = "2.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069323743400cb7ab06a8fe5c1ed911d36b6919ec531661d034c89083629595b" +checksum = "6cb02ffbf25e8ef0f4a95bff054eef75f6d1bf16919474cf2caa63b21997478a" dependencies = [ "doctest-file", "libc", @@ -909,9 +906,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "line-clipping" @@ -1084,9 +1081,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1517,9 +1514,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1529,9 +1526,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1540,9 +1537,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc_version" @@ -1580,9 +1577,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -1593,14 +1590,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1617,9 +1614,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1627,33 +1624,33 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1668,9 +1665,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1836,7 +1833,7 @@ dependencies = [ [[package]] name = "superherdr" -version = "0.9.3" +version = "0.9.5" dependencies = [ "base64", "bincode", @@ -1868,6 +1865,7 @@ dependencies = [ "unicode-width", "uuid", "widestring", + "windows", "windows-sys", "wmi", "zbus", @@ -2033,12 +2031,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -2050,15 +2047,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2255,9 +2252,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" diff --git a/Cargo.toml b/Cargo.toml index 637fc706..04d56702 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "superherdr" # Herdr base version; the Superherdr revision lives in src/build_info.rs. -version = "0.9.3" +version = "0.9.5" edition = "2021" build = "build.rs" description = "Superherdr: a terminal project built on Herdr with Focus and Snooze" @@ -49,7 +49,7 @@ serde_ignored = "0.1.14" serde_json = "1" sha2 = "0.10" time = { version = "0.3.47", features = ["formatting"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "signal"] } toml = "0.8" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } @@ -70,10 +70,13 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] } # UTF-16 SDDL input for interprocess security descriptors. widestring = "1.2" wmi = { version = "0.18.4", default-features = false } +# Native Notification Center delivery for unpackaged Windows installs. +windows = { version = "0.62.2", features = ["Data_Xml_Dom", "UI_Notifications", "Win32_System_Com"] } windows-sys = { version = "0.61.2", features = [ "Wdk_System_Threading", "Win32_Foundation", "Win32_Globalization", + "Win32_Graphics_Gdi", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_DataExchange", @@ -83,8 +86,10 @@ windows-sys = { version = "0.61.2", features = [ "Win32_System_JobObjects", "Win32_System_Kernel", "Win32_System_Memory", + "Win32_System_LibraryLoader", "Win32_System_Ole", "Win32_System_Pipes", + "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Input_Ime", diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index 7ee1e751..4bf36754 100644 --- a/crates/ghostty-vt/src/lib.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -883,6 +883,8 @@ impl Terminal { }; let userdata = (&mut *terminal.callback_state as *mut TerminalCallbackState).cast(); let glyph_protocol = false; + // libghostty enables image storage by default; Herdr opts in explicitly. + let kitty_storage_limit: u64 = 0; let terminfo_name = ffi::GhosttyString { ptr: TERM.as_ptr().cast(), len: TERM.len(), @@ -894,6 +896,12 @@ impl Terminal { let clipboard_callback: ffi::GhosttyTerminalClipboardWriteFn = Some(clipboard_write_trampoline); unsafe { + ffi::ghostty_terminal_set( + terminal.raw, + ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, + (&kitty_storage_limit as *const u64).cast(), + ) + .into_result()?; ffi::ghostty_terminal_set( terminal.raw, ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_TERMINFO_NAME, @@ -955,6 +963,10 @@ impl Terminal { ) .into_result()?; } + // Disabling storage advances its generation even though it is empty. + terminal + .kitty_empty_generation + .set(Some(terminal.kitty_graphics_generation()?)); Ok(terminal) } @@ -1280,6 +1292,29 @@ impl Terminal { self.screen_text_rows_range(0, usize::MAX) } + /// Visit a screen row without allocating an owned grapheme vector for each cell. + /// The borrowed graphemes are valid only for the duration of each callback. + /// Like `screen_text_rows_range`, rows outside the active screen/history are empty. + pub fn for_each_screen_row_cell( + &self, + row: u32, + mut visit: impl FnMut(CellWide, &[u32]), + ) -> Result<(), Error> { + if row as usize >= self.total_rows()? { + return Ok(()); + } + let cols = self.cols()?; + let mut grid_ref = self.grid_ref(ghostty_screen_point(0, row))?; + let mut graphemes = Vec::new(); + for x in 0..cols { + grid_ref.x = x; + let wide = grid_ref_wide(&grid_ref)?; + grid_ref_graphemes_into(&grid_ref, &mut graphemes)?; + visit(wide, &graphemes); + } + Ok(()) + } + pub fn screen_text_rows_range( &self, start_row: usize, @@ -2304,6 +2339,8 @@ fn grid_ref_graphemes(grid_ref: &ffi::GhosttyGridRef) -> Result, Error> if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE { result.into_result()?; } + // Owned cells keep only this grapheme; unlike the row scratch buffer, they + // do not benefit from spare capacity for subsequent cells. let mut buffer = vec![0u32; required]; if required == 0 { return Ok(buffer); @@ -2316,6 +2353,28 @@ fn grid_ref_graphemes(grid_ref: &ffi::GhosttyGridRef) -> Result, Error> Ok(buffer) } +fn grid_ref_graphemes_into( + grid_ref: &ffi::GhosttyGridRef, + buffer: &mut Vec, +) -> Result<(), Error> { + let mut required = 0usize; + let result = + unsafe { ffi::ghostty_grid_ref_graphemes(grid_ref, ptr::null_mut(), 0, &mut required) }; + if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE { + result.into_result()?; + } + buffer.resize(required, 0); + if required == 0 { + return Ok(()); + } + unsafe { + ffi::ghostty_grid_ref_graphemes(grid_ref, buffer.as_mut_ptr(), buffer.len(), &mut required) + .into_result()?; + } + buffer.truncate(required); + Ok(()) +} + fn grid_ref_wide(grid_ref: &ffi::GhosttyGridRef) -> Result { let mut raw = ffi::GhosttyCell::default(); unsafe { @@ -2974,13 +3033,19 @@ impl Drop for RenderState { pub struct KeyEvent { raw: ffi::GhosttyKeyEvent, + // libghostty borrows the UTF-8 pointer instead of copying it, so the event + // owns the text for as long as the handle can be encoded. + utf8: Box, } impl KeyEvent { pub fn new() -> Result { let mut raw = ptr::null_mut(); unsafe { ffi::ghostty_key_event_new(ptr::null(), &mut raw).into_result()? }; - Ok(Self { raw }) + Ok(Self { + raw, + utf8: Box::from(""), + }) } pub fn set_action(&mut self, action: ffi::GhosttyKeyAction) { @@ -2996,14 +3061,27 @@ impl KeyEvent { } pub fn set_utf8(&mut self, text: &str) { + self.utf8 = Box::from(text); unsafe { - ffi::ghostty_key_event_set_utf8(self.raw, text.as_ptr().cast::(), text.len()) + ffi::ghostty_key_event_set_utf8( + self.raw, + self.utf8.as_ptr().cast::(), + self.utf8.len(), + ) } } pub fn set_unshifted_codepoint(&mut self, codepoint: u32) { unsafe { ffi::ghostty_key_event_set_unshifted_codepoint(self.raw, codepoint) } } + + pub fn set_consumed_mods(&mut self, mods: u16) { + unsafe { ffi::ghostty_key_event_set_consumed_mods(self.raw, mods) } + } + + pub fn set_composing(&mut self, composing: bool) { + unsafe { ffi::ghostty_key_event_set_composing(self.raw, composing) } + } } impl Drop for KeyEvent { @@ -3027,6 +3105,23 @@ impl KeyEncoder { unsafe { ffi::ghostty_key_encoder_setopt_from_terminal(self.raw, terminal.raw()) } } + /// Whether macOS Option acts as Alt (ESC prefix) rather than a text + /// modifier. `set_from_terminal` resets this to false. + pub fn set_macos_option_as_alt(&mut self, enabled: bool) { + let value = if enabled { + ffi::GhosttyOptionAsAlt_GHOSTTY_OPTION_AS_ALT_TRUE + } else { + ffi::GhosttyOptionAsAlt_GHOSTTY_OPTION_AS_ALT_FALSE + }; + unsafe { + ffi::ghostty_key_encoder_setopt( + self.raw, + ffi::GhosttyKeyEncoderOption_GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT, + (&value as *const ffi::GhosttyOptionAsAlt).cast(), + ) + } + } + pub fn encode(&mut self, event: &KeyEvent) -> Result, Error> { encode_with_retry(|buf, len, out_len| unsafe { ffi::ghostty_key_encoder_encode(self.raw, event.raw, buf, len, out_len) @@ -3673,6 +3768,9 @@ impl<'a> RowCellIter<'a> { } } +#[cfg(test)] +mod test_allocations; + #[cfg(test)] mod tests { use super::*; @@ -3842,6 +3940,7 @@ mod tests { #[test] fn kitty_image_fingerprint_refreshes_on_retransmission() { let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); terminal.write(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=10,r=5,q=2;/wAA/w==\x1b\\"); let first = terminal .kitty_image_placements_with_data_filter(|_| true) @@ -3892,6 +3991,7 @@ mod tests { #[test] fn kitty_image_fingerprint_survives_hidden_placements_until_the_image_is_deleted() { let mut terminal = Terminal::new(10, 5, 0).unwrap(); + terminal.enable_kitty_graphics().unwrap(); let cached = |terminal: &Terminal| terminal.kitty_fingerprints.lock().unwrap().contains_key(&7); terminal.write(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=10,r=5,q=2;/wAA/w==\x1b\\"); @@ -3919,16 +4019,17 @@ mod tests { #[test] fn kitty_storage_generation_skips_only_proven_empty_storage() { let mut terminal = Terminal::new(10, 5, 1_000_000).unwrap(); + assert!(!terminal.kitty_graphics_may_have_placements().unwrap()); terminal.enable_kitty_graphics().unwrap(); terminal.resize(10, 5, 8, 16).unwrap(); - assert_eq!(terminal.kitty_graphics_generation().unwrap(), 0); + let initial_generation = terminal.kitty_graphics_generation().unwrap(); assert!(!terminal.kitty_graphics_may_have_placements().unwrap()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); terminal.write(b"\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"); let transmitted = terminal.kitty_graphics_generation().unwrap(); - assert_ne!(transmitted, 0); + assert_ne!(transmitted, initial_generation); assert!(terminal.kitty_graphics_may_have_placements().unwrap()); assert!(terminal.kitty_image_placements().unwrap().is_empty()); assert_eq!(terminal.kitty_empty_generation.get(), Some(transmitted)); @@ -4006,6 +4107,52 @@ mod tests { assert_eq!(placements[0].render.grid_rows, 5); } + #[test] + fn kitty_graphics_queries_require_explicit_enablement() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + let responses = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let sink = responses.clone(); + terminal + .set_write_pty_callback(move |bytes| sink.lock().unwrap().extend_from_slice(bytes)) + .unwrap(); + // The reporter's direct, compressed RGBA capability probe followed by DA1. + let query = b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=32,o=z;eAFjYGBgAAAABAAB\x1b\\\x1b[c"; + + for enabled in [false, true] { + if enabled { + terminal.enable_kitty_graphics().unwrap(); + } + for reset in [false, true] { + if reset { + terminal.write(b"\x1bc"); + } + responses.lock().unwrap().clear(); + terminal.write(query); + let expected: &[u8] = if enabled { + b"\x1b_Gi=31;OK\x1b\\\x1b[?62;22c" + } else { + b"\x1b[?62;22c" + }; + assert_eq!( + responses.lock().unwrap().as_slice(), + expected, + "enabled={enabled}, reset={reset}" + ); + } + } + } + + #[test] + fn disabled_kitty_graphics_discards_uploads_across_screen_changes() { + let mut terminal = Terminal::new(10, 5, 0).unwrap(); + for sequence in [b"".as_slice(), b"\x1b[?1049h", b"\x1b[?1049l"] { + terminal.write(sequence); + terminal.resize(12, 6, 8, 16).unwrap(); + terminal.write(b"\x1b_Ga=T,f=32,t=d,i=7,p=3,s=1,v=1,c=1,r=1;/wAA/w==\x1b\\"); + assert!(terminal.kitty_image_placements().unwrap().is_empty()); + } + } + #[test] fn kitty_graphics_local_media_are_enabled() { let mut terminal = Terminal::new(10, 5, 0).unwrap(); @@ -4647,6 +4794,76 @@ mod tests { assert_eq!(rows[2].cells[2].graphemes, vec!['e' as u32, 0x301]); } + #[test] + fn owned_cell_graphemes_keep_tight_capacity() { + let mut terminal = Terminal::new(8, 1, 100).unwrap(); + terminal.write("xe\u{301}".as_bytes()); + + let rows = terminal.screen_text_rows().unwrap(); + assert_eq!(rows[0].cells[0].graphemes, vec!['x' as u32]); + assert_eq!(rows[0].cells[1].graphemes, vec!['e' as u32, 0x301]); + for cell in &rows[0].cells { + assert_eq!(cell.graphemes.capacity(), cell.graphemes.len()); + } + } + + #[test] + fn borrowed_screen_row_cells_match_owned_cells() { + let mut terminal = Terminal::new(12, 4, 100).unwrap(); + let content = format!( + "abcdefghi界Z\r\ne{} X\r\n{}\r\n", + "\u{301}".repeat(40), + char::from_u32(KITTY_UNICODE_PLACEHOLDER).unwrap() + ); + for screen in ["", "\x1b[?1049h"] { + terminal.write(screen.as_bytes()); + terminal.write(content.as_bytes()); + let expected = terminal.screen_text_rows().unwrap(); + assert!(expected + .iter() + .flat_map(|row| &row.cells) + .any(|cell| cell.graphemes.len() > 4)); + for (y, row) in expected.iter().enumerate() { + let mut actual = Vec::new(); + terminal + .for_each_screen_row_cell(y as u32, |wide, graphemes| { + actual.push((wide, graphemes.to_vec())); + }) + .unwrap(); + let expected: Vec<_> = row + .cells + .iter() + .map(|cell| (cell.wide, cell.graphemes.clone())) + .collect(); + assert_eq!(actual, expected); + } + terminal + .for_each_screen_row_cell(u32::MAX, |_, _| panic!("out-of-range row")) + .unwrap(); + } + } + + #[test] + fn borrowed_screen_row_allocations_do_not_scale_with_cell_count() { + for cols in [80, 330] { + let mut terminal = Terminal::new(cols, 1, 100).unwrap(); + terminal.write("x".repeat(usize::from(cols)).as_bytes()); + let mut cells = 0; + let (result, allocations) = crate::test_allocations::count(|| { + terminal.for_each_screen_row_cell(0, |_, graphemes| { + std::hint::black_box(graphemes); + cells += 1; + }) + }); + result.unwrap(); + assert_eq!(cells, cols); + assert!( + allocations <= 1, + "one reusable grapheme buffer per ASCII row, got {allocations} allocations for {cols} cells" + ); + } + } + #[test] fn render_state_row_dirty_can_be_cleared_independently() { let mut terminal = Terminal::new(8, 3, 100).unwrap(); diff --git a/crates/ghostty-vt/src/test_allocations.rs b/crates/ghostty-vt/src/test_allocations.rs new file mode 100644 index 00000000..eb354992 --- /dev/null +++ b/crates/ghostty-vt/src/test_allocations.rs @@ -0,0 +1,59 @@ +//! Count Rust allocation requests on only the calling test thread. +//! Native terminal allocations and concurrent tests are deliberately excluded. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; + +thread_local! { + static ALLOCATIONS: Cell> = const { Cell::new(None) }; +} + +struct CountingAllocator; + +fn record_allocation() { + // Thread-local storage may already be gone during thread teardown. + let _ = ALLOCATIONS.try_with(|count| { + if let Some(value) = count.get() { + count.set(Some(value + 1)); + } + }); +} + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record_allocation(); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + record_allocation(); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { + record_allocation(); + unsafe { System.realloc(ptr, layout, size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator; + +pub fn count(operation: impl FnOnce() -> T) -> (T, usize) { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + ALLOCATIONS.set(None); + } + } + + assert!(ALLOCATIONS.replace(Some(0)).is_none()); + let _reset = Reset; + let result = operation(); + let count = ALLOCATIONS.get().expect("allocation counting is active"); + (result, count) +} diff --git a/distribution/agent-detection/antigravity.toml b/distribution/agent-detection/antigravity.toml index 8b9bcfcf..5daa8dec 100644 --- a/distribution/agent-detection/antigravity.toml +++ b/distribution/agent-detection/antigravity.toml @@ -1,13 +1,60 @@ id = "agy" -version = "2026.06.24.1" +version = "2026.10.05.1" min_engine_version = 1 -updated_at = "2026-06-24T00:00:00Z" +updated_at = "2026-10-05T00:00:00Z" aliases = ["antigravity", "antigravity-cli"] +# Evidence: Antigravity CLI 1.2.17 live pane reads, plus reporter captures +# from 1.1.28 through 1.2.16. +# +# Dialogs replace the prompt box and end with a key-hint row directly above +# the "esc to cancel" footer: +# permission: "↑/↓ Navigate · tab Amend · ctrl+g edit/expand command" +# "↑/↓ Navigate · tab Amend · f full diff" +# question: "↑/↓ Navigate · enter Select · esc Skip" +# trust: "↑/↓ Navigate · enter Confirm" +# slash menu: "↑/↓ Navigate · enter Select · tab Complete" (idle) +# Some captures hold only the two footer rows, so dialog rules must not +# require the question text. +# +# Working turns draw a braille spinner followed by either an -ing word or a +# thought summary above the prompt box, and the footer reads "esc to cancel" +# directly under the prompt box's bottom border. The idle footer reads +# "? for shortcuts". +# +# Background tasks and subagents show "· N task(s)" in the footer and a task +# panel under the prompt box. Once the agent is back at its prompt they do +# not count as working. + [[rules]] id = "permission_prompt" state = "blocked" priority = 300 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["navigate", "tab amend"] + +[[rules]] +id = "question_prompt" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["navigate", "esc skip"] + +[[rules]] +id = "trust_prompt" +state = "blocked" +priority = 280 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["do you trust the contents of this project?", "yes, i trust this folder", "enter confirm"] + +# Pre-1.1.28 permission dialog. +[[rules]] +id = "legacy_permission_prompt" +state = "blocked" +priority = 270 region = "whole_recent" visible_blocker = true contains = ["requesting permission for:"] @@ -17,17 +64,17 @@ any = [ ] [[rules]] -id = "spinner_working" +id = "esc_cancel_footer_working" state = "working" -priority = 100 -region = "whole_recent" +priority = 110 +region = "bottom_non_empty_lines(2)" visible_working = true -line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b'] +regex = ['(?m)^\s*─{3,}\s*esc to cancel\b'] [[rules]] -id = "background_tasks_working" +id = "spinner_working" state = "working" -priority = 90 -region = "bottom_non_empty_lines(5)" +priority = 100 +region = "bottom_non_empty_lines(12)" visible_working = true -line_regex = ['(?i)·\s*[1-9][0-9]*\s+task'] +line_regex = ['^\s*[\u2801-\u28FF]+\s+\S'] diff --git a/distribution/agent-detection/claude.toml b/distribution/agent-detection/claude.toml index 0c7bdcf2..7cb3393e 100644 --- a/distribution/agent-detection/claude.toml +++ b/distribution/agent-detection/claude.toml @@ -1,7 +1,7 @@ id = "claude" -version = "2026.09.11.1" +version = "2026.10.11.1" min_engine_version = 2 -updated_at = "2026-09-11T00:00:00Z" +updated_at = "2026-10-11T00:00:00Z" aliases = ["claude-code"] [[rules]] @@ -94,6 +94,18 @@ any = [ ] }, ] +[[rules]] +id = "live_blocked_form_named_session" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true + +# Bind every control to the same footer, allowing wrapping between words. +# After the name divider, accept only a counted task panel with marked rows +# and optional pending overflow. Free-form activity can be newer output (#4573). +regex = ['(?i)(?:^|\n)[ \t]*enter\s+to\s+(?:confirm|select\s+·\s+(?:(?:tab/arrow|arrow)\s+keys|arrows|↑/↓|↑↓)\s+to\s+navigate)\s+·\s+esc\s+to\s+cancel[ \t]*\n(?:[ \t]*\n)*[ \t]*─{3,}[^\n]*(?:\n(?:[ \t]*\n)*[ \t]+\d+ tasks? \(\d+ done, (?:\d+ in progress, )?\d+ open\)(?:\n[ \t]+[◻◼✔][ \t]+[^\n]+)+(?:\n[ \t]+…[ \t]+\+\d+[ \t]+pending)?)?(?:\n[ \t]*)*\z'] + [[rules]] id = "dynamic_workflow_prompt" state = "blocked" diff --git a/distribution/agent-detection/codex.toml b/distribution/agent-detection/codex.toml index dd275758..d35820c9 100644 --- a/distribution/agent-detection/codex.toml +++ b/distribution/agent-detection/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.23.1" +version = "2026.10.11.1" min_engine_version = 3 -updated_at = "2026-09-23T22:12:00Z" +updated_at = "2026-10-11T00:00:00Z" [[rules]] id = "osc_title_blocked" @@ -38,8 +38,20 @@ priority = 950 region = "top_non_empty_lines(20)" visible_blocker = true all = [ - { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, - { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, + { any = [ + { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, + { contains = ["Folder access"] }, + ] }, + { any = [ + { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, + { all = [ + { contains = ["Trust this folder?", "Codex can read, edit, and run files here"] }, + { any = [ + { contains = ["Trust and continue"] }, + { contains = ["enter continue"] }, + ] }, + ] }, + ] }, ] [[rules]] @@ -48,8 +60,19 @@ state = "blocked" priority = 950 region = "bottom_non_empty_lines(20)" visible_blocker = true -contains = ["Update available!", "Update now"] -regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z'] +# Keep the legacy dialog; the shared picker (0.159+) has a different heading +# and footer. Choices can scroll out of a short viewport, but both gates must +# remain visible. The passive update history notice has neither picker footer. +any = [ + { all = [ + { contains = ["Update available!", "Update now"] }, + { regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z'] }, + ] }, + { all = [ + { regex = ['(?m)^\s*Update\s+available\s+·'] }, + { regex = ['enter\s+continue\s+·\s+esc\s+skip\s*\z'] }, + ] }, +] [[rules]] id = "live_strong_blocker" @@ -92,3 +115,15 @@ any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }] regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] # A failed reconnect keeps its final elapsed timer but is no longer working. not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['\S'] +not = [ + { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, + { contains = ["Action Required"] }, +] diff --git a/distribution/agent-detection/grok.toml b/distribution/agent-detection/grok.toml index 4abcefd6..bc37bc4b 100644 --- a/distribution/agent-detection/grok.toml +++ b/distribution/agent-detection/grok.toml @@ -1,7 +1,7 @@ id = "grok" -version = "2026.09.18.1" -min_engine_version = 2 -updated_at = "2026-09-18T00:00:00Z" +version = "2026.10.05.1" +min_engine_version = 3 +updated_at = "2026-10-05T00:00:00Z" aliases = ["grok-build"] # Evidence: Grok Build 0.2.101 source and 1.0.34 live pane reads. @@ -74,15 +74,8 @@ any = [ { contains = ["←/→:scope"] }, ] -# Grok 1.0.34 moves background counts above the composer and clears OSC -# progress between turns even while these commands are still running. -[[rules]] -id = "background_status_working" -state = "working" -priority = 1165 -region = "bottom_non_empty_lines(12)" -visible_working = true -line_regex = ['^\s*[○◎◉]\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?)(?:\s+·\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?))*\s+still running(?:\s+·\s+send a message to interrupt)?\s*$'] +# Background commands, monitors, and subagents left running after Grok returns +# to its prompt do not count as working, matching Claude and agy (#1217, #4735). [[rules]] id = "osc_progress_working" diff --git a/distribution/agent-detection/pi.toml b/distribution/agent-detection/pi.toml index 77b2d632..5ae51a85 100644 --- a/distribution/agent-detection/pi.toml +++ b/distribution/agent-detection/pi.toml @@ -1,7 +1,7 @@ id = "pi" -version = "2026.09.14.1" +version = "2026.10.01.1" min_engine_version = 1 -updated_at = "2026-09-14T00:00:00Z" +updated_at = "2026-10-01T00:00:00Z" aliases = ["herdr:pi"] [[rules]] @@ -10,7 +10,10 @@ state = "working" priority = 100 region = "whole_recent" visible_working = true -contains = ["Working..."] +any = [ + { contains = ["Working..."] }, + { line_regex = ['^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working$'] }, +] [[rules]] id = "working_border" @@ -18,4 +21,7 @@ state = "working" priority = 100 region = "bottom_non_empty_lines(12)" visible_working = true -line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$'] +any = [ + { line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$'] }, + { line_regex = ['^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working$'] }, +] diff --git a/distribution/latest.json b/distribution/latest.json index 456b8145..745abace 100644 --- a/distribution/latest.json +++ b/distribution/latest.json @@ -1,23 +1,61 @@ { - "version": "0.9.1", + "version": "0.9.3", "protocol": 22, "endpoint_generation": 1, - "notes": "### Added\n- Control agents, panes, workspaces, and worktrees on saved SSH machines with `herdr --machine `. Commands use the saved machine's session without needing an open Herdr window. Update Herdr on both machines to use CLI forwarding; failed remote commands never fall back to Local. (#3918)\n- Connect to Windows SSH hosts from Linux, macOS, or Windows. Interactive setup can install or update the complete Windows package after confirmation; background reconnects never install updates. (#3651, #3701, #3661, #3687, thanks @JJLiebig)\n- Edit names, filters, and search text at the cursor instead of only at the end. Herdr inputs now support character and word movement, Home/End, deletion, and familiar Ctrl+A/E/K/U/W/Y shortcuts, including Unicode text. (#1803, #3698, thanks @markjaquith)\n- Added Letta Code detection and native conversation restore, including default conversations. Its experimental integration is installed through `herdr integration install letta`, not the Settings integration list or JSON integration API. (#3106, #3107, thanks @just-cameron)\n- Hold Ctrl over a link to highlight it before opening it. Wrapped URLs and OSC 8 links remain clickable even when their beginning or end is outside the viewport. (#1282)\n- Sidebar token rules can now hide matching tokens and their separators with `hide = true`; rows disappear when no tokens remain. (#3925)\n\n### Changed\n- Workspace keyboard navigation now spans connected machines in sidebar order, with a visible highlight before Enter switches machines. Collapsed machine and worktree groups remain navigable without accidentally sending actions to the wrong machine. (#3754)\n- Experimental live handoff can now transfer more than 64 panes when the sending server includes this update. An older running server still has its old limit for the first upgrade; this does not change update installation order or make live handoff non-experimental. (#3393, #3411, thanks @kataokatsuki)\n- On Linux and macOS, terminal observers that stop accepting output are disconnected after 30 seconds without write progress. Their pane and other clients keep running; an interrupted stream may end without a final close record. (#3612)\n- Windows terminal cleanup now explicitly resets mouse-reporting modes. The remaining standalone Git Bash detach report in #3748 is still under investigation; do not treat this release as a confirmed fix for that report. (#4055, thanks @JJLiebig)\n\n### Fixed\n- Remote typing, switching, and popup interaction no longer resend the entire pane screen for small changes. Busy SSH sessions use less bandwidth, and idle attached clients avoid unnecessary redraw work. (#3769, #3745, #3822)\n- A stalled SSH machine no longer traps the client away from Local. Clicking a local workspace or agent cancels the unfinished remote switch, and a recovered remote workspace refreshes without an away-and-back selection. Stale screens from before a reconnect are not reused. (#3903, #3842)\n- Idle SSH connections use less CPU without losing final output. Repeated connection failures back off instead of reconnecting rapidly, and supported idle bridges clean up without stopping remote panes. (#3728, #4083)\n- Large pastes no longer disconnect the client with an output-queue error. (#3833)\n- SSH setup now shows authentication instructions and useful errors, including Tailscale login URLs and host-key failures, rather than appearing to hang or only reporting a lost connection. `machine add` also accepts options before the SSH target. (#3606, #3731, #3883)\n- Machine and worktree groups collapse correctly without changing the selected workspace. The sidebar respects workspace row gaps, stays stable during resizing, and reveals newly focused workspaces. (#3778, #3738, #3817)\n- The agent list keeps its scroll position when switching machines. Current-workspace and current-tab filters distinguish identical IDs on different machines. (#3937, #4101, #3732, thanks @minatoaquaMK2)\n- Background machine activation no longer resizes another client's focused pane. Window titles follow each client's own view rather than another client's selection. (#3744, #4091, #4130, thanks @JJLiebig)\n- `agent focus` and `pane move --focus` now move attached clients to the requested pane. Moving without focus no longer leaves a client following a deleted tab or later unrelated focus changes. (#3760, #4153, #4171, thanks @minatoaquaMK2)\n- Creating a worktree focuses its new workspace again. Partially failed worktree removals can be retried, and worktrees with submodules offer an explicit forced-removal choice rather than failing without a recovery action. (#3766, #3314, #1797)\n- Temporary worktree-action error banners expire instead of remaining on screen indefinitely. (#1827, #4063, thanks @JJLiebig)\n- The last tab remains fully visible when the tab strip overflows, and the session navigator once again draws connected tree branches. (#4151, #3843)\n- Muted sidebar and tab labels remain readable on dark themes, including Windows Terminal. (#2692, #4062, thanks @JJLiebig)\n- Mouse drag and copy-mode selections stay active while output continues, even when part of the selection is outside the viewport. Copying uses the current text in the selected range. Selection repainting also avoids building up a backlog of mouse movements. (#3841, #3895)\n- Double-click selections remain visible with automatic copy disabled. Holding the second click and dragging extends by whole words, and token selection stops at CJK punctuation. Host selection colors remain visible on transparent backgrounds. (#3847, #1836, #3706, #3798)\n- Scrolling no longer combines mismatched terminal snapshots or leaves stale wrapped rows in fullscreen applications such as Neovim. Alternate-screen history reads preserve content across changing headers and scrollbars. (#3900, #3329, #3979)\n- Local Kitty graphics keep their direct transport after the first image. Popups and notifications hide only overlapping image placements; uncovered images remain visible. (#3785, #4081)\n- Pixel mouse coordinates work independently of image rendering and use the terminal's integer cell size, preventing clicks from drifting across the screen. (#3295, #4140, thanks @JJLiebig)\n- Function keys F1\u2013F12 are recognized in Kitty keyboard reports, and F1\u2013F4 also work with parameterized reports from terminals such as foot. (#1809, #2378, #4067, thanks @ashokDevs and @JJLiebig)\n- WezTerm control-key reports now preserve Enter, Backspace, Tab, and Escape. Cmd/Super-modified input reaches pane applications with its modifier intact. Modified Enter stays compatible with panes that have not requested enhanced key encoding. (#3589, #3710, #4103, #4110, thanks @minatoaquaMK2)\n- Mouse reports split across delayed reads no longer leak their trailing bytes into pane input. Clients reassert mouse reporting after terminal reconnects. (#3911, #4079, thanks @JJLiebig)\n- Saved sessions that cannot be loaded are preserved before replacement, rather than silently overwritten. Recovery copies are kept in `session-backups/`; failed preservation leaves the original untouched. (#4125)\n- Deleting a session now requires its exact name, including case, on case-insensitive filesystems. Names beginning with a hyphen can be passed after `--`. (#3819, #4233, #3220, thanks @minatoaquaMK2)\n- Newly started macOS servers retain access to user lookup and DNS across logout. Existing affected servers need a full session restart; live handoff cannot repair their inherited service context. (#4100)\n- Linux process discovery stays responsive around stuck WSL agents and avoids repeatedly scanning an ever-growing process tree. (#2179, #3621, #3674, thanks @dark2momo and @caner-akca)\n- Linux desktop notifications identify Herdr as their source. (#3638)\n- `pane split` without a target now splits the calling pane when invoked inside Herdr, rather than another client's focused pane. Explicit targets keep their existing behavior. (#4123)\n- Live agent names survive temporary process-detection uncertainty instead of leaving an active agent unreachable by name. (#3225, #3574, thanks @caner-akca)\n- Windows Codex prompts reliably submit after pasted text instead of losing Enter, including long prompts. (#3187, #3961, thanks @JJLiebig)\n- OpenCode V2 reports lifecycle state from the pane displaying the conversation, so shared servers do not mix up panes. Completed and interrupted turns return to idle, while pending permissions, forms, and failed executions remain blocked. V1 remains supported; restart OpenCode after installing the updated integration. (#3757, thanks @markjaquith)\n- Codex stays working with static titles, animations disabled, and queued follow-ups. Composer sparkles and old confirmation text no longer make an idle pane appear blocked. (#4092, #4099, #3988, thanks @minatoaquaMK2)\n- Claude Code's Unicode activity spinner and Pi's working border are recognized correctly. Cline launcher detection and idle-state reporting are more reliable. (#3949, #3629, #2396)\n- Pi records native session paths on Windows. Nested OMP processes no longer replace the parent pane's resumable session, and Windows Kimi npm shims are recognized. (#3726, #2593, #3994, #3317, #4053, thanks @JJLiebig)\n- Grok updates its restore reference after `/new`, and its imported Claude-compatible hooks no longer overwrite that reference. Reinstall the Claude integration to adopt the narrower hook matching. (#2681, #4018)\n- Integration configuration updates preserve existing files when writes fail. Hermes installation also preserves valid YAML when enabled plugins use an inline list. (#3970, #3839, #4056, thanks @JJLiebig)\n- Plugin registry writes preserve symlinks. Plugins keep using the running server's compatible binary after a client-only update, and manual pane navigation emits focus events again. (#3922, #3824, #4077)\n- Config files containing a UTF-8 BOM load and save correctly. The default config places `ui.accent` in the right table. (#3678, #3962, #2697, thanks @JJLiebig)\n- Windows local and SSH input preserves Alt combinations, Ctrl+S, and enhanced keyboard text instead of dropping keys or inserting garbage. Remote dead-key input no longer adds an extra base character. (#3702, #3910, #3932, #3948, #4133, #4148, thanks @JJLiebig)\n- Windows mouse capture preserves SGR coordinates, including past column 95, and reattaching restores mouse reporting. Legacy SSH mouse reports no longer produce phantom characters or block subsequent input. (#4155, #3735, #4080, thanks @JJLiebig)\n- Git Bash SSH connections honor host aliases from the user's SSH config. (#3947, #4054, thanks @JJLiebig)\n- Windows login-mode panes honor the configured shell and keep PowerShell working-directory reports current. Pane launch paths use consistent directory casing. (#1445, #4060, #4065, #4199, thanks @JJLiebig)", + "notes": "This is a hotfix release for v0.9.2. See the v0.9.2 notes for the full feature release: https://github.com/herdrdev/herdr/releases/tag/v0.9.2\n\n### Fixed\n- Terminal shortcuts that send Escape followed by a key work again in panes. On macOS, Option+Left/Right and Option+Backspace from Ghostty's defaults or iTerm2's Natural Text Editing preset move and delete by word again, instead of typing `b` and `f` or deleting one character. Escape-based Shift+Enter bindings insert a newline in Claude Code instead of submitting. Clicking a pane still doesn't send a stray Escape. (#4751)\n- Alt+[ followed quickly by another key no longer merges into a different key. (#4751)", "assets": { - "linux-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-linux-x86_64", - "linux-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-linux-aarch64", - "macos-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-macos-x86_64", - "macos-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-macos-aarch64", - "windows-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.1/herdr-windows-x86_64.zip" + "linux-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-x86_64", + "linux-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-aarch64", + "macos-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-macos-x86_64", + "macos-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-macos-aarch64", + "windows-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-windows-x86_64.zip" }, "sha256": { - "linux-x86_64": "2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7", - "linux-aarch64": "f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e", - "macos-x86_64": "053be0639935fe54ab5efbdb46651054e4f6a753a5b43153c88bd6912bce1e94", - "macos-aarch64": "5fc7a7e7adfaca56fa80aa89dcb025693357268dab8285b9ce2d08a2313c89de", - "windows-x86_64": "04ce380cac5af27bfcf75d0951ac49b7afe4c984aee8852985806d4f71f93a6e" + "linux-x86_64": "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7", + "linux-aarch64": "4de7aa3e25678812e92960de64f7c2aaa1bca1f0f80a3c5e559837e231e1f5c0", + "macos-x86_64": "db62d548ff3e832b087a96b1894a08d26be3905f1830309cd556783f215d4054", + "macos-aarch64": "5173a3e0ae42d5d1ab7ebfa5d5e6329f7c3d23f8e1a3677c7ce3231da2884157", + "windows-x86_64": "c75b1fa49f7a3ba4b8b11789912a6147e4214a3b6fd3556d0f80076c8887d795" }, "releases": { + "0.9.3": { + "notes": "This is a hotfix release for v0.9.2. See the v0.9.2 notes for the full feature release: https://github.com/herdrdev/herdr/releases/tag/v0.9.2\n\n### Fixed\n- Terminal shortcuts that send Escape followed by a key work again in panes. On macOS, Option+Left/Right and Option+Backspace from Ghostty's defaults or iTerm2's Natural Text Editing preset move and delete by word again, instead of typing `b` and `f` or deleting one character. Escape-based Shift+Enter bindings insert a newline in Claude Code instead of submitting. Clicking a pane still doesn't send a stray Escape. (#4751)\n- Alt+[ followed quickly by another key no longer merges into a different key. (#4751)", + "protocol": 22, + "endpoint_generation": 1, + "assets": { + "linux-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-x86_64", + "linux-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-linux-aarch64", + "macos-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-macos-x86_64", + "macos-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-macos-aarch64", + "windows-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.3/herdr-windows-x86_64.zip" + }, + "sha256": { + "linux-x86_64": "18a8dc65f1c2fa485884344356dea1cfd911c6f06cf46fa78e193f4087f4dba7", + "linux-aarch64": "4de7aa3e25678812e92960de64f7c2aaa1bca1f0f80a3c5e559837e231e1f5c0", + "macos-x86_64": "db62d548ff3e832b087a96b1894a08d26be3905f1830309cd556783f215d4054", + "macos-aarch64": "5173a3e0ae42d5d1ab7ebfa5d5e6329f7c3d23f8e1a3677c7ce3231da2884157", + "windows-x86_64": "c75b1fa49f7a3ba4b8b11789912a6147e4214a3b6fd3556d0f80076c8887d795" + } + }, + "0.9.2": { + "notes": "### Breaking Changes\n- The Herdr-specific pane graphics API is gone. `pane.graphics.info`, `set`, `clear`, and `stream` now return `unknown_method`. Apps show images by writing standard Kitty graphics to their terminal, which Herdr renders natively. (#4561)\n\n### Added\n- Agents can report their own resume command. Herdr then reopens their exact session after a server restart, with no built-in integration needed. Self-reported agents are also cleared once their pane is back at an idle shell. The new [Add Herdr support to your agent](https://herdr.dev/docs/add-herdr-support/) guide covers state, resume, and release for agent authors. (#4687)\n- Use more than one prefix key: `prefix = [\"ctrl+space\", \"ctrl+s\"]`. Every entry enters the same prefix mode, and `prefix+?` lists them all. (#4653, thanks @JJLiebig)\n- Go To shows every agent and terminal as its own row, grouped by workspace, with its status and path. Left and Right jump between workspaces. (#4384)\n- Bind `keys.clear_pane` to clear the focused pane's screen and scrollback while keeping the current prompt line. It is unbound by default and leaves full-screen apps such as Vim alone. (#4383)\n- `herdr machine status` checks saved machines without prompting. `herdr machine reconnect` finishes SSH authentication, including MFA, in your terminal. Open clients recheck failed machines every 30 seconds, so no restart is needed after fixing a connection. (#3763)\n- Interactive `machine add` finds the Herdr sessions already running on the host and lets you pick one. `--label` is optional; the machine is named after its SSH host. (#4204, #4678, thanks @JJLiebig and @dhh)\n- `terminal session control` accepts `terminal.mouse` events, so bridge clients can click buttons in apps that enable mouse reporting. (#4685)\n- Restored agents start one at a time, 100 ms apart by default, instead of all at once. Change the spacing with `[session] startup_per_agent_delay_ms`. (#4102, thanks @JJLiebig)\n\n### Changed\n- Images render faster and more reliably. Local Ghostty receives image data through temporary files instead of terminal output. Popups and notifications crop images around themselves instead of hiding them. Images that scroll out of view stay loaded, so scrolling back no longer resends them. (#4561, #4652, #4686)\n- SSH connections request compression. On slow links, remote panes catch up after scrolling in a fraction of the time. (#4340)\n- Scrolling output on saved machines sends only the rows that changed, cutting bandwidth for build logs and streaming agents. Older clients and servers keep working with the previous format. (#4711)\n- New panes set `TERM_PROGRAM=herdr` and `TERM_PROGRAM_VERSION`. They no longer inherit terminal session IDs, such as iTerm2's, or Claude Code session markers from the terminal that started the server. (#4104)\n- Windows panes default to PowerShell 7 (`pwsh`) when it is installed, falling back to Windows PowerShell. An explicit `default_shell` still wins. (#4297, thanks @JJLiebig)\n- Closing the last tab of a workspace now asks for confirmation. (#4379, #4409, thanks @minatoaquaMK2)\n- Event subscriptions deliver bursts in full. A reader that falls too far behind now gets an `events_lost` error instead of silently skipping events. (#4178, #4225, thanks @minatoaquaMK2)\n- On Unix, sending `SIGWINCH` to a Herdr client re-reads the host terminal's colors, so theme switchers that change colors without a light/dark notification can refresh panes. (#4349, thanks @dhh)\n\n### Fixed\n- Saved layouts survive host shutdown and failed restores. On Linux with logind, Herdr saves before the system shuts down. Every platform keeps up to 48 layout snapshots in `session-snapshots/` for manual recovery. (#4320)\n- Clicking a pane no longer sends a stray Escape that interrupts a working agent. (#3480)\n- An agent's first task now counts as done, even when it started with a prompt. Startup, restored sessions, and Pi's `/new` no longer fire false done notifications. Hook-reported status survives live handoff. (#3338, #3990, #3916)\n- The server uses far less CPU with many populated panes. The navigator, pane splits, and named targets stay fast in large sessions, and bursts of external events no longer stall rendering. (#4506, #4546, #4426, #4669, #4670, #4671, #4672, thanks @JJLiebig and @minatoaquaMK2)\n- Apps that use synchronized output no longer show torn frames while you type. The cursor no longer flickers during status redraws or jumps around an idle Codex on Windows. (#2968, #4303, thanks @JJLiebig)\n- The API socket keeps accepting connections after a transient error, so CLI commands and live handoff no longer fail while the server keeps running. (#4601)\n- Slow Git operations during worktree lookups no longer freeze typing. (#4492, thanks @JJLiebig)\n- `worktree open` no longer takes over the repository's own workspace, so removing the worktree no longer closes it. (#4293)\n- Forwarded SSH agents keep working in remote panes after a reconnect. Repeated `--machine` commands open far fewer SSH connections. (#1931, #4252)\n- OpenCode V1 panes stay working or blocked while their subagents run. An existing registration in `tui.json` is respected instead of regenerating `tui.jsonc`. (#1362, #4240)\n- Codex is no longer reported idle during active output, its mention popups are recognized, and remapped interrupt keys no longer break detection. (#4507, #4196, thanks @JJLiebig and @unmanbearpig)\n- Kiro approval prompts are reported as blocked. Grok idle panes settle with custom or disabled OSC titles. (#4203, #4372, #4333, thanks @vinayshah1998)\n- Droid's scrollback clears are honored, so its welcome header and old transcript no longer repeat. (#4432, thanks @factory-ain3sh)\n- Copy mode stays active while output continues and a movement key is held. (#3812, #4281, thanks @marcomayer)\n- Ctrl+Shift+letter keeps Shift in panes that have not enabled enhanced keyboard input, so Ctrl+Shift+C no longer arrives as Ctrl+C. (#4581, #4597, thanks @factory-ain3sh)\n- Mouse reports split across reads no longer leak into the shell after a focus switch over SSH. (#4630)\n- Windows: multi-line pastes into Claude Code no longer submit at the first newline, and Shift+Enter is preserved. Mouse capture survives focus changes and resizes, Ctrl+Win no longer opens prefix mode with a `ctrl+space` prefix, and remote clipboard image paste works again. (#4251, #4284, #4470, #4314, thanks @JJLiebig)\n- Windows: npm-installed OMP and agents launched through hardened runtimes are detected. `machine add` against a Windows host no longer fails as \"not ready for saved machines.\" (#4313, #4579, #4309, thanks @JJLiebig)\n- The navigate-mode workspace highlight is visible with `theme = \"terminal\"`. Session navigator search matches words independently. The sidebar reveals agents selected by cycling or shortcuts. (#4300, #4408, #4273, #4535, #4355, thanks @minatoaquaMK2)\n- `session attach` without a terminal explains the problem instead of leaving a new session running. (#4393)\n- Socket error responses keep the original request ID, including subscription setup errors. (#4344)\n- `plugin install` accepts options before the repository. (#4446)\n- Windows panes pick up the host terminal's light or dark colors from the start, so apps no longer default to dark mode inside a light terminal. (#1530, #4369)\n- After `herdr update`, Herdr lists which running servers are still on the old version, with the exact commands to restart each one, and reminds you to update your saved SSH machines.", + "protocol": 22, + "endpoint_generation": 1, + "assets": { + "linux-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.2/herdr-linux-x86_64", + "linux-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.2/herdr-linux-aarch64", + "macos-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.2/herdr-macos-x86_64", + "macos-aarch64": "https://github.com/herdrdev/herdr/releases/download/v0.9.2/herdr-macos-aarch64", + "windows-x86_64": "https://github.com/herdrdev/herdr/releases/download/v0.9.2/herdr-windows-x86_64.zip" + }, + "sha256": { + "linux-x86_64": "74de34746f96236f76d599f8f5d2439f884351eabe25b7495e0edab75414064a", + "linux-aarch64": "50ba6fe8dde5ff35af6654082d7e1a4afcb58c9ff3d22bfcf9d41445c971fcc7", + "macos-x86_64": "665b7f7aee96bfdd34ff46c111599170ceee68d860c9fe9ea17cd5f9a80bdc43", + "macos-aarch64": "e5dae9c7d93010968897de10c6e9ea2aacb14965f7b9e3b3d061dba82d2a1944", + "windows-x86_64": "de7529c55f3083a444f74673399ed16d1abe45ff08a43dbfea99e4d0edf47d42" + } + }, "0.9.1": { "notes": "### Added\n- Control agents, panes, workspaces, and worktrees on saved SSH machines with `herdr --machine `. Commands use the saved machine's session without needing an open Herdr window. Update Herdr on both machines to use CLI forwarding; failed remote commands never fall back to Local. (#3918)\n- Connect to Windows SSH hosts from Linux, macOS, or Windows. Interactive setup can install or update the complete Windows package after confirmation; background reconnects never install updates. (#3651, #3701, #3661, #3687, thanks @JJLiebig)\n- Edit names, filters, and search text at the cursor instead of only at the end. Herdr inputs now support character and word movement, Home/End, deletion, and familiar Ctrl+A/E/K/U/W/Y shortcuts, including Unicode text. (#1803, #3698, thanks @markjaquith)\n- Added Letta Code detection and native conversation restore, including default conversations. Its experimental integration is installed through `herdr integration install letta`, not the Settings integration list or JSON integration API. (#3106, #3107, thanks @just-cameron)\n- Hold Ctrl over a link to highlight it before opening it. Wrapped URLs and OSC 8 links remain clickable even when their beginning or end is outside the viewport. (#1282)\n- Sidebar token rules can now hide matching tokens and their separators with `hide = true`; rows disappear when no tokens remain. (#3925)\n\n### Changed\n- Workspace keyboard navigation now spans connected machines in sidebar order, with a visible highlight before Enter switches machines. Collapsed machine and worktree groups remain navigable without accidentally sending actions to the wrong machine. (#3754)\n- Experimental live handoff can now transfer more than 64 panes when the sending server includes this update. An older running server still has its old limit for the first upgrade; this does not change update installation order or make live handoff non-experimental. (#3393, #3411, thanks @kataokatsuki)\n- On Linux and macOS, terminal observers that stop accepting output are disconnected after 30 seconds without write progress. Their pane and other clients keep running; an interrupted stream may end without a final close record. (#3612)\n- Windows terminal cleanup now explicitly resets mouse-reporting modes. The remaining standalone Git Bash detach report in #3748 is still under investigation; do not treat this release as a confirmed fix for that report. (#4055, thanks @JJLiebig)\n\n### Fixed\n- Remote typing, switching, and popup interaction no longer resend the entire pane screen for small changes. Busy SSH sessions use less bandwidth, and idle attached clients avoid unnecessary redraw work. (#3769, #3745, #3822)\n- A stalled SSH machine no longer traps the client away from Local. Clicking a local workspace or agent cancels the unfinished remote switch, and a recovered remote workspace refreshes without an away-and-back selection. Stale screens from before a reconnect are not reused. (#3903, #3842)\n- Idle SSH connections use less CPU without losing final output. Repeated connection failures back off instead of reconnecting rapidly, and supported idle bridges clean up without stopping remote panes. (#3728, #4083)\n- Large pastes no longer disconnect the client with an output-queue error. (#3833)\n- SSH setup now shows authentication instructions and useful errors, including Tailscale login URLs and host-key failures, rather than appearing to hang or only reporting a lost connection. `machine add` also accepts options before the SSH target. (#3606, #3731, #3883)\n- Machine and worktree groups collapse correctly without changing the selected workspace. The sidebar respects workspace row gaps, stays stable during resizing, and reveals newly focused workspaces. (#3778, #3738, #3817)\n- The agent list keeps its scroll position when switching machines. Current-workspace and current-tab filters distinguish identical IDs on different machines. (#3937, #4101, #3732, thanks @minatoaquaMK2)\n- Background machine activation no longer resizes another client's focused pane. Window titles follow each client's own view rather than another client's selection. (#3744, #4091, #4130, thanks @JJLiebig)\n- `agent focus` and `pane move --focus` now move attached clients to the requested pane. Moving without focus no longer leaves a client following a deleted tab or later unrelated focus changes. (#3760, #4153, #4171, thanks @minatoaquaMK2)\n- Creating a worktree focuses its new workspace again. Partially failed worktree removals can be retried, and worktrees with submodules offer an explicit forced-removal choice rather than failing without a recovery action. (#3766, #3314, #1797)\n- Temporary worktree-action error banners expire instead of remaining on screen indefinitely. (#1827, #4063, thanks @JJLiebig)\n- The last tab remains fully visible when the tab strip overflows, and the session navigator once again draws connected tree branches. (#4151, #3843)\n- Muted sidebar and tab labels remain readable on dark themes, including Windows Terminal. (#2692, #4062, thanks @JJLiebig)\n- Mouse drag and copy-mode selections stay active while output continues, even when part of the selection is outside the viewport. Copying uses the current text in the selected range. Selection repainting also avoids building up a backlog of mouse movements. (#3841, #3895)\n- Double-click selections remain visible with automatic copy disabled. Holding the second click and dragging extends by whole words, and token selection stops at CJK punctuation. Host selection colors remain visible on transparent backgrounds. (#3847, #1836, #3706, #3798)\n- Scrolling no longer combines mismatched terminal snapshots or leaves stale wrapped rows in fullscreen applications such as Neovim. Alternate-screen history reads preserve content across changing headers and scrollbars. (#3900, #3329, #3979)\n- Local Kitty graphics keep their direct transport after the first image. Popups and notifications hide only overlapping image placements; uncovered images remain visible. (#3785, #4081)\n- Pixel mouse coordinates work independently of image rendering and use the terminal's integer cell size, preventing clicks from drifting across the screen. (#3295, #4140, thanks @JJLiebig)\n- Function keys F1\u2013F12 are recognized in Kitty keyboard reports, and F1\u2013F4 also work with parameterized reports from terminals such as foot. (#1809, #2378, #4067, thanks @ashokDevs and @JJLiebig)\n- WezTerm control-key reports now preserve Enter, Backspace, Tab, and Escape. Cmd/Super-modified input reaches pane applications with its modifier intact. Modified Enter stays compatible with panes that have not requested enhanced key encoding. (#3589, #3710, #4103, #4110, thanks @minatoaquaMK2)\n- Mouse reports split across delayed reads no longer leak their trailing bytes into pane input. Clients reassert mouse reporting after terminal reconnects. (#3911, #4079, thanks @JJLiebig)\n- Saved sessions that cannot be loaded are preserved before replacement, rather than silently overwritten. Recovery copies are kept in `session-backups/`; failed preservation leaves the original untouched. (#4125)\n- Deleting a session now requires its exact name, including case, on case-insensitive filesystems. Names beginning with a hyphen can be passed after `--`. (#3819, #4233, #3220, thanks @minatoaquaMK2)\n- Newly started macOS servers retain access to user lookup and DNS across logout. Existing affected servers need a full session restart; live handoff cannot repair their inherited service context. (#4100)\n- Linux process discovery stays responsive around stuck WSL agents and avoids repeatedly scanning an ever-growing process tree. (#2179, #3621, #3674, thanks @dark2momo and @caner-akca)\n- Linux desktop notifications identify Herdr as their source. (#3638)\n- `pane split` without a target now splits the calling pane when invoked inside Herdr, rather than another client's focused pane. Explicit targets keep their existing behavior. (#4123)\n- Live agent names survive temporary process-detection uncertainty instead of leaving an active agent unreachable by name. (#3225, #3574, thanks @caner-akca)\n- Windows Codex prompts reliably submit after pasted text instead of losing Enter, including long prompts. (#3187, #3961, thanks @JJLiebig)\n- OpenCode V2 reports lifecycle state from the pane displaying the conversation, so shared servers do not mix up panes. Completed and interrupted turns return to idle, while pending permissions, forms, and failed executions remain blocked. V1 remains supported; restart OpenCode after installing the updated integration. (#3757, thanks @markjaquith)\n- Codex stays working with static titles, animations disabled, and queued follow-ups. Composer sparkles and old confirmation text no longer make an idle pane appear blocked. (#4092, #4099, #3988, thanks @minatoaquaMK2)\n- Claude Code's Unicode activity spinner and Pi's working border are recognized correctly. Cline launcher detection and idle-state reporting are more reliable. (#3949, #3629, #2396)\n- Pi records native session paths on Windows. Nested OMP processes no longer replace the parent pane's resumable session, and Windows Kimi npm shims are recognized. (#3726, #2593, #3994, #3317, #4053, thanks @JJLiebig)\n- Grok updates its restore reference after `/new`, and its imported Claude-compatible hooks no longer overwrite that reference. Reinstall the Claude integration to adopt the narrower hook matching. (#2681, #4018)\n- Integration configuration updates preserve existing files when writes fail. Hermes installation also preserves valid YAML when enabled plugins use an inline list. (#3970, #3839, #4056, thanks @JJLiebig)\n- Plugin registry writes preserve symlinks. Plugins keep using the running server's compatible binary after a client-only update, and manual pane navigation emits focus events again. (#3922, #3824, #4077)\n- Config files containing a UTF-8 BOM load and save correctly. The default config places `ui.accent` in the right table. (#3678, #3962, #2697, thanks @JJLiebig)\n- Windows local and SSH input preserves Alt combinations, Ctrl+S, and enhanced keyboard text instead of dropping keys or inserting garbage. Remote dead-key input no longer adds an extra base character. (#3702, #3910, #3932, #3948, #4133, #4148, thanks @JJLiebig)\n- Windows mouse capture preserves SGR coordinates, including past column 95, and reattaching restores mouse reporting. Legacy SSH mouse reports no longer produce phantom characters or block subsequent input. (#4155, #3735, #4080, thanks @JJLiebig)\n- Git Bash SSH connections honor host aliases from the user's SSH config. (#3947, #4054, thanks @JJLiebig)\n- Windows login-mode panes honor the configured shell and keep PowerShell working-directory reports current. Pane launch paths use consistent directory casing. (#1445, #4060, #4065, #4199, thanks @JJLiebig)", "protocol": 22, diff --git a/distribution/preview.json b/distribution/preview.json index b609cf1f..e3579f97 100644 --- a/distribution/preview.json +++ b/distribution/preview.json @@ -1,37 +1,99 @@ { "schema_version": 1, "channel": "preview", - "base_version": "0.9.1", - "build_id": "2026-09-28-80c0c07250d2", - "commit": "80c0c07250d22f69d3fa05cb1700302d66180eb8", - "built_at": "2026-09-28T13:31:32Z", + "base_version": "0.9.2", + "build_id": "2026-09-29-8e78f929d8f0", + "commit": "8e78f929d8f0306a5c68518969e90274c44cb1f0", + "built_at": "2026-09-29T18:25:31Z", "protocol": 22, "endpoint_generation": 1, - "notes": "Preview build 2026-09-28-80c0c07250d2\n\n[View changes](https://github.com/herdrdev/herdr/compare/0ff0f27e222633c97ba4291f6b9be4137002ca84...80c0c07250d22f69d3fa05cb1700302d66180eb8)", + "notes": "Preview build 2026-09-29-8e78f929d8f0\n\n[View changes](https://github.com/herdrdev/herdr/compare/v0.9.2...8e78f929d8f0306a5c68518969e90274c44cb1f0)", "assets": { "linux-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-linux-x86_64", - "sha256": "6ffe6a33b407657c83675ac78e1fe0ca1c2fcea5b87d6322bc4a1e7bb835ca64" + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-linux-x86_64", + "sha256": "6f3dffff0c9805e414ff9285c0a5ce8ff8bd1c0118383360048837baad8ad6f5" }, "linux-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-linux-aarch64", - "sha256": "9b7a37299ff34522202acf565a31c757dc72c05b6494bb2963479e20e71004c9" + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-linux-aarch64", + "sha256": "f26292297337d1b54dc9d1f01e473d4c95d572410577aac70534a69bb9123b91" }, "macos-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-macos-x86_64", - "sha256": "9a071247bfb1ca075f2a1c8f4645870eaa1a1500db8248e4c4e386dd3befcc07" + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-macos-x86_64", + "sha256": "7cdf43b44cb4c0731a98932aeabe992e75781f4eead337bc52aa6889eff188ff" }, "macos-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-macos-aarch64", - "sha256": "6b2fa7088721eb7ca715401e90a1cfc25bbf53ff86ab84e19302877fcf0fbf28" + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-macos-aarch64", + "sha256": "7ebb310f974eaac7e45e9a46db470de11b24050dd929314757c952459bceaf3f" }, "windows-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-28-80c0c07250d2/herdr-windows-x86_64.zip", - "sha256": "e688bdc81cf4c8c608cca5ed7528626cf82c8ddf1eaa8e35274b9fc6e202be1c", + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-windows-x86_64.zip", + "sha256": "bbebbd192fc6356228efdfb89ad1ce86813f3aff41ad48dbf5082fbe94e2485c", "format": "zip" } }, "builds": { + "2026-09-29-8e78f929d8f0": { + "base_version": "0.9.2", + "commit": "8e78f929d8f0306a5c68518969e90274c44cb1f0", + "built_at": "2026-09-29T18:25:31Z", + "protocol": 22, + "endpoint_generation": 1, + "tag": "preview-2026-09-29-8e78f929d8f0", + "assets": { + "linux-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-linux-x86_64", + "sha256": "6f3dffff0c9805e414ff9285c0a5ce8ff8bd1c0118383360048837baad8ad6f5" + }, + "linux-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-linux-aarch64", + "sha256": "f26292297337d1b54dc9d1f01e473d4c95d572410577aac70534a69bb9123b91" + }, + "macos-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-macos-x86_64", + "sha256": "7cdf43b44cb4c0731a98932aeabe992e75781f4eead337bc52aa6889eff188ff" + }, + "macos-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-macos-aarch64", + "sha256": "7ebb310f974eaac7e45e9a46db470de11b24050dd929314757c952459bceaf3f" + }, + "windows-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-8e78f929d8f0/herdr-windows-x86_64.zip", + "sha256": "bbebbd192fc6356228efdfb89ad1ce86813f3aff41ad48dbf5082fbe94e2485c", + "format": "zip" + } + } + }, + "2026-09-29-9dc3a1df2b56": { + "base_version": "0.9.1", + "commit": "9dc3a1df2b563df0637264fc0dffcd607c324ac5", + "built_at": "2026-09-29T11:55:31Z", + "protocol": 22, + "endpoint_generation": 1, + "tag": "preview-2026-09-29-9dc3a1df2b56", + "assets": { + "linux-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-9dc3a1df2b56/herdr-linux-x86_64", + "sha256": "e550b8b82c0ace119426b1f1c80350314404c447a1e412b160b6ef5e0aa014e6" + }, + "linux-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-9dc3a1df2b56/herdr-linux-aarch64", + "sha256": "c0d0c97ccf181e68284382b56061785ef8fc483687ffacb9e77ef19cf0275bcb" + }, + "macos-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-9dc3a1df2b56/herdr-macos-x86_64", + "sha256": "a73ffb9f42631561dcd61dc882ca69db19a73cdec59ac9d2ba553229de255b12" + }, + "macos-aarch64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-9dc3a1df2b56/herdr-macos-aarch64", + "sha256": "14f32140cdac41d0c7339bbe348e8e97bb7c70450e9686af30bf5a0b058e8628" + }, + "windows-x86_64": { + "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-09-29-9dc3a1df2b56/herdr-windows-x86_64.zip", + "sha256": "09f37d09134dc9d1da34017dc27bb514552c692ff2355e7e16a87727d61f4745", + "format": "zip" + } + } + }, "2026-09-28-80c0c07250d2": { "base_version": "0.9.1", "commit": "80c0c07250d22f69d3fa05cb1700302d66180eb8", @@ -855,56 +917,6 @@ "sha256": "4bd46e2f4e3d76bf730b105fe11de53093c5b580b7e0a042359a887c5ac6aef0" } } - }, - "2026-06-04-670656b26f1a": { - "base_version": "0.6.8", - "commit": "670656b26f1aeadc3f18e6692a9fdf1b02da799c", - "built_at": "2026-06-04T19:48:01Z", - "protocol": 12, - "tag": "preview-2026-06-04-670656b26f1a", - "assets": { - "linux-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-04-670656b26f1a/herdr-linux-x86_64", - "sha256": "24dbbcc8f56d0d6fb05f08b30b42034de10155f09326a9f535810d0f4d53812a" - }, - "linux-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-04-670656b26f1a/herdr-linux-aarch64", - "sha256": "8f3708274a4e56f613709d05b4b8d56ba8d7b3a10705c12f2b57899cc5ff105b" - }, - "macos-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-04-670656b26f1a/herdr-macos-x86_64", - "sha256": "1a24cc6793a61a70c5e9a52514b809d267adde795152dd236f854048a82aa4bf" - }, - "macos-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-04-670656b26f1a/herdr-macos-aarch64", - "sha256": "396c97c613f33e490f66c36456d47cfb34dd9aca50a5911ef0adf6e1914c6c4c" - } - } - }, - "2026-06-03-854fc3c1aff5": { - "base_version": "0.6.7", - "commit": "854fc3c1aff525913c180543bfc8741b81253da0", - "built_at": "2026-06-03T12:17:39Z", - "protocol": 12, - "tag": "preview-2026-06-03-854fc3c1aff5", - "assets": { - "linux-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-854fc3c1aff5/herdr-linux-x86_64", - "sha256": "7513c0d40dda605c08c909e9652cdb30333900f4602a54c0eddb2e6878599fba" - }, - "linux-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-854fc3c1aff5/herdr-linux-aarch64", - "sha256": "a25b0d226e1c43bd11713e690e962c75a42ef528f9695dfbee4be7223eba8b33" - }, - "macos-x86_64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-854fc3c1aff5/herdr-macos-x86_64", - "sha256": "0ca3b10722c9077feabcc728e56a284fb24256fbe6ec01af60a1af582f9ff690" - }, - "macos-aarch64": { - "url": "https://github.com/herdrdev/herdr/releases/download/preview-2026-06-03-854fc3c1aff5/herdr-macos-aarch64", - "sha256": "ba2ac2ba89c46a328bb87f942cac7bc18d6d91c23f81b51b7eef8e55cc488fdb" - } - } } } } diff --git a/scripts/agent_detection_manifest_check.py b/scripts/agent_detection_manifest_check.py index bfc2c312..026a1e4b 100644 --- a/scripts/agent_detection_manifest_check.py +++ b/scripts/agent_detection_manifest_check.py @@ -53,16 +53,10 @@ MAX_TOTAL_MATCHERS = 1024 MAX_MATCHER_CHARS = 512 -# Keep engine-2 clients on the OSC-capable manifest until an engine-3 release -# can consume top_non_empty_lines. Remove this entry when the distribution -# publishes the bundled Grok manifest. -STAGED_PUBLISHED_MANIFESTS = { - "grok": ( - "2026.09.18.2", - "2026.09.18.1", - "0f31b111144900b02f303577d27587f72d58d8c505185a682bd7887f822316ee", - ), -} +# Keep older-engine clients on a published manifest until a release with the +# bundled manifest's engine ships. Maps agent id to (bundled version, published +# version, published sha256). Remove an entry once the bundled manifest ships. +STAGED_PUBLISHED_MANIFESTS: dict[str, tuple[str, str, str]] = {} UNPUBLISHED_BUNDLED_MANIFESTS: dict[str, tuple[str, str]] = {} diff --git a/scripts/package_windows_conpty.py b/scripts/package_windows_conpty.py index 1877d6b0..42daae3f 100644 --- a/scripts/package_windows_conpty.py +++ b/scripts/package_windows_conpty.py @@ -4,8 +4,11 @@ import argparse import hashlib import json +import os import shutil import struct +import subprocess +import sys import tempfile import time import urllib.error @@ -313,6 +316,94 @@ def stage_bundle( staging.rename(output_dir) +def install_local_bundle( + metadata_path: Path, architecture: str, package_path: Path, herdr_exe: Path +) -> None: + release_dir = herdr_exe.resolve().parent + destination = release_dir / "conpty" + with tempfile.TemporaryDirectory(prefix="herdr-conpty-install-", dir=release_dir) as temporary: + stage = Path(temporary) / "stage" + stage_bundle(metadata_path, architecture, package_path, herdr_exe, stage) + staged_bundle = stage / "conpty" + if destination.exists() or destination.is_symlink(): + staged_files = { + path.relative_to(staged_bundle) + for path in staged_bundle.rglob("*") + if path.is_file() + } + installed_files = { + path.relative_to(destination) + for path in destination.rglob("*") + if path.is_file() + } + matches = ( + destination.is_dir() + and not destination.is_symlink() + and not any(path.is_symlink() for path in destination.rglob("*")) + and installed_files == staged_files + and all( + not (destination / relative).is_symlink() + and sha256_file(destination / relative) == sha256_file(staged_bundle / relative) + for relative in staged_files + ) + ) + # ponytail: pin upgrades require stopping Herdr and removing the old local bundle. + if not matches: + raise ValueError( + f"local ConPTY bundle at {destination} differs from the pinned version; " + "stop Herdr, remove that directory, then run just build again" + ) + + for notice in load_metadata(metadata_path)["notices"]: + relative = PurePosixPath(notice["destination"]) + target = release_dir / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(stage / relative, target) + if not destination.exists(): + staged_bundle.rename(destination) + + +def build_local_bundle(metadata_path: Path) -> None: + executable = None + with subprocess.Popen( + ["cargo", "build", "--release", "--locked", "--message-format=json"], + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + text=True, + encoding="utf-8", + ) as cargo: + assert cargo.stdout is not None + for line in cargo.stdout: + if not line.startswith("{"): + print(line, end="") + continue + message = json.loads(line) + if message.get("reason") == "compiler-message": + print(message["message"]["rendered"], end="", file=sys.stderr) + elif ( + message.get("reason") == "compiler-artifact" + and message["manifest_path"] == str(PROJECT_ROOT / "Cargo.toml") + and message["target"]["name"] == "herdr" + and "bin" in message["target"]["kind"] + ): + executable = Path(message["executable"]) + status = cargo.wait() + if status: + raise SystemExit(status) + if executable is None: + raise ValueError("cargo did not report the Herdr executable") + + target = Path(os.environ.get("CARGO_TARGET_DIR", "target")) + if not target.is_absolute(): + target = PROJECT_ROOT / target + install_local_bundle( + metadata_path, + "x86_64", + target / "conpty" / "Microsoft.Windows.Console.ConPTY.nupkg", + executable, + ) + + def expected_stage_files(metadata: dict[str, Any], architecture: str) -> set[str]: files = {"herdr.exe", MARKER_PATH.as_posix()} files.update(item["destination"] for item in metadata["bundles"][architecture]["files"]) @@ -376,6 +467,7 @@ def parse_args() -> argparse.Namespace: archive.add_argument("--architecture", choices=("x86_64",), default="x86_64") archive.add_argument("--stage-dir", type=Path, required=True) archive.add_argument("--output", type=Path, required=True) + subparsers.add_parser("build-local") return parser.parse_args() @@ -389,8 +481,10 @@ def main() -> None: args.herdr_exe, args.output_dir, ) - else: + elif args.command == "archive": archive_bundle(args.metadata, args.architecture, args.stage_dir, args.output) + else: + build_local_bundle(args.metadata) if __name__ == "__main__": diff --git a/scripts/test_agent_detection_manifest_check.py b/scripts/test_agent_detection_manifest_check.py index fed0e06f..ae01a4ad 100644 --- a/scripts/test_agent_detection_manifest_check.py +++ b/scripts/test_agent_detection_manifest_check.py @@ -29,18 +29,27 @@ def catalog(agent_id: str = "codex", path: str = "codex.toml") -> str: ''' -def staged_grok_dirs(root: Path) -> tuple[Path, Path]: +STAGED_BUNDLED_MANIFEST = manifest("codex", "2026.06.10.2").replace( + "min_engine_version = 1", "min_engine_version = 2" +) +STAGED_PUBLISHED_MANIFEST = manifest("codex", "2026.06.10.1") +STAGED_TEST_EXCEPTION = { + "codex": ( + "2026.06.10.2", + "2026.06.10.1", + hashlib.sha256(STAGED_PUBLISHED_MANIFEST.encode()).hexdigest(), + ), +} + + +def staged_manifest_dirs(root: Path) -> tuple[Path, Path]: bundled = root / "bundled" published = root / "published" bundled.mkdir() published.mkdir() - (bundled / "grok.toml").write_bytes( - (check.DEFAULT_BUNDLED_DIR / "grok.toml").read_bytes() - ) - (published / "grok.toml").write_bytes( - (check.DEFAULT_PUBLISHED_DIR / "grok.toml").read_bytes() - ) - (published / "index.toml").write_text(catalog("grok", "grok.toml")) + (bundled / "codex.toml").write_text(STAGED_BUNDLED_MANIFEST, encoding="utf-8", newline="\n") + (published / "codex.toml").write_text(STAGED_PUBLISHED_MANIFEST, encoding="utf-8", newline="\n") + (published / "index.toml").write_text(catalog()) return bundled, published @@ -94,22 +103,24 @@ def test_rejects_published_version_lower_than_bundled(self): with self.assertRaisesRegex(check.CheckError, "lower than bundled"): check.validate_catalog(website, bundled_manifests, engine_version=1) + @patch.dict(check.STAGED_PUBLISHED_MANIFESTS, STAGED_TEST_EXCEPTION, clear=True) def test_allows_explicitly_staged_published_manifest(self): with tempfile.TemporaryDirectory() as tmp: - bundled, website = staged_grok_dirs(Path(tmp)) + bundled, website = staged_manifest_dirs(Path(tmp)) - bundled_manifests = check.load_manifest_dir(bundled, engine_version=3) - check.validate_catalog(website, bundled_manifests, engine_version=3) + bundled_manifests = check.load_manifest_dir(bundled, engine_version=2) + check.validate_catalog(website, bundled_manifests, engine_version=2) + @patch.dict(check.STAGED_PUBLISHED_MANIFESTS, STAGED_TEST_EXCEPTION, clear=True) def test_rejects_mutated_staged_published_manifest(self): with tempfile.TemporaryDirectory() as tmp: - bundled, website = staged_grok_dirs(Path(tmp)) - with (website / "grok.toml").open("a") as manifest_file: + bundled, website = staged_manifest_dirs(Path(tmp)) + with (website / "codex.toml").open("a") as manifest_file: manifest_file.write("\n# unexpected mutation\n") - bundled_manifests = check.load_manifest_dir(bundled, engine_version=3) + bundled_manifests = check.load_manifest_dir(bundled, engine_version=2) with self.assertRaisesRegex(check.CheckError, "lower than bundled"): - check.validate_catalog(website, bundled_manifests, engine_version=3) + check.validate_catalog(website, bundled_manifests, engine_version=2) def test_rejects_unlisted_published_manifest_lag_for_new_engine(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/scripts/test_package_windows_conpty.py b/scripts/test_package_windows_conpty.py index 61aec9d0..a8c006cc 100644 --- a/scripts/test_package_windows_conpty.py +++ b/scripts/test_package_windows_conpty.py @@ -3,6 +3,7 @@ import hashlib import io import json +import os import struct import tempfile import unittest @@ -15,6 +16,29 @@ class WindowsConptyPackageTests(unittest.TestCase): + def test_local_build_installs_beside_cargos_reported_executable(self) -> None: + executable = Path("other-target/x86_64-pc-windows-msvc/release/herdr.exe") + cargo = mock.MagicMock() + cargo.__enter__.return_value = cargo + cargo.stdout = io.StringIO( + json.dumps( + { + "reason": "compiler-artifact", + "manifest_path": str(package.PROJECT_ROOT / "Cargo.toml"), + "target": {"name": "herdr", "kind": ["bin"]}, + "executable": str(executable), + } + ) + + "\n" + ) + cargo.wait.return_value = 0 + with ( + mock.patch.object(package.subprocess, "Popen", return_value=cargo), + mock.patch.object(package, "install_local_bundle") as install, + ): + package.build_local_bundle(package.DEFAULT_METADATA) + self.assertEqual(install.call_args.args[-1], executable) + def test_pinned_metadata_and_notices_are_consistent(self) -> None: metadata = package.load_metadata(package.DEFAULT_METADATA) self.assertEqual(metadata["package"]["id"], "Microsoft.Windows.Console.ConPTY") @@ -110,7 +134,11 @@ def test_stage_and_archive_validate_exact_package(self) -> None: ] nupkg = root / "conpty.nupkg" self._write_package(nupkg, files, {item["source"]: data for item, data in zip(files, (dll, x64_host, arm64_host))}) - metadata_path = root / "conpty.json" + notice_paths = [root / "NOTICE-a.txt", root / "NOTICE-b.txt"] + for index, notice in enumerate(notice_paths): + notice.write_text(f"notice {index}", encoding="utf-8") + metadata_path = root / "packaging" / "windows" / "conpty.json" + metadata_path.parent.mkdir(parents=True) metadata_path.write_text( json.dumps( { @@ -123,7 +151,14 @@ def test_stage_and_archive_validate_exact_package(self) -> None: "license": "MIT", }, "bundles": {"x86_64": {"files": files}}, - "notices": [], + "notices": [ + { + "source": notice.name, + "destination": f"THIRD-PARTY-NOTICES/{notice.name}", + "sha256": package.sha256_file(notice), + } + for notice in notice_paths + ], } ), encoding="utf-8", @@ -153,6 +188,55 @@ def test_stage_and_archive_validate_exact_package(self) -> None: ), ) + local = root / "local" / "release" + local.mkdir(parents=True) + local_exe = local / "herdr.exe" + local_exe.write_bytes(herdr.read_bytes()) + package.install_local_bundle(metadata_path, "x86_64", nupkg, local_exe) + self.assertEqual((local / "conpty" / "conpty.dll").read_bytes(), dll) + self.assertEqual( + (local / "conpty" / "herdr-conpty.json").read_bytes(), + package.marker_data(package.load_metadata(metadata_path), "x86_64"), + ) + for notice in notice_paths: + self.assertEqual( + (local / "THIRD-PARTY-NOTICES" / notice.name).read_bytes(), + notice.read_bytes(), + ) + + package.install_local_bundle(metadata_path, "x86_64", nupkg, local_exe) + (local / "conpty" / "stale-file").write_bytes(b"stale") + with self.assertRaisesRegex(ValueError, "differs from the pinned version"): + package.install_local_bundle(metadata_path, "x86_64", nupkg, local_exe) + self.assertTrue((local / "conpty" / "stale-file").exists()) + (local / "conpty" / "stale-file").unlink() + package.install_local_bundle(metadata_path, "x86_64", nupkg, local_exe) + + dangling = local / "conpty" / "dangling" + try: + dangling.symlink_to("missing") + except OSError: + if os.name != "nt": + raise + else: + with self.assertRaisesRegex(ValueError, "differs from the pinned version"): + package.install_local_bundle(metadata_path, "x86_64", nupkg, local_exe) + dangling.unlink() + + dangling_root = root / "dangling-root" + dangling_root.mkdir() + dangling_exe = dangling_root / "herdr.exe" + dangling_exe.write_bytes(herdr.read_bytes()) + try: + (dangling_root / "conpty").symlink_to("missing", target_is_directory=True) + except OSError: + if os.name != "nt": + raise + else: + with self.assertRaisesRegex(ValueError, "differs from the pinned version"): + package.install_local_bundle(metadata_path, "x86_64", nupkg, dangling_exe) + self.assertFalse((dangling_root / "THIRD-PARTY-NOTICES").exists()) + (stage / "conpty" / "conpty.dll").write_bytes(b"tampered") with self.assertRaisesRegex(ValueError, "staged file hash mismatch"): package.validate_stage(metadata_path, "x86_64", stage) diff --git a/scripts/windows_conpty_enhanced_input_probe.ps1 b/scripts/windows_conpty_enhanced_input_probe.ps1 index 559396c4..574ea42f 100644 --- a/scripts/windows_conpty_enhanced_input_probe.ps1 +++ b/scripts/windows_conpty_enhanced_input_probe.ps1 @@ -399,9 +399,10 @@ fn main() { $kittyInitialHex = Get-LatestProbeHex -PaneText $report.kitty_initial $report.device_attributes_response = $kittyInitialHex -match "1b5b3f(?:3[0-9]|3b)+63" $report.kitty_query_response = $kittyInitialHex.Contains("1b5b3f3775") - $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b333a3175" - $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b353a3175" - $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b353a3175" + # libghostty omits the default press event type (`;3u`, not `;3:1u`). + $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b3375" + $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b3575" + $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b3575" $report.kitty_shift_enter = Send-KeyAndObserve -PaneId $kittyPane -Key "shift+enter" -ExpectedHex "1b5b31333b3275" $report.kitty_ctrl_backspace = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+backspace" -ExpectedHex "1b5b3132373b3575" $report.kitty_up = Send-KeyAndObserve -PaneId $kittyPane -Key "up" -ExpectedHex "1b5b313b313a3141" @@ -414,6 +415,11 @@ fn main() { $report.raw_kitty_ctrl_delete = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[57426;5u") -ExpectedHex "1b5b35373432363b3575" $report.raw_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "v") -ExpectedHex "1b76" $report.raw_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([string][char]0x15) -ExpectedHex "15" + # ConPTY must pass the richer Kitty and modifyOtherKeys forms through intact. + $report.raw_kitty_release = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[97;1:3u") -ExpectedHex "1b5b39373b313a3375" + $report.raw_kitty_alternate = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[97:65;2u") -ExpectedHex "1b5b39373a36353b3275" + $report.raw_kitty_associated_text = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[97;;97u") -ExpectedHex "1b5b39373b3b393775" + $report.raw_modify_other_keys = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[27;5;97~") -ExpectedHex "1b5b32373b353b39377e" $nativePane = New-ProbePane -Mode "native" # Win32 input mode records use CSI Vk;Scan;Unicode;Down;Control;Repeat _. @@ -465,6 +471,10 @@ fn main() { -or -not $report.raw_kitty_ctrl_delete.delivered ` -or -not $report.raw_alt_v.delivered ` -or -not $report.raw_ctrl_u.delivered ` + -or -not $report.raw_kitty_release.delivered ` + -or -not $report.raw_kitty_alternate.delivered ` + -or -not $report.raw_kitty_associated_text.delivered ` + -or -not $report.raw_modify_other_keys.delivered ` -or -not $report.native_escape_down.delivered ` -or -not $report.native_escape_release.delivered ` -or ($appLocalHostRequired -and -not $report.app_local_console_host) diff --git a/scripts/windows_input/report.py b/scripts/windows_input/report.py index f63e700e..1d6cc524 100644 --- a/scripts/windows_input/report.py +++ b/scripts/windows_input/report.py @@ -38,6 +38,10 @@ def key(name, vk, text, modifiers=(), enhanced=None, legacy=None): key("letter-a", 65, "a") key("shift-letter", 65, "A", (16,)) + # xterm modifyOtherKeys level 2 keeps Shift on printable keys and treats + # 0x40-0x7f as control input, so Shift+A is CSI 27;2;65~ (xterm input.c, + # ModifyOtherKeys and IsControlInput). + cases[-1]["expected"]["mok2"] = {"hex": [hex_of("\x1b[27;2;65~")]} key("enter", 13, "\r") key("shift-enter", 13, None, (16,), 13) cases[-1]["expected"]["legacy"] = {"loss": "enter", "hex": ["0d"]} diff --git a/skills/herdr/SKILL.md b/skills/herdr/SKILL.md index fe2ccb6f..4943e557 100644 --- a/skills/herdr/SKILL.md +++ b/skills/herdr/SKILL.md @@ -54,9 +54,9 @@ Choose the primitive that matches the job: A pane exists whether or not it contains an agent. `agent start` requires an existing available shell pane and never creates, splits, or moves layout. Use pane commands for ordinary processes. Use agent commands when Herdr must validate agent identity or interpret `idle`, `working`, `blocked`, `done`, and `unknown` lifecycle states. -Agent commands accept either a unique live agent name or the pane ID currently hosting that agent. They do not accept terminal IDs or bare agent-kind labels. Names must match `[a-z][a-z0-9_-]{0,31}` and be unique among live agents. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. +Agent commands accept either a unique live agent name or the pane ID currently hosting that agent. They do not accept terminal IDs or bare agent-kind labels. Names must match `[a-z][a-z0-9_-]{0,31}` and be unique among live agents. A name follows the current pane occupant and is cleared when that agent exits, is released, or is replaced. Suspending an agent with Ctrl+Z and running a non-agent command preserves its identity while the exact process remains alive. This does not preserve the original identity across another recognized agent taking over. A retained identity does not mean the agent is in the foreground; inspect the pane before sending input. -`idle` and `done` both mean the agent is ready for input. The CLI/API uses the server's seen state to distinguish them; explicit focus commands mark the target seen, while reads do not. Each TUI client tracks viewed completions independently, so its Done badge can differ from the CLI or another client's badge. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify it confidently; it does not prove completion. +`idle` and `done` normally indicate the agent is ready for input; neither guarantees that an unfamiliar approval dialog was recognized or that a retained agent is in the foreground. The CLI/API uses the server's seen state to distinguish them; explicit focus commands mark the target seen, while reads do not. Each TUI client tracks viewed completions independently, so its Done badge can differ from the CLI or another client's badge. Antigravity and Grok can be idle while background tasks or subagents keep running, so a settled-state wait is not proof those jobs have finished. `blocked` means Herdr recognized an approval or question UI. `unknown` means an agent is present but Herdr cannot classify it confidently; it does not prove completion. ## Use IDs and caller context @@ -186,7 +186,7 @@ herdr pane wait-output --match "test result" --timeout 120000 herdr pane read --source recent-unwrapped --lines 120 ``` -`pane run` atomically sends command text and Enter. `pane wait-output` searches the selected snapshot immediately, so output that already exists can match. Use `--match ` for a literal substring or `--regex ` for a Rust regular expression. Omitting `--timeout` allows an indefinite wait. +`pane run` queues command text and Enter together; success acknowledges admission, not command execution. On macOS, `pane run` and `agent start` reject a command that exceeds the terminal's current canonical line-input limit before queuing it. Wait for the interactive shell prompt before retrying. The check covers this submission, not an earlier incomplete line or a later terminal-mode change. `pane wait-output` searches the selected snapshot immediately, so output that already exists can match. Use `--match ` for a literal substring or `--regex ` for a Rust regular expression. Omitting `--timeout` allows an indefinite wait. Use the read source that matches the task: @@ -206,7 +206,7 @@ If a larger recent read still does not reveal the completed response, ask the ag - Use `--no-focus` for background work unless the user asked to switch context. - Use `--current`, an explicit pane ID, or a unique agent name. Do not rely on another client's focused pane. - Parse IDs from JSON responses. Do not derive them from sidebar order or examples. -- Do not close workspaces, tabs, panes, or sessions you did not create unless the user explicitly asked. `workspace close --group` closes the primary workspace and its linked worktree workspaces; never add it merely to bypass `workspace_group_close_required`. +- Do not close workspaces, tabs, panes, or sessions you did not create unless the user explicitly asked. Ordinary CLI/API closes do not inherit the TUI's last-pane confirmation. `workspace close --group` closes the primary workspace and its linked worktree workspaces; never add it merely to bypass `workspace_group_close_required`. - Use `--trust-repository` only after the user has verified the repository. It grants per-request Git trust; it is not a routine retry for a failed worktree command. - Client and server versions can differ after an update. Check `herdr status` before relying on new server features. A missing method is not permission to stop or upgrade a server. - Never run `herdr server stop` from an active session unless the user explicitly intends to stop the server and its pane processes. diff --git a/src/api/server.rs b/src/api/server.rs index d597659e..b706e036 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -21,6 +21,7 @@ use crate::ipc::{ poll_local_stream_read, remove_socket_file_if_owned, set_local_stream_polling, socket_file_identity, LocalStream, LocalStreamRead, SocketFileIdentity, }; +use crate::server::shutdown::{ServerStop, ShutdownReason}; #[cfg(test)] mod subscription_socket_tests; @@ -61,7 +62,7 @@ impl ServerHandle { pub(crate) fn start_server_with_stop_control( api_tx: ApiRequestSender, event_hub: EventHub, - server_stop: Arc, + server_stop: ServerStop, ) -> std::io::Result { start_server_inner(api_tx, event_hub, default_capabilities(), Some(server_stop)) } @@ -81,7 +82,7 @@ fn start_server_inner( api_tx: ApiRequestSender, event_hub: EventHub, mut capabilities: Option, - server_stop: Option>, + server_stop: Option, ) -> std::io::Result { let path = socket_path(); prepare_socket_path(&path)?; @@ -118,7 +119,7 @@ fn start_server_inner( let running = Arc::new(AtomicBool::new(true)); let listener_running = Arc::clone(&running); - let thread = std::thread::spawn(move || { + let spawned = crate::thread_spawn::spawn_named("herdr-api-accept", move || { run_accept_loop( listener.incoming(), &listener_running, @@ -131,7 +132,7 @@ fn start_server_inner( let connection_running = Arc::clone(&listener_running); #[cfg(unix)] let ssh_agents = ssh_agents.clone(); - std::thread::spawn(move || { + spawn_connection_handler(move || { if let Err(err) = handle_connection_with_stop( stream, &api_tx, @@ -149,6 +150,14 @@ fn start_server_inner( ); debug!("api server thread exiting"); }); + let thread = match spawned { + Ok(thread) => thread, + Err(err) => { + // No ServerHandle owns the socket yet, so its Drop cleanup won't run. + let _ = remove_socket_file_if_owned(&path, &identity); + return Err(err); + } + }; Ok(ServerHandle { _thread: thread, @@ -158,6 +167,14 @@ fn start_server_inner( }) } +/// Starts one connection's worker. When the OS refuses a thread, only this +/// connection is dropped; the accept loop keeps serving. +fn spawn_connection_handler(work: impl FnOnce() + Send + 'static) { + if let Err(err) = crate::thread_spawn::spawn_named("herdr-api-conn", work) { + warn!(err = %err, "failed to spawn api connection thread; dropping connection"); + } +} + fn run_accept_loop( incoming: impl IntoIterator>, running: &AtomicBool, @@ -216,6 +233,21 @@ mod accept_loop_tests { assert_eq!(handled, vec![1, 2]); } + #[test] + fn keeps_serving_after_connection_thread_spawn_fails() { + let running = AtomicBool::new(true); + let (tx, rx) = std::sync::mpsc::channel(); + + crate::thread_spawn::test_hook::fail_next_spawns(1); + run_accept_loop([Ok(1), Ok(2)], &running, Duration::ZERO, |stream| { + let tx = tx.clone(); + spawn_connection_handler(move || tx.send(stream).unwrap()); + }); + drop(tx); + + assert_eq!(rx.iter().collect::>(), vec![2]); + } + #[test] fn exits_when_shutdown_happens_while_errors_continue() { let running = AtomicBool::new(true); @@ -299,7 +331,7 @@ fn handle_connection_with_stop( event_hub: &EventHub, running: &Arc, capabilities: Option, - server_stop: Option<&Arc>, + server_stop: Option<&ServerStop>, #[cfg(unix)] ssh_agents: Option<&crate::platform::ssh_agent::SshAgentRegistry>, ) -> std::io::Result<()> { if let Err(err) = stream.set_send_timeout(Some(STREAM_WRITE_TIMEOUT)) { @@ -455,6 +487,9 @@ fn handle_connection_with_stop( } method_body => { let (response_write_tx, response_write_rx) = std::sync::mpsc::channel(); + let stop_caller = matches!(method_body, Method::ServerStop(_)) + .then(|| crate::platform::local_stream_peer_description(&stream)) + .flatten(); let response = handle_request( Request { id: request_id.clone(), @@ -463,6 +498,7 @@ fn handle_connection_with_stop( api_tx, capabilities, server_stop, + stop_caller, Some(response_write_rx), ); let result = write_text_line_allow_disconnect(&mut stream, &response); @@ -516,7 +552,8 @@ fn handle_request( request: Request, api_tx: &ApiRequestSender, capabilities: Option, - server_stop: Option<&Arc>, + server_stop: Option<&ServerStop>, + stop_caller: Option, response_write_complete: Option>, ) -> String { if matches!(&request.method, Method::Ping(_)) { @@ -545,14 +582,16 @@ fn handle_request( if matches!(&request.method, Method::ServerStop(_)) { if let Some(server_stop) = server_stop { - server_stop.store(true, Ordering::Release); + server_stop.request(ShutdownReason::ApiStop { + caller: stop_caller, + }); return serde_json::to_string(&SuccessResponse { id: request.id, result: ResponseResult::Ok {}, }) .unwrap_or_else(|_| "{}".to_string()); } - } else if server_stop.is_some_and(|stop| stop.load(Ordering::Acquire)) { + } else if server_stop.is_some_and(ServerStop::is_requested) { return error_response_json( request.id, "server_unavailable", @@ -1266,6 +1305,24 @@ mod tests { (api_tx, responder) } + #[test] + fn api_accept_thread_spawn_failure_removes_bound_socket() { + let _guard = env_lock().lock().unwrap(); + let dir = unique_test_path("api-accept-spawn-failure"); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("herdr.sock"); + std::env::set_var(crate::api::SOCKET_PATH_ENV_VAR, &path); + let (api_tx, _api_rx) = mpsc::unbounded_channel(); + + crate::thread_spawn::test_hook::fail_next_spawns(1); + let result = start_server_inner(api_tx, EventHub::default(), None, None); + std::env::remove_var(crate::api::SOCKET_PATH_ENV_VAR); + + assert!(result.is_err()); + assert!(!path.exists(), "bound socket must be removed"); + let _ = fs::remove_dir_all(dir); + } + #[test] fn socket_path_prefers_explicit_env_override() { let _guard = env_lock().lock().unwrap(); @@ -1455,6 +1512,7 @@ mod tests { }), None, None, + None, ); let parsed: SuccessResponse = serde_json::from_str(&response).unwrap(); @@ -1465,7 +1523,7 @@ mod tests { #[test] fn server_stop_control_bypasses_app_channel() { let (tx, mut rx) = mpsc::unbounded_channel(); - let stop = Arc::new(AtomicBool::new(false)); + let stop = ServerStop::default(); let response = handle_request( Request { id: "priority_stop".into(), @@ -1474,13 +1532,20 @@ mod tests { &tx, None, Some(&stop), + Some("pid 42 (herdr)".into()), None, ); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); assert_eq!(response["id"], "priority_stop"); assert_eq!(response["result"]["type"], "ok"); - assert!(stop.load(Ordering::Acquire)); + assert!(stop.is_requested()); + assert_eq!( + stop.take_reason(), + Some(ShutdownReason::ApiStop { + caller: Some("pid 42 (herdr)".into()) + }) + ); let rejected = handle_request( Request { @@ -1491,6 +1556,7 @@ mod tests { None, Some(&stop), None, + None, ); let rejected: serde_json::Value = serde_json::from_str(&rejected).unwrap(); assert_eq!(rejected["error"]["code"], "server_unavailable"); @@ -1506,8 +1572,9 @@ mod tests { }; let request_for_thread = request.clone(); - let thread = - std::thread::spawn(move || handle_request(request_for_thread, &tx, None, None, None)); + let thread = std::thread::spawn(move || { + handle_request(request_for_thread, &tx, None, None, None, None) + }); let msg = rx.blocking_recv().unwrap(); assert_eq!(msg.request.id, "req_2"); diff --git a/src/app/actions.rs b/src/app/actions.rs index e1ae2ffc..f01dd05b 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -664,11 +664,15 @@ impl AppState { } pub fn close_selected_workspace(&mut self) { + let close_indices = self.workspace_close_indices(self.selected); + self.close_workspaces(close_indices); + } + + pub(crate) fn close_workspaces(&mut self, close_indices: Vec) { if self.workspaces.is_empty() { return; } self.mark_session_dirty(); - let close_indices = self.workspace_close_indices(self.selected); let mut terminal_ids = Vec::new(); let mut pane_ids = Vec::new(); @@ -879,6 +883,20 @@ impl AppState { } pub(crate) fn workspace_close_indices(&self, ws_idx: usize) -> Vec { + let group = self.workspace_group_close_indices(ws_idx); + if group.iter().any(|index| { + *index != ws_idx + && self.workspaces[*index] + .worktree_space() + .is_some_and(|space| !space.is_linked_worktree) + }) { + vec![ws_idx] + } else { + group + } + } + + pub(crate) fn workspace_group_close_indices(&self, ws_idx: usize) -> Vec { self.workspaces .get(ws_idx) .and_then(|ws| ws.worktree_space()) @@ -1630,6 +1648,22 @@ impl AppState { let _ = cache_updates; Vec::new() } + AppEvent::RestoredWorktreeSpaceChecked { + workspace_id, + expected, + valid, + } => { + if !valid { + if let Some(workspace) = self.workspaces.iter_mut().find(|workspace| { + workspace.id == workspace_id + && workspace.worktree_space.as_ref() == Some(&expected) + }) { + workspace.worktree_space = None; + self.session_dirty = true; + } + } + Vec::new() + } AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), AppEvent::WorktreeReadFinished(_) => Vec::new(), @@ -2562,6 +2596,46 @@ mod tests { assert_eq!(state.workspaces[1].git_ahead_behind(), None); } + #[test] + fn restored_worktree_rejection_preserves_changed_and_missing_workspaces() { + let mut state = AppState::test_with_adversarial_identity_state(); + let workspace_id = state.workspaces[0].id.clone(); + let expected = crate::workspace::WorktreeSpaceMembership { + key: "saved-repo".into(), + label: "saved".into(), + repo_root: "/repo".into(), + checkout_path: "/checkout".into(), + is_linked_worktree: true, + }; + let current = crate::workspace::WorktreeSpaceMembership { + key: "new-repo".into(), + ..expected.clone() + }; + state.workspaces[0].worktree_space = Some(current.clone()); + state.session_dirty = false; + state.assert_invariants_for_test(); + + for id in [workspace_id.clone(), "closed-workspace".into()] { + state.handle_app_event(AppEvent::RestoredWorktreeSpaceChecked { + workspace_id: id, + expected: expected.clone(), + valid: false, + }); + } + assert_eq!(state.workspaces[0].worktree_space, Some(current.clone())); + assert!(!state.session_dirty); + state.assert_invariants_for_test(); + + state.handle_app_event(AppEvent::RestoredWorktreeSpaceChecked { + workspace_id, + expected: current, + valid: false, + }); + assert!(state.workspaces[0].worktree_space.is_none()); + assert!(state.session_dirty); + state.assert_invariants_for_test(); + } + #[test] fn apply_workspace_git_statuses_ignores_stale_cwd() { let mut state = app_with_workspaces(&["one"]); diff --git a/src/app/agent_suspend_tests.rs b/src/app/agent_suspend_tests.rs new file mode 100644 index 00000000..50546d25 --- /dev/null +++ b/src/app/agent_suspend_tests.rs @@ -0,0 +1,285 @@ +//! Agent identity across foreground changes: real exits, relaunches, and +//! an agent suspended behind the shell. + +use std::time::{Duration, Instant}; + +use crate::agent_resume::AgentSessionRef; +use crate::app::state::AppState; +use crate::detect::{Agent, AgentState}; +use crate::events::AppEvent; +use crate::layout::PaneId; +use crate::terminal::TerminalId; + +fn adversarial_pane() -> (AppState, PaneId, TerminalId) { + let app = AppState::test_with_adversarial_identity_state(); + let ws = &app.workspaces[0]; + let pane_id = ws.tabs[ws.active_tab].root_pane; + let terminal_id = ws.panes[&pane_id].attached_terminal_id.clone(); + (app, pane_id, terminal_id) +} + +fn pi_session() -> AgentSessionRef { + AgentSessionRef::path( + std::env::current_dir() + .expect("cwd") + .join("takeover-pi.jsonl") + .display() + .to_string(), + ) + .expect("pi session path") +} + +fn claude_session() -> AgentSessionRef { + AgentSessionRef::id("2f0c0c2e-1647-4a4b-9a3e-0000000c1a0d").expect("claude session id") +} + +fn detect(app: &mut AppState, pane_id: PaneId, agent: Agent, at: Instant) { + app.handle_app_event(AppEvent::AgentProcessDetected { + pane_id, + agent, + observed_at: at, + }); +} + +fn detection_state( + app: &mut AppState, + pane_id: PaneId, + agent: Option, + process_exited: bool, + at: Instant, +) { + app.handle_app_event(AppEvent::StateChanged { + pane_id, + agent, + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited, + observed_at: at, + }); +} + +/// The two events the detection loop publishes when the pane shell takes the +/// foreground back from an agent whose process is gone. +fn real_exit(app: &mut AppState, pane_id: PaneId, agent: Agent, at: Instant) { + detection_state(app, pane_id, Some(agent), true, at); + detection_state(app, pane_id, None, false, at + Duration::from_millis(1)); +} + +fn pi_report(app: &mut AppState, pane_id: PaneId, state: AgentState, seq: u64) { + app.handle_app_event(AppEvent::HookStateReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + state, + message: None, + seq: Some(seq), + session_ref: Some(pi_session()), + }); +} + +fn pi_session_report( + app: &mut AppState, + pane_id: PaneId, + session_start_source: Option<&str>, + seq: u64, +) { + app.handle_app_event(AppEvent::AgentSessionReported { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + seq: Some(seq), + session_ref: Some(pi_session()), + session_start_source: session_start_source.map(str::to_string), + }); +} + +fn claude_session_report(app: &mut AppState, pane_id: PaneId, seq: u64) { + app.handle_app_event(AppEvent::AgentSessionReported { + pane_id, + source: "herdr:claude".into(), + agent_label: "claude".into(), + seq: Some(seq), + session_ref: Some(claude_session()), + session_start_source: Some("startup".into()), + }); +} + +fn session_value(app: &AppState, terminal_id: &TerminalId) -> Option { + let terminal = &app.terminals[terminal_id]; + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()) + .or_else(|| { + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref) + }) + .map(|session_ref| session_ref.value.clone()) +} + +fn name_agent(app: &mut AppState, terminal_id: &TerminalId, name: &str) { + app.terminals + .get_mut(terminal_id) + .expect("terminal") + .set_agent_name(name.into()); +} + +fn pi_with_live_authority(start: Instant) -> (AppState, PaneId, TerminalId) { + let (mut app, pane_id, terminal_id) = adversarial_pane(); + detect(&mut app, pane_id, Agent::Pi, start); + pi_session_report(&mut app, pane_id, Some("startup"), 99); + pi_report(&mut app, pane_id, AgentState::Working, 100); + name_agent(&mut app, &terminal_id, "worker"); + assert!(app.terminals[&terminal_id].full_lifecycle_hook_authority_active()); + assert_eq!(session_value(&app, &terminal_id), Some(pi_session().value)); + (app, pane_id, terminal_id) +} + +fn claude_with_session(start: Instant) -> (AppState, PaneId, TerminalId) { + let (mut app, pane_id, terminal_id) = adversarial_pane(); + detect(&mut app, pane_id, Agent::Claude, start); + claude_session_report(&mut app, pane_id, 1); + name_agent(&mut app, &terminal_id, "c1"); + assert_eq!( + session_value(&app, &terminal_id), + Some(claude_session().value) + ); + (app, pane_id, terminal_id) +} + +#[test] +fn real_exit_clears_claude_session_and_name() { + let start = Instant::now(); + let (mut app, pane_id, terminal_id) = claude_with_session(start); + + real_exit( + &mut app, + pane_id, + Agent::Claude, + start + Duration::from_secs(1), + ); + + let terminal = &app.terminals[&terminal_id]; + assert_eq!(session_value(&app, &terminal_id), None); + assert_eq!(terminal.agent_name, None); + assert_eq!(terminal.effective_agent_label(), None); + app.assert_invariants_for_test(); +} + +#[test] +fn real_exit_clears_pi_session_name_and_authority() { + let start = Instant::now(); + let (mut app, pane_id, terminal_id) = pi_with_live_authority(start); + + real_exit(&mut app, pane_id, Agent::Pi, start + Duration::from_secs(1)); + + let terminal = &app.terminals[&terminal_id]; + assert_eq!(session_value(&app, &terminal_id), None); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.agent_name, None); + assert_eq!(terminal.effective_agent_label(), None); + app.assert_invariants_for_test(); +} + +#[test] +fn exit_then_different_agent_relaunch_drops_previous_identity() { + let start = Instant::now(); + let (mut app, pane_id, terminal_id) = claude_with_session(start); + + real_exit( + &mut app, + pane_id, + Agent::Claude, + start + Duration::from_secs(1), + ); + detect( + &mut app, + pane_id, + Agent::Codex, + start + Duration::from_secs(2), + ); + + let terminal = &app.terminals[&terminal_id]; + assert_eq!(session_value(&app, &terminal_id), None); + assert_eq!(terminal.agent_name, None); + assert_eq!(terminal.effective_agent_label(), Some("codex")); + app.assert_invariants_for_test(); +} + +#[test] +fn exited_pi_session_is_not_replayed_into_a_relaunched_pi() { + let start = Instant::now(); + let (mut app, pane_id, terminal_id) = pi_with_live_authority(start); + + real_exit(&mut app, pane_id, Agent::Pi, start + Duration::from_secs(1)); + detect(&mut app, pane_id, Agent::Pi, start + Duration::from_secs(2)); + // A late report from the exited process still carries its old session. + pi_report(&mut app, pane_id, AgentState::Working, 101); + + let terminal = &app.terminals[&terminal_id]; + assert!(!terminal.full_lifecycle_hook_authority_active()); + assert_eq!(session_value(&app, &terminal_id), None); + app.assert_invariants_for_test(); +} + +#[test] +fn suspended_pi_keeps_session_name_and_authority_for_its_next_reports() { + let start = Instant::now(); + let (mut app, pane_id, terminal_id) = pi_with_live_authority(start); + + // While Pi's job is stopped behind the shell, detection publishes nothing. + // After `fg`, Pi re-announces its session on agent_start and reports state. + pi_session_report(&mut app, pane_id, None, 101); + pi_report(&mut app, pane_id, AgentState::Idle, 102); + + let terminal = &app.terminals[&terminal_id]; + assert!(terminal.full_lifecycle_hook_authority_active()); + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!(terminal.agent_name.as_deref(), Some("worker")); + assert_eq!(session_value(&app, &terminal_id), Some(pi_session().value)); + app.assert_invariants_for_test(); +} + +/// The events detection publishes when a new process of the same agent kind +/// replaces the previous one without the shell in between. +fn same_kind_replacement(app: &mut AppState, pane_id: PaneId, agent: Agent, at: Instant) { + detection_state(app, pane_id, Some(agent), true, at); + detect(app, pane_id, agent, at + Duration::from_millis(1)); +} + +#[test] +fn same_kind_replacement_drops_the_previous_claude_session() { + let start = Instant::now(); + let (mut app, pane_id, terminal_id) = claude_with_session(start); + + same_kind_replacement( + &mut app, + pane_id, + Agent::Claude, + start + Duration::from_secs(1), + ); + + assert_eq!(session_value(&app, &terminal_id), None); + assert_eq!( + app.terminals[&terminal_id].effective_agent_label(), + Some("claude") + ); + app.assert_invariants_for_test(); +} + +#[test] +fn same_kind_replacement_drops_the_previous_pi_session_and_authority() { + let start = Instant::now(); + let (mut app, pane_id, terminal_id) = pi_with_live_authority(start); + + same_kind_replacement(&mut app, pane_id, Agent::Pi, start + Duration::from_secs(1)); + pi_report(&mut app, pane_id, AgentState::Working, 101); + + let terminal = &app.terminals[&terminal_id]; + assert!(!terminal.full_lifecycle_hook_authority_active()); + assert_eq!(session_value(&app, &terminal_id), None); + app.assert_invariants_for_test(); +} diff --git a/src/app/agents.rs b/src/app/agents.rs index fd2a58ed..89625f00 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -208,6 +208,10 @@ impl App { return Err(AgentStartError::InvalidTimeout); } + runtime + .validate_input_submission(&bytes) + .map_err(|err| AgentStartError::InputFailed(err.to_string()))?; + let now = Instant::now(); let terminal = self .state @@ -374,7 +378,7 @@ impl App { if !terminal.is_agent_terminal() { return None; } - let pane = self.pane_info(ws_idx, pane_id)?; + let pane = self.pane_metadata(ws_idx, pane_id)?; Some(crate::api::schema::AgentInfo { terminal_id: pane.terminal_id, name: terminal.agent_name.clone(), diff --git a/src/app/api.rs b/src/app/api.rs index 20ca9d0f..dd87c508 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -123,6 +123,34 @@ impl App { return Vec::new(); } + if let AppEvent::RestoredWorktreeSpaceChecked { + workspace_id, + expected, + valid, + } = ev + { + self.pending_restored_worktree_spaces + .retain(|(id, space)| id != &workspace_id || space != &expected); + let changed_workspace = (!valid) + .then(|| { + self.state.workspaces.iter().position(|workspace| { + workspace.id == workspace_id + && workspace.worktree_space.as_ref() == Some(&expected) + }) + }) + .flatten(); + self.state + .handle_app_event(AppEvent::RestoredWorktreeSpaceChecked { + workspace_id, + expected, + valid, + }); + if let Some(ws_idx) = changed_workspace { + self.emit_workspace_updated(ws_idx); + } + return Vec::new(); + } + if let AppEvent::TabBarCommandFinished { generation, segment_index, @@ -566,7 +594,13 @@ impl App { .terminal_runtimes .get(&terminal_id) .map(|runtime| runtime.current_size()) - .unwrap_or_else(|| self.state.estimate_pane_size()); + .unwrap_or_else(|| { + self.state + .new_pane_size(crate::ui::NewPanePlacement::Existing { + ws_idx, + pane: pane_id, + }) + }); let Some(launch_env) = self.pane_launch_env(ws_idx, pane_id, Vec::new()) else { return false; }; @@ -1554,8 +1588,17 @@ mod tests { .attached_terminal_id .clone(); let (runtime, _rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + #[cfg(unix)] + let (runtime, _detection_events) = { + let mut runtime = runtime; + runtime.test_process_pty_bytes(b"shell prompt"); + let events = runtime.test_start_basic_detection(); + runtime.test_wait_for_detection_reads(1).await; + (runtime, events) + }; + #[cfg(not(unix))] let reset_notify = runtime.agent_detection_reset_notify_for_test(); - app.terminal_runtimes.insert(terminal_id, runtime); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); let response = app.handle_api_request(crate::api::schema::Request { id: "reload_manifests".into(), @@ -1570,6 +1613,13 @@ mod tests { .unwrap() .is_empty()); + #[cfg(unix)] + app.terminal_runtimes + .get(&terminal_id) + .unwrap() + .test_wait_for_detection_reads(2) + .await; + #[cfg(not(unix))] tokio::time::timeout( std::time::Duration::from_millis(50), reset_notify.notified(), diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 9289ae02..690e53c9 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -87,19 +87,47 @@ impl App { let crate::api::schema::Method::AgentPrompt(params) = request.method else { return false; }; - match self.queue_agent_prompt(request.id, params) { - Ok((id, agent, completion)) => { - std::thread::spawn(move || { - let response = match completion.recv() { - Ok(Ok(())) => encode_success(id, ResponseResult::AgentPrompted { agent }), - Ok(Err(err)) if err.kind() == std::io::ErrorKind::TimedOut => { - encode_error(id, "timeout", err.to_string()) - } - Ok(Err(err)) => encode_error(id, "agent_prompt_failed", err.to_string()), - Err(_) => encode_error(id, "agent_prompt_failed", "pty actor closed"), - }; - let _ = respond_to.send(response); - }); + let validated = match self.validate_agent_prompt(request.id, ¶ms) { + Ok(validated) => validated, + Err(response) => { + let _ = respond_to.send(response); + return true; + } + }; + // Start the completion waiter before submitting, so a refused thread + // fails the request while nothing has been sent yet. + let (handoff_tx, handoff_rx) = std::sync::mpsc::channel::<( + String, + crate::api::schema::AgentInfo, + std::sync::mpsc::Receiver>, + )>(); + let waiter_respond_to = respond_to.clone(); + let spawned = crate::thread_spawn::spawn_named("herdr-agent-prompt", move || { + let Ok((id, agent, completion)) = handoff_rx.recv() else { + return; + }; + let response = match completion.recv() { + Ok(Ok(())) => encode_success(id, ResponseResult::AgentPrompted { agent }), + Ok(Err(err)) if err.kind() == std::io::ErrorKind::TimedOut => { + encode_error(id, "timeout", err.to_string()) + } + Ok(Err(err)) => encode_error(id, "agent_prompt_failed", err.to_string()), + Err(_) => encode_error(id, "agent_prompt_failed", "pty actor closed"), + }; + let _ = waiter_respond_to.send(response); + }); + if let Err(err) = spawned { + tracing::warn!(err = %err, "failed to spawn agent prompt thread"); + let _ = respond_to.send(encode_error( + validated.id, + "agent_prompt_failed", + format!("could not start prompt completion waiter: {err}"), + )); + return true; + } + match self.submit_agent_prompt(validated, ¶ms) { + Ok(submission) => { + let _ = handoff_tx.send(submission); } Err(response) => { let _ = respond_to.send(response); @@ -108,18 +136,12 @@ impl App { true } - fn queue_agent_prompt( - &mut self, + /// Runs every check that can reject a prompt without touching the pane. + fn validate_agent_prompt( + &self, id: String, - params: AgentPromptParams, - ) -> Result< - ( - String, - crate::api::schema::AgentInfo, - std::sync::mpsc::Receiver>, - ), - String, - > { + params: &AgentPromptParams, + ) -> Result { if params.text.is_empty() { return Err(encode_error( id, @@ -172,6 +194,40 @@ impl App { ), )); } + let Some(agent) = self.agent_info(resolved.ws_idx, resolved.pane_id) else { + return Err(agent_not_found(id, ¶ms.target)); + }; + Ok(ValidatedAgentPrompt { + id, + ws_idx: resolved.ws_idx, + pane_id: resolved.pane_id, + expected_agent, + agent, + }) + } + + fn submit_agent_prompt( + &mut self, + validated: ValidatedAgentPrompt, + params: &AgentPromptParams, + ) -> Result< + ( + String, + crate::api::schema::AgentInfo, + std::sync::mpsc::Receiver>, + ), + String, + > { + let ValidatedAgentPrompt { + id, + ws_idx, + pane_id, + expected_agent, + agent, + } = validated; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return Err(agent_not_found(id, ¶ms.target)); + }; #[cfg(windows)] let submit_deadline = params .wait @@ -201,9 +257,6 @@ impl App { } else { text }; - let Some(agent) = self.agent_info(resolved.ws_idx, resolved.pane_id) else { - return Err(agent_not_found(id, ¶ms.target)); - }; let completion = runtime .queue_user_input_submission( Bytes::from(text), @@ -373,6 +426,14 @@ impl App { } } +struct ValidatedAgentPrompt { + id: String, + ws_idx: usize, + pane_id: crate::layout::PaneId, + expected_agent: crate::detect::Agent, + agent: crate::api::schema::AgentInfo, +} + fn agent_not_ready(id: String, target: &str) -> String { encode_error( id, @@ -611,6 +672,74 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn agent_prompt_waiter_spawn_failure_fails_before_submitting() { + let mut app = app_with_agent(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_agent_name("reviewer".into()); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + crate::thread_spawn::test_hook::fail_next_spawns(1); + let response = run_deferred_agent_prompt( + &mut app, + "req", + AgentPromptParams { + target: "reviewer".into(), + text: "hello".into(), + wait: None, + }, + ); + + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "agent_prompt_failed"); + std::thread::sleep(AGENT_PROMPT_SUBMIT_DELAY * 2); + assert!(rx.try_recv().is_err(), "a failed prompt must not be sent"); + } + + #[tokio::test] + async fn agent_prompt_validation_errors_win_over_waiter_spawn_failure() { + let mut app = app_with_agent(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_agent_name("reviewer".into()); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + for (target, text, code) in [ + ("reviewer", "", "empty_agent_prompt"), + ("missing", "hello", "agent_not_found"), + ] { + crate::thread_spawn::test_hook::fail_next_spawns(1); + let response = run_deferred_agent_prompt( + &mut app, + "req", + AgentPromptParams { + target: target.into(), + text: text.into(), + wait: None, + }, + ); + + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, code); + assert!( + crate::thread_spawn::spawn_named("probe", || {}).is_err(), + "a rejected prompt must not start a waiter thread" + ); + } + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn agent_prompt_rejects_blocked_agent_without_writing() { let mut app = app_with_agent(); diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index 74190434..bde3421f 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -89,7 +89,13 @@ impl App { }); let root_leaf = first_layout_leaf(¶ms.root); let first_cwd = self.layout_root_cwd(ws_idx, replace_target, root_leaf); - let (rows, cols) = self.state.estimate_pane_size(); + let pane_sizes = self + .state + .new_layout_pane_sizes(&final_tile_layout(¶ms.root)); + // Sizes come from the same tree the panes are built from, in pane order. + let Some(&(rows, cols)) = pane_sizes.first() else { + return encode_error(id, "invalid_layout", "layout has no panes"); + }; let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; @@ -145,7 +151,9 @@ impl App { } self.apply_layout_pane_label(ws_idx, new_root_pane, root_leaf); - if let Err(message) = self.apply_layout_node_to_pane(ws_idx, new_root_pane, ¶ms.root) { + if let Err(message) = + self.apply_layout_node_to_pane(ws_idx, new_root_pane, ¶ms.root, &pane_sizes) + { self.rollback_layout_tab(ws_idx, new_root_pane); return encode_error(id, "layout_apply_failed", message); } @@ -362,6 +370,7 @@ impl App { ws_idx: usize, pane_id: PaneId, node: &LayoutNode, + pane_sizes: &[(u16, u16)], ) -> Result<(), String> { match node { LayoutNode::Pane { pane } => { @@ -374,16 +383,21 @@ impl App { first, second, } => { - let second_leaf = first_layout_leaf(second); + let (first_sizes, second_sizes) = + pane_sizes.split_at(layout_leaf_count(first).min(pane_sizes.len())); + let Some(&size) = second_sizes.first() else { + return Err("layout pane sizes do not match the layout".into()); + }; let new_pane = self.layout_split_pane( ws_idx, pane_id, direction.clone(), *ratio, - second_leaf, + first_layout_leaf(second), + size, )?; - self.apply_layout_node_to_pane(ws_idx, pane_id, first)?; - self.apply_layout_node_to_pane(ws_idx, new_pane, second) + self.apply_layout_node_to_pane(ws_idx, pane_id, first, first_sizes)?; + self.apply_layout_node_to_pane(ws_idx, new_pane, second, second_sizes) } } } @@ -395,8 +409,9 @@ impl App { direction: SplitDirection, ratio: f32, pane: &LayoutPane, + (rows, cols): (u16, u16), ) -> Result { - let (rows, cols) = self.state.estimate_pane_size(); + let direction = layout_direction(&direction); let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; @@ -408,10 +423,6 @@ impl App { .or_else(|| self.launch_cwd_for_pane_in_workspace(ws_idx, target_pane_id)); let extra_env = super::env::normalize_launch_env(pane.env.clone()) .map_err(|(_, message)| message.to_string())?; - let direction = match direction { - SplitDirection::Right => Direction::Horizontal, - SplitDirection::Down => Direction::Vertical, - }; let command = layout_command(pane)?; let result = { let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { @@ -515,6 +526,47 @@ impl App { } } +fn layout_direction(direction: &SplitDirection) -> Direction { + match direction { + SplitDirection::Right => Direction::Horizontal, + SplitDirection::Down => Direction::Vertical, + } +} + +fn layout_leaf_count(node: &LayoutNode) -> usize { + match node { + LayoutNode::Pane { .. } => 1, + LayoutNode::Split { first, second, .. } => { + layout_leaf_count(first) + layout_leaf_count(second) + } + } +} + +/// The tab layout `root` will produce, with placeholder pane ids in pane order. +fn final_tile_layout(root: &LayoutNode) -> crate::layout::TileLayout { + fn build(node: &LayoutNode, next_id: &mut u32) -> Node { + match node { + LayoutNode::Pane { .. } => { + *next_id += 1; + Node::Pane(PaneId::from_raw(*next_id)) + } + LayoutNode::Split { + direction, + ratio, + first, + second, + } => Node::Split { + direction: layout_direction(direction), + ratio: crate::layout::valid_split_ratio(*ratio), + first: Box::new(build(first, next_id)), + second: Box::new(build(second, next_id)), + }, + } + } + let root = build(root, &mut 0); + crate::layout::TileLayout::from_saved(root, PaneId::from_raw(1)) +} + fn first_layout_leaf(node: &LayoutNode) -> &LayoutPane { match node { LayoutNode::Pane { pane } => pane, @@ -723,6 +775,81 @@ mod tests { assert_eq!(error.error.code, "split_not_found"); } + #[tokio::test] + async fn layout_apply_starts_every_pane_at_its_final_size() { + let mut app = app_with_workspace(); + let area = ratatui::layout::Rect::new(0, 0, 120, 40); + crate::ui::compute_view_without_resizing_panes( + &mut app.state, + &app.terminal_runtimes, + area, + ); + let pane = || { + Box::new(LayoutNode::Pane { + pane: LayoutPane::default(), + }) + }; + let split = |direction, ratio, first, second| { + Box::new(LayoutNode::Split { + direction, + ratio, + first, + second, + }) + }; + let root = split( + SplitDirection::Right, + 0.6, + split(SplitDirection::Down, 0.3, pane(), pane()), + split( + SplitDirection::Down, + 0.95, + pane(), + split(SplitDirection::Right, 0.5, pane(), pane()), + ), + ); + + let response = app.handle_layout_apply( + "req".into(), + LayoutApplyParams { + workspace_id: None, + tab_id: None, + tab_label: None, + focus: false, + root: *root, + }, + ); + + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + let tab_idx = app.state.workspaces[0].tabs.len() - 1; + assert_ne!(app.state.workspaces[0].active_tab_index(), tab_idx); + let sizes = |app: &App| { + app.state.workspaces[0].tabs[tab_idx] + .layout + .pane_ids() + .into_iter() + .map(|pane_id| { + app.state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .unwrap() + .current_size() + }) + .collect::>() + }; + let spawned = sizes(&app); + assert_eq!(spawned.len(), 5); + crate::ui::resize_tab_surface( + &app.state, + &app.terminal_runtimes, + 0, + tab_idx, + area, + crate::kitty_graphics::HostCellSize::default(), + ); + assert_eq!(sizes(&app), spawned); + shutdown_test_runtimes(&mut app); + } + #[tokio::test] async fn layout_apply_replaces_tab_with_requested_tree() { let mut app = app_with_workspace(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index b2e68406..73ce7890 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -52,7 +52,18 @@ impl App { Ok(env) => env, Err((code, message)) => return encode_error(id, &code, message), }; - let (rows, cols) = self.state.estimate_pane_size(); + let direction = match params.direction { + crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal, + crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical, + }; + let (rows, cols) = self + .state + .new_pane_size(crate::ui::NewPanePlacement::Split { + ws_idx, + target: target_pane_id, + direction, + ratio: params.ratio.unwrap_or(0.5), + }); let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { let follow_cwd = self.launch_cwd_for_pane_in_workspace(ws_idx, target_pane_id); Some(self.resolve_new_terminal_cwd(follow_cwd)) @@ -65,10 +76,6 @@ impl App { let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return encode_error(id, "pane_not_found", "pane not found"); }; - let direction = match params.direction { - crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal, - crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical, - }; let shell_config = crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode); let split_result = match params.ratio { Some(ratio) => ws.split_pane_with_ratio( @@ -1943,6 +1950,9 @@ impl App { Ok(bytes) => bytes, Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), }; + if let Err(err) = runtime.validate_input_submission(&bytes) { + return encode_error(id, "pane_send_failed", err.to_string()); + } if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { return encode_error(id, "pane_send_failed", err.to_string()); } @@ -1967,14 +1977,18 @@ impl App { }; let workspace_id = self.public_workspace_id(ws_idx); let layout_update_target = self.layout_update_target_after_pane_removal(ws_idx, pane_id); - if self.state.close_pane_would_close_workspace(ws_idx, pane_id) - && self.state.confirm_implicit_worktree_group_close(ws_idx) - { - return Err(encode_error( - id, - "confirmation_required", - "closing this pane would close a worktree group", - )); + if self.state.close_pane_would_close_workspace(ws_idx, pane_id) { + self.require_restored_group_close_ready( + &id, + &self.state.workspace_close_indices(ws_idx), + )?; + if self.state.confirm_implicit_worktree_group_close(ws_idx) { + return Err(encode_error( + id, + "confirmation_required", + "closing this pane would close a worktree group", + )); + } } let workspace_snapshot = self.workspace_info(ws_idx); let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); @@ -2037,7 +2051,7 @@ impl App { Ok(encoded_keys) => encoded_keys, Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), }; - for bytes in encoded_keys { + for bytes in encoded_keys.into_iter().filter(|bytes| !bytes.is_empty()) { if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { return encode_error(id, "pane_send_failed", err.to_string()); } @@ -2829,7 +2843,7 @@ mod tests { } #[tokio::test] - async fn api_pane_send_keys_preserves_super_chord_in_legacy_pane() { + async fn api_pane_send_keys_does_not_type_super_chord_into_legacy_pane() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; assert_eq!( @@ -2850,11 +2864,38 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); assert_eq!(success.result, ResponseResult::Ok {}); + // Neither a bare "c" (#3710) nor a Kitty report the shell prints (#4356). + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn api_pane_send_keys_reports_super_chord_to_kitty_pane() { + let (mut app, pane_id) = app_with_test_workspace(); + let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[>1u", + 1, + ); + app.state.insert_test_runtime(internal_pane_id, runtime); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { + pane_id, + keys: vec!["cmd+c".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!( rx.try_recv().unwrap(), bytes::Bytes::from_static(b"\x1b[99;9u") ); - assert!(rx.try_recv().is_err()); } #[tokio::test] diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs index fb650065..74ce0525 100644 --- a/src/app/api/plugins/manifest.rs +++ b/src/app/api/plugins/manifest.rs @@ -290,10 +290,19 @@ pub(super) fn normalize_plugin_source( let plugin_root = std::path::PathBuf::from(&plugin.plugin_root) .canonicalize() .map_err(|err| ("invalid_plugin_source", err.to_string()))?; - let expected = crate::plugin_paths::managed_checkout_path(&plugin.plugin_id) - .canonicalize() - .map_err(|err| ("invalid_plugin_source", err.to_string()))?; - if managed_path != expected { + let legacy = crate::plugin_paths::managed_checkout_path(&plugin.plugin_id).canonicalize(); + let installations = + crate::plugin_paths::managed_installations_dir(&plugin.plugin_id).canonicalize(); + let is_installation = installations.as_ref().is_ok_and(|root| { + managed_path + .file_name() + .is_some_and(|name| name == "checkout") + && managed_path + .parent() + .and_then(|generation| generation.parent()) + == Some(root.as_path()) + }); + if !is_installation && !legacy.as_ref().is_ok_and(|path| path == &managed_path) { return Err(( "invalid_plugin_source", "GitHub plugin managed_path does not match the plugin id".to_string(), diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index c9b37bfe..daafc839 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -40,7 +40,7 @@ impl App { if !self.policy.persist_plugin_registry { return Ok(()); } - let entries = crate::persist::plugin_registry::try_load()?; + let entries = crate::plugin_installations::load(&mut self.plugin_installation_leases)?; self.replace_installed_plugins(entries); Ok(()) } @@ -52,7 +52,7 @@ impl App { if !self.policy.persist_plugin_registry { return Ok(mutation(&mut self.state.installed_plugins)); } - let (result, entries) = crate::persist::plugin_registry::update(|entries| { + let (result, _) = crate::persist::plugin_registry::update(|entries| { let mut registry = entries .drain(..) .map(|plugin| (plugin.plugin_id.clone(), plugin)) @@ -61,7 +61,7 @@ impl App { *entries = registry.into_values().collect(); result })?; - self.replace_installed_plugins(entries); + self.refresh_installed_plugins()?; Ok(result) } @@ -345,7 +345,7 @@ impl App { Ok(handled) => handled, Err(err) => { tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); - false + return encode_error(id, "plugin_link_failed", err); } }, None => false, @@ -878,6 +878,71 @@ mod tests { assert_eq!(response["error"]["code"], "stale_target"); } + #[tokio::test] + async fn pane_link_activation_reports_claimed_plugin_launch_failure() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("link-failure")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let public_id = app.public_pane_id(0, pane_id).unwrap(); + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 4); + runtime.test_process_pty_bytes( + b"\x1b]8;;obsidian://open?vault=meta\x1b\\Open note\x1b]8;;\x1b\\", + ); + app.terminal_runtimes.insert(terminal_id, runtime); + let root = unique_temp_path("claimed-plugin-link-failure"); + write_manifest_content( + &root, + r#" +id = "example.failed-link" +name = "Failed link" +version = "0.1.0" +min_herdr_version = "0.9.3" +platforms = ["linux", "macos", "windows"] +[[actions]] +id = "open" +title = "Open note" +command = ["never-launched"] +[[link_handlers]] +id = "note" +title = "Note" +pattern = "^obsidian://" +action = "open" +"#, + ); + link_manifest(&mut app, &root); + app.state.plugin_commands_in_flight = runtime::MAX_PLUGIN_COMMANDS_IN_FLIGHT; + let response = app.handle_pane_link_activate( + "click".into(), + PaneLinkActivateParams { + pane_id: public_id, + viewport_row: 0, + col: 1, + content_revision: None, + offset_from_bottom: None, + }, + ); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["error"]["code"], "plugin_link_failed"); + assert!(response["error"]["message"] + .as_str() + .unwrap() + .contains("maximum concurrent plugin commands")); + assert!( + response.get("result").is_none(), + "a claim failure is not an unhandled link" + ); + assert!( + rx.try_recv().is_err(), + "activation must not write to the PTY" + ); + std::fs::remove_dir_all(root).expect("remove test plugin"); + } + fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( @@ -1694,7 +1759,8 @@ command = ["cmd.exe", "/d", "/c", "slot.cmd", "default"] let expected = child_cwd.join(where_probe).canonicalize().unwrap(); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { - let text = runtime.recent_unwrapped_text(20); + // The pane is a narrow split, so the long path wraps across many rows. + let text = runtime.recent_unwrapped_text(400); if text.lines().any(|line| { std::fs::canonicalize(line.trim()).is_ok_and(|path| path == expected) }) { @@ -2655,6 +2721,86 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] } } + #[test] + fn running_plugin_command_keeps_its_files_after_app_teardown() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let base = unique_temp_path("plugin-worker-lease"); + let previous_config = std::env::var_os("XDG_CONFIG_HOME"); + let previous_state = std::env::var_os("XDG_STATE_HOME"); + std::env::set_var("XDG_CONFIG_HOME", &base); + std::env::set_var("XDG_STATE_HOME", base.join("state")); + let installation = + crate::plugin_paths::create_managed_installation("example.worktree-bootstrap").unwrap(); + let root = installation.join("checkout"); + write_manifest(&root); + std::fs::create_dir_all(crate::plugin_paths::managed_plugins_dir().join(".locks")).unwrap(); + std::fs::write(root.join("payload"), "original").unwrap(); + drop(crate::plugin_installations::create_lease(&installation).unwrap()); + let mut plugin = load_plugin_manifest(&root.to_string_lossy(), true).unwrap(); + plugin.source.managed_path = Some(root.display().to_string()); + crate::persist::plugin_registry::update(|entries| *entries = vec![plugin.clone()]).unwrap(); + let mut app = test_app(); + crate::plugin_installations::load(&mut app.plugin_installation_leases).unwrap(); + let command = if cfg!(windows) { + vec![ + "powershell.exe", + "-NoProfile", + "-Command", + "Set-Content ready ready; $deadline = [DateTime]::UtcNow.AddSeconds(10); while (!(Test-Path release) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 10 }; Get-Content payload | Set-Content result", + ] + } else { + vec![ + "sh", + "-c", + "printf ready > ready; i=0; while [ ! -f release ] && [ $i -lt 500 ]; do sleep 0.02; i=$((i+1)); done; cat payload > result", + ] + } + .into_iter() + .map(str::to_string) + .collect(); + app.start_plugin_command( + &plugin, + None, + None, + command, + &app.current_plugin_context("lease-test"), + None, + ) + .unwrap(); + assert_eq!( + read_capture_when_ready(&root.join("ready"), || {}).trim(), + "ready" + ); + crate::persist::plugin_registry::update(Vec::clear).unwrap(); + drop(app); + crate::plugin_installations::cleanup().unwrap(); + assert!(root.exists(), "the worker still owns the old installation"); + std::fs::write(root.join("release"), "").unwrap(); + assert_eq!( + read_capture_when_ready(&root.join("result"), || {}).trim(), + "original" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while installation.exists() { + crate::plugin_installations::cleanup().unwrap(); + assert!( + std::time::Instant::now() < deadline, + "finished worker must release its installation" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + for (key, previous) in [ + ("XDG_CONFIG_HOME", previous_config), + ("XDG_STATE_HOME", previous_state), + ] { + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + std::fs::remove_dir_all(base).unwrap(); + } + #[cfg(unix)] #[test] fn manifest_action_invoke_runs_command_and_captures_log() { diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index 05791886..a4f30b0c 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -115,7 +115,17 @@ impl App { crate::api::schema::SplitDirection::Right => Direction::Horizontal, crate::api::schema::SplitDirection::Down => Direction::Vertical, }; - let (rows, cols) = self.state.estimate_pane_size(); + let new_pane_placement = if placement == PluginPanePlacement::Zoomed { + crate::ui::NewPanePlacement::ZoomedOverlay + } else { + crate::ui::NewPanePlacement::Split { + ws_idx, + target: target_pane, + direction, + ratio: 0.5, + } + }; + let (rows, cols) = self.state.new_pane_size(new_pane_placement); let previous_focus = self.state.current_pane_focus_target(); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return encode_error(id, "workspace_not_found", "workspace not found"); @@ -123,8 +133,8 @@ impl App { let result = ws.split_pane_argv_command( target_pane, direction, - rows.max(4), - cols.max(10), + rows, + cols, Some(cwd), &pane.command, extra_env, @@ -195,13 +205,13 @@ impl App { Ok(env) => env, Err((code, message)) => return encode_error(id, &code, message), }; - let (rows, cols) = self.state.estimate_pane_size(); + let (rows, cols) = self.state.new_pane_size(crate::ui::NewPanePlacement::Alone); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return encode_error(id, "workspace_not_found", "workspace not found"); }; let (tab_idx, terminal, runtime) = match ws.create_tab_argv_command( - rows.max(4), - cols.max(10), + rows, + cols, cwd, &pane.command, extra_env, diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs index 30e2f7ae..4723570b 100644 --- a/src/app/api/plugins/runtime.rs +++ b/src/app/api/plugins/runtime.rs @@ -115,10 +115,12 @@ impl App { stderr: None, error: None, }; - self.push_plugin_command_log(log.clone()); - self.state.plugin_commands_in_flight += 1; let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { + let installation_lease = + crate::plugin_installations::command_lease(&self.plugin_installation_leases, plugin); + let spawned = crate::thread_spawn::spawn_named("herdr-plugin-command", move || { + // The worker may outlive App during normal server teardown. + let _installation_lease = installation_lease; let child = crate::plugin_command::command_for_argv_in_dir(&program, &args, &plugin_root) .envs(env) @@ -126,46 +128,7 @@ impl App { .stderr(Stdio::piped()) .spawn(); let finished = match child { - Ok(mut child) => { - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let stdout_reader = stdout.map(|stdout| { - std::thread::spawn(move || { - read_capped_plugin_output(stdout, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) - }) - }); - let stderr_reader = stderr.map(|stderr| { - std::thread::spawn(move || { - read_capped_plugin_output(stderr, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) - }) - }); - match child.wait() { - Ok(status) => crate::events::AppEvent::PluginCommandFinished { - log_id, - finished_unix_ms: current_unix_ms(), - exit_code: status.code(), - stdout: stdout_reader - .and_then(|reader| reader.join().ok()) - .unwrap_or_default(), - stderr: stderr_reader - .and_then(|reader| reader.join().ok()) - .unwrap_or_default(), - error: None, - }, - Err(err) => crate::events::AppEvent::PluginCommandFinished { - log_id, - finished_unix_ms: current_unix_ms(), - exit_code: None, - stdout: stdout_reader - .and_then(|reader| reader.join().ok()) - .unwrap_or_default(), - stderr: stderr_reader - .and_then(|reader| reader.join().ok()) - .unwrap_or_default(), - error: Some(err.to_string()), - }, - } - } + Ok(child) => finish_plugin_child(log_id, child), Err(err) => crate::events::AppEvent::PluginCommandFinished { log_id, finished_unix_ms: current_unix_ms(), @@ -177,10 +140,32 @@ impl App { }; let _ = event_tx.blocking_send(finished); }); + if let Err(err) = spawned { + tracing::warn!(err = %err, "failed to spawn plugin command thread"); + let log = PluginCommandLogInfo { + status: PluginCommandStatus::Failed, + finished_unix_ms: Some(current_unix_ms()), + stdout: Some(String::new()), + stderr: Some(String::new()), + error: Some(format!("could not start plugin command: {err}")), + ..log + }; + self.push_plugin_command_log(log.clone()); + return Ok(log); + } + self.push_plugin_command_log(log.clone()); + self.state.plugin_commands_in_flight += 1; Ok(log) } pub(crate) fn run_plugin_startup_hooks(&mut self) { + if self.policy.persist_plugin_registry && !self.plugin_installation_cleanup_allowed { + tracing::warn!("plugin cleanup skipped: some installations could not be pinned"); + } else if self.policy.persist_plugin_registry { + if let Err(err) = crate::plugin_installations::cleanup() { + tracing::warn!(%err, "plugin cleanup deferred"); + } + } let mut context = self.current_plugin_context("plugin.startup"); context.invocation_source = Some("startup".to_string()); let mut plugins = self @@ -274,6 +259,74 @@ impl App { } } +type PluginOutputReader = std::thread::JoinHandle; + +fn finish_plugin_child(log_id: String, mut child: std::process::Child) -> crate::events::AppEvent { + let (stdout_reader, stderr_reader) = + match spawn_plugin_output_readers(child.stdout.take(), child.stderr.take()) { + Ok(readers) => readers, + Err(err) => { + // A missing reader would leave the plugin writing into a closed + // pipe, so stop it rather than report a misleading result. + tracing::warn!(err = %err, "failed to spawn plugin output reader"); + let _ = child.kill(); + let _ = child.wait(); + return crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(format!("could not start output reader: {err}")), + }; + } + }; + let wait = child.wait(); + // Descendants can hold the pipes open after the command exits; the run + // ends when the command does, not when its output closes. + let finished_unix_ms = current_unix_ms(); + let join = |reader: Option| { + reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default() + }; + let stdout = join(stdout_reader); + let stderr = join(stderr_reader); + let (exit_code, error) = match wait { + Ok(status) => (status.code(), None), + Err(err) => (None, Some(err.to_string())), + }; + crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms, + exit_code, + stdout, + stderr, + error, + } +} + +fn spawn_plugin_output_readers( + stdout: Option, + stderr: Option, +) -> std::io::Result<(Option, Option)> { + let stdout = stdout + .map(|stdout| { + crate::thread_spawn::spawn_named("herdr-plugin-stdout", move || { + read_capped_plugin_output(stdout, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) + }) + }) + .transpose()?; + let stderr = stderr + .map(|stderr| { + crate::thread_spawn::spawn_named("herdr-plugin-stderr", move || { + read_capped_plugin_output(stderr, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) + }) + }) + .transpose()?; + Ok((stdout, stderr)) +} + fn current_unix_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -309,3 +362,102 @@ pub(super) fn read_capped_plugin_output(mut reader: impl Read, cap: usize) -> St } output } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn plugin_output_reader_spawn_failure_stops_the_command() { + let child = std::process::Command::new("sh") + .args(["-c", "sleep 30"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let started = std::time::Instant::now(); + + crate::thread_spawn::test_hook::fail_next_spawns(1); + let finished = finish_plugin_child("log".into(), child); + + let crate::events::AppEvent::PluginCommandFinished { + exit_code, error, .. + } = finished + else { + panic!("expected plugin command result"); + }; + assert_eq!(exit_code, None); + assert!(error.is_some_and(|error| error.starts_with("could not start output reader"))); + assert!(started.elapsed() < std::time::Duration::from_secs(10)); + } + + #[test] + fn plugin_second_output_reader_spawn_failure_stops_the_command() { + let child = std::process::Command::new("sh") + .args(["-c", "sleep 30"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let started = std::time::Instant::now(); + + crate::thread_spawn::test_hook::fail_spawns_after(1, 1); + let finished = finish_plugin_child("log".into(), child); + + let crate::events::AppEvent::PluginCommandFinished { + exit_code, error, .. + } = finished + else { + panic!("expected plugin command result"); + }; + assert_eq!(exit_code, None); + assert!(error.is_some_and(|error| error.starts_with("could not start output reader"))); + assert!(started.elapsed() < std::time::Duration::from_secs(10)); + } + + #[test] + fn plugin_finish_time_is_taken_when_the_command_exits() { + // The background `cat` keeps both output pipes open after `sh` exits, + // until the test closes its stdin. + let mut child = std::process::Command::new("sh") + .args(["-c", "cat <&0 >/dev/null &"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let stdin = child.stdin.take().unwrap(); + let pid = child.id().to_string(); + let releaser = std::thread::spawn(move || { + // Hold the pipes until `sh` has exited and been reaped. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline + && std::process::Command::new("ps") + .args(["-p", &pid]) + .stdout(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + std::thread::sleep(std::time::Duration::from_millis(200)); + let released_unix_ms = current_unix_ms(); + drop(stdin); + released_unix_ms + }); + + let finished = finish_plugin_child("log".into(), child); + let released_unix_ms = releaser.join().unwrap(); + + let crate::events::AppEvent::PluginCommandFinished { + finished_unix_ms, + exit_code, + .. + } = finished + else { + panic!("expected plugin command result"); + }; + assert_eq!(exit_code, Some(0)); + assert!(finished_unix_ms <= released_unix_ms); + } +} diff --git a/src/app/api/session.rs b/src/app/api/session.rs index a7d14b3d..acd65174 100644 --- a/src/app/api/session.rs +++ b/src/app/api/session.rs @@ -1,4 +1,4 @@ -use crate::api::schema::{ResponseResult, SessionSnapshot}; +use crate::api::schema::{PaneInfo, ResponseResult, SessionSnapshot}; use crate::app::App; use super::responses::encode_success; @@ -14,6 +14,17 @@ impl App { } pub(crate) fn session_snapshot(&self) -> SessionSnapshot { + self.session_snapshot_with_pane_info(Self::pane_info) + } + + pub(crate) fn session_metadata_snapshot(&self) -> SessionSnapshot { + self.session_snapshot_with_pane_info(Self::pane_metadata) + } + + fn session_snapshot_with_pane_info( + &self, + pane_info: fn(&Self, usize, crate::layout::PaneId) -> Option, + ) -> SessionSnapshot { let focused_workspace_id = self .state .active @@ -30,6 +41,7 @@ impl App { let mut workspaces = Vec::new(); let mut tabs = Vec::new(); let mut layouts = Vec::new(); + let mut panes = Vec::new(); for (ws_idx, ws) in self.state.workspaces.iter().enumerate() { workspaces.push(self.workspace_info(ws_idx)); for tab_idx in 0..ws.tabs.len() { @@ -39,6 +51,13 @@ impl App { if let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) { layouts.push(layout); } + panes.extend( + ws.tabs[tab_idx] + .layout + .pane_ids() + .into_iter() + .filter_map(|pane_id| pane_info(self, ws_idx, pane_id)), + ); } } @@ -50,7 +69,7 @@ impl App { focused_pane_id, workspaces, tabs, - panes: self.collect_panes_for_workspace(None).unwrap_or_default(), + panes, layouts, agents: self.collect_agent_infos(), } diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index c1bd86e9..b0085859 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -65,7 +65,7 @@ impl App { let cwd = cwd.map(PathBuf::from).unwrap_or_else(|| { self.resolve_new_terminal_cwd(self.focused_pane_cwd_in_workspace(ws_idx)) }); - let (rows, cols) = self.state.estimate_pane_size(); + let (rows, cols) = self.state.new_pane_size(crate::ui::NewPanePlacement::Alone); let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; @@ -233,6 +233,12 @@ impl App { .unwrap_or_default(); if closes_workspace { + if let Err(response) = self.require_restored_group_close_ready( + &id, + &self.state.workspace_close_indices(ws_idx), + ) { + return response; + } if self.state.confirm_implicit_worktree_group_close(ws_idx) { return encode_error( id, diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 9b125e57..10544bae 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -319,7 +319,14 @@ impl App { if self.state.workspaces.get(index).is_none() { return workspace_not_found(id, ¶ms.workspace_id); } - let close_indices = self.state.workspace_close_indices(index); + let close_indices = if params.close_group { + self.state.workspace_group_close_indices(index) + } else { + self.state.workspace_close_indices(index) + }; + if let Err(response) = self.require_restored_group_close_ready(&id, &close_indices) { + return response; + } if close_indices.len() >= 2 && !params.close_group { return encode_error( id, @@ -337,7 +344,7 @@ impl App { }) .collect::>(); self.state.selected = index; - self.state.close_selected_workspace(); + self.state.close_workspaces(close_indices); self.shutdown_detached_terminal_runtimes(); for (workspace_id, workspace) in closed_workspaces { self.emit_event(EventEnvelope { @@ -587,6 +594,86 @@ mod tests { app } + #[test] + fn restored_group_close_waits_for_every_membership_before_mutating_state() { + for method in ["workspace.close", "pane.close", "tab.close", "group"] { + for pending_index in [0, 2] { + for valid in [false, true] { + let mut app = app_with_worktree_group(); + let parent = app.state.workspaces.remove(0); + let linked = app.state.workspaces.remove(0); + app.state = crate::app::AppState::test_with_adversarial_identity_state(); + app.state.workspaces.insert(0, parent); + app.state.workspaces.push(linked); + app.state.confirm_close = false; + app.state.ensure_test_terminals(); + app.state.assert_invariants_for_test(); + let expected = app.state.workspaces[pending_index] + .worktree_space + .clone() + .unwrap(); + let pending_id = app.state.workspaces[pending_index].id.clone(); + app.pending_restored_worktree_spaces + .push((pending_id.clone(), expected.clone())); + let parent_pane = app.state.workspaces[0].tabs[0].root_pane; + let request = serde_json::json!({ + "id": "req", + "method": if method == "group" { "workspace.close" } else { method }, + "params": match method { + "pane.close" => serde_json::json!({"pane_id": app.public_pane_id(0, parent_pane).unwrap()}), + "tab.close" => serde_json::json!({"tab_id": app.public_tab_id(0, 0).unwrap()}), + _ => serde_json::json!({"workspace_id": app.public_workspace_id(0), "close_group": method == "group"}), + } + }); + let before = app.workspace_list_info(); + let response = + app.handle_api_request(serde_json::from_value(request.clone()).unwrap()); + let response: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(response.error.code, "worktree_operation_in_progress"); + assert_eq!(app.workspace_list_info(), before); + assert!(app.state.terminal_runtime_shutdowns.is_empty()); + assert!(app.event_hub.events_after(0).is_empty()); + app.state.assert_invariants_for_test(); + + app.handle_internal_event( + crate::events::AppEvent::RestoredWorktreeSpaceChecked { + workspace_id: pending_id, + expected, + valid, + }, + ); + let response = app.handle_api_request(serde_json::from_value(request).unwrap()); + if valid && method == "workspace.close" { + let response: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(response.error.code, "workspace_group_close_required"); + } else { + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(app.state.workspaces.len(), if valid { 1 } else { 2 }); + } + app.state.assert_invariants_for_test(); + } + } + } + } + + #[test] + fn restored_linked_workspace_can_close_while_its_validation_is_pending() { + let mut app = app_with_worktree_group(); + let linked = &app.state.workspaces[1]; + app.pending_restored_worktree_spaces + .push((linked.id.clone(), linked.worktree_space.clone().unwrap())); + let response = app.handle_workspace_close( + "req".into(), + WorkspaceCloseParams { + workspace_id: app.public_workspace_id(1), + close_group: true, + }, + ); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(app.state.workspaces.len(), 1); + assert_eq!(app.state.workspaces[0].display_name(), "parent"); + } + #[test] fn api_workspace_close_parent_group_requires_explicit_group_intent() { for confirm_close in [true, false] { @@ -625,6 +712,84 @@ mod tests { } } + #[test] + fn duplicate_repo_parents_close_independently_unless_group_is_explicit() { + for method in ["workspace.close", "pane.close", "tab.close", "group"] { + for target_index in [1, 3] { + let mut app = app_with_worktree_group(); + let parent = app.state.workspaces.remove(0); + let linked = app.state.workspaces.remove(0); + let mut duplicate = Workspace::test_new("duplicate"); + duplicate.worktree_space = parent.worktree_space.clone(); + app.state = crate::app::state::AppState::test_with_adversarial_identity_state(); + let focused_id = app.state.workspaces[0].id.clone(); + app.state.workspaces.extend([parent, linked, duplicate]); + app.state.ensure_test_terminals(); + app.state.assert_invariants_for_test(); + let target_id = app.public_workspace_id(target_index); + let target_pane = app.state.workspaces[target_index].tabs[0].root_pane; + let target_terminal = app + .state + .terminal_id_for_pane(target_index, target_pane) + .unwrap(); + let closed_indices = if method == "group" { + vec![1, 2, 3] + } else { + vec![target_index] + }; + let closed_ids = closed_indices + .iter() + .map(|index| app.public_workspace_id(*index)) + .collect::>(); + let surviving_ids = app + .state + .workspaces + .iter() + .enumerate() + .filter(|(index, _)| !closed_indices.contains(index)) + .map(|(_, workspace)| workspace.id.clone()) + .collect::>(); + let request = serde_json::json!({ + "id": "req", + "method": if method == "group" { "workspace.close" } else { method }, + "params": match method { + "pane.close" => serde_json::json!({"pane_id": app.public_pane_id(target_index, target_pane).unwrap()}), + "tab.close" => serde_json::json!({"tab_id": app.public_tab_id(target_index, 0).unwrap()}), + _ => serde_json::json!({"workspace_id": target_id, "close_group": method == "group"}), + } + }); + + let response = app.handle_api_request(serde_json::from_value(request).unwrap()); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + + assert_eq!( + app.state + .workspaces + .iter() + .map(|workspace| workspace.id.clone()) + .collect::>(), + surviving_ids + ); + assert_eq!( + app.state.workspaces[app.state.active.unwrap()].id, + focused_id + ); + assert!(!app.state.terminals.contains_key(&target_terminal)); + app.state.assert_invariants_for_test(); + let closed_events = app + .event_hub + .events_after(0) + .into_iter() + .filter_map(|(_, event)| match event.data { + EventData::WorkspaceClosed { workspace_id, .. } => Some(workspace_id), + _ => None, + }) + .collect::>(); + assert_eq!(closed_events, closed_ids); + } + } + } + #[test] fn api_workspace_close_noncontiguous_group_preserves_adversarial_identity_state() { let mut app = app_with_worktree_group(); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index b2fc0330..be282c1c 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -225,6 +225,7 @@ impl App { } fn worktree_source_from_workspace(&self, ws_idx: usize) -> Result { + self.require_restored_worktree_ready(ws_idx)?; let Some(ws) = self.state.workspaces.get(ws_idx) else { return Err(ApiFailure::new( "workspace_not_found", @@ -273,6 +274,38 @@ impl App { }) } + fn require_restored_worktree_ready(&self, ws_idx: usize) -> Result<(), ApiFailure> { + if self.state.workspaces.get(ws_idx).is_some_and(|workspace| { + self.pending_restored_worktree_spaces + .iter() + .any(|(id, expected)| { + id == &workspace.id && workspace.worktree_space.as_ref() == Some(expected) + }) + }) { + return Err(ApiFailure::new( + "worktree_operation_in_progress", + "Restored worktree is still loading. Try again shortly.", + )); + } + Ok(()) + } + + pub(super) fn require_restored_group_close_ready( + &self, + request_id: &str, + close_indices: &[usize], + ) -> Result<(), String> { + // Closing one workspace does not trust saved group identity and must + // remain possible even when that checkout's metadata is unavailable. + if close_indices.len() >= 2 { + for &ws_idx in close_indices { + self.require_restored_worktree_ready(ws_idx) + .map_err(|err| encode_error(request_id.to_owned(), err.code, err.message))?; + } + } + Ok(()) + } + fn ensure_source_parent_membership( &mut self, source: &mut WorktreeSource, @@ -485,7 +518,7 @@ impl App { }); } - fn emit_workspace_updated(&mut self, ws_idx: usize) { + pub(super) fn emit_workspace_updated(&mut self, ws_idx: usize) { self.emit_event(EventEnvelope { event: EventKind::WorkspaceUpdated, data: EventData::WorkspaceUpdated { @@ -1004,6 +1037,81 @@ mod tests { let _ = std::fs::remove_dir_all(repo); } + #[test] + fn deferred_api_worktree_create_spawn_failure_reports_error_and_clears_pending() { + let repo = create_committed_repo("api-worktree-create-spawn-failure-repo"); + let worktree_root = unique_temp_path("api-worktree-create-spawn-failure-root"); + let mut app = app_with_parent(&repo); + app.state.worktree_directory = worktree_root.clone(); + let request = Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams { + workspace_id: Some(app.state.workspaces[0].id.clone()), + branch: Some("spawn-failure".into()), + ..WorktreeCreateParams::default() + }), + }; + + crate::thread_spawn::test_hook::fail_next_spawns(1); + let response = run_deferred_api_request(&mut app, request); + + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "worktree_create_failed"); + assert!(app.pending_api_worktree_creates.is_empty()); + assert!(!worktree_root.exists()); + + let _ = std::fs::remove_dir_all(repo); + } + + #[tokio::test] + async fn deferred_api_worktree_spawn_failure_survives_full_event_queue() { + let repo = create_committed_repo("api-worktree-create-spawn-full-repo"); + let worktree_root = unique_temp_path("api-worktree-create-spawn-full-root"); + let mut app = app_with_parent(&repo); + app.state.worktree_directory = worktree_root.clone(); + while app + .event_tx + .try_send(AppEvent::UpdateReady { + version: "9.9.9".into(), + install_command: "herdr update".into(), + }) + .is_ok() + {} + let (respond_to, response_rx) = response_channel(); + let request = Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams { + workspace_id: Some(app.state.workspaces[0].id.clone()), + branch: Some("spawn-failure".into()), + ..WorktreeCreateParams::default() + }), + }; + + crate::thread_spawn::test_hook::fail_next_spawns(1); + assert!(app.handle_deferred_worktree_api_request(request, respond_to, false)); + + let finished = loop { + let event = + tokio::time::timeout(std::time::Duration::from_secs(2), app.event_rx.recv()) + .await + .expect("failed completion is delivered once the queue drains") + .expect("event channel open"); + if matches!(event, AppEvent::WorktreeAddFinished(_)) { + break event; + } + }; + app.handle_internal_event(finished); + + let response = response_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("request gets a response"); + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "worktree_create_failed"); + assert!(app.pending_api_worktree_creates.is_empty()); + + let _ = std::fs::remove_dir_all(repo); + } + #[tokio::test] async fn deferred_api_worktree_create_completes_after_source_workspace_changes() { let event_hub = crate::api::EventHub::default(); diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 0f5ed547..c20aaec4 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -190,32 +190,56 @@ impl App { focus: params.focus, respond_to, }; - let path = checkout_path; let source_checkout_path = api_request.source_checkout_path.clone(); let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let result = if let Some(parent_dir) = parent_dir { - std::fs::create_dir_all(&parent_dir).map_err(|err| err.to_string()) - } else { - Ok(()) + let finished = crate::events::WorktreeAddResult { + path: checkout_path, + api_request: Some(api_request), + result: Ok(()), + }; + let spawned = crate::thread_spawn::spawn_named_with( + "herdr-worktree-add", + finished, + move |mut finished| { + finished.result = if let Some(parent_dir) = parent_dir { + std::fs::create_dir_all(&parent_dir).map_err(|err| err.to_string()) + } else { + Ok(()) + } + .and_then(|()| { + crate::worktree::run_worktree_add_command( + &source_checkout_path, + &finished.path, + &branch, + &base, + params.trust_repository, + ) + }); + let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new(finished))); + }, + ); + if let Err((mut finished, err)) = spawned { + finished.result = Err(format!("could not start worktree creation: {err}")); + self.queue_worktree_spawn_failure(AppEvent::WorktreeAddFinished(Box::new(finished))); + } + } + + /// Reports a worker that never started through the normal completion path, + /// so pending-operation and runtime cleanup stay in one place. + fn queue_worktree_spawn_failure(&self, finished: AppEvent) { + tracing::warn!("failed to spawn worktree operation thread"); + match self.event_tx.try_send(finished) { + Ok(()) => {} + // The event loop drains this channel, so wait for room on a task + // instead of dropping the only completion for this request. + Err(tokio::sync::mpsc::error::TrySendError::Full(finished)) => { + let event_tx = self.event_tx.clone(); + tokio::spawn(async move { + let _ = event_tx.send(finished).await; + }); } - .and_then(|()| { - crate::worktree::run_worktree_add_command( - &source_checkout_path, - &path, - &branch, - &base, - params.trust_repository, - ) - }); - let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( - crate::events::WorktreeAddResult { - path, - api_request: Some(api_request), - result, - }, - ))); - }); + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {} + } } fn start_api_worktree_remove( @@ -235,6 +259,10 @@ impl App { ); return; }; + if let Err(err) = self.require_restored_worktree_ready(ws_idx) { + Self::send_api_response(respond_to, encode_error(id, err.code, err.message)); + return; + } let Some(space) = self .state .workspaces @@ -343,30 +371,36 @@ impl App { respond_to, }; let repo_root = space.repo_root; - let path = space.checkout_path; - let force = params.force; let trust_repository = params.trust_repository; let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let result = crate::worktree::run_worktree_remove_command_with_recovery( - &command, - &repo_root, - &path, - force, - trust_repository, - ); - let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new( - crate::events::WorktreeRemoveResult { - workspace_id: workspace_internal_id, - path, - workspace: Some(Box::new(workspace_snapshot)), - worktree: Some(Box::new(worktree)), - forced: force, - api_request: Some(api_request), - result, - }, - ))); - }); + let finished = crate::events::WorktreeRemoveResult { + workspace_id: workspace_internal_id, + path: space.checkout_path, + workspace: Some(Box::new(workspace_snapshot)), + worktree: Some(Box::new(worktree)), + forced: params.force, + api_request: Some(api_request), + result: Ok(()), + }; + let spawned = crate::thread_spawn::spawn_named_with( + "herdr-worktree-remove", + finished, + move |mut finished| { + finished.result = crate::worktree::run_worktree_remove_command_with_recovery( + &command, + &repo_root, + &finished.path, + finished.forced, + trust_repository, + ); + let _ = + event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new(finished))); + }, + ); + if let Err((mut finished, err)) = spawned { + finished.result = Err(format!("could not start worktree removal: {err}")); + self.queue_worktree_spawn_failure(AppEvent::WorktreeRemoveFinished(Box::new(finished))); + } } pub(crate) fn handle_api_worktree_add_finished( diff --git a/src/app/api/worktrees/reads.rs b/src/app/api/worktrees/reads.rs index 5024e7f0..aa032645 100644 --- a/src/app/api/worktrees/reads.rs +++ b/src/app/api/worktrees/reads.rs @@ -121,6 +121,7 @@ impl App { })? }; let ws = &self.state.workspaces[ws_idx]; + self.require_restored_worktree_ready(ws_idx)?; Ok(SourceInput { workspace_id: Some(ws.id.clone()), membership: ws.worktree_space().cloned(), diff --git a/src/app/creation.rs b/src/app/creation.rs index 05736fe5..21e976ce 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -129,7 +129,7 @@ impl App { focus: bool, extra_env: Vec<(String, String)>, ) -> std::io::Result { - let (rows, cols) = self.state.estimate_pane_size(); + let (rows, cols) = self.state.new_pane_size(crate::ui::NewPanePlacement::Alone); let (ws, terminal, runtime) = Workspace::new_with_extra_env( initial_cwd, rows, @@ -309,11 +309,8 @@ impl App { ws_idx: usize, pane_id: crate::layout::PaneId, ) -> Option { - let ws = self.state.workspaces.get(ws_idx)?; - let pane = ws.pane_state(pane_id)?; - let terminal = self.state.terminals.get(&pane.attached_terminal_id)?; - let tab_idx = ws.find_tab_index_for_pane(pane_id)?; - let scroll = self + let mut pane = self.pane_metadata(ws_idx, pane_id)?; + pane.scroll = self .state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) .and_then(|runtime| runtime.scroll_metrics()) @@ -322,6 +319,18 @@ impl App { max_offset_from_bottom: metrics.max_offset_from_bottom as u64, viewport_rows: metrics.viewport_rows as u64, }); + Some(pane) + } + + pub(super) fn pane_metadata( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) -> Option { + let ws = self.state.workspaces.get(ws_idx)?; + let pane = ws.pane_state(pane_id)?; + let terminal = self.state.terminals.get(&pane.attached_terminal_id)?; + let tab_idx = ws.find_tab_index_for_pane(pane_id)?; let focused = self.state.active == Some(ws_idx) && ws.active_tab == tab_idx && ws @@ -351,7 +360,7 @@ impl App { state_labels: presentation.state_labels, tokens: terminal.metadata_tokens.values(), agent_session: terminal_agent_session_info(terminal), - scroll, + scroll: None, revision: terminal.revision, }) } diff --git a/src/app/custom_commands.rs b/src/app/custom_commands.rs index a2245884..6e8a6c01 100644 --- a/src/app/custom_commands.rs +++ b/src/app/custom_commands.rs @@ -369,9 +369,9 @@ impl App { return Err(std::io::Error::other("no active workspace")); }; let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); + let (rows, cols) = self + .state + .new_pane_size(crate::ui::NewPanePlacement::ZoomedOverlay); let (env, _) = self.custom_command_env(); let ws = self @@ -393,8 +393,8 @@ impl App { }); let new_pane = ws.split_focused_command( Direction::Horizontal, - new_rows, - new_cols, + rows, + cols, cwd, command, env, @@ -448,9 +448,9 @@ impl App { return Err(std::io::Error::other("no active workspace")); }; let previous_focus_target = self.state.current_pane_focus_target(); - let (rows, cols) = self.state.estimate_pane_size(); - let new_rows = rows.max(4); - let new_cols = cols.max(10); + let (rows, cols) = self + .state + .new_pane_size(crate::ui::NewPanePlacement::ZoomedOverlay); let ws = self .state @@ -480,8 +480,8 @@ impl App { let result = ws.split_pane_argv_command( previous_focus, Direction::Horizontal, - new_rows, - new_cols, + rows, + cols, cwd, argv, extra_env, diff --git a/src/app/git_refresh.rs b/src/app/git_refresh.rs index fd512d55..c2cd4bb5 100644 --- a/src/app/git_refresh.rs +++ b/src/app/git_refresh.rs @@ -1,7 +1,11 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; use std::time::Instant; +use tracing::warn; + use super::{App, GIT_REMOTE_STATUS_REFRESH_INTERVAL, GIT_REPO_DISCOVERY_REFRESH_INTERVAL}; use crate::events::AppEvent; use crate::workspace::{GitStatusCacheEntry, GitStatusRefreshDemand, WorkspaceGitStatus}; @@ -33,6 +37,75 @@ struct WorkspaceGitRefreshOutput { } impl App { + pub(crate) fn refresh_restored_workspace_git_metadata(&mut self) { + if self.state.workspaces.is_empty() { + return; + } + self.pending_restored_worktree_spaces = self + .state + .workspaces + .iter() + .filter_map(|workspace| { + workspace + .worktree_space + .clone() + .map(|space| (workspace.id.clone(), space)) + }) + .collect(); + let now = Instant::now(); + self.start_restored_worktree_validation(now); + self.request_git_identity_refresh(now); + // Start once even without an attached TUI or sidebar Git tokens. Reuse + // the single detached worker so stalled I/O cannot block server shutdown. + self.start_git_status_refresh_if_due(now); + } + + pub(crate) fn start_restored_worktree_validation(&mut self, now: Instant) { + self.restored_worktree_validation_retry_at = None; + let jobs = Arc::new(self.pending_restored_worktree_spaces.clone()); + let next = Arc::new(AtomicUsize::new(0)); + let worker_count = jobs.len().min(4); + let mut started = 0; + // These one-shot checks are independent of the optional Git cache. A + // stalled checkout keeps its slot; never retry it on each refresh tick. + for _ in 0..worker_count { + let jobs = Arc::clone(&jobs); + let next = Arc::clone(&next); + let event_tx = self.event_tx.clone(); + let spawned = crate::thread_spawn::spawn_named("herdr-worktree-check", move || { + while let Some((workspace_id, expected)) = + jobs.get(next.fetch_add(1, Ordering::Relaxed)) + { + let valid = + crate::persist::restored_worktree_space_membership(Some(expected.clone())) + .is_some(); + if event_tx + .blocking_send(AppEvent::RestoredWorktreeSpaceChecked { + workspace_id: workspace_id.clone(), + expected: expected.clone(), + valid, + }) + .is_err() + { + break; + } + } + }); + match spawned { + Ok(_) => started += 1, + Err(err) => { + warn!(err = %err, "failed to spawn restored worktree check thread"); + } + } + } + // Any started worker drains the shared queue. With none, memberships + // stay unvalidated (worktree actions keep waiting) and we retry later. + if worker_count > 0 && started == 0 { + self.restored_worktree_validation_retry_at = + Some(now + GIT_REMOTE_STATUS_REFRESH_INTERVAL); + } + } + pub(crate) fn start_git_status_refresh_if_due(&mut self, now: Instant) { let Some(deadline) = self.git_refresh_deadline() else { return; @@ -50,21 +123,17 @@ impl App { if workspaces.is_empty() { self.last_git_remote_status_refresh = now; self.git_identity_refresh_requested = false; + self.git_refresh_spawn_retry_pending = false; return; } - self.git_refresh_in_flight = true; let event_tx = self.event_tx.clone(); let cache = self.git_status_cache.clone(); let mut demand = self.git_refresh_demand(); if self.git_identity_refresh_requested { demand.branch = true; } - self.git_identity_refresh_requested = false; - if refresh_repo_discovery { - self.last_git_repo_discovery_refresh = now; - } - std::thread::spawn(move || { + let spawned = crate::thread_spawn::spawn_named("herdr-git-refresh", move || { let output = refresh_workspace_git_statuses_with_cache_and_demand(workspaces, &cache, demand); let _ = event_tx.blocking_send(AppEvent::GitStatusRefreshed { @@ -72,6 +141,19 @@ impl App { cache_updates: output.cache_updates, }); }); + if let Err(err) = spawned { + // Keep the pending requests and retry after the normal interval. + warn!(err = %err, "failed to spawn git refresh thread; retrying later"); + self.last_git_remote_status_refresh = now; + self.git_refresh_spawn_retry_pending = true; + return; + } + self.git_refresh_spawn_retry_pending = false; + self.git_refresh_in_flight = true; + self.git_identity_refresh_requested = false; + if refresh_repo_discovery { + self.last_git_repo_discovery_refresh = now; + } } pub(crate) fn request_git_identity_refresh(&mut self, now: Instant) { @@ -359,6 +441,76 @@ mod tests { assert_eq!(jobs[0].cached, None); } + #[test] + fn failed_git_refresh_spawn_retries_after_interval() { + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + app.state.workspaces.push(Workspace::test_new("test")); + let now = Instant::now(); + app.request_git_identity_refresh(now); + + crate::thread_spawn::test_hook::fail_next_spawns(1); + app.start_git_status_refresh_if_due(now); + + assert!(!app.git_refresh_in_flight); + assert!(app.git_identity_refresh_requested); + assert_eq!( + app.git_refresh_deadline(), + Some(now + GIT_REMOTE_STATUS_REFRESH_INTERVAL) + ); + + let retry_at = now + GIT_REMOTE_STATUS_REFRESH_INTERVAL; + app.start_git_status_refresh_if_due(retry_at); + assert!(app.git_refresh_in_flight); + assert!(!app.git_identity_refresh_requested); + } + + #[test] + fn failed_restored_worktree_check_spawns_keep_spaces_pending_and_retry() { + let mut app = test_app(&crate::config::Config::default()); + let mut workspace = Workspace::test_new("restored"); + let membership = crate::workspace::WorktreeSpaceMembership { + key: "repo".into(), + label: "repo".into(), + repo_root: "/nonexistent/repo".into(), + checkout_path: "/nonexistent/repo".into(), + is_linked_worktree: false, + }; + workspace.worktree_space = Some(membership.clone()); + app.pending_restored_worktree_spaces + .push((workspace.id.clone(), membership.clone())); + app.state.workspaces.push(workspace); + + let now = Instant::now(); + crate::thread_spawn::test_hook::fail_next_spawns(1); + app.start_restored_worktree_validation(now); + + assert_eq!(app.pending_restored_worktree_spaces.len(), 1); + let retry_at = now + GIT_REMOTE_STATUS_REFRESH_INTERVAL; + assert_eq!(app.restored_worktree_validation_retry_at, Some(retry_at)); + assert!(app + .next_headless_loop_deadline_with_git_refresh(now, false, false) + .is_some_and(|deadline| deadline <= retry_at)); + + app.start_restored_worktree_validation(retry_at); + assert_eq!(app.restored_worktree_validation_retry_at, None); + let deadline = Instant::now() + std::time::Duration::from_secs(5); + let event = loop { + match app.event_rx.try_recv() { + Ok(event @ AppEvent::RestoredWorktreeSpaceChecked { .. }) => break event, + Ok(_) => {} + Err(_) => { + assert!(Instant::now() < deadline, "validation retry never reported"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + }; + app.handle_internal_event(event); + assert!(app.pending_restored_worktree_spaces.is_empty()); + assert_eq!(app.state.workspaces[0].worktree_space, Some(membership)); + } + #[test] fn cwd_identity_refresh_runs_once_without_sidebar_git_tokens() { let mut config = crate::config::Config::default(); @@ -375,6 +527,63 @@ mod tests { assert!(!app.git_identity_refresh_requested); } + #[tokio::test] + async fn restored_workspace_metadata_refresh_runs_once_without_sidebar_git_tokens() { + let repo = + std::env::temp_dir().join(format!("herdr-restored-git-refresh-{}", std::process::id())); + let nested = repo.join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::create_dir_all(repo.join(".git/objects")).unwrap(); + std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + let mut config = crate::config::Config::default(); + config.ui.sidebar.spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + let mut app = test_app(&config); + let mut workspace = Workspace::test_new("restored"); + workspace.identity_cwd = nested.clone(); + workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "replaced-repo".into(), + label: "old".into(), + repo_root: repo.clone(), + checkout_path: repo.clone(), + is_linked_worktree: false, + }); + app.state.workspaces.push(workspace); + app.state.active = Some(0); + app.state.ensure_test_terminals(); + for terminal in app.state.terminals.values_mut() { + terminal.cwd = nested.clone(); + } + app.state.assert_invariants_for_test(); + + app.refresh_restored_workspace_git_metadata(); + assert!(app.git_refresh_in_flight); + assert_eq!(app.pending_restored_worktree_spaces.len(), 1); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while app.git_refresh_in_flight || !app.pending_restored_worktree_spaces.is_empty() { + let event = app.event_rx.recv().await.expect("Git worker completion"); + app.handle_internal_event(event); + } + }) + .await + .expect("restored Git metadata refresh completes"); + + let workspace = &app.state.workspaces[0]; + assert_eq!(workspace.cached_git_branch.as_deref(), Some("main")); + assert_eq!( + workspace.cached_auto_label, + repo.file_name().unwrap().to_string_lossy() + ); + assert!(workspace.cached_git_space.is_some()); + assert!(workspace.worktree_space.is_none()); + assert!(app.pending_restored_worktree_spaces.is_empty()); + assert!(app.state.session_dirty); + assert_eq!(app.git_refresh_deadline(), None); + app.start_git_status_refresh_if_due(Instant::now()); + assert!(!app.git_refresh_in_flight); + app.state.assert_invariants_for_test(); + std::fs::remove_dir_all(repo).unwrap(); + } + #[test] fn due_git_refresh_does_not_start_without_sidebar_consumer() { let mut config = crate::config::Config::default(); @@ -390,6 +599,58 @@ mod tests { assert!(app.event_rx.try_recv().is_err()); } + #[test] + fn restored_worktree_validation_notifies_only_matching_rejections() { + let mut app = test_app(&crate::config::Config::default()); + app.state = crate::app::AppState::test_with_adversarial_identity_state(); + let workspace_id = app.state.workspaces[0].id.clone(); + let expected = crate::workspace::WorktreeSpaceMembership { + key: "saved".into(), + label: "saved".into(), + repo_root: "/repo".into(), + checkout_path: "/checkout".into(), + is_linked_worktree: true, + }; + app.state.workspaces[0].worktree_space = Some(expected.clone()); + app.pending_restored_worktree_spaces + .push((workspace_id.clone(), expected.clone())); + app.handle_internal_event(AppEvent::RestoredWorktreeSpaceChecked { + workspace_id: workspace_id.clone(), + expected: expected.clone(), + valid: true, + }); + assert!(app.pending_restored_worktree_spaces.is_empty()); + assert!(app.event_hub.events_after(0).is_empty()); + assert_eq!( + app.state.workspaces[0].worktree_space, + Some(expected.clone()) + ); + + let stale = crate::workspace::WorktreeSpaceMembership { + key: "stale".into(), + ..expected.clone() + }; + app.handle_internal_event(AppEvent::RestoredWorktreeSpaceChecked { + workspace_id: workspace_id.clone(), + expected: stale, + valid: false, + }); + assert!(app.event_hub.events_after(0).is_empty()); + app.handle_internal_event(AppEvent::RestoredWorktreeSpaceChecked { + workspace_id, + expected, + valid: false, + }); + let events = app.event_hub.events_after(0); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].1.event, + crate::api::schema::EventKind::WorkspaceUpdated + ); + assert!(app.state.workspaces[0].worktree_space.is_none()); + app.state.assert_invariants_for_test(); + } + #[test] fn git_refresh_demand_matches_sidebar_rows() { let cases = [ diff --git a/src/app/ids.rs b/src/app/ids.rs index 9caa2d70..15414481 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -64,6 +64,7 @@ impl App { .position(|workspace| workspace.id == id) .or_else(|| id.strip_prefix("w_")?.parse::().ok()?.checked_sub(1)) .or_else(|| id.parse::().ok()?.checked_sub(1)) + .filter(|&index| index < self.state.workspaces.len()) } pub(crate) fn canonical_workspace_id(&self, id: &str) -> Option { @@ -158,3 +159,31 @@ impl App { (self.public_pane_id(ws_idx, pane_id).as_deref() == Some(id)).then_some((ws_idx, pane_id)) } } + +#[cfg(test)] +mod tests { + use super::App; + use crate::{config::Config, workspace::Workspace}; + + #[test] + fn numeric_workspace_ids_beyond_the_last_workspace_are_not_found() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![Workspace::test_new("only")]; + let id = app.state.workspaces[0].id.clone(); + + assert_eq!(app.parse_workspace_id(&id), Some(0)); + assert_eq!(app.parse_workspace_id("1"), Some(0)); + assert_eq!(app.parse_workspace_id("w_1"), Some(0)); + for id in ["2", "999", "w_2", "w_999", "0", "w_0"] { + assert_eq!(app.parse_workspace_id(id), None, "{id}"); + } + assert_eq!(app.parse_tab_id("2:1"), None); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 78e92979..66d385e5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,8 @@ pub(crate) mod actions; mod agent_resume; +#[cfg(test)] +mod agent_suspend_tests; pub(crate) mod agent_view; mod agents; pub(crate) use agents::{AGENT_START_SETTLE_DELAY, MAX_AGENT_START_TIMEOUT}; @@ -34,7 +36,7 @@ use std::io; use std::sync::Arc; use std::time::{Duration, Instant}; -const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); +pub(crate) const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const GIT_REPO_DISCOVERY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); @@ -118,6 +120,11 @@ pub struct App { pub(crate) git_refresh_in_flight: bool, pub(crate) git_refresh_due_after_in_flight: bool, pub(crate) git_identity_refresh_requested: bool, + /// A refresh worker failed to start; retry it even without a client. + pub(crate) git_refresh_spawn_retry_pending: bool, + pub(crate) pending_restored_worktree_spaces: + Vec<(String, crate::workspace::WorktreeSpaceMembership)>, + pub(crate) restored_worktree_validation_retry_at: Option, pub(crate) git_status_cache: HashMap, pub(crate) pending_api_worktree_creates: HashMap, pub(crate) worktree_read_slots: std::sync::Arc, @@ -158,6 +165,14 @@ pub struct App { pub(crate) config_reloaded_from_disk: bool, client_shell_keybindings_profile: Option, endpoint_commands: custom_commands::EndpointCommandRegistry, + // Drop after terminal runtimes have shut down their processes. Command + // workers retain their own references until completion. + // ponytail: server-lifetime pins defer reclamation until restart; use + // per-pane leases only if reclamation during long-running sessions matters. + pub(crate) plugin_installation_leases: crate::plugin_installations::Leases, + // False when startup could not pin every installation; cleanup could + // otherwise reclaim files a restored consumer still uses. + pub(crate) plugin_installation_cleanup_allowed: bool, } pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256; @@ -173,11 +188,15 @@ fn background_update_check_enabled(background_updates: bool, check_enabled: bool fn load_plugin_registry( persist_plugin_registry: bool, + leases: &mut crate::plugin_installations::Leases, ) -> crate::app::state::InstalledPluginRegistry { if !persist_plugin_registry { return std::collections::HashMap::new(); } - let entries = crate::persist::plugin_registry::load(); + let entries = crate::plugin_installations::load(leases).unwrap_or_else(|err| { + tracing::warn!(%err, "failed to load plugin installations"); + Vec::new() + }); let entries = crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| { crate::app::api::plugins::load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg) }); @@ -354,6 +373,7 @@ pub(crate) fn client_palette_for_appearance( } impl App { + #[cfg(test)] pub fn new( config: &Config, policy: AppPolicy, @@ -361,6 +381,17 @@ impl App { api_rx: tokio::sync::mpsc::UnboundedReceiver, event_hub: crate::api::EventHub, ) -> Self { + Self::try_new(config, policy, config_diagnostic, api_rx, event_hub) + .expect("test app startup") + } + + pub fn try_new( + config: &Config, + policy: AppPolicy, + config_diagnostic: Option, + api_rx: tokio::sync::mpsc::UnboundedReceiver, + event_hub: crate::api::EventHub, + ) -> std::io::Result { let prefix_keys = config.prefix_keys(); crate::kitty_graphics::set_enabled(config.kitty_graphics_enabled()); let (event_tx, event_rx) = mpsc::channel::(APP_EVENT_CHANNEL_CAPACITY); @@ -371,6 +402,23 @@ impl App { let mut restored_terminals = std::collections::HashMap::new(); let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new(); let snapshot = policy.restore_session.then(crate::persist::load).flatten(); + let mut plugin_installation_leases = crate::plugin_installations::Leases::new(); + let mut plugin_installation_cleanup_allowed = true; + if policy.persist_plugin_registry { + let restored_cwds = snapshot + .as_ref() + .into_iter() + .flat_map(|snapshot| &snapshot.workspaces) + .flat_map(|workspace| &workspace.tabs) + .flat_map(|tab| tab.panes.values()) + .map(|pane| pane.cwd.clone()) + .collect::>(); + plugin_installation_cleanup_allowed = crate::plugin_installations::retain_startup( + &mut plugin_installation_leases, + &restored_cwds, + false, + ); + } let session_writer = Arc::new(std::sync::Mutex::new(crate::persist::SessionWriter::new( policy.restore_session && snapshot.is_none(), ))); @@ -514,7 +562,10 @@ impl App { integration_recommendations: crate::integration::integration_recommendations(), agent_manifest_summaries, agent_manifest_update_status: crate::detect::manifest_update::load_status(), - installed_plugins: load_plugin_registry(policy.persist_plugin_registry), + installed_plugins: load_plugin_registry( + policy.persist_plugin_registry, + &mut plugin_installation_leases, + ), plugin_panes: std::collections::HashMap::new(), popup_pane: None, plugin_command_logs: Vec::new(), @@ -528,13 +579,6 @@ impl App { state.terminals = restored_terminals; - for ws_idx in 0..state.workspaces.len() { - let cwd = state.workspaces[ws_idx] - .resolved_identity_cwd_from(&state.terminals, &restored_terminal_runtimes); - state.workspaces[ws_idx].cached_git_branch = - cwd.as_deref().and_then(crate::workspace::git_branch); - } - // Background auto-update is disabled for non-persistent test apps // and in debug/test builds so local development never mutates the // running binary out from under spawned test processes. @@ -546,13 +590,19 @@ impl App { ); if version_check_enabled { let update_tx = event_tx.clone(); - std::thread::spawn(move || crate::update::auto_update(update_tx)); + if let Err(err) = crate::thread_spawn::spawn_named("herdr-update-check", move || { + crate::update::auto_update(update_tx) + }) { + tracing::warn!(err = %err, "failed to spawn update check thread"); + } } if manifest_check_enabled { let manifest_update_tx = event_tx.clone(); - std::thread::spawn(move || { + if let Err(err) = crate::thread_spawn::spawn_named("herdr-manifest-check", move || { crate::detect::manifest_update::auto_update(manifest_update_tx) - }); + }) { + tracing::warn!(err = %err, "failed to spawn agent manifest check thread"); + } } let last_focus = state.active.and_then(|idx| { @@ -566,6 +616,8 @@ impl App { custom_commands::EndpointCommandRegistry::new(&state.keybinds.custom_commands); let mut app = Self { + plugin_installation_leases, + plugin_installation_cleanup_allowed, config_diagnostic_deadline: None, toast_deadline: None, last_api_notification_at: None, @@ -579,6 +631,9 @@ impl App { git_refresh_in_flight: false, git_refresh_due_after_in_flight: false, git_identity_refresh_requested: false, + git_refresh_spawn_retry_pending: false, + pending_restored_worktree_spaces: Vec::new(), + restored_worktree_validation_retry_at: None, git_status_cache: HashMap::new(), pending_api_worktree_creates: HashMap::new(), worktree_read_slots: std::sync::Arc::new(tokio::sync::Semaphore::new(8)), @@ -627,7 +682,8 @@ impl App { }; app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator); app.configure_window_title(&config.ui.window_title); - app + app.refresh_restored_workspace_git_metadata(); + Ok(app) } #[cfg(unix)] @@ -642,13 +698,20 @@ impl App { crate::handoff_runtime::ImportedHandoffRuntime, >, ) -> io::Result { - let mut app = Self::new( + let mut app = Self::try_new( config, AppPolicy::HANDOFF_REPLACEMENT, config_diagnostic, api_rx, event_hub, - ); + )?; + if !crate::plugin_installations::retain_startup( + &mut app.plugin_installation_leases, + &[], + true, + ) { + app.plugin_installation_cleanup_allowed = false; + } let (workspaces, terminals, runtimes) = crate::persist::restore_handoff( snapshot, config.advanced.scrollback_limit_bytes, @@ -682,6 +745,7 @@ impl App { .get(idx) .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); + app.refresh_restored_workspace_git_metadata(); Ok(app) } @@ -2764,6 +2828,83 @@ mod tests { } } + #[tokio::test] + async fn hidden_panes_start_at_the_size_their_tab_layout_gives_them() { + let mut app = test_app(); + let mut visible = Workspace::test_new("visible-with-tiny-first-pane"); + let first = visible.tabs[0].root_pane; + for _ in 0..4 { + visible.tabs[0].layout.focus_pane(first); + visible.test_split(ratatui::layout::Direction::Vertical); + } + app.state.workspaces = vec![visible]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let area = Rect::new(0, 0, 120, 40); + crate::ui::compute_view_without_resizing_panes( + &mut app.state, + &app.terminal_runtimes, + area, + ); + assert!(app.state.view.pane_infos[0].rect.height <= 3); + + let size_of = |app: &App, ws_idx: usize, pane_id| { + app.state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, ws_idx, pane_id) + .unwrap() + .current_size() + }; + let relayout = |app: &App, ws_idx: usize| { + crate::ui::resize_tab_surface( + &app.state, + &app.terminal_runtimes, + ws_idx, + 0, + area, + crate::kitty_graphics::HostCellSize::default(), + ); + }; + + let ws_idx = app + .create_workspace_with_options(std::env::temp_dir(), false) + .unwrap(); + assert_eq!(app.state.active, Some(0)); + let root = app.state.workspaces[ws_idx].tabs[0].root_pane; + let spawned = size_of(&app, ws_idx, root); + relayout(&app, ws_idx); + assert_eq!(size_of(&app, ws_idx, root), spawned); + assert!(spawned.0 > 30, "hidden root spawned at {spawned:?}"); + + let response = + app.handle_api_request_after_internal_events_drained(crate::api::schema::Request { + id: "req_hidden_split_size".into(), + method: crate::api::schema::Method::PaneSplit( + crate::api::schema::PaneSplitParams { + workspace_id: None, + target_pane_id: Some(app.pane_info(ws_idx, root).unwrap().pane_id), + direction: crate::api::schema::SplitDirection::Down, + ratio: Some(0.3), + cwd: None, + focus: false, + right_click: Default::default(), + env: Default::default(), + }, + ), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + let (_, new_pane) = app + .parse_pane_id(response["result"]["pane"]["pane_id"].as_str().unwrap()) + .unwrap(); + let spawned = size_of(&app, ws_idx, new_pane); + relayout(&app, ws_idx); + assert_eq!(size_of(&app, ws_idx, new_pane), spawned); + + for (_terminal_id, runtime) in app.terminal_runtimes.drain().collect::>() { + runtime.shutdown(); + } + } + #[tokio::test] async fn pane_split_request_uses_active_focused_pane_when_target_is_omitted() { let _guard = config_env_lock().lock().unwrap(); diff --git a/src/app/popup.rs b/src/app/popup.rs index 0643ea8f..988b3cb6 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -142,8 +142,8 @@ impl App { { self.state.view.terminal_area } else { - let (estimated_rows, estimated_cols) = self.state.estimate_pane_size(); - ratatui::layout::Rect::new(0, 0, estimated_cols, estimated_rows) + let (cols, rows) = self.state.headless_size; + ratatui::layout::Rect::new(0, 0, cols, rows) }; let Some(resolved_geometry) = resolve_popup_geometry(geometry.width, geometry.height, terminal_area) diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 99769e7b..c668087a 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -114,7 +114,11 @@ impl App { } let update_tx = self.event_tx.clone(); - std::thread::spawn(move || crate::update::auto_update(update_tx)); + if let Err(err) = crate::thread_spawn::spawn_named("herdr-update-check", move || { + crate::update::auto_update(update_tx) + }) { + tracing::warn!(err = %err, "failed to spawn update check thread"); + } } pub(crate) fn run_agent_manifest_update_check(&mut self) { @@ -129,7 +133,11 @@ impl App { self.next_agent_manifest_update_check = Some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL); let manifest_update_tx = self.event_tx.clone(); - std::thread::spawn(move || crate::detect::manifest_update::auto_update(manifest_update_tx)); + if let Err(err) = crate::thread_spawn::spawn_named("herdr-manifest-check", move || { + crate::detect::manifest_update::auto_update(manifest_update_tx) + }) { + tracing::warn!(err = %err, "failed to spawn agent manifest check thread"); + } } pub(crate) fn next_headless_loop_deadline_with_git_refresh( @@ -154,6 +162,7 @@ impl App { include_git_refresh .then(|| self.git_refresh_deadline()) .flatten(), + self.restored_worktree_validation_retry_at, self.next_auto_update_check, self.next_agent_manifest_update_check, self.agent_metadata_deadline, diff --git a/src/app/state.rs b/src/app/state.rs index 0c094bf9..5f1fbbce 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -925,11 +925,27 @@ impl AppState { }) } - pub fn estimate_pane_size(&self) -> (u16, u16) { - if let Some(info) = self.view.pane_infos.first() { - (info.rect.height, info.rect.width) + /// Rows and columns a pane being created at `placement` will have once its + /// tab is laid out, including tabs that are not on screen. + pub(crate) fn new_pane_size(&self, placement: crate::ui::NewPanePlacement) -> (u16, u16) { + crate::ui::new_pane_terminal_size(self, self.new_pane_area(), placement) + } + + /// Rows and columns for every pane of a tab about to be built as `layout`, + /// in pane order. + pub(crate) fn new_layout_pane_sizes( + &self, + layout: &crate::layout::TileLayout, + ) -> Vec<(u16, u16)> { + crate::ui::new_layout_terminal_sizes(self, self.new_pane_area(), layout) + } + + fn new_pane_area(&self) -> Rect { + let area = self.view.terminal_area; + if area.width == 0 || area.height == 0 { + Rect::new(0, 0, self.headless_size.0, self.headless_size.1) } else { - (self.headless_size.1, self.headless_size.0) + area } } @@ -1315,11 +1331,15 @@ mod tests { use crossterm::event::KeyEvent; #[test] - fn pane_size_estimate_uses_headless_size_before_first_view() { + fn new_pane_size_uses_headless_size_before_first_view() { let mut state = AppState::test_new(); state.headless_size = (132, 41); + state.pane_scrollbars = false; - assert_eq!(state.estimate_pane_size(), (41, 132)); + assert_eq!( + state.new_pane_size(crate::ui::NewPanePlacement::Alone), + (41, 132) + ); } #[test] diff --git a/src/cli.rs b/src/cli.rs index 427eaaf2..4e073132 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -998,7 +998,9 @@ fn print_session_table(sessions: &[crate::session::SessionInfo]) { println!( "{:<20} {:<8} {:<48} {}", session.name, - if session.running { + if session.connection_error.is_some() { + "unavailable" + } else if session.running { "running" } else { "stopped" @@ -1006,6 +1008,9 @@ fn print_session_table(sessions: &[crate::session::SessionInfo]) { session.session_dir, session.socket_path ); + if let Some(error) = &session.connection_error { + println!(" {error}"); + } } } diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index 46caf509..3d9b4a54 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -26,6 +26,7 @@ pub(super) fn run_plugin_command(args: &[String]) -> std::io::Result { match subcommand { "install" => plugin_install(&args[1..]), + "update" => plugin_update(&args[1..]), "uninstall" => plugin_uninstall(&args[1..]), "link" => plugin_link(&args[1..]), "list" => plugin_list(&args[1..]), @@ -211,74 +212,241 @@ fn plugin_install(args: &[String]) -> std::io::Result { return Ok(2); } - let temp_root = create_plugin_temp_dir("install")?; + match install_github_plugin(source, requested_ref, yes, true, None) { + Ok(code) => Ok(code), + Err(err) => { + eprintln!("{err}"); + Ok(1) + } + } +} + +fn plugin_update(args: &[String]) -> std::io::Result { + let mut targets = Vec::new(); + let mut yes = false; + for arg in args { + match arg.as_str() { + "--yes" | "-y" => yes = true, + other if other.starts_with('-') => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + _ => targets.push(arg.as_str()), + } + } + + let installed = match live_installed_plugins() { + Ok(plugins) => plugins, + Err(err) if is_connection_error(&err) => crate::persist::plugin_registry::try_load()?, + Err(err) => return Err(err), + }; + let plugins = if targets.is_empty() { + installed + .into_iter() + .filter(|plugin| plugin.source.kind == PluginSourceKind::Github) + .collect::>() + } else { + let mut plugins: Vec = Vec::new(); + for target in targets { + let plugin = if target.contains('/') { + let source = match GithubPluginSource::parse(target) { + Ok(source) => source, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + installed + .iter() + .find(|plugin| plugin_matches_github_source(plugin, &source)) + } else { + installed.iter().find(|plugin| plugin.plugin_id == target) + }; + let Some(plugin) = plugin else { + eprintln!("plugin not installed: {target}"); + return Ok(1); + }; + if plugin.source.kind != PluginSourceKind::Github { + eprintln!("plugin is locally linked and cannot be updated: {target}"); + return Ok(1); + } + if !plugins + .iter() + .any(|selected| selected.plugin_id == plugin.plugin_id) + { + plugins.push(plugin.clone()); + } + } + plugins + }; + + if plugins.is_empty() { + println!("No GitHub-managed plugins installed."); + return Ok(0); + } + if !yes && !io::stdin().is_terminal() { + eprintln!("plugin update requires --yes when stdin is not interactive"); + return Ok(2); + } + + let mut exit_code = 0; + for plugin in plugins { + let plugin_id = plugin.plugin_id.clone(); + let source = match GithubPluginSource::from_installed(&plugin) { + Ok(source) => source, + Err(err) => { + eprintln!("error updating {plugin_id}: {err}"); + exit_code = 1; + continue; + } + }; + match install_github_plugin( + source, + plugin.source.requested_ref.clone(), + yes, + plugin.enabled, + Some(&plugin), + ) { + Ok(code) => exit_code = exit_code.max(code), + Err(err) => { + eprintln!("error updating {plugin_id}: {err}"); + exit_code = 1; + } + } + } + Ok(exit_code) +} + +fn install_github_plugin( + source: GithubPluginSource, + requested_ref: Option, + yes: bool, + enabled: bool, + updating_plugin: Option<&InstalledPluginInfo>, +) -> std::io::Result { + let updating = updating_plugin.is_some(); + let temp_root = create_plugin_temp_dir(if updating { "update" } else { "install" })?; let checkout = temp_root.join("checkout"); let install_result = (|| { git_checkout(&source, requested_ref.as_deref(), &checkout)?; let resolved_commit = git_output(&checkout, ["rev-parse", "HEAD"])?; let manifest_root = source.manifest_root(&checkout); - let preview_plugin = load_cli_plugin_manifest(&manifest_root, true)?; + let preview_plugin = load_cli_plugin_manifest(&manifest_root, enabled)?; + if let Some(plugin) = updating_plugin { + if preview_plugin.plugin_id != plugin.plugin_id { + return Err(io::Error::other(format!( + "update source now contains plugin {}, expected {}", + preview_plugin.plugin_id, plugin.plugin_id + ))); + } + } + let _checkout_lock = lock_managed_checkout(&preview_plugin.plugin_id)?; let existing = installed_plugin_info(&preview_plugin.plugin_id)?; + if let Some(expected) = updating_plugin { + ensure_update_target_unchanged(expected, existing.as_ref())?; + } ensure_replacement_allowed(&preview_plugin, existing.as_ref())?; + if updating + && existing + .as_ref() + .and_then(|plugin| plugin.source.resolved_commit.as_deref()) + == Some(resolved_commit.as_str()) + { + println!("{} is already up to date.", preview_plugin.plugin_id); + return Ok(true); + } let mut source_info = source.to_source_info(requested_ref, resolved_commit, None, current_unix_ms()); - print_install_preview(&preview_plugin, &source_info, existing.as_ref()); - if !yes && !confirm("Install this plugin?")? { - eprintln!("plugin install cancelled"); - return Ok(0); - } - if let Err(err) = run_plugin_build_commands(&preview_plugin, &manifest_root) { - eprintln!("{err}"); - return Ok(1); - } - let post_build_plugin = load_cli_plugin_manifest(&manifest_root, true)?; - ensure_manifest_unchanged_after_build(&preview_plugin, &post_build_plugin)?; - - let final_checkout = crate::plugin_paths::managed_checkout_path(&preview_plugin.plugin_id); - let backup_checkout = temp_root.join("previous-checkout"); - let mut backup_moved = false; - if final_checkout.exists() { - std::fs::rename(&final_checkout, &backup_checkout) - .map_err(|err| plugin_checkout_lifecycle_error("replace", &final_checkout, err))?; - backup_moved = true; + print_install_preview(&preview_plugin, &source_info, existing.as_ref(), updating); + let prompt = if updating { + "Update this plugin?" + } else { + "Install this plugin?" + }; + if !yes && !confirm(prompt)? { + eprintln!( + "plugin {} cancelled", + if updating { "update" } else { "install" } + ); + return Ok(false); } + let installation = + crate::plugin_paths::create_managed_installation(&preview_plugin.plugin_id)?; + let installation_lease = crate::plugin_installations::create_lease(&installation)?; + let final_checkout = installation.join("checkout"); + let mut activation_attempted = false; let install_attempt = (|| { - if let Some(parent) = final_checkout.parent() { - std::fs::create_dir_all(parent).map_err(InstallFailure::Rollback)?; - } - std::fs::rename(&checkout, &final_checkout) - .map_err(|err| plugin_checkout_lifecycle_error("install", &final_checkout, err)) - .map_err(InstallFailure::Rollback)?; - - source_info.managed_path = Some(final_checkout.display().to_string()); + std::fs::rename(&checkout, &final_checkout)?; let final_manifest_root = source.manifest_root(&final_checkout); - let mut plugin = load_cli_plugin_manifest(&final_manifest_root, true) - .map_err(InstallFailure::Rollback)?; - plugin.source = source_info.clone(); - register_installed_plugin(plugin.clone(), source_info.clone())?; - Ok::(plugin) + let mut preview_plugin = preview_plugin.clone(); + let relocated_plugin = load_cli_plugin_manifest(&final_manifest_root, enabled)?; + preview_plugin.manifest_path = relocated_plugin.manifest_path; + preview_plugin.plugin_root = relocated_plugin.plugin_root; + run_plugin_build_commands(&preview_plugin, &final_manifest_root).map_err(|err| { + io::Error::other(format!( + "{err}\n\nPlugin was not {}.", + if updating { "updated" } else { "installed" } + )) + })?; + let mut plugin = load_cli_plugin_manifest(&final_manifest_root, enabled)?; + ensure_manifest_unchanged_after_build(&preview_plugin, &plugin)?; + source_info.managed_path = Some(final_checkout.display().to_string()); + plugin.source = source_info; + activation_attempted = true; + if let Some(expected) = updating_plugin { + persist_updated_plugin(&plugin, expected)?; + } else { + persist_plugin_offline(&plugin, true)?; + } + Ok::(plugin) })(); let plugin = match install_attempt { Ok(plugin) => plugin, - Err(InstallFailure::Rollback(err)) => { - let _ = std::fs::remove_dir_all(&final_checkout); - if backup_moved && backup_checkout.exists() { - let _ = std::fs::rename(&backup_checkout, &final_checkout); + Err(err) => { + drop(installation_lease); + // A failed activation may have published the path. Never delete files + // that a server or a plugin process could already be using. + if activation_attempted { + return Err(io::Error::other(format!( + "{err}; plugin files retained at {}", + final_checkout.display() + ))); + } + if let Err(cleanup_err) = + crate::persist::plugin_registry::with_registry_lock(|| { + std::fs::remove_dir_all(&installation) + }) + { + return Err(io::Error::other(format!( + "{err}; could not remove failed installation at {}: {cleanup_err}", + installation.display() + ))); } return Err(err); } - Err(InstallFailure::KeepCheckout(err)) => return Err(err), }; - println!("Installed {} from {}.", plugin.plugin_id, source.display()); + println!( + "{} {} from {}.", + if updating { "Updated" } else { "Installed" }, + plugin.plugin_id, + source.display() + ); println!( "Config: {}", crate::plugin_paths::plugin_config_dir(&plugin.plugin_id).display() ); - Ok(0) + Ok(true) })(); let _ = std::fs::remove_dir_all(&temp_root); - install_result + install_result.map(|cleanup| { + if cleanup { + if let Err(err) = crate::plugin_installations::cleanup() { + eprintln!("Plugin cleanup deferred: {err}"); + } + } + 0 + }) } fn plugin_uninstall(args: &[String]) -> std::io::Result { @@ -309,6 +477,11 @@ fn plugin_uninstall(args: &[String]) -> std::io::Result { (target.clone(), existing) } }; + let _checkout_lock = existing + .as_ref() + .filter(|plugin| plugin.source.kind == PluginSourceKind::Github) + .map(|_| lock_managed_checkout(&plugin_id)) + .transpose()?; match super::send_request(&Request { id: "cli:plugin".into(), @@ -339,9 +512,6 @@ fn plugin_uninstall(args: &[String]) -> std::io::Result { Err(err) => return Err(err), } - if let Some(plugin) = existing.as_ref() { - remove_managed_plugin_files(plugin)?; - } println!("Uninstalled {plugin_id}."); Ok(0) } @@ -784,6 +954,23 @@ impl GithubPluginSource { format!("https://github.com/{}/{}.git", self.owner, self.repo) } + fn from_installed(plugin: &InstalledPluginInfo) -> std::io::Result { + let owner = plugin + .source + .owner + .clone() + .ok_or_else(|| io::Error::other("installed GitHub plugin has no source owner"))?; + let repo = + plugin.source.repo.clone().ok_or_else(|| { + io::Error::other("installed GitHub plugin has no source repository") + })?; + Ok(Self { + owner, + repo, + subdir: plugin.source.subdir.clone(), + }) + } + fn display(&self) -> String { match &self.subdir { Some(subdir) => format!("{}/{}/{}", self.owner, self.repo, subdir), @@ -834,6 +1021,23 @@ fn ensure_replacement_allowed( Ok(()) } +fn ensure_update_target_unchanged( + expected: &InstalledPluginInfo, + current: Option<&InstalledPluginInfo>, +) -> std::io::Result<()> { + if current.is_some_and(|current| same_update_target(expected, current)) { + return Ok(()); + } + Err(io::Error::other(format!( + "plugin {} changed while its update was in progress; retry the update", + expected.plugin_id + ))) +} + +fn same_update_target(expected: &InstalledPluginInfo, current: &InstalledPluginInfo) -> bool { + current.manifest_path == expected.manifest_path && current.source == expected.source +} + fn validate_github_segment(label: &str, value: &str) -> Result<(), String> { if value.is_empty() { return Err(format!("GitHub {label} must not be empty")); @@ -932,106 +1136,54 @@ fn load_cli_plugin_manifest(path: &Path, enabled: bool) -> std::io::Result std::io::Result<()> { +fn persist_plugin_offline( + plugin: &InstalledPluginInfo, + preserve_enabled: bool, +) -> std::io::Result<()> { crate::plugin_paths::ensure_plugin_user_dirs(&plugin.plugin_id)?; crate::persist::plugin_registry::update(|plugins| { - plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); - plugins.push(plugin.clone()); - })?; - Ok(()) -} - -fn register_installed_plugin( - plugin: InstalledPluginInfo, - source: PluginSourceInfo, -) -> Result<(), InstallFailure> { - let request = Request { - id: "cli:plugin".into(), - method: Method::PluginLink(PluginLinkParams { - path: plugin.manifest_path.clone(), - enabled: plugin.enabled, - source: Some(source.clone()), - }), - }; - match super::send_request(&request) { - Ok(response) => { - if response.get("error").is_some() { - return Err(InstallFailure::Rollback(std::io::Error::other( - serde_json::to_string(&response).unwrap(), - ))); - } - if let Err(err) = - verify_plugin_link_source_response(response, &plugin.plugin_id, &source) + let mut plugin = plugin.clone(); + if preserve_enabled { + if let Some(existing) = plugins + .iter() + .find(|entry| entry.plugin_id == plugin.plugin_id) { - let unlink = super::send_request(&Request { - id: "cli:plugin".into(), - method: Method::PluginUnlink(PluginUnlinkParams { - plugin_id: plugin.plugin_id.clone(), - }), - }); - match unlink { - Ok(response) if response.get("error").is_none() => { - return Err(InstallFailure::Rollback(err)); - } - Ok(response) => { - return Err(InstallFailure::KeepCheckout(std::io::Error::other( - format!( - "{err}; failed to undo incompatible plugin registration: {}", - serde_json::to_string(&response).unwrap() - ), - ))); - } - Err(unlink_err) if super::protocol_mismatch_was_reported(&unlink_err) => { - return Err(InstallFailure::KeepCheckout(unlink_err)); - } - Err(unlink_err) => { - return Err(InstallFailure::KeepCheckout(std::io::Error::other( - format!( - "{err}; failed to undo incompatible plugin registration: {unlink_err}" - ), - ))); - } - } + plugin.enabled = existing.enabled; } - Ok(()) - } - Err(err) if is_connection_error(&err) => { - persist_plugin_offline(&plugin).map_err(InstallFailure::Rollback) } - Err(err) => Err(InstallFailure::Rollback(err)), - } -} - -#[derive(Debug)] -enum InstallFailure { - Rollback(std::io::Error), - KeepCheckout(std::io::Error), + plugins.retain(|entry| entry.plugin_id != plugin.plugin_id); + plugins.push(plugin); + })?; + Ok(()) } -fn verify_plugin_link_source_response( - response: serde_json::Value, - plugin_id: &str, - expected: &PluginSourceInfo, +fn persist_updated_plugin( + plugin: &InstalledPluginInfo, + expected: &InstalledPluginInfo, ) -> std::io::Result<()> { - let parsed: SuccessResponse = - serde_json::from_value(response).map_err(std::io::Error::other)?; - let ResponseResult::PluginLinked { plugin } = parsed.result else { - return Err(std::io::Error::other("expected plugin_linked response")); - }; - if plugin.plugin_id != plugin_id - || plugin.source.kind != PluginSourceKind::Github - || plugin.source.owner != expected.owner - || plugin.source.repo != expected.repo - || plugin.source.subdir != expected.subdir - || plugin.source.requested_ref != expected.requested_ref - || plugin.source.resolved_commit != expected.resolved_commit - || plugin.source.managed_path != expected.managed_path - { - return Err(std::io::Error::other( - "running Herdr server did not persist GitHub plugin source metadata", - )); + let (updated, _) = crate::persist::plugin_registry::update(|plugins| { + let Some(current) = plugins + .iter_mut() + .find(|entry| entry.plugin_id == expected.plugin_id) + else { + return false; + }; + if !same_update_target(expected, current) { + return false; + } + let enabled = current.enabled; + *current = plugin.clone(); + current.enabled = enabled; + true + })?; + if updated { + Ok(()) + } else { + Err(io::Error::other(format!( + "plugin {} changed while its update was in progress; retry the update", + expected.plugin_id + ))) } - Ok(()) } fn installed_plugin_info(plugin_id: &str) -> std::io::Result> { @@ -1121,14 +1273,22 @@ fn plugin_by_github_source( fn plugin_matches_github_source(plugin: &InstalledPluginInfo, source: &GithubPluginSource) -> bool { plugin.source.kind == PluginSourceKind::Github - && plugin.source.owner.as_deref() == Some(source.owner.as_str()) - && plugin.source.repo.as_deref() == Some(source.repo.as_str()) + && plugin + .source + .owner + .as_deref() + .is_some_and(|owner| owner.eq_ignore_ascii_case(&source.owner)) + && plugin + .source + .repo + .as_deref() + .is_some_and(|repo| repo.eq_ignore_ascii_case(&source.repo)) && plugin.source.subdir.as_deref() == source.subdir.as_deref() } fn offline_plugin_link_response(params: &PluginLinkParams) -> std::io::Result { let plugin = load_cli_plugin_manifest(Path::new(¶ms.path), params.enabled)?; - persist_plugin_offline(&plugin)?; + persist_plugin_offline(&plugin, false)?; serde_json::to_value(SuccessResponse { id: "cli:plugin".into(), result: ResponseResult::PluginLinked { plugin }, @@ -1233,8 +1393,12 @@ fn print_install_preview( plugin: &InstalledPluginInfo, source: &PluginSourceInfo, existing: Option<&InstalledPluginInfo>, + updating: bool, ) { - eprintln!("Plugin install preview:"); + eprintln!( + "Plugin {} preview:", + if updating { "update" } else { "install" } + ); eprintln!(" id: {}", plugin.plugin_id); eprintln!(" name: {}", plugin.name); eprintln!(" version: {}", plugin.version); @@ -1467,8 +1631,7 @@ impl fmt::Display for PluginBuildFailure { write_output_section(f, "stdout", stdout)?; } } - writeln!(f)?; - write!(f, "Plugin was not installed.") + Ok(()) } } @@ -1595,49 +1758,19 @@ fn create_plugin_temp_dir(label: &str) -> std::io::Result { Ok(path) } -fn remove_managed_plugin_files(plugin: &InstalledPluginInfo) -> std::io::Result<()> { - if plugin.source.kind != PluginSourceKind::Github { - return Ok(()); - } - let Some(path) = plugin.source.managed_path.as_deref() else { - return Ok(()); - }; - let path = PathBuf::from(path); - if !path.exists() { - return Ok(()); - } - if !is_expected_managed_path(plugin, &path) { - return Err(std::io::Error::other(format!( - "refusing to delete unmanaged plugin path: {}", - path.display() - ))); - } - std::fs::remove_dir_all(&path) - .map_err(|err| plugin_checkout_lifecycle_error("remove", &path, err)) -} - -fn plugin_checkout_lifecycle_error(operation: &str, path: &Path, err: io::Error) -> io::Error { - if cfg!(windows) && err.kind() == io::ErrorKind::PermissionDenied { - return io::Error::new( - err.kind(), - format!( - "failed to {operation} managed plugin checkout at {}; close any Herdr plugin panes or plugin commands using that checkout, then retry: {err}", - path.display() - ), - ); +fn lock_managed_checkout(plugin_id: &str) -> std::io::Result { + let lock_path = crate::plugin_paths::managed_checkout_lock_path(plugin_id); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; } - err -} - -fn is_expected_managed_path(plugin: &InstalledPluginInfo, path: &Path) -> bool { - let Ok(path) = path.canonicalize() else { - return false; - }; - let expected = crate::plugin_paths::managed_checkout_path(&plugin.plugin_id); - let Ok(expected) = expected.canonicalize() else { - return false; - }; - path == expected + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(lock_path)?; + lock.lock()?; + Ok(lock) } fn current_unix_ms() -> u64 { @@ -1676,6 +1809,7 @@ fn print_plugin_response(method: Method) -> std::io::Result { fn print_plugin_help() { eprintln!("herdr plugin commands:"); eprintln!(" herdr plugin install /[/subdir...] [--ref REF] [--yes]"); + eprintln!(" herdr plugin update [...] [--yes]"); eprintln!(" herdr plugin uninstall "); eprintln!(" herdr plugin link [--disabled]"); eprintln!(" herdr plugin list [--plugin ID] [--json]"); @@ -1895,6 +2029,22 @@ mod tests { assert!(plugin_by_github_source(plugins, &source).is_none()); } + #[test] + fn github_source_lookup_ignores_owner_and_repo_case() { + let source = GithubPluginSource::parse("OGULCANCELIK/HERDR-PLUGIN-EXAMPLES").unwrap(); + let plugins = vec![github_plugin( + "examples.root", + "ogulcancelik", + "herdr-plugin-examples", + None, + )]; + + assert_eq!( + plugin_by_github_source(plugins, &source).unwrap().plugin_id, + "examples.root" + ); + } + #[test] fn github_source_lookup_ignores_local_plugins() { let source = GithubPluginSource::parse("ogulcancelik/herdr-plugin-examples").unwrap(); @@ -1909,6 +2059,24 @@ mod tests { assert!(plugin_by_github_source([plugin], &source).is_none()); } + #[test] + fn update_target_revalidation_allows_state_changes_but_rejects_install_changes() { + let expected = github_plugin("examples.root", "owner", "repo", None); + let mut current = expected.clone(); + current.enabled = false; + assert!(ensure_update_target_unchanged(&expected, Some(¤t)).is_ok()); + + current.source.resolved_commit = Some("newer".to_string()); + assert!(ensure_update_target_unchanged(&expected, Some(¤t)).is_err()); + current = expected.clone(); + current.source = PluginSourceInfo::default(); + assert!(ensure_update_target_unchanged(&expected, Some(¤t)).is_err()); + current = expected.clone(); + current.manifest_path.push_str(".reinstalled"); + assert!(ensure_update_target_unchanged(&expected, Some(¤t)).is_err()); + assert!(ensure_update_target_unchanged(&expected, None).is_err()); + } + #[test] fn cli_user_dir_creation_seeds_legacy_config_before_printing_config_dir() { let plugin_id = unique_plugin_id("legacy-config"); diff --git a/src/cli/server.rs b/src/cli/server.rs index 2045f420..657a5db8 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -1,6 +1,11 @@ use crate::api::schema::{EmptyParams, Method, Request, ServerLiveHandoffParams}; pub(super) fn run_server_command(args: &[String]) -> std::io::Result> { + #[cfg(windows)] + if args == ["--allow-unelevated-clients"] { + crate::platform::allow_unelevated_clients(); + return Ok(None); + } let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { return Ok(None); }; @@ -259,6 +264,8 @@ fn parse_live_handoff_params(args: &[String]) -> Option fn print_server_help() { eprintln!("herdr server commands:"); eprintln!(" herdr server run as headless server"); + #[cfg(windows)] + eprintln!(" herdr server --allow-unelevated-clients allow ordinary same-account clients to control an elevated server"); eprintln!(" herdr server stop stop the running server via the API socket"); eprintln!(" herdr server live-handoff hand off live panes to a new local server"); eprintln!(" herdr server reload-config reload config.toml in the running server"); diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 1cfc2e7f..9a503a18 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -157,7 +157,7 @@ fn channel_command() -> Command { } fn server_command() -> Command { - Command::new("server") + let command = Command::new("server") .about("Run or control the headless server") .subcommand(Command::new("stop").about("Stop the running server")) .subcommand(Command::new("reload-config").about("Reload config in the running server")) @@ -174,7 +174,13 @@ fn server_command() -> Command { .subcommand( Command::new("reload-agent-manifests") .about("Reload local agent detection manifest overrides"), - ) + ); + #[cfg(windows)] + let command = command.arg( + flag("allow-unelevated-clients") + .help("Allow ordinary same-account clients to control this elevated server"), + ); + command } fn api_command() -> Command { @@ -801,6 +807,17 @@ fn plugin_command() -> Command { .about("Uninstall a plugin") .arg(required("plugin", "PLUGIN")), ) + .subcommand( + Command::new("update") + .about("Update GitHub-installed plugins") + .arg(Arg::new("plugins").value_name("PLUGIN").num_args(0..)) + .arg( + Arg::new("yes") + .short('y') + .long("yes") + .action(ArgAction::SetTrue), + ), + ) .subcommand( Command::new("link") .about("Link a local plugin") diff --git a/src/client/attach.rs b/src/client/attach.rs index 2cabe5bb..9a9e3fde 100644 --- a/src/client/attach.rs +++ b/src/client/attach.rs @@ -153,7 +153,7 @@ impl AttachEscapeState { #[cfg(unix)] fn single_attach_key(data: &[u8]) -> Option { - let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + let mut events = crate::raw_input::parse_framed_input(data); if events.len() != 1 { return None; } @@ -172,19 +172,17 @@ pub(super) fn direct_attach_pixel_mouse( crate::protocol::ClientMousePosition, u8, )> { - let (x, y) = crate::input::mouse::parse_report(data)?; - let (column, row) = geometry.cell(x, y)?; - let cell_report = crate::input::mouse::report_at_cell(data, column, row)?; - let mut events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); - if events.len() != 1 { - return None; - } - let crate::raw_input::RawInputEvent::Mouse(mouse) = events.pop()? else { - return None; - }; + let report = crate::raw_input::parse_sgr_mouse_report(data)?; + let (column, row) = geometry.cell(report.x, report.y)?; + let mouse = report.at_cell(column, row); Some(( crate::protocol::ClientMouseKind::from_crossterm(mouse.kind)?, - crate::protocol::ClientMousePosition::Pixels { x, y, column, row }, + crate::protocol::ClientMousePosition::Pixels { + x: report.x, + y: report.y, + column, + row, + }, mouse.modifiers.bits(), )) } @@ -195,7 +193,7 @@ fn attach_scroll_action( viewport_rows: u16, mouse_scroll_lines: usize, ) -> Option { - let mut events = crate::raw_input::parse_raw_input_bytes_sync(data); + let mut events = crate::raw_input::parse_framed_input(data); if events.len() != 1 { return None; } diff --git a/src/client/catalog_reload.rs b/src/client/catalog_reload.rs index 89715f7d..6f982c99 100644 --- a/src/client/catalog_reload.rs +++ b/src/client/catalog_reload.rs @@ -71,6 +71,7 @@ pub(super) fn apply_profiles( }) { catalog.select_local(); } + let pixel_mouse_available = state.pixel_mouse_available(); if let Some(shell) = state.shell.as_mut() { shell.set_endpoint_catalog(&catalog.ssh); if endpoints.active_surface_available() @@ -84,7 +85,7 @@ pub(super) fn apply_profiles( state.reported_size.1, state.reported_cell_size.0, state.reported_cell_size.1, - state.pixel_geometry_exact, + pixel_mouse_available, )); } } diff --git a/src/client/clipboard_forwarding.rs b/src/client/clipboard_forwarding.rs index 2de5f49f..f3ba282b 100644 --- a/src/client/clipboard_forwarding.rs +++ b/src/client/clipboard_forwarding.rs @@ -11,6 +11,5 @@ pub(super) fn forward_clipboard(data: &str) -> bool { warn!("received invalid clipboard payload from server"); return false; }; - crate::selection::write_osc52_bytes(&bytes); - true + crate::selection::write_osc52_bytes(&bytes) } diff --git a/src/client/clipboard_images.rs b/src/client/clipboard_images.rs index 1faebf39..cd2f1480 100644 --- a/src/client/clipboard_images.rs +++ b/src/client/clipboard_images.rs @@ -79,7 +79,7 @@ pub(super) fn should_bridge_clipboard_image_paste( return false; }; - let events = crate::raw_input::parse_raw_input_bytes_sync(data); + let events = crate::raw_input::parse_framed_input(data); matches!( events.as_slice(), [crate::raw_input::RawInputEvent::Key(key)] diff --git a/src/client/config_reload.rs b/src/client/config_reload.rs index c20de760..696158fc 100644 --- a/src/client/config_reload.rs +++ b/src/client/config_reload.rs @@ -40,7 +40,7 @@ pub(super) fn apply_reload( let sgr_pixels = effective_sgr_pixel_mouse( enabled, state.endpoint_sgr_pixels_requested, - state.pixel_geometry_exact, + state.pixel_mouse_available(), ); if enabled != state.mouse_capture_active || sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire) @@ -51,6 +51,7 @@ pub(super) fn apply_reload( host_mouse_capture_active.store(enabled, Ordering::Release); host_sgr_pixels_active.store(sgr_pixels, Ordering::Release); } + let pixel_mouse_available = state.pixel_mouse_available(); let (frame, resize) = if let Some(shell) = state.shell.as_mut() { let previous_size = shell.surface_size(state.reported_size.0, state.reported_size.1); shell.reload_client_config(); @@ -63,7 +64,7 @@ pub(super) fn apply_reload( state.reported_size.1, state.reported_cell_size.0, state.reported_cell_size.1, - state.pixel_geometry_exact, + pixel_mouse_available, ) }); ( diff --git a/src/client/endpoint/activation.rs b/src/client/endpoint/activation.rs index d0c195e9..aa74d531 100644 --- a/src/client/endpoint/activation.rs +++ b/src/client/endpoint/activation.rs @@ -601,6 +601,11 @@ impl PendingEndpointActivation { focus: Option, endpoints: &mut EndpointRegistry, ) -> Result<(), String> { + #[cfg(windows)] + if matches!(&focus, Some(crate::client::shell::ClientEndpointFocusTarget::Notification { boot_id, .. }) if boot_id != &self.target.boot_id) + { + return Ok(()); + } self.focus = focus; if let ActivationPhase::ActivatingTarget { focus_request_id, @@ -1193,6 +1198,16 @@ impl PendingEndpointActivation { _ => return false, }; match &self.focus { + #[cfg(windows)] + Some(crate::client::shell::ClientEndpointFocusTarget::Notification { + pane_id, .. + }) => { + evidence.focused_pane_id.as_deref() == Some(pane_id) + && surface + .panes + .iter() + .any(|pane| pane.focused && &pane.pane_id == pane_id) + } Some(crate::client::shell::ClientEndpointFocusTarget::Pane(pane_id)) => { evidence.focused_pane_id.as_deref() == Some(pane_id) && surface diff --git a/src/client/endpoint/activation/protocol.rs b/src/client/endpoint/activation/protocol.rs index 569d53f3..707dd0e3 100644 --- a/src/client/endpoint/activation/protocol.rs +++ b/src/client/endpoint/activation/protocol.rs @@ -10,6 +10,14 @@ pub(super) fn focus_result_matches( Some(crate::client::shell::ClientEndpointFocusTarget::Pane(expected)), crate::api::schema::ResponseResult::PaneInfo { pane }, ) => pane.focused && &pane.pane_id == expected, + #[cfg(windows)] + ( + Some(crate::client::shell::ClientEndpointFocusTarget::Notification { + pane_id: expected, + .. + }), + crate::api::schema::ResponseResult::PaneInfo { pane }, + ) => pane.focused && &pane.pane_id == expected, ( Some(crate::client::shell::ClientEndpointFocusTarget::Workspace(expected)), crate::api::schema::ResponseResult::WorkspaceInfo { workspace }, @@ -166,6 +174,21 @@ pub(super) fn focus_request( focus: &crate::client::shell::ClientEndpointFocusTarget, ) -> std::io::Result { let method = match focus { + #[cfg(windows)] + crate::client::shell::ClientEndpointFocusTarget::Notification { + pane_id, + boot_id: expected, + } => { + if boot_id != expected { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "notification target restarted", + )); + } + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id: pane_id.clone(), + }) + } crate::client::shell::ClientEndpointFocusTarget::Workspace(workspace_id) => { crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { workspace_id: workspace_id.clone(), diff --git a/src/client/endpoint/activation_tests.rs b/src/client/endpoint/activation_tests.rs index 7201940a..3cece787 100644 --- a/src/client/endpoint/activation_tests.rs +++ b/src/client/endpoint/activation_tests.rs @@ -1164,6 +1164,19 @@ fn local_selection_abandons_every_unfinished_remote_handoff_phase() { let mut state = ClientState::test_new(); state.shell = Some(shell); let mut commands = EndpointCommands::default(); + commands.enqueue( + ClientEndpointId::Local, + 1, + "local-boot".into(), + Box::new(crate::api::schema::Request { + id: "abandoned-workspace-focus".into(), + method: crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: "stale-workspace".into(), + }, + ), + }), + ); let mut pending = Some(abandoned); let mut serial = 31; let mut scheduled = None; @@ -1187,6 +1200,14 @@ fn local_selection_abandons_every_unfinished_remote_handoff_phase() { assert_eq!(local.target(), &ClientEndpointId::Local); assert!(!endpoints.active_surface_available()); assert!(state.presentation_frozen); + assert!(commands + .send_next(&ClientEndpointId::Local, &mut endpoints) + .is_empty()); + assert!(!local_sent.lock().unwrap().iter().any(|message| { + matches!(message, crate::protocol::ClientMessage::ClientShellEndpointRequest { request, .. } + if serde_json::from_str::(request) + .is_ok_and(|request| request.id == "abandoned-workspace-focus")) + }), "a fresh activation must discard focus retained by an older target epoch"); let activations = local_sent .lock() .unwrap() @@ -1370,6 +1391,98 @@ fn newer_remote_selection_cancels_deferred_local_selection() { assert_eq!(pending.as_ref().unwrap().target(), &endpoint()); } +#[cfg(windows)] +#[test] +fn notification_successor_replay_rejects_a_restarted_destination() { + use crate::client::shell::ClientEndpointFocusTarget; + use crate::client::{ + endpoint_commands::EndpointCommands, shell_runtime::begin_endpoint_activation, ClientState, + }; + for destination_boot in ["local-boot", "replacement-boot"] { + let (shell, mut endpoints, local_sent, remote_sent) = shell_and_registry(); + let mut activation = PendingEndpointActivation::begin( + &shell, + &mut endpoints, + endpoint(), + None, + resize(), + 70, + Instant::now(), + ) + .unwrap(); + activation.receive_response( + &ClientEndpointId::Local, + 1, + "client-shell-surface:70:off", + &surface_success("client-shell-surface:70:off", false, 1), + &mut endpoints, + ); + activation.supersede( + ClientEndpointId::Local, + Some(ClientEndpointFocusTarget::Notification { + pane_id: "pane_1".into(), + boot_id: "local-boot".into(), + }), + &mut endpoints, + ); + let intent = activation + .successor + .take() + .expect("notification queued during handoff"); + let mut state = ClientState::test_new(); + state.shell = Some(shell); + let mut snapshot = test_snapshot(destination_boot, 2); + snapshot.panes.push(crate::protocol::ClientShellPane { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: None, + foreground_cwd: None, + focused: false, + right_click_passthrough: false, + }); + state + .shell + .as_mut() + .unwrap() + .set_snapshot(Box::new(snapshot)); + // Model restoration completing before the retained intent is replayed. + endpoints.set_surface_active(&ClientEndpointId::Local, true); + endpoints.unfreeze_input(); + local_sent.lock().unwrap().clear(); + remote_sent.lock().unwrap().clear(); + let mut pending = None; + let mut commands = EndpointCommands::default(); + begin_endpoint_activation( + &mut state, + &mut endpoints, + &mut commands, + &mut pending, + &mut 71, + intent.endpoint_id, + intent.target, + true, + Instant::now(), + &mut None, + ) + .unwrap(); + if destination_boot == "replacement-boot" { + assert!(pending.is_none()); + assert!( + local_sent.lock().unwrap().is_empty(), + "stale successor must emit no navigation or surface write" + ); + assert!(remote_sent.lock().unwrap().is_empty()); + } else { + assert!( + pending.is_some(), + "same boot remains eligible after handoff" + ); + } + } +} + #[test] fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() { let (shell, mut endpoints, _local_sent, _remote_sent) = shell_and_registry(); @@ -1423,6 +1536,101 @@ fn rollback_keeps_the_latest_intent_even_when_it_returns_to_the_target() { ); } +#[cfg(windows)] +#[test] +fn notification_click_on_remote_source_supersedes_a_handoff() { + use crate::client::shell::{ClientEndpointFocusTarget, ClientSystemNotificationTarget}; + use crate::client::{ + endpoint_commands::EndpointCommands, + events::ClientLoopEvent, + shell_runtime::{begin_endpoint_activation, dispatch_client_shell_actions}, + ClientState, + }; + let (mut shell, mut endpoints, _, _) = shell_and_registry(); + let remote = endpoint(); + let mut snapshot = test_snapshot("remote-boot", 2); + snapshot.panes.push(crate::protocol::ClientShellPane { + pane_id: "pane_1".into(), + workspace_id: "ws_1".into(), + tab_id: "tab_1".into(), + label: None, + cwd: None, + foreground_cwd: None, + focused: false, + right_click_passthrough: false, + }); + shell.set_endpoint_snapshot(&remote, Box::new(snapshot)); + endpoints.set_surface_active(&ClientEndpointId::Local, false); + endpoints.set_surface_active(&remote, true); + assert!(endpoints.set_active(&remote)); + assert!(shell.activate_endpoint_projection(&remote)); + let mut pending = Some( + PendingEndpointActivation::begin( + &shell, + &mut endpoints, + ClientEndpointId::Local, + None, + resize(), + 80, + Instant::now(), + ) + .unwrap(), + ); + assert!( + !endpoints.active_surface_available(), + "source is inactive during handoff" + ); + let outcome = shell.activate_system_notification(ClientSystemNotificationTarget { + endpoint_id: remote.clone(), + boot_id: "remote-boot".into(), + pane_id: "pane_1".into(), + }); + let mut commands = EndpointCommands::default(); + let mut scheduled = None; + dispatch_client_shell_actions( + outcome.actions, + &mut commands, + &mut endpoints, + Some(&mut shell), + &mut Vec::new(), + &mut scheduled, + ) + .unwrap(); + let ClientLoopEvent::ActivateEndpoint { + endpoint_id, + target, + force, + } = scheduled.expect("click must reach the handoff manager") + else { + panic!("activation expected"); + }; + let mut state = ClientState::test_new(); + state.shell = Some(shell); + begin_endpoint_activation( + &mut state, + &mut endpoints, + &mut commands, + &mut pending, + &mut 81, + endpoint_id, + target, + force, + Instant::now(), + &mut None, + ) + .unwrap(); + assert_eq!( + pending.unwrap().successor, + Some(EndpointActivationIntent { + endpoint_id: remote, + target: Some(ClientEndpointFocusTarget::Notification { + pane_id: "pane_1".into(), + boot_id: "remote-boot".into() + }), + }) + ); +} + #[test] fn unacknowledged_target_release_closes_target_before_restoring_source() { let (shell, mut endpoints, local_sent, _remote_sent) = shell_and_registry(); diff --git a/src/client/endpoint/supervisor.rs b/src/client/endpoint/supervisor.rs index 5b4e7ad1..efad076b 100644 --- a/src/client/endpoint/supervisor.rs +++ b/src/client/endpoint/supervisor.rs @@ -20,6 +20,7 @@ pub(crate) struct EndpointConnectOptions { pub(crate) cell_width_px: u32, pub(crate) cell_height_px: u32, pub(crate) pixel_geometry_exact: bool, + pub(crate) pixel_mouse: bool, pub(crate) surface_size: ClientSurfaceSize, pub(crate) endpoint_keybindings: bool, pub(crate) mouse_capture: bool, @@ -298,7 +299,10 @@ fn connect_once( options.rows, options.cell_width_px, options.cell_height_px, - options.pixel_geometry_exact, + super::super::HandshakePixelCapabilities { + exact_geometry: options.pixel_geometry_exact, + sgr_mouse: options.pixel_mouse, + }, Some(options.surface_size), options.endpoint_keybindings, options.mouse_capture, diff --git a/src/client/events.rs b/src/client/events.rs index 43f7b8ad..dc4415bd 100644 --- a/src/client/events.rs +++ b/src/client/events.rs @@ -10,6 +10,8 @@ pub(super) enum ClientLoopEvent { DirectGraphicsResponse(direct_graphics::Response), #[cfg(windows)] StdinEvents(Vec), + #[cfg(windows)] + NotificationActivated(shell::ClientSystemNotificationTarget), Resize(u16, u16, u32, u32, bool), TerminalUnavailable(io::Error), ServerMessage { diff --git a/src/client/handshake.rs b/src/client/handshake.rs index 2520286c..75241ad1 100644 --- a/src/client/handshake.rs +++ b/src/client/handshake.rs @@ -144,7 +144,10 @@ pub(crate) fn probe_endpoint_negotiation( 24, 0, 0, - false, + HandshakePixelCapabilities { + exact_geometry: false, + sgr_mouse: false, + }, Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }), false, false, @@ -158,6 +161,11 @@ pub(crate) fn probe_endpoint_negotiation( )) } +pub(super) struct HandshakePixelCapabilities { + pub(super) exact_geometry: bool, + pub(super) sgr_mouse: bool, +} + /// Performs the client→server handshake. /// /// Direct terminal clients retain the same-install private protocol. Client-owned @@ -170,7 +178,7 @@ pub(super) fn do_handshake( rows: u16, cell_width_px: u32, cell_height_px: u32, - exact_cell_size: bool, + pixel_capabilities: HandshakePixelCapabilities, shell_surface_size: Option, endpoint_keybindings: bool, mouse_capture: bool, @@ -188,10 +196,10 @@ pub(super) fn do_handshake( cell_width_px, cell_height_px, surface_size, - pixel_mouse: exact_cell_size && cfg!(unix), + pixel_mouse: pixel_capabilities.exact_geometry && pixel_capabilities.sgr_mouse, direct_graphics: direct_graphics_capability( local_transport, - exact_cell_size, + pixel_capabilities.exact_geometry, (cell_width_px, cell_height_px), direct_graphics_profile_allowed(), ), @@ -219,7 +227,7 @@ pub(super) fn do_handshake( rows, cell_width_px, cell_height_px, - pixel_mouse: exact_cell_size && cfg!(unix), + pixel_mouse: pixel_capabilities.exact_geometry && pixel_capabilities.sgr_mouse, } }; protocol::write_message(stream, &hello) diff --git a/src/client/input.rs b/src/client/input.rs index 08254155..023596aa 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -10,21 +10,21 @@ //! - We avoid duplicating parsing logic in the client //! - Host terminal control replies can be buffered or discarded before they leak -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +#[cfg(unix)] +use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, AtomicU32}; use std::sync::Arc; #[cfg(unix)] use std::io::{self, Read}; #[cfg(unix)] use std::os::fd::AsRawFd; -#[cfg(windows)] -use std::time::Duration; use tokio::sync::mpsc; use super::ClientLoopEvent; #[cfg(any(windows, test))] -mod windows_vti; +pub(super) mod windows_vti; // --------------------------------------------------------------------------- // Stdin reader thread @@ -126,7 +126,7 @@ fn unix_stdin_reader_loop( if (framer.has_pending_input() || !pending_palette.is_empty()) && stdin_read_ready( &reader, - idle_flush_timeout_ms(&framer, host_mouse_capture_active.load(Ordering::Acquire)), + framer.idle_flush_timeout_ms(host_mouse_capture_active.load(Ordering::Acquire)), ) == Some(false) { let had_pending = framer.has_pending_input(); @@ -228,10 +228,8 @@ fn unix_stdin_reader_loop( return; } - let timeout_ms = idle_flush_timeout_ms( - &framer, - host_mouse_capture_active.load(Ordering::Acquire), - ); + let timeout_ms = + framer.idle_flush_timeout_ms(host_mouse_capture_active.load(Ordering::Acquire)); if stdin_read_ready(&reader, timeout_ms) == Some(false) { let had_pending = framer.has_pending_input(); let chunks = framer.flush_timeout(); @@ -351,7 +349,7 @@ fn classify_unix_input( sgr_pixels: bool, geometry: Option, ) -> Option { - if sgr_pixels && crate::input::mouse::parse_report(&data).is_some() { + if sgr_pixels && crate::raw_input::parse_sgr_mouse_report(&data).is_some() { return geometry.map(|geometry| ClientLoopEvent::PixelMouse(data, geometry)); } Some(ClientLoopEvent::StdinInput(data)) @@ -371,238 +369,26 @@ fn flush_unix_palette_input( .is_ok() } -#[cfg(unix)] -fn idle_flush_timeout_ms( - framer: &crate::raw_input::RawInputByteFramer, - host_mouse_capture_active: bool, -) -> i32 { - if !host_mouse_capture_active { - return crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS; - } - if framer.has_pending_lone_escape() || framer.has_pending_incomplete_mouse_sequence() { - crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS - } else if framer.has_pending_csi_introducer() { - // A mouse report split after ESC[ is still ambiguous with legacy Alt+[. - crate::raw_input::MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS - } else { - crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS - } -} - #[cfg(windows)] fn windows_stdin_reader_loop( event_tx: mpsc::Sender, should_quit: &Arc, host_color_query_sent: bool, ) { - if !super::windows_vti_input_backend_enabled() { - windows_vti::trace_input_transport("reader=crossterm"); - windows_crossterm_reader_loop(event_tx, should_quit); - } else { - match windows_vti::console_input_handle() { - Ok(handle) => { - windows_vti::trace_input_transport("reader=windows-console"); - windows_vti::raw_console_reader_loop( - handle, - event_tx, - should_quit, - host_color_query_sent, - ); - } - _ => { - windows_vti::trace_input_transport("reader=crossterm-fallback"); - windows_crossterm_reader_loop(event_tx, should_quit); - } - } - } -} - -#[cfg(windows)] -fn windows_crossterm_reader_loop( - event_tx: mpsc::Sender, - should_quit: &Arc, -) { - let mut framer = crate::raw_input::RawInputFramer::for_host_input(); - - while !should_quit.load(Ordering::Acquire) { - match crossterm::event::poll(Duration::from_millis(10)) { - Ok(true) => {} - Ok(false) => { - if framer.has_pending_input() { - tracing::debug!("windows input raw sequence timed out; flushing"); - if !send_windows_raw_events(framer.flush_timeout(), &event_tx) { - return; - } - } - continue; - } - Err(_) => break, - } - - let event = match crossterm::event::read() { - Ok(event) => event, - Err(_) => break, - }; - - let (raw_events, event) = frame_windows_crossterm_event(&mut framer, event); - if !send_windows_raw_events(raw_events, &event_tx) { - return; - } - let Some(event) = event else { - continue; - }; - if event_tx - .blocking_send(ClientLoopEvent::StdinEvents(vec![event])) - .is_err() - { - return; - } - } - - if framer.has_pending_input() { - let _ = send_windows_raw_events(framer.flush_interrupted(), &event_tx); - } -} - -#[cfg(any(windows, test))] -fn frame_windows_crossterm_event( - framer: &mut crate::raw_input::RawInputFramer, - event: crossterm::event::Event, -) -> ( - Vec, - Option, -) { - let raw_sequence_pending = framer.has_pending_input(); - if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { - tracing::debug!( - bytes = ?bytes, - pending_before = raw_sequence_pending, - "windows input routed through raw framer" - ); - return (framer.push(&bytes), None); - } - - if windows_event_is_control_key(&event) { - tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); - } - let Some(event) = windows_crossterm_input_event(event) else { - // Preserve the existing flush for unrelated pending input, but do not - // cancel mouse recovery for an event that will not be forwarded. - return ( - if raw_sequence_pending { - framer.flush_timeout() - } else { - Vec::new() - }, - None, - ); - }; - if raw_sequence_pending { - tracing::debug!("windows input raw sequence interrupted by semantic event; flushing"); - } - // Even a dormant mouse prefix must stop claiming bytes after semantic input. - (framer.flush_interrupted(), Some(event)) -} - -#[cfg(any(windows, test))] -fn windows_crossterm_input_event( - event: crossterm::event::Event, -) -> Option { - let event = crate::protocol::ClientInputEvent::from_crossterm(event)?; - match event { - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char(codepoint), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - source, - .. - } => Some(crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char(codepoint), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - generated_text: Some(codepoint.to_string()), - source, - }), - event => Some(event), - } -} - -#[cfg(any(windows, test))] -fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool { - use crossterm::event::{Event, KeyModifiers}; - - matches!( - event, - Event::Key(key) - if key.modifiers.contains(KeyModifiers::CONTROL) - || matches!(key.code, crossterm::event::KeyCode::Char(ch) if ch.is_control()) - ) -} - -#[cfg(any(windows, test))] -fn windows_key_raw_bytes( - event: &crossterm::event::Event, - raw_sequence_pending: bool, -) -> Option> { - use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; - - let Event::Key(key) = event else { - return None; - }; - if key.kind == KeyEventKind::Release { - return None; - } - - match key.code { - KeyCode::Esc if key.modifiers.is_empty() => Some(vec![0x1b]), - KeyCode::Char('[') if !raw_sequence_pending && key.modifiers == KeyModifiers::CONTROL => { - Some(vec![0x1b]) - } - KeyCode::Char(ch) - if !raw_sequence_pending - && matches!(ch, 'i' | 'I') - && key.modifiers.contains(KeyModifiers::CONTROL) - && !key.modifiers.contains(KeyModifiers::ALT) => - { - let mut buf = [0; 4]; - Some(ch.encode_utf8(&mut buf).as_bytes().to_vec()) + match windows_vti::console_input_handle() { + Ok(handle) => { + windows_vti::trace_input_transport("reader=windows-console"); + windows_vti::raw_console_reader_loop( + handle, + event_tx, + should_quit, + host_color_query_sent, + ); } - KeyCode::Char(ch) if raw_sequence_pending || ch.is_control() => { - let mut bytes = Vec::new(); - if key.modifiers.contains(KeyModifiers::ALT) { - bytes.push(0x1b); - } - let mut buf = [0; 4]; - bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); - Some(bytes) + Err(err) => { + tracing::error!(%err, "no Windows console input available; keyboard input is disabled"); } - _ => None, - } -} - -#[cfg(windows)] -fn send_windows_raw_events( - events: Vec, - event_tx: &mpsc::Sender, -) -> bool { - let raw_event_count = events.len(); - let events = events - .into_iter() - .filter_map(windows_client_input_event_from_raw) - .collect::>(); - if events.is_empty() { - return true; } - - tracing::debug!( - raw_event_count, - forwarded_event_count = events.len(), - "windows raw-framed input events forwarded" - ); - event_tx - .blocking_send(ClientLoopEvent::StdinEvents(events)) - .is_ok() } #[cfg(any(windows, test))] @@ -726,6 +512,12 @@ mod tests { }; assert_eq!(data, report); assert_eq!(captured, geometry); + let Some(ClientLoopEvent::StdinInput(cell_report)) = + classify_unix_input(report.clone(), false, Some(geometry)) + else { + panic!("unconfirmed host pixel mode must keep cell reports on the raw path"); + }; + assert_eq!(cell_report, report); assert!(classify_unix_input(report, true, None).is_none()); for raw in [ @@ -792,7 +584,7 @@ mod tests { let events = framer.push(b"\x1b\x1b"); assert_eq!(events.len(), 1); - assert!(framer.has_pending_input()); + // The second escape is still pending until the idle flush. assert_eq!(framer.flush_timeout().len(), 1); } @@ -805,7 +597,7 @@ mod tests { next: &[u8], ) -> Vec> { let first_wait = - std::time::Duration::from_millis(idle_flush_timeout_ms(framer, mouse_capture) as u64); + std::time::Duration::from_millis(framer.idle_flush_timeout_ms(mouse_capture) as u64); let mut chunks = Vec::new(); if gap >= first_wait { chunks.extend(framer.flush_timeout()); @@ -1058,7 +850,7 @@ mod tests { } #[test] - fn mouse_active_escape_sequences_get_longer_reassembly_window() { + fn only_ambiguous_escape_prefixes_get_the_short_keyboard_window() { let mut escape = crate::raw_input::RawInputByteFramer::default(); assert!(escape.push(b"\x1b").is_empty()); let mut csi = crate::raw_input::RawInputByteFramer::default(); @@ -1070,26 +862,30 @@ mod tests { let mut unrelated = crate::raw_input::RawInputByteFramer::default(); assert!(unrelated.push(b"\x1b[49:33;2:").is_empty()); - for framer in [&escape, &csi, &sgr_mouse, &default_mouse, &unrelated] { + // A lone ESC or ESC[ may still be a key (Escape, Alt+[). + for framer in [&escape, &csi] { assert_eq!( - idle_flush_timeout_ms(framer, false), + framer.idle_flush_timeout_ms(false), crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS ); } - for framer in [&escape, &sgr_mouse, &default_mouse] { - assert_eq!( - idle_flush_timeout_ms(framer, true), - crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS - ); - } assert_eq!( - idle_flush_timeout_ms(&csi, true), - crate::raw_input::MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS + escape.idle_flush_timeout_ms(true), + crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS ); assert_eq!( - idle_flush_timeout_ms(&unrelated, true), - crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + csi.idle_flush_timeout_ms(true), + crate::raw_input::MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS ); + // Anything further inside a sequence cannot be a key: wait for the rest. + for framer in [&sgr_mouse, &default_mouse, &unrelated] { + for mouse_capture in [false, true] { + assert_eq!( + framer.idle_flush_timeout_ms(mouse_capture), + crate::raw_input::INCOMPLETE_SEQUENCE_FLUSH_TIMEOUT_MS + ); + } + } let mouse_timeout_ms = std::hint::black_box(crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS); @@ -1100,201 +896,6 @@ mod tests { #[cfg(test)] mod windows_tests { use super::*; - use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; - - #[test] - fn windows_control_chars_are_reframed_as_raw_bytes() { - let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())); - assert_eq!( - windows_key_raw_bytes(&escape, false).as_deref(), - Some(b"\x1b".as_slice()) - ); - - let enter = Event::Key(KeyEvent::new(KeyCode::Char('\r'), KeyModifiers::empty())); - assert_eq!( - windows_key_raw_bytes(&enter, false).as_deref(), - Some(b"\r".as_slice()) - ); - - let printable = Event::Key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty())); - assert_eq!(windows_key_raw_bytes(&printable, false), None); - - let pending_arrow_tail = - Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::empty())); - assert_eq!( - windows_key_raw_bytes(&pending_arrow_tail, true).as_deref(), - Some(b"[".as_slice()) - ); - } - - #[test] - fn windows_crossterm_semantic_input_cancels_dormant_and_buffered_mouse_recovery() { - for buffered in [false, true] { - let mut framer = crate::raw_input::RawInputFramer::for_host_input(); - assert!(framer.push(b"\x1b[<3").is_empty()); - assert!(framer.flush_timeout().is_empty()); - assert!(framer.flush_timeout().is_empty()); - if buffered { - assert!(framer.push(b"5").is_empty()); - } - // Printable input bypasses the raw route when no bytes are pending; - // an arrow bypasses it even when a continuation is buffered. - let key = if buffered { - KeyCode::Up - } else { - KeyCode::Char('x') - }; - let event = Event::Key(KeyEvent::new(key, KeyModifiers::empty())); - let (raw, semantic) = frame_windows_crossterm_event(&mut framer, event.clone()); - let raw_bytes: Vec<_> = raw - .into_iter() - .filter_map(|event| { - let crate::raw_input::RawInputEvent::Key(key) = event else { - return None; - }; - key.vt_bytes().map(ToOwned::to_owned) - }) - .collect(); - assert_eq!( - raw_bytes.concat(), - if buffered { b"5".as_slice() } else { b"" } - ); - assert_eq!(semantic, windows_crossterm_input_event(event)); - assert_eq!(framer.push(b"5;28;31M").len(), 8); - } - } - - #[test] - fn windows_crossterm_ignored_event_preserves_mouse_recovery() { - let mut framer = crate::raw_input::RawInputFramer::for_host_input(); - assert!(framer.push(b"\x1b[<3").is_empty()); - assert!(framer.flush_timeout().is_empty()); - assert!(framer.push(b"5").is_empty()); - let (raw, semantic) = frame_windows_crossterm_event(&mut framer, Event::Resize(80, 24)); - assert!(raw.is_empty()); - assert!(semantic.is_none()); - let events = framer.push(b";28;31Mx"); - assert!( - matches!(events.as_slice(), [crate::raw_input::RawInputEvent::Key(key)] - if key.code == KeyCode::Char('x')) - ); - assert!(!framer.has_pending_input()); - } - - #[test] - fn windows_crossterm_printable_press_keeps_key_semantics_and_text() { - let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty())); - - assert_eq!( - windows_crossterm_input_event(event), - Some(crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('你'), - modifiers: 0, - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - generated_text: Some("你".to_string()), - source: crate::protocol::ClientKeySource::Synthesized, - }) - ); - } - - #[test] - fn windows_ctrl_bracket_starts_raw_escape_sequence() { - let ctrl_bracket = Event::Key(KeyEvent::new(KeyCode::Char('['), KeyModifiers::CONTROL)); - assert_eq!( - windows_key_raw_bytes(&ctrl_bracket, false).as_deref(), - Some(b"\x1b".as_slice()) - ); - - let mut framer = crate::raw_input::RawInputFramer::default(); - assert!(framer.push(b"\x1b").is_empty()); - let events = framer.push(b"[<35;48;26M"); - assert_eq!(events.len(), 1); - - let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap()) - .expect("raw mouse converts"); - assert!(matches!( - event, - crate::protocol::ClientInputEvent::Mouse { - kind: crate::protocol::ClientMouseKind::Moved, - column: 47, - row: 25, - modifiers: _, - } - )); - } - - #[test] - fn windows_ctrl_shift_bracket_stays_semantic() { - let ctrl_shift_bracket = Event::Key(KeyEvent::new( - KeyCode::Char('['), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - )); - assert_eq!(windows_key_raw_bytes(&ctrl_shift_bracket, false), None); - } - - #[cfg(windows)] - #[test] - fn windows_ctrl_d_semantic_event_encodes_to_eot() { - let event = Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - assert_eq!(windows_key_raw_bytes(&event, false), None); - - let event = - crate::protocol::ClientInputEvent::from_crossterm(event).expect("ctrl-d converts"); - let raw = event.to_raw_input_event(); - let crate::raw_input::RawInputEvent::Key(key) = raw else { - panic!("expected key"); - }; - assert_eq!(key.code, KeyCode::Char('d')); - assert_eq!(key.modifiers, KeyModifiers::CONTROL); - assert_eq!( - crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), - b"\x04" - ); - } - - #[test] - fn windows_pasted_printable_ctrl_i_routes_as_literal_i() { - let event = Event::Key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::CONTROL)); - assert_eq!( - windows_key_raw_bytes(&event, false).as_deref(), - Some(b"i".as_slice()) - ); - - let event = Event::Key(KeyEvent::new( - KeyCode::Char('I'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - )); - assert_eq!( - windows_key_raw_bytes(&event, false).as_deref(), - Some(b"I".as_slice()) - ); - } - - #[test] - fn windows_eot_control_char_normalizes_to_ctrl_d() { - let event = Event::Key(KeyEvent::new(KeyCode::Char('\u{4}'), KeyModifiers::empty())); - let bytes = windows_key_raw_bytes(&event, false).expect("eot routes through raw framer"); - assert_eq!(bytes, b"\x04"); - - let mut framer = crate::raw_input::RawInputFramer::default(); - let events = framer.push(&bytes); - assert_eq!(events.len(), 1); - - let event = windows_client_input_event_from_raw(events.into_iter().next().unwrap()) - .expect("raw eot converts"); - assert_eq!( - event, - crate::protocol::ClientInputEvent::Key { - code: crate::protocol::ClientKeyCode::Char('d'), - modifiers: KeyModifiers::CONTROL.bits(), - kind: crate::protocol::ClientKeyKind::Press, - repeat_count: 1, - generated_text: None, - source: crate::protocol::ClientKeySource::Vt { bytes: vec![4] }, - } - ); - } #[test] fn windows_pending_escape_sequence_converts_to_semantic_arrow() { diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index c4642885..56ae7bc4 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -106,19 +106,48 @@ fn push_platform_input_events( } #[cfg(windows)] +/// The console input buffer: standard input when it is the console, otherwise +/// the attached console's `CONIN$` (stdin redirected, e.g. mintty). pub(super) fn console_input_handle() -> std::io::Result { - use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Foundation::{ + GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; use windows_sys::Win32::System::Console::{GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE}; - let handle: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; - if handle.is_null() || handle == INVALID_HANDLE_VALUE { - return Err(std::io::Error::last_os_error()); - } - let mut mode = 0; - if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { - return Err(std::io::Error::last_os_error()); + let is_console = |handle: HANDLE| { + let mut mode = 0; + !handle.is_null() + && handle != INVALID_HANDLE_VALUE + && unsafe { GetConsoleMode(handle, &mut mode) } != 0 + }; + + // SAFETY: querying the process standard input handle has no preconditions. + let stdin: HANDLE = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if is_console(stdin) { + return Ok(stdin); + } + let name: Vec = "CONIN$".encode_utf16().chain(Some(0)).collect(); + // SAFETY: `name` is NUL-terminated and outlives the call; the handle is owned + // by the reader for the rest of the process. + let conin = unsafe { + CreateFileW( + name.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + OPEN_EXISTING, + 0, + std::ptr::null_mut(), + ) + }; + if is_console(conin) { + Ok(conin) + } else { + Err(std::io::Error::last_os_error()) } - Ok(handle) } #[cfg(windows)] @@ -563,7 +592,16 @@ impl WindowsInputPump { ) -> Vec { for event in &mut events { if let crate::raw_input::RawInputEvent::Paste(text) = event { - decode_windows_terminal_paste_enters(text); + let decoded = if self.paste_from_win32_key_records { + None + } else { + decode_win32_paste_payload(text) + }; + if let Some(decoded) = decoded { + *text = decoded; + } else { + decode_windows_terminal_paste_enters(text); + } self.paste_from_win32_key_records = false; } } @@ -680,6 +718,54 @@ impl PlatformInputItem { } } +/// WezTerm can put serialized key records inside raw bracketed-paste markers. +/// Only recognize a complete payload of matching text press/release pairs and +/// modifier-only reports. Mixed, incomplete, or unsupported payloads stay opaque. +fn decode_win32_paste_payload(text: &str) -> Option { + let mut reports = text.split_inclusive('_'); + let mut units = Vec::new(); + while let Some(report) = reports.next() { + let pressed = parse_win32_paste_record(report)?; + if WindowsInputMapper::key_record_is_modifier_only(pressed) { + continue; + } + if !pressed.key_down || pressed.unicode == 0 { + return None; + } + let released = parse_win32_paste_record(reports.next()?)?; + if released + != (WindowsKeyRecord { + key_down: false, + ..pressed + }) + { + return None; + } + units.push(pressed.unicode); + } + if units.is_empty() { + return None; + } + // Decode locally so invalid or incomplete surrogates cannot leak into the + // next paste, and reject rather than partially replacing malformed text. + String::from_utf16(&units).ok() +} + +fn parse_win32_paste_record(report: &str) -> Option { + let body = report.strip_prefix("\x1b[")?.strip_suffix('_')?; + if !body + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b';') + || !matches!(body.split(';').nth(3), Some("0" | "1")) + { + return None; + } + let record = parse_win32_input_mode_key_record(body)?; + // This compatibility exception covers the captured one-unit records, not + // repeated key streams that could expand a small paste into large output. + (record.repeat_count == 1).then_some(record) +} + fn decode_windows_terminal_paste_enters(text: &mut String) { const ENTER_REPORT_PAIR: &str = "\x1b[13;28;13;1;0;1_\x1b[13;28;13;0;0;1_"; @@ -697,6 +783,41 @@ struct WindowsInputTranslator { pump: WindowsInputPump, } +/// Test handle that feeds native key records through the real Windows input +/// translation (mapper + pump), for cross-module conformance tests. +#[cfg(all(test, windows))] +#[derive(Default)] +pub(crate) struct TestWindowsInput(WindowsInputTranslator); + +#[cfg(all(test, windows))] +impl TestWindowsInput { + pub(crate) fn key_in_layout( + &mut self, + record: crate::input::WindowsKeyRecord, + layout: windows_sys::Win32::UI::Input::KeyboardAndMouse::HKL, + ) -> Vec { + let oem_char = (is_oem_virtual_key(record.virtual_key_code) && record.unicode == 0) + .then(|| { + crate::platform::resolve_base_printable_key_in_layout( + record.virtual_key_code, + record.virtual_scan_code, + layout, + ) + }) + .flatten(); + self.0 + .mapper + .translate_key_with_oem_char(record, oem_char) + .into_iter() + .flat_map(|item| self.0.pump.process(item)) + .collect() + } + + pub(crate) fn idle(&mut self) -> Vec { + self.0.idle() + } +} + #[cfg(test)] impl WindowsInputTranslator { fn translate(&mut self, record: WindowsInputRecord) -> Vec { @@ -763,6 +884,14 @@ impl WindowsInputMapper { } fn translate_key(&mut self, key: WindowsKeyRecord) -> Vec { + self.translate_key_with_oem_char(key, resolve_ctrl_oem_char(key)) + } + + fn translate_key_with_oem_char( + &mut self, + key: WindowsKeyRecord, + oem_char: Option, + ) -> Vec { if !self.key_record_can_emit_event(key) { return Vec::new(); } @@ -799,7 +928,7 @@ impl WindowsInputMapper { }; } - let events = self.translate_semantic_key_events(key, resolve_ctrl_oem_char(key)); + let events = self.translate_semantic_key_events(key, oem_char); let items = if let Some(bytes) = self.paste_payload_bytes_for_key(key) { vec![PlatformInputItem::PasteAwareKey { win32_paste_bytes: bytes.clone(), @@ -1053,7 +1182,7 @@ impl WindowsInputMapper { kind: crate::protocol::ClientKeyKind, oem_char: Option, ) -> Option { - let modifiers = windows_key_modifiers(key.control_key_state); + let modifiers = windows_record_modifiers(key); if key.virtual_key_code == 0 { let codepoint = self.utf16_unit_to_char(key.unicode)?; if !codepoint.is_control() { @@ -1109,6 +1238,7 @@ impl WindowsInputMapper { .or_else(|| { windows_virtual_key_to_char_code(key.virtual_key_code, key.unicode, modifiers) }) + .or_else(|| oem_char.map(crate::protocol::ClientKeyCode::Char)) }; code.map(|code| { @@ -1426,12 +1556,10 @@ fn ctrl_key_code(vk: u16, u: u16, oem: Option) -> Option Option { - if key.virtual_key_code == 0xbf - && key.unicode == 0 - && windows_key_modifiers(key.control_key_state) - .contains(crossterm::event::KeyModifiers::CONTROL) - { + if is_oem_virtual_key(key.virtual_key_code) && key.unicode == 0 { #[cfg(windows)] return crate::platform::resolve_base_printable_key( key.virtual_key_code, @@ -1441,6 +1569,28 @@ fn resolve_ctrl_oem_char(key: WindowsKeyRecord) -> Option { None } +fn is_oem_virtual_key(vk: u16) -> bool { + matches!(vk, 0xba..=0xc0 | 0xdb..=0xdf | 0xe2) +} + +/// Modifiers for a key record. AltGr is a character shift on character keys +/// (which may also be dead keys that report no character yet, #3948). On keys +/// that never type a character it is the Ctrl+Alt chord Windows reports. +fn windows_record_modifiers(key: WindowsKeyRecord) -> crossterm::event::KeyModifiers { + const RIGHT_ALT_PRESSED: u32 = 0x0001; + const LEFT_CTRL_PRESSED: u32 = 0x0008; + let modifiers = windows_key_modifiers(key.control_key_state); + let alt_gr = key.control_key_state & (RIGHT_ALT_PRESSED | LEFT_CTRL_PRESSED) + == (RIGHT_ALT_PRESSED | LEFT_CTRL_PRESSED); + let character_key = matches!(key.virtual_key_code, 0x30..=0x39 | 0x41..=0x5a) + || is_oem_virtual_key(key.virtual_key_code); + if alt_gr && key.unicode == 0 && !character_key { + modifiers | crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT + } else { + modifiers + } +} + #[cfg(windows)] fn windows_input_trace_enabled() -> bool { std::env::var_os("HERDR_WINDOWS_INPUT_TRACE").is_some() @@ -2037,6 +2187,108 @@ mod tests { ); } + #[test] + fn vti_reported_wezterm_paste_preserves_clipboard_text() { + // Exact raw Unicode fields from #4725, comments 5877227755/5877228192. + // All records have the other fields supplied by key_char. Preserve the + // seven paste batch boundaries and the preceding modifier records. + let batches = [ + "\x1b[17;29;0;1;8;1_", + "\x1b[16;42;0;1;24;1_", + "\x1b[200~\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_\x1b[76;38;108;1;0;1_\x1b[76;3", + "8;108;0;0;1_\x1b[80;25;112;1;0;1_\x1b[80;25;112;0;0;1_\x1b[72;35;104;1;0;", + "1_\x1b[72;35;104;0;0;1_\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_\x1b[17;29;0;", + "1;8;1_\x1b[13;28;10;1;8;1_\x1b[13;28;10;0;8;1_\x1b[17;29;0;0;0;1_\x1b[66;48;", + "98;1;0;1_\x1b[66;48;98;0;0;1_\x1b[69;18;101;1;0;1_\x1b[69;18;101;0;0;1_\x1b[", + "84;20;116;1;0;1_\x1b[84;20;116;0;0;1_\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;", + "0;1_\x1b[201~", + ]; + let mut translator = WindowsInputTranslator::default(); + let mut events = Vec::new(); + for batch in batches { + for record in batch.chars().map(key_char) { + events.extend(translator.translate(record)); + } + } + assert_eq!( + events, + vec![crate::protocol::ClientInputEvent::Paste { + text: "alpha\nbeta".into(), + }] + ); + } + + #[test] + fn win32_paste_payload_rejects_incomplete_or_unsupported_records() { + const PAIR: &str = "\x1b[65;30;97;1;0;1_\x1b[65;30;97;0;0;1_"; + for payload in [ + format!("{PAIR}trailing text"), + format!("{PAIR}\x1b[65;30;97;1;0;"), + "\x1b[65;30;97;1;0;1_".into(), + "\x1b[65;30;97;0;0;1_".into(), + "\x1b[65;30;97;1;0;1_\x1b[66;48;98;0;0;1_".into(), + PAIR.replace(";97;1;", ";97;2;"), + PAIR.replace(";0;1_", ";0;0_"), + PAIR.replace(";0;1_", ";0;65535_"), + PAIR.replace(";97;", ";65536;"), + PAIR.replace(";97;", ";+97;"), + PAIR.replace(";97;", ";0;"), + PAIR.replace(";97;", ";55296;"), + "\x1b[17;29;0;1;8;1_\x1b[17;29;0;0;0;1_".into(), + ] { + assert_eq!(decode_win32_paste_payload(&payload), None, "{payload:?}"); + } + } + + fn serialized_paste_text(text: &str) -> String { + text.encode_utf16() + .map(|unit| format!("\x1b[0;0;{unit};1;0;1_\x1b[0;0;{unit};0;0;1_")) + .collect() + } + + #[test] + fn vti_raw_win32_paste_keeps_decoded_controls_as_text_and_surrogates_local() { + let text = "😀\x1b[201~\x1b[<0;1;1M"; + let high = "\x1b[0;0;55357;1;0;1_\x1b[0;0;55357;0;0;1_"; + let low = "\x1b[0;0;56832;1;0;1_\x1b[0;0;56832;0;0;1_"; + let payloads = [ + serialized_paste_text(text), + high.into(), + low.into(), + "plain".into(), + ]; + let mut translator = WindowsInputTranslator::default(); + let mut events = Vec::new(); + for payload in payloads { + for record in format!("\x1b[200~{payload}\x1b[201~").chars().map(key_char) { + events.extend(translator.translate(record)); + } + events.extend(translator.idle()); + } + assert_eq!( + events, + [text, high, low, "plain"] + .into_iter() + .map(|text| crate::protocol::ClientInputEvent::Paste { text: text.into() }) + .collect::>() + ); + } + + #[test] + fn vti_encoded_paste_preserves_reports_then_raw_paste_decodes() { + let text = serialized_paste_text("a"); + let framed = format!("\x1b[200~{text}\x1b[201~"); + let mut records = win32_input_mode_encoded_key_bytes(framed.as_bytes()); + records.extend(framed.chars().map(key_char)); + assert_eq!( + translate(records), + vec![ + crate::protocol::ClientInputEvent::Paste { text }, + crate::protocol::ClientInputEvent::Paste { text: "a".into() }, + ] + ); + } + #[test] fn vti_remote_bracketed_paste_decodes_reported_enter_records() { let records = concat!( @@ -2904,6 +3156,28 @@ mod tests { } } + #[test] + fn altgr_is_ctrl_alt_only_on_keys_that_never_type_characters() { + let altgr = |virtual_key_code: u16, unicode: u16| WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code, + virtual_scan_code: 0, + unicode, + control_key_state: 0x0009, + }; + let ctrl_alt = + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT; + + // PageUp and Space type nothing: the chord must not look like a plain key. + assert_eq!(windows_record_modifiers(altgr(0x21, 0)), ctrl_alt); + assert_eq!(windows_record_modifiers(altgr(0x20, 0)), ctrl_alt); + // Character keys keep AltGr as a character shift, dead keys included. + assert!(windows_record_modifiers(altgr(0x34, 0)).is_empty()); + assert!(windows_record_modifiers(altgr(0xba, 0)).is_empty()); + assert!(windows_record_modifiers(altgr(0x51, u16::from(b'@'))).is_empty()); + } + #[test] fn vti_altgr_dead_key_preserves_native_record_without_command_modifiers() { // AltGr+4 press captured in #3948, Spanish ISO layout. @@ -2935,10 +3209,12 @@ mod tests { events .into_iter() .flat_map(|event| match event.to_raw_input_event() { - crate::raw_input::RawInputEvent::Key(key) => crate::input::encode_terminal_key( - key, - crate::input::KeyboardProtocol::Kitty { flags }, - ), + crate::raw_input::RawInputEvent::Key(key) => { + crate::pane::test_encode_key_for_app( + format!("\x1b[>{flags}u").as_bytes(), + key, + ) + } crate::raw_input::RawInputEvent::Text(text) => { text.as_str().as_bytes().to_vec() } @@ -3069,23 +3345,14 @@ mod tests { let crate::raw_input::RawInputEvent::Key(key) = events[0].to_raw_input_event() else { panic!("expected translated key"); }; + assert_eq!(crate::pane::test_encode_key_for_app(b"", key.clone()), b"/"); assert_eq!( - crate::input::encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy), + crate::pane::test_encode_key_for_app(b"\x1b[>7u", key.clone()), b"/" ); assert_eq!( - crate::input::encode_terminal_key( - key.clone(), - crate::input::KeyboardProtocol::Kitty { flags: 7 }, - ), - b"/" - ); - assert_eq!( - crate::input::encode_terminal_key( - key, - crate::input::KeyboardProtocol::Kitty { flags: 15 }, - ), - b"\x1b[47;2:1u" + crate::pane::test_encode_key_for_app(b"\x1b[>15u", key), + b"\x1b[47;2u" ); } @@ -3194,7 +3461,7 @@ mod tests { assert_eq!(key.modifiers.bits(), modifiers, "{name}: modifiers"); assert_eq!(key.generated_text.as_deref(), text, "{name}: text"); assert_eq!( - crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), + crate::pane::test_encode_key_for_app(b"", key), expected, "{name}: encoding" ); diff --git a/src/client/loop_config.rs b/src/client/loop_config.rs index 97979bbf..c4e65917 100644 --- a/src/client/loop_config.rs +++ b/src/client/loop_config.rs @@ -10,6 +10,7 @@ pub(super) struct ClientLoopConfig { pub(super) pixel_geometry_fallback: bool, pub(super) mouse_capture_active: bool, pub(super) host_escape_disambiguation_active: bool, + pub(super) host_sgr_pixel_mouse: Option, pub(super) initial_host_input: Vec, pub(super) endpoint_keybindings: bool, pub(super) remote_image_paste_key: diff --git a/src/client/mod.rs b/src/client/mod.rs index 4ca0aad6..265b64f5 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -87,8 +87,22 @@ fn refresh_host_mouse_capture(enabled: bool, sgr_pixels: bool) { } } +fn refresh_host_keyboard_protocol( + terminal_guard: &TerminalGuard, + report_all_keys: bool, + focus_reporting: bool, +) { + if let Err(err) = terminal_guard.refresh_host_keyboard_protocol( + &mut io::stdout(), + report_all_keys, + focus_reporting, + ) { + warn!(err = %err, "failed to re-assert host keyboard protocol"); + } +} + #[cfg(windows)] -use terminal_setup::{is_ssh_session, windows_vti_input_backend_enabled}; +use terminal_setup::is_ssh_session; #[cfg(test)] use terminal_setup::{ should_enable_host_color_scheme_reports, windows_virtual_terminal_input_mode, @@ -116,7 +130,10 @@ use frame_output::{ write_encoded_frame_with_graphics, }; pub(crate) use handshake::probe_endpoint_negotiation; -use handshake::{client_shell_keybinding_source, do_handshake, is_remote_client_process}; +use handshake::{ + client_shell_keybinding_source, do_handshake, is_remote_client_process, + HandshakePixelCapabilities, +}; #[cfg(test)] use handshake::{ direct_graphics_profile_values, handshake_read_timeout, REMOTE_HANDSHAKE_READ_TIMEOUT, @@ -199,7 +216,7 @@ fn run_client_with_mode( .with_startup_config_diagnostic(startup_config_diagnostic) .with_startup_onboarding(loaded_config.config.should_show_onboarding()) .with_keybinding_source(keybinding_source) - .with_local_endpoint(&socket_path) + .with_process_endpoint_preferences(&socket_path) }); let mouse_capture = loaded_config.config.ui.mouse_capture; let mouse_scroll_lines = loaded_config.config.ui.mouse_scroll_lines(); @@ -222,6 +239,7 @@ fn run_client_with_mode( pixel_geometry_fallback: kitty_graphics_enabled, mouse_capture_active: mouse_capture, host_escape_disambiguation_active: false, + host_sgr_pixel_mouse: None, initial_host_input: Vec::new(), endpoint_keybindings, remote_image_paste_key, @@ -254,10 +272,25 @@ fn run_client_with_mode( } }; - // Get the terminal geometry before handshake (before raw mode). + // Get one coherent geometry snapshot before raw mode changes the terminal. let (cols, rows, cell_width_px, cell_height_px, exact_cell_size) = initial_terminal_geometry(pixel_geometry_enabled, kitty_graphics_enabled)?; + let direct_attach = attach_escape.is_some(); + let mut terminal_guard = if direct_attach { + setup_direct_attach_terminal(mouse_capture) + } else { + setup_terminal(mouse_capture) + } + .map_err(|err| { + eprintln!("herdr: failed to set up terminal: {err}"); + err + })?; + loop_config.host_escape_disambiguation_active = + terminal_guard.host_escape_disambiguation_active(); + loop_config.host_sgr_pixel_mouse = terminal_guard.host_sgr_pixel_mouse(); + loop_config.initial_host_input = terminal_guard.take_buffered_host_input(); + let shell_surface_size = loop_config .shell_config .as_ref() @@ -271,7 +304,10 @@ fn run_client_with_mode( rows, cell_width_px, cell_height_px, - exact_cell_size, + HandshakePixelCapabilities { + exact_geometry: exact_cell_size, + sgr_mouse: loop_config.host_sgr_pixel_mouse == Some(true), + }, shell_surface_size, endpoint_keybindings, loop_config.mouse_capture_active, @@ -312,21 +348,6 @@ fn run_client_with_mode( Err(error) => return Err(error), }; - // The federated shell can show connection notices without any server snapshot. - let direct_attach = attach_escape.is_some(); - let mut terminal_guard = if direct_attach { - setup_direct_attach_terminal(mouse_capture) - } else { - setup_terminal(mouse_capture) - } - .map_err(|err| { - eprintln!("herdr: failed to set up terminal: {err}"); - err - })?; - loop_config.host_escape_disambiguation_active = - terminal_guard.host_escape_disambiguation_active(); - loop_config.initial_host_input = terminal_guard.take_buffered_host_input(); - // Install a panic hook so the foreground client always restores its terminal. let panic_restore = terminal_guard.panic_restore(); let original_hook = std::panic::take_hook(); @@ -466,6 +487,7 @@ async fn run_client_loop( kitty_graphics_enabled: config.kitty_graphics_enabled, pixel_geometry_enabled: config.pixel_geometry_enabled, pixel_geometry_exact: initial_pixel_geometry_exact, + host_sgr_pixel_mouse: config.host_sgr_pixel_mouse, #[cfg(unix)] direct_graphics_response: Arc::new(Mutex::new(direct_graphics::ResponseMatcher::default())), #[cfg(unix)] @@ -489,6 +511,8 @@ async fn run_client_loop( }; let mut federated = endpoint_catalog.has_enabled_ssh(); if let Some(shell) = state.shell.as_mut() { + shell.set_host_reports_key_releases(config.host_escape_disambiguation_active); + shell.set_host_erase_byte(crate::platform::terminal_erase_byte()); shell.set_graphics_cell_size(initial_cell_width_px, initial_cell_height_px); shell.set_endpoint_catalog(&endpoint_catalog.ssh); shell.set_endpoint_methods_for( @@ -780,6 +804,7 @@ async fn run_client_loop( cell_width_px: state.reported_cell_size.0, cell_height_px: state.reported_cell_size.1, pixel_geometry_exact: state.pixel_geometry_exact, + pixel_mouse: state.pixel_mouse_available(), surface_size: shell.surface_size(state.reported_size.0, state.reported_size.1), endpoint_keybindings: config.endpoint_keybindings, mouse_capture: state.shell_mouse_capture_preference, @@ -839,7 +864,7 @@ async fn run_client_loop( ); if state.shell.is_some() { if will_query_host_cell_size { - let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + let events = crate::raw_input::parse_framed_input(&data); if let Some((width_px, height_px)) = reported_cell_size_from_events(&events) { store_reported_cell_size(&reported_cell_size, width_px, height_px); @@ -880,12 +905,17 @@ async fn run_client_loop( continue; } } - let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + let events = crate::raw_input::parse_framed_input(&data); if crate::raw_input::events_require_host_mode_refresh(&events) { refresh_host_mouse_capture( state.mouse_capture_active, host_sgr_pixels_active.load(Ordering::Acquire), ); + refresh_host_keyboard_protocol( + _terminal_guard, + state.keyboard_report_all_active, + false, + ); } let (outcome, frame) = { let shell = state.shell.as_mut().expect("checked shell mode"); @@ -955,7 +985,7 @@ async fn run_client_loop( AttachInputAction::None => continue, } } else { - let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + let events = crate::raw_input::parse_framed_input(&data); if crate::raw_input::events_require_host_surface_redraw( &events, state.redraw_on_focus_gained, @@ -1147,6 +1177,11 @@ async fn run_client_loop( state.mouse_capture_active, host_sgr_pixels_active.load(Ordering::Acquire), ); + refresh_host_keyboard_protocol( + _terminal_guard, + state.keyboard_report_all_active, + false, + ); } let image_target = state .shell @@ -1208,6 +1243,31 @@ async fn run_client_loop( } // Direct terminal attach is Unix-only; every Windows client uses ClientShell. } + #[cfg(windows)] + ClientLoopEvent::NotificationActivated(target) => { + if let Some(shell) = state.shell.as_mut() { + let outcome = shell.activate_system_notification(target); + if !outcome.actions.is_empty() { + crate::platform::foreground_desktop_notification_host(); + } + let frame = outcome + .repaint + .then(|| shell.compose(state.reported_size.0, state.reported_size.1)) + .flatten(); + if finish_client_shell_input( + &mut state, + outcome, + frame, + &mut write_stream, + &mut pending_activation, + &mut endpoint_commands, + &mut prefix_input_source, + &mut scheduled_activation, + )? { + return Ok(()); + } + } + } ClientLoopEvent::TerminalUnavailable(err) => { info!(err = %err, "client terminal unavailable; detaching"); let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); @@ -1227,7 +1287,9 @@ async fn run_client_loop( host_theme_query_pending.fetch_add(1, Ordering::AcqRel); query_host_terminal_theme(); } - if !pixel_geometry_exact && host_sgr_pixels_active.load(Ordering::Acquire) { + let pixel_mouse_available = + pixel_geometry_exact && state.host_sgr_pixel_mouse == Some(true); + if !pixel_mouse_available && host_sgr_pixels_active.load(Ordering::Acquire) { set_mouse_capture(state.mouse_capture_active, false) .map_err(ClientError::ConnectionFailed)?; host_sgr_pixels_active.store(false, Ordering::Release); @@ -1237,6 +1299,14 @@ async fn run_client_loop( host_sgr_pixels_active.load(Ordering::Acquire), ); } + // A host that reset its modes (a reconnect or window reload) + // stops sending focus reports, so a resize is the one + // dependable trigger to restore them. + refresh_host_keyboard_protocol( + _terminal_guard, + state.keyboard_report_all_active, + true, + ); state.reported_size = (new_cols, new_rows); state.reported_cell_size = (cell_width_px, cell_height_px); state.pixel_geometry_exact = pixel_geometry_exact; @@ -1253,7 +1323,7 @@ async fn run_client_loop( new_rows, cell_width_px, cell_height_px, - pixel_geometry_exact, + pixel_mouse_available, ) } else { ClientMessage::Resize { @@ -1261,7 +1331,7 @@ async fn run_client_loop( rows: new_rows, cell_width_px, cell_height_px, - pixel_mouse: pixel_geometry_exact, + pixel_mouse: pixel_mouse_available, } }; if let Some(activation) = pending_activation.as_mut() { @@ -1791,7 +1861,12 @@ async fn run_client_loop( .flatten(); (effects, frame) }; - handle_shell_notification_effects(effects, &state.sound_config); + handle_shell_notification_effects( + effects, + &state.sound_config, + #[cfg(windows)] + &event_tx, + ); if let Some(frame) = frame { state.present_frame(frame); } @@ -2040,12 +2115,12 @@ async fn run_client_loop( let next_sgr_pixels = effective_sgr_pixel_mouse( enabled, sgr_pixels, - state.pixel_geometry_exact, + state.pixel_mouse_available(), ); let mouse_mode_changed = enabled != state.mouse_capture_active || next_sgr_pixels != host_sgr_pixels_active.load(Ordering::Acquire); #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && is_ssh_session() { + if enabled && is_ssh_session() { _terminal_guard .recover_windows_virtual_terminal_input() .map_err(ClientError::ConnectionFailed)?; @@ -2055,7 +2130,7 @@ async fn run_client_loop( .map_err(ClientError::ConnectionFailed)?; } #[cfg(windows)] - if enabled && windows_vti_input_backend_enabled() && !is_ssh_session() { + if enabled && !is_ssh_session() { _terminal_guard .recover_windows_virtual_terminal_input() .map_err(ClientError::ConnectionFailed)?; @@ -2455,7 +2530,12 @@ async fn run_client_loop( .flatten(); (effects, outcome, frame) }; - handle_shell_notification_effects(effects, &state.sound_config); + handle_shell_notification_effects( + effects, + &state.sound_config, + #[cfg(windows)] + &event_tx, + ); if finish_client_shell_input( &mut state, outcome, diff --git a/src/client/notifications.rs b/src/client/notifications.rs index 7c97742e..0b87f2db 100644 --- a/src/client/notifications.rs +++ b/src/client/notifications.rs @@ -6,9 +6,53 @@ use crate::protocol::NotifyKind; use super::shell; +#[cfg(not(windows))] +use crate::platform::show_desktop_notification as show_untargeted_system_notification; + +#[cfg(windows)] +fn queue_system_notification( + task: impl FnOnce() -> io::Result + Send + 'static, +) -> io::Result { + type Task = Box io::Result + Send>; + static QUEUE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + let queue = QUEUE.get_or_init(|| { + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); + // Preserve replacement order while keeping native waits off the client loop. + tokio::spawn(async move { + while let Some(task) = receiver.recv().await { + let result = tokio::task::spawn_blocking(task) + .await + .unwrap_or_else(|err| Err(io::Error::other(err))); + if let Err(err) = result { + warn!(err = %err, "failed to emit system notification"); + } + } + }); + sender + }); + queue.send(Box::new(task)).map_err(|_| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "notification delivery queue closed", + ) + })?; + Ok(true) +} + +#[cfg(windows)] +fn show_untargeted_system_notification(title: &str, body: Option<&str>) -> io::Result { + let title = title.to_owned(); + let body = body.map(str::to_owned); + queue_system_notification(move || { + crate::platform::show_desktop_notification(&title, body.as_deref()) + }) +} + pub(super) fn handle_shell_notification_effects( effects: Vec, sound_config: &crate::config::SoundConfig, + #[cfg(windows)] event_tx: &tokio::sync::mpsc::Sender, ) { for effect in effects { match effect { @@ -24,10 +68,17 @@ pub(super) fn handle_shell_notification_effects( warn!(err = %err, "failed to emit terminal notification"); } } - shell::ClientShellNotificationEffect::System { title, body } => { - if let Err(err) = - crate::platform::show_desktop_notification(&title, body.as_deref()) - { + shell::ClientShellNotificationEffect::System { + title, + body, + #[cfg(windows)] + target, + } => { + #[cfg(windows)] + let result = show_system_notification(&title, body.as_deref(), target, event_tx); + #[cfg(not(windows))] + let result = crate::platform::show_desktop_notification(&title, body.as_deref()); + if let Err(err) = result { warn!(err = %err, "failed to emit system notification"); } } @@ -35,6 +86,47 @@ pub(super) fn handle_shell_notification_effects( } } +#[cfg(windows)] +fn show_system_notification( + title: &str, + body: Option<&str>, + target: Option, + event_tx: &tokio::sync::mpsc::Sender, +) -> io::Result { + let Some(target) = target else { + return show_untargeted_system_notification(title, body); + }; + let key = serde_json::to_string(&( + &target.endpoint_id.storage_key(), + &target.boot_id, + &target.pane_id, + )) + .map_err(io::Error::other)?; + let event_tx = event_tx.clone(); + let runtime = tokio::runtime::Handle::current(); + let title = title.to_owned(); + let body = body.map(str::to_owned); + queue_system_notification(move || { + crate::platform::show_actionable_desktop_notification( + &title, + body.as_deref(), + key, + std::sync::Arc::new(move || { + let event_tx = event_tx.clone(); + let target = target.clone(); + // Never block a native callback: WinRT can dispatch it while Show is awaited. + runtime.spawn(async move { + let _ = event_tx + .send(super::events::ClientLoopEvent::NotificationActivated( + target, + )) + .await; + }); + }), + ) + }) +} + pub(super) fn handle_notify( kind: NotifyKind, message: &str, @@ -47,7 +139,7 @@ pub(super) fn handle_notify( body, sound_config, crate::terminal_notify::show_notification, - crate::platform::show_desktop_notification, + show_untargeted_system_notification, ); } @@ -100,3 +192,36 @@ pub(super) fn sound_from_notify_message(message: &str) -> Option None, } } + +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[tokio::test(flavor = "current_thread")] + async fn native_delivery_keeps_client_responsive_and_preserves_replacement_order() { + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = std::sync::mpsc::channel(); + queue_system_notification(move || { + let _ = started.send(()); + wait.recv_timeout(std::time::Duration::from_secs(5)) + .map_err(io::Error::other)?; + Ok(true) + }) + .expect("queued first notification"); + ready.await.expect("notification worker started"); + let (finished, mut second) = tokio::sync::oneshot::channel(); + queue_system_notification(move || { + let _ = finished.send(()); + Ok(true) + }) + .expect("queued replacement"); + tokio::task::yield_now().await; + assert_eq!( + second.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ); + // This send must run while native delivery is still waiting. + release.send(()).expect("client loop remained responsive"); + second.await.expect("replacement delivered after original"); + } +} diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 100ad1ae..96134bd7 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -107,23 +107,21 @@ impl ClientShellState { } if action == crate::input::KeybindAction::CloseWorkspace { if let Some(workspace_id) = self.workspace_action_id() { - if self.config.confirm_close { - self.open_confirm_close_overlay(workspace_id); - } else { - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceClose( - crate::api::schema::WorkspaceCloseParams { - workspace_id, - close_group: true, - }, - ), - outcome, - ); - } + self.request_workspace_close(workspace_id, None, outcome); } outcome.repaint = true; return; } + if action == crate::input::KeybindAction::ClosePane { + if let Some(pane_id) = self + .snapshot + .as_deref() + .and_then(|snapshot| snapshot.focused_pane_id.clone()) + { + self.request_pane_close(pane_id, outcome); + } + return; + } if action == crate::input::KeybindAction::CloseTab { if let Some(tab_id) = self .snapshot @@ -550,6 +548,10 @@ impl ClientShellState { &mut self, target: ClientEndpointFocusTarget, ) -> Vec { + #[cfg(windows)] + if !self.notification_target_is_current(&self.active_endpoint_id, &target) { + return Vec::new(); + } let method = match target { ClientEndpointFocusTarget::Workspace(workspace_id) => { crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { @@ -562,6 +564,10 @@ impl ClientShellState { ClientEndpointFocusTarget::Pane(pane_id) => { crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) } + #[cfg(windows)] + ClientEndpointFocusTarget::Notification { pane_id, .. } => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }) + } }; let mut outcome = ClientShellInput::default(); self.push_endpoint_method(method, &mut outcome); @@ -1015,7 +1021,8 @@ impl ClientShellState { event.kind == crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) }); - let replay = (self.mode == ClientShellMode::Terminal + let replay = (self.config.mouse_capture + && self.mode == ClientShellMode::Terminal && self.overlay.is_none() && self .hits @@ -1043,7 +1050,7 @@ impl ClientShellState { Ok(crate::api::schema::ResponseResult::PaneLinkActivated { url: Some(url), handled: false, - }) if crate::app::actions::safe_web_url(&url).is_some() => { + }) if crate::app::actions::safe_web_url(&url).is_some() && replay.is_some() => { self.url_click_consumes_until_up = completed_before_release; (false, vec![ClientShellAction::OpenSafeWebUrl(url)]) } @@ -1054,6 +1061,10 @@ impl ClientShellState { self.set_endpoint_error("endpoint returned an unexpected link result"); (true, replay_action(replay)) } + Err(error) if error.code.as_deref() == Some("plugin_link_failed") => { + self.url_click_consumes_until_up = completed_before_release; + (true, Vec::new()) + } Err(error) if matches!( error.code.as_deref(), @@ -1412,9 +1423,6 @@ impl ClientShellState { env: Default::default(), })) } - KeybindAction::ClosePane => Some(Method::PaneClose(PaneTarget { - pane_id: focused_pane.clone()?, - })), KeybindAction::CyclePaneNext | KeybindAction::CyclePanePrevious => { let focused_tab = focused_tab?; let panes = snapshot diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index 8803a03e..046448ac 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -195,6 +195,31 @@ impl ClientShellConfig { self.with_preferences_path(preferences::path_for_local_endpoint(socket_path)) } + pub(crate) fn with_process_endpoint_preferences(self, socket_path: &std::path::Path) -> Self { + self.with_endpoint_preferences( + socket_path, + std::env::var(crate::remote::REMOTE_PREFERENCES_ENV_VAR) + .ok() + .as_deref(), + ) + } + + pub(crate) fn with_endpoint_preferences( + self, + socket_path: &std::path::Path, + remote_identity: Option<&str>, + ) -> Self { + let identity = remote_identity + .and_then(|value| serde_json::from_str::<(String, String)>(value).ok()) + .filter(|(target, session)| !target.is_empty() && !session.is_empty()); + match identity { + Some((target, session)) => { + self.with_preferences_path(preferences::path_for_remote_endpoint(&target, &session)) + } + None => self.with_local_endpoint(socket_path), + } + } + pub(super) fn with_preferences_path(mut self, path: std::path::PathBuf) -> Self { self.preferences = preferences::load(&path).unwrap_or_default(); self.preferences_path = Some(path); diff --git a/src/client/shell/context_menu.rs b/src/client/shell/context_menu.rs index bebe41f0..d8b7e6ed 100644 --- a/src/client/shell/context_menu.rs +++ b/src/client/shell/context_menu.rs @@ -15,6 +15,7 @@ impl ClientContextMenuOverlay { is_git, is_linked_worktree, has_worktree_children, + close_group, collapsed, can_clear_focus, workspace_record_revision, @@ -24,11 +25,10 @@ impl ClientContextMenuOverlay { let mut items = Vec::new(); if *is_active_endpoint { items.push(item("Rename", Action::Rename)); - if *has_worktree_children { - items.push(item("Close group", Action::Close)); - } else { - items.push(item("Close", Action::Close)); - } + items.push(item( + if *close_group { "Close group" } else { "Close" }, + Action::Close, + )); if *is_git && !*is_linked_worktree { items.push(item("New worktree", Action::NewWorktree)); items.push(item("Open worktree...", Action::OpenWorktree)); @@ -370,18 +370,13 @@ impl ClientShellState { let worktree = workspace.worktree.as_ref(); let has_worktree_children = worktree.is_some_and(|worktree| { !worktree.is_linked_worktree - && snapshot - .workspaces - .iter() - .filter(|candidate| { - candidate - .worktree - .as_ref() - .is_some_and(|candidate| candidate.key == worktree.key) + && snapshot.workspaces.iter().any(|candidate| { + candidate.worktree.as_ref().is_some_and(|candidate| { + candidate.key == worktree.key && candidate.is_linked_worktree }) - .count() - >= 2 + }) }); + let close_group = super::sidebar::workspace_close_is_group(snapshot, workspace); let collapsed = worktree.is_some_and(|worktree| { self.group_is_collapsed(&self.active_endpoint_id, &worktree.key) }); @@ -428,6 +423,7 @@ impl ClientShellState { is_git: worktree.is_some() || workspace.branch.is_some(), is_linked_worktree: worktree.is_some_and(|worktree| worktree.is_linked_worktree), has_worktree_children, + close_group, collapsed, can_clear_focus, }, @@ -514,6 +510,7 @@ impl ClientShellState { expected_revision, workspace_record_revision, project_record_revision, + close_group, .. } => self.activate_workspace_context_action( endpoint_id, @@ -523,6 +520,7 @@ impl ClientShellState { expected_revision, workspace_record_revision, project_record_revision, + close_group, action, outcome, ), @@ -557,6 +555,7 @@ impl ClientShellState { expected_revision: u64, workspace_record_revision: Option, project_record_revision: Option, + close_group: bool, action: ClientContextMenuAction, outcome: &mut ClientShellInput, ) { @@ -739,19 +738,7 @@ impl ClientShellState { } } ClientContextMenuAction::Close => { - if self.config.confirm_close { - self.open_confirm_close_overlay(workspace_id); - } else { - self.push_endpoint_method( - crate::api::schema::Method::WorkspaceClose( - crate::api::schema::WorkspaceCloseParams { - workspace_id, - close_group: true, - }, - ), - outcome, - ); - } + self.request_workspace_close(workspace_id, Some(close_group), outcome); } ClientContextMenuAction::NewWorktree => { self.begin_worktree_action_for(KeybindAction::NewWorktree, workspace_id, outcome) @@ -1090,9 +1077,7 @@ impl ClientShellState { }), outcome, ), - ClientContextMenuAction::ClosePane => { - self.push_endpoint_method(Method::PaneClose(PaneTarget { pane_id }), outcome) - } + ClientContextMenuAction::ClosePane => self.request_pane_close(pane_id, outcome), _ => {} } } diff --git a/src/client/shell/endpoint_navigation.rs b/src/client/shell/endpoint_navigation.rs index 66d8f9aa..007fe33f 100644 --- a/src/client/shell/endpoint_navigation.rs +++ b/src/client/shell/endpoint_navigation.rs @@ -279,6 +279,10 @@ impl ClientShellState { target: ClientEndpointFocusTarget, outcome: &mut ClientShellInput, ) -> bool { + #[cfg(windows)] + if !self.notification_target_is_current(&endpoint_id, &target) { + return false; + } self.pending_workspace_highlight = None; self.pending_agent_reveal = None; let online = self.endpoint_is_online(&endpoint_id); @@ -306,6 +310,12 @@ impl ClientShellState { pane_id, }) } + #[cfg(windows)] + ClientEndpointFocusTarget::Notification { pane_id, .. } => { + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { + pane_id, + }) + } }; self.push_endpoint_method(method, outcome); } else { diff --git a/src/client/shell/endpoints.rs b/src/client/shell/endpoints.rs index 3059bb78..6179ec39 100644 --- a/src/client/shell/endpoints.rs +++ b/src/client/shell/endpoints.rs @@ -38,6 +38,11 @@ pub(crate) enum ClientEndpointFocusTarget { Workspace(String), Tab(String), Pane(String), + #[cfg(windows)] + Notification { + pane_id: String, + boot_id: String, + }, } impl ClientShellState { diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 1e1498d2..0bfbcefb 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -10,6 +10,19 @@ fn is_retained_selection_copy_key(key: &crate::input::TerminalKey) -> bool { && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) } +/// True for Ctrl+[, the terminal-level equivalent of Esc. +/// +/// A legacy terminal sends Ctrl+[ as 0x1b, the same byte as Esc, so it already +/// arrives as `KeyCode::Esc`. Under the kitty keyboard protocol the modified key +/// is reported on its own and reaches navigate mode as `Char('[')` with CONTROL. +/// Ctrl+Shift+[ stays distinct because it carries SHIFT. +/// +/// Navigate mode checks this only after keybinding dispatch, so a configured +/// Ctrl+[ binding keeps working and this stays a fallback cancel. +fn is_ctrl_bracket_key(key: &crate::input::TerminalKey) -> bool { + key.code == KeyCode::Char('[') && key.modifiers == KeyModifiers::CONTROL +} + pub(super) fn is_modal_paste_shortcut_for_platform( key: &crate::input::TerminalKey, macos: bool, @@ -85,9 +98,62 @@ impl ClientShellState { ) } + pub(crate) fn set_host_reports_key_releases(&mut self, reports: bool) { + self.host_reports_key_releases = reports; + } + + pub(crate) fn set_host_reports_all_keys(&mut self, reports_all: bool) { + self.host_reports_all_keys = reports_all; + } + + /// While the pane asks for every key as an escape code, committed text + /// (IME, compose) must reach it as text, not as invented key reports. + /// On a Kitty host switched to report-all, typed keys arrive as reports, + /// so any plain text is a commit (kitty sends those as text too). A legacy + /// host sends both as plain text; non-ASCII text has no key in the + /// keyboard model, so only that is treated as a commit there. + /// Only text bound for the focused pane is a commit. In prefix, navigate + /// and other Herdr-owned input, plain text is still a key that may run a + /// binding (#5255). + fn host_text_commit(&self, key: &crate::input::TerminalKey) -> Option { + if !self.host_reports_all_keys + || key.kind != KeyEventKind::Press + || self.mode != ClientShellMode::Terminal + || self.overlay.is_some() + { + return None; + } + let plain_text = key + .vt_bytes() + .is_some_and(|bytes| bytes.first().is_some_and(|byte| *byte != 0x1b)); + let text = key.generated_text.as_ref().filter(|_| plain_text)?; + (self.host_reports_key_releases || !text.is_ascii()).then(|| text.clone()) + } + + pub(crate) fn set_host_erase_byte(&mut self, erase: Option) { + self.host_erase_is_ctrl_h = erase == Some(0x08); + } + + /// A raw 0x08 (or ESC 0x08) from a host whose erase character is `^H` is + /// its Backspace key (#3244). + fn host_erase_key(&self, key: crate::input::TerminalKey) -> crate::input::TerminalKey { + if !self.host_erase_is_ctrl_h { + return key; + } + let alt = match key.vt_bytes() { + Some([0x08]) => KeyModifiers::empty(), + Some([0x1b, 0x08]) => KeyModifiers::ALT, + _ => return key, + }; + let bytes = key.vt_bytes().map(<[u8]>::to_vec).unwrap_or_default(); + crate::input::TerminalKey::new(KeyCode::Backspace, alt) + .with_kind(key.kind) + .with_vt_bytes(bytes) + } + #[cfg(test)] pub(crate) fn handle_input_bytes(&mut self, data: &[u8]) -> ClientShellInput { - self.handle_raw_events(crate::raw_input::parse_raw_input_bytes_sync(data)) + self.handle_raw_events(crate::raw_input::parse_framed_input(data)) } #[cfg(any(unix, test))] @@ -96,21 +162,19 @@ impl ClientShellState { data: &[u8], geometry: crate::input::mouse::HostGeometry, ) -> ClientShellInput { - let Some((x, y)) = crate::input::mouse::parse_report(data) else { - return ClientShellInput::default(); - }; - let Some((column, row)) = geometry.cell(x, y) else { + let Some(report) = crate::raw_input::parse_sgr_mouse_report(data) else { return ClientShellInput::default(); }; - let Some(cell_report) = crate::input::mouse::report_at_cell(data, column, row) else { + let Some((column, row)) = geometry.cell(report.x, report.y) else { return ClientShellInput::default(); }; - let events = crate::raw_input::parse_raw_input_bytes_sync(&cell_report); - if events.len() != 1 || !matches!(events[0], RawInputEvent::Mouse(_)) { - return ClientShellInput::default(); - } - self.host_mouse_pixels = Some(crate::input::mouse::HostPixels { x, y, geometry }); - let outcome = self.handle_raw_events(events); + self.host_mouse_pixels = Some(crate::input::mouse::HostPixels { + x: report.x, + y: report.y, + geometry, + }); + let outcome = + self.handle_raw_events(vec![RawInputEvent::Mouse(report.at_cell(column, row))]); self.host_mouse_pixels = None; outcome } @@ -164,6 +228,13 @@ impl ClientShellState { outcome.repaint = true; } for event in events { + let event = match event { + RawInputEvent::Key(key) => match self.host_text_commit(&key) { + Some(text) => RawInputEvent::Text(crate::input::TextCommit::new(text)), + None => RawInputEvent::Key(key), + }, + other => other, + }; if self.handle_machine_badge_event(&event, &mut outcome) { continue; } @@ -171,7 +242,10 @@ impl ClientShellState { push_host_theme_update(&mut outcome.requests, update); } match event { - RawInputEvent::Key(key) => self.handle_key(key, &mut outcome), + RawInputEvent::Key(key) => { + let key = self.host_erase_key(key); + self.handle_key(key, &mut outcome) + } RawInputEvent::Text(text) => { let text = text.into_string(); if matches!( @@ -317,6 +391,11 @@ impl ClientShellState { self.copy_input_queue.push_back(key); return; } + let key = if self.host_reports_key_releases && key.generated_text.is_some() { + key.with_physical_identity_hint(true) + } else { + key + }; let lease_key = crate::input::InputLeaseKey::new(LOCAL_INPUT_SOURCE, &key); let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { @@ -344,21 +423,49 @@ impl ClientShellState { self.execute_repeat_plan(lease_key, key, plan, outcome); } KeyEventKind::Release => { - if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { - let release = lease + let Some(lease) = self.take_release_lease(&lease_key, &key) else { + return; + }; + let crate::input::InputLease::Forwarded(lease) = lease else { + // Herdr consumed the press; its release stays with Herdr. + return; + }; + // A native record is released as the recorded key. A VT release + // report already names its key and shifted character exactly, + // except around a non-Latin Ctrl chord: Ctrl alone resolves to + // the physical key, so a modifier let go before the key flips + // between the layout character and that key. It is released as + // the key the pane saw pressed. + let layout_chord = + key.code != lease.key.code && key.identity() == lease.key.identity(); + let release = if key.physical_key_id().is_some() || layout_chord { + lease .key .with_modifiers(key.modifiers) - .with_kind(KeyEventKind::Release); - self.push_pane_key(lease.target, release, outcome); + .with_kind(KeyEventKind::Release) } else { - let _ = self.input_leases.remove(&lease_key); - } + key + }; + self.push_pane_key(lease.target, release, outcome); } } } fn release_input_leases(&mut self, outcome: &mut ClientShellInput) { for lease in self.input_leases.remove_source(LOCAL_INPUT_SOURCE) { + // A press that arrived as plain text (IME commits, and printable keys + // the host sends as text) reached the pane as text, not as a key + // event. IME commits never get a host release, so inventing one per + // character is noise. Key reports, with or without text, still get + // their release. + let plain_text_press = lease.key.generated_text.is_some() + && lease + .key + .vt_bytes() + .is_some_and(|bytes| bytes.first().is_some_and(|byte| *byte != 0x1b)); + if plain_text_press { + continue; + } self.push_pane_key( lease.target, lease.key.with_kind(KeyEventKind::Release), @@ -673,9 +780,7 @@ impl ClientShellState { self.pending_workspace_highlight = None; if key.code == KeyCode::Esc || self.config.keybinds.matches_prefix(key) { - self.mode = self.copy_or_terminal_mode(); - self.navigate_workspace_id = None; - outcome.repaint = true; + self.cancel_navigate(outcome); return; } @@ -710,6 +815,12 @@ impl ClientShellState { return; } if self.workspace_preview_action_blocked() { + // Bindings cannot run against a foreign preview, so Ctrl+[ has + // nothing to lose to and cancels here the same way Esc does. + if is_ctrl_bracket_key(key) { + self.cancel_navigate(outcome); + return; + } self.push_endpoint_notice( ClientEndpointNoticeKind::Rejected, "navigate_endpoint_inactive", @@ -839,9 +950,17 @@ impl ClientShellState { }); if let Some(binding) = binding { self.record_navigate_binding(binding, false, outcome); + } else if is_ctrl_bracket_key(key) { + self.cancel_navigate(outcome); } } + fn cancel_navigate(&mut self, outcome: &mut ClientShellInput) { + self.mode = self.copy_or_terminal_mode(); + self.navigate_workspace_id = None; + outcome.repaint = true; + } + fn record_navigate_binding( &mut self, binding: crate::input::KeybindMatch, @@ -1028,6 +1147,48 @@ impl ClientShellState { .map(crate::protocol::ClientClipboardImageTarget::Pane) } + /// The tracked press for a release. A press that arrived as text ("A") is + /// reported released as its key ("a", shifted alternate "A"), possibly after + /// Shift was let go, so the shifted alternate and the letter's other case + /// also match. + fn take_release_lease( + &mut self, + lease_key: &crate::input::InputLeaseKey, + key: &crate::input::TerminalKey, + ) -> Option> { + if let Some(lease) = self.input_leases.remove(lease_key) { + return Some(lease); + } + let KeyCode::Char(c) = key.layout_key().map_or(key.code, KeyCode::Char) else { + return None; + }; + fn single(mut chars: impl Iterator) -> Option { + match (chars.next(), chars.next()) { + (Some(only), None) => Some(only), + _ => None, + } + } + let other_case = if c.is_lowercase() { + single(c.to_uppercase()) + } else { + single(c.to_lowercase()) + } + .filter(|other| *other != c); + key.shifted_codepoint + .and_then(char::from_u32) + .into_iter() + .chain(other_case) + .find_map(|candidate| { + self.input_leases.remove(&crate::input::InputLeaseKey::new( + LOCAL_INPUT_SOURCE, + &crate::input::TerminalKey::new(KeyCode::Char(candidate), key.modifiers), + )) + }) + // Shifted punctuation ("?") released after Shift: the report names + // only the unshifted key ("/"), which depends on the layout. + .or_else(|| self.input_leases.remove_sole_text_press(LOCAL_INPUT_SOURCE)) + } + fn popup_input_target(&self) -> Option { self.popup_terminal_id .as_ref() diff --git a/src/client/shell/link_hover.rs b/src/client/shell/link_hover.rs index 1a89d706..efbc0ee4 100644 --- a/src/client/shell/link_hover.rs +++ b/src/client/shell/link_hover.rs @@ -38,7 +38,8 @@ impl ClientShellState { } fn link_hover_allowed(&self) -> bool { - self.mode == ClientShellMode::Terminal + self.config.mouse_capture + && self.mode == ClientShellMode::Terminal && self.overlay.is_none() && self.popup_terminal_id.is_none() && self.outer_focused != Some(false) diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index fe84b605..822fe553 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -518,14 +518,27 @@ impl ClientShellState { { return None; } + let snapshot = self.snapshot.as_deref()?; let mut slots = self .hits .workspaces .iter() .filter(|hit| hit.endpoint_id == self.active_endpoint_id && !hit.indented) + .filter(|hit| { + hit.group_toggle.as_ref().is_none_or(|(_, key)| { + snapshot + .workspaces + .iter() + .find(|workspace| { + workspace.worktree.as_ref().is_some_and(|worktree| { + worktree.key == *key && !worktree.is_linked_worktree + }) + }) + .is_some_and(|workspace| workspace.workspace_id == hit.workspace_id) + }) + }) .map(|hit| (Some(hit.workspace_id.clone()), hit.rect.y.saturating_sub(1))) .collect::>(); - let snapshot = self.snapshot.as_deref()?; let empty_collapsed_groups = HashSet::new(); let collapsed_groups = self .collapsed_groups_for_endpoint(&self.active_endpoint_id) @@ -544,7 +557,15 @@ impl ClientShellState { .is_some_and(|workspace| workspace.workspace_id == last_hit.workspace_id) })?; let next = entries.get(last_position + 1); - if !next.is_some_and(|entry| entry.indented) { + if !next.is_some_and(|entry| { + entry.indented + || last_hit.group_toggle.as_ref().is_some_and(|(_, key)| { + snapshot.workspaces[entry.index] + .worktree + .as_ref() + .is_some_and(|worktree| worktree.key == *key) + }) + }) { let before = next.and_then(|entry| { snapshot .workspaces @@ -607,7 +628,7 @@ impl ClientShellState { .position(|workspace| workspace.workspace_id == target)?, None => remaining.len(), }; - if insert_position == source_position { + if source.worktree.is_none() && insert_position == source_position { return None; } @@ -626,7 +647,11 @@ impl ClientShellState { }) .map(|workspace| workspace.workspace_id.clone()), ) - .collect(); + .collect::>(); + if before_workspace_id.is_some_and(|target| workspace_ids.iter().any(|id| id == target)) + { + return None; + } Some(crate::api::schema::Method::WorkspaceMoveBlock( crate::api::schema::WorkspaceMoveBlockParams { workspace_ids, @@ -651,6 +676,10 @@ impl ClientShellState { } } + /// Routes mouse input through overlays, shell controls, and pane interactions. + /// + /// Hit-test order determines which overlapping control receives the event; + /// the sidebar toggle takes precedence over the agent scrollbar beneath it. pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { self.update_link_hover(mouse, outcome); let point = (mouse.column, mouse.row); @@ -916,7 +945,8 @@ impl ClientShellState { if self.popup_terminal_id.is_some() { return; } - if !self.replaying_url_click + if self.config.mouse_capture + && !self.replaying_url_click && self.overlay.is_none() && self.mode == ClientShellMode::Terminal && mouse.kind == MouseEventKind::Down(MouseButton::Left) @@ -2032,9 +2062,17 @@ impl ClientShellState { self.workspace_press = None; self.tab_press = None; self.chrome_drag = None; - if super::contains(self.hits.sidebar_divider, point) - && !super::contains(self.hits.sidebar_toggle, point) - { + // The toggle is painted over the agent scrollbar's last cell. + if super::contains(self.hits.sidebar_toggle, point) { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + return; + } + if super::contains(self.hits.sidebar_divider, point) { let now = std::time::Instant::now(); let double_click = self.last_sidebar_divider_click.is_some_and(|last| { now.duration_since(last) <= std::time::Duration::from_millis(350) diff --git a/src/client/shell/notification_policy.rs b/src/client/shell/notification_policy.rs index f3eeeb3c..10ab3d4b 100644 --- a/src/client/shell/notification_policy.rs +++ b/src/client/shell/notification_policy.rs @@ -229,9 +229,21 @@ impl ClientShellState { }); } crate::config::ToastDelivery::System if !suppress_external => { + #[cfg(windows)] + let target = pending.event.pane_id.as_ref().and_then(|pane_id| { + self.endpoint_boot_id(&pending.endpoint_id).map(|boot_id| { + ClientSystemNotificationTarget { + endpoint_id: pending.endpoint_id.clone(), + boot_id: boot_id.to_owned(), + pane_id: pane_id.clone(), + } + }) + }); effects.push(ClientShellNotificationEffect::System { title: pending.event.title, body: pending.event.body, + #[cfg(windows)] + target, }); } crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {} @@ -240,6 +252,46 @@ impl ClientShellState { (effects, repaint) } + #[cfg(windows)] + pub(crate) fn notification_target_is_current( + &self, + endpoint_id: &ClientEndpointId, + target: &ClientEndpointFocusTarget, + ) -> bool { + let ClientEndpointFocusTarget::Notification { pane_id, boot_id } = target else { + return true; + }; + self.endpoint_is_online(endpoint_id) + && self + .endpoints + .iter() + .find(|endpoint| &endpoint.endpoint_id == endpoint_id) + .and_then(|endpoint| endpoint.snapshot.as_deref()) + .is_some_and(|snapshot| { + snapshot.boot_id == *boot_id + && snapshot.panes.iter().any(|pane| pane.pane_id == *pane_id) + }) + } + + #[cfg(windows)] + pub(crate) fn activate_system_notification( + &mut self, + target: ClientSystemNotificationTarget, + ) -> ClientShellInput { + let focus = ClientEndpointFocusTarget::Notification { + pane_id: target.pane_id, + boot_id: target.boot_id, + }; + let mut outcome = ClientShellInput::default(); + if self.notification_target_is_current(&target.endpoint_id, &focus) { + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id: target.endpoint_id, + target: Some(focus), + }); + } + outcome + } + fn notification_target_is_active( &self, endpoint_id: &ClientEndpointId, diff --git a/src/client/shell/overlay_input.rs b/src/client/shell/overlay_input.rs index 3072ce0c..64b83fd1 100644 --- a/src/client/shell/overlay_input.rs +++ b/src/client/shell/overlay_input.rs @@ -717,7 +717,14 @@ impl ClientShellState { outcome.repaint = true; return; } - if code == KeyCode::Char('/') && modifiers.is_empty() { + // Match the typed character so layouts where `/` needs Shift work + // when the host reports it as a shifted key (report-all panes). + // A shifted key needs the host's text or shifted alternate: bare + // `CSI 47;2u` is Shift+/, which is not `/` on most layouts. + let shifted_without_evidence = key.modifiers.contains(KeyModifiers::SHIFT) + && key.generated_text.is_none() + && key.shifted_codepoint.is_none(); + if !shifted_without_evidence && crate::input::keybind_help_text_char(key) == Some('/') { if let Some(ClientShellOverlay::Navigator(navigator)) = self.overlay.as_mut() { navigator.search_focused = true; navigator.filter = None; @@ -1046,6 +1053,67 @@ impl ClientShellState { outcome.repaint = true; } + pub(super) fn request_workspace_close( + &mut self, + workspace_id: String, + close_group: Option, + outcome: &mut ClientShellInput, + ) { + if self.config.confirm_close { + self.open_close_confirmation(workspace_id, None, close_group); + return; + } + let close_group = close_group.unwrap_or_else(|| { + self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.workspaces.iter().any(|workspace| { + workspace.workspace_id == workspace_id + && super::sidebar::workspace_close_is_group(snapshot, workspace) + }) + }) + }); + self.push_endpoint_method( + crate::api::schema::Method::WorkspaceClose(crate::api::schema::WorkspaceCloseParams { + workspace_id, + close_group, + }), + outcome, + ); + } + + pub(super) fn request_pane_close(&mut self, pane_id: String, outcome: &mut ClientShellInput) { + let target = self.snapshot.as_deref().and_then(|snapshot| { + let pane = snapshot.panes.iter().find(|pane| pane.pane_id == pane_id)?; + let tab = snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == pane.tab_id && tab.workspace_id == pane.workspace_id)?; + (self.config.confirm_close + && !snapshot + .panes + .iter() + .any(|other| other.tab_id == tab.tab_id && other.pane_id != pane_id) + && !snapshot.tabs.iter().any(|other| { + other.workspace_id == tab.workspace_id && other.tab_id != tab.tab_id + })) + .then(|| (tab.workspace_id.clone(), tab.tab_id.clone())) + }); + if let Some((workspace_id, tab_id)) = target { + if self.open_close_confirmation(workspace_id, Some(tab_id), None) { + if let Some(ClientShellOverlay::ConfirmClose(confirm)) = self.overlay.as_mut() { + if let Some(target) = confirm.tab_target.as_mut() { + target.pane_id = Some(pane_id); + } + } + outcome.repaint = true; + return; + } + } + self.push_endpoint_method( + crate::api::schema::Method::PaneClose(crate::api::schema::PaneTarget { pane_id }), + outcome, + ); + } + pub(super) fn request_tab_close(&mut self, tab_id: String, outcome: &mut ClientShellInput) { let workspace_id = self.snapshot.as_deref().and_then(|snapshot| { let target = snapshot.tabs.iter().find(|tab| tab.tab_id == tab_id)?; @@ -1057,7 +1125,7 @@ impl ClientShellState { .then(|| target.workspace_id.clone()) }); if let Some(workspace_id) = workspace_id { - if self.open_close_confirmation(workspace_id, Some(tab_id.clone())) { + if self.open_close_confirmation(workspace_id, Some(tab_id.clone()), None) { outcome.repaint = true; return; } @@ -1082,29 +1150,47 @@ impl ClientShellState { && tab.workspace_id == target.workspace.workspace_id }) }) + || target.pane_id.as_ref().is_some_and(|pane_id| { + !self.snapshot.as_deref().is_some_and(|snapshot| { + snapshot.panes.iter().any(|pane| { + pane.pane_id == *pane_id + && pane.tab_id == target.tab_id + && pane.workspace_id == target.workspace.workspace_id + }) + }) + }) { self.receive_endpoint_unavailable( - "Close target changed; try closing the tab again".into(), + "Close target changed; try closing it again".into(), ); return; } - crate::api::schema::Method::TabClose(crate::api::schema::TabTarget { - tab_id: target.tab_id, - }) + if let Some(pane_id) = target.pane_id { + crate::api::schema::Method::PaneClose(crate::api::schema::PaneTarget { pane_id }) + } else { + crate::api::schema::Method::TabClose(crate::api::schema::TabTarget { + tab_id: target.tab_id, + }) + } } else { crate::api::schema::Method::WorkspaceClose(crate::api::schema::WorkspaceCloseParams { workspace_id: confirm.workspace_id, - close_group: true, + close_group: confirm.close_group, }) }; self.push_endpoint_method(method, outcome); } pub(super) fn open_confirm_close_overlay(&mut self, workspace_id: String) { - self.open_close_confirmation(workspace_id, None); + self.open_close_confirmation(workspace_id, None, None); } - fn open_close_confirmation(&mut self, workspace_id: String, tab_id: Option) -> bool { + fn open_close_confirmation( + &mut self, + workspace_id: String, + tab_id: Option, + close_group: Option, + ) -> bool { let Some(snapshot) = self.snapshot.as_deref() else { return false; }; @@ -1118,7 +1204,11 @@ impl ClientShellState { let group_key = workspace .worktree .as_ref() - .filter(|worktree| !worktree.is_linked_worktree) + .filter(|_| { + close_group.unwrap_or_else(|| { + super::sidebar::workspace_close_is_group(snapshot, workspace) + }) + }) .map(|worktree| worktree.key.as_str()); let group = group_key .map(|key| { @@ -1144,7 +1234,11 @@ impl ClientShellState { else { return false; }; - Some(ClientTabCloseConfirmation { tab_id, workspace }) + Some(ClientTabCloseConfirmation { + tab_id, + workspace, + pane_id: None, + }) } else { None }; @@ -1171,6 +1265,7 @@ impl ClientShellState { self.overlay = Some(ClientShellOverlay::ConfirmClose( ClientConfirmCloseOverlay { workspace_id, + close_group: closes_group, tab_target, title: if closes_group { "Close worktree group?".to_owned() diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index 1ca652b1..47349a8b 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -65,7 +65,7 @@ pub(crate) fn render_client_overlay( } } match o { - ClientShellOverlay::Onboarding => render_onboarding_overlay(b, p), + ClientShellOverlay::Onboarding => render_onboarding_overlay(b, k, p), ClientShellOverlay::ProductAnnouncement(v) => render_product_announcement_overlay(b, v, p), ClientShellOverlay::ReleaseNotes(v) => { render_release_notes_overlay(b, v, &s.update_install_command, p) @@ -908,7 +908,11 @@ fn panel( Some(Rect::new(a.x + 1, a.y + 1, a.width - 2, a.height - 2)) } fn popup(a: Rect, w: u16, h: u16) -> Option { - let w = w.min(a.width.saturating_sub(4)); + popup_with_width_cap(a, w, h, 4) +} + +fn popup_with_width_cap(a: Rect, w: u16, h: u16, width_cap: u16) -> Option { + let w = w.min(a.width.saturating_sub(width_cap)); let h = h.min(a.height.saturating_sub(2)); if w < 4 || h < 4 { return None; @@ -1179,8 +1183,18 @@ fn render_product_announcement_overlay( }) } -fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option { - let outer = popup(b.area, 64, 16)?; +fn render_onboarding_overlay( + b: &mut Buffer, + k: &LiveKeybindConfig, + p: &Palette, +) -> Option { + let prefix = k.primary_prefix_label(); + let hint_width = display_width(" ") + + display_width(&prefix) + + display_width(crate::ui::ONBOARDING_PREFIX_SUFFIX) + + display_width(crate::ui::ONBOARDING_HELP_LABEL) + + display_width(crate::ui::ONBOARDING_HELP_SUFFIX); + let outer = popup_with_width_cap(b.area, (hint_width + 5).max(64), 16, 3)?; let inner = panel(b, outer, p.accent, p.panel_bg)?; if inner.height < 11 { return Some(OverlayRender { @@ -1230,7 +1244,7 @@ fn render_onboarding_overlay(b: &mut Buffer, p: &Palette) -> Option PathBuf { .join(format!("local-{hash:016x}.json")) } +/// SSH preferences belong to the target/session, not its per-process bridge socket. +pub(super) fn path_for_remote_endpoint(target: &str, session: &str) -> PathBuf { + use sha2::{Digest as _, Sha256}; + + let mut hash = Sha256::new(); + for part in [target, session] { + hash.update((part.len() as u64).to_le_bytes()); + hash.update(part.as_bytes()); + } + crate::config::state_dir() + .join("client-shell") + .join(format!("remote-{:x}.json", hash.finalize())) +} + pub(super) fn load(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; serde_json::from_str(&content).ok() @@ -82,6 +96,22 @@ mod tests { assert_ne!(first, second); } + #[test] + fn remote_preferences_distinguish_targets_sessions_and_component_boundaries() { + let original = path_for_remote_endpoint("dev@build", "agents"); + assert_eq!(original, path_for_remote_endpoint("dev@build", "agents")); + assert_ne!(original, path_for_remote_endpoint("other@build", "agents")); + assert_ne!(original, path_for_remote_endpoint("dev@build", "other")); + assert_ne!( + path_for_remote_endpoint("ab", "c"), + path_for_remote_endpoint("a", "bc") + ); + assert_ne!( + original, + path_for_local_endpoint(Path::new("dev@build/agents")) + ); + } + #[test] fn legacy_preferences_default_remote_collapses() { let preferences: ClientChromePreferences = diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs index 6feea7b3..efbb13b9 100644 --- a/src/client/shell/sidebar.rs +++ b/src/client/shell/sidebar.rs @@ -519,13 +519,17 @@ pub(crate) fn workspace_entries_with_filter( let grouped = members .iter() .filter(|(_, indices)| { - indices.len() >= 2 - && indices.iter().any(|index| { - snapshot.workspaces[*index] - .worktree - .as_ref() - .is_some_and(|worktree| !worktree.is_linked_worktree) - }) + indices.iter().any(|index| { + snapshot.workspaces[*index] + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) && indices.iter().any(|index| { + snapshot.workspaces[*index] + .worktree + .as_ref() + .is_some_and(|worktree| !worktree.is_linked_worktree) + }) }) .map(|(key, _)| *key) .collect::>(); @@ -552,29 +556,40 @@ pub(crate) fn workspace_entries_with_filter( let Some(group_members) = members.get(worktree.key.as_str()) else { continue; }; - let parent = group_members + let parents = group_members .iter() .copied() - .find(|member| { + .filter(|member| { snapshot.workspaces[*member] .worktree .as_ref() .is_some_and(|worktree| !worktree.is_linked_worktree) }) - .unwrap_or(index); - let parent_visible = filter(&snapshot.workspaces[parent]); - if parent_visible { + .collect::>(); + let visible_parents = parents + .iter() + .copied() + .filter(|parent| filter(&snapshot.workspaces[*parent])) + .collect::>(); + for parent in visible_parents.iter().copied() { entries.push(WorkspaceEntry { index: parent, indented: false, last_child: false, }); } - if !parent_visible { + if visible_parents.is_empty() { + // A hidden repository parent must not erase its visible linked worktrees. let children = group_members .iter() .copied() - .filter(|member| *member != parent && filter(&snapshot.workspaces[*member])) + .filter(|member| { + filter(&snapshot.workspaces[*member]) + && snapshot.workspaces[*member] + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) .collect::>(); for child in children { entries.push(WorkspaceEntry { @@ -587,9 +602,13 @@ pub(crate) fn workspace_entries_with_filter( } if collapsed_groups.contains(&worktree.key) { if let Some(active) = group_members.iter().copied().find(|member| { - *member != parent - && snapshot.workspaces[*member].focused - && filter(&snapshot.workspaces[*member]) + let workspace = &snapshot.workspaces[*member]; + workspace.focused + && filter(workspace) + && workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) }) { entries.push(WorkspaceEntry { index: active, @@ -602,7 +621,13 @@ pub(crate) fn workspace_entries_with_filter( let children = group_members .iter() .copied() - .filter(|member| *member != parent && filter(&snapshot.workspaces[*member])) + .filter(|member| { + filter(&snapshot.workspaces[*member]) + && snapshot.workspaces[*member] + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + }) .collect::>(); for (child_index, child) in children.iter().enumerate() { entries.push(WorkspaceEntry { @@ -628,20 +653,47 @@ pub(super) fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> if worktree.is_linked_worktree { return None; } - (snapshot + snapshot .workspaces .iter() - .filter(|candidate| { - candidate - .worktree - .as_ref() - .is_some_and(|candidate| candidate.key == worktree.key) + .any(|candidate| { + candidate.worktree.as_ref().is_some_and(|candidate| { + candidate.key == worktree.key && candidate.is_linked_worktree + }) }) - .count() - >= 2) .then(|| worktree.key.clone()) } +pub(in crate::client::shell) fn workspace_close_is_group( + snapshot: &ClientShellSnapshot, + workspace: &ClientShellWorkspace, +) -> bool { + let Some(worktree) = workspace + .worktree + .as_ref() + .filter(|worktree| !worktree.is_linked_worktree) + else { + return false; + }; + let mut has_child = false; + for member in &snapshot.workspaces { + if member.workspace_id == workspace.workspace_id { + continue; + } + if let Some(candidate) = member + .worktree + .as_ref() + .filter(|candidate| candidate.key == worktree.key) + { + if !candidate.is_linked_worktree { + return false; + } + has_child = true; + } + } + has_child +} + pub(in crate::client::shell) fn render_parent_group_toggle( buffer: &mut Buffer, workspace_rect: Rect, @@ -691,10 +743,11 @@ pub(in crate::client::shell) fn displayed_workspace_status( .workspaces .iter() .filter(|candidate| { - candidate - .worktree - .as_ref() - .is_some_and(|candidate| candidate.key == worktree.key) + candidate.worktree.as_ref().is_some_and(|member| { + member.key == worktree.key + && (member.is_linked_worktree + || candidate.workspace_id == workspace.workspace_id) + }) }) .map(|candidate| candidate.agent_status) .max_by_key(|status| status_priority(*status)) diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index 8342a5a4..04cc7791 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -570,6 +570,7 @@ pub(super) enum ClientContextMenuTarget { is_git: bool, is_linked_worktree: bool, has_worktree_children: bool, + close_group: bool, collapsed: bool, can_clear_focus: bool, }, @@ -661,6 +662,7 @@ pub(super) struct ClientContextMenuItem { #[derive(Debug)] pub(super) struct ClientTabCloseConfirmation { + pub(super) pane_id: Option, pub(super) tab_id: String, pub(super) workspace: WorkspaceNavigationTarget, } @@ -668,6 +670,7 @@ pub(super) struct ClientTabCloseConfirmation { #[derive(Debug)] pub(super) struct ClientConfirmCloseOverlay { pub(super) workspace_id: String, + pub(super) close_group: bool, pub(super) tab_target: Option, pub(super) title: String, pub(super) detail: String, @@ -867,9 +870,19 @@ pub(crate) enum ClientShellNotificationEffect { System { title: String, body: Option, + #[cfg(windows)] + target: Option, }, } +#[cfg(windows)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ClientSystemNotificationTarget { + pub(crate) endpoint_id: ClientEndpointId, + pub(crate) boot_id: String, + pub(crate) pane_id: String, +} + pub(super) struct ClientPendingNotification { pub(super) endpoint_id: ClientEndpointId, pub(super) event: SemanticNotification, @@ -1043,6 +1056,15 @@ pub(crate) struct ClientShellState { pub(super) pane_mouse_gesture: Option, pub(super) link_hover: Option, pub(super) url_click_consumes_until_up: bool, + /// The host terminal reports key releases (Kitty event types), so text + /// presses can be tracked until their release arrives. + pub(super) host_reports_key_releases: bool, + /// The host tty's erase character is `^H`: a raw 0x08 is Backspace, not + /// Ctrl+H (MobaXterm, PuTTY-style terminals; tmux reads VERASE the same way). + pub(super) host_erase_is_ctrl_h: bool, + /// The focused pane asks for every key as an escape code, so Herdr pushed + /// report-all to the host; plain text input then reaches it as text. + pub(super) host_reports_all_keys: bool, pub(super) replaying_url_click: bool, pub(super) selection: Option>, pub(super) last_pane_click: Option, @@ -1222,6 +1244,9 @@ impl ClientShellState { pane_mouse_gesture: None, link_hover: None, url_click_consumes_until_up: false, + host_reports_key_releases: false, + host_erase_is_ctrl_h: false, + host_reports_all_keys: false, replaying_url_click: false, selection: None, last_pane_click: None, diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs index 5b8b1224..df09f464 100644 --- a/src/client/shell/tests/agents_worktrees_notifications.rs +++ b/src/client/shell/tests/agents_worktrees_notifications.rs @@ -170,6 +170,125 @@ fn grouped_worktrees_render_parent_branch_and_indented_child() { )); } +#[test] +fn duplicate_repo_parents_remain_visible_and_focusable_when_collapsed() { + for linked_child in [false, true] { + let mut projected = snapshot(); + projected.workspaces[0].agent_status = AgentStatus::Idle; + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + let mut duplicate = projected.workspaces[0].clone(); + duplicate.workspace_id = "ws_duplicate".into(); + duplicate.label = "duplicate-repo".into(); + duplicate.focused = false; + duplicate.agent_status = AgentStatus::Blocked; + projected.workspaces.push(duplicate); + if linked_child { + let mut child = projected.workspaces[0].clone(); + child.workspace_id = "ws_child".into(); + child.focused = false; + child.worktree.as_mut().unwrap().is_linked_worktree = true; + projected.workspaces.push(child); + } + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + for collapsed in [false, true] { + if collapsed { + state.collapsed_groups.insert("repo".into()); + } + let frame = state + .compose(106, 24) + .expect("duplicate repository parents"); + for workspace_id in ["ws_1", "ws_duplicate"] { + let hit = state + .hits + .workspaces + .iter() + .find(|hit| hit.workspace_id == workspace_id) + .expect("repository parent remains visible"); + assert!(!hit.indented); + assert_eq!(hit.group_toggle.is_some(), linked_child); + } + let parent = state.hits.workspaces[0].rect; + let status_cell = + usize::from(parent.y) * usize::from(frame.width) + usize::from(parent.x + 1); + assert_ne!( + frame.cells[status_cell].fg, + crate::protocol::color_to_u32(state.config.palette.red) + ); + assert_eq!( + state + .hits + .workspaces + .iter() + .any(|hit| hit.workspace_id == "ws_child" && hit.indented), + linked_child && !collapsed + ); + let duplicate = state.hits.workspaces[1].rect; + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: duplicate.x + 2, + row: duplicate.y, + modifiers: KeyModifiers::empty(), + })]); + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: duplicate.x + 2, + row: duplicate.y, + modifiers: KeyModifiers::empty(), + })]); + assert!(matches!(click.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_duplicate"))); + } + let original = state.snapshot.as_deref().unwrap().clone(); + for confirm_close in [false, true] { + let mut restored = original.clone(); + restored.revision = state.snapshot.as_deref().unwrap().revision + 1; + state.set_snapshot(Box::new(restored)); + state.config.confirm_close = confirm_close; + state.open_workspace_context_menu("ws_duplicate".into(), 0, 0); + let Some(ClientShellOverlay::ContextMenu(menu)) = state.overlay.as_ref() else { + panic!("repository workspace context menu"); + }; + assert_eq!(menu.items()[1].label, "Close"); + assert_eq!( + menu.items() + .iter() + .any(|item| matches!(item.action, ClientContextMenuAction::ToggleGroup)), + linked_child + ); + if linked_child { + let mut replacement = original.clone(); + replacement.revision = state.snapshot.as_deref().unwrap().revision + 1; + replacement + .workspaces + .retain(|workspace| workspace.workspace_id != "ws_1"); + state.set_snapshot(Box::new(replacement)); + } + let mut close = ClientShellInput::default(); + state.activate_context_menu_item(1, &mut close); + if confirm_close { + assert!(close.actions.is_empty()); + assert!(matches!(state.overlay.as_ref(), + Some(ClientShellOverlay::ConfirmClose(confirm)) + if confirm.title == "Close workspace?" && !confirm.close_group)); + close = state.handle_input_bytes(b"\r"); + } + assert!(matches!(close.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::WorkspaceClose(params) + if params.workspace_id == "ws_duplicate" && !params.close_group))); + } + } +} + #[test] fn workspace_click_waits_for_release_and_drag_reorders_by_stable_id() { let mut projected = snapshot(); @@ -257,6 +376,59 @@ fn workspace_click_waits_for_release_and_drag_reorders_by_stable_id() { )); } +#[test] +fn duplicate_repo_parent_drag_does_not_target_its_own_move_block() { + let mut projected = snapshot(); + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: false, + }); + let mut other = projected.workspaces[0].clone(); + other.workspace_id = "ws_other".into(); + other.worktree = None; + other.focused = false; + let mut duplicate = projected.workspaces[0].clone(); + duplicate.workspace_id = "ws_duplicate".into(); + duplicate.focused = false; + projected.workspaces.extend([other, duplicate]); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state + .compose(106, 24) + .expect("separated repository parents"); + let source = state.hits.workspaces[0].rect; + let target = state.hits.workspaces[2].rect; + for (kind, rect, row) in [ + (MouseEventKind::Down(MouseButton::Left), source, source.y), + ( + MouseEventKind::Drag(MouseButton::Left), + target, + target.y.saturating_sub(1), + ), + ] { + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind, + column: rect.x + 2, + row, + modifiers: KeyModifiers::empty(), + })]); + } + assert!( + matches!(state.chrome_drag.as_ref(), Some(ClientChromeDrag::Workspace { + target: Some((Some(workspace_id), _)), .. + }) if workspace_id == "ws_duplicate") + ); + let drop = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: target.x + 2, + row: target.y.saturating_sub(1), + modifiers: KeyModifiers::empty(), + })]); + assert!(drop.actions.is_empty()); +} + #[test] fn workspace_drag_moves_parent_worktree_as_one_block_and_rejects_child() { let mut projected = snapshot(); @@ -337,6 +509,54 @@ fn workspace_drag_moves_parent_worktree_as_one_block_and_rejects_child() { })]); assert!(dragging_child.actions.is_empty()); assert!(state.chrome_drag.is_none()); + + let mut projected = state.snapshot.as_deref().unwrap().clone(); + let mut duplicate = projected.workspaces[0].clone(); + duplicate.workspace_id = "ws_duplicate".into(); + duplicate.focused = false; + projected.workspaces.push(duplicate); + state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state + .compose(106, 24) + .expect("repository block with duplicate root"); + let duplicate = state.hits.workspaces[1].rect; + let other = state.hits.workspaces[3].rect; + for (source, target_row, moving_duplicate) in [ + (other, duplicate.y.saturating_sub(1), false), + (duplicate, other.bottom(), true), + ] { + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: source.x + 2, + row: source.y, + modifiers: KeyModifiers::empty(), + })]); + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Drag(MouseButton::Left), + column: source.x + 2, + row: target_row, + modifiers: KeyModifiers::empty(), + })]); + let moved = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + column: source.x + 2, + row: target_row, + modifiers: KeyModifiers::empty(), + })]); + if moving_duplicate { + assert!(matches!(moved.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::WorkspaceMoveBlock(params) + if params.workspace_ids == ["ws_duplicate", "ws_1", "ws_child"] && params.before_workspace_id.is_none()))); + } else { + assert!(matches!(moved.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::WorkspaceMove(params) + if params.workspace_id == "ws_other" && params.insert_index == 0))); + } + } } #[test] @@ -855,7 +1075,7 @@ fn workspace_actions_preserve_selected_target_and_client_confirmation() { assert!(matches!( &request.method, crate::api::schema::Method::WorkspaceClose(params) - if params.workspace_id == "ws_2" && params.close_group + if params.workspace_id == "ws_2" && !params.close_group )); } diff --git a/src/client/shell/tests/chrome_context.rs b/src/client/shell/tests/chrome_context.rs index bc121a94..66eab767 100644 --- a/src/client/shell/tests/chrome_context.rs +++ b/src/client/shell/tests/chrome_context.rs @@ -536,13 +536,17 @@ fn new_tab_overlay_owns_text_cursor_and_submits_public_api_request() { } #[test] -fn close_confirmation_error_becomes_client_owned_overlay_and_stable_group_close() { +fn close_confirmation_error_becomes_client_owned_overlay_and_stable_workspace_close() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); state.set_pane_surface(surface()); let mut close = ClientShellInput::default(); - state.record_binding( - crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClosePane), + // Exercise the server error fallback directly: last-pane keybindings now + // confirm locally before issuing a request. + state.push_endpoint_method( + crate::api::schema::Method::PaneClose(crate::api::schema::PaneTarget { + pane_id: "pane_1".into(), + }), &mut close, ); let [ClientShellAction::Endpoint { request, .. }] = &close.actions[..] else { @@ -582,6 +586,6 @@ fn close_confirmation_error_becomes_client_owned_overlay_and_stable_group_close( assert!(matches!( &request.method, crate::api::schema::Method::WorkspaceClose(params) - if params.workspace_id == "ws_1" && params.close_group + if params.workspace_id == "ws_1" && !params.close_group )); } diff --git a/src/client/shell/tests/close_pane.rs b/src/client/shell/tests/close_pane.rs new file mode 100644 index 00000000..970afe22 --- /dev/null +++ b/src/client/shell/tests/close_pane.rs @@ -0,0 +1,292 @@ +use super::*; +use crate::api::schema::Method; +use crossterm::event::{MouseButton, MouseEventKind}; + +fn close_state(confirm: bool, tab_count: usize) -> ClientShellState { + let mut projected = snapshot(); + for number in 2..=tab_count { + let mut tab = projected.tabs[0].clone(); + tab.tab_id = format!("tab_{number}"); + tab.number = number; + tab.focused = false; + projected.tabs.push(tab); + } + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.confirm_close = confirm; + state.set_snapshot(Box::new(projected)); + state.set_pane_surface(surface()); + state.compose(106, 24).unwrap(); + state +} + +fn click(state: &mut ClientShellState, rect: Rect) -> ClientShellInput { + state.handle_raw_events(vec![crate::raw_input::RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: rect.x, + row: rect.y, + modifiers: KeyModifiers::empty(), + })]) +} + +fn request_close(state: &mut ClientShellState, menu: bool) -> ClientShellInput { + if menu { + let pane = state.hits.panes[0].rect; + state.handle_raw_events(vec![crate::raw_input::RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: pane.x + 1, + row: pane.y, + modifiers: KeyModifiers::empty(), + })]); + state.compose(106, 24).unwrap(); + let index = match state.overlay.as_ref() { + Some(ClientShellOverlay::ContextMenu(menu)) => menu + .items() + .iter() + .position(|item| item.action == ClientContextMenuAction::ClosePane) + .unwrap(), + _ => panic!("pane menu"), + }; + let close_row = state.hits.context_menu_rows[index].0; + click(state, close_row) + } else { + let mut outcome = ClientShellInput::default(); + state.record_binding( + crate::input::KeybindMatch::Action(crate::input::KeybindAction::ClosePane), + &mut outcome, + ); + outcome + } +} + +fn assert_no_close(outcome: &ClientShellInput) { + assert!(outcome.requests.is_empty()); + assert!(outcome.actions.iter().all(|action| { + !matches!(action, ClientShellAction::Endpoint { request, .. } + if matches!(request.method, Method::PaneClose(_) | Method::TabClose(_) | Method::WorkspaceClose(_))) + })); +} + +fn assert_pane_close(outcome: &ClientShellInput) { + let methods = outcome + .actions + .iter() + .filter_map(|action| match action { + ClientShellAction::Endpoint { request, .. } => Some(&request.method), + _ => None, + }) + .filter(|method| !matches!(method, Method::TabFocus(_) | Method::PaneFocus(_))) + .collect::>(); + assert!( + matches!(methods.as_slice(), [Method::PaneClose(target)] if target.pane_id == "pane_1") + ); +} + +#[test] +fn last_pane_close_waits_for_keyboard_or_mouse_confirmation() { + for menu in [false, true] { + let mut state = close_state(true, 1); + let requested = request_close(&mut state, menu); + assert_no_close(&requested); + assert!(requested.repaint); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let frame = state.compose(106, 24).unwrap(); + let text = frame_rows(&frame).join("\n"); + assert!(text.contains("Close workspace?")); + assert!(text.contains("1 pane")); + let accepted = if menu { + let primary = state.hits.overlay_primary; + click(&mut state, primary) + } else { + state.handle_input_bytes(b"\r") + }; + assert_pane_close(&accepted); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn last_pane_close_confirmation_can_be_cancelled() { + for mouse in [false, true] { + let mut state = close_state(true, 1); + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + state.compose(106, 24).unwrap(); + let cancelled = if mouse { + let cancel = state.hits.overlay_cancel; + click(&mut state, cancel) + } else { + state.handle_input_bytes(b"\x1b") + }; + assert_no_close(&cancelled); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn pane_close_stays_immediate_with_confirmation_disabled_or_other_tabs() { + for (confirm, tabs) in [(false, 1), (false, 2), (true, 2)] { + for menu in [false, true] { + let mut state = close_state(confirm, tabs); + assert_pane_close(&request_close(&mut state, menu)); + assert!(state.overlay.is_none()); + } + } +} + +#[test] +fn last_pane_confirmation_preserves_target_across_focus_changes_and_new_tabs() { + let mut state = close_state(true, 1); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + let mut other_workspace = projected.workspaces[0].clone(); + other_workspace.workspace_id = "ws_2".into(); + other_workspace.active_tab_id = "other_tab".into(); + other_workspace.focused = false; + projected.workspaces.push(other_workspace); + let mut other_tab = projected.tabs[0].clone(); + other_tab.workspace_id = "ws_2".into(); + other_tab.tab_id = "other_tab".into(); + other_tab.focused = false; + projected.tabs.push(other_tab); + state.set_snapshot(Box::new(projected.clone())); + + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let mut new_tab = projected.tabs[0].clone(); + new_tab.tab_id = "new_tab".into(); + new_tab.focused = false; + projected.tabs.push(new_tab); + let mut new_pane = projected.panes[0].clone(); + new_pane.pane_id = "new_pane".into(); + new_pane.focused = false; + projected.panes.push(new_pane); + projected.focused_pane_id = Some("new_pane".into()); + projected.focused_workspace_id = Some("ws_2".into()); + projected.focused_tab_id = Some("other_tab".into()); + for workspace in &mut projected.workspaces { + workspace.focused = workspace.workspace_id == "ws_2"; + } + for tab in &mut projected.tabs { + tab.focused = tab.tab_id == "other_tab"; + } + state.set_snapshot(Box::new(projected)); + assert_pane_close(&state.handle_input_bytes(b"\r")); +} + +#[test] +fn last_pane_confirmation_rejects_missing_moved_or_reconnected_targets() { + for change in [ + "missing", + "moved", + "reconnected", + "pane_missing", + "pane_moved", + "pane_workspace", + "endpoint", + ] { + let mut state = close_state(true, 1); + assert_no_close(&request_close(&mut state, false)); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + match change { + "missing" => projected.tabs.clear(), + "moved" => projected.tabs[0].workspace_id = "different_workspace".into(), + "reconnected" => state.endpoints[0].snapshot_generation = Some(2), + "pane_missing" => projected.panes.clear(), + "pane_moved" => projected.panes[0].tab_id = "different_tab".into(), + "pane_workspace" => projected.panes[0].workspace_id = "different_workspace".into(), + "endpoint" => { + state.active_endpoint_id = + ClientEndpointId::Ssh(crate::client::endpoint::ProfileId::generate()) + } + _ => unreachable!(), + } + if !matches!(change, "reconnected" | "endpoint") { + state.set_snapshot(Box::new(projected)); + } + assert_no_close(&state.handle_input_bytes(b"\r")); + assert!(state.overlay.is_none()); + } +} + +#[test] +fn last_pane_close_preserves_parent_group_and_linked_workspace_scope() { + for linked in [false, true] { + let mut state = close_state(true, 1); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + projected.workspaces[0].worktree = Some(ClientShellWorktree { + key: "repo".into(), + label: "repo".into(), + is_linked_worktree: linked, + }); + let mut sibling = projected.workspaces[0].clone(); + sibling.workspace_id = "ws_2".into(); + sibling.active_tab_id = "tab_2".into(); + sibling.focused = false; + sibling.worktree.as_mut().unwrap().is_linked_worktree = !linked; + projected.workspaces.push(sibling); + let mut tab = projected.tabs[0].clone(); + tab.tab_id = "tab_2".into(); + tab.workspace_id = "ws_2".into(); + tab.focused = false; + projected.tabs.push(tab); + state.set_snapshot(Box::new(projected)); + + let close = request_close(&mut state, false); + if linked { + assert_no_close(&close); + assert!(matches!( + state.overlay, + Some(ClientShellOverlay::ConfirmClose(_)) + )); + assert_pane_close(&state.handle_input_bytes(b"\r")); + } else { + assert_pane_close(&close); + assert!(state.overlay.is_none()); + let [ClientShellAction::Endpoint { request, .. }] = close.actions.as_slice() else { + panic!("tab close request"); + }; + state.handle_endpoint_result( + "boot-1", + &request.id, + Err(ClientShellEndpointError { + code: Some("confirmation_required".into()), + message: "closing this tab would close a worktree group".into(), + }), + ); + assert!(matches!(state.overlay.as_ref(), + Some(ClientShellOverlay::ConfirmClose(confirm)) if confirm.title == "Close worktree group?")); + let accepted = state.handle_input_bytes(b"\r"); + assert!(matches!(accepted.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, Method::WorkspaceClose(params) + if params.workspace_id == "ws_1" && params.close_group))); + } + } +} + +#[test] +fn ordinary_pane_close_stays_immediate_with_another_pane() { + for menu in [false, true] { + let mut state = close_state(true, 1); + let mut projected = state.snapshot.as_deref().unwrap().clone(); + let mut pane = projected.panes[0].clone(); + pane.pane_id = "pane_2".into(); + pane.focused = false; + projected.panes.push(pane); + state.set_snapshot(Box::new(projected)); + assert_pane_close(&request_close(&mut state, menu)); + assert!(state.overlay.is_none()); + } +} diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 2ec95c5f..08d755e1 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -75,6 +75,206 @@ fn state_with_remote() -> (ClientShellState, ClientEndpointId) { (state, endpoint_id) } +#[test] +fn workspace_navigation_waits_for_the_target_presentation_fence() { + use crate::client::endpoint::{EndpointNegotiation, EndpointRegistry}; + use crate::client::endpoint_commands::EndpointCommands; + + struct Capture(std::sync::Arc>>); + impl crate::client::endpoint::EndpointTransport for Capture { + fn send(&mut self, message: &ClientMessage) -> std::io::Result<()> { + self.0.lock().unwrap().push(message.clone()); + Ok(()) + } + } + + for (key, focused, expected, surface_active) in [ + (KeyCode::Down, "ws_1", "ws_2", true), + (KeyCode::Up, "ws_2", "ws_1", true), + (KeyCode::Down, "ws_1", "ws_2", false), + ] { + let (mut state, remote) = state_with_remote(); + let config: Config = + toml::from_str("[keys]\nnext_workspace = 'ctrl+down'\nprevious_workspace = 'ctrl+up'") + .unwrap(); + state.config = ClientShellConfig::from_config(&config); + let mut projection = snapshot(); + projection.boot_id = "remote-boot".into(); + let mut second = projection.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + projection.workspaces.push(second); + projection.focused_workspace_id = Some(focused.into()); + for workspace in &mut projection.workspaces { + workspace.focused = workspace.workspace_id == focused; + } + state.set_endpoint_snapshot(&remote, Box::new(projection)); + assert!(state.activate_endpoint_projection(&remote)); + + let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut endpoints = EndpointRegistry::empty(); + endpoints.insert( + remote.clone(), + Capture(sent.clone()), + 7, + EndpointNegotiation::default(), + true, + ); + assert!(endpoints.set_active(&remote)); + endpoints.set_surface_active(&remote, surface_active); + // Activation publishes this coherent target before releasing its effects fence. + endpoints.freeze_input(); + let outcome = state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new(key, KeyModifiers::CONTROL), + )]); + assert!(matches!(outcome.actions.as_slice(), + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == expected))); + let mut commands = EndpointCommands::default(); + let mut scheduled = None; + crate::client::shell_runtime::dispatch_client_shell_actions( + outcome.actions, + &mut commands, + &mut endpoints, + Some(&mut state), + &mut Vec::new(), + &mut scheduled, + ) + .unwrap(); + if !surface_active { + assert!(state + .visible_endpoint_notice + .as_ref() + .is_some_and(|notice| notice.title == "Action interrupted")); + assert!(commands.disconnect(&remote).is_empty()); + assert!(sent.lock().unwrap().is_empty()); + continue; + } + assert!( + state.visible_endpoint_notice.is_none(), + "navigation was cancelled" + ); + assert!( + sent.lock().unwrap().is_empty(), + "the fence must still block sends" + ); + assert!(!endpoints.active_surface_available()); + + endpoints.unfreeze_input(); + assert!(commands.send_next(&remote, &mut endpoints).is_empty()); + let messages = sent.lock().unwrap(); + let [ClientMessage::ClientShellEndpointRequest { request, .. }] = messages.as_slice() + else { + panic!("queued navigation must reach the target after the fence"); + }; + let request: crate::api::schema::Request = serde_json::from_str(request).unwrap(); + assert!( + matches!(request.method, crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == expected) + ); + } +} + +#[cfg(windows)] +#[test] +fn system_notification_clicks_keep_endpoint_and_boot_identity() { + let (mut state, remote) = state_with_remote(); + state.config.toast_delivery = crate::config::ToastDelivery::System; + state.config.toast_delay_seconds = 0; + state.outer_focused = Some(false); + for endpoint_id in [ClientEndpointId::Local, remote.clone()] { + let (effects, _) = state.receive_notification( + &endpoint_id, + SemanticNotification { + kind: SemanticNotificationKind::Custom, + title: "test".into(), + body: None, + sound: None, + agent: None, + workspace_id: Some("ws_1".into()), + tab_id: Some("tab_1".into()), + pane_id: Some("pane_1".into()), + position: None, + }, + std::time::Instant::now(), + ); + let [ClientShellNotificationEffect::System { + target: Some(target), + .. + }] = effects.as_slice() + else { + panic!("system effect must retain notification target"); + }; + let target = target.clone(); + assert_eq!(target.endpoint_id, endpoint_id); + let outcome = state.activate_system_notification(target.clone()); + assert!( + !outcome.actions.is_empty(), + "a current target must navigate" + ); + if endpoint_id == remote { + assert!( + matches!(&outcome.actions[..], [ClientShellAction::ActivateEndpoint { + endpoint_id: id, target: Some(ClientEndpointFocusTarget::Notification { pane_id, boot_id }), + }] if id == &remote && pane_id == "pane_1" && boot_id == "remote-boot") + ); + } + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Reconnecting); + assert!(state + .activate_system_notification(target.clone()) + .actions + .is_empty()); + state.set_endpoint_status(&endpoint_id, ClientEndpointStatus::Online); + assert!( + !state + .activate_system_notification(target.clone()) + .actions + .is_empty(), + "same-boot reconnect remains valid" + ); + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap(); + let snapshot = endpoint.snapshot.as_mut().unwrap(); + snapshot.boot_id = "replacement-boot".into(); + assert!( + state + .activate_system_notification(target.clone()) + .actions + .is_empty(), + "same pane ID from another boot must not navigate" + ); + let endpoint = state + .endpoints + .iter_mut() + .find(|endpoint| endpoint.endpoint_id == endpoint_id) + .unwrap(); + let snapshot = endpoint.snapshot.as_mut().unwrap(); + snapshot.boot_id = target.boot_id.clone(); + snapshot.panes.clear(); + assert!( + state + .activate_system_notification(target.clone()) + .actions + .is_empty(), + "closed pane must not navigate" + ); + if endpoint_id == remote { + state.set_endpoint_catalog(&[]); + assert!( + state + .activate_system_notification(target) + .actions + .is_empty(), + "removed profile must not navigate" + ); + } + } +} + #[test] fn machine_diagnostic_badge_reopens_notice_without_collapsing_machine() { let (mut state, id) = state_with_remote(); @@ -150,6 +350,54 @@ fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) { (state, remote) } +/// Checks that the visible toggle wins overlapping scrollbar clicks and can reopen +/// the sidebar, for both endpoint layouts and both ends of the overflowing list. +#[test] +fn sidebar_toggle_remains_clickable_with_overflowing_agents() { + for saved_machine in [false, true] { + for scroll_to_bottom in [false, true] { + let (mut state, _) = state_with_scrollable_agents(); + if !saved_machine { + state.set_endpoint_catalog(&[]); + } + state.agent_scroll = if scroll_to_bottom { usize::MAX } else { 0 }; + let frame = state.compose(100, 28).expect("overflowing agent panel"); + assert!(!state.hits.agent_scrollbar.is_empty()); + let scroll = state.agent_scroll; + + let toggle = state.hits.sidebar_toggle; + let buffer = frame.to_ratatui_buffer().expect("sidebar buffer"); + assert_eq!(buffer[(toggle.x, toggle.y)].symbol(), "«"); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::NONE, + })]); + assert!( + state.sidebar_collapsed, + "collapse with saved_machine={saved_machine}, scroll_to_bottom={scroll_to_bottom}" + ); + assert!(state.sidebar_collapsed_manual); + assert!(outcome.repaint && outcome.resize); + assert_eq!(state.agent_scroll, scroll); + assert!(state.chrome_drag.is_none()); + + state.compose(100, 28).expect("collapsed sidebar"); + let toggle = state.hits.sidebar_toggle; + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(!state.sidebar_collapsed); + assert!(outcome.repaint && outcome.resize); + assert!(state.chrome_drag.is_none()); + } + } +} + #[test] fn agent_navigation_reveals_offscreen_targets() { use crate::input::KeybindAction; diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs index c7b38bfa..1711bf71 100644 --- a/src/client/shell/tests/graphics.rs +++ b/src/client/shell/tests/graphics.rs @@ -259,6 +259,7 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() { }), ClientShellOverlay::ConfirmClose(ClientConfirmCloseOverlay { workspace_id: "ws_1".into(), + close_group: false, tab_target: None, title: "close".into(), detail: "confirm".into(), diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index 145c7ae6..8b817d0a 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -240,35 +240,55 @@ fn client_shell_graphics_follow_final_shell_origin_and_local_overlay_visibility( #[test] fn delayed_link_fallback_does_not_replay_against_changed_geometry() { - let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); - state.set_snapshot(Box::new(snapshot())); - state.set_pane_surface(surface()); - state.compose(106, 20).expect("pane frame"); - let pane = state.hits.panes[0].clone(); - let down = MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: pane.inner_rect.x + 2, - row: pane.inner_rect.y + 1, - modifiers: KeyModifiers::CONTROL, - }; - let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); - let request_id = match &activate.actions[..] { - [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), - _ => panic!("expected link activation request"), - }; - state.hits.panes[0].inner_rect.x = state.hits.panes[0].inner_rect.x.saturating_add(1); - - let (_, actions) = state.handle_endpoint_result( - "boot-1", - &request_id, - Ok(crate::api::schema::ResponseResult::PaneLinkActivated { - url: None, - handled: false, - }), - ); - - assert!(actions.is_empty()); - assert!(state.url_click_consumes_until_up); + for url in [ + None, + Some("https://example.test"), + Some("file:///C:/Code/note.md"), + ] { + for changed in 0..4 { + let mut state = + ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state.compose(106, 20).expect("pane frame"); + let pane = state.hits.panes[0].clone(); + let down = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: pane.inner_rect.x + 2, + row: pane.inner_rect.y + 1, + modifiers: KeyModifiers::CONTROL, + }; + let activate = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let [ClientShellAction::Endpoint { request, .. }] = &activate.actions[..] else { + panic!("expected link activation request"); + }; + match changed { + 0 => state.hits.panes[0].inner_rect.x += 1, + 1 => state.mode = ClientShellMode::Navigate, + 2 => state.config.mouse_capture = false, + _ => { + state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay { + query: TextEditor::default(), + search_focused: false, + scroll: 0, + })) + } + } + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request.id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: url.map(str::to_owned), + handled: false, + }), + ); + assert!( + actions.is_empty(), + "late replies must not replay or open links" + ); + assert!(state.url_click_consumes_until_up); + } + } } #[test] @@ -282,6 +302,216 @@ fn invalid_experimental_reload_keeps_input_source_preference() { assert!(!shell.switch_ascii_input_source_in_prefix); } +fn pane_key_events( + outcome: &ClientShellInput, +) -> Vec<( + crate::protocol::ClientKeyCode, + crate::protocol::ClientKeyKind, +)> { + outcome + .requests + .iter() + .filter_map(|request| match request { + ClientMessage::ClientShellPaneInput { events, .. } => Some(events), + _ => None, + }) + .flatten() + .filter_map(|event| match event { + ClientPaneInputEvent::Key { code, kind, .. } => Some((code.clone(), *kind)), + _ => None, + }) + .collect() +} + +#[test] +fn text_press_release_survives_shift_released_first_and_never_becomes_a_repeat() { + use crate::protocol::{ClientKeyCode, ClientKeyKind}; + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_host_reports_key_releases(true); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + // Shift+A arrives as text; Shift is let go before A, so the release names + // plain `a` with no shifted alternate. + let press = state.handle_input_bytes(b"A"); + assert_eq!( + pane_key_events(&press), + [(ClientKeyCode::Char('A'), ClientKeyKind::Press)] + ); + let release = state.handle_input_bytes(b"\x1b[97;1:3u"); + assert_eq!( + pane_key_events(&release), + [(ClientKeyCode::Char('a'), ClientKeyKind::Release)] + ); + + // A text press whose release never arrived must not turn the next press + // into a repeat routed to the old target. + let _ = state.handle_input_bytes(b"%"); + let again = state.handle_input_bytes(b"%"); + assert_eq!( + pane_key_events(&again), + [(ClientKeyCode::Char('%'), ClientKeyKind::Press)] + ); +} + +#[test] +fn raw_backspace_follows_the_host_tty_erase_character() { + use crate::protocol::{ClientKeyCode, ClientKeyKind}; + + // #3244: MobaXterm sends 0x08 for Backspace and its tty says erase is ^H. + for (erase, plain, alt) in [ + ( + Some(0x08), + ClientKeyCode::Backspace, + ClientKeyCode::Backspace, + ), + ( + Some(0x7f), + ClientKeyCode::Char('h'), + ClientKeyCode::Char('h'), + ), + (None, ClientKeyCode::Char('h'), ClientKeyCode::Char('h')), + ] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_host_erase_byte(erase); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let press = state.handle_input_bytes(b"\x08"); + assert_eq!( + pane_key_events(&press), + [(plain.clone(), ClientKeyKind::Press)], + "erase {erase:?}" + ); + let alt_press = state.handle_input_bytes(b"\x1b\x08"); + assert_eq!( + pane_key_events(&alt_press), + [(alt, ClientKeyKind::Press)], + "alt, erase {erase:?}" + ); + } +} + +#[test] +fn shifted_punctuation_release_after_shift_reaches_the_pane() { + use crate::protocol::{ClientKeyCode, ClientKeyKind}; + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_host_reports_key_releases(true); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + // "?" arrives as text; with Shift let go first the release names only the + // unshifted key, which no layout-free rule can relate to "?". + let _ = state.handle_input_bytes(b"?"); + let release = state.handle_input_bytes(b"\x1b[47;1:3u"); + assert_eq!( + pane_key_events(&release), + [(ClientKeyCode::Char('/'), ClientKeyKind::Release)] + ); + // Nothing is left to be released later as a stale "?". + let blur = state.handle_input_bytes(b"\x1b[O"); + assert!(pane_key_events(&blur).is_empty()); + + // With two text keys held the release is ambiguous and nothing is taken. + let _ = state.handle_input_bytes(b"?"); + let _ = state.handle_input_bytes(b"!"); + let ambiguous = state.handle_input_bytes(b"\x1b[47;1:3u"); + assert!(pane_key_events(&ambiguous).is_empty()); +} + +#[test] +fn held_key_reports_keep_their_release_around_ime_commits_and_focus_loss() { + use crate::protocol::{ClientKeyCode, ClientKeyKind}; + + let new_state = || { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_host_reports_key_releases(true); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state + }; + + // A key report carrying its text (report-all host) is a real held key: + // losing focus releases it. + let mut state = new_state(); + let _ = state.handle_input_bytes(b"\x1b[97;;97u"); + let blur = state.handle_input_bytes(b"\x1b[O"); + assert_eq!( + pane_key_events(&blur), + [(ClientKeyCode::Char('a'), ClientKeyKind::Release)] + ); + + // Many IME commits while a key is held never displace that key: its + // release still reaches the pane as the held key. + let mut state = new_state(); + let _ = state.handle_input_bytes(b"\x1b[120;;120u"); + for ch in + "\u{65e5}\u{672c}\u{8a9e}\u{d55c}\u{ad6d}\u{c5b4}\u{4e2d}\u{6587}\u{3042}\u{3044}\u{3046}" + .chars() + { + let _ = state.handle_input_bytes(ch.to_string().as_bytes()); + } + let release = state.handle_input_bytes(b"\x1b[120;1:3u"); + assert_eq!( + pane_key_events(&release), + [(ClientKeyCode::Char('x'), ClientKeyKind::Release)] + ); +} + +#[test] +fn non_latin_ctrl_chord_release_matches_after_ctrl_is_let_go() { + use crate::protocol::{ClientKeyCode, ClientKeyKind}; + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_host_reports_key_releases(true); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + // Russian layout: Ctrl+\u{441} is the C key; Ctrl goes up before C. + let press = state.handle_input_bytes(b"\x1b[1089::99;5u"); + assert_eq!( + pane_key_events(&press), + [(ClientKeyCode::Char('c'), ClientKeyKind::Press)] + ); + let release = state.handle_input_bytes(b"\x1b[1089::99;1:3u"); + assert_eq!( + pane_key_events(&release), + [(ClientKeyCode::Char('c'), ClientKeyKind::Release)] + ); + + // Plain Cyrillic typing still releases as its own key. + let _ = state.handle_input_bytes(b"\x1b[1089::99;;1089u"); + let release = state.handle_input_bytes(b"\x1b[1089::99;1:3u"); + assert_eq!( + pane_key_events(&release), + [(ClientKeyCode::Char('\u{441}'), ClientKeyKind::Release)] + ); +} + +#[test] +fn unmatched_release_never_takes_a_held_key_while_herdr_owns_another_press() { + use crate::protocol::{ClientKeyCode, ClientKeyKind}; + + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_host_reports_key_releases(true); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + // Hold `a` in the pane, then press the prefix: Herdr owns that press. + let _ = state.handle_input_bytes(b"a"); + let _ = state.handle_input_bytes(b"\x1b[98;5u"); + // A release the leases cannot name must not be charged to the held `a`. + let stray = state.handle_input_bytes(b"\x1b[47;1:3u"); + assert!(pane_key_events(&stray).is_empty()); + let release = state.handle_input_bytes(b"\x1b[97;1:3u"); + assert_eq!( + pane_key_events(&release), + [(ClientKeyCode::Char('a'), ClientKeyKind::Release)] + ); +} + #[test] fn physical_release_uses_the_leased_press_code_with_current_modifiers() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); @@ -629,26 +859,79 @@ fn rename_pane_empty_value_is_preserved_as_a_clear_request() { #[test] fn styled_client_composition_preserves_pane_hyperlinks() { - let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); - state.set_snapshot(Box::new(snapshot())); - let mut pane_surface = surface(); - let linked = Buffer::with_lines(["LIVE", "PANE"]); - pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( - &linked, - None, - &[((0, 0), "L".into(), "https://example.test".into())], - ); - state.set_pane_surface(pane_surface); - let mut selection = - crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1)); - assert!(selection.finish()); - state.selection = Some(selection); - let frame = state.compose(106, 20).expect("composed frame"); - let hit = &state.hits.panes[0]; - let index = - usize::from(hit.inner_rect.y) * usize::from(frame.width) + usize::from(hit.inner_rect.x); - let link = frame.cells[index].hyperlink.expect("linked cell") as usize; - assert_eq!(frame.hyperlinks[link], "https://example.test"); + for uri in [ + "https://example.test", + "obsidian://open?vault=meta&file=note.md", + ] { + for draw_host_cursor in [false, true] { + let mut state = + ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + let linked = Buffer::with_lines(["LIVE", "PANE"]); + pane_surface.frame = FrameData::from_ratatui_buffer_with_hyperlinks( + &linked, + None, + &[((0, 0), "L".into(), uri.into())], + ); + state.set_pane_surface(pane_surface); + let mut selection = + crate::selection::Selection::absolute_range("pane_1".to_owned(), (0, 0), (0, 1)); + assert!(selection.finish()); + state.selection = Some(selection); + let frame = state.compose(106, 20).expect("composed frame"); + let hit = &state.hits.panes[0]; + let index = usize::from(hit.inner_rect.y) * usize::from(frame.width) + + usize::from(hit.inner_rect.x); + let link = frame.cells[index].hyperlink.expect("linked cell") as usize; + assert_eq!(frame.hyperlinks[link], uri); + let x = hit.inner_rect.x; + let y = hit.inner_rect.y; + let mut client = crate::client::state::ClientState::test_new(); + client.draw_host_cursor = draw_host_cursor; + let mut terminal = crate::ghostty::Terminal::new(106, 20, 0).unwrap(); + for (phase, (capture, repaint)) in [ + (false, false), + (true, false), + (false, false), + (true, true), + (true, false), + (false, true), + ] + .into_iter() + .enumerate() + { + client.shell_mouse_capture_preference = capture; + // Effective mouse reporting may still be requested by a pane app. + client.mouse_capture_active = true; + client.repaint_pending = repaint; + let mut frame = state.compose(106, 20).expect("linked semantic frame"); + let link = frame.cells[index].hyperlink.expect("retained link") as usize; + assert_eq!(frame.hyperlinks[link], uri); + // Change the linked cell on the diff pass so it must be painted again. + if phase == 4 { + frame.frame.cells[index].symbol = "D".into(); + } + frame.frame.cursor = Some(crate::protocol::CursorState { + x: x + 1, + y, + visible: true, + shape: Default::default(), + }); + let expected = frame.cells[index].symbol.clone(); + let mut output = Vec::new(); + assert!(client.try_present_frame_to(&mut output, frame)); + terminal.write(&output); + assert_eq!( + terminal.viewport_hyperlink_uri(x, u32::from(y)).unwrap(), + (!capture).then_some(uri.to_owned()), + "host link ownership must follow configured capture for {uri}" + ); + let (_, label) = terminal.screen_cell(x, u32::from(y)).unwrap(); + assert_eq!(label, expected.chars().map(u32::from).collect::>()); + } + } + } } #[test] @@ -675,3 +958,96 @@ fn every_configured_prefix_enters_prefix_mode() { assert_eq!(state.mode, ClientShellMode::Terminal); } } + +fn pane_text_commits(outcome: &ClientShellInput) -> Vec { + outcome + .requests + .iter() + .filter_map(|request| match request { + ClientMessage::ClientShellPaneInput { events, .. } => Some(events), + _ => None, + }) + .flatten() + .filter_map(|event| match event { + ClientPaneInputEvent::TextCommit(text) => Some(text.clone()), + _ => None, + }) + .collect() +} + +/// A Kitty host that Herdr switched to report-all for prefix or navigate +/// mode. Plain text can still arrive: Tern ignores report-all, IMEs commit +/// text, and Ghostty `text:` binds type their bytes (#5255). +fn report_all_kitty_host_state(snapshot: crate::protocol::ClientShellSnapshot) -> ClientShellState { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_host_reports_key_releases(true); + state.set_host_reports_all_keys(true); + state.set_snapshot(Box::new(snapshot)); + state.set_pane_surface(surface()); + state +} + +#[test] +fn plain_text_after_the_prefix_runs_the_prefix_binding_on_a_report_all_host() { + // Prefix as a key report, then a plain `q` (Tern, IME commit). + let mut state = report_all_kitty_host_state(snapshot()); + let _ = state.handle_input_bytes(b"\x1b[98;5u"); + assert_eq!(state.mode, ClientShellMode::Prefix); + let detach = state.handle_input_bytes(b"q"); + assert!(detach.detach, "plain q after a reported prefix must detach"); + assert!(pane_text_commits(&detach).is_empty()); + + // Raw prefix byte and the key in one write (Ghostty `text:` bind). + let mut state = report_all_kitty_host_state(snapshot()); + let detach = state.handle_input_bytes(b"\x02q"); + assert!(detach.detach, "one-write ^B q must detach"); + assert!(pane_text_commits(&detach).is_empty()); +} + +#[test] +fn plain_text_in_navigate_mode_runs_the_navigate_binding_on_a_report_all_host() { + let mut snapshot = snapshot(); + let mut second = snapshot.workspaces[0].clone(); + second.workspace_id = "ws_2".into(); + second.number = 2; + second.label = "second".into(); + second.focused = false; + snapshot.workspaces.push(second); + let mut state = report_all_kitty_host_state(snapshot); + + let _ = state.handle_input_bytes(b"\x1b[98;5u"); + let _ = state.handle_input_bytes(b"\x1b[119u"); + assert_eq!(state.mode, ClientShellMode::Navigate); + let jump = state.handle_input_bytes(b"2"); + assert!( + jump.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!( + &request.method, + crate::api::schema::Method::WorkspaceFocus(target) + if target.workspace_id == "ws_2" + ) + )), + "plain 2 in navigate mode must focus workspace 2" + ); +} + +#[test] +fn committed_text_still_reaches_a_report_all_pane_as_text() { + // The #5017 behavior #5255 must keep: in a pane that asked for every key, + // IME and compose commits arrive as text, not invented key reports. + let mut state = report_all_kitty_host_state(snapshot()); + assert_eq!(state.mode, ClientShellMode::Terminal); + let cjk = state.handle_input_bytes("\u{4f60}\u{597d}".as_bytes()); + assert_eq!(pane_text_commits(&cjk).concat(), "\u{4f60}\u{597d}"); + let ascii = state.handle_input_bytes(b"n"); + assert_eq!(pane_text_commits(&ascii), ["n"]); + + // And after leaving prefix mode the pane gets text commits again. + let _ = state.handle_input_bytes(b"\x1b[98;5u"); + let _ = state.handle_input_bytes(b"\x1b"); + assert_eq!(state.mode, ClientShellMode::Terminal); + let after = state.handle_input_bytes("\u{3042}".as_bytes()); + assert_eq!(pane_text_commits(&after).concat(), "\u{3042}"); +} diff --git a/src/client/shell/tests/input_conformance.rs b/src/client/shell/tests/input_conformance.rs new file mode 100644 index 00000000..5053a616 --- /dev/null +++ b/src/client/shell/tests/input_conformance.rs @@ -0,0 +1,2111 @@ +//! Keyboard transparency conformance. +//! +//! Herdr should be invisible to the application in a pane: for every keystroke, +//! the pane must receive the bytes it would receive running directly in the +//! host terminal. The oracle is libghostty's encoder fed the same key event the +//! Ghostty app builds from an OS key event. The Herdr path is the real one: +//! host bytes -> host framer -> client shell routing -> server pane input -> +//! pane encoder, with the pane's modes set by application output. +//! +//! Keystrokes whose host bytes cannot distinguish keys the pane would see +//! differently are counted as host-lossy, not as Herdr failures. + +// The Unix-host checks are compiled out on Windows, leaving helpers that only +// they use. +#![cfg_attr(windows, allow(dead_code))] + +use std::collections::{BTreeMap, HashMap}; + +use super::*; + +use crate::ghostty::{self, ffi}; + +#[derive(Clone, Copy)] +struct KeyDef { + name: &'static str, + key: ffi::GhosttyKey, + /// US layout text: (unshifted, shifted). + text: Option<(char, char)>, +} + +const fn text(name: &'static str, key: ffi::GhosttyKey, base: char, shifted: char) -> KeyDef { + KeyDef { + name, + key, + text: Some((base, shifted)), + } +} + +const fn func(name: &'static str, key: ffi::GhosttyKey) -> KeyDef { + KeyDef { + name, + key, + text: None, + } +} + +const KEYS: &[KeyDef] = &[ + text("a", ffi::GhosttyKey_GHOSTTY_KEY_A, 'a', 'A'), + text("b", ffi::GhosttyKey_GHOSTTY_KEY_B, 'b', 'B'), + text("c", ffi::GhosttyKey_GHOSTTY_KEY_C, 'c', 'C'), + text("d", ffi::GhosttyKey_GHOSTTY_KEY_D, 'd', 'D'), + text("e", ffi::GhosttyKey_GHOSTTY_KEY_E, 'e', 'E'), + text("f", ffi::GhosttyKey_GHOSTTY_KEY_F, 'f', 'F'), + text("g", ffi::GhosttyKey_GHOSTTY_KEY_G, 'g', 'G'), + text("h", ffi::GhosttyKey_GHOSTTY_KEY_H, 'h', 'H'), + text("i", ffi::GhosttyKey_GHOSTTY_KEY_I, 'i', 'I'), + text("j", ffi::GhosttyKey_GHOSTTY_KEY_J, 'j', 'J'), + text("k", ffi::GhosttyKey_GHOSTTY_KEY_K, 'k', 'K'), + text("l", ffi::GhosttyKey_GHOSTTY_KEY_L, 'l', 'L'), + text("m", ffi::GhosttyKey_GHOSTTY_KEY_M, 'm', 'M'), + text("n", ffi::GhosttyKey_GHOSTTY_KEY_N, 'n', 'N'), + text("o", ffi::GhosttyKey_GHOSTTY_KEY_O, 'o', 'O'), + text("p", ffi::GhosttyKey_GHOSTTY_KEY_P, 'p', 'P'), + text("q", ffi::GhosttyKey_GHOSTTY_KEY_Q, 'q', 'Q'), + text("r", ffi::GhosttyKey_GHOSTTY_KEY_R, 'r', 'R'), + text("s", ffi::GhosttyKey_GHOSTTY_KEY_S, 's', 'S'), + text("t", ffi::GhosttyKey_GHOSTTY_KEY_T, 't', 'T'), + text("u", ffi::GhosttyKey_GHOSTTY_KEY_U, 'u', 'U'), + text("v", ffi::GhosttyKey_GHOSTTY_KEY_V, 'v', 'V'), + text("w", ffi::GhosttyKey_GHOSTTY_KEY_W, 'w', 'W'), + text("x", ffi::GhosttyKey_GHOSTTY_KEY_X, 'x', 'X'), + text("y", ffi::GhosttyKey_GHOSTTY_KEY_Y, 'y', 'Y'), + text("z", ffi::GhosttyKey_GHOSTTY_KEY_Z, 'z', 'Z'), + text("0", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_0, '0', ')'), + text("1", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_1, '1', '!'), + text("2", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_2, '2', '@'), + text("3", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_3, '3', '#'), + text("4", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_4, '4', '$'), + text("5", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_5, '5', '%'), + text("6", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_6, '6', '^'), + text("7", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_7, '7', '&'), + text("8", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_8, '8', '*'), + text("9", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_9, '9', '('), + text("`", ffi::GhosttyKey_GHOSTTY_KEY_BACKQUOTE, '`', '~'), + text("-", ffi::GhosttyKey_GHOSTTY_KEY_MINUS, '-', '_'), + text("=", ffi::GhosttyKey_GHOSTTY_KEY_EQUAL, '=', '+'), + text("[", ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_LEFT, '[', '{'), + text("]", ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_RIGHT, ']', '}'), + text("\\", ffi::GhosttyKey_GHOSTTY_KEY_BACKSLASH, '\\', '|'), + text(";", ffi::GhosttyKey_GHOSTTY_KEY_SEMICOLON, ';', ':'), + text("'", ffi::GhosttyKey_GHOSTTY_KEY_QUOTE, '\'', '"'), + text(",", ffi::GhosttyKey_GHOSTTY_KEY_COMMA, ',', '<'), + text(".", ffi::GhosttyKey_GHOSTTY_KEY_PERIOD, '.', '>'), + text("/", ffi::GhosttyKey_GHOSTTY_KEY_SLASH, '/', '?'), + text("space", ffi::GhosttyKey_GHOSTTY_KEY_SPACE, ' ', ' '), + func("enter", ffi::GhosttyKey_GHOSTTY_KEY_ENTER), + func("tab", ffi::GhosttyKey_GHOSTTY_KEY_TAB), + func("backspace", ffi::GhosttyKey_GHOSTTY_KEY_BACKSPACE), + func("escape", ffi::GhosttyKey_GHOSTTY_KEY_ESCAPE), + func("up", ffi::GhosttyKey_GHOSTTY_KEY_ARROW_UP), + func("down", ffi::GhosttyKey_GHOSTTY_KEY_ARROW_DOWN), + func("left", ffi::GhosttyKey_GHOSTTY_KEY_ARROW_LEFT), + func("right", ffi::GhosttyKey_GHOSTTY_KEY_ARROW_RIGHT), + func("home", ffi::GhosttyKey_GHOSTTY_KEY_HOME), + func("end", ffi::GhosttyKey_GHOSTTY_KEY_END), + func("pageup", ffi::GhosttyKey_GHOSTTY_KEY_PAGE_UP), + func("pagedown", ffi::GhosttyKey_GHOSTTY_KEY_PAGE_DOWN), + func("insert", ffi::GhosttyKey_GHOSTTY_KEY_INSERT), + func("delete", ffi::GhosttyKey_GHOSTTY_KEY_DELETE), + func("f1", ffi::GhosttyKey_GHOSTTY_KEY_F1), + func("f2", ffi::GhosttyKey_GHOSTTY_KEY_F2), + func("f3", ffi::GhosttyKey_GHOSTTY_KEY_F3), + func("f4", ffi::GhosttyKey_GHOSTTY_KEY_F4), + func("f5", ffi::GhosttyKey_GHOSTTY_KEY_F5), + func("f6", ffi::GhosttyKey_GHOSTTY_KEY_F6), + func("f7", ffi::GhosttyKey_GHOSTTY_KEY_F7), + func("f8", ffi::GhosttyKey_GHOSTTY_KEY_F8), + func("f9", ffi::GhosttyKey_GHOSTTY_KEY_F9), + func("f10", ffi::GhosttyKey_GHOSTTY_KEY_F10), + func("f11", ffi::GhosttyKey_GHOSTTY_KEY_F11), + func("f12", ffi::GhosttyKey_GHOSTTY_KEY_F12), +]; + +/// Modes an application can request by writing to its terminal. +const PANE_MODES: &[(&str, &[u8])] = &[ + ("legacy", b""), + ("legacy+decckm", b"\x1b[?1h"), + ("modifyOtherKeys1", b"\x1b[>4;1m"), + ("modifyOtherKeys2", b"\x1b[>4;2m"), + ("kitty1", b"\x1b[>1u"), + ("kitty3", b"\x1b[>3u"), + ("kitty7", b"\x1b[>7u"), + ("kitty11", b"\x1b[>11u"), + ("kitty15", b"\x1b[>15u"), + ("kitty31", b"\x1b[>31u"), +]; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum HostProfile { + /// Kitty keyboard host (Ghostty, kitty, WezTerm, foot...). Herdr pushes 7, + /// or 31 while the pane wants all keys reported. + Kitty, + /// Host without an enhanced keyboard protocol. + Legacy, +} + +impl HostProfile { + fn name(self) -> &'static str { + match self { + Self::Kitty => "kitty-host", + Self::Legacy => "legacy-host", + } + } + + fn setup(self, pane_mode: &[u8]) -> &'static [u8] { + match self { + Self::Kitty if pane_mode == b"\x1b[>11u" || pane_mode == b"\x1b[>15u" => b"\x1b[>31u", + Self::Kitty if pane_mode == b"\x1b[>31u" => b"\x1b[>31u", + Self::Kitty => b"\x1b[>7u", + Self::Legacy => b"", + } + } +} + +const MODS: [(u16, &str); 4] = [ + (ghostty::MOD_CTRL, "ctrl"), + (ghostty::MOD_ALT, "alt"), + (ghostty::MOD_SHIFT, "shift"), + (ghostty::MOD_SUPER, "super"), +]; + +fn mods_name(mods: u16) -> String { + let mut parts: Vec<&str> = MODS + .iter() + .filter(|(bit, _)| mods & bit != 0) + .map(|(_, name)| *name) + .collect(); + if parts.is_empty() { + parts.push("plain"); + } + parts.join("+") +} + +/// Host bytes for one keystroke: (press, release). +type HostKeystroke = (Vec, Vec); + +struct Oracle { + _terminal: ghostty::Terminal, + encoder: ghostty::KeyEncoder, +} + +impl Oracle { + fn new(app_output: &[u8]) -> Self { + let mut terminal = ghostty::Terminal::new(80, 24, 0).expect("terminal"); + terminal.write(app_output); + let mut encoder = ghostty::KeyEncoder::new().expect("encoder"); + encoder.set_from_terminal(&terminal); + // Keystrokes here carry an interpreted Alt, as on Linux; keep the oracle + // identical on macOS, where libghostty would otherwise treat Option as text. + encoder.set_macos_option_as_alt(true); + Self { + _terminal: terminal, + encoder, + } + } + + /// Bytes for one keystroke (press then release), built like the Ghostty app + /// builds key events from the OS: physical key, modifiers, layout text, the + /// unshifted codepoint, and Shift consumed when it produced the text. + fn keystroke(&mut self, def: KeyDef, mods: u16) -> HostKeystroke { + let press = self.encode(def, mods, ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS); + let release = self.encode(def, mods, ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_RELEASE); + (press, release) + } + + fn encode(&mut self, def: KeyDef, mods: u16, action: ffi::GhosttyKeyAction) -> Vec { + let mut event = ghostty::KeyEvent::new().expect("key event"); + event.set_key(def.key); + event.set_mods(mods); + event.set_action(action); + let utf8; + if let Some((base, shifted)) = def.text { + let shift = mods & ghostty::MOD_SHIFT != 0; + utf8 = if shift { shifted } else { base }.to_string(); + event.set_utf8(&utf8); + event.set_unshifted_codepoint(base as u32); + if shift && shifted != base { + event.set_consumed_mods(ghostty::MOD_SHIFT); + } + } + self.encoder.encode(&event).expect("encode") + } +} + +/// Panes that negotiated nothing. Like Ghostty, modifyOtherKeys level 1 counts +/// as nothing. +fn is_legacy_pane(pane_mode: &[u8]) -> bool { + matches!(pane_mode, b"" | b"\x1b[?1h" | b"\x1b[>4;1m") +} + +/// Agreed exception table: panes that negotiated no keyboard protocol keep the +/// classic bytes for keys where Ghostty's escape sequences break shells, and +/// get no Super chords (see `is_legacy_pane` use above). +fn legacy_shell_exception(pane_mode: &[u8], key: &str, mods: u16) -> Option> { + if !is_legacy_pane(pane_mode) { + return None; + } + let alt = mods & ghostty::MOD_ALT != 0; + let base = mods & !ghostty::MOD_ALT; + let classic: &[u8] = match (key, base) { + ("enter", m) if m != 0 => b"\r", + ("m", ghostty::MOD_CTRL) => b"\r", + ("i", ghostty::MOD_CTRL) | ("tab", ghostty::MOD_CTRL) => b"\t", + ("[", ghostty::MOD_CTRL) => b"\x1b", + // Real terminals (lab: Alacritty, WezTerm) send the control byte. + (letter, m) + if m == ghostty::MOD_CTRL | ghostty::MOD_SHIFT + && letter.len() == 1 + && letter.as_bytes()[0].is_ascii_lowercase() => + { + let control = [letter.as_bytes()[0] & 0x1f]; + return Some(if alt { + [b"\x1b".as_slice(), &control].concat() + } else { + control.to_vec() + }); + } + _ => return None, + }; + Some(if alt { + [b"\x1b", classic].concat() + } else { + classic.to_vec() + }) +} + +struct HerdrPath { + state: ClientShellState, + framer: crate::raw_input::RawInputByteFramer, + runtime: crate::terminal::TerminalRuntime, + rx: tokio::sync::mpsc::Receiver, + host: HostProfile, + host_reports_all: bool, +} + +impl HerdrPath { + fn new(host: HostProfile, app_output: &[u8]) -> Self { + // Mirrors the client: report-all is pushed to the host while the pane + // asks for it (`HostProfile::setup` picks 31 for those panes). + let host_reports_all = matches!(app_output, b"\x1b[>11u" | b"\x1b[>15u" | b"\x1b[>31u"); + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 24, 0, b"", 4096, + ); + runtime.test_process_pty_bytes(app_output); + while rx.try_recv().is_ok() {} + Self { + state: Self::fresh_state(host, host_reports_all), + framer: Self::fresh_framer(host), + runtime, + rx, + host, + host_reports_all, + } + } + + fn fresh_state(host: HostProfile, host_reports_all: bool) -> ClientShellState { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + // Mirrors the client: Herdr pushes Kitty event types on Kitty hosts. + state.set_host_reports_key_releases(host == HostProfile::Kitty); + state.set_host_reports_all_keys(host_reports_all); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + state + } + + fn fresh_framer(host: HostProfile) -> crate::raw_input::RawInputByteFramer { + #[allow(unused_mut)] // only mutated on unix + let mut framer = crate::raw_input::RawInputByteFramer::for_host_input(); + #[cfg(unix)] + framer.set_host_escape_disambiguation_active(host == HostProfile::Kitty); + #[cfg(not(unix))] + let _ = host; + framer + } + + /// Collect pane input from one client outcome, the way the client loop does: + /// side requests (pane focus) are ignored, link lookups get a no-link answer + /// and the held mouse events are replayed. Returns true when Herdr kept the + /// input for itself. + fn absorb( + &mut self, + outcome: ClientShellInput, + events: &mut Vec, + ) -> bool { + let mut owned = outcome.detach; + for request in outcome.requests { + if let ClientMessage::ClientShellPaneInput { events: batch, .. } = request { + events.extend(batch); + } + } + let mut replay = Vec::new(); + for action in outcome.actions { + match action { + ClientShellAction::Endpoint { + boot_id, request, .. + } if matches!( + request.method, + crate::api::schema::Method::PaneLinkActivate(_) + ) => + { + let (_, actions) = self.state.handle_endpoint_result( + &boot_id, + &request.id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: None, + handled: false, + }), + ); + for action in actions { + match action { + ClientShellAction::ReplayMouse(mouse) => replay.extend(mouse), + _ => owned = true, + } + } + } + ClientShellAction::Endpoint { .. } => {} + ClientShellAction::ReplayMouse(mouse) => replay.extend(mouse), + _ => owned = true, + } + } + if !replay.is_empty() { + let outcome = self.state.replay_mouse_events(replay); + owned |= self.absorb(outcome, events); + } + owned + } + + fn reset_client(&mut self) { + self.state = Self::fresh_state(self.host, self.host_reports_all); + self.framer = Self::fresh_framer(self.host); + } + + /// Feed one host write, then let idle flushes run. Returns None when Herdr + /// itself consumed the key (prefix, binding, mode change). + fn feed(&mut self, host_bytes: &[u8]) -> Option> { + let mut chunks = self.framer.push(host_bytes); + for _ in 0..3 { + if !self.framer.has_pending_input() { + break; + } + chunks.extend(self.framer.flush_timeout()); + } + let outcomes = chunks + .iter() + .map(|chunk| self.state.handle_input_bytes(chunk)) + .collect(); + self.deliver(outcomes) + } + + /// Route client outcomes through server pane input and collect the bytes the + /// pane receives. None when Herdr kept the input for itself. + fn deliver(&mut self, outcomes: Vec) -> Option> { + let mut events = Vec::new(); + let mut herdr_owned = false; + for outcome in outcomes { + herdr_owned |= self.absorb(outcome, &mut events); + } + herdr_owned |= self.state.mode != ClientShellMode::Terminal || self.state.overlay.is_some(); + if herdr_owned { + return None; + } + let mut out = Vec::new(); + if let Err(err) = + crate::server::pane_input::test_apply_client_pane_input_events(&self.runtime, &events) + { + out.extend_from_slice(format!("").as_bytes()); + } + while let Ok(bytes) = self.rx.try_recv() { + out.extend_from_slice(&bytes); + } + Some(out) + } +} + +/// Kitty's default event type is press, so `;mods:1` and `;mods` are the same +/// report. Strip the explicit form so equivalent encodings compare equal. +fn normalize_kitty_press(bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i..].starts_with(b":1") + && bytes + .get(i + 2) + .is_some_and(|b| matches!(b, b'u' | b'~' | b';') || b.is_ascii_uppercase()) + { + i += 2; + continue; + } + out.push(bytes[i]); + i += 1; + } + out +} + +#[derive(Default)] +struct Tally { + scored: usize, + passed: usize, + equivalent: usize, + host_lossy: usize, + herdr_owned: usize, +} + +struct Failure { + host: &'static str, + pane: &'static str, + keystroke: String, + expected: Vec, + actual: Vec, +} + +fn show(bytes: &[u8]) -> String { + bytes.escape_ascii().to_string() +} + +struct Report { + by_cell: BTreeMap<(&'static str, &'static str, &'static str), Tally>, + failures: Vec, + owned: Vec, +} + +fn run_keyboard_conformance() -> Report { + let mut report = Report { + by_cell: BTreeMap::new(), + failures: Vec::new(), + owned: Vec::new(), + }; + for host in [HostProfile::Kitty, HostProfile::Legacy] { + for &(pane_name, pane_mode) in PANE_MODES { + let mut host_oracle = Oracle::new(host.setup(pane_mode)); + let mut direct_oracle = Oracle::new(pane_mode); + + let mut cases = Vec::new(); + for def in KEYS { + for mods in 0u16..16 { + let mods = MODS + .iter() + .enumerate() + .filter(|(i, _)| mods & (1 << i) != 0) + .fold(0, |acc, (_, (bit, _))| acc | bit); + let host_bytes = host_oracle.keystroke(*def, mods); + let (mut press, release) = direct_oracle.keystroke(*def, mods); + if is_legacy_pane(pane_mode) && mods & ghostty::MOD_SUPER != 0 { + press = Vec::new(); + } else if let Some(classic) = legacy_shell_exception(pane_mode, def.name, mods) + { + press = classic; + } + // A host that never reports releases gives nobody a release to forward. + let expected = if host_bytes.1.is_empty() { + press + } else { + [press, release].concat() + }; + cases.push((*def, mods, host_bytes, expected)); + } + } + + let mut expected_by_host_bytes: HashMap<&HostKeystroke, Vec<&Vec>> = HashMap::new(); + for (_, mods, host_bytes, expected) in &cases { + if host == HostProfile::Legacy && mods & ghostty::MOD_SUPER != 0 { + continue; + } + expected_by_host_bytes + .entry(host_bytes) + .or_default() + .push(expected); + } + + let mut herdr = HerdrPath::new(host, pane_mode); + for (def, mods, host_bytes, expected) in &cases { + let group = if mods & ghostty::MOD_SUPER != 0 { + "super" + } else { + "core" + }; + let tally = report + .by_cell + .entry((host.name(), pane_name, group)) + .or_default(); + // Legacy hosts cannot report Super at all (they drop it or send + // the chord without it), so nothing downstream can recover it. + let super_unreportable = + host == HostProfile::Legacy && mods & ghostty::MOD_SUPER != 0; + // Plain text names a character, not a key: which key and Shift state + // produced punctuation depends on the layout. Only a letter's Shift + // is layout independent (its case). Legacy hosts never report + // Shift for non-letters; Kitty hosts send unmodified punctuation + // presses as plain text. + let non_letter_text = def.text.is_some_and(|(base, _)| !base.is_alphabetic()); + let plain_text_press = + host_bytes.0.len() == 1 && host_bytes.0[0].is_ascii_graphic(); + let layout_unreportable = non_letter_text + && mods & ghostty::MOD_SHIFT != 0 + && (host == HostProfile::Legacy || plain_text_press); + let ambiguous = expected_by_host_bytes + .get(host_bytes) + .is_some_and(|distinct| distinct.iter().any(|other| *other != expected)); + if super_unreportable || layout_unreportable || ambiguous { + tally.host_lossy += 1; + continue; + } + let press = herdr.feed(&host_bytes.0); + let release = herdr.feed(&host_bytes.1); + let page_key_scrolls_herdr = *mods == 0 + && matches!(def.name, "pageup" | "pagedown") + && herdr.runtime.plain_page_keys_use_host_scrollback() == Some(true); + let (Some(press), Some(release), false) = (press, release, page_key_scrolls_herdr) + else { + report.owned.push(format!( + "{}\t{}\t{}+{}", + host.name(), + pane_name, + mods_name(*mods), + def.name + )); + tally.herdr_owned += 1; + herdr.reset_client(); + continue; + }; + let actual = [press, release].concat(); + tally.scored += 1; + if &actual == expected { + tally.passed += 1; + } else if normalize_kitty_press(&actual) == normalize_kitty_press(expected) { + tally.passed += 1; + tally.equivalent += 1; + } else { + report.failures.push(Failure { + host: host.name(), + pane: pane_name, + keystroke: format!("{}+{}", mods_name(*mods), def.name), + expected: expected.clone(), + actual, + }); + } + } + } + } + report +} + +fn print_report(report: &Report) { + let mut total = Tally::default(); + println!( + "\n{:<12} {:<17} {:<6} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}", + "host", "pane", "keys", "scored", "passed", "score", "equiv", "lossy", "owned" + ); + for ((host, pane, group), tally) in &report.by_cell { + println!( + "{:<12} {:<17} {:<6} {:>7} {:>7} {:>6.1}% {:>7} {:>7} {:>7}", + host, + pane, + group, + tally.scored, + tally.passed, + 100.0 * tally.passed as f64 / tally.scored.max(1) as f64, + tally.equivalent, + tally.host_lossy, + tally.herdr_owned + ); + total.scored += tally.scored; + total.passed += tally.passed; + total.equivalent += tally.equivalent; + total.host_lossy += tally.host_lossy; + total.herdr_owned += tally.herdr_owned; + } + println!( + "{:<37} {:>7} {:>7} {:>6.1}% {:>7} {:>7} {:>7}", + "TOTAL", + total.scored, + total.passed, + 100.0 * total.passed as f64 / total.scored.max(1) as f64, + total.equivalent, + total.host_lossy, + total.herdr_owned + ); + + if let Ok(path) = std::env::var("HERDR_INPUT_CONFORMANCE_FAILURES") { + let lines: Vec = report + .failures + .iter() + .map(|failure| { + format!( + "{}\t{}\t{}\texpected={}\tactual={}", + failure.host, + failure.pane, + failure.keystroke, + show(&failure.expected), + show(&failure.actual) + ) + }) + .chain(report.owned.iter().map(|owned| format!("owned\t{owned}"))) + .collect(); + std::fs::write(path, lines.join("\n")).expect("write failures"); + } +} + +// Ratchet: failure counts may only go down. Lower them when a change fixes +// cases; a rise means a regression in Herdr's input transparency. +// Remaining: Alt+], Alt+Shift+P/X, Alt+^, Alt+_ behind legacy hosts. Their bytes +// also start host replies, so they are never forwarded and the reply-tail +// discard can swallow the next keystroke (#344). +const KEYBOARD_FAILURES_BASELINE: usize = 60; +const MOUSE_FAILURES_BASELINE: usize = 96; +// Remaining: legacy hosts split right after ESC[, which is also Alt+[. +const SPLIT_IDLE_MISMATCH_BASELINE: usize = 207; + +// --------------------------------------------------------------------------- +// Mouse +// --------------------------------------------------------------------------- + +const MOUSE_TRACKING: &[(&str, &[u8])] = &[ + ("off", b""), + ("x10", b"\x1b[?9h"), + ("normal", b"\x1b[?1000h"), + ("button", b"\x1b[?1002h"), + ("any", b"\x1b[?1003h"), +]; + +const MOUSE_FORMATS: &[(&str, &[u8])] = &[ + ("default", b""), + ("utf8", b"\x1b[?1005h"), + ("sgr", b"\x1b[?1006h"), + ("urxvt", b"\x1b[?1015h"), +]; + +/// What Herdr asks the host for while it captures the mouse. +const HOST_MOUSE_SETUP: &[u8] = b"\x1b[?1003h\x1b[?1006h"; + +#[derive(Clone, Copy)] +struct MouseStep { + action: ffi::GhosttyMouseAction, + button: Option, + /// Offset added to the gesture's start cell. + dx: u16, + dy: u16, +} + +const fn step( + action: ffi::GhosttyMouseAction, + button: Option, + dx: u16, + dy: u16, +) -> MouseStep { + MouseStep { + action, + button, + dx, + dy, + } +} + +const GESTURES: &[(&str, &[MouseStep])] = &[ + ( + "click-left", + &[ + step( + ghostty::MOUSE_ACTION_PRESS, + Some(ghostty::MOUSE_BUTTON_LEFT), + 0, + 0, + ), + step( + ghostty::MOUSE_ACTION_RELEASE, + Some(ghostty::MOUSE_BUTTON_LEFT), + 0, + 0, + ), + ], + ), + ( + "click-middle", + &[ + step( + ghostty::MOUSE_ACTION_PRESS, + Some(ghostty::MOUSE_BUTTON_MIDDLE), + 0, + 0, + ), + step( + ghostty::MOUSE_ACTION_RELEASE, + Some(ghostty::MOUSE_BUTTON_MIDDLE), + 0, + 0, + ), + ], + ), + ( + "click-right", + &[ + step( + ghostty::MOUSE_ACTION_PRESS, + Some(ghostty::MOUSE_BUTTON_RIGHT), + 0, + 0, + ), + step( + ghostty::MOUSE_ACTION_RELEASE, + Some(ghostty::MOUSE_BUTTON_RIGHT), + 0, + 0, + ), + ], + ), + ( + "drag-left", + &[ + step( + ghostty::MOUSE_ACTION_PRESS, + Some(ghostty::MOUSE_BUTTON_LEFT), + 0, + 0, + ), + step( + ghostty::MOUSE_ACTION_MOTION, + Some(ghostty::MOUSE_BUTTON_LEFT), + 1, + 0, + ), + step( + ghostty::MOUSE_ACTION_MOTION, + Some(ghostty::MOUSE_BUTTON_LEFT), + 2, + 1, + ), + step( + ghostty::MOUSE_ACTION_RELEASE, + Some(ghostty::MOUSE_BUTTON_LEFT), + 2, + 1, + ), + ], + ), + ( + "move", + &[ + step(ghostty::MOUSE_ACTION_MOTION, None, 0, 0), + step(ghostty::MOUSE_ACTION_MOTION, None, 1, 1), + ], + ), + ( + "wheel-up", + &[step( + ghostty::MOUSE_ACTION_PRESS, + Some(ghostty::MOUSE_BUTTON_WHEEL_UP), + 0, + 0, + )], + ), + ( + "wheel-down", + &[step( + ghostty::MOUSE_ACTION_PRESS, + Some(ghostty::MOUSE_BUTTON_WHEEL_DOWN), + 0, + 0, + )], + ), + ( + "wheel-left", + &[step( + ghostty::MOUSE_ACTION_PRESS, + Some(ghostty::MOUSE_BUTTON_WHEEL_LEFT), + 0, + 0, + )], + ), + ( + "wheel-right", + &[step( + ghostty::MOUSE_ACTION_PRESS, + Some(ghostty::MOUSE_BUTTON_WHEEL_RIGHT), + 0, + 0, + )], + ), +]; + +const MOUSE_MODS: [(u16, &str); 4] = [ + (0, "plain"), + (ghostty::MOD_SHIFT, "shift"), + (ghostty::MOD_ALT, "alt"), + (ghostty::MOD_CTRL, "ctrl"), +]; + +struct MouseOracle { + _terminal: ghostty::Terminal, + encoder: ghostty::MouseEncoder, +} + +impl MouseOracle { + fn new(app_output: &[u8], cols: u16, rows: u16) -> Self { + let mut terminal = ghostty::Terminal::new(cols, rows, 0).expect("terminal"); + terminal.write(app_output); + let mut encoder = ghostty::MouseEncoder::new().expect("encoder"); + encoder.set_from_terminal(&terminal); + encoder.set_size(u32::from(cols), u32::from(rows), 1, 1); + Self { + _terminal: terminal, + encoder, + } + } + + fn encode(&mut self, step: MouseStep, mods: u16, column: u16, row: u16) -> Vec { + let mut event = ghostty::MouseEvent::new().expect("mouse event"); + event.set_action(step.action); + match step.button { + Some(button) => event.set_button(button), + None => event.clear_button(), + } + event.set_mods(mods); + event.set_position(f32::from(column), f32::from(row)); + self.encoder.encode(&event).expect("encode mouse") + } +} + +const MOUSE_PANE_COLS: u16 = 40; +const MOUSE_PANE_ROWS: u16 = 10; + +fn mouse_surface(mouse_reporting: bool) -> PaneSurfaceFrame { + let mut surface = surface(); + let pane = &mut surface.panes[0]; + for rect in [&mut pane.rect, &mut pane.inner_rect] { + rect.width = MOUSE_PANE_COLS; + rect.height = MOUSE_PANE_ROWS; + } + pane.mouse_reporting = mouse_reporting; + surface +} + +fn run_mouse_conformance() -> Report { + let mut report = Report { + by_cell: BTreeMap::new(), + failures: Vec::new(), + owned: Vec::new(), + }; + for &(tracking_name, tracking) in MOUSE_TRACKING { + for &(format_name, format) in MOUSE_FORMATS { + let pane_mode = [tracking, format].concat(); + let pane_name: &'static str = + Box::leak(format!("{tracking_name}/{format_name}").into_boxed_str()); + let tally = report + .by_cell + .entry(("kitty-host", pane_name, "mouse")) + .or_default(); + + let mut herdr = HerdrPath::new(HostProfile::Kitty, &pane_mode); + herdr + .state + .set_pane_surface(mouse_surface(!tracking.is_empty())); + herdr.state.compose(106, 20).expect("composed frame"); + let inner = herdr.state.hits.panes[0].inner_rect; + let mut direct = MouseOracle::new(&pane_mode, 80, 24); + let mut host = MouseOracle::new(HOST_MOUSE_SETUP, 106, 20); + + let starts = [ + (0u16, 0u16), + (inner.width / 2, inner.height / 2), + ( + inner.width.saturating_sub(3), + inner.height.saturating_sub(2), + ), + ]; + for &(gesture_name, steps) in GESTURES { + for &(mods, mods_label) in &MOUSE_MODS { + for &(column, row) in &starts { + let mut expected = Vec::new(); + let mut actual = Vec::new(); + let mut owned = false; + for step in steps { + let (c, r) = (column + step.dx, row + step.dy); + expected.extend(direct.encode(*step, mods, c, r)); + let host_bytes = host.encode(*step, mods, inner.x + c, inner.y + r); + match herdr.feed(&host_bytes) { + Some(bytes) => actual.extend(bytes), + None => owned = true, + } + } + if owned { + tally.herdr_owned += 1; + report.owned.push(format!( + "{pane_name}\t{mods_label}+{gesture_name}@{column},{row}" + )); + herdr.reset_client(); + herdr + .state + .set_pane_surface(mouse_surface(!tracking.is_empty())); + herdr.state.compose(106, 20).expect("composed frame"); + continue; + } + tally.scored += 1; + if actual == expected { + tally.passed += 1; + } else { + report.failures.push(Failure { + host: "kitty-host", + pane: pane_name, + keystroke: format!("{mods_label}+{gesture_name}@{column},{row}"), + expected, + actual, + }); + } + } + } + } + } + } + report +} + +// --------------------------------------------------------------------------- +// Split reads +// --------------------------------------------------------------------------- + +fn host_input_corpus() -> Vec<(HostProfile, Vec)> { + let mut corpus = Vec::new(); + for (host, setup) in [ + (HostProfile::Kitty, &b"\x1b[>7u"[..]), + (HostProfile::Kitty, &b"\x1b[>31u"[..]), + (HostProfile::Legacy, &b""[..]), + ] { + let mut oracle = Oracle::new(setup); + for def in KEYS { + for mods in [ + 0, + ghostty::MOD_SHIFT, + ghostty::MOD_CTRL, + ghostty::MOD_ALT, + ghostty::MOD_CTRL | ghostty::MOD_SHIFT, + ghostty::MOD_CTRL | ghostty::MOD_ALT, + ] { + let (press, release) = oracle.keystroke(*def, mods); + corpus.push((host, press)); + if !release.is_empty() { + corpus.push((host, release)); + } + } + } + } + let mut mouse = MouseOracle::new(HOST_MOUSE_SETUP, 300, 100); + for &(_, steps) in GESTURES { + for &(mods, _) in &MOUSE_MODS { + for step in steps { + corpus.push((HostProfile::Kitty, mouse.encode(*step, mods, 7, 3))); + corpus.push((HostProfile::Kitty, mouse.encode(*step, mods, 150, 42))); + } + } + } + corpus.sort(); + corpus.dedup(); + corpus.retain(|(_, bytes)| bytes.len() > 1); + corpus +} + +/// Replies Herdr asks the host for at startup and on focus (colors, palette, +/// cell size, appearance). +const HOST_REPLIES: &[&[u8]] = &[ + b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\", + b"\x1b]11;rgb:1e1e/1e1e/2e2e\x07", + b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\", + b"\x1b]4;1;rgb:cdcd/0000/0000\x1b\\", + b"\x1b]4;255;rgb:eeee/eeee/eeee\x07", + b"\x1b[6;20;10t", + b"\x1b[?997;1n", + b"\x1b[?997;2n", +]; + +/// A network hiccup between two reads of one sequence (SSH, slow links). +const SPLIT_PAUSE_MS: i32 = 50; + +fn framed_events(host: HostProfile, pieces: &[&[u8]], pause_ms: Option) -> String { + framed_events_with(host, pieces, pause_ms, false) +} + +/// Feed `pieces` as separate reads. With `pause_ms`, the reads are that far +/// apart: like the client loop, the framer is flushed only when the pause +/// outlasts the wait it asked for. +fn framed_events_with( + host: HostProfile, + pieces: &[&[u8]], + pause_ms: Option, + awaiting_replies: bool, +) -> String { + let mut framer = HerdrPath::fresh_framer(host); + if awaiting_replies { + // Mirrors the Unix client right after it sent its host queries. + framer.host_color_query_sent(); + framer.enable_host_color_scheme_change_tracking(); + framer.enable_host_appearance_query_on_focus(); + framer.host_cell_size_query_sent(); + } + let mut chunks = Vec::new(); + for (index, piece) in pieces.iter().enumerate() { + chunks.extend(framer.push(piece)); + let Some(pause_ms) = pause_ms.filter(|_| index + 1 < pieces.len()) else { + continue; + }; + let mut waited = 0; + for _ in 0..3 { + let wait = framer.idle_flush_timeout_ms(true); + if !framer.has_pending_input() || waited + wait > pause_ms { + break; + } + waited += wait; + chunks.extend(framer.flush_timeout()); + } + } + for _ in 0..3 { + if !framer.has_pending_input() { + break; + } + chunks.extend(framer.flush_timeout()); + } + let events: Vec<_> = chunks + .iter() + .flat_map(|chunk| crate::raw_input::parse_raw_input_bytes_sync(chunk)) + .collect(); + format!("{events:?}") +} + +// The keyboard, mouse and split-read checks model a Unix host sending terminal +// bytes. Windows input arrives as native records; see `windows_records`. +#[cfg(unix)] +#[test] +fn split_read_robustness() { + let corpus = host_input_corpus(); + let mut splits = 0usize; + let mut burst_mismatch = Vec::new(); + let mut idle_mismatch = Vec::new(); + for (host, bytes) in &corpus { + let whole = framed_events(*host, &[bytes], None); + for cut in 1..bytes.len() { + splits += 1; + let pieces = [&bytes[..cut], &bytes[cut..]]; + if framed_events(*host, &pieces, None) != whole { + burst_mismatch.push(format!( + "{}\t{}|{}", + host.name(), + show(pieces[0]), + show(pieces[1]) + )); + } + if framed_events(*host, &pieces, Some(SPLIT_PAUSE_MS)) != whole { + idle_mismatch.push(format!( + "{}\t{}|{}", + host.name(), + show(pieces[0]), + show(pieces[1]) + )); + } + } + } + let mut reply_splits = 0usize; + let mut reply_burst_mismatch = Vec::new(); + let mut reply_idle_mismatch = Vec::new(); + for reply in HOST_REPLIES { + let whole = framed_events_with(HostProfile::Kitty, &[reply], None, true); + for cut in 1..reply.len() { + reply_splits += 1; + let pieces = [&reply[..cut], &reply[cut..]]; + let line = format!("reply\t{}|{}", show(pieces[0]), show(pieces[1])); + if framed_events_with(HostProfile::Kitty, &pieces, None, true) != whole { + reply_burst_mismatch.push(line.clone()); + } + if framed_events_with(HostProfile::Kitty, &pieces, Some(SPLIT_PAUSE_MS), true) != whole + { + reply_idle_mismatch.push(line); + } + } + } + println!( + "\nhost replies: {} replies, {} splits\n burst (no pause): {} differ\n {SPLIT_PAUSE_MS}ms pause: {} differ", + HOST_REPLIES.len(), + reply_splits, + reply_burst_mismatch.len(), + reply_idle_mismatch.len() + ); + + let pct = |bad: usize| 100.0 * (splits - bad) as f64 / splits.max(1) as f64; + println!( + "\nsplit reads: {} sequences, {} splits\n burst (no pause): {:.1}% identical ({} differ)\n {SPLIT_PAUSE_MS}ms pause: {:.1}% identical ({} differ)", + corpus.len(), + splits, + pct(burst_mismatch.len()), + burst_mismatch.len(), + pct(idle_mismatch.len()), + idle_mismatch.len() + ); + if let Ok(path) = std::env::var("HERDR_INPUT_CONFORMANCE_FAILURES") { + let lines: Vec = burst_mismatch + .iter() + .chain(&reply_burst_mismatch) + .map(|line| format!("burst\t{line}")) + .chain( + idle_mismatch + .iter() + .chain(&reply_idle_mismatch) + .map(|line| format!("idle\t{line}")), + ) + .collect(); + std::fs::write(path, lines.join("\n")).expect("write failures"); + } + assert!( + burst_mismatch.is_empty() && reply_burst_mismatch.is_empty(), + "split reads without a pause changed the decoded input" + ); + assert!( + idle_mismatch.len() <= SPLIT_IDLE_MISMATCH_BASELINE, + "split-read robustness regressed: {} > {SPLIT_IDLE_MISMATCH_BASELINE}", + idle_mismatch.len() + ); + assert!( + reply_idle_mismatch.is_empty(), + "split host replies changed after a {SPLIT_PAUSE_MS}ms pause: {reply_idle_mismatch:?}" + ); +} + +// --------------------------------------------------------------------------- +// Reporter captures: exact bytes and timing from real terminals in issues. +// --------------------------------------------------------------------------- + +/// (issue, host, reads, gap between reads in ms, Herdr awaiting host replies) +type SplitCapture = ( + &'static str, + HostProfile, + &'static [&'static [u8]], + i32, + bool, +); + +#[cfg(unix)] +#[test] +fn reporter_split_captures_decode_like_the_unsplit_input() { + let captures: &[SplitCapture] = &[ + // Alacritty 0.17 on Windows over SSH: the space release split after + // `ESC[32;1`, the tail arrived 36 ms later (client log in the issue). + ( + "#4856", + HostProfile::Kitty, + &[b"\x1b[115;1:3u\x1b[32;1", b":3u"], + 36, + false, + ), + // tmux 3.6 relays each OSC 4 palette reply from the outer terminal + // with arbitrary gaps; the tail of `e4e4` gray entries leaked. + ( + "#4025", + HostProfile::Kitty, + &[b"\x1b]4;254;rgb:e4", b"e4/e4e4/e4e4\x1b\\"], + 200, + true, + ), + ( + "#4025", + HostProfile::Legacy, + &[b"\x1b", b"]4;254;rgb:e4e4/e4e4/e4e4\x07"], + 200, + true, + ), + ]; + for &(issue, host, reads, gap_ms, awaiting) in captures { + let whole = reads.concat(); + assert_eq!( + framed_events_with(host, reads, Some(gap_ms), awaiting), + framed_events_with(host, &[&whole], None, awaiting), + "{issue}: {} read(s) {gap_ms}ms apart", + reads.len() + ); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn ime_committed_text_reaches_every_pane_mode_exactly_once() { + // IME commits (CJK, Korean syllables) arrive as plain UTF-8 text on every + // host, with no key releases. Split reads can cut inside a character. + // (Spaces between words are the space key, not part of a commit.) + let text = "日本語한국어中文"; + let bytes = text.as_bytes(); + for host in [HostProfile::Kitty, HostProfile::Legacy] { + for &(pane_name, pane_mode) in PANE_MODES { + for cut in [None, Some(1), Some(4), Some(bytes.len() - 2)] { + let mut herdr = HerdrPath::new(host, pane_mode); + let got = match cut { + None => herdr.feed(bytes), + Some(cut) => { + let mut chunks = herdr.framer.push(&bytes[..cut]); + chunks.extend(herdr.framer.push(&bytes[cut..])); + for _ in 0..3 { + chunks.extend(herdr.framer.flush_timeout()); + } + let outcomes = chunks + .iter() + .map(|chunk| herdr.state.handle_input_bytes(chunk)) + .collect(); + herdr.deliver(outcomes) + } + } + .expect("text reaches the pane"); + assert_eq!( + String::from_utf8_lossy(&got), + text, + "{} {pane_name} cut {cut:?}", + host.name() + ); + // Losing focus must not replay releases for committed text. + let after = herdr.feed(b"\x1b[O").unwrap_or_default(); + assert!( + !String::from_utf8_lossy(&after).contains(":3u"), + "{} {pane_name}: focus loss sent {}", + host.name(), + show(&after) + ); + } + } + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn lab_kitty_function_keys_reach_the_pane() { + // #4403, recorded from kitty 0.47.1 through real key presses: with Herdr's + // keyboard flags pushed, unmodified F1, F2 and F4 arrive as bare CSI P/Q/S. + for (host_bytes, plain_shell) in [ + (&b"\x1b[P"[..], &b"\x1bOP"[..]), + (b"\x1b[Q", b"\x1bOQ"), + (b"\x1b[13~", b"\x1bOR"), + (b"\x1b[S", b"\x1bOS"), + ] { + let mut herdr = HerdrPath::new(HostProfile::Kitty, b""); + let got = herdr + .feed(host_bytes) + .expect("function key reaches the pane"); + assert_eq!(show(&got), show(plain_shell), "{}", show(host_bytes)); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn reporter_text_key_release_reaches_kitty_event_pane() { + // #4184: an app asking for event types gets typed-letter releases. + // (pane mode, kitty host bytes for `a` press then release, pane bytes) + for (pane_mode, press, want) in [ + // Event types only: Herdr keeps the host at flags 7, so the press + // arrives as text and its release as a report (the reporter's capture). + (&b"\x1b[>3u"[..], &b"a"[..], &b"a\x1b[97;1:3u"[..]), + // All keys too: Herdr switches the host to report-all (31), so kitty + // reports the press with its text. + (b"\x1b[>11u", b"\x1b[97;;97u", b"\x1b[97u\x1b[97;1:3u"), + ] { + let mut herdr = HerdrPath::new(HostProfile::Kitty, pane_mode); + let press = herdr.feed(press).expect("press reaches the pane"); + let release = herdr + .feed(b"\x1b[97;1:3u") + .expect("release reaches the pane"); + assert_eq!( + show(&[press, release].concat()), + show(want), + "{}", + show(pane_mode) + ); + } +} + +/// A key on a non-US layout, as the Ghostty app reports it: the physical key +/// and the layout's (unshifted, shifted) text. +const fn layout_key(key: ffi::GhosttyKey, base: char, shifted: char) -> KeyDef { + text("layout", key, base, shifted) +} + +fn layout_keystroke_through_herdr( + host: HostProfile, + pane_mode: &[u8], + def: KeyDef, + mods: u16, +) -> (HostKeystroke, String, String) { + let keystroke = Oracle::new(host.setup(pane_mode)).keystroke(def, mods); + let (press, release) = Oracle::new(pane_mode).keystroke(def, mods); + let direct = if keystroke.1.is_empty() { + press + } else { + [press, release].concat() + }; + let mut herdr = HerdrPath::new(host, pane_mode); + let pane_press = herdr.feed(&keystroke.0).expect("press reaches the pane"); + let pane_release = herdr.feed(&keystroke.1).expect("release reaches the pane"); + ( + keystroke, + show(&direct), + show(&[pane_press, pane_release].concat()), + ) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn layout_ctrl_chords_match_ghostty_direct() { + // #1079. Keystrokes from a Kitty host on Russian, German, Turkish, French, + // Nordic and Polish layouts, built like Ghostty builds them. `ghostty` is + // what Ghostty sends the app directly (recorded from libghostty, and + // checked against it below); `herdr` is what the pane gets through Herdr. + use ffi::*; + const CTRL: u16 = ghostty::MOD_CTRL; + const ALT: u16 = ghostty::MOD_ALT; + const SHIFT: u16 = ghostty::MOD_SHIFT; + let ru_w = layout_key(GhosttyKey_GHOSTTY_KEY_W, '\u{446}', '\u{426}'); + let ru_c = layout_key(GhosttyKey_GHOSTTY_KEY_C, '\u{441}', '\u{421}'); + let ru_i = layout_key(GhosttyKey_GHOSTTY_KEY_I, '\u{448}', '\u{428}'); + let ru_bracket = layout_key(GhosttyKey_GHOSTTY_KEY_BRACKET_LEFT, '\u{445}', '\u{425}'); + let de = layout_key(GhosttyKey_GHOSTTY_KEY_SEMICOLON, '\u{f6}', '\u{d6}'); + let tr = layout_key(GhosttyKey_GHOSTTY_KEY_I, '\u{131}', 'I'); + let fr = layout_key(GhosttyKey_GHOSTTY_KEY_DIGIT_2, '\u{e9}', '2'); + let no = layout_key(GhosttyKey_GHOSTTY_KEY_BRACKET_LEFT, '\u{e5}', '\u{c5}'); + let pl = layout_key(GhosttyKey_GHOSTTY_KEY_E, '\u{119}', '\u{118}'); + let keys = [ + ("ru ctrl+w", ru_w, CTRL), + ("ru ctrl+c", ru_c, CTRL), + ("ru ctrl+i", ru_i, CTRL), + ("ru ctrl+[", ru_bracket, CTRL), + ("ru ctrl+shift+[", ru_bracket, CTRL | SHIFT), + ("ru alt+w", ru_w, ALT), + ("ru ctrl+alt+w", ru_w, CTRL | ALT), + ("ru w", ru_w, 0), + ("ru shift+w", ru_w, SHIFT), + ("de ctrl+\u{f6}", de, CTRL), + ("tr ctrl+\u{131}", tr, CTRL), + ("fr ctrl+\u{e9}", fr, CTRL), + ("no ctrl+\u{e5}", no, CTRL), + ("pl altgr \u{119}", pl, CTRL | ALT), + ]; + // (pane, key, ghostty, herdr) + let table: &[(&str, &str, &str, &str)] = &[ + // Plain shell. Ctrl alone maps to the physical key's control byte + // when Ghostty has one; Ctrl+I and Ctrl+[ keep the layout character. + ("legacy", "ru ctrl+w", "\\x17", "\\x17"), + ("legacy", "ru ctrl+c", "\\x03", "\\x03"), + ("legacy", "ru ctrl+i", "\\x1b[1096;5u", "\\x1b[1096;5u"), + ("legacy", "ru ctrl+[", "\\x1b[1093;5u", "\\x1b[1093;5u"), + ( + "legacy", + "ru ctrl+shift+[", + "\\x1b[1061;5u", + "\\x1b[1061;5u", + ), + ("legacy", "ru alt+w", "\\x1b\\xd1\\x86", "\\x1b\\xd1\\x86"), + ("legacy", "ru w", "\\xd1\\x86", "\\xd1\\x86"), + ("legacy", "ru shift+w", "\\xd0\\xa6", "\\xd0\\xa6"), + ("legacy", "de ctrl+\u{f6}", "\\x1b[246;5u", "\\x1b[246;5u"), + ("legacy", "tr ctrl+\u{131}", "\\x1b[305;5u", "\\x1b[305;5u"), + ("legacy", "no ctrl+\u{e5}", "\\x1b[229;5u", "\\x1b[229;5u"), + // Ghostty maps these by physical key too, but Ctrl+Alt can be AltGr + // on Windows hosts and Latin keys are shortcuts of their own, so + // Herdr keeps the layout character. + ("legacy", "ru ctrl+alt+w", "\\x1b\\x17", "\\x1b[1094;7u"), + ("legacy", "fr ctrl+\u{e9}", "\\x00", "\\x1b[233;5u"), + ("legacy", "pl altgr \u{119}", "\\x1b\\x05", "\\x1b[281;7u"), + // Kitty apps. A rewritten chord reaches them as the physical key, the + // key the Kitty spec says to match shortcuts on. + ("kitty1", "ru ctrl+w", "\\x1b[1094;5u", "\\x1b[119;5u"), + ("kitty1", "ru ctrl+c", "\\x1b[1089;5u", "\\x1b[99;5u"), + ("kitty1", "ru ctrl+i", "\\x1b[1096;5u", "\\x1b[1096;5u"), + ("kitty1", "ru ctrl+[", "\\x1b[1093;5u", "\\x1b[1093;5u"), + ( + "kitty1", + "ru ctrl+shift+[", + "\\x1b[1093;6u", + "\\x1b[1093;6u", + ), + ("kitty1", "ru alt+w", "\\x1b[1094;3u", "\\x1b[1094;3u"), + ("kitty1", "ru ctrl+alt+w", "\\x1b[1094;7u", "\\x1b[1094;7u"), + ("kitty1", "ru w", "\\xd1\\x86", "\\xd1\\x86"), + ("kitty1", "ru shift+w", "\\xd0\\xa6", "\\xd0\\xa6"), + ("kitty1", "de ctrl+\u{f6}", "\\x1b[246;5u", "\\x1b[246;5u"), + ("kitty1", "tr ctrl+\u{131}", "\\x1b[305;5u", "\\x1b[305;5u"), + ("kitty1", "fr ctrl+\u{e9}", "\\x1b[233;5u", "\\x1b[233;5u"), + ("kitty1", "no ctrl+\u{e5}", "\\x1b[229;5u", "\\x1b[229;5u"), + ("kitty1", "pl altgr \u{119}", "\\x1b[281;7u", "\\x1b[281;7u"), + ( + "kitty3", + "ru ctrl+w", + "\\x1b[1094;5u\\x1b[1094;5:3u", + "\\x1b[119;5u\\x1b[119;5:3u", + ), + ( + "kitty3", + "ru w", + "\\xd1\\x86\\x1b[1094;1:3u", + "\\xd1\\x86\\x1b[1094;1:3u", + ), + // Alternate keys: the pane input wire has no base-layout slot, so + // Herdr drops that field for every non-ASCII key (as on master). + ("kitty5", "ru ctrl+w", "\\x1b[1094::119;5u", "\\x1b[119;5u"), + ("kitty5", "ru ctrl+c", "\\x1b[1089::99;5u", "\\x1b[99;5u"), + ( + "kitty5", + "ru ctrl+shift+[", + "\\x1b[1093:1061:91;6u", + "\\x1b[1093:1061;6u", + ), + ("kitty5", "ru alt+w", "\\x1b[1094::119;3u", "\\x1b[1094;3u"), + ("kitty5", "ru w", "\\xd1\\x86", "\\xd1\\x86"), + ( + "kitty5", + "de ctrl+\u{f6}", + "\\x1b[246::59;5u", + "\\x1b[246;5u", + ), + ]; + let panes: HashMap<&str, &[u8]> = [ + ("legacy", &b""[..]), + ("kitty1", b"\x1b[>1u"), + ("kitty3", b"\x1b[>3u"), + ("kitty5", b"\x1b[>5u"), + ] + .into_iter() + .collect(); + for &(pane, name, ghostty, want) in table { + let &(_, def, mods) = keys.iter().find(|(key, ..)| *key == name).unwrap(); + let (host, direct, got) = + layout_keystroke_through_herdr(HostProfile::Kitty, panes[pane], def, mods); + assert_eq!(direct, ghostty, "{pane} {name}: Ghostty direct"); + assert_eq!(got, want, "{pane} {name}: host sent {}", show(&host.0)); + } + // Every key is covered in the plain shell and a Kitty app. + for (name, ..) in keys { + for pane in ["legacy", "kitty1"] { + assert!( + table.iter().any(|row| row.0 == pane && row.1 == name), + "{pane} {name}" + ); + } + } + + // Plain Cyrillic typing from a host without the Kitty protocol. + for (pane, pane_mode) in [("legacy", &b""[..]), ("kitty1", b"\x1b[>1u")] { + for mods in [0, SHIFT] { + let (_, direct, got) = + layout_keystroke_through_herdr(HostProfile::Legacy, pane_mode, ru_w, mods); + assert_eq!(got, direct, "legacy host {pane} {}", mods_name(mods)); + } + } + + // Hosts that name no base-layout key: nothing is known about the physical + // key, so the layout character goes through as reported. + for (pane_mode, host_bytes, want) in [ + (&b""[..], &b"\x1b[1094;5u"[..], "\\x1b[1094;5u"), + (b"\x1b[>1u", b"\x1b[1094;5u", "\\x1b[1094;5u"), + (b"", b"\x1b[1094;3u", "\\x1b\\xd1\\x86"), + (b"\x1b[>1u", b"\x1b[1089:1057;6u", "\\x1b[1089;6u"), + ] { + let mut herdr = HerdrPath::new(HostProfile::Kitty, pane_mode); + let got = herdr.feed(host_bytes).expect("reaches the pane"); + assert_eq!(show(&got), want, "{}", show(host_bytes)); + } + + // Ctrl let go before the key: the release names the layout character + // without Ctrl, and is paired with the physical-key press. + let mut herdr = HerdrPath::new(HostProfile::Kitty, b"\x1b[>3u"); + let press = herdr.feed(b"\x1b[1094::119;5u").expect("press"); + let release = herdr.feed(b"\x1b[1094::119;1:3u").expect("release"); + assert_eq!( + show(&[press, release].concat()), + "\\x1b[119;5u\\x1b[119;1:3u" + ); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn layout_chord_releases_pair_when_modifiers_change_first() { + // A modifier let go before the key changes whether the chord is Ctrl + // alone, so the press and the release can name different keys (the layout + // character or the physical key). The release must still reach the pane + // as the key it saw pressed, and must not stay leased. + use ffi::*; + const CTRL: u16 = ghostty::MOD_CTRL; + const ALT: u16 = ghostty::MOD_ALT; + const SHIFT: u16 = ghostty::MOD_SHIFT; + const PRESS: ffi::GhosttyKeyAction = ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS; + const RELEASE: ffi::GhosttyKeyAction = ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_RELEASE; + let ru_w = layout_key(GhosttyKey_GHOSTTY_KEY_W, '\u{446}', '\u{426}'); + // (case, press mods, release mods, Ghostty direct, Herdr) + let cases = [ + ( + "ctrl+shift, shift let go first", + CTRL | SHIFT, + CTRL, + "\\x1b[1094;6u\\x1b[1094;5:3u", + "\\x1b[1094;6u\\x1b[1094;5:3u", + ), + ( + "ctrl+alt, alt let go first", + CTRL | ALT, + CTRL, + "\\x1b[1094;7u\\x1b[1094;5:3u", + "\\x1b[1094;7u\\x1b[1094;5:3u", + ), + ( + "ctrl+shift, ctrl let go first", + CTRL | SHIFT, + SHIFT, + "\\x1b[1094;6u\\x1b[1094;2:3u", + "\\x1b[1094;6u\\x1b[1094;2:3u", + ), + // A Ctrl chord reaches Kitty apps as its physical key (see + // `layout_ctrl_chords_match_ghostty_direct`); its release follows. + ( + "ctrl, ctrl let go first", + CTRL, + 0, + "\\x1b[1094;5u\\x1b[1094;1:3u", + "\\x1b[119;5u\\x1b[119;1:3u", + ), + ( + "ctrl, shift added before release", + CTRL, + CTRL | SHIFT, + "\\x1b[1094;5u\\x1b[1094;6:3u", + "\\x1b[119;5u\\x1b[119;6:3u", + ), + ]; + let pane_mode = b"\x1b[>3u"; + for (name, press_mods, release_mods, ghostty, want) in cases { + let mut host = Oracle::new(HostProfile::Kitty.setup(pane_mode)); + let host_press = host.encode(ru_w, press_mods, PRESS); + let host_release = host.encode(ru_w, release_mods, RELEASE); + let mut direct = Oracle::new(pane_mode); + let direct_bytes = [ + direct.encode(ru_w, press_mods, PRESS), + direct.encode(ru_w, release_mods, RELEASE), + ] + .concat(); + assert_eq!(show(&direct_bytes), ghostty, "{name}: Ghostty direct"); + + let mut herdr = HerdrPath::new(HostProfile::Kitty, pane_mode); + let press = herdr.feed(&host_press).expect("press reaches the pane"); + let release = herdr.feed(&host_release).expect("release reaches the pane"); + assert_eq!( + show(&[press, release].concat()), + want, + "{name}: host sent {} {}", + show(&host_press), + show(&host_release) + ); + // Nothing stays leased: losing focus releases no further key. + let after = herdr.feed(b"\x1b[O").unwrap_or_default(); + assert!( + !show(&after).contains(":3u"), + "{name}: focus loss sent {}", + show(&after) + ); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn overlapping_layout_and_us_keys_release_exactly() { + // A US `w` held together with a Russian chord on the same physical key. + // Each release must end its own press, in either order, however the + // chord's modifiers changed in between. + use ffi::*; + const CTRL: u16 = ghostty::MOD_CTRL; + const SHIFT: u16 = ghostty::MOD_SHIFT; + const PRESS: ffi::GhosttyKeyAction = ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS; + const RELEASE: ffi::GhosttyKeyAction = ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_RELEASE; + let us_w = *KEYS.iter().find(|def| def.name == "w").unwrap(); + let ru_w = layout_key(GhosttyKey_GHOSTTY_KEY_W, '\u{446}', '\u{426}'); + type Step = (KeyDef, u16, ffi::GhosttyKeyAction); + // (case, steps, Ghostty direct, Herdr) + let cases: [(&str, Vec, &str, &str); 4] = [ + ( + "ctrl+shift+\u{446}, shift let go, \u{446} then w released", + vec![ + (us_w, 0, PRESS), + (ru_w, CTRL | SHIFT, PRESS), + (ru_w, CTRL, RELEASE), + (us_w, CTRL, RELEASE), + ], + "w\\x1b[1094;6u\\x1b[1094;5:3u\\x1b[119;5:3u", + "w\\x1b[1094;6u\\x1b[1094;5:3u\\x1b[119;5:3u", + ), + ( + "ctrl+shift+\u{446}, shift let go, w then \u{446} released", + vec![ + (us_w, 0, PRESS), + (ru_w, CTRL | SHIFT, PRESS), + (us_w, CTRL, RELEASE), + (ru_w, CTRL, RELEASE), + ], + "w\\x1b[1094;6u\\x1b[119;5:3u\\x1b[1094;5:3u", + "w\\x1b[1094;6u\\x1b[119;5:3u\\x1b[1094;5:3u", + ), + // A Ctrl chord reaches Kitty apps as its physical key (see + // `layout_ctrl_chords_match_ghostty_direct`); its release follows. + ( + "ctrl+\u{446}, ctrl let go, \u{446} then w released", + vec![ + (us_w, 0, PRESS), + (ru_w, CTRL, PRESS), + (ru_w, 0, RELEASE), + (us_w, 0, RELEASE), + ], + "w\\x1b[1094;5u\\x1b[1094;1:3u\\x1b[119;1:3u", + "w\\x1b[119;5u\\x1b[119;1:3u\\x1b[119;1:3u", + ), + ( + "ctrl+\u{446}, ctrl let go, w then \u{446} released", + vec![ + (us_w, 0, PRESS), + (ru_w, CTRL, PRESS), + (us_w, 0, RELEASE), + (ru_w, 0, RELEASE), + ], + "w\\x1b[1094;5u\\x1b[119;1:3u\\x1b[1094;1:3u", + "w\\x1b[119;5u\\x1b[119;1:3u\\x1b[119;1:3u", + ), + ]; + let pane_mode = b"\x1b[>3u"; + for (name, steps, ghostty, want) in cases { + let mut host = Oracle::new(HostProfile::Kitty.setup(pane_mode)); + let mut direct = Oracle::new(pane_mode); + let mut herdr = HerdrPath::new(HostProfile::Kitty, pane_mode); + let mut direct_bytes = Vec::new(); + let mut pane_bytes = Vec::new(); + let mut host_bytes = Vec::new(); + for (def, mods, action) in steps { + let report = host.encode(def, mods, action); + direct_bytes.extend(direct.encode(def, mods, action)); + pane_bytes.extend(herdr.feed(&report).expect("reaches the pane")); + host_bytes.push(show(&report)); + } + assert_eq!(show(&direct_bytes), ghostty, "{name}: Ghostty direct"); + assert_eq!(show(&pane_bytes), want, "{name}: host sent {host_bytes:?}"); + // Nothing stays leased: losing focus releases no further key. + let after = herdr.feed(b"\x1b[O").unwrap_or_default(); + assert!( + !show(&after).contains(":3u"), + "{name}: focus loss sent {}", + show(&after) + ); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn goto_search_opens_on_shifted_slash_from_a_report_all_pane() { + // #4963, recorded from Ghostty 1.3.1 with a Portuguese layout, where `/` is + // Shift+7. With a plain pane Herdr keeps the host at flags 7 and the press + // arrives as text; when the focused pane asks for all keys Herdr pushes 31 + // and the press arrives as a report carrying the shifted key and its text. + for (pane_mode, press, release) in [ + (&b""[..], &b"/"[..], &b"\x1b[55:47;2:3u"[..]), + (b"\x1b[>31u", b"\x1b[55:47;2;47u", b"\x1b[55:47;2:3u"), + ] { + let mut herdr = HerdrPath::new(HostProfile::Kitty, pane_mode); + herdr.state.open_navigator_overlay(); + assert_eq!(herdr.feed(press), None, "{}", show(pane_mode)); + assert_eq!(herdr.feed(release), None, "{}", show(pane_mode)); + assert!( + matches!( + &herdr.state.overlay, + Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused + ), + "goto search did not activate for pane {}", + show(pane_mode) + ); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn goto_search_opens_only_for_a_typed_slash() { + // (pane mode, host bytes, whether goto search opens) + for (pane_mode, press, opens) in [ + // Unshifted `/` reported as a kitty key. + (&b"\x1b[>31u"[..], &b"\x1b[47u"[..], true), + // Shift+/ typing `?` without a shifted alternate is not `/`. + (b"\x1b[>31u", b"\x1b[47;2;63u", false), + // Shift+/ without text or alternate says nothing about `/`. + (b"\x1b[>31u", b"\x1b[47;2u", false), + // Shift+7 with a `/` alternate and no text is `/`. + (b"\x1b[>31u", b"\x1b[55:47;2u", true), + // Ctrl+/ and Alt+/ are not text. + (b"\x1b[>31u", b"\x1b[47;5u", false), + (b"\x1b[>31u", b"\x1b[47;3u", false), + (b"", b"\x1b/", false), + ] { + let mut herdr = HerdrPath::new(HostProfile::Kitty, pane_mode); + herdr.state.open_navigator_overlay(); + let _ = herdr.feed(press); + let focused = matches!( + &herdr.state.overlay, + Some(ClientShellOverlay::Navigator(navigator)) if navigator.search_focused + ); + assert_eq!(focused, opens, "{}", show(press)); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn wezterm_escape_tap_reaches_the_pane_with_doubled_escape_preserved() { + // #1266: WezTerm with `enable_kitty_keyboard` sends an Escape press as a + // bare ESC and its release as `CSI 27;1:3u`. The macOS host policy keeps + // legacy `ESC ESC` whole, which used to swallow quick taps. + const PRESS: &[u8] = b"\x1b"; + const RELEASE: &[u8] = b"\x1b[27;1:3u"; + let mouse: &[u8] = b"\x1b[?1000h\x1b[?1006h"; + for (pane_mode, want) in [ + (&b""[..], &b"\x1b"[..]), + (mouse, b"\x1b"), + (b"\x1b[>1u", b"\x1b[27u"), + ] { + for (reads, label) in [ + (vec![[PRESS, RELEASE].concat()], "one read"), + (vec![PRESS.to_vec(), RELEASE.to_vec()], "two reads"), + ] { + let mut herdr = HerdrPath::new(HostProfile::Kitty, pane_mode); + herdr.framer = crate::raw_input::RawInputByteFramer::with_host_input_policy(true); + herdr.framer.set_host_escape_disambiguation_active(true); + let mut chunks = Vec::new(); + for read in &reads { + chunks.extend(herdr.framer.push(read)); + } + for _ in 0..3 { + chunks.extend(herdr.framer.flush_timeout()); + } + let outcomes = chunks + .iter() + .map(|chunk| herdr.state.handle_input_bytes(chunk)) + .collect(); + let got = herdr.deliver(outcomes).expect("escape reaches the pane"); + assert_eq!(show(&got), show(want), "{label}, pane {}", show(pane_mode)); + } + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn mouse_transparency_conformance() { + let report = run_mouse_conformance(); + print_report(&report); + assert!( + report.failures.len() <= MOUSE_FAILURES_BASELINE, + "mouse transparency regressed: {} failures > {MOUSE_FAILURES_BASELINE}", + report.failures.len() + ); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn keyboard_transparency_conformance() { + let report = run_keyboard_conformance(); + print_report(&report); + assert!( + report.failures.len() <= KEYBOARD_FAILURES_BASELINE, + "keyboard transparency regressed: {} failures > {KEYBOARD_FAILURES_BASELINE}", + report.failures.len() + ); +} + +// --------------------------------------------------------------------------- +// Windows: native key records +// --------------------------------------------------------------------------- +// +// In Windows Terminal each keypress reaches ConPTY as a native key record +// (win32-input-mode). Herdr's pane is a ConPTY too, so for a pane that +// negotiated no keyboard protocol, transparency means the pane's ConPTY gets +// the same records Herdr received. Records are built from the real keyboard +// layouts with ToUnicodeEx, the way Windows produces them. + +#[cfg(windows)] +mod windows_records { + use super::*; + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + GetKeyboardLayout, LoadKeyboardLayoutW, MapVirtualKeyExW, ToUnicodeEx, HKL, + KLF_NOTELLSHELL, MAPVK_VK_TO_VSC, + }; + + const RIGHT_ALT_PRESSED: u32 = 0x0001; + const LEFT_ALT_PRESSED: u32 = 0x0002; + const LEFT_CTRL_PRESSED: u32 = 0x0008; + const SHIFT_PRESSED: u32 = 0x0010; + const ENHANCED_KEY: u32 = 0x0100; + + const VK_SHIFT: usize = 0x10; + const VK_CONTROL: usize = 0x11; + const VK_MENU: usize = 0x12; + const VK_LSHIFT: usize = 0xa0; + const VK_LCONTROL: usize = 0xa2; + const VK_LMENU: usize = 0xa4; + const VK_RMENU: usize = 0xa5; + + const LAYOUTS: &[(&str, &str)] = &[("us", "00000409"), ("de", "00000407")]; + + #[derive(Clone, Copy)] + struct Chord { + name: &'static str, + shift: bool, + ctrl: bool, + left_alt: bool, + /// AltGr: Windows reports it as Right Alt plus Left Ctrl. + alt_gr: bool, + } + + const fn chord( + name: &'static str, + shift: bool, + ctrl: bool, + left_alt: bool, + alt_gr: bool, + ) -> Chord { + Chord { + name, + shift, + ctrl, + left_alt, + alt_gr, + } + } + + const CHORDS: &[Chord] = &[ + chord("plain", false, false, false, false), + chord("shift", true, false, false, false), + chord("ctrl", false, true, false, false), + chord("alt", false, false, true, false), + chord("ctrl+shift", true, true, false, false), + chord("alt+shift", true, false, true, false), + chord("ctrl+alt", false, true, true, false), + chord("altgr", false, false, false, true), + chord("altgr+shift", true, false, false, true), + ]; + + fn virtual_keys() -> Vec<(String, u16, bool)> { + let mut keys: Vec<(String, u16, bool)> = Vec::new(); + for vk in (0x41..=0x5a).chain(0x30..=0x39) { + keys.push((format!("vk{vk:02x}"), vk, false)); + } + for vk in [ + 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xc0, 0xdb, 0xdc, 0xdd, 0xde, 0xe2, + ] { + keys.push((format!("oem{vk:02x}"), vk, false)); + } + for (name, vk) in [ + ("space", 0x20), + ("enter", 0x0d), + ("tab", 0x09), + ("backspace", 0x08), + ("escape", 0x1b), + ] { + keys.push((name.to_owned(), vk, false)); + } + for (name, vk) in [ + ("pageup", 0x21), + ("pagedown", 0x22), + ("end", 0x23), + ("home", 0x24), + ("left", 0x25), + ("up", 0x26), + ("right", 0x27), + ("down", 0x28), + ("insert", 0x2d), + ("delete", 0x2e), + ] { + keys.push((name.to_owned(), vk, true)); + } + for n in 0..12u16 { + keys.push((format!("f{}", n + 1), 0x70 + n, false)); + } + keys + } + + /// Sessions without a desktop (SSH) can only use the current layout. + fn load_layout(klid: &str) -> Option { + let wide: Vec = klid.encode_utf16().chain(Some(0)).collect(); + // SAFETY: `wide` is a NUL-terminated layout id that outlives the call. + let loaded = unsafe { LoadKeyboardLayoutW(wide.as_ptr(), KLF_NOTELLSHELL) }; + if !loaded.is_null() { + return Some(loaded); + } + // SAFETY: querying the calling thread's layout has no preconditions. + let current = unsafe { GetKeyboardLayout(0) }; + let current_id = (current as usize & 0xffff) as u32; + (u32::from_str_radix(klid, 16).ok()? & 0xffff == current_id).then_some(current) + } + + /// The press and release records Windows produces for `vk` under `chord`, + /// or None for multi-unit output. + fn key_records( + layout: HKL, + vk: u16, + enhanced: bool, + chord: Chord, + ) -> Option<( + crate::input::WindowsKeyRecord, + crate::input::WindowsKeyRecord, + )> { + // SAFETY: pure layout lookup. + let scan = unsafe { MapVirtualKeyExW(u32::from(vk), MAPVK_VK_TO_VSC, layout) } as u16; + let mut state = [0u8; 256]; + let mut control_key_state = if enhanced { ENHANCED_KEY } else { 0 }; + if chord.shift { + state[VK_SHIFT] = 0x80; + state[VK_LSHIFT] = 0x80; + control_key_state |= SHIFT_PRESSED; + } + if chord.ctrl || chord.alt_gr { + state[VK_CONTROL] = 0x80; + state[VK_LCONTROL] = 0x80; + control_key_state |= LEFT_CTRL_PRESSED; + } + if chord.left_alt { + state[VK_MENU] = 0x80; + state[VK_LMENU] = 0x80; + control_key_state |= LEFT_ALT_PRESSED; + } + if chord.alt_gr { + state[VK_MENU] = 0x80; + state[VK_RMENU] = 0x80; + control_key_state |= RIGHT_ALT_PRESSED; + } + let mut buf = [0u16; 8]; + // SAFETY: buffers are valid for the given lengths. Flag 0x4 leaves the + // kernel dead-key state untouched. + let written = unsafe { + ToUnicodeEx( + u32::from(vk), + u32::from(scan), + state.as_ptr(), + buf.as_mut_ptr(), + buf.len() as i32, + 0x4, + layout, + ) + }; + let unicode = match written { + 0 => 0, + 1 => buf[0], + // A dead key has not committed a character yet. + n if n < 0 => 0, + _ => return None, + }; + let press = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: vk, + virtual_scan_code: scan, + unicode, + control_key_state, + }; + Some(( + press, + crate::input::WindowsKeyRecord { + key_down: false, + ..press + }, + )) + } + + fn win32_input_mode(record: crate::input::WindowsKeyRecord) -> Vec { + format!( + "\x1b[{};{};{};{};{};{}_", + record.virtual_key_code, + record.virtual_scan_code, + record.unicode, + u8::from(record.key_down), + record.control_key_state, + record.repeat_count + ) + .into_bytes() + } + + fn deliver_record( + herdr: &mut HerdrPath, + input: &mut crate::client::input::windows_vti::TestWindowsInput, + record: crate::input::WindowsKeyRecord, + layout: HKL, + ) -> Option> { + // The generated record belongs to this layout, independently of the + // foreground window's layout while the test runs. + let mut events = input.key_in_layout(record, layout); + events.extend(input.idle()); + let outcome = herdr.state.handle_client_events(&events); + herdr.deliver(vec![outcome]) + } + + fn run() -> Report { + let mut report = Report { + by_cell: BTreeMap::new(), + failures: Vec::new(), + owned: Vec::new(), + }; + for &(layout_name, klid) in LAYOUTS { + let Some(layout) = load_layout(klid) else { + println!( + "keyboard layout {layout_name} ({klid}) unavailable in this session; skipped" + ); + continue; + }; + let mut herdr = HerdrPath::new(HostProfile::Legacy, b""); + let mut input = crate::client::input::windows_vti::TestWindowsInput::default(); + for (key_name, vk, enhanced) in virtual_keys() { + for chord in CHORDS { + let tally = report + .by_cell + .entry(("windows", layout_name, chord.name)) + .or_default(); + let Some((press, release)) = key_records(layout, vk, enhanced, *chord) else { + tally.host_lossy += 1; + continue; + }; + let expected = [win32_input_mode(press), win32_input_mode(release)].concat(); + let pressed = deliver_record(&mut herdr, &mut input, press, layout); + let released = deliver_record(&mut herdr, &mut input, release, layout); + let page_key_scrolls_herdr = chord.name == "plain" + && matches!(key_name.as_str(), "pageup" | "pagedown") + && herdr.runtime.plain_page_keys_use_host_scrollback() == Some(true); + let (Some(pressed), Some(released), false) = + (pressed, released, page_key_scrolls_herdr) + else { + tally.herdr_owned += 1; + herdr.reset_client(); + input = Default::default(); + continue; + }; + let actual = [pressed, released].concat(); + tally.scored += 1; + if actual == expected { + tally.passed += 1; + } else { + report.failures.push(Failure { + host: "windows", + pane: layout_name, + keystroke: format!( + "{}+{key_name} vk={vk:#x} uc={:#x}", + chord.name, press.unicode + ), + expected, + actual, + }); + } + } + } + } + report + } + + #[tokio::test(flavor = "multi_thread")] + async fn windows_key_record_transparency() { + let report = run(); + print_report(&report); + assert!( + report.failures.len() <= WINDOWS_RECORD_FAILURES_BASELINE, + "windows key record transparency regressed: {} failures > {WINDOWS_RECORD_FAILURES_BASELINE}", + report.failures.len() + ); + } + + // Ratchet (may only go down). Remaining: Ctrl+[ is forwarded as a plain + // Escape record rather than its own key record. + const WINDOWS_RECORD_FAILURES_BASELINE: usize = 1; +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index dd60fe11..fd3301db 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -1,4 +1,66 @@ use super::*; +use crate::input::TerminalKey; + +#[test] +fn plus_commands_survive_snapshots_and_reload_for_both_keybinding_sources() { + for source in [ + ClientShellKeybindingSource::Local, + ClientShellKeybindingSource::Endpoint, + ] { + let mut state = ClientShellState::new( + ClientShellConfig::from_config(&Config::default()).with_keybinding_source(source), + ); + for (revision, key, modifiers) in [ + (1, "prefix+plus", KeyModifiers::empty()), + (2, "prefix+ctrl+plus", KeyModifiers::CONTROL), + ] { + let config: Config = toml::from_str(&format!( + "[[keys.command]]\nkey = {key:?}\ntype = \"shell\"\ncommand = \"echo plus\"\n" + )) + .unwrap(); + assert!(config.collect_diagnostics().is_empty()); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let app = crate::app::App::new( + &config, + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + let mut projection = snapshot(); + projection.revision = revision; + projection.server_keybindings_toml = config.local_keybindings_profile_toml().ok(); + projection.commands = app.client_shell_command_manifest(); + assert_eq!(projection.commands.len(), 1); + let command_id = projection.commands[0].command_id.clone(); + state.set_snapshot(Box::new(projection)); + state.set_pane_surface(surface()); + assert_eq!(state.config.keybinds.keybinds.custom_commands.len(), 1); + + for plus in [ + TerminalKey::new(KeyCode::Char('+'), modifiers), + TerminalKey::new(KeyCode::Char('+'), modifiers | KeyModifiers::SHIFT), + TerminalKey::new(KeyCode::Char('='), modifiers | KeyModifiers::SHIFT) + .with_shifted_codepoint('+' as u32), + ] { + let outcome = state.handle_raw_events(vec![ + RawInputEvent::Key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL)), + RawInputEvent::Key(plus), + ]); + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!( + "expected endpoint command invocation: {:?}", + outcome.actions + ); + }; + let crate::api::schema::Method::CommandInvoke(params) = &request.method else { + panic!("expected command.invoke"); + }; + assert_eq!(params.command_id, command_id); + } + } + } +} #[test] fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() { @@ -44,6 +106,83 @@ fn shell_new_controls_use_the_same_client_action_routes_as_keybinds() { )); } +#[test] +fn remote_client_preferences_keep_the_same_identity_across_bridge_processes() { + let first = ClientShellConfig::from_config(&Config::default()).with_endpoint_preferences( + std::path::Path::new("/tmp/herdr-remote-100-dev-agents.sock"), + Some(r#"["dev", "agents"]"#), + ); + let next = ClientShellConfig::from_config(&Config::default()).with_endpoint_preferences( + std::path::Path::new("/tmp/herdr-remote-200-dev-agents.sock"), + Some(r#"["dev", "agents"]"#), + ); + assert_eq!(first.preferences_path, next.preferences_path); + let root = + std::env::temp_dir().join(format!("herdr-remote-preferences-{}", std::process::id())); + let first_path = root.join( + first + .preferences_path + .as_ref() + .unwrap() + .file_name() + .unwrap(), + ); + let next_path = root.join(next.preferences_path.as_ref().unwrap().file_name().unwrap()); + let mut state = ClientShellState::new(first.with_preferences_path(first_path)); + state.sidebar_width = 31; + state.sidebar_width_manual = true; + state.sidebar_collapsed = true; + state.sidebar_collapsed_manual = true; + state.persist_chrome_preferences(&mut ClientShellInput::default()); + let restored = ClientShellState::new(next.with_preferences_path(next_path)); + assert_eq!(restored.sidebar_width, 31); + assert!(restored.sidebar_collapsed); + std::fs::remove_dir_all(root).unwrap(); + + let local_socket = std::path::Path::new("/tmp/local.sock"); + for identity in [ + None, + Some("invalid"), + Some(r#"["dev", ""]"#), + Some(r#"["", "agents"]"#), + ] { + let config = ClientShellConfig::from_config(&Config::default()) + .with_endpoint_preferences(local_socket, identity); + assert_eq!( + config.preferences_path, + Some(super::super::preferences::path_for_local_endpoint( + local_socket + )) + ); + } +} + +#[test] +fn remote_client_preferences_process_child() { + if std::env::var("HERDR_TEST_PREFERENCES_CHILD").as_deref() != Ok("1") { + return; + } + let expected_path = super::super::preferences::path_for_remote_endpoint("dev", "agents"); + if !expected_path.exists() { + super::super::preferences::store( + expected_path.as_path(), + super::super::preferences::ClientChromePreferences { + sidebar_width: Some(31), + sidebar_collapsed: Some(true), + ..Default::default() + }, + ) + .unwrap(); + } + let socket = crate::server::socket_paths::client_socket_path(); + let config = ClientShellConfig::from_config(&Config::default()) + .with_process_endpoint_preferences(&socket); + assert_eq!(config.preferences_path, Some(expected_path)); + let restored = ClientShellState::new(config); + assert_eq!(restored.sidebar_width, 31); + assert!(restored.sidebar_collapsed); +} + #[test] fn manual_client_chrome_preferences_round_trip_per_endpoint() { let path = std::env::temp_dir().join(format!( diff --git a/src/client/shell/tests/link_hover.rs b/src/client/shell/tests/link_hover.rs index f29fdec2..40173fdc 100644 --- a/src/client/shell/tests/link_hover.rs +++ b/src/client/shell/tests/link_hover.rs @@ -276,6 +276,17 @@ fn ctrl_hover_ignores_late_reply_after_pointer_leaves() { })]); assert!(!resolve_hover(&mut state, &id)); assert!(state.link_hover.is_none()); + + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + state.config.mouse_capture = false; + assert!(!resolve_hover(&mut state, &id)); + let ignored = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!( + ignored.actions.is_empty(), + "native links must not request Herdr hover" + ); + assert!(state.link_hover.is_none()); } #[test] diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index ba2e4f2f..a3905d87 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -247,12 +247,14 @@ fn surface_with_popup() -> PaneSurfaceFrame { mod agents_worktrees_notifications; mod chrome_context; +mod close_pane; mod close_tab; mod copy; mod endpoint_requests; mod endpoints; mod focus_snooze; mod graphics; +mod input_conformance; #[path = "input.rs"] mod input_domain; mod keybindings_settings; diff --git a/src/client/shell/tests/mouse_selection.rs b/src/client/shell/tests/mouse_selection.rs index ae4168c2..eced2347 100644 --- a/src/client/shell/tests/mouse_selection.rs +++ b/src/client/shell/tests/mouse_selection.rs @@ -112,6 +112,77 @@ fn ctrl_click_routes_link_activation_through_endpoint_then_client_host() { [ClientShellAction::OpenSafeWebUrl(url)] if url == "https://example.test" )); assert!(!state.url_click_consumes_until_up); + + let activate = |state: &mut ClientShellState, url: &str, handled| { + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else { + panic!("expected link activation request"); + }; + state.handle_raw_events(vec![RawInputEvent::Mouse(up)]); + state + .handle_endpoint_result( + "boot-1", + &request.id, + Ok(crate::api::schema::ResponseResult::PaneLinkActivated { + url: Some(url.into()), + handled, + }), + ) + .1 + }; + for url in [ + "https://example.test", + "file:///C:/Code/note.md", + "obsidian://open?vault=meta", + ] { + assert!( + activate(&mut state, url, true).is_empty(), + "claimed links belong to plugins" + ); + if !url.starts_with("https:") { + assert!( + matches!( + &activate(&mut state, url, false)[..], + [ClientShellAction::ReplayMouse(_)] + ), + "unclaimed non-web links must not open externally" + ); + } + } + let click = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + let [ClientShellAction::Endpoint { request, .. }] = &click.actions[..] else { + panic!("expected link activation request"); + }; + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request.id, + Err(ClientShellEndpointError { + code: Some("plugin_link_failed".into()), + message: "Plugin could not start".into(), + }), + ); + assert!( + actions.is_empty(), + "claimed-link errors must not fall through" + ); + assert!(state.visible_endpoint_notice.is_some()); + state.handle_raw_events(vec![RawInputEvent::Mouse(up)]); + + // Pane apps can still request mouse reporting when Herdr capture is off. + state.config.mouse_capture = false; + let mut pane_surface = surface(); + pane_surface.panes[0].mouse_reporting = true; + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("capture-off pane frame"); + let native = state.handle_raw_events(vec![RawInputEvent::Mouse(down)]); + assert!(!native.actions.iter().any(|action| matches!(action, + ClientShellAction::Endpoint { request, .. } + if matches!(request.method, crate::api::schema::Method::PaneLinkActivate(_))))); + assert!(matches!(&native.requests[..], + [ClientMessage::ClientShellPaneInput { events, .. }] + if matches!(&events[..], [ClientPaneInputEvent::Mouse { modifiers, .. }] + if *modifiers == KeyModifiers::CONTROL.bits()))); + assert!(state.selection.is_none()); } #[test] diff --git a/src/client/shell/tests/startup_overlays.rs b/src/client/shell/tests/startup_overlays.rs index 741a9fcb..88318d18 100644 --- a/src/client/shell/tests/startup_overlays.rs +++ b/src/client/shell/tests/startup_overlays.rs @@ -358,6 +358,26 @@ fn startup_onboarding_is_client_rendered_and_modal() { assert!(popup_paste.requests.is_empty()); } +#[test] +fn startup_onboarding_shows_configured_prefix() { + let mut config = Config::default(); + config.keys.prefix = crate::config::BindingConfig::one("ctrl+shift+b"); + let config = ClientShellConfig::from_config(&config).with_startup_onboarding(true); + let mut state = ClientShellState::new(config); + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + + let frame = state.compose(70, 20).expect("onboarding frame"); + let text = frame + .cells + .iter() + .map(|cell| cell.symbol.as_str()) + .collect::(); + assert!(text.contains("ctrl+shift+b enters prefix mode")); + assert!(text.contains("shows keybinds and settings")); + assert!(!text.contains("ctrl+b enters prefix mode")); +} + #[test] fn onboarding_completion_persists_and_opens_endpoint_integrations() { let path = std::env::temp_dir().join(format!( @@ -754,7 +774,7 @@ fn update_ready_menu_opens_client_owned_release_notes_and_dismisses_by_version() }) .collect::>() .join("\n"); - assert!(text.contains("v0.9.3.1")); + assert!(text.contains("v0.9.5.1")); assert!(text.contains("SUPERHERDR 0.9.1.2")); assert!(!state.hits.release_notes_scrollbar.is_empty()); let outer = crate::ui::centered_popup_rect( diff --git a/src/client/shell/tests/text_editing.rs b/src/client/shell/tests/text_editing.rs index 118a606e..f3db057e 100644 --- a/src/client/shell/tests/text_editing.rs +++ b/src/client/shell/tests/text_editing.rs @@ -393,3 +393,22 @@ fn every_field_renders_long_unicode_across_resize_without_mutation() { } } } + +#[test] +fn plain_and_ime_text_fill_every_field_on_a_report_all_host() { + // #5255: a report-all pane under a Kitty host still lets plain bytes and + // IME commits type into Herdr's own fields. + for field in 0..10 { + let mut state = shell(field); + state.set_host_reports_key_releases(true); + state.set_host_reports_all_keys(true); + *editor(&mut state) = TextEditor::default(); + let result = state.handle_input_bytes("x\u{4f60}\u{597d}".as_bytes()); + assert!(result.requests.is_empty(), "field {field}"); + assert_eq!( + editor(&mut state).as_str(), + "x\u{4f60}\u{597d}", + "field {field}" + ); + } +} diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs index c117a319..dff7f7d4 100644 --- a/src/client/shell/tests/workspace_navigation.rs +++ b/src/client/shell/tests/workspace_navigation.rs @@ -968,3 +968,61 @@ fn navigation_highlight_ends_for_noop_focus_and_focused_creation() { } } } + +// Kitty keyboard protocol (CSI u) encodings of `[` (codepoint 91). +const KITTY_CTRL_BRACKET: &[u8] = b"\x1b[91;5u"; +const KITTY_CTRL_SHIFT_BRACKET: &[u8] = b"\x1b[91;6u"; + +#[test] +fn kitty_ctrl_bracket_leaves_navigation_like_esc() { + let mut state = local_navigation_state(false); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b[B"); + + preview_key(&mut state, KITTY_CTRL_BRACKET); + + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.navigate_workspace_id.is_none()); +} + +#[test] +fn configured_ctrl_bracket_navigate_binding_wins_over_cancel() { + let config: Config = toml::from_str("[keys]\nnavigate_workspace_down = \"ctrl+[\"\n").unwrap(); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(workspaces(3))); + state.set_pane_surface(surface()); + state.compose(100, 28).unwrap(); + enter_navigation(&mut state); + assert_selected(&state, &ClientEndpointId::Local, "ws_1"); + + preview_key(&mut state, KITTY_CTRL_BRACKET); + + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_selected(&state, &ClientEndpointId::Local, "ws_2"); +} + +#[test] +fn kitty_ctrl_shift_bracket_keeps_navigation_open() { + let mut state = local_navigation_state(false); + enter_navigation(&mut state); + + state.handle_input_bytes(KITTY_CTRL_SHIFT_BRACKET); + + assert_eq!(state.mode, ClientShellMode::Navigate); + assert_selected(&state, &ClientEndpointId::Local, "ws_1"); +} + +#[test] +fn kitty_ctrl_bracket_cancels_a_blocked_foreign_preview() { + let (mut state, remote) = state_with_remote(); + state.compose(100, 28).unwrap(); + enter_navigation(&mut state); + preview_key(&mut state, b"\x1b[B"); + assert_selected(&state, &remote, "ws_1"); + assert!(state.workspace_preview_action_blocked()); + + preview_key(&mut state, KITTY_CTRL_BRACKET); + + assert_ne!(state.mode, ClientShellMode::Navigate); + assert!(state.navigate_workspace_id.is_none()); +} diff --git a/src/client/shell_runtime.rs b/src/client/shell_runtime.rs index 82fd4c33..794bf3d2 100644 --- a/src/client/shell_runtime.rs +++ b/src/client/shell_runtime.rs @@ -22,7 +22,14 @@ pub(super) fn dispatch_client_shell_actions( let generation = endpoints.connection(&endpoint_id).and_then(|connection| { (inactive_shared || (endpoints.active_id() == &endpoint_id - && endpoints.active_surface_available())) + && (endpoints.active_surface_available() + // The coherent target is visible before its input fence opens. + // Retain workspace navigation; sending remains gated below. + || (connection.surface_active + && matches!( + request.method, + crate::api::schema::Method::WorkspaceFocus(_) + ))))) .then_some(connection.generation) }); if let Some(generation) = generation { @@ -113,6 +120,9 @@ pub(super) fn sync_client_shell_keyboard_report_all( crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired) .map_err(ClientError::ConnectionFailed)?; state.keyboard_report_all_active = desired; + if let Some(shell) = state.shell.as_mut() { + shell.set_host_reports_all_keys(desired); + } Ok(()) } @@ -128,7 +138,8 @@ pub(super) fn clear_endpoint_host_effects( } else { state.direct_mouse_capture_preference }; - let sgr_pixels = super::effective_sgr_pixel_mouse(enabled, false, state.pixel_geometry_exact); + let sgr_pixels = + super::effective_sgr_pixel_mouse(enabled, false, state.pixel_mouse_available()); if enabled != state.mouse_capture_active || sgr_pixels != host_sgr_pixels_active.load(std::sync::atomic::Ordering::Acquire) { @@ -168,10 +179,11 @@ fn install_pending_activation( next_surface_serial: &mut u64, activation: endpoint::PendingEndpointActivation, ) { - let retired = activation - .source_command_lane() - .map(|source| endpoint_commands.retire_lane(source)) - .unwrap_or_default(); + // A fresh target epoch must not replay navigation retained by an abandoned handoff. + let mut retired = endpoint_commands.retire_lane(activation.target()); + if let Some(source) = activation.source_command_lane() { + retired.extend(endpoint_commands.retire_lane(source)); + } if let Some(shell) = state.shell.as_mut() { for request_id in retired { shell.cancel_endpoint_request(&request_id); @@ -229,6 +241,15 @@ pub(super) fn begin_endpoint_activation( now: std::time::Instant, scheduled_activation: &mut Option, ) -> Result<(), ClientError> { + #[cfg(windows)] + if target.as_ref().is_some_and(|target| { + !state + .shell + .as_ref() + .is_some_and(|shell| shell.notification_target_is_current(&endpoint_id, target)) + }) { + return Ok(()); + } state.deferred_local_activation = None; if endpoint_id.is_local() && !local_activation_metadata_ready(state, endpoints) { state.deferred_local_activation = Some(endpoint::EndpointActivationIntent { @@ -299,7 +320,7 @@ pub(super) fn begin_endpoint_activation( state.reported_size.1, state.reported_cell_size.0, state.reported_cell_size.1, - state.pixel_geometry_exact, + state.pixel_mouse_available(), ); match endpoint::PendingEndpointActivation::prepare( shell, @@ -642,6 +663,7 @@ pub(super) fn install_client_shell_snapshot( let generation = connection.generation; let project_snapshot = !projection_pending && endpoints.active_id() == endpoint_id && connection.surface_active; + let pixel_mouse_available = state.pixel_mouse_available(); let (composed, resize, graphics_cleanup) = if let Some(shell) = &mut state.shell { let waits_for_selected_surface = projection_pending || (endpoints.active_id() == endpoint_id @@ -671,7 +693,7 @@ pub(super) fn install_client_shell_snapshot( state.reported_size.1, state.reported_cell_size.0, state.reported_cell_size.1, - state.pixel_geometry_exact, + pixel_mouse_available, ) }), graphics_cleanup, @@ -717,7 +739,7 @@ pub(super) fn finish_client_shell_input( state.reported_size.1, state.reported_cell_size.0, state.reported_cell_size.1, - state.pixel_geometry_exact, + state.pixel_mouse_available(), ); if let Some(activation) = pending_activation.as_mut() { if let Err(error) = activation.update_resize(resize, endpoints) { diff --git a/src/client/state.rs b/src/client/state.rs index 2cadfa62..312c65ca 100644 --- a/src/client/state.rs +++ b/src/client/state.rs @@ -51,6 +51,8 @@ pub(super) struct ClientState { pub(super) kitty_graphics_enabled: bool, pub(super) pixel_geometry_enabled: bool, pub(super) pixel_geometry_exact: bool, + /// The host's answer to the SGR pixel mouse probe; None when unknown. + pub(super) host_sgr_pixel_mouse: Option, #[cfg(unix)] pub(super) direct_graphics_response: Arc>, #[cfg(unix)] @@ -94,6 +96,10 @@ impl Drop for ClientState { } impl ClientState { + pub(super) fn pixel_mouse_available(&self) -> bool { + self.pixel_geometry_exact && self.host_sgr_pixel_mouse == Some(true) + } + #[cfg(test)] pub(super) fn test_new() -> Self { Self { @@ -114,6 +120,7 @@ impl ClientState { kitty_graphics_enabled: false, pixel_geometry_enabled: false, pixel_geometry_exact: false, + host_sgr_pixel_mouse: None, #[cfg(unix)] direct_graphics_response: Default::default(), #[cfg(unix)] @@ -542,14 +549,27 @@ impl ClientState { pub(super) fn try_present_frame( &mut self, frame_data: impl Into, + ) -> bool { + self.try_present_frame_to(&mut io::stdout(), frame_data) + } + + pub(super) fn try_present_frame_to( + &mut self, + writer: &mut impl io::Write, + frame_data: impl Into, ) -> bool { if self.presentation_frozen { return false; } let frame_output::ComposedFrame { - frame: frame_data, + frame: mut frame_data, graphics, } = frame_data.into(); + // With capture enabled, host OSC 8 can intercept Ctrl-click before plugins. + // Keep semantic links internally and restore native links when capture is off. + if self.shell_mouse_capture_preference { + frame_data.hyperlinks.clear(); + } let frame_data = if self.draw_host_cursor { render_ansi::frame_with_drawn_cursor(frame_data) } else { @@ -561,8 +581,7 @@ impl ClientState { } else { self.blit_encoder.encode(&frame_data, self.repaint_pending) }; - let mut stdout = io::stdout(); - if let Err(error) = self.write_composed_output(&mut stdout, &encoded.bytes, graphics) { + if let Err(error) = self.write_composed_output(writer, &encoded.bytes, graphics) { tracing::warn!(%error, "failed to present client frame"); self.repaint_pending = true; return false; diff --git a/src/client/terminal_sessions.rs b/src/client/terminal_sessions.rs index aeecbf47..2cc7784b 100644 --- a/src/client/terminal_sessions.rs +++ b/src/client/terminal_sessions.rs @@ -12,7 +12,7 @@ use crate::protocol::{ }; use crate::server::socket_paths::client_socket_path; -use super::{do_handshake, init_logging, write_to_server, ClientError}; +use super::{do_handshake, init_logging, write_to_server, ClientError, HandshakePixelCapabilities}; /// Runs a read-only terminal session observer and prints one JSON envelope per frame. pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { @@ -95,7 +95,10 @@ fn connect_terminal_session_stream( rows, 0, 0, - false, + HandshakePixelCapabilities { + exact_geometry: false, + sgr_mouse: false, + }, None, false, false, diff --git a/src/client/terminal_setup.rs b/src/client/terminal_setup.rs index 249e29ad..8a0a45eb 100644 --- a/src/client/terminal_setup.rs +++ b/src/client/terminal_setup.rs @@ -48,9 +48,11 @@ pub(super) fn setup_terminal_with_capabilities( ratatui::init(); let mut terminal_guard = TerminalGuard { host_escape_disambiguation_active: false, + host_sgr_pixel_mouse: None, buffered_host_input: Vec::new(), reset_keyboard_enhancements: false, reset_modify_other_keys: false, + modify_other_keys_sequence: None, reset_host_color_scheme_reports: false, restore_claimed: Arc::new(AtomicBool::new(false)), restored: false, @@ -63,38 +65,38 @@ pub(super) fn setup_terminal_with_capabilities( #[cfg(windows)] let windows_ssh_session = is_ssh_session(); #[cfg(windows)] - let mut windows_virtual_terminal_input = - if windows_vti_input_backend_enabled() && windows_ssh_session { - enable_windows_virtual_terminal_input( - &terminal_guard.restore_claimed, - &terminal_guard.restore_windows_input_mode, - ) - } else { - WindowsVirtualTerminalInputSetup::default() - }; + let mut windows_virtual_terminal_input = if windows_ssh_session { + enable_windows_virtual_terminal_input( + &terminal_guard.restore_claimed, + &terminal_guard.restore_windows_input_mode, + ) + } else { + WindowsVirtualTerminalInputSetup::default() + }; - let (host_escape_disambiguation_active, buffered_host_input) = if enable_client_protocols { + let (host_probe, buffered_host_input) = if enable_client_protocols { terminal_guard.reset_keyboard_enhancements = true; push_keyboard_enhancement_flags()?; - let (active, buffered_input) = query_host_escape_disambiguation(); + let (probe, buffered_input) = query_host_capabilities(true); set_mouse_capture(mouse_capture, false)?; execute!(io::stdout(), EnableBracketedPaste, EnableFocusChange)?; if host_color_scheme_reports { terminal_guard.reset_host_color_scheme_reports = true; write_host_color_scheme_report_mode(&mut io::stdout(), true)?; } - (active, buffered_input) + (probe, buffered_input) } else { if should_enable_host_color_scheme_reports(true) { write_host_color_scheme_report_mode(&mut io::stdout(), false)?; } + let (probe, buffered_input) = query_host_capabilities(false); set_mouse_capture(mouse_capture, false)?; execute!(io::stdout(), EnableBracketedPaste)?; - (false, Vec::new()) + (probe, buffered_input) }; #[cfg(windows)] - if enable_client_protocols && windows_vti_input_backend_enabled() && !windows_ssh_session { + if enable_client_protocols && !windows_ssh_session { windows_virtual_terminal_input = enable_windows_virtual_terminal_input( &terminal_guard.restore_claimed, &terminal_guard.restore_windows_input_mode, @@ -103,7 +105,6 @@ pub(super) fn setup_terminal_with_capabilities( #[cfg(windows)] if enable_client_protocols - && windows_vti_input_backend_enabled() && windows_virtual_terminal_input.active && windows_win32_input_mode_enabled() { @@ -115,13 +116,15 @@ pub(super) fn setup_terminal_with_capabilities( .flatten(); if let Some(mode) = modify_other_keys_mode { terminal_guard.reset_modify_other_keys = true; + terminal_guard.modify_other_keys_sequence = Some(mode.set_sequence()); io::stdout().write_all(mode.set_sequence())?; io::stdout().flush()?; } execute!(io::stdout(), DisableLineWrap)?; - terminal_guard.host_escape_disambiguation_active = host_escape_disambiguation_active; + terminal_guard.host_escape_disambiguation_active = host_probe.escape_disambiguation; + terminal_guard.host_sgr_pixel_mouse = host_probe.sgr_pixel_mouse; terminal_guard.buffered_host_input = buffered_host_input; Ok(terminal_guard) } @@ -133,9 +136,12 @@ pub(super) fn should_enable_host_color_scheme_reports(enable_client_protocols: b /// Guard that restores the terminal when dropped. pub(super) struct TerminalGuard { host_escape_disambiguation_active: bool, + host_sgr_pixel_mouse: Option, buffered_host_input: Vec, reset_keyboard_enhancements: bool, reset_modify_other_keys: bool, + /// The modifyOtherKeys sequence written at setup, re-sent on refresh. + modify_other_keys_sequence: Option<&'static [u8]>, reset_host_color_scheme_reports: bool, restore_claimed: Arc, restored: bool, @@ -144,7 +150,7 @@ pub(super) struct TerminalGuard { } #[cfg(not(windows))] -const HOST_KEYBOARD_QUERY_TIMEOUT: Duration = Duration::from_millis(250); +const HOST_CAPABILITY_QUERY_TIMEOUT: Duration = Duration::from_millis(250); #[cfg(not(windows))] const MAX_BUFFERED_HOST_INPUT: usize = 64 * 1024; @@ -152,26 +158,41 @@ const MAX_BUFFERED_HOST_INPUT: usize = 64 * 1024; #[derive(Default)] struct HostKeyboardProbeResponses { flags: Option, + sgr_pixel_mouse_status: Option, primary_device_attributes: bool, } +/// What the startup probe learned about the host terminal. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct HostTerminalProbe { + pub(super) escape_disambiguation: bool, + /// None when the host did not answer; exact pixel input stays disabled. + pub(super) sgr_pixel_mouse: Option, +} + #[cfg(not(windows))] -fn query_host_escape_disambiguation() -> (bool, Vec) { - const QUERY: &[u8] = b"\x1b[?u\x1b[c"; +fn query_host_capabilities(query_keyboard: bool) -> (HostTerminalProbe, Vec) { + const FULL_QUERY: &[u8] = b"\x1b[?1016$p\x1b[?u\x1b[c"; + const PIXEL_MOUSE_QUERY: &[u8] = b"\x1b[?1016$p\x1b[c"; let mut buffered_input = Vec::new(); + let query = if query_keyboard { + FULL_QUERY + } else { + PIXEL_MOUSE_QUERY + }; if let Err(err) = io::stdout() - .write_all(QUERY) + .write_all(query) .and_then(|()| io::stdout().flush()) { - tracing::debug!(%err, "host keyboard enhancement query unavailable"); - return (false, buffered_input); + tracing::debug!(%err, "host capability query unavailable"); + return (HostTerminalProbe::default(), buffered_input); } // Bypass StdinLock's shared buffer so poll and read observe the same bytes. let stdin = io::stdin(); let stdin_fd = stdin.as_raw_fd(); - let deadline = Instant::now() + HOST_KEYBOARD_QUERY_TIMEOUT; + let deadline = Instant::now() + HOST_CAPABILITY_QUERY_TIMEOUT; let mut responses = HostKeyboardProbeResponses::default(); while !responses.primary_device_attributes && buffered_input.len() < MAX_BUFFERED_HOST_INPUT { let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { @@ -205,10 +226,27 @@ fn query_host_escape_disambiguation() -> (bool, Vec) { } } - ( - host_escape_disambiguation_confirmed(&responses), - buffered_input, - ) + (host_terminal_probe(&responses), buffered_input) +} + +#[cfg(not(windows))] +fn host_terminal_probe(responses: &HostKeyboardProbeResponses) -> HostTerminalProbe { + let sgr_pixel_mouse = match responses.sgr_pixel_mouse_status { + // The query follows an explicit reset, so a host still reporting SET may + // send pixels where Herdr expects cells. Wrong mouse units beat refusing + // to start, so keep pixel mouse off and continue. + Some(status @ (1 | 3)) => { + tracing::warn!(status, "host SGR pixel mouse mode stays set after reset"); + Some(false) + } + Some(2) => Some(true), + Some(0 | 4) => Some(false), + _ => None, + }; + HostTerminalProbe { + escape_disambiguation: host_escape_disambiguation_confirmed(responses), + sgr_pixel_mouse, + } } #[cfg(not(windows))] @@ -263,6 +301,21 @@ fn consume_host_keyboard_probe_responses( break; } let body = &buffered_input[start + 3..end]; + // DECRPM: CSI ? 1016 ; Ps $ y. + if buffered_input[end] == b'$' { + let Some(&final_byte) = buffered_input.get(end + 1) else { + break; + }; + if final_byte == b'y' { + if let Some(status) = parse_sgr_pixel_mouse_status(body) { + responses.sgr_pixel_mouse_status = Some(status); + buffered_input.drain(start..=end + 1); + continue; + } + } + offset += 1; + continue; + } let recognized = match buffered_input[end] { b'u' if !body.is_empty() && body.iter().all(u8::is_ascii_digit) => { std::str::from_utf8(body) @@ -293,6 +346,14 @@ fn consume_host_keyboard_probe_responses( } } +#[cfg(not(windows))] +fn parse_sgr_pixel_mouse_status(body: &[u8]) -> Option { + let mut fields = body.split(|byte| *byte == b';'); + (fields.next()? == b"1016").then_some(())?; + let status = std::str::from_utf8(fields.next()?).ok()?.parse().ok()?; + (fields.next().is_none() && status <= 4).then_some(status) +} + #[cfg(not(windows))] fn host_control_string_end(bytes: &[u8]) -> Option> { if bytes.first() != Some(&0x1b) { @@ -322,8 +383,8 @@ fn host_control_string_end(bytes: &[u8]) -> Option> { } #[cfg(windows)] -fn query_host_escape_disambiguation() -> (bool, Vec) { - (false, Vec::new()) +fn query_host_capabilities(_query_keyboard: bool) -> (HostTerminalProbe, Vec) { + (HostTerminalProbe::default(), Vec::new()) } pub(super) fn write_host_color_scheme_report_mode( @@ -495,13 +556,6 @@ pub(super) fn is_ssh_session() -> bool { std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() } -#[cfg(windows)] -pub(super) fn windows_vti_input_backend_enabled() -> bool { - std::env::var("HERDR_WINDOWS_INPUT_BACKEND") - .map(|backend| !backend.eq_ignore_ascii_case("crossterm")) - .unwrap_or(true) -} - #[cfg(any(windows, test))] pub(super) fn windows_virtual_terminal_input_mode(mode: u32) -> u32 { mode | 0x0200 @@ -531,9 +585,9 @@ pub(super) fn effective_mouse_capture( pub(super) fn effective_sgr_pixel_mouse( enabled: bool, requested: bool, - exact_geometry: bool, + pixel_mouse_available: bool, ) -> bool { - enabled && requested && exact_geometry + enabled && requested && pixel_mouse_available } #[cfg(any(windows, test))] @@ -555,8 +609,7 @@ fn set_windows_native_mouse_capture( #[cfg(windows)] fn windows_uses_vt_mouse_reporting() -> bool { - windows_vti_input_backend_enabled() - && (is_ssh_session() || crate::platform::windows_virtual_terminal_input_active()) + is_ssh_session() || crate::platform::windows_virtual_terminal_input_active() } pub(super) fn set_mouse_capture(enabled: bool, sgr_pixels: bool) -> io::Result<()> { @@ -666,7 +719,7 @@ fn restore_terminal_state( write_terminal_restore_postlude(&mut io::stdout(), reset_host_color_scheme_reports); #[cfg(windows)] - if windows_vti_input_backend_enabled() && windows_win32_input_mode_enabled() { + if windows_win32_input_mode_enabled() { let _ = disable_windows_win32_input_mode(&mut io::stdout()); } @@ -715,15 +768,78 @@ fn disable_windows_win32_input_mode(writer: &mut impl std::io::Write) -> io::Res writer.flush() } +/// Host input modes to re-assert after the host may have reset them. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct HostKeyboardProtocolRefresh { + /// Some when the host confirmed the kitty keyboard protocol at startup; + /// the value says whether all keys are currently reported as escapes. + pub(super) kitty_report_all_keys: Option, + pub(super) modify_other_keys_sequence: Option<&'static [u8]>, + /// Re-enable focus reporting (1004). Callers reacting to a focus-in skip + /// it: that host still reports focus, and re-enabling from a focus report + /// could loop on a host that answers the enable with another report. + pub(super) focus_reporting: bool, +} + +pub(super) fn write_host_keyboard_protocol_refresh( + writer: &mut impl io::Write, + refresh: HostKeyboardProtocolRefresh, +) -> io::Result<()> { + if let Some(report_all_keys) = refresh.kitty_report_all_keys { + // Replace Herdr's stack entry rather than pushing, so repeated + // refreshes never grow the host's keyboard stack. On a host that + // reset its stack, the pop is a no-op and the push restores the flags. + crate::terminal_modes::set_host_kitty_keyboard_report_all(writer, report_all_keys)?; + } + if let Some(sequence) = refresh.modify_other_keys_sequence { + writer.write_all(sequence)?; + } + writer.write_all(b"\x1b[?2004h")?; + if refresh.focus_reporting { + writer.write_all(b"\x1b[?1004h")?; + } + writer.flush() +} + impl TerminalGuard { pub(super) fn host_escape_disambiguation_active(&self) -> bool { self.host_escape_disambiguation_active } + /// Whether the host answered that it supports SGR pixel mouse reports. + pub(super) fn host_sgr_pixel_mouse(&self) -> Option { + self.host_sgr_pixel_mouse + } + pub(super) fn take_buffered_host_input(&mut self) -> Vec { std::mem::take(&mut self.buffered_host_input) } + /// Re-sends the keyboard and input modes enabled at setup. A host that + /// reset its terminal state (a reconnect or window reload) forgets them, + /// which turns Shift+Enter and Ctrl+Enter into plain Enter. + pub(super) fn refresh_host_keyboard_protocol( + &self, + writer: &mut impl io::Write, + report_all_keys: bool, + focus_reporting: bool, + ) -> io::Result<()> { + // Direct attach leaves these modes to the attached pane. + if !self.reset_keyboard_enhancements || self.restore_claimed.load(Ordering::Acquire) { + return Ok(()); + } + write_host_keyboard_protocol_refresh( + writer, + HostKeyboardProtocolRefresh { + kitty_report_all_keys: self + .host_escape_disambiguation_active + .then_some(report_all_keys), + modify_other_keys_sequence: self.modify_other_keys_sequence, + focus_reporting, + }, + ) + } + #[cfg(windows)] pub(super) fn recover_windows_virtual_terminal_input(&self) -> io::Result<()> { let active = enable_windows_virtual_terminal_input( @@ -848,10 +964,151 @@ mod tests { } } + #[cfg(not(windows))] + fn refresh_test_guard( + kitty_confirmed: bool, + client_protocols: bool, + modify_other_keys_sequence: Option<&'static [u8]>, + ) -> TerminalGuard { + TerminalGuard { + host_escape_disambiguation_active: kitty_confirmed, + host_sgr_pixel_mouse: None, + buffered_host_input: Vec::new(), + reset_keyboard_enhancements: client_protocols, + reset_modify_other_keys: modify_other_keys_sequence.is_some(), + modify_other_keys_sequence, + reset_host_color_scheme_reports: false, + restore_claimed: Arc::new(AtomicBool::new(false)), + // Keep Drop from writing restore sequences to the test's stdout. + restored: true, + } + } + + #[cfg(not(windows))] + fn refresh_bytes(guard: &TerminalGuard, report_all_keys: bool, focus: bool) -> Vec { + let mut output = Vec::new(); + guard + .refresh_host_keyboard_protocol(&mut output, report_all_keys, focus) + .unwrap(); + output + } + + #[cfg(not(windows))] + #[test] + fn host_keyboard_protocol_refresh_resends_the_setup_modes() { + // Focus-in on a kitty host: replace Herdr's stack entry, keep paste on. + let kitty = refresh_test_guard(true, true, None); + assert_eq!( + refresh_bytes(&kitty, false, false), + b"\x1b[<1u\x1b[>7u\x1b[?2004h" + ); + // Resize while a pane wants all keys, on a modifyOtherKeys host too. + let both = refresh_test_guard(true, true, Some(b"\x1b[>4;1m")); + assert_eq!( + refresh_bytes(&both, true, true), + b"\x1b[<1u\x1b[>31u\x1b[>4;1m\x1b[?2004h\x1b[?1004h" + ); + // A host that never confirmed the kitty protocol gets no kitty flags. + let legacy = refresh_test_guard(false, true, None); + assert_eq!( + refresh_bytes(&legacy, false, true), + b"\x1b[?2004h\x1b[?1004h" + ); + // Direct attach leaves keyboard modes to the attached pane. + let direct = refresh_test_guard(true, false, None); + assert!(refresh_bytes(&direct, false, true).is_empty()); + // Nothing is re-enabled once the terminal is being restored. + kitty.restore_claimed.store(true, Ordering::Release); + assert!(refresh_bytes(&kitty, false, true).is_empty()); + } + + #[cfg(not(windows))] + #[tokio::test(flavor = "multi_thread")] + async fn host_keyboard_protocol_refresh_restores_shift_enter_after_host_reset() { + use crate::input::{KeyboardProtocol, TerminalKey}; + use crossterm::event::{KeyCode, KeyModifiers}; + + let guard = refresh_test_guard(true, true, None); + let shift_enter = TerminalKey::new(KeyCode::Enter, KeyModifiers::SHIFT); + // libghostty stands in for the host terminal. `reset` models a host + // that lost its modes, like a reloaded VS Code window (#4985). + for reset in [false, true] { + let (host, _rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 24, 0, b"", 4096, + ); + // Herdr's setup push. + host.test_process_pty_bytes(b"\x1b[>7u"); + if reset { + host.test_process_pty_bytes(b"\x1bc"); + assert_eq!(host.keyboard_protocol(), KeyboardProtocol::Legacy); + assert_eq!(host.encode_terminal_key(shift_enter.clone()), b"\r"); + } + for _ in 0..3 { + host.test_process_pty_bytes(&refresh_bytes(&guard, false, true)); + } + assert_eq!( + host.keyboard_protocol(), + KeyboardProtocol::Kitty { flags: 7 }, + "reset={reset}" + ); + assert_eq!( + host.encode_terminal_key(shift_enter.clone()), + b"\x1b[13;2u", + "reset={reset}" + ); + // Refreshes never grow the stack: Herdr's one exit pop restores + // the host's own keyboard mode. + host.test_process_pty_bytes(b"\x1b[<1u"); + assert_eq!( + host.keyboard_protocol(), + KeyboardProtocol::Legacy, + "reset={reset}" + ); + } + } + + #[cfg(not(windows))] + #[tokio::test(flavor = "multi_thread")] + async fn host_keyboard_protocol_refresh_keeps_the_outer_mode_across_report_all_toggles() { + use crate::input::KeyboardProtocol; + + let guard = refresh_test_guard(true, true, None); + let (host, _rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 24, 0, b"", 4096, + ); + // Something outside Herdr (a shell or wrapper) already set its own mode. + host.test_process_pty_bytes(b"\x1b[>1u"); + // Herdr's setup push. + host.test_process_pty_bytes(b"\x1b[>7u"); + for round in 0..6 { + let report_all = round % 2 == 0; + let mut toggle = Vec::new(); + crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut toggle, report_all) + .unwrap(); + host.test_process_pty_bytes(&toggle); + for focus in [false, true, false] { + host.test_process_pty_bytes(&refresh_bytes(&guard, report_all, focus)); + } + let flags = if report_all { 31 } else { 7 }; + assert_eq!( + host.keyboard_protocol(), + KeyboardProtocol::Kitty { flags }, + "round {round}" + ); + } + // Herdr's one exit pop restores exactly the outer mode. + host.test_process_pty_bytes(b"\x1b[<1u"); + assert_eq!( + host.keyboard_protocol(), + KeyboardProtocol::Kitty { flags: 1 } + ); + } + #[cfg(not(windows))] #[test] - fn host_keyboard_probe_consumes_fragmented_responses_and_preserves_input() { - let stream = b"before\x1b[?7u-middle-\x1b[?1;2cafter"; + fn host_capability_probe_consumes_fragmented_responses_and_preserves_input() { + let stream = b"before\x1b[?1016;2$y-middle-\x1b[?7u-\x1b[?1;2cafter"; for split in 1..stream.len() { let mut buffered = Vec::new(); @@ -861,21 +1118,52 @@ mod tests { buffered.extend_from_slice(&stream[split..]); consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + assert_eq!(responses.sgr_pixel_mouse_status, Some(2), "split {split}"); assert_eq!(responses.flags, Some(7), "split {split}"); assert!(responses.primary_device_attributes, "split {split}"); - assert_eq!(buffered, b"before-middle-after", "split {split}"); + assert_eq!(buffered, b"before-middle--after", "split {split}"); } } + #[cfg(not(windows))] + #[test] + fn host_capability_probe_accepts_only_usable_sgr_pixel_mouse_states() { + for status in 0..=4 { + let mut buffered = format!("\x1b[?1016;{status}$y").into_bytes(); + let mut responses = HostKeyboardProbeResponses::default(); + + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert_eq!( + host_terminal_probe(&responses).sgr_pixel_mouse, + Some(status == 2), + "status {status}" + ); + assert!(buffered.is_empty()); + } + } + + #[cfg(not(windows))] + #[test] + fn host_probe_keeps_a_pixel_mouse_answer_without_device_attributes() { + let mut buffered = b"\x1b[?1016;0$y".to_vec(); + let mut responses = HostKeyboardProbeResponses::default(); + consume_host_keyboard_probe_responses(&mut buffered, &mut responses); + + assert!(!responses.primary_device_attributes); + assert_eq!(host_terminal_probe(&responses).sgr_pixel_mouse, Some(false)); + } + #[cfg(not(windows))] #[test] fn host_keyboard_probe_preserves_typed_input_before_responses() { - let mut buffered = b"aPtyped\x1b[?7u\x1b[?1;2c".to_vec(); + let mut buffered = b"aPtyped\x1b[?1016;2$y\x1b[?7u\x1b[?1;2c".to_vec(); let mut responses = HostKeyboardProbeResponses::default(); consume_host_keyboard_probe_responses(&mut buffered, &mut responses); assert!(host_escape_disambiguation_confirmed(&responses)); + assert_eq!(host_terminal_probe(&responses).sgr_pixel_mouse, Some(true)); assert_eq!(buffered, b"aPtyped"); } @@ -909,27 +1197,33 @@ mod tests { #[cfg(not(windows))] #[test] fn host_keyboard_probe_preserves_response_shaped_payloads() { - let opaque = b"\x1b[200~paste \x1b[?1u \x1b[?1;2c\x1b[201~-\x1bPdata \x1b[?7u\x1b\\"; - let mut buffered = [opaque.as_slice(), b"\x1b[?7u\x1b[?1;2c"].concat(); + let opaque = + b"\x1b[200~paste \x1b[?1016;2$y \x1b[?1u \x1b[?1;2c\x1b[201~-\x1bPdata \x1b[?7u\x1b\\"; + let mut buffered = [opaque.as_slice(), b"\x1b[?1016;2$y\x1b[?7u\x1b[?1;2c"].concat(); let mut responses = HostKeyboardProbeResponses::default(); consume_host_keyboard_probe_responses(&mut buffered, &mut responses); assert!(host_escape_disambiguation_confirmed(&responses)); + assert_eq!(host_terminal_probe(&responses).sgr_pixel_mouse, Some(true)); assert_eq!(buffered, opaque); } #[cfg(not(windows))] #[test] fn host_keyboard_probe_preserves_malformed_responses() { - let mut buffered = b"a\x1b[?7;1ub\x1b[?65536uc".to_vec(); + let mut buffered = b"a\x1b[?7;1ub\x1b[?65536uc\x1b[?1016;5$y\x1b[?1006;2$y".to_vec(); let mut responses = HostKeyboardProbeResponses::default(); consume_host_keyboard_probe_responses(&mut buffered, &mut responses); assert_eq!(responses.flags, None); + assert_eq!(responses.sgr_pixel_mouse_status, None); assert!(!responses.primary_device_attributes); - assert_eq!(buffered, b"a\x1b[?7;1ub\x1b[?65536uc"); + assert_eq!( + buffered, + b"a\x1b[?7;1ub\x1b[?65536uc\x1b[?1016;5$y\x1b[?1006;2$y" + ); } #[test] diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 035d00d3..323af2f8 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -961,11 +961,3 @@ fn terminal_control_scroll_command_maps_to_attach_scroll() { assert_eq!(direction, AttachScrollDirection::Up); assert_eq!(lines, 3); } - -#[test] -fn forward_clipboard_uses_local_clipboard_path() { - let _guard = env_lock().lock().unwrap(); - let _ssh = EnvVarGuard::set("SSH_CONNECTION", "1 2 3 4"); - assert!(forward_clipboard("dGVzdA==")); - assert!(!forward_clipboard("not base64")); -} diff --git a/src/config.rs b/src/config.rs index 8550097d..925423d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -200,6 +200,21 @@ pub(crate) fn keybindings_from_profile_toml(profile: &str) -> Result Option { } pub(crate) fn parse_key_combo(s: &str) -> Option { + let s = s.trim(); + // Labels are also used in endpoint profiles and command manifests. A literal + // plus is the key after the final separator, not an empty key token. + if s == "+" { + return Some((KeyCode::Char('+'), KeyModifiers::empty())); + } + if let Some(modifiers) = s.strip_suffix("++") { + return Some((KeyCode::Char('+'), parse_modifier_combo(modifiers)?)); + } let parts: Vec<&str> = s.split('+').collect(); let mut modifiers = KeyModifiers::empty(); let mut key_str: Option<&str> = None; @@ -1867,6 +1876,73 @@ close_tab = "X" ); } + #[test] + fn supported_key_labels_round_trip_through_the_combo_parser() { + let keys = [ + "a", + "A", + "ğ", + "Ğ", + "space", + "enter", + "escape", + "tab", + "backspace", + "left", + "right", + "up", + "down", + "f1", + "f12", + "minus", + "comma", + "period", + "slash", + "backslash", + "quote", + "double_quote", + "semicolon", + "colon", + "percent", + "ampersand", + "backtick", + "plus", + "(", + ")", + "{", + "}", + "?", + ]; + let modifiers = ["ctrl", "alt", "shift", "super", "hyper"]; + for mask in 0..(1 << modifiers.len()) { + for key in keys { + let mut parts: Vec<_> = modifiers + .iter() + .enumerate() + .filter_map(|(index, modifier)| (mask & (1 << index) != 0).then_some(*modifier)) + .collect(); + parts.push(key); + let input = parts.join("+"); + let combo = parse_key_combo(&input).expect("supported key combo"); + let label = format_key_combo(combo); + assert_eq!(parse_key_combo(&label), Some(combo), "{input} -> {label}"); + } + } + } + + #[test] + fn literal_plus_does_not_accept_missing_or_extra_key_tokens() { + for invalid in ["", "ctrl+", "++", "ctrl+++", "ctrl++x", "x++", "unknown++"] { + assert_eq!(parse_key_combo(invalid), None, "{invalid}"); + } + assert!(parse_binding_string("prefix+++").is_none()); + assert_eq!(parse_key_combo(" + "), parse_key_combo("plus")); + assert_eq!( + parse_key_combo("ctrl + alt ++"), + parse_key_combo("ctrl+alt+plus") + ); + } + #[test] fn shifted_punctuation_matches_enhanced_input() { let help = ActionKeybinds::prefix("?"); @@ -1990,6 +2066,43 @@ prefix = [] assert!(config.live_keybinds_with_diagnostics().is_err()); } + #[test] + fn ctrl_keybind_matches_non_latin_layout_chord_with_base_key() { + let config: Config = toml::from_str( + r#" +[keys] +navigate_pane_down = "ctrl+w" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + + // Russian layout: "\u{0446}" on the physical `w` key, Ctrl held. + let key = crate::input::parse_terminal_key_sequence("\x1b[1094::119;5u").unwrap(); + assert!(keybinds.navigate.pane_down.matches_direct_key(&key)); + let without_base = crate::input::parse_terminal_key_sequence("\x1b[1094;5u").unwrap(); + assert!(!keybinds + .navigate + .pane_down + .matches_direct_key(&without_base)); + } + + #[test] + fn latin_layout_ctrl_keybind_keeps_matching_layout_key() { + let config: Config = toml::from_str( + r#" +[keys] +navigate_pane_down = "ctrl+\u00f6" +"#, + ) + .unwrap(); + let keybinds = config.keybinds(); + + // German layout: "\u{f6}" on the physical `;` key. + let key = crate::input::parse_terminal_key_sequence("\x1b[246::59;5u").unwrap(); + assert!(keybinds.navigate.pane_down.matches_direct_key(&key)); + } + #[test] fn navigate_bindings_allow_plain_keys_and_reject_local_conflicts() { let config: Config = toml::from_str( diff --git a/src/config/model.rs b/src/config/model.rs index c3cd5d92..e9b038d5 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1075,6 +1075,9 @@ impl ImeCursorShape { #[derive(Debug, Deserialize)] #[serde(default)] pub struct ServerConfig { + /// Windows: allow ordinary same-account clients to control an elevated server. Default: false. + #[cfg(windows)] + pub allow_unelevated_clients: bool, /// Virtual terminal width used when no client is attached. Default: 120. pub headless_cols: u16, /// Virtual terminal height used when no client is attached. Default: 40. @@ -1353,6 +1356,8 @@ impl<'de> Deserialize<'de> for ToastConfig { impl Default for ServerConfig { fn default() -> Self { Self { + #[cfg(windows)] + allow_unelevated_clients: false, headless_cols: crate::config::DEFAULT_HEADLESS_COLS, headless_rows: crate::config::DEFAULT_HEADLESS_ROWS, } diff --git a/src/detect/manifest.rs b/src/detect/manifest.rs index e224adfe..03a279cc 100644 --- a/src/detect/manifest.rs +++ b/src/detect/manifest.rs @@ -540,14 +540,14 @@ fn fallback_explain( ) }) .unwrap_or((None, Vec::new(), None, None, None, false)); - let assume_idle = agent.is_some_and(|agent| agent != Agent::Codex); + let known_agent = agent.is_some(); let remote_update_status = include_update_status .then(|| agent.and_then(remote_update_status)) .flatten(); DetectionExplain { agent: agent.map(|agent| agent_label(agent).to_string()), - state: if assume_idle { + state: if known_agent { AgentState::Idle } else { AgentState::Unknown @@ -560,11 +560,7 @@ fn fallback_explain( visible_working: false, skip_state_update: false, skipped_update_reason: None, - fallback_reason: match agent { - Some(Agent::Codex) => Some("codex_state_ambiguous".to_string()), - Some(_) => Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), - None => None, - }, + fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()), evaluated_rules, warning, manifest_version, diff --git a/src/detect/manifest/tests.rs b/src/detect/manifest/tests.rs index 1e6b1a85..6691c895 100644 --- a/src/detect/manifest/tests.rs +++ b/src/detect/manifest/tests.rs @@ -89,21 +89,15 @@ fn write_local_codex(content: &str) { } #[test] -fn codex_no_match_is_unknown_without_changing_other_agents() { +fn known_agent_no_match_defaults_to_idle_fallback() { with_manifest_dirs("no-match", || { write_local_codex(&local_manifest("working", "active-marker")); let explain = explain(Agent::Codex, "unmatched-marker"); - assert_eq!(explain.state, AgentState::Unknown); + assert_eq!(explain.state, AgentState::Idle); assert!(!explain.visible_idle); assert_eq!( explain.fallback_reason.as_deref(), - Some("codex_state_ambiguous") - ); - let other = fallback_explain(Some(Agent::Pi), None, false); - assert_eq!(other.state, AgentState::Idle); - assert_eq!( - other.fallback_reason.as_deref(), Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) ); }); @@ -163,6 +157,130 @@ line_regex = ["^exact line$"] }); } +// A synthetic terminal protocol: live controls may precede a named divider +// and an optional panel, but a newer prompt/output makes them historical. +#[test] +fn end_anchored_footer_gates_allow_only_auxiliary_tail() { + with_manifest_dirs("footer-tail", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "live_controls" +state = "blocked" +region = "whole_recent" +regex = ['(?i)(?:^|\n)controls:\s+(?:confirm-marker|select-marker\s+navigate-marker)\s+cancel-marker[ \t]*\n---[^\n]*(?:\npanel: \d+(?:\nrow: [^\n]+)+(?:\n… \+\d+ pending)?)?(?:\n[ \t]*)*\z'] +"#, + )); + let footer = "controls: select-marker navigate-marker cancel-marker"; + for tail in [ + "--- name", + "--- name\npanel: 5\nrow: active\nrow: pending\nrow: pending\nrow: pending\nrow: done\n", + "--- name\npanel: 5\nrow: active\nrow: done\n… +3 pending", + ] { + let live = format!("old output\n{footer}\n{tail}"); + assert_eq!(explain(Agent::Codex, &live).state, AgentState::Blocked); + for later in [ + "new output", + "prompt:", + "--- next\nprompt:", + "prompt: continue…", + "working: processing…", + "…ordinary output", + "finished…", + "… +2 arbitrary words", + ] { + assert_eq!( + explain(Agent::Codex, &format!("{live}\n{later}")).state, + AgentState::Idle + ); + } + } + for tail in [ + "--- name\n… +2 pending", + "--- name\nrow: active", + "--- name\npanel: 5", + "--- name\npanel: 5\nrow: active\n working: processing…", + "--- name\npanel: 5\nrow: active\n prompt: continue…", + ] { + assert_eq!( + explain(Agent::Codex, &format!("{footer}\n{tail}")).state, + AgentState::Idle + ); + } + for controls in [ + "controls: confirm-marker cancel-marker", + "controls: select-marker\nnavigate-marker cancel-marker", + "controls:\nselect-marker\nnavigate-marker\ncancel-marker", + ] { + assert_eq!( + explain(Agent::Codex, &format!("{controls}\n--- name")).state, + AgentState::Blocked + ); + } + for controls in [ + "controls: select-marker cancel-marker", + "controls: navigate-marker cancel-marker", + "controls: confirm-marker", + "controls: select-marker unrelated navigate-marker cancel-marker", + "controls: select-marker\nprompt: navigate-marker cancel-marker", + ] { + assert_eq!( + explain( + Agent::Codex, + &format!("confirm-marker navigate-marker\n{controls}\n--- name") + ) + .state, + AgentState::Idle + ); + } + }); +} + +#[test] +fn paired_alternatives_require_complete_gates_at_bottom_buffer() { + with_manifest_dirs("paired-alternatives", || { + write_local_codex(&rules_manifest( + r#" +[[rules]] +id = "paired" +state = "blocked" +region = "bottom_non_empty_lines(3)" +visible_blocker = true +any = [ + { all = [ + { contains = ["alpha heading"] }, + { regex = ['alpha\s+control\s*\z'] }, + ] }, + { all = [ + { contains = ["beta heading"] }, + { regex = ['beta\s+control\s*\z'] }, + ] }, +] +"#, + )); + for text in [ + "alpha heading\nalpha control", + "beta heading\nbeta\ncontrol", + ] { + let result = explain(Agent::Codex, text); + assert_eq!(result.state, AgentState::Blocked); + assert!(result.visible_blocker); + } + for text in [ + "alpha heading\nbeta control", + "beta heading\nalpha control", + "beta control", + "beta heading", + "beta heading\nbeta control\nnewer content", + "beta heading\nfiller one\nfiller two\nbeta control", + ] { + let result = explain(Agent::Codex, text); + assert!(result.matched_rule.is_none(), "{text}"); + assert!(!result.visible_blocker, "{text}"); + } + }); +} + #[test] fn remote_manifest_loads_between_local_override_and_bundled() { with_manifest_dirs("remote-source", || { @@ -190,10 +308,10 @@ fn fallback_explain_preserves_active_manifest_version() { let explain = explain(Agent::Codex, "ordinary prompt text"); - assert_eq!(explain.state, AgentState::Unknown); + assert_eq!(explain.state, AgentState::Idle); assert_eq!( explain.fallback_reason.as_deref(), - Some("codex_state_ambiguous") + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) ); assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1")); assert!(matches!( @@ -273,10 +391,10 @@ fn detection_uses_cached_manifest_until_explicit_reload() { write_remote_codex_without_reload(&remote_manifest("9999.01.01.2", "working", "new-ready")); let unchanged = explain(Agent::Codex, "new-ready"); - assert_eq!(unchanged.state, AgentState::Unknown); + assert_eq!(unchanged.state, AgentState::Idle); assert_eq!( unchanged.fallback_reason.as_deref(), - Some("codex_state_ambiguous") + Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK) ); assert_eq!( unchanged.cached_remote_version.as_deref(), diff --git a/src/detect/manifests/antigravity.toml b/src/detect/manifests/antigravity.toml index 8b9bcfcf..5daa8dec 100644 --- a/src/detect/manifests/antigravity.toml +++ b/src/detect/manifests/antigravity.toml @@ -1,13 +1,60 @@ id = "agy" -version = "2026.06.24.1" +version = "2026.10.05.1" min_engine_version = 1 -updated_at = "2026-06-24T00:00:00Z" +updated_at = "2026-10-05T00:00:00Z" aliases = ["antigravity", "antigravity-cli"] +# Evidence: Antigravity CLI 1.2.17 live pane reads, plus reporter captures +# from 1.1.28 through 1.2.16. +# +# Dialogs replace the prompt box and end with a key-hint row directly above +# the "esc to cancel" footer: +# permission: "↑/↓ Navigate · tab Amend · ctrl+g edit/expand command" +# "↑/↓ Navigate · tab Amend · f full diff" +# question: "↑/↓ Navigate · enter Select · esc Skip" +# trust: "↑/↓ Navigate · enter Confirm" +# slash menu: "↑/↓ Navigate · enter Select · tab Complete" (idle) +# Some captures hold only the two footer rows, so dialog rules must not +# require the question text. +# +# Working turns draw a braille spinner followed by either an -ing word or a +# thought summary above the prompt box, and the footer reads "esc to cancel" +# directly under the prompt box's bottom border. The idle footer reads +# "? for shortcuts". +# +# Background tasks and subagents show "· N task(s)" in the footer and a task +# panel under the prompt box. Once the agent is back at its prompt they do +# not count as working. + [[rules]] id = "permission_prompt" state = "blocked" priority = 300 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["navigate", "tab amend"] + +[[rules]] +id = "question_prompt" +state = "blocked" +priority = 290 +region = "bottom_non_empty_lines(2)" +visible_blocker = true +contains = ["navigate", "esc skip"] + +[[rules]] +id = "trust_prompt" +state = "blocked" +priority = 280 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["do you trust the contents of this project?", "yes, i trust this folder", "enter confirm"] + +# Pre-1.1.28 permission dialog. +[[rules]] +id = "legacy_permission_prompt" +state = "blocked" +priority = 270 region = "whole_recent" visible_blocker = true contains = ["requesting permission for:"] @@ -17,17 +64,17 @@ any = [ ] [[rules]] -id = "spinner_working" +id = "esc_cancel_footer_working" state = "working" -priority = 100 -region = "whole_recent" +priority = 110 +region = "bottom_non_empty_lines(2)" visible_working = true -line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b'] +regex = ['(?m)^\s*─{3,}\s*esc to cancel\b'] [[rules]] -id = "background_tasks_working" +id = "spinner_working" state = "working" -priority = 90 -region = "bottom_non_empty_lines(5)" +priority = 100 +region = "bottom_non_empty_lines(12)" visible_working = true -line_regex = ['(?i)·\s*[1-9][0-9]*\s+task'] +line_regex = ['^\s*[\u2801-\u28FF]+\s+\S'] diff --git a/src/detect/manifests/claude.toml b/src/detect/manifests/claude.toml index 0c7bdcf2..7cb3393e 100644 --- a/src/detect/manifests/claude.toml +++ b/src/detect/manifests/claude.toml @@ -1,7 +1,7 @@ id = "claude" -version = "2026.09.11.1" +version = "2026.10.11.1" min_engine_version = 2 -updated_at = "2026-09-11T00:00:00Z" +updated_at = "2026-10-11T00:00:00Z" aliases = ["claude-code"] [[rules]] @@ -94,6 +94,18 @@ any = [ ] }, ] +[[rules]] +id = "live_blocked_form_named_session" +state = "blocked" +priority = 980 +region = "whole_recent" +visible_blocker = true + +# Bind every control to the same footer, allowing wrapping between words. +# After the name divider, accept only a counted task panel with marked rows +# and optional pending overflow. Free-form activity can be newer output (#4573). +regex = ['(?i)(?:^|\n)[ \t]*enter\s+to\s+(?:confirm|select\s+·\s+(?:(?:tab/arrow|arrow)\s+keys|arrows|↑/↓|↑↓)\s+to\s+navigate)\s+·\s+esc\s+to\s+cancel[ \t]*\n(?:[ \t]*\n)*[ \t]*─{3,}[^\n]*(?:\n(?:[ \t]*\n)*[ \t]+\d+ tasks? \(\d+ done, (?:\d+ in progress, )?\d+ open\)(?:\n[ \t]+[◻◼✔][ \t]+[^\n]+)+(?:\n[ \t]+…[ \t]+\+\d+[ \t]+pending)?)?(?:\n[ \t]*)*\z'] + [[rules]] id = "dynamic_workflow_prompt" state = "blocked" diff --git a/src/detect/manifests/codex.toml b/src/detect/manifests/codex.toml index dd275758..d35820c9 100644 --- a/src/detect/manifests/codex.toml +++ b/src/detect/manifests/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.09.23.1" +version = "2026.10.11.1" min_engine_version = 3 -updated_at = "2026-09-23T22:12:00Z" +updated_at = "2026-10-11T00:00:00Z" [[rules]] id = "osc_title_blocked" @@ -38,8 +38,20 @@ priority = 950 region = "top_non_empty_lines(20)" visible_blocker = true all = [ - { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, - { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, + { any = [ + { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] }, + { contains = ["Folder access"] }, + ] }, + { any = [ + { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, + { all = [ + { contains = ["Trust this folder?", "Codex can read, edit, and run files here"] }, + { any = [ + { contains = ["Trust and continue"] }, + { contains = ["enter continue"] }, + ] }, + ] }, + ] }, ] [[rules]] @@ -48,8 +60,19 @@ state = "blocked" priority = 950 region = "bottom_non_empty_lines(20)" visible_blocker = true -contains = ["Update available!", "Update now"] -regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z'] +# Keep the legacy dialog; the shared picker (0.159+) has a different heading +# and footer. Choices can scroll out of a short viewport, but both gates must +# remain visible. The passive update history notice has neither picker footer. +any = [ + { all = [ + { contains = ["Update available!", "Update now"] }, + { regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z'] }, + ] }, + { all = [ + { regex = ['(?m)^\s*Update\s+available\s+·'] }, + { regex = ['enter\s+continue\s+·\s+esc\s+skip\s*\z'] }, + ] }, +] [[rules]] id = "live_strong_blocker" @@ -92,3 +115,15 @@ any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }] regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] # A failed reconnect keeps its final elapsed timer but is no longer working. not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['\S'] +not = [ + { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, + { contains = ["Action Required"] }, +] diff --git a/src/detect/manifests/grok.toml b/src/detect/manifests/grok.toml index 67ba4ebc..bc37bc4b 100644 --- a/src/detect/manifests/grok.toml +++ b/src/detect/manifests/grok.toml @@ -1,7 +1,7 @@ id = "grok" -version = "2026.09.18.2" +version = "2026.10.05.1" min_engine_version = 3 -updated_at = "2026-09-18T00:00:00Z" +updated_at = "2026-10-05T00:00:00Z" aliases = ["grok-build"] # Evidence: Grok Build 0.2.101 source and 1.0.34 live pane reads. @@ -74,26 +74,8 @@ any = [ { contains = ["←/→:scope"] }, ] -# Grok clears its OSC busy signals while background work runs. The first -# non-empty row is pinned application chrome, where this animated chip shows -# the number of running background tasks and disappears when the count is zero. -[[rules]] -id = "background_work_chip_working" -state = "working" -priority = 1170 -region = "top_non_empty_lines(1)" -visible_working = true -line_regex = ['[⋅:⸬⁙.·]\s+[1-9][0-9]*\s+│'] - -# Grok 1.0.34 moves background counts above the composer and clears OSC -# progress between turns even while these commands are still running. -[[rules]] -id = "background_status_working" -state = "working" -priority = 1165 -region = "bottom_non_empty_lines(12)" -visible_working = true -line_regex = ['^\s*[○◎◉]\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?)(?:\s+·\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?))*\s+still running(?:\s+·\s+send a message to interrupt)?\s*$'] +# Background commands, monitors, and subagents left running after Grok returns +# to its prompt do not count as working, matching Claude and agy (#1217, #4735). [[rules]] id = "osc_progress_working" diff --git a/src/detect/manifests/pi.toml b/src/detect/manifests/pi.toml index 77b2d632..5ae51a85 100644 --- a/src/detect/manifests/pi.toml +++ b/src/detect/manifests/pi.toml @@ -1,7 +1,7 @@ id = "pi" -version = "2026.09.14.1" +version = "2026.10.01.1" min_engine_version = 1 -updated_at = "2026-09-14T00:00:00Z" +updated_at = "2026-10-01T00:00:00Z" aliases = ["herdr:pi"] [[rules]] @@ -10,7 +10,10 @@ state = "working" priority = 100 region = "whole_recent" visible_working = true -contains = ["Working..."] +any = [ + { contains = ["Working..."] }, + { line_regex = ['^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working$'] }, +] [[rules]] id = "working_border" @@ -18,4 +21,7 @@ state = "working" priority = 100 region = "bottom_non_empty_lines(12)" visible_working = true -line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$'] +any = [ + { line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$'] }, + { line_regex = ['^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working$'] }, +] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 37d5481a..380a41fb 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -247,6 +247,13 @@ pub fn identify_agent(process_name: &str) -> Option { } pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Agent, String)> { + identify_agent_process_in_job(job).map(|(agent, name, _)| (agent, name)) +} + +/// Like [`identify_agent_in_job`], plus the pid of the process that matched. +pub fn identify_agent_process_in_job( + job: &crate::platform::ForegroundJob, +) -> Option<(Agent, String, u32)> { if let Some(process) = job .processes .iter() @@ -255,12 +262,12 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag let candidate = normalized_process_name(process); if let Some(agent) = identify_agent(&candidate) { if agent != Agent::Letta || is_interactive_letta_process(process) { - return Some((agent, candidate)); + return Some((agent, candidate, process.pid)); } } } - let mut best: Option<(u8, Agent, String)> = None; + let mut best: Option<(u8, Agent, String, u32)> = None; for process in &job.processes { let candidate = normalized_process_name(process); @@ -273,12 +280,12 @@ pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Ag let score = process_priority(process, &candidate); match &best { - Some((best_score, _, _)) if *best_score >= score => {} - _ => best = Some((score, agent, candidate)), + Some((best_score, ..)) if *best_score >= score => {} + _ => best = Some((score, agent, candidate, process.pid)), } } - best.map(|(_, agent, name)| (agent, name)) + best.map(|(_, agent, name, pid)| (agent, name, pid)) } /// Detect the state of an agent from the live terminal tail snapshot. @@ -420,7 +427,8 @@ fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>) "node" => cursor_agent_name_from_bundled_node_argv(argv) .or_else(|| script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[])), "bun" => script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]), - name if is_python_runtime(name) => script_arg_agent_name(argv, &["-c"], &["-m"]), + name if is_python_runtime(name) => hermes_installer_agent_name(argv) + .or_else(|| script_arg_agent_name(argv, &["-c"], &["-m"])), "sh" | "bash" | "zsh" | "fish" => script_arg_agent_name(argv, &["-c"], &[]), "cmd" => windows_cmd_arg_agent_name(argv), "powershell" | "pwsh" => powershell_arg_agent_name(argv), @@ -429,6 +437,61 @@ fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>) } } +fn hermes_installer_agent_name(argv: &[String]) -> Option { + let [_, isolation, command, code, args @ ..] = argv else { + return None; + }; + if isolation != "-I" + || command != "-c" + || args + .first() + .is_some_and(|arg| matches!(arg.as_str(), "--run-module" | "--print-runtime-command")) + { + return None; + } + + // Recognize the captured installer bootstrap, not arbitrary Python source. + // Both root literals must agree; reject escapes or quotes rather than parse Python. + let rest = code.strip_prefix(HERMES_INSTALLER_PREFIX)?; + let (root, rest) = rest.split_once("')\n")?; + if root.is_empty() || root.contains(['\'', '\\', '\n', '\r']) { + return None; + } + let rest = rest + .strip_prefix(HERMES_INSTALLER_MIDDLE)? + .strip_prefix(root)?; + (rest == HERMES_INSTALLER_SUFFIX).then(|| agent_label(Agent::Hermes).to_string()) +} + +// Installer source captured in #4910. Only the installation root varies. Unknown +// bootstrap revisions deliberately fall back to ordinary process identification. +const HERMES_INSTALLER_PREFIX: &str = "import os, re, sys +os.environ.pop('PYTHONHOME', None) +os.environ.pop('PYTHONPATH', None) +sys.path.insert(0, '"; +const HERMES_INSTALLER_MIDDLE: &str = + "if sys.argv[1:2] == ['--print-runtime-command']: sys.dont_write_bytecode = True +from hermes_constants import get_default_hermes_root +os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or str(get_default_hermes_root()) +if sys.argv[1:2] == ['--print-runtime-command']: + from pathlib import Path + from hermes_cli._launchers import print_runtime_command + print_runtime_command(Path('"; +const HERMES_INSTALLER_SUFFIX: &str = r"'), sys.argv[2:]) + sys.exit(0) +import hermes_bootstrap +if sys.argv[1:2] == ['--run-module']: + import runpy + if len(sys.argv) < 3: sys.exit('hermes: --run-module needs a module') + module = sys.argv.pop(2) + del sys.argv[1] + runpy.run_module(module, run_name='__main__', alter_sys=True) + sys.exit(0) +from hermes_cli.main import main +sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) +sys.exit(main()) +"; + fn cursor_agent_name_from_bundled_node_argv(argv: &[String]) -> Option { let (runtime_parent, runtime_name) = path_parent_and_basename(argv.first()?)?; let (script_parent, script_name) = path_parent_and_basename(argv.get(1)?)?; @@ -1368,6 +1431,107 @@ mod tests { ); } + fn hermes_installer_capture() -> crate::platform::ForegroundJob { + // Exact reporter capture from #4910; private path components were redacted. + let capture: serde_json::Value = serde_json::from_str(include_str!( + "../../tests/fixtures/hermes-installer-process-info-4910.json" + )) + .unwrap(); + let info = &capture["result"]["process_info"]; + crate::platform::ForegroundJob { + process_group_id: info["foreground_process_group_id"].as_u64().unwrap() as u32, + processes: info["foreground_processes"] + .as_array() + .unwrap() + .iter() + .map(|process| crate::platform::ForegroundProcess { + pid: process["pid"].as_u64().unwrap() as u32, + name: process["name"].as_str().unwrap().to_string(), + argv0: Some(process["argv0"].as_str().unwrap().to_string()), + argv: Some(serde_json::from_value(process["argv"].clone()).unwrap()), + cmdline: Some(process["cmdline"].as_str().unwrap().to_string()), + }) + .collect(), + } + } + + #[test] + fn identify_agent_in_job_detects_hermes_installer_capture() { + let mut job = hermes_installer_capture(); + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Hermes, "hermes".to_string())) + ); + // Pane probing also uses a leader-only fast path. + job.processes + .retain(|process| process.pid == job.process_group_id); + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Hermes, "hermes".to_string())) + ); + } + + #[test] + fn hermes_installer_signature_allows_another_install_root() { + let job = hermes_installer_capture(); + let mut argv = job.processes.last().unwrap().argv.clone().unwrap(); + argv[3] = argv[3].replace( + "/Users/REDACTED/.hermes/hermes-agent", + "/opt/another install/hermes-agent", + ); + assert_eq!(hermes_installer_agent_name(&argv), Some("hermes".into())); + } + + #[test] + fn identify_agent_in_job_rejects_hermes_installer_helper_modes() { + for mode in ["--run-module", "--print-runtime-command"] { + let mut job = hermes_installer_capture(); + let argv = job.processes.last_mut().unwrap().argv.as_mut().unwrap(); + argv.truncate(4); + argv.extend([mode.into(), "tui_gateway.entry".into()]); + assert_eq!(identify_agent_in_job(&job), None, "{mode}"); + } + } + + #[test] + fn hermes_installer_signature_rejects_incidental_source_and_unsafe_roots() { + let job = hermes_installer_capture(); + let mut argv = job.processes.last().unwrap().argv.clone().unwrap(); + let original = argv[3].clone(); + for code in [ + "print('hermes_cli.main')".to_string(), + format!("'''{original}'''"), + original.lines().map(|line| format!("# {line}\n")).collect(), + original.replace("/Users/REDACTED", "/Users/has'quote"), + original.replace("/Users/REDACTED", r"/Users/has\escape"), + original.replacen("/Users/REDACTED", "/different/root", 1), + ] { + argv[3] = code; + assert_eq!(hermes_installer_agent_name(&argv), None); + } + } + + #[test] + fn identify_agent_in_job_rejects_hermes_source_outside_installer_invocation() { + for prefix in [ + vec!["python3", "-m", "module"], + vec!["python3", "--", "script.py"], + vec!["python3", "script.py"], + vec!["bash", "-I"], + ] { + let mut job = hermes_installer_capture(); + let process = job.processes.last_mut().unwrap(); + let argv = process.argv.as_mut().unwrap(); + let source = argv[3].clone(); + *argv = prefix.iter().map(|arg| (*arg).to_string()).collect(); + argv.extend(["-c".into(), source]); + process.name = prefix[0].into(); + process.argv0 = Some(prefix[0].into()); + process.cmdline = Some(argv.join(" ")); + assert_eq!(identify_agent_in_job(&job), None, "{prefix:?}"); + } + } + #[test] fn identify_agent_in_job_detects_python_version_wrapped_hermes() { let job = crate::platform::ForegroundJob { diff --git a/src/events.rs b/src/events.rs index c87a50a3..3faf8989 100644 --- a/src/events.rs +++ b/src/events.rs @@ -190,6 +190,12 @@ pub enum AppEvent { results: Vec, cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>, }, + /// Background validation of a saved membership after session restore. + RestoredWorktreeSpaceChecked { + workspace_id: String, + expected: crate::workspace::WorktreeSpaceMembership, + valid: bool, + }, /// A configured tab bar status command finished. TabBarCommandFinished { generation: u64, diff --git a/src/input/encode.rs b/src/input/encode.rs deleted file mode 100644 index 91b28fc8..00000000 --- a/src/input/encode.rs +++ /dev/null @@ -1,1309 +0,0 @@ -use std::fmt::Write as _; - -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind}; - -use super::model::KITTY_FLAG_REPORT_ALL_KEYS; -use super::{KeyboardProtocol, MouseProtocolEncoding, TerminalKey}; - -const KITTY_FLAG_REPORT_EVENT_TYPES: u16 = 0b0000_0010; -const KITTY_FLAG_REPORT_ALTERNATE_KEYS: u16 = 0b0000_0100; -const KITTY_FLAG_REPORT_ASSOCIATED_TEXT: u16 = 0b0001_0000; - -/// Encode a key event for a PTY child using the pane's negotiated keyboard protocol. -#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers -pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec { - encode_terminal_key(key.into(), protocol) -} - -pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec { - // The host layout has not committed text for this Windows dead key. Neither - // legacy nor Kitty panes should receive its physical character fallback. - // Legacy Windows panes take the native ConPTY fallback before reaching this encoder. - if key.is_windows_dead_key() { - return Vec::new(); - } - - // Super has no legacy character encoding, and Ctrl+Shift+letter would - // collapse into the same C0 byte as Ctrl+letter. Preserve both chords with - // CSI-u, matching Ghostty's legacy encoder. - if matches!(protocol, KeyboardProtocol::Legacy) - && key.kind != crossterm::event::KeyEventKind::Release - && legacy_chord_needs_csi_u(&key) - { - if let Some(bytes) = try_encode_csi_u(&key, 0) { - return bytes; - } - } - - // REPORT_ALL_KEYS must retain physical press/repeat/release semantics instead of - // reducing a native key to its layout-generated text. - let preserve_physical_key = key.has_physical_identity() && protocol.reports_all_keys(); - if !preserve_physical_key && key.kind != crossterm::event::KeyEventKind::Release { - if let Some(text) = &key.generated_text { - return text.as_bytes().to_vec(); - } - } - - // A release event only produces bytes when the pane protocol reports event - // types (Kitty REPORT_EVENT_TYPES). Otherwise the child expects a single - // legacy byte per keystroke, so re-emitting it on release would double keys - // like Enter/Backspace. The Ghostty wrapper can route release events through - // this fallback, so guard the fallback encoder too. - if key.kind == crossterm::event::KeyEventKind::Release && !protocol.reports_event_types() { - return Vec::new(); - } - - let kitty_first = protocol.reports_all_keys() - || (key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types()); - - if kitty_first { - if let KeyboardProtocol::Kitty { flags } = protocol { - if let Some(bytes) = try_encode_csi_u(&key, flags) { - return bytes; - } - } - } - - if let Some(bytes) = encode_text_input(&key) { - return bytes; - } - - if !kitty_first { - if let KeyboardProtocol::Kitty { flags } = protocol { - if let Some(bytes) = try_encode_csi_u(&key, flags) { - return bytes; - } - } - } - if key.kind == crossterm::event::KeyEventKind::Release && protocol.reports_event_types() { - return Vec::new(); - } - encode_legacy(key) -} - -fn legacy_chord_needs_csi_u(key: &TerminalKey) -> bool { - let KeyCode::Char(ch) = key.code else { - return false; - }; - key.modifiers.contains(KeyModifiers::SUPER) - || (ch.is_ascii_alphabetic() - && key - .modifiers - .contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT)) -} - -#[allow(dead_code)] // exercised in input unit tests; production uses TerminalRuntime helpers -pub fn encode_cursor_key(code: KeyCode, application_cursor: bool) -> Vec { - match (code, application_cursor) { - (KeyCode::Up, true) => b"\x1bOA".to_vec(), - (KeyCode::Down, true) => b"\x1bOB".to_vec(), - (KeyCode::Right, true) => b"\x1bOC".to_vec(), - (KeyCode::Left, true) => b"\x1bOD".to_vec(), - (KeyCode::Up, false) => b"\x1b[A".to_vec(), - (KeyCode::Down, false) => b"\x1b[B".to_vec(), - (KeyCode::Right, false) => b"\x1b[C".to_vec(), - (KeyCode::Left, false) => b"\x1b[D".to_vec(), - _ => encode_legacy(KeyEvent::new(code, KeyModifiers::empty()).into()), - } -} - -#[allow(dead_code)] // exercised in input unit tests; pane runtime uses backend helpers -pub fn encode_mouse_scroll( - kind: MouseEventKind, - column: u16, - row: u16, - modifiers: KeyModifiers, - encoding: MouseProtocolEncoding, -) -> Option> { - let button = match kind { - MouseEventKind::ScrollUp => 64u16, - MouseEventKind::ScrollDown => 65u16, - MouseEventKind::ScrollLeft => 66u16, - MouseEventKind::ScrollRight => 67u16, - _ => return None, - }; - encode_mouse_cb(button, false, column, row, modifiers, encoding) -} - -#[allow(dead_code)] // exercised in input unit tests; pane runtime uses backend helpers -pub fn encode_mouse_button( - kind: MouseEventKind, - column: u16, - row: u16, - modifiers: KeyModifiers, - encoding: MouseProtocolEncoding, -) -> Option> { - let (button, release) = match kind { - MouseEventKind::Down(MouseButton::Left) => (0u16, false), - MouseEventKind::Down(MouseButton::Middle) => (1u16, false), - MouseEventKind::Down(MouseButton::Right) => (2u16, false), - MouseEventKind::Up(MouseButton::Left) => (0u16, true), - MouseEventKind::Up(MouseButton::Middle) => (1u16, true), - MouseEventKind::Up(MouseButton::Right) => (2u16, true), - MouseEventKind::Drag(MouseButton::Left) => (32u16, false), - MouseEventKind::Drag(MouseButton::Middle) => (33u16, false), - MouseEventKind::Drag(MouseButton::Right) => (34u16, false), - _ => return None, - }; - encode_mouse_cb(button, release, column, row, modifiers, encoding) -} - -#[allow(dead_code)] // only reached through mouse encoding helpers above -fn encode_mouse_cb( - base_button: u16, - release: bool, - column: u16, - row: u16, - modifiers: KeyModifiers, - encoding: MouseProtocolEncoding, -) -> Option> { - let mut cb = match (encoding, release) { - (MouseProtocolEncoding::Sgr | MouseProtocolEncoding::SgrPixels, true) => base_button, - (_, true) => 3, - (_, false) => base_button, - }; - if modifiers.contains(KeyModifiers::SHIFT) { - cb += 4; - } - if modifiers.contains(KeyModifiers::ALT) { - cb += 8; - } - if modifiers.contains(KeyModifiers::CONTROL) { - cb += 16; - } - - let column = column as u32 + 1; - let row = row as u32 + 1; - - match encoding { - MouseProtocolEncoding::Sgr | MouseProtocolEncoding::SgrPixels => Some( - format!( - "\x1b[<{cb};{column};{row}{}", - if release { 'm' } else { 'M' } - ) - .into_bytes(), - ), - MouseProtocolEncoding::Default => { - let cb = u8::try_from(cb + 32).ok()?; - let column = u8::try_from(column + 32).ok()?; - let row = u8::try_from(row + 32).ok()?; - Some(vec![0x1b, b'[', b'M', cb, column, row]) - } - MouseProtocolEncoding::Utf8 => { - let mut bytes = Vec::with_capacity(16); - bytes.extend_from_slice(b"\x1b[M"); - push_mouse_codepoint(&mut bytes, cb as u32 + 32)?; - push_mouse_codepoint(&mut bytes, column + 32)?; - push_mouse_codepoint(&mut bytes, row + 32)?; - Some(bytes) - } - } -} - -#[allow(dead_code)] // only reached through mouse encoding helpers above -fn push_mouse_codepoint(bytes: &mut Vec, value: u32) -> Option<()> { - let ch = char::from_u32(value)?; - let mut buf = [0u8; 4]; - bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); - Some(()) -} - -/// CSI u encoding: \e[{codepoint};{modifiers}u -/// Used when the child has pushed Kitty keyboard enhancement. -/// Returns None if the key doesn't need CSI u (unmodified basic keys). -fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option> { - let mods = key.modifiers; - let event_suffix = kitty_event_suffix(key, flags); - let report_all_keys = flags & KITTY_FLAG_REPORT_ALL_KEYS != 0; - - if !report_all_keys - && key.modifiers.is_empty() - && matches!(key.code, KeyCode::Enter | KeyCode::Tab | KeyCode::Backspace) - { - return None; - } - - // Unmodified keys use legacy encoding (more compatible) - if mods.is_empty() && event_suffix.is_none() && !report_all_keys { - return None; - } - - // Special keys (arrows, F-keys, etc.) have well-established legacy - // xterm modified formats (\x1b[1;3A for Alt+Up, etc.) that are universally - // understood. Even Ghostty sends these in legacy format with kitty mode on. - // Only use CSI u for character keys and keys without legacy representations. - match key.code { - KeyCode::Up - | KeyCode::Down - | KeyCode::Left - | KeyCode::Right - | KeyCode::Home - | KeyCode::End - | KeyCode::PageUp - | KeyCode::PageDown - | KeyCode::Insert - | KeyCode::Delete - | KeyCode::F(_) - if event_suffix.is_none() && !report_all_keys => - { - return None; // let legacy handle these - } - _ => {} - } - - let (codepoint, alternate_shifted) = match key.code { - KeyCode::Char(c) => { - let base = canonical_kitty_char(c, mods); - let shifted = alternate_shifted_codepoint(key, flags); - (base as u32, shifted) - } - KeyCode::Enter => (13, None), - KeyCode::Tab => (9, None), - KeyCode::Backspace => (127, None), - KeyCode::Esc => (27, None), - KeyCode::Left => (57417, None), - KeyCode::Right => (57418, None), - KeyCode::Up => (57419, None), - KeyCode::Down => (57420, None), - KeyCode::PageUp => (57421, None), - KeyCode::PageDown => (57422, None), - KeyCode::Home => (57423, None), - KeyCode::End => (57424, None), - KeyCode::Insert => (57425, None), - KeyCode::Delete => (57426, None), - _ => return None, // fall back to legacy for unhandled keys - }; - - let modifier = kitty_modifier(mods); - - let mut sequence = String::with_capacity(32); - sequence.push_str("\x1b["); - write!(&mut sequence, "{codepoint}").ok()?; - if let Some(shifted) = alternate_shifted { - write!(&mut sequence, ":{shifted}").ok()?; - } - write!(&mut sequence, ";{modifier}").ok()?; - if let Some(event) = event_suffix { - write!(&mut sequence, ":{event}").ok()?; - } - if flags & KITTY_FLAG_REPORT_ASSOCIATED_TEXT != 0 { - if let Some(text) = text_codepoint_for_key(key) { - write!(&mut sequence, ";{text}").ok()?; - } - } - sequence.push('u'); - - Some(sequence.into_bytes()) -} - -fn text_codepoint_for_key(key: &TerminalKey) -> Option { - let ch = text_char_for_key(key)?; - (!ch.is_control()).then_some(ch as u32) -} - -/// Legacy terminal encoding (standard escape sequences). -fn encode_legacy(key: TerminalKey) -> Vec { - let mods = key.modifiers; - - // Modified special keys (arrows, home, end, etc.) use xterm format: - // \x1b[1;{modifier}A for arrows/home/end - // \x1b[{n};{modifier}~ for insert/delete/pgup/pgdn - // The ESC-prefix hack doesn't work for these since they're already escape sequences. - if !mods.is_empty() { - if let Some(bytes) = encode_modified_special(key.code, mods) { - return bytes; - } - } - - // Alt modifier on character keys: prefix with ESC - if mods.contains(KeyModifiers::ALT) { - let inner = key.with_modifiers(mods.difference(KeyModifiers::ALT)); - let mut bytes = vec![0x1b]; - bytes.extend(encode_legacy_inner(inner)); - return bytes; - } - encode_legacy_inner(key) -} - -/// xterm-style encoding for modified special keys. -/// Modifier value: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0) -fn encode_modified_special(code: KeyCode, mods: KeyModifiers) -> Option> { - let modifier = xterm_modifier(mods); - if modifier <= 1 { - return None; // no modifiers to encode - } - - match code { - // CSI 1;{mod}{letter} format - KeyCode::Up => Some(format!("\x1b[1;{modifier}A").into_bytes()), - KeyCode::Down => Some(format!("\x1b[1;{modifier}B").into_bytes()), - KeyCode::Right => Some(format!("\x1b[1;{modifier}C").into_bytes()), - KeyCode::Left => Some(format!("\x1b[1;{modifier}D").into_bytes()), - KeyCode::Home => Some(format!("\x1b[1;{modifier}H").into_bytes()), - KeyCode::End => Some(format!("\x1b[1;{modifier}F").into_bytes()), - // CSI {n};{mod}~ format - KeyCode::Insert => Some(format!("\x1b[2;{modifier}~").into_bytes()), - KeyCode::Delete => Some(format!("\x1b[3;{modifier}~").into_bytes()), - KeyCode::PageUp => Some(format!("\x1b[5;{modifier}~").into_bytes()), - KeyCode::PageDown => Some(format!("\x1b[6;{modifier}~").into_bytes()), - // F1-F4: CSI 1;{mod}{P-S} - KeyCode::F(1) => Some(format!("\x1b[1;{modifier}P").into_bytes()), - KeyCode::F(2) => Some(format!("\x1b[1;{modifier}Q").into_bytes()), - KeyCode::F(3) => Some(format!("\x1b[1;{modifier}R").into_bytes()), - KeyCode::F(4) => Some(format!("\x1b[1;{modifier}S").into_bytes()), - // F5-F12: CSI {n};{mod}~ - KeyCode::F(n @ 5..=12) => { - let code = match n { - 5 => 15, - 6 => 17, - 7 => 18, - 8 => 19, - 9 => 20, - 10 => 21, - 11 => 23, - 12 => 24, - _ => unreachable!(), - }; - Some(format!("\x1b[{code};{modifier}~").into_bytes()) - } - _ => None, - } -} - -/// xterm modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) -/// Used for legacy modified special keys (arrows, function keys, etc.) -fn xterm_modifier(mods: KeyModifiers) -> u32 { - let mut m = 1u32; - if mods.contains(KeyModifiers::SHIFT) { - m += 1; - } - if mods.contains(KeyModifiers::ALT) { - m += 2; - } - if mods.contains(KeyModifiers::CONTROL) { - m += 4; - } - m -} - -/// Kitty protocol modifier encoding: 1 + shift(1) + alt(2) + ctrl(4) + super(8) + hyper(16) + meta(32) -/// Superset of xterm — adds Super/Hyper/Meta bits. -fn kitty_modifier(mods: KeyModifiers) -> u32 { - let mut m = xterm_modifier(mods); - if mods.contains(KeyModifiers::SUPER) { - m += 8; - } - if mods.contains(KeyModifiers::HYPER) { - m += 16; - } - if mods.contains(KeyModifiers::META) { - m += 32; - } - m -} - -fn encode_text_input(key: &TerminalKey) -> Option> { - let ch = text_char_for_key(key)?; - let mut buf = [0u8; 4]; - Some(ch.encode_utf8(&mut buf).as_bytes().to_vec()) -} - -fn text_char_for_key(key: &TerminalKey) -> Option { - if key.kind == crossterm::event::KeyEventKind::Release { - return None; - } - - let KeyCode::Char(ch) = key.code else { - return None; - }; - - if key.modifiers.is_empty() { - return Some(ch); - } - if key.modifiers == KeyModifiers::SHIFT { - return shifted_text_char(key, ch); - } - None -} - -fn shifted_text_char(key: &TerminalKey, ch: char) -> Option { - if let Some(shifted) = key.shifted_codepoint.and_then(char::from_u32) { - return Some(shifted); - } - - if ch.is_ascii_uppercase() { - return Some(ch); - } - - if ch.is_ascii_lowercase() { - return Some(ch.to_ascii_uppercase()); - } - - if is_shifted_ascii_punctuation(ch) { - return Some(ch); - } - - None -} - -fn is_shifted_ascii_punctuation(ch: char) -> bool { - matches!( - ch, - '!' | '@' - | '#' - | '$' - | '%' - | '^' - | '&' - | '*' - | '(' - | ')' - | '_' - | '+' - | '{' - | '}' - | '|' - | ':' - | '"' - | '<' - | '>' - | '?' - | '~' - ) -} - -fn canonical_kitty_char(ch: char, mods: KeyModifiers) -> char { - if mods.contains(KeyModifiers::SHIFT) && ch.is_ascii_uppercase() { - ch.to_ascii_lowercase() - } else { - ch - } -} - -fn alternate_shifted_codepoint(key: &TerminalKey, flags: u16) -> Option { - if flags & KITTY_FLAG_REPORT_ALTERNATE_KEYS == 0 { - return None; - } - - if let Some(shifted) = key.shifted_codepoint { - return Some(shifted); - } - - match key.code { - KeyCode::Char(ch) - if key.modifiers.contains(KeyModifiers::SHIFT) && ch.is_ascii_uppercase() => - { - Some(ch as u32) - } - _ => None, - } -} - -fn kitty_event_suffix(key: &TerminalKey, flags: u16) -> Option { - if flags & KITTY_FLAG_REPORT_EVENT_TYPES == 0 { - return None; - } - - Some(match key.kind { - crossterm::event::KeyEventKind::Press => 1, - crossterm::event::KeyEventKind::Repeat => 2, - crossterm::event::KeyEventKind::Release => 3, - }) -} - -fn encode_legacy_inner(key: TerminalKey) -> Vec { - match key.code { - KeyCode::Char(ch) => { - if key.modifiers.contains(KeyModifiers::CONTROL) { - let upper = ch.to_ascii_uppercase(); - match upper { - 'A'..='Z' => vec![upper as u8 - 64], - ' ' | '@' | '2' => vec![0], - '[' | '3' => vec![27], - '\\' | '4' => vec![28], - ']' | '5' => vec![29], - '^' | '6' => vec![30], - '_' | '/' | '7' | '-' => vec![31], - _ => ch.to_string().into_bytes(), - } - } else { - let ch = if key.modifiers == KeyModifiers::SHIFT { - shifted_text_char(&key, ch).unwrap_or(ch) - } else { - ch - }; - let mut buf = [0u8; 4]; - ch.encode_utf8(&mut buf).as_bytes().to_vec() - } - } - KeyCode::Enter => vec![b'\r'], - KeyCode::Backspace => vec![127], - KeyCode::Tab => vec![9], - KeyCode::BackTab => vec![27, 91, 90], - KeyCode::Esc => vec![27], - KeyCode::Left => vec![27, 91, 68], - KeyCode::Right => vec![27, 91, 67], - KeyCode::Up => vec![27, 91, 65], - KeyCode::Down => vec![27, 91, 66], - KeyCode::Home => vec![27, 91, 72], - KeyCode::End => vec![27, 91, 70], - KeyCode::PageUp => vec![27, 91, 53, 126], - KeyCode::PageDown => vec![27, 91, 54, 126], - KeyCode::Delete => vec![27, 91, 51, 126], - KeyCode::Insert => vec![27, 91, 50, 126], - KeyCode::F(n) => encode_f_key(n), - _ => vec![], - } -} - -fn encode_f_key(n: u8) -> Vec { - match n { - 1 => vec![27, 79, 80], - 2 => vec![27, 79, 81], - 3 => vec![27, 79, 82], - 4 => vec![27, 79, 83], - 5 => vec![27, 91, 49, 53, 126], - 6 => vec![27, 91, 49, 55, 126], - 7 => vec![27, 91, 49, 56, 126], - 8 => vec![27, 91, 49, 57, 126], - 9 => vec![27, 91, 50, 48, 126], - 10 => vec![27, 91, 50, 49, 126], - 11 => vec![27, 91, 50, 51, 126], - 12 => vec![27, 91, 50, 52, 126], - _ => vec![], - } -} - -#[cfg(test)] -mod tests { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - use super::*; - use crate::input::parse_terminal_key_sequence; - - fn assert_terminal_key_eq( - actual: TerminalKey, - code: KeyCode, - modifiers: KeyModifiers, - kind: crossterm::event::KeyEventKind, - shifted_codepoint: Option, - ) { - assert_eq!(actual.code, code); - assert_eq!(actual.modifiers, modifiers); - assert_eq!(actual.kind, kind); - assert_eq!(actual.shifted_codepoint, shifted_codepoint); - } - - #[test] - fn kitty_all_keys_does_not_encode_windows_altgr_dead_key_phases() { - use crossterm::event::KeyEventKind; - - let key = TerminalKey::new(KeyCode::Char('4'), KeyModifiers::empty()).with_windows_record( - crate::input::WindowsKeyRecord { - key_down: true, - repeat_count: 1, - virtual_key_code: 52, - virtual_scan_code: 5, - unicode: 0, - control_key_state: 9, - }, - ); - for kind in [ - KeyEventKind::Press, - KeyEventKind::Repeat, - KeyEventKind::Release, - ] { - assert!( - encode_terminal_key( - key.clone().with_kind(kind), - KeyboardProtocol::Kitty { flags: 31 }, - ) - .is_empty(), - "{kind:?}" - ); - } - } - - #[test] - fn legacy_enter() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); - } - - #[test] - fn legacy_ctrl_c() { - let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]); - } - - #[test] - fn legacy_ctrl_shift_letter_preserves_shift_with_csi_u() { - for ch in ['c', 'C'] { - let key = KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Legacy), - b"\x1b[99;6u", - "ch={ch}" - ); - } - } - - #[test] - fn legacy_ctrl_alt_shift_letter_uses_csi_u() { - let key = KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT, - ); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[99;8u"); - } - - #[test] - fn legacy_ctrl_shift_letter_from_kitty_host_input_keeps_shift() { - let key = parse_terminal_key_sequence("\x1b[99:67;6:1u").expect("Ctrl+Shift+C"); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Legacy), - b"\x1b[99;6u" - ); - } - - #[test] - fn legacy_ctrl_shift_punctuation_keeps_c0_byte() { - let key = KeyEvent::new( - KeyCode::Char('_'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![31]); - } - - #[test] - fn legacy_ctrl_slash_aliases_ctrl_underscore() { - let key = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![31]); - } - - #[test] - fn legacy_ctrl_non_ascii_char_uses_utf8() { - let key = KeyEvent::new(KeyCode::Char('ß'), KeyModifiers::CONTROL); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), "ß".as_bytes()); - } - - #[test] - fn legacy_shift_enter_is_just_cr() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']); - } - - #[test] - fn legacy_alt_up() { - let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;3A"); - } - - #[test] - fn legacy_shift_right() { - let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;2C"); - } - - #[test] - fn legacy_ctrl_left() { - let key = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;5D"); - } - - #[test] - fn legacy_ctrl_shift_end() { - let key = KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;6F"); - } - - #[test] - fn legacy_alt_delete() { - let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[3;3~"); - } - - #[test] - fn legacy_shift_f5() { - let key = KeyEvent::new(KeyCode::F(5), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[15;2~"); - } - - #[test] - fn legacy_alt_char_still_esc_prefix() { - let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba"); - } - - #[test] - fn legacy_alt_shift_punctuation_uses_shifted_text() { - let key = parse_terminal_key_sequence("\x1b[44:60;4u").unwrap(); - assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\x1b<"); - } - - #[test] - fn legacy_alt_backspace_sends_escape_delete() { - let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b\x7f"); - } - - #[test] - fn application_cursor_keys_use_ss3_sequences() { - assert_eq!(encode_cursor_key(KeyCode::Up, true), b"\x1bOA"); - assert_eq!(encode_cursor_key(KeyCode::Down, true), b"\x1bOB"); - } - - #[test] - fn normal_cursor_keys_use_csi_sequences() { - assert_eq!(encode_cursor_key(KeyCode::Up, false), b"\x1b[A"); - assert_eq!(encode_cursor_key(KeyCode::Down, false), b"\x1b[B"); - } - - #[test] - fn sgr_mouse_scroll_encodes_wheel_button_and_coordinates() { - let encoded = encode_mouse_scroll( - crossterm::event::MouseEventKind::ScrollDown, - 4, - 6, - KeyModifiers::SHIFT, - MouseProtocolEncoding::Sgr, - ) - .expect("mouse scroll should encode"); - - assert_eq!(encoded, b"\x1b[<69;5;7M"); - } - - #[test] - fn sgr_mouse_release_keeps_button_code() { - let encoded = encode_mouse_button( - crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left), - 11, - 9, - KeyModifiers::empty(), - MouseProtocolEncoding::Sgr, - ) - .expect("mouse release should encode"); - - assert_eq!(encoded, b"\x1b[<0;12;10m"); - } - - #[test] - fn kitty_shift_enter() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[13;2u" - ); - } - - #[test] - fn kitty_ctrl_shift_a() { - let key = KeyEvent::new( - KeyCode::Char('a'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[97;6u" - ); - } - - #[test] - fn kitty_shift_uppercase_letter_sends_text() { - let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L"); - } - - #[test] - fn kitty_shift_uppercase_letter_ignores_alternate_key_reporting_for_text() { - let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"L"); - } - - #[test] - fn kitty_shift_lowercase_letter_sends_uppercase_text() { - let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L"); - } - - #[test] - fn kitty_alt_shift_uppercase_letter_uses_base_codepoint() { - let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::ALT | KeyModifiers::SHIFT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[108;4u" - ); - } - - #[test] - fn kitty_ctrl_shift_uppercase_letter_uses_base_codepoint() { - let key = KeyEvent::new( - KeyCode::Char('L'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[108;6u" - ); - } - - #[test] - fn legacy_shift_uppercase_letter_stays_uppercase() { - let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT); - assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"L"); - } - - #[test] - fn kitty_alt_enter() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[13;3u" - ); - } - - #[test] - fn kitty_alt_backspace_uses_csi_u() { - let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[127;3u" - ); - } - - #[test] - fn kitty_plain_ctrl_c_uses_csi_u() { - let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[99;5u" - ); - } - - #[test] - fn kitty_plain_ctrl_c_includes_press_event_when_requested() { - let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[99;5:1u" - ); - } - - #[test] - fn kitty_unmodified_uses_legacy() { - let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()); - assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"a"); - } - - #[test] - fn kitty_report_event_types_keeps_basic_compatibility_keys_legacy() { - let cases = [ - (KeyCode::Enter, b"\r".as_slice()), - (KeyCode::Tab, b"\t".as_slice()), - (KeyCode::Backspace, b"\x7f".as_slice()), - ]; - - for (code, expected) in cases { - let press = KeyEvent::new_with_kind( - code, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Press, - ); - assert_eq!( - encode_key(press, KeyboardProtocol::Kitty { flags: 3 }), - expected, - "{code:?} press should stay legacy-compatible without REPORT_ALL_KEYS" - ); - - let repeat = KeyEvent::new_with_kind( - code, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Repeat, - ); - assert_eq!( - encode_key(repeat, KeyboardProtocol::Kitty { flags: 3 }), - expected, - "{code:?} repeat should stay legacy-compatible without REPORT_ALL_KEYS" - ); - - let release = KeyEvent::new_with_kind( - code, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(release, KeyboardProtocol::Kitty { flags: 3 }), - b"", - "{code:?} release should not fall back to legacy bytes" - ); - } - } - - #[test] - fn kitty_report_all_keys_encodes_basic_compatibility_keys_with_events() { - let enter_press = KeyEvent::new_with_kind( - KeyCode::Enter, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Press, - ); - assert_eq!( - encode_key(enter_press, KeyboardProtocol::Kitty { flags: 9 }), - b"\x1b[13;1u" - ); - - let backspace_press = KeyEvent::new_with_kind( - KeyCode::Backspace, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Press, - ); - assert_eq!( - encode_key(backspace_press, KeyboardProtocol::Kitty { flags: 11 }), - b"\x1b[127;1:1u" - ); - - let backspace_release = KeyEvent::new_with_kind( - KeyCode::Backspace, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(backspace_release, KeyboardProtocol::Kitty { flags: 11 }), - b"\x1b[127;1:3u" - ); - } - - #[test] - fn kitty_report_all_keys_encodes_printable_event_kinds() { - for (kind, expected) in [ - ( - crossterm::event::KeyEventKind::Press, - b"\x1b[106;1:1u".as_slice(), - ), - ( - crossterm::event::KeyEventKind::Repeat, - b"\x1b[106;1:2u".as_slice(), - ), - ( - crossterm::event::KeyEventKind::Release, - b"\x1b[106;1:3u".as_slice(), - ), - ] { - let key = KeyEvent::new_with_kind(KeyCode::Char('j'), KeyModifiers::empty(), kind); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 15 }), - expected - ); - } - } - - #[test] - fn kitty_report_associated_text_embeds_shifted_printables() { - let cases = [ - ( - TerminalKey::new(KeyCode::Char('A'), KeyModifiers::SHIFT), - b"\x1b[97;2;65u".as_slice(), - ), - ( - TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) - .with_shifted_codepoint('!' as u32), - b"\x1b[49;2;33u".as_slice(), - ), - ( - TerminalKey::new(KeyCode::Char(':'), KeyModifiers::SHIFT), - b"\x1b[58;2;58u".as_slice(), - ), - ]; - - for (key, expected) in cases { - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 25 }), - expected - ); - } - } - - #[test] - fn kitty_associated_text_composes_with_alternates_and_events() { - for (kind, expected) in [ - ( - crossterm::event::KeyEventKind::Press, - b"\x1b[97:65;2:1;65u".as_slice(), - ), - ( - crossterm::event::KeyEventKind::Repeat, - b"\x1b[97:65;2:2;65u".as_slice(), - ), - ( - crossterm::event::KeyEventKind::Release, - b"\x1b[97:65;2:3u".as_slice(), - ), - ] { - let key = TerminalKey::new(KeyCode::Char('A'), KeyModifiers::SHIFT).with_kind(kind); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 31 }), - expected - ); - } - } - - #[test] - fn kitty_printable_release_is_encoded_without_report_all() { - let release = KeyEvent::new_with_kind( - KeyCode::Char('j'), - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(release, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[106;1:3u" - ); - - let mut malformed_release = TerminalKey::from(release); - malformed_release.generated_text = Some("j".to_owned()); - assert_eq!( - encode_terminal_key(malformed_release, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[106;1:3u" - ); - } - - #[test] - fn kitty_shift_tab() { - let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[9;2u" - ); - } - - #[test] - fn kitty_ctrl_shift_enter() { - let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), - b"\x1b[13;6u" - ); - } - - #[test] - fn kitty_repeat_event_type_is_encoded_when_requested() { - let key = KeyEvent::new_with_kind( - KeyCode::Enter, - KeyModifiers::SHIFT, - crossterm::event::KeyEventKind::Repeat, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[13;2:2u" - ); - } - - #[test] - fn kitty_shift_letter_release_uses_csi_u() { - let key = KeyEvent::new_with_kind( - KeyCode::Char('L'), - KeyModifiers::SHIFT, - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"\x1b[108:76;2:3u" - ); - } - - #[test] - fn kitty_shifted_punctuation_literals_send_text() { - for ch in "!@#$%^&*()_+{}|:\"<>?~".chars() { - let key = TerminalKey::new(KeyCode::Char(ch), KeyModifiers::SHIFT); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, ch.to_string().into_bytes(), "ch={ch}"); - } - } - - #[test] - fn kitty_shifted_punctuation_release_does_not_emit_text() { - let key = TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT) - .with_kind(crossterm::event::KeyEventKind::Release); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"\x1b[63;2:3u" - ); - } - - #[test] - fn kitty_shifted_punctuation_does_not_infer_layout() { - let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"\x1b[49;2:1u" - ); - } - - #[test] - fn kitty_modified_shifted_punctuation_stays_modified_key() { - for (modifiers, expected) in [ - ( - KeyModifiers::CONTROL | KeyModifiers::SHIFT, - b"\x1b[33;6:1u".as_slice(), - ), - ( - KeyModifiers::ALT | KeyModifiers::SHIFT, - b"\x1b[33;4:1u".as_slice(), - ), - ( - KeyModifiers::SUPER | KeyModifiers::SHIFT, - b"\x1b[33;10:1u".as_slice(), - ), - ] { - let key = TerminalKey::new(KeyCode::Char('!'), modifiers); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, expected, "modifiers={modifiers:?}"); - } - } - - #[test] - fn release_bytes_gated_on_report_event_types() { - for code in [KeyCode::Enter, KeyCode::Backspace] { - let release = KeyEvent::new_with_kind( - code, - KeyModifiers::empty(), - crossterm::event::KeyEventKind::Release, - ); - - // Legacy and Kitty disambiguate-only (no REPORT_EVENT_TYPES) must not - // emit a byte on release, otherwise Enter/Backspace double (issue #769). - assert_eq!(encode_key(release, KeyboardProtocol::Legacy), b""); - assert_eq!( - encode_key(release, KeyboardProtocol::Kitty { flags: 1 }), - b"" - ); - } - - let modified_release = KeyEvent::new_with_kind( - KeyCode::Enter, - KeyModifiers::CONTROL, - crossterm::event::KeyEventKind::Release, - ); - assert_eq!( - encode_key(modified_release, KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[13;5:3u" - ); - } - - #[test] - fn kitty_shifted_symbol_sends_text() { - let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) - .with_shifted_codepoint('!' as u32); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), - b"!" - ); - } - - #[test] - fn legacy_modified_special_roundtrip_matrix() { - let cases = [ - KeyEvent::new(KeyCode::Up, KeyModifiers::ALT), - KeyEvent::new(KeyCode::Down, KeyModifiers::ALT), - KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), - KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT), - KeyEvent::new(KeyCode::PageUp, KeyModifiers::ALT), - KeyEvent::new(KeyCode::PageDown, KeyModifiers::CONTROL), - KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT), - KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT), - ]; - - for key in cases { - let encoded = encode_key(key, KeyboardProtocol::Legacy); - let parsed = - parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); - assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None); - } - } - - #[test] - fn kitty_shifted_symbol_prefers_text_over_roundtrip_key_identity() { - let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT) - .with_shifted_codepoint('!' as u32); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, b"!"); - } - - #[test] - fn legacy_basic_special_roundtrip_matrix() { - let cases = [ - KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Up, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Left, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Right, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Home, KeyModifiers::empty()), - KeyEvent::new(KeyCode::End, KeyModifiers::empty()), - KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()), - KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Insert, KeyModifiers::empty()), - KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()), - ]; - - for key in cases { - let encoded = encode_key(key, KeyboardProtocol::Legacy); - let parsed = - parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap(); - assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None); - } - } - - #[test] - fn legacy_super_character_preserves_csi_u_chord() { - let sequence = "\x1b[99;9u"; - let key = parse_terminal_key_sequence(sequence).expect("Super+C CSI-u key"); - - assert_eq!(key.code, KeyCode::Char('c')); - assert_eq!(key.modifiers, KeyModifiers::SUPER); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Legacy), - sequence.as_bytes() - ); - } - - #[test] - fn kitty_shifted_symbol_pair_matrix_is_encoded_as_text() { - let cases = [('1', '!'), ('/', '?'), ('[', '{')]; - - for (base, shifted) in cases { - let key = TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT) - .with_shifted_codepoint(shifted as u32); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, shifted.to_string().into_bytes(), "base={base}"); - } - } - - #[test] - fn chinese_char_encodes_as_utf8() { - let key = TerminalKey::new(KeyCode::Char('中'), KeyModifiers::empty()); - let encoded = encode_terminal_key(key, KeyboardProtocol::Legacy); - assert_eq!(encoded, "中".as_bytes()); - } - - #[test] - fn chinese_char_with_kitty_protocol_encodes_as_utf8() { - let key = TerminalKey::new(KeyCode::Char('文'), KeyModifiers::empty()); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert_eq!(encoded, "文".as_bytes()); - } - - #[test] - fn chinese_char_with_modifiers_falls_back_to_kitty_encoding() { - let key = TerminalKey::new(KeyCode::Char('测'), KeyModifiers::ALT); - let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }); - assert!(!encoded.is_empty()); - assert_ne!(encoded, "测".as_bytes()); - } -} diff --git a/src/input/keybind_help.rs b/src/input/keybind_help.rs index 735499cf..86fbef74 100644 --- a/src/input/keybind_help.rs +++ b/src/input/keybind_help.rs @@ -10,10 +10,21 @@ use crate::{ pub(crate) type KeybindHelpEntry = (String, Cow<'static, str>); pub(crate) type KeybindHelpGroup = (&'static str, Vec); +/// The character a key types, for overlay shortcuts and text fields. pub(crate) fn keybind_help_text_char(key: &TerminalKey) -> Option { if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { return None; } + // Text the host says the key produced is authoritative: kitty hosts may + // report Shift+/ as `/` with text `?` and no shifted alternate. + if let Some(text) = key.generated_text.as_deref() { + let mut chars = text.chars(); + if let (Some(character), None) = (chars.next(), chars.next()) { + if !character.is_control() { + return Some(character); + } + } + } if let Some(character) = key.shifted_codepoint.and_then(char::from_u32) { return Some(character); } @@ -271,6 +282,28 @@ mod tests { ] } + #[test] + fn text_char_prefers_generated_text_then_alternate_then_code() { + let parse = |bytes: &str| { + crate::input::parse_terminal_key_sequence(bytes) + .unwrap_or_else(|| panic!("{bytes:?} parses")) + }; + for (bytes, want) in [ + // Shift+/ typing `?` with no shifted alternate. + ("\x1b[47;2;63u", Some('?')), + ("\x1b[47u", Some('/')), + ("/", Some('/')), + // Portuguese Shift+7: alternate and text agree. + ("\x1b[55:47;2;47u", Some('/')), + ("\x1b[55:47;2u", Some('/')), + ("\x1b[55;2u", Some('7')), + ("\x1b[47;5u", None), + ("\x1b[47;3u", None), + ] { + assert_eq!(keybind_help_text_char(&parse(bytes)), want, "{bytes:?}"); + } + } + #[test] fn filter_matches_labels_and_shortcuts_case_insensitively() { let filtered = filter_keybind_help_groups(groups(), "WoRk"); diff --git a/src/input/lease.rs b/src/input/lease.rs index 0352df42..afcee487 100644 --- a/src/input/lease.rs +++ b/src/input/lease.rs @@ -18,6 +18,12 @@ impl InputLeaseKey { } } +/// A press that arrived as text (typed, or committed by an IME) rather than as +/// a native key record. +fn is_text_press(key: &TerminalKey) -> bool { + key.generated_text.is_some() && key.physical_key_id().is_none() +} + #[derive(Debug, PartialEq, Eq)] pub(crate) struct ForwardedInputLease { pub(crate) target: Target, @@ -69,8 +75,11 @@ where lease_key: &InputLeaseKey, key: TerminalKey, ) -> TerminalKey { + // A VT text press is always a new press: hosts send text repeats as + // text, and a stale lease (release reported after Shift was let go) + // must not turn the next press into a repeat routed to an old target. if key.kind != crossterm::event::KeyEventKind::Press - || (key.generated_text.is_some() && !key.has_physical_identity()) + || (key.generated_text.is_some() && key.physical_key_id().is_none()) { return key; } @@ -172,14 +181,28 @@ where allowed } - pub(crate) fn remove_forwarded( + /// The forwarded text press from `source` when it is the only key held. + /// A text press ("?") names a character, not a key, so its release ("/" + /// after Shift was let go) cannot be matched by identity. When it is the + /// only tracked key the match is certain; with any other press held, + /// forwarded or consumed by Herdr, nothing is taken. + pub(crate) fn remove_sole_text_press( &mut self, - key: &InputLeaseKey, - ) -> Option> { - match self.leases.remove(key) { - Some(InputLease::Forwarded(lease)) => Some(lease), - Some(InputLease::Consumed(_)) | None => None, - } + source: Source, + ) -> Option> { + let mut held = self + .leases + .iter() + .filter(|(lease_key, _)| lease_key.source == source); + let (Some((lease_key, lease)), None) = (held.next(), held.next()) else { + return None; + }; + let is_text_press = + matches!(lease, InputLease::Forwarded(lease) if is_text_press(&lease.key)); + let lease_key = *lease_key; + is_text_press + .then(|| self.leases.remove(&lease_key)) + .flatten() } #[cfg(test)] @@ -375,7 +398,10 @@ mod tests { leases.plan_repeat(lease_key, &repeated, Some(&context)), RepeatPlan::Forwarded(10) )); - assert!(leases.remove_forwarded(&lease_key).is_some()); + assert!(matches!( + leases.remove(&lease_key), + Some(InputLease::Forwarded(_)) + )); } #[test] @@ -408,7 +434,7 @@ mod tests { leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10)), RepeatPlan::Ignore )); - assert_eq!(leases.remove_forwarded(&lease_key), None); + assert!(leases.remove(&lease_key).is_none()); } #[test] diff --git a/src/input/mod.rs b/src/input/mod.rs index 13b8ffa3..5580da25 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,4 +1,3 @@ -mod encode; mod keybind_help; mod keybindings; mod lease; @@ -6,10 +5,6 @@ mod model; pub(crate) mod mouse; mod parse; -#[allow(unused_imports)] -pub use encode::{ - encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key, -}; pub(crate) use keybind_help::{ filter_keybind_help_groups, keybind_help_groups, keybind_help_text_char, }; @@ -18,14 +13,13 @@ pub(crate) use keybindings::{ resolve_non_indexed_action, resolve_prefix_binding, KeybindAction, KeybindDispatch, KeybindMatch, }; -pub(crate) use lease::{InputLeaseKey, InputLeaseTable, RepeatPlan}; +pub(crate) use lease::{InputLease, InputLeaseKey, InputLeaseTable, RepeatPlan}; #[cfg(not(windows))] pub use model::ime_compatible_keyboard_enhancement_flags; -#[cfg(any(unix, test))] -pub use model::MouseProtocolMode; pub use model::WindowsKeyRecord; pub use model::{ - host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, TerminalKey, - TextCommit, + host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, TerminalKey, TextCommit, }; +#[cfg(any(unix, test))] +pub use model::{MouseProtocolEncoding, MouseProtocolMode}; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index 3828174f..7d3a480f 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -79,6 +79,11 @@ pub struct TerminalKey { pub generated_text: Option, physical_identity_hint: bool, windows_dead_key: bool, + /// The layout character a non-Latin Ctrl chord was reported with before it + /// was resolved to its base-layout key. Client side only: the key's + /// identity, so a press and its release pair exactly even when a modifier + /// let go first changes which of the two the host report resolves to. + layout_key: Option, source: KeySource, } @@ -93,10 +98,20 @@ impl TerminalKey { generated_text: None, physical_identity_hint: false, windows_dead_key: false, + layout_key: None, source: KeySource::Synthesized, } } + pub(crate) fn with_layout_key(mut self, key: Option) -> Self { + self.layout_key = key; + self + } + + pub(crate) fn layout_key(&self) -> Option { + self.layout_key + } + pub fn with_kind(mut self, kind: crossterm::event::KeyEventKind) -> Self { if kind == crossterm::event::KeyEventKind::Release { self.repeat_count = 1; @@ -173,11 +188,10 @@ impl TerminalKey { self } - #[cfg(any(windows, test))] pub(crate) fn vt_bytes(&self) -> Option<&[u8]> { match &self.source { KeySource::Vt { bytes } => Some(bytes), - KeySource::Synthesized | KeySource::WindowsConsole { .. } => None, + _ => None, } } @@ -206,7 +220,9 @@ impl TerminalKey { KeySource::WindowsConsole { physical_key: None, .. } => KeyIdentity::Semantic(self.code), - KeySource::Synthesized | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), + KeySource::Synthesized | KeySource::Vt { .. } => { + KeyIdentity::Semantic(self.layout_key.map_or(self.code, KeyCode::Char)) + } } } @@ -358,6 +374,7 @@ impl MouseProtocolMode { } } +#[cfg(any(unix, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MouseProtocolEncoding { diff --git a/src/input/mouse.rs b/src/input/mouse.rs index eeaf4dec..7579e709 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -124,44 +124,6 @@ fn map_axis_within_cell( .checked_add(1) } -#[cfg(any(unix, test))] -pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> { - let body = data.strip_prefix(b"\x1b[<")?; - let body = body - .strip_suffix(b"M") - .or_else(|| body.strip_suffix(b"m"))?; - let mut fields = body.split(|byte| *byte == b';'); - parse_number(fields.next()?)?; - let x = parse_number(fields.next()?)?; - let y = parse_number(fields.next()?)?; - fields.next().is_none().then_some((x, y)) -} - -#[cfg(any(unix, test))] -pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option> { - let body = data.strip_prefix(b"\x1b[<")?; - let suffix = if body.ends_with(b"M") { 'M' } else { 'm' }; - let body = body.strip_suffix(&[suffix as u8])?; - let buttons = body.split(|byte| *byte == b';').next()?; - Some( - format!( - "\x1b[<{};{};{}{}", - std::str::from_utf8(buttons).ok()?, - u32::from(column) + 1, - u32::from(row) + 1, - suffix - ) - .into_bytes(), - ) -} - -#[cfg(any(unix, test))] -fn parse_number(value: &[u8]) -> Option { - (!value.is_empty() && value.iter().all(u8::is_ascii_digit)) - .then(|| std::str::from_utf8(value).ok()?.parse().ok()) - .flatten() -} - fn boundary(index: u16, count: u16, extent: u32) -> Option { (count > 0 && index <= count && extent > 0) .then(|| (u64::from(index) * u64::from(extent) / u64::from(count)) as u32) @@ -189,19 +151,6 @@ fn scale(pixel: u32, source: u32, target: u32) -> u32 { mod tests { use super::*; - #[test] - fn parser_accepts_only_complete_sgr_mouse_reports() { - for (input, expected) in [ - (b"\x1b[<35;321;241M".as_slice(), Some((321, 241))), - (b"\x1b[<0;1;2m".as_slice(), Some((1, 2))), - (b"key".as_slice(), None), - (b"\x1b[<0;1;2Mkey".as_slice(), None), - (b"\x1b[<0;1M".as_slice(), None), - ] { - assert_eq!(parse_report(input), expected); - } - } - #[test] fn integer_cell_pitch_ignores_trailing_pixel_remainder() { let geometry = HostGeometry::new(127, 31, 1_276, 626).unwrap(); diff --git a/src/input/parse.rs b/src/input/parse.rs index 0213bf3f..9e124398 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -33,8 +33,14 @@ fn parse_kitty_key_sequence(data: &str) -> Option { .next() .filter(|field| !field.is_empty()) .and_then(|field| field.parse::().ok()); + let base_layout_codepoint = key_fields + .next() + .filter(|field| !field.is_empty()) + .and_then(|field| field.parse::().ok()); - let code = kitty_codepoint_to_keycode(codepoint)?; + let mut code = kitty_codepoint_to_keycode(codepoint)?; + // Control-code text only ever matches Enter/Backspace/Tab/Esc, never a + // character key, so it is checked against the reported key. let associated_text = match associated_text { Some(value) => match parse_kitty_associated_text(value) { Some(text) => Some(text), @@ -43,6 +49,32 @@ fn parse_kitty_key_sequence(data: &str) -> Option { }, None => None, }; + // Ctrl chords on a non-Latin layout (Ctrl+\u{441} on Russian) are shortcuts + // for the physical key the host names in the base-layout field: Ctrl+C. + // Ghostty sends ^C to a plain shell for them, and the Kitty spec tells apps + // to match shortcuts on that key. Only what Ghostty maps is rewritten: Ctrl + // alone, on a key with a control byte. Shift, Alt and Super chords keep the + // layout character, Ctrl+Alt may be AltGr typing it, and Latin layouts + // (\u{f6}, \u{131}, \u{e5}) keep their own keys. + let mut shifted_codepoint = shifted_codepoint; + let mut layout_key = None; + if let (KeyCode::Char(ch), Some(base)) = (code, base_layout_codepoint.and_then(char::from_u32)) + { + // A shifted alternate means Shift was held, even if the modifier + // field leaves it out (normalized below). + let shifted = shifted_codepoint.is_some_and(|shifted| shifted != codepoint); + if key_modifiers_from_u8(modifier) == KeyModifiers::CONTROL + && !shifted + && is_non_latin_script(ch) + && ghostty_maps_ctrl_to_physical_key(base) + && associated_text.is_none() + { + layout_key = Some(ch); + code = KeyCode::Char(base); + // The shifted alternate belongs to the layout character. + shifted_codepoint = None; + } + } let kind = parse_kitty_event_type(event_type)?; let mut modifiers = key_modifiers_from_u8(modifier); // Kitty permits the shifted alternate only while Shift is active. Normalize @@ -54,13 +86,56 @@ fn parse_kitty_key_sequence(data: &str) -> Option { modifiers |= KeyModifiers::SHIFT; } - let mut key = TerminalKey::new(code, modifiers).with_kind(kind); + let mut key = TerminalKey::new(code, modifiers) + .with_kind(kind) + .with_layout_key(layout_key); if let Some(shifted_codepoint) = shifted_codepoint { key = key.with_shifted_codepoint(shifted_codepoint); } Some(key.with_generated_text(associated_text)) } +/// Physical keys Ghostty sends a control byte for when Ctrl is held on a +/// non-Latin layout. Ctrl+I, Ctrl+M and Ctrl+[ are left out on purpose +/// (fixterms), so Ctrl+\u{448} is not Tab and Ctrl+\u{445} is not Esc. +fn ghostty_maps_ctrl_to_physical_key(base: char) -> bool { + matches!(base, 'a'..='z' | '0'..='9' | '/' | '\\' | ']') && !matches!(base, 'i' | 'm') +} + +/// Scripts whose layouts put their own letters on the US letter keys, so a +/// Ctrl chord names the physical key. Latin scripts are excluded: their keys +/// are shortcuts of their own (Ctrl+\u{f6} is not Ctrl+;). +fn is_non_latin_script(ch: char) -> bool { + matches!( + u32::from(ch), + 0x0370..=0x03FF // Greek + | 0x0400..=0x052F // Cyrillic and supplement + | 0x0530..=0x058F // Armenian + | 0x0590..=0x05FF // Hebrew + | 0x0600..=0x06FF // Arabic + | 0x0700..=0x07FF // Syriac, Arabic supplement, Thaana, NKo + | 0x08A0..=0x08FF // Arabic extended-A + | 0x0900..=0x0DFF // Indic scripts through Sinhala + | 0x0E00..=0x0EFF // Thai, Lao + | 0x0F00..=0x0FFF // Tibetan + | 0x1000..=0x109F // Myanmar + | 0x10A0..=0x10FF // Georgian + | 0x1100..=0x11FF // Hangul Jamo + | 0x1200..=0x18FF // Ethiopic, Cherokee, Canadian Syllabics, Khmer, Mongolian + | 0x1C80..=0x1C8F // Cyrillic extended-C + | 0x1C90..=0x1CBF // Georgian extended + | 0x1F00..=0x1FFF // Greek extended + | 0x2DE0..=0x2DFF // Cyrillic extended-A + | 0x3040..=0x30FF // Hiragana, Katakana + | 0x3100..=0x318F // Bopomofo, Hangul compatibility Jamo + | 0x3400..=0x4DBF // CJK extension A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xA640..=0xA69F // Cyrillic extended-B + | 0xAB70..=0xABBF // Cherokee supplement (lowercase) + | 0xAC00..=0xD7AF // Hangul syllables + ) +} + fn parse_kitty_associated_text(value: &str) -> Option { let mut text = String::new(); for codepoint in value.split(':') { @@ -197,10 +272,18 @@ fn parse_legacy_special_sequence(data: &str) -> Option { "\x1bOj" => Some(TerminalKey::new(KeyCode::Char('*'), KeyModifiers::empty())), "\x1bOo" => Some(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())), "\x1bOM" => Some(TerminalKey::new(KeyCode::Enter, KeyModifiers::empty())), - "\x1bOP" | "\x1b[11~" => Some(TerminalKey::new(KeyCode::F(1), KeyModifiers::empty())), - "\x1bOQ" | "\x1b[12~" => Some(TerminalKey::new(KeyCode::F(2), KeyModifiers::empty())), + // Kitty keyboard hosts send unmodified F1, F2 and F4 as bare `CSI P/Q/S` + // (#4403). F3 stays `CSI 13~` there: a bare `CSI R` is a cursor report. + "\x1bOP" | "\x1b[11~" | "\x1b[P" => { + Some(TerminalKey::new(KeyCode::F(1), KeyModifiers::empty())) + } + "\x1bOQ" | "\x1b[12~" | "\x1b[Q" => { + Some(TerminalKey::new(KeyCode::F(2), KeyModifiers::empty())) + } "\x1bOR" | "\x1b[13~" => Some(TerminalKey::new(KeyCode::F(3), KeyModifiers::empty())), - "\x1bOS" | "\x1b[14~" => Some(TerminalKey::new(KeyCode::F(4), KeyModifiers::empty())), + "\x1bOS" | "\x1b[14~" | "\x1b[S" => { + Some(TerminalKey::new(KeyCode::F(4), KeyModifiers::empty())) + } "\x1b[15~" => Some(TerminalKey::new(KeyCode::F(5), KeyModifiers::empty())), "\x1b[17~" => Some(TerminalKey::new(KeyCode::F(6), KeyModifiers::empty())), "\x1b[18~" => Some(TerminalKey::new(KeyCode::F(7), KeyModifiers::empty())), @@ -405,7 +488,6 @@ mod tests { use crossterm::event::{KeyCode, KeyModifiers, ModifierKeyCode}; use super::*; - use crate::input::{encode_terminal_key, KeyboardProtocol}; fn assert_terminal_key_eq( actual: TerminalKey, @@ -586,7 +668,7 @@ mod tests { crossterm::event::KeyEventKind::Press, None, ); - assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\x1bA"); + assert_eq!(crate::pane::test_encode_key_for_app(b"", key), b"\x1bA"); } #[test] @@ -600,10 +682,7 @@ mod tests { crossterm::event::KeyEventKind::Press, None, ); - assert_eq!( - encode_terminal_key(key, KeyboardProtocol::Legacy), - b"\x1b\x06" - ); + assert_eq!(crate::pane::test_encode_key_for_app(b"", key), b"\x1b\x06"); } #[test] @@ -749,6 +828,144 @@ mod tests { assert_eq!(key.generated_text.as_deref(), Some("你好")); } + #[test] + fn parse_ctrl_chords_on_non_latin_layouts_as_their_base_layout_key() { + // Lab capture, kitty on a Russian layout: Ctrl+\u{441} is the C key. + for (sequence, code, modifiers) in [ + ( + "\x1b[1089::99;5u", + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ), + // Ctrl+Shift keeps the layout character, as Ghostty sends it. + ( + "\x1b[1089:1057:99;6u", + KeyCode::Char('\u{441}'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + // Shift implied only by the shifted alternate. + ( + "\x1b[1089:1057:99;5u", + KeyCode::Char('\u{441}'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + ), + ( + "\x1b[1089::99;5:3u", + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ), + // Without Ctrl it is text on that layout; without a base key + // nothing is known about the physical key. + ( + "\x1b[1089::99u", + KeyCode::Char('\u{441}'), + KeyModifiers::empty(), + ), + ( + "\x1b[1089;5u", + KeyCode::Char('\u{441}'), + KeyModifiers::CONTROL, + ), + ] { + let key = parse_terminal_key_sequence(sequence).expect(sequence); + assert_eq!((key.code, key.modifiers), (code, modifiers), "{sequence:?}"); + } + } + + #[test] + fn parse_ctrl_chords_on_every_non_latin_script_as_their_base_layout_key() { + // Greek, Cyrillic, Hebrew, Arabic, Thai, Georgian, Hangul, Cherokee + // (and its lowercase supplement), Canadian Syllabics, Mongolian. + for codepoint in [ + 945, 1092, 1513, 1588, 3615, 4304, 12609, 5024, 0xAB70, 5121, 6176, + ] { + for (event, kind) in [ + ("", crossterm::event::KeyEventKind::Press), + (":2", crossterm::event::KeyEventKind::Repeat), + (":3", crossterm::event::KeyEventKind::Release), + ] { + let sequence = format!("\x1b[{codepoint}::97;5{event}u"); + let key = parse_terminal_key_sequence(&sequence).unwrap(); + assert_terminal_key_eq(key, KeyCode::Char('a'), KeyModifiers::CONTROL, kind, None); + } + } + } + + /// Without a base-layout field nothing can be rewritten, so stripping it + /// gives the report as the layout key. + fn without_base_layout_field(sequence: &str) -> String { + let (key_part, rest) = sequence.split_once(';').unwrap_or((sequence, "")); + let mut fields = key_part.splitn(3, ':'); + let codepoint = fields.next().unwrap_or_default(); + let stripped = match fields.next() { + Some(shifted) if !shifted.is_empty() => format!("{codepoint}:{shifted}"), + _ => codepoint.to_string(), + }; + if rest.is_empty() { + stripped + } else { + format!("{stripped};{rest}") + } + } + + const LAYOUT_KEY_CHORDS: &[&str] = &[ + // German \u{f6} on `;`, French \u{e9} on `2`, Turkish \u{131} on `i`, + // Nordic \u{e5} on `[`: Ctrl, Alt, Super, Ctrl+Shift, press and release. + "\x1b[246::59;5u", + "\x1b[246::59;5:3u", + "\x1b[246:214:59;6u", + "\x1b[233::50;5u", + "\x1b[233::50;3u", + "\x1b[305::105;5u", + "\x1b[305:73:105;6u", + "\x1b[229::91;5u", + "\x1b[229::91;9u", + "\x1b[229:197:91;6:3u", + // Textless AltGr (Ctrl+Alt), Latin and non-Latin. + "\x1b[281::101;7u", + "\x1b[281::101;7:3u", + "\x1b[1094::119;7u", + // Non-Latin chords other than Ctrl alone: plain, Shift, Ctrl+Shift, + // Alt, Super, Ctrl+Super. Ghostty sends the layout character for them. + "\x1b[1094::119;1u", + "\x1b[1094:1062:119;2u", + "\x1b[1093:1061:91;6u", + "\x1b[1094:1062:119;6:3u", + "\x1b[1094::119;3u", + "\x1b[1094::119;9u", + "\x1b[1094::119;13u", + // A chord that generated text is typing. + "\x1b[1094::119;5;1094u", + // Keys Ghostty sends no control byte for: Russian \u{448} on `i`, + // \u{44c} on `m`, \u{445} on `[`, \u{436} on `;`. + "\x1b[1096::105;5u", + "\x1b[1100::109;5u", + "\x1b[1093::91;5u", + "\x1b[1078::59;5u", + // macOS Option-generated characters reported with Alt. + "\x1b[248::111;3u", + "\x1b[8721::119;3u", + "\x1b[8721::119;3:3u", + ]; + + #[test] + fn parse_chords_other_than_non_latin_ctrl_keep_layout_key() { + for sequence in LAYOUT_KEY_CHORDS { + let stripped = without_base_layout_field(sequence); + assert_ne!(*sequence, stripped); + let key = parse_terminal_key_sequence(sequence).unwrap(); + let layout_key = parse_terminal_key_sequence(&stripped).unwrap(); + assert_eq!(key, layout_key, "{sequence:?}"); + for pane_mode in [&b""[..], b"\x1b[>1u", b"\x1b[>5u", b"\x1b[>31u"] { + assert_eq!( + crate::pane::test_encode_key_for_app(pane_mode, key.clone()), + crate::pane::test_encode_key_for_app(pane_mode, layout_key.clone()), + "{sequence:?} {pane_mode:?}" + ); + } + } + } + #[test] fn parse_wezterm_control_associated_text_keeps_report_all_key_events() { for (sequence, expected_code, expected_kind) in [ @@ -954,7 +1171,7 @@ mod tests { #[test] fn legacy_lf_roundtrips_as_lf() { let key = parse_terminal_key_sequence("\n").unwrap(); - assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\n"); + assert_eq!(crate::pane::test_encode_key_for_app(b"", key), b"\n"); } #[test] diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 b/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 index e446a431..f2caacf9 100644 --- a/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 +++ b/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=antigravity_cli -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 # Session-only: this hook reports the Antigravity conversation so Herdr can # resume the pane. Lifecycle state comes from Herdr's screen detection. @@ -20,7 +20,10 @@ if ($Action -ne "session") { Exit-Hook } if ($env:HERDR_ENV -ne "1") { Exit-Hook } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { Exit-Hook } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.sh b/src/integration/assets/antigravity_cli/herdr-agent-state.sh index 2e55afad..51015744 100644 --- a/src/integration/assets/antigravity_cli/herdr-agent-state.sh +++ b/src/integration/assets/antigravity_cli/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=antigravity_cli -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 # Session-only: this hook reports the Antigravity conversation so Herdr can # resume the pane. Lifecycle state comes from Herdr's screen detection. diff --git a/src/integration/assets/claude/herdr-agent-state.ps1 b/src/integration/assets/claude/herdr-agent-state.ps1 index d62c5b9f..b4bc6a31 100644 --- a/src/integration/assets/claude/herdr-agent-state.ps1 +++ b/src/integration/assets/claude/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=10 +# HERDR_INTEGRATION_VERSION=11 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index e0b88ce7..792bd54d 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=10 +# HERDR_INTEGRATION_VERSION=11 set -eu diff --git a/src/integration/assets/codex/herdr-agent-state.ps1 b/src/integration/assets/codex/herdr-agent-state.ps1 index 81ed381b..1ee0db04 100644 --- a/src/integration/assets/codex/herdr-agent-state.ps1 +++ b/src/integration/assets/codex/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=codex -# HERDR_INTEGRATION_VERSION=8 +# HERDR_INTEGRATION_VERSION=10 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { @@ -24,7 +27,7 @@ if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } if ([string]::IsNullOrWhiteSpace($payload.transcript_path)) { exit 0 } if (-not [string]::IsNullOrWhiteSpace($env:CODEX_THREAD_ID) -and $env:CODEX_THREAD_ID -ne $sessionId) { exit 0 } -$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +$seq = [DateTimeOffset]::UtcNow.Ticks $herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } try { $args = @( diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index 2931936a..4e94757e 100644 --- a/src/integration/assets/codex/herdr-agent-state.sh +++ b/src/integration/assets/codex/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=codex -# HERDR_INTEGRATION_VERSION=8 +# HERDR_INTEGRATION_VERSION=10 set -eu diff --git a/src/integration/assets/copilot/herdr-agent-state.ps1 b/src/integration/assets/copilot/herdr-agent-state.ps1 index f424f122..be485778 100644 --- a/src/integration/assets/copilot/herdr-agent-state.ps1 +++ b/src/integration/assets/copilot/herdr-agent-state.ps1 @@ -2,13 +2,16 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=copilot -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { @{} } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/copilot/herdr-agent-state.sh b/src/integration/assets/copilot/herdr-agent-state.sh index 82522ac1..d58457b8 100644 --- a/src/integration/assets/copilot/herdr-agent-state.sh +++ b/src/integration/assets/copilot/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=copilot -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 set -eu diff --git a/src/integration/assets/cursor/herdr-agent-state.ps1 b/src/integration/assets/cursor/herdr-agent-state.ps1 index ebfdb393..ba0b0dc3 100644 --- a/src/integration/assets/cursor/herdr-agent-state.ps1 +++ b/src/integration/assets/cursor/herdr-agent-state.ps1 @@ -1,6 +1,6 @@ # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=cursor -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") @@ -13,7 +13,10 @@ if ($Action -ne "session") { Exit-Hook } if ($env:HERDR_ENV -ne "1") { Exit-Hook } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { Exit-Hook } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() $jsonStart = $inputText.IndexOf("{") if ($jsonStart -gt 0) { $inputText = $inputText.Substring($jsonStart) diff --git a/src/integration/assets/cursor/herdr-agent-state.sh b/src/integration/assets/cursor/herdr-agent-state.sh index 20dcb9ba..e80fba84 100644 --- a/src/integration/assets/cursor/herdr-agent-state.sh +++ b/src/integration/assets/cursor/herdr-agent-state.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=cursor -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 [ "${1:-}" = "session" ] || exit 0 [ "${HERDR_ENV:-}" = "1" ] || exit 0 diff --git a/src/integration/assets/devin/herdr-agent-state.ps1 b/src/integration/assets/devin/herdr-agent-state.ps1 index 7d14ad75..31650d89 100644 --- a/src/integration/assets/devin/herdr-agent-state.ps1 +++ b/src/integration/assets/devin/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=devin -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/devin/herdr-agent-state.sh b/src/integration/assets/devin/herdr-agent-state.sh index 86249b88..5b671ddd 100644 --- a/src/integration/assets/devin/herdr-agent-state.sh +++ b/src/integration/assets/devin/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=devin -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu diff --git a/src/integration/assets/droid/herdr-agent-state.ps1 b/src/integration/assets/droid/herdr-agent-state.ps1 index fa6a4e25..536507c6 100644 --- a/src/integration/assets/droid/herdr-agent-state.ps1 +++ b/src/integration/assets/droid/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=droid -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/droid/herdr-agent-state.sh b/src/integration/assets/droid/herdr-agent-state.sh index d777b2c1..ccb18849 100644 --- a/src/integration/assets/droid/herdr-agent-state.sh +++ b/src/integration/assets/droid/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=droid -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 set -eu diff --git a/src/integration/assets/grok/herdr-agent-state.ps1 b/src/integration/assets/grok/herdr-agent-state.ps1 index 1b183a3b..fd2c6749 100644 --- a/src/integration/assets/grok/herdr-agent-state.ps1 +++ b/src/integration/assets/grok/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=grok -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/grok/herdr-agent-state.sh b/src/integration/assets/grok/herdr-agent-state.sh index 3eaa3431..9b71eebf 100644 --- a/src/integration/assets/grok/herdr-agent-state.sh +++ b/src/integration/assets/grok/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=grok -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1 index 3141326d..ea390fa6 100644 --- a/src/integration/assets/kimi/herdr-agent-state.ps1 +++ b/src/integration/assets/kimi/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=7 +# HERDR_INTEGRATION_VERSION=8 param([string]$Action = "") @@ -10,7 +10,10 @@ if (@("session", "working", "blocked", "idle") -notcontains $Action) { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh index c8ed8e23..e4f65e4c 100644 --- a/src/integration/assets/kimi/herdr-agent-state.sh +++ b/src/integration/assets/kimi/herdr-agent-state.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=7 +# HERDR_INTEGRATION_VERSION=8 action="${1:-}" case "$action" in diff --git a/src/integration/assets/letta/herdr-agent-session.ps1 b/src/integration/assets/letta/herdr-agent-session.ps1 index 27f40e9b..cbb93d02 100644 --- a/src/integration/assets/letta/herdr-agent-session.ps1 +++ b/src/integration/assets/letta/herdr-agent-session.ps1 @@ -1,6 +1,6 @@ # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=letta -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") @@ -9,7 +9,10 @@ if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/letta/herdr-agent-session.sh b/src/integration/assets/letta/herdr-agent-session.sh index d6e052d6..ee84742b 100644 --- a/src/integration/assets/letta/herdr-agent-session.sh +++ b/src/integration/assets/letta/herdr-agent-session.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=letta -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 [ "${1:-}" = "session" ] || exit 0 [ "${HERDR_ENV:-}" = "1" ] || exit 0 diff --git a/src/integration/assets/mastracode/herdr-agent-state.ps1 b/src/integration/assets/mastracode/herdr-agent-state.ps1 index b0cca059..6e47c880 100644 --- a/src/integration/assets/mastracode/herdr-agent-state.ps1 +++ b/src/integration/assets/mastracode/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=mastracode -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -notin @("session", "working", "idle", "blocked")) { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/mastracode/herdr-agent-state.sh b/src/integration/assets/mastracode/herdr-agent-state.sh index 39a8461b..338ce4ef 100644 --- a/src/integration/assets/mastracode/herdr-agent-state.sh +++ b/src/integration/assets/mastracode/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=mastracode -# HERDR_INTEGRATION_VERSION=2 +# HERDR_INTEGRATION_VERSION=3 set -eu diff --git a/src/integration/assets/qodercli/herdr-agent-state.ps1 b/src/integration/assets/qodercli/herdr-agent-state.ps1 index 6d0e037b..fde7534b 100644 --- a/src/integration/assets/qodercli/herdr-agent-state.ps1 +++ b/src/integration/assets/qodercli/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=qodercli -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 param([string]$Action = "") @@ -10,7 +10,10 @@ if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/qodercli/herdr-agent-state.sh b/src/integration/assets/qodercli/herdr-agent-state.sh index 8f03389a..890a9751 100644 --- a/src/integration/assets/qodercli/herdr-agent-state.sh +++ b/src/integration/assets/qodercli/herdr-agent-state.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=qodercli -# HERDR_INTEGRATION_VERSION=3 +# HERDR_INTEGRATION_VERSION=4 [ "${1:-}" = "session" ] || exit 0 [ "${HERDR_ENV:-}" = "1" ] || exit 0 diff --git a/src/integration/assets/qwen/herdr-agent-session.ps1 b/src/integration/assets/qwen/herdr-agent-session.ps1 index 882caf86..4257568d 100644 --- a/src/integration/assets/qwen/herdr-agent-session.ps1 +++ b/src/integration/assets/qwen/herdr-agent-session.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=qwen -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") @@ -11,7 +11,10 @@ if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } -$inputText = [Console]::In.ReadToEnd() +# Read hook input as raw bytes so UTF-8 payloads decode correctly even when the +# console input code page is not UTF-8 (for example CP949 on Korean Windows). +$stdinReader = New-Object System.IO.StreamReader([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8) +$inputText = $stdinReader.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { diff --git a/src/integration/assets/qwen/herdr-agent-session.sh b/src/integration/assets/qwen/herdr-agent-session.sh index 226c2a97..f2256ece 100644 --- a/src/integration/assets/qwen/herdr-agent-session.sh +++ b/src/integration/assets/qwen/herdr-agent-session.sh @@ -1,7 +1,7 @@ #!/bin/sh # managed by herdr; reinstalling the integration replaces this file. # HERDR_INTEGRATION_ID=qwen -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 [ "${1:-}" = "session" ] || exit 0 [ "${HERDR_ENV:-}" = "1" ] || exit 0 diff --git a/src/integration/config_edit.rs b/src/integration/config_edit.rs index 812907e7..e7c72cb6 100644 --- a/src/integration/config_edit.rs +++ b/src/integration/config_edit.rs @@ -399,6 +399,9 @@ pub(crate) fn remove_hermes_plugin_enabled(content: &str) -> String { update_hermes_enabled_plugin(content, false) } +/// Edits supported flat `plugins` and mapped `plugins.enabled` sequences. +/// Unrelated sibling entries and comments are retained, and repeating the same +/// enable or disable operation leaves the result unchanged. pub(crate) fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> String { let trailing_newline = content.ends_with('\n'); let mut lines: Vec = content.lines().map(str::to_string).collect(); @@ -416,126 +419,167 @@ pub(crate) fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> Stri let plugins_end = next_top_level_yaml_key_index(&lines, plugins_index + 1).unwrap_or(lines.len()); - let plugins_inline_items = yaml_key_value_at_indent(&lines[plugins_index], 0, "plugins") + let first_child = + (plugins_index + 1..plugins_end).find(|&index| yaml_indent(&lines[index]).is_some()); + // A list under disabled (or another mapping member) is not the flat plugins + // list. Classify the owner's first child before looking for sequence items. + let flat_sequence = + first_child.is_some_and(|index| yaml_list_item_value(&lines[index]).is_some()); + let mapping_indent = first_child + .and_then(|index| yaml_indent(&lines[index])) + .filter(|&indent| indent > 0) + .unwrap_or(2); + let enabled_index = (!flat_sequence) + .then(|| { + (plugins_index + 1..plugins_end) + .find(|&index| yaml_key_at_indent(&lines[index], mapping_indent) == Some("enabled")) + }) + .flatten(); + let plugins_flow = yaml_key_value_at_indent(&lines[plugins_index], 0, "plugins") .and_then(yaml_flow_sequence_items); - let enabled_index = lines[plugins_index + 1..plugins_end] - .iter() - .position(|line| yaml_key_at_indent(line, 2) == Some("enabled")) - .map(|offset| plugins_index + 1 + offset); - let flat_list_start = lines[plugins_index + 1..plugins_end] - .iter() - .position(|line| yaml_list_item_value_at_indent(line, 2).is_some()) - .map(|offset| plugins_index + 1 + offset); - - if let Some(enabled_index) = enabled_index { - if let Some(mut items) = yaml_key_value_at_indent(&lines[enabled_index], 2, "enabled") - .and_then(yaml_flow_sequence_items) - { - let existing_item_index = items - .iter() - .position(|item| yaml_scalar_value(item) == HERMES_PLUGIN_INSTALL_NAME); - - match (enabled, existing_item_index) { - (true, Some(_)) | (false, None) => return content.to_string(), - (true, None) => items.insert(0, HERMES_PLUGIN_INSTALL_NAME.to_string()), - (false, Some(index)) => { - items.remove(index); - } - } - let comment = yaml_inline_comment(&lines[enabled_index]); - let replacement = hermes_enabled_plugin_lines(&items, comment); - lines.splice(enabled_index..enabled_index + 1, replacement); + let (key_index, key_indent, key, flow_items) = if let Some(index) = enabled_index { + let items = yaml_key_value_at_indent(&lines[index], mapping_indent, "enabled") + .and_then(yaml_flow_sequence_items); + (index, mapping_indent, "enabled", items) + } else if flat_sequence || plugins_flow.is_some() { + (plugins_index, 0, "plugins", plugins_flow) + } else { + if enabled { + let indent = " ".repeat(mapping_indent); + lines.insert(plugins_index + 1, format!("{indent}enabled:")); + lines.insert( + plugins_index + 2, + format!("{indent} - {HERMES_PLUGIN_INSTALL_NAME}"), + ); return join_yaml_lines(lines, trailing_newline); } + return content.to_string(); + }; - let list_start = enabled_index + 1; - let list_end = lines[list_start..plugins_end] - .iter() - .position(|line| { - yaml_indent(line).is_some_and(|indent| indent <= 2) && yaml_key_name(line).is_some() - }) - .map(|offset| list_start + offset) - .unwrap_or(plugins_end); - let existing_item_index = lines[list_start..list_end] - .iter() - .position(|line| yaml_list_item_matches(line, HERMES_PLUGIN_INSTALL_NAME)) - .map(|offset| list_start + offset); - - match (enabled, existing_item_index) { - (true, Some(_)) | (false, None) => return content.to_string(), - (true, None) => lines.insert(list_start, " - herdr-agent-state".to_string()), - (false, Some(index)) => { - lines.remove(index); - } - } - return join_yaml_lines(lines, trailing_newline); - } - - if let Some(mut items) = plugins_inline_items { - let existing_item_index = items + if let Some(mut items) = flow_items { + let contains_plugin = items .iter() - .position(|item| yaml_scalar_value(item) == HERMES_PLUGIN_INSTALL_NAME); - - match (enabled, existing_item_index) { - (true, Some(_)) | (false, None) => return content.to_string(), - (true, None) => items.insert(0, HERMES_PLUGIN_INSTALL_NAME.to_string()), - (false, Some(index)) => { - items.remove(index); + .any(|item| yaml_scalar_value(item) == HERMES_PLUGIN_INSTALL_NAME); + match (enabled, contains_plugin) { + (true, true) | (false, false) => return content.to_string(), + (true, false) => items.insert(0, HERMES_PLUGIN_INSTALL_NAME.to_string()), + (false, true) => { + items.retain(|item| yaml_scalar_value(item) != HERMES_PLUGIN_INSTALL_NAME) } } - - let comment = yaml_inline_comment(&lines[plugins_index]); - let replacement = hermes_flat_plugin_lines(&items, comment); - lines.splice(plugins_index..plugins_end, replacement); - return join_yaml_lines(lines, trailing_newline); - } - - if let Some(flat_list_start) = flat_list_start { - let existing_item_index = lines[plugins_index + 1..plugins_end] + let comment = yaml_inline_comment(&lines[key_index]); + let header = strip_yaml_inline_comment(&lines[key_index]).trim_end(); + let content = yaml_key_value_at_indent(header, key_indent, key) + .map(yaml_node_content) + .unwrap_or_default(); + let prefix = header[..header.len() - content.len()].trim_end(); + let replacement = yaml_sequence_lines(prefix, key_indent, &items, comment); + // Only the header belongs to a flow sequence. Keep following comments. + lines.splice(key_index..key_index + 1, replacement); + } else { + let sequence = YamlBlockSequence::new(&lines, key_index, key_indent, plugins_end); + let matches: Vec<_> = sequence + .items .iter() - .position(|line| yaml_list_item_matches_at_indent(line, 2, HERMES_PLUGIN_INSTALL_NAME)) - .map(|offset| plugins_index + 1 + offset); - - match (enabled, existing_item_index) { - (true, Some(_)) | (false, None) => return content.to_string(), - (true, None) => lines.insert(flat_list_start, " - herdr-agent-state".to_string()), - (false, Some(index)) => { - lines.remove(index); + .filter(|item| { + yaml_list_item_matches_at_indent( + &lines[(*item).clone()], + sequence.indent, + HERMES_PLUGIN_INSTALL_NAME, + ) + }) + .cloned() + .collect(); + match (enabled, matches.is_empty()) { + (true, false) | (false, true) => return content.to_string(), + (true, true) => lines.insert( + key_index + 1, + format!( + "{}- {HERMES_PLUGIN_INSTALL_NAME}", + " ".repeat(sequence.indent) + ), + ), + (false, false) => { + // Preserve both node properties and the sequence type, so aliases + // still refer to an empty list and reinstall can reuse the header. + if matches.len() == sequence.items.len() + && yaml_key_value_at_indent(&lines[key_index], key_indent, key) + .is_some_and(|value| yaml_node_content(value).is_empty()) + { + lines[key_index] = with_yaml_inline_comment( + format!( + "{} []", + strip_yaml_inline_comment(&lines[key_index]).trim_end() + ), + yaml_inline_comment(&lines[key_index]), + ); + } + for item in matches.into_iter().rev() { + for index in item.rev() { + // Remove the indicator and scalar, retaining standalone + // comments and blank lines surrounding the matched item. + if yaml_indent(&lines[index]).is_some() { + lines.remove(index); + } + } + } } } - return join_yaml_lines(lines, trailing_newline); - } - - if enabled { - lines.insert(plugins_index + 1, " enabled:".to_string()); - lines.insert(plugins_index + 2, " - herdr-agent-state".to_string()); - return join_yaml_lines(lines, trailing_newline); } - - content.to_string() + join_yaml_lines(lines, trailing_newline) } -pub(crate) fn hermes_flat_plugin_lines(items: &[String], comment: Option<&str>) -> Vec { - if items.is_empty() { - return vec![with_yaml_inline_comment("plugins: []".to_string(), comment)]; +/// Direct items of one block sequence, including YAML's indentless form. +/// Sibling keys bound the range before indentation or membership is inferred. +struct YamlBlockSequence { + indent: usize, + items: Vec>, +} + +impl YamlBlockSequence { + /// Finds direct items before the next sibling or ancestor key. + /// Empty sequences default to two spaces beyond the owner key. + fn new(lines: &[String], key_index: usize, key_indent: usize, parent_end: usize) -> Self { + let start = key_index + 1; + let end = (start..parent_end) + .find(|&index| { + yaml_indent(&lines[index]).is_some_and(|indent| { + indent < key_indent + || indent == key_indent && yaml_list_item_value(&lines[index]).is_none() + }) + }) + .unwrap_or(parent_end); + let indent = (start..end) + .find(|&index| yaml_indent(&lines[index]).is_some()) + .and_then(|index| yaml_indent(&lines[index])) + .unwrap_or(key_indent + 2); + let starts: Vec<_> = (start..end) + .filter(|&index| yaml_list_item_value_at_indent(&lines[index], indent).is_some()) + .collect(); + let items = starts + .iter() + .enumerate() + .map(|(index, &start)| start..starts.get(index + 1).copied().unwrap_or(end)) + .collect(); + Self { indent, items } } - - let mut lines = vec![with_yaml_inline_comment("plugins:".to_string(), comment)]; - lines.extend(items.iter().map(|item| format!(" - {item}"))); - lines } -pub(crate) fn hermes_enabled_plugin_lines(items: &[String], comment: Option<&str>) -> Vec { +/// Renders parsed flow items under the existing key and node properties. +/// Empty sequences stay inline as `[]`, retaining the header comment. +fn yaml_sequence_lines( + header: &str, + indent: usize, + items: &[String], + comment: Option<&str>, +) -> Vec { + let padding = " ".repeat(indent); if items.is_empty() { - return vec![with_yaml_inline_comment( - " enabled: []".to_string(), - comment, - )]; + return vec![with_yaml_inline_comment(format!("{header} []"), comment)]; } - - let mut lines = vec![with_yaml_inline_comment(" enabled:".to_string(), comment)]; - lines.extend(items.iter().map(|item| format!(" - {item}"))); + let mut lines = vec![with_yaml_inline_comment(header.to_string(), comment)]; + lines.extend(items.iter().map(|item| format!("{padding} - {item}"))); lines } @@ -602,11 +646,9 @@ pub(crate) fn yaml_indent(line: &str) -> Option { } pub(crate) fn yaml_list_item_value(line: &str) -> Option<&str> { - line.trim().strip_prefix("- ").map(str::trim) -} - -pub(crate) fn yaml_list_item_matches(line: &str, value: &str) -> bool { - yaml_list_item_value(line).is_some_and(|item| yaml_scalar_value(item) == value) + let value = line.trim().strip_prefix('-')?; + // A bare indicator is still a list item; its value may be on the next line. + (value.is_empty() || value.starts_with(' ')).then(|| value.trim()) } pub(crate) fn yaml_list_item_value_at_indent(line: &str, indent: usize) -> Option<&str> { @@ -616,13 +658,44 @@ pub(crate) fn yaml_list_item_value_at_indent(line: &str, indent: usize) -> Optio yaml_list_item_value(line) } -pub(crate) fn yaml_list_item_matches_at_indent(line: &str, indent: usize, value: &str) -> bool { - yaml_list_item_value_at_indent(line, indent) - .is_some_and(|item| yaml_scalar_value(item) == value) +/// Matches a direct scalar, either inline or below a bare indicator. Additional +/// content makes it a multiline scalar or collection, not the requested plugin. +fn yaml_list_item_matches_at_indent(lines: &[String], indent: usize, value: &str) -> bool { + let Some(item) = lines + .first() + .and_then(|line| yaml_list_item_value_at_indent(line, indent)) + else { + return false; + }; + let mut continuation = lines[1..].iter().filter(|line| yaml_indent(line).is_some()); + let scalar = if strip_yaml_inline_comment(item).is_empty() { + let Some(line) = continuation.next() else { + return false; + }; + if yaml_indent(line).is_none_or(|depth| depth <= indent) { + return false; + } + line.trim() + } else { + item + }; + continuation.next().is_none() && yaml_scalar_value(scalar) == value +} + +/// Separates leading anchor/tag properties from a node's actual content. +fn yaml_node_content(value: &str) -> &str { + let mut value = strip_yaml_inline_comment(value).trim(); + while value.starts_with('&') || value.starts_with('!') { + value = value + .find(char::is_whitespace) + .map(|index| value[index..].trim_start()) + .unwrap_or_default(); + } + value } pub(crate) fn yaml_flow_sequence_items(value: &str) -> Option> { - let value = strip_yaml_inline_comment(value).trim(); + let value = yaml_node_content(value); let inner = value.strip_prefix('[')?.strip_suffix(']')?.trim(); if inner.is_empty() { return Some(Vec::new()); diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 673225a3..749c34ec 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -49,7 +49,7 @@ const CLAUDE_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/claude/herdr-agent-state.sh") }; -const CLAUDE_INTEGRATION_VERSION: u32 = 10; +const CLAUDE_INTEGRATION_VERSION: u32 = 11; const CODEX_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -60,7 +60,7 @@ const CODEX_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/codex/herdr-agent-state.sh") }; -const CODEX_INTEGRATION_VERSION: u32 = 8; +const CODEX_INTEGRATION_VERSION: u32 = 10; const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -71,7 +71,7 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/kimi/herdr-agent-state.sh") }; -const KIMI_INTEGRATION_VERSION: u32 = 7; +const KIMI_INTEGRATION_VERSION: u32 = 8; const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; const KIMI_MIN_VERSION: &str = "0.14.0"; @@ -113,7 +113,7 @@ const COPILOT_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/copilot/herdr-agent-state.sh") }; -const COPILOT_INTEGRATION_VERSION: u32 = 3; +const COPILOT_INTEGRATION_VERSION: u32 = 4; const COPILOT_HOOK_EVENTS: [&str; 1] = ["SessionStart"]; const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [ "UserPromptSubmit", @@ -136,7 +136,7 @@ const DEVIN_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/devin/herdr-agent-state.sh") }; -const DEVIN_INTEGRATION_VERSION: u32 = 2; +const DEVIN_INTEGRATION_VERSION: u32 = 3; const DEVIN_HOOK_EVENTS: [(&str, &str); 6] = [ ("SessionStart", "session"), ("UserPromptSubmit", "session"), @@ -163,7 +163,7 @@ const DROID_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/droid/herdr-agent-state.sh") }; -const DROID_INTEGRATION_VERSION: u32 = 3; +const DROID_INTEGRATION_VERSION: u32 = 4; const DROID_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [ ("SessionStart", "idle"), @@ -204,7 +204,7 @@ const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/qodercli/herdr-agent-state.sh") }; -const QODERCLI_INTEGRATION_VERSION: u32 = 3; +const QODERCLI_INTEGRATION_VERSION: u32 = 4; const QODERCLI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; const QWEN_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-session.ps1" @@ -216,7 +216,7 @@ const QWEN_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/qwen/herdr-agent-session.sh") }; -const QWEN_INTEGRATION_VERSION: u32 = 1; +const QWEN_INTEGRATION_VERSION: u32 = 2; const QWEN_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; const LETTA_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-session.ps1" @@ -228,7 +228,7 @@ const LETTA_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/letta/herdr-agent-session.sh") }; -const LETTA_INTEGRATION_VERSION: u32 = 1; +const LETTA_INTEGRATION_VERSION: u32 = 2; const LETTA_HOOK_TIMEOUT_MS: u64 = 10_000; const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ ("SessionStart", "idle"), @@ -254,7 +254,7 @@ const CURSOR_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/cursor/herdr-agent-state.sh") }; -const CURSOR_INTEGRATION_VERSION: u32 = 1; +const CURSOR_INTEGRATION_VERSION: u32 = 2; #[cfg(windows)] const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.ps1"; #[cfg(not(windows))] @@ -265,7 +265,7 @@ const ANTIGRAVITY_CLI_HOOK_ASSET: &str = #[cfg(not(windows))] const ANTIGRAVITY_CLI_HOOK_ASSET: &str = include_str!("assets/antigravity_cli/herdr-agent-state.sh"); -const ANTIGRAVITY_CLI_INTEGRATION_VERSION: u32 = 3; +const ANTIGRAVITY_CLI_INTEGRATION_VERSION: u32 = 4; /// Antigravity CLI keys `hooks.json` by hook name, so every Herdr entry lives /// under one Herdr-owned block that install rewrites and uninstall removes. const ANTIGRAVITY_CLI_HOOK_BLOCK_NAME: &str = "herdr"; @@ -291,7 +291,7 @@ const MASTRACODE_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/mastracode/herdr-agent-state.sh") }; -const MASTRACODE_INTEGRATION_VERSION: u32 = 2; +const MASTRACODE_INTEGRATION_VERSION: u32 = 3; const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000; const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] = [("SessionStart", "idle"), ("SessionEnd", "release")]; @@ -319,7 +319,7 @@ const GROK_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/grok/herdr-agent-state.sh") }; -const GROK_INTEGRATION_VERSION: u32 = 2; +const GROK_INTEGRATION_VERSION: u32 = 3; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/integration/targets.rs b/src/integration/targets.rs index ec605866..e38ee446 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -191,6 +191,7 @@ pub(crate) fn install_codex() -> io::Result { remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + remove_hook_commands(hooks, "Interrupt", &hook_path, Some("idle"))?; remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?; ensure_command_hook( hooks, @@ -629,6 +630,7 @@ pub(crate) fn uninstall_codex() -> io::Result { updated_hooks |= remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; updated_hooks |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; + updated_hooks |= remove_hook_commands(hooks, "Interrupt", &hook_path, Some("idle"))?; } if updated_hooks { diff --git a/src/integration/tests.rs b/src/integration/tests.rs index bffe2f45..147daff8 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -1132,7 +1132,7 @@ fn claude_v9_integration_status_is_outdated_until_reinstalled() { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(9)); - assert_eq!(claude.expected_version, 10); + assert_eq!(claude.expected_version, 11); assert_eq!(claude.state, IntegrationStatusKind::Outdated); install_claude().unwrap(); @@ -1141,7 +1141,7 @@ fn claude_v9_integration_status_is_outdated_until_reinstalled() { hook_path, CLAUDE_INTEGRATION_VERSION, ); - assert_eq!(status.installed_version, Some(10)); + assert_eq!(status.installed_version, Some(11)); assert_eq!(status.state, IntegrationStatusKind::Current); std::env::remove_var("HOME"); @@ -1171,13 +1171,139 @@ fn claude_v2_integration_status_is_outdated() { assert_eq!(claude.path, hook_path); assert_eq!(claude.installed_version, Some(2)); - assert_eq!(claude.expected_version, 10); + assert_eq!(claude.expected_version, 11); assert_eq!(claude.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } +#[test] +fn windows_hooks_decode_stdin_as_utf8() { + // Windows PowerShell decodes `[Console]::In` with the console input code + // page, which corrupts UTF-8 hook payloads on non-UTF-8 locales such as + // Korean Windows (CP949) and makes the hook silently drop the session. + // Every bundled PowerShell hook must read the raw stdin stream as UTF-8. + // See #5253. + for (name, asset) in [ + ( + "antigravity_cli", + include_str!("assets/antigravity_cli/herdr-agent-state.ps1"), + ), + ( + "claude", + include_str!("assets/claude/herdr-agent-state.ps1"), + ), + ("codex", include_str!("assets/codex/herdr-agent-state.ps1")), + ( + "copilot", + include_str!("assets/copilot/herdr-agent-state.ps1"), + ), + ( + "cursor", + include_str!("assets/cursor/herdr-agent-state.ps1"), + ), + ("devin", include_str!("assets/devin/herdr-agent-state.ps1")), + ("droid", include_str!("assets/droid/herdr-agent-state.ps1")), + ("grok", include_str!("assets/grok/herdr-agent-state.ps1")), + ("kimi", include_str!("assets/kimi/herdr-agent-state.ps1")), + ( + "letta", + include_str!("assets/letta/herdr-agent-session.ps1"), + ), + ( + "mastracode", + include_str!("assets/mastracode/herdr-agent-state.ps1"), + ), + ( + "qodercli", + include_str!("assets/qodercli/herdr-agent-state.ps1"), + ), + ("qwen", include_str!("assets/qwen/herdr-agent-session.ps1")), + ] { + assert!( + !asset.contains("[Console]::In.ReadToEnd()"), + "{name} hook must not decode stdin with the console input code page" + ); + assert!( + asset.contains("[Console]::OpenStandardInput()") + && asset.contains("[System.Text.Encoding]::UTF8"), + "{name} hook must decode raw stdin as UTF-8" + ); + } +} + +#[cfg(windows)] +#[test] +fn claude_windows_hook_reports_utf8_session_path() { + use std::io::Write; + use std::process::{Command, Stdio}; + + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(home.join(".claude")).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &home); + let installed = install_claude().unwrap(); + + let capture = base.join("args.txt"); + let fake_herdr = base.join("herdr-capture.ps1"); + fs::write( + &fake_herdr, + "$capture = $env:HERDR_TEST_CAPTURE\n[System.IO.File]::WriteAllText($capture, ($args -join \"`n\"), [System.Text.UTF8Encoding]::new($false))\n", + ) + .unwrap(); + + // A Korean profile path: decoded with a non-UTF-8 console input code page + // (CP949 on Korean Windows) it garbles the JSON and the hook exits silently. + let transcript = "C:\\Users\\\u{d55c}\u{ae00}\\transcript.jsonl"; + let payload = format!( + r#"{{"hook_event_name":"SessionStart","session_id":"claude-session","transcript_path":{}}}"#, + serde_json::to_string(transcript).unwrap() + ); + + let mut child = Command::new("powershell") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(&installed.hook_path) + .arg("session") + .env("HERDR_ENV", "1") + .env("HERDR_PANE_ID", "w1:p2") + .env("HERDR_BIN_PATH", &fake_herdr) + .env("HERDR_TEST_CAPTURE", &capture) + .env_remove("CURSOR_VERSION") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(payload.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "hook failed: stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + + let args = fs::read_to_string(&capture).unwrap(); + assert!(args.contains("report-agent-session"), "captured: {args}"); + assert!(args.contains("--agent-session-id"), "captured: {args}"); + assert!(args.contains("claude-session"), "captured: {args}"); + assert!(args.contains(transcript), "captured: {args}"); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = fs::remove_dir_all(base); +} + #[test] fn uninstall_claude_removes_herdr_hooks_and_preserves_others() { let _lock = integration_env_lock(); @@ -1304,7 +1430,7 @@ fn codex_v2_integration_status_is_outdated() { assert_eq!(codex.path, hook_path); assert_eq!(codex.installed_version, Some(2)); - assert_eq!(codex.expected_version, 8); + assert_eq!(codex.expected_version, 10); assert_eq!(codex.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); @@ -1339,6 +1465,7 @@ fn install_codex_writes_hook_and_updates_hooks_and_config() { assert!(hooks["hooks"].get("PreToolUse").is_none()); assert!(hooks["hooks"].get("PermissionRequest").is_none()); assert!(hooks["hooks"].get("Stop").is_none()); + assert!(hooks["hooks"].get("Interrupt").is_none()); assert!(config.contains("model = \"gpt-5.4\"")); assert!(config.contains("[features]")); assert!(config.contains("hooks = true")); @@ -1393,6 +1520,7 @@ fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() { assert!(hooks["hooks"].get("PreToolUse").is_none()); assert!(hooks["hooks"].get("PermissionRequest").is_none()); assert!(hooks["hooks"].get("Stop").is_none()); + assert!(hooks["hooks"].get("Interrupt").is_none()); assert_eq!(config.matches("hooks = true").count(), 1); assert!(!config.contains("codex_hooks")); assert!(config.contains("other = true")); @@ -1426,6 +1554,49 @@ fn install_codex_only_migrates_top_level_feature_flags() { let _ = fs::remove_dir_all(base); } +#[test] +fn install_codex_removes_turn_status_hooks_from_previous_integration() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let codex_dir = home.join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let hook_path = codex_dir.join(CODEX_HOOK_INSTALL_NAME); + let hook = |action| json!({"type": "command", "command": hook_command(&hook_path, Some(action)), "timeout": 10}); + let hooks = json!({ + "hooks": { + "SessionStart": [{"hooks": [hook("session")]}], + "UserPromptSubmit": [{"hooks": [ + hook("working"), + {"type": "command", "command": "echo keep", "timeout": 10} + ]}], + "Stop": [{"hooks": [hook("idle")]}], + "Interrupt": [{"hooks": [hook("idle")]}] + } + }); + fs::write( + codex_dir.join("hooks.json"), + serde_json::to_string(&hooks).unwrap(), + ) + .unwrap(); + std::env::set_var("HOME", &home); + + install_codex().unwrap(); + + let hooks: Value = + serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap()).unwrap(); + assert_eq!(hooks["hooks"]["SessionStart"].as_array().unwrap().len(), 1); + assert_eq!( + hooks["hooks"]["UserPromptSubmit"][0]["hooks"], + json!([{"type": "command", "command": "echo keep", "timeout": 10}]) + ); + assert!(hooks["hooks"].get("Stop").is_none()); + assert!(hooks["hooks"].get("Interrupt").is_none()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + #[test] fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() { let _lock = integration_env_lock(); @@ -1444,7 +1615,8 @@ fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() { ]}], "PreToolUse": [{"hooks": [{"type": "command", "command": format!("bash '{}' working", hook_path.display()), "timeout": 10}]}], "PermissionRequest": [{"hooks": [{"type": "command", "command": format!("bash '{}' blocked", hook_path.display()), "timeout": 10}]}], - "Stop": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}] + "Stop": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}], + "Interrupt": [{"hooks": [{"type": "command", "command": format!("bash '{}' idle", hook_path.display()), "timeout": 10}]}] } }); fs::write( @@ -1471,6 +1643,7 @@ fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() { assert!(hooks["hooks"].get("PreToolUse").is_none()); assert!(hooks["hooks"].get("PermissionRequest").is_none()); assert!(hooks["hooks"].get("Stop").is_none()); + assert!(hooks["hooks"].get("Interrupt").is_none()); assert_eq!( hooks["hooks"]["UserPromptSubmit"][0]["hooks"] .as_array() @@ -2910,6 +3083,283 @@ fn install_hermes_converts_inline_enabled_list_to_block_list() { ); } +#[test] +fn install_hermes_preserves_indentless_enabled_list() { + let input = "plugins:\n enabled:\n - example-plugin\n disabled:\n - other-plugin\n"; + let actual = update_hermes_enabled_plugin(input, true); + assert_eq!( + actual, + "plugins:\n enabled:\n - herdr-agent-state\n - example-plugin\n disabled:\n - other-plugin\n", + "new plugin must share the existing sequence indentation" + ); +} + +#[test] +fn hermes_block_sequences_preserve_indentation_comments_and_siblings() { + for key_indent in [2, 4] { + for item_indent in [key_indent, key_indent + 2, key_indent + 4] { + let key_padding = " ".repeat(key_indent); + let item_padding = " ".repeat(item_indent); + let header = format!("plugins:\n{key_padding}enabled: # selected plugins\n"); + let tail = format!( + "{item_padding}# existing plugin\n{item_padding}- 'example-plugin' # keep\n\n{key_padding}disabled:\n{key_padding}- herdr-agent-state\nmodel: example" + ); + let input = format!("{header}{tail}"); + let expected = format!("{header}{item_padding}- herdr-agent-state\n{tail}"); + let installed = update_hermes_enabled_plugin(&input, true); + assert_eq!(installed, expected); + assert_eq!(update_hermes_enabled_plugin(&installed, true), installed); + assert_eq!(update_hermes_enabled_plugin(&installed, false), input); + assert_eq!(update_hermes_enabled_plugin(&input, false), input); + } + } + for indent in [0, 2, 4] { + let padding = " ".repeat(indent); + let input = format!( + "plugins: # selected\n# keep comment\n{padding}- other-plugin\nmodel: example\n" + ); + let installed = update_hermes_enabled_plugin(&input, true); + assert_eq!(installed, format!("plugins: # selected\n{padding}- herdr-agent-state\n# keep comment\n{padding}- other-plugin\nmodel: example\n")); + assert_eq!(update_hermes_enabled_plugin(&installed, true), installed); + assert_eq!(update_hermes_enabled_plugin(&installed, false), input); + } +} + +#[test] +fn hermes_sequence_membership_is_scoped_to_direct_enabled_items() { + for input in [ + "plugins:\n disabled:\n - herdr-agent-state\n enabled:\n - other\n", + "plugins:\n enabled:\n - options:\n - herdr-agent-state\n", + ] { + let expected = input.replacen( + " enabled:\n", + if input.contains("options:") { + " enabled:\n - herdr-agent-state\n" + } else { + " enabled:\n - herdr-agent-state\n" + }, + 1, + ); + let installed = update_hermes_enabled_plugin(input, true); + assert_eq!(installed, expected); + assert_eq!(update_hermes_enabled_plugin(&installed, false), input); + assert_eq!(update_hermes_enabled_plugin(input, false), input); + } +} + +#[test] +fn install_hermes_does_not_treat_disabled_as_a_flat_plugin_list() { + let input = "plugins:\n disabled:\n - herdr-agent-state\nmodel: example\n"; + let installed = update_hermes_enabled_plugin(input, true); + assert_eq!(installed, "plugins:\n enabled:\n - herdr-agent-state\n disabled:\n - herdr-agent-state\nmodel: example\n"); + assert_eq!(update_hermes_enabled_plugin(&installed, true), installed); + let uninstalled = update_hermes_enabled_plugin(&installed, false); + assert_eq!( + uninstalled, + "plugins:\n enabled: []\n disabled:\n - herdr-agent-state\nmodel: example\n" + ); + assert_eq!( + update_hermes_enabled_plugin(&uninstalled, false), + uninstalled + ); +} + +#[test] +fn uninstall_hermes_keeps_empty_sequences_and_removes_duplicate_direct_items() { + for (input, expected) in [ + ("plugins:\n enabled: # keep\n - 'herdr-agent-state'\n - herdr-agent-state # duplicate\n disabled: []\n", "plugins:\n enabled: [] # keep\n disabled: []\n"), + ("plugins: # keep\n- herdr-agent-state\n", "plugins: [] # keep\n"), + ("plugins:\n enabled: [herdr-agent-state, herdr-agent-state] # keep\n", "plugins:\n enabled: [] # keep\n"), + ] { + let uninstalled = update_hermes_enabled_plugin(input, false); + assert_eq!(uninstalled, expected); + assert_eq!(update_hermes_enabled_plugin(&uninstalled, false), uninstalled); + } +} + +#[test] +fn uninstall_hermes_preserves_bare_sequence_items() { + for (header, indent) in [ + ("plugins:\n", ""), + ("plugins:\n", " "), + ("plugins:\n enabled:\n", " "), + ("plugins:\n enabled:\n", " "), + ] { + for indicator in ["-", "- ", "- # keep"] { + let remaining = format!("{indent}{indicator}\n{indent} other-plugin\n"); + for plugin_first in [true, false] { + let plugin = format!("{indent}- herdr-agent-state\n"); + let input = if plugin_first { + format!("{header}{plugin}{remaining}model: example\n") + } else { + format!("{header}{remaining}{plugin}model: example\n") + }; + let expected = format!("{header}{remaining}model: example\n"); + let uninstalled = update_hermes_enabled_plugin(&input, false); + assert_eq!(uninstalled, expected, "input: {input}"); + assert_eq!(update_hermes_enabled_plugin(&uninstalled, false), expected); + let installed = update_hermes_enabled_plugin(&uninstalled, true); + assert_eq!( + installed, + format!("{header}{plugin}{remaining}model: example\n") + ); + } + } + } +} + +#[test] +fn uninstall_hermes_preserves_bare_item_in_config_file() { + let _lock = integration_env_lock(); + let base = unique_base(); + fs::create_dir_all(&base).unwrap(); + std::env::set_var(HERMES_HOME_ENV_VAR, &base); + let config_path = base.join("config.yaml"); + let remaining = + "plugins:\n enabled:\n -\n other-plugin\n disabled:\n - disabled-plugin\n"; + fs::write(&config_path, remaining).unwrap(); + install_hermes().unwrap(); + + let result = uninstall_hermes().unwrap(); + assert!(result.removed_plugin_dir); + assert!(result.updated_config); + assert_eq!(fs::read_to_string(&config_path).unwrap(), remaining); + assert!(!uninstall_hermes().unwrap().updated_config); + + std::env::remove_var(HERMES_HOME_ENV_VAR); + fs::remove_dir_all(base).unwrap(); +} + +#[test] +fn uninstall_hermes_preserves_empty_sequence_node_properties() { + for properties in [ + "&selected", + "!!seq &selected", + "&selected !!seq", + "! &selected", + ] { + for (header, indent, alias) in [ + ("plugins:", " ", "other: *selected\n"), + ("plugins:\n enabled:", " ", " disabled: *selected\n"), + ] { + let input = + format!("{header} {properties} # keep\n{indent}- herdr-agent-state\n{alias}"); + let expected = format!("{header} {properties} [] # keep\n{alias}"); + let uninstalled = update_hermes_enabled_plugin(&input, false); + assert_eq!(uninstalled, expected); + assert_eq!(update_hermes_enabled_plugin(&uninstalled, false), expected); + assert_eq!(update_hermes_enabled_plugin(&uninstalled, true), input); + + let flow = + format!("{header} {properties} [herdr-agent-state, other-plugin] # keep\n{alias}"); + assert_eq!( + update_hermes_enabled_plugin(&flow, false), + format!("{header} {properties} # keep\n{indent}- other-plugin\n{alias}") + ); + } + } +} + +#[test] +fn hermes_bare_plugin_items_use_the_continued_scalar() { + for (header, indent) in [ + ("plugins:\n", ""), + ("plugins:\n", " "), + ("plugins:\n enabled:\n", " "), + ("plugins:\n enabled:\n", " "), + ] { + for indicator in ["-", "- ", "- # plugin"] { + for value in [ + "herdr-agent-state", + "'herdr-agent-state'", + "\"herdr-agent-state\" # installed", + ] { + let plugin = format!("{indent}{indicator}\n{indent} {value}\n"); + let other = format!("{indent}-\n{indent} other-plugin\n"); + for plugin_first in [true, false] { + let items = if plugin_first { + format!("{plugin}{other}") + } else { + format!("{other}{plugin}") + }; + let input = format!("{header}{items}model: example\n"); + assert_eq!(update_hermes_enabled_plugin(&input, true), input); + let expected = format!("{header}{other}model: example\n"); + assert_eq!(update_hermes_enabled_plugin(&input, false), expected); + assert_eq!(update_hermes_enabled_plugin(&expected, false), expected); + } + } + } + } + let input = "plugins:\n enabled:\n - # plugin\n # keep comment\n\n herdr-agent-state\n - herdr-agent-state # duplicate\n # keep trailing comment\n disabled: []\n"; + assert_eq!( + update_hermes_enabled_plugin(input, false), + "plugins:\n enabled: []\n # keep comment\n\n # keep trailing comment\n disabled: []\n" + ); +} + +#[test] +fn hermes_does_not_match_scalar_prefixes_or_nested_continuations() { + for item in [ + " - herdr-agent-state\n other\n", + " -\n herdr-agent-state\n other\n", + " -\n - herdr-agent-state\n", + ] { + let input = format!("plugins:\n enabled:\n{item} disabled: []\n"); + assert_eq!(update_hermes_enabled_plugin(&input, false), input); + let installed = update_hermes_enabled_plugin(&input, true); + assert_eq!( + installed, + format!("plugins:\n enabled:\n - herdr-agent-state\n{item} disabled: []\n") + ); + assert_eq!(update_hermes_enabled_plugin(&installed, false), input); + } +} + +#[test] +fn hermes_flow_sequence_edits_keep_following_comments() { + for (input, expected) in [ + ("plugins: [other] # selected\n# keep following comment\nmodel: example\n", "plugins: # selected\n - herdr-agent-state\n - other\n# keep following comment\nmodel: example\n"), + ("plugins:\n enabled: [other] # selected\n # keep following comment\n disabled: []\n", "plugins:\n enabled: # selected\n - herdr-agent-state\n - other\n # keep following comment\n disabled: []\n"), + ] { + assert_eq!(update_hermes_enabled_plugin(input, true), expected); + assert_eq!(update_hermes_enabled_plugin(expected, true), expected); + } +} + +#[test] +fn hermes_install_and_uninstall_are_idempotent_with_indentless_config() { + let _lock = integration_env_lock(); + let base = unique_base(); + fs::create_dir_all(&base).unwrap(); + std::env::set_var(HERMES_HOME_ENV_VAR, &base); + let input = "plugins:\n enabled:\n - example-plugin\n disabled:\n - other-plugin\n"; + let config_path = base.join("config.yaml"); + fs::write(&config_path, input).unwrap(); + + let installed = install_hermes().unwrap(); + let first = fs::read_to_string(&config_path).unwrap(); + assert_eq!(first, "plugins:\n enabled:\n - herdr-agent-state\n - example-plugin\n disabled:\n - other-plugin\n"); + assert!(installed + .plugin_dir + .join(HERMES_PLUGIN_INIT_INSTALL_NAME) + .is_file()); + install_hermes().unwrap(); + assert_eq!(fs::read_to_string(&config_path).unwrap(), first); + + let uninstalled = uninstall_hermes().unwrap(); + assert!(uninstalled.removed_plugin_dir); + assert!(uninstalled.updated_config); + assert_eq!(fs::read_to_string(&config_path).unwrap(), input); + let again = uninstall_hermes().unwrap(); + assert!(!again.removed_plugin_dir); + assert!(!again.updated_config); + assert_eq!(fs::read_to_string(&config_path).unwrap(), input); + + std::env::remove_var(HERMES_HOME_ENV_VAR); + fs::remove_dir_all(base).unwrap(); +} + #[test] fn install_hermes_preserves_quoted_inline_enabled_items() { let config = @@ -3549,7 +3999,7 @@ fn install_qwen_writes_session_hook_and_preserves_settings() { assert!(settings.get("permissions").is_some()); let hook_asset = fs::read_to_string(&installed.hook_path).unwrap(); assert!(hook_asset.contains("HERDR_INTEGRATION_ID=qwen")); - assert!(hook_asset.contains("HERDR_INTEGRATION_VERSION=1")); + assert!(hook_asset.contains("HERDR_INTEGRATION_VERSION=2")); assert!(hook_asset.contains("herdr:qwen")); install_qwen().unwrap(); @@ -3927,7 +4377,7 @@ fn install_cursor_uses_cursor_config_dir_env() { } #[test] -fn cursor_v1_integration_status_is_current() { +fn cursor_v1_integration_status_is_outdated() { let _lock = integration_env_lock(); let base = unique_base(); let cursor_dir = base.join(".cursor"); @@ -3945,8 +4395,9 @@ fn cursor_v1_integration_status_is_current() { .iter() .find(|status| status.target == crate::api::schema::IntegrationTarget::Cursor) .expect("cursor integration status"); - assert_eq!(cursor.state, IntegrationStatusKind::Current); - assert_eq!(cursor.installed_version, Some(CURSOR_INTEGRATION_VERSION)); + assert_eq!(cursor.installed_version, Some(1)); + assert_eq!(cursor.expected_version, CURSOR_INTEGRATION_VERSION); + assert_eq!(cursor.state, IntegrationStatusKind::Outdated); clear_integration_path_env(); let _ = fs::remove_dir_all(base); @@ -4438,7 +4889,7 @@ fn install_antigravity_cli_writes_hook_and_updates_hooks_json() { } #[test] -fn antigravity_cli_v2_install_is_outdated_until_reinstalled() { +fn antigravity_cli_v3_install_is_outdated_until_reinstalled() { let _lock = integration_env_lock(); let base = unique_base(); let agy_dir = base.join(".gemini").join("config"); @@ -4447,7 +4898,7 @@ fn antigravity_cli_v2_install_is_outdated_until_reinstalled() { fs::write( hook_dir.join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME), ANTIGRAVITY_CLI_HOOK_ASSET - .replace("HERDR_INTEGRATION_VERSION=3", "HERDR_INTEGRATION_VERSION=2"), + .replace("HERDR_INTEGRATION_VERSION=4", "HERDR_INTEGRATION_VERSION=3"), ) .unwrap(); std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); @@ -4460,8 +4911,8 @@ fn antigravity_cli_v2_install_is_outdated_until_reinstalled() { }; let outdated = status(); assert_eq!(outdated.state, IntegrationStatusKind::Outdated); - assert_eq!(outdated.installed_version, Some(2)); - assert_eq!(outdated.expected_version, 3); + assert_eq!(outdated.installed_version, Some(3)); + assert_eq!(outdated.expected_version, 4); install_antigravity_cli().unwrap(); assert_eq!(status().state, IntegrationStatusKind::Current); diff --git a/src/ipc.rs b/src/ipc.rs index 291b6ca2..f740b17f 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -47,7 +47,7 @@ pub(crate) fn connect_local_stream(path: &Path) -> io::Result { let name = path.to_string_lossy().to_string(); let name = name.to_ns_name::()?; - LocalStream::connect(name) + LocalStream::connect(name).map_err(crate::platform::local_server_connection_error) } } @@ -66,12 +66,14 @@ pub(crate) fn bind_local_listener(path: &Path) -> io::Result { #[cfg(windows)] { use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions}; + use interprocess::os::windows::local_socket::ListenerOptionsExt as _; let name = path.to_string_lossy().to_string(); let name = name.to_ns_name::()?; let listener = ListenerOptions::new() .name(name) .reclaim_name(false) + .security_descriptor(crate::platform::local_server_security_descriptor()?) .create_sync()?; fs::write(path, windows_socket_marker())?; Ok(listener) diff --git a/src/layout.rs b/src/layout.rs index 5ce72250..aaa207ae 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -129,6 +129,29 @@ impl TileLayout { result } + /// Compute pane rects as they will be after splitting `target`, without + /// changing the layout. Returns the rects and the new pane's index; the + /// new pane's entry carries `target`'s id. + pub fn panes_after_split( + &self, + area: Rect, + target: PaneId, + direction: Direction, + ratio: f32, + ) -> Option<(Vec, usize)> { + let mut panes = self.panes(area); + let index = panes.iter().position(|info| info.id == target)?; + let (first, second) = split_rect(panes[index].rect, direction, valid_split_ratio(ratio)); + panes[index].rect = first; + panes[index].inner_rect = first; + let mut new_pane = panes[index].clone(); + new_pane.rect = second; + new_pane.inner_rect = second; + new_pane.is_focused = false; + panes.insert(index + 1, new_pane); + Some((panes, index + 1)) + } + /// Collect all split boundaries for mouse drag resize. pub fn splits(&self, area: Rect) -> Vec { let mut result = Vec::new(); @@ -602,7 +625,7 @@ fn split_node(target: PaneId, direction: Direction, new_id: PaneId, split_ratio: } } -fn valid_split_ratio(ratio: f32) -> f32 { +pub(crate) fn valid_split_ratio(ratio: f32) -> f32 { if ratio.is_finite() { ratio.clamp(0.1, 0.9) } else { @@ -789,6 +812,37 @@ mod tests { ); } + #[test] + fn panes_after_split_predicts_the_real_split_geometry() { + let area = Rect::new(3, 1, 121, 37); + let (mut layout, root) = TileLayout::new(); + let right = layout.split_pane(root, Direction::Horizontal, 0.6).unwrap(); + let bottom_left = layout.split_pane(root, Direction::Vertical, 0.4).unwrap(); + for (target, direction, ratio) in [ + (bottom_left, Direction::Horizontal, 0.5), + (right, Direction::Vertical, 0.3), + (root, Direction::Horizontal, f32::NAN), + ] { + let (predicted, new_index) = layout + .panes_after_split(area, target, direction, ratio) + .unwrap(); + let new_id = layout.split_pane(target, direction, ratio).unwrap(); + let actual: Vec<_> = layout + .panes(area) + .into_iter() + .map(|info| info.rect) + .collect(); + assert_eq!( + predicted.iter().map(|info| info.rect).collect::>(), + actual + ); + assert_eq!(layout.pane_ids()[new_index], new_id); + } + assert!(layout + .panes_after_split(area, PaneId::alloc(), Direction::Vertical, 0.5) + .is_none()); + } + #[test] #[ignore = "manual BSP allocation and traversal scaling profile"] fn bsp_layout_profile() { diff --git a/src/main.rs b/src/main.rs index 64c9ee99..d665e352 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ use ghostty_vt::pane_graphics_files; mod persist; mod platform; mod plugin_command; +mod plugin_installations; mod plugin_paths; mod popup_size; mod product_announcements; @@ -57,6 +58,7 @@ mod terminal_effects; mod terminal_modes; mod terminal_notify; mod terminal_theme; +mod thread_spawn; mod ui; mod update; mod workspace; @@ -223,6 +225,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Size of the virtual terminal used when no client is attached. # Attached clients always use their own terminal size. [server] +# Windows only: allow ordinary same-account clients to control an elevated server. +# Requires a server restart. +# allow_unelevated_clients = false # headless_cols = 120 # headless_rows = 40 @@ -512,6 +517,10 @@ fn main() -> io::Result<()> { std::process::exit(2); } }; + #[cfg(windows)] + if let Some(result) = platform::maybe_activate_desktop_notification(&raw_args) { + return result; + } if let Some(outcome) = cli::maybe_run_machine(&raw_args) { return finish_cli(outcome); } diff --git a/src/pane.rs b/src/pane.rs index 8fd8e396..f80baeb8 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -25,6 +25,7 @@ use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyIoActorHandle, PtyReadR use crate::render_signal::RenderSignal; mod agent_detection; +mod background_agent; mod cursor; mod input; mod kitty_keyboard; @@ -37,9 +38,12 @@ use self::agent_detection::{ codex_prompt_ready, decide_detection_screen_read, decide_screen_detection_publish, detection_update_for_publish_with_osc, mark_detection_content_changed, observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision, - DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput, - AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, + DetectionScreenReadInput, DetectionTextCache, PendingIdleConfirmation, + ScreenDetectionPublishInput, AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, }; +use self::background_agent::{AgentJobStatus, AgentJobTracker}; +#[cfg(test)] +pub(crate) use self::terminal::test_encode_key_for_app; #[cfg(unix)] pub use self::terminal::InputState; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; @@ -66,7 +70,11 @@ const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration:: const TERMINAL_COMPRESSION_IDLE: std::time::Duration = std::time::Duration::from_millis(250); const TERMINAL_COMPRESSION_STEP: std::time::Duration = std::time::Duration::from_millis(1); pub(crate) const PANE_TERM: &str = crate::ghostty::TERM; +/// Smallest terminal grid a pane is resized to. +pub(crate) const MIN_PANE_ROWS: u16 = 2; +pub(crate) const MIN_PANE_COLS: u16 = 4; const PANE_COLORTERM: &str = "truecolor"; +const FISH_HANDLE_REFLOW_ENV_VAR: &str = "fish_handle_reflow"; fn terminal_compression_permits() -> Arc { static PERMITS: OnceLock> = OnceLock::new(); @@ -98,6 +106,16 @@ fn apply_pane_terminal_env(cmd: &mut CommandBuilder) { cmd.env("COLORTERM", PANE_COLORTERM); cmd.env("TERM_PROGRAM", "herdr"); cmd.env("TERM_PROGRAM_VERSION", crate::build_info::version()); + // fish only skips its own resize repaint for terminals it recognizes as + // reflowing. Herdr reflows too, and the competing repaint glues prompt copies. + // Match the exact casing: Windows env keys are case-insensitive here, but + // MSYS/Cygwin fish only reads the lowercase name. + if !cmd + .iter_full_env_as_str() + .any(|(key, _)| key == FISH_HANDLE_REFLOW_ENV_VAR) + { + cmd.env(FISH_HANDLE_REFLOW_ENV_VAR, "0"); + } // Host handles refer to the outer terminal, never to this pane. for key in [ "ITERM_SESSION_ID", @@ -440,6 +458,7 @@ fn foreground_member_cwd_different_from_shell( #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ForegroundShellAgentAction { ObserveProbe, + HoldBackgroundAgent, ReportProcessExit, ReportReplacementProcess, ClearAgent, @@ -450,6 +469,7 @@ fn foreground_shell_agent_action( new_agent: Option, foreground_is_pane_shell: bool, process_exit_reported: bool, + agent_job: AgentJobStatus, ) -> ForegroundShellAgentAction { let Some(previous_agent) = previous_agent else { return ForegroundShellAgentAction::ObserveProbe; @@ -463,9 +483,18 @@ fn foreground_shell_agent_action( ForegroundShellAgentAction::ObserveProbe }; } + if agent_job == AgentJobStatus::ReplacedInFront { + // The held agent's identity must not pass to the new process. + return ForegroundShellAgentAction::ReportProcessExit; + } if new_agent.is_some() { return ForegroundShellAgentAction::ObserveProbe; } + match agent_job { + AgentJobStatus::Background => return ForegroundShellAgentAction::HoldBackgroundAgent, + AgentJobStatus::ExitedInBackground => return ForegroundShellAgentAction::ReportProcessExit, + AgentJobStatus::Unknown | AgentJobStatus::ReplacedInFront => {} + } if foreground_is_pane_shell { // Do not clear identity immediately. First publish an idle process-exit @@ -501,6 +530,11 @@ fn apply_foreground_shell_agent_action( agent_presence.observe_process_probe(previous_agent); true } + ForegroundShellAgentAction::HoldBackgroundAgent => { + *pending_foreground_shell_clear = false; + *foreground_shell_exit_reported = false; + agent_presence.observe_process_probe(previous_agent) + } ForegroundShellAgentAction::ReportProcessExit => { *pending_foreground_shell_clear = true; false @@ -518,6 +552,109 @@ fn apply_foreground_shell_agent_action( } } +/// Process facts used to tell a backgrounded agent from an exited one. +trait AgentProcessLiveness { + fn start_token(&self, pid: u32) -> Option; + fn live_process_group(&self, shell_pid: u32, pid: u32, start_token: u64) -> Option; +} + +struct PlatformAgentProcesses; + +impl AgentProcessLiveness for PlatformAgentProcesses { + fn start_token(&self, pid: u32) -> Option { + crate::platform::process_start_token(pid) + } + + fn live_process_group(&self, shell_pid: u32, pid: u32, start_token: u64) -> Option { + crate::platform::live_pane_process_group(shell_pid, pid, start_token) + } +} + +/// One foreground probe result, after a suppressed agent has been filtered out. +#[derive(Debug, Clone, Copy)] +struct ForegroundAgentObservation { + shell_pid: u32, + agent: Option, + agent_pid: Option, + process_group_id: Option, + foreground_is_pane_shell: bool, +} + +/// Applies one foreground probe to the pane's agent presence. Returns the +/// chosen action and whether the current agent changed. +fn observe_foreground_agent( + agent_presence: &mut AgentDetectionPresence, + agent_job: &mut AgentJobTracker, + pending_foreground_shell_clear: &mut bool, + foreground_shell_exit_reported: &mut bool, + observation: ForegroundAgentObservation, + processes: &impl AgentProcessLiveness, + pane_id: PaneId, +) -> (ForegroundShellAgentAction, bool) { + let previous_agent = agent_presence.current_agent(); + let same_agent_in_front = previous_agent.is_some() + && observation.agent == previous_agent + && !*foreground_shell_exit_reported; + let agent_job_status = if same_agent_in_front + && agent_job.replaced_in_front(observation.agent_pid, |pid| processes.start_token(pid)) + { + AgentJobStatus::ReplacedInFront + } else { + agent_job.status( + previous_agent.is_some() + && observation.agent.is_none() + && !*foreground_shell_exit_reported, + observation.shell_pid, + observation.process_group_id, + |shell_pid, pid, start_token| processes.live_process_group(shell_pid, pid, start_token), + ) + }; + let action = foreground_shell_agent_action( + previous_agent, + observation.agent, + observation.foreground_is_pane_shell, + *foreground_shell_exit_reported, + agent_job_status, + ); + let changed = apply_foreground_shell_agent_action( + agent_presence, + action, + previous_agent, + observation.agent, + pending_foreground_shell_clear, + foreground_shell_exit_reported, + ); + let was_in_background = agent_job.in_background(); + agent_job.observe( + agent_job_status, + agent_presence.current_agent(), + observation.agent, + observation.agent_pid, + |pid| processes.start_token(pid), + ); + if was_in_background != agent_job.in_background() { + info!( + pane = pane_id.raw(), + agent = ?agent_presence.current_agent(), + in_background = agent_job.in_background(), + "agent job foreground changed" + ); + } + (action, changed) +} + +/// Lifecycle-hook authority normally skips steady-state probes, but a held +/// agent is invisible to its hooks once killed, so it keeps the probe cadence. +fn should_check_foreground_process( + lifecycle_authority_active: bool, + agent_held_in_background: bool, + input: ProcessProbeInput, +) -> bool { + (agent_held_in_background + || !should_skip_process_probe_for_lifecycle_authority(lifecycle_authority_active, input)) + && should_probe_foreground_job(input) +} + #[derive(Debug, Clone, Copy)] struct ProcessProbeInput { current_agent: Option, @@ -660,25 +797,28 @@ struct ProcessProbeResult { process_group_id: Option, foreground_is_pane_shell: bool, agent: Option, + /// The process that identified as `agent`, which need not lead its job. + agent_pid: Option, process_name: Option, } fn agent_hint_for_foreground_job_members( job: &crate::platform::ForegroundJob, read_hint: impl Fn(u32) -> Option, -) -> Option { +) -> Option<(Agent, u32)> { read_hint(job.process_group_id) + .map(|agent| (agent, job.process_group_id)) .or_else(|| agent_hint_for_non_leader_foreground_job_members(job, read_hint)) } fn agent_hint_for_non_leader_foreground_job_members( job: &crate::platform::ForegroundJob, read_hint: impl Fn(u32) -> Option, -) -> Option { +) -> Option<(Agent, u32)> { job.processes .iter() .filter(|process| process.pid != job.process_group_id) - .find_map(|process| read_hint(process.pid)) + .find_map(|process| read_hint(process.pid).map(|agent| (agent, process.pid))) } fn identify_process_group_leader_in_job( @@ -698,13 +838,13 @@ fn identify_process_group_leader_in_job( fn process_probe_result( job: &crate::platform::ForegroundJob, pid: u32, - agent: Agent, - process_name: String, + (agent, process_name, agent_pid): (Agent, String, u32), ) -> ProcessProbeResult { ProcessProbeResult { process_group_id: Some(job.process_group_id), foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), agent: Some(agent), + agent_pid: Some(agent_pid), process_name: Some(process_name), } } @@ -712,15 +852,17 @@ fn process_probe_result( fn hinted_process_probe_result( job: &crate::platform::ForegroundJob, pid: u32, - read_hint: impl Fn(u32) -> Option, -) -> Option { - let agent = agent_hint_for_foreground_job_members(job, read_hint)?; - Some(process_probe_result( + (agent, agent_pid): (Agent, u32), +) -> ProcessProbeResult { + process_probe_result( job, pid, - agent, - crate::detect::agent_label(agent).to_string(), - )) + ( + agent, + crate::detect::agent_label(agent).to_string(), + agent_pid, + ), + ) } fn probe_foreground_process_from_jobs( @@ -731,42 +873,34 @@ fn probe_foreground_process_from_jobs( read_hint: impl Fn(u32) -> Option + Copy, ) -> ProcessProbeResult { if let Some(job) = leader_job.as_ref() { - if let Some(hinted) = hinted_process_probe_result(job, pid, read_hint) { - return hinted; + if let Some(hinted) = agent_hint_for_foreground_job_members(job, read_hint) { + return hinted_process_probe_result(job, pid, hinted); } - if let Some((agent, process_name)) = crate::detect::identify_agent_in_job(job) { - return process_probe_result(job, pid, agent, process_name); + if let Some(identified) = crate::detect::identify_agent_process_in_job(job) { + return process_probe_result(job, pid, identified); } } let foreground_job = foreground_job(); if let Some(job) = foreground_job.as_ref() { if let Some(agent) = read_hint(job.process_group_id) { - return process_probe_result( - job, - pid, - agent, - crate::detect::agent_label(agent).to_string(), - ); + return hinted_process_probe_result(job, pid, (agent, job.process_group_id)); } if let Some((agent, process_name)) = identify_process_group_leader_in_job(job) { - return process_probe_result(job, pid, agent, process_name); + return process_probe_result(job, pid, (agent, process_name, job.process_group_id)); } - if let Some(agent) = agent_hint_for_non_leader_foreground_job_members(job, read_hint) { - return process_probe_result( - job, - pid, - agent, - crate::detect::agent_label(agent).to_string(), - ); + if let Some(hinted) = agent_hint_for_non_leader_foreground_job_members(job, read_hint) { + return hinted_process_probe_result(job, pid, hinted); + } + if let Some(identified) = crate::detect::identify_agent_process_in_job(job) { + return process_probe_result(job, pid, identified); } - - let identified = crate::detect::identify_agent_in_job(job); return ProcessProbeResult { process_group_id: Some(job.process_group_id), foreground_is_pane_shell: job.processes.iter().any(|process| process.pid == pid), - agent: identified.as_ref().map(|(agent, _)| *agent), - process_name: identified.map(|(_, process_name)| process_name), + agent: None, + agent_pid: None, + process_name: None, }; } @@ -774,6 +908,7 @@ fn probe_foreground_process_from_jobs( process_group_id: foreground_pgid, foreground_is_pane_shell: false, agent: None, + agent_pid: None, process_name: None, } } @@ -789,10 +924,13 @@ fn probe_foreground_process(pid: u32, foreground_pgid: Option) -> ProcessPr } #[cfg(unix)] +// Handoff detection needs both the write-bracketed text revision and detection epoch. +#[allow(clippy::too_many_arguments)] fn spawn_basic_detection_task( pane_id: PaneId, child_pid: Arc, terminal: Arc, + content_seq: Arc, detection_content_seq: Arc, full_lifecycle_authority_active: Arc, self_reported_agent_active: Arc, @@ -822,12 +960,13 @@ fn spawn_basic_detection_task( let mut pending_foreground_shell_clear = false; let mut foreground_shell_exit_reported = false; let mut release_was_active = false; - let mut last_detection_text = String::new(); + let mut last_detection_text = DetectionTextCache::default(); let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); let mut last_codex_prompt_ready = false; let mut last_self_reported_shell_check = None; + let mut agent_job = AgentJobTracker::default(); loop { let sleep_duration = if pending_idle.active() { @@ -860,6 +999,7 @@ fn spawn_basic_detection_task( last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; pending_idle.clear(); + agent_job = AgentJobTracker::default(); } } @@ -903,10 +1043,11 @@ fn spawn_basic_detection_task( pending_restore_probe: false, elapsed_since_process_check: now.duration_since(last_process_check), }; - !should_skip_process_probe_for_lifecycle_authority( + should_check_foreground_process( lifecycle_authority_active, + agent_job.in_background(), process_probe_input, - ) && should_probe_foreground_job(process_probe_input) + ) }; if should_check_process { @@ -927,19 +1068,20 @@ fn spawn_basic_detection_task( } } let previous_agent = agent_presence.current_agent(); - let foreground_action = foreground_shell_agent_action( - previous_agent, - new_agent, - foreground_is_pane_shell, - foreground_shell_exit_reported, - ); - let changed = apply_foreground_shell_agent_action( + let (foreground_action, changed) = observe_foreground_agent( &mut agent_presence, - foreground_action, - previous_agent, - new_agent, + &mut agent_job, &mut pending_foreground_shell_clear, &mut foreground_shell_exit_reported, + ForegroundAgentObservation { + shell_pid: pid, + agent: new_agent, + agent_pid: new_agent.and(probe.agent_pid), + process_group_id, + foreground_is_pane_shell, + }, + &PlatformAgentProcesses, + pane_id, ); last_foreground_pgid = tracked_process_group_id; if new_agent.is_some() { @@ -989,7 +1131,9 @@ fn spawn_basic_detection_task( && agent.is_some() && !foreground_shell_exit_reported; - if lifecycle_authority_active && !process_exited { + // The visible screen belongs to another job while the agent waits + // in the background, so it says nothing about the agent's state. + if (lifecycle_authority_active || agent_job.in_background()) && !process_exited { pending_idle.clear(); continue; } @@ -1028,15 +1172,15 @@ fn spawn_basic_detection_task( DetectionScreenReadDecision::Skip => continue, } - let content = terminal.detection_text(); + let content_changed = last_detection_text + .refresh(agent, &content_seq, || terminal.detection_text_for_cache()); + let content = &last_detection_text.text; last_screen_scan_detection_content_seq = current_detection_content_seq; - let content_changed = content != last_detection_text; - last_detection_text.clone_from(&content); let osc_title = terminal.agent_osc_title(); let osc_progress = terminal.agent_osc_progress(); let screen_detection = detection_update_for_publish_with_osc( agent, - &content, + content, &osc_title, &osc_progress, process_exited, @@ -1045,7 +1189,7 @@ fn spawn_basic_detection_task( &state_events, pane_id, agent, - &content, + content, screen_detection.as_ref(), process_exited, &mut last_codex_prompt_ready, @@ -2507,6 +2651,7 @@ impl PaneRuntime { pane_id, child_pid.clone(), terminal.clone(), + content_seq.clone(), detection_content_seq.clone(), full_lifecycle_authority_active.clone(), self_reported_agent_active.clone(), @@ -2710,6 +2855,7 @@ impl PaneRuntime { let child_pid = child_pid.clone(); let terminal = terminal.clone(); let state_events = events.clone(); + let text_content_seq = content_seq.clone(); let detection_content_seq = detection_content_seq.clone(); let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone(); let self_reported_agent_active_for_task = self_reported_agent_active.clone(); @@ -2739,12 +2885,13 @@ impl PaneRuntime { let mut last_visible_blocker = false; let mut last_visible_working = false; let mut last_visible_signal_refresh = None; - let mut last_detection_text = String::new(); + let mut last_detection_text = DetectionTextCache::default(); let mut last_screen_scan_detection_content_seq = None; let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); let mut last_codex_prompt_ready = false; let mut last_self_reported_shell_check = None; + let mut agent_job = AgentJobTracker::default(); tokio::time::sleep(Duration::from_millis(50)).await; @@ -2787,6 +2934,7 @@ impl PaneRuntime { last_screen_scan_detection_content_seq = None; agent_startup_grace_until = None; pending_idle.clear(); + agent_job = AgentJobTracker::default(); } } @@ -2856,10 +3004,11 @@ impl PaneRuntime { foreground_pgid, ..process_probe_input }; - !should_skip_process_probe_for_lifecycle_authority( + should_check_foreground_process( lifecycle_authority_active, + agent_job.in_background(), process_probe_input, - ) && should_probe_foreground_job(process_probe_input) + ) }; let mut agent_changed = false; @@ -2889,19 +3038,20 @@ impl PaneRuntime { } let previous_agent = agent_presence.current_agent(); - let foreground_action = foreground_shell_agent_action( - previous_agent, - new_agent, - foreground_is_pane_shell, - foreground_shell_exit_reported, - ); - let changed = apply_foreground_shell_agent_action( + let (foreground_action, changed) = observe_foreground_agent( &mut agent_presence, - foreground_action, - previous_agent, - new_agent, + &mut agent_job, &mut pending_foreground_shell_clear, &mut foreground_shell_exit_reported, + ForegroundAgentObservation { + shell_pid: pid, + agent: new_agent, + agent_pid: new_agent.and(probe.agent_pid), + process_group_id, + foreground_is_pane_shell, + }, + &PlatformAgentProcesses, + pane_id, ); last_foreground_pgid = tracked_process_group_id; if new_agent.is_some() { @@ -2986,7 +3136,10 @@ impl PaneRuntime { && agent.is_some() && !foreground_shell_exit_reported; - if lifecycle_authority_active && !process_exited { + // The visible screen belongs to another job while the agent + // waits in the background, so it says nothing about its state. + if (lifecycle_authority_active || agent_job.in_background()) && !process_exited + { pending_idle.clear(); continue; } @@ -3025,15 +3178,17 @@ impl PaneRuntime { DetectionScreenReadDecision::Skip => continue, } - let content = terminal.detection_text(); + let content_changed = + last_detection_text.refresh(agent, &text_content_seq, || { + terminal.detection_text_for_cache() + }); + let content = &last_detection_text.text; last_screen_scan_detection_content_seq = current_detection_content_seq; - let content_changed = content != last_detection_text; - last_detection_text.clone_from(&content); let osc_title = terminal.agent_osc_title(); let osc_progress = terminal.agent_osc_progress(); let screen_detection = detection_update_for_publish_with_osc( agent, - &content, + content, &osc_title, &osc_progress, process_exited, @@ -3042,7 +3197,7 @@ impl PaneRuntime { &state_events, pane_id, agent, - &content, + content, screen_detection.as_ref(), process_exited, &mut last_codex_prompt_ready, @@ -3184,8 +3339,8 @@ impl PaneRuntime { /// Resize if the dimensions actually changed. pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { - let rows = rows.max(2); - let cols = cols.max(4); + let rows = rows.max(MIN_PANE_ROWS); + let cols = cols.max(MIN_PANE_COLS); let size = (rows, cols, cell_width_px, cell_height_px); if self.current_size.get() == size { return; @@ -3512,14 +3667,28 @@ impl PaneRuntime { } pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { - self.terminal - .encode_terminal_key(key, self.keyboard_protocol()) + self.terminal.encode_terminal_key(key) } pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError> { self.io.try_send_bytes(bytes) } + /// Preflight a complete API command without changing input queue semantics. + pub(crate) fn validate_input_submission(&self, bytes: &[u8]) -> std::io::Result<()> { + match &self.io { + #[cfg(unix)] + PaneRuntimeIo::Actor(actor) => actor.validate_input_submission(bytes), + #[cfg(windows)] + PaneRuntimeIo::Actor(_) => { + let _ = bytes; + Ok(()) + } + #[cfg(test)] + PaneRuntimeIo::TestChannel { .. } => Ok(()), + } + } + pub fn queue_user_input_submission( &self, text: Bytes, @@ -3646,14 +3815,14 @@ impl PaneRuntime { } let pid = self.child_pid.load(Ordering::Relaxed); - crate::platform::process_cwd(pid) + crate::platform::pane_process_cwd(pid) } pub fn cwd_for_persistence(&self) -> Option { let pid = self.child_pid.load(Ordering::Acquire); let exited = self.cwd_process_exited.load(Ordering::Acquire); if let Some(cwd) = (!exited) - .then(|| crate::platform::process_cwd(pid)) + .then(|| crate::platform::pane_process_cwd(pid)) .flatten() .filter(|cwd| cwd.is_absolute()) { @@ -3731,6 +3900,13 @@ impl PaneRuntime { Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], 4) } + #[cfg(unix)] + pub(crate) fn test_with_actor(actor: PtyIoActorHandle) -> Self { + let (mut runtime, _) = Self::test_with_channel(80, 24); + runtime.io = PaneRuntimeIo::Actor(actor); + runtime + } + pub(crate) fn test_with_channel_capacity( cols: u16, rows: u16, @@ -3743,6 +3919,52 @@ impl PaneRuntime { Self::test_with_scrollback_bytes(cols, rows, 0, bytes) } + #[cfg(unix)] + pub(crate) fn test_start_basic_detection(&mut self) -> mpsc::Receiver { + if let Some(handle) = self.detect_handle.take() { + handle.abort(); + } + let (events, receiver) = mpsc::channel(8); + let (handle, reset, release) = spawn_basic_detection_task( + self.pane_id, + self.child_pid.clone(), + self.terminal.clone(), + self.content_seq.clone(), + self.detection_content_seq.clone(), + self.full_lifecycle_authority_active.clone(), + self.self_reported_agent_active.clone(), + events, + ); + self.detect_handle = Some(handle); + self.detect_reset_notify = reset; + self.pending_release = release; + receiver + } + + #[cfg(unix)] + pub(crate) async fn test_wait_for_detection_reads(&self, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while self + .terminal + .ghostty + .detection_text_reads + .load(Ordering::Relaxed) + < expected + { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("detection task should extract fresh text"); + } + + pub(crate) fn test_scroll_metrics_reads(&self) -> usize { + self.terminal + .ghostty + .scroll_metrics_reads + .load(Ordering::Relaxed) + } + pub(crate) fn test_contend_during_dirty_collection( &self, bytes: Vec, @@ -3829,6 +4051,8 @@ impl PaneRuntime { let (resize_tx, _resize_rx) = watch::channel((rows, cols, 0, 0)); let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes).unwrap(); + // Runtime fixtures model the default config, which enables graphics. + terminal.enable_kitty_graphics().unwrap(); terminal.write(bytes); let pane_id = PaneId::from_raw(0); let terminal = Arc::new(PaneTerminal::new( @@ -3872,6 +4096,140 @@ mod tests { use super::*; use std::ffi::OsStr; + #[tokio::test] + async fn unidentified_text_cache_tracks_runtime_mutations_not_viewport_scroll() { + let runtime = PaneRuntime::test_with_scrollback_bytes( + 12, + 3, + 100_000, + b"old\r\nold\r\nabcdefghijklmnop\r\nprompt", + ); + let mut cache = DetectionTextCache::default(); + let reads = Cell::new(0); + let refresh = |cache: &mut DetectionTextCache| { + cache.refresh(None, &runtime.content_seq, || { + reads.set(reads.get() + 1); + runtime.terminal.detection_text_for_cache() + }) + }; + assert!( + refresh(&mut cache), + "seeded history must be read at revision zero" + ); + assert_eq!(reads.get(), 1); + runtime.scroll_up(2); + assert!(!refresh(&mut cache)); + assert_eq!( + reads.get(), + 1, + "viewport scrolling must not rebuild bottom text" + ); + assert_eq!(cache.text, runtime.detection_text()); + + // Control-only and fragmented output still invalidate the physical read, + // but do not manufacture a text change for acquisition scheduling. + runtime.test_process_pty_bytes(b"\x1b]0;title\x07\x1b["); + assert!(!refresh(&mut cache)); + assert_eq!(reads.get(), 2); + runtime.test_process_pty_bytes(b"2J\x1b[Hupdated"); + assert!(refresh(&mut cache)); + assert_eq!(reads.get(), 3); + assert_eq!(cache.text, runtime.detection_text()); + + runtime.resize(4, 8, 0, 0); + refresh(&mut cache); + assert_eq!(reads.get(), 4); + assert_eq!(cache.text, runtime.detection_text()); + runtime.clear_screen().unwrap(); + refresh(&mut cache); + assert_eq!(reads.get(), 5); + assert_eq!(cache.text, runtime.detection_text()); + runtime.test_process_pty_bytes(b"\x1b[?1049halt"); + assert!(refresh(&mut cache)); + assert_eq!(reads.get(), 6); + assert_eq!(cache.text, runtime.detection_text()); + assert!(!refresh(&mut cache)); + assert_eq!(reads.get(), 6); + runtime.test_process_pty_bytes(b"\x1b[?1049l"); + assert!(refresh(&mut cache)); + assert_eq!(reads.get(), 7); + assert_eq!(cache.text, runtime.detection_text()); + assert!(!cache.text.contains("alt")); + assert!(!refresh(&mut cache)); + assert_eq!(reads.get(), 7); + } + + #[cfg(unix)] + #[tokio::test] + async fn unidentified_text_cache_resets_with_adopted_runtime_and_recreation() { + let mut runtime = PaneRuntime::test_with_screen_bytes(20, 4, b"first runtime"); + let _events = runtime.test_start_basic_detection(); + runtime.test_wait_for_detection_reads(1).await; + let revision = runtime.content_seq(); + runtime.reset_agent_detection(); + runtime.test_wait_for_detection_reads(2).await; + assert_eq!( + runtime.content_seq(), + revision, + "reset does not require output" + ); + drop(runtime); + + // A new runtime can reuse both the pane identity and revision zero. Its + // detector must not inherit the previous runtime's text or revision. + let mut recreated = PaneRuntime::test_with_screen_bytes(20, 4, b"second runtime"); + assert_eq!(recreated.content_seq(), revision); + let _events = recreated.test_start_basic_detection(); + recreated.test_wait_for_detection_reads(1).await; + assert!(recreated.visible_text().contains("second runtime")); + } + + #[cfg(unix)] + #[tokio::test] + async fn unidentified_text_cache_resets_in_spawned_runtime_without_output() { + let (events, _event_rx) = mpsc::channel(8); + let runtime = PaneRuntime::spawn_shell_command( + PaneId::from_raw(42), + 4, + 20, + std::env::temp_dir(), + "printf 'cache-ready'; exec sleep 30", + &PaneLaunchEnv::default(), + AgentDetection::Enabled, + 0, + crate::terminal_theme::TerminalTheme::default(), + None, + events, + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ) + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !runtime.visible_text().contains("cache-ready") { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("shell should populate the terminal"); + // The first reset guarantees a populated read even if the initial tick + // preceded shell output. The second proves that a warm cache is cleared. + let revision = { + let _write = runtime.content_write_lock.lock().unwrap(); + runtime.content_seq() + }; + for _ in 0..2 { + let reads = runtime + .terminal + .ghostty + .detection_text_reads + .load(Ordering::Relaxed); + runtime.reset_agent_detection(); + runtime.test_wait_for_detection_reads(reads + 1).await; + } + assert_eq!(runtime.content_seq(), revision); + runtime.shutdown(); + } + #[tokio::test] async fn clear_pane_preserves_wrapped_input_and_unfinished_vt_sequence() { let (runtime, mut rx) = PaneRuntime::test_with_channel_and_scrollback_bytes( @@ -4043,6 +4401,55 @@ mod tests { ); } + #[test] + fn pane_terminal_env_tells_fish_that_herdr_reflows() { + let mut cmd = CommandBuilder::new("shell"); + cmd.env_remove(FISH_HANDLE_REFLOW_ENV_VAR); + apply_pane_terminal_env(&mut cmd); + assert_eq!( + cmd.get_env(FISH_HANDLE_REFLOW_ENV_VAR), + Some(OsStr::new("0")) + ); + } + + #[test] + fn pane_terminal_env_keeps_existing_fish_handle_reflow_entry() { + let mut cmd = CommandBuilder::new("shell"); + cmd.env(FISH_HANDLE_REFLOW_ENV_VAR, "1"); + apply_pane_terminal_env(&mut cmd); + apply_pane_launch_env(&mut cmd, &PaneLaunchEnv::default()); + assert_eq!( + cmd.get_env(FISH_HANDLE_REFLOW_ENV_VAR), + Some(OsStr::new("1")) + ); + } + + #[test] + fn pane_terminal_env_sets_lowercase_fish_handle_reflow_beside_other_casing() { + let mut cmd = CommandBuilder::new("shell"); + cmd.env_remove(FISH_HANDLE_REFLOW_ENV_VAR); + cmd.env("FISH_HANDLE_REFLOW", "1"); + apply_pane_terminal_env(&mut cmd); + assert!(cmd + .iter_full_env_as_str() + .any(|entry| entry == (FISH_HANDLE_REFLOW_ENV_VAR, "0"))); + } + + #[test] + fn pane_launch_env_explicit_fish_handle_reflow_wins() { + let mut explicit = CommandBuilder::new("shell"); + explicit.env_remove(FISH_HANDLE_REFLOW_ENV_VAR); + apply_pane_terminal_env(&mut explicit); + apply_pane_launch_env( + &mut explicit, + &PaneLaunchEnv::from_extra(vec![(FISH_HANDLE_REFLOW_ENV_VAR.into(), "1".into())]), + ); + assert_eq!( + explicit.get_env(FISH_HANDLE_REFLOW_ENV_VAR), + Some(OsStr::new("1")) + ); + } + #[test] fn pane_launch_env_allows_explicit_session_identity() { let extra = vec![ @@ -5094,11 +5501,23 @@ mod tests { #[test] fn foreground_shell_reports_process_exit_before_clearing_agent() { assert_eq!( - foreground_shell_agent_action(Some(Agent::Codex), None, true, false), + foreground_shell_agent_action( + Some(Agent::Codex), + None, + true, + false, + AgentJobStatus::Unknown + ), ForegroundShellAgentAction::ReportProcessExit ); assert_eq!( - foreground_shell_agent_action(Some(Agent::Codex), None, true, true), + foreground_shell_agent_action( + Some(Agent::Codex), + None, + true, + true, + AgentJobStatus::Unknown + ), ForegroundShellAgentAction::ClearAgent ); } @@ -5106,7 +5525,13 @@ mod tests { #[test] fn same_agent_after_reported_exit_is_a_replacement_process() { assert_eq!( - foreground_shell_agent_action(Some(Agent::Pi), Some(Agent::Pi), false, true), + foreground_shell_agent_action( + Some(Agent::Pi), + Some(Agent::Pi), + false, + true, + AgentJobStatus::Unknown + ), ForegroundShellAgentAction::ReportReplacementProcess ); } @@ -5114,11 +5539,316 @@ mod tests { #[test] fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() { assert_eq!( - foreground_shell_agent_action(Some(Agent::Claude), None, false, false), + foreground_shell_agent_action( + Some(Agent::Claude), + None, + false, + false, + AgentJobStatus::Unknown + ), ForegroundShellAgentAction::ObserveProbe ); } + #[test] + fn shell_in_front_of_a_live_agent_job_holds_the_agent() { + for foreground_is_pane_shell in [true, false] { + assert_eq!( + foreground_shell_agent_action( + Some(Agent::Claude), + None, + foreground_is_pane_shell, + false, + AgentJobStatus::Background + ), + ForegroundShellAgentAction::HoldBackgroundAgent + ); + } + } + + #[test] + fn held_agent_job_exit_is_reported_behind_any_foreground_job() { + for foreground_is_pane_shell in [true, false] { + assert_eq!( + foreground_shell_agent_action( + Some(Agent::Pi), + None, + foreground_is_pane_shell, + false, + AgentJobStatus::ExitedInBackground + ), + ForegroundShellAgentAction::ReportProcessExit + ); + } + } + + /// Drives `observe_foreground_agent` the way the detection loops do, against + /// a fake process table of `pid -> (start token, process group)`. + mod held_agent { + use super::*; + use std::cell::RefCell; + use std::collections::HashMap; + + pub(super) const SHELL: u32 = 10; + pub(super) const WRAPPER_JOB: u32 = 20; + pub(super) const AGENT_PID: u32 = 21; + const AGENT_START: u64 = 7_000; + + #[derive(Default)] + pub(super) struct FakeProcesses(RefCell>); + + impl FakeProcesses { + pub(super) fn spawn(&self, pid: u32, start_token: u64, group: u32) { + self.0.borrow_mut().insert(pid, (start_token, group)); + } + + pub(super) fn exit(&self, pid: u32) { + self.0.borrow_mut().remove(&pid); + } + } + + impl AgentProcessLiveness for FakeProcesses { + fn start_token(&self, pid: u32) -> Option { + self.0 + .borrow() + .get(&pid) + .map(|(start_token, _)| *start_token) + } + + fn live_process_group( + &self, + shell_pid: u32, + pid: u32, + start_token: u64, + ) -> Option { + assert_eq!(shell_pid, SHELL); + self.0 + .borrow() + .get(&pid) + .filter(|(token, _)| *token == start_token) + .map(|(_, group)| *group) + } + } + + pub(super) struct Pane { + pub(super) presence: AgentDetectionPresence, + pub(super) job: AgentJobTracker, + pub(super) processes: FakeProcesses, + pending_clear: bool, + exit_reported: bool, + } + + impl Pane { + /// An agent process `AGENT_PID` under a wrapper that leads its job. + pub(super) fn with_agent(agent: Agent) -> Self { + let processes = FakeProcesses::default(); + processes.spawn(WRAPPER_JOB, 6_000, WRAPPER_JOB); + processes.spawn(AGENT_PID, AGENT_START, WRAPPER_JOB); + let mut pane = Self { + presence: AgentDetectionPresence::from_agent(None), + job: AgentJobTracker::default(), + processes, + pending_clear: false, + exit_reported: false, + }; + let (_, acquired) = pane.probe(Some(agent), WRAPPER_JOB); + assert!(acquired); + pane + } + + pub(super) fn probe( + &mut self, + agent: Option, + foreground_group: u32, + ) -> (ForegroundShellAgentAction, bool) { + self.probe_process(agent, AGENT_PID, foreground_group) + } + + pub(super) fn probe_process( + &mut self, + agent: Option, + agent_pid: u32, + foreground_group: u32, + ) -> (ForegroundShellAgentAction, bool) { + let result = observe_foreground_agent( + &mut self.presence, + &mut self.job, + &mut self.pending_clear, + &mut self.exit_reported, + ForegroundAgentObservation { + shell_pid: SHELL, + agent, + agent_pid: agent.map(|_| agent_pid), + process_group_id: Some(foreground_group), + foreground_is_pane_shell: foreground_group == SHELL, + }, + &self.processes, + PaneId::from_raw(1), + ); + // The loop publishes the pending exit before the next probe. + if self.pending_clear { + self.exit_reported = true; + } + result + } + + pub(super) fn assert_held_while_shell_in_front(&mut self) { + for _ in 0..(AGENT_MISS_CONFIRMATION_ATTEMPTS + 2) { + assert_eq!( + self.probe(None, SHELL), + (ForegroundShellAgentAction::HoldBackgroundAgent, false) + ); + } + assert!(self.job.in_background()); + } + + pub(super) fn assert_exit_reported_then_cleared(&mut self) { + assert_eq!( + self.probe(None, SHELL), + (ForegroundShellAgentAction::ReportProcessExit, false) + ); + assert_eq!( + self.probe(None, SHELL), + (ForegroundShellAgentAction::ClearAgent, true) + ); + assert_eq!(self.presence.current_agent(), None); + assert!(!self.job.in_background()); + } + } + } + + #[test] + fn suspend_command_and_resume_publish_no_agent_transition() { + let mut pane = held_agent::Pane::with_agent(Agent::Claude); + + // ctrl-z: the shell is in front, the stopped agent is alive. + pane.assert_held_while_shell_in_front(); + // A plain command runs in front of the stopped agent. + for _ in 0..(AGENT_MISS_CONFIRMATION_ATTEMPTS + 2) { + assert_eq!( + pane.probe(None, 30), + (ForegroundShellAgentAction::HoldBackgroundAgent, false) + ); + } + // fg: the same agent is back in front. + assert_eq!( + pane.probe(Some(Agent::Claude), held_agent::WRAPPER_JOB), + (ForegroundShellAgentAction::ObserveProbe, false) + ); + assert_eq!(pane.presence.current_agent(), Some(Agent::Claude)); + assert!(!pane.job.in_background()); + + // A real exit afterwards still reports and clears. + pane.processes.exit(held_agent::AGENT_PID); + pane.processes.exit(held_agent::WRAPPER_JOB); + pane.assert_exit_reported_then_cleared(); + } + + #[test] + fn agent_killed_while_suspended_reports_its_exit() { + let mut pane = held_agent::Pane::with_agent(Agent::Pi); + pane.assert_held_while_shell_in_front(); + + // `kill -9 %1`: the whole stopped job is gone. + pane.processes.exit(held_agent::AGENT_PID); + pane.processes.exit(held_agent::WRAPPER_JOB); + + pane.assert_exit_reported_then_cleared(); + } + + #[test] + fn held_agent_exit_is_reported_while_its_wrapper_survives() { + let mut pane = held_agent::Pane::with_agent(Agent::Pi); + pane.assert_held_while_shell_in_front(); + + // The agent dies; the wrapper that leads its job lives on. + pane.processes.exit(held_agent::AGENT_PID); + + pane.assert_exit_reported_then_cleared(); + } + + #[test] + fn held_agent_pid_reused_by_another_process_reports_exit() { + let mut pane = held_agent::Pane::with_agent(Agent::Claude); + pane.assert_held_while_shell_in_front(); + + // The agent exits and an unrelated process in the same job gets its pid. + pane.processes.exit(held_agent::AGENT_PID); + pane.processes + .spawn(held_agent::AGENT_PID, 9_000, held_agent::WRAPPER_JOB); + + pane.assert_exit_reported_then_cleared(); + } + + #[test] + fn same_kind_agent_started_in_front_of_a_held_one_replaces_it() { + let mut pane = held_agent::Pane::with_agent(Agent::Claude); + pane.assert_held_while_shell_in_front(); + + // A second Claude starts in front of the suspended one. + pane.processes.spawn(31, 9_000, 30); + assert_eq!( + pane.probe_process(Some(Agent::Claude), 31, 30), + (ForegroundShellAgentAction::ReportProcessExit, false) + ); + assert_eq!( + pane.probe_process(Some(Agent::Claude), 31, 30), + (ForegroundShellAgentAction::ReportReplacementProcess, true) + ); + assert!(!pane.job.in_background()); + } + + #[test] + fn same_kind_agent_reusing_the_held_pid_replaces_it() { + let mut pane = held_agent::Pane::with_agent(Agent::Claude); + pane.assert_held_while_shell_in_front(); + + pane.processes.exit(held_agent::AGENT_PID); + pane.processes.spawn(held_agent::AGENT_PID, 9_000, 30); + + assert_eq!( + pane.probe(Some(Agent::Claude), 30), + (ForegroundShellAgentAction::ReportProcessExit, false) + ); + assert_eq!( + pane.probe(Some(Agent::Claude), 30), + (ForegroundShellAgentAction::ReportReplacementProcess, true) + ); + } + + #[test] + fn held_agent_under_hook_authority_is_reprobed_on_the_identified_cadence() { + let mut pane = held_agent::Pane::with_agent(Agent::Pi); + pane.assert_held_while_shell_in_front(); + pane.processes.exit(held_agent::AGENT_PID); + pane.processes.exit(held_agent::WRAPPER_JOB); + + // Detection ticks every 300 ms; lifecycle hooks own the pane, and the + // foreground group stays the shell's, so only the cadence can probe. + let tick = std::time::Duration::from_millis(300); + let mut since_check = std::time::Duration::ZERO; + loop { + since_check += tick; + assert!( + since_check <= PROCESS_RECHECK_IDENTIFIED + tick, + "never reprobed" + ); + let input = ProcessProbeInput { + current_agent: pane.presence.current_agent(), + foreground_pgid: Some(held_agent::SHELL), + last_foreground_pgid: Some(held_agent::SHELL), + elapsed_since_process_check: since_check, + ..process_probe_input() + }; + if should_check_foreground_process(true, pane.job.in_background(), input) { + break; + } + } + + assert!(since_check >= PROCESS_RECHECK_IDENTIFIED); + pane.assert_exit_reported_then_cleared(); + } + #[tokio::test] async fn first_agent_acquisition_keeps_osc_evidence_replacement_clears_it() { let runtime = PaneRuntime::test_with_screen_bytes(80, 24, b""); @@ -5136,7 +5866,13 @@ mod tests { #[test] fn reported_process_exit_clears_before_unknown_foreground_probe() { assert_eq!( - foreground_shell_agent_action(Some(Agent::Claude), None, false, true), + foreground_shell_agent_action( + Some(Agent::Claude), + None, + false, + true, + AgentJobStatus::Unknown + ), ForegroundShellAgentAction::ClearAgent ); } @@ -5144,7 +5880,13 @@ mod tests { #[test] fn foreground_agent_job_is_not_clear_signal() { assert_eq!( - foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false,), + foreground_shell_agent_action( + Some(Agent::Claude), + Some(Agent::OpenCode), + true, + false, + AgentJobStatus::Unknown + ), ForegroundShellAgentAction::ObserveProbe ); } @@ -5170,7 +5912,7 @@ mod tests { agent_hint_for_foreground_job_members(&job, |pid| { (pid == 42).then_some(Agent::Claude) }), - Some(Agent::Claude) + Some((Agent::Claude, 42)) ); } @@ -5188,7 +5930,7 @@ mod tests { agent_hint_for_foreground_job_members(&job, |pid| { (pid == 100).then_some(Agent::Codex) }), - Some(Agent::Codex) + Some((Agent::Codex, 100)) ); } @@ -5249,9 +5991,27 @@ mod tests { ); assert_eq!(result.agent, Some(Agent::Codex)); + assert_eq!(result.agent_pid, Some(99)); assert_eq!(result.process_name.as_deref(), Some("codex")); } + #[test] + fn probe_reports_the_agent_process_behind_an_unrecognized_job_leader() { + let job = crate::platform::ForegroundJob { + process_group_id: 99, + processes: vec![ + foreground_process(99, "some_wrapper"), + foreground_process(100, "claude"), + ], + }; + + let result = probe_foreground_process_from_jobs(42, Some(99), None, || Some(job), |_| None); + + assert_eq!(result.agent, Some(Agent::Claude)); + assert_eq!(result.agent_pid, Some(100)); + assert_eq!(result.process_group_id, Some(99)); + } + #[test] fn non_leader_agent_hint_wins_when_leader_is_unidentified() { let job = crate::platform::ForegroundJob { @@ -5828,6 +6588,57 @@ mod tests { .expect("re-entering active authority should notify detection reset"); } + #[cfg(unix)] + #[tokio::test] + async fn spawned_pane_process_sees_fish_handle_reflow() { + let output_dir = std::env::temp_dir().join(format!( + "herdr-fish-handle-reflow-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&output_dir).unwrap(); + let output_path = output_dir.join("out.txt"); + let (events, _event_rx) = mpsc::channel(8); + // The cwd keeps the temp path out of the shell command line. + let runtime = PaneRuntime::spawn_shell_command( + PaneId::from_raw(42), + 24, + 80, + output_dir.clone(), + "printf '%s\\n' \"$fish_handle_reflow\" > out.txt", + &PaneLaunchEnv::default(), + AgentDetection::Disabled, + 0, + crate::terminal_theme::TerminalTheme::default(), + None, + events, + Arc::new(Notify::new()), + Arc::new(RenderSignal::new()), + ) + .unwrap(); + + let output = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Ok(output) = std::fs::read_to_string(&output_path) { + if output.ends_with('\n') { + break output; + } + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("pane process should write its env"); + runtime.shutdown(); + let _ = std::fs::remove_dir_all(&output_dir); + + let expected = std::env::var(FISH_HANDLE_REFLOW_ENV_VAR).unwrap_or_else(|_| "0".into()); + assert_eq!(output, format!("{expected}\n")); + } + #[cfg(unix)] #[tokio::test] async fn spawned_pty_reader_aggregates_terminal_bells() { diff --git a/src/pane/agent_detection.rs b/src/pane/agent_detection.rs index e37aec3b..f3edc638 100644 --- a/src/pane/agent_detection.rs +++ b/src/pane/agent_detection.rs @@ -104,6 +104,48 @@ pub(super) fn should_skip_idle_screen_scan(input: IdleScreenScanSkipInput) -> bo && input.last_screen_scan_detection_content_seq == input.current_detection_content_seq } +/// Retains text, not a classification: callers still evaluate discovery and +/// lifecycle bookkeeping on every scheduled observation. +#[derive(Default)] +pub(super) struct DetectionTextCache { + pub(super) text: String, + revision: Option, +} + +impl DetectionTextCache { + pub(super) fn clear(&mut self) { + self.text.clear(); + self.revision = None; + } + + /// Returns whether the extracted text changed, not whether PTY bytes arrived. + pub(super) fn refresh( + &mut self, + agent: Option, + content_seq: &AtomicU64, + read: impl FnOnce() -> (String, bool), + ) -> bool { + let before = content_seq.load(Ordering::Acquire); + // Identified agents retain their existing screen-read behavior everywhere. + if agent.is_none() + // The terminal accessor also returns empty on failure; keep retrying it. + && !self.text.is_empty() + && before.is_multiple_of(2) + && self.revision == Some(before) + { + return false; + } + let (text, reusable) = read(); + let after = content_seq.load(Ordering::Acquire); + // Writers announce themselves before touching the terminal. A read that + // overlaps a writer must not bless either revision for future reuse. + self.revision = (reusable && before == after && before.is_multiple_of(2)).then_some(before); + let changed = text != self.text; + self.text = text; + changed + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum DetectionScreenReadDecision { Read, @@ -349,6 +391,63 @@ pub(super) fn mark_detection_content_changed(detection_content_seq: &AtomicU64) mod tests { use super::*; + #[test] + fn unidentified_text_cache_preserves_text_changes_and_forced_reads() { + let sequence = AtomicU64::new(0); + let mut cache = DetectionTextCache::default(); + assert!(cache.refresh(None, &sequence, || ("one".into(), true))); + for _ in 0..20 { + assert!(!cache.refresh(None, &sequence, || panic!("unchanged text was extracted"))); + } + sequence.store(2, Ordering::Release); + assert!( + !cache.refresh(None, &sequence, || ("one".into(), true)), + "bytes are not text changes" + ); + sequence.store(4, Ordering::Release); + assert!(cache.refresh(None, &sequence, || ("two".into(), true))); + for _ in 0..2 { + let mut read = false; + assert!(!cache.refresh(Some(Agent::Codex), &sequence, || { + read = true; + ("two".into(), true) + })); + assert!(read, "identified agents must keep their existing reads"); + } + cache.clear(); + assert!(cache.refresh(None, &sequence, || ("two".into(), true))); + sequence.store(6, Ordering::Release); + assert!(cache.refresh(None, &sequence, || (String::new(), false))); + assert!(cache.refresh(None, &sequence, || ("retry after empty read".into(), true))); + } + + #[test] + fn unidentified_text_cache_does_not_reuse_overlapping_writes() { + let sequence = AtomicU64::new(0); + let mut cache = DetectionTextCache::default(); + assert!(cache.refresh(None, &sequence, || { + sequence.store(2, Ordering::Release); + ("old".into(), true) + })); + assert_eq!( + cache.revision, None, + "old text must not acquire the new revision" + ); + assert!(cache.refresh(None, &sequence, || ("new".into(), true))); + + sequence.store(3, Ordering::Release); + assert!(cache.refresh(None, &sequence, || ("during write".into(), true))); + assert_eq!( + cache.revision, None, + "an in-progress write cannot be reused" + ); + sequence.store(4, Ordering::Release); + assert!(cache.refresh(None, &sequence, || ("completed write".into(), true))); + assert!(!cache.refresh(None, &sequence, || panic!( + "completed revision should be reusable" + ))); + } + fn publish_state(state: AgentState) -> DetectionPublishState { DetectionPublishState { state, diff --git a/src/pane/background_agent.rs b/src/pane/background_agent.rs new file mode 100644 index 00000000..233c158a --- /dev/null +++ b/src/pane/background_agent.rs @@ -0,0 +1,281 @@ +//! Keeps an agent's identity while job control moves it out of the foreground. +//! +//! Suspending an agent with ctrl-z, or running a command in front of it, hands +//! the terminal to another job while the agent process lives on. Treating that +//! as an exit would drop the agent's session, name, and hook authority, and +//! nothing would restore them when it returns. + +use crate::detect::Agent; + +/// Where the process that identified as the current agent is now, when the +/// foreground probe no longer finds that agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum AgentJobStatus { + /// Untracked, still in the foreground, or gone without having been held. + Unknown, + /// Alive outside the foreground, such as a job stopped with ctrl-z. + Background, + /// Was held in the background and has since exited. + ExitedInBackground, + /// Was held, and a different process of the same agent kind is in front. + ReplacedInFront, +} + +/// The identified agent process. The start token tells it apart from a later +/// process that reuses its pid. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AgentProcess { + pid: u32, + start_token: u64, +} + +#[derive(Debug, Default)] +pub(super) struct AgentJobTracker { + process: Option, + in_background: bool, +} + +impl AgentJobTracker { + /// A held agent must be rechecked even when lifecycle-hook authority would + /// otherwise skip probing, so a job killed in the background is noticed. + pub(super) fn in_background(&self) -> bool { + self.in_background + } + + /// `agent_missing` means the probe lost a current agent whose exit has not + /// been reported yet. Liveness is checked only then, never per render. + /// + /// `live_group(shell_pid, pid, start_token)` returns the process group of + /// that exact process while it is alive in the pane's terminal session. + pub(super) fn status( + &self, + agent_missing: bool, + shell_pid: u32, + foreground_group: Option, + live_group: impl FnOnce(u32, u32, u64) -> Option, + ) -> AgentJobStatus { + if !agent_missing { + return AgentJobStatus::Unknown; + } + let Some(process) = self.process else { + return AgentJobStatus::Unknown; + }; + match live_group(shell_pid, process.pid, process.start_token) { + // An agent in the shell's own group was never a separate job, and + // one still in the foreground group is a plain miss. + Some(group) if group != shell_pid && Some(group) != foreground_group => { + AgentJobStatus::Background + } + Some(_) => AgentJobStatus::Unknown, + None if self.in_background => AgentJobStatus::ExitedInBackground, + None => AgentJobStatus::Unknown, + } + } + + /// While an agent is held, a same-kind agent in front is that agent only + /// when its pid and start token match the held process. + pub(super) fn replaced_in_front( + &self, + identified_pid: Option, + start_token: impl FnOnce(u32) -> Option, + ) -> bool { + let Some(held) = self.process.filter(|_| self.in_background) else { + return false; + }; + identified_pid + .is_none_or(|pid| pid != held.pid || start_token(pid) != Some(held.start_token)) + } + + pub(super) fn observe( + &mut self, + status: AgentJobStatus, + current_agent: Option, + identified_agent: Option, + identified_pid: Option, + start_token: impl FnOnce(u32) -> Option, + ) { + if current_agent.is_none() { + *self = Self::default(); + } else if status == AgentJobStatus::ReplacedInFront { + // Keep holding until the held agent's exit has been reported. + } else if identified_agent == current_agent { + self.in_background = false; + // Reread every time: a new process may have reused the pid. + self.process = identified_pid.and_then(|pid| { + start_token(pid).map(|start_token| AgentProcess { pid, start_token }) + }); + } else if status == AgentJobStatus::Background { + self.in_background = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHELL: u32 = 10; + const WRAPPER_JOB: u32 = 20; + const AGENT_PID: u32 = 21; + const AGENT_START: u64 = 7_000; + + fn acquired(agent: Agent) -> AgentJobTracker { + let mut tracker = AgentJobTracker::default(); + tracker.observe( + AgentJobStatus::Unknown, + Some(agent), + Some(agent), + Some(AGENT_PID), + |pid| (pid == AGENT_PID).then_some(AGENT_START), + ); + tracker + } + + fn live_agent(shell: u32, pid: u32, start_token: u64) -> Option { + assert_eq!(shell, SHELL); + (pid == AGENT_PID && start_token == AGENT_START).then_some(WRAPPER_JOB) + } + + #[test] + fn stopped_agent_behind_the_shell_is_in_background() { + let tracker = acquired(Agent::Claude); + + assert_eq!( + tracker.status(true, SHELL, Some(SHELL), live_agent), + AgentJobStatus::Background + ); + } + + #[test] + fn agent_that_exited_in_front_is_unknown() { + let tracker = acquired(Agent::Claude); + + assert_eq!( + tracker.status(true, SHELL, Some(SHELL), |_, _, _| None), + AgentJobStatus::Unknown + ); + } + + #[test] + fn liveness_is_only_checked_when_the_agent_went_missing() { + let tracker = acquired(Agent::Claude); + let no_check = |_: u32, _: u32, _: u64| -> Option { panic!("liveness checked") }; + + assert_eq!( + tracker.status(false, SHELL, Some(SHELL), no_check), + AgentJobStatus::Unknown + ); + assert_eq!( + AgentJobTracker::default().status(true, SHELL, Some(SHELL), no_check), + AgentJobStatus::Unknown + ); + } + + #[test] + fn agent_still_in_the_foreground_group_is_not_held() { + let tracker = acquired(Agent::Claude); + + assert_eq!( + tracker.status(true, SHELL, Some(WRAPPER_JOB), live_agent), + AgentJobStatus::Unknown + ); + } + + #[test] + fn agent_in_the_shell_group_is_never_held() { + let tracker = acquired(Agent::Pi); + + assert_eq!( + tracker.status(true, SHELL, Some(30), |_, _, _| Some(SHELL)), + AgentJobStatus::Unknown + ); + } + + #[test] + fn agent_without_a_start_token_is_never_held() { + let mut tracker = AgentJobTracker::default(); + tracker.observe( + AgentJobStatus::Unknown, + Some(Agent::Pi), + Some(Agent::Pi), + Some(AGENT_PID), + |_| None, + ); + + assert_eq!( + tracker.status(true, SHELL, Some(SHELL), |_, _, _| Some(WRAPPER_JOB)), + AgentJobStatus::Unknown + ); + } + + #[test] + fn reidentified_agent_refreshes_its_start_token() { + const REUSED_START: u64 = 9_000; + let mut tracker = acquired(Agent::Pi); + // A new Pi reused the pid between two probes. + tracker.observe( + AgentJobStatus::Unknown, + Some(Agent::Pi), + Some(Agent::Pi), + Some(AGENT_PID), + |_| Some(REUSED_START), + ); + + assert_eq!( + tracker.status(true, SHELL, Some(SHELL), |_, pid, token| { + (pid == AGENT_PID && token == REUSED_START).then_some(WRAPPER_JOB) + }), + AgentJobStatus::Background + ); + } + + #[test] + fn held_agent_is_replaced_only_by_a_different_process() { + let mut tracker = acquired(Agent::Claude); + assert!( + !tracker.replaced_in_front(Some(31), |_| Some(1)), + "not held" + ); + let status = tracker.status(true, SHELL, Some(SHELL), live_agent); + tracker.observe(status, Some(Agent::Claude), None, None, |_| None); + + assert!(!tracker.replaced_in_front(Some(AGENT_PID), |_| Some(AGENT_START))); + assert!(tracker.replaced_in_front(Some(31), |_| Some(AGENT_START))); + assert!(tracker.replaced_in_front(Some(AGENT_PID), |_| Some(9_000))); + assert!(tracker.replaced_in_front(Some(AGENT_PID), |_| None)); + assert!(tracker.replaced_in_front(None, |_| Some(AGENT_START))); + } + + #[test] + fn returning_agent_ends_the_hold() { + let mut tracker = acquired(Agent::Claude); + let status = tracker.status(true, SHELL, Some(SHELL), live_agent); + tracker.observe(status, Some(Agent::Claude), None, None, |_| None); + assert!(tracker.in_background()); + + tracker.observe( + AgentJobStatus::Unknown, + Some(Agent::Claude), + Some(Agent::Claude), + Some(AGENT_PID), + |_| None, + ); + + assert!(!tracker.in_background()); + } + + #[test] + fn cleared_agent_resets_the_tracker() { + let mut tracker = acquired(Agent::Pi); + let status = tracker.status(true, SHELL, Some(SHELL), live_agent); + tracker.observe(status, Some(Agent::Pi), None, None, |_| None); + + tracker.observe(AgentJobStatus::Unknown, None, None, None, |_| None); + + assert!(!tracker.in_background()); + assert_eq!( + tracker.status(true, SHELL, Some(SHELL), live_agent), + AgentJobStatus::Unknown + ); + } +} diff --git a/src/pane/input.rs b/src/pane/input.rs index 10900c94..87c1c6f6 100644 --- a/src/pane/input.rs +++ b/src/pane/input.rs @@ -1,6 +1,23 @@ +/// Build the key event the Ghostty app would build from an OS key event, so +/// libghostty can encode it for the pane's current keyboard modes: physical +/// key, modifiers, the layout text, the unshifted codepoint, and Shift marked +/// consumed when it produced the text. `legacy_pane` is true when the pane +/// negotiated no keyboard protocol. +/// +/// Hyper and Meta have no Ghostty equivalent, so those chords are not +/// forwarded rather than typed as their bare key. pub(super) fn ghostty_key_event_from_terminal_key( key: &crate::input::TerminalKey, + legacy_pane: bool, ) -> Option { + use crossterm::event::{KeyCode, KeyModifiers}; + + if key + .modifiers + .intersects(KeyModifiers::HYPER | KeyModifiers::META) + { + return None; + } let mut event = crate::ghostty::KeyEvent::new().ok()?; event.set_action(match key.kind { crossterm::event::KeyEventKind::Press => { @@ -14,31 +31,167 @@ pub(super) fn ghostty_key_event_from_terminal_key( } }); let mut mods = ghostty_mods_from_key_modifiers(key.modifiers); - if matches!(key.code, crossterm::event::KeyCode::BackTab) { - // Ghostty represents backtab as Tab with Shift rather than a distinct key. - mods |= crate::ghostty::MOD_SHIFT; + match key.code { + KeyCode::Char(c) => { + // An uppercase letter's key is its lowercase letter on every layout. + // Nothing else is inferred: Kitty reports already name the unshifted + // key (`+` on a German layout stays `+`), and legacy input does not + // say which key produced punctuation. + let base = unshifted_letter(c); + if base != c { + mods |= crate::ghostty::MOD_SHIFT; + } + event.set_key( + ghostty_key_from_char(base) + .unwrap_or(crate::ghostty::ffi::GhosttyKey_GHOSTTY_KEY_UNIDENTIFIED), + ); + event.set_unshifted_codepoint(base as u32); + let shifted = mods & crate::ghostty::MOD_SHIFT != 0; + if let Some(text) = key_text(key, base, shifted) { + // Text the host reported with Shift held already includes Shift + // (e.g. Shift+7 = "/" on a German layout, where `base` cannot + // be recovered from the character alone). + let reported_shifted_text = key.generated_text.is_some() && text != " "; + // A non-letter given with Shift and no reported shifted character + // is the produced character itself ("shift+?" is "?"). Kitty + // hosts report shifted alternates, so a changed character shows + // up there. + let given_shifted_char = + key.shifted_codepoint.is_none() && !base.is_alphabetic() && text != " "; + if shifted + && (!text.starts_with(base) || reported_shifted_text || given_shifted_char) + { + event.set_consumed_mods(crate::ghostty::MOD_SHIFT); + } + // Legacy Alt prefixes the produced text, but libghostty on macOS + // prefixes the unshifted codepoint for non-ASCII text (it + // assumes Option translated it). Herdr already decoded Alt, so + // the text is the key's real output: Alt+Shift+ö is ESC Ö. + if legacy_pane + && key.modifiers.contains(KeyModifiers::ALT) + && !key.modifiers.contains(KeyModifiers::CONTROL) + { + if let Some(produced) = single_char(&text).filter(|c| !c.is_ascii()) { + event.set_unshifted_codepoint(produced as u32); + } + } + event.set_utf8(&text); + } + } + KeyCode::BackTab => { + // Ghostty represents backtab as Tab with Shift rather than a distinct key. + mods |= crate::ghostty::MOD_SHIFT; + event.set_key(crate::ghostty::ffi::GhosttyKey_GHOSTTY_KEY_TAB); + } + code => event.set_key(ghostty_key_from_key_code(code)?), } event.set_mods(mods); - event.set_key(ghostty_key_from_crossterm_key_code( - key.code, - key.shifted_codepoint, - )?); + event.set_composing(key.is_windows_dead_key()); - if let Some(text) = ghostty_key_text(key) { - event.set_utf8(&text); - } else { - event.set_utf8(""); + Some(event) +} + +/// Configure `encoder` for a pane's live terminal modes. Herdr has already +/// decoded Alt from the host, so macOS Option must keep its Alt meaning (ESC +/// prefix) instead of being treated as a text modifier. +pub(super) fn configure_key_encoder( + encoder: &mut crate::ghostty::KeyEncoder, + terminal: &crate::ghostty::Terminal, +) { + encoder.set_from_terminal(terminal); + encoder.set_macos_option_as_alt(true); +} + +/// Exception table for panes that negotiated no keyboard protocol. Ghostty sends +/// these chords as escape sequences that plain shells do not understand; keep +/// the classic key instead. libghostty still encodes the result (so Alt keeps +/// its ESC prefix). +pub(super) fn legacy_shell_key(key: crate::input::TerminalKey) -> crate::input::TerminalKey { + use crossterm::event::{KeyCode, KeyModifiers}; + + let alt = key.modifiers & KeyModifiers::ALT; + let chord = key.modifiers - KeyModifiers::ALT; + let classic = match (key.code, chord) { + (KeyCode::Enter, chord) if !chord.is_empty() => KeyCode::Enter, + (KeyCode::Char('m'), KeyModifiers::CONTROL) => KeyCode::Enter, + (KeyCode::Char('i'), KeyModifiers::CONTROL) | (KeyCode::Tab, KeyModifiers::CONTROL) => { + KeyCode::Tab + } + (KeyCode::Char('['), KeyModifiers::CONTROL) => KeyCode::Esc, + // Ghostty reports Ctrl+Shift+letter as CSI u; every terminal sends the + // plain control byte, which is all a shell can read. + // WezTerm names it Ctrl plus an uppercase letter, without Shift. + (KeyCode::Char(c), chord) + if chord - KeyModifiers::SHIFT == KeyModifiers::CONTROL + && c.is_ascii_alphabetic() + && (chord.contains(KeyModifiers::SHIFT) || c.is_ascii_uppercase()) => + { + // Ctrl+I and Ctrl+M then follow the rows above (Tab, Enter). + return legacy_shell_key( + crate::input::TerminalKey::new( + KeyCode::Char(c.to_ascii_lowercase()), + KeyModifiers::CONTROL | alt, + ) + .with_kind(key.kind) + .with_repeat_count(key.repeat_count), + ); + } + _ => return key, + }; + crate::input::TerminalKey::new(classic, alt) + .with_kind(key.kind) + .with_repeat_count(key.repeat_count) +} + +/// Second exception for panes that negotiated nothing: legacy encoding has no +/// Super, so these chords are not forwarded. Typing the bare key turns Cmd+C +/// into "c" (#3710), and a Kitty report prints as garbage in plain shells +/// (#4356). Ghostty on macOS sends nothing for them either. +pub(super) fn legacy_super_chord(key: &crate::input::TerminalKey) -> bool { + key.modifiers + .contains(crossterm::event::KeyModifiers::SUPER) +} + +fn single_char(text: &str) -> Option { + let mut chars = text.chars(); + match (chars.next(), chars.next()) { + (Some(only), None) => Some(only), + _ => None, } +} - if let Some(codepoint) = ghostty_unshifted_codepoint(key) { - event.set_unshifted_codepoint(codepoint); +/// Text the key produced on the user's layout, before Ctrl/Alt transformations. +fn key_text(key: &crate::input::TerminalKey, base: char, shifted: bool) -> Option { + if let Some(text) = key + .generated_text + .as_ref() + .filter(|text| !text.is_empty() && !text.chars().any(char::is_control)) + { + return Some(text.clone()); } + // Without a reported shifted character, only a letter's Shift is known. + let produced = match key.shifted_codepoint.and_then(char::from_u32) { + Some(shifted_char) if shifted => shifted_char, + _ if shifted => shifted_letter(base), + _ => base, + }; + (!produced.is_control()).then(|| produced.to_string()) +} - Some(event) +fn shifted_letter(c: char) -> char { + let mut upper = c.to_uppercase(); + match (upper.next(), upper.next()) { + (Some(upper), None) if c.is_lowercase() => upper, + _ => c, + } } -pub(super) fn ghostty_prefers_herdr_text_encoding(key: &crate::input::TerminalKey) -> bool { - matches!(key.code, crossterm::event::KeyCode::Char(_)) +fn unshifted_letter(c: char) -> char { + let mut lower = c.to_lowercase(); + match (lower.next(), lower.next()) { + (Some(lower), None) if c.is_uppercase() => lower, + _ => c, + } } pub(super) fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyModifiers) -> u16 { @@ -61,7 +214,7 @@ pub(super) fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyMo pub(super) fn ghostty_mouse_encoder_for_terminal( terminal: &crate::ghostty::Terminal, position: crate::input::mouse::Position, -) -> Option { +) -> Option<(crate::ghostty::MouseEncoder, (f32, f32))> { let mut encoder = crate::ghostty::MouseEncoder::new().ok()?; encoder.set_from_terminal(terminal); let cols = terminal.cols().ok()? as u32; @@ -69,35 +222,46 @@ pub(super) fn ghostty_mouse_encoder_for_terminal( let sgr_pixels = terminal .mode_get(crate::ghostty::MODE_MOUSE_SGR_PIXELS) .ok()?; - match position { - crate::input::mouse::Position::Cell { .. } => { - if sgr_pixels { - encoder.set_format(crate::ghostty::MOUSE_FORMAT_SGR); + let event_position = match position { + crate::input::mouse::Position::Cell { column, row } if sgr_pixels => { + let width_px = terminal.width_px().ok()?; + let height_px = terminal.height_px().ok()?; + let x = cell_center_px(u32::from(column), cols, width_px)?; + let y = cell_center_px(u32::from(row), rows, height_px)?; + encoder.set_format(crate::ghostty::MOUSE_FORMAT_SGR_PIXELS); + encoder.set_size(width_px, height_px, width_px / cols, height_px / rows); + (x as f32, y as f32) + } + crate::input::mouse::Position::Cell { column, row } => { + if cols == 0 || rows == 0 { + return None; } encoder.set_size(cols, rows, 1, 1); + (f32::from(column), f32::from(row)) } - crate::input::mouse::Position::Pixels { .. } => { - if sgr_pixels { - encoder.set_format(crate::ghostty::MOUSE_FORMAT_SGR_PIXELS); - } + crate::input::mouse::Position::Pixels { x, y } => { let width_px = terminal.width_px().ok()?; let height_px = terminal.height_px().ok()?; if width_px == 0 || height_px == 0 || cols == 0 || rows == 0 { return None; } + if sgr_pixels { + encoder.set_format(crate::ghostty::MOUSE_FORMAT_SGR_PIXELS); + } encoder.set_size(width_px, height_px, width_px / cols, height_px / rows); + (x as f32, y as f32) } - } - Some(encoder) + }; + Some((encoder, event_position)) } -pub(super) fn ghostty_mouse_position_for_terminal( - position: crate::input::mouse::Position, -) -> Option<(f32, f32)> { - match position { - crate::input::mouse::Position::Pixels { x, y } => Some((x as f32, y as f32)), - crate::input::mouse::Position::Cell { column, row } => Some((column as f32, row as f32)), +fn cell_center_px(index: u32, count: u32, extent: u32) -> Option { + if count == 0 || extent == 0 || index >= count { + return None; } + let start = u64::from(index) * u64::from(extent) / u64::from(count); + let end = u64::from(index + 1) * u64::from(extent) / u64::from(count); + u32::try_from(start + end.saturating_sub(start) / 2).ok() } pub(super) fn ghostty_mouse_event_from_button_kind( @@ -196,31 +360,14 @@ pub(super) fn ghostty_mouse_event_from_wheel_kind( Some(event) } -fn ghostty_key_text(key: &crate::input::TerminalKey) -> Option { - match key.code { - crossterm::event::KeyCode::Char(c) => Some( - key.shifted_codepoint - .and_then(char::from_u32) - .unwrap_or(c) - .to_string(), - ), - _ => None, - } -} - -fn ghostty_unshifted_codepoint(key: &crate::input::TerminalKey) -> Option { - match key.code { - crossterm::event::KeyCode::Char(c) => Some(c as u32), - _ => None, - } -} - -fn ghostty_key_from_crossterm_key_code( +/// Physical key for a non-character key code. Keys libghostty has no name for +/// (some media keys, Hyper/Meta, ISO level shifts) return None; the Ghostty app +/// cannot send them either. +fn ghostty_key_from_key_code( code: crossterm::event::KeyCode, - shifted_codepoint: Option, ) -> Option { use crate::ghostty::ffi; - use crossterm::event::KeyCode; + use crossterm::event::{KeyCode, MediaKeyCode, ModifierKeyCode}; match code { KeyCode::Backspace => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKSPACE), @@ -250,26 +397,75 @@ fn ghostty_key_from_crossterm_key_code( 10 => ffi::GhosttyKey_GHOSTTY_KEY_F10, 11 => ffi::GhosttyKey_GHOSTTY_KEY_F11, 12 => ffi::GhosttyKey_GHOSTTY_KEY_F12, + 13 => ffi::GhosttyKey_GHOSTTY_KEY_F13, + 14 => ffi::GhosttyKey_GHOSTTY_KEY_F14, + 15 => ffi::GhosttyKey_GHOSTTY_KEY_F15, + 16 => ffi::GhosttyKey_GHOSTTY_KEY_F16, + 17 => ffi::GhosttyKey_GHOSTTY_KEY_F17, + 18 => ffi::GhosttyKey_GHOSTTY_KEY_F18, + 19 => ffi::GhosttyKey_GHOSTTY_KEY_F19, + 20 => ffi::GhosttyKey_GHOSTTY_KEY_F20, + 21 => ffi::GhosttyKey_GHOSTTY_KEY_F21, + 22 => ffi::GhosttyKey_GHOSTTY_KEY_F22, + 23 => ffi::GhosttyKey_GHOSTTY_KEY_F23, + 24 => ffi::GhosttyKey_GHOSTTY_KEY_F24, + 25 => ffi::GhosttyKey_GHOSTTY_KEY_F25, _ => return None, }), - KeyCode::Char(c) => ghostty_key_from_char(c, shifted_codepoint), + KeyCode::CapsLock => Some(ffi::GhosttyKey_GHOSTTY_KEY_CAPS_LOCK), + KeyCode::ScrollLock => Some(ffi::GhosttyKey_GHOSTTY_KEY_SCROLL_LOCK), + KeyCode::NumLock => Some(ffi::GhosttyKey_GHOSTTY_KEY_NUM_LOCK), + KeyCode::PrintScreen => Some(ffi::GhosttyKey_GHOSTTY_KEY_PRINT_SCREEN), + KeyCode::Pause => Some(ffi::GhosttyKey_GHOSTTY_KEY_PAUSE), + KeyCode::Menu => Some(ffi::GhosttyKey_GHOSTTY_KEY_CONTEXT_MENU), + KeyCode::KeypadBegin => Some(ffi::GhosttyKey_GHOSTTY_KEY_NUMPAD_BEGIN), + KeyCode::Media(MediaKeyCode::PlayPause) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_MEDIA_PLAY_PAUSE) + } + KeyCode::Media(MediaKeyCode::Stop) => Some(ffi::GhosttyKey_GHOSTTY_KEY_MEDIA_STOP), + KeyCode::Media(MediaKeyCode::TrackNext) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_MEDIA_TRACK_NEXT) + } + KeyCode::Media(MediaKeyCode::TrackPrevious) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS) + } + KeyCode::Media(MediaKeyCode::LowerVolume) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_AUDIO_VOLUME_DOWN) + } + KeyCode::Media(MediaKeyCode::RaiseVolume) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_AUDIO_VOLUME_UP) + } + KeyCode::Media(MediaKeyCode::MuteVolume) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_AUDIO_VOLUME_MUTE) + } + KeyCode::Modifier(ModifierKeyCode::LeftShift) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_SHIFT_LEFT) + } + KeyCode::Modifier(ModifierKeyCode::RightShift) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_SHIFT_RIGHT) + } + KeyCode::Modifier(ModifierKeyCode::LeftControl) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_CONTROL_LEFT) + } + KeyCode::Modifier(ModifierKeyCode::RightControl) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_CONTROL_RIGHT) + } + KeyCode::Modifier(ModifierKeyCode::LeftAlt) => Some(ffi::GhosttyKey_GHOSTTY_KEY_ALT_LEFT), + KeyCode::Modifier(ModifierKeyCode::RightAlt) => Some(ffi::GhosttyKey_GHOSTTY_KEY_ALT_RIGHT), + KeyCode::Modifier(ModifierKeyCode::LeftSuper) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_META_LEFT) + } + KeyCode::Modifier(ModifierKeyCode::RightSuper) => { + Some(ffi::GhosttyKey_GHOSTTY_KEY_META_RIGHT) + } _ => None, } } -fn ghostty_key_from_char( - c: char, - shifted_codepoint: Option, -) -> Option { +fn ghostty_key_from_char(base: char) -> Option { use crate::ghostty::ffi; - let base = if let Some(shifted) = shifted_codepoint.and_then(char::from_u32) { - ghostty_unshifted_ascii_pair(shifted).unwrap_or(c) - } else { - c - }; - - match base.to_ascii_lowercase() { + match base { 'a' => Some(ffi::GhosttyKey_GHOSTTY_KEY_A), 'b' => Some(ffi::GhosttyKey_GHOSTTY_KEY_B), 'c' => Some(ffi::GhosttyKey_GHOSTTY_KEY_C), @@ -321,30 +517,3 @@ fn ghostty_key_from_char( _ => None, } } - -fn ghostty_unshifted_ascii_pair(c: char) -> Option { - Some(match c { - '!' => '1', - '@' => '2', - '#' => '3', - '$' => '4', - '%' => '5', - '^' => '6', - '&' => '7', - '*' => '8', - '(' => '9', - ')' => '0', - '_' => '-', - '+' => '=', - '{' => '[', - '}' => ']', - '|' => '\\', - ':' => ';', - '"' => '\'', - '<' => ',', - '>' => '.', - '?' => '/', - '~' => '`', - _ => return None, - }) -} diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs index bc8876e6..4c6667e3 100644 --- a/src/pane/kitty_keyboard.rs +++ b/src/pane/kitty_keyboard.rs @@ -1,3 +1,7 @@ +/// Handoff-only record of the keyboard modes a pane's app requested, so an +/// update handoff can replay the full Kitty keyboard stack into the new server. +/// It is never consulted for live input: libghostty owns keyboard state and +/// encoding. Remove once handoff restores panes from libghostty snapshots. #[derive(Debug, Clone, Default)] pub(crate) struct KittyKeyboardTracker { pending: Vec, @@ -67,15 +71,11 @@ impl KittyKeyboardTracker { } } + #[cfg(test)] pub(crate) fn modify_other_keys_level(&self) -> u8 { self.modify_other_keys_level } - #[cfg(windows)] - pub(crate) fn modify_other_keys_enabled(&self) -> bool { - self.modify_other_keys_level > 0 - } - fn observe_modify_other_keys(&mut self, params: &[u8]) { let Some(params) = params.strip_prefix(b">") else { return; @@ -173,14 +173,10 @@ mod tests { assert_eq!(tracker.flags, 1); assert_eq!(tracker.stack, vec![0]); assert_eq!(tracker.modify_other_keys_level(), 1); - #[cfg(windows)] - { - assert!(tracker.modify_other_keys_enabled()); - tracker.observe(b"\x1b[>1m"); - assert!(tracker.modify_other_keys_enabled()); - tracker.observe(b"\x1b[>04n"); - assert!(!tracker.modify_other_keys_enabled()); - } + tracker.observe(b"\x1b[>1m"); + assert_eq!(tracker.modify_other_keys_level(), 1); + tracker.observe(b"\x1b[>04n"); + assert_eq!(tracker.modify_other_keys_level(), 0); } #[test] diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 24379d22..d4b7ef57 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -9,7 +9,7 @@ use ratatui::{layout::Rect, Frame}; #[cfg(any(unix, test))] use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; use unicode_width::UnicodeWidthStr; use crate::layout::PaneId; @@ -25,10 +25,10 @@ use super::cursor::CURSOR_POSITION_SETTLE; use super::cursor::{CursorPositionSettleState, DecscusrTracker}; use super::{ input::{ - ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal, - ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_motion_kind, - ghostty_mouse_event_from_wheel_kind, ghostty_mouse_position_for_terminal, - ghostty_prefers_herdr_text_encoding, + configure_key_encoder, ghostty_key_event_from_terminal_key, + ghostty_mouse_encoder_for_terminal, ghostty_mouse_event_from_button_kind, + ghostty_mouse_event_from_motion_kind, ghostty_mouse_event_from_wheel_kind, + legacy_shell_key, legacy_super_chord, }, kitty_keyboard::KittyKeyboardTracker, osc::{ @@ -187,6 +187,10 @@ pub(crate) enum TerminalCompressionStep { pub(crate) struct GhosttyPaneTerminal { pub core: Mutex, + #[cfg(test)] + pub(super) scroll_metrics_reads: std::sync::atomic::AtomicUsize, + #[cfg(all(test, unix))] + pub(super) detection_text_reads: std::sync::atomic::AtomicUsize, key_encoder: Mutex, pending_pty_responses: Arc>>, } @@ -499,6 +503,11 @@ impl PaneTerminal { self.ghostty.detection_text() } + /// The flag permits reuse only when a later read cannot refresh Windows fallback text. + pub(crate) fn detection_text_for_cache(&self) -> (String, bool) { + self.ghostty.detection_text_for_cache() + } + pub(crate) fn try_compression_activity(&self) -> Result, crate::ghostty::Error> { self.ghostty.try_compression_activity() } @@ -634,12 +643,8 @@ impl PaneTerminal { .filter(|ansi| !ansi.is_empty()) } - pub fn encode_terminal_key( - &self, - key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, - ) -> Vec { - self.ghostty.encode_terminal_key(key, protocol) + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { + self.ghostty.encode_terminal_key(key) } pub(crate) fn encode_mouse_button( @@ -1160,18 +1165,22 @@ impl GhosttyPaneTerminal { }) .map_err(|e| std::io::Error::other(e.to_string()))?; - let mut render_state = + let render_state = crate::ghostty::RenderState::new().map_err(|e| std::io::Error::other(e.to_string()))?; - let initial_colors = render_state - .update(&terminal) - .ok() - .and_then(|_| render_state.colors().ok()); + // Callers pass a terminal with unset default colors, so an updated render + // state would report the same baseline as an empty one. Updating here would + // build a cell snapshot at the spawn size, which hidden panes keep alive. + let initial_colors = render_state.colors().ok(); let initial_default_foreground = initial_colors.map(|colors| colors.foreground); let initial_default_background = initial_colors.map(|colors| colors.background); let mut key_encoder = crate::ghostty::KeyEncoder::new().map_err(|e| std::io::Error::other(e.to_string()))?; - key_encoder.set_from_terminal(&terminal); + configure_key_encoder(&mut key_encoder, &terminal); Ok(Self { + #[cfg(test)] + scroll_metrics_reads: std::sync::atomic::AtomicUsize::new(0), + #[cfg(all(test, unix))] + detection_text_reads: std::sync::atomic::AtomicUsize::new(0), core: Mutex::new(GhosttyPaneCore { #[cfg(test)] dirty_collection_hook: None, @@ -1428,7 +1437,7 @@ impl GhosttyPaneTerminal { debug!(pane = pane_id.raw(), "processed kitty graphics sequence"); } if let Ok(mut key_encoder) = self.key_encoder.lock() { - key_encoder.set_from_terminal(&core.terminal); + configure_key_encoder(&mut key_encoder, &core.terminal); } let synchronized_output = core .terminal @@ -1578,7 +1587,7 @@ impl GhosttyPaneTerminal { #[cfg(windows)] windows_recent_fallback::update(&mut core); if let Ok(mut key_encoder) = self.key_encoder.lock() { - key_encoder.set_from_terminal(&core.terminal); + configure_key_encoder(&mut key_encoder, &core.terminal); } } @@ -1642,7 +1651,7 @@ impl GhosttyPaneTerminal { } if let Ok(mut key_encoder) = self.key_encoder.lock() { - key_encoder.set_from_terminal(&core.terminal); + configure_key_encoder(&mut key_encoder, &core.terminal); } } @@ -1665,7 +1674,7 @@ impl GhosttyPaneTerminal { core.kitty_keyboard.observe(ansi.as_bytes()); core.terminal.write(ansi.as_bytes()); if let Ok(mut key_encoder) = self.key_encoder.lock() { - key_encoder.set_from_terminal(&core.terminal); + configure_key_encoder(&mut key_encoder, &core.terminal); } } @@ -1805,6 +1814,9 @@ impl GhosttyPaneTerminal { } pub fn scroll_metrics(&self) -> Option { + #[cfg(test)] + self.scroll_metrics_reads + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); let Ok(core) = self.core.lock() else { return None; }; @@ -1847,10 +1859,16 @@ impl GhosttyPaneTerminal { .is_ok_and(|core| core.terminal.mouse_tracking_enabled().unwrap_or(false)) } + /// libghostty tracks only level 2; like Ghostty, level 1 behaves as if the + /// app negotiated nothing. pub fn modify_other_keys_level(&self) -> u8 { - self.core - .lock() - .map_or(0, |core| core.kitty_keyboard.modify_other_keys_level()) + self.core.lock().map_or(0, |core| { + if core.terminal.modify_other_keys_enabled().unwrap_or(false) { + 2 + } else { + 0 + } + }) } pub fn sgr_pixel_mouse_enabled(&self) -> bool { @@ -2013,90 +2031,59 @@ impl GhosttyPaneTerminal { .unwrap_or((true, 0)) } - pub fn encode_terminal_key( - &self, - key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, - ) -> Vec { + /// libghostty encodes every key for the pane's live keyboard modes. The only + /// Herdr policy is the legacy shell table for panes that negotiated nothing. + /// + /// The terminal lock is held until the key and its grouped repeats are + /// encoded, so a mode change from the PTY reader cannot split the decision + /// (legacy table) from the encoder's configuration. Lock order matches + /// output processing: terminal core, then key encoder. + pub fn encode_terminal_key(&self, key: crate::input::TerminalKey) -> Vec { + let Ok(core) = self.core.lock() else { + return Vec::new(); + }; + let negotiated_nothing = keyboard_negotiated_nothing(&core.terminal); + // Before the Windows record fallback too: a remote client can report + // Super, and a record would deliver the bare key (#3710). + if negotiated_nothing && legacy_super_chord(&key) { + debug!(code = ?key.code, "super chord in a pane without keyboard protocol; not forwarded"); + return Vec::new(); + } #[cfg(windows)] - if self.core.lock().is_ok_and(|core| { - core.terminal - .kitty_keyboard_flags() - .is_ok_and(|flags| flags == 0) - && !core.kitty_keyboard.modify_other_keys_enabled() - && core - .terminal - .modify_other_keys_enabled() - .is_ok_and(|enabled| !enabled) - }) { + if negotiated_nothing { if let Some(bytes) = crate::platform::encode_windows_conpty_fallback(&key) { return bytes; } } + let Ok(mut encoder) = self.key_encoder.lock() else { + return Vec::new(); + }; let repeat_count = key.repeat_count; let first = key.with_repeat_count(1); - let mut bytes = self.encode_terminal_key_once(first.clone(), protocol); + let mut bytes = encode_key_with(&mut encoder, negotiated_nothing, first.clone()); if repeat_count > 1 && first.kind != crossterm::event::KeyEventKind::Release { let repeated = first.with_kind(crossterm::event::KeyEventKind::Repeat); - let repeated_bytes = self.encode_terminal_key_once(repeated, protocol); + let repeated_bytes = encode_key_with(&mut encoder, negotiated_nothing, repeated); for _ in 1..repeat_count { bytes.extend_from_slice(&repeated_bytes); } } + drop(encoder); + drop(core); bytes } - fn encode_terminal_key_once( - &self, - key: crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, - ) -> Vec { - // Ghostty emits extended Enter sequences even without negotiation. - // Ordinary shells need legacy input unless the child enabled an extension. - if key.code == crossterm::event::KeyCode::Enter - && !key.modifiers.is_empty() - && self.core.lock().is_ok_and(|core| { - core.terminal - .kitty_keyboard_flags() - .is_ok_and(|flags| flags == 0) - && core.kitty_keyboard.modify_other_keys_level() == 0 - && core - .terminal - .modify_other_keys_enabled() - .is_ok_and(|enabled| !enabled) - }) - { - return crate::input::encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); - } - - if matches!(protocol, crate::input::KeyboardProtocol::Legacy) - && key.code == crossterm::event::KeyCode::Tab - && key.modifiers == crossterm::event::KeyModifiers::CONTROL - { - return crate::input::encode_terminal_key(key, protocol); - } - - if ghostty_prefers_herdr_text_encoding(&key) { - return crate::input::encode_terminal_key(key, protocol); - } - - let Some(event) = ghostty_key_event_from_terminal_key(&key) else { - return crate::input::encode_terminal_key(key, protocol); + #[cfg(test)] + fn encode_terminal_key_once(&self, key: crate::input::TerminalKey) -> Vec { + let Ok(core) = self.core.lock() else { + return Vec::new(); }; - + let negotiated_nothing = keyboard_negotiated_nothing(&core.terminal); let Ok(mut encoder) = self.key_encoder.lock() else { - return crate::input::encode_terminal_key(key, protocol); + return Vec::new(); }; - match encoder.encode(&event) { - Ok(bytes) - if !bytes.is_empty() - && encoded_key_preserves_event_kind(&bytes, &key, protocol) => - { - bytes - } - Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol), - } + encode_key_with(&mut encoder, negotiated_nothing, key) } pub(crate) fn encode_mouse_button( @@ -2148,8 +2135,7 @@ impl GhosttyPaneTerminal { if require_any_motion && !core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? { return None; } - let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal, position)?; - let (x, y) = ghostty_mouse_position_for_terminal(position)?; + let (mut encoder, (x, y)) = ghostty_mouse_encoder_for_terminal(&core.terminal, position)?; event.set_position(x, y); encoder .encode(&event) @@ -2189,10 +2175,17 @@ impl GhosttyPaneTerminal { } pub fn detection_text(&self) -> String { + self.detection_text_for_cache().0 + } + + pub(crate) fn detection_text_for_cache(&self) -> (String, bool) { + #[cfg(all(test, unix))] + self.detection_text_reads + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.core .lock() .ok() - .and_then(|mut core| ghostty_detection_text(&mut core).ok()) + .and_then(|mut core| ghostty_detection_text_for_cache(&mut core).ok()) .unwrap_or_default() } @@ -2506,21 +2499,42 @@ impl GhosttyPaneTerminal { } } -fn encoded_key_preserves_event_kind( - bytes: &[u8], - key: &crate::input::TerminalKey, - protocol: crate::input::KeyboardProtocol, -) -> bool { - if !protocol.reports_event_types() || key.kind == crossterm::event::KeyEventKind::Press { - return true; - } +/// Encode one key with an encoder configured for the same mode snapshot as +/// `negotiated_nothing`. +fn encode_key_with( + encoder: &mut crate::ghostty::KeyEncoder, + negotiated_nothing: bool, + key: crate::input::TerminalKey, +) -> Vec { + // Before the shell table: Super+Enter must not become a plain Enter. + if negotiated_nothing && legacy_super_chord(&key) { + debug!(code = ?key.code, "super chord in a pane without keyboard protocol; not forwarded"); + return Vec::new(); + } + let key = if negotiated_nothing { + legacy_shell_key(key) + } else { + key + }; + let Some(event) = ghostty_key_event_from_terminal_key(&key, negotiated_nothing) else { + debug!(code = ?key.code, modifiers = ?key.modifiers, "key has no libghostty equivalent; not forwarded"); + return Vec::new(); + }; + encoder.encode(&event).unwrap_or_else(|err| { + warn!(?err, code = ?key.code, "libghostty key encoding failed"); + Vec::new() + }) +} - std::str::from_utf8(bytes) - .ok() - .and_then(crate::input::parse_terminal_key_sequence) - .is_some_and(|parsed| { - parsed.code == key.code && parsed.modifiers == key.modifiers && parsed.kind == key.kind - }) +/// True when the pane's app asked for no enhanced keyboard protocol (no Kitty +/// flags, no modifyOtherKeys 2), read from libghostty's live state. +fn keyboard_negotiated_nothing(terminal: &crate::ghostty::Terminal) -> bool { + terminal + .kitty_keyboard_flags() + .is_ok_and(|flags| flags == 0) + && terminal + .modify_other_keys_enabled() + .is_ok_and(|enabled| !enabled) } fn effective_cursor_state( @@ -2829,13 +2843,29 @@ fn ghostty_visible_ansi(core: &GhosttyPaneCore) -> Result Result { + ghostty_detection_text_for_cache(core).map(|(text, _)| text) +} + +fn ghostty_detection_text_for_cache( + core: &mut GhosttyPaneCore, +) -> Result<(String, bool), crate::ghostty::Error> { let lines = core .terminal .rows() .ok() .map(|rows| usize::from(rows).max(1)) .unwrap_or(DEFAULT_DETECTION_ROWS); - ghostty_recent_text(core, lines) + let text = ghostty_recent_text_for_terminal(&core.terminal, lines)?; + Ok(finish_detection_text(core, text, lines)) +} + +fn finish_detection_text(core: &mut GhosttyPaneCore, text: String, lines: usize) -> (String, bool) { + // Windows fallback reads may refresh deferred history without a content revision. + let reusable = !cfg!(windows) || !text.trim().is_empty(); + ( + finish_recent_snapshot(core, text, lines, false).text, + reusable, + ) } #[cfg(windows)] @@ -2901,13 +2931,6 @@ fn windows_powershell_prompt_line_cwd(line: &str) -> Option (cwd.is_absolute() && cwd.is_dir()).then_some(cwd) } -fn ghostty_recent_text( - core: &mut GhosttyPaneCore, - lines: usize, -) -> Result { - ghostty_recent_text_snapshot(core, lines).map(|snapshot| snapshot.text) -} - fn ghostty_recent_text_snapshot( core: &mut GhosttyPaneCore, lines: usize, @@ -3082,27 +3105,23 @@ fn ghostty_screen_row( y: u32, ) -> Result { let mut line = String::new(); - // Resolve the scrollback page once per row rather than once per column. - for crate::ghostty::ScreenTextCell { wide, graphemes } in terminal - .screen_text_rows_range(y as usize, y as usize + 1)? - .into_iter() - .flat_map(|row| row.cells) - { + // Keep one page lookup per row and reuse grapheme storage across its cells. + terminal.for_each_screen_row_cell(y, |wide, graphemes| { if wide == crate::ghostty::CellWide::SpacerTail { - continue; + return; } if graphemes.is_empty() || graphemes.first().copied() == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER) { line.push(' '); } else { - for codepoint in graphemes { + for &codepoint in graphemes { if let Some(ch) = char::from_u32(codepoint) { line.push(ch); } } } - } + })?; Ok(line.trim_end().to_string()) } @@ -3628,6 +3647,21 @@ fn should_probe_host_terminal_theme_restore(core: &GhosttyPaneCore) -> bool { .unwrap_or(false) } +/// Encode one `key` the way a Unix server's pane does after its app wrote +/// `app_output` (for example a Kitty keyboard push): the libghostty path, +/// without the Windows ConPTY record fallback (covered by the Windows harness). +#[cfg(test)] +pub(crate) fn test_encode_key_for_app( + app_output: &[u8], + key: crate::input::TerminalKey, +) -> Vec { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).expect("terminal"); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).expect("pane terminal"); + pane.process_pty_bytes(PaneId::from_raw(1), 0, app_output, &tx); + pane.encode_terminal_key_once(key) +} + #[cfg(test)] mod tests { use super::*; @@ -4721,13 +4755,10 @@ mod tests { let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('a'), - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(encoded, b"a"); } @@ -4744,16 +4775,15 @@ mod tests { terminal.write(format!("\x1b[>{flags}u").as_bytes()); } let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let protocol = pane.keyboard_protocol().unwrap(); for modifiers in [ crossterm::event::KeyModifiers::empty(), crossterm::event::KeyModifiers::SHIFT, ] { - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new(crossterm::event::KeyCode::BackTab, modifiers), - protocol, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::BackTab, + modifiers, + )); assert_eq!(encoded, expected, "backtab with modifiers {modifiers:?}"); } } @@ -4761,13 +4791,10 @@ mod tests { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Tab, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(encoded, b"\t"); } @@ -4781,18 +4808,12 @@ mod tests { crossterm::event::KeyModifiers::CONTROL, ); - assert_eq!( - legacy.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy), - b"\t" - ); + assert_eq!(legacy.encode_terminal_key(key.clone()), b"\t"); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.write(b"\x1b[>3u"); let kitty = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - assert_eq!( - kitty.encode_terminal_key(key, crate::input::KeyboardProtocol::Kitty { flags: 3 }), - b"\x1b[9;5u" - ); + assert_eq!(kitty.encode_terminal_key(key), b"\x1b[9;5u"); } #[cfg(unix)] @@ -4803,7 +4824,6 @@ mod tests { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let protocol = crate::input::KeyboardProtocol::Legacy; for modifiers in [ KeyModifiers::empty(), @@ -4816,25 +4836,29 @@ mod tests { KeyModifiers::ALT | KeyModifiers::CONTROL | KeyModifiers::SUPER, ] { let key = crate::input::TerminalKey::new(KeyCode::Enter, modifiers); - let expected = if modifiers.contains(KeyModifiers::ALT) { + // Super chords never reach a plain shell, so Cmd+Enter cannot run + // the command line. + let expected = if modifiers.contains(KeyModifiers::SUPER) { + b"".as_slice() + } else if modifiers.contains(KeyModifiers::ALT) { b"\x1b\r".as_slice() } else { b"\r".as_slice() }; for kind in [KeyEventKind::Press, KeyEventKind::Repeat] { assert_eq!( - pane.encode_terminal_key(key.clone().with_kind(kind), protocol), + pane.encode_terminal_key(key.clone().with_kind(kind)), expected, "{modifiers:?} {kind:?}" ); } assert_eq!( - pane.encode_terminal_key(key.clone().with_repeat_count(3), protocol), + pane.encode_terminal_key(key.clone().with_repeat_count(3)), expected.repeat(3), "{modifiers:?} grouped repeat" ); assert!( - pane.encode_terminal_key(key.with_kind(KeyEventKind::Release), protocol) + pane.encode_terminal_key(key.with_kind(KeyEventKind::Release)) .is_empty(), "{modifiers:?} release" ); @@ -4850,8 +4874,9 @@ mod tests { let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); let pane_id = PaneId::from_raw(1); - let legacy = ["\r", "\r", "\r", "\x1b\r"]; - let mode_one = ["\x1b[27;2;13~", "\x1b[27;5;13~", "\x1b[27;9;13~", "\x1b\r"]; + // Like Ghostty, modifyOtherKeys level 1 behaves as if nothing was negotiated. + // Super+Enter is not forwarded to panes without a keyboard protocol. + let legacy = ["\r", "\r", "", "\x1b\r"]; let mode_two = [ "\x1b[27;2;13~", "\x1b[27;5;13~", @@ -4862,7 +4887,7 @@ mod tests { for (sequence, expected) in [ ("", legacy), - ("\x1b[>4;1m", mode_one), + ("\x1b[>4;1m", legacy), ("\x1b[>4;2m", mode_two), ("\x1b[>4n", legacy), ("\x1b[>4;2m", mode_two), @@ -4872,7 +4897,7 @@ mod tests { ("\x1b[>4;2m\x1b[>1u", kitty), ("\x1b[4;0m", legacy), - ("\x1b[>4;1m", mode_one), + ("\x1b[>4;1m", legacy), ("\x1b[>04n", legacy), ("\x1b[>4;2m", mode_two), ("\x1b[>4", mode_two), @@ -4890,7 +4915,7 @@ mod tests { { let key = crate::input::TerminalKey::new(KeyCode::Enter, modifiers); assert_eq!( - pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), + pane.encode_terminal_key(key), expected.as_bytes(), "{modifiers:?} after {sequence:?}" ); @@ -4909,10 +4934,7 @@ mod tests { crossterm::event::KeyModifiers::SHIFT, ); - assert_eq!( - pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), - b"\x1b[27;2;13~" - ); + assert_eq!(pane.encode_terminal_key(key), b"\x1b[27;2;13~"); } #[test] @@ -4925,14 +4947,13 @@ mod tests { crossterm::event::KeyCode::Enter, crossterm::event::KeyCode::Backspace, ] { - let press = pane.encode_terminal_key( - crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()), - crate::input::KeyboardProtocol::Legacy, - ); + let press = pane.encode_terminal_key(crate::input::TerminalKey::new( + code, + crossterm::event::KeyModifiers::empty(), + )); let release = pane.encode_terminal_key( crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()) .with_kind(crossterm::event::KeyEventKind::Release), - crate::input::KeyboardProtocol::Legacy, ); assert!(!press.is_empty(), "{code:?} press should emit bytes"); assert!( @@ -4954,10 +4975,10 @@ mod tests { (crossterm::event::KeyCode::Enter, b"\r".as_slice()), (crossterm::event::KeyCode::Backspace, b"\x7f".as_slice()), ] { - let press = pane.encode_terminal_key( - crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()), - pane.keyboard_protocol().unwrap(), - ); + let press = pane.encode_terminal_key(crate::input::TerminalKey::new( + code, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!( press, expected, "{code:?} press should stay legacy-compatible without REPORT_ALL_KEYS" @@ -4966,7 +4987,6 @@ mod tests { let release = pane.encode_terminal_key( crate::input::TerminalKey::new(code, crossterm::event::KeyModifiers::empty()) .with_kind(crossterm::event::KeyEventKind::Release), - pane.keyboard_protocol().unwrap(), ); assert!( release.is_empty(), @@ -4976,21 +4996,173 @@ mod tests { } #[test] - fn ghostty_char_keys_still_use_herdr_encoding() { + fn ghostty_alt_letters_keep_esc_prefix_on_every_platform() { + // libghostty treats macOS Option as a text modifier unless told otherwise; + // Herdr has already decoded Alt from these host bytes. + for (host_bytes, expected) in [ + ("\x1bf", &b"\x1bf"[..]), + ("\x1bb", b"\x1bb"), + ("\x1bA", b"\x1bA"), + ("\x1b.", b"\x1b."), + ] { + let key = crate::input::parse_terminal_key_sequence(host_bytes).expect("alt key"); + assert_eq!( + test_encode_key_for_app(b"", key), + expected, + "{host_bytes:?}" + ); + } + } + + #[test] + fn hyper_and_meta_chords_are_never_typed_as_their_bare_key() { + // iTerm2 can report Ctrl as Meta (#4476): `CSI 97;33u`. Typing "a" + // would run a command in vim normal mode; libghostty has no Meta. + for host_bytes in ["\x1b[97;33u", "\x1b[120;17u"] { + let key = crate::input::parse_terminal_key_sequence(host_bytes).expect("kitty key"); + for app_output in [&b""[..], b"\x1b[>1u", b"\x1b[>31u"] { + assert!( + test_encode_key_for_app(app_output, key.clone()).is_empty(), + "{host_bytes:?} after {app_output:?}" + ); + } + } + } + + #[test] + fn super_chords_reach_only_panes_that_negotiated_a_keyboard_protocol() { + // Super+Space from a Kitty host (#4356) and Cmd+C (#3710). + // Super+Enter must not become a plain Enter that runs the command line. + for (host_bytes, kitty) in [ + ("\x1b[32;9u", &b"\x1b[32;9u"[..]), + ("\x1b[99;9u", b"\x1b[99;9u"), + ("\x1b[13;9u", b"\x1b[13;9u"), + ] { + let key = crate::input::parse_terminal_key_sequence(host_bytes).expect("kitty key"); + assert!( + test_encode_key_for_app(b"", key.clone()).is_empty(), + "{host_bytes:?} in a plain shell" + ); + assert_eq!( + test_encode_key_for_app(b"\x1b[>1u", key), + kitty, + "{host_bytes:?} in a Kitty pane" + ); + } + } + + #[test] + fn ctrl_shift_letters_reach_plain_shells_as_control_bytes() { + // Lab capture: Alacritty and WezTerm (with or without Kitty keyboard) + // send 0x01 for Ctrl+Shift+A; Ghostty's CSI u prints as garbage in a shell. + for (host_bytes, plain, kitty) in [ + ("\x1b[97;6u", &b"\x01"[..], &b"\x1b[97;6u"[..]), + ("\x1b[65;5u", b"\x01", b"\x1b[97;6u"), + ("\x1b[97;8u", b"\x1b\x01", b"\x1b[97;8u"), + ] { + let key = crate::input::parse_terminal_key_sequence(host_bytes).expect("kitty key"); + assert_eq!( + test_encode_key_for_app(b"", key.clone()), + plain, + "{host_bytes:?} in a plain shell" + ); + assert_eq!( + test_encode_key_for_app(b"\x1b[>1u", key), + kitty, + "{host_bytes:?} in a Kitty pane" + ); + } + } + + #[test] + fn ctrl_on_a_russian_layout_interrupts_a_plain_shell() { + // Lab capture: kitty reports Ctrl+\u{441} as `CSI 1089::99;5u`; kitty + // itself sends ^C to a plain shell. + let key = crate::input::parse_terminal_key_sequence("\x1b[1089::99;5u").expect("key"); + assert_eq!(test_encode_key_for_app(b"", key.clone()), b"\x03"); + assert_eq!(test_encode_key_for_app(b"\x1b[>1u", key), b"\x1b[99;5u"); + } + + #[test] + fn legacy_alt_prefixes_the_produced_non_ascii_text() { + // A legacy host's Alt+Shift+\u{f6}. libghostty on macOS would prefix the + // unshifted "\u{f6}" instead. + for host_bytes in ["\x1b\u{d6}", "\x1b\u{f6}"] { + let key = crate::input::parse_terminal_key_sequence(host_bytes).expect("alt key"); + assert_eq!( + test_encode_key_for_app(b"", key), + host_bytes.as_bytes(), + "{host_bytes:?}" + ); + } + } + + #[test] + fn ghostty_kitty_reports_keep_non_us_key_identity() { + // Ctrl++ on a German layout, where + is an unshifted key. The report + // already names the key; nothing may reinterpret it as US Shift+=. + let key = crate::input::parse_terminal_key_sequence("\x1b[43;5u").expect("kitty key"); + assert_eq!(test_encode_key_for_app(b"\x1b[>1u", key), b"\x1b[43;5u"); + } + + #[test] + fn ghostty_disambiguate_reports_ctrl_letters_as_kitty_keys() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.write(b"\x1b[>1u"); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('a'), - crossterm::event::KeyModifiers::CONTROL, - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::CONTROL, + )); - assert_eq!(encoded, vec![1]); + assert_eq!(encoded, b"\x1b[97;5u"); + } + + #[test] + fn ghostty_encodes_text_keys_with_layout_text() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + for (key, expected) in [ + ( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('D'), + crossterm::event::KeyModifiers::SHIFT, + ), + "D", + ), + ( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('%'), + crossterm::event::KeyModifiers::empty(), + ), + "%", + ), + ( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('ö'), + crossterm::event::KeyModifiers::empty(), + ) + .with_generated_text(Some("ö".to_owned())), + "ö", + ), + ( + crate::input::TerminalKey::new( + crossterm::event::KeyCode::Char('8'), + crossterm::event::KeyModifiers::CONTROL, + ), + "\x7f", + ), + ] { + assert_eq!( + pane.encode_terminal_key(key.clone()), + expected.as_bytes(), + "{key:?}" + ); + } } #[test] @@ -5002,13 +5174,10 @@ mod tests { .unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(encoded, b"\x1bOA"); } @@ -5048,17 +5217,14 @@ mod tests { ); assert_eq!(pane.modify_other_keys_level(), 2); - let encoded = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let encoded = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(encoded, b"\x1bOA"); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!(encoded, b"\x1b[27;2;13~"); let encoded = pane.encode_mouse_wheel( @@ -5080,10 +5246,7 @@ mod tests { ) .with_repeat_count(3); - assert_eq!( - pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), - b"xxx" - ); + assert_eq!(pane.encode_terminal_key(key), b"xxx"); let shifted = crate::input::TerminalKey::new( crossterm::event::KeyCode::Char('/'), @@ -5102,17 +5265,14 @@ mod tests { let legacy_expected = b"\x1b[55;8;47;1;16;3_".as_slice(); #[cfg(not(windows))] let legacy_expected = b"///".as_slice(); - assert_eq!( - pane.encode_terminal_key(shifted.clone(), crate::input::KeyboardProtocol::Legacy,), - legacy_expected - ); + assert_eq!(pane.encode_terminal_key(shifted.clone()), legacy_expected); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.write(b"\x1b[>15u"); let (tx, _rx) = mpsc::channel(4); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); assert_eq!( - pane.encode_terminal_key(shifted, crate::input::KeyboardProtocol::Kitty { flags: 15 },), - b"\x1b[47;2:1u\x1b[47;2:2u\x1b[47;2:2u" + pane.encode_terminal_key(shifted), + b"\x1b[47;2u\x1b[47;2:2u\x1b[47;2:2u" ); } @@ -5122,21 +5282,17 @@ mod tests { let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.write(b"\x1b[>11u"); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - let protocol = pane.keyboard_protocol().unwrap(); let release = crate::input::TerminalKey::new( crossterm::event::KeyCode::Up, crossterm::event::KeyModifiers::empty(), ) .with_kind(crossterm::event::KeyEventKind::Release); - let expected = pane.encode_terminal_key(release.clone(), protocol); + let expected = pane.encode_terminal_key(release.clone()); assert!(!expected.is_empty()); let mut malformed_release = release; malformed_release.repeat_count = 3; - assert_eq!( - pane.encode_terminal_key(malformed_release, protocol), - expected - ); + assert_eq!(pane.encode_terminal_key(malformed_release), expected); } #[test] @@ -5146,24 +5302,18 @@ mod tests { let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); let pane_id = PaneId::from_raw(1); - let before = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let before = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(before, b"\x1b[A"); pane.process_pty_bytes(pane_id, 0, b"\x1b[?1h", &tx); - let after = pane.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let after = pane.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(after, b"\x1bOA"); } @@ -5178,9 +5328,9 @@ mod tests { crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT, ); - let before = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let before = pane.encode_terminal_key(key.clone()); pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); - let after = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let after = pane.encode_terminal_key(key.clone()); assert_ne!(before, after); assert_eq!(after, b"\x1b[13;6u"); @@ -5195,7 +5345,7 @@ mod tests { pane.process_pty_bytes(pane_id, 0, b"\x1b[>5u", &tx); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!( pane.keyboard_protocol(), @@ -5213,7 +5363,7 @@ mod tests { pane.seed_keyboard_protocol_flags(5); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!( pane.keyboard_protocol(), @@ -5258,10 +5408,7 @@ mod tests { let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); - assert_eq!( - pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), - b"\x1b[13;28;13;1;16;1_" - ); + assert_eq!(pane.encode_terminal_key(key), b"\x1b[13;28;13;1;16;1_"); } #[cfg(windows)] @@ -5279,30 +5426,31 @@ mod tests { b"\x1b[13;28;13;1;16;1_".as_slice(), ), (crossterm::event::KeyModifiers::CONTROL, b"\r".as_slice()), - (crossterm::event::KeyModifiers::SUPER, b"\r".as_slice()), + // Super chords never reach a plain shell (no command-line submit). + (crossterm::event::KeyModifiers::SUPER, b"".as_slice()), (crossterm::event::KeyModifiers::ALT, b"\x1b\r".as_slice()), ] { let key = crate::input::TerminalKey::new(crossterm::event::KeyCode::Enter, modifiers); - assert_eq!( - pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy), - expected, - "{modifiers:?}" - ); + assert_eq!(pane.encode_terminal_key(key), expected, "{modifiers:?}"); } } #[test] - fn ghostty_modify_other_keys_mode_one_preserves_shift_enter() { + fn ghostty_modify_other_keys_mode_one_is_treated_as_unnegotiated() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); pane.seed_history_ansi("\x1b[>4;1m"); - assert_eq!(pane.modify_other_keys_level(), 1); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + assert_eq!(pane.modify_other_keys_level(), 0); + let encoded = pane.encode_terminal_key(key.clone()); - assert_eq!(encoded, b"\x1b[27;2;13~"); + // An unnegotiated Windows pane gets the native ConPTY record instead. + #[cfg(windows)] + assert_eq!(encoded, b"\x1b[13;28;13;1;16;1_"); + #[cfg(not(windows))] + assert_eq!(encoded, b"\r"); } #[test] @@ -5314,7 +5462,7 @@ mod tests { pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap(); - let encoded = pane.encode_terminal_key(key.clone(), crate::input::KeyboardProtocol::Legacy); + let encoded = pane.encode_terminal_key(key.clone()); assert_eq!(encoded, b"\x1b[127;3u"); } @@ -5331,7 +5479,7 @@ mod tests { let crate::raw_input::RawInputEvent::Key(key) = events.remove(0) else { panic!("expected key event"); }; - let encoded = pane.encode_terminal_key(key, pane.keyboard_protocol().unwrap()); + let encoded = pane.encode_terminal_key(key); assert_eq!(encoded, b"\x1b[102;7u"); } @@ -5349,22 +5497,13 @@ mod tests { crossterm::event::KeyCode::Backspace, crossterm::event::KeyModifiers::CONTROL, ); - assert_eq!( - legacy.encode_terminal_key( - ctrl_backspace.clone(), - crate::input::KeyboardProtocol::Legacy - ), - b"\x08" - ); + assert_eq!(legacy.encode_terminal_key(ctrl_backspace.clone()), b"\x08"); let plain_backspace = crate::input::TerminalKey::new( crossterm::event::KeyCode::Backspace, crossterm::event::KeyModifiers::empty(), ); - assert_eq!( - legacy.encode_terminal_key(plain_backspace, crate::input::KeyboardProtocol::Legacy), - b"\x7f" - ); + assert_eq!(legacy.encode_terminal_key(plain_backspace), b"\x7f"); let kitty = GhosttyPaneTerminal::new( crate::ghostty::Terminal::new(80, 24, 0).unwrap(), @@ -5374,10 +5513,7 @@ mod tests { let pane_id = PaneId::from_raw(1); kitty.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx); - assert_eq!( - kitty.encode_terminal_key(ctrl_backspace, crate::input::KeyboardProtocol::Legacy), - b"\x1b[127;5u" - ); + assert_eq!(kitty.encode_terminal_key(ctrl_backspace), b"\x1b[127;5u"); } #[test] @@ -5396,20 +5532,14 @@ mod tests { first.process_pty_bytes(PaneId::from_raw(1), 0, b"\x1b[?1h", &tx); - let first_encoded = first.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); - let second_encoded = second.encode_terminal_key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Up, - crossterm::event::KeyModifiers::empty(), - ), - crate::input::KeyboardProtocol::Legacy, - ); + let first_encoded = first.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); + let second_encoded = second.encode_terminal_key(crate::input::TerminalKey::new( + crossterm::event::KeyCode::Up, + crossterm::event::KeyModifiers::empty(), + )); assert_eq!(first_encoded, b"\x1bOA"); assert_eq!(second_encoded, b"\x1b[A"); @@ -5480,7 +5610,7 @@ mod tests { } #[test] - fn ghostty_mouse_sgr_pixels_preserves_exact_and_downgrades_cell_input() { + fn ghostty_mouse_sgr_pixels_preserves_exact_and_scales_cell_input() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); terminal.resize(80, 24, 10, 20).unwrap(); @@ -5499,7 +5629,7 @@ mod tests { ); assert_eq!(exact.as_deref(), Some(&b"\x1b[<35;48;139M"[..])); - assert_eq!(fallback.as_deref(), Some(&b"\x1b[<35;5;7M"[..])); + assert_eq!(fallback.as_deref(), Some(&b"\x1b[<35;45;130M"[..])); } #[test] @@ -6123,6 +6253,64 @@ mod tests { assert_eq!(row, "restored history"); } + #[test] + fn new_pane_terminal_defers_render_snapshot_and_keeps_baseline_colors() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(200, 60, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + let mut reference_state = crate::ghostty::RenderState::new().unwrap(); + reference_state + .update(&crate::ghostty::Terminal::new(1, 1, 0).unwrap()) + .unwrap(); + let reference = reference_state.colors().unwrap(); + + let core = pane.core.lock().unwrap(); + assert_eq!(core.render_state.rows().unwrap(), 0); + assert_eq!(core.render_state.cols().unwrap(), 0); + assert_eq!(core.initial_default_foreground, Some(reference.foreground)); + assert_eq!(core.initial_default_background, Some(reference.background)); + } + + #[test] + fn pane_created_large_renders_correctly_after_shrinking_before_first_draw() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(200, 60, 100).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + pane.seed_history_ansi("restored\r\n"); + pane.resize(5, 20, 0, 0); + { + let mut core = pane.core.lock().unwrap(); + core.terminal + .write(b"\x1b]10;#abcdef\x07\x1b]11;#123456\x07hi"); + } + + let backend = ratatui::backend::TestBackend::new(20, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), true)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let row = |y| (0..20).map(|x| buffer[(x, y)].symbol()).collect::(); + assert_eq!(row(0).trim_end(), "restored"); + assert_eq!(row(1).trim_end(), "hi"); + assert_eq!( + buffer[(0, 1)].style().fg, + Some(Color::Rgb(0xab, 0xcd, 0xef)) + ); + assert_eq!( + buffer[(0, 1)].style().bg, + Some(Color::Rgb(0x12, 0x34, 0x56)) + ); + assert_eq!( + buffer[(19, 4)].style().bg, + Some(Color::Rgb(0x12, 0x34, 0x56)) + ); + let cursor = pane.cursor_state().unwrap(); + assert_eq!((cursor.x, cursor.y), (2, 1)); + } + #[test] fn render_leaves_unknown_host_default_background_transparent() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/pane/terminal/windows_recent_fallback.rs b/src/pane/terminal/windows_recent_fallback.rs index 7f632c08..759e96b1 100644 --- a/src/pane/terminal/windows_recent_fallback.rs +++ b/src/pane/terminal/windows_recent_fallback.rs @@ -322,6 +322,8 @@ mod tests { #[test] fn deferred_refresh_replaces_visible_tail_after_repaint() { + let sequence = std::sync::atomic::AtomicU64::new(0); + let mut cache = crate::pane::agent_detection::DetectionTextCache::default(); let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); @@ -329,19 +331,28 @@ mod tests { pane.process_pty_bytes(pane_id, 0, b"older", &tx); pane.process_pty_bytes(pane_id, 0, b"\r\nold\r\nprompt", &tx); let mut core = pane.core.lock().unwrap(); - let _ = super::super::finish_recent_snapshot(&mut core, String::new(), 3, false); + cache.refresh(None, &sequence, || { + super::super::finish_detection_text(&mut core, String::new(), 3) + }); + assert!(cache.text.contains("prompt")); drop(core); pane.resize(2, 40, 8, 16); pane.process_pty_bytes(pane_id, 0, b"\rupdated", &tx); let mut core = pane.core.lock().unwrap(); - let resized = super::super::finish_recent_snapshot(&mut core, String::new(), 10, false); - assert_eq!(resized.text, "older\nold\nupdated\n"); + assert!(core.recent_fallback.needs_refresh); + assert!(cache.refresh(None, &sequence, || { + super::super::finish_detection_text(&mut core, String::new(), 10) + })); + assert!(!core.recent_fallback.needs_refresh); + assert_eq!(cache.text, "older\nold\nupdated\n"); drop(core); pane.process_pty_bytes(pane_id, 0, b"\x1b[2J\x1b[H", &tx); pane.process_pty_bytes(pane_id, 0, b"updated", &tx); let mut core = pane.core.lock().unwrap(); - let refreshed = super::super::finish_recent_snapshot(&mut core, String::new(), 10, false); - assert_eq!(refreshed.text, "older\nupdated\n"); + assert!(cache.refresh(None, &sequence, || { + super::super::finish_detection_text(&mut core, String::new(), 10) + })); + assert_eq!(cache.text, "older\nupdated\n"); } #[test] diff --git a/src/persist.rs b/src/persist.rs index be07a63b..0b88c5d7 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -12,6 +12,7 @@ mod writer; pub(crate) use self::io::{checkpoint_session_pair, clear_history, load, load_history}; pub use self::restore::restore; +pub(crate) use self::restore::restored_worktree_space_membership; #[cfg(unix)] pub use self::restore::{handoff_pane_aliases, restore_handoff}; pub use self::snapshot::{ diff --git a/src/persist/io.rs b/src/persist/io.rs index 95ff4012..876258d2 100644 --- a/src/persist/io.rs +++ b/src/persist/io.rs @@ -105,12 +105,17 @@ fn save_json_to_path(path: &Path, snapshot: &T) -> std::io: } let json = serde_json::to_string_pretty(snapshot)?; let tmp_path = target.with_extension("json.tmp"); - std::fs::write(&tmp_path, &json)?; - if let Err(err) = std::fs::rename(&tmp_path, &target) { + let mut temporary = std::fs::File::create(&tmp_path)?; + // A rename can survive a hard crash before unflushed file contents reach disk. + let result = temporary + .write_all(json.as_bytes()) + .and_then(|()| temporary.sync_all()); + drop(temporary); + if let Err(err) = result.and_then(|()| crate::platform::replace_file(&tmp_path, &target)) { let _ = std::fs::remove_file(&tmp_path); return Err(err); } - Ok(()) + crate::platform::sync_parent_directory(target.parent().unwrap_or_else(|| Path::new("."))) } pub(super) fn save_history_to_path( @@ -313,6 +318,52 @@ mod tests { assert!(history.contains("split-secret")); } + #[cfg(windows)] + #[test] + fn failed_replacement_preserves_saved_files_and_retries() { + use std::os::windows::fs::OpenOptionsExt; + + let (session_path, history_path) = temp_session_paths("failed-replacement"); + save_to_path(&session_path, &empty_snapshot()).unwrap(); + save_history_to_path(&history_path, Some(&history_snapshot("old history"))).unwrap(); + + for path in [&session_path, &history_path] { + let original = std::fs::read(path).unwrap(); + let locked = std::fs::OpenOptions::new() + .read(true) + .share_mode(windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ) + .open(path) + .unwrap(); + let mut changed = empty_snapshot(); + changed.selected = 7; + let save = || { + if path == &session_path { + save_to_path(path, &changed) + } else { + save_history_to_path(path, Some(&history_snapshot("new history"))) + } + }; + assert!(save().is_err()); + assert_eq!(std::fs::read(path).unwrap(), original); + assert!(!path.with_extension("json.tmp").exists()); + + drop(locked); + save().unwrap(); + assert!(!path.with_extension("json.tmp").exists()); + } + + assert_eq!( + parse_snapshot(&std::fs::read_to_string(&session_path).unwrap()) + .unwrap() + .selected, + 7 + ); + let history = std::fs::read_to_string(&history_path).unwrap(); + assert!(history.contains("new history")); + assert!(!history.contains("old history")); + std::fs::remove_dir_all(session_path.parent().unwrap()).unwrap(); + } + #[test] fn save_to_paths_removes_stale_history_when_history_is_disabled() { let (session_path, history_path) = temp_session_paths("clear-history"); diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs index 8b1efa2c..58c13596 100644 --- a/src/persist/plugin_registry.rs +++ b/src/persist/plugin_registry.rs @@ -16,7 +16,9 @@ fn registry_lock_path() -> PathBuf { crate::config::config_dir().join(REGISTRY_LOCK_FILE) } -fn with_registry_lock(operation: impl FnOnce() -> std::io::Result) -> std::io::Result { +pub(crate) fn with_registry_lock( + operation: impl FnOnce() -> std::io::Result, +) -> std::io::Result { let lock_path = registry_lock_path(); if let Some(parent) = lock_path.parent() { std::fs::create_dir_all(parent)?; @@ -64,14 +66,7 @@ fn save_json_to_path(path: &Path, value: &T) -> st let json = serde_json::to_string_pretty(value)?; let tmp_path = path.with_extension("json.tmp"); std::fs::write(&tmp_path, json)?; - #[cfg(windows)] - if path.exists() { - if let Err(err) = std::fs::remove_file(path) { - let _ = std::fs::remove_file(&tmp_path); - return Err(err); - } - } - if let Err(err) = std::fs::rename(&tmp_path, path) { + if let Err(err) = crate::platform::replace_file(&tmp_path, path) { let _ = std::fs::remove_file(&tmp_path); return Err(err); } @@ -95,7 +90,13 @@ pub fn update( } pub fn try_load() -> std::io::Result> { - with_registry_lock(|| load_from_path_strict(®istry_path())) + read(Ok) +} + +pub(crate) fn read( + operation: impl FnOnce(Vec) -> std::io::Result, +) -> std::io::Result { + with_registry_lock(|| operation(load_from_path_strict(®istry_path())?)) } /// Load the global registry. Returns an empty vec on failure so a corrupt or @@ -436,4 +437,15 @@ mod tests { assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].plugin_id, "example.second"); } + + #[test] + fn failed_save_preserves_previous_registry() { + let path = temp_registry_path("failed-save"); + save_to_path(&path, &[sample_plugin("example.original")]).unwrap(); + let original = std::fs::read(&path).unwrap(); + std::fs::create_dir(path.with_extension("json.tmp")).unwrap(); + assert!(save_to_path(&path, &[sample_plugin("example.replacement")]).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), original); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } } diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 71a48cbc..dff23040 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -442,10 +442,6 @@ fn restore_workspace( return (None, failed_imports); } - let worktree_space = restored_worktree_space_membership(snap.worktree_space.clone()); - let (cached_git_space, cached_auto_label, cached_git_status_key) = - crate::workspace::discover_workspace_git_identity(&snap.identity_cwd); - ( Some(Workspace { id: workspace_id, @@ -453,12 +449,14 @@ fn restore_workspace( custom_name: snap.custom_name.clone(), identity_cwd: snap.identity_cwd.clone(), cached_identity_cwd: snap.identity_cwd.clone(), - cached_auto_label, - cached_git_status_key, - cached_git_branch: crate::workspace::git_branch(&snap.identity_cwd), + // Repository metadata is optional and may block on unavailable + // storage. The app refreshes it after restoring the session. + cached_auto_label: crate::workspace::fallback_label_from_cwd(&snap.identity_cwd), + cached_git_status_key: snap.identity_cwd.clone(), + cached_git_branch: None, cached_git_ahead_behind: None, - cached_git_space, - worktree_space, + cached_git_space: None, + worktree_space: snap.worktree_space.clone(), metadata_tokens: crate::metadata_tokens::MetadataTokens::default(), metadata_token_sequences: HashMap::new(), public_pane_numbers, @@ -505,13 +503,20 @@ fn unavailable_restored_terminal( terminal } -fn restored_worktree_space_membership( +pub(crate) fn restored_worktree_space_membership( space: Option, ) -> Option { space.filter(|space| { - space.checkout_path.exists() - && crate::workspace::git_space_metadata(&space.checkout_path) - .is_some_and(|current| current.key == space.key) + crate::workspace::git_space_metadata(&space.checkout_path).is_none_or(|current| { + // Discovery may find an ancestor repo, or fall back to a per-worktree + // Git directory without objects when common-directory metadata is unreadable. + current.checkout_key + != crate::worktree::canonical_or_original(&space.checkout_path) + .display() + .to_string() + || current.key == space.key + || !std::path::Path::new(¤t.key).join("objects").is_dir() + }) }) } @@ -1119,18 +1124,80 @@ mod tests { } #[test] - fn restored_worktree_space_membership_drops_missing_checkout() { - let missing = - std::env::temp_dir().join(format!("herdr-missing-worktree-{}", std::process::id())); + fn restored_worktree_space_membership_preserves_unavailable_checkout_and_rejects_replacement() { + let root = std::env::temp_dir().join(format!( + "herdr-restored-worktree-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(root.join(".git/objects")).unwrap(); + std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + let ancestor = crate::workspace::git_space_metadata(&root).unwrap(); + let checkout = root.join("checkout"); let membership = crate::workspace::WorktreeSpaceMembership { - key: "repo-key".into(), + key: "original-repo-key".into(), label: "herdr".into(), - repo_root: missing.join("repo"), - checkout_path: missing.join("checkout"), + repo_root: root.join("original-repo"), + checkout_path: checkout.clone(), is_linked_worktree: true, }; - assert_eq!(restored_worktree_space_membership(Some(membership)), None); + // Missing and leftover directories may discover an unrelated ancestor repo. + assert_eq!( + restored_worktree_space_membership(Some(membership.clone())), + Some(membership.clone()) + ); + std::fs::create_dir_all(&checkout).unwrap(); + assert_eq!( + restored_worktree_space_membership(Some(membership.clone())), + Some(membership.clone()) + ); + + // A real repository at the saved checkout path can prove replacement. + std::fs::create_dir_all(checkout.join(".git/objects")).unwrap(); + std::fs::write(checkout.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + assert_eq!( + restored_worktree_space_membership(Some(membership.clone())), + None + ); + let current = crate::workspace::git_space_metadata(&checkout).unwrap(); + assert_ne!(current.key, ancestor.key); + let matching = crate::workspace::WorktreeSpaceMembership { + key: current.key, + ..membership.clone() + }; + assert_eq!( + restored_worktree_space_membership(Some(matching.clone())), + Some(matching) + ); + + // Partial linked-worktree metadata is not evidence of replacement. + std::fs::remove_dir_all(checkout.join(".git")).unwrap(); + let git_dir = root.join(".git/worktrees/restored"); + std::fs::create_dir_all(&git_dir).unwrap(); + std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write( + checkout.join(".git"), + format!("gitdir: {}\n", git_dir.display()), + ) + .unwrap(); + let linked = crate::workspace::WorktreeSpaceMembership { + key: ancestor.key, + ..membership.clone() + }; + assert_eq!( + restored_worktree_space_membership(Some(linked.clone())), + Some(linked) + ); + std::fs::remove_dir_all(root).unwrap(); + assert!(crate::workspace::git_space_metadata(&checkout).is_none()); + assert_eq!( + restored_worktree_space_membership(Some(membership.clone())), + Some(membership) + ); } #[test] @@ -1458,6 +1525,14 @@ mod tests { } } } + let membership = crate::workspace::WorktreeSpaceMembership { + key: "saved-repo-key".into(), + label: "saved-repo".into(), + repo_root: cwd.clone(), + checkout_path: missing.clone(), + is_linked_worktree: true, + }; + snapshot.workspaces[0].worktree_space = Some(membership.clone()); let failed = snapshot.workspaces[0].tabs[0].panes.get_mut(&1).unwrap(); failed.cwd = missing.clone(); failed.label = Some("keep my pane".into()); @@ -1493,6 +1568,11 @@ mod tests { "a launch failure must not delete a workspace" ); assert_eq!(captured.workspaces[0].tabs.len(), 2); + assert_eq!( + captured.workspaces[0].worktree_space, + Some(membership), + "an unavailable checkout must retain its saved repository group across restart" + ); let pane = captured.workspaces[0].tabs[0] .panes .values() diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index ebc73bb2..5a74062e 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -13,6 +13,16 @@ pub(crate) fn set_default_plugin_pane_pwd( ) { } +#[cfg(unix)] +pub(super) fn socket_peer_pid(_fd: std::os::fd::RawFd) -> Option { + None +} + +#[cfg(unix)] +pub(super) fn process_name_and_parent(_pid: u32) -> Option<(String, u32)> { + None +} + #[cfg(unix)] pub(super) const REMOTE_BRIDGE_CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC; @@ -228,6 +238,16 @@ pub fn session_processes(_child_pid: u32) -> Vec { /// Unsupported platform stub. pub fn signal_processes(_pids: &[u32], _signal: Signal) {} +/// Unsupported platform stub. +pub fn process_start_token(_pid: u32) -> Option { + None +} + +/// Unsupported platform stub. +pub fn live_pane_process_group(_shell_pid: u32, _pid: u32, _start_token: u64) -> Option { + None +} + /// Unsupported platform stub. pub fn process_exists(_pid: u32) -> bool { false @@ -243,6 +263,11 @@ pub fn read_clipboard_text() -> Option { None } +/// Unsupported platform stub. +pub fn clipboard_text_matches(_bytes: &[u8]) -> Option { + None +} + /// Unsupported platform stub. pub fn open_url(_url: &str) -> std::io::Result> { Err(std::io::Error::new( diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 2cda5251..1c6f4568 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -664,6 +664,78 @@ pub fn foreground_process_group_id_for_tty_fd(fd: RawFd) -> Option { (pgid > 0).then_some(pgid as u32) } +/// Start time of `pid` in clock ticks since boot. It tells a process apart +/// from a later one that reuses its pid. +pub fn process_start_token(pid: u32) -> Option { + process_job_stat(pid).map(|stat| stat.start_time) +} + +/// Process group of `pid` while that same process, matched by its start +/// token, is alive in the terminal session of the pane shell `shell_pid`. +/// A stopped or backgrounded job still counts. +pub fn live_pane_process_group(shell_pid: u32, pid: u32, start_token: u64) -> Option { + let stat = process_job_stat(pid)?; + let shell_session = process_job_stat(shell_pid)?.session; + (!matches!(stat.state, 'Z' | 'X') + && stat.start_time == start_token + && stat.session == shell_session + && stat.pgrp > 0) + .then_some(stat.pgrp as u32) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ProcessJobStat { + state: char, + pgrp: i32, + session: i32, + start_time: u64, +} + +fn process_job_stat(pid: u32) -> Option { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + process_job_stat_from_stat(&stat) +} + +fn process_job_stat_from_stat(stat: &str) -> Option { + let rest = stat.get(stat.rfind(')')? + 2..)?; + let fields: Vec<&str> = rest.split_whitespace().collect(); + // After (comm): state(0) ppid(1) pgrp(2) session(3) ... starttime(19) + Some(ProcessJobStat { + state: fields.first()?.chars().next()?, + pgrp: fields.get(2)?.parse().ok()?, + session: fields.get(3)?.parse().ok()?, + start_time: fields.get(19)?.parse().ok()?, + }) +} + +pub(super) fn socket_peer_pid(fd: RawFd) -> Option { + let mut cred: libc::ucred = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&mut cred as *mut libc::ucred).cast(), + &mut len, + ) + }; + (result == 0 && cred.pid > 0).then_some(cred.pid as u32) +} + +pub(super) fn process_name_and_parent(pid: u32) -> Option<(String, u32)> { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let close = stat.rfind(')')?; + let name = stat.get(1 + stat.find('(')?..close)?.to_string(); + let parent = stat + .get(close + 2..)? + .split_whitespace() + .nth(1)? + .parse() + .ok()?; + Some((name, parent)) +} + fn process_pgrp_comm_and_state(pid: u32) -> Option<(i32, String, char)> { let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; process_pgrp_comm_and_state_from_stat(&stat) @@ -795,6 +867,10 @@ pub fn read_clipboard_text() -> Option { None } +pub fn clipboard_text_matches(_bytes: &[u8]) -> Option { + None +} + pub fn open_url(url: &str) -> std::io::Result> { Command::new("xdg-open") .arg(url) @@ -1169,6 +1245,17 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + #[test] + fn socket_peer_resolves_to_the_connecting_process_and_its_parent() { + use std::os::fd::AsRawFd as _; + + let (local, _peer) = std::os::unix::net::UnixStream::pair().unwrap(); + let pid = std::process::id(); + assert_eq!(socket_peer_pid(local.as_raw_fd()), Some(pid)); + let (_, parent) = process_name_and_parent(pid).unwrap(); + assert_eq!(parent, std::os::unix::process::parent_id()); + } + #[test] fn wsl_marker_detection_matches_kernel_release_text() { assert!(text_indicates_wsl("5.15.167.4-microsoft-standard-WSL2")); @@ -1572,6 +1659,22 @@ mod tests { ); } + #[test] + fn proc_stat_parsing_reads_job_fields_and_start_time() { + let stat = + "123 (node ) x) T 1 456 789 34816 456 4194560 1 2 3 4 5 6 7 8 20 0 11 0 987654 0 0"; + assert_eq!( + process_job_stat_from_stat(stat), + Some(ProcessJobStat { + state: 'T', + pgrp: 456, + session: 789, + start_time: 987654, + }) + ); + assert_eq!(process_job_stat_from_stat("123 (short) S 1 456 789"), None); + } + #[test] fn foreground_job_does_not_read_remote_memory_for_uninterruptible_members() { let argv_reads = RefCell::new(Vec::new()); diff --git a/src/platform/linux/shutdown.rs b/src/platform/linux/shutdown.rs index b678161a..b201bc09 100644 --- a/src/platform/linux/shutdown.rs +++ b/src/platform/linux/shutdown.rs @@ -6,6 +6,8 @@ use futures_util::StreamExt; pub(crate) fn monitor_host_shutdown( requested: Arc, + // logind has no cancellable announcement to carry across handoff. + _intent: crate::platform::HostShutdownIntentCell, wake: impl Fn() + Send + Sync + 'static, ) -> Option> { Some(tokio::spawn(async move { diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 79a96d78..7c72253f 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -26,9 +26,18 @@ pub(crate) use super::unix_common::{ mod bootstrap; pub(crate) use bootstrap::{configure_server_daemon_context, prepare_server_process}; +mod shutdown; +pub(crate) use shutdown::monitor_host_shutdown; + #[cfg(test)] mod config_file_tests; +#[cfg(test)] +mod input_tests; + +mod input; +pub(crate) use input::PtyInputGuard; + pub(crate) fn config_file_link_count(path: &Path) -> std::io::Result { use std::os::unix::fs::MetadataExt; Ok(std::fs::metadata(path)?.nlink()) @@ -672,6 +681,10 @@ pub fn read_clipboard_text() -> Option { } } +pub fn clipboard_text_matches(_bytes: &[u8]) -> Option { + None +} + pub fn open_url(url: &str) -> std::io::Result> { Command::new("open") .arg(url) @@ -908,6 +921,50 @@ fn run_clipboard_command(command: &ClipboardCommand, bytes: &[u8]) -> bool { child.wait().map(|status| status.success()).unwrap_or(false) } +/// Start time of `pid` in microseconds. It tells a process apart from a later +/// one that reuses its pid. +pub fn process_start_token(pid: u32) -> Option { + process_bsdinfo(pid).map(|info| bsdinfo_start_token(&info)) +} + +/// Process group of `pid` while that same process, matched by its start +/// token, is alive on the terminal of the pane shell `shell_pid`. A stopped or +/// backgrounded job still counts. +pub fn live_pane_process_group(shell_pid: u32, pid: u32, start_token: u64) -> Option { + let process = process_bsdinfo(pid)?; + let shell = process_bsdinfo(shell_pid)?; + (process.pbi_status != libc::SZOMB + && bsdinfo_start_token(&process) == start_token + && process.e_tdev == shell.e_tdev) + .then_some(process.pbi_pgid) +} + +fn bsdinfo_start_token(info: &libc::proc_bsdinfo) -> u64 { + info.pbi_start_tvsec + .saturating_mul(1_000_000) + .saturating_add(info.pbi_start_tvusec) +} + +pub(super) fn socket_peer_pid(fd: RawFd) -> Option { + let mut pid: libc::pid_t = 0; + let mut len = std::mem::size_of::() as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + fd, + libc::SOL_LOCAL, + libc::LOCAL_PEERPID, + (&mut pid as *mut libc::pid_t).cast(), + &mut len, + ) + }; + (result == 0 && pid > 0).then_some(pid as u32) +} + +pub(super) fn process_name_and_parent(pid: u32) -> Option<(String, u32)> { + let info = process_bsdinfo(pid)?; + Some((comm_from_bsdinfo(&info)?, info.pbi_ppid)) +} + fn process_bsdinfo(pid: u32) -> Option { let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; let size = std::mem::size_of::() as libc::c_int; diff --git a/src/platform/macos/input.rs b/src/platform/macos/input.rs new file mode 100644 index 00000000..ec80f524 --- /dev/null +++ b/src/platform/macos/input.rs @@ -0,0 +1,143 @@ +//! Preflight for submissions that overflow Darwin's canonical line discipline. + +use std::os::fd::RawFd; +use std::sync::{Arc, Weak}; + +/// Samples the actor's terminal without keeping its master descriptor open. +#[derive(Clone, Default)] +pub(crate) struct PtyInputGuard(Weak); + +impl PtyInputGuard { + pub(crate) fn new(master: &Arc) -> Self { + Self(Arc::downgrade(master)) + } + + /// Check the current terminal mode before an API command enters the queue. + pub(crate) fn validate(&self, bytes: &[u8]) -> std::io::Result<()> { + use std::os::fd::AsRawFd; + + let master = self.0.upgrade().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed") + })?; + validate_pty_submission(master.as_raw_fd(), bytes, &[]) + } +} + +/// Validate encoded input against the terminal mode sampled at admission. +/// This bounds the current submission, not earlier incomplete input or later +/// mode changes while accepted input waits in the actor's existing queue. +pub(crate) fn validate_pty_submission(fd: RawFd, text: &[u8], enter: &[u8]) -> std::io::Result<()> { + let mut termios = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(fd, termios.as_mut_ptr()) } != 0 { + let err = std::io::Error::last_os_error(); + // Non-terminal transports have no canonical line discipline. + return if matches!(err.raw_os_error(), Some(libc::ENOTTY | libc::EOPNOTSUPP)) { + Ok(()) + } else { + Err(err) + }; + } + let termios = unsafe { termios.assume_init() }; + if termios.c_lflag & libc::ICANON == 0 { + return Ok(()); + } + let limit = unsafe { libc::fpathconf(fd, libc::_PC_MAX_CANON) }; + if limit <= 0 { + return Err(std::io::Error::other( + "could not determine canonical input capacity", + )); + } + let line_len = longest_canonical_line(&termios, text.iter().chain(enter).copied()); + if line_len > limit as usize { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("input contains a {line_len}-byte line but the terminal is in canonical mode with a {limit}-byte limit; wait for the shell prompt and retry"), + )); + } + Ok(()) +} + +/// Count an upper bound between effective delimiters. Editing controls do not +/// reduce the bound: this is a preflight, not a shadow of the kernel's queue. +fn longest_canonical_line(termios: &libc::termios, input: impl Iterator) -> usize { + let iflag = termios.c_iflag; + let lflag = termios.c_lflag; + let extproc = lflag & libc::EXTPROC != 0; + let extended = lflag & libc::IEXTEN != 0; + let matches_control = + |byte: u8, index: usize| byte != libc::_POSIX_VDISABLE && byte == termios.c_cc[index]; + let mut quoted = false; + let mut line_len = 0usize; + let mut longest = 0; + for mut byte in input { + if iflag & libc::ISTRIP != 0 { + byte &= 0x7f; + } + let was_quoted = std::mem::take(&mut quoted); + let mut delimiter = false; + if !extproc && !was_quoted { + if extended && matches_control(byte, libc::VLNEXT) { + quoted = true; + continue; + } + // Controls consumed before CR translation / line-break detection + // cannot become delimiters, even with custom control characters. + let consumed = (extended && matches_control(byte, libc::VDISCARD)) + || (lflag & libc::ISIG != 0 + && [libc::VINTR, libc::VQUIT, libc::VSUSP] + .into_iter() + .any(|index| matches_control(byte, index))) + || (iflag & libc::IXON != 0 + && [libc::VSTART, libc::VSTOP] + .into_iter() + .any(|index| matches_control(byte, index))); + if consumed { + continue; + } + if byte == b'\r' { + if iflag & libc::IGNCR != 0 { + continue; + } + if iflag & libc::ICRNL != 0 { + byte = b'\n'; + } + } else if byte == b'\n' && iflag & libc::INLCR != 0 { + byte = b'\r'; + } + } + if !was_quoted { + let editing = !extproc + && ([libc::VERASE, libc::VKILL] + .into_iter() + .any(|index| matches_control(byte, index)) + || (extended + && [libc::VWERASE, libc::VREPRINT, libc::VSTATUS] + .into_iter() + .any(|index| matches_control(byte, index)))); + if editing { + continue; + } + delimiter = byte == b'\n' + || matches_control(byte, libc::VEOF) + || matches_control(byte, libc::VEOL) + || (extended && matches_control(byte, libc::VEOL2)); + } + // Darwin escapes an unstripped 0xff when PARMRK is active. + let width = if !was_quoted + && byte == 0xff + && iflag & libc::PARMRK != 0 + && iflag & libc::ISTRIP == 0 + && iflag & (libc::IGNBRK | libc::IGNPAR) != (libc::IGNBRK | libc::IGNPAR) + { + 2 + } else { + 1 + }; + line_len = line_len.saturating_add(width); + longest = longest.max(line_len); + if delimiter { + line_len = 0; + } + } + longest +} diff --git a/src/platform/macos/input_tests.rs b/src/platform/macos/input_tests.rs new file mode 100644 index 00000000..5d73c248 --- /dev/null +++ b/src/platform/macos/input_tests.rs @@ -0,0 +1,387 @@ +use std::io::{Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::sync::mpsc; +use std::time::Duration; + +use bytes::Bytes; + +use crate::api::schema::{AgentStartParams, Method, PaneSendInputParams, Request}; +use crate::app::{App, AppPolicy, AppState}; +use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyReadResult}; +use crate::terminal::{TerminalId, TerminalRuntime}; + +fn pty_pair() -> (OwnedFd, std::fs::File) { + let mut master = -1; + let mut slave = -1; + assert_eq!( + unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + ( + unsafe { OwnedFd::from_raw_fd(master) }, + std::fs::File::from(unsafe { OwnedFd::from_raw_fd(slave) }), + ) +} + +fn set_canonical(slave: &std::fs::File, canonical: bool) { + let mut termios = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { libc::tcgetattr(slave.as_raw_fd(), termios.as_mut_ptr()) }, + 0 + ); + let mut termios = unsafe { termios.assume_init() }; + termios.c_lflag &= !(libc::ICANON | libc::ECHO); + if canonical { + termios.c_lflag |= libc::ICANON; + } + termios.c_cc[libc::VMIN] = 0; + termios.c_cc[libc::VTIME] = 20; + assert_eq!( + unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &termios) }, + 0 + ); +} + +fn read_line(slave: &mut std::fs::File) -> Vec { + let mut poll = libc::pollfd { + fd: slave.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + assert_eq!(unsafe { libc::poll(&mut poll, 1, 2000) }, 1); + let mut received = vec![0; 2048]; + let count = slave.read(&mut received).unwrap(); + received.truncate(count); + received +} + +fn app_with_actor(handle: crate::pty::actor::PtyIoActorHandle) -> (App, String, TerminalId) { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state = AppState::test_with_adversarial_identity_state(); + let pane = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].terminal_id(pane).unwrap().clone(); + app.terminal_runtimes.insert( + terminal_id.clone(), + TerminalRuntime::test_with_actor(handle), + ); + let pane_id = app.public_pane_id(0, pane).unwrap(); + app.state.assert_invariants_for_test(); + (app, pane_id, terminal_id) +} + +fn start_request(pane_id: &str, argument: &str) -> Request { + Request { + id: "start".into(), + method: Method::AgentStart(AgentStartParams { + name: "worker".into(), + kind: "codex".into(), + pane_id: pane_id.into(), + args: vec!["resume".into(), argument.into()], + timeout_ms: Some(10_000), + }), + } +} + +fn pane_request(pane_id: &str, text: &str) -> Request { + Request { + id: "input".into(), + method: Method::PaneSendInput(PaneSendInputParams { + pane_id: pane_id.into(), + text: text.into(), + keys: vec!["enter".into()], + }), + } +} + +#[tokio::test] +async fn canonical_api_rejection_writes_nothing_and_allows_same_name_retry() { + for agent_start in [false, true] { + let (master, mut slave) = pty_pair(); + set_canonical(&slave, true); + let limit = unsafe { libc::fpathconf(slave.as_raw_fd(), libc::_PC_MAX_CANON) } as usize; + assert_eq!(limit, 1024); + let handle = PtyIoActor::spawn(PtyIoActorConfig { + pane_id: 1, + master_fd: master, + initially_quiesced: false, + on_read: Box::new(|_| PtyReadResult::empty()), + on_reader_exit: None, + }) + .unwrap(); + let (mut app, pane_id, terminal_id) = app_with_actor(handle); + // Check encoded bytes, including Enter, and reject the entire input even + // when a preceding line would fit. Agent arguments cannot contain LF. + let rejected = if agent_start { + vec!["é".repeat(limit / 2)] + } else { + vec![ + "é".repeat(limit / 2), + "x".repeat(limit), + format!("first line\n{}", "x".repeat(limit)), + ] + }; + for text in rejected { + let request = if agent_start { + start_request(&pane_id, &text) + } else { + pane_request(&pane_id, &text) + }; + let response: serde_json::Value = + serde_json::from_str(&app.handle_api_request(request)).unwrap(); + assert_eq!( + response["error"]["code"], + if agent_start { + "agent_start_input_failed" + } else { + "pane_send_failed" + } + ); + assert!(response["error"]["message"] + .as_str() + .unwrap() + .contains("canonical mode")); + let terminal = &app.state.terminals[&terminal_id]; + assert!(terminal.agent_name.is_none()); + assert!(terminal.managed_agent_kind().is_none()); + assert!(terminal.persisted_agent_session.is_none()); + app.state.assert_invariants_for_test(); + } + let request = if agent_start { + start_request(&pane_id, "retry") + } else { + pane_request(&pane_id, "retry") + }; + let response: serde_json::Value = + serde_json::from_str(&app.handle_api_request(request)).unwrap(); + assert!(response.get("error").is_none(), "{response}"); + let received = String::from_utf8(read_line(&mut slave)).unwrap(); + assert!(received.contains("retry")); + assert!(!received.contains("first line") && !received.contains('é')); + if agent_start { + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + Some("worker") + ); + } else { + assert_eq!(received, "retry\n"); + let text = "x".repeat(limit - 1); + let response: serde_json::Value = + serde_json::from_str(&app.handle_api_request(pane_request(&pane_id, &text))) + .unwrap(); + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(read_line(&mut slave), format!("{text}\n").as_bytes()); + } + app.state.assert_invariants_for_test(); + } +} + +#[tokio::test] +async fn input_admission_does_not_wait_for_actor_and_raw_input_stays_intact() { + let (master, mut slave) = pty_pair(); + set_canonical(&slave, true); + let (entered_tx, entered_rx) = mpsc::channel(); + let (resume_tx, resume_rx) = mpsc::channel(); + let mut barrier = Some((entered_tx, resume_rx)); + let handle = PtyIoActor::spawn(PtyIoActorConfig { + pane_id: 1, + master_fd: master, + initially_quiesced: false, + on_read: Box::new(move |_| { + if let Some((entered, resume)) = barrier.take() { + let _ = entered.send(()); + let _ = resume.recv(); + } + PtyReadResult::empty() + }), + on_reader_exit: None, + }) + .unwrap(); + let (mut app, pane_id, _) = app_with_actor(handle); + slave.write_all(b"barrier").unwrap(); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let text = "x".repeat(3017); + let response: serde_json::Value = + serde_json::from_str(&app.handle_api_request(pane_request(&pane_id, &text))).unwrap(); + assert_eq!(response["error"]["code"], "pane_send_failed"); + set_canonical(&slave, false); + let response: serde_json::Value = + serde_json::from_str(&app.handle_api_request(pane_request(&pane_id, &text))).unwrap(); + assert!(response.get("error").is_none(), "{response}"); + // Both rejection and successful admission return while the actor is held. + resume_tx.send(()).unwrap(); + let mut received = vec![0; text.len() + 1]; + slave.read_exact(&mut received).unwrap(); + assert_eq!(received, format!("{text}\n").as_bytes()); +} + +#[test] +fn input_guard_does_not_keep_the_master_open() { + let (master, slave) = pty_pair(); + set_canonical(&slave, true); + let owner = std::sync::Arc::new(std::fs::File::from(master)); + let guard = super::PtyInputGuard::new(&owner); + assert_eq!(std::sync::Arc::strong_count(&owner), 1); + guard.validate(b"valid\n").unwrap(); + drop(owner); + assert_eq!( + guard.validate(b"valid\n").unwrap_err().kind(), + std::io::ErrorKind::BrokenPipe + ); + assert_eq!( + guard.clone().validate(b"valid\n").unwrap_err().kind(), + std::io::ErrorKind::BrokenPipe + ); +} + +#[test] +fn canonical_multiline_submission_preserves_each_line() { + for delimiter in [b'\n', b'\r', b'|', 4] { + let (master, mut slave) = pty_pair(); + set_canonical(&slave, true); + let mut settings = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { libc::tcgetattr(slave.as_raw_fd(), settings.as_mut_ptr()) }, + 0 + ); + let mut settings = unsafe { settings.assume_init() }; + settings.c_cc[libc::VEOL] = b'|'; + settings.c_cc[libc::VEOF] = 4; + settings.c_iflag |= libc::ICRNL; + settings.c_iflag &= !(libc::IGNCR | libc::INLCR); + assert_eq!( + unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &settings) }, + 0 + ); + let handle = PtyIoActor::spawn(PtyIoActorConfig { + pane_id: 1, + master_fd: master, + initially_quiesced: false, + on_read: Box::new(|_| PtyReadResult::empty()), + on_reader_exit: None, + }) + .unwrap(); + let line = [vec![b'x'; 600], vec![delimiter]].concat(); + let bytes = line.repeat(2); + let expected = bytes + .iter() + .copied() + .filter(|byte| *byte != 4) + .map(|byte| if byte == b'\r' { b'\n' } else { byte }) + .collect::>(); + let reader = std::thread::spawn(move || { + let mut received = Vec::new(); + while received.len() < expected.len() { + received.extend(read_line(&mut slave)); + } + assert_eq!(received, expected); + }); + handle.validate_input_submission(&bytes).unwrap(); + handle.try_write_user_input(Bytes::from(bytes)).unwrap(); + reader.join().unwrap(); + handle.shutdown(); + } +} + +#[test] +fn canonical_preflight_respects_quoted_and_translated_delimiters() { + let (master, slave) = pty_pair(); + set_canonical(&slave, true); + let mut settings = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { libc::tcgetattr(slave.as_raw_fd(), settings.as_mut_ptr()) }, + 0 + ); + let original = unsafe { settings.assume_init() }; + let prefix = vec![b'x'; 600]; + let suffix = vec![b'y'; 600]; + for (middle, setting) in [ + (vec![b'\n'], 0), + (vec![b'\r'], 1), + (vec![22, b'\n'], 2), + (vec![b'\n'], 3), + (vec![b'\n'], 4), + (vec![150, b'\n'], 5), + (vec![b'|'], 6), + ] { + let mut settings = original; + settings.c_lflag |= libc::IEXTEN | libc::ISIG; + settings.c_cc[libc::VLNEXT] = 22; + match setting { + 0 => settings.c_iflag |= libc::INLCR, + 1 => settings.c_iflag |= libc::IGNCR, + 2 => {} + 3 => settings.c_cc[libc::VINTR] = b'\n', + 4 => settings.c_cc[libc::VERASE] = b'\n', + 5 => settings.c_iflag |= libc::ISTRIP, + 6 => { + settings.c_cc[libc::VEOL] = libc::_POSIX_VDISABLE; + settings.c_cc[libc::VEOL2] = b'|'; + settings.c_lflag &= !libc::IEXTEN; + } + _ => unreachable!(), + } + assert_eq!( + unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &settings) }, + 0 + ); + let input = [prefix.clone(), middle, suffix.clone()].concat(); + let result = super::input::validate_pty_submission(master.as_raw_fd(), &input, b"\n"); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput); + } + + // A quoted newline in the Enter part must not reset a line begun in text. + let mut settings = original; + settings.c_lflag |= libc::IEXTEN; + settings.c_cc[libc::VLNEXT] = 22; + assert_eq!( + unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &settings) }, + 0 + ); + let text = [prefix.clone(), vec![22]].concat(); + let enter = [vec![b'\n'], suffix.clone(), vec![b'\n']].concat(); + assert!(super::input::validate_pty_submission(master.as_raw_fd(), &text, &enter).is_err()); + // EXTPROC bypasses quoting and translations, but still recognizes line breaks. + let external_processing: libc::c_int = 1; + assert_eq!( + unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCEXT, &external_processing) }, + 0 + ); + settings.c_iflag |= libc::INLCR; + assert_eq!( + unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &settings) }, + 0 + ); + assert!(super::input::validate_pty_submission(master.as_raw_fd(), &text, &enter).is_ok()); + let external_processing: libc::c_int = 0; + assert_eq!( + unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCEXT, &external_processing) }, + 0 + ); + + let mut settings = original; + settings.c_iflag |= libc::PARMRK; + settings.c_iflag &= !(libc::ISTRIP | libc::IGNPAR); + assert_eq!( + unsafe { libc::tcsetattr(slave.as_raw_fd(), libc::TCSANOW, &settings) }, + 0 + ); + assert!(super::input::validate_pty_submission(master.as_raw_fd(), &[0xff; 511], b"\n").is_ok()); + assert!( + super::input::validate_pty_submission(master.as_raw_fd(), &[0xff; 512], b"\n").is_err() + ); +} diff --git a/src/platform/macos/shutdown.rs b/src/platform/macos/shutdown.rs new file mode 100644 index 00000000..3240676e --- /dev/null +++ b/src/platform/macos/shutdown.rs @@ -0,0 +1,532 @@ +//! Restart and shutdown detection through loginwindow's notify(3) keys. +//! +//! During a restart or shutdown, loginwindow kills LaunchServices-registered +//! background processes (for example Node-based agents that set +//! `process.title`) right after its point of no return, 15-20 seconds before +//! launchd sends SIGTERM to the server. The server must save its intact session +//! before it applies those exits. +//! +//! loginwindow posts these keys as system notifications. They are undocumented, +//! so a missing key degrades to the previous behavior (save on SIGTERM). + +use std::ffi::{c_char, c_int, c_ulong, c_void, CString}; +use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use crate::platform::{HostShutdownIntent, HostShutdownIntentCell}; + +const LOGINWINDOW_PREFIX: &str = "com.apple.system.loginwindow."; +const NOTIFY_STATUS_OK: u32 = 0; + +/// How long a restart or shutdown announcement stays armed without a +/// cancellation. loginwindow can wait indefinitely on an app that blocks the +/// logout (a real software-update restart waited 28 minutes before its point +/// of no return), but it does not always post `logoutcancelled` when the user +/// abandons the restart. Without a limit, a much later plain logout would stop +/// the server, which is meant to outlive logout. A restart held open longer +/// than this falls back to the previous behavior and saves on SIGTERM. +const INTENT_LIFETIME: Duration = Duration::from_secs(2 * 60 * 60); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LoginwindowEvent { + /// A restart or shutdown started. The user or an app can still cancel it. + Intent, + /// The pending logout, restart or shutdown was cancelled. + Cancelled, + /// loginwindow passed its point of no return and starts killing processes. + NoReturn, +} + +const LOGINWINDOW_KEYS: [(&str, LoginwindowEvent); 6] = [ + ("restartinitiated", LoginwindowEvent::Intent), + ("shutdownInitiated", LoginwindowEvent::Intent), + ("likelyShutdown", LoginwindowEvent::Intent), + ("logoutcancelled", LoginwindowEvent::Cancelled), + ("logoutNoReturn", LoginwindowEvent::NoReturn), + ("shutdownNoReturn", LoginwindowEvent::NoReturn), +]; + +/// Applies one loginwindow event to the pending intent and returns true when +/// the server should save and exit now. +/// +/// A plain logout also reaches the point of no return, but the server is meant +/// to outlive it, so only a recent restart or shutdown announcement arms the +/// exit. Any point of no return consumes the announcement. +fn observe(intent: &mut Option, event: LoginwindowEvent, now_ns: u64) -> bool { + match event { + LoginwindowEvent::Intent => { + *intent = Some(HostShutdownIntent { + announced_at_ns: now_ns, + }); + false + } + LoginwindowEvent::Cancelled => { + *intent = None; + false + } + LoginwindowEvent::NoReturn => intent.take().is_some_and(|intent| { + u128::from(now_ns.saturating_sub(intent.announced_at_ns)) <= INTENT_LIFETIME.as_nanos() + }), + } +} + +/// Boot-wide monotonic time that keeps counting while the Mac sleeps, so an +/// announcement ages across sleep and compares across server processes. +fn monotonic_now_ns() -> u64 { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: `ts` is a valid out-pointer. Darwin's CLOCK_MONOTONIC includes sleep. + if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) } != 0 { + return 0; + } + (ts.tv_sec as u64) + .saturating_mul(1_000_000_000) + .saturating_add(ts.tv_nsec as u64) +} + +struct Watcher { + intent: HostShutdownIntentCell, + requested: Arc, + wake: Box, + #[cfg(test)] + seen: std::sync::atomic::AtomicUsize, +} + +impl Watcher { + fn handle(&self, event: LoginwindowEvent) { + tracing::info!(?event, "macos loginwindow notification"); + let mut intent = self.intent.lock(); + let shutdown = observe(&mut intent, event, monotonic_now_ns()); + drop(intent); + if shutdown { + tracing::info!("host shutdown requested; preserving session before pane termination"); + self.requested.store(true, Ordering::Release); + (self.wake)(); + } + #[cfg(test)] + self.seen.fetch_add(1, Ordering::Release); + } +} + +pub(crate) fn monitor_host_shutdown( + requested: Arc, + intent: HostShutdownIntentCell, + wake: impl Fn() + Send + Sync + 'static, +) -> Option> { + let watcher = Arc::new(Watcher { + intent, + requested, + wake: Box::new(wake), + #[cfg(test)] + seen: Default::default(), + }); + let registration = match Registration::new(LOGINWINDOW_PREFIX, watcher) { + Ok(registration) => registration, + Err(err) => { + tracing::debug!(err = %err, "host shutdown notification unavailable"); + return None; + } + }; + tracing::debug!("host shutdown notification ready"); + // Dropping the monitor aborts this task, which cancels the registration. + Some(tokio::spawn(async move { + let _registration = registration; + std::future::pending::<()>().await; + })) +} + +// libSystem: notify(3), libdispatch and the Blocks runtime. +type DispatchQueue = *mut c_void; + +unsafe extern "C" { + fn notify_register_dispatch( + name: *const c_char, + out_token: *mut c_int, + queue: DispatchQueue, + handler: *mut c_void, + ) -> u32; + fn notify_cancel(token: c_int) -> u32; + fn dispatch_queue_create(label: *const c_char, attr: *const c_void) -> DispatchQueue; + fn dispatch_release(object: DispatchQueue); + static _NSConcreteStackBlock: [*const c_void; 32]; +} + +const BLOCK_HAS_COPY_DISPOSE: c_int = 1 << 25; + +/// A Clang block literal `^(int token)` capturing one strong `Watcher` +/// reference and its event. libnotify copies it to the heap with `Block_copy` +/// (which calls `copy_handler`) and releases it after `notify_cancel`. +#[repr(C)] +struct HandlerBlock { + isa: *const c_void, + flags: c_int, + reserved: c_int, + invoke: unsafe extern "C" fn(*mut HandlerBlock, c_int), + descriptor: *const HandlerBlockDescriptor, + watcher: *const Watcher, + event: LoginwindowEvent, +} + +#[repr(C)] +struct HandlerBlockDescriptor { + reserved: c_ulong, + size: c_ulong, + copy: unsafe extern "C" fn(*mut HandlerBlock, *const HandlerBlock), + dispose: unsafe extern "C" fn(*const HandlerBlock), +} + +static HANDLER_BLOCK_DESCRIPTOR: HandlerBlockDescriptor = HandlerBlockDescriptor { + reserved: 0, + size: std::mem::size_of::() as c_ulong, + copy: copy_handler, + dispose: dispose_handler, +}; + +// The Blocks runtime updates a block's `flags` (its reference count) while other +// threads may hold it, so these helpers never form a reference to the whole +// block. They read only the immutable captured fields through raw pointers. + +unsafe extern "C" fn invoke_handler(block: *mut HandlerBlock, _token: c_int) { + // SAFETY: libnotify invokes a live copy of our block. Its captured fields are + // written once before registration and never change; the copy owns a + // Watcher reference, so the Watcher outlives this call. + let (watcher, event) = unsafe { + ( + std::ptr::addr_of!((*block).watcher).read(), + std::ptr::addr_of!((*block).event).read(), + ) + }; + let watcher = unsafe { &*watcher }; + // A panic must not unwind into libdispatch. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| watcher.handle(event))); +} + +unsafe extern "C" fn copy_handler(_dst: *mut HandlerBlock, src: *const HandlerBlock) { + // SAFETY: the Blocks runtime already copied the bytes; the copy takes its own reference. + unsafe { + let watcher = std::ptr::addr_of!((*src).watcher).read(); + Arc::increment_strong_count(watcher); + } +} + +unsafe extern "C" fn dispose_handler(block: *const HandlerBlock) { + // SAFETY: releases the reference taken in `copy_handler`. + unsafe { + let watcher = std::ptr::addr_of!((*block).watcher).read(); + Arc::decrement_strong_count(watcher); + } +} + +/// notify(3) registrations handled one at a time on a shared serial queue. +/// +/// Ordering is best effort. libnotify reads its notification port from a +/// single dispatch source and forwards each token with `dispatch_async` to the +/// registration's queue, so the serial queue keeps the order in which this +/// process received notifyd's Mach messages. notifyd itself may defer or +/// coalesce deliveries under backpressure, so that order is not guaranteed to +/// match posting order. loginwindow's announcement and point of no return are +/// normally seconds to minutes apart. File-descriptor registrations give +/// weaker ordering still: libnotify writes those from a global concurrent queue. +struct Registration { + queue: DispatchQueue, + tokens: Vec, +} + +// SAFETY: the queue handle and tokens are process-wide identifiers that libdispatch +// and libnotify allow to be used and released from any thread. +unsafe impl Send for Registration {} + +impl Registration { + fn new(prefix: &str, watcher: Arc) -> io::Result { + // SAFETY: a NULL attribute creates a serial queue; the label is NUL-terminated. + let queue = + unsafe { dispatch_queue_create(c"herdr.host-shutdown".as_ptr(), std::ptr::null()) }; + if queue.is_null() { + return Err(io::Error::other("dispatch_queue_create failed")); + } + let mut registration = Self { + queue, + tokens: Vec::with_capacity(LOGINWINDOW_KEYS.len()), + }; + for (key, event) in LOGINWINDOW_KEYS { + let name = CString::new(format!("{prefix}{key}")) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let mut block = HandlerBlock { + isa: std::ptr::addr_of!(_NSConcreteStackBlock).cast(), + flags: BLOCK_HAS_COPY_DISPOSE, + reserved: 0, + invoke: invoke_handler, + descriptor: &HANDLER_BLOCK_DESCRIPTOR, + watcher: Arc::as_ptr(&watcher), + event, + }; + let mut token = -1; + // SAFETY: `name` is NUL-terminated, `token` is a valid out-pointer, and + // libnotify copies `block` (taking its own Watcher reference) before returning. + let status = unsafe { + notify_register_dispatch( + name.as_ptr(), + &mut token, + queue, + std::ptr::addr_of_mut!(block).cast(), + ) + }; + if status != NOTIFY_STATUS_OK { + return Err(io::Error::other(format!( + "notify_register_dispatch({key}) failed with status {status}" + ))); + } + registration.tokens.push(token); + } + Ok(registration) + } +} + +impl Drop for Registration { + fn drop(&mut self) { + for token in &self.tokens { + // SAFETY: each token came from a successful registration and is cancelled once. + // libnotify releases its block copy, and the Watcher with it, on the queue. + unsafe { + notify_cancel(*token); + } + } + // SAFETY: releases the reference from dispatch_queue_create; registrations + // hold their own until libnotify is done with them. + unsafe { dispatch_release(self.queue) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicUsize; + use std::time::Instant; + + use LoginwindowEvent::{Cancelled, Intent, NoReturn}; + + unsafe extern "C" { + fn notify_post(name: *const c_char) -> u32; + } + + const MINUTE_NS: u64 = 60 * 1_000_000_000; + + #[test] + fn point_of_no_return_requires_restart_or_shutdown_intent() { + let mut intent = None; + assert!( + !observe(&mut intent, NoReturn, 0), + "plain logout keeps the server" + ); + + assert!(!observe(&mut intent, Intent, 0)); + assert!(observe(&mut intent, NoReturn, 28 * MINUTE_NS)); + } + + #[test] + fn cancelled_logout_disarms_until_the_next_intent() { + let mut intent = None; + assert!(!observe(&mut intent, Intent, 0)); + assert!(!observe(&mut intent, Cancelled, 1)); + assert!(!observe(&mut intent, NoReturn, 2)); + assert!(!observe(&mut intent, Intent, 3)); + assert!(observe(&mut intent, NoReturn, 4)); + } + + #[test] + fn abandoned_intent_expires_before_a_later_logout() { + let lifetime = INTENT_LIFETIME.as_nanos() as u64; + let mut intent = None; + assert!(!observe(&mut intent, Intent, 1_000)); + assert!( + !observe(&mut intent, NoReturn, 1_000 + lifetime + 1), + "a logout after the restart was abandoned keeps the server" + ); + + assert!(!observe(&mut intent, Intent, 5_000)); + assert!(observe(&mut intent, NoReturn, 5_000 + lifetime)); + } + + #[test] + fn point_of_no_return_consumes_the_intent() { + let mut intent = None; + assert!(!observe(&mut intent, Intent, 0)); + assert!(observe(&mut intent, NoReturn, 1)); + assert!(!observe(&mut intent, NoReturn, 2)); + } + + #[test] + fn registers_loginwindow_keys() { + let key = |name| { + LOGINWINDOW_KEYS + .iter() + .find(|(key, _)| *key == name) + .map(|(_, event)| *event) + }; + assert_eq!(LOGINWINDOW_PREFIX, "com.apple.system.loginwindow."); + for intent in ["restartinitiated", "shutdownInitiated", "likelyShutdown"] { + assert_eq!(key(intent), Some(Intent), "{intent}"); + } + assert_eq!(key("logoutcancelled"), Some(Cancelled)); + assert_eq!(key("logoutNoReturn"), Some(NoReturn)); + assert_eq!(key("shutdownNoReturn"), Some(NoReturn)); + assert_eq!(key("logoutInitiated"), None, "plain logout must not arm"); + } + + // Unprivileged processes cannot post `com.apple.system.*`, so these tests + // use a private prefix with the same suffixes and never touch the real keys. + struct Harness { + prefix: String, + watcher: Arc, + _registration: Registration, + posted: usize, + } + + impl Harness { + fn new(case: &str, intent: HostShutdownIntentCell) -> Self { + let prefix = format!("dev.herdr.test.{}.{case}.", std::process::id()); + let watcher = Arc::new(Watcher { + intent, + requested: Arc::new(AtomicBool::new(false)), + wake: Box::new(|| {}), + seen: AtomicUsize::new(0), + }); + let registration = Registration::new(&prefix, watcher.clone()).expect("register"); + Self { + prefix, + watcher, + _registration: registration, + posted: 0, + } + } + + /// Posts each key and waits for it to be handled before the next one. + /// notifyd may reorder deliveries under load, so ordering semantics are + /// tested on `Watcher::handle` directly. + fn post(&mut self, keys: &[&str]) { + for key in keys { + self.post_unregistered(key); + self.posted += 1; + let deadline = Instant::now() + Duration::from_secs(5); + while self.watcher.seen.load(Ordering::Acquire) < self.posted { + assert!(Instant::now() < deadline, "{key} was not delivered"); + std::thread::sleep(Duration::from_millis(2)); + } + } + } + + fn post_unregistered(&self, key: &str) { + let name = CString::new(format!("{}{key}", self.prefix)).unwrap(); + assert_eq!(unsafe { notify_post(name.as_ptr()) }, NOTIFY_STATUS_OK); + } + + fn requested(&self) -> bool { + self.watcher.requested.swap(false, Ordering::AcqRel) + } + } + + #[test] + fn watcher_applies_events_in_handling_order() { + let woken = Arc::new(AtomicUsize::new(0)); + let watcher = Watcher { + intent: HostShutdownIntentCell::default(), + requested: Arc::new(AtomicBool::new(false)), + wake: Box::new({ + let woken = woken.clone(); + move || { + woken.fetch_add(1, Ordering::AcqRel); + } + }), + seen: AtomicUsize::new(0), + }; + let requested = || watcher.requested.swap(false, Ordering::AcqRel); + for round in 0..3 { + for event in [Intent, Cancelled, NoReturn] { + watcher.handle(event); + } + assert!( + !requested(), + "round {round}: cancel came last before no-return" + ); + + for event in [Intent, NoReturn] { + watcher.handle(event); + } + assert!(requested(), "round {round}: intent preceded no-return"); + + watcher.handle(NoReturn); + assert!(!requested(), "round {round}: no-return consumed the intent"); + } + assert_eq!(woken.load(Ordering::Acquire), 3); + } + + #[test] + fn live_notifications_drive_the_watcher() { + let mut harness = Harness::new("live", HostShutdownIntentCell::default()); + harness.post(&["restartinitiated", "logoutcancelled", "logoutNoReturn"]); + assert!(!harness.requested(), "cancelled restart keeps the server"); + + harness.post(&["shutdownInitiated", "shutdownNoReturn"]); + assert!( + harness.requested(), + "shutdown reached its point of no return" + ); + + harness.post(&["likelyShutdown", "logoutNoReturn"]); + assert!( + harness.requested(), + "every intent key arms and every no-return key fires" + ); + } + + #[test] + fn plain_logout_keeps_server() { + let mut harness = Harness::new("logout", HostShutdownIntentCell::default()); + // `logoutInitiated` is not registered, so it is never delivered. + harness.post_unregistered("logoutInitiated"); + harness.post(&["logoutNoReturn", "shutdownNoReturn"]); + assert!(!harness.requested()); + } + + #[test] + fn replacement_server_inherits_an_announced_restart() { + let old_intent = HostShutdownIntentCell::default(); + let mut old = Harness::new("handoff-old", old_intent.clone()); + old.post(&["restartinitiated"]); + assert!(!old.requested()); + let carried = old_intent.get().expect("restart announced"); + drop(old); + + // The handoff manifest carries the intent as JSON to the new process. + let carried: HostShutdownIntent = + serde_json::from_str(&serde_json::to_string(&carried).unwrap()).unwrap(); + let new_intent = HostShutdownIntentCell::default(); + new_intent.set(Some(carried)); + let mut new = Harness::new("handoff-new", new_intent); + new.post(&["logoutNoReturn"]); + assert!(new.requested()); + + let mut unarmed = Harness::new("handoff-unarmed", HostShutdownIntentCell::default()); + unarmed.post(&["logoutNoReturn"]); + assert!(!unarmed.requested()); + } + + #[tokio::test] + async fn monitor_task_releases_its_watcher_when_aborted() { + let requested = Arc::new(AtomicBool::new(false)); + let task = monitor_host_shutdown(requested.clone(), Default::default(), || {}) + .expect("notify registration"); + task.abort(); + let _ = task.await; + let deadline = Instant::now() + Duration::from_secs(5); + // The registration and every block copy release their references. + while Arc::strong_count(&requested) > 1 { + assert!(Instant::now() < deadline, "watcher was not released"); + std::thread::sleep(Duration::from_millis(2)); + } + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 927cd32b..0349d7c6 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -13,9 +13,10 @@ pub(crate) struct HostShutdownMonitor { impl HostShutdownMonitor { pub(crate) fn start( requested: std::sync::Arc, + intent: HostShutdownIntentCell, wake: impl Fn() + Send + Sync + 'static, ) -> Self { - let task = monitor_host_shutdown(requested, wake); + let task = monitor_host_shutdown(requested, intent, wake); Self { task } } } @@ -28,14 +29,60 @@ impl Drop for HostShutdownMonitor { } } -#[cfg(not(target_os = "linux"))] +/// A restart or shutdown the host announced but has not committed to yet. +/// +/// Live handoff carries it so a replacement server still recognizes the +/// commit point. The value is captured when the old server builds its handoff +/// manifest; announcements or cancellations that arrive after that and before +/// the replacement starts its monitor reach only the old server. Each handoff +/// phase may take up to 30 seconds, so that window can last tens of seconds. +/// Only macOS reports an intent; the timestamp is in its boot-wide monotonic +/// clock, which is shared by the old and new server processes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct HostShutdownIntent { + announced_at_ns: u64, +} + +/// The pending host shutdown intent shared by the server and its monitor. +// Windows has no live handoff and no announcement to record, so nothing reads it there. +#[cfg_attr(windows, allow(dead_code))] +#[derive(Debug, Clone, Default)] +pub(crate) struct HostShutdownIntentCell( + std::sync::Arc>>, +); + +// Windows has no live handoff and no announcement to record, so nothing reads it there. +#[cfg_attr(windows, allow(dead_code))] +impl HostShutdownIntentCell { + pub(crate) fn get(&self) -> Option { + *self.lock() + } + + pub(crate) fn set(&self, intent: Option) { + *self.lock() = intent; + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] fn monitor_host_shutdown( _requested: std::sync::Arc, + _intent: HostShutdownIntentCell, _wake: impl Fn() + Send + Sync + 'static, ) -> Option> { None } +#[cfg(not(windows))] +pub(crate) fn host_shutdown_in_progress() -> bool { + false +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ForegroundProcess { pub pid: u32, @@ -51,6 +98,47 @@ pub struct ForegroundJob { pub processes: Vec, } +/// A request from outside the process to stop the server. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ServerQuitSignal { + #[cfg(unix)] + Interrupt, + #[cfg(unix)] + Terminate, + #[cfg(not(unix))] + ConsoleControl, +} + +impl std::fmt::Display for ServerQuitSignal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + #[cfg(unix)] + Self::Interrupt => "SIGINT", + #[cfg(unix)] + Self::Terminate => "SIGTERM", + #[cfg(not(unix))] + Self::ConsoleControl => "console control event", + }) + } +} + +#[cfg(not(unix))] +pub(crate) fn spawn_server_signal_monitor( + on_quit: impl Fn(ServerQuitSignal) + Send + Sync + 'static, +) { + if let Err(err) = ctrlc::set_handler(move || on_quit(ServerQuitSignal::ConsoleControl)) { + tracing::warn!(%err, "failed to install server stop handler"); + } +} + +#[cfg(not(unix))] +pub(crate) fn ignore_server_hangup() {} + +#[cfg(not(unix))] +pub(crate) fn local_stream_peer_description(_stream: &crate::ipc::LocalStream) -> Option { + None +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Signal { Hangup, @@ -166,6 +254,15 @@ pub(crate) const fn capabilities() -> PlatformCapabilities { } } +/// The byte the host terminal sends for Backspace according to the tty's erase +/// setting (e.g. `^H` for MobaXterm and PuTTY-style terminals), when known. +pub(crate) fn terminal_erase_byte() -> Option { + #[cfg(unix)] + return unix_common::terminal_erase_byte(); + #[cfg(not(unix))] + None +} + pub(crate) fn terminal_grid_size() -> std::io::Result<(u16, u16)> { #[cfg(unix)] let (cols, rows) = unix_common::read_terminal_grid_size()?; @@ -327,11 +424,28 @@ mod remote_bridge; mod remote_bridge_tests; #[cfg(unix)] mod unix_common; +/// Other Unix platforms keep their existing input admission policy. +/// Their _PC_MAX_CANON values need not describe the actual queue capacity. +#[cfg(all(unix, not(target_os = "macos")))] +#[derive(Clone, Default)] +pub(crate) struct PtyInputGuard; + +#[cfg(all(unix, not(target_os = "macos")))] +impl PtyInputGuard { + pub(crate) fn new(_master: &std::sync::Arc) -> Self { + Self + } + + pub(crate) fn validate(&self, _bytes: &[u8]) -> std::io::Result<()> { + Ok(()) + } +} #[cfg(unix)] pub(crate) mod unix_image_files; #[cfg(unix)] pub(crate) use unix_common::{ - begin_cli_output, end_cli_output, forward_remote_bridge_stdio, RemoteBridgeWake, + begin_cli_output, end_cli_output, forward_remote_bridge_stdio, ignore_server_hangup, + local_stream_peer_description, spawn_server_signal_monitor, RemoteBridgeWake, }; mod client_state; @@ -358,6 +472,9 @@ mod windows; #[cfg(target_os = "windows")] pub use windows::*; +#[cfg(not(windows))] +pub(crate) use process_cwd as pane_process_cwd; + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] mod fallback; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] @@ -565,6 +682,102 @@ fn child_exit_classification_only_checkpoints_interruptions() { mod tests { use super::*; + #[cfg(target_os = "linux")] + #[test] + fn live_pane_process_group_rejects_processes_outside_the_pane_session() { + use std::os::unix::process::CommandExt; + + let mut detached = std::process::Command::new("sleep"); + detached.arg("30"); + // SAFETY: setsid is async-signal-safe and touches only the child. + unsafe { + detached.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + let mut detached = detached.spawn().expect("spawn detached"); + let token = process_start_token(detached.id()).expect("start token"); + let mut gone = std::process::Command::new("true").spawn().expect("spawn"); + let gone_pid = gone.id(); + gone.wait().expect("reap"); + + assert_eq!( + live_pane_process_group(std::process::id(), detached.id(), token), + None, + "a live process in another terminal session" + ); + assert_eq!( + live_pane_process_group(gone_pid, detached.id(), token), + None, + "a pane shell that is gone" + ); + let _ = detached.kill(); + let _ = detached.wait(); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn live_pane_process_group_follows_the_agent_process_not_its_job() { + use std::os::unix::process::CommandExt; + + let shell_pid = std::process::id(); + let mut wrapper = std::process::Command::new("sleep") + .arg("30") + .process_group(0) + .spawn() + .expect("spawn wrapper"); + let job = wrapper.id(); + let mut agent = std::process::Command::new("sleep") + .arg("30") + .process_group(job as i32) + .spawn() + .expect("spawn agent"); + let agent_pid = agent.id(); + let token = process_start_token(agent_pid).expect("agent start token"); + let wrapper_token = process_start_token(job).expect("wrapper start token"); + + assert_eq!( + live_pane_process_group(shell_pid, agent_pid, token), + Some(job) + ); + assert_eq!( + live_pane_process_group(shell_pid, agent_pid, token + 1), + None, + "a reused pid has a different start token" + ); + unsafe { + libc::kill(agent_pid as libc::pid_t, libc::SIGSTOP); + } + assert_eq!( + live_pane_process_group(shell_pid, agent_pid, token), + Some(job) + ); + + unsafe { + libc::kill(agent_pid as libc::pid_t, libc::SIGKILL); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while live_pane_process_group(shell_pid, agent_pid, token).is_some() + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!( + live_pane_process_group(shell_pid, agent_pid, token), + None, + "an unreaped agent must not count as alive while its wrapper lives" + ); + assert_eq!( + live_pane_process_group(shell_pid, job, wrapper_token), + Some(job) + ); + agent.wait().expect("reap agent"); + assert_eq!(live_pane_process_group(shell_pid, agent_pid, token), None); + let _ = wrapper.kill(); + let _ = wrapper.wait(); + } + #[test] fn terminal_resize_signal_is_recorded_once_per_delivery() { watch_terminal_resize_signal(); diff --git a/src/platform/ssh_agent.rs b/src/platform/ssh_agent.rs index 9caddd73..54a70d4e 100644 --- a/src/platform/ssh_agent.rs +++ b/src/platform/ssh_agent.rs @@ -12,7 +12,11 @@ use interprocess::ConnectWaitMode; use crate::ipc::LocalStream; -const PROBE_INTERVAL: Duration = Duration::from_secs(1); +const CHECK_INTERVAL: Duration = Duration::from_secs(1); +const UNAVAILABLE_RETRY_INTERVAL: Duration = Duration::from_secs(5); + +/// Device, inode, mode, and ctime of a usable agent socket file. +type Fingerprint = (u64, u64, u32, i64, i64); #[derive(Clone)] pub(crate) struct SshAgentRegistry(Arc>); @@ -23,6 +27,9 @@ struct State { agents: Vec<(u64, PathBuf)>, next_id: u64, identity: Option<(u64, u64)>, + observed: Vec>, + selected: bool, + last_check: Option, last_probe: Option, } @@ -42,10 +49,27 @@ fn agent_path_for(api_path: &Path) -> PathBuf { } fn usable_socket(path: &Path) -> bool { - fs::metadata(path).is_ok_and(|metadata| { - // The API is user-private; do not redirect that user's panes to another user's agent. - metadata.file_type().is_socket() && metadata.uid() == unsafe { libc::geteuid() } - }) + fingerprint(path).is_some() +} + +// A listener that dies in place, or an in-place change that keeps all of these fields, is +// noticed only at the next selection; periodic connect probes would cost a forwarded channel. +fn fingerprint(path: &Path) -> Option { + fs::metadata(path) + .ok() + .filter(|metadata| { + // The API is user-private; do not redirect that user's panes to another user's agent. + metadata.file_type().is_socket() && metadata.uid() == unsafe { libc::geteuid() } + }) + .map(|metadata| { + ( + metadata.dev(), + metadata.ino(), + metadata.mode(), + metadata.ctime(), + metadata.ctime_nsec(), + ) + }) } fn live_socket(path: &Path) -> bool { @@ -84,10 +108,13 @@ impl SshAgentRegistry { agents: Vec::new(), next_id: 0, identity: None, + observed: Vec::new(), + selected: false, + last_check: None, last_probe: None, }; if managed { - state.publish()?; + state.publish(Instant::now())?; } Ok(Self(Arc::new(Mutex::new(state)))) } @@ -107,7 +134,7 @@ impl SshAgentRegistry { let id = state.next_id; state.next_id += 1; state.agents.push((id, path)); - if let Err(error) = state.publish() { + if let Err(error) = state.publish(Instant::now()) { state.agents.retain(|(candidate, _)| *candidate != id); return Err(error); } @@ -119,7 +146,7 @@ impl SshAgentRegistry { } impl State { - fn publish(&mut self) -> io::Result<()> { + fn ensure_owned(&self) -> io::Result<()> { if let Some(identity) = self.identity { let metadata = fs::symlink_metadata(&self.path)?; if identity != (metadata.dev(), metadata.ino()) { @@ -128,9 +155,23 @@ impl State { )); } } + Ok(()) + } + + fn observe(&self) -> Vec> { + self.fallback + .iter() + .chain(self.agents.iter().map(|(_, path)| path)) + .map(|path| fingerprint(path)) + .collect() + } + + fn publish(&mut self, now: Instant) -> io::Result<()> { + self.ensure_owned()?; // Keep a working agent rather than letting probes or a second client replace it. let unavailable = self.path.with_extension("unavailable"); - self.last_probe = Some(Instant::now()); + let observed = self.observe(); + self.last_probe = Some(now); let target = self .fallback .as_deref() @@ -141,20 +182,25 @@ impl State { .map(|(_, path)| path.as_path()) .find(|path| live_socket(path)) }) - .unwrap_or(&unavailable); - if self.identity.is_some() && fs::read_link(&self.path).ok().as_deref() == Some(target) { - return Ok(()); - } - let temporary = self - .path - .with_extension(format!("{}.new", std::process::id())); - symlink(target, &temporary)?; - if let Err(error) = fs::rename(&temporary, &self.path) { - let _ = fs::remove_file(&temporary); - return Err(error); + .unwrap_or(&unavailable) + .to_path_buf(); + if self.identity.is_none() + || fs::read_link(&self.path).ok().as_deref() != Some(target.as_path()) + { + let temporary = self + .path + .with_extension(format!("{}.new", std::process::id())); + symlink(&target, &temporary)?; + if let Err(error) = fs::rename(&temporary, &self.path) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + let metadata = fs::symlink_metadata(&self.path)?; + self.identity = Some((metadata.dev(), metadata.ino())); } - let metadata = fs::symlink_metadata(&self.path)?; - self.identity = Some((metadata.dev(), metadata.ino())); + // Record the selection only once it is published, so a failed publish is retried. + self.observed = observed; + self.selected = target != unavailable; Ok(()) } } @@ -180,12 +226,24 @@ impl SshAgentLease { .0 .lock() .map_err(|_| io::Error::other("SSH agent registry poisoned"))?; - // Share the probe budget across attachments, not one SSH channel per polling client. if state - .last_probe - .is_none_or(|last| now.saturating_duration_since(last) >= PROBE_INTERVAL) + .last_check + .is_some_and(|last| now.saturating_duration_since(last) < CHECK_INTERVAL) { - state.publish()?; + return Ok(()); + } + state.last_check = Some(now); + state.ensure_owned()?; + // Each probe opens a forwarded SSH channel, so a selected agent is probed again only when a + // socket file changes. Without one, retry slowly in case a probe failed transiently. + let observed = state.observe(); + let retry = !state.selected + && observed.iter().any(Option::is_some) + && state.last_probe.is_none_or(|last| { + now.saturating_duration_since(last) >= UNAVAILABLE_RETRY_INTERVAL + }); + if retry || observed != state.observed { + state.publish(now)?; } Ok(()) } @@ -195,7 +253,7 @@ impl Drop for SshAgentLease { fn drop(&mut self) { if let Ok(mut state) = self.registry.0.lock() { state.agents.retain(|(id, _)| *id != self.id); - if let Err(error) = state.publish() { + if let Err(error) = state.publish(Instant::now()) { tracing::warn!(%error, "could not refresh SSH agent after attachment ended"); } } @@ -252,7 +310,13 @@ mod tests { assert_eq!(fs::read_link(&stable).unwrap(), a); drop(listener_a); assert!(fs::metadata(&a).unwrap().file_type().is_socket()); - lease_b.refresh_at(Instant::now() + PROBE_INTERVAL).unwrap(); + // Periodic checks do not probe an unchanged socket file. + lease_b.refresh_at(Instant::now() + CHECK_INTERVAL).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), a); + // The next selection skips the stale socket. + let lease_c = registry.register(b.clone()).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), b); + drop(lease_c); assert_eq!(fs::read_link(&stable).unwrap(), b); drop(lease_b); drop(registry); @@ -283,6 +347,134 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + fn accepted_connections(listener: &UnixListener) -> usize { + listener.set_nonblocking(true).unwrap(); + std::iter::from_fn(|| listener.accept().ok()).count() + } + + #[test] + fn periodic_refresh_does_not_connect_to_agents() { + let directory = + std::env::temp_dir().join(format!("herdr-agent-quiet-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let stable = directory.join("agent"); + let a = directory.join("a"); + let b = directory.join("b"); + let stale = UnixListener::bind(&a).unwrap(); + drop(stale); + let listener_b = UnixListener::bind(&b).unwrap(); + let registry = SshAgentRegistry::new(stable.clone(), Some(a.clone())).unwrap(); + let lease = registry.register(b.clone()).unwrap(); + assert_eq!( + fs::read_link(&stable).unwrap(), + b, + "selection must skip a socket file left by a crashed sshd" + ); + assert!(accepted_connections(&listener_b) > 0); + + let start = Instant::now(); + for second in 1..=100 { + lease.refresh_at(start + CHECK_INTERVAL * second).unwrap(); + } + assert_eq!( + accepted_connections(&listener_b), + 0, + "periodic refresh must not open forwarded SSH channels" + ); + assert_eq!(fs::read_link(&stable).unwrap(), b); + + // A replaced socket file is probed again. The old listener keeps its inode alive. + let before = fingerprint(&b).unwrap(); + fs::remove_file(&b).unwrap(); + let replacement = UnixListener::bind(&b).unwrap(); + assert_ne!(fingerprint(&b).unwrap(), before); + lease.refresh_at(start + CHECK_INTERVAL * 101).unwrap(); + assert_eq!(accepted_connections(&replacement), 1); + assert_eq!(accepted_connections(&listener_b), 0); + assert_eq!(fs::read_link(&stable).unwrap(), b); + + drop(lease); + drop(registry); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn failed_publication_is_retried_by_remaining_leases() { + use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; + } + let directory = + std::env::temp_dir().join(format!("herdr-agent-publish-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let stable = directory.join("agent"); + let a = directory.join("a"); + let b = directory.join("b"); + let _listener_a = UnixListener::bind(&a).unwrap(); + let _listener_b = UnixListener::bind(&b).unwrap(); + let registry = SshAgentRegistry::new(stable.clone(), None).unwrap(); + let lease_a = registry.register(a.clone()).unwrap(); + let lease_b = registry.register(b.clone()).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), a); + + fs::set_permissions(&directory, fs::Permissions::from_mode(0o500)).unwrap(); + drop(lease_a); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), a); + lease_b.refresh_at(Instant::now() + CHECK_INTERVAL).unwrap(); + assert_eq!(fs::read_link(&stable).unwrap(), b); + drop(lease_b); + drop(registry); + fs::remove_dir_all(directory).unwrap(); + } + + #[cfg(target_os = "linux")] + #[test] + fn transient_probe_failure_recovers_without_a_socket_change() { + use std::os::fd::AsRawFd; + use std::os::unix::net::UnixStream; + let directory = + std::env::temp_dir().join(format!("herdr-agent-retry-{}", std::process::id())); + fs::create_dir(&directory).unwrap(); + let stable = directory.join("agent"); + let a = directory.join("a"); + let listener = UnixListener::bind(&a).unwrap(); + // Linux allows one queued connection with a zero backlog. + assert_eq!(unsafe { libc::listen(listener.as_raw_fd(), 0) }, 0); + let queued = UnixStream::connect(&a).unwrap(); + let registry = SshAgentRegistry::new(stable.clone(), None).unwrap(); + let lease = registry.register(a.clone()).unwrap(); + assert_eq!( + fs::read_link(&stable).unwrap(), + stable.with_extension("unavailable") + ); + + drop(queued); + assert_eq!(accepted_connections(&listener), 1); + let fingerprint_before = fingerprint(&a); + let start = Instant::now(); + lease.refresh_at(start + CHECK_INTERVAL).unwrap(); + assert_eq!(accepted_connections(&listener), 0, "retries back off"); + lease + .refresh_at(start + UNAVAILABLE_RETRY_INTERVAL) + .unwrap(); + assert_eq!(fingerprint(&a), fingerprint_before); + assert_eq!(fs::read_link(&stable).unwrap(), a); + assert_eq!(accepted_connections(&listener), 1); + + lease + .refresh_at(start + UNAVAILABLE_RETRY_INTERVAL * 10) + .unwrap(); + assert_eq!( + accepted_connections(&listener), + 0, + "a selected agent is not probed" + ); + drop(lease); + drop(registry); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn registration_preserves_the_supplied_socket_address() { let directory = @@ -344,7 +536,7 @@ mod tests { let lease_b = registry.register(b.clone()).unwrap(); assert_eq!(fs::read_link(&stable).unwrap(), a); fs::remove_file(&a).unwrap(); - lease_b.refresh_at(Instant::now() + PROBE_INTERVAL).unwrap(); + lease_b.refresh_at(Instant::now() + CHECK_INTERVAL).unwrap(); assert_eq!(fs::read_link(&stable).unwrap(), b); drop(lease_a); assert_eq!(fs::read_link(&stable).unwrap(), b); diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index 8ebf2044..3a39eaec 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -17,6 +17,17 @@ pub(crate) fn read_fd(fd: std::os::fd::RawFd, data: &mut [u8]) -> std::io::Resul } } +/// The tty's erase character (VERASE) on stdin, when one is set. +pub(super) fn terminal_erase_byte() -> Option { + let mut termios: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut termios) } != 0 { + return None; + } + let erase = termios.c_cc[libc::VERASE]; + // 0 (Linux) and 0xff (BSD) disable the special character. + (erase != 0 && erase != 0xff).then_some(erase) +} + pub(crate) fn poll_fd_readable(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result { let mut descriptor = libc::pollfd { fd, @@ -253,6 +264,77 @@ fn set_sigpipe_disposition(handler: libc::sighandler_t) { } } +extern "C" fn discard_signal(_signal: libc::c_int) {} + +/// The server must outlive the terminal or shell that launched it, like tmux. +/// Installed at process start so SIGHUP cannot stop the server before its event +/// loop runs. A handler, unlike SIG_IGN, resets to the default on exec, so +/// child processes keep normal hangup behavior. +pub(crate) fn ignore_server_hangup() { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = discard_signal as extern "C" fn(libc::c_int) as libc::sighandler_t; + action.sa_flags = libc::SA_RESTART; + unsafe { + libc::sigemptyset(&mut action.sa_mask); + libc::sigaction(libc::SIGHUP, &action, std::ptr::null_mut()); + } +} + +/// Routes SIGINT and SIGTERM to `on_quit`, and logs SIGHUP without stopping. +pub(crate) fn spawn_server_signal_monitor( + on_quit: impl Fn(super::ServerQuitSignal) + Send + Sync + 'static, +) { + use tokio::signal::unix::{signal, SignalKind}; + use tracing::{info, warn}; + + let on_quit = std::sync::Arc::new(on_quit); + for (kind, quit_signal) in [ + (SignalKind::interrupt(), super::ServerQuitSignal::Interrupt), + (SignalKind::terminate(), super::ServerQuitSignal::Terminate), + ] { + match signal(kind) { + Ok(mut stream) => { + let on_quit = on_quit.clone(); + tokio::spawn(async move { + while stream.recv().await.is_some() { + on_quit(quit_signal); + } + }); + } + Err(err) => warn!(%err, signal = %quit_signal, "failed to install server stop handler"), + } + } + match signal(SignalKind::hangup()) { + Ok(mut stream) => { + tokio::spawn(async move { + while stream.recv().await.is_some() { + info!("ignoring SIGHUP; server keeps running"); + } + }); + } + Err(err) => warn!(%err, "failed to install SIGHUP handler"), + } +} + +/// Describes the process on the other end of a local socket, for logs. +pub(crate) fn local_stream_peer_description(stream: &crate::ipc::LocalStream) -> Option { + use std::os::fd::{AsFd as _, AsRawFd as _}; + + let crate::ipc::LocalStream::UdSocket(socket) = stream; + let pid = super::socket_peer_pid(socket.as_fd().as_raw_fd())?; + let Some((name, parent)) = super::process_name_and_parent(pid) else { + return Some(format!("pid {pid}")); + }; + let mut description = format!("pid {pid} ({name})"); + if parent > 1 { + description.push_str(&format!(", parent pid {parent}")); + if let Some((parent_name, _)) = super::process_name_and_parent(parent) { + description.push_str(&format!(" ({parent_name})")); + } + } + Some(description) +} + pub(crate) fn begin_cli_output() { set_sigpipe_disposition(libc::SIG_DFL); } @@ -550,6 +632,31 @@ mod tests { assert!(earliest <= daylight); } + #[test] + fn server_signal_monitor_survives_hangup_and_reports_terminate() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + spawn_server_signal_monitor(move |signal| { + let _ = tx.send(signal); + }); + + // An unhandled SIGHUP would end the test process here. + assert_eq!(unsafe { libc::kill(libc::getpid(), libc::SIGHUP) }, 0); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!(rx.try_recv().is_err()); + + assert_eq!(unsafe { libc::kill(libc::getpid(), libc::SIGTERM) }, 0); + let signal = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()) + .await + .unwrap(); + assert_eq!(signal, Some(super::super::ServerQuitSignal::Terminate)); + }); + } + #[test] fn plugin_pane_pwd_defaults_to_cwd_without_overriding_explicit_env() { let cwd = Path::new("/plugin-cwd"); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 653bccbb..8b416bee 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -7,7 +7,7 @@ use std::{ path::PathBuf, ptr::{copy_nonoverlapping, null_mut}, sync::{ - atomic::{AtomicU64, Ordering as AtomicOrdering}, + atomic::{AtomicU32, AtomicU64, Ordering as AtomicOrdering}, Arc, LazyLock, Mutex, OnceLock, }, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, @@ -15,6 +15,135 @@ use std::{ mod clipboard_image; mod config_backup; +mod notifications; +pub(crate) use notifications::{ + foreground_desktop_notification_host, maybe_activate_desktop_notification, + show_actionable_desktop_notification, show_desktop_notification, +}; + +static ALLOW_UNELEVATED_CLIENTS: OnceLock = OnceLock::new(); + +pub(crate) fn allow_unelevated_clients() { + let _ = ALLOW_UNELEVATED_CLIENTS.set(true); +} + +pub(crate) fn probe_local_server(path: &std::path::Path) -> std::io::Result<()> { + use interprocess::os::windows::named_pipe::{pipe_mode::Bytes, DuplexPipeStream}; + use interprocess::ConnectWaitMode; + + // The local-socket wrapper ignores wait_mode on Windows in interprocess + // 2.4.2. Its named-pipe API honors it without importing/reopening a handle. + let name = format!(r"\\.\pipe\{}", path.to_string_lossy()); + DuplexPipeStream::::connect_by_path_with_wait_mode( + name.as_str(), + ConnectWaitMode::Timeout(Duration::from_millis(500)), + ) + .map(|_| ()) + .map_err(local_server_connection_error) +} + +pub(crate) fn local_server_security_descriptor( +) -> std::io::Result { + use windows_sys::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(std::io::Error::last_os_error()); + } + let token = unsafe { OwnedHandle::from_raw_handle(token) }; + let mut elevation = TOKEN_ELEVATION::default(); + let mut needed = 0; + if unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenElevation, + (&mut elevation as *mut TOKEN_ELEVATION).cast(), + size_of::() as u32, + &mut needed, + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + let allow_unelevated = ALLOW_UNELEVATED_CLIENTS.get().copied().unwrap_or(false); + let integrity = if elevation.TokenIsElevated != 0 && !allow_unelevated { + "HI" + } else { + "ME" + }; + // The account DACL alone cannot distinguish ordinary and elevated clients. + // The integrity label blocks both reading and writing from lower levels. + user_security_descriptor("GRGW", &format!("S:(ML;;NRNW;;;{integrity})")) +} + +fn user_security_descriptor( + access: &str, + integrity_label: &str, +) -> std::io::Result { + use interprocess::os::windows::security_descriptor::SecurityDescriptor; + use widestring::{U16CStr, U16CString}; + use windows_sys::Win32::Security::{ + Authorization::ConvertSidToStringSidW, GetTokenInformation, TokenUser, TOKEN_QUERY, + TOKEN_USER, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token = null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(std::io::Error::last_os_error()); + } + let token = unsafe { OwnedHandle::from_raw_handle(token) }; + let mut needed = 0; + unsafe { GetTokenInformation(token.as_raw_handle(), TokenUser, null_mut(), 0, &mut needed) }; + if needed == 0 { + return Err(std::io::Error::last_os_error()); + } + // usize storage keeps TOKEN_USER aligned and its trailing SID alive. + let mut buffer = vec![0usize; (needed as usize).div_ceil(size_of::())]; + if unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenUser, + buffer.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: GetTokenInformation initialized the aligned TOKEN_USER and SID. + let user = unsafe { &*buffer.as_ptr().cast::() }; + let mut sid = null_mut(); + if unsafe { ConvertSidToStringSidW(user.User.Sid, &mut sid) } == 0 { + return Err(std::io::Error::last_os_error()); + } + let sid_text = unsafe { U16CStr::from_ptr_str(sid) }.to_string_lossy(); + unsafe { LocalFree(sid.cast()) }; + // Use the account SID rather than the elevated token's Administrators owner. + let sddl = U16CString::from_str(format!( + "D:P(A;;GA;;;SY)(A;;{access};;;{sid_text}){integrity_label}" + )) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?; + SecurityDescriptor::deserialize(&sddl) +} + +pub(crate) fn local_server_connection_error(error: std::io::Error) -> std::io::Error { + if error.kind() != std::io::ErrorKind::PermissionDenied { + return error; + } + std::io::Error::new( + error.kind(), + format!( + "For an elevated server, use an administrator terminal. Sharing with ordinary \ + clients requires stopping it there and restarting its `herdr server` command \ + with --allow-unelevated-clients (closes panes). {error}" + ), + ) +} pub(crate) fn windows_virtual_terminal_input_active() -> bool { use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; @@ -39,6 +168,14 @@ pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::C } } +pub(crate) fn host_shutdown_in_progress() -> bool { + use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_SHUTTINGDOWN}; + + // This describes the current Windows session, including logoff. Child exit + // codes alone cannot distinguish host shutdown from ordinary process failure. + unsafe { GetSystemMetrics(SM_SHUTTINGDOWN) != 0 } +} + pub(crate) struct RemoteBridgeWake; impl RemoteBridgeWake { @@ -177,10 +314,7 @@ pub(crate) fn create_config_temporary( .create_new(true) .open(path); } - use interprocess::os::windows::security_descriptor::{ - AsSecurityDescriptorExt as _, SecurityDescriptor, - }; - use widestring::U16CString; + use interprocess::os::windows::security_descriptor::AsSecurityDescriptorExt as _; use windows_sys::Win32::{ Foundation::GENERIC_WRITE, Storage::FileSystem::{ @@ -188,9 +322,7 @@ pub(crate) fn create_config_temporary( FILE_SHARE_WRITE, }, }; - let sddl = - U16CString::from_str("D:P(A;;GA;;;SY)(A;;GA;;;OW)").map_err(std::io::Error::other)?; - let descriptor = SecurityDescriptor::deserialize(&sddl)?; + let descriptor = user_security_descriptor("GA", "")?; let mut attributes = SECURITY_ATTRIBUTES { nLength: size_of::() as u32, lpSecurityDescriptor: null_mut(), @@ -314,6 +446,11 @@ pub(crate) fn set_default_plugin_pane_pwd( ) { } +#[cfg(target_pointer_width = "64")] +use windows_sys::{ + Wdk::System::Threading::ProcessWow64Information, Win32::System::Kernel::STRING32, +}; + use windows_sys::{ Wdk::System::Threading::ProcessCommandLineInformation, Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation}, @@ -329,7 +466,8 @@ use windows_sys::{ System::{ Console::GetConsoleWindow, DataExchange::{ - CloseClipboard, EmptyClipboard, GetClipboardData, OpenClipboard, + CloseClipboard, CountClipboardFormats, EmptyClipboard, EnumClipboardFormats, + GetClipboardData, GetClipboardOwner, GetClipboardSequenceNumber, OpenClipboard, RegisterClipboardFormatW, SetClipboardData, }, Diagnostics::{ @@ -350,7 +488,7 @@ use windows_sys::{ GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE, MEMORY_BASIC_INFORMATION, }, - Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT}, + Ole::{CF_DIB, CF_DIBV5, CF_LOCALE, CF_OEMTEXT, CF_TEXT, CF_UNICODETEXT}, Threading::{ GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, IsWow64Process2, OpenProcess, OpenThread, QueryFullProcessImageNameW, ResumeThread, @@ -367,13 +505,10 @@ use windows_sys::{ KEYBDINPUT, KEYEVENTF_KEYUP, }, }, - Shell::{ - CommandLineToArgvW, ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_TIP, - NIIF_INFO, NIIF_NOSOUND, NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, - }, + Shell::{CommandLineToArgvW, ShellExecuteW}, WindowsAndMessaging::{ - CreateWindowExW, DestroyWindow, GetForegroundWindow, GetWindowThreadProcessId, - LoadIconW, SendMessageTimeoutW, IDI_APPLICATION, SMTO_ABORTIFHUNG, WM_IME_CONTROL, + GetForegroundWindow, GetWindowThreadProcessId, SendMessageTimeoutW, + SMTO_ABORTIFHUNG, WM_IME_CONTROL, }, }, }, @@ -393,6 +528,20 @@ const FOREGROUND_SELECTION_CACHE_CAPACITY: usize = 1_024; const FOREGROUND_SELECTION_CACHE_RETENTION: Duration = Duration::from_secs(60); const PANE_RUNTIME_MARKER_ENV_VAR: &str = "HERDR_PANE_RUNTIME_ID"; +#[cfg(test)] +#[derive(Clone, Copy, Debug, Default)] +struct ProcessInspectionCounts { + snapshots: u64, + opens: u64, + command_reads: u64, +} + +#[cfg(test)] +thread_local! { + static PROCESS_INSPECTION_COUNTS: std::cell::RefCell = + std::cell::RefCell::new(ProcessInspectionCounts::default()); +} + /// Native processor architecture of the Windows host as an /// `IMAGE_FILE_MACHINE_*` value. `IsWow64Process2` reports the native machine /// even when an x64 Herdr runs under emulation on Windows ARM64, which the @@ -513,11 +662,21 @@ fn standard_windows_path(path: &std::path::Path) -> Option { /// Resolves against the current foreground layout because asynchronous console /// records do not retain the layout that was active when the key was pressed. pub(crate) fn resolve_base_printable_key(vk: u16, scan: u16) -> Option { - // SAFETY: Win32 owns the handles; the fixed buffers match the API lengths. - unsafe { + // SAFETY: the foreground window and its layout are owned by Win32. + let layout = unsafe { let thread_id = GetWindowThreadProcessId(GetForegroundWindow(), null_mut()); - let layout = GetKeyboardLayout(thread_id); + GetKeyboardLayout(thread_id) + }; + resolve_base_printable_key_in_layout(vk, scan, layout) +} +pub(crate) fn resolve_base_printable_key_in_layout( + vk: u16, + scan: u16, + layout: windows_sys::Win32::UI::Input::KeyboardAndMouse::HKL, +) -> Option { + // SAFETY: Win32 owns the handles; the fixed buffers match the API lengths. + unsafe { let key_state = [0u8; 256]; let mut output = [0u16; 2]; let written = ToUnicodeEx( @@ -529,7 +688,9 @@ pub(crate) fn resolve_base_printable_key(vk: u16, scan: u16) -> Option { 0x4, layout, ); - let units = output.get(..usize::try_from(written).ok()?)?; + // A negative result identifies a dead key; its spacing accent is still + // the key's identity. Flag 0x4 above keeps composition state unchanged. + let units = output.get(..usize::try_from(written.unsigned_abs()).ok()?)?; let mut chars = char::decode_utf16(units.iter().copied()); let ch = chars.next()?.ok()?; (chars.next().is_none() && !ch.is_control()).then_some(ch) @@ -734,6 +895,8 @@ enum ProcessIdentity { impl ProcessIdentity { fn open(pid: u32) -> Option { + #[cfg(test)] + PROCESS_INSPECTION_COUNTS.with_borrow_mut(|counts| counts.opens += 1); let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if handle.is_null() { return None; @@ -1468,12 +1631,80 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option { pub fn process_cwd(pid: u32) -> Option { let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?; - let process_parameters = read_process_parameters(process.0)?; - read_unicode_string(process.0, process_parameters.current_directory.dos_path) + process_cwd_from_handle(process.0) +} + +pub(crate) fn pane_process_cwd(child_pid: u32) -> Option { + let process = ProcessHandle::open( + child_pid, + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, + )?; + let executable = PathBuf::from(process_executable_path(process.0)?); + if executable.with_extension("shim").is_file() { + // A Scoop shim keeps its launch cwd while the real shell can change directories. + // Never replace the saved shell directory with that launcher directory on exit. + let snapshot = cached_foreground_processes(); + return process_cwd(shim_shell_pid(child_pid, &snapshot)?); + } + process_cwd_from_handle(process.0) +} + +fn process_cwd_from_handle(process: HANDLE) -> Option { + read_unicode_string(process, process_cwd_descriptor(process)?) .map(PathBuf::from) .filter(|path| path.is_absolute()) } +fn process_cwd_descriptor(process: HANDLE) -> Option { + #[cfg(target_pointer_width = "64")] + { + let mut peb32_address = 0_usize; + // SAFETY: the output is a writable ULONG_PTR with its exact buffer size. + let status = unsafe { + NtQueryInformationProcess( + process, + ProcessWow64Information, + (&mut peb32_address as *mut usize).cast(), + size_of::() as u32, + null_mut(), + ) + }; + if status != STATUS_SUCCESS as NTSTATUS { + return None; + } + if peb32_address != 0 { + // WoW64's native PEB can report C:\Windows while the x86 shell uses + // another directory. Read its own pointer-width layout instead. + let peb = read_process_value::(process, peb32_address as *const c_void)?; + let parameters = read_process_value::( + process, + peb.process_parameters as usize as *const c_void, + )?; + let cwd = parameters.current_directory; + return Some(UNICODE_STRING { + Length: cwd.Length, + MaximumLength: cwd.MaximumLength, + Buffer: cwd.Buffer as usize as *mut u16, + }); + } + } + Some(read_process_parameters(process)?.current_directory.dos_path) +} + +fn shim_shell_pid(child_pid: u32, snapshot: &ProcessSnapshot) -> Option { + snapshot.entry(child_pid)?; + let mut shells = snapshot + .children_by_parent + .get(&child_pid)? + .iter() + .filter_map(|&index| { + let entry = &snapshot.entries[index]; + super::is_pane_shell_process_name(&entry.name).then_some(entry.pid) + }); + let shell_pid = shells.next()?; + shells.next().is_none().then_some(shell_pid) +} + fn select_pane_foreground_job_cached(shell_pid: u32) -> Option { let snapshot = cached_foreground_processes(); let (job, retry_with_fresh_snapshot) = @@ -1548,14 +1779,15 @@ fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( return Some(foreground_job_from_entry(shell)); } - let Some(shell_runtime_marker) = runtime_marker(shell).filter(|marker| !marker.is_empty()) - else { + let Some(shell_runtime_marker) = pane_runtime_marker(shell, &mut runtime_marker) else { return Some(foreground_job_from_entry(shell)); }; let matching_candidates: Vec<_> = escaped_agent_indices .iter() .map(|&index| &entries[index]) - .filter(|entry| runtime_marker(entry).as_deref() == Some(shell_runtime_marker.as_str())) + .filter(|entry| { + carries_pane_runtime_marker(entry, &shell_runtime_marker, &mut runtime_marker) + }) .collect(); let selected = select_topmost_agent_chain_candidate(&matching_candidates, snapshot).unwrap_or(shell); @@ -1573,6 +1805,69 @@ fn select_pane_foreground_job( ) } +/// Git Bash can start agents outside the pane shell's process tree. Those +/// belong to the pane when they carry the runtime marker the shell got. +fn pane_runtime_marker( + shell: &WindowsProcessEntry, + runtime_marker: &mut impl FnMut(&WindowsProcessEntry) -> Option, +) -> Option { + runtime_marker(shell).filter(|marker| !marker.is_empty()) +} + +fn carries_pane_runtime_marker( + entry: &WindowsProcessEntry, + pane_marker: &str, + runtime_marker: &mut impl FnMut(&WindowsProcessEntry) -> Option, +) -> bool { + runtime_marker(entry).as_deref() == Some(pane_marker) +} + +/// Whether foreground selection could pick `pid` for this pane: a descendant +/// of the pane shell, or an escaped Git Bash agent with the pane's marker. +fn process_belongs_to_pane( + shell_pid: u32, + pid: u32, + snapshot: &ProcessSnapshot, + shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool, + mut runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option, +) -> bool { + if process_is_ancestor(shell_pid, pid, snapshot) { + return true; + } + let (Some(shell), Some(entry)) = (snapshot.entry(shell_pid), snapshot.entry(pid)) else { + return false; + }; + shell_is_git_bash(shell) + && process_entry_identifies_agent(entry) + && pane_runtime_marker(shell, &mut runtime_marker).is_some_and(|pane_marker| { + carries_pane_runtime_marker(entry, &pane_marker, &mut runtime_marker) + }) +} + +/// Creation time of `pid`. It tells a process apart from a later one that +/// reuses its pid. +pub fn process_start_token(pid: u32) -> Option { + ProcessIdentity::open(pid)?.creation_time() +} + +/// Returns `pid` while that same process, matched by its creation time, is +/// still running for the pane shell `shell_pid`. Windows has no job control, +/// so the process stands in for its own group. +pub fn live_pane_process_group(shell_pid: u32, pid: u32, start_token: u64) -> Option { + let identity = ProcessIdentity::open(pid)?; + if !identity.running() || identity.creation_time() != Some(start_token) { + return None; + } + process_belongs_to_pane( + shell_pid, + pid, + &cached_foreground_processes(), + |shell| process_is_git_bash(shell.pid), + |entry| process_runtime_marker(entry.pid), + ) + .then_some(pid) +} + fn process_entry_identifies_agent(entry: &WindowsProcessEntry) -> bool { crate::detect::identify_agent(&entry.name).is_some() || crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some() @@ -1658,6 +1953,8 @@ fn foreground_process_from_entry(entry: &WindowsProcessEntry) -> super::Foregrou } fn snapshot_processes() -> Vec { + #[cfg(test)] + PROCESS_INSPECTION_COUNTS.with_borrow_mut(|counts| counts.snapshots += 1); let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; if snapshot == INVALID_HANDLE_VALUE { return Vec::new(); @@ -1703,7 +2000,7 @@ fn prepare_cached_foreground_selection( snapshot: &ProcessSnapshot, job: &ForegroundJob, ) -> Option { - if job.process_group_id == shell_pid { + if !CachedForegroundSelection::can_cache(shell_pid, snapshot, job) { return None; } let shell_identity = ProcessIdentity::open(shell_pid)?; @@ -1725,6 +2022,17 @@ fn prepare_cached_foreground_selection( } impl CachedForegroundSelection { + fn can_cache(shell_pid: u32, snapshot: &ProcessSnapshot, job: &ForegroundJob) -> bool { + // Idle native shells cannot use Git Bash's escaped-agent fallback. + // Keep reevaluating unknown children; a first child invalidates topology. + job.process_group_id != shell_pid + || (snapshot.entry(shell_pid).is_some_and(|shell| { + ["cmd.exe", "powershell.exe", "pwsh.exe"] + .iter() + .any(|name| shell.name.eq_ignore_ascii_case(name)) + }) && !snapshot.children_by_parent.contains_key(&shell_pid)) + } + fn from_snapshot_with_identities( shell_pid: u32, snapshot: &ProcessSnapshot, @@ -1734,7 +2042,7 @@ impl CachedForegroundSelection { shell_identity: ProcessIdentity, selected_identity: ProcessIdentity, ) -> Option { - if job.process_group_id == shell_pid { + if !Self::can_cache(shell_pid, snapshot, job) { return None; } let shell_entry = snapshot.entry(shell_pid)?; @@ -2210,6 +2518,8 @@ fn environment_variable_from_utf16(environment: &[u16], name: &str) -> Option Option { + #[cfg(test)] + PROCESS_INSPECTION_COUNTS.with_borrow_mut(|counts| counts.command_reads += 1); let mut required = 0_u32; // SAFETY: a null buffer with length 0 only asks for the required size, and // `required` is a valid out-pointer for the duration of the call. @@ -2401,6 +2711,8 @@ pub fn process_exists(pid: u32) -> bool { ok && exit_code == STILL_ACTIVE } +static LAST_CLIPBOARD_WRITE_SEQUENCE: AtomicU32 = AtomicU32::new(0); + pub fn write_clipboard(bytes: &[u8]) -> bool { let Ok(text) = std::str::from_utf8(bytes) else { return false; @@ -2443,6 +2755,16 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { return false; } + // Closing may generate additional text formats and advance the sequence. + drop(_clipboard); + // Read the sequence first: a later writer must not become our last write. + let sequence = GetClipboardSequenceNumber(); + let sequence = if GetClipboardOwner() == owner { + sequence + } else { + 0 + }; + LAST_CLIPBOARD_WRITE_SEQUENCE.store(sequence, AtomicOrdering::Relaxed); true } } @@ -2451,6 +2773,76 @@ pub fn read_clipboard_text() -> Option { None } +/// Whether the system clipboard currently holds exactly this text. +/// +/// Returns `None` when the clipboard changed since our last write, cannot be read, +/// or has non-text formats. +/// Kept separate from [`read_clipboard_text`] so unsupported modal paste on +/// Windows is unchanged. +pub fn clipboard_text_matches(bytes: &[u8]) -> Option { + let current = read_clipboard_unicode_text()?; + Some(clipboard_text_equals(¤t, bytes)) +} + +fn clipboard_text_equals(current: &str, bytes: &[u8]) -> bool { + let Ok(payload) = std::str::from_utf8(bytes) else { + return false; + }; + normalized_clipboard_newlines(payload) == normalized_clipboard_newlines(current) +} + +fn normalized_clipboard_newlines(text: &str) -> std::borrow::Cow<'_, str> { + if text.contains("\r\n") { + std::borrow::Cow::Owned(text.replace("\r\n", "\n")) + } else { + std::borrow::Cow::Borrowed(text) + } +} + +fn plain_text_clipboard_format(format: u32) -> bool { + format == CF_UNICODETEXT as u32 + || format == CF_TEXT as u32 + || format == CF_OEMTEXT as u32 + || format == CF_LOCALE as u32 +} + +fn read_clipboard_unicode_text() -> Option { + const MAX_CLIPBOARD_TEXT_BYTES: usize = 1024 * 1024; + + for attempt in 0..10 { + if unsafe { OpenClipboard(null_mut()) } != 0 { + let _clipboard = ClipboardGuard; + let sequence = unsafe { GetClipboardSequenceNumber() }; + if sequence == 0 + || sequence != LAST_CLIPBOARD_WRITE_SEQUENCE.load(AtomicOrdering::Relaxed) + { + return None; + } + let format_count = unsafe { CountClipboardFormats() }; + if format_count <= 0 { + return None; + } + let mut format = 0; + for _ in 0..format_count { + format = unsafe { EnumClipboardFormats(format) }; + if format == 0 || !plain_text_clipboard_format(format) { + return None; + } + } + let bytes = clipboard_global_bytes(CF_UNICODETEXT as u32, MAX_CLIPBOARD_TEXT_BYTES)?; + let units = bytes + .chunks_exact(2) + .map(|pair| u16::from_ne_bytes([pair[0], pair[1]])) + .take_while(|unit| *unit != 0); + return String::from_utf16(&units.collect::>()).ok(); + } + if attempt < 9 { + std::thread::sleep(Duration::from_millis(5)); + } + } + None +} + pub fn open_url(url: &str) -> std::io::Result> { let operation = wide_null("open"); let url = wide_null(url); @@ -2544,112 +2936,6 @@ fn clipboard_global_bytes(format: u32, max_bytes: usize) -> Option> { Some(bytes) } -pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result { - let title = title.to_owned(); - let body = body.unwrap_or(&title).to_owned(); - let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1); - std::thread::Builder::new() - .name("herdr-windows-notification".into()) - .spawn(move || show_desktop_notification_on_thread(&title, &body, ready_tx))?; - ready_rx - .recv_timeout(Duration::from_secs(2)) - .map_err(|err| match err { - std::sync::mpsc::RecvTimeoutError::Timeout => std::io::Error::new( - std::io::ErrorKind::TimedOut, - "Windows notification setup timed out", - ), - std::sync::mpsc::RecvTimeoutError::Disconnected => std::io::Error::other( - "Windows notification thread exited before reporting readiness", - ), - })? -} - -fn show_desktop_notification_on_thread( - title: &str, - body: &str, - ready_tx: std::sync::mpsc::SyncSender>, -) { - let class_name = wide_null("STATIC"); - let window_name = wide_null("Herdr notifications"); - let hwnd = unsafe { - CreateWindowExW( - 0, - class_name.as_ptr(), - window_name.as_ptr(), - 0, - 0, - 0, - 0, - 0, - null_mut(), - null_mut(), - null_mut(), - std::ptr::null(), - ) - }; - if hwnd.is_null() { - let _ = ready_tx.send(Err(std::io::Error::last_os_error())); - return; - } - - let mut notification = unsafe { std::mem::zeroed::() }; - notification.cbSize = size_of::() as u32; - notification.hWnd = hwnd; - notification.uID = 1; - notification.hIcon = unsafe { LoadIconW(null_mut(), IDI_APPLICATION) }; - notification.uFlags = NIF_TIP; - if !notification.hIcon.is_null() { - notification.uFlags |= NIF_ICON; - } - copy_wide_truncated(&mut notification.szTip, "Herdr"); - - if unsafe { Shell_NotifyIconW(NIM_ADD, ¬ification) } == 0 { - let _ = ready_tx.send(Err(std::io::Error::other( - "failed to add Herdr notification-area icon", - ))); - unsafe { - DestroyWindow(hwnd); - } - return; - } - - notification.uFlags = NIF_INFO; - notification.dwInfoFlags = NIIF_INFO | NIIF_NOSOUND; - copy_wide_truncated(&mut notification.szInfoTitle, title); - copy_wide_truncated(&mut notification.szInfo, body); - if unsafe { Shell_NotifyIconW(NIM_MODIFY, ¬ification) } == 0 { - unsafe { - Shell_NotifyIconW(NIM_DELETE, ¬ification); - DestroyWindow(hwnd); - } - let _ = ready_tx.send(Err(std::io::Error::other( - "failed to show Herdr desktop notification", - ))); - return; - } - - let _ = ready_tx.send(Ok(true)); - std::thread::sleep(Duration::from_secs(10)); - unsafe { - Shell_NotifyIconW(NIM_DELETE, ¬ification); - DestroyWindow(hwnd); - } -} - -fn copy_wide_truncated(destination: &mut [u16; N], value: &str) { - destination.fill(0); - let mut offset = 0; - for ch in value.chars() { - let mut units = [0; 2]; - let encoded = ch.encode_utf16(&mut units); - if offset + encoded.len() >= N { - break; - } - destination[offset..offset + encoded.len()].copy_from_slice(encoded); - offset += encoded.len(); - } -} - fn wide_null(value: &str) -> Vec { value.encode_utf16().chain(std::iter::once(0)).collect() } @@ -2671,6 +2957,8 @@ impl ProcessHandle { if pid == 0 { return None; } + #[cfg(test)] + PROCESS_INSPECTION_COUNTS.with_borrow_mut(|counts| counts.opens += 1); let handle = unsafe { OpenProcess(access, 0, pid) }; (!handle.is_null()).then_some(Self(handle)) } @@ -2695,6 +2983,24 @@ struct Peb { process_parameters: *mut RtlUserProcessParameters, } +// Prefixes of the x86 PEB and RTL_USER_PROCESS_PARAMETERS through the fields +// needed for cwd; remote pointers must stay 32-bit on a 64-bit reader. +#[cfg(target_pointer_width = "64")] +#[repr(C)] +#[derive(Clone, Copy)] +struct Peb32 { + reserved: [u32; 4], + process_parameters: u32, +} + +#[cfg(target_pointer_width = "64")] +#[repr(C)] +#[derive(Clone, Copy)] +struct ProcessCwdParameters32 { + reserved: [u32; 9], + current_directory: STRING32, +} + #[repr(C)] #[derive(Clone, Copy)] struct CurDir { @@ -3083,6 +3389,177 @@ mod tests { AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow, }; + #[test] + fn local_resources_authorize_account_without_admin_rights() { + use interprocess::local_socket::traits::Listener as _; + use std::io::{Read, Write}; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Security::{ + CreateRestrictedToken, GetTokenInformation, ImpersonateLoggedOnUser, RevertToSelf, + TokenUser, DISABLE_MAX_PRIVILEGE, LUA_TOKEN, SID_AND_ATTRIBUTES, TOKEN_DUPLICATE, + TOKEN_QUERY, TOKEN_USER, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let path = + std::env::temp_dir().join(format!("herdr-user-pipe-{}.sock", std::process::id())); + let listener = crate::ipc::bind_local_listener(&path).unwrap(); + let mut raw_token = std::ptr::null_mut(); + assert_ne!( + unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_DUPLICATE | TOKEN_QUERY, + &mut raw_token, + ) + }, + 0 + ); + let token = unsafe { OwnedHandle::from_raw_handle(raw_token) }; + let mut restricted = std::ptr::null_mut(); + assert_ne!( + unsafe { + CreateRestrictedToken( + token.as_raw_handle(), + DISABLE_MAX_PRIVILEGE | LUA_TOKEN, + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + &mut restricted, + ) + }, + 0 + ); + let restricted = unsafe { OwnedHandle::from_raw_handle(restricted) }; + let private_path = path.with_extension("private"); + super::create_config_temporary(&private_path, true) + .unwrap() + .write_all(b"recovery") + .unwrap(); + assert_ne!( + unsafe { ImpersonateLoggedOnUser(restricted.as_raw_handle()) }, + 0 + ); + let connection = crate::ipc::connect_local_stream(&path); + let private_read = fs::read(&private_path); + let private_write = fs::write(&private_path, b"updated"); + let reverted = unsafe { RevertToSelf() }; + assert_ne!(reverted, 0); + assert_eq!(private_read.unwrap(), b"recovery"); + private_write.unwrap(); + let mut client = connection.expect("the account SID must work without admin membership"); + let mut server = listener.accept().unwrap(); + client.write_all(b"account").unwrap(); + let mut received = [0; 7]; + server.read_exact(&mut received).unwrap(); + assert_eq!(&received, b"account"); + + // Removing the account SID must not leave access through Everyone or + // another ordinary group. This exercises the real DACL access check. + let mut size = 0; + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenUser, + std::ptr::null_mut(), + 0, + &mut size, + ) + }; + let mut user = vec![0usize; (size as usize).div_ceil(std::mem::size_of::())]; + assert_ne!( + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenUser, + user.as_mut_ptr().cast(), + size, + &mut size, + ) + }, + 0 + ); + let user = unsafe { &*user.as_ptr().cast::() }; + let disabled = SID_AND_ATTRIBUTES { + Sid: user.User.Sid, + Attributes: 0, + }; + let mut without_account = std::ptr::null_mut(); + assert_ne!( + unsafe { + CreateRestrictedToken( + token.as_raw_handle(), + DISABLE_MAX_PRIVILEGE | LUA_TOKEN, + 1, + &disabled, + 0, + std::ptr::null(), + 0, + std::ptr::null(), + &mut without_account, + ) + }, + 0 + ); + let without_account = unsafe { OwnedHandle::from_raw_handle(without_account) }; + assert_ne!( + unsafe { ImpersonateLoggedOnUser(without_account.as_raw_handle()) }, + 0 + ); + let denied = crate::ipc::connect_local_stream(&path); + let private_denied = fs::read(&private_path); + let reverted = unsafe { RevertToSelf() }; + assert_ne!(reverted, 0); + assert_eq!( + denied.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert_eq!( + private_denied.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert_ne!( + unsafe { ImpersonateLoggedOnUser(restricted.as_raw_handle()) }, + 0 + ); + let removed = fs::remove_file(private_path); + let reverted = unsafe { RevertToSelf() }; + assert_ne!(reverted, 0); + removed.expect("the account must be able to remove its private recovery files"); + drop(client); + drop(server); + drop(listener); + fs::remove_file(path).unwrap(); + } + + #[test] + fn clipboard_text_equals_normalizes_line_endings() { + assert!(super::clipboard_text_equals("hello", b"hello")); + assert!(super::clipboard_text_equals("a\r\nb", b"a\nb")); + assert!(super::clipboard_text_equals("a\nb", b"a\r\nb")); + assert!(!super::clipboard_text_equals("hello ", b"hello")); + assert!(!super::clipboard_text_equals("hello", b"world")); + assert!(!super::clipboard_text_equals("hello", &[0xff])); + assert!(!super::clipboard_text_equals("a\rb", b"a\nb")); + } + + #[test] + fn clipboard_format_check_rejects_rich_content() { + for format in [ + super::CF_UNICODETEXT, + super::CF_TEXT, + super::CF_OEMTEXT, + super::CF_LOCALE, + ] { + assert!(super::plain_text_clipboard_format(format as u32)); + } + assert!(!super::plain_text_clipboard_format(super::CF_DIB as u32)); + assert!(!super::plain_text_clipboard_format(0xC000)); + } + #[test] fn windows_standard_plugin_runtime_paths_drop_only_disk_and_unc_verbatim_prefixes() { assert_eq!( @@ -3260,15 +3737,6 @@ mod tests { ); } - #[test] - fn windows_notification_text_is_null_terminated_and_unicode_safe() { - let mut destination = [u16::MAX; 6]; - super::copy_wide_truncated(&mut destination, "abc😀def"); - - assert_eq!(String::from_utf16(&destination[..5]).unwrap(), "abc😀"); - assert_eq!(destination[5], 0); - } - #[test] fn powershell_agent_command_omits_argument_list_when_no_arguments_are_passed() { let argv = vec!["opencode".into()]; @@ -3691,40 +4159,108 @@ mod tests { #[test] fn windows_process_cwd_reads_normalized_child_launch_directory() { + use std::path::PathBuf; + let name = format!("Herdr-Cwd-Case-{}", std::process::id()); let cwd = std::env::temp_dir().join(&name); fs::create_dir_all(&cwd).expect("create cwd fixture"); + let cwd = super::normalize_cwd_for_launch_platform(&cwd); let launch_cwd = cwd.with_file_name(name.to_ascii_lowercase()); - - let shell = - std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()); - let mut child = Command::new(shell) - .args(["/D", "/Q", "/C", "ping -n 11 127.0.0.1 > NUL"]) - .current_dir(super::normalize_cwd_for_launch_platform(&launch_cwd)) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn cmd"); - - let deadline = Instant::now() + Duration::from_secs(5); - let mut observed = None; - while Instant::now() < deadline { - observed = super::process_cwd(child.id()); - if observed.as_ref().and_then(|path| path.file_name()) == Some(name.as_ref()) { - break; - } - thread::sleep(Duration::from_millis(100)); + let changed = cwd.join("Changed"); + fs::create_dir(&changed).expect("create changed cwd"); + let windows = PathBuf::from(std::env::var_os("SystemRoot").expect("Windows SystemRoot")); + let shells = [ + windows.join("System32").join("cmd.exe"), + #[cfg(target_pointer_width = "64")] + windows.join("SysWOW64").join("cmd.exe"), + ]; + let mut observations = Vec::new(); + for shell in shells { + let mut child = Command::new(&shell) + .args(["/D", "/Q", "/K"]) + .current_dir(super::normalize_cwd_for_launch_platform(&launch_cwd)) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cmd"); + let observe = |expected: &PathBuf| { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let observed = super::process_cwd(child.id()); + if observed.as_ref() == Some(expected) || Instant::now() >= deadline { + break observed; + } + thread::sleep(Duration::from_millis(20)); + } + }; + let initial = observe(&cwd); + use std::io::Write; + writeln!( + child.stdin.as_ref().unwrap(), + "cd /d \"{}\"\r", + changed.display() + ) + .expect("change cmd cwd"); + let after_cd = observe(&changed); + let pane_cwd = super::pane_process_cwd(child.id()); + let _ = child.kill(); + let _ = child.wait(); + observations.push((shell, initial, after_cd, pane_cwd)); } - - let _ = child.kill(); - let _ = child.wait(); let _ = fs::remove_dir_all(&cwd); + for (shell, initial, after_cd, pane_cwd) in observations { + assert_eq!(initial, Some(cwd.clone()), "{} launch cwd", shell.display()); + assert_eq!( + after_cd, + Some(changed.clone()), + "{} live cd", + shell.display() + ); + assert_eq!(pane_cwd, Some(changed.clone()), "ordinary shell owns cwd"); + } + } - assert_eq!( - observed.as_ref().and_then(|path| path.file_name()), - Some(name.as_ref()) - ); + #[test] + fn windows_shim_cwd_uses_only_an_unambiguous_direct_shell() { + let cases = [ + ("cmdx.exe", vec![(11, 10, "cmd.exe")], Some(11)), + ("pwsh.exe", vec![(11, 10, "pwsh.exe")], Some(11)), + ( + "cmdx.exe", + vec![ + (11, 10, "cmd.exe"), + (12, 11, "pwsh.exe"), + (13, 11, "node.exe"), + ], + Some(11), + ), + ( + "cmdx.exe", + vec![(11, 10, "node.exe"), (12, 11, "cmd.exe")], + None, + ), + ("cmdx.exe", vec![], None), + ( + "cmdx.exe", + vec![(11, 10, "cmd.exe"), (12, 10, "pwsh.exe")], + None, + ), + ]; + for (root_name, children, expected) in cases { + let mut entries = vec![test_entry(10, 1, root_name, &[root_name])]; + entries.extend( + children + .iter() + .map(|&(pid, parent, name)| test_entry(pid, parent, name, &[name])), + ); + let snapshot = super::ProcessSnapshot::new(entries); + assert_eq!( + super::shim_shell_pid(10, &snapshot), + expected, + "{root_name}: {children:?}" + ); + } } #[test] @@ -4015,6 +4551,50 @@ mod tests { assert_eq!(job.processes[0].name, "bash.exe"); } + #[test] + fn windows_held_agent_must_still_belong_to_the_pane() { + let entries = vec![ + test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), + test_entry(11, 10, "claude.exe", &["claude.exe"]), + test_entry(20, 99, "codex.exe", &["codex.exe"]), + test_entry(30, 98, "vim.exe", &["vim.exe"]), + test_entry(50, 77, "claude.exe", &["claude.exe"]), + ]; + let snapshot = super::ProcessSnapshot::new(entries); + let marker = |pane: &'static str| { + move |entry: &super::WindowsProcessEntry| { + Some(if entry.pid == 10 { "pane-a" } else { pane }.to_string()) + } + }; + let belongs = |pid, git_bash, pane| { + super::process_belongs_to_pane(10, pid, &snapshot, |_| git_bash, marker(pane)) + }; + + assert!(belongs(11, false, "pane-b"), "descendant of the shell"); + assert!( + belongs(20, true, "pane-a"), + "escaped agent with the pane marker" + ); + assert!(!belongs(20, true, "pane-b"), "marker from another pane"); + assert!( + !belongs(20, false, "pane-a"), + "escape only applies to Git Bash" + ); + assert!(!belongs(30, true, "pane-a"), "escaped non-agent process"); + assert!( + !belongs(40, true, "pane-a"), + "process gone from the snapshot" + ); + assert!( + !belongs(50, false, "pane-a"), + "agent whose parent chain no longer reaches the shell" + ); + assert!( + !super::process_belongs_to_pane(60, 11, &snapshot, |_| true, marker("pane-a")), + "pane shell gone" + ); + } + #[test] fn windows_process_tree_rejects_ambiguous_runtime_marker_candidates() { let entries = vec![ @@ -4036,6 +4616,131 @@ mod tests { assert_eq!(job.processes[0].name, "bash.exe"); } + #[test] + #[ignore = "isolated 1/15/118-shell process-inspection profile"] + fn windows_process_inspection_profile() { + struct Shell { + child: Box, + pty: Option, + reader: Option>, + } + impl Drop for Shell { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + self.pty.take(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } + } + + fn cpu_time() -> Duration { + let mut creation = super::FILETIME::default(); + let mut exit = super::FILETIME::default(); + let mut kernel = super::FILETIME::default(); + let mut user = super::FILETIME::default(); + assert_ne!( + unsafe { + super::GetProcessTimes( + super::GetCurrentProcess(), + &mut creation, + &mut exit, + &mut kernel, + &mut user, + ) + }, + 0 + ); + let ticks = |time: super::FILETIME| { + (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime) + }; + Duration::from_nanos((ticks(kernel) + ticks(user)) * 100) + } + + let shell = + std::path::PathBuf::from(std::env::var_os("SystemRoot").expect("Windows SystemRoot")) + .join("System32") + .join("cmd.exe"); + for panes in [1, 15, 118] { + let mut shells = Vec::new(); + for _ in 0..panes { + let pty = portable_pty::native_pty_system() + .openpty(portable_pty::PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("open isolated fixed-geometry PTY"); + let mut command = portable_pty::CommandBuilder::new(&shell); + command.args(["/D", "/Q", "/K"]); + let child = pty + .slave + .spawn_command(command) + .expect("spawn isolated idle shell"); + let mut reader = pty + .master + .try_clone_reader() + .expect("clone profile PTY reader"); + shells.push(Shell { + child, + pty: Some(pty), + reader: Some(thread::spawn(move || { + let _ = std::io::copy(&mut reader, &mut std::io::sink()); + })), + }); + } + thread::sleep(Duration::from_millis(300)); + let snapshot = super::ProcessSnapshot::new(super::snapshot_processes()); + for shell in &shells { + assert!( + super::descendant_entries(shell.child.process_id().unwrap(), &snapshot) + .is_empty() + ); + } + for sample in 0..3 { + super::FOREGROUND_PROCESS_SNAPSHOT_CACHE + .lock() + .unwrap() + .cached = None; + super::FOREGROUND_SELECTION_CACHE + .lock() + .unwrap() + .entries + .clear(); + super::PROCESS_INSPECTION_COUNTS + .with_borrow_mut(|counts| *counts = super::ProcessInspectionCounts::default()); + let started = Instant::now(); + let cpu_started = cpu_time(); + let mut inspection_time = Duration::ZERO; + for poll in 0..20 { + let next_poll = started + Duration::from_millis(poll * 500); + thread::sleep(next_poll.saturating_duration_since(Instant::now())); + let inspecting = Instant::now(); + for shell in &mut shells { + assert!(shell.child.try_wait().unwrap().is_none()); + let pid = shell.child.process_id().unwrap(); + let job = super::foreground_job(pid).expect("live shell job"); + assert_eq!(job.process_group_id, pid); + } + inspection_time += inspecting.elapsed(); + } + let cpu = cpu_time() - cpu_started; + let counts = super::PROCESS_INSPECTION_COUNTS.with_borrow(|counts| *counts); + println!( + "panes={panes} sample={sample} polls=20 snapshots={} opens={} command_reads={} inspection_ms={:.3} cpu_ms={:.3} elapsed_ms={:.3}", + counts.snapshots, + counts.opens, + counts.command_reads, + inspection_time.as_secs_f64() * 1000.0, + cpu.as_secs_f64() * 1000.0, + started.elapsed().as_secs_f64() * 1000.0, + ); + } + } + } + #[test] fn windows_foreground_process_snapshot_is_shared_within_ttl() { let mut cache = super::ProcessSnapshotCache { cached: None }; @@ -4231,19 +4936,69 @@ mod tests { } #[test] - fn windows_foreground_selection_cache_does_not_retain_shell_result() { - let snapshot = super::ProcessSnapshot::new(vec![test_entry( - 10, - 1, - "powershell.exe", - &["powershell.exe"], - )]); - let shell = super::foreground_job_from_entry(snapshot.entry(10).unwrap()); - let mut cache = super::ForegroundSelectionCache::default(); - - cache.remember_for_test(10, &snapshot, &shell); + fn windows_foreground_selection_cache_retains_idle_native_shell_until_launch_or_exit() { + for name in ["cmd.exe", "powershell.exe", "PWSH.EXE"] { + let snapshot = super::ProcessSnapshot::new(vec![test_entry(10, 1, name, &[name])]); + let shell = super::foreground_job_from_entry(snapshot.entry(10).unwrap()); + let mut cache = super::ForegroundSelectionCache::default(); + cache.remember_for_test(10, &snapshot, &shell); + assert_eq!(cache.get(10, &snapshot), Some(shell.clone())); + + let launched = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, name, &[name]), + test_entry(20, 10, "codex.exe", &["codex.exe"]), + ]); + assert_eq!(cache.get(10, &launched), None); + let agent = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( + 10, + &launched, + |_| panic!("direct child must bypass escaped-agent inspection"), + |_| panic!("direct child must bypass runtime markers"), + ) + .unwrap(); + assert_eq!(agent.process_group_id, 20); + cache.remember_for_test(10, &launched, &agent); + assert_eq!(cache.get(10, &snapshot), None); + + cache.remember_for_test(10, &snapshot, &shell); + assert_eq!(cache.get(10, &snapshot), Some(shell.clone())); + let short_lived_child = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, name, &[name]), + test_entry(21, 10, "git.exe", &["git.exe"]), + ]); + assert_eq!(cache.get(10, &short_lived_child), None); + cache.remember_for_test(10, &snapshot, &shell); + cache.entries.get_mut(&10).unwrap().shell_identity = super::ProcessIdentity::Stub { + running: false, + creation_time: None, + }; + // An identical fresh signature must not hide shell exit/PID reuse. + assert_eq!(cache.get(10, &snapshot), None); + + cache.remember_for_test(10, &snapshot, &shell); + cache.entries.get_mut(&10).unwrap().verified_at = Instant::now() + .checked_sub(super::FOREGROUND_SELECTION_RECHECK + Duration::from_secs(1)) + .unwrap(); + assert_eq!(cache.get(10, &snapshot), None); + } + } - assert!(cache.entries.is_empty()); + #[test] + fn windows_foreground_selection_cache_keeps_escaped_and_unknown_children_fresh() { + for entries in [ + vec![test_entry(10, 1, "bash.exe", &["bash.exe"])], + vec![test_entry(10, 1, "launcher.exe", &["launcher.exe"])], + vec![ + test_entry(10, 1, "pwsh.exe", &["pwsh.exe"]), + test_entry(20, 10, "node.exe", &["node.exe", "worker.js"]), + ], + ] { + let snapshot = super::ProcessSnapshot::new(entries); + let shell = super::foreground_job_from_entry(snapshot.entry(10).unwrap()); + let mut cache = super::ForegroundSelectionCache::default(); + cache.remember_for_test(10, &snapshot, &shell); + assert_eq!(cache.get(10, &snapshot), None); + } } #[test] diff --git a/src/platform/windows/notifications.rs b/src/platform/windows/notifications.rs new file mode 100644 index 00000000..a8b318d8 --- /dev/null +++ b/src/platform/windows/notifications.rs @@ -0,0 +1,533 @@ +use std::{ + collections::HashMap, + io, + ptr::null_mut, + sync::{mpsc, Arc, LazyLock, Mutex}, + time::Duration, +}; + +use sha2::{Digest, Sha256}; +use windows::{ + core::HSTRING, + Data::Xml::Dom::XmlDocument, + Win32::System::Com::{CoCreateGuid, CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED}, + UI::Notifications::{ToastNotification, ToastNotificationManager, ToastNotifier}, +}; +use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, WPARAM}, + System::{Console::GetConsoleWindow, LibraryLoader::GetModuleHandleW, Registry::*}, + UI::WindowsAndMessaging::*, +}; + +const APP_ID: &str = "Herdr.Desktop"; +const WINDOW_CLASS: &str = "HerdrNotificationActivation"; +const ACTIVATION_MESSAGE: &str = "Herdr.Notification.Activate"; +const SHOW_MESSAGE: u32 = WM_APP + 1; +const ACTIVATOR: &str = "{D58C72D3-4548-4A40-B55B-092EF0B365C3}"; + +type Callback = Arc; + +struct Activation { + token: u128, + callback: Callback, +} + +// ponytail: one entry per notified target for this client lifetime. Retiring by +// history loses clicks already removed by Windows; add retention only if measured. +static ACTIVATIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static SERVICE: Mutex> = Mutex::new(None); + +#[derive(Clone)] +struct Service { + window: usize, + requests: mpsc::Sender, +} + +struct Request { + title: String, + body: Option, + key: String, + callback: Callback, + ready: mpsc::SyncSender>, +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|err| err.into_inner()) +} + +fn winrt(result: windows::core::Result) -> io::Result { + result.map_err(io::Error::other) +} + +struct ComApartment; + +impl ComApartment { + fn new() -> io::Result { + winrt(unsafe { CoInitializeEx(None, COINIT_MULTITHREADED).ok() })?; + Ok(Self) + } +} + +impl Drop for ComApartment { + fn drop(&mut self) { + unsafe { + CoUninitialize(); + } + } +} + +fn random_token() -> io::Result { + winrt(unsafe { CoCreateGuid() }).map(|guid| guid.to_u128()) +} + +fn issue_activation( + activations: &mut HashMap, + key: &str, + callback: Callback, +) -> io::Result { + if let Some(activation) = activations.get_mut(key) { + activation.callback = callback; + return Ok(activation.token); + } + let token = random_token()?; + activations.insert(key.into(), Activation { token, callback }); + Ok(token) +} + +fn scheme_for_executable(executable: &std::path::Path) -> String { + let hash = Sha256::digest(executable.as_os_str().as_encoded_bytes()); + format!( + "herdr-notification-{:016x}", + u64::from_be_bytes(hash[..8].try_into().expect("eight bytes")) + ) +} + +struct RegistryKey(HKEY); + +impl RegistryKey { + fn create(path: &str) -> io::Result { + let mut handle = null_mut(); + let status = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + super::wide_null(path).as_ptr(), + 0, + null_mut(), + REG_OPTION_NON_VOLATILE, + KEY_SET_VALUE, + std::ptr::null(), + &mut handle, + null_mut(), + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + Ok(Self(handle)) + } + + fn set(&self, name: &str, value: &str) -> io::Result<()> { + let value = super::wide_null(value); + let status = unsafe { + RegSetValueExW( + self.0, + super::wide_null(name).as_ptr(), + 0, + REG_SZ, + value.as_ptr().cast(), + (value.len() * 2) as u32, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + Ok(()) + } +} + +impl Drop for RegistryKey { + fn drop(&mut self) { + unsafe { + RegCloseKey(self.0); + } + } +} + +fn register(executable: &std::path::Path, scheme: &str) -> io::Result<()> { + let parent = executable + .parent() + .ok_or_else(|| io::Error::other("missing executable directory"))?; + let host = parent.join("conpty/x64/OpenConsole.exe"); + if !host.is_file() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "Windows notification activation requires the bundled ConPTY runtime", + )); + } + let identity = RegistryKey::create(&format!(r"Software\Classes\AppUserModelId\{APP_ID}"))?; + identity.set("DisplayName", "Herdr")?; + // Unpackaged protocol toasts use a stub activator; no COM server is installed. + identity.set("CustomActivator", ACTIVATOR)?; + let protocol = RegistryKey::create(&format!(r"Software\Classes\{scheme}"))?; + protocol.set("URL Protocol", "")?; + let command = RegistryKey::create(&format!(r"Software\Classes\{scheme}\shell\open\command"))?; + // The pinned, app-local console host avoids flashing a new terminal on click. + command.set( + "", + &format!( + "\"{}\" --headless -- \"{}\" --notification-activate \"%1\"", + host.display(), + executable.display() + ), + ) +} + +fn activation_message() -> u32 { + unsafe { RegisterWindowMessageW(super::wide_null(ACTIVATION_MESSAGE).as_ptr()) } +} + +unsafe extern "system" fn window_proc(window: HWND, message: u32, w: WPARAM, l: LPARAM) -> LRESULT { + if message == activation_message() { + let token = ((w as u128) << 64) | (l as u64 as u128); + let callback = lock(&ACTIVATIONS) + .values() + .find(|activation| activation.token == token) + .map(|activation| Arc::clone(&activation.callback)); + if let Some(callback) = callback { + callback(); + } + return 0; + } + DefWindowProcW(window, message, w, l) +} + +pub(crate) fn foreground_desktop_notification_host() { + // Resolve after target validation: a Windows Terminal tab can move windows. + unsafe { + let host = GetAncestor(GetConsoleWindow(), GA_ROOTOWNER); + if IsWindowVisible(host) != 0 { + if IsIconic(host) != 0 { + ShowWindow(host, SW_RESTORE); + } + SetForegroundWindow(host); + } + } +} + +fn create_window() -> io::Result { + let class = super::wide_null(WINDOW_CLASS); + let instance = unsafe { GetModuleHandleW(std::ptr::null()) }; + let descriptor = WNDCLASSW { + lpfnWndProc: Some(window_proc), + hInstance: instance, + lpszClassName: class.as_ptr(), + ..unsafe { std::mem::zeroed() } + }; + if unsafe { RegisterClassW(&descriptor) } == 0 { + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(1410) { + return Err(err); + } + } + let window = unsafe { + CreateWindowExW( + 0, + class.as_ptr(), + class.as_ptr(), + 0, + 0, + 0, + 0, + 0, + null_mut(), + null_mut(), + instance, + std::ptr::null(), + ) + }; + if window.is_null() { + return Err(io::Error::last_os_error()); + } + let message = activation_message(); + if message == 0 + || unsafe { ChangeWindowMessageFilterEx(window, message, MSGFLT_ALLOW, null_mut()) } == 0 + { + let error = io::Error::last_os_error(); + unsafe { + DestroyWindow(window); + } + return Err(error); + } + Ok(window) +} + +fn notification_xml(title: &str, body: Option<&str>, uri: &str) -> io::Result { + let xml = winrt(XmlDocument::new())?; + winrt(xml.LoadXml(&HSTRING::from(r#""#)))?; + let root = winrt(xml.DocumentElement())?; + winrt(root.SetAttribute(&HSTRING::from("launch"), &HSTRING::from(uri)))?; + let binding = winrt(winrt(xml.GetElementsByTagName(&HSTRING::from("binding")))?.Item(0))?; + for text in std::iter::once(title).chain(body) { + let element = winrt(xml.CreateElement(&HSTRING::from("text")))?; + let node = winrt(xml.CreateTextNode(&HSTRING::from(text)))?; + winrt(element.AppendChild(&node))?; + winrt(binding.AppendChild(&element))?; + } + Ok(xml) +} + +fn show( + request: &Request, + window: HWND, + scheme: &str, + group: &str, + notifier: &ToastNotifier, +) -> io::Result { + let token = { + issue_activation( + &mut lock(&ACTIVATIONS), + &request.key, + Arc::clone(&request.callback), + )? + }; + let uri = format!("{scheme}://{:x}/{token:032x}", window as usize); + let xml = notification_xml(&request.title, request.body.as_deref(), &uri)?; + let notification = winrt(ToastNotification::CreateToastNotification(&xml))?; + winrt(notification.SetGroup(&HSTRING::from(group)))?; + // Only pane notifications replace one another; custom messages stay distinct. + if request.key != "general" { + winrt(notification.SetTag(&HSTRING::from(format!("{token:032x}"))))?; + } + // Setting can report ELEMENT_NOT_FOUND before the first successful Show. + winrt(notifier.Show(¬ification))?; + Ok(true) +} + +fn run(requests: mpsc::Receiver, ready: mpsc::SyncSender>) { + let _apartment = match ComApartment::new() { + Ok(apartment) => apartment, + Err(err) => { + let _ = ready.send(Err(err)); + return; + } + }; + let initialize = || -> io::Result<_> { + let executable = std::env::current_exe()?; + let scheme = scheme_for_executable(&executable); + register(&executable, &scheme)?; + let group = format!("{:032x}", random_token()?); + let notifier = winrt(ToastNotificationManager::CreateToastNotifierWithId( + &HSTRING::from(APP_ID), + ))?; + let window = create_window()?; + Ok((window, scheme, group, notifier)) + }; + let (window, scheme, group, notifier) = match initialize() { + Ok(state) => state, + Err(err) => { + let _ = ready.send(Err(err)); + return; + } + }; + if ready.send(Ok(window as usize)).is_ok() { + let mut message: MSG = unsafe { std::mem::zeroed() }; + while unsafe { GetMessageW(&mut message, window, 0, 0) } > 0 { + if message.message == SHOW_MESSAGE { + while let Ok(request) = requests.try_recv() { + let result = show(&request, window, &scheme, &group, ¬ifier); + let _ = request.ready.send(result); + } + } else { + unsafe { + DispatchMessageW(&message); + } + } + } + } + unsafe { + DestroyWindow(window); + } +} + +pub(crate) fn show_actionable_desktop_notification( + title: &str, + body: Option<&str>, + key: String, + callback: Callback, +) -> io::Result { + let service = { + let mut service = lock(&SERVICE); + if service.is_none() { + let (requests, receiver) = mpsc::channel(); + let (ready, response) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("herdr-windows-notification".into()) + .spawn(move || run(receiver, ready))?; + let window = response + .recv_timeout(Duration::from_secs(2)) + .map_err(io::Error::other)??; + *service = Some(Service { window, requests }); + } + service + .as_ref() + .expect("initialized notification service") + .clone() + }; + let (ready, response) = mpsc::sync_channel(1); + service + .requests + .send(Request { + title: title.into(), + body: body.map(str::to_owned), + key, + callback, + ready, + }) + .map_err(io::Error::other)?; + if unsafe { PostMessageW(service.window as HWND, SHOW_MESSAGE, 0, 0) } == 0 { + return Err(io::Error::last_os_error()); + } + response + .recv_timeout(Duration::from_secs(2)) + .map_err(io::Error::other)? +} + +pub(crate) fn show_desktop_notification(title: &str, body: Option<&str>) -> io::Result { + show_actionable_desktop_notification( + title, + body, + "general".into(), + Arc::new(foreground_desktop_notification_host), + ) +} + +fn parse_activation(uri: &str, scheme: &str) -> Option<(usize, u128)> { + let rest = uri.strip_prefix(scheme)?.strip_prefix("://")?; + let (window, token) = rest.split_once('/')?; + if window.is_empty() + || window.len() > 16 + || token.len() != 32 + || !window + .bytes() + .chain(token.bytes()) + .all(|byte| byte.is_ascii_hexdigit()) + { + return None; + } + let window = usize::from_str_radix(window, 16).ok()?; + (window != 0).then_some((window, u128::from_str_radix(token, 16).ok()?)) +} + +pub(crate) fn maybe_activate_desktop_notification(args: &[String]) -> Option> { + if args.get(1).map(String::as_str) != Some("--notification-activate") { + return None; + } + Some((|| { + let scheme = scheme_for_executable(&std::env::current_exe()?); + let (window, token) = args + .get(2) + .filter(|_| args.len() == 3) + .and_then(|uri| parse_activation(uri, &scheme)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid notification activation", + ) + })?; + let window = window as HWND; + let mut class = [0u16; 64]; + let length = unsafe { GetClassNameW(window, class.as_mut_ptr(), class.len() as i32) }; + if length <= 0 || String::from_utf16_lossy(&class[..length as usize]) != WINDOW_CLASS { + return Ok(()); + } + let mut process = 0; + unsafe { + GetWindowThreadProcessId(window, &mut process); + AllowSetForegroundWindow(process); + } + let message = activation_message(); + if message == 0 + || unsafe { PostMessageW(window, message, (token >> 64) as usize, token as isize) } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + })()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replacement_preserves_issued_click_and_uses_latest_callback() { + let mut activations = HashMap::new(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let first_count = Arc::clone(&count); + let original = issue_activation( + &mut activations, + "endpoint/boot/pane", + Arc::new(move || { + first_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + }), + ) + .expect("token"); + let latest_count = Arc::clone(&count); + let replacement = issue_activation( + &mut activations, + "endpoint/boot/pane", + Arc::new(move || { + latest_count.fetch_add(10, std::sync::atomic::Ordering::Relaxed); + }), + ) + .expect("replacement"); + assert_eq!(original, replacement); + // There is deliberately no dependency on the history entry still existing. + let callback = activations + .values() + .find(|entry| entry.token == original) + .expect("issued click"); + (callback.callback)(); + assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 10); + let restarted = + issue_activation(&mut activations, "endpoint/new-boot/pane", Arc::new(|| {})) + .expect("new boot"); + assert_ne!(restarted, original); + } + + #[test] + fn activation_requires_exact_scheme_window_and_full_issued_token_shape() { + let scheme = "herdr-notification-test"; + let token = 0x0123456789abcdef_fedcba9876543210u128; + let uri = format!("{scheme}://1a/{token:032x}"); + assert_eq!(parse_activation(&uri, scheme), Some((0x1a, token))); + for invalid in [ + "other://1a/0123456789abcdeffedcba9876543210", + "herdr-notification-test://0/0123456789abcdeffedcba9876543210", + "herdr-notification-test://1a/123", + "herdr-notification-test://1a/0123456789abcdeffedcba9876543210/extra", + "herdr-notification-test://1a/+123456789abcdeffedcba9876543210", + ] { + assert_eq!(parse_activation(invalid, scheme), None, "{invalid}"); + } + } + + #[test] + fn notification_text_remains_literal_xml_and_unicode() { + let _apartment = ComApartment::new().expect("COM"); + let title = "Agent & 😀"; + let xml = notification_xml(title, Some("body & "), "herdr-test://1/123").expect("XML"); + let texts = xml + .GetElementsByTagName(&HSTRING::from("text")) + .expect("texts"); + assert_eq!( + texts.Item(0).expect("title").InnerText().expect("text"), + HSTRING::from(title) + ); + assert_eq!( + texts.Item(1).expect("body").InnerText().expect("text"), + HSTRING::from("body & ") + ); + } +} diff --git a/src/plugin_installations.rs b/src/plugin_installations.rs new file mode 100644 index 00000000..2d646c61 --- /dev/null +++ b/src/plugin_installations.rs @@ -0,0 +1,470 @@ +use std::collections::HashMap; +use std::fs::{File, OpenOptions, TryLockError}; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::api::schema::InstalledPluginInfo; + +const LEASE_FILE: &str = ".in-use"; +pub(crate) type Leases = HashMap>; + +pub(crate) fn create_lease(installation: &Path) -> io::Result { + let file = OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(installation.join(LEASE_FILE))?; + file.lock_shared()?; + Ok(file) +} + +fn lease_path(path: &Path) -> Option { + let root = crate::plugin_paths::managed_plugins_dir() + .join("github-installations") + .canonicalize() + .ok()?; + let path = path.canonicalize().ok()?; + let mut components = path.strip_prefix(&root).ok()?.components(); + let installation = root.join(components.next()?).join(components.next()?); + (components.next()?.as_os_str() == "checkout").then_some(installation) +} + +pub(crate) fn command_lease(leases: &Leases, plugin: &InstalledPluginInfo) -> Option> { + let installation = lease_path(Path::new(&plugin.plugin_root))?; + leases.get(&installation).cloned() +} + +// The caller holds the registry lock, preventing cleanup between registry +// selection and acquisition. Missing markers denote untracked installations. +fn retain_checkout(leases: &mut Leases, checkout: &Path) -> io::Result<()> { + let Some(installation) = lease_path(checkout) else { + return Ok(()); + }; + if leases.contains_key(&installation) { + return Ok(()); + } + let file = match OpenOptions::new() + .read(true) + .write(true) + .open(installation.join(LEASE_FILE)) + { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + file.lock_shared()?; + leases.insert(installation, Arc::new(file)); + Ok(()) +} + +// Pin failures leave a registered installation unprotected only after it is +// replaced; plugin store errors must not hide every plugin. +pub(crate) fn load(leases: &mut Leases) -> io::Result> { + crate::persist::plugin_registry::read(|entries| { + for entry in &entries { + if let Err(err) = retain_checkout(leases, Path::new(&entry.plugin_root)) { + tracing::warn!(%err, plugin_root = %entry.plugin_root, "failed to pin plugin installation"); + } + } + Ok(entries) + }) +} + +// Unreadable entries are skipped and reported so callers can stay conservative. +fn installations(errors: &mut Vec) -> Vec { + let root = crate::plugin_paths::managed_plugins_dir().join("github-installations"); + let plugins = match std::fs::read_dir(root) { + Ok(plugins) => plugins, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Vec::new(), + Err(err) => { + errors.push(err); + return Vec::new(); + } + }; + let mut result = Vec::new(); + let generations = |plugin: io::Result| -> io::Result> { + let plugin = plugin?; + if !plugin.file_type()?.is_dir() { + return Ok(Vec::new()); + } + let mut paths = Vec::new(); + for generation in std::fs::read_dir(plugin.path())? { + let generation = generation?; + if generation.file_type()?.is_dir() { + paths.push(generation.path().canonicalize()?); + } + } + Ok(paths) + }; + for plugin in plugins { + match generations(plugin) { + Ok(paths) => result.extend(paths), + Err(err) => errors.push(err), + } + } + result +} + +fn retain_startup_installation( + leases: &mut Leases, + installation: PathBuf, + restored_cwds: &[PathBuf], + handoff: bool, +) -> io::Result<()> { + if leases.contains_key(&installation) { + return Ok(()); + } + let file = match OpenOptions::new() + .read(true) + .write(true) + .open(installation.join(LEASE_FILE)) + { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + let referenced = restored_cwds.iter().any(|cwd| { + cwd.canonicalize() + .unwrap_or_else(|_| cwd.clone()) + .starts_with(installation.join("checkout")) + }); + if !referenced && !handoff { + return Ok(()); + } + match file.try_lock() { + Ok(()) if !referenced => return Ok(()), + Ok(()) => file.unlock()?, + Err(TryLockError::WouldBlock) => {} + Err(TryLockError::Error(err)) => return Err(err), + } + // Also retain generations held by the outgoing server during handoff. + file.lock_shared()?; + leases.insert(installation, Arc::new(file)); + Ok(()) +} + +/// Best-effort: plugin store errors never block startup or handoff. Returns +/// false when an installation may be unpinned; the caller must then skip +/// startup cleanup so it never reclaims files a restored consumer still uses. +pub(crate) fn retain_startup( + leases: &mut Leases, + restored_cwds: &[PathBuf], + handoff: bool, +) -> bool { + let result = crate::persist::plugin_registry::with_registry_lock(|| { + let mut errors = Vec::new(); + let installations = installations(&mut errors); + for err in &errors { + tracing::warn!(%err, "failed to scan plugin installations"); + } + let mut complete = errors.is_empty(); + for installation in installations { + if let Err(err) = + retain_startup_installation(leases, installation.clone(), restored_cwds, handoff) + { + tracing::warn!(%err, installation = %installation.display(), "failed to pin plugin installation"); + complete = false; + } + } + Ok(complete) + }); + result.unwrap_or_else(|err| { + tracing::warn!(%err, "failed to pin plugin installations"); + false + }) +} + +fn reclaim(installation: &Path) -> io::Result<()> { + let Some(component) = installation.parent().and_then(Path::file_name) else { + return Ok(()); + }; + let mutation_path = crate::plugin_paths::managed_plugins_dir() + .join(".locks") + .join(format!(".{}.lock", component.to_string_lossy())); + let mutation = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(mutation_path)?; + match mutation.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => return Ok(()), + Err(TryLockError::Error(err)) => return Err(err), + } + let lease = match OpenOptions::new() + .read(true) + .write(true) + .open(installation.join(LEASE_FILE)) + { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + match lease.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => return Ok(()), + Err(TryLockError::Error(err)) => return Err(err), + } + // Windows cannot remove the open lease file. Registry + mutation + // locks exclude new readers/installers while the handle is closed. + drop(lease); + // Keep the marker if removing checkout files fails so a later + // cleanup can retry (for example, an open Windows build artifact). + match std::fs::remove_dir_all(installation.join("checkout")) { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + std::fs::remove_dir_all(installation) +} + +/// Reclaims every unused installation it can and returns the first error. +pub(crate) fn cleanup() -> io::Result<()> { + crate::persist::plugin_registry::read(|entries| { + let mut errors = Vec::new(); + for installation in installations(&mut errors) { + if entries.iter().any(|entry| { + lease_path(Path::new(&entry.plugin_root)).as_ref() == Some(&installation) + }) { + continue; + } + if let Err(err) = reclaim(&installation) { + errors.push(err); + } + } + errors.into_iter().next().map_or(Ok(()), Err) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn with_config(test: impl FnOnce()) { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let root = std::env::temp_dir().join(format!( + "herdr-plugin-cleanup-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + std::env::set_var("XDG_CONFIG_HOME", &root); + std::fs::create_dir_all(crate::plugin_paths::managed_plugins_dir().join(".locks")).unwrap(); + test(); + match previous { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + std::fs::remove_dir_all(root).unwrap(); + } + + fn installation(id: &str) -> (PathBuf, InstalledPluginInfo) { + let root = crate::plugin_paths::create_managed_installation(id).unwrap(); + let checkout = root.join("checkout"); + std::fs::create_dir(&checkout).unwrap(); + std::fs::write( + checkout.join("herdr-plugin.toml"), + format!( + "id = {id:?}\nname = 'Cleanup'\nversion = '0.1.0'\nmin_herdr_version = '0.6.10'\n" + ), + ) + .unwrap(); + let mut plugin = + crate::app::load_plugin_manifest(&checkout.to_string_lossy(), true).unwrap(); + plugin.source.managed_path = Some(checkout.display().to_string()); + drop(create_lease(&root).unwrap()); + (root, plugin) + } + + #[test] + fn cleanup_waits_for_all_servers_and_command_references_and_preserves_current_paths() { + with_config(|| { + let (current, mut registered) = installation("example.current"); + // Registry paths and config paths can use different aliases. + registered.plugin_root = current.join("checkout/../checkout").display().to_string(); + let (retired, old) = installation("example.retired"); + let untracked = + crate::plugin_paths::create_managed_installation("example.legacy").unwrap(); + crate::persist::plugin_registry::update(|entries| { + *entries = vec![registered.clone(), old.clone()]; + }) + .unwrap(); + let mut first_server = Leases::new(); + let mut second_server = Leases::new(); + load(&mut first_server).unwrap(); + load(&mut second_server).unwrap(); + let command = command_lease(&first_server, &old).unwrap(); + crate::persist::plugin_registry::update(|entries| *entries = vec![registered]).unwrap(); + drop(first_server); + cleanup().unwrap(); + assert!(retired.exists()); + drop(second_server); + cleanup().unwrap(); + assert!(retired.exists(), "running commands outlive the server map"); + drop(command); + cleanup().unwrap(); + assert!(!retired.exists()); + assert!( + current.exists(), + "registered alias must protect the current files" + ); + assert!( + untracked.exists(), + "untracked installations are never reclaimed" + ); + + let (building, _) = installation("example.building"); + let mutation = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(crate::plugin_paths::managed_checkout_lock_path( + "example.building", + )) + .unwrap(); + mutation.lock().unwrap(); + cleanup().unwrap(); + assert!( + building.exists(), + "a building checkout is not yet registered" + ); + drop(mutation); + cleanup().unwrap(); + assert!(!building.exists()); + + // Relinking a checkout subdirectory clears managed source metadata, + // but it still needs registry and running-server protection. + let (linked, mut local) = installation("example.linked"); + let subdir = linked.join("checkout/subplugin"); + std::fs::create_dir(&subdir).unwrap(); + local.plugin_root = subdir.display().to_string(); + local.source = Default::default(); + crate::persist::plugin_registry::update(|entries| *entries = vec![local]).unwrap(); + cleanup().unwrap(); + assert!( + subdir.exists(), + "registered local roots protect their files" + ); + let mut server = Leases::new(); + load(&mut server).unwrap(); + crate::persist::plugin_registry::update(Vec::clear).unwrap(); + cleanup().unwrap(); + assert!( + subdir.exists(), + "unlinked local roots retain live server protection" + ); + drop(server); + cleanup().unwrap(); + assert!(!linked.exists()); + + let (retry, _) = installation("example.retry"); + std::fs::remove_dir_all(retry.join("checkout")).unwrap(); + std::fs::write(retry.join("checkout"), "not a directory").unwrap(); + assert!(cleanup().is_err()); + assert!( + retry.join(LEASE_FILE).exists(), + "failed cleanup remains tracked" + ); + std::fs::remove_file(retry.join("checkout")).unwrap(); + cleanup().unwrap(); + assert!(!retry.exists(), "a later cleanup retries the installation"); + }); + } + + #[test] + fn startup_retains_restored_and_handoff_generations_and_survives_pin_errors() { + with_config(|| { + let (restored, _) = installation("example.restored"); + let (handoff, _) = installation("example.handoff"); + let outgoing = OpenOptions::new() + .read(true) + .write(true) + .open(handoff.join(LEASE_FILE)) + .unwrap(); + outgoing.lock_shared().unwrap(); + let mut unrelated = Leases::new(); + assert!(retain_startup(&mut unrelated, &[], false)); + assert!( + unrelated.is_empty(), + "ordinary startup must not inherit other servers' pins" + ); + let mut incoming = Leases::new(); + assert!(retain_startup( + &mut incoming, + &[restored.join("checkout")], + true + )); + drop(outgoing); + cleanup().unwrap(); + assert!(restored.exists()); + assert!(handoff.exists()); + drop(incoming); + cleanup().unwrap(); + assert!(!restored.exists()); + assert!(!handoff.exists()); + + // Corrupt registry contents disable plugins, not the server. The + // startup scan needs serialization, not registry deserialization. + let registry = crate::config::config_dir().join("plugins.json"); + std::fs::write(®istry, "not json").unwrap(); + let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let app = crate::app::App::try_new( + &crate::config::Config::default(), + crate::app::AppPolicy { + persist_plugin_registry: true, + ..crate::app::AppPolicy::TEST + }, + None, + rx, + crate::api::EventHub::default(), + ) + .expect("corrupt registry must not prevent server startup"); + assert!(app.state.installed_plugins.is_empty()); + drop(app); + std::fs::remove_file(registry).unwrap(); + + let broken = + crate::plugin_paths::create_managed_installation("example.broken").unwrap(); + std::fs::create_dir(broken.join(LEASE_FILE)).unwrap(); + let (retired, _) = installation("example.unused"); + assert!(!retain_startup(&mut Leases::new(), &[], true)); + let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::try_new( + &crate::config::Config::default(), + crate::app::AppPolicy { + persist_plugin_registry: true, + ..crate::app::AppPolicy::TEST + }, + None, + rx, + crate::api::EventHub::default(), + ) + .expect("plugin pin errors must not prevent server startup"); + assert!(!app.plugin_installation_cleanup_allowed); + app.run_plugin_startup_hooks(); + assert!( + retired.exists(), + "startup cleanup is skipped when an installation could not be pinned" + ); + }); + } + + #[test] + fn cleanup_continues_past_installations_it_cannot_remove() { + with_config(|| { + let (stuck, _) = installation("example.stuck"); + std::fs::remove_dir_all(stuck.join("checkout")).unwrap(); + std::fs::write(stuck.join("checkout"), "not a directory").unwrap(); + let (unused, _) = installation("example.unused"); + assert!(cleanup().is_err(), "the failure is still reported"); + assert!(stuck.join(LEASE_FILE).exists()); + assert!(!unused.exists(), "other installations are still reclaimed"); + }); + } +} diff --git a/src/plugin_paths.rs b/src/plugin_paths.rs index 62eed4ff..9f075781 100644 --- a/src/plugin_paths.rs +++ b/src/plugin_paths.rs @@ -12,6 +12,35 @@ pub(crate) fn managed_checkout_path(plugin_id: &str) -> PathBuf { .join(crate::api::schema::plugin_managed_path_component(plugin_id)) } +pub(crate) fn managed_checkout_lock_path(plugin_id: &str) -> PathBuf { + managed_plugins_dir().join(".locks").join(format!( + ".{}.lock", + crate::api::schema::plugin_managed_path_component(plugin_id) + )) +} + +pub(crate) fn managed_installations_dir(plugin_id: &str) -> PathBuf { + managed_plugins_dir() + .join("github-installations") + .join(crate::api::schema::plugin_managed_path_component(plugin_id)) +} + +pub(crate) fn create_managed_installation(plugin_id: &str) -> std::io::Result { + let parent = managed_installations_dir(plugin_id); + std::fs::create_dir_all(&parent)?; + for generation in 0u64.. { + let path = parent.join(format!("{}-{generation}", std::process::id())); + match std::fs::create_dir(&path) { + Ok(()) => return Ok(path), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::other( + "plugin installation directory exhausted", + )) +} + pub(crate) fn plugin_config_dir(plugin_id: &str) -> PathBuf { managed_plugins_dir() .join("config") @@ -47,8 +76,11 @@ fn ensure_plugin_config_dir(plugin_id: &str) -> std::io::Result<()> { fn legacy_plugin_config_dirs(plugin_id: &str) -> Vec { let plugins_dir = managed_plugins_dir(); - let old_unhashed = - (!matches!(plugin_id, "config" | "github")).then(|| plugins_dir.join(plugin_id)); + let old_unhashed = (!matches!( + plugin_id, + "config" | "github" | "github-installations" | ".locks" + )) + .then(|| plugins_dir.join(plugin_id)); let current_hashed = plugins_dir.join(crate::api::schema::plugin_managed_path_component(plugin_id)); let mut candidates = Vec::new(); @@ -110,6 +142,46 @@ fn copy_dir_all(source: &Path, destination: &Path) -> std::io::Result<()> { mod tests { use super::*; + #[test] + fn installations_never_reuse_or_move_existing_files() { + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + let base = std::env::temp_dir().join(format!( + "herdr-installations-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let previous = std::env::var_os("XDG_CONFIG_HOME"); + std::env::set_var("XDG_CONFIG_HOME", &base); + let id = format!("example.generations-{}", std::process::id()); + let first = create_managed_installation(&id).unwrap(); + std::fs::write(first.join("keep"), "original").unwrap(); + let second = create_managed_installation(&id).unwrap(); + assert_ne!(first, second); + assert_eq!( + std::fs::read_to_string(first.join("keep")).unwrap(), + "original" + ); + ensure_plugin_config_dir("github-installations").unwrap(); + assert!( + std::fs::read_dir(plugin_config_dir("github-installations")) + .unwrap() + .next() + .is_none(), + "managed installations are not legacy user configuration" + ); + std::fs::remove_dir_all(&first).unwrap(); + std::fs::remove_dir_all(&second).unwrap(); + std::fs::remove_dir(managed_installations_dir(&id)).unwrap(); + match previous { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn plugin_config_path_component_is_readable_and_collision_free() { assert_eq!( diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index 98653601..62aed7a3 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -52,6 +52,7 @@ pub(crate) struct EncodedBlit { pub(crate) full: bool, next_last_visible_cursor: Option<(u16, u16)>, next_last_cursor_shape: u8, + suppress_visible_cursor: bool, } /// Stateful encoder that diffs semantic frames into terminal ANSI bytes. @@ -60,6 +61,7 @@ pub(crate) struct BlitEncoder { last_frame: Option, last_visible_cursor: Option<(u16, u16)>, last_cursor_shape: u8, + suppress_visible_cursor: bool, } impl BlitEncoder { @@ -124,12 +126,14 @@ impl BlitEncoder { full, next_last_visible_cursor, next_last_cursor_shape, + suppress_visible_cursor, } } pub(crate) fn commit(&mut self, frame: FrameData, encoded: EncodedBlit) { self.last_visible_cursor = encoded.next_last_visible_cursor; self.last_cursor_shape = encoded.next_last_cursor_shape; + self.suppress_visible_cursor = encoded.suppress_visible_cursor; self.last_frame = Some(frame); } @@ -147,17 +151,17 @@ impl BlitEncoder { if rows.iter().any(|row| !patch_row_fits(frame, row)) || patch_rows_overlap(rows) { return None; } - // Metadata revisions need no terminal output. Keep visible cursors on - // the normal path because their suppression policy can change. + // Metadata revisions need no output unless the cursor's display policy changed. if rows.is_empty() && cursor == frame.cursor - && cursor.as_ref().is_none_or(|cursor| !cursor.visible) + && self.suppress_visible_cursor == suppress_visible_cursor { return Some(EncodedBlit { bytes: Vec::new(), full: false, next_last_visible_cursor: self.last_visible_cursor, next_last_cursor_shape: self.last_cursor_shape, + suppress_visible_cursor, }); } let mut bytes = Vec::new(); @@ -178,6 +182,7 @@ impl BlitEncoder { full: false, next_last_visible_cursor, next_last_cursor_shape, + suppress_visible_cursor, }) } @@ -244,6 +249,7 @@ impl BlitEncoder { frame.cursor = cursor; self.last_visible_cursor = encoded.next_last_visible_cursor; self.last_cursor_shape = encoded.next_last_cursor_shape; + self.suppress_visible_cursor = encoded.suppress_visible_cursor; true } } @@ -1928,6 +1934,12 @@ mod tests { visible: false, shape: 2, }), + Some(CursorState { + x: 0, + y: 0, + visible: true, + shape: 2, + }), ] { frame.cursor = cursor.clone(); let initial = encoder.encode(&frame, false); @@ -1966,6 +1978,21 @@ mod tests { ) .unwrap(); assert!(String::from_utf8_lossy(&encoded.bytes).contains("\x1b[?25l")); + let cursor = encoder.last_frame.as_ref().unwrap().cursor.clone(); + assert!(encoder.commit_patch(&[], cursor.clone(), encoded)); + assert!(encoder + .encode_patch(&[], cursor.clone(), true) + .unwrap() + .bytes + .is_empty()); + let encoded = encoder.encode_patch(&[], cursor.clone(), false).unwrap(); + assert!(String::from_utf8_lossy(&encoded.bytes).contains("\x1b[?25h")); + assert!(encoder.commit_patch(&[], cursor.clone(), encoded)); + assert!(encoder + .encode_patch(&[], cursor, false) + .unwrap() + .bytes + .is_empty()); } #[test] diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 6d49592d..ead2ddd7 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -397,29 +397,6 @@ impl ClientPaneInputEvent { #[cfg(any(windows, test))] impl ClientInputEvent { - pub(crate) fn from_crossterm(event: crossterm::event::Event) -> Option { - match event { - crossterm::event::Event::Key(key) => Some(Self::Key { - code: ClientKeyCode::from_crossterm(key.code)?, - modifiers: key.modifiers.bits(), - kind: ClientKeyKind::from_crossterm(key.kind), - repeat_count: 1, - generated_text: None, - source: ClientKeySource::Synthesized, - }), - crossterm::event::Event::Mouse(mouse) => Some(Self::Mouse { - kind: ClientMouseKind::from_crossterm(mouse.kind)?, - column: mouse.column, - row: mouse.row, - modifiers: mouse.modifiers.bits(), - }), - crossterm::event::Event::Paste(text) => Some(Self::Paste { text }), - crossterm::event::Event::FocusGained => Some(Self::FocusGained), - crossterm::event::Event::FocusLost => Some(Self::FocusLost), - crossterm::event::Event::Resize(_, _) => None, - } - } - pub(crate) fn to_raw_input_event(&self) -> crate::raw_input::RawInputEvent { match self { Self::Key { @@ -2225,11 +2202,7 @@ mod tests { }; assert!(key.is_windows_dead_key()); assert_eq!(key.windows_record(), None); - assert!(crate::input::encode_terminal_key( - key, - crate::input::KeyboardProtocol::Kitty { flags: 1 }, - ) - .is_empty()); + assert!(crate::pane::test_encode_key_for_app(b"\x1b[>1u", key).is_empty()); } #[tokio::test] @@ -2307,10 +2280,7 @@ mod tests { }; assert!(roundtripped.has_physical_identity()); - let encoded = crate::input::encode_terminal_key( - roundtripped, - crate::input::KeyboardProtocol::Kitty { flags: 8 }, - ); + let encoded = crate::pane::test_encode_key_for_app(b"\x1b[>8u", roundtripped); assert_ne!(encoded, b"/"); assert!(encoded.starts_with(b"\x1b[")); } diff --git a/src/pty/actor.rs b/src/pty/actor.rs index 76e793ef..36cbaedd 100644 --- a/src/pty/actor.rs +++ b/src/pty/actor.rs @@ -12,7 +12,7 @@ mod windows { use bytes::Bytes; use portable_pty::{MasterPty, PtySize}; - use tokio::sync::mpsc; + use tokio::sync::{mpsc, oneshot}; use tracing::{debug, warn}; pub(crate) struct PtyReadResult { @@ -59,10 +59,16 @@ mod windows { SubmissionPart { bytes: Bytes, deadline: Option, - reply: std_mpsc::Sender>, + reply: oneshot::Sender>, + enter_completion: Option>, }, } + struct InputAcceptance { + accepting: bool, + enter_completion: Option>, + } + enum PtyIoControlCommand { Resize(PtyResizeRequest), Shutdown, @@ -74,7 +80,7 @@ mod windows { control_tx: std_mpsc::Sender, write_tx: std_mpsc::Sender, response_order: Arc>, - accepting: Arc>, + accepting: Arc>, } impl PtyIoActorHandle { @@ -82,10 +88,11 @@ mod windows { &self, bytes: Bytes, ) -> Result<(), mpsc::error::TrySendError> { - if !*self + if !self .accepting .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) + .accepting { return Err(mpsc::error::TrySendError::Closed(bytes)); } @@ -118,7 +125,7 @@ mod windows { .accepting .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !*accepting { + if !accepting.accepting { return Err(std::io::Error::new( std::io::ErrorKind::BrokenPipe, "pty actor closed", @@ -177,9 +184,17 @@ mod windows { } pub(crate) fn shutdown(&self) { - if let Ok(mut accepting) = self.accepting.lock() { - *accepting = false; + let mut accepting = self + .accepting + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // Give a queued Enter the same grace as process termination. A blocked + // pipe writer must not prevent teardown from terminating the child. + if let Some(completion) = accepting.enter_completion.take() { + let _ = completion.recv_timeout(Duration::from_millis(250)); } + accepting.accepting = false; + drop(accepting); let _ = self.control_tx.send(PtyIoControlCommand::Shutdown); } } @@ -206,26 +221,29 @@ mod windows { let (control_tx, control_rx) = std_mpsc::channel::(); let (write_tx, write_rx) = std_mpsc::channel::(); let response_order = Arc::new(Mutex::new(())); - let accepting = Arc::new(Mutex::new(!initially_quiesced)); + let accepting = Arc::new(Mutex::new(InputAcceptance { + accepting: !initially_quiesced, + enter_completion: None, + })); - std::thread::spawn(move || { + crate::thread_spawn::spawn_named("herdr-pty-writer", move || { run_writer(&mut writer, write_rx); debug!(pane_id, "windows pty writer thread exiting"); - }); + })?; { let write_tx = write_tx.clone(); let accepting = Arc::clone(&accepting); - std::thread::spawn(move || { - run_input_forwarder(&mut data_rx, write_tx, accepting); - debug!(pane_id, "windows pty input thread exiting"); + tokio::spawn(async move { + run_input_forwarder(&mut data_rx, write_tx, accepting).await; + debug!(pane_id, "windows pty input task exiting"); }); } { let write_tx = write_tx.clone(); let response_order = Arc::clone(&response_order); - std::thread::spawn(move || { + crate::thread_spawn::spawn_named("herdr-pty-reader", move || { let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf) { @@ -251,12 +269,12 @@ mod windows { on_reader_exit(); } debug!(pane_id, "windows pty reader thread exiting"); - }); + })?; } { let write_tx = write_tx.clone(); - std::thread::spawn(move || { + crate::thread_spawn::spawn_named("herdr-pty-control", move || { for command in control_rx { match command { PtyIoControlCommand::Resize(request) => { @@ -279,7 +297,7 @@ mod windows { } } debug!(pane_id, "windows pty control thread exiting"); - }); + })?; } Ok(PtyIoActorHandle { @@ -300,6 +318,7 @@ mod windows { bytes, deadline, reply, + enter_completion, } => { let result = if deadline.is_some_and(|deadline| Instant::now() >= deadline) { Err(input_submission_timed_out()) @@ -309,6 +328,9 @@ mod windows { let failed = result .as_ref() .is_err_and(|err| err.kind() != std::io::ErrorKind::TimedOut); + // Wake synchronous shutdown directly; it may be blocking the executor + // worker that would otherwise receive the async acknowledgement. + drop(enter_completion); let _ = reply.send(result); if failed { break; @@ -322,12 +344,12 @@ mod windows { } } - fn run_input_forwarder( + async fn run_input_forwarder( data_rx: &mut mpsc::Receiver, write_tx: std_mpsc::Sender, - accepting: Arc>, + accepting: Arc>, ) { - while let Some(command) = data_rx.blocking_recv() { + while let Some(command) = data_rx.recv().await { match command { PtyIoDataCommand::WriteUserInput(bytes) => { if write_tx.send(PtyIoWriteCommand::Write(bytes)).is_err() { @@ -348,18 +370,16 @@ mod windows { } else { let text_deadline = deadline.and_then(|deadline| deadline.checked_sub(delay)); - write_submission_part(&write_tx, text, text_deadline).and_then(|()| { + async { + write_submission_part(&write_tx, text, text_deadline, None).await?; // A started text write is committed. Finish Enter even if the caller // stops waiting so a timeout cannot leave a partial prompt. - std::thread::sleep(delay); - let accepting = accepting - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !*accepting { - return Err(pty_actor_closed()); + if !delay.is_zero() { + tokio::time::sleep(delay).await; } - write_submission_part(&write_tx, enter, None) - }) + write_submission_part(&write_tx, enter, None, Some(&accepting)).await + } + .await }; let failed = result .as_ref() @@ -373,22 +393,40 @@ mod windows { } } - fn write_submission_part( + async fn write_submission_part( write_tx: &std_mpsc::Sender, bytes: Bytes, deadline: Option, + accepting: Option<&Arc>>, ) -> std::io::Result<()> { - let (reply, completion) = std_mpsc::channel(); - write_tx - .send(PtyIoWriteCommand::SubmissionPart { - bytes, - deadline, - reply, - }) - .map_err(|_| pty_actor_closed())?; - completion - .recv() - .unwrap_or_else(|_| Err(pty_actor_closed())) + let completion = { + let (reply, completion) = oneshot::channel(); + let mut accepting = accepting.map(|accepting| { + accepting + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + }); + let enter_completion = if let Some(accepting) = accepting.as_mut() { + if !accepting.accepting { + return Err(pty_actor_closed()); + } + let (done, completion) = std_mpsc::channel(); + accepting.enter_completion = Some(completion); + Some(done) + } else { + None + }; + write_tx + .send(PtyIoWriteCommand::SubmissionPart { + bytes, + deadline, + reply, + enter_completion, + }) + .map_err(|_| pty_actor_closed())?; + completion + }; + completion.await.unwrap_or_else(|_| Err(pty_actor_closed())) } fn pty_actor_closed() -> std::io::Error { @@ -410,6 +448,38 @@ mod windows { #[cfg(test)] mod tests { use super::*; + + fn input_acceptance(accepting: bool) -> Arc> { + Arc::new(Mutex::new(InputAcceptance { + accepting, + enter_completion: None, + })) + } + + fn test_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap() + } + + async fn next_write_command( + receiver: &std_mpsc::Receiver, + ) -> PtyIoWriteCommand { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + match receiver.try_recv() { + Ok(command) => return command, + Err(std_mpsc::TryRecvError::Empty) => tokio::task::yield_now().await, + Err(err) => panic!("writer disconnected: {err}"), + } + } + }) + .await + .expect("async forwarder queues writer command") + } + struct RecordingWriter { writes: Vec<(Vec, Instant)>, flushes: Vec, @@ -440,7 +510,10 @@ mod windows { fail_after: Option, delay: Duration, deadline: Option, - during_delay: impl FnOnce(&std_mpsc::Sender, &Arc>), + during_delay: impl FnOnce( + &std_mpsc::Sender, + &Arc>, + ), ) -> (RecordingWriter, std::io::Result<()>) { let (flushed_tx, flushed_rx) = std_mpsc::channel(); let mut writer = RecordingWriter { @@ -452,7 +525,7 @@ mod windows { let (data_tx, mut data_rx) = mpsc::channel(2); let (write_tx, write_rx) = std_mpsc::channel(); let (reply_tx, reply_rx) = std_mpsc::channel(); - let accepting = Arc::new(Mutex::new(true)); + let accepting = input_acceptance(true); data_tx .try_send(PtyIoDataCommand::SubmitUserInput { text: Bytes::from_static(b"prompt"), @@ -473,14 +546,15 @@ mod windows { }); let input_write_tx = write_tx.clone(); let input_accepting = Arc::clone(&accepting); - let input_thread = std::thread::spawn(move || { - run_input_forwarder(&mut data_rx, input_write_tx, input_accepting) + let runtime = test_runtime(); + let input_task = runtime.spawn(async move { + run_input_forwarder(&mut data_rx, input_write_tx, input_accepting).await }); flushed_rx.recv().expect("prompt was flushed"); during_delay(&write_tx, &accepting); let result = reply_rx.recv().expect("writer reports submission"); drop(data_tx); - input_thread.join().expect("input thread joins"); + runtime.block_on(input_task).expect("input task joins"); drop(write_tx); (writer_thread.join().expect("writer thread joins"), result) } @@ -515,7 +589,7 @@ mod windows { fn shutdown_during_submission_delay_cancels_enter() { let (writer, result) = run_recorded_submission(None, Duration::from_millis(30), None, |_, accepting| { - *accepting.lock().unwrap() = false; + accepting.lock().unwrap().accepting = false; }); let err = result.expect_err("shutdown cancels enter"); assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe); @@ -542,7 +616,7 @@ mod windows { let (write_tx, write_rx) = std_mpsc::channel(); let (first_reply_tx, first_reply_rx) = std_mpsc::channel(); let (expired_reply_tx, expired_reply_rx) = std_mpsc::channel(); - let accepting = Arc::new(Mutex::new(true)); + let accepting = input_acceptance(true); data_tx .try_send(PtyIoDataCommand::SubmitUserInput { text: Bytes::from_static(b"first"), @@ -567,15 +641,16 @@ mod windows { writer }); let input_write_tx = write_tx.clone(); - let input_thread = std::thread::spawn(move || { - run_input_forwarder(&mut data_rx, input_write_tx, accepting) + let runtime = test_runtime(); + let input_task = runtime.spawn(async move { + run_input_forwarder(&mut data_rx, input_write_tx, accepting).await }); first_reply_rx.recv().unwrap().unwrap(); let err = expired_reply_rx.recv().unwrap().unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); drop(data_tx); - input_thread.join().unwrap(); + runtime.block_on(input_task).unwrap(); drop(write_tx); let writer = writer_thread.join().unwrap(); assert_eq!( @@ -587,6 +662,179 @@ mod windows { vec![b"first".as_slice(), b"\r".as_slice()] ); } + + #[tokio::test] + async fn abandoned_committed_submission_finishes_without_blocking_executor() { + let (data_tx, mut data_rx) = mpsc::channel(2); + let (write_tx, write_rx) = std_mpsc::channel(); + let (reply_tx, reply_rx) = std_mpsc::channel(); + let deadline = Instant::now() + Duration::from_millis(50); + data_tx + .try_send(PtyIoDataCommand::SubmitUserInput { + text: Bytes::from_static(b"committed"), + enter: Bytes::from_static(b"\r"), + delay: Duration::from_millis(20), + deadline: Some(deadline), + reply: reply_tx, + }) + .unwrap(); + data_tx + .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( + b"after", + ))) + .unwrap(); + drop(data_tx); + let forwarder = tokio::spawn(async move { + run_input_forwarder(&mut data_rx, write_tx, input_acceptance(true)).await + }); + + let PtyIoWriteCommand::SubmissionPart { bytes, reply, .. } = + next_write_command(&write_rx).await + else { + panic!("text must be first"); + }; + assert_eq!(bytes, b"committed".as_slice()); + assert!(reply_rx.try_recv().is_err(), "completion waits for flush"); + // A text write can finish after the caller's deadline. Its acknowledgement + // must still be consumed, and Enter must follow even after the caller leaves. + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + drop(reply_rx); + reply.send(Ok(())).unwrap(); + let PtyIoWriteCommand::SubmissionPart { + bytes, + deadline, + reply, + enter_completion, + } = next_write_command(&write_rx).await + else { + panic!("queued user input cannot overtake Enter"); + }; + assert_eq!(bytes, b"\r".as_slice()); + assert!(deadline.is_none(), "committed Enter cannot expire"); + assert!( + write_rx.try_recv().is_err(), + "user input waits for Enter flush" + ); + drop(enter_completion); + reply.send(Ok(())).unwrap(); + forwarder.await.unwrap(); + let PtyIoWriteCommand::Write(bytes) = write_rx.try_recv().unwrap() else { + panic!("ordinary user input follows Enter"); + }; + assert_eq!(bytes, b"after".as_slice()); + } + + #[test] + fn shutdown_gives_queued_enter_a_bounded_grace() { + for outcome in ["flushed", "failed", "disconnected", "stalled"] { + let runtime = test_runtime(); + let accepting = input_acceptance(true); + let (data_tx, _data_rx) = mpsc::channel(1); + let (control_tx, control_rx) = std_mpsc::channel(); + let (write_tx, write_rx) = std_mpsc::channel(); + let handle = PtyIoActorHandle { + data_tx, + control_tx, + write_tx: write_tx.clone(), + response_order: Arc::new(Mutex::new(())), + accepting: Arc::clone(&accepting), + }; + let input_accepting = Arc::clone(&accepting); + let input_task = runtime.spawn(async move { + write_submission_part( + &write_tx, + Bytes::from_static(b"\r"), + None, + Some(&input_accepting), + ) + .await + }); + let command = write_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let (shutdown_done_tx, shutdown_done_rx) = std_mpsc::channel(); + let shutdown = std::thread::spawn(move || { + handle.shutdown(); + shutdown_done_tx.send(()).unwrap(); + }); + if outcome == "stalled" { + // Keep the queued Enter and its completion sender alive until + // shutdown returns, as a blocked pipe writer would do. + let result = shutdown_done_rx.recv_timeout(Duration::from_secs(2)); + drop(command); + shutdown.join().unwrap(); + assert!(result.is_ok(), "stalled Enter cannot prevent teardown"); + assert!(matches!( + control_rx.try_recv(), + Ok(PtyIoControlCommand::Shutdown) + )); + assert!(!accepting.lock().unwrap().accepting); + assert!(runtime.block_on(input_task).unwrap().is_err()); + continue; + } + let deadline = Instant::now() + Duration::from_secs(2); + while accepting.try_lock().is_ok() { + assert!(Instant::now() < deadline, "shutdown takes acceptance lock"); + std::thread::yield_now(); + } + assert!( + shutdown_done_rx.try_recv().is_err(), + "shutdown waits for Enter" + ); + assert!( + control_rx.try_recv().is_err(), + "master stays open until Enter finishes" + ); + + let (flushed_tx, _flushed_rx) = std_mpsc::channel(); + let mut writer = RecordingWriter { + writes: vec![], + flushes: vec![], + fail_after: (outcome == "failed").then_some(0), + flushed: flushed_tx, + }; + // Shutdown cannot rely on the async receiver running. Writer completion, + // write failure, and discarded queued commands must all release it. + if outcome == "disconnected" { + drop(command); + } else { + let (writer_tx, writer_rx) = std_mpsc::channel(); + writer_tx.send(command).unwrap(); + drop(writer_tx); + run_writer(&mut writer, writer_rx); + } + shutdown_done_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + shutdown.join().unwrap(); + let result = runtime.block_on(input_task).unwrap(); + assert_eq!(result.is_err(), outcome != "flushed"); + assert_eq!(writer.flushes.len(), usize::from(outcome == "flushed")); + assert!(!accepting.lock().unwrap().accepting); + } + } + + #[tokio::test] + async fn enter_rejects_quiesced_actor_and_failed_enqueue_releases_shutdown_wait() { + let (write_tx, write_rx) = std_mpsc::channel(); + let accepting = input_acceptance(false); + let error = + write_submission_part(&write_tx, Bytes::from_static(b"\r"), None, Some(&accepting)) + .await + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::BrokenPipe); + assert!(write_rx.try_recv().is_err()); + accepting.lock().unwrap().accepting = true; + drop(write_rx); + let error = + write_submission_part(&write_tx, Bytes::from_static(b"\r"), None, Some(&accepting)) + .await + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::BrokenPipe); + let completion = accepting.lock().unwrap().enter_completion.take().unwrap(); + assert!(matches!( + completion.try_recv(), + Err(std_mpsc::TryRecvError::Disconnected) + )); + } } } diff --git a/src/pty/actor/unix.rs b/src/pty/actor/unix.rs index 4d57dd16..47f3c672 100644 --- a/src/pty/actor/unix.rs +++ b/src/pty/actor/unix.rs @@ -98,6 +98,7 @@ pub(crate) struct PtyIoActorHandle { user_writes: Arc>, controls: Arc>, response_order: Arc>, + input_guard: crate::platform::PtyInputGuard, } #[derive(Debug)] @@ -106,6 +107,10 @@ struct UserWriteGate { } impl PtyIoActorHandle { + pub(crate) fn validate_input_submission(&self, bytes: &[u8]) -> std::io::Result<()> { + self.input_guard.validate(bytes) + } + pub(crate) fn try_write_user_input( &self, bytes: Bytes, @@ -393,6 +398,7 @@ impl PtyIoActor { })); let controls = Arc::new(Mutex::new(SharedPtyControls::default())); let response_order = Arc::new(Mutex::new(())); + let file = Arc::new(std::fs::File::from(config.master_fd)); let handle = PtyIoActorHandle { data_tx, control_tx, @@ -400,11 +406,12 @@ impl PtyIoActor { user_writes, controls: Arc::clone(&controls), response_order: Arc::clone(&response_order), + input_guard: crate::platform::PtyInputGuard::new(&file), }; let mut runner = PtyIoActorRunner { pane_id: config.pane_id, - file: std::fs::File::from(config.master_fd), + file, data_rx, control_rx, state: if config.initially_quiesced { @@ -442,7 +449,7 @@ impl PtyIoActor { struct PtyIoActorRunner { pane_id: u32, - file: std::fs::File, + file: Arc, data_rx: mpsc::Receiver, control_rx: std_mpsc::Receiver, state: ActorState, @@ -815,7 +822,7 @@ impl PtyIoActorRunner { fn read_once(&mut self) -> bool { let mut buf = [0u8; 8192]; - match self.file.read(&mut buf) { + match self.file.as_ref().read(&mut buf) { Ok(0) => false, Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => true, Err(err) if err.kind() == std::io::ErrorKind::Interrupted => true, @@ -931,7 +938,7 @@ impl PtyIoActorRunner { fn flush_pending_writes_once(&mut self) -> std::io::Result> { while let Some(write) = self.pending_writes.front() { let chunk = &write.bytes[self.current_write_offset..]; - match self.file.write(chunk) { + match self.file.as_ref().write(chunk) { Ok(0) => { return Err(std::io::Error::new( std::io::ErrorKind::WriteZero, @@ -944,7 +951,7 @@ impl PtyIoActorRunner { let completed = self.pending_writes.pop_front().unwrap(); self.current_write_offset = 0; if let Some(boundary) = completed.boundary { - self.file.flush()?; + self.file.as_ref().flush()?; return Ok(Some(boundary)); } } @@ -959,7 +966,7 @@ impl PtyIoActorRunner { } } } - self.file.flush()?; + self.file.as_ref().flush()?; Ok(None) } @@ -1098,7 +1105,7 @@ mod tests { let wake_pipe = fd::create_wake_pipe().expect("wake pipe"); let runner = PtyIoActorRunner { pane_id: 1, - file: std::fs::File::from(owned), + file: Arc::new(std::fs::File::from(owned)), data_rx, control_rx, state: ActorState::Running, @@ -1600,6 +1607,30 @@ mod tests { handle.shutdown(); } + #[tokio::test] + async fn input_preflight_preserves_handoff_and_shutdown_write_gates() { + let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); + let runtime = crate::terminal::TerminalRuntime::test_with_actor(handle.clone()); + handle.begin_handoff(Duration::from_secs(1)).unwrap(); + // A successful preflight is not permission to bypass the existing gate. + runtime.validate_input_submission(b"blocked").unwrap(); + assert!(runtime + .try_send_bytes(Bytes::from_static(b"blocked")) + .is_err()); + handle.rollback_handoff().unwrap(); + runtime.validate_input_submission(b"after").unwrap(); + runtime + .try_send_bytes(Bytes::from_static(b"after")) + .unwrap(); + let mut bytes = [0; 5]; + peer.read_exact(&mut bytes).unwrap(); + assert_eq!(&bytes, b"after"); + handle.shutdown(); + assert!(runtime + .try_send_bytes(Bytes::from_static(b"closed")) + .is_err()); + } + #[test] fn resize_and_nudge_keep_latest_request_when_command_queue_is_full() { let (data_tx, _data_rx) = mpsc::channel(1); @@ -1618,6 +1649,7 @@ mod tests { user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), controls: Arc::clone(&controls), response_order: Arc::new(Mutex::new(())), + input_guard: Default::default(), }; handle.resize(20, 80, 8, 16, vec![Bytes::from_static(b"old")]); @@ -1669,7 +1701,7 @@ mod tests { let query_light = Arc::clone(&light); let runner = PtyIoActorRunner { pane_id: 1, - file: std::fs::File::from(owned), + file: Arc::new(std::fs::File::from(owned)), data_rx, control_rx, state: ActorState::Running, @@ -1697,6 +1729,7 @@ mod tests { user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), controls, response_order, + input_guard: crate::platform::PtyInputGuard::new(&runner.file), }; let (changed_tx, changed_rx) = std_mpsc::channel(); let (continue_tx, continue_rx) = std_mpsc::channel(); @@ -1766,6 +1799,7 @@ mod tests { user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), controls: Arc::new(Mutex::new(SharedPtyControls::default())), response_order: Arc::new(Mutex::new(())), + input_guard: Default::default(), }; let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1))); @@ -1802,7 +1836,9 @@ mod tests { .expect("queued write"); let mut runner = PtyIoActorRunner { pane_id: 1, - file: std::fs::File::from(unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }), + file: Arc::new(std::fs::File::from(unsafe { + OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) + })), data_rx, control_rx, state: ActorState::Running, diff --git a/src/raw_input.rs b/src/raw_input.rs index c66d7584..aff36e98 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -1,12 +1,11 @@ use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; -#[cfg(windows)] use std::time::{Duration, Instant}; /// Parse raw terminal input bytes into a list of `RawInputEvent`s. /// /// This directly extracts events without going through a channel, making it /// suitable for synchronous use. -#[cfg(any(unix, test))] +#[cfg(test)] pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec { let mut framer = RawInputFramer::default(); let mut events = framer.push(data); @@ -14,6 +13,24 @@ pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec { events } +/// Decode bytes the byte framer already split into complete input. This applies +/// no timing policy of its own: the reader decided where each event ends. +#[cfg(any(unix, test))] +pub(crate) fn parse_framed_input(data: &[u8]) -> Vec { + let mut events = Vec::new(); + let mut rest = data; + while !rest.is_empty() { + let Some((event, consumed)) = extract_one_event(rest) else { + // A lone ESC or Alt introducer the reader released as a key. + events.extend(events_from_framed_chunks(vec![rest.to_vec()])); + break; + }; + events.push(event); + rest = &rest[consumed..]; + } + events +} + use crate::input::{parse_terminal_key_sequence, TerminalKey, TextCommit}; use crate::terminal_theme::{ parse_default_color_response, parse_palette_color_response, DefaultColorKind, HostAppearance, @@ -21,16 +38,20 @@ use crate::terminal_theme::{ }; const ESC: u8 = 0x1b; -#[cfg(unix)] +#[cfg(any(unix, test))] pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10; -#[cfg(unix)] +#[cfg(any(unix, test))] pub(crate) const MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS: i32 = 150; /// Covers the 33 ms split in #4630 without gluing legacy Alt+[ to the next key. -#[cfg(unix)] +#[cfg(any(unix, test))] pub(crate) const MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS: i32 = 50; /// Covers the 350 ms mouse tail delay in #3480. Other input ends it early (#4751). #[cfg(unix)] const DISAMBIGUATED_MOUSE_TAIL_FLUSH_TIMEOUT_MS: i32 = 500; +/// Bytes already inside a sequence cannot be a key, so wait long enough for a +/// split read over a slow link before giving up on them. +#[cfg(any(unix, test))] +pub(crate) const INCOMPLETE_SEQUENCE_FLUSH_TIMEOUT_MS: i32 = 500; pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n"; pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n"; const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~"; @@ -79,13 +100,15 @@ pub enum RawInputEvent { Unsupported, } +/// Byte framer plus event decoding, for the Windows input pump. +#[cfg(any(windows, test))] #[derive(Default)] pub(crate) struct RawInputFramer { byte_framer: RawInputByteFramer, } +#[cfg(any(windows, test))] impl RawInputFramer { - #[cfg(any(windows, test))] pub(crate) fn for_host_input() -> Self { Self { byte_framer: RawInputByteFramer::for_host_input(), @@ -98,12 +121,7 @@ impl RawInputFramer { } pub(crate) fn push(&mut self, data: &[u8]) -> Vec { - Self::events_from_chunks(self.byte_framer.push(data)) - } - - #[cfg(any(windows, test))] - pub(crate) fn has_pending_input(&self) -> bool { - self.byte_framer.has_pending_input() + events_from_framed_chunks(self.byte_framer.push(data)) } #[cfg(windows)] @@ -121,58 +139,61 @@ impl RawInputFramer { false } - #[cfg(any(windows, test))] pub(crate) fn has_pending_bracketed_paste(&self) -> bool { self.byte_framer.has_pending_bracketed_paste() } - #[cfg(any(windows, test))] pub(crate) fn has_pending_default_mouse_sequence(&self) -> bool { starts_with_incomplete_default_mouse_sequence(&self.byte_framer.buffer) } pub(crate) fn flush_timeout(&mut self) -> Vec { - Self::events_from_chunks(self.byte_framer.flush_timeout()) + events_from_framed_chunks(self.byte_framer.flush_timeout()) } - #[cfg(any(windows, test))] pub(crate) fn flush_keyboard_escape(&mut self) -> Vec { if self.byte_framer.buffer.as_slice() == [ESC] { self.byte_framer.lone_escape_recently_flushed = true; - Self::events_from_chunks(vec![std::mem::take(&mut self.byte_framer.buffer)]) + events_from_framed_chunks(vec![std::mem::take(&mut self.byte_framer.buffer)]) } else { self.flush_timeout() } } /// Semantic input ends mouse recovery, unlike another idle interval. - #[cfg(any(windows, test))] pub(crate) fn flush_interrupted(&mut self) -> Vec { #[cfg(windows)] self.byte_framer.flush_lone_escape_on_interruption(); let mut chunks = self.byte_framer.flush_timeout(); self.byte_framer.timed_out_mouse_prefix = None; chunks.extend(self.byte_framer.drain_available_chunks()); - Self::events_from_chunks(chunks) + events_from_framed_chunks(chunks) } +} - fn events_from_chunks(chunks: Vec>) -> Vec { - chunks - .into_iter() - .filter_map(|chunk| { - if chunk.as_slice() == [ESC] { - return Some(RawInputEvent::Key( - TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty()) - .with_vt_bytes(chunk), - )); - } - extract_one_event(&chunk).map(|(event, _consumed)| { - tracing::debug!(raw_bytes = ?chunk, event = ?event, "raw input event parsed"); - event - }) +/// Events for chunks the byte framer released as complete input. +fn events_from_framed_chunks(chunks: Vec>) -> Vec { + chunks + .into_iter() + .filter_map(|chunk| { + if chunk.as_slice() == [ESC] { + return Some(RawInputEvent::Key( + TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty()) + .with_vt_bytes(chunk), + )); + } + // The byte framer released this introducer as a key on its own. + if is_alt_key_introducer(&chunk) { + let text = std::str::from_utf8(&chunk).ok()?; + return parse_terminal_key_sequence(text) + .map(|key| RawInputEvent::Key(key.with_vt_bytes(chunk.clone()))); + } + extract_one_event(&chunk).map(|(event, _consumed)| { + tracing::debug!(raw_bytes = ?chunk, event = ?event, "raw input event parsed"); + event }) - .collect() - } + }) + .collect() } #[derive(Default)] @@ -194,6 +215,11 @@ pub(crate) struct RawInputByteFramer { host_color_tail_split_st: bool, host_cell_size_replies_awaited: u16, host_appearance_reply_awaited: bool, + /// Last query sent or reply received. Hosts may answer only part of a + /// query (or none of it), so the reply counters alone never expire. + host_reply_activity_at: Option, + /// Last read that left an unfinished OSC 10/11 reply buffered. + partial_host_color_reply_read_at: Option, held_pending_host_reply_esc: bool, host_color_scheme_change_tracking: bool, host_appearance_query_on_focus: bool, @@ -205,6 +231,15 @@ pub(crate) struct RawInputByteFramer { } const HOST_COLOR_QUERY_REPLIES: u16 = 258; +/// How long after the last query or reply Herdr still treats `ESC[` / `ESC]` +/// as a possible host reply instead of a key. +const HOST_REPLY_QUIET_WINDOW: Duration = Duration::from_secs(1); +/// How long an unfinished OSC 10/11 reply may wait for its next read before +/// it is dropped (#5052). Twice the split-read wait for any incomplete +/// sequence, and far above the tens of milliseconds seen between fragments +/// over SSH and tmux, yet short enough that a reply the link cut off cannot +/// hold input for long. +const HOST_COLOR_REPLY_IDLE_LIMIT: Duration = Duration::from_secs(1); #[cfg(any(unix, test))] const HOST_CELL_SIZE_QUERY_REPLIES: u16 = 1; const MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES: usize = 32; @@ -216,7 +251,7 @@ impl RawInputByteFramer { ) } - fn with_host_input_policy(preserve_legacy_doubled_escape_input: bool) -> Self { + pub(crate) fn with_host_input_policy(preserve_legacy_doubled_escape_input: bool) -> Self { Self { split_coalesced_escape: !preserve_legacy_doubled_escape_input, ..Self::default() @@ -224,7 +259,19 @@ impl RawInputByteFramer { } pub(crate) fn push(&mut self, data: &[u8]) -> Vec> { + if data.is_empty() { + return self.push_extended(); + } + // Bytes after the idle limit are never part of the abandoned reply. + self.drop_abandoned_host_color_reply(); self.buffer.extend_from_slice(data); + let chunks = self.push_extended(); + self.partial_host_color_reply_read_at = + starts_with_incomplete_default_color_response(&self.buffer).then(Instant::now); + chunks + } + + fn push_extended(&mut self) -> Vec> { #[cfg(unix)] if let Some(prefix_len) = self.awaiting_mouse_tail_after.take() { if !continues_escape_sequence(&self.buffer) { @@ -254,6 +301,36 @@ impl RawInputByteFramer { fn host_color_query_sent_with_replies(&mut self, replies: u16) { self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_add(replies); self.held_pending_host_reply_esc = false; + self.host_reply_activity_at = Some(Instant::now()); + } + + /// Whether a buffered unfinished OSC 10/11 reply has waited past its idle + /// limit or grown past any real reply, so it can no longer hold input. + fn host_color_reply_abandoned(&self) -> bool { + // The Windows default color query keeps its own reply deadline. + #[cfg(windows)] + if self.host_default_color_query_deadline.is_some() { + return false; + } + self.buffer.len() >= MAX_DISCARDED_CONTROL_TAIL_BYTES + || self + .partial_host_color_reply_read_at + .is_none_or(|at| at.elapsed() >= HOST_COLOR_REPLY_IDLE_LIMIT) + } + + fn drop_abandoned_host_color_reply(&mut self) { + if self.partial_host_color_reply_read_at.is_none() + || !starts_with_incomplete_default_color_response(&self.buffer) + || !self.host_color_reply_abandoned() + { + return; + } + tracing::debug!( + len = self.buffer.len(), + "dropping unfinished host color response before new input" + ); + self.buffer.clear(); + self.partial_host_color_reply_read_at = None; } #[cfg(windows)] @@ -267,6 +344,7 @@ impl RawInputByteFramer { fn host_appearance_query_sent(&mut self) { self.host_appearance_reply_awaited = true; self.held_pending_host_reply_esc = false; + self.host_reply_activity_at = Some(Instant::now()); } /// Same hold window as `host_color_query_sent`, for the XTWINOPS cell size @@ -275,12 +353,48 @@ impl RawInputByteFramer { pub(crate) fn host_cell_size_query_sent(&mut self) { self.host_cell_size_replies_awaited = HOST_CELL_SIZE_QUERY_REPLIES; self.held_pending_host_reply_esc = false; + self.host_reply_activity_at = Some(Instant::now()); } fn awaiting_host_reply(&self) -> bool { - self.host_color_replies_awaited > 0 + (self.host_color_replies_awaited > 0 || self.host_cell_size_replies_awaited > 0 - || self.host_appearance_reply_awaited + || self.host_appearance_reply_awaited) + && self + .host_reply_activity_at + .is_some_and(|at| at.elapsed() < HOST_REPLY_QUIET_WINDOW) + } + + /// Stop expecting replies the host never sent, so their reply handling no + /// longer holds or discards what is now ordinary input. + fn expire_unanswered_host_queries(&mut self) { + if self.awaiting_host_reply() || self.host_reply_activity_at.is_none() { + return; + } + // The Windows default color query keeps its own reply deadline. + #[cfg(windows)] + if self.host_default_color_query_deadline.is_some() { + return; + } + self.host_color_replies_awaited = 0; + self.host_cell_size_replies_awaited = 0; + self.host_appearance_reply_awaited = false; + self.held_pending_host_reply_esc = false; + self.host_reply_activity_at = None; + } + + #[cfg(test)] + fn age_partial_host_color_reply(&mut self, by: Duration) { + self.partial_host_color_reply_read_at = self + .partial_host_color_reply_read_at + .and_then(|at| at.checked_sub(by)); + } + + #[cfg(test)] + fn age_host_reply_activity(&mut self, by: Duration) { + self.host_reply_activity_at = self + .host_reply_activity_at + .and_then(|at| at.checked_sub(by)); } #[cfg(any(unix, test))] @@ -295,6 +409,7 @@ impl RawInputByteFramer { self.host_appearance_query_on_focus = true; } + #[cfg(any(unix, test))] pub(crate) fn has_pending_input(&self) -> bool { !self.buffer.is_empty() } @@ -304,6 +419,44 @@ impl RawInputByteFramer { self.host_escape_disambiguation_active = active; } + /// Pending bytes that could still be a key on their own: a lone ESC or ESC + /// plus one introducer. Legacy hosts send those for Escape and Alt+key, and + /// host text bindings (e.g. Ghostty `text:\x1bO`) send them even when the + /// host disambiguates escapes. + #[cfg(any(unix, test))] + fn pending_could_be_escape_key(&self) -> bool { + is_escape_key_prefix(&self.buffer) + } + + /// How long to wait for more input before `flush_timeout`. Only bytes that + /// could still be a key get the short keyboard window; anything already + /// inside a sequence waits long enough for split reads over slow links. + #[cfg(any(unix, test))] + pub(crate) fn idle_flush_timeout_ms(&self, host_mouse_capture_active: bool) -> i32 { + if self.buffer.is_empty() { + return RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS; + } + // A reply Herdr asked for can split right after its introducer. + if self.awaiting_host_reply() && matches!(self.buffer.as_slice(), b"\x1b[" | b"\x1b]") { + return INCOMPLETE_SEQUENCE_FLUSH_TIMEOUT_MS; + } + if self.pending_could_be_escape_key() { + if !host_mouse_capture_active { + return RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS; + } + return match self.buffer.as_slice() { + [ESC] => MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS, + // A mouse report split after ESC[ is still ambiguous with legacy Alt+[. + b"\x1b[" => MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS, + _ => RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS, + }; + } + if self.buffer.first() == Some(&ESC) || starts_with_incomplete_utf8_char(&self.buffer) { + return INCOMPLETE_SEQUENCE_FLUSH_TIMEOUT_MS; + } + RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + } + /// How long to keep holding input after a first idle flush held it. #[cfg(unix)] pub(crate) fn held_input_flush_timeout_ms(&self) -> i32 { @@ -314,22 +467,6 @@ impl RawInputByteFramer { } } - #[cfg(unix)] - pub(crate) fn has_pending_lone_escape(&self) -> bool { - self.buffer.as_slice() == [ESC] - } - - #[cfg(unix)] - pub(crate) fn has_pending_csi_introducer(&self) -> bool { - self.buffer.as_slice() == b"\x1b[" - } - - #[cfg(unix)] - pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool { - starts_with_incomplete_sgr_mouse_sequence(&self.buffer) - || starts_with_incomplete_default_mouse_sequence(&self.buffer) - } - #[cfg(any(windows, test))] pub(crate) fn has_pending_bracketed_paste(&self) -> bool { self.buffer.starts_with(BRACKETED_PASTE_START) @@ -338,6 +475,7 @@ impl RawInputByteFramer { pub(crate) fn flush_timeout(&mut self) -> Vec> { let mut chunks = self.drain_available_chunks(); + self.expire_unanswered_host_queries(); #[cfg(windows)] let host_color_query_expired = self @@ -392,8 +530,11 @@ impl RawInputByteFramer { #[cfg(not(unix))] let mouse_wait_served = false; #[cfg(unix)] + // Prefixes already inside a sequence waited the long window; only the + // ambiguous ESC / ESC[ prefixes need this extra mouse-tail hold. if !mouse_wait_served && self.host_escape_disambiguation_active + && self.pending_could_be_escape_key() && could_continue_as_mouse_report(&self.buffer) { self.awaiting_mouse_tail_after = Some(self.buffer.len()); @@ -459,6 +600,16 @@ impl RawInputByteFramer { } if starts_with_incomplete_default_color_response(&self.buffer) { + if self.host_color_reply_abandoned() { + // The host went quiet mid-reply; never let it hold input (#5052). + tracing::debug!( + len = self.buffer.len(), + "dropping unfinished host color response after idle limit" + ); + self.buffer.clear(); + self.partial_host_color_reply_read_at = None; + return chunks; + } tracing::trace!( len = self.buffer.len(), "waiting for host color response terminator" @@ -515,6 +666,15 @@ impl RawInputByteFramer { return chunks; } + // A legacy host sends Alt+[, Alt+O and Alt+Escape as ESC plus that byte. When nothing + // followed and no reply is expected, it was the key. String introducers + // (OSC, DCS, APC, PM, SOS) also start host replies, so they keep the + // control-string path and are never forwarded as Alt keys (#344). + if is_alt_key_introducer(&self.buffer) && !self.awaiting_host_reply() { + chunks.push(std::mem::take(&mut self.buffer)); + return chunks; + } + if let Some(ControlString::Incomplete { family }) = control_string(&self.buffer) { tracing::debug!( len = self.buffer.len(), @@ -688,7 +848,13 @@ impl RawInputByteFramer { continue; } - if self.split_coalesced_escape && self.buffer.starts_with(b"\x1b\x1b") { + // Kitty key reports carry Alt in their modifiers and are never + // Alt-prefixed, so ESC before a complete report is its own key. WezTerm + // sends an Escape press as bare ESC and the release as a report, + // and a quick tap delivers both together (#1266). + if self.buffer.starts_with(b"\x1b\x1b") + && (self.split_coalesced_escape || starts_with_kitty_key_report(&self.buffer[1..])) + { chunks.push(vec![ESC]); self.buffer.drain(..1); continue; @@ -715,6 +881,10 @@ impl RawInputByteFramer { self.host_color_query_sent(); } } + if is_host_reply_event(&event) && self.host_reply_activity_at.is_some() { + // Long reply bursts (256 palette entries) stay protected. + self.host_reply_activity_at = Some(Instant::now()); + } self.held_pending_host_reply_esc = false; chunks.push(self.buffer[..consumed].to_vec()); self.buffer.drain(..consumed); @@ -733,6 +903,22 @@ impl RawInputByteFramer { } const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128; + +/// Whether `bytes` starts with a complete sequence shaped like a kitty key +/// report, `CSI [:alternates][;mods[:event][;text]] u`. Only the shape is +/// checked: digit-led parameters made of digits, `;` and `:`, ending in `u`. +/// That excludes `CSI u` (cursor restore) and private replies like `CSI ? 7 u`. +fn starts_with_kitty_key_report(bytes: &[u8]) -> bool { + let Some(rest) = bytes.strip_prefix(b"\x1b[") else { + return false; + }; + let params = rest + .iter() + .take_while(|byte| byte.is_ascii_digit() || matches!(byte, b';' | b':')) + .count(); + rest.first().is_some_and(u8::is_ascii_digit) && rest.get(params) == Some(&b'u') +} + #[cfg(windows)] const HOST_COLOR_TAIL_RECOVERY_GRACE: Duration = Duration::from_millis(100); @@ -942,12 +1128,14 @@ fn parse_host_cell_size_report(buffer: &[u8]) -> Option<(u32, u32)> { } fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool { - matches!( - control_string(buffer), - Some(ControlString::Incomplete { - family: ControlStringFamily::Osc - }) - ) && matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;")) + // The prefix check keeps this constant-cost for ordinary input. + matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;")) + && matches!( + control_string(buffer), + Some(ControlString::Incomplete { + family: ControlStringFamily::Osc + }) + ) } #[cfg(windows)] @@ -991,6 +1179,34 @@ fn starts_with_incomplete_host_cell_size_report(buffer: &[u8]) -> bool { && !(height.is_some_and(<[u8]>::is_empty) && width.is_some()) } +fn is_host_reply_event(event: &RawInputEvent) -> bool { + matches!( + event, + RawInputEvent::HostDefaultColor { .. } + | RawInputEvent::HostPaletteColors { .. } + | RawInputEvent::HostCellSizeReport { .. } + | RawInputEvent::HostColorSchemeChanged(_) + ) +} + +/// ESC plus a byte that can also start a longer sequence: Alt+[, Alt+O, and +/// Alt+Escape (ESC ESC, kept whole where doubled escapes are preserved). +fn is_alt_key_introducer(buffer: &[u8]) -> bool { + matches!(buffer, [ESC, b'[' | b'O' | ESC]) +} + +#[cfg(any(unix, test))] +fn is_escape_key_prefix(buffer: &[u8]) -> bool { + match buffer { + [ESC] => true, + [ESC, introducer] => matches!( + introducer, + b'[' | b'O' | b'P' | b']' | b'X' | b'^' | b'_' | 0x1b + ), + _ => false, + } +} + fn control_string(buffer: &[u8]) -> Option { let family = match buffer.get(..2)? { b"\x1b]" => ControlStringFamily::Osc, @@ -1121,6 +1337,7 @@ fn could_continue_as_mouse_report(buffer: &[u8]) -> bool { } #[cfg(any(unix, windows, test))] +#[cfg(any(windows, test))] fn starts_with_incomplete_default_mouse_sequence(buffer: &[u8]) -> bool { buffer.starts_with(b"\x1b[M") && buffer.len() < 6 } @@ -1318,36 +1535,66 @@ fn parse_default_mouse(sequence: &[u8]) -> Option { } fn parse_sgr_mouse(sequence: &str) -> Option { - let body = sequence.strip_prefix("\x1b[<")?; - let final_char = body.chars().last()?; - if final_char != 'M' && final_char != 'm' { - return None; - } + let report = parse_sgr_mouse_report(sequence.as_bytes())?; + let column = u16::try_from(report.x.checked_sub(1)?).ok()?; + let row = u16::try_from(report.y.checked_sub(1)?).ok()?; + Some(report.at_cell(column, row)) +} - let payload = &body[..body.len() - 1]; - let mut parts = payload.split(';'); - let cb = parts.next()?.parse::().ok()?; - let column = parts.next()?.parse::().ok()?.checked_sub(1)?; - let row = parts.next()?.parse::().ok()?.checked_sub(1)?; - let (kind, modifiers) = parse_mouse_cb(cb)?; +/// One SGR mouse report (`CSI < Cb ; x ; y M/m`) with its raw 1-based +/// coordinates: cells normally, pixels when the host reports SGR pixels (1016). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SgrMouseReport { + kind: MouseEventKind, + modifiers: KeyModifiers, + pub(crate) x: u32, + pub(crate) y: u32, +} - let kind = if final_char == 'm' { - match kind { - MouseEventKind::Down(button) => MouseEventKind::Up(button), - other => other, +impl SgrMouseReport { + /// The report as a mouse event at a 0-based cell. + pub(crate) fn at_cell(self, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind: self.kind, + column, + row, + modifiers: self.modifiers, } - } else { - kind - }; + } +} - Some(MouseEvent { +pub(crate) fn parse_sgr_mouse_report(sequence: &[u8]) -> Option { + let body = sequence.strip_prefix(b"\x1b[<")?; + let (&final_byte, payload) = body.split_last()?; + if final_byte != b'M' && final_byte != b'm' { + return None; + } + let mut parts = payload.split(|byte| *byte == b';'); + let cb = u8::try_from(parse_report_number(parts.next()?)?).ok()?; + let x = parse_report_number(parts.next()?)?; + let y = parse_report_number(parts.next()?)?; + if parts.next().is_some() { + return None; + } + let (kind, modifiers) = parse_mouse_cb(cb)?; + let kind = match (final_byte, kind) { + (b'm', MouseEventKind::Down(button)) => MouseEventKind::Up(button), + (_, kind) => kind, + }; + Some(SgrMouseReport { kind, - column, - row, modifiers, + x, + y, }) } +fn parse_report_number(value: &[u8]) -> Option { + (!value.is_empty() && value.iter().all(u8::is_ascii_digit)) + .then(|| std::str::from_utf8(value).ok()?.parse().ok()) + .flatten() +} + fn parse_mouse_cb(cb: u8) -> Option<(MouseEventKind, KeyModifiers)> { let button_number = (cb & 0b0000_0011) | ((cb & 0b1100_0000) >> 4); let dragging = cb & 0b0010_0000 == 0b0010_0000; @@ -1489,6 +1736,24 @@ mod tests { assert!(!is_complete_text_bracketed_paste(b"\x1b[200~\xff\x1b[201~")); } + #[test] + fn sgr_mouse_report_parser_accepts_only_complete_reports() { + for (input, expected) in [ + (b"\x1b[<35;321;241M".as_slice(), Some((321, 241))), + (b"\x1b[<0;1;2m".as_slice(), Some((1, 2))), + (b"key".as_slice(), None), + (b"\x1b[<0;1;2Mkey".as_slice(), None), + (b"\x1b[<0;1M".as_slice(), None), + (b"\x1b[<0;1;2;3M".as_slice(), None), + ] { + assert_eq!( + parse_sgr_mouse_report(input).map(|report| (report.x, report.y)), + expected, + "{input:?}" + ); + } + } + #[test] fn parses_sgr_mouse() { let (RawInputEvent::Mouse(mouse), consumed) = extract_one_event(b"\x1b[<0;20;10M").unwrap() @@ -2153,6 +2418,138 @@ mod tests { assert_eq!(framer.push(b"\x1b\x1b[D"), vec![b"\x1b\x1b[D".to_vec()]); } + /// WezTerm with `enable_kitty_keyboard` sends an Escape press as a bare + /// ESC and its release as a kitty report (#1266). A quick tap lands both in + /// one read, or in two reads before the idle flush. + const WEZTERM_ESCAPE_TAP: (&[u8], &[u8]) = (b"\x1b", b"\x1b[27;1:3u"); + + fn assert_escape_press_then_release(chunks: Vec>, taps: usize) { + let events = events_from_framed_chunks(chunks); + assert_eq!(events.len(), taps * 2, "{events:?}"); + for pair in events.chunks(2) { + assert!( + matches!(&pair[0], RawInputEvent::Key(key) + if key.code == KeyCode::Esc + && key.modifiers.is_empty() + && key.kind == KeyEventKind::Press), + "{events:?}" + ); + assert!( + matches!(&pair[1], RawInputEvent::Key(key) + if key.code == KeyCode::Esc + && key.modifiers.is_empty() + && key.kind == KeyEventKind::Release), + "{events:?}" + ); + } + } + + #[test] + fn preserved_doubled_escape_policy_splits_escape_press_from_kitty_release() { + let (press, release) = WEZTERM_ESCAPE_TAP; + let tap = [press, release].concat(); + + // One read. + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut chunks = framer.push(&tap); + chunks.extend(framer.flush_timeout()); + assert_eq!(chunks, vec![press.to_vec(), release.to_vec()]); + assert_escape_press_then_release(chunks, 1); + + // Two reads before the idle flush. + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut chunks = framer.push(press); + chunks.extend(framer.push(release)); + chunks.extend(framer.flush_timeout()); + assert_escape_press_then_release(chunks, 1); + + // Two quick taps (double Esc) in one read. + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut chunks = framer.push(&[tap.clone(), tap].concat()); + chunks.extend(framer.flush_timeout()); + assert_escape_press_then_release(chunks, 2); + + // The release split across reads still separates once it completes. + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut chunks = framer.push(b"\x1b\x1b[27;"); + chunks.extend(framer.push(b"1:3u")); + chunks.extend(framer.flush_timeout()); + assert_escape_press_then_release(chunks, 1); + } + + #[test] + fn preserved_doubled_escape_policy_splits_the_tap_at_every_read_boundary() { + let (press, release) = WEZTERM_ESCAPE_TAP; + let tap = [press, release].concat(); + for taps in [tap.clone(), [tap.clone(), tap.clone()].concat()] { + let count = taps.len() / tap.len(); + for split in 1..taps.len() { + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut chunks = framer.push(&taps[..split]); + chunks.extend(framer.push(&taps[split..])); + chunks.extend(framer.flush_timeout()); + assert_escape_press_then_release(chunks, count); + } + } + } + + #[test] + fn preserved_doubled_escape_policy_splits_escape_before_alt_kitty_report() { + // Escape, then Alt+a reported by a kitty host: two keys, not Alt+Alt+a. + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut chunks = framer.push(b"\x1b\x1b[97;3u"); + chunks.extend(framer.flush_timeout()); + assert_eq!(chunks, vec![b"\x1b".to_vec(), b"\x1b[97;3u".to_vec()]); + let events = events_from_framed_chunks(chunks); + assert!( + matches!(&events[..], [RawInputEvent::Key(esc), RawInputEvent::Key(alt_a)] + if esc.code == KeyCode::Esc && esc.modifiers.is_empty() + && alt_a.code == KeyCode::Char('a') && alt_a.modifiers == KeyModifiers::ALT), + "{events:?}" + ); + } + + #[test] + fn preserved_doubled_escape_policy_keeps_legacy_alt_sequences_whole() { + for bytes in [ + &b"\x1b\x1b[D"[..], + b"\x1b\x1b[1;3D", + b"\x1b\x1b[3~", + b"\x1b\x1bOA", + ] { + let mut framer = RawInputByteFramer::with_host_input_policy(true); + let mut chunks = framer.push(bytes); + chunks.extend(framer.flush_timeout()); + assert_eq!(chunks, vec![bytes.to_vec()], "{bytes:?}"); + } + for bytes in [ + &b"\x1b[u"[..], + b"\x1b[?7u", + b"\x1b[;3u", + b"\x1b[27;1:3", + b"\x1b[27;1:3~", + b"\x1b[D", + b"\x1bOA", + ] { + assert!(!starts_with_kitty_key_report(bytes), "{bytes:?}"); + } + for bytes in [&b"\x1b[27u"[..], b"\x1b[27;1:3u", b"\x1b[55:47;2;47u"] { + assert!(starts_with_kitty_key_report(bytes), "{bytes:?}"); + } + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_host_input_delivers_quick_wezterm_escape_taps() { + let (press, release) = WEZTERM_ESCAPE_TAP; + let mut framer = RawInputByteFramer::for_host_input(); + let mut chunks = framer.push(&[press, release].concat()); + chunks.extend(framer.flush_timeout()); + assert_escape_press_then_release(chunks, 1); + // Legacy Alt+arrow keeps its doubled escape on macOS. + assert_eq!(framer.push(b"\x1b\x1b[D"), vec![b"\x1b\x1b[D".to_vec()]); + } + #[test] fn sgr_mouse_sequence_split_after_button_prefix_is_reassembled_before_timeout() { let mut framer = RawInputFramer::default(); @@ -2662,6 +3059,48 @@ mod tests { assert_eq!(framer.push(b"b"), vec![b"b".to_vec()]); } + #[test] + fn preserved_doubled_escape_is_alt_escape_after_a_short_wait() { + // macOS keeps legacy ESC ESC whole: Alt+Escape (and Ctrl+Alt+3). + let mut framer = RawInputByteFramer::with_host_input_policy(true); + assert!(framer.push(b"\x1b\x1b").is_empty()); + assert_eq!( + framer.idle_flush_timeout_ms(false), + RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + ); + let chunks = framer.flush_timeout(); + assert_eq!(chunks, vec![b"\x1b\x1b".to_vec()]); + let events = events_from_framed_chunks(chunks); + assert!( + matches!(&events[..], [RawInputEvent::Key(key)] + if key.code == KeyCode::Esc && key.modifiers == KeyModifiers::ALT), + "{events:?}" + ); + assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]); + // A longer Alt sequence still joins up within the window. + assert!(framer.push(b"\x1b\x1b").is_empty()); + assert_eq!(framer.push(b"[A"), vec![b"\x1b\x1b[A".to_vec()]); + } + + #[test] + fn csi_and_ss3_introducers_without_expected_reply_are_alt_keys() { + for (bytes, code) in [ + (&b"\x1b["[..], KeyCode::Char('[')), + (b"\x1bO", KeyCode::Char('O')), + ] { + let mut framer = RawInputFramer::default(); + assert!(framer.push(bytes).is_empty()); + let events = framer.flush_timeout(); + assert!( + matches!(&events[..], [RawInputEvent::Key(key)] + if key.code == code && key.modifiers.contains(KeyModifiers::ALT)), + "{bytes:?}: {events:?}" + ); + // The next keystroke is not swallowed as a sequence tail. + assert_eq!(framer.push(b"a").len(), 1, "{bytes:?}"); + } + } + #[test] fn parse_raw_input_bytes_sync_does_not_parse_incomplete_strings_as_alt_keys() { for bytes in [ @@ -2849,6 +3288,149 @@ mod tests { )); } + fn decoded(chunks: &[Vec]) -> String { + let events: Vec<_> = chunks + .iter() + .flat_map(|chunk| parse_raw_input_bytes_sync(chunk)) + .collect(); + format!("{events:?}") + } + + fn flush_pending(framer: &mut RawInputByteFramer, chunks: &mut Vec>) { + for _ in 0..3 { + if !framer.has_pending_input() { + break; + } + chunks.extend(framer.flush_timeout()); + } + } + + #[test] + fn truncated_host_color_reply_does_not_hold_later_input() { + // #5052: the link cut an OSC 11 reply and nothing else arrived. + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + assert!(framer.push(b"\x1b]11;rgb:1e1e/1e").is_empty()); + // Like the client loop: idle flushes, then a blocking read. + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + framer.age_partial_host_color_reply(HOST_COLOR_REPLY_IDLE_LIMIT); + + assert_eq!(framer.push(b"echo ok\r").concat(), b"echo ok\r"); + assert!(!framer.has_pending_input()); + assert_eq!(framer.push(b"ls").concat(), b"ls"); + } + + #[test] + fn idle_flush_drops_host_color_reply_after_idle_limit() { + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + assert!(framer.push(b"\x1b]11;rgb:1e1e/1e").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.has_pending_input()); + + framer.age_partial_host_color_reply(HOST_COLOR_REPLY_IDLE_LIMIT); + assert!(framer.flush_timeout().is_empty()); + assert!(!framer.has_pending_input()); + // The next read is input, not a reply tail to discard. + assert_eq!(framer.push(b"echo").concat(), b"echo"); + } + + #[test] + fn complete_unsupported_default_color_osc_is_consumed() { + let reply = b"\x1b]11;rgbi:0.5/0.5/0.5\x07"; + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + let mut input = reply.to_vec(); + input.push(b'x'); + assert_eq!(framer.push(&input), vec![reply.to_vec(), b"x".to_vec()]); + assert!(matches!( + extract_one_event(reply), + Some((RawInputEvent::Unsupported, _)) + )); + } + + #[test] + fn host_color_reply_split_within_idle_limit_assembles() { + let reply = b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\"; + let gap = HOST_COLOR_REPLY_IDLE_LIMIT - Duration::from_millis(100); + for cut in 1..reply.len() { + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + let mut chunks = framer.push(&reply[..cut]); + // Once the OSC 10/11 prefix is in, idle flushes keep holding it. + if cut >= 5 { + chunks.extend(framer.flush_timeout()); + chunks.extend(framer.flush_timeout()); + framer.age_partial_host_color_reply(gap); + } + chunks.extend(framer.push(&reply[cut..])); + assert_eq!(chunks, vec![reply.to_vec()], "cut at {cut}"); + } + } + + #[test] + fn unsolicited_host_color_reply_split_in_three_reads_assembles() { + let pieces: [&[u8]; 3] = [b"\x1b]11;rgb:1e1e/", b"1e1e/", b"2e2e\x07"]; + let gap = HOST_COLOR_REPLY_IDLE_LIMIT - Duration::from_millis(100); + let mut framer = RawInputByteFramer::default(); + let mut chunks = Vec::new(); + for piece in pieces { + chunks.extend(framer.push(piece)); + chunks.extend(framer.flush_timeout()); + framer.age_partial_host_color_reply(gap); + } + assert_eq!(chunks, vec![pieces.concat()]); + } + + #[test] + fn input_after_dropped_host_color_reply_is_intact_at_every_split() { + let follow_ups: [&[u8]; 3] = [ + b"\x1b[200~hello\r\x1b[201~", + b"\x1b[A", + b"\x1b]4;1;rgb:cdcd/0000/0000\x1b\\", + ]; + for head in [ + b"\x1b]11;rgb:1e1e/1e".as_slice(), + b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b", + ] { + for follow in follow_ups { + let expected = decoded(&[follow.to_vec()]); + for cut in 0..follow.len() { + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + assert!(framer.push(head).is_empty()); + assert!(framer.flush_timeout().is_empty()); + framer.age_partial_host_color_reply(HOST_COLOR_REPLY_IDLE_LIMIT); + let mut chunks = framer.push(&follow[..cut]); + chunks.extend(framer.push(&follow[cut..])); + flush_pending(&mut framer, &mut chunks); + assert_eq!( + decoded(&chunks), + expected, + "head {head:?} follow {follow:?} cut {cut}" + ); + } + } + } + } + + #[test] + fn unfinished_host_color_reply_is_bounded_across_pushes() { + let prefix = b"\x1b]11;rgb:"; + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + assert!(framer.push(prefix).is_empty()); + let mut chunks = Vec::new(); + for _ in 0..200 { + chunks.extend(framer.push(b"1")); + assert!(framer.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES); + } + // Once the buffer reaches the bound it is dropped; later bytes are input. + let held = MAX_DISCARDED_CONTROL_TAIL_BYTES - prefix.len(); + assert_eq!(chunks.concat(), vec![b'1'; 200 - held]); + } + #[test] fn holds_lone_escape_and_stitches_split_host_cell_size_reply() { let mut framer = RawInputByteFramer::default(); @@ -2903,6 +3485,43 @@ mod tests { assert_eq!(alt_bracket.flush_timeout(), vec![b"\x1b[".to_vec()]); } + #[test] + fn unanswered_host_query_stops_holding_alt_bracket() { + // Many hosts answer only part of the 256-entry palette query, or no + // cell size query at all. Once nothing arrives for a while, ESC[ is a + // key again: a short wait, released alone, next key not swallowed. + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + framer.host_cell_size_query_sent(); + framer.age_host_reply_activity(HOST_REPLY_QUIET_WINDOW); + + assert!(framer.push(b"\x1b[").is_empty()); + assert_eq!( + framer.idle_flush_timeout_ms(false), + RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + ); + assert_eq!(framer.flush_timeout(), vec![b"\x1b[".to_vec()]); + assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]); + } + + #[test] + fn host_reply_burst_keeps_split_protection_alive() { + let mut framer = RawInputByteFramer::default(); + framer.host_color_query_sent(); + framer.age_host_reply_activity(HOST_REPLY_QUIET_WINDOW); + // A reply arriving late still refreshes the window for the next one. + assert_eq!( + framer.push(b"\x1b]4;1;rgb:cd/00/00\x1b\\").len(), + 1, + "palette reply" + ); + assert!(framer.push(b"\x1b]").is_empty()); + assert_eq!( + framer.idle_flush_timeout_ms(false), + INCOMPLETE_SEQUENCE_FLUSH_TIMEOUT_MS + ); + } + #[test] fn malformed_host_reply_tail_preserves_following_input() { let mut framer = RawInputByteFramer::default(); diff --git a/src/remote/args.rs b/src/remote/args.rs index 476acf70..dce8ba55 100644 --- a/src/remote/args.rs +++ b/src/remote/args.rs @@ -1,3 +1,4 @@ +pub(crate) const REMOTE_PREFERENCES_ENV_VAR: &str = "HERDR_REMOTE_PREFERENCES_IDENTITY"; pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; diff --git a/src/remote/attach.rs b/src/remote/attach.rs index ec9ca047..700fa423 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -75,15 +75,21 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { )?; let _bridge = SshStdioBridge::start( - remote.target, + remote.target.clone(), prepared_remote.remote_herdr, local_socket.clone(), - session_name, + session_name.clone(), remote_ssh.options(), false, )?; - run_client_process(&local_socket, &reattach_command, remote.keybindings) + run_client_process( + &local_socket, + &reattach_command, + remote.keybindings, + &remote.target, + &session_name, + ) } pub(crate) fn check_saved_ssh(target: &str, session: &str) -> io::Result<()> { @@ -138,7 +144,8 @@ impl SavedSshSetup { manage, crate::session::DEFAULT_SESSION_NAME.to_owned(), ); - let remote_herdr = RemoteHerdr::for_platform(detect_remote_platform(&ssh)?); + let remote_herdr = + RemoteHerdr::for_platform(detect_remote_platform(&ssh)?.into_setup_platform()); let candidates = remote_binary_candidates(&ssh, &remote_herdr)?; Ok(Self { ssh, @@ -258,6 +265,21 @@ struct RemoteSessionJson { running: bool, } +#[derive(Debug, PartialEq, Eq)] +struct DetectedRemotePlatform { + platform: RemotePlatform, + setup_note: Option<&'static str>, +} + +impl DetectedRemotePlatform { + fn into_setup_platform(self) -> RemotePlatform { + if let Some(note) = self.setup_note { + eprintln!("note: {note}"); + } + self.platform + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RemotePlatform { os: &'static str, @@ -515,8 +537,12 @@ fn posix_remote_output_command(command: &str) -> String { } fn windows_powershell_script_command(script: &str) -> String { + // `$ProgressPreference = 'SilentlyContinue'` stops PowerShell from + // serializing progress records (for example module auto-loading's + // "Preparing modules for first use.") into the stderr CLIXML stream that + // Herdr forwards to the terminal. let script = format!( - "[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); {script}" + "[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); $ProgressPreference = 'SilentlyContinue'; {script}" ); let utf16 = script .encode_utf16() @@ -1272,7 +1298,7 @@ pub(super) fn prepare_remote_herdr( live_handoff_enabled: bool, require_surface_interest: bool, ) -> io::Result { - let platform = detect_remote_platform(ssh)?; + let platform = detect_remote_platform(ssh)?.into_setup_platform(); let remote_herdr = RemoteHerdr::for_platform(platform); let candidates = remote_binary_candidates(ssh, &remote_herdr)?; prepare_discovered_remote_herdr( @@ -1372,7 +1398,7 @@ fn prepare_discovered_remote_herdr( } pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result { - let platform = detect_remote_platform(ssh)?; + let platform = detect_remote_platform(ssh)?.platform; let remote_herdr = RemoteHerdr::for_platform(platform); let candidates = remote_binary_candidates(ssh, &remote_herdr)?; for mut candidate in candidates { @@ -1466,7 +1492,7 @@ pub(super) fn discover_remote_api_metadata( ssh: &RemoteSsh, session: &str, ) -> io::Result { - let platform = detect_remote_platform(ssh)?; + let platform = detect_remote_platform(ssh)?.platform; if !platform.is_windows() { let output = ssh.framed_user_shell_output(&posix_remote_api_discovery_command(&platform, session))?; @@ -1508,7 +1534,7 @@ pub(super) fn discover_remote_api_metadata( )) } -fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result { +fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result { let output = ssh.sh_output("uname -s\nuname -m\n")?; let mut windows_uname_hint = false; let posix_error = if output.status.success() { @@ -1517,7 +1543,10 @@ fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result { let os = lines.next().unwrap_or_default(); let arch = lines.next().unwrap_or_default(); if let Some(platform) = RemotePlatform::from_uname(os, arch) { - return Ok(platform); + return Ok(DetectedRemotePlatform { + platform, + setup_note: None, + }); } windows_uname_hint = looks_like_windows_uname(os); io::Error::other(format!( @@ -1568,20 +1597,27 @@ fn windows_platform_probe_command() -> String { ) } -fn parse_windows_platform_probe(stdout: &str) -> Result, String> { +fn parse_windows_platform_probe(stdout: &str) -> Result, String> { let Some(arch) = stdout .lines() .find_map(|line| line.trim().strip_prefix("herdr-windows:")) else { return Ok(None); }; - match arch.trim().to_ascii_uppercase().as_str() { - "AMD64" | "X86_64" => Ok(Some(RemotePlatform { + let setup_note = match arch.trim().to_ascii_uppercase().as_str() { + "AMD64" | "X86_64" => None, + "ARM64" => Some( + "Windows ARM64 support is best-effort and uses x64 emulation; bugs and issues are expected.", + ), + arch => return Err(format!("unsupported remote platform: Windows {arch}")), + }; + Ok(Some(DetectedRemotePlatform { + platform: RemotePlatform { os: "windows", arch: "x86_64", - })), - arch => Err(format!("unsupported remote platform: Windows {arch}")), - } + }, + setup_note, + })) } fn remote_binary_candidates( @@ -3337,17 +3373,21 @@ fn run_client_process( local_socket: &Path, reattach_command: &str, keybindings: RemoteKeybindings, + target: &str, + session: &str, ) -> io::Result<()> { let exe = std::env::current_exe()?; - let status = Command::new(exe) - .arg("client") - .env( - crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, - local_socket, - ) - .env(REATTACH_COMMAND_ENV_VAR, reattach_command) - .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) - .env_remove(crate::api::SOCKET_PATH_ENV_VAR) + let mut command = Command::new(exe); + command.arg("client"); + configure_remote_client_environment( + &mut command, + local_socket, + reattach_command, + keybindings, + target, + session, + )?; + let status = command .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) @@ -3363,6 +3403,28 @@ fn run_client_process( } } +fn configure_remote_client_environment( + command: &mut Command, + local_socket: &Path, + reattach_command: &str, + keybindings: RemoteKeybindings, + target: &str, + session: &str, +) -> io::Result<()> { + let preference_identity = + serde_json::to_string(&(target, session)).map_err(io::Error::other)?; + command + .env( + crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, + local_socket, + ) + .env(REMOTE_PREFERENCES_ENV_VAR, preference_identity) + .env(REATTACH_COMMAND_ENV_VAR, reattach_command) + .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) + .env_remove(crate::api::SOCKET_PATH_ENV_VAR); + Ok(()) +} + fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf { let pid = std::process::id(); let target_clean = sanitize_path_component(target); @@ -3410,6 +3472,44 @@ fn sanitize_path_component(input: &str) -> String { mod tests { use super::*; + #[test] + fn remote_client_preferences_cross_process_handoff() { + let root = + std::env::temp_dir().join(format!("herdr-preference-handoff-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + for bridge in ["bridge-100.sock", "bridge-200.sock"] { + let mut command = Command::new(std::env::current_exe().unwrap()); + command.args(["--exact", "client::shell::tests::keybindings_settings::remote_client_preferences_process_child", "--nocapture"]); + command + .env("HERDR_TEST_PREFERENCES_CHILD", "1") + .env("XDG_STATE_HOME", &root); + // A stale inherited identity must be replaced by this launch's identity. + command.env(REMOTE_PREFERENCES_ENV_VAR, r#"["wrong", "session"]"#); + configure_remote_client_environment( + &mut command, + &root.join(bridge), + "herdr --remote dev", + RemoteKeybindings::Local, + "dev", + "agents", + ) + .unwrap(); + let output = command.output().unwrap(); + assert!( + output.status.success(), + "child failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("1 passed"), + "child test did not run: {}", + String::from_utf8_lossy(&output.stdout) + ); + } + fs::remove_dir_all(root).unwrap(); + } + fn decode_windows_command(command: &str) -> String { let encoded = command .strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ") @@ -4406,21 +4506,41 @@ mod tests { } #[test] - fn windows_platform_probe_accepts_only_x86_64() { - assert_eq!( - parse_windows_platform_probe("profile noise\r\nherdr-windows:AMD64\r\n").unwrap(), - Some(RemotePlatform { - os: "windows", - arch: "x86_64", - }) - ); + fn windows_platform_probe_preserves_native_and_unsupported_architectures() { + for arch in ["AMD64", "x86_64"] { + assert_eq!( + parse_windows_platform_probe(&format!("profile noise\r\nherdr-windows:{arch}\r\n")) + .unwrap(), + Some(DetectedRemotePlatform { + platform: RemotePlatform { + os: "windows", + arch: "x86_64", + }, + setup_note: None, + }) + ); + } assert_eq!(parse_windows_platform_probe("other output").unwrap(), None); assert_eq!( - parse_windows_platform_probe("herdr-windows:ARM64").unwrap_err(), - "unsupported remote platform: Windows ARM64" + parse_windows_platform_probe("herdr-windows:x86").unwrap_err(), + "unsupported remote platform: Windows X86" ); } + #[test] + fn windows_arm64_probe_selects_x64_package() { + let platform = parse_windows_platform_probe("herdr-windows:ARM64") + .expect("Windows ARM64 should use the x64 build on a best-effort basis") + .expect("the Windows platform marker should be recognized"); + assert_eq!(platform.platform.asset_key(), "windows-x86_64"); + let note = platform + .setup_note + .expect("ARM64 setup must warn about emulation"); + assert!(note.contains("best-effort")); + assert!(note.contains("x64 emulation")); + assert!(note.contains("bugs and issues are expected")); + } + #[test] fn windows_probe_preserves_transport_and_unsupported_unix_failures() { let cases = [ @@ -4625,7 +4745,7 @@ mod tests { ]; let preamble = format!( - "[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); " + "[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); $ProgressPreference = 'SilentlyContinue'; " ); for (label, command, expected) in commands { let script = decode_windows_command(&command); @@ -4636,6 +4756,21 @@ mod tests { } } + #[test] + fn windows_remote_commands_suppress_powershell_progress_records() { + // PowerShell serializes progress records (such as module auto-loading's + // "Preparing modules for first use.") to the stderr CLIXML stream when + // stderr is redirected under `-EncodedCommand`. Herdr forwards that + // stderr, so the shared preamble must silence progress. + let script = decode_windows_command(&windows_powershell_script_command("exit 0")); + let preference = "$ProgressPreference = 'SilentlyContinue'; "; + assert!(script.contains(preference), "{script}"); + assert!( + script.find(preference).unwrap() < script.find("exit 0").unwrap(), + "{script}" + ); + } + #[test] fn windows_install_commands_copy_zip_and_return_concrete_path() { assert_eq!( diff --git a/src/remote/host.rs b/src/remote/host.rs index 825ae10c..41e54b6a 100644 --- a/src/remote/host.rs +++ b/src/remote/host.rs @@ -39,7 +39,7 @@ pub(crate) fn run_remote_client_bridge(args: &[String]) -> io::Result<()> { fn ensure_remote_server_running() -> io::Result<()> { let socket_path = crate::server::socket_paths::client_socket_path(); - if crate::server::autodetect::is_server_listening() { + if crate::server::autodetect::is_server_listening()? { let status = crate::api::read_runtime_status_at( &crate::api::socket_path(), Duration::from_millis(500), diff --git a/src/render_signal.rs b/src/render_signal.rs index a6ebd839..89888bf7 100644 --- a/src/render_signal.rs +++ b/src/render_signal.rs @@ -76,6 +76,18 @@ impl RenderSignal { .any(|pane_id| state.immediate_pty_sources.contains(pane_id)) } + pub(crate) fn has_pending_visible_pty_source(&self, eligible: impl Fn(PaneId) -> bool) -> bool { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state + .request + .pty_sources + .iter() + .any(|pane_id| state.immediate_pty_sources.contains(pane_id) && eligible(*pane_id)) + } + /// Coalesces terminal-title changes separately from ordinary PTY damage so /// consumers can update metadata without inspecting every pane. pub(crate) fn request_terminal_title(&self, pane_id: PaneId) -> bool { diff --git a/src/selection.rs b/src/selection.rs index 5f81fdcd..9b47b2e0 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -358,14 +358,22 @@ fn should_prefer_osc52() -> bool { /// /// Some terminals still only honor BEL-terminated OSC 52 writes, so herdr /// emits BEL here even though ST works in newer emulators. -pub fn write_osc52_bytes(bytes: &[u8]) { - if !should_prefer_osc52() && crate::platform::write_clipboard(bytes) { - return; +/// +/// Returns false when the clipboard already holds the same text or output fails. +pub fn write_osc52_bytes(bytes: &[u8]) -> bool { + let prefer_osc52 = should_prefer_osc52(); + if !prefer_osc52 && crate::platform::clipboard_text_matches(bytes) == Some(true) { + tracing::debug!(bytes = bytes.len(), "suppressed duplicate clipboard write"); + return false; } - let sequence = osc52_sequence(bytes); - let _ = std::io::stdout().write_all(sequence.as_bytes()); - let _ = std::io::stdout().flush(); + if !prefer_osc52 && crate::platform::write_clipboard(bytes) { + true + } else { + let sequence = osc52_sequence(bytes); + let mut stdout = std::io::stdout(); + stdout.write_all(sequence.as_bytes()).is_ok() && stdout.flush().is_ok() + } } #[cfg(test)] diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs index afd8edfd..729e7890 100644 --- a/src/server/autodetect.rs +++ b/src/server/autodetect.rs @@ -41,25 +41,35 @@ pub(crate) const STARTUP_CWD_ENV_VAR: &str = "HERDR_STARTUP_CWD"; /// server) are detected because connect returns `ConnectionRefused` /// when nobody is listening. #[allow(dead_code)] // Public API for external use and testing -pub fn is_server_listening() -> bool { +pub fn is_server_listening() -> io::Result { is_server_listening_at(&client_socket_path()) } /// Checks whether a herdr server is listening at a specific socket path. -fn is_server_listening_at(socket_path: &Path) -> bool { +fn is_server_listening_at(socket_path: &Path) -> io::Result { #[cfg(windows)] { - let _ = socket_path; - read_server_status().ok().flatten().is_some() + match crate::platform::probe_local_server(socket_path) { + Ok(()) => Ok(true), + Err(err) + if matches!( + err.kind(), + io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused + ) => + { + Ok(false) + } + Err(err) => Err(err), + } } #[cfg(not(windows))] { if !socket_path.exists() { - return false; + return Ok(false); } - match crate::ipc::connect_local_stream(socket_path) { + Ok(match crate::ipc::connect_local_stream(socket_path) { Ok(_) => { // Server is listening. Close the test connection immediately. // The server's handshake handler will time out on this connection @@ -84,7 +94,7 @@ fn is_server_listening_at(socket_path: &Path) -> bool { tracing::warn!(err = %err, "unexpected error checking server socket"); false } - } + }) } } @@ -260,7 +270,7 @@ pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Resu } #[cfg(not(windows))] - if is_server_listening_at(socket_path) { + if is_server_listening_at(socket_path)? { info!(path = %socket_path.display(), "server socket ready"); return Ok(()); } @@ -304,18 +314,20 @@ pub fn auto_detect_launch(saved_federation: bool) -> io::Result<()> { let socket_path = client_socket_path(); info!(path = %socket_path.display(), "auto-detect launch starting"); - let startup = if is_server_listening_at(&socket_path) { - info!("server already running, attaching as client"); - if saved_federation { - Ok(()) + let startup = is_server_listening().and_then(|listening| { + if listening { + info!("server already running, attaching as client"); + if saved_federation { + Ok(()) + } else { + validate_running_server_compatibility(false) + } } else { - validate_running_server_compatibility(false) + info!("no server running, spawning server daemon"); + spawn_server_daemon() + .and_then(|_| wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)) } - } else { - info!("no server running, spawning server daemon"); - spawn_server_daemon() - .and_then(|_| wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)) - }; + }); if let Err(error) = startup { if !saved_federation { return Err(error); @@ -331,6 +343,71 @@ pub fn auto_detect_launch(saved_federation: bool) -> io::Result<()> { // Tests // --------------------------------------------------------------------------- +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions}; + use interprocess::os::windows::local_socket::ListenerOptionsExt as _; + use interprocess::os::windows::security_descriptor::SecurityDescriptor; + + #[test] + fn reachability_distinguishes_denied_silent_busy_and_absent_pipes() { + let path = + std::env::temp_dir().join(format!("herdr-startup-probe-{}.sock", std::process::id())); + let name = path.to_string_lossy(); + let denied = ListenerOptions::new() + .name(name.to_ns_name::().unwrap()) + .security_descriptor( + SecurityDescriptor::deserialize( + &widestring::U16CString::from_str("D:P(A;;GA;;;SY)").unwrap(), + ) + .unwrap(), + ) + .create_sync() + .unwrap(); + assert_eq!( + is_server_listening_at(&path).unwrap_err().kind(), + io::ErrorKind::PermissionDenied + ); + drop(denied); + // A real listener that never answers the status API is still present: + // automatic startup must not replace it just because it is unresponsive. + let silent = crate::ipc::bind_local_listener(&path).unwrap(); + assert!(is_server_listening_at(&path).unwrap()); + drop(silent); + let busy = crate::ipc::bind_local_listener(&path).unwrap(); + let occupied = crate::ipc::connect_local_stream(&path).unwrap(); + let start_probe = || { + let probe_path = path.clone(); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = result_tx.send(is_server_listening_at(&probe_path)); + }); + result_rx + }; + assert_eq!( + start_probe() + .recv_timeout(Duration::from_secs(5)) + .expect("an occupied pipe must not block startup") + .unwrap_err() + .kind(), + io::ErrorKind::TimedOut + ); + let retry = start_probe(); + assert!(matches!( + retry.recv_timeout(Duration::from_millis(50)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + )); + let accepted = busy.accept().unwrap(); + assert!(retry.recv_timeout(Duration::from_secs(5)).unwrap().unwrap()); + drop(accepted); + drop(occupied); + drop(busy); + assert!(!is_server_listening_at(&path).unwrap()); + std::fs::remove_file(path).unwrap(); + } +} + #[cfg(all(test, unix))] mod tests { use super::*; @@ -356,7 +433,7 @@ mod tests { fn is_server_listening_returns_false_for_nonexistent_path() { let dir = unique_test_dir("nonexistent"); let path = dir.join("s.sock"); - assert!(!is_server_listening_at(&path)); + assert!(!is_server_listening_at(&path).unwrap()); } #[test] @@ -424,7 +501,7 @@ test "$sid" = "$$" let path = dir.join("s.sock"); let _listener = UnixListener::bind(&path).unwrap(); - assert!(is_server_listening_at(&path)); + assert!(is_server_listening_at(&path).unwrap()); let _ = std::fs::remove_dir_all(dir); } @@ -441,7 +518,7 @@ test "$sid" = "$$" } // The socket file exists but nobody is listening. - assert!(!is_server_listening_at(&path)); + assert!(!is_server_listening_at(&path).unwrap()); let _ = std::fs::remove_dir_all(dir); } @@ -455,7 +532,7 @@ test "$sid" = "$$" drop(UnixListener::bind(&path).unwrap()); // Socket is stale — should return false. - assert!(!is_server_listening_at(&path)); + assert!(!is_server_listening_at(&path).unwrap()); let _ = std::fs::remove_dir_all(dir); } diff --git a/src/server/client_accept.rs b/src/server/client_accept.rs index e2a8a42d..34d22ef1 100644 --- a/src/server/client_accept.rs +++ b/src/server/client_accept.rs @@ -31,7 +31,7 @@ pub(crate) fn accept_pending_client_connections( let should_quit = should_quit.clone(); let server_event_tx = server_event_tx.clone(); - std::thread::spawn(move || { + let spawned = crate::thread_spawn::spawn_named("herdr-client-conn", move || { if let Err(err) = client_transport::handle_client_handshake( stream, client_id, @@ -41,6 +41,9 @@ pub(crate) fn accept_pending_client_connections( debug!(client_id, err = %err, "client handshake failed"); } }); + if let Err(err) = spawned { + warn!(client_id, err = %err, "failed to spawn client connection thread; dropping connection"); + } } Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => break, Err(err) => { diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index d79c89cf..0a98da09 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -24,7 +24,7 @@ pub(super) fn snapshot_with_completions( protocol::ClientShellSnapshot, protocol::endpoint::EndpointAgentCompletions, ) { - let snapshot = app.session_snapshot(); + let snapshot = app.session_metadata_snapshot(); let completions = protocol::endpoint::EndpointAgentCompletions { boot_id: boot_id.to_owned(), revision, @@ -629,6 +629,94 @@ fn split_hit_rect( mod tests { use super::*; + #[tokio::test(flavor = "current_thread")] + async fn snapshot_metadata_skips_scroll_reads_and_preserves_public_session_data() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + let mut workspace = crate::workspace::Workspace::test_new("active"); + workspace.test_add_tab(None); + app.state.workspaces = vec![workspace, crate::workspace::Workspace::test_new("hidden")]; + app.state.active = Some(0); + app.state.ensure_test_terminals(); + let history = (0..40) + .map(|line| format!("line-{line}\r\n")) + .collect::(); + for (index, terminal) in app.state.terminals.values_mut().enumerate() { + if index < 2 { + terminal.set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Working, + ); + terminal.agent_name = Some(format!("agent-{index}")); + terminal.last_agent_state_change_seq = Some(5); + terminal.last_agent_completion_seq = Some(7); + } + let runtime = crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + history.as_bytes(), + ); + runtime.scroll_up(3); + app.terminal_runtimes.insert(terminal.id.clone(), runtime); + } + + let metadata = app.session_metadata_snapshot(); + assert_eq!(metadata.panes.len(), 3); + assert_eq!(metadata.agents.len(), 2); + assert!(metadata.panes.iter().all(|pane| pane.scroll.is_none())); + assert!(app + .terminal_runtimes + .values() + .all(|runtime| runtime.test_scroll_metrics_reads() == 0)); + + let mut public = app.session_snapshot(); + for pane in &mut public.panes { + let scroll = pane + .scroll + .take() + .expect("public session must expose scroll"); + assert_eq!(scroll.offset_from_bottom, 3); + assert!(scroll.max_offset_from_bottom >= 3); + assert_eq!(scroll.viewport_rows, 5); + } + assert_eq!(metadata, public); + assert!(app + .terminal_runtimes + .values() + .all(|runtime| runtime.test_scroll_metrics_reads() == 1)); + + let (shell, completions) = snapshot_with_completions(&app, "boot", 9, None, None); + assert_eq!(shell.panes.len(), 3); + assert_eq!(shell.agents.len(), 2); + assert_eq!(shell.focused_pane_id, metadata.focused_pane_id); + for (pane, public_pane) in shell.panes.iter().zip(&metadata.panes) { + assert_eq!(pane.pane_id, public_pane.pane_id); + assert_eq!(pane.workspace_id, public_pane.workspace_id); + assert_eq!(pane.tab_id, public_pane.tab_id); + assert_eq!(pane.cwd, public_pane.cwd); + } + for (agent, public_agent) in shell.agents.iter().zip(&metadata.agents) { + assert_eq!(agent.pane_id, public_agent.pane_id); + assert_eq!(agent.name, public_agent.name); + assert_eq!(agent.agent_status, public_agent.agent_status); + assert_eq!(agent.state_change_seq, 5); + } + assert_eq!(completions.revision, 9); + assert_eq!(completions.completions.len(), 2); + assert!(completions.completions.iter().all(|(_, seq)| *seq == 7)); + assert!(app + .terminal_runtimes + .values() + .all(|runtime| runtime.test_scroll_metrics_reads() == 1)); + } + #[test] fn snapshot_projects_cached_release_and_update_facts() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index 075967ef..a9a38c27 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -865,9 +865,9 @@ pub(crate) fn handle_client_handshake( // Spawn a writer thread that forwards messages from the channels to the stream. let write_stream = stream.try_clone()?; let writer_event_tx = server_event_tx.clone(); - std::thread::spawn(move || { + crate::thread_spawn::spawn_named("herdr-client-writer", move || { client_writer_loop(write_stream, client_id, writer_queue, writer_event_tx); - }); + })?; if should_quit.load(Ordering::Acquire) { send_shutdown_to_unregistered_client(&writer); diff --git a/src/server/handoff.rs b/src/server/handoff.rs index aaa87f38..efe554e6 100644 --- a/src/server/handoff.rs +++ b/src/server/handoff.rs @@ -47,6 +47,10 @@ pub(crate) struct HandoffManifest { /// Absent from manifests written before this field existed. #[serde(default)] pub api_window_title: Option, + /// A host restart or shutdown announced to the old server but not yet + /// committed. Absent from manifests written before this field existed. + #[serde(default)] + pub host_shutdown_intent: Option, } #[cfg(unix)] @@ -307,6 +311,7 @@ pub(crate) fn manifest_for( expected_protocol: Option, expected_version: Option, api_window_title: Option, + host_shutdown_intent: Option, ) -> HandoffManifest { HandoffManifest { version: HANDOFF_VERSION, @@ -317,6 +322,7 @@ pub(crate) fn manifest_for( snapshot, panes, api_window_title, + host_shutdown_intent, } } @@ -549,11 +555,78 @@ mod tests { None, None, Some("deploying".to_string()), + None, ); assert_eq!(manifest.api_window_title.as_deref(), Some("deploying")); } + #[test] + fn a_handoff_carries_an_announced_host_shutdown() { + let intent: crate::platform::HostShutdownIntent = + serde_json::from_value(serde_json::json!({ "announced_at_ns": 42 })) + .expect("intent should deserialize"); + let manifest = manifest_for(empty_snapshot(), Vec::new(), None, None, None, Some(intent)); + let encoded = serde_json::to_vec(&manifest).expect("manifest should serialize"); + let decoded: HandoffManifest = + serde_json::from_slice(&encoded).expect("manifest should deserialize"); + + assert_eq!(decoded.host_shutdown_intent, Some(intent)); + } + + #[test] + fn a_manifest_written_before_the_host_shutdown_field_still_loads() { + let manifest = manifest_for(empty_snapshot(), Vec::new(), None, None, None, None); + let mut value = serde_json::to_value(&manifest).expect("manifest should serialize"); + value + .as_object_mut() + .expect("manifest should be a json object") + .remove("host_shutdown_intent"); + + let older: HandoffManifest = + serde_json::from_value(value).expect("an older manifest should still load"); + + assert!(older.host_shutdown_intent.is_none()); + } + + #[test] + fn an_older_server_imports_a_manifest_with_an_announced_host_shutdown() { + /// The manifest as servers before `host_shutdown_intent` decode it, + /// frozen here so the new field stays ignorable by them. + #[derive(Deserialize)] + #[allow(dead_code)] // Only decoding is under test. + struct PreHostShutdownManifest { + version: u32, + source_version: String, + source_protocol: u32, + expected_version: Option, + expected_protocol: Option, + snapshot: crate::persist::SessionSnapshot, + panes: Vec, + #[serde(default)] + api_window_title: Option, + } + + let intent: crate::platform::HostShutdownIntent = + serde_json::from_value(serde_json::json!({ "announced_at_ns": 42 })) + .expect("intent should deserialize"); + let manifest = manifest_for( + empty_snapshot(), + Vec::new(), + None, + None, + Some("deploying".to_string()), + Some(intent), + ); + let encoded = serde_json::to_vec(&manifest).expect("manifest should serialize"); + + let older: PreHostShutdownManifest = + serde_json::from_slice(&encoded).expect("an older server should still load it"); + + assert_eq!(older.version, HANDOFF_VERSION); + assert_eq!(older.api_window_title.as_deref(), Some("deploying")); + } + #[test] fn a_manifest_written_before_the_title_field_still_loads() { let manifest = manifest_for( @@ -562,6 +635,7 @@ mod tests { None, None, Some("deploying".to_string()), + None, ); let mut value = serde_json::to_value(&manifest).expect("manifest should serialize"); value diff --git a/src/server/headless.rs b/src/server/headless.rs index 0de0c699..c722e728 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -64,9 +64,11 @@ use crate::server::notifications::{ should_forward_toast_to_clients, toast_message_from_state_change, toast_notify_kind, }; use crate::server::pane_input::{ - apply_client_pane_input_events, apply_client_popup_input_events, apply_terminal_attach_input, - apply_terminal_attach_scroll, terminal_attach_mouse_position, + apply_client_pane_input_events, apply_client_popup_input_events, + apply_client_terminal_input_events, apply_terminal_attach_input, apply_terminal_attach_scroll, + terminal_attach_mouse_position, }; +use crate::server::shutdown::{ServerStop, ShutdownReason}; use crate::server::snooze_store; use crate::server::socket_paths::{ client_socket_path, prepare_socket_path, restrict_socket_permissions, @@ -175,6 +177,10 @@ const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5); /// avoid reintroducing the idle CPU spin. const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250); +// Split shell redraws need several early updates; sustained output still coalesces. +const INTERACTIVE_RENDER_BUDGET: u8 = 16; +const INTERACTIVE_RENDER_WINDOW: Duration = Duration::from_millis(32); + // --------------------------------------------------------------------------- // Headless server // --------------------------------------------------------------------------- @@ -207,6 +213,8 @@ pub struct HeadlessServer { client_socket_identity: SocketFileIdentity, clients: HashMap, native_graphics: native_graphics::NativeGraphics, + interactive_input_deadlines: HashMap, + interactive_render_budget: Option<(Instant, u8)>, #[cfg(unix)] next_client_id: u64, /// The client currently driving session-wide host presentation and side effects. @@ -258,9 +266,14 @@ pub struct HeadlessServer { /// Imported panes get one app-safe resize nudge after the first client attaches. #[cfg(unix)] pending_handoff_repaint_nudge: bool, - /// Flag set by Ctrl+C or `server stop` signal. + /// Flag set by a stop signal or `server stop`; shares `server_stop`'s flag. should_quit: Arc, + server_stop: ServerStop, host_shutdown_requested: Arc, + /// An announced, still cancellable host shutdown; carried across live handoff. + host_shutdown_intent: crate::platform::HostShutdownIntentCell, + #[cfg(test)] + host_shutdown_probe: fn() -> bool, /// Channel for receiving server events from client connection threads. server_event_rx: mpsc::Receiver, /// Sender for server events (cloned for each client thread). @@ -272,8 +285,8 @@ fn spawn_windows_client_accept_thread( listener: LocalListener, should_quit: Arc, server_event_tx: mpsc::Sender, -) { - std::thread::spawn(move || { +) -> io::Result> { + crate::thread_spawn::spawn_named("herdr-client-accept", move || { let mut next_client_id = 1_u64; while !should_quit.load(Ordering::Acquire) { let stream = match listener.accept() { @@ -298,7 +311,7 @@ fn spawn_windows_client_accept_thread( let should_quit = should_quit.clone(); let server_event_tx = server_event_tx.clone(); - std::thread::spawn(move || { + let spawned = crate::thread_spawn::spawn_named("herdr-client-conn", move || { if let Err(err) = crate::server::client_transport::handle_client_handshake( stream, client_id, @@ -308,8 +321,11 @@ fn spawn_windows_client_accept_thread( debug!(client_id, err = %err, "client handshake failed"); } }); + if let Err(err) = spawned { + warn!(client_id, err = %err, "failed to spawn client connection thread; dropping connection"); + } } - }); + }) } impl HeadlessServer { @@ -324,8 +340,9 @@ impl HeadlessServer { config_diagnostics: &[String], api_tx: Option, api_server: Option, - should_quit: Arc, + server_stop: ServerStop, ) -> io::Result { + let should_quit = server_stop.flag().clone(); let client_path = client_socket_path(); prepare_socket_path(&client_path)?; @@ -341,7 +358,7 @@ impl HeadlessServer { // Channel for server events from client threads. let (server_event_tx, server_event_rx) = mpsc::channel(64); #[cfg(windows)] - spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); + spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone())?; let server_keybindings = app_keybindings(&app); let headless_size = app.state.headless_size; @@ -387,6 +404,8 @@ impl HeadlessServer { client_socket_identity, clients: HashMap::new(), native_graphics: Default::default(), + interactive_input_deadlines: HashMap::new(), + interactive_render_budget: None, #[cfg(unix)] next_client_id: 1, foreground_client_id: None, @@ -411,10 +430,14 @@ impl HeadlessServer { effective_size: headless_size, shutting_down: false, host_shutdown_requested: Arc::new(AtomicBool::new(false)), + host_shutdown_intent: Default::default(), + #[cfg(test)] + host_shutdown_probe: crate::platform::host_shutdown_in_progress, handoff_in_progress: false, #[cfg(unix)] pending_handoff_repaint_nudge: false, should_quit, + server_stop, server_event_rx, server_event_tx, }) @@ -432,13 +455,16 @@ impl HeadlessServer { pub async fn run(&mut self) -> io::Result<()> { crate::logging::startup("server"); - // Register SIGINT handler for graceful shutdown. - let should_quit = self.should_quit.clone(); + let server_stop = self.server_stop.clone(); let quit_notify = self.server_event_tx.clone(); - ctrlc_handler(should_quit, quit_notify); + crate::platform::spawn_server_signal_monitor(move |signal| { + server_stop.request(ShutdownReason::Signal(signal)); + let _ = quit_notify.try_send(ServerEvent::QuitSignal); + }); let quit_notify = self.server_event_tx.clone(); let _host_shutdown = crate::platform::HostShutdownMonitor::start( self.host_shutdown_requested.clone(), + self.host_shutdown_intent.clone(), move || { let _ = quit_notify.try_send(ServerEvent::QuitSignal); }, @@ -459,9 +485,9 @@ impl HeadlessServer { break; } - // A host shutdown warning precedes process termination. Do not drain pane - // deaths here: logind's delay lock stays held until the final session save. - if self.host_shutdown_requested.load(Ordering::Acquire) { + // Preserve the session before applying shutdown-time process exits. + // On Linux, logind's delay lock stays held until the final session save. + if self.host_shutdown_requested() { self.initiate_shutdown(); continue; } @@ -566,14 +592,13 @@ impl HeadlessServer { // bounded classification cadence without delaying presentation work // that joins the same coalesced request. let render_cadence_due = self.app.can_render_now(now); - if needs_render - && (render_cadence_due - || (self.app.can_present_now(now) - && self.has_pending_presentation_work( - needs_full_render, - needs_graphics_render, - ))) - { + let presentation_due = self.app.can_present_now(now) + && self.has_pending_presentation_work(needs_full_render, needs_graphics_render); + let interactive_due = needs_render + && !render_cadence_due + && !presentation_due + && self.can_render_interactive(now); + if needs_render && (render_cadence_due || presentation_due || interactive_due) { crate::render_prof::event("render.attempt"); let render_request = self.app.render_dirty.take(); let pty_dirty = !render_request.pty_sources.is_empty(); @@ -620,6 +645,9 @@ impl HeadlessServer { self.render_and_stream(); } self.app.record_render_attempt(now, !hidden_only); + if interactive_due { + self.record_interactive_render(now); + } needs_render = false; needs_full_render = false; needs_graphics_render = false; @@ -632,7 +660,7 @@ impl HeadlessServer { .next_headless_loop_deadline_with_git_refresh( now, needs_render, - self.has_app_client(), + self.git_refresh_scheduled(), ) .map(|deadline| deadline.min(now + CLIENT_ACCEPT_POLL_INTERVAL)) .or(Some(now + CLIENT_ACCEPT_POLL_INTERVAL)); @@ -692,9 +720,7 @@ impl HeadlessServer { } }; - if self.should_quit.load(Ordering::Acquire) - || self.host_shutdown_requested.load(Ordering::Acquire) - { + if self.should_quit.load(Ordering::Acquire) || self.host_shutdown_requested() { match event { LoopEvent::Internal(ev) => { self.handle_internal_event_with_forwarding(ev); @@ -754,6 +780,22 @@ impl HeadlessServer { Ok(()) } + fn host_shutdown_requested(&self) -> bool { + if self.host_shutdown_requested.load(Ordering::Acquire) { + return true; + } + #[cfg(not(test))] + let in_progress = crate::platform::host_shutdown_in_progress(); + #[cfg(test)] + let in_progress = (self.host_shutdown_probe)(); + if in_progress { + self.host_shutdown_requested.store(true, Ordering::Release); + // Stop this tick before autosave or another API request can run. + self.should_quit.store(true, Ordering::Release); + } + in_progress + } + fn allocate_activity_stamp(&mut self) -> u64 { let stamp = self.next_activity_stamp; self.next_activity_stamp = self.next_activity_stamp.saturating_add(1); @@ -973,6 +1015,12 @@ impl HeadlessServer { self.app_client_count() > 0 } + /// Periodic Git refresh follows attached clients. A refresh whose worker + /// failed to start still retries without one, so restored metadata settles. + fn git_refresh_scheduled(&self) -> bool { + self.has_app_client() || self.app.git_refresh_spawn_retry_pending + } + fn remove_client(&mut self, client_id: u64) -> bool { self.disconnect_native_graphics(client_id); let disconnected_focus = self @@ -3013,10 +3061,21 @@ impl HeadlessServer { return foreground_changed | geometry_changed; }; let scroll_before = runtime.scroll_metrics(); - if let Err(err) = apply_client_pane_input_events(runtime, &events) { + let mut keyboard_forwarded = false; + let input_result = apply_client_terminal_input_events( + runtime, + &events, + true, + &mut keyboard_forwarded, + ); + let scroll_changed = runtime.scroll_metrics() != scroll_before; + if keyboard_forwarded { + self.note_interactive_input(runtime_pane_id, Instant::now()); + } + if let Err(err) = input_result { warn!(client_id, pane_id, err = %err, "targeted client shell input failed"); } - foreground_changed | geometry_changed || runtime.scroll_metrics() != scroll_before + foreground_changed | geometry_changed || scroll_changed } ServerEvent::ClientShellPopupInput { client_id, @@ -3035,12 +3094,12 @@ impl HeadlessServer { client.pixel_mouse && client.host_sgr_pixels_active == Some(true) }); let mut events = events; - let Some(popup_terminal_id) = self + let Some((popup_terminal_id, popup_pane_id)) = self .app .state .popup_pane .as_ref() - .map(|popup| popup.terminal_id.clone()) + .map(|popup| (popup.terminal_id.clone(), popup.pane_id)) else { return false; }; @@ -3091,10 +3150,21 @@ impl HeadlessServer { return foreground_changed | geometry_changed; }; let scroll_before = runtime.scroll_metrics(); - if let Err(err) = apply_client_popup_input_events(runtime, &events) { + let mut keyboard_forwarded = false; + let input_result = apply_client_terminal_input_events( + runtime, + &events, + false, + &mut keyboard_forwarded, + ); + let scroll_changed = runtime.scroll_metrics() != scroll_before; + if keyboard_forwarded { + self.note_interactive_input(popup_pane_id, Instant::now()); + } + if let Err(err) = input_result { warn!(client_id, terminal_id, err = %err, "targeted client popup input failed"); } - foreground_changed | geometry_changed || runtime.scroll_metrics() != scroll_before + foreground_changed | geometry_changed || scroll_changed } ServerEvent::ClientShellEndpointRequestError { client_id, @@ -3448,26 +3518,30 @@ impl HeadlessServer { } } + fn reject_api_request_for_shutdown(msg: api::ApiRequestMessage) { + let response = serde_json::to_string(&api::schema::ErrorResponse { + id: msg.request.id, + error: api::schema::ErrorBody { + code: "server_unavailable".into(), + message: "server is shutting down".into(), + }, + }) + .unwrap_or_else(|_| { + r#"{"id":"","error":{"code":"server_unavailable","message":"server is shutting down"}}"# + .to_string() + }); + let _ = msg.respond_to.send(response); + } + fn handle_api_request_with_shutdown_check_inner( &mut self, msg: api::ApiRequestMessage, skip_default_workspace_for_request: bool, client_local: bool, ) -> bool { - if self.shutting_down { + if self.shutting_down || self.host_shutdown_requested() { // During shutdown, respond with server_unavailable. - let response = serde_json::to_string(&api::schema::ErrorResponse { - id: msg.request.id, - error: api::schema::ErrorBody { - code: "server_unavailable".into(), - message: "server is shutting down".into(), - }, - }) - .unwrap_or_else(|_| { - r#"{"id":"","error":{"code":"server_unavailable","message":"server is shutting down"}}"# - .to_string() - }); - let _ = msg.respond_to.send(response); + Self::reject_api_request_for_shutdown(msg); return false; } @@ -4029,6 +4103,10 @@ impl HeadlessServer { api::schema::Method::ServerStop(_) | api::schema::Method::ServerLiveHandoff(_) ); changed |= self.drain_all_internal_events_with_forwarding(); + if self.host_shutdown_requested() { + Self::reject_api_request_for_shutdown(msg); + return changed; + } // Capture toast and effective pane states before the API call so we can // forward resulting client-local notifications. API requests like @@ -4314,6 +4392,9 @@ impl HeadlessServer { /// /// Similar to the former App scheduler but without terminal resize polling. fn handle_scheduled_tasks_headless(&mut self, now: Instant, geometry_dirty: bool) -> bool { + if self.host_shutdown_requested() { + return false; + } let mut changed = false; // No resize polling needed — server has no terminal. @@ -4358,7 +4439,15 @@ impl HeadlessServer { } } - if self.has_app_client() { + if self + .app + .restored_worktree_validation_retry_at + .is_some_and(|deadline| now >= deadline) + { + self.app.start_restored_worktree_validation(now); + } + + if self.git_refresh_scheduled() { self.app.start_git_status_refresh_if_due(now); } @@ -4448,16 +4537,6 @@ impl Drop for HeadlessServer { // Helpers // --------------------------------------------------------------------------- -/// Installs a Ctrl+C handler that sets the should_quit flag and wakes up -/// the event loop by sending a QuitSignal on the server event channel. -fn ctrlc_handler(should_quit: Arc, server_event_tx: mpsc::Sender) { - let _ = ctrlc::set_handler(move || { - should_quit.store(true, Ordering::Release); - // Wake up the event loop so the quit flag is checked promptly. - let _ = server_event_tx.try_send(ServerEvent::QuitSignal); - }); -} - /// Sleep until a deadline, or return pending if none. async fn sleep_until_or_pending(deadline: Option) { match deadline { diff --git a/src/server/headless/bootstrap.rs b/src/server/headless/bootstrap.rs index e5ff400d..9522f158 100644 --- a/src/server/headless/bootstrap.rs +++ b/src/server/headless/bootstrap.rs @@ -2,6 +2,7 @@ use super::*; /// Run the headless server. This is the entry point called from main.rs. pub fn run_server() -> io::Result<()> { + crate::platform::ignore_server_hangup(); let args: Vec = std::env::args().collect(); let handoff_import = args.get(2).map(String::as_str) == Some("--handoff-import"); let process_context = crate::platform::prepare_server_process(handoff_import); @@ -27,15 +28,19 @@ pub fn run_server() -> io::Result<()> { } let loaded_config = config::Config::load(); + #[cfg(windows)] + if loaded_config.config.server.allow_unelevated_clients { + crate::platform::allow_unelevated_clients(); + } let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let event_hub = api::EventHub::default(); - let should_quit = Arc::new(AtomicBool::new(false)); + let server_stop = crate::server::shutdown::ServerStop::default(); // Start the JSON API socket server. let _api_server = match api::start_server_with_stop_control( api_tx.clone(), event_hub.clone(), - should_quit.clone(), + server_stop.clone(), ) { Ok(server) => server, Err(err) if err.kind() == io::ErrorKind::AddrInUse => { @@ -53,13 +58,13 @@ pub fn run_server() -> io::Result<()> { let result = rt.block_on(async { // Create the App (with AppState, event channels, etc.). - let mut app = app::App::new( + let mut app = app::App::try_new( &loaded_config.config, app::AppPolicy::PRODUCTION, config::config_diagnostic_summary(&loaded_config.diagnostics), api_rx, event_hub, - ); + )?; seed_startup_workspace_if_empty(&mut app); // Create the headless server. @@ -68,7 +73,7 @@ pub fn run_server() -> io::Result<()> { &loaded_config.diagnostics, Some(api_tx.clone()), Some(_api_server), - should_quit, + server_stop, ) { Ok(server) => server, Err(err) if err.kind() == io::ErrorKind::AddrInUse => { @@ -133,7 +138,7 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let event_hub = api::EventHub::default(); - let should_quit = Arc::new(AtomicBool::new(false)); + let server_stop = crate::server::shutdown::ServerStop::default(); let mut imports = HashMap::new(); for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { @@ -172,18 +177,22 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> let api_server = api::start_server_with_stop_control( api_tx.clone(), event_hub.clone(), - should_quit.clone(), + server_stop.clone(), )?; let mut server = HeadlessServer::new( app, &loaded_config.diagnostics, Some(api_tx.clone()), Some(api_server), - should_quit, + server_stop, )?; // Carried across before any client attaches, so the first title sent is // the override rather than the configured one it replaced. server.api_window_title = received.manifest.api_window_title.take(); + // A restart announced before the handoff still commits after it. + server + .host_shutdown_intent + .set(received.manifest.host_shutdown_intent); crate::server::handoff::report_ready(&mut received.stream)?; crate::server::handoff::wait_committed(&mut received.stream)?; server.app.assume_handoff_ownership(); @@ -209,16 +218,20 @@ fn run_handoff_import_server(_socket_path: &Path, _token: &str) -> io::Result<() } fn print_ready_message(api_socket: &Path, client_socket: &Path) { - eprintln!("herdr server running; you can use any herdr CLI command in another terminal."); - eprintln!("api socket: {}", api_socket.display()); - eprintln!("client socket: {}", client_socket.display()); - eprintln!( - "logs: {}", + let message = format!( + "herdr server running; you can use any herdr CLI command in another terminal.\n\ + api socket: {}\n\ + client socket: {}\n\ + logs: {}\n\ + did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`.\n", + api_socket.display(), + client_socket.display(), crate::session::data_dir() .join("herdr-server.log") .display() ); - eprintln!("did you mean to open the Herdr TUI? run `herdr`; you do not need `herdr server`."); + // The launching terminal may already be gone; the server keeps running. + let _ = io::Write::write_all(&mut io::stderr(), message.as_bytes()); } /// Initialize logging for the server process. diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs index 1c990738..c7b7205e 100644 --- a/src/server/headless/lifecycle.rs +++ b/src/server/headless/lifecycle.rs @@ -112,6 +112,7 @@ impl HeadlessServer { params.expected_protocol, params.expected_version, self.api_window_title.clone(), + self.host_shutdown_intent.get(), ); let mut import_child = match crate::server::handoff::spawn_handoff_import( import_exe.as_deref(), @@ -248,7 +249,7 @@ impl HeadlessServer { let api_server = api::start_server_with_stop_control( api_tx, self.app.event_hub.clone(), - self.should_quit.clone(), + self.server_stop.clone(), )?; let client_path = client_socket_path(); @@ -305,7 +306,14 @@ impl HeadlessServer { if self.shutting_down { return; } - info!("server shutdown initiated"); + let reason = if self.host_shutdown_requested.load(Ordering::Acquire) { + ShutdownReason::HostShutdown + } else { + self.server_stop + .take_reason() + .unwrap_or(ShutdownReason::Unknown) + }; + info!(%reason, "server shutdown initiated"); self.shutting_down = true; // Clear client-local host graphics, then send ServerShutdown to all connected clients. diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index e6d217e2..1025acb7 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -299,7 +299,7 @@ impl HeadlessServer { /// /// Returns true if the event changed visual state (requiring a re-render). pub(super) fn handle_internal_event_with_forwarding(&mut self, mut ev: AppEvent) -> bool { - if self.host_shutdown_requested.load(Ordering::Acquire) { + if self.host_shutdown_requested() { return false; } let focus_response = match &mut ev { @@ -734,7 +734,7 @@ impl HeadlessServer { let mut had_event = false; let mut changed = false; for _ in 0..limit { - if self.host_shutdown_requested.load(Ordering::Acquire) { + if self.host_shutdown_requested() { break; } let Ok(ev) = self.app.event_rx.try_recv() else { diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index 5bc17c10..aa075a95 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -1,6 +1,38 @@ use super::*; impl HeadlessServer { + pub(super) fn note_interactive_input(&mut self, pane_id: crate::layout::PaneId, now: Instant) { + self.interactive_input_deadlines + .retain(|_, deadline| *deadline > now); + self.interactive_input_deadlines + .insert(pane_id, now + INTERACTIVE_RENDER_WINDOW); + } + + pub(super) fn can_render_interactive(&self, now: Instant) -> bool { + if self.interactive_render_budget.is_some_and(|(epoch, used)| { + now.duration_since(epoch) < crate::app::MIN_RENDER_INTERVAL + && used >= INTERACTIVE_RENDER_BUDGET + }) { + return false; + } + self.app + .render_dirty + .has_pending_visible_pty_source(|pane_id| { + self.interactive_input_deadlines + .get(&pane_id) + .is_some_and(|deadline| *deadline > now) + }) + } + + pub(super) fn record_interactive_render(&mut self, now: Instant) { + let (epoch, used) = self.interactive_render_budget.get_or_insert((now, 0)); + if now.duration_since(*epoch) >= crate::app::MIN_RENDER_INTERVAL { + *epoch = now; + *used = 0; + } + *used += 1; + } + fn shell_focused_runtime( &self, client_id: u64, @@ -33,6 +65,43 @@ impl HeadlessServer { .map(|runtime| (runtime, pane_id)) } + /// Whether a pane the client shows asked for SGR pixel mouse reports. Host + /// reports go to whichever pane is under the pointer, so an unfocused pane + /// needs pixels from the host too (#4750); others get them as cells. + fn shell_visible_pane_wants_sgr_pixels(&self, client_id: u64) -> bool { + if self + .shell_focused_runtime(client_id) + .is_some_and(|(runtime, _)| runtime.sgr_pixel_mouse_enabled()) + { + return true; + } + let Some(target) = self.shell_target_for_client(client_id) else { + return false; + }; + let Some(tab) = self + .app + .state + .workspaces + .get(target.workspace_index) + .and_then(|workspace| workspace.tabs.get(target.tab_index)) + else { + return false; + }; + if tab.zoomed { + return false; + } + tab.layout.pane_ids().into_iter().any(|pane_id| { + self.app + .state + .runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + target.workspace_index, + pane_id, + ) + .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled) + }) + } + pub(super) fn stream_host_mouse_capture_mode(&mut self) { let requested = self .clients @@ -46,7 +115,8 @@ impl HeadlessServer { let child_requests_mouse = focused.is_some_and(|(runtime, _)| runtime.mouse_reporting_enabled()); let sgr_pixels = client.pixel_mouse - && focused.is_some_and(|(runtime, _)| runtime.sgr_pixel_mouse_enabled()); + && client.shell_surface_active + && self.shell_visible_pane_wants_sgr_pixels(client_id); Some(( client_id, client.shell_surface_active diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 7d613a04..82dace89 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -94,9 +94,11 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ .set_nonblocking(ListenerNonblockingMode::Accept) .expect("set listener nonblocking"); let (server_event_tx, server_event_rx) = mpsc::channel(64); - let should_quit = Arc::new(AtomicBool::new(false)); + let server_stop = ServerStop::default(); + let should_quit = server_stop.flag().clone(); #[cfg(windows)] - spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()); + spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone()) + .expect("spawn client accept thread"); let server_keybindings = app_keybindings(&app); let headless_size = app.state.headless_size; @@ -111,6 +113,8 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ client_socket_identity, clients: HashMap::new(), native_graphics: Default::default(), + interactive_input_deadlines: HashMap::new(), + interactive_render_budget: None, #[cfg(unix)] next_client_id: 1, foreground_client_id: None, @@ -137,10 +141,13 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ effective_size: headless_size, shutting_down: false, host_shutdown_requested: Arc::new(AtomicBool::new(false)), + host_shutdown_intent: Default::default(), + host_shutdown_probe: crate::platform::host_shutdown_in_progress, handoff_in_progress: false, #[cfg(unix)] pending_handoff_repaint_nudge: false, should_quit, + server_stop, server_event_rx, server_event_tx, } @@ -3660,6 +3667,145 @@ async fn client_shell_mouse_motion_delivers_without_render_when_foreground() { shutdown_test_runtimes(&mut server); } +#[tokio::test] +async fn client_shell_split_echo_bypasses_cadence_with_bounded_visible_burst() { + let mut server = test_headless_server(); + let mut input_rx = install_focused_test_runtime(&mut server, b"PS> old"); + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + let (_control_rx, render_rx) = connect_matching_test_shell(&mut server, 1); + server.render_and_stream(); + recv_pane_surface(&render_rx, "initial prompt"); + server.sync_immediate_pty_sources(); + server.app.render_dirty.take(); + + assert_eq!( + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id: public_pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "Z".into() + )], + }), + RenderImpact::None + ); + assert_eq!(input_rx.try_recv().unwrap(), Bytes::from_static(b"Z")); + assert!(server.interactive_input_deadlines.contains_key(&pane_id)); + + // A full queue after an accepted key must not lose that key's priority. + for _ in 0..3 { + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .unwrap(); + runtime + .try_send_bytes(Bytes::from_static(b"queued")) + .unwrap(); + } + server.interactive_input_deadlines.clear(); + server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { + client_id: 1, + pane_id: server.app.public_pane_id(0, pane_id).unwrap(), + events: vec![ + crate::protocol::ClientPaneInputEvent::TextCommit("A".into()), + crate::protocol::ClientPaneInputEvent::TextCommit("B".into()), + ], + }); + for _ in 0..3 { + assert_eq!(input_rx.try_recv().unwrap(), Bytes::from_static(b"queued")); + } + assert_eq!(input_rx.try_recv().unwrap(), Bytes::from_static(b"A")); + assert!(input_rx.try_recv().is_err()); + assert!(server.interactive_input_deadlines.contains_key(&pane_id)); + + let start = Instant::now(); + server.note_interactive_input(pane_id, start); + server.app.record_render_attempt(start, true); + // Escape-only PTY updates must not use the whole allowance before the glyph. + for index in 0..12 { + let now = start + Duration::from_micros(index * 50 + 50); + write_shared_test_pane(&mut server, pane_id, b"\x1b[0m"); + server.app.render_dirty.request_pty(pane_id); + assert!(server.can_render_interactive(now)); + let request = server.app.render_dirty.take(); + assert!(server.render_retained_pane_surface_and_stream(&request.pty_sources)); + while let Ok(message) = render_rx.try_recv() { + let ServerMessage::PaneSurfacePatch(patch) = read_server_message(message) else { + panic!("expected retained patch for escape-only update"); + }; + assert!(patch.rows.is_empty()); + } + server.app.record_render_attempt(now, true); + server.record_interactive_render(now); + } + for (index, bytes) in [b"\rPS> \rPS> ".as_slice(), b"Z", b"\r\n", b"PS> "] + .into_iter() + .enumerate() + { + let now = start + Duration::from_millis(index as u64 + 1); + write_shared_test_pane(&mut server, pane_id, bytes); + server.app.render_dirty.request_pty(pane_id); + assert!(!server.app.can_render_now(now)); + assert!(!server.app.can_present_now(now)); + assert!(server.can_render_interactive(now)); + let request = server.app.render_dirty.take(); + assert!(server.render_retained_pane_surface_and_stream(&request.pty_sources)); + recv_pane_surface_patch(&render_rx, "split prompt update"); + if index == 1 { + assert!(frame_text( + &server.clients[&1] + .render_state + .last_pane_surface() + .unwrap() + .frame + ) + .contains("PS> Z")); + } + server.app.record_render_attempt(now, true); + server.record_interactive_render(now); + } + + let busy = start + Duration::from_millis(5); + // Repeated keys cannot replenish the output budget within its epoch. + server.note_interactive_input(pane_id, busy); + server.app.render_dirty.request_pty(pane_id); + assert!(!server.can_render_interactive(busy)); + assert!(!server.app.can_render_now(busy)); + let next_render = server + .app + .next_headless_loop_deadline_with_git_refresh(busy, true, false) + .unwrap(); + assert!(server.app.can_render_now(next_render)); + + let next_epoch = start + Duration::from_millis(17); + assert!(server.can_render_interactive(next_epoch)); + server.app.render_dirty.take(); + let unrelated = crate::layout::PaneId::from_raw(pane_id.raw() + 1000); + server + .app + .render_dirty + .set_immediate_pty_sources(HashSet::from([pane_id, unrelated])); + server.app.render_dirty.request_pty(unrelated); + assert!(!server.can_render_interactive(next_epoch)); + server.app.render_dirty.request_pty(pane_id); + server + .app + .render_dirty + .set_immediate_pty_sources(HashSet::from([unrelated])); + assert!( + !server.can_render_interactive(next_epoch), + "hidden input target stays coalesced" + ); + server + .app + .render_dirty + .set_immediate_pty_sources(HashSet::from([pane_id])); + assert!(server.can_render_interactive(start + Duration::from_millis(33))); + assert!(!server.can_render_interactive(start + Duration::from_millis(38))); + shutdown_test_runtimes(&mut server); +} + #[tokio::test] async fn client_shell_mouse_motion_promotes_and_requests_render() { let mut server = test_headless_server(); @@ -4669,6 +4815,38 @@ fn unchanged_git_refresh_does_not_request_headless_render() { assert!(!server.app.git_refresh_in_flight); } +#[test] +fn failed_startup_git_refresh_retries_without_clients() { + let mut server = test_headless_server(); + server + .app + .state + .workspaces + .push(crate::workspace::Workspace::test_new("restored")); + assert_eq!(server.app_client_count(), 0); + + crate::thread_spawn::test_hook::fail_next_spawns(1); + server.app.refresh_restored_workspace_git_metadata(); + assert!(!server.app.git_refresh_in_flight); + + let now = Instant::now(); + let retry_at = server + .app + .next_headless_loop_deadline_with_git_refresh(now, false, server.git_refresh_scheduled()) + .expect("failed startup refresh schedules a retry"); + assert_eq!(Some(retry_at), server.app.git_refresh_deadline()); + + server.handle_scheduled_tasks_headless(retry_at, false); + assert!(server.app.git_refresh_in_flight); + assert!(!server.app.git_identity_refresh_requested); + + // Once the retry starts, clientless servers stop polling again. + server.app.git_refresh_in_flight = false; + server.app.request_git_identity_refresh(retry_at); + server.handle_scheduled_tasks_headless(retry_at, false); + assert!(!server.app.git_refresh_in_flight); +} + #[test] fn changed_git_refresh_requests_headless_render() { let mut server = test_headless_server(); @@ -4725,6 +4903,216 @@ async fn host_shutdown_preserves_panes_from_queued_and_selected_death_events() { shutdown_test_runtimes(&mut server); } +#[cfg(windows)] +#[test] +fn windows_shutdown_probe_preserves_panes_and_agent_sessions() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _runtime = runtime.enter(); + thread_local! { + static PROBES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + fn shutdown_after_api_entry() -> bool { + PROBES.with(|probes| { + let count = probes.get(); + probes.set(count + 1); + count > 0 + }) + } + let _guard = crate::config::test_config_env_lock().lock().unwrap(); + struct RestoreEnv([(&'static str, Option); 2]); + impl Drop for RestoreEnv { + fn drop(&mut self) { + for (key, previous) in &self.0 { + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + let _env = RestoreEnv( + ["XDG_CONFIG_HOME", crate::session::SESSION_ENV_VAR] + .map(|key| (key, std::env::var_os(key))), + ); + let config_home = std::env::temp_dir().join(format!( + "herdr-shutdown-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + std::env::remove_var(crate::session::SESSION_ENV_VAR); + + for entry in [ + "queued-death", + "selected-death", + "agent-exit", + "autosave", + "api-drain", + ] { + let mut server = test_headless_server(); + server.app.policy.persist_session = true; + let workspace = crate::workspace::Workspace::test_new("shutdown"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + let terminal = server.app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.respawn_shell_on_exit = true; + terminal.set_agent_name("codex".into()); + terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("previous-session").unwrap(), + }); + server.app.save_session_now(); + // Leave both the extra pane and new resume identity unsaved. The real + // server loop must write them rather than merely retain the old file. + let other_pane = + server.app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + server.app.state.ensure_test_terminals(); + server + .app + .state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { + source: "herdr:codex".into(), + agent: "codex".into(), + session_ref: crate::agent_resume::AgentSessionRef::id("shutdown-session").unwrap(), + }); + server.host_shutdown_probe = || true; + let death = || AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::classify_child_exit( + &portable_pty::ExitStatus::with_exit_code(0x40010004), + ), + }; + let agent_exit = || AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Codex), + state: crate::detect::AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: Instant::now(), + }; + match entry { + "queued-death" => { + server.app.event_tx.try_send(death()).unwrap(); + assert_eq!( + server.drain_internal_events_with_forwarding_up_to(16), + (false, false) + ); + assert!(server.app.event_rx.try_recv().is_ok()); + } + "selected-death" => assert!(!server.handle_internal_event_with_forwarding(death())), + "agent-exit" => assert!(!server.handle_internal_event_with_forwarding(agent_exit())), + "autosave" => { + server.app.session_save_deadline = Some(Instant::now()); + assert!(!server.handle_scheduled_tasks_headless(Instant::now(), false)); + } + "api-drain" => { + PROBES.with(|probes| probes.set(0)); + server.host_shutdown_probe = shutdown_after_api_entry; + server.app.event_tx.try_send(death()).unwrap(); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "shutdown-race".into(), + method: api::schema::Method::WorkspaceRename( + api::schema::WorkspaceRenameParams { + workspace_id: server.app.public_workspace_id(0), + label: "must-not-be-saved".into(), + }, + ), + }, + respond_to, + response_write_complete: None, + }); + let response: api::schema::ErrorResponse = + serde_json::from_str(&response_rx.recv().unwrap()).unwrap(); + assert_eq!(response.error.code, "server_unavailable"); + } + _ => unreachable!(), + } + // Latch the warning: later process exits and due autosaves must not + // erase resume identity or layout, even if the native probe changes. + server.host_shutdown_probe = || false; + assert!(!server.handle_internal_event_with_forwarding(agent_exit())); + assert!(!server.handle_internal_event_with_forwarding(death())); + assert!( + !server.handle_internal_event_with_forwarding(AppEvent::PaneDied { + pane_id: other_pane, + exit_reason: crate::platform::classify_child_exit( + &portable_pty::ExitStatus::with_exit_code(0xC0000142), + ), + }) + ); + assert!(server.should_quit.load(Ordering::Acquire)); + assert_eq!( + server.app.terminal_runtimes.len(), + 0, + "shutdown must not respawn a shell" + ); + server.app.session_save_deadline = Some(Instant::now()); + assert!( + !server.handle_scheduled_tasks_headless(Instant::now() + Duration::from_secs(6), false) + ); + assert!(server.app.session_save_thread.is_none()); + runtime.block_on(async { + tokio::time::timeout(Duration::from_secs(5), server.run()) + .await + .expect("host shutdown must stop the isolated server loop") + .expect("server loop shuts down cleanly"); + }); + let snapshot = crate::persist::load().expect("shutdown session should be saved"); + let panes = &snapshot.workspaces[0].tabs[0].panes; + assert_eq!(panes.len(), 2); + assert_eq!( + snapshot.workspaces[0].custom_name.as_deref(), + Some("shutdown") + ); + assert_eq!( + panes[&pane_id.raw()].agent_session.as_ref().unwrap().value, + "shutdown-session" + ); + shutdown_test_runtimes(&mut server); + } + + // The same code outside host shutdown must not stop unrelated panes. + for code in [0x40010004, 0xC0000142] { + let mut server = test_headless_server(); + server.host_shutdown_probe = || false; + let mut workspace = crate::workspace::Workspace::test_new("ordinary-exit"); + let pane_id = workspace.tabs[0].root_pane; + let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + assert!( + server.handle_internal_event_with_forwarding(AppEvent::PaneDied { + pane_id, + exit_reason: crate::platform::classify_child_exit( + &portable_pty::ExitStatus::with_exit_code(code) + ), + }) + ); + assert!(server.app.find_pane(pane_id).is_none()); + assert!(server.app.find_pane(other_pane).is_some()); + assert!(!server.should_quit.load(Ordering::Acquire)); + shutdown_test_runtimes(&mut server); + } + + std::fs::remove_dir_all(config_home).unwrap(); +} + #[tokio::test] async fn pane_death_reconciles_each_client_view_and_focus() { let mut server = test_headless_server(); @@ -5112,6 +5500,43 @@ fn client_pane_pixel_mouse_uses_runtime_pixel_encoding() { rt.shutdown_timeout(Duration::from_millis(100)); } +#[test] +fn client_pane_cell_mouse_uses_pixel_units_when_sgr_pixels_is_set() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _runtime_guard = rt.enter(); + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 20, + 5, + 0, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", + 4, + ); + runtime.resize(5, 20, 10, 20); + + apply_client_pane_input_events( + &runtime, + &[crate::protocol::ClientPaneInputEvent::Mouse { + kind: crate::protocol::ClientMouseKind::Moved, + position: crate::protocol::ClientMousePosition::Cell { column: 2, row: 1 }, + geometry: None, + modifiers: 0, + lines: 3, + }], + ) + .expect("cell-aligned pixel mouse input"); + assert_eq!( + input_rx.try_recv().expect("encoded pixel-unit mouse"), + Bytes::from_static(b"\x1b[<35;25;30M") + ); + drop(runtime); + drop(_runtime_guard); + rt.shutdown_timeout(Duration::from_millis(100)); +} + #[test] fn client_pane_pixel_mouse_stays_pixel_scaled_when_sgr_is_reasserted() { let rt = tokio::runtime::Builder::new_current_thread() @@ -6013,6 +6438,75 @@ async fn client_shell_release_cleanup_does_not_promote_and_survives_disconnect() shutdown_test_runtimes(&mut server); } +// Pixel mouse is a Unix client capability. +#[cfg(unix)] +#[tokio::test] +async fn client_shell_requests_host_pixels_for_an_unfocused_pixel_pane() { + // #4750: host reports go to the pane under the pointer, focused or not. + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("pixel-split"); + let first = workspace.tabs[0].root_pane; + let second = workspace.test_split(ratatui::layout::Direction::Horizontal); + let focused = workspace.focused_pane_id().expect("focused pane"); + let unfocused = if focused == first { second } else { first }; + workspace.insert_test_runtime( + focused, + crate::terminal::TerminalRuntime::test_with_screen_bytes(40, 23, b"plain"), + ); + workspace.insert_test_runtime( + unfocused, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + 40, + 23, + b"\x1b[?1003h\x1b[?1006h\x1b[?1016h", + ), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let (writer, control_rx, _render_rx) = test_client_writer(); + assert!( + server.handle_server_event(ServerEvent::ClientShellConnected { + surface_reuse: false, + surface_delta: false, + surface_scroll: false, + client_id: 7, + surface_cols: 80, + surface_rows: 23, + cell_width_px: 10, + cell_height_px: 20, + pixel_mouse: true, + direct_graphics: false, + endpoint_keybindings: false, + mouse_capture: true, + surface_active: true, + writer, + }) + ); + + // Control messages arrive asynchronously; wait for the mode instead of + // reading the channel once. + let mut mouse_modes = Vec::new(); + for _ in 0..20 { + server.stream_host_mouse_capture_mode(); + while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(50)) { + if let ServerMessage::MouseCapture { + enabled, + sgr_pixels, + } = read_server_message(bytes) + { + mouse_modes.push((enabled, sgr_pixels)); + } + } + if mouse_modes.last() == Some(&(true, true)) { + break; + } + } + assert_eq!(mouse_modes.last(), Some(&(true, true)), "{mouse_modes:?}"); + shutdown_test_runtimes(&mut server); +} + #[test] fn client_shell_mouse_capture_combines_local_preference_with_endpoint_demand() { let mut server = test_headless_server(); @@ -6198,11 +6692,12 @@ fn direct_terminal_streams_child_keyboard_and_mouse_modes() { read_server_message( client_control_rx .recv_timeout(Duration::from_millis(100)) - .expect("modifyOtherKeys mode-one keyboard message") + .expect("kitty flags change with modifyOtherKeys mode one") ), + // Like Ghostty, modifyOtherKeys level 1 is not a negotiated mode. ServerMessage::DirectTerminalKeyboardProtocol { flags: 3, - modify_other_keys_level: 1 + modify_other_keys_level: 0 } )); diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index 70904fd5..03386aed 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -620,3 +620,24 @@ async fn pixel_mouse_activation_follows_child_1016_without_graphics_demand() { } )); } + +#[tokio::test] +async fn mouse_capture_stays_cell_based_without_host_pixel_capability() { + let (mut server, _client_rx, _pane_id) = + retained_test_server(b"\x1b[?1003h\x1b[?1006h\x1b[?1016h"); + let (writer, control_rx, _render_rx) = test_client_writer(); + let client = server.clients.get_mut(&1).unwrap(); + client.writer = Some(writer); + client.pixel_mouse = false; + client.host_mouse_capture_active = None; + client.host_sgr_pixels_active = None; + + server.stream_host_mouse_capture_mode(); + assert!(matches!( + read_server_message(control_rx.recv_timeout(Duration::from_millis(100)).unwrap()), + ServerMessage::MouseCapture { + enabled: true, + sgr_pixels: false + } + )); +} diff --git a/src/server/mod.rs b/src/server/mod.rs index bdb90483..add6c3a2 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod pane_input; #[cfg(test)] mod render_scale_benchmark; pub(crate) mod render_stream; +pub(crate) mod shutdown; pub(crate) mod snooze_store; pub mod socket_paths; pub(crate) mod terminal_attach; diff --git a/src/server/pane_input.rs b/src/server/pane_input.rs index 4eb64cab..905f2335 100644 --- a/src/server/pane_input.rs +++ b/src/server/pane_input.rs @@ -200,24 +200,37 @@ pub(super) fn apply_terminal_attach_input( } } +#[cfg(test)] +pub(crate) fn test_apply_client_pane_input_events( + runtime: &crate::terminal::TerminalRuntime, + events: &[ClientPaneInputEvent], +) -> Result { + apply_client_pane_input_events(runtime, events) +} + pub(super) fn apply_client_pane_input_events( runtime: &crate::terminal::TerminalRuntime, events: &[ClientPaneInputEvent], -) -> Result<(), String> { - apply_client_terminal_input_events(runtime, events, true) +) -> Result { + let mut keyboard_forwarded = false; + apply_client_terminal_input_events(runtime, events, true, &mut keyboard_forwarded)?; + Ok(keyboard_forwarded) } pub(super) fn apply_client_popup_input_events( runtime: &crate::terminal::TerminalRuntime, events: &[ClientPaneInputEvent], -) -> Result<(), String> { - apply_client_terminal_input_events(runtime, events, false) +) -> Result { + let mut keyboard_forwarded = false; + apply_client_terminal_input_events(runtime, events, false, &mut keyboard_forwarded)?; + Ok(keyboard_forwarded) } -fn apply_client_terminal_input_events( +pub(super) fn apply_client_terminal_input_events( runtime: &crate::terminal::TerminalRuntime, events: &[ClientPaneInputEvent], host_page_keys: bool, + keyboard_forwarded: &mut bool, ) -> Result<(), String> { for event in events { if let ClientPaneInputEvent::Mouse { @@ -316,6 +329,7 @@ fn apply_client_terminal_input_events( runtime .try_send_bytes(Bytes::from(bytes)) .map_err(|err| format!("targeted pane key input failed: {err}"))?; + *keyboard_forwarded |= key_event.kind != KeyEventKind::Release; } } crate::raw_input::RawInputEvent::Text(text) => { @@ -323,6 +337,7 @@ fn apply_client_terminal_input_events( runtime .try_send_bytes(Bytes::copy_from_slice(text.as_str().as_bytes())) .map_err(|err| format!("targeted pane text input failed: {err}"))?; + *keyboard_forwarded |= !text.as_str().is_empty(); } crate::raw_input::RawInputEvent::Paste(text) => { runtime.scroll_reset(); diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs index 754c9dbc..cba0cbfd 100644 --- a/src/server/render_scale_benchmark.rs +++ b/src/server/render_scale_benchmark.rs @@ -16,7 +16,6 @@ const ROWS: u16 = 40; const SAMPLE_COUNT: usize = 40; const WARMUP_COUNT: usize = 5; const CARDINALITIES: [usize; 3] = [1, 15, 50]; -const CLIENT_CARDINALITIES: [usize; 2] = [1, 4]; #[derive(Clone, Copy)] struct StageStats { @@ -233,7 +232,8 @@ fn profile_snapshot_encoding( build: fn(usize) -> Vec, count: usize, client_count: usize, -) -> StageStats { + hidden_output: bool, +) -> (StageStats, StageStats) { let mut pipeline = RenderPipeline::new(build(count)); pipeline.app.state.ensure_test_terminals(); for terminal in pipeline.app.state.terminals.values_mut() { @@ -245,6 +245,23 @@ fn profile_snapshot_encoding( terminal.last_agent_completion_seq = Some(1); } let run = || { + if hidden_output { + for (workspace_index, workspace) in + pipeline.app.state.workspaces.iter().enumerate().skip(1) + { + let pane_id = workspace.tabs[0].root_pane; + pipeline + .app + .state + .runtime_for_pane_in_workspace( + &pipeline.app.terminal_runtimes, + workspace_index, + pane_id, + ) + .unwrap() + .test_process_pty_bytes(b"hidden output\r\n"); + } + } let started = Instant::now(); let (template, completions) = super::client_shell::snapshot_with_completions( &pipeline.app, @@ -253,6 +270,7 @@ fn profile_snapshot_encoding( None, None, ); + let construction_elapsed = started.elapsed(); for client_index in 0..client_count { let mut snapshot = template.clone(); snapshot.revision = client_index as u64 + 1; @@ -271,24 +289,31 @@ fn profile_snapshot_encoding( .expect("benchmark snapshot message should frame"), ); } - started.elapsed() + (construction_elapsed, started.elapsed()) }; for _ in 0..WARMUP_COUNT { black_box(run()); } - summarize((0..SAMPLE_COUNT).map(|_| run()).collect()) + let (construction, total) = (0..SAMPLE_COUNT).map(|_| run()).unzip(); + (summarize(construction), summarize(total)) } fn print_snapshot_encoding_profiles(label: &str, build: fn(usize) -> Vec) { println!("{label} populated agent snapshots + completions + JSON framing"); - println!(" panes clients median_us p95_us max_us"); - for count in CARDINALITIES { - for client_count in CLIENT_CARDINALITIES { - let stats = profile_snapshot_encoding(build, count, client_count); - println!( - " {count:>10} {client_count:>7} {:>9} {:>6} {:>6}", - stats.median_us, stats.p95_us, stats.max_us - ); + println!(" panes clients hidden_output construction_us median_us p95_us max_us"); + for count in [1, 15, 118] { + for client_count in [1, 2] { + for hidden_output in [false, true] { + if hidden_output && label != "background workspaces" { + continue; + } + let (construction, stats) = + profile_snapshot_encoding(build, count, client_count, hidden_output); + println!( + " {count:>10} {client_count:>7} {hidden_output:>13} {:>15} {:>9} {:>6} {:>6}", + construction.median_us, stats.median_us, stats.p95_us, stats.max_us + ); + } } } } diff --git a/src/server/shutdown.rs b/src/server/shutdown.rs new file mode 100644 index 00000000..6b381c5e --- /dev/null +++ b/src/server/shutdown.rs @@ -0,0 +1,86 @@ +//! Why the server is stopping, recorded where the request arrives and logged +//! once when shutdown begins. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::platform::ServerQuitSignal; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ShutdownReason { + Signal(ServerQuitSignal), + ApiStop { caller: Option }, + HostShutdown, + Unknown, +} + +impl std::fmt::Display for ShutdownReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Signal(signal) => write!(f, "{signal}"), + Self::ApiStop { + caller: Some(caller), + } => write!(f, "server.stop request from {caller}"), + Self::ApiStop { caller: None } => f.write_str("server.stop request"), + Self::HostShutdown => f.write_str("host shutdown"), + Self::Unknown => f.write_str("unknown"), + } + } +} + +/// The server's stop flag plus the reason recorded by the first stop request. +#[derive(Clone, Default)] +pub(crate) struct ServerStop { + flag: Arc, + reason: Arc>>, +} + +impl ServerStop { + pub(crate) fn flag(&self) -> &Arc { + &self.flag + } + + pub(crate) fn is_requested(&self) -> bool { + self.flag.load(Ordering::Acquire) + } + + pub(crate) fn request(&self, reason: ShutdownReason) { + if let Ok(mut recorded) = self.reason.lock() { + recorded.get_or_insert(reason); + } + self.flag.store(true, Ordering::Release); + } + + pub(crate) fn take_reason(&self) -> Option { + self.reason.lock().ok()?.take() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_stop_reason_wins() { + let stop = ServerStop::default(); + assert!(!stop.is_requested()); + + stop.request(ShutdownReason::HostShutdown); + stop.request(ShutdownReason::ApiStop { caller: None }); + + assert!(stop.is_requested()); + assert_eq!(stop.take_reason(), Some(ShutdownReason::HostShutdown)); + assert_eq!(stop.take_reason(), None); + } + + #[test] + fn api_stop_reason_names_the_caller() { + let reason = ShutdownReason::ApiStop { + caller: Some("pid 42 (herdr), parent pid 7 (zsh)".into()), + }; + assert_eq!( + reason.to_string(), + "server.stop request from pid 42 (herdr), parent pid 7 (zsh)" + ); + } +} diff --git a/src/session.rs b/src/session.rs index 29e3e17e..017c20dd 100644 --- a/src/session.rs +++ b/src/session.rs @@ -22,6 +22,8 @@ pub struct SessionInfo { pub name: String, pub default: bool, pub running: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub connection_error: Option, pub socket_path: String, pub session_dir: String, } @@ -216,10 +218,17 @@ pub fn session_info(name: Option<&str>) -> SessionInfo { let display_name = name.unwrap_or(DEFAULT_SESSION_NAME).to_string(); let socket_path = api_socket_path_for(name); let session_dir = data_dir_for(name); + let connection = crate::ipc::connect_local_stream(&socket_path); + let running = connection.is_ok(); + let connection_error = connection + .err() + .filter(|error| !crate::cli::server_not_running_error(error)) + .map(|error| error.to_string()); SessionInfo { name: display_name, default, - running: is_running_at(&socket_path), + running, + connection_error, socket_path: socket_path.display().to_string(), session_dir: session_dir.display().to_string(), } @@ -496,11 +505,77 @@ mod tests { use super::*; #[cfg(unix)] use interprocess::local_socket::traits::Listener as _; - use std::sync::{Mutex, OnceLock}; + use std::sync::Mutex; fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) + crate::config::test_config_env_lock() + } + + #[cfg(windows)] + #[test] + fn session_discovery_distinguishes_denied_running_and_stopped_servers() { + use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions}; + use interprocess::os::windows::local_socket::ListenerOptionsExt as _; + use interprocess::os::windows::security_descriptor::SecurityDescriptor; + use widestring::U16CString; + + let _guard = env_lock().lock().unwrap(); + let config_home = + std::env::temp_dir().join(format!("herdr-session-denied-{}", std::process::id())); + let previous_config_home = std::env::var_os("XDG_CONFIG_HOME"); + std::env::set_var("XDG_CONFIG_HOME", &config_home); + let denied_path = api_socket_path_for(Some("denied")); + let running_path = api_socket_path_for(Some("running")); + std::fs::create_dir_all(denied_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(running_path.parent().unwrap()).unwrap(); + + // A SYSTEM-only pipe reproduces access denial without requiring elevation in CI. + let sddl = U16CString::from_str("D:P(A;;GA;;;SY)").unwrap(); + let denied_listener = ListenerOptions::new() + .name( + denied_path + .to_string_lossy() + .to_ns_name::() + .unwrap(), + ) + .security_descriptor(SecurityDescriptor::deserialize(&sddl).unwrap()) + .create_sync() + .unwrap(); + let running_listener = crate::ipc::bind_local_listener(&running_path).unwrap(); + let error = crate::ipc::connect_local_stream(&denied_path).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + + let sessions = list_sessions().unwrap(); + let denied = sessions + .iter() + .find(|session| session.name == "denied") + .unwrap(); + assert!(!denied.running); + assert_eq!(denied.connection_error, Some(error.to_string())); + let running = sessions + .iter() + .find(|session| session.name == "running") + .unwrap(); + assert!(running.running); + assert!(running.connection_error.is_none()); + let stopped = sessions.iter().find(|session| session.default).unwrap(); + assert!(!stopped.running); + assert!(stopped.connection_error.is_none()); + for session in [running, stopped] { + assert!(serde_json::to_value(session) + .unwrap() + .get("connection_error") + .is_none()); + } + assert!(serde_json::to_value(denied).unwrap()["connection_error"].is_string()); + + drop(denied_listener); + drop(running_listener); + match previous_config_home { + Some(value) => std::env::set_var("XDG_CONFIG_HOME", value), + None => std::env::remove_var("XDG_CONFIG_HOME"), + } + std::fs::remove_dir_all(config_home).unwrap(); } #[cfg(unix)] diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 91713409..23b61da2 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -478,6 +478,10 @@ impl TerminalRuntime { self.0.try_send_bytes(bytes) } + pub(crate) fn validate_input_submission(&self, bytes: &[u8]) -> std::io::Result<()> { + self.0.validate_input_submission(bytes) + } + pub fn queue_user_input_submission( &self, text: Bytes, @@ -601,6 +605,20 @@ impl TerminalRuntime { #[cfg(test)] impl TerminalRuntime { + #[cfg(unix)] + pub(crate) fn test_start_basic_detection(&mut self) -> mpsc::Receiver { + self.0.test_start_basic_detection() + } + + #[cfg(unix)] + pub(crate) async fn test_wait_for_detection_reads(&self, expected: usize) { + self.0.test_wait_for_detection_reads(expected).await; + } + + pub(crate) fn test_scroll_metrics_reads(&self) -> usize { + self.0.test_scroll_metrics_reads() + } + #[cfg(unix)] pub(crate) fn test_enable_kitty_source_forwarding(&self) { self.0.test_enable_kitty_source_forwarding(); @@ -618,6 +636,11 @@ impl TerminalRuntime { (Self(runtime), rx) } + #[cfg(unix)] + pub(crate) fn test_with_actor(actor: crate::pty::actor::PtyIoActorHandle) -> Self { + Self(crate::pane::PaneRuntime::test_with_actor(actor)) + } + pub(crate) fn test_with_channel_capacity( cols: u16, rows: u16, diff --git a/src/thread_spawn.rs b/src/thread_spawn.rs new file mode 100644 index 00000000..3ad7f26f --- /dev/null +++ b/src/thread_spawn.rs @@ -0,0 +1,109 @@ +//! Thread spawning that reports OS failures instead of panicking. +//! +//! `std::thread::spawn` panics when the OS refuses a new thread (for example +//! EAGAIN at a process or cgroup task limit). In the server that panic either +//! exits the whole process or silently kills a long-lived loop, so server code +//! spawns through these helpers and degrades per call site. + +use std::io; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +pub(crate) fn spawn_named(name: &str, f: F) -> io::Result> +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + #[cfg(test)] + if test_hook::take_injected_failure() { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "injected thread spawn failure", + )); + } + std::thread::Builder::new().name(name.to_owned()).spawn(f) +} + +/// Runs `work(payload)` on a new thread. If the thread cannot start, the +/// payload comes back so the caller can still finish the operation it was +/// meant to complete. +pub(crate) fn spawn_named_with(name: &str, payload: P, work: F) -> Result<(), (P, io::Error)> +where + P: Send + 'static, + F: FnOnce(P) + Send + 'static, +{ + let slot = Arc::new(Mutex::new(Some(payload))); + let worker_slot = Arc::clone(&slot); + let spawned = spawn_named(name, move || { + if let Some(payload) = take_payload(&worker_slot) { + work(payload); + } + }); + match spawned { + Ok(_) => Ok(()), + // A failed spawn never runs the closure, so the payload is still here. + Err(err) => take_payload(&slot).map_or(Ok(()), |payload| Err((payload, err))), + } +} + +fn take_payload

(slot: &Mutex>) -> Option

{ + slot.lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() +} + +#[cfg(test)] +pub(crate) mod test_hook { + use std::cell::Cell; + + thread_local! { + static INJECTED_FAILURES: Cell = const { Cell::new(0) }; + static ALLOWED_BEFORE_FAILURES: Cell = const { Cell::new(0) }; + } + + /// Makes the next `count` spawns from the current thread fail. + pub(crate) fn fail_next_spawns(count: usize) { + fail_spawns_after(0, count); + } + + /// Lets `allowed` spawns from the current thread succeed, then fails `count`. + pub(crate) fn fail_spawns_after(allowed: usize, count: usize) { + ALLOWED_BEFORE_FAILURES.with(|cell| cell.set(allowed)); + INJECTED_FAILURES.with(|failures| failures.set(count)); + } + + pub(super) fn take_injected_failure() -> bool { + let remaining = INJECTED_FAILURES.with(Cell::get); + if remaining == 0 { + return false; + } + let allowed = ALLOWED_BEFORE_FAILURES.with(Cell::get); + if allowed > 0 { + ALLOWED_BEFORE_FAILURES.with(|cell| cell.set(allowed - 1)); + return false; + } + INJECTED_FAILURES.with(|failures| failures.set(remaining - 1)); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn injected_failure_returns_payload() { + test_hook::fail_next_spawns(1); + let result = spawn_named_with("test-payload", 7_u32, |_| {}); + let (payload, err) = result.expect_err("spawn should fail"); + assert_eq!(payload, 7); + assert_eq!(err.kind(), io::ErrorKind::WouldBlock); + + let (tx, rx) = std::sync::mpsc::channel(); + spawn_named_with("test-payload", 8_u32, move |payload| { + tx.send(payload).unwrap(); + }) + .expect("spawn should succeed after injected failure"); + assert_eq!(rx.recv().unwrap(), 8); + } +} diff --git a/src/ui.rs b/src/ui.rs index 141e09d3..4ec80dba 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,14 +12,15 @@ mod widgets; pub(crate) use self::onboarding::{ onboarding_welcome_continue_rect, ONBOARDING_DESCRIPTION, ONBOARDING_HELP_LABEL, - ONBOARDING_HELP_SUFFIX, ONBOARDING_NEXT, ONBOARDING_PREFIX_LABEL, ONBOARDING_PREFIX_SUFFIX, - ONBOARDING_SUBTITLE, ONBOARDING_TITLE, + ONBOARDING_HELP_SUFFIX, ONBOARDING_NEXT, ONBOARDING_PREFIX_SUFFIX, ONBOARDING_SUBTITLE, + ONBOARDING_TITLE, }; #[cfg(all(test, unix))] pub(crate) use self::panes::popup_pane_rects; use self::panes::resize_popup_pane; pub(crate) use self::panes::{ - apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back, render_selection_highlight, + apply_pane_chrome, new_layout_terminal_sizes, new_pane_terminal_size, pane_inner_rect, + pane_is_scrolled_back, render_selection_highlight, NewPanePlacement, }; pub(crate) use self::release_notes::{ product_announcement_display_lines, product_announcement_scroll_metrics, diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index cab4ae9c..4ad55845 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -7,7 +7,6 @@ pub(crate) const ONBOARDING_DESCRIPTION: [&str; 3] = [ " click the sidebar to switch workspaces, drag pane", " borders to resize, right-click for context menus.", ]; -pub(crate) const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; pub(crate) const ONBOARDING_PREFIX_SUFFIX: &str = " enters prefix mode · "; pub(crate) const ONBOARDING_HELP_LABEL: &str = "?"; pub(crate) const ONBOARDING_HELP_SUFFIX: &str = " shows keybinds and settings"; diff --git a/src/ui/panes.rs b/src/ui/panes.rs index adf1f3d3..6a91f18a 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -1,6 +1,6 @@ use ratatui::{ buffer::Buffer, - layout::Rect, + layout::{Direction, Rect}, style::{Color, Modifier, Style}, widgets::{Block, Borders, Paragraph, Wrap}, Frame, @@ -13,7 +13,7 @@ use super::text::truncate_end; use super::widgets::panel_contrast_fg; use crate::app::state::Palette; use crate::app::AppState; -use crate::layout::PaneInfo; +use crate::layout::{PaneId, PaneInfo}; use crate::popup_size::resolve_popup_geometry; use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry}; @@ -34,7 +34,11 @@ fn pane_border_title(label: &str, pane_width: u16, _focused: bool) -> Option Rect { - if !pane_scrollbars || pane_inner.width <= 4 || rt.alternate_screen_active() { + terminal_inner_rect_for(pane_inner, pane_scrollbars && !rt.alternate_screen_active()) +} + +fn terminal_inner_rect_for(pane_inner: Rect, scrollbar_gutter: bool) -> Rect { + if !scrollbar_gutter || pane_inner.width <= 4 { return pane_inner; } @@ -46,6 +50,108 @@ fn terminal_inner_rect(rt: &TerminalRuntime, pane_inner: Rect, pane_scrollbars: ) } +fn zoomed_pane_borders(app: &AppState, multi_pane: bool) -> Borders { + if app.pane_borders.shows_borders(multi_pane) && app.pane_outer_borders { + Borders::ALL + } else { + Borders::NONE + } +} + +/// Where a pane that is about to be created will sit in its tab. +#[derive(Debug, Clone, Copy)] +pub(crate) enum NewPanePlacement { + /// The only pane of a new tab or workspace. + Alone, + /// The pane created by splitting `target` in workspace `ws_idx`. + Split { + ws_idx: usize, + target: PaneId, + direction: Direction, + ratio: f32, + }, + /// A pane split off and immediately zoomed over its tab. + ZoomedOverlay, + /// An existing pane in workspace `ws_idx` getting a new terminal. + Existing { ws_idx: usize, pane: PaneId }, +} + +/// Terminal rows and columns a new pane gets once its tab is laid out in +/// `area`, so its program starts at that size instead of being resized. +pub(crate) fn new_pane_terminal_size( + app: &AppState, + area: Rect, + placement: NewPanePlacement, +) -> (u16, u16) { + let laid_out = |panes: Vec, index: usize| { + let infos = apply_pane_chrome( + panes, + app.pane_borders, + app.pane_gaps, + app.pane_outer_borders, + ); + pane_inner_rect(infos[index].rect, infos[index].borders) + }; + // A lone pane has no neighbors, so its chrome matches a zoomed single pane. + let alone = || pane_inner_rect(area, zoomed_pane_borders(app, false)); + let tab_for = |ws_idx: usize, pane: PaneId| { + let ws = app.workspaces.get(ws_idx)?; + ws.tabs.get(ws.find_tab_index_for_pane(pane)?) + }; + let pane_inner = match placement { + NewPanePlacement::Alone => alone(), + NewPanePlacement::Existing { ws_idx, pane } => tab_for(ws_idx, pane) + .and_then(|tab| { + if tab.zoomed && tab.layout.focused() == pane { + let multi_pane = tab.layout.pane_count() > 1; + return Some(pane_inner_rect(area, zoomed_pane_borders(app, multi_pane))); + } + let panes = tab.layout.panes(area); + let index = panes.iter().position(|info| info.id == pane)?; + Some(laid_out(panes, index)) + }) + .unwrap_or_else(alone), + NewPanePlacement::ZoomedOverlay => pane_inner_rect(area, zoomed_pane_borders(app, true)), + NewPanePlacement::Split { + ws_idx, + target, + direction, + ratio, + } => tab_for(ws_idx, target) + .and_then(|tab| tab.layout.panes_after_split(area, target, direction, ratio)) + .map(|(panes, new_index)| laid_out(panes, new_index)) + .unwrap_or_else(alone), + }; + new_terminal_size(app, pane_inner) +} + +/// Terminal rows and columns for every pane of `layout` laid out in `area`, in +/// pane order, so a multi-pane layout can start each program at its final size. +pub(crate) fn new_layout_terminal_sizes( + app: &AppState, + area: Rect, + layout: &crate::layout::TileLayout, +) -> Vec<(u16, u16)> { + apply_pane_chrome( + layout.panes(area), + app.pane_borders, + app.pane_gaps, + app.pane_outer_borders, + ) + .into_iter() + .map(|info| new_terminal_size(app, pane_inner_rect(info.rect, info.borders))) + .collect() +} + +fn new_terminal_size(app: &AppState, pane_inner: Rect) -> (u16, u16) { + // A new program starts on the primary screen, which reserves the gutter. + let inner = terminal_inner_rect_for(pane_inner, app.pane_scrollbars); + ( + inner.height.max(crate::pane::MIN_PANE_ROWS), + inner.width.max(crate::pane::MIN_PANE_COLS), + ) +} + pub(crate) fn pane_inner_rect(area: Rect, borders: Borders) -> Rect { if borders.is_empty() { area @@ -220,12 +326,7 @@ pub(super) fn resize_tab_panes( if let Some((terminal_id, rt)) = runtime_for_tab_pane(app, terminal_runtimes, workspace_index, tab, focused_id) { - let borders = if app.pane_borders.shows_borders(multi_pane) && app.pane_outer_borders { - Borders::ALL - } else { - Borders::NONE - }; - let pane_inner = pane_inner_rect(area, borders); + let pane_inner = pane_inner_rect(area, zoomed_pane_borders(app, multi_pane)); let inner_rect = terminal_inner_rect(rt, pane_inner, app.pane_scrollbars); if !app.direct_attach_resize_locks.contains(terminal_id) { rt.resize( @@ -285,11 +386,7 @@ pub(super) fn compute_pane_infos_for_tab( if tab.zoomed { let focused_id = tab.layout.focused(); - let borders = if app.pane_borders.shows_borders(multi_pane) && app.pane_outer_borders { - Borders::ALL - } else { - Borders::NONE - }; + let borders = zoomed_pane_borders(app, multi_pane); let pane_inner = pane_inner_rect(area, borders); let mut inner_rect = pane_inner; let mut scrollbar_rect = None; diff --git a/tests/api_ping.rs b/tests/api_ping.rs index b86d0378..4aab928a 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -32,7 +32,7 @@ struct SpawnedHerdr { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + support::stop_spawned_herdr(&mut *self.child); if let Some(pid) = pid { let deadline = Instant::now() + Duration::from_secs(2); @@ -141,6 +141,7 @@ fn spawn_herdr_with_options( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); @@ -311,6 +312,47 @@ fn ping_over_socket_returns_version() { cleanup_spawned_herdr(child, base); } +#[test] +fn spawned_server_ignores_inherited_pane_env() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let startup_cwd = base.join("startup"); + fs::create_dir_all(&startup_cwd).unwrap(); + + // A shell inside a herdr pane passes these to every process it starts. + let saved: Vec<_> = ["HERDR_STARTUP_CWD", "HERDR_SESSION"] + .into_iter() + .map(|name| (name, std::env::var_os(name))) + .collect(); + std::env::set_var("HERDR_STARTUP_CWD", &startup_cwd); + std::env::set_var("HERDR_SESSION", "inherited"); + let child = spawn_herdr(&config_home, &runtime_dir, &socket_path); + for (name, value) in saved { + match value { + Some(value) => std::env::set_var(name, value), + None => std::env::remove_var(name), + } + } + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let value = send_request( + &socket_path, + r#"{"id":"req_1","method":"workspace.list","params":{}}"#, + ); + assert_eq!(value["result"]["workspaces"], serde_json::json!([])); + let app_dir = if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + }; + assert!(!config_home.join(app_dir).join("sessions").exists()); + + cleanup_spawned_herdr(child, base); +} + #[test] fn server_reload_agent_manifests_reports_runtime_override() { let _lock = test_lock(); diff --git a/tests/auto_detect.rs b/tests/auto_detect.rs index deb01bab..4d4a2bcd 100644 --- a/tests/auto_detect.rs +++ b/tests/auto_detect.rs @@ -39,7 +39,7 @@ struct SpawnedHerdr { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + support::stop_spawned_herdr(&mut *self.child); if let Some(pid) = pid { let deadline = Instant::now() + Duration::from_secs(2); @@ -106,6 +106,7 @@ fn spawn_server( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); @@ -150,6 +151,7 @@ fn spawn_herdr_auto( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); // No subcommand → auto-detect launch. cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); @@ -600,6 +602,7 @@ fn auto_detect_default_socket_path_from_config_dir() { .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", &config_home); cmd.env("XDG_RUNTIME_DIR", &runtime_dir); diff --git a/tests/cli/harness.rs b/tests/cli/harness.rs index 91dab149..fc5ed618 100644 --- a/tests/cli/harness.rs +++ b/tests/cli/harness.rs @@ -7,14 +7,11 @@ pub(super) use std::thread; pub(super) use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub(super) use crate::support::{ - cleanup_test_base, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, CURRENT_PROTOCOL, + cleanup_test_base, isolate_herdr_test_process, register_runtime_dir, + register_spawned_herdr_pid, unregister_spawned_herdr_pid, CURRENT_PROTOCOL, }; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -pub(super) const WORKTREE_BOOTSTRAP_MANAGED_COMPONENT: &str = - "example.worktree-bootstrap-ef876653ffc3"; - pub(super) fn unique_test_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -24,7 +21,10 @@ pub(super) fn unique_test_dir() -> PathBuf { } pub(super) fn managed_github_plugin_dir(config_home: &Path) -> PathBuf { - config_home.join("herdr-dev").join("plugins").join("github") + config_home + .join("herdr-dev") + .join("plugins") + .join("github-installations") } pub(super) fn path_missing_or_empty(path: &Path) -> bool { @@ -81,7 +81,7 @@ impl Drop for SpawnedServerProcess { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + crate::support::stop_spawned_herdr(&mut *self.child); if let Some(pid) = pid { let deadline = Instant::now() + Duration::from_secs(2); @@ -182,6 +182,8 @@ pub(super) fn spawn_named_server( .env_remove("HERDR_SOCKET_PATH") .env_remove("HERDR_CLIENT_SOCKET_PATH") .env_remove("HERDR_ENV") + .env_remove("HERDR_STARTUP_CWD") + .env_remove("HERDR_SESSION") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); @@ -300,6 +302,7 @@ pub(super) fn spawn_herdr_with_config( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); diff --git a/tests/cli/plugins.rs b/tests/cli/plugins.rs index 23f1b352..6cbb3ccd 100644 --- a/tests/cli/plugins.rs +++ b/tests/cli/plugins.rs @@ -81,6 +81,7 @@ fn named_sessions_share_live_plugin_registry() { &runtime_dir, &["--session", "beta", "plugin", "disable", "example.first"], ); + let disabled = run_named_cli( &config_home, &runtime_dir, @@ -247,6 +248,591 @@ platforms = ["linux", "macos", "windows"] cleanup_test_base(&base); } +#[test] +fn plugin_update_refreshes_selected_then_all_github_plugins() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source-repo"); + create_committed_repo(&source_repo); + run_git(&source_repo, &["checkout", "-b", "plugin-updates"]); + + for (subdir, id) in [("first", "example.first"), ("second", "example.second")] { + let plugin_dir = source_repo.join(subdir); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + format!( + "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n" + ), + ) + .unwrap(); + } + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugins"]); + + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/example/plugins.git\n", + source_repo.display() + ), + ) + .unwrap(); + for subdir in ["first", "second"] { + let source = format!("example/plugins/{subdir}"); + let ref_args = (subdir == "first").then_some(["--ref", "plugin-updates"]); + let mut args = vec!["plugin", "install", source.as_str(), "--yes"]; + if let Some(ref_args) = ref_args { + args.extend(ref_args); + } + let output = run_named_cli_with_env( + &config_home, + &runtime_dir, + &args, + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let server = spawn_named_server(&config_home, &runtime_dir, "updates"); + wait_for_socket( + &named_session_socket(&config_home, "updates"), + Duration::from_secs(5), + ); + run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "disable", "example.first"], + ); + let reinstalled = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "updates", + "plugin", + "install", + "example/plugins/first", + "--ref", + "plugin-updates", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(reinstalled.status.success()); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "list", "--json"], + ); + assert_eq!(listed["result"]["plugins"][0]["enabled"], false); + let first_installation = PathBuf::from( + listed["result"]["plugins"][0]["plugin_root"] + .as_str() + .unwrap(), + ); + let offline_reinstall = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "offline-update", + "plugin", + "install", + "example/plugins/first", + "--ref", + "plugin-updates", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(offline_reinstall.status.success()); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "list", "--json"], + ); + assert_eq!(listed["result"]["plugins"][0]["enabled"], false); + let second_installation = PathBuf::from( + listed["result"]["plugins"][0]["plugin_root"] + .as_str() + .unwrap(), + ); + assert_ne!(first_installation, second_installation); + assert!(first_installation.join("herdr-plugin.toml").exists()); + let malformed = run_named_cli( + &config_home, + &runtime_dir, + &[ + "--session", + "updates", + "plugin", + "update", + "example/plugins/..", + "--yes", + ], + ); + assert_eq!(malformed.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&malformed.stderr).contains("invalid plugin subdir segment")); + + for (subdir, id) in [("first", "example.first"), ("second", "example.second")] { + fs::write( + source_repo.join(subdir).join("herdr-plugin.toml"), + format!( + "id = \"{id}\"\nname = \"{id}\"\nversion = \"0.2.0\"\nmin_herdr_version = \"0.6.10\"\n" + ), + ) + .unwrap(); + } + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "update plugins"]); + + let selected = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "updates", + "plugin", + "update", + "EXAMPLE/PLUGINS/first", + "example.first", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(selected.status.success()); + assert!(String::from_utf8_lossy(&selected.stdout).contains("Updated example.first")); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "list", "--json"], + ); + assert_eq!(listed["result"]["plugins"][0]["version"], "0.2.0"); + assert_eq!(listed["result"]["plugins"][0]["enabled"], false); + assert_eq!(listed["result"]["plugins"][1]["version"], "0.1.0"); + + let local_dir = base.join("local-plugin"); + fs::create_dir_all(&local_dir).unwrap(); + fs::write( + local_dir.join("herdr-plugin.toml"), + "id = \"example.local\"\nname = \"Local\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n", + ) + .unwrap(); + let linked = run_named_cli( + &config_home, + &runtime_dir, + &[ + "--session", + "updates", + "plugin", + "link", + local_dir.to_str().unwrap(), + ], + ); + assert!(linked.status.success()); + + let all = run_named_cli_with_env( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "update", "--yes"], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(all.status.success()); + let stdout = String::from_utf8_lossy(&all.stdout); + assert!(stdout.contains("example.first is already up to date")); + assert!(stdout.contains("Updated example.second")); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "updates", "plugin", "list", "--json"], + ); + let plugins = listed["result"]["plugins"].as_array().unwrap(); + for id in ["example.first", "example.second"] { + let plugin = plugins + .iter() + .find(|plugin| plugin["plugin_id"] == id) + .unwrap(); + assert_eq!(plugin["version"], "0.2.0"); + } + assert!(plugins + .iter() + .any(|plugin| plugin["plugin_id"] == "example.local")); + + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "updates"]); + drop(server); + let unchanged = run_named_cli_with_env( + &config_home, + &runtime_dir, + &["plugin", "update", "--yes"], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(unchanged.status.success()); + assert!( + !first_installation.exists(), + "stopped servers release old files" + ); + assert!(!second_installation.exists()); + assert!(local_dir.exists(), "cleanup never removes local plugins"); + cleanup_test_base(&base); +} + +#[test] +fn plugin_update_does_not_resurrect_a_plugin_unlinked_during_build() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source-repo"); + let plugin_dir = source_repo.join("plugin"); + let build_started = base.join("build-started"); + let release_build = base.join("release-build"); + create_committed_repo(&source_repo); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write( + plugin_dir.join("herdr-plugin.toml"), + "id = \"example.race\"\nname = \"Race\"\nversion = \"0.1.0\"\nmin_herdr_version = \"0.6.10\"\n", + ) + .unwrap(); + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); + + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/example/race.git\n", + source_repo.display() + ), + ) + .unwrap(); + let installed = run_named_cli_with_env( + &config_home, + &runtime_dir, + &[ + "--session", + "race", + "plugin", + "install", + "example/race/plugin", + "--yes", + ], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(installed.status.success()); + + let server = spawn_named_server(&config_home, &runtime_dir, "race"); + wait_for_socket( + &named_session_socket(&config_home, "race"), + Duration::from_secs(5), + ); + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "race", "plugin", "list", "--json"], + ); + let managed_path = PathBuf::from( + listed["result"]["plugins"][0]["source"]["managed_path"] + .as_str() + .unwrap(), + ); + + fs::write( + plugin_dir.join("herdr-plugin.toml"), + format!( + "id = \"example.race\"\nname = \"Race\"\nversion = \"0.2.0\"\nmin_herdr_version = \"0.6.10\"\n\n[[build]]\ncommand = [\"sh\", \"-c\", \"touch {}; while [ ! -e {} ]; do sleep 0.05; done\"]\n", + build_started.display(), + release_build.display() + ), + ) + .unwrap(); + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "update plugin"]); + + let mut update = Command::new(env!("CARGO_BIN_EXE_herdr")); + update + .args([ + "--session", + "race", + "plugin", + "update", + "example.race", + "--yes", + ]) + .env("XDG_CONFIG_HOME", &config_home) + .env("XDG_RUNTIME_DIR", &runtime_dir) + .env("GIT_CONFIG_GLOBAL", &git_config) + .env_remove("HERDR_SOCKET_PATH") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_ENV") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = update.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !build_started.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + if !build_started.exists() { + fs::write(&release_build, "release").unwrap(); + let output = child.wait_with_output().unwrap(); + panic!( + "plugin update never reached its build: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let unlinked = run_named_cli( + &config_home, + &runtime_dir, + &["--session", "race", "plugin", "unlink", "example.race"], + ); + assert!(unlinked.status.success()); + fs::write(&release_build, "release").unwrap(); + let updated = child.wait_with_output().unwrap(); + assert!(!updated.status.success()); + assert!(String::from_utf8_lossy(&updated.stderr) + .contains("changed while its update was in progress")); + + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "race", "plugin", "list", "--json"], + ); + assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); + assert!( + fs::read_to_string(managed_path.join("plugin/herdr-plugin.toml")) + .unwrap() + .contains("version = \"0.1.0\"") + ); + + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "race"]); + drop(server); + cleanup_test_base(&base); +} + +#[test] +fn plugin_updates_preserve_running_consumers_and_failed_activation() { + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let source_repo = base.join("source"); + create_committed_repo(&source_repo); + let manifest = r#" +id = "example.live" +name = "Live" +version = "0.1.0" +min_herdr_version = "0.6.10" +[[actions]] +id = "read" +title = "Read" +command = ["sh", "./read.sh", "action"] +[[panes]] +id = "read" +title = "Read" +placement = "tab" +command = ["sh", "./read.sh", "pane"] +"#; + fs::write(source_repo.join("herdr-plugin.toml"), manifest).unwrap(); + fs::write(source_repo.join("version"), "old").unwrap(); + fs::write(source_repo.join("read.sh"), r#" +kind="$1" +touch "$HERDR_PLUGIN_ROOT/ready-$kind" +while [ ! -e "$HERDR_PLUGIN_ROOT/release" ]; do sleep 0.05; done +printf '%s:%s' "$(cat version)" "$(cat "$HERDR_PLUGIN_ROOT/version")" > "$HERDR_PLUGIN_ROOT/result-$kind.tmp" +mv "$HERDR_PLUGIN_ROOT/result-$kind.tmp" "$HERDR_PLUGIN_ROOT/result-$kind" +"#).unwrap(); + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "old plugin"]); + let git_config = base.join("gitconfig"); + fs::write( + &git_config, + format!( + "[url \"file://{}\"]\n insteadOf = https://github.com/example/live.git\n", + source_repo.display() + ), + ) + .unwrap(); + let install = run_named_cli_with_env( + &config_home, + &runtime_dir, + &["plugin", "install", "example/live", "--yes"], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!( + install.status.success(), + "{}", + String::from_utf8_lossy(&install.stderr) + ); + + // Model an existing installation from before generation directories. + let registry = config_home.join("herdr-dev/plugins.json"); + let mut entries: serde_json::Value = + serde_json::from_slice(&fs::read(®istry).unwrap()).unwrap(); + let installed_root = PathBuf::from(entries[0]["plugin_root"].as_str().unwrap()); + let component = installed_root + .parent() + .unwrap() + .parent() + .unwrap() + .file_name() + .unwrap(); + let legacy_root = config_home.join("herdr-dev/plugins/github").join(component); + fs::create_dir_all(legacy_root.parent().unwrap()).unwrap(); + fs::rename(&installed_root, &legacy_root).unwrap(); + entries[0]["plugin_root"] = serde_json::json!(legacy_root); + entries[0]["manifest_path"] = serde_json::json!(legacy_root.join("herdr-plugin.toml")); + entries[0]["source"]["managed_path"] = serde_json::json!(legacy_root); + fs::write(®istry, serde_json::to_vec(&entries).unwrap()).unwrap(); + + let alpha = spawn_named_server(&config_home, &runtime_dir, "alpha"); + let beta = spawn_named_server(&config_home, &runtime_dir, "beta"); + for name in ["alpha", "beta"] { + wait_for_socket( + &named_session_socket(&config_home, name), + Duration::from_secs(5), + ); + } + let socket = named_session_socket(&config_home, "alpha"); + run_cli_json( + &socket, + &[ + "workspace", + "create", + "--cwd", + base.to_str().unwrap(), + "--focus", + ], + ); + let current_root = || { + let listed = run_named_cli_json( + &config_home, + &runtime_dir, + &["--session", "beta", "plugin", "list", "--json"], + ); + PathBuf::from( + listed["result"]["plugins"][0]["plugin_root"] + .as_str() + .unwrap(), + ) + }; + let launch = || { + let action = run_named_cli_json( + &config_home, + &runtime_dir, + &[ + "--session", + "beta", + "plugin", + "action", + "invoke", + "read", + "--plugin", + "example.live", + ], + ); + assert_eq!(action["result"]["type"], "plugin_action_invoked"); + let pane = run_cli_json( + &socket, + &[ + "plugin", + "pane", + "open", + "--plugin", + "example.live", + "--entrypoint", + "read", + ], + ); + assert_eq!(pane["result"]["type"], "plugin_pane_opened"); + }; + let wait_file = |path: &Path| { + let deadline = Instant::now() + Duration::from_secs(10); + while !path.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(path.exists(), "missing {}", path.display()); + }; + let old_root = current_root(); + launch(); + for kind in ["action", "pane"] { + wait_file(&old_root.join(format!("ready-{kind}"))); + } + fs::write(source_repo.join("version"), "new").unwrap(); + run_git(&source_repo, &["add", "."]); + run_git(&source_repo, &["commit", "--quiet", "-m", "new plugin"]); + let update = run_named_cli_with_env( + &config_home, + &runtime_dir, + &["--session", "alpha", "plugin", "update", "--yes"], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!( + update.status.success(), + "{}", + String::from_utf8_lossy(&update.stderr) + ); + let new_root = current_root(); + assert_ne!(old_root, new_root); + launch(); + for root in [&old_root, &new_root] { + fs::write(root.join("release"), "").unwrap(); + for kind in ["action", "pane"] { + wait_file(&root.join(format!("result-{kind}"))); + } + } + for kind in ["action", "pane"] { + assert_eq!( + fs::read_to_string(old_root.join(format!("result-{kind}"))).unwrap(), + "old:old" + ); + assert_eq!( + fs::read_to_string(new_root.join(format!("result-{kind}"))).unwrap(), + "new:new" + ); + } + + // Fail registry activation after the build, preserving the working version + // and both running consumers' directories without a restore operation. + let registry = config_home.join("herdr-dev/plugins.json"); + let original_registry = fs::read(®istry).unwrap(); + let block_write = format!("mkdir '{}'", registry.with_extension("json.tmp").display()); + fs::write( + source_repo.join("herdr-plugin.toml"), + format!( + "{manifest}\n[[build]]\ncommand = {}\n", + serde_json::to_string(&["sh", "-c", block_write.as_str()]).unwrap() + ), + ) + .unwrap(); + run_git(&source_repo, &["add", "."]); + run_git( + &source_repo, + &["commit", "--quiet", "-m", "fail activation"], + ); + let failed = run_named_cli_with_env( + &config_home, + &runtime_dir, + &["--session", "alpha", "plugin", "update", "--yes"], + &[("GIT_CONFIG_GLOBAL", &git_config)], + ); + assert!(!failed.status.success()); + assert!(String::from_utf8_lossy(&failed.stderr).contains("plugin files retained at")); + assert_eq!(fs::read(®istry).unwrap(), original_registry); + assert_eq!(current_root(), new_root); + assert_eq!(fs::read_to_string(old_root.join("version")).unwrap(), "old"); + assert_eq!(fs::read_to_string(new_root.join("version")).unwrap(), "new"); + for name in ["alpha", "beta"] { + let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", name]); + } + drop((alpha, beta)); + cleanup_test_base(&base); +} + #[test] fn plugin_link_list_unlink_cli_smoke_test() { let base = unique_test_dir(); @@ -539,8 +1125,8 @@ command = ["sh", "-c", "echo bootstrap"] String::from_utf8_lossy(&uninstall.stderr) ); assert!( - !managed_path.exists(), - "managed checkout should be deleted on uninstall" + managed_path.exists(), + "uninstall must preserve files used by running plugins" ); let listed = run_named_cli_json( @@ -640,7 +1226,9 @@ command = ["sh", "-c", "echo should-not-install"] assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); assert!( - path_missing_or_empty(&managed_github_plugin_dir(&config_home)), + fs::read_dir(managed_github_plugin_dir(&config_home)) + .unwrap() + .all(|plugin| path_missing_or_empty(&plugin.unwrap().path())), "failed build should not leave managed checkouts" ); @@ -833,383 +1421,11 @@ EOF assert!(listed["result"]["plugins"].as_array().unwrap().is_empty()); assert!( - path_missing_or_empty(&managed_github_plugin_dir(&config_home)), + fs::read_dir(managed_github_plugin_dir(&config_home)) + .unwrap() + .all(|plugin| path_missing_or_empty(&plugin.unwrap().path())), "manifest mutation should not leave managed checkouts" ); cleanup_test_base(&base); } - -#[test] -fn plugin_install_restores_previous_checkout_when_registration_fails() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.2.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo new"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - fs::create_dir_all(&managed_checkout).unwrap(); - fs::write(managed_checkout.join("old-marker"), "old checkout\n").unwrap(); - - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, first_line) = accept_fake_cli_operation(&listener); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - assert_eq!(first_request["method"], "plugin.list"); - writeln!( - first, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_list", - "plugins": [{ - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": { - "kind": "github", - "owner": "ogulcancelik", - "repo": "herdr-plugin-examples", - "subdir": "worktree-bootstrap", - "resolved_commit": "old", - "managed_path": managed_checkout_for_server.display().to_string(), - "installed_unix_ms": 1 - } - }] - } - }) - ) - .unwrap(); - first.flush().unwrap(); - - let (mut second, second_line) = accept_fake_cli_operation(&listener); - let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); - assert_eq!(second_request["method"], "plugin.link"); - second - .write_all( - br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced failure"}}"#, - ) - .unwrap(); - second.write_all(b"\n").unwrap(); - second.flush().unwrap(); - }); - - let install = run_named_cli_with_env_and_socket_override( - &config_home, - &runtime_dir, - &[ - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - Some(&socket_path), - ); - assert!( - !install.status.success(), - "install should fail when plugin.link fails" - ); - server.join().unwrap(); - assert!( - managed_checkout.join("old-marker").exists(), - "old checkout should be restored after registration failure" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_rejects_server_that_drops_source_metadata() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo install"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, first_line) = accept_fake_cli_operation(&listener); - let first_request: serde_json::Value = serde_json::from_str(&first_line).unwrap(); - assert_eq!(first_request["method"], "plugin.list"); - first - .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#) - .unwrap(); - first.write_all(b"\n").unwrap(); - first.flush().unwrap(); - - let (mut second, second_line) = accept_fake_cli_operation(&listener); - let second_request: serde_json::Value = serde_json::from_str(&second_line).unwrap(); - assert_eq!(second_request["method"], "plugin.link"); - writeln!( - second, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_linked", - "plugin": { - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": {"kind": "local"} - } - } - }) - ) - .unwrap(); - second.flush().unwrap(); - - let (mut third, third_line) = accept_fake_cli_operation(&listener); - let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); - assert_eq!(third_request["method"], "plugin.unlink"); - assert_eq!( - third_request["params"]["plugin_id"], - "example.worktree-bootstrap" - ); - third - .write_all( - br#"{"id":"cli:plugin","result":{"type":"plugin_unlinked","plugin_id":"example.worktree-bootstrap","removed":true}}"#, - ) - .unwrap(); - third.write_all(b"\n").unwrap(); - third.flush().unwrap(); - }); - - let install = run_named_cli_with_env_and_socket_override( - &config_home, - &runtime_dir, - &[ - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - Some(&socket_path), - ); - assert!( - !install.status.success(), - "install should fail when server drops GitHub source metadata" - ); - server.join().unwrap(); - assert!( - !managed_checkout.exists(), - "new checkout should be removed after incompatible plugin.link response" - ); - - cleanup_test_base(&base); -} - -#[test] -fn plugin_install_keeps_checkout_when_incompatible_server_cleanup_fails() { - let base = unique_test_dir(); - let config_home = base.join("config"); - let runtime_dir = base.join("runtime"); - let socket_path = runtime_dir.join("fake-herdr.sock"); - let source_repo = base.join("source-repo"); - let plugin_dir = source_repo.join("worktree-bootstrap"); - fs::create_dir_all(&plugin_dir).unwrap(); - create_committed_repo(&source_repo); - fs::write( - plugin_dir.join("herdr-plugin.toml"), - r#" -id = "example.worktree-bootstrap" -name = "Worktree Bootstrap" -version = "0.1.0" -min_herdr_version = "0.6.10" -platforms = ["linux", "macos", "windows"] - -[[actions]] -id = "bootstrap" -title = "Bootstrap" -command = ["sh", "-c", "echo install"] -"#, - ) - .unwrap(); - run_git( - &source_repo, - &["add", "worktree-bootstrap/herdr-plugin.toml"], - ); - run_git(&source_repo, &["commit", "--quiet", "-m", "add plugin"]); - - fs::create_dir_all(&config_home).unwrap(); - fs::create_dir_all(&runtime_dir).unwrap(); - let managed_checkout = config_home - .join("herdr-dev") - .join("plugins") - .join("github") - .join(WORKTREE_BOOTSTRAP_MANAGED_COMPONENT); - let git_config = base.join("gitconfig"); - fs::write( - &git_config, - format!( - "[url \"file://{}\"]\n insteadOf = https://github.com/ogulcancelik/herdr-plugin-examples.git\n", - source_repo.display() - ), - ) - .unwrap(); - - let listener = UnixListener::bind(&socket_path).unwrap(); - let managed_checkout_for_server = managed_checkout.clone(); - let server = thread::spawn(move || { - let (mut first, _first_line) = accept_fake_cli_operation(&listener); - first - .write_all(br#"{"id":"cli:plugin","result":{"type":"plugin_list","plugins":[]}}"#) - .unwrap(); - first.write_all(b"\n").unwrap(); - first.flush().unwrap(); - - let (mut second, _second_line) = accept_fake_cli_operation(&listener); - writeln!( - second, - "{}", - serde_json::json!({ - "id": "cli:plugin", - "result": { - "type": "plugin_linked", - "plugin": { - "plugin_id": "example.worktree-bootstrap", - "name": "Worktree Bootstrap", - "version": "0.1.0", - "min_herdr_version": "0.6.10", - "manifest_path": managed_checkout_for_server.join("herdr-plugin.toml").display().to_string(), - "plugin_root": managed_checkout_for_server.display().to_string(), - "enabled": true, - "source": {"kind": "local"} - } - } - }) - ) - .unwrap(); - second.flush().unwrap(); - - let (mut third, third_line) = accept_fake_cli_operation(&listener); - let third_request: serde_json::Value = serde_json::from_str(&third_line).unwrap(); - assert_eq!(third_request["method"], "plugin.unlink"); - third - .write_all( - br#"{"id":"cli:plugin","error":{"code":"plugin_registry_save_failed","message":"forced unlink failure"}}"#, - ) - .unwrap(); - third.write_all(b"\n").unwrap(); - third.flush().unwrap(); - }); - - let install = run_named_cli_with_env_and_socket_override( - &config_home, - &runtime_dir, - &[ - "plugin", - "install", - "ogulcancelik/herdr-plugin-examples/worktree-bootstrap", - "--yes", - ], - &[("GIT_CONFIG_GLOBAL", &git_config)], - Some(&socket_path), - ); - assert!( - !install.status.success(), - "install should fail when source metadata is dropped and cleanup fails" - ); - server.join().unwrap(); - assert!( - managed_checkout.exists(), - "checkout should stay when server cleanup fails" - ); - - cleanup_test_base(&base); -} diff --git a/tests/client_mode.rs b/tests/client_mode.rs index b5532317..8843edcf 100644 --- a/tests/client_mode.rs +++ b/tests/client_mode.rs @@ -50,7 +50,7 @@ impl SpawnedHerdr { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + support::stop_spawned_herdr(&mut *self.child); self.close_master(); if let Some(pid) = pid { @@ -125,6 +125,7 @@ fn spawn_client_process_with_args_and_env( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.args(args); cmd.env("HERDR_DISABLE_SOUND", "1"); cmd.env("XDG_STATE_HOME", runtime_dir.join("state")); @@ -185,6 +186,7 @@ fn spawn_server_with_config( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); @@ -435,6 +437,7 @@ fn client_sees_headless_startup_config_diagnostic() { .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", &config_home); cmd.env("XDG_RUNTIME_DIR", &runtime_dir); @@ -1127,7 +1130,7 @@ fn federated_client_starts_without_local_and_survives_its_restart() { || screen_text().contains("local-online") )); - local.child.kill().unwrap(); + support::stop_spawned_herdr(&mut *local.child); local.close_master(); drop(local); assert!( @@ -1225,7 +1228,7 @@ fn federated_client_starts_without_local_and_survives_its_restart() { )); let watermark = output_len(&output); - remote_server.child.kill().unwrap(); + support::stop_spawned_herdr(&mut *remote_server.child); assert!( wait_until(Duration::from_secs(10), Duration::from_millis(20), || { read_output(&output)[watermark..].contains("reconnecting") @@ -2068,6 +2071,7 @@ fn client_receives_notify_on_agent_state_change() { .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", &config_home); cmd.env("XDG_RUNTIME_DIR", &runtime_dir); diff --git a/tests/cross_area.rs b/tests/cross_area.rs index 3e933c95..df97647e 100644 --- a/tests/cross_area.rs +++ b/tests/cross_area.rs @@ -46,7 +46,7 @@ impl SpawnedHerdr { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + support::stop_spawned_herdr(&mut *self.child); self.close_master(); if let Some(pid) = pid { @@ -118,6 +118,7 @@ fn spawn_server_with_path( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_STATE_HOME", runtime_dir.join("state")); cmd.env("XDG_CONFIG_HOME", config_home); @@ -156,6 +157,7 @@ fn spawn_client_process( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("client"); cmd.env("HERDR_DISABLE_SOUND", "1"); cmd.env("XDG_STATE_HOME", runtime_dir.join("state")); diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs index 0bbe94b6..80df00c4 100644 --- a/tests/detach_reattach.rs +++ b/tests/detach_reattach.rs @@ -53,7 +53,7 @@ struct SpawnedHerdr { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + support::stop_spawned_herdr(&mut *self.child); if let Some(pid) = pid { let deadline = Instant::now() + Duration::from_secs(2); @@ -121,6 +121,7 @@ fn spawn_server_with_config( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); diff --git a/tests/fixtures/hermes-installer-process-info-4910.json b/tests/fixtures/hermes-installer-process-info-4910.json new file mode 100644 index 00000000..a4f11100 --- /dev/null +++ b/tests/fixtures/hermes-installer-process-info-4910.json @@ -0,0 +1 @@ +{"id":"cli:pane:process_info","result":{"process_info":{"foreground_process_group_id":30455,"foreground_processes":[{"argv":["/Users/REDACTED/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3","-m","tui_gateway.entry"],"argv0":"python3","cmdline":"/Users/REDACTED/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3 -m tui_gateway.entry","cwd":"/Users/REDACTED/work/repro","name":"python3.14","pid":30781},{"argv":["/Users/REDACTED/.hermes/tools/node-26.7.0-darwin-arm64/bin/node","--expose-gc","/Users/REDACTED/.hermes/hermes-agent/ui-tui/dist/entry.js"],"argv0":"node","cmdline":"/Users/REDACTED/.hermes/tools/node-26.7.0-darwin-arm64/bin/node --expose-gc /Users/REDACTED/.hermes/hermes-agent/ui-tui/dist/entry.js","cwd":"/Users/REDACTED/.hermes/hermes-agent/ui-tui","name":"node","pid":30778},{"argv":["/Users/REDACTED/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3","-I","-c","import os, re, sys\nos.environ.pop('PYTHONHOME', None)\nos.environ.pop('PYTHONPATH', None)\nsys.path.insert(0, '/Users/REDACTED/.hermes/hermes-agent')\nif sys.argv[1:2] == ['--print-runtime-command']: sys.dont_write_bytecode = True\nfrom hermes_constants import get_default_hermes_root\nos.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or str(get_default_hermes_root())\nif sys.argv[1:2] == ['--print-runtime-command']:\n from pathlib import Path\n from hermes_cli._launchers import print_runtime_command\n print_runtime_command(Path('/Users/REDACTED/.hermes/hermes-agent'), sys.argv[2:])\n sys.exit(0)\nimport hermes_bootstrap\nif sys.argv[1:2] == ['--run-module']:\n import runpy\n if len(sys.argv) < 3: sys.exit('hermes: --run-module needs a module')\n module = sys.argv.pop(2)\n del sys.argv[1]\n runpy.run_module(module, run_name='__main__', alter_sys=True)\n sys.exit(0)\nfrom hermes_cli.main import main\nsys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\nsys.exit(main())\n","--profile","sdlc-planner","--skills","grill-with-docs"],"argv0":"python3","cmdline":"/Users/REDACTED/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3 -I -c import os, re, sys\nos.environ.pop('PYTHONHOME', None)\nos.environ.pop('PYTHONPATH', None)\nsys.path.insert(0, '/Users/REDACTED/.hermes/hermes-agent')\nif sys.argv[1:2] == ['--print-runtime-command']: sys.dont_write_bytecode = True\nfrom hermes_constants import get_default_hermes_root\nos.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or str(get_default_hermes_root())\nif sys.argv[1:2] == ['--print-runtime-command']:\n from pathlib import Path\n from hermes_cli._launchers import print_runtime_command\n print_runtime_command(Path('/Users/REDACTED/.hermes/hermes-agent'), sys.argv[2:])\n sys.exit(0)\nimport hermes_bootstrap\nif sys.argv[1:2] == ['--run-module']:\n import runpy\n if len(sys.argv) < 3: sys.exit('hermes: --run-module needs a module')\n module = sys.argv.pop(2)\n del sys.argv[1]\n runpy.run_module(module, run_name='__main__', alter_sys=True)\n sys.exit(0)\nfrom hermes_cli.main import main\nsys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\nsys.exit(main())\n --profile sdlc-planner --skills grill-with-docs","cwd":"/Users/REDACTED/work/repro","name":"python3.14","pid":30455}],"pane_id":"w1N:p6","shell_pid":28709},"type":"pane_process_info"}} diff --git a/tests/host_shutdown.rs b/tests/host_shutdown.rs index f988a52a..9e4f117d 100644 --- a/tests/host_shutdown.rs +++ b/tests/host_shutdown.rs @@ -134,6 +134,7 @@ async fn host_shutdown_saves_layout_before_releasing_delay_lock() { .env_remove("HERDR_SOCKET_PATH") .env("DBUS_SYSTEM_BUS_ADDRESS", address.trim()) .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_STARTUP_CWD") .env_remove("HERDR_SESSION") .env_remove("HERDR_WORKSPACE_ID") .env_remove("HERDR_TAB_ID") diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index 3420940c..7a64b571 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -33,7 +33,7 @@ struct RequestError { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + support::stop_spawned_herdr(&mut *self.child); unregister_spawned_herdr_pid(pid); } } @@ -78,6 +78,7 @@ fn spawn_server_with_env( }) .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); @@ -121,6 +122,7 @@ fn spawn_named_session_server( }) .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); @@ -155,6 +157,7 @@ fn spawn_default_session_server(config_home: &Path, runtime_dir: &Path) -> Spawn }) .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); @@ -196,6 +199,7 @@ fn spawn_server_with_args_and_socket_env( }) .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); if let Some(session_name) = session_name { cmd.arg("--session"); cmd.arg(session_name); diff --git a/tests/multi_client.rs b/tests/multi_client.rs index 04f5ed0f..c0a13a4f 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -41,7 +41,7 @@ struct SpawnedHerdr { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + support::stop_spawned_herdr(&mut *self.child); if let Some(pid) = pid { let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline { @@ -89,6 +89,7 @@ fn spawn_server(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr { }) .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config); cmd.env("XDG_RUNTIME_DIR", runtime); @@ -116,6 +117,7 @@ fn spawn_client(config: &Path, runtime: &Path, api: &Path) -> SpawnedHerdr { }) .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("client"); cmd.env("HERDR_DISABLE_SOUND", "1"); cmd.env("XDG_CONFIG_HOME", config); diff --git a/tests/server_headless.rs b/tests/server_headless.rs index 6bb054ad..2f5cfb31 100644 --- a/tests/server_headless.rs +++ b/tests/server_headless.rs @@ -44,7 +44,7 @@ impl SpawnedHerdr { impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); - let _ = self.child.kill(); + support::stop_spawned_herdr(&mut *self.child); self.close_master(); if let Some(pid) = pid { @@ -123,6 +123,7 @@ fn spawn_server( .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); @@ -248,8 +249,8 @@ fn server_removes_client_socket_on_exit() { wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_file(&client_socket, Duration::from_secs(10)); - // Kill the server. - let _ = spawned.child.kill(); + // Stop the server. + support::stop_spawned_herdr(&mut *spawned.child); spawned.close_master(); let _ = spawned.child.wait(); @@ -356,6 +357,7 @@ fn duplicate_server_start_fails_gracefully() { .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); + support::isolate_herdr_test_process(&mut cmd); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", &config_home); cmd.env("XDG_RUNTIME_DIR", &runtime_dir); @@ -535,3 +537,114 @@ fn no_hello_client_closed_within_five_seconds() { cleanup_spawned_herdr(spawned, base); } + +fn server_log_path(config_home: &Path) -> PathBuf { + let app_dir_name = if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + }; + config_home.join(app_dir_name).join("herdr-server.log") +} + +fn wait_for_exit(child: &mut Box, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if child.try_wait().ok().flatten().is_some() { + return true; + } + thread::sleep(Duration::from_millis(25)); + } + false +} + +fn wait_for_log_line(path: &Path, needle: &str, timeout: Duration) -> String { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let content = fs::read_to_string(path).unwrap_or_default(); + if let Some(line) = content.lines().find(|line| line.contains(needle)) { + return line.to_string(); + } + thread::sleep(Duration::from_millis(25)); + } + panic!( + "log {} did not contain {needle:?}:\n{}", + path.display(), + fs::read_to_string(path).unwrap_or_default() + ); +} + +#[test] +fn server_survives_hangup_and_logs_why_it_stops() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + + let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + let pid = spawned.child.process_id().expect("server pid") as libc::pid_t; + + assert_eq!(unsafe { libc::kill(pid, libc::SIGHUP) }, 0); + // Closing the terminal hangs up the server's whole session too. + spawned.close_master(); + assert!( + !wait_for_exit(&mut spawned.child, Duration::from_millis(500)), + "server must not stop on SIGHUP" + ); + assert!(ping_socket(&api_socket).contains("pong")); + + assert_eq!(unsafe { libc::kill(pid, libc::SIGTERM) }, 0); + assert!( + wait_for_exit(&mut spawned.child, Duration::from_secs(10)), + "server must stop on SIGTERM" + ); + let line = wait_for_log_line( + &server_log_path(&config_home), + "server shutdown initiated", + Duration::from_secs(5), + ); + assert!(line.contains("reason=SIGTERM"), "{line}"); + + cleanup_spawned_herdr(spawned, base); +} + +#[test] +fn server_stop_request_logs_its_caller() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + + let mut spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + + let mut stream = UnixStream::connect(&api_socket).unwrap(); + writeln!( + stream, + r#"{{"id":"stop","method":"server.stop","params":{{}}}}"# + ) + .unwrap(); + let mut response = String::new(); + BufReader::new(stream).read_line(&mut response).unwrap(); + assert!(response.contains("\"ok\""), "{response}"); + + assert!(wait_for_exit(&mut spawned.child, Duration::from_secs(10))); + let line = wait_for_log_line( + &server_log_path(&config_home), + "server shutdown initiated", + Duration::from_secs(5), + ); + let expected = if cfg!(any(target_os = "linux", target_os = "macos")) { + format!("server.stop request from pid {}", std::process::id()) + } else { + "server.stop request".to_string() + }; + assert!(line.contains(&expected), "{line}"); + + cleanup_spawned_herdr(spawned, base); +} diff --git a/tests/session_restore.rs b/tests/session_restore.rs new file mode 100644 index 00000000..c402ce26 --- /dev/null +++ b/tests/session_restore.rs @@ -0,0 +1,226 @@ +//! Session restore must not wait for optional repository metadata. +#![cfg(unix)] + +pub mod support; + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +struct RestoredServer { + child: std::process::Child, + base: PathBuf, +} + +impl Drop for RestoredServer { + fn drop(&mut self) { + let pid = self.child.id(); + let _ = self.child.kill(); + let _ = self.child.wait(); + support::unregister_spawned_herdr_pid(Some(pid)); + support::cleanup_test_base(&self.base); + } +} + +fn restore_request(socket_path: &Path, method: &str) -> serde_json::Value { + let response = restore_request_with_params(socket_path, method, serde_json::json!({})); + assert!(response.get("error").is_none(), "{response}"); + response +} + +fn restore_request_with_params( + socket_path: &Path, + method: &str, + params: serde_json::Value, +) -> serde_json::Value { + let mut stream = UnixStream::connect(socket_path).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + writeln!( + stream, + "{}", + serde_json::json!({"id": "restore", "method": method, "params": params}) + ) + .unwrap(); + let mut line = String::new(); + BufReader::new(stream) + .read_line(&mut line) + .unwrap_or_else(|err| panic!("{method} blocked during session restore: {err}")); + serde_json::from_str(&line).unwrap() +} + +#[test] +fn server_restores_while_git_metadata_is_blocked() { + use std::os::unix::fs::OpenOptionsExt; + + for saved_membership in [false, true] { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let base = PathBuf::from(format!("/tmp/herdr-restore-{}-{nonce}", std::process::id())); + fs::create_dir_all(&base).unwrap(); + let base = fs::canonicalize(base).unwrap(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + let data_dir = config_home.join(if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + }); + let repo = base.join("repo"); + fs::create_dir_all(repo.join(".git/objects")).unwrap(); + fs::create_dir_all(&data_dir).unwrap(); + fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + let config_path = repo.join(".git/config"); + assert!(Command::new("mkfifo") + .arg(&config_path) + .status() + .unwrap() + .success()); + // Keep the FIFO open without writing: metadata reads cannot finish until + // this guard drops, so API readiness must not depend on a lucky delay. + let _blocked_metadata = fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK) + .open(&config_path) + .unwrap(); + let membership = saved_membership.then(|| { + serde_json::json!({ + "key": "saved-repo", "label": "saved-space", "repo_root": repo, + "checkout_path": repo, "is_linked_worktree": false + }) + }); + let healthy = base.join("healthy"); + assert!(Command::new("git") + .args(["init", "--quiet", "--initial-branch=main"]) + .arg(&healthy) + .status() + .unwrap() + .success()); + let healthy_membership = serde_json::json!({ + "key": healthy.join(".git"), "label": "healthy", "repo_root": healthy, + "checkout_path": healthy, "is_linked_worktree": false + }); + let snapshot = serde_json::json!({ + "version": 3, "active": 0, "selected": 0, + "workspaces": [{ + "id": "w1", "custom_name": "restored", "identity_cwd": repo, + "worktree_space": membership, + "public_pane_numbers": {"7": 3}, "public_tab_numbers": [2], + "tabs": [{"layout": {"Pane": 7}, "panes": {"7": {"cwd": repo}}, + "zoomed": false, "focused": 7, "root_pane": 7}] + }, { + "id": "w2", "custom_name": "healthy", "identity_cwd": healthy, + "worktree_space": healthy_membership, + "tabs": [{"layout": {"Pane": 9}, "panes": {"9": {"cwd": healthy}}, + "zoomed": false, "focused": 9, "root_pane": 9}] + }] + }); + fs::write(data_dir.join("session.json"), snapshot.to_string()).unwrap(); + fs::write(data_dir.join("config.toml"), + "onboarding = false\n[terminal]\ndefault_shell = \"/bin/sh\"\n[ui.sidebar.spaces]\nrows = [[\"workspace\"]]\n").unwrap(); + support::register_runtime_dir(&runtime_dir); + let child = Command::new(env!("CARGO_BIN_EXE_herdr")) + .arg("server") + .env("XDG_CONFIG_HOME", &config_home) + .env("XDG_RUNTIME_DIR", &runtime_dir) + .env("HERDR_SOCKET_PATH", &socket_path) + .env("SHELL", "/bin/sh") + .env_remove("HERDR_CLIENT_SOCKET_PATH") + .env_remove("HERDR_STARTUP_CWD") + .env_remove("HERDR_SESSION") + .env_remove("HERDR_ENV") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + support::register_spawned_herdr_pid(Some(child.id())); + let mut herdr = RestoredServer { child, base }; + support::wait_for_socket(&socket_path, Duration::from_secs(5)); + + let workspaces = restore_request(&socket_path, "workspace.list"); + let restored = &workspaces["result"]["workspaces"][0]; + assert_eq!(restored["workspace_id"], "w1"); + assert_eq!(restored["label"], "restored"); + assert_eq!(restored["active_tab_id"], "w1:t2"); + assert_eq!(restored["pane_count"], 1); + if saved_membership { + for (method, params) in [ + ( + "worktree.create", + serde_json::json!({"workspace_id": "w1", "branch": "blocked"}), + ), + ( + "worktree.open", + serde_json::json!({"workspace_id": "w1", "branch": "main"}), + ), + ("worktree.list", serde_json::json!({"workspace_id": "w1"})), + ( + "worktree.remove", + serde_json::json!({"workspace_id": "w1", "force": true}), + ), + ] { + let response = restore_request_with_params(&socket_path, method, params); + assert_eq!( + response["error"]["code"], "worktree_operation_in_progress", + "{method}: {response}" + ); + } + } + if saved_membership { + // A blocked saved membership must not keep unrelated saved + // memberships pending or prevent their worktree operations. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let response = restore_request_with_params( + &socket_path, + "worktree.list", + serde_json::json!({"workspace_id": "w2"}), + ); + if response.get("error").is_none() { + break; + } + assert_eq!(response["error"]["code"], "worktree_operation_in_progress"); + assert!( + std::time::Instant::now() < deadline, + "healthy workspace validation waited for another checkout's blocked metadata" + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + restore_request(&socket_path, "session.snapshot"); + restore_request(&socket_path, "agent.list"); + restore_request(&socket_path, "server.stop"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = herdr.child.try_wait().unwrap() { + assert!(status.success()); + break; + } + assert!( + std::time::Instant::now() < deadline, + "server shutdown waited for blocked Git metadata" + ); + std::thread::sleep(Duration::from_millis(25)); + } + let saved: serde_json::Value = + serde_json::from_slice(&fs::read(data_dir.join("session.json")).unwrap()).unwrap(); + assert_eq!( + saved["workspaces"][0]["identity_cwd"], + snapshot["workspaces"][0]["identity_cwd"] + ); + assert_eq!( + saved["workspaces"][0]["worktree_space"], + snapshot["workspaces"][0]["worktree_space"] + ); + drop(herdr); + } +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index cc29afe9..2b6cf535 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -7,6 +7,8 @@ use std::sync::{Mutex, Once, OnceLock}; use std::thread; use std::time::{Duration, Instant}; +use portable_pty::CommandBuilder; + static PID_REGISTRY: OnceLock>> = OnceLock::new(); static RUNTIME_DIR_REGISTRY: OnceLock>> = OnceLock::new(); static INIT: Once = Once::new(); @@ -24,6 +26,11 @@ const CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT: u32 = 13; const CLIENT_MESSAGE_CLIENT_SHELL_FOCUS: u32 = 18; const CLIENT_MESSAGE_ENDPOINT_CONTROL: u32 = 20; +pub fn isolate_herdr_test_process(command: &mut CommandBuilder) { + command.env_remove("HERDR_STARTUP_CWD"); + command.env_remove("HERDR_SESSION"); +} + pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else { return; @@ -34,6 +41,19 @@ pub fn register_spawned_herdr_pid(pid: Option) { registry.insert(pid); } +/// Asks a spawned herdr process to exit. portable-pty's `kill` sends SIGHUP, +/// which the server deliberately ignores. +pub fn stop_spawned_herdr(child: &mut (dyn portable_pty::Child + Send + Sync)) { + match child.process_id() { + Some(pid) => unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + }, + None => { + let _ = child.kill(); + } + } +} + pub fn unregister_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else { return; diff --git a/tests/windows_server_security.rs b/tests/windows_server_security.rs new file mode 100644 index 00000000..87fe3979 --- /dev/null +++ b/tests/windows_server_security.rs @@ -0,0 +1,262 @@ +#![cfg(windows)] + +use std::io; +use std::mem::size_of; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::os::windows::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::ptr::null_mut; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use interprocess::os::windows::named_pipe::{pipe_mode::Bytes, DuplexPipeStream}; +use interprocess::ConnectWaitMode; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Security::{ + Authorization::ConvertStringSidToSidW, CreateRestrictedToken, GetLengthSid, GetSidSubAuthority, + GetSidSubAuthorityCount, GetTokenInformation, ImpersonateLoggedOnUser, RevertToSelf, + SetTokenInformation, TokenIntegrityLevel, DISABLE_MAX_PRIVILEGE, LUA_TOKEN, SID_AND_ATTRIBUTES, + TOKEN_ADJUST_DEFAULT, TOKEN_DUPLICATE, TOKEN_MANDATORY_LABEL, TOKEN_QUERY, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, OpenProcessToken, CREATE_NO_WINDOW, +}; + +struct TestServer { + child: Option, + directory: PathBuf, +} + +impl Drop for TestServer { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +fn connect(path: &Path) -> io::Result<()> { + let name = format!(r"\\.\pipe\{}", path.display()); + DuplexPipeStream::::connect_by_path_with_wait_mode( + name.as_str(), + ConnectWaitMode::Timeout(Duration::from_millis(500)), + ) + .map(|_| ()) +} + +#[test] +fn server_integrity_requires_startup_consent_on_both_endpoints() { + // Windows integrity SID RIDs and the SE_GROUP_INTEGRITY attribute. + const LOW: u32 = 0x1000; + const MEDIUM: u32 = 0x2000; + const HIGH: u32 = 0x3000; + + let mut raw_token = null_mut(); + assert_ne!( + unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ADJUST_DEFAULT, + &mut raw_token, + ) + }, + 0 + ); + let token = unsafe { OwnedHandle::from_raw_handle(raw_token) }; + let mut buffer = [0usize; 64]; + let mut needed = 0; + assert_ne!( + unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenIntegrityLevel, + buffer.as_mut_ptr().cast(), + std::mem::size_of_val(&buffer) as u32, + &mut needed, + ) + }, + 0 + ); + let label = unsafe { &*buffer.as_ptr().cast::() }; + let integrity = unsafe { + *GetSidSubAuthority( + label.Label.Sid, + u32::from(*GetSidSubAuthorityCount(label.Label.Sid)) - 1, + ) + }; + + let directory = std::env::temp_dir().join(format!( + "herdr-server-integrity-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&directory).unwrap(); + let mut server = TestServer { + child: None, + directory, + }; + let app_dir = if cfg!(debug_assertions) { + "herdr-dev" + } else { + "herdr" + }; + let session_dir = server + .directory + .join(app_dir) + .join("sessions") + .join("integrity-test"); + let endpoints = [ + session_dir.join("herdr.sock"), + session_dir.join("herdr-client.sock"), + ]; + + // Reuse the named session after stopping, proving consent is process-local. + for (configured, allow_unelevated) in [ + (false, false), + (false, true), + (false, false), + (true, false), + (false, false), + ] { + std::fs::create_dir_all(server.directory.join(app_dir)).unwrap(); + let configuration = if configured || allow_unelevated { + format!("[server]\nallow_unelevated_clients = {configured}\n") + } else { + String::new() + }; + std::fs::write( + server.directory.join(app_dir).join("config.toml"), + configuration, + ) + .unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_herdr")); + command.args(["--session", "integrity-test", "server"]); + if allow_unelevated { + command.arg("--allow-unelevated-clients"); + } + command + .env("XDG_CONFIG_HOME", &server.directory) + .env("XDG_STATE_HOME", &server.directory) + .current_dir(&server.directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW); + for name in [ + "HERDR_SOCKET_PATH", + "HERDR_CLIENT_SOCKET_PATH", + "HERDR_CONFIG_PATH", + "HERDR_SESSION", + "HERDR_WORKSPACE", + "HERDR_TAB", + "HERDR_PANE", + "HERDR_TERMINAL", + "HERDR_STARTUP_CWD", + ] { + command.env_remove(name); + } + server.child = Some(command.spawn().expect("start isolated headless server")); + let deadline = Instant::now() + Duration::from_secs(30); + for endpoint in &endpoints { + loop { + let status = server.child.as_mut().unwrap().try_wait().unwrap(); + assert!( + status.is_none(), + "server exited before readiness: {status:?}" + ); + match connect(endpoint) { + Ok(()) => break, + Err(error) => { + assert!( + Instant::now() < deadline, + "{} not ready: {error}", + endpoint.display() + ); + std::thread::sleep(Duration::from_millis(25)); + } + } + } + } + + let mut restricted = null_mut(); + assert_ne!( + unsafe { + CreateRestrictedToken( + token.as_raw_handle(), + DISABLE_MAX_PRIVILEGE | LUA_TOKEN, + 0, + null_mut(), + 0, + null_mut(), + 0, + null_mut(), + &mut restricted, + ) + }, + 0 + ); + let restricted = unsafe { OwnedHandle::from_raw_handle(restricted) }; + + // Exercise high -> medium on elevated runners and medium -> low otherwise. + // Removing administrator groups alone does not lower a token's integrity. + for client_integrity in [MEDIUM, LOW] { + if client_integrity >= integrity { + continue; + } + let sid_text = + widestring::U16CString::from_str(format!("S-1-16-{client_integrity}")).unwrap(); + let mut sid = null_mut(); + assert_ne!( + unsafe { ConvertStringSidToSidW(sid_text.as_ptr(), &mut sid) }, + 0 + ); + let client_label = TOKEN_MANDATORY_LABEL { + Label: SID_AND_ATTRIBUTES { + Sid: sid, + Attributes: 0x20, + }, + }; + let set = unsafe { + SetTokenInformation( + restricted.as_raw_handle(), + TokenIntegrityLevel, + (&client_label as *const TOKEN_MANDATORY_LABEL).cast(), + size_of::() as u32 + GetLengthSid(sid), + ) + }; + let error = io::Error::last_os_error(); + unsafe { LocalFree(sid.cast()) }; + assert_ne!(set, 0, "lower client integrity: {error}"); + + for endpoint in &endpoints { + assert_ne!( + unsafe { ImpersonateLoggedOnUser(restricted.as_raw_handle()) }, + 0 + ); + let connection = connect(endpoint); + assert_ne!(unsafe { RevertToSelf() }, 0); + if integrity >= HIGH + && (configured || allow_unelevated) + && client_integrity == MEDIUM + { + connection.expect("explicit sharing must admit ordinary same-account clients"); + } else { + assert_eq!( + connection.unwrap_err().kind(), + io::ErrorKind::PermissionDenied, + "{}: config={configured}, flag={allow_unelevated}, client integrity={client_integrity}", + endpoint.display() + ); + } + } + } + let child = server.child.as_mut().unwrap(); + child.kill().unwrap(); + child.wait().unwrap(); + server.child = None; + } +} diff --git a/vendor/patches/portable-pty/0004-skip-directories-when-resolving-windows-programs.patch b/vendor/patches/portable-pty/0004-skip-directories-when-resolving-windows-programs.patch new file mode 100644 index 00000000..5297a81c --- /dev/null +++ b/vendor/patches/portable-pty/0004-skip-directories-when-resolving-windows-programs.patch @@ -0,0 +1,90 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers +Date: Wed, 14 Oct 2026 00:00:00 +0000 +Subject: [PATCH] skip directories when resolving Windows programs + +Windows PATH commonly contains directories whose names match a program, for +example `C:\Windows\System32\PowerShell`. `search_path` returned such a +directory because `Path::exists()` is true for directories, so +`CreateProcessW` was asked to execute a directory and failed with os error 5. +Require a regular file for both the exact name and the PATHEXT candidates so +the real executable is found, matching CreateProcessW's own PATH search. + +Herdr issue: https://github.com/herdrdev/herdr/issues/5267 +Vendored base: portable-pty 0.9.0 +--- +diff --git a/vendor/portable-pty/src/cmdbuilder.rs b/vendor/portable-pty/src/cmdbuilder.rs +index e4a9edc7..81ebd723 100644 +--- a/vendor/portable-pty/src/cmdbuilder.rs ++++ b/vendor/portable-pty/src/cmdbuilder.rs +@@ -636,7 +636,7 @@ impl CommandBuilder { + for path in std::env::split_paths(&path) { + // Check for exactly the user's string in this path dir + let candidate = path.join(&exe); +- if candidate.exists() { ++ if candidate.is_file() { + return candidate.into_os_string(); + } + +@@ -648,7 +648,7 @@ impl CommandBuilder { + // doesn't want that + let ext = ext.to_str().expect("PATHEXT entries must be utf8"); + let path = path.join(&exe).with_extension(&ext[1..]); +- if path.exists() { ++ if path.is_file() { + return path.into_os_string(); + } + } +@@ -831,6 +831,52 @@ fn is_cwd_relative_path>(p: P) -> bool { + mod tests { + use super::*; + ++ #[cfg(windows)] ++ #[test] ++ fn windows_search_path_skips_directories() { ++ use std::os::windows::ffi::OsStringExt; ++ ++ let root = std::env::temp_dir().join(format!( ++ "herdr-portable-pty-search-path-{}", ++ std::process::id() ++ )); ++ let shadow_dir = root.join("shadow"); ++ let real_dir = root.join("real"); ++ std::fs::create_dir_all(shadow_dir.join("herdrprobe")).expect("create shadow directory"); ++ std::fs::create_dir_all(shadow_dir.join("herdrprobe.EXE")) ++ .expect("create shadow extension directory"); ++ std::fs::create_dir_all(&real_dir).expect("create real directory"); ++ let source = std::path::PathBuf::from(std::env::var_os("SystemRoot").expect("SystemRoot")) ++ .join("System32") ++ .join("where.exe"); ++ let executable = real_dir.join("herdrprobe.exe"); ++ std::fs::copy(&source, &executable).expect("copy probe executable"); ++ ++ let mut cmd = CommandBuilder::new("herdrprobe"); ++ cmd.env( ++ "PATH", ++ std::env::join_paths([&shadow_dir, &real_dir]).expect("join PATH"), ++ ); ++ cmd.env("PATHEXT", ".EXE"); ++ ++ let (exe, _) = cmd.cmdline().expect("command line"); ++ let nul = exe ++ .iter() ++ .position(|unit| *unit == 0) ++ .expect("nul terminated executable"); ++ let resolved_path = std::path::PathBuf::from(OsString::from_wide(&exe[..nul])); ++ let resolved = resolved_path.canonicalize(); ++ let expected = executable.canonicalize().expect("probe executable"); ++ let _ = std::fs::remove_dir_all(&root); ++ ++ assert_eq!( ++ resolved.expect("resolved executable"), ++ expected, ++ "search_path resolved {:?}", ++ resolved_path ++ ); ++ } ++ + #[cfg(unix)] + #[test] + fn test_cwd_relative() { diff --git a/vendor/portable-pty.patches.md b/vendor/portable-pty.patches.md index 14eae869..09a0bfc9 100644 --- a/vendor/portable-pty.patches.md +++ b/vendor/portable-pty.patches.md @@ -129,3 +129,48 @@ On Windows, also run: ```sh cargo test --manifest-path vendor/portable-pty/Cargo.toml windows_environment_rejects_malformed_entries ``` + +## 0004 skip directories when resolving Windows programs + +status: active + +patch: `vendor/patches/portable-pty/0004-skip-directories-when-resolving-windows-programs.patch` + +herdr issue: https://github.com/herdrdev/herdr/issues/5267 + +upstream discussion: none + +upstream pr: none + +vendored base: `portable-pty 0.9.0` + +local files: + +- `vendor/portable-pty/src/cmdbuilder.rs` + +reason: Windows `PATH` commonly contains a directory whose name matches a +program, such as `C:\Windows\System32\PowerShell`. `search_path` returned that +directory because `Path::exists()` is true for directories, so Herdr asked +`CreateProcessW` to execute a directory and the pane failed to open with +`Access is denied` (os error 5). Requiring `is_file()` for both the exact name +and the PATHEXT candidates skips shadowing directories and finds the real +executable, matching CreateProcessW's own `PATH` search. + +remove when: upstream `portable-pty` skips non-file `PATH` candidates, or Herdr +replaces this launch path. + +verification: + +```sh +python3 -m unittest scripts.test_vendor_portable_pty +``` + +On Windows, run the regression test against the vendored crate. It lives inside +the Herdr workspace but is only a `[patch]` provider, not a workspace member, so +build a standalone copy and mark it as its own workspace root: + +```sh +cp -r vendor/portable-pty "$TMP/portable-pty" +printf '\n[workspace]\n' >> "$TMP/portable-pty/Cargo.toml" +cargo test --manifest-path "$TMP/portable-pty/Cargo.toml" windows_search_path_skips_directories +``` diff --git a/vendor/portable-pty/src/cmdbuilder.rs b/vendor/portable-pty/src/cmdbuilder.rs index e4a9edc7..81ebd723 100644 --- a/vendor/portable-pty/src/cmdbuilder.rs +++ b/vendor/portable-pty/src/cmdbuilder.rs @@ -636,7 +636,7 @@ impl CommandBuilder { for path in std::env::split_paths(&path) { // Check for exactly the user's string in this path dir let candidate = path.join(&exe); - if candidate.exists() { + if candidate.is_file() { return candidate.into_os_string(); } @@ -648,7 +648,7 @@ impl CommandBuilder { // doesn't want that let ext = ext.to_str().expect("PATHEXT entries must be utf8"); let path = path.join(&exe).with_extension(&ext[1..]); - if path.exists() { + if path.is_file() { return path.into_os_string(); } } @@ -831,6 +831,52 @@ fn is_cwd_relative_path>(p: P) -> bool { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn windows_search_path_skips_directories() { + use std::os::windows::ffi::OsStringExt; + + let root = std::env::temp_dir().join(format!( + "herdr-portable-pty-search-path-{}", + std::process::id() + )); + let shadow_dir = root.join("shadow"); + let real_dir = root.join("real"); + std::fs::create_dir_all(shadow_dir.join("herdrprobe")).expect("create shadow directory"); + std::fs::create_dir_all(shadow_dir.join("herdrprobe.EXE")) + .expect("create shadow extension directory"); + std::fs::create_dir_all(&real_dir).expect("create real directory"); + let source = std::path::PathBuf::from(std::env::var_os("SystemRoot").expect("SystemRoot")) + .join("System32") + .join("where.exe"); + let executable = real_dir.join("herdrprobe.exe"); + std::fs::copy(&source, &executable).expect("copy probe executable"); + + let mut cmd = CommandBuilder::new("herdrprobe"); + cmd.env( + "PATH", + std::env::join_paths([&shadow_dir, &real_dir]).expect("join PATH"), + ); + cmd.env("PATHEXT", ".EXE"); + + let (exe, _) = cmd.cmdline().expect("command line"); + let nul = exe + .iter() + .position(|unit| *unit == 0) + .expect("nul terminated executable"); + let resolved_path = std::path::PathBuf::from(OsString::from_wide(&exe[..nul])); + let resolved = resolved_path.canonicalize(); + let expected = executable.canonicalize().expect("probe executable"); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!( + resolved.expect("resolved executable"), + expected, + "search_path resolved {:?}", + resolved_path + ); + } + #[cfg(unix)] #[test] fn test_cwd_relative() {