From 09cbe3bc970bef14a54ad9f2d9ab909c34fdea4e Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 22:06:51 +0200 Subject: [PATCH] feat(termio): port pty, child command spawning, shell integration Ports the parts of Ghostty's IO layer that don't depend on the terminal: - pty.zig: pty pair, window size, mode, child-side setsid/TIOCSCTTY; master is CLOEXEC with IUTF8 set - Command.zig: fork/execvpe with Ghostty's exact child setup (stdio dup2, best-effort chdir, rlimit restore, os/runtime pre-exec hooks, cgroup handling) and exit status decoding - shell_integration.zig for bash/zsh/fish/elvish/nushell plus feature flags; scripts copied verbatim to resources/shell-integration/ - terminfo generator for xterm-ghostty, byte-identical to Zig's encode output (golden file produced with the real Zig code) This crate opts into unsafe_code = "deny" (fork/exec need it) with allow + SAFETY comments on minimal items. Integration left: config-derived option types are local stand-ins; cgroup D-Bus scope creation is a closure hook; Exec/Termio/Thread/ stream_handler wait for ghostty-terminal. Pty now closes both fds on drop (Zig never auto-closed the slave) - to re-check when wiring Exec. Test Plan: - cargo test -p ghostty-termio (52 tests, incl. real pty spawn test expecting "hi\r\n31 97\r\n" from `echo hi; stty size`) --- Cargo.lock | 1239 ++++++++++++++++- crates/ghostty-termio/Cargo.toml | 50 +- crates/ghostty-termio/src/command.rs | 872 ++++++++++++ crates/ghostty-termio/src/lib.rs | 24 + crates/ghostty-termio/src/pty.rs | 303 ++++ .../ghostty-termio/src/shell_integration.rs | 893 ++++++++++++ crates/ghostty-termio/src/terminfo/ghostty.rs | 1184 ++++++++++++++++ crates/ghostty-termio/src/terminfo/mod.rs | 14 + crates/ghostty-termio/src/terminfo/source.rs | 214 +++ .../tests/data/xterm-ghostty.terminfo | 269 ++++ crates/ghostty-termio/tests/pty_command.rs | 100 ++ .../ghostty-termio/tests/terminfo_golden.rs | 34 + docs/rewrite/notes/ghostty-termio-IO1.md | 72 + resources/shell-integration/README.md | 117 ++ .../shell-integration/bash/bash-preexec.sh | 382 +++++ resources/shell-integration/bash/ghostty.bash | 315 +++++ .../elvish/lib/ghostty-integration.elv | 191 +++ .../ghostty-shell-integration.fish | 246 ++++ .../nushell/vendor/autoload/ghostty.nu | 110 ++ resources/shell-integration/zsh/.zshenv | 61 + .../shell-integration/zsh/ghostty-integration | 458 ++++++ 21 files changed, 7120 insertions(+), 28 deletions(-) create mode 100644 crates/ghostty-termio/src/command.rs create mode 100644 crates/ghostty-termio/src/pty.rs create mode 100644 crates/ghostty-termio/src/shell_integration.rs create mode 100644 crates/ghostty-termio/src/terminfo/ghostty.rs create mode 100644 crates/ghostty-termio/src/terminfo/mod.rs create mode 100644 crates/ghostty-termio/src/terminfo/source.rs create mode 100644 crates/ghostty-termio/tests/data/xterm-ghostty.terminfo create mode 100644 crates/ghostty-termio/tests/pty_command.rs create mode 100644 crates/ghostty-termio/tests/terminfo_golden.rs create mode 100644 docs/rewrite/notes/ghostty-termio-IO1.md create mode 100644 resources/shell-integration/README.md create mode 100644 resources/shell-integration/bash/bash-preexec.sh create mode 100644 resources/shell-integration/bash/ghostty.bash create mode 100644 resources/shell-integration/elvish/lib/ghostty-integration.elv create mode 100644 resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish create mode 100644 resources/shell-integration/nushell/vendor/autoload/ghostty.nu create mode 100644 resources/shell-integration/zsh/.zshenv create mode 100644 resources/shell-integration/zsh/ghostty-integration diff --git a/Cargo.lock b/Cargo.lock index 75fa80e..ff84597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -160,6 +181,15 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -172,6 +202,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -184,10 +220,10 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -198,6 +234,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -210,6 +267,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.7.0" @@ -229,12 +295,27 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.8.0" @@ -277,6 +358,20 @@ dependencies = [ "libloading", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -307,6 +402,24 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -316,18 +429,149 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-utils" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.8", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dlib" version = "0.5.3" @@ -337,6 +581,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.19.0" @@ -386,6 +639,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -406,6 +668,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -421,12 +693,35 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.10" @@ -438,6 +733,18 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "freetype-sys" version = "0.23.0" @@ -492,6 +799,28 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -500,7 +829,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -536,9 +865,14 @@ dependencies = [ name = "ghostty-cli" version = "0.1.0" dependencies = [ + "crossterm", + "flate2", "ghostty-config", "ghostty-input", "ghostty-os", + "log", + "ratatui", + "thiserror 2.0.21", ] [[package]] @@ -551,7 +885,7 @@ dependencies = [ "ghostty-vt", "log", "onig", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -573,20 +907,20 @@ name = "ghostty-datastruct" version = "0.1.0" dependencies = [ "half", - "thiserror", + "thiserror 2.0.21", ] [[package]] name = "ghostty-font" version = "0.1.0" dependencies = [ - "bitflags", + "bitflags 2.13.2", "freetype-sys", "ghostty-unicode", "harfbuzz-sys", "log", "png", - "thiserror", + "thiserror 2.0.21", "tiny-skia", "yeslogic-fontconfig-sys", ] @@ -597,7 +931,7 @@ version = "0.1.0" dependencies = [ "ghostty-vt", "indexmap", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -606,10 +940,10 @@ version = "0.1.0" dependencies = [ "gettext-rs", "log", - "nix", + "nix 0.31.3", "rustix", "temp-env", - "thiserror", + "thiserror 2.0.21", "zbus", ] @@ -642,9 +976,13 @@ dependencies = [ name = "ghostty-termio" version = "0.1.0" dependencies = [ - "ghostty-config", "ghostty-os", - "ghostty-terminal", + "libc", + "log", + "nix 0.31.3", + "rustix", + "temp-env", + "thiserror 2.0.21", ] [[package]] @@ -655,10 +993,10 @@ version = "0.1.0" name = "ghostty-vt" version = "0.1.0" dependencies = [ - "base64", + "base64 0.23.1", "log", "memchr", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -692,11 +1030,33 @@ dependencies = [ "windows", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" @@ -710,6 +1070,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.14.2" @@ -717,7 +1083,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.8", ] [[package]] @@ -729,6 +1117,21 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.106" @@ -740,6 +1143,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.21", +] + [[package]] name = "konst" version = "0.2.20" @@ -755,6 +1169,12 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.1" @@ -777,6 +1197,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libz-sys" version = "1.1.29" @@ -789,12 +1215,27 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "locale_config" version = "0.3.0" @@ -823,6 +1264,25 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" +dependencies = [ + "nix 0.31.3", + "windows-sys", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -838,6 +1298,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.9.1" @@ -873,16 +1339,41 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -895,6 +1386,41 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -921,7 +1447,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -939,7 +1465,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "objc2-core-foundation", ] @@ -964,7 +1490,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags", + "bitflags 2.13.2", "libc", "once_cell", "onig_sys", @@ -981,6 +1507,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -991,6 +1526,39 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking" version = "2.2.1" @@ -1020,6 +1588,102 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pest" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b568374ba38b33a6c627141f891faf16902b08d2db26b8ede1bcb0a15b1919fa" +dependencies = [ + "memchr", + "psm", + "stacker", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66e184b924cebaaff20ab2256ca52f12332d528a39aa76553b5d96f92aacf7f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87478d267e4de54a626af9754f2f0f58e927aac6ed0575fe89bc05ad6851694" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "pest_meta" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f986f248b4241ac359b831f6139aaa34e03b08a37b6caf7e201a33f95c869e1" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1049,7 +1713,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -1070,6 +1734,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1098,6 +1774,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "quote" version = "1.0.47" @@ -1107,19 +1792,141 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -1157,13 +1964,22 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -1176,12 +1992,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1223,6 +2051,17 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1235,6 +2074,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1251,6 +2111,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + [[package]] name = "slab" version = "0.4.12" @@ -1263,12 +2129,69 @@ version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -1313,19 +2236,115 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1339,6 +2358,27 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tiny-skia" version = "0.12.0" @@ -1426,6 +2466,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -1443,18 +2495,49 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ + "atomic", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -1466,6 +2549,36 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -1511,6 +2624,78 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1661,6 +2846,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "yeslogic-fontconfig-sys" version = "6.0.1" diff --git a/crates/ghostty-termio/Cargo.toml b/crates/ghostty-termio/Cargo.toml index c73d411..2535bc7 100644 --- a/crates/ghostty-termio/Cargo.toml +++ b/crates/ghostty-termio/Cargo.toml @@ -4,10 +4,52 @@ version.workspace = true edition.workspace = true license.workspace = true +# NOTE: the `ghostty-config` and `ghostty-terminal` dependencies were removed +# for now (config is being written concurrently, terminal is in progress). +# TODO(integration): re-add them when Exec/Termio/stream_handler are ported and +# replace the local stand-ins (shell_integration::{Command, ShellIntegrationFeatures}, +# command::{LinuxCgroup, GtkSingleInstance}). [dependencies] -ghostty-config.workspace = true -ghostty-terminal.workspace = true ghostty-os.workspace = true +libc = "0.2.190" +log = "0.4.34" +nix = { version = "0.31.3", features = ["signal"] } +rustix = { version = "1.1.5", features = ["fs", "process", "pty", "termios", "stdio"] } +thiserror = "2.0.21" -[lints] -workspace = true +# Termio forks/execs children and sets up ptys; unsafe is denied by default +# and only allowed on minimal, SAFETY-commented items. +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +todo = "warn" +unwrap_used = "warn" +# Porting a low-level terminal emulator involves a lot of intentional +# numeric casts and long state-machine functions; these pedantic lints are +# noise for this codebase. +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +module_name_repetitions = "allow" +too_many_lines = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +fn_params_excessive_bools = "allow" +match_same_arms = "allow" +unreadable_literal = "allow" +many_single_char_names = "allow" +doc_markdown = "allow" +must_use_candidate = "allow" +return_self_not_must_use = "allow" +used_underscore_binding = "allow" +large_stack_arrays = "allow" +inline_always = "allow" + +[dev-dependencies] +temp-env = "0.3.6" diff --git a/crates/ghostty-termio/src/command.rs b/crates/ghostty-termio/src/command.rs new file mode 100644 index 0000000..d004c3b --- /dev/null +++ b/crates/ghostty-termio/src/command.rs @@ -0,0 +1,872 @@ +//! Command launches sub-processes. This is an alternate implementation to +//! `std::process::Command` (mirroring why the Zig one exists) since we need +//! precise control over what happens between fork and exec (e.g. attaching +//! a shell to a pty, cgroup transitions, best-effort chdir). +//! +//! Port of `Command.zig` (POSIX/Linux only) plus the Linux cgroup pre-exec / +//! post-fork logic from `apprt/gtk/{pre_exec,post_fork}.zig`. +//! +//! Safety: the child side of [`Command::start`] runs between `fork` and `exec` +//! in a possibly multithreaded process. All allocation is done before the +//! fork; the child only performs raw syscalls plus the user supplied +//! `os_pre_exec` / `rt_pre_exec` hooks (which must therefore be +//! async-signal-safe, like their Zig counterparts are expected to be). + +use std::{ + collections::HashMap, + ffi::{CString, NulError, c_char}, + os::fd::OwnedFd, + panic::{AssertUnwindSafe, catch_unwind}, + time::{Duration, Instant}, +}; + +use ghostty_os::file::Rlimit; +use rustix::{ + io::Errno, + process::{self, WaitOptions}, + stdio, +}; + +/// Function executed /in the child process/ after the fork, but before +/// exec'ing the command. If the function returns `Some(code)`, the child +/// process will be exited with that exit code. +/// +/// Must be async-signal-safe (see module docs). +pub type PreExecFn = Box Option + Send + Sync>; + +/// Allowable set of errors that can be returned by a post fork function. Any +/// errors will result in the failure to create the surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("post fork hook failed")] +pub struct PostForkError; + +/// Function executed /in the parent process/ after the fork. It receives the +/// command (the pid is already set). +pub type PostForkFn = Box Result<(), PostForkError> + Send + Sync>; + +/// Errors from [`Command::start`] (all happen in the parent; the child never +/// returns from `start`, it either execs or exits like Zig's +/// `ExecFailedInChild` handling). +#[derive(Debug, thiserror::Error)] +pub enum StartError { + #[error("argument or environment contains an interior NUL byte")] + InvalidArgument(#[from] NulError), + #[error("fork failed: {0}")] + Fork(Errno), + #[error(transparent)] + PostFork(#[from] PostForkError), +} + +/// The various methods a process may exit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Exit { + /// Exited by normal exit call, value is exit status + Exited(u8), + + /// Exited by a signal, value is the signal + Signal(u32), + + /// Exited by a stop signal, value is signal + Stopped(u32), + + /// Unknown exit reason, value is the status from waitpid + Unknown(u32), +} + +impl Exit { + /// Decode a raw `waitpid` status (`W.IFEXITED` etc). + pub fn init(status: u32) -> Exit { + let low = status & 0x7f; + if low == 0 { + Exit::Exited(((status >> 8) & 0xff) as u8) + } else if (((low + 1) as u8 as i8) >> 1) > 0 { + Exit::Signal(low) + } else if (status & 0xff) == 0x7f { + Exit::Stopped((status >> 8) & 0xff) + } else { + Exit::Unknown(status) + } + } +} + +/// Mirrors `config.Command`'s relevant enum `LinuxCgroup`. +/// TODO(integration): replace with the ghostty-config type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LinuxCgroup { + Never, + Always, + #[default] + SingleInstance, +} + +/// TODO(integration): replace with the ghostty-config type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GtkSingleInstance { + False, + True, + #[default] + Detect, +} + +/// Configuration information needed by the apprt pre exec function +/// (`apprt/gtk/pre_exec.zig` `PreExecInfo`). Trivially copyable. +/// TODO(integration): build from `ghostty-config::Config` (`init(cfg)`). +#[derive(Debug, Clone, Copy, Default)] +pub struct RtPreExecInfo { + pub gtk_single_instance: GtkSingleInstance, + pub linux_cgroup: LinuxCgroup, + pub linux_cgroup_hard_fail: bool, +} + +/// Configuration information needed by the apprt post fork function +/// (`apprt/gtk/post_fork.zig` `PostForkInfo`). +/// TODO(integration): build from `ghostty-config::Config` (`init(cfg)`). +#[derive(Debug, Clone, Copy, Default)] +pub struct RtPostForkInfo { + pub gtk_single_instance: GtkSingleInstance, + pub linux_cgroup: LinuxCgroup, + pub linux_cgroup_hard_fail: bool, + pub linux_cgroup_memory_limit: Option, + pub linux_cgroup_processes_limit: Option, +} + +/// A command to launch. +#[derive(Default)] +pub struct Command { + /// Path to the command to run. This doesn't have to be an absolute path, + /// because we use exec functions that search the PATH, if necessary. + pub path: String, + + /// Command-line arguments. It is the responsibility of the caller to set + /// args[0] to the command. If args is empty then args[0] will + /// automatically be set to equal path. + pub args: Vec, + + /// Environment variables for the child process. If this is `None`, + /// inherits the environment variables from this process. These are the + /// exact environment variables to set; these are /not/ merged. + pub env: Option>, + + /// Working directory to change to in the child process. If not set, the + /// working directory of the calling process is preserved. Failure to + /// change directory is ignored (as in Ghostty). + pub cwd: Option, + + /// The file handle to set for stdin/out/err. If this isn't set, we do + /// nothing explicitly so it is up to the behavior of the operating system. + /// (The fds are duplicated onto 0/1/2 in the child; the caller keeps its + /// own handles by passing `try_clone()`d fds.) + pub stdin: Option, + pub stdout: Option, + pub stderr: Option, + + /// Rlimits to restore in the child (Ghostty raises RLIMIT_NOFILE at + /// startup; replaces `global_state.rlimits.restore()`). + /// TODO(integration): fill from the global state. + pub restore_rlimit: Option, + + /// Executed /in the child process/ after fork but before exec. Useful to + /// setup some state in the child before the exec process takes over, such + /// as signal handlers, setsid, setuid, etc. + pub os_pre_exec: Option, + + /// Same as `os_pre_exec` but runs afterwards and is meant for the apprt + /// (e.g. [`linux_cgroup_pre_exec`]). + pub rt_pre_exec: Option, + + /// Executed /in the parent process/ after the fork. + pub rt_post_fork: Option, + + /// Process ID is set after start is called. + pub pid: Option, +} + +impl std::fmt::Debug for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Command") + .field("path", &self.path) + .field("args", &self.args) + .field("cwd", &self.cwd) + .field("pid", &self.pid) + .finish_non_exhaustive() + } +} + +/// Everything that must be allocated before forking. +struct Prepared { + path: CString, + argv: Vec, + envp: Vec, +} + +impl Command { + pub fn new(path: impl Into, args: &[&str]) -> Self { + Self { + path: path.into(), + args: args.iter().map(|s| (*s).to_owned()).collect(), + ..Self::default() + } + } + + fn prepare(&self) -> Result { + let path = CString::new(self.path.as_str())?; + let argv = if self.args.is_empty() { + vec![path.clone()] + } else { + self.args + .iter() + .map(|a| CString::new(a.as_str())) + .collect::>()? + }; + let envp = match &self.env { + Some(map) => map + .iter() + .map(|(k, v)| CString::new(format!("{k}={v}"))) + .collect::>()?, + // Inherit: snapshot our environment before the fork (nothing may + // allocate in the child). + None => std::env::vars_os() + .map(|(k, v)| { + let mut b = k.into_encoded_bytes(); + b.push(b'='); + b.extend_from_slice(v.as_encoded_bytes()); + CString::new(b) + }) + .collect::>()?, + }; + Ok(Prepared { path, argv, envp }) + } + + /// Start the subprocess. This returns immediately once the child is + /// started. + /// + /// After this is successful, `self.pid` is available. + pub fn start(&mut self) -> Result<(), StartError> { + // IMPORTANT: do all allocation prior to the fork(). + let prep = self.prepare()?; + let cwd = self.cwd.as_deref().and_then(|c| CString::new(c).ok()); + + let mut argv_ptrs: Vec<*const c_char> = prep.argv.iter().map(|c| c.as_ptr()).collect(); + argv_ptrs.push(std::ptr::null()); + let mut envp_ptrs: Vec<*const c_char> = prep.envp.iter().map(|c| c.as_ptr()).collect(); + envp_ptrs.push(std::ptr::null()); + + let pid = fork().map_err(StartError::Fork)?; + if pid != 0 { + // Parent, return immediately. + self.pid = Some(pid); + if let Some(f) = &self.rt_post_fork { + f(self)?; + } + return Ok(()); + } + + // We are the child. This never returns. + self.run_child(&prep, &argv_ptrs, &envp_ptrs, cwd.as_deref()) + } + + /// The child side of `start`: set up fds, chdir, rlimits, hooks, exec. + fn run_child( + &self, + prep: &Prepared, + argv: &[*const c_char], + envp: &[*const c_char], + cwd: Option<&std::ffi::CStr>, + ) -> ! { + // Setup our file descriptors for std streams. Any failure here is + // equivalent to Zig's `ExecFailedInChild` -> the child exits 1. + if let Some(fd) = &self.stdin + && setup_fd(|f| stdio::dup2_stdin(f), fd).is_err() + { + exit_immediately(1); + } + if let Some(fd) = &self.stdout + && setup_fd(|f| stdio::dup2_stdout(f), fd).is_err() + { + exit_immediately(1); + } + if let Some(fd) = &self.stderr + && setup_fd(|f| stdio::dup2_stderr(f), fd).is_err() + { + exit_immediately(1); + } + + // Setup our working directory. This can fail if we don't have + // permission to go to this directory or if due to race conditions it + // doesn't exist or any various other reasons. We don't want to crash + // the entire process if this fails so we ignore it. We don't log + // because that'll show up in the output. + if let Some(cwd) = cwd { + let _ = process::chdir(cwd); + } + + // Restore any rlimits that were set by Ghostty. This might fail but + // any failures are ignored (its best effort). + if let Some(lim) = self.restore_rlimit { + ghostty_os::restore_max_files(lim); + } + + // If there are pre exec callbacks, call them now. + for hook in [&self.os_pre_exec, &self.rt_pre_exec].into_iter().flatten() { + match catch_unwind(AssertUnwindSafe(hook)) { + Ok(Some(code)) => exit_immediately(code), + Ok(None) => {} + Err(_) => exit_immediately(1), + } + } + + // Finally, replace our process. + // Note: we must use the "p"-variant of exec here because we do not + // guarantee our command is looked up already in the path. + let err = execvpe(prep, argv, envp); + + // If we are executing this code, the exec failed. We're in the child + // process so there isn't much we can do. We try to output something + // reasonable, then exit (Zig returns ExecFailedInChild which the + // caller turns into exit(1)). + if err == Errno::NOENT { + write_stderr( + b"Requested executable not found. Please verify the command is on\n\ + the PATH and try again.\n", + ); + } else { + let mut buf = [0u8; 96]; + let mut n = 0; + let prefix = b"exec syscall failed with unexpected error: errno="; + buf[..prefix.len()].copy_from_slice(prefix); + n += prefix.len(); + n += format_u32(err.raw_os_error() as u32, &mut buf[n..]); + buf[n] = b'\n'; + n += 1; + write_stderr(&buf[..n]); + } + exit_immediately(1) + } + + /// Wait for the command to exit and return information about how it + /// exited. With `block == false` this still loops until the child has a + /// status to report (see the comment in the body). + pub fn wait(&self, block: bool) -> Result { + let raw = self.pid.expect("wait called before start"); + // SAFETY of unwrap-free construction: pids are nonzero after fork. + let pid = process::Pid::from_raw(raw).ok_or(Errno::SRCH)?; + let opts = if block { + WaitOptions::empty() + } else { + // We specify NOHANG because its not our fault if the process we + // launch for the tty doesn't properly waitpid its children. We + // don't want to hang the terminal over it. When NOHANG is + // specified, waitpid returns "nothing" if the process doesn't + // have a status to report. When that happens, it is as though the + // wait call has not been performed, so we need to keep trying + // until we get a status, otherwise we end up with zombie + // processes. + WaitOptions::NOHANG + }; + loop { + match process::waitpid(Some(pid), opts) { + Ok(Some((_pid, status))) => return Ok(Exit::init(status.as_raw() as u32)), + Ok(None) => std::thread::yield_now(), + Err(Errno::INTR) => {} + Err(e) => return Err(e), + } + } + } +} + +fn setup_fd(dup: impl Fn(&OwnedFd) -> rustix::io::Result<()>, src: &OwnedFd) -> Result<(), Errno> { + // dup2 (like Zig's dup3 with flags=0) leaves the target without + // CLOEXEC: we do NOT want this file descriptor to be closed on exec. + loop { + match dup(src) { + Err(Errno::INTR) => {} + r => return r, + } + } +} + +/// Exit the current (forked child) process immediately without running +/// destructors or atexit handlers (`_exit`). Use this from `os_pre_exec` / +/// `rt_pre_exec` hooks instead of `std::process::exit`. +#[allow(unsafe_code)] +pub fn exit_immediately(code: u8) -> ! { + // SAFETY: _exit is async-signal-safe and has no preconditions. + unsafe { libc::_exit(i32::from(code)) } +} + +#[allow(unsafe_code)] +fn fork() -> Result { + // SAFETY: the child branch (in `Command::start`) only performs + // async-signal-safe operations on memory allocated before the fork and + // then execs or `_exit`s; it never returns into Rust code that could + // allocate or take locks. + let pid = unsafe { libc::fork() }; + if pid < 0 { + Err(Errno::from_raw_os_error( + std::io::Error::last_os_error().raw_os_error().unwrap_or(0), + )) + } else { + Ok(pid) + } +} + +#[allow(unsafe_code)] +fn execvpe(prep: &Prepared, argv: &[*const c_char], envp: &[*const c_char]) -> Errno { + debug_assert!(argv.last().is_some_and(|p| p.is_null())); + debug_assert!(envp.last().is_some_and(|p| p.is_null())); + // SAFETY: path is a valid NUL-terminated string; argv/envp are + // NULL-terminated arrays of pointers into CStrings (owned by `prep`) that + // outlive the call. execvpe only returns on failure. + unsafe { + libc::execvpe(prep.path.as_ptr(), argv.as_ptr(), envp.as_ptr()); + } + Errno::from_raw_os_error(errno_now()) +} + +#[allow(unsafe_code)] +fn errno_now() -> i32 { + // SAFETY: __errno_location returns a valid thread-local pointer. + unsafe { *libc::__errno_location() } +} + +fn write_stderr(msg: &[u8]) { + // Best effort, no allocation. + let _ = rustix::io::write(borrow_stderr(), msg); +} + +#[allow(unsafe_code)] +fn borrow_stderr() -> rustix::fd::BorrowedFd<'static> { + // SAFETY: STDERR_FILENO is valid for the life of the process (or the + // write simply fails with EBADF). + unsafe { rustix::fd::BorrowedFd::borrow_raw(libc::STDERR_FILENO) } +} + +/// Format `v` in decimal into `out`, returning the number of bytes written. +fn format_u32(mut v: u32, out: &mut [u8]) -> usize { + let mut tmp = [0u8; 10]; + let mut i = tmp.len(); + loop { + i -= 1; + tmp[i] = b'0' + (v % 10) as u8; + v /= 10; + if v == 0 { + break; + } + } + let n = tmp.len() - i; + out[..n].copy_from_slice(&tmp[i..]); + n +} + +// --------------------------------------------------------------------------- +// Linux cgroup transitions (apprt/gtk/pre_exec.zig, post_fork.zig, cgroup.zig) +// --------------------------------------------------------------------------- + +/// The name of the transient systemd scope for the given child pid +/// (`apprt/gtk/cgroup.zig` `fmtScope`). +pub fn fmt_scope(pid: u32) -> String { + format!("app-ghostty-surface-transient-{pid}.scope") +} + +/// Wait for the current process' cgroup to end in `expected`. Returns true if +/// seen, false on timeout (250ms). +fn wait_for_cgroup(pid: u32, expected: &str) -> bool { + let start = Instant::now(); + loop { + if start.elapsed() > Duration::from_millis(250) { + return false; + } + if let Some(raw) = ghostty_os::cgroup::current(pid) + && let Some(idx) = raw.rfind('/') + && &raw[idx + 1..] == expected + { + return true; + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +/// If we are expecting to be moved to a transient systemd scope, wait to see +/// if that happens by checking for the correct name of the current cgroup. +/// Wait at most 250ms so that we don't overly delay the soft-fail scenario. +/// +/// If we are configured to hard fail, log an error message and return an +/// exit code if we don't detect the move in time. +/// +/// Intended as an `rt_pre_exec` hook (runs in the child; note that, like the +/// Zig code, it allocates and sleeps). +pub fn linux_cgroup_pre_exec(info: &RtPreExecInfo) -> Option { + match info.linux_cgroup { + LinuxCgroup::Always => {} + LinuxCgroup::Never => return None, + LinuxCgroup::SingleInstance => match info.gtk_single_instance { + GtkSingleInstance::True => {} + GtkSingleInstance::False => return None, + GtkSingleInstance::Detect => { + log::error!("gtk-single-instance is set to detect"); + return Some(127); + } + }, + } + + let pid = process::getpid().as_raw_nonzero().get() as u32; + let expected = fmt_scope(pid); + + if !wait_for_cgroup(pid, &expected) && info.linux_cgroup_hard_fail { + log::error!("transition to new transient systemd scope took too long"); + return Some(127); + } + None +} + +/// If we are configured to do so, tell `systemd` to move the new child PID +/// into a transient `systemd` scope with the configured resource limits. +/// +/// `create_scope(pid, memory_high, tasks_max)` performs the D-Bus call +/// (`cgroup.createScope`); it returns `None` if there is no D-Bus connection +/// and `Some(Err(msg))` on failure. +/// TODO(integration): wire to the apprt-gtk D-Bus connection. +pub fn linux_cgroup_post_fork( + cmd: &Command, + info: &RtPostForkInfo, + create_scope: impl FnOnce(u32, Option, Option) -> Option>, +) -> Result<(), PostForkError> { + match info.linux_cgroup { + LinuxCgroup::Always => {} + LinuxCgroup::Never => return Ok(()), + LinuxCgroup::SingleInstance => match info.gtk_single_instance { + GtkSingleInstance::True => {} + GtkSingleInstance::False => return Ok(()), + GtkSingleInstance::Detect => { + log::error!("gtk-single-instance is set to detect which should be impossible!"); + return Err(PostForkError); + } + }, + } + + let Some(pid) = cmd.pid else { + log::error!("PID of child not known!"); + return Err(PostForkError); + }; + let pid = pid as u32; + let expected = fmt_scope(pid); + log::debug!("beginning transition to transient systemd scope {expected}"); + + match create_scope( + pid, + info.linux_cgroup_memory_limit, + info.linux_cgroup_processes_limit, + ) { + None => { + if info.linux_cgroup_hard_fail { + log::error!("dbus connection required for cgroup isolation, exiting"); + return Err(PostForkError); + } + return Ok(()); + } + Some(Err(err)) => { + if info.linux_cgroup_hard_fail { + log::error!("unable to create transient systemd scope {expected}: {err}"); + return Err(PostForkError); + } + log::warn!("unable to create transient systemd scope {expected}: {err}"); + return Ok(()); + } + Some(Ok(())) => {} + } + + if wait_for_cgroup(pid, &expected) { + log::debug!("transition to transient systemd scope {expected} complete"); + } else { + if info.linux_cgroup_hard_fail { + log::error!("transition to new transient systemd scope {expected} took too long"); + return Err(PostForkError); + } + log::warn!("transition to transient systemd scope {expected} took too long"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs::File, + io::{Read, Seek, SeekFrom}, + sync::Arc, + }; + + use std::os::fd::AsRawFd; + + use ghostty_os::TempDir; + + use super::*; + + fn test_file(td: &TempDir, name: &str) -> File { + std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(td.path().join(name)) + .unwrap() + } + + fn read_all(mut f: File) -> String { + f.seek(SeekFrom::Start(0)).unwrap(); + let mut s = String::new(); + f.read_to_string(&mut s).unwrap(); + s + } + + fn sh(script: &str) -> Command { + Command::new("/bin/sh", &["/bin/sh", "-c", script]) + } + + #[test] + fn create_null_delimited_env_map() { + // The Zig test checks the null-delimited env block; here the + // equivalent is `prepare()`. + let mut cmd = Command::new("/bin/sh", &[]); + cmd.env = Some( + [ + ("HOME", "/home/ifreund"), + ("WAYLAND_DISPLAY", "wayland-1"), + ("DISPLAY", ":1"), + ("DEBUGINFOD_URLS", " "), + ("XCURSOR_SIZE", "24"), + ] + .into_iter() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect(), + ); + let prep = cmd.prepare().unwrap(); + assert_eq!(prep.envp.len(), 5); + for target in [ + "HOME=/home/ifreund", + "WAYLAND_DISPLAY=wayland-1", + "DISPLAY=:1", + "DEBUGINFOD_URLS= ", + "XCURSOR_SIZE=24", + ] { + assert!(prep.envp.iter().any(|v| v.to_str().unwrap() == target)); + } + // Empty args fall back to path as args[0]. + assert_eq!(prep.argv.len(), 1); + } + + fn pre_exec_exit_test(cmd: &mut Command) { + cmd.start().unwrap(); + assert!(cmd.pid.is_some()); + assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(42)); + } + + #[test] + fn os_pre_exec_1() { + let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-v"]); + // This runs in the child, so we can exit and it won't kill the test + // runner. + cmd.os_pre_exec = Some(Box::new(|| exit_immediately(42))); + pre_exec_exit_test(&mut cmd); + } + + #[test] + fn os_pre_exec_2() { + let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-v"]); + cmd.os_pre_exec = Some(Box::new(|| Some(42))); + pre_exec_exit_test(&mut cmd); + } + + #[test] + fn rt_pre_exec_1() { + let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-v"]); + cmd.rt_pre_exec = Some(Box::new(|| exit_immediately(42))); + pre_exec_exit_test(&mut cmd); + } + + #[test] + fn rt_pre_exec_2() { + let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-v"]); + cmd.rt_pre_exec = Some(Box::new(|| Some(42))); + pre_exec_exit_test(&mut cmd); + } + + #[test] + fn rt_post_fork_1() { + let mut cmd = sh("sleep 1"); + cmd.rt_post_fork = Some(Box::new(|_| Err(PostForkError))); + assert!(matches!( + cmd.start(), + Err(StartError::PostFork(PostForkError)) + )); + // Reap the child that was started anyway (like the Zig test leaks it). + if let Some(pid) = cmd.pid.and_then(process::Pid::from_raw) { + let _ = process::kill_process(pid, process::Signal::KILL); + } + let _ = cmd.wait(true); + } + + #[test] + fn redirect_stdout_to_file() { + let td = TempDir::init().unwrap(); + let stdout = test_file(&td, "stdout.txt"); + + let mut cmd = sh("echo hello"); + cmd.stdout = Some(stdout.try_clone().unwrap().into()); + cmd.start().unwrap(); + assert!(cmd.pid.is_some()); + assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0)); + + assert!(!read_all(stdout).is_empty()); + } + + #[test] + fn custom_env_vars() { + let td = TempDir::init().unwrap(); + let stdout = test_file(&td, "stdout.txt"); + + let mut cmd = sh("echo $VALUE"); + cmd.stdout = Some(stdout.try_clone().unwrap().into()); + cmd.env = Some(HashMap::from([("VALUE".to_owned(), "hello".to_owned())])); + cmd.start().unwrap(); + assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0)); + + assert_eq!("hello\n", read_all(stdout)); + } + + #[test] + fn custom_working_directory() { + let td = TempDir::init().unwrap(); + let stdout = test_file(&td, "stdout.txt"); + + let mut cmd = sh("pwd"); + cmd.stdout = Some(stdout.try_clone().unwrap().into()); + cmd.cwd = Some("/tmp".to_owned()); + cmd.start().unwrap(); + assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0)); + + assert_eq!("/tmp\n", read_all(stdout)); + } + + #[test] + fn invalid_cwd_is_ignored() { + let td = TempDir::init().unwrap(); + let stdout = test_file(&td, "stdout.txt"); + + let mut cmd = sh("echo ok"); + cmd.stdout = Some(stdout.try_clone().unwrap().into()); + cmd.cwd = Some("/definitely/not/a/dir".to_owned()); + cmd.start().unwrap(); + assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0)); + assert_eq!("ok\n", read_all(stdout)); + } + + // Validate an exec failure correctly terminates the child (in Zig: + // error.ExecFailedInChild must be handled or a second copy of the process + // keeps running; here the child can never return into the caller). + #[test] + fn posix_fork_handles_execve_failure() { + let td = TempDir::init().unwrap(); + let stdout = test_file(&td, "stdout.txt"); + let stderr = test_file(&td, "stderr.txt"); + + let mut cmd = Command::new("/not/a/binary", &["/not/a/binary", ""]); + cmd.stdout = Some(stdout.try_clone().unwrap().into()); + cmd.stderr = Some(stderr.try_clone().unwrap().into()); + cmd.cwd = Some("/bin".to_owned()); + cmd.start().unwrap(); + assert!(cmd.pid.is_some()); + assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(1)); + assert!(read_all(stderr).starts_with("Requested executable not found.")); + } + + #[test] + fn wait_nonblocking_eventually_reports() { + let mut cmd = sh("exit 3"); + cmd.start().unwrap(); + assert_eq!(cmd.wait(false).unwrap(), Exit::Exited(3)); + } + + #[test] + fn exit_signal_and_decode() { + let mut cmd = sh("kill -TERM $$"); + cmd.start().unwrap(); + assert_eq!(cmd.wait(true).unwrap(), Exit::Signal(15)); + + assert_eq!(Exit::init(0), Exit::Exited(0)); + assert_eq!(Exit::init(42 << 8), Exit::Exited(42)); + assert_eq!(Exit::init(9), Exit::Signal(9)); + assert_eq!(Exit::init((19 << 8) | 0x7f), Exit::Stopped(19)); + } + + #[test] + fn post_fork_sees_pid() { + let seen = Arc::new(std::sync::Mutex::new(None)); + let seen2 = Arc::clone(&seen); + let mut cmd = sh("true"); + cmd.rt_post_fork = Some(Box::new(move |c| { + *seen2.lock().unwrap() = c.pid; + Ok(()) + })); + cmd.start().unwrap(); + assert_eq!(*seen.lock().unwrap(), cmd.pid); + cmd.wait(true).unwrap(); + } + + #[test] + fn stdio_fds_are_not_cloexec_in_child_and_closed_others() { + // The master end of a pty is CLOEXEC so the child must not see it. + let pty = crate::pty::Pty::open(crate::pty::Winsize::default()).unwrap(); + let master = pty.master.as_raw_fd(); + let td = TempDir::init().unwrap(); + let stdout = test_file(&td, "stdout.txt"); + let mut cmd = sh(&format!( + "[ -e /proc/self/fd/{master} ] && echo leaked || echo ok" + )); + cmd.stdout = Some(stdout.try_clone().unwrap().into()); + cmd.start().unwrap(); + cmd.wait(true).unwrap(); + assert_eq!("ok\n", read_all(stdout)); + } + + #[test] + fn cgroup_pre_exec_modes() { + let never = RtPreExecInfo { + linux_cgroup: LinuxCgroup::Never, + ..Default::default() + }; + assert_eq!(linux_cgroup_pre_exec(&never), None); + let detect = RtPreExecInfo::default(); + assert_eq!(linux_cgroup_pre_exec(&detect), Some(127)); + let single_false = RtPreExecInfo { + gtk_single_instance: GtkSingleInstance::False, + ..Default::default() + }; + assert_eq!(linux_cgroup_pre_exec(&single_false), None); + assert_eq!(fmt_scope(1234), "app-ghostty-surface-transient-1234.scope"); + } + + #[test] + fn cgroup_post_fork_modes() { + let mut cmd = sh("true"); + cmd.pid = Some(1); + let never = RtPostForkInfo { + linux_cgroup: LinuxCgroup::Never, + ..Default::default() + }; + assert!(linux_cgroup_post_fork(&cmd, &never, |_, _, _| unreachable!()).is_ok()); + let detect = RtPostForkInfo::default(); + assert!(linux_cgroup_post_fork(&cmd, &detect, |_, _, _| unreachable!()).is_err()); + // No dbus: soft fail ok, hard fail err. + let always = RtPostForkInfo { + linux_cgroup: LinuxCgroup::Always, + ..Default::default() + }; + assert!(linux_cgroup_post_fork(&cmd, &always, |_, _, _| None).is_ok()); + let hard = RtPostForkInfo { + linux_cgroup_hard_fail: true, + ..always + }; + assert!(linux_cgroup_post_fork(&cmd, &hard, |_, _, _| None).is_err()); + assert!(linux_cgroup_post_fork(&cmd, &hard, |_, _, _| Some(Err("x".into()))).is_err()); + } +} diff --git a/crates/ghostty-termio/src/lib.rs b/crates/ghostty-termio/src/lib.rs index 5a45ed8..5ba5cb7 100644 --- a/crates/ghostty-termio/src/lib.rs +++ b/crates/ghostty-termio/src/lib.rs @@ -1 +1,25 @@ //! ghostty-termio: see docs/rewrite/ARCHITECTURE.md for the module map. +//! +//! Ported so far (Linux only): `pty.zig`, `Command.zig`, +//! `termio/shell_integration.zig`, the `terminfo/*` source generator. +//! See `docs/rewrite/notes/ghostty-termio-IO1.md`. +//! +//! This crate may use limited `unsafe` (fork/exec in `command`, a few +//! async-signal-safe libc calls in `pty`); everything else is safe. + +#![cfg_attr( + test, + allow( + clippy::unwrap_used, + clippy::assert_is_empty, + clippy::items_after_statements + ) +)] + +pub mod command; +pub mod pty; +pub mod shell_integration; +pub mod terminfo; + +pub use command::Command; +pub use pty::{Pty, Winsize}; diff --git a/crates/ghostty-termio/src/pty.rs b/crates/ghostty-termio/src/pty.rs new file mode 100644 index 0000000..47b5cd5 --- /dev/null +++ b/crates/ghostty-termio/src/pty.rs @@ -0,0 +1,303 @@ +//! Linux PTY creation and management. This is just a thin layer on top +//! of Linux syscalls. Port of `pty.zig` (POSIX variant only; the Windows +//! and null variants are out of scope). + +use std::os::fd::{AsRawFd, OwnedFd, RawFd}; + +use rustix::{fs, process, pty, termios}; + +/// Redeclare the winsize struct so we can just use a Rust struct. The +/// defaults on this are some reasonable screen size but you should probably +/// not use them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Winsize { + pub ws_row: u16, + pub ws_col: u16, + pub ws_xpixel: u16, + pub ws_ypixel: u16, +} + +impl Default for Winsize { + fn default() -> Self { + Self { + ws_row: 100, + ws_col: 80, + ws_xpixel: 800, + ws_ypixel: 600, + } + } +} + +impl From for termios::Winsize { + fn from(w: Winsize) -> Self { + Self { + ws_row: w.ws_row, + ws_col: w.ws_col, + ws_xpixel: w.ws_xpixel, + ws_ypixel: w.ws_ypixel, + } + } +} + +impl From for Winsize { + fn from(w: termios::Winsize) -> Self { + Self { + ws_row: w.ws_row, + ws_col: w.ws_col, + ws_xpixel: w.ws_xpixel, + ws_ypixel: w.ws_ypixel, + } + } +} + +/// The modes of a pty. Not all of these modes are supported on +/// all platforms but all platforms share the same mode struct. +/// +/// The default values of fields in this struct are set to the +/// most typical values for a pty. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Mode { + /// ICANON on POSIX + pub canonical: bool, + + /// ECHO on POSIX + pub echo: bool, +} + +impl Default for Mode { + fn default() -> Self { + Self { + canonical: true, + echo: true, + } + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("openpty failed")] + OpenptyFailed, + #[error("get mode failed")] + GetModeFailed, + #[error("ioctl failed")] + IoctlFailed, + #[error("operation not supported")] + OperationNotSupported, + #[error("setsid failed")] + ProcessGroupFailed, + #[error("setting the controlling terminal failed")] + SetControllingTerminalFailed, +} + +/// A pty master/slave pair. +/// +/// Deviation from Zig: both fds are `OwnedFd` and are closed on drop (the Zig +/// struct never closes the slave automatically). To close the slave early +/// (as Exec does after the fork) use [`Pty::close_slave`] or destructure. +#[derive(Debug)] +pub struct Pty { + pub master: OwnedFd, + /// `None` once closed with [`Pty::close_slave`]. + pub slave: Option, +} + +impl Pty { + /// Open a new PTY with the given initial size. + pub fn open(size: Winsize) -> Result { + // Equivalent of glibc openpty(): the master is CLOEXEC (Ghostty sets + // this explicitly: only the slave fd should be inherited by the + // child), the slave is not. + let master = pty::openpt( + pty::OpenptFlags::RDWR | pty::OpenptFlags::NOCTTY | pty::OpenptFlags::CLOEXEC, + ) + .map_err(|_| Error::OpenptyFailed)?; + pty::grantpt(&master).map_err(|_| Error::OpenptyFailed)?; + pty::unlockpt(&master).map_err(|_| Error::OpenptyFailed)?; + let name = pty::ptsname(&master, Vec::new()).map_err(|_| Error::OpenptyFailed)?; + let slave = fs::open( + name.as_c_str(), + fs::OFlags::RDWR | fs::OFlags::NOCTTY, + fs::Mode::empty(), + ) + .map_err(|_| Error::OpenptyFailed)?; + + termios::tcsetwinsize(&master, size.into()).map_err(|_| Error::OpenptyFailed)?; + + // Enable UTF-8 mode. I think this is on by default on Linux but it + // is NOT on by default on macOS so we ensure that it is always set. + let mut attrs = termios::tcgetattr(&master).map_err(|_| Error::OpenptyFailed)?; + attrs.input_modes.insert(termios::InputModes::IUTF8); + termios::tcsetattr(&master, termios::OptionalActions::Now, &attrs) + .map_err(|_| Error::OpenptyFailed)?; + + Ok(Pty { + master, + slave: Some(slave), + }) + } + + /// Close the slave side (the parent does this after forking the child). + pub fn close_slave(&mut self) { + self.slave = None; + } + + /// Get the modes of the pty (read from the master). + pub fn get_mode(&self) -> Result { + let attrs = termios::tcgetattr(&self.master).map_err(|_| Error::GetModeFailed)?; + Ok(Mode { + canonical: attrs.local_modes.contains(termios::LocalModes::ICANON), + echo: attrs.local_modes.contains(termios::LocalModes::ECHO), + }) + } + + /// Return the size of the pty. + pub fn get_size(&self) -> Result { + termios::tcgetwinsize(&self.master) + .map(Into::into) + .map_err(|_| Error::IoctlFailed) + } + + /// Set the size of the pty. + pub fn set_size(&mut self, size: Winsize) -> Result<(), Error> { + termios::tcsetwinsize(&self.master, size.into()).map_err(|_| Error::IoctlFailed) + } + + /// Raw fds for use in the child's pre-exec hook (see [`child_pre_exec`]). + pub fn raw_fds(&self) -> (RawFd, Option) { + ( + self.master.as_raw_fd(), + self.slave.as_ref().map(AsRawFd::as_raw_fd), + ) + } + + /// This should be called prior to exec in the forked child process + /// in order to setup the tty properly. See [`child_pre_exec`]. + pub fn child_pre_exec(&self) -> Result<(), Error> { + let (master, slave) = self.raw_fds(); + child_pre_exec(master, slave.unwrap_or(-1)) + } +} + +/// Signals reset to their default disposition in the child. +const RESET_SIGNALS: [nix::sys::signal::Signal; 13] = { + use nix::sys::signal::Signal as S; + [ + S::SIGABRT, + S::SIGALRM, + S::SIGBUS, + S::SIGCHLD, + S::SIGFPE, + S::SIGHUP, + S::SIGILL, + S::SIGINT, + S::SIGPIPE, + S::SIGSEGV, + S::SIGTRAP, + S::SIGTERM, + S::SIGQUIT, + ] +}; + +/// Child-side setup of the tty: reset signals, `setsid`, `TIOCSCTTY` on the +/// slave and close the master/slave fds. Operates on raw fd numbers so it can +/// be used from a post-fork hook (`Command::os_pre_exec`) without touching +/// any `OwnedFd` destructors. Everything in here is async-signal-safe (no +/// allocation, no locks). +/// +/// A negative `slave` skips the controlling-terminal step and the close. +pub fn child_pre_exec(master: RawFd, slave: RawFd) -> Result<(), Error> { + use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet}; + + // Reset our signals + let sa = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty()); + for sig in RESET_SIGNALS { + reset_signal(sig, &sa); + } + + // Create a new process group + process::setsid().map_err(|_| Error::ProcessGroupFailed)?; + + if slave >= 0 { + // Set controlling terminal + set_controlling_terminal(slave)?; + } + + // Can close master/slave pair now + close_raw(slave); + close_raw(master); + + Ok(()) +} + +#[allow(unsafe_code)] +fn reset_signal(sig: nix::sys::signal::Signal, sa: &nix::sys::signal::SigAction) { + // SAFETY: sigaction(2) is async-signal-safe and SIG_DFL installs no + // Rust handler code. + let _ = unsafe { nix::sys::signal::sigaction(sig, sa) }; +} + +#[allow(unsafe_code)] +fn set_controlling_terminal(slave: RawFd) -> Result<(), Error> { + use std::os::fd::BorrowedFd; + // SAFETY: the fd is owned by the caller and stays open for the duration of + // this call; we only borrow it for one ioctl. + let fd = unsafe { BorrowedFd::borrow_raw(slave) }; + process::ioctl_tiocsctty(fd).map_err(|err| { + log::error!("error setting controlling terminal errno={err:?}"); + Error::SetControllingTerminalFailed + }) +} + +#[allow(unsafe_code)] +fn close_raw(fd: RawFd) { + if fd < 0 { + return; + } + // SAFETY: closing a raw fd number we were told to close; in the child + // the `OwnedFd`s that nominally own these are never dropped (the child + // execs or `_exit`s), so there is no double close. + unsafe { rustix::io::close(fd) }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pty_size() { + let mut ws = Winsize { + ws_row: 50, + ws_col: 80, + ws_xpixel: 1, + ws_ypixel: 1, + }; + + let mut pty = Pty::open(ws).unwrap(); + + // Initialize size should match what we gave it + assert_eq!(ws, pty.get_size().unwrap()); + + // Can set and read new sizes + ws.ws_row *= 2; + pty.set_size(ws).unwrap(); + assert_eq!(ws, pty.get_size().unwrap()); + } + + #[test] + fn default_mode_and_utf8() { + let pty = Pty::open(Winsize::default()).unwrap(); + let mode = pty.get_mode().unwrap(); + assert_eq!(mode, Mode::default()); + let attrs = termios::tcgetattr(&pty.master).unwrap(); + assert!(attrs.input_modes.contains(termios::InputModes::IUTF8)); + } + + #[test] + fn master_is_cloexec_slave_is_not() { + let pty = Pty::open(Winsize::default()).unwrap(); + let m = rustix::io::fcntl_getfd(&pty.master).unwrap(); + assert!(m.contains(rustix::io::FdFlags::CLOEXEC)); + let s = rustix::io::fcntl_getfd(pty.slave.as_ref().unwrap()).unwrap(); + assert!(!s.contains(rustix::io::FdFlags::CLOEXEC)); + } +} diff --git a/crates/ghostty-termio/src/shell_integration.rs b/crates/ghostty-termio/src/shell_integration.rs new file mode 100644 index 0000000..1a4b050 --- /dev/null +++ b/crates/ghostty-termio/src/shell_integration.rs @@ -0,0 +1,893 @@ +//! Shell integration setup. Port of `termio/shell_integration.zig`. +//! +//! The shell scripts live in `resources/shell-integration/` at the workspace +//! root (copied verbatim from `src/shell-integration/`). + +use std::{collections::HashMap, path::Path}; + +use ghostty_os::{env::prepend_env, homedir, shell::ShellCommandBuilder}; + +/// The environment passed to the child (Zig `std.process.EnvMap`). +pub type EnvMap = HashMap; + +/// Shell types we support +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shell { + Bash, + Elvish, + Fish, + Nushell, + Zsh, +} + +impl Shell { + pub const ALL: [Shell; 5] = [ + Shell::Bash, + Shell::Elvish, + Shell::Fish, + Shell::Nushell, + Shell::Zsh, + ]; + + /// The name of the shell (Zig `@tagName`), also the directory name inside + /// `shell-integration/`. + pub fn tag_name(self) -> &'static str { + match self { + Shell::Bash => "bash", + Shell::Elvish => "elvish", + Shell::Fish => "fish", + Shell::Nushell => "nushell", + Shell::Zsh => "zsh", + } + } +} + +/// Local stand-in for `config.Command`. +/// TODO(integration): replace with `ghostty_config::Command`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + /// A shell command line, split with `ArgIteratorGeneral` rules. + Shell(String), + /// Directly executed argv. + Direct(Vec), +} + +impl Command { + /// The arguments of the command. For shell commands this is a best + /// effort parse equivalent to Zig's `std.process.ArgIteratorGeneral(.{})` + /// (whitespace separated, double quotes group, backslashes only special + /// before a quote). + pub fn arg_iterator(&self) -> std::vec::IntoIter { + match self { + Command::Direct(v) => v.clone().into_iter(), + Command::Shell(s) => split_args_general(s).into_iter(), + } + } +} + +/// `std.process.ArgIteratorGeneral(.{})` (Windows command-line rules). +fn split_args_general(cmd_line: &str) -> Vec { + let b = cmd_line.as_bytes(); + let mut i = 0; + let mut out = Vec::new(); + loop { + // Skip whitespace + while i < b.len() && (b[i] == b' ' || b[i] == b'\t') { + i += 1; + } + if i >= b.len() { + return out; + } + let mut buf: Vec = Vec::new(); + let mut backslashes = 0usize; + let mut in_quote = false; + loop { + let c = b.get(i).copied(); + match c { + None => { + buf.extend(std::iter::repeat_n(b'\\', backslashes)); + break; + } + Some(b' ' | b'\t') if !in_quote => { + buf.extend(std::iter::repeat_n(b'\\', backslashes)); + i += 1; + break; + } + Some(b'\\') => { + backslashes += 1; + i += 1; + } + Some(b'"') => { + let real = backslashes.is_multiple_of(2); + buf.extend(std::iter::repeat_n(b'\\', backslashes / 2)); + backslashes = 0; + if real { + in_quote = !in_quote; + } else { + buf.push(b'"'); + } + i += 1; + } + Some(ch) => { + buf.extend(std::iter::repeat_n(b'\\', backslashes)); + backslashes = 0; + buf.push(ch); + i += 1; + } + } + } + out.push(String::from_utf8_lossy(&buf).into_owned()); + } +} + +/// Local stand-in for `config.ShellIntegrationFeatures` (same defaults). +/// TODO(integration): replace with `ghostty_config::ShellIntegrationFeatures`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShellIntegrationFeatures { + pub cursor: bool, + pub sudo: bool, + pub title: bool, + pub ssh_env: bool, + pub ssh_terminfo: bool, + pub path: bool, +} + +impl Default for ShellIntegrationFeatures { + fn default() -> Self { + Self { + cursor: true, + sudo: false, + title: true, + ssh_env: false, + ssh_terminfo: false, + path: true, + } + } +} + +impl ShellIntegrationFeatures { + /// All features disabled (`std.mem.zeroes`). + pub const NONE: Self = Self { + cursor: false, + sudo: false, + title: false, + ssh_env: false, + ssh_terminfo: false, + path: false, + }; +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("unable to determine home directory: {0}")] + Home(#[from] homedir::Error), +} + +/// The result of setting up a shell integration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShellIntegration { + /// The successfully-integrated shell. + pub shell: Shell, + + /// The command to use to start the shell with the integration. + /// In most cases this is identical to the command given but for + /// bash in particular it may be different. + pub command: Command, +} + +/// Set up the command execution environment for automatic +/// integrated shell integration and return a ShellIntegration +/// struct describing the integration. If integration fails +/// (shell type couldn't be detected, etc.), this will return None. +pub fn setup( + resource_dir: &str, + command: &Command, + env: &mut EnvMap, + force_shell: Option, +) -> Result, Error> { + let Some(shell) = force_shell.or_else(|| detect_shell(command)) else { + return Ok(None); + }; + + let new_command = match shell { + Shell::Bash => setup_bash(command, resource_dir, env)?, + Shell::Nushell => setup_nushell(command, resource_dir, env), + Shell::Zsh => setup_zsh(command, resource_dir, env), + Shell::Elvish | Shell::Fish => { + if !setup_xdg_data_dirs(resource_dir, env) { + return Ok(None); + } + Some(command.clone()) + } + }; + + Ok(new_command.map(|command| ShellIntegration { shell, command })) +} + +fn basename(path: &str) -> &str { + // std.fs.path.basename: strips trailing separators. + let trimmed = path.trim_end_matches('/'); + match trimmed.rfind('/') { + Some(i) => &trimmed[i + 1..], + None => trimmed, + } +} + +fn detect_shell(command: &Command) -> Option { + let mut it = command.arg_iterator(); + let arg0 = it.next()?; + let exe = basename(&arg0); + + match exe { + // (The Darwin /bin/bash special case does not apply on Linux.) + "bash" => Some(Shell::Bash), + "elvish" => Some(Shell::Elvish), + "fish" => Some(Shell::Fish), + "nu" => Some(Shell::Nushell), + "zsh" => Some(Shell::Zsh), + _ => None, + } +} + +/// Set up the shell integration features environment variable. +pub fn setup_features(env: &mut EnvMap, features: ShellIntegrationFeatures, cursor_blink: bool) { + // Sorted (case-insensitively) so that the output is deterministic. + let fields: [(&str, bool); 6] = [ + ("cursor", features.cursor), + ("path", features.path), + ("ssh-env", features.ssh_env), + ("ssh-terminfo", features.ssh_terminfo), + ("sudo", features.sudo), + ("title", features.title), + ]; + + let mut buf = String::new(); + for (name, enabled) in fields { + if enabled { + if !buf.is_empty() { + buf.push(','); + } + buf.push_str(name); + + if name == "cursor" { + buf.push_str(if cursor_blink { ":blink" } else { ":steady" }); + } + } + } + + if !buf.is_empty() { + env.insert("GHOSTTY_SHELL_FEATURES".to_owned(), buf); + } +} + +/// Setup the bash automatic shell integration. This works by +/// starting bash in POSIX mode and using the ENV environment +/// variable to load our bash integration script. This prevents +/// bash from loading its normal startup files, which becomes +/// our script's responsibility (along with disabling POSIX +/// mode). +/// +/// This returns a new shell command string that enables the integration +/// or None if integration failed. +fn setup_bash( + command: &Command, + resource_dir: &str, + env: &mut EnvMap, +) -> Result, Error> { + let mut cmd = ShellCommandBuilder::new(); + + // Iterator that yields each argument in the original command line. + let mut iter = command.arg_iterator(); + + // Start accumulating arguments with the executable and initial flags. + match iter.next() { + Some(exe) => cmd.append_arg(&exe), + None => return Ok(None), + } + cmd.append_arg("--posix"); + + // Stores the list of intercepted command line flags that will be passed + // to our shell integration script: --norc --noprofile + // We always include at least "1" so the script can differentiate between + // being manually sourced or automatically injected (from here). + let mut inject = String::from("1"); + + // Walk through the rest of the given arguments. If we see an option that + // would require complex or unsupported integration behavior, we bail out + // and skip loading our shell integration. Users can still manually source + // the shell integration script. + // + // Unsupported options: + // -c -c is always non-interactive + // --posix POSIX mode (a la /bin/sh) + let mut rcfile: Option = None; + while let Some(arg) = iter.next() { + if arg == "--posix" { + return Ok(None); + } else if arg == "--norc" { + inject.push_str(" --norc"); + } else if arg == "--noprofile" { + inject.push_str(" --noprofile"); + } else if arg == "--rcfile" || arg == "--init-file" { + rcfile = iter.next(); + } else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') { + // '-c command' is always non-interactive + if arg.contains('c') { + return Ok(None); + } + cmd.append_arg(&arg); + } else if arg == "-" || arg == "--" { + // All remaining arguments should be passed directly to the shell + // command. We shouldn't perform any further option processing. + cmd.append_arg(&arg); + for remaining in iter.by_ref() { + cmd.append_arg(&remaining); + } + break; + } else { + cmd.append_arg(&arg); + } + } + + // Preserve an existing ENV value. We're about to overwrite it. + if let Some(v) = env.get("ENV").cloned() { + env.insert("GHOSTTY_BASH_ENV".to_owned(), v); + } + + // Set our new ENV to point to our integration script. + let script_path = format!("{resource_dir}/shell-integration/bash/ghostty.bash"); + match std::fs::File::open(&script_path) { + Ok(_) => { + env.insert("ENV".to_owned(), script_path); + } + Err(err) => { + log::warn!("unable to open {script_path}: {err}"); + env.remove("GHOSTTY_BASH_ENV"); + return Ok(None); + } + } + + env.insert("GHOSTTY_BASH_INJECT".to_owned(), inject); + if let Some(v) = rcfile { + env.insert("GHOSTTY_BASH_RCFILE".to_owned(), v); + } + + // In POSIX mode, HISTFILE defaults to ~/.sh_history, so unless we're + // staying in POSIX mode (--posix), change it back to ~/.bash_history. + if !env.contains_key("HISTFILE") + && let Some(home) = homedir::home()? + { + env.insert("HISTFILE".to_owned(), format!("{home}/.bash_history")); + env.insert("GHOSTTY_BASH_UNEXPORT_HISTFILE".to_owned(), "1".to_owned()); + } + + // Return a copy of our modified command line to use as the shell command. + Ok(Some(Command::Shell(cmd.written().to_owned()))) +} + +/// Setup automatic shell integration for shells that include +/// their modules from paths in `XDG_DATA_DIRS` env variable. +/// +/// The shell-integration path is prepended to `XDG_DATA_DIRS`. +/// It is also saved in the `GHOSTTY_SHELL_INTEGRATION_XDG_DIR` variable +/// so that the shell can refer to it and safely remove this directory +/// from `XDG_DATA_DIRS` when integration is complete. +fn setup_xdg_data_dirs(resource_dir: &str, env: &mut EnvMap) -> bool { + // Get our path to the shell integration directory. + let integ_path = format!("{resource_dir}/shell-integration"); + if let Err(err) = std::fs::read_dir(&integ_path) { + log::warn!("unable to open {integ_path}: {err}"); + return false; + } + + // Set an env var so we can remove this from XDG_DATA_DIRS later. + // This happens in the shell integration config itself. We do this + // so that our modifications don't interfere with other commands. + env.insert( + "GHOSTTY_SHELL_INTEGRATION_XDG_DIR".to_owned(), + integ_path.clone(), + ); + + // If no XDG_DATA_DIRS set use the default value as specified. + // This ensures that the default directories aren't lost by setting + // our desired integration dir directly. See #2711. + // + let key = "XDG_DATA_DIRS"; + let current = env + .get(key) + .map_or("/usr/local/share:/usr/share", String::as_str); + let new = prepend_env(current, &integ_path); + env.insert(key.to_owned(), new); + + true +} + +/// Set up automatic Nushell shell integration. This works by adding our +/// shell resource directory to the `XDG_DATA_DIRS` environment variable, +/// which Nushell will use to load `nushell/vendor/autoload/ghostty.nu`. +/// +/// We then add `--execute 'use ghostty ...'` to the nu command line to +/// automatically enable our shell features. +fn setup_nushell(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option { + // Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This + // makes our 'ghostty' module automatically available, even if any + // of the later checks abort the rest of our automatic integration. + if !setup_xdg_data_dirs(resource_dir, env) { + return None; + } + + let mut cmd = ShellCommandBuilder::new(); + let mut iter = command.arg_iterator(); + + // Start accumulating arguments with the executable and initial flags. + cmd.append_arg(&iter.next()?); + + // Tell nu to immediately "use" all of the exported functions in our + // 'ghostty' module. + // + // We can consider making this more specific based on the set of + // enabled shell features (e.g. `use ghostty sudo`). At the moment, + // shell features are all runtime-guarded in the nushell script. + cmd.append_arg("--execute 'use ghostty *'"); + + // Walk through the rest of the given arguments. If we see an option that + // would require complex or unsupported integration behavior, we bail out + // and skip loading our shell integration. Users can still manually source + // the shell integration module. + // + // Unsupported options: + // -c / --command -c is always non-interactive + // --lsp --lsp starts the language server + while let Some(arg) = iter.next() { + if arg == "--command" || arg == "--lsp" { + return None; + } else if arg.len() > 1 && arg.starts_with('-') && !arg[1..].starts_with('-') { + if arg.contains('c') { + return None; + } + cmd.append_arg(&arg); + } else if arg == "-" || arg == "--" { + // All remaining arguments should be passed directly to the shell + // command. We shouldn't perform any further option processing. + cmd.append_arg(&arg); + for remaining in iter.by_ref() { + cmd.append_arg(&remaining); + } + break; + } else { + cmd.append_arg(&arg); + } + } + + // Return a copy of our modified command line to use as the shell command. + Some(Command::Shell(cmd.written().to_owned())) +} + +/// Setup the zsh automatic shell integration. This works by setting +/// ZDOTDIR to our resources dir so that zsh will load our config. This +/// config then loads the true user config. +fn setup_zsh(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option { + // Preserve an existing ZDOTDIR value. We're about to overwrite it. + if let Some(old) = env.get("ZDOTDIR").cloned() { + env.insert("GHOSTTY_ZSH_ZDOTDIR".to_owned(), old); + } + + // Set our new ZDOTDIR to point to our shell resource directory. + let integ_path = format!("{resource_dir}/shell-integration/zsh"); + if !Path::new(&integ_path).is_dir() { + // Mirrors openDirAbsolute failing (missing or not a directory). + log::warn!("unable to open {integ_path}"); + return None; + } + env.insert("ZDOTDIR".to_owned(), integ_path); + + Some(command.clone()) +} + +#[cfg(test)] +mod tests { + use ghostty_os::TempDir; + + use super::*; + + /// Test helper that creates a temporary resources directory with shell + /// integration paths. + struct TmpResourcesDir { + _tmp: TempDir, + path: String, + shell_path: String, + } + + impl TmpResourcesDir { + fn new(shell: Shell) -> Self { + let tmp = TempDir::init().unwrap(); + let path = std::fs::canonicalize(tmp.path()) + .unwrap() + .to_string_lossy() + .into_owned(); + let rel = format!("shell-integration/{}", shell.tag_name()); + std::fs::create_dir_all(format!("{path}/{rel}")).unwrap(); + let shell_path = format!("{path}/{rel}"); + if shell == Shell::Bash { + std::fs::write(format!("{path}/shell-integration/bash/ghostty.bash"), "").unwrap(); + } + Self { + _tmp: tmp, + path, + shell_path, + } + } + } + + fn sh(s: &str) -> Command { + Command::Shell(s.to_owned()) + } + + fn shell_str(c: Option<&Command>) -> &str { + match c.unwrap() { + Command::Shell(s) => s, + Command::Direct(_) => panic!("expected shell"), + } + } + + #[test] + fn force_shell() { + let mut env = EnvMap::new(); + for shell in Shell::ALL { + let res = TmpResourcesDir::new(shell); + let result = setup(&res.path, &sh("sh"), &mut env, Some(shell)).unwrap(); + assert_eq!(shell, result.unwrap().shell); + } + } + + #[test] + fn shell_integration_failure() { + let mut env = EnvMap::new(); + let result = setup("/nonexistent", &sh("sh"), &mut env, None).unwrap(); + assert!(result.is_none()); + assert_eq!(0, env.len()); + } + + #[test] + fn detect_shell_cases() { + assert_eq!(detect_shell(&sh("sh")), None); + assert_eq!(detect_shell(&sh("bash")), Some(Shell::Bash)); + assert_eq!(detect_shell(&sh("elvish")), Some(Shell::Elvish)); + assert_eq!(detect_shell(&sh("fish")), Some(Shell::Fish)); + assert_eq!(detect_shell(&sh("nu")), Some(Shell::Nushell)); + assert_eq!(detect_shell(&sh("zsh")), Some(Shell::Zsh)); + + assert_eq!(detect_shell(&sh("bash -c 'command'")), Some(Shell::Bash)); + assert_eq!(detect_shell(&sh("\"/a b/bash\"")), Some(Shell::Bash)); + assert_eq!(detect_shell(&sh("/usr/bin/zsh")), Some(Shell::Zsh)); + assert_eq!( + detect_shell(&Command::Direct(vec!["/bin/fish".into()])), + Some(Shell::Fish) + ); + assert_eq!(detect_shell(&sh("")), None); + } + + #[test] + fn arg_iterator_shell_and_direct() { + let v: Vec<_> = sh("echo hello world").arg_iterator().collect(); + assert_eq!(v, ["echo", "hello", "world"]); + let v: Vec<_> = Command::Direct(vec!["echo".into(), "hello world".into()]) + .arg_iterator() + .collect(); + assert_eq!(v, ["echo", "hello world"]); + let v: Vec<_> = sh("a \"b c\"\td\\\"e").arg_iterator().collect(); + assert_eq!(v, ["a", "b c", "d\"e"]); + } + + #[test] + fn setup_features_all_variants() { + // all features enabled + let mut env = EnvMap::new(); + setup_features( + &mut env, + ShellIntegrationFeatures { + cursor: true, + sudo: true, + title: true, + ssh_env: true, + ssh_terminfo: true, + path: true, + }, + true, + ); + assert_eq!( + "cursor:blink,path,ssh-env,ssh-terminfo,sudo,title", + env["GHOSTTY_SHELL_FEATURES"] + ); + + // all features disabled + let mut env = EnvMap::new(); + setup_features(&mut env, ShellIntegrationFeatures::NONE, true); + assert!(!env.contains_key("GHOSTTY_SHELL_FEATURES")); + + // mixed features + let mut env = EnvMap::new(); + setup_features( + &mut env, + ShellIntegrationFeatures { + sudo: true, + ssh_env: true, + ..ShellIntegrationFeatures::NONE + }, + true, + ); + assert_eq!("ssh-env,sudo", env["GHOSTTY_SHELL_FEATURES"]); + + // blinking cursor + let only_cursor = ShellIntegrationFeatures { + cursor: true, + ..ShellIntegrationFeatures::NONE + }; + let mut env = EnvMap::new(); + setup_features(&mut env, only_cursor, true); + assert_eq!("cursor:blink", env["GHOSTTY_SHELL_FEATURES"]); + + // steady cursor + let mut env = EnvMap::new(); + setup_features(&mut env, only_cursor, false); + assert_eq!("cursor:steady", env["GHOSTTY_SHELL_FEATURES"]); + } + + #[test] + fn default_features() { + let mut env = EnvMap::new(); + setup_features(&mut env, ShellIntegrationFeatures::default(), true); + assert_eq!("cursor:blink,path,title", env["GHOSTTY_SHELL_FEATURES"]); + } + + #[test] + fn bash() { + let res = TmpResourcesDir::new(Shell::Bash); + let mut env = EnvMap::new(); + + let command = setup_bash(&sh("bash"), &res.path, &mut env).unwrap(); + assert_eq!("bash --posix", shell_str(command.as_ref())); + assert_eq!("1", env["GHOSTTY_BASH_INJECT"]); + assert_eq!(format!("{}/ghostty.bash", res.shell_path), env["ENV"]); + } + + #[test] + fn bash_unsupported_options() { + let res = TmpResourcesDir::new(Shell::Bash); + for cmdline in [ + "bash --posix", + "bash --rcfile script.sh --posix", + "bash --init-file script.sh --posix", + "bash -c script.sh", + "bash -ic script.sh", + ] { + let mut env = EnvMap::new(); + assert!( + setup_bash(&sh(cmdline), &res.path, &mut env) + .unwrap() + .is_none() + ); + assert_eq!(0, env.len()); + } + } + + #[test] + fn bash_inject_flags() { + let res = TmpResourcesDir::new(Shell::Bash); + + let mut env = EnvMap::new(); + let command = setup_bash(&sh("bash --norc"), &res.path, &mut env).unwrap(); + assert_eq!("bash --posix", shell_str(command.as_ref())); + assert_eq!("1 --norc", env["GHOSTTY_BASH_INJECT"]); + + let mut env = EnvMap::new(); + let command = setup_bash(&sh("bash --noprofile"), &res.path, &mut env).unwrap(); + assert_eq!("bash --posix", shell_str(command.as_ref())); + assert_eq!("1 --noprofile", env["GHOSTTY_BASH_INJECT"]); + } + + #[test] + fn bash_rcfile() { + let res = TmpResourcesDir::new(Shell::Bash); + let mut env = EnvMap::new(); + + let command = setup_bash(&sh("bash --rcfile profile.sh"), &res.path, &mut env).unwrap(); + assert_eq!("bash --posix", shell_str(command.as_ref())); + assert_eq!("profile.sh", env["GHOSTTY_BASH_RCFILE"]); + + let command = setup_bash(&sh("bash --init-file profile.sh"), &res.path, &mut env).unwrap(); + assert_eq!("bash --posix", shell_str(command.as_ref())); + assert_eq!("profile.sh", env["GHOSTTY_BASH_RCFILE"]); + } + + #[test] + fn bash_histfile() { + let res = TmpResourcesDir::new(Shell::Bash); + + // HISTFILE unset (needs a home dir; set HOME so it is deterministic) + temp_env::with_var("HOME", Some("/home/test"), || { + let mut env = EnvMap::new(); + setup_bash(&sh("bash"), &res.path, &mut env).unwrap(); + assert!(env["HISTFILE"].ends_with(".bash_history")); + assert_eq!("1", env["GHOSTTY_BASH_UNEXPORT_HISTFILE"]); + }); + + // HISTFILE set + let mut env = EnvMap::new(); + env.insert("HISTFILE".into(), "my_history".into()); + setup_bash(&sh("bash"), &res.path, &mut env).unwrap(); + assert_eq!("my_history", env["HISTFILE"]); + assert!(!env.contains_key("GHOSTTY_BASH_UNEXPORT_HISTFILE")); + } + + #[test] + fn bash_env() { + let res = TmpResourcesDir::new(Shell::Bash); + let mut env = EnvMap::new(); + env.insert("ENV".into(), "env.sh".into()); + + setup_bash(&sh("bash"), &res.path, &mut env).unwrap(); + assert_eq!("env.sh", env["GHOSTTY_BASH_ENV"]); + assert_eq!(format!("{}/ghostty.bash", res.shell_path), env["ENV"]); + } + + #[test] + fn bash_additional_arguments() { + let res = TmpResourcesDir::new(Shell::Bash); + let mut env = EnvMap::new(); + + let command = setup_bash(&sh("bash - --arg file1 file2"), &res.path, &mut env).unwrap(); + assert_eq!( + "bash --posix - --arg file1 file2", + shell_str(command.as_ref()) + ); + + let command = setup_bash(&sh("bash -- --arg file1 file2"), &res.path, &mut env).unwrap(); + assert_eq!( + "bash --posix -- --arg file1 file2", + shell_str(command.as_ref()) + ); + } + + #[test] + fn bash_missing_resources() { + let tmp = TempDir::init().unwrap(); + let dir = std::fs::canonicalize(tmp.path()).unwrap(); + let mut env = EnvMap::new(); + assert!( + setup_bash(&sh("bash"), dir.to_str().unwrap(), &mut env) + .unwrap() + .is_none() + ); + assert_eq!(0, env.len()); + } + + #[test] + fn xdg_empty_xdg_data_dirs() { + let res = TmpResourcesDir::new(Shell::Fish); + let mut env = EnvMap::new(); + + assert!(setup_xdg_data_dirs(&res.path, &mut env)); + assert_eq!( + format!("{}/shell-integration", res.path), + env["GHOSTTY_SHELL_INTEGRATION_XDG_DIR"] + ); + assert_eq!( + format!("{}/shell-integration:/usr/local/share:/usr/share", res.path), + env["XDG_DATA_DIRS"] + ); + } + + #[test] + fn xdg_existing_xdg_data_dirs() { + let res = TmpResourcesDir::new(Shell::Fish); + let mut env = EnvMap::new(); + env.insert("XDG_DATA_DIRS".into(), "/opt/share".into()); + + assert!(setup_xdg_data_dirs(&res.path, &mut env)); + assert_eq!( + format!("{}/shell-integration", res.path), + env["GHOSTTY_SHELL_INTEGRATION_XDG_DIR"] + ); + assert_eq!( + format!("{}/shell-integration:/opt/share", res.path), + env["XDG_DATA_DIRS"] + ); + } + + #[test] + fn xdg_missing_resources() { + let tmp = TempDir::init().unwrap(); + let dir = std::fs::canonicalize(tmp.path()).unwrap(); + let mut env = EnvMap::new(); + assert!(!setup_xdg_data_dirs(dir.to_str().unwrap(), &mut env)); + assert_eq!(0, env.len()); + } + + #[test] + fn nushell() { + let res = TmpResourcesDir::new(Shell::Nushell); + let mut env = EnvMap::new(); + + let command = setup_nushell(&sh("nu"), &res.path, &mut env); + assert_eq!("nu --execute 'use ghostty *'", shell_str(command.as_ref())); + assert_eq!( + format!("{}/shell-integration", res.path), + env["GHOSTTY_SHELL_INTEGRATION_XDG_DIR"] + ); + assert!(env["XDG_DATA_DIRS"].starts_with(&format!("{}/shell-integration", res.path))); + } + + #[test] + fn nushell_unsupported_options() { + let res = TmpResourcesDir::new(Shell::Nushell); + for cmdline in [ + "nu --command exit", + "nu --lsp", + "nu -c script.sh", + "nu -ic script.sh", + ] { + let mut env = EnvMap::new(); + assert!(setup_nushell(&sh(cmdline), &res.path, &mut env).is_none()); + assert!(env.contains_key("XDG_DATA_DIRS")); + assert!(env.contains_key("GHOSTTY_SHELL_INTEGRATION_XDG_DIR")); + } + } + + #[test] + fn nushell_missing_resources() { + let tmp = TempDir::init().unwrap(); + let dir = std::fs::canonicalize(tmp.path()).unwrap(); + let mut env = EnvMap::new(); + assert!(setup_nushell(&sh("nu"), dir.to_str().unwrap(), &mut env).is_none()); + assert_eq!(0, env.len()); + } + + #[test] + fn zsh() { + let res = TmpResourcesDir::new(Shell::Zsh); + let mut env = EnvMap::new(); + + let command = setup_zsh(&sh("zsh"), &res.path, &mut env); + assert_eq!("zsh", shell_str(command.as_ref())); + assert_eq!(res.shell_path, env["ZDOTDIR"]); + assert!(!env.contains_key("GHOSTTY_ZSH_ZDOTDIR")); + } + + #[test] + fn zsh_zdotdir() { + let res = TmpResourcesDir::new(Shell::Zsh); + let mut env = EnvMap::new(); + env.insert("ZDOTDIR".into(), "$HOME/.config/zsh".into()); + + let command = setup_zsh(&sh("zsh"), &res.path, &mut env); + assert_eq!("zsh", shell_str(command.as_ref())); + assert_eq!(res.shell_path, env["ZDOTDIR"]); + assert_eq!("$HOME/.config/zsh", env["GHOSTTY_ZSH_ZDOTDIR"]); + } + + #[test] + fn zsh_missing_resources() { + let tmp = TempDir::init().unwrap(); + let dir = std::fs::canonicalize(tmp.path()).unwrap(); + let mut env = EnvMap::new(); + assert!(setup_zsh(&sh("zsh"), dir.to_str().unwrap(), &mut env).is_none()); + assert_eq!(0, env.len()); + } + + #[test] + fn bundled_resources_exist() { + // The shipped scripts that setup() points shells at. + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources"); + let root = root.to_str().unwrap(); + let mut env = EnvMap::new(); + let r = setup(root, &sh("bash"), &mut env, None).unwrap().unwrap(); + assert_eq!(r.shell, Shell::Bash); + assert!(env["ENV"].ends_with("shell-integration/bash/ghostty.bash")); + assert!( + Path::new(root) + .join("shell-integration/nushell/vendor/autoload/ghostty.nu") + .is_file() + ); + } +} diff --git a/crates/ghostty-termio/src/terminfo/ghostty.rs b/crates/ghostty-termio/src/terminfo/ghostty.rs new file mode 100644 index 0000000..ece7e1b --- /dev/null +++ b/crates/ghostty-termio/src/terminfo/ghostty.rs @@ -0,0 +1,1184 @@ +//! Ghostty's terminfo entry. Port of `terminfo/ghostty.zig`. + +use super::source::{Capability, Source, Value}; + +/// Ghostty's terminfo entry. +pub const GHOSTTY: Source = Source { + names: &[ + // We support the "xterm-" prefix because some poorly behaved programs + // use this to detect if the terminal supports 256 colors and other + // features. + // HACK: This is a hack on a hack...we use "xterm-ghostty" to prevent + // vim from breaking, and when we do this as the default we break + // tcell-based applications (lazygit, aerc, etc). tcell has a bug where + // the primary terminfo name must be the value of TERM. + // https://github.com/gdamore/tcell/pull/639 fixes the issue but is not + // merged yet. Consider switching these in the future. + "xterm-ghostty", + // The preferred name + "ghostty", + // Our "formal" name + "Ghostty", + ], + + // NOTE: These capabilities are super underdocumented and I'm not 100% + // I've got the list or my understanding of any in this list fully correct. + // As we learn more, please update the comments to better explain what + // anything means. + // + // I've marked some capabilities as "???" if I don't understand what they + // mean but I just assume I support since other modern terminals do. In + // this case, I'd love if anyone could help explain what this means and + // verify that Ghostty does indeed support it and if not we can fix it. + capabilities: &[ + // automatic right margin -- when reaching the end of a line, text is + // wrapped to the next line. + Capability { + name: "am", + value: Value::Boolean, + }, + // background color erase -- screen is erased with the background color + Capability { + name: "bce", + value: Value::Boolean, + }, + // terminal can change color definitions, i.e. we can change the color + // palette. + Capability { + name: "ccc", + value: Value::Boolean, + }, + // supports changing the window title. + Capability { + name: "hs", + value: Value::Boolean, + }, + // terminal has a meta key + Capability { + name: "km", + value: Value::Boolean, + }, + // terminal will not echo input on the screen on its own + Capability { + name: "mc5i", + value: Value::Boolean, + }, + // safe to move (move what?) while in insert/standout mode. (???) + Capability { + name: "mir", + value: Value::Boolean, + }, + Capability { + name: "msgr", + value: Value::Boolean, + }, + // no pad character (???) + Capability { + name: "npc", + value: Value::Boolean, + }, + // newline ignored after 80 cols (???) + Capability { + name: "xenl", + value: Value::Boolean, + }, + // Terminal supports default colors + Capability { + name: "AX", + value: Value::Boolean, + }, + // Tmux "truecolor" mode. Other programs also use this to detect + // if the terminal supports "truecolor". This means that the terminal + // can display 24-bit RGB colors. + Capability { + name: "Tc", + value: Value::Boolean, + }, + // Colored underlines. https://sw.kovidgoyal.net/kitty/underlines/ + Capability { + name: "Su", + value: Value::Boolean, + }, + // Terminal supports a number of xterm extensions + Capability { + name: "XT", + value: Value::Boolean, + }, + // Full keyboard support using Kitty's keyboard protocol: + // https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + Capability { + name: "fullkbd", + value: Value::Boolean, + }, + // Number of colors in the color palette. + Capability { + name: "colors", + value: Value::Numeric(256), + }, + // Number of columns in a line. Our terminal is variable width on + // Window resize but this appears to just be the value set by most + // terminals. + Capability { + name: "cols", + value: Value::Numeric(80), + }, + // Initial tabstop interval. + Capability { + name: "it", + value: Value::Numeric(8), + }, + // Number of lines on a page. Similar to cols this is variable width + // but this appears to be the value set by most terminals. + Capability { + name: "lines", + value: Value::Numeric(24), + }, + // Number of color pairs on the screen. + Capability { + name: "pairs", + value: Value::Numeric(32767), + }, + // Alternate character set. This is the VT100 alternate character set. + // I don't know what the value means, I copied this from Kitty and + // verified with some other terminals (looks similar). + Capability { + name: "acsc", + value: Value::String( + "++\\,\\,--..00``aaffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~", + ), + }, + // Curly, dashed, etc underlines + Capability { + name: "Smulx", + value: Value::String("\\E[4:%p1%dm"), + }, + // Colored underlines + Capability { + name: "Setulc", + value: Value::String( + "\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m", + ), + }, + // Cursor styles + Capability { + name: "Ss", + value: Value::String("\\E[%p1%d q"), + }, + // Cursor style reset + Capability { + name: "Se", + value: Value::String("\\E[2 q"), + }, + // OSC 52 Clipboard + Capability { + name: "Ms", + value: Value::String("\\E]52;%p1%s;%p2%s\\007"), + }, + // Synchronized output + Capability { + name: "Sync", + value: Value::String("\\E[?2026%?%p1%{1}%-%tl%eh%;"), + }, + // Bracketed paste mode + Capability { + name: "BD", + value: Value::String("\\E[?2004l"), + }, + Capability { + name: "BE", + value: Value::String("\\E[?2004h"), + }, + // Bracketed paste start/end + Capability { + name: "PS", + value: Value::String("\\E[200~"), + }, + Capability { + name: "PE", + value: Value::String("\\E[201~"), + }, + // Mouse + Capability { + name: "XM", + value: Value::String("\\E[?1006;1000%?%p1%{1}%=%th%el%;"), + }, + Capability { + name: "xm", + value: Value::String("\\E[<%i%p3%d;%p1%d;%p2%d;%?%p4%tM%em%;"), + }, + // Secondary device attributes request / response + Capability { + name: "RV", + value: Value::String("\\E[>c"), + }, + Capability { + name: "rv", + value: Value::String("\\E\\\\[[0-9]+;[0-9]+;[0-9]+c"), + }, + // XTVERSION + Capability { + name: "XR", + value: Value::String("\\E[>0q"), + }, + Capability { + name: "xr", + value: Value::String("\\EP>\\\\|[ -~]+a\\E\\\\"), + }, + // DECSLRM (Left/Right Margins) + Capability { + name: "Enmg", + value: Value::String("\\E[?69h"), + }, + Capability { + name: "Dsmg", + value: Value::String("\\E[?69l"), + }, + Capability { + name: "Clmg", + value: Value::String("\\E[s"), + }, + Capability { + name: "Cmg", + value: Value::String("\\E[%i%p1%d;%p2%ds"), + }, + // Clear screen. E3 is the extension to clear scrollback + Capability { + name: "clear", + value: Value::String("\\E[H\\E[2J"), + }, + Capability { + name: "E3", + value: Value::String("\\E[3J"), + }, + // Focus reporting. Introduced in ncurses 6.4-20231028 + Capability { + name: "fe", + value: Value::String("\\E[?1004h"), + }, + Capability { + name: "fd", + value: Value::String("\\E[?1004l"), + }, + Capability { + name: "kxIN", + value: Value::String("\\E[I"), + }, + Capability { + name: "kxOUT", + value: Value::String("\\E[O"), + }, + // These are all capabilities that should be pretty straightforward + // and map to input sequences. + Capability { + name: "bel", + value: Value::String("^G"), + }, + Capability { + name: "blink", + value: Value::String("\\E[5m"), + }, + Capability { + name: "bold", + value: Value::String("\\E[1m"), + }, + Capability { + name: "cbt", + value: Value::String("\\E[Z"), + }, + Capability { + name: "civis", + value: Value::String("\\E[?25l"), + }, + Capability { + name: "cnorm", + value: Value::String("\\E[?12l\\E[?25h"), + }, + Capability { + name: "cr", + value: Value::String("\\r"), + }, + Capability { + name: "csr", + value: Value::String("\\E[%i%p1%d;%p2%dr"), + }, + Capability { + name: "cub", + value: Value::String("\\E[%p1%dD"), + }, + Capability { + name: "cub1", + value: Value::String("^H"), + }, + Capability { + name: "cud", + value: Value::String("\\E[%p1%dB"), + }, + Capability { + name: "cud1", + value: Value::String("^J"), + }, + Capability { + name: "cuf", + value: Value::String("\\E[%p1%dC"), + }, + Capability { + name: "cuf1", + value: Value::String("\\E[C"), + }, + Capability { + name: "cup", + value: Value::String("\\E[%i%p1%d;%p2%dH"), + }, + Capability { + name: "cuu", + value: Value::String("\\E[%p1%dA"), + }, + Capability { + name: "cuu1", + value: Value::String("\\E[A"), + }, + Capability { + name: "cvvis", + value: Value::String("\\E[?12;25h"), + }, + Capability { + name: "dch", + value: Value::String("\\E[%p1%dP"), + }, + Capability { + name: "dch1", + value: Value::String("\\E[P"), + }, + Capability { + name: "dim", + value: Value::String("\\E[2m"), + }, + Capability { + name: "dl", + value: Value::String("\\E[%p1%dM"), + }, + Capability { + name: "dl1", + value: Value::String("\\E[M"), + }, + Capability { + name: "dsl", + value: Value::String("\\E]2;\\007"), + }, + Capability { + name: "ech", + value: Value::String("\\E[%p1%dX"), + }, + Capability { + name: "ed", + value: Value::String("\\E[J"), + }, + Capability { + name: "el", + value: Value::String("\\E[K"), + }, + Capability { + name: "el1", + value: Value::String("\\E[1K"), + }, + Capability { + name: "flash", + value: Value::String("\\E[?5h$<100/>\\E[?5l"), + }, + Capability { + name: "fsl", + value: Value::String("^G"), + }, + Capability { + name: "home", + value: Value::String("\\E[H"), + }, + Capability { + name: "hpa", + value: Value::String("\\E[%i%p1%dG"), + }, + Capability { + name: "ht", + value: Value::String("^I"), + }, + Capability { + name: "hts", + value: Value::String("\\EH"), + }, + Capability { + name: "ich", + value: Value::String("\\E[%p1%d@"), + }, + Capability { + name: "ich1", + value: Value::String("\\E[@"), + }, + Capability { + name: "il", + value: Value::String("\\E[%p1%dL"), + }, + Capability { + name: "il1", + value: Value::String("\\E[L"), + }, + Capability { + name: "ind", + value: Value::String("\\n"), + }, + Capability { + name: "indn", + value: Value::String("\\E[%p1%dS"), + }, + Capability { + name: "initc", + value: Value::String( + "\\E]4;%p1%d;rgb\\:%p2%{255}%*%{1000}%/%2.2X/%p3%{255}%*%{1000}%/%2.2X/%p4%{255}%*%{1000}%/%2.2X\\E\\\\", + ), + }, + Capability { + name: "invis", + value: Value::String("\\E[8m"), + }, + Capability { + name: "oc", + value: Value::String("\\E]104\\007"), + }, + Capability { + name: "op", + value: Value::String("\\E[39;49m"), + }, + Capability { + name: "rc", + value: Value::String("\\E8"), + }, + Capability { + name: "rep", + value: Value::String("%p1%c\\E[%p2%{1}%-%db"), + }, + Capability { + name: "rev", + value: Value::String("\\E[7m"), + }, + Capability { + name: "ri", + value: Value::String("\\EM"), + }, + Capability { + name: "rin", + value: Value::String("\\E[%p1%dT"), + }, + Capability { + name: "ritm", + value: Value::String("\\E[23m"), + }, + Capability { + name: "rmacs", + value: Value::String("\\E(B"), + }, + Capability { + name: "rmam", + value: Value::String("\\E[?7l"), + }, + Capability { + name: "rmcup", + value: Value::String("\\E[?1049l"), + }, + Capability { + name: "rmir", + value: Value::String("\\E[4l"), + }, + Capability { + name: "rmkx", + value: Value::String("\\E[?1l\\E>"), + }, + Capability { + name: "rmso", + value: Value::String("\\E[27m"), + }, + Capability { + name: "rmul", + value: Value::String("\\E[24m"), + }, + Capability { + name: "rmxx", + value: Value::String("\\E[29m"), + }, + Capability { + name: "setab", + value: Value::String( + "\\E[%?%p1%{8}%<%t4%p1%d%e%p1%{16}%<%t10%p1%{8}%-%d%e48;5;%p1%d%;m", + ), + }, + Capability { + name: "setaf", + value: Value::String( + "\\E[%?%p1%{8}%<%t3%p1%d%e%p1%{16}%<%t9%p1%{8}%-%d%e38;5;%p1%d%;m", + ), + }, + Capability { + name: "setrgbb", + value: Value::String("\\E[48:2:%p1%d:%p2%d:%p3%dm"), + }, + Capability { + name: "setrgbf", + value: Value::String("\\E[38:2:%p1%d:%p2%d:%p3%dm"), + }, + Capability { + name: "sgr", + value: Value::String( + "%?%p9%t\\E(0%e\\E(B%;\\E[0%?%p6%t;1%;%?%p5%t;2%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;%?%p7%t;8%;m", + ), + }, + Capability { + name: "sgr0", + value: Value::String("\\E(B\\E[m"), + }, + Capability { + name: "sitm", + value: Value::String("\\E[3m"), + }, + Capability { + name: "smacs", + value: Value::String("\\E(0"), + }, + Capability { + name: "smam", + value: Value::String("\\E[?7h"), + }, + Capability { + name: "smcup", + value: Value::String("\\E[?1049h"), + }, + Capability { + name: "smir", + value: Value::String("\\E[4h"), + }, + Capability { + name: "smkx", + value: Value::String("\\E[?1h\\E="), + }, + Capability { + name: "smso", + value: Value::String("\\E[7m"), + }, + Capability { + name: "smul", + value: Value::String("\\E[4m"), + }, + Capability { + name: "smxx", + value: Value::String("\\E[9m"), + }, + Capability { + name: "tbc", + value: Value::String("\\E[3g"), + }, + Capability { + name: "tsl", + value: Value::String("\\E]2;"), + }, + Capability { + name: "u6", + value: Value::String("\\E[%i%d;%dR"), + }, + Capability { + name: "u7", + value: Value::String("\\E[6n"), + }, + Capability { + name: "u8", + value: Value::String("\\E[?%[;0123456789]c"), + }, + Capability { + name: "u9", + value: Value::String("\\E[c"), + }, + Capability { + name: "vpa", + value: Value::String("\\E[%i%p1%dd"), + }, + //----------------------------------------------------------- + // Completely unvalidated entries that are blindly copied from + // other terminals (Kitty, Wezterm, Alacritty) and may or may not + // actually work with Ghostty. todo is to validate these! + Capability { + name: "kDC", + value: Value::String("\\E[3;2~"), + }, + Capability { + name: "kDC3", + value: Value::String("\\E[3;3~"), + }, + Capability { + name: "kDC4", + value: Value::String("\\E[3;4~"), + }, + Capability { + name: "kDC5", + value: Value::String("\\E[3;5~"), + }, + Capability { + name: "kDC6", + value: Value::String("\\E[3;6~"), + }, + Capability { + name: "kDC7", + value: Value::String("\\E[3;7~"), + }, + Capability { + name: "kDN", + value: Value::String("\\E[1;2B"), + }, + Capability { + name: "kDN3", + value: Value::String("\\E[1;3B"), + }, + Capability { + name: "kDN4", + value: Value::String("\\E[1;4B"), + }, + Capability { + name: "kDN5", + value: Value::String("\\E[1;5B"), + }, + Capability { + name: "kDN6", + value: Value::String("\\E[1;6B"), + }, + Capability { + name: "kDN7", + value: Value::String("\\E[1;7B"), + }, + Capability { + name: "kEND", + value: Value::String("\\E[1;2F"), + }, + Capability { + name: "kEND3", + value: Value::String("\\E[1;3F"), + }, + Capability { + name: "kEND4", + value: Value::String("\\E[1;4F"), + }, + Capability { + name: "kEND5", + value: Value::String("\\E[1;5F"), + }, + Capability { + name: "kEND6", + value: Value::String("\\E[1;6F"), + }, + Capability { + name: "kEND7", + value: Value::String("\\E[1;7F"), + }, + Capability { + name: "kHOM", + value: Value::String("\\E[1;2H"), + }, + Capability { + name: "kHOM3", + value: Value::String("\\E[1;3H"), + }, + Capability { + name: "kHOM4", + value: Value::String("\\E[1;4H"), + }, + Capability { + name: "kHOM5", + value: Value::String("\\E[1;5H"), + }, + Capability { + name: "kHOM6", + value: Value::String("\\E[1;6H"), + }, + Capability { + name: "kHOM7", + value: Value::String("\\E[1;7H"), + }, + Capability { + name: "kIC", + value: Value::String("\\E[2;2~"), + }, + Capability { + name: "kIC3", + value: Value::String("\\E[2;3~"), + }, + Capability { + name: "kIC4", + value: Value::String("\\E[2;4~"), + }, + Capability { + name: "kIC5", + value: Value::String("\\E[2;5~"), + }, + Capability { + name: "kIC6", + value: Value::String("\\E[2;6~"), + }, + Capability { + name: "kIC7", + value: Value::String("\\E[2;7~"), + }, + Capability { + name: "kLFT", + value: Value::String("\\E[1;2D"), + }, + Capability { + name: "kLFT3", + value: Value::String("\\E[1;3D"), + }, + Capability { + name: "kLFT4", + value: Value::String("\\E[1;4D"), + }, + Capability { + name: "kLFT5", + value: Value::String("\\E[1;5D"), + }, + Capability { + name: "kLFT6", + value: Value::String("\\E[1;6D"), + }, + Capability { + name: "kLFT7", + value: Value::String("\\E[1;7D"), + }, + Capability { + name: "kNXT", + value: Value::String("\\E[6;2~"), + }, + Capability { + name: "kNXT3", + value: Value::String("\\E[6;3~"), + }, + Capability { + name: "kNXT4", + value: Value::String("\\E[6;4~"), + }, + Capability { + name: "kNXT5", + value: Value::String("\\E[6;5~"), + }, + Capability { + name: "kNXT6", + value: Value::String("\\E[6;6~"), + }, + Capability { + name: "kNXT7", + value: Value::String("\\E[6;7~"), + }, + Capability { + name: "kPRV", + value: Value::String("\\E[5;2~"), + }, + Capability { + name: "kPRV3", + value: Value::String("\\E[5;3~"), + }, + Capability { + name: "kPRV4", + value: Value::String("\\E[5;4~"), + }, + Capability { + name: "kPRV5", + value: Value::String("\\E[5;5~"), + }, + Capability { + name: "kPRV6", + value: Value::String("\\E[5;6~"), + }, + Capability { + name: "kPRV7", + value: Value::String("\\E[5;7~"), + }, + Capability { + name: "kRIT", + value: Value::String("\\E[1;2C"), + }, + Capability { + name: "kRIT3", + value: Value::String("\\E[1;3C"), + }, + Capability { + name: "kRIT4", + value: Value::String("\\E[1;4C"), + }, + Capability { + name: "kRIT5", + value: Value::String("\\E[1;5C"), + }, + Capability { + name: "kRIT6", + value: Value::String("\\E[1;6C"), + }, + Capability { + name: "kRIT7", + value: Value::String("\\E[1;7C"), + }, + Capability { + name: "kUP", + value: Value::String("\\E[1;2A"), + }, + Capability { + name: "kUP3", + value: Value::String("\\E[1;3A"), + }, + Capability { + name: "kUP4", + value: Value::String("\\E[1;4A"), + }, + Capability { + name: "kUP5", + value: Value::String("\\E[1;5A"), + }, + Capability { + name: "kUP6", + value: Value::String("\\E[1;6A"), + }, + Capability { + name: "kUP7", + value: Value::String("\\E[1;7A"), + }, + Capability { + name: "kbs", + value: Value::String("^?"), + }, + Capability { + name: "kcbt", + value: Value::String("\\E[Z"), + }, + Capability { + name: "kcub1", + value: Value::String("\\EOD"), + }, + Capability { + name: "kcud1", + value: Value::String("\\EOB"), + }, + Capability { + name: "kcuf1", + value: Value::String("\\EOC"), + }, + Capability { + name: "kcuu1", + value: Value::String("\\EOA"), + }, + Capability { + name: "kdch1", + value: Value::String("\\E[3~"), + }, + Capability { + name: "kend", + value: Value::String("\\EOF"), + }, + Capability { + name: "kent", + value: Value::String("\\EOM"), + }, + Capability { + name: "kf1", + value: Value::String("\\EOP"), + }, + Capability { + name: "kf10", + value: Value::String("\\E[21~"), + }, + Capability { + name: "kf11", + value: Value::String("\\E[23~"), + }, + Capability { + name: "kf12", + value: Value::String("\\E[24~"), + }, + Capability { + name: "kf13", + value: Value::String("\\E[1;2P"), + }, + Capability { + name: "kf14", + value: Value::String("\\E[1;2Q"), + }, + Capability { + name: "kf15", + value: Value::String("\\E[1;2R"), + }, + Capability { + name: "kf16", + value: Value::String("\\E[1;2S"), + }, + Capability { + name: "kf17", + value: Value::String("\\E[15;2~"), + }, + Capability { + name: "kf18", + value: Value::String("\\E[17;2~"), + }, + Capability { + name: "kf19", + value: Value::String("\\E[18;2~"), + }, + Capability { + name: "kf2", + value: Value::String("\\EOQ"), + }, + Capability { + name: "kf20", + value: Value::String("\\E[19;2~"), + }, + Capability { + name: "kf21", + value: Value::String("\\E[20;2~"), + }, + Capability { + name: "kf22", + value: Value::String("\\E[21;2~"), + }, + Capability { + name: "kf23", + value: Value::String("\\E[23;2~"), + }, + Capability { + name: "kf24", + value: Value::String("\\E[24;2~"), + }, + Capability { + name: "kf25", + value: Value::String("\\E[1;5P"), + }, + Capability { + name: "kf26", + value: Value::String("\\E[1;5Q"), + }, + Capability { + name: "kf27", + value: Value::String("\\E[1;5R"), + }, + Capability { + name: "kf28", + value: Value::String("\\E[1;5S"), + }, + Capability { + name: "kf29", + value: Value::String("\\E[15;5~"), + }, + Capability { + name: "kf3", + value: Value::String("\\EOR"), + }, + Capability { + name: "kf30", + value: Value::String("\\E[17;5~"), + }, + Capability { + name: "kf31", + value: Value::String("\\E[18;5~"), + }, + Capability { + name: "kf32", + value: Value::String("\\E[19;5~"), + }, + Capability { + name: "kf33", + value: Value::String("\\E[20;5~"), + }, + Capability { + name: "kf34", + value: Value::String("\\E[21;5~"), + }, + Capability { + name: "kf35", + value: Value::String("\\E[23;5~"), + }, + Capability { + name: "kf36", + value: Value::String("\\E[24;5~"), + }, + Capability { + name: "kf37", + value: Value::String("\\E[1;6P"), + }, + Capability { + name: "kf38", + value: Value::String("\\E[1;6Q"), + }, + Capability { + name: "kf39", + value: Value::String("\\E[1;6R"), + }, + Capability { + name: "kf4", + value: Value::String("\\EOS"), + }, + Capability { + name: "kf40", + value: Value::String("\\E[1;6S"), + }, + Capability { + name: "kf41", + value: Value::String("\\E[15;6~"), + }, + Capability { + name: "kf42", + value: Value::String("\\E[17;6~"), + }, + Capability { + name: "kf43", + value: Value::String("\\E[18;6~"), + }, + Capability { + name: "kf44", + value: Value::String("\\E[19;6~"), + }, + Capability { + name: "kf45", + value: Value::String("\\E[20;6~"), + }, + Capability { + name: "kf46", + value: Value::String("\\E[21;6~"), + }, + Capability { + name: "kf47", + value: Value::String("\\E[23;6~"), + }, + Capability { + name: "kf48", + value: Value::String("\\E[24;6~"), + }, + Capability { + name: "kf49", + value: Value::String("\\E[1;3P"), + }, + Capability { + name: "kf5", + value: Value::String("\\E[15~"), + }, + Capability { + name: "kf50", + value: Value::String("\\E[1;3Q"), + }, + Capability { + name: "kf51", + value: Value::String("\\E[1;3R"), + }, + Capability { + name: "kf52", + value: Value::String("\\E[1;3S"), + }, + Capability { + name: "kf53", + value: Value::String("\\E[15;3~"), + }, + Capability { + name: "kf54", + value: Value::String("\\E[17;3~"), + }, + Capability { + name: "kf55", + value: Value::String("\\E[18;3~"), + }, + Capability { + name: "kf56", + value: Value::String("\\E[19;3~"), + }, + Capability { + name: "kf57", + value: Value::String("\\E[20;3~"), + }, + Capability { + name: "kf58", + value: Value::String("\\E[21;3~"), + }, + Capability { + name: "kf59", + value: Value::String("\\E[23;3~"), + }, + Capability { + name: "kf6", + value: Value::String("\\E[17~"), + }, + Capability { + name: "kf60", + value: Value::String("\\E[24;3~"), + }, + Capability { + name: "kf61", + value: Value::String("\\E[1;4P"), + }, + Capability { + name: "kf62", + value: Value::String("\\E[1;4Q"), + }, + Capability { + name: "kf63", + value: Value::String("\\E[1;4R"), + }, + Capability { + name: "kf7", + value: Value::String("\\E[18~"), + }, + Capability { + name: "kf8", + value: Value::String("\\E[19~"), + }, + Capability { + name: "kf9", + value: Value::String("\\E[20~"), + }, + Capability { + name: "khome", + value: Value::String("\\EOH"), + }, + Capability { + name: "kich1", + value: Value::String("\\E[2~"), + }, + Capability { + name: "kind", + value: Value::String("\\E[1;2B"), + }, + Capability { + name: "kmous", + value: Value::String("\\E[<"), + }, + Capability { + name: "knp", + value: Value::String("\\E[6~"), + }, + Capability { + name: "kpp", + value: Value::String("\\E[5~"), + }, + Capability { + name: "kri", + value: Value::String("\\E[1;2A"), + }, + Capability { + name: "rs1", + value: Value::String("\\E]\\E\\\\\\Ec"), + }, + Capability { + name: "sc", + value: Value::String("\\E7"), + }, + ], +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode() { + let mut buf = Vec::new(); + GHOSTTY.encode(&mut buf).unwrap(); + assert!(!buf.is_empty()); + let s = String::from_utf8(buf).unwrap(); + assert!(s.starts_with("xterm-ghostty|ghostty|Ghostty,\n\tam,\n\tbce,\n")); + assert!(s.ends_with("\tsc=\\E7,\n")); + } +} diff --git a/crates/ghostty-termio/src/terminfo/mod.rs b/crates/ghostty-termio/src/terminfo/mod.rs new file mode 100644 index 0000000..5c79312 --- /dev/null +++ b/crates/ghostty-termio/src/terminfo/mod.rs @@ -0,0 +1,14 @@ +//! Package terminfo provides functionality related to terminfo/termcap files. +//! +//! At the time of writing this comment, the focus is on generating terminfo +//! files so that we can maintain our terminfo in Rust instead of hand-writing +//! the archaic (imo) terminfo format by hand. But eventually we may want to +//! extract this into a more full-featured library on its own. +//! +//! Port of `terminfo/main.zig`. + +pub mod ghostty; +pub mod source; + +pub use ghostty::GHOSTTY as ghostty; +pub use source::Source; diff --git a/crates/ghostty-termio/src/terminfo/source.rs b/crates/ghostty-termio/src/terminfo/source.rs new file mode 100644 index 0000000..c18a4b8 --- /dev/null +++ b/crates/ghostty-termio/src/terminfo/source.rs @@ -0,0 +1,214 @@ +//! Terminfo source format. This can be used to encode terminfo files. +//! This cannot parse terminfo source files yet because it isn't something +//! I need to do but this can be added later. +//! +//! Background: +//! +//! Port of `terminfo/Source.zig`. + +use std::collections::HashMap; +use std::io; + +/// A terminfo source description. +#[derive(Debug, Clone, Copy)] +pub struct Source { + /// The set of names for the terminal. These match the TERM environment + /// variable and are used to look up this terminal. Historically, the final + /// name in the list was the most common name for the terminal and contains + /// spaces and other characters. See terminfo(5) for details. + pub names: &'static [&'static str], + + /// The set of capabilities in this terminfo file. + pub capabilities: &'static [Capability], +} + +/// A capability in a terminfo file. This also includes any "use" capabilities +/// since they behave just like other capabilities as documented in terminfo(5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Capability { + /// The name of capability. This is the "Cap-name" value in terminfo(5). + pub name: &'static str, + pub value: Value, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Value { + /// Canceled value, i.e. suffixed with @ + Canceled, + + /// Boolean values are always true if they exist so there is no value. + Boolean, + + /// Numeric values are always "unsigned decimal integers". The size + /// of the integer is unspecified in terminfo(5). 32-bits is a common + /// integer size but this may be wrong. + Numeric(u32), + + String(&'static str), +} + +impl Source { + /// Encode as a terminfo source file. The encoding is always done in a + /// human-readable format with whitespace. Fields are always written in the + /// order of the slices on this struct; this will not do any reordering. + pub fn encode(&self, writer: &mut W) -> io::Result<()> { + // Encode the names in the order specified + for (i, name) in self.names.iter().enumerate() { + if i != 0 { + writer.write_all(b"|")?; + } + writer.write_all(name.as_bytes())?; + } + writer.write_all(b",\n")?; + + // Encode each of the capabilities in the order specified + for cap in self.capabilities { + writer.write_all(b"\t")?; + writer.write_all(cap.name.as_bytes())?; + match cap.value { + Value::Canceled => writer.write_all(b"@")?, + Value::Boolean => {} + Value::Numeric(v) => write!(writer, "#{v}")?, + Value::String(v) => write!(writer, "={v}")?, + } + writer.write_all(b",\n")?; + } + Ok(()) + } + + /// Encode to an owned `String`. + pub fn encode_to_string(&self) -> String { + let mut buf = Vec::new(); + // Writing to a Vec cannot fail. + let _ = self.encode(&mut buf); + String::from_utf8_lossy(&buf).into_owned() + } + + /// Returns a map for all of the capabilities in this terminfo. The key is + /// the hex-encoded (uppercase) capability name and the value is what + /// should be sent as a response to XTGETTCAP. + /// Important: the value is the FULL response including the escape + /// sequences. + /// + /// Panics on canceled capabilities (unsupported, like the Zig + /// `@compileError`) and on `^` control characters that are not at the + /// start of a string. + pub fn xtgettcap_map(&self) -> HashMap { + let mut kvs: Vec<(String, String)> = Vec::with_capacity(self.capabilities.len() + 3); + + // We have all of our capabilities plus TN, Co, and RGB which aren't + // in the capabilities list but are query-able. + kvs.push(("TN".into(), self.names[0].into())); + kvs.push(("Co".into(), "256".into())); + kvs.push(("RGB".into(), "8".into())); + for cap in self.capabilities { + let value = match cap.value { + Value::Canceled => panic!("canceled not handled yet"), + Value::Boolean => String::new(), + Value::String(v) => { + // If a string contains parameters, then we do not escape + // anything within the string. I BELIEVE the history here is + // xterm initially only supported specific capabilities and + // none had parameters so it returned the tcap encoded form. + // Later, Kitty added support for more capabilities some of + // which have parameters. But Kitty returned them in + // terminfo source format. So we need to handle both cases. + if v.contains('%') { + v.to_string() + } else { + // No-parameters. Encode and return. + // First replace \E with the escape char (0x1B) + let mut result = v.replace("\\E", "\x1b"); + // Replace '^' with the control char version of that char. + while let Some(idx) = result.find('^') { + assert!(idx == 0, "handle control-char in middle of string"); + let c = result.as_bytes()[idx + 1]; + let replacement = match c { + b'?' => 0x7F, // DEL, special cased from ncurses + c => c - 64, + }; + let pat = result[idx..idx + 2].to_string(); + result = result.replace(&pat, &char::from(replacement).to_string()); + } + result + } + } + Value::Numeric(v) => v.to_string(), + }; + kvs.push((cap.name.to_string(), value)); + } + + // Now go through and convert them all to hex-encoded strings. + kvs.into_iter() + .map(|(k, v)| { + let key = hexencode(k.as_bytes()); + let out = if v.is_empty() { + format!("\x1bP1+r{key}\x1b\\") + } else { + format!("\x1bP1+r{key}={}\x1b\\", hexencode(v.as_bytes())) + }; + (key, out) + }) + .collect() + } +} + +/// Uppercase hex encoding (`std.fmt.bytesToHex(.., .upper)`). +pub fn hexencode(input: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(input.len() * 2); + for b in input { + let _ = write!(s, "{b:02X}"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + const fn cap(name: &'static str, value: Value) -> Capability { + Capability { name, value } + } + + #[test] + fn xtgettcap_map() { + static SRC: Source = Source { + names: &["ghostty", "xterm-ghostty", "Ghostty"], + capabilities: &[ + cap("am", Value::Boolean), + cap("colors", Value::Numeric(256)), + cap("kx", Value::String("^?")), + cap("kbs", Value::String("^H")), + cap("kf1", Value::String("\\EOP")), + cap("Smulx", Value::String("\\E[4:%p1%dm")), + ], + }; + + let map = SRC.xtgettcap_map(); + assert_eq!("\x1bP1+r616D\x1b\\", map[&hexencode(b"am")]); + assert_eq!("\x1bP1+r6B78=7F\x1b\\", map[&hexencode(b"kx")]); + assert_eq!("\x1bP1+r6B6273=08\x1b\\", map[&hexencode(b"kbs")]); + assert_eq!("\x1bP1+r6B6631=1B4F50\x1b\\", map[&hexencode(b"kf1")]); + assert_eq!( + "\x1bP1+r536D756C78=5C455B343A25703125646D\x1b\\", + map[&hexencode(b"Smulx")] + ); + } + + #[test] + fn encode() { + static SRC: Source = Source { + names: &["ghostty", "xterm-ghostty", "Ghostty"], + capabilities: &[ + cap("am", Value::Boolean), + cap("ccc", Value::Canceled), + cap("colors", Value::Numeric(256)), + cap("bel", Value::String("^G")), + ], + }; + + let expected = "ghostty|xterm-ghostty|Ghostty,\n\tam,\n\tccc@,\n\tcolors#256,\n\tbel=^G,\n"; + assert_eq!(expected, SRC.encode_to_string()); + } +} diff --git a/crates/ghostty-termio/tests/data/xterm-ghostty.terminfo b/crates/ghostty-termio/tests/data/xterm-ghostty.terminfo new file mode 100644 index 0000000..24d638a --- /dev/null +++ b/crates/ghostty-termio/tests/data/xterm-ghostty.terminfo @@ -0,0 +1,269 @@ +xterm-ghostty|ghostty|Ghostty, + am, + bce, + ccc, + hs, + km, + mc5i, + mir, + msgr, + npc, + xenl, + AX, + Tc, + Su, + XT, + fullkbd, + colors#256, + cols#80, + it#8, + lines#24, + pairs#32767, + acsc=++\,\,--..00``aaffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~, + Smulx=\E[4:%p1%dm, + Setulc=\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m, + Ss=\E[%p1%d q, + Se=\E[2 q, + Ms=\E]52;%p1%s;%p2%s\007, + Sync=\E[?2026%?%p1%{1}%-%tl%eh%;, + BD=\E[?2004l, + BE=\E[?2004h, + PS=\E[200~, + PE=\E[201~, + XM=\E[?1006;1000%?%p1%{1}%=%th%el%;, + xm=\E[<%i%p3%d;%p1%d;%p2%d;%?%p4%tM%em%;, + RV=\E[>c, + rv=\E\\[[0-9]+;[0-9]+;[0-9]+c, + XR=\E[>0q, + xr=\EP>\\|[ -~]+a\E\\, + Enmg=\E[?69h, + Dsmg=\E[?69l, + Clmg=\E[s, + Cmg=\E[%i%p1%d;%p2%ds, + clear=\E[H\E[2J, + E3=\E[3J, + fe=\E[?1004h, + fd=\E[?1004l, + kxIN=\E[I, + kxOUT=\E[O, + bel=^G, + blink=\E[5m, + bold=\E[1m, + cbt=\E[Z, + civis=\E[?25l, + cnorm=\E[?12l\E[?25h, + cr=\r, + csr=\E[%i%p1%d;%p2%dr, + cub=\E[%p1%dD, + cub1=^H, + cud=\E[%p1%dB, + cud1=^J, + cuf=\E[%p1%dC, + cuf1=\E[C, + cup=\E[%i%p1%d;%p2%dH, + cuu=\E[%p1%dA, + cuu1=\E[A, + cvvis=\E[?12;25h, + dch=\E[%p1%dP, + dch1=\E[P, + dim=\E[2m, + dl=\E[%p1%dM, + dl1=\E[M, + dsl=\E]2;\007, + ech=\E[%p1%dX, + ed=\E[J, + el=\E[K, + el1=\E[1K, + flash=\E[?5h$<100/>\E[?5l, + fsl=^G, + home=\E[H, + hpa=\E[%i%p1%dG, + ht=^I, + hts=\EH, + ich=\E[%p1%d@, + ich1=\E[@, + il=\E[%p1%dL, + il1=\E[L, + ind=\n, + indn=\E[%p1%dS, + initc=\E]4;%p1%d;rgb\:%p2%{255}%*%{1000}%/%2.2X/%p3%{255}%*%{1000}%/%2.2X/%p4%{255}%*%{1000}%/%2.2X\E\\, + invis=\E[8m, + oc=\E]104\007, + op=\E[39;49m, + rc=\E8, + rep=%p1%c\E[%p2%{1}%-%db, + rev=\E[7m, + ri=\EM, + rin=\E[%p1%dT, + ritm=\E[23m, + rmacs=\E(B, + rmam=\E[?7l, + rmcup=\E[?1049l, + rmir=\E[4l, + rmkx=\E[?1l\E>, + rmso=\E[27m, + rmul=\E[24m, + rmxx=\E[29m, + setab=\E[%?%p1%{8}%<%t4%p1%d%e%p1%{16}%<%t10%p1%{8}%-%d%e48;5;%p1%d%;m, + setaf=\E[%?%p1%{8}%<%t3%p1%d%e%p1%{16}%<%t9%p1%{8}%-%d%e38;5;%p1%d%;m, + setrgbb=\E[48:2:%p1%d:%p2%d:%p3%dm, + setrgbf=\E[38:2:%p1%d:%p2%d:%p3%dm, + sgr=%?%p9%t\E(0%e\E(B%;\E[0%?%p6%t;1%;%?%p5%t;2%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;%?%p7%t;8%;m, + sgr0=\E(B\E[m, + sitm=\E[3m, + smacs=\E(0, + smam=\E[?7h, + smcup=\E[?1049h, + smir=\E[4h, + smkx=\E[?1h\E=, + smso=\E[7m, + smul=\E[4m, + smxx=\E[9m, + tbc=\E[3g, + tsl=\E]2;, + u6=\E[%i%d;%dR, + u7=\E[6n, + u8=\E[?%[;0123456789]c, + u9=\E[c, + vpa=\E[%i%p1%dd, + kDC=\E[3;2~, + kDC3=\E[3;3~, + kDC4=\E[3;4~, + kDC5=\E[3;5~, + kDC6=\E[3;6~, + kDC7=\E[3;7~, + kDN=\E[1;2B, + kDN3=\E[1;3B, + kDN4=\E[1;4B, + kDN5=\E[1;5B, + kDN6=\E[1;6B, + kDN7=\E[1;7B, + kEND=\E[1;2F, + kEND3=\E[1;3F, + kEND4=\E[1;4F, + kEND5=\E[1;5F, + kEND6=\E[1;6F, + kEND7=\E[1;7F, + kHOM=\E[1;2H, + kHOM3=\E[1;3H, + kHOM4=\E[1;4H, + kHOM5=\E[1;5H, + kHOM6=\E[1;6H, + kHOM7=\E[1;7H, + kIC=\E[2;2~, + kIC3=\E[2;3~, + kIC4=\E[2;4~, + kIC5=\E[2;5~, + kIC6=\E[2;6~, + kIC7=\E[2;7~, + kLFT=\E[1;2D, + kLFT3=\E[1;3D, + kLFT4=\E[1;4D, + kLFT5=\E[1;5D, + kLFT6=\E[1;6D, + kLFT7=\E[1;7D, + kNXT=\E[6;2~, + kNXT3=\E[6;3~, + kNXT4=\E[6;4~, + kNXT5=\E[6;5~, + kNXT6=\E[6;6~, + kNXT7=\E[6;7~, + kPRV=\E[5;2~, + kPRV3=\E[5;3~, + kPRV4=\E[5;4~, + kPRV5=\E[5;5~, + kPRV6=\E[5;6~, + kPRV7=\E[5;7~, + kRIT=\E[1;2C, + kRIT3=\E[1;3C, + kRIT4=\E[1;4C, + kRIT5=\E[1;5C, + kRIT6=\E[1;6C, + kRIT7=\E[1;7C, + kUP=\E[1;2A, + kUP3=\E[1;3A, + kUP4=\E[1;4A, + kUP5=\E[1;5A, + kUP6=\E[1;6A, + kUP7=\E[1;7A, + kbs=^?, + kcbt=\E[Z, + kcub1=\EOD, + kcud1=\EOB, + kcuf1=\EOC, + kcuu1=\EOA, + kdch1=\E[3~, + kend=\EOF, + kent=\EOM, + kf1=\EOP, + kf10=\E[21~, + kf11=\E[23~, + kf12=\E[24~, + kf13=\E[1;2P, + kf14=\E[1;2Q, + kf15=\E[1;2R, + kf16=\E[1;2S, + kf17=\E[15;2~, + kf18=\E[17;2~, + kf19=\E[18;2~, + kf2=\EOQ, + kf20=\E[19;2~, + kf21=\E[20;2~, + kf22=\E[21;2~, + kf23=\E[23;2~, + kf24=\E[24;2~, + kf25=\E[1;5P, + kf26=\E[1;5Q, + kf27=\E[1;5R, + kf28=\E[1;5S, + kf29=\E[15;5~, + kf3=\EOR, + kf30=\E[17;5~, + kf31=\E[18;5~, + kf32=\E[19;5~, + kf33=\E[20;5~, + kf34=\E[21;5~, + kf35=\E[23;5~, + kf36=\E[24;5~, + kf37=\E[1;6P, + kf38=\E[1;6Q, + kf39=\E[1;6R, + kf4=\EOS, + kf40=\E[1;6S, + kf41=\E[15;6~, + kf42=\E[17;6~, + kf43=\E[18;6~, + kf44=\E[19;6~, + kf45=\E[20;6~, + kf46=\E[21;6~, + kf47=\E[23;6~, + kf48=\E[24;6~, + kf49=\E[1;3P, + kf5=\E[15~, + kf50=\E[1;3Q, + kf51=\E[1;3R, + kf52=\E[1;3S, + kf53=\E[15;3~, + kf54=\E[17;3~, + kf55=\E[18;3~, + kf56=\E[19;3~, + kf57=\E[20;3~, + kf58=\E[21;3~, + kf59=\E[23;3~, + kf6=\E[17~, + kf60=\E[24;3~, + kf61=\E[1;4P, + kf62=\E[1;4Q, + kf63=\E[1;4R, + kf7=\E[18~, + kf8=\E[19~, + kf9=\E[20~, + khome=\EOH, + kich1=\E[2~, + kind=\E[1;2B, + kmous=\E[<, + knp=\E[6~, + kpp=\E[5~, + kri=\E[1;2A, + rs1=\E]\E\\\Ec, + sc=\E7, diff --git a/crates/ghostty-termio/tests/pty_command.rs b/crates/ghostty-termio/tests/pty_command.rs new file mode 100644 index 0000000..72c19d5 --- /dev/null +++ b/crates/ghostty-termio/tests/pty_command.rs @@ -0,0 +1,100 @@ +//! Integration test: run a real child attached to a pty. + +#![allow(clippy::unwrap_used)] + +use std::{ + fs::File, + io::Read, + os::fd::{AsRawFd, OwnedFd}, +}; + +use ghostty_termio::{Command, Pty, Winsize, command::Exit, pty::child_pre_exec}; + +fn dup(fd: &OwnedFd) -> OwnedFd { + fd.try_clone().unwrap() +} + +/// Read from the master until the child side hangs up (EIO on Linux). +fn read_until_hangup(master: OwnedFd) -> String { + let mut f = File::from(master); + let mut out = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match f.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => out.extend_from_slice(&buf[..n]), + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn run_on_pty(script: &str, size: Winsize) -> (String, Exit) { + let mut pty = Pty::open(size).unwrap(); + let slave = pty.slave.take().unwrap(); + + let (master_raw, _) = pty.raw_fds(); + let slave_raw = slave.as_raw_fd(); + + let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-c", script]); + cmd.stdin = Some(dup(&slave)); + cmd.stdout = Some(dup(&slave)); + cmd.stderr = Some(dup(&slave)); + cmd.os_pre_exec = Some(Box::new(move || { + // Make the slave our controlling terminal in a new session; this + // also closes the master/slave descriptors in the child. + match child_pre_exec(master_raw, slave_raw) { + Ok(()) => None, + Err(_) => Some(1), + } + })); + cmd.start().unwrap(); + + // Close our copies of the slave so we see a hangup when the child exits. + drop(cmd.stdin.take()); + drop(cmd.stdout.take()); + drop(cmd.stderr.take()); + drop(slave); + + let exit = cmd.wait(true).unwrap(); + let out = read_until_hangup(pty.master); + (out, exit) +} + +#[test] +fn echo_and_stty_size_on_pty() { + let size = Winsize { + ws_row: 31, + ws_col: 97, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let (out, exit) = run_on_pty("echo hi; stty size", size); + assert_eq!(exit, Exit::Exited(0)); + // A pty translates \n to \r\n. + assert_eq!(out, "hi\r\n31 97\r\n"); +} + +#[test] +fn child_has_controlling_terminal() { + // `tty` succeeds only with a terminal on stdin; /dev/tty only opens when + // we have a controlling terminal (TIOCSCTTY). + let (out, exit) = run_on_pty( + "tty >/dev/null && echo tty; echo x > /dev/tty && echo ctty", + Winsize::default(), + ); + assert_eq!(exit, Exit::Exited(0)); + assert!(out.contains("tty\r\n"), "{out:?}"); + assert!(out.contains("ctty\r\n"), "{out:?}"); +} + +#[test] +fn resize_is_visible_to_child() { + let size = Winsize { + ws_row: 10, + ws_col: 20, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let (out, _) = run_on_pty("stty size", size); + assert_eq!(out, "10 20\r\n"); +} diff --git a/crates/ghostty-termio/tests/terminfo_golden.rs b/crates/ghostty-termio/tests/terminfo_golden.rs new file mode 100644 index 0000000..0b7a83a --- /dev/null +++ b/crates/ghostty-termio/tests/terminfo_golden.rs @@ -0,0 +1,34 @@ +//! The terminfo source emitted by the Rust port must be byte-identical to the + +#![allow(clippy::unwrap_used)] +//! output of the Zig `terminfo/ghostty.zig` `encode` (golden file generated +//! with the Zig reference implementation). + +use ghostty_termio::terminfo; + +#[test] +fn terminfo_source_matches_zig_output() { + let expected = include_str!("data/xterm-ghostty.terminfo"); + assert_eq!(expected, terminfo::ghostty.encode_to_string()); +} + +#[test] +fn terminfo_compiles_with_tic_if_available() { + let Ok(dir) = std::env::var("CARGO_TARGET_TMPDIR") else { + return; + }; + let src = std::path::Path::new(&dir).join("xterm-ghostty.src"); + std::fs::write(&src, terminfo::ghostty.encode_to_string()).unwrap(); + let out = std::path::Path::new(&dir).join("terminfo-out"); + let _ = std::fs::create_dir_all(&out); + let Ok(status) = std::process::Command::new("tic") + .arg("-x") + .arg("-o") + .arg(&out) + .arg(&src) + .status() + else { + return; // tic not installed + }; + assert!(status.success()); +} diff --git a/docs/rewrite/notes/ghostty-termio-IO1.md b/docs/rewrite/notes/ghostty-termio-IO1.md new file mode 100644 index 0000000..e0a947f --- /dev/null +++ b/docs/rewrite/notes/ghostty-termio-IO1.md @@ -0,0 +1,72 @@ +# ghostty-termio notes (agent IO1) + +Linux only. Crate lints: `unsafe_code = "deny"` (workspace clippy table copied +into the crate's `[lints.clippy]`); `#[allow(unsafe_code)]` only on tiny items +with `// SAFETY:` (fork, execvpe, _exit, close, sigaction, borrow_raw). + +## File -> module +- `pty.zig` -> `pty.rs` (`Pty`, `Winsize`, `Mode`, `child_pre_exec(master, slave)`) +- `Command.zig` (+ `apprt/gtk/{pre_exec,post_fork}.zig` cgroup logic, `cgroup.zig` fmtScope) + -> `command.rs` +- `termio/shell_integration.zig` -> `shell_integration.rs` +- `terminfo/{main,Source,ghostty}.zig` -> `terminfo/{mod,source,ghostty}.rs` + (`terminfo::ghostty` is a `const Source`; `encode`, `encode_to_string`, + `xtgettcap_map`). Output verified byte-identical with the Zig `encode` (golden + file `tests/data/xterm-ghostty.terminfo`, generated with `zig run`). +- Shell scripts copied verbatim to `resources/shell-integration/` (workspace root). + +## Not ported (deferred; need ghostty-terminal / renderer) +Exec.zig, Termio.zig, Thread.zig, stream_handler.zig, mailbox.zig, message.zig, +backend.zig, Options.zig (all reference `terminal`/`renderer`/`apprt` types). + +## Design / deviations +- Deps `ghostty-config` and `ghostty-terminal` REMOVED from Cargo.toml (note in file). + Deps: ghostty-os, rustix (pty, termios, process, fs, stdio), nix (signal), libc, log, thiserror. +- `Command` does its own `fork` + `libc::execvpe` (not std::process::Command) so the + child setup is exactly Ghostty's: dup2 stdio (CLOEXEC cleared), best-effort chdir + (failure ignored), restore rlimit (`restore_rlimit: Option` replaces + `global_state.rlimits`), `os_pre_exec` then `rt_pre_exec` hooks (`Fn() -> Option`, + `Some(code)` => `_exit(code)`; panics => exit 1), then execvpe. All allocation happens + before fork. Exec failure: child prints the same "Requested executable not found" message + (other errors: `errno=N` instead of the Zig error name) to stderr and `_exit(1)`; there + is no `ExecFailedInChild` error because the child can never return into caller code. + `exit_immediately(code)` is provided for hooks that want to exit (use instead of + `std::process::exit`). +- Signal mask: Zig never reset the mask explicitly in Command either; only `Pty::child_pre_exec` + resets 13 signal dispositions to SIG_DFL (same list as Zig), then setsid + TIOCSCTTY and + closes slave+master. Close-all-fds is not done (Zig doesn't either; master is CLOEXEC). +- stdio fields are `Option` (callers pass `try_clone()`d fds); `wait(block)` returns + `Result`; non-blocking wait loops until a status is available (as Zig, with a + `yield_now`). `setData/getData` dropped (closures capture state). Empty `args` => argv = + [path] (what the Zig doc comment promises; Zig code itself passed an empty argv). +- Inherited env is snapshotted with `vars_os()` before fork (Zig passes `environ`). +- `Pty`: `slave: Option`; both fds close on drop (Zig never auto-closed the slave). + Master opened CLOEXEC, slave not (like glibc openpty + explicit CLOEXEC). IUTF8 set. +- cgroup: `linux_cgroup_pre_exec(&RtPreExecInfo)` (child side, waits <=250ms) and + `linux_cgroup_post_fork(cmd, &RtPostForkInfo, create_scope)` where `create_scope` is a + closure standing in for the GIO D-Bus `cgroup.createScope`. `fmt_scope(pid)`. + Local `LinuxCgroup`/`GtkSingleInstance`/`Rt*Info` types: TODO(integration) from ghostty-config. +- shell_integration: local `Command` (Shell/Direct) with `ArgIteratorGeneral` quoting rules, + `ShellIntegrationFeatures` (same defaults) - TODO(integration) replace with config types. + `EnvMap = HashMap`. `setup(resource_dir, &Command, &mut EnvMap, Option)` + -> `Result, Error>` (Error only from `homedir::home`). + Darwin `/bin/bash` special case dropped. + +## Tests (52 total: 47 unit + 5 integration, all passing) +Zig tests ported (35): pty 1; Command 10 (createNullDelimitedEnvMap as `prepare()`, os/rt pre exec +1+2, rt post fork, redirect stdout, custom env, custom cwd, execve failure); shell_integration 21 +(force shell, failure, detectShell, setup features, bash x8, xdg x3, nushell x3, zsh x3); +terminfo 3 (xtgettcap map, Source encode, ghostty encode). +Skipped: Windows/NullPty/Windows env block helpers, `refAllDecls` tests. +Extras: pty cloexec/IUTF8/mode, Exit decoding, signal exit, nonblocking wait, invalid cwd ignored, +fd leak check, cgroup mode matrices, arg splitter, bundled-resources check, terminfo golden vs Zig, +`tic -x` compile of the generated source (skipped if tic absent), +`tests/pty_command.rs`: real `/bin/sh -c 'echo hi; stty size'` on a 31x97 pty (output +`hi\r\n31 97\r\n`), controlling-tty check, resize check. + +## Known gaps / TODO(integration) +- Config-derived types above; `restore_rlimit` from global state; D-Bus scope creation. +- `Pty::child_pre_exec` is meant to be called from Exec's `os_pre_exec` (see tests/pty_command.rs + for the wiring). Flatpak passwd path in ghostty-os needs Pty (ghostty-os TODO) - now available. +- Hooks run after fork in a possibly multithreaded process; `linux_cgroup_pre_exec` allocates + and sleeps (same as Zig) - fine on glibc. diff --git a/resources/shell-integration/README.md b/resources/shell-integration/README.md new file mode 100644 index 0000000..590632d --- /dev/null +++ b/resources/shell-integration/README.md @@ -0,0 +1,117 @@ +# Shell Integration Code + +This is the shell-specific shell-integration code that is +used for the shell-integration feature set that Ghostty +supports. + +This README is meant as developer documentation and not as +user documentation. For user documentation, see the main +README or [ghostty.org](https://ghostty.org/docs) + +## Implementation Details + +### Bash + +Automatic [Bash](https://www.gnu.org/software/bash/) shell integration works by +starting Bash in POSIX mode and using the `ENV` environment variable to load +our integration script (`bash/ghostty.bash`). This prevents Bash from loading +its normal startup files, which becomes our script's responsibility (along with +disabling POSIX mode). + +Bash shell integration can also be sourced manually from `bash/ghostty.bash`. +This also works for older versions of Bash. + +```bash +# Ghostty shell integration for Bash. This must be at the top of your bashrc! +if [ -n "${GHOSTTY_RESOURCES_DIR}" ]; then + builtin source "${GHOSTTY_RESOURCES_DIR}/shell-integration/bash/ghostty.bash" +fi +``` + +> [!NOTE] +> +> The version of Bash distributed with macOS (`/bin/bash`) does not support +> automatic shell integration. You'll need to manually source the shell +> integration script (as shown above). You can also install a standard +> version of Bash from Homebrew or elsewhere and set it as your shell. + +### Elvish + +For [Elvish](https://elv.sh), `$GHOSTTY_RESOURCES_DIR/src/shell-integration` +contains an `./elvish/lib/ghostty-integration.elv` file. + +Elvish, on startup, searches for paths defined in `XDG_DATA_DIRS` +variable for `./elvish/lib/*.elv` files and imports them. They are thus +made available for use as modules by way of `use `. + +Ghostty launches Elvish, passing the environment with `XDG_DATA_DIRS` prepended +with `$GHOSTTY_RESOURCES_DIR/src/shell-integration`. It contains +`./elvish/lib/ghostty-integration.elv`. The user can then import it +by `use ghostty-integration` every time after shell startup or +autostart integration in `$XDG_CONFIG_HOME/elvish/rc.elv`, +which will run the integration routines. + +If you decide to autostart `ghostty-integration` with `rc.elv`, you should +detect whether the terminal is Ghostty or not. To do this, add this to the end +of your `rc.elv` file: + +```elvish +if (eq $E:TERM "xterm-ghostty") { + try { use ghostty-integration } catch { } +} +``` + +The [Elvish](https://elv.sh) shell integration is supported by +the community and is not officially supported by Ghostty. We distribute +it for ease of access and use but do not provide support for it. +If you experience issues with the Elvish shell integration, I welcome +any contributions to fix them. Thank you! + +### Fish + +For [Fish](https://fishshell.com/), Ghostty prepends to the +`XDG_DATA_DIRS` directory. Fish automatically loads configuration +files in `/fish/vendor_conf.d/*.fish` on startup, +allowing us to automatically integrate with the shell. For details +on the Fish startup process, see the +[Fish documentation](https://fishshell.com/docs/current/language.html). + +### Nushell + +For [Nushell](https://www.nushell.sh/), Ghostty prepends to the +`XDG_DATA_DIRS` directory, making the `ghostty` module available through +Nushell's vendor autoload mechanism. Ghostty then automatically imports +the module using the `-e "use ghostty *"` flag when starting Nushell. + +Nushell provides many shell features itself, such as `title` and `cursor`, +so our integration focuses on Ghostty-specific features like `sudo`, +`ssh-env`, and `ssh-terminfo`. + +The shell integration is automatically enabled when running Nushell in Ghostty, +but you can also load it manually is shell integration is disabled: + +```nushell +source $GHOSTTY_RESOURCES_DIR/shell-integration/nushell/vendor/autoload/ghostty.nu +use ghostty * +``` + +### Zsh + +Automatic [Zsh](https://www.zsh.org/) integration works by temporarily setting +`ZDOTDIR` to our `zsh` directory. An existing `ZDOTDIR` environment variable +value will be retained and restored after our shell integration scripts are +run. + +However, if `ZDOTDIR` is set in a system-wide file like `/etc/zshenv`, it will +override Ghostty's `ZDOTDIR` value, preventing the shell integration from being +loaded. In this case, the shell integration needs to be loaded manually. + +To load the Zsh shell integration manually: + +```zsh +if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then + source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration +fi +``` + +Shell integration requires Zsh 5.1+. diff --git a/resources/shell-integration/bash/bash-preexec.sh b/resources/shell-integration/bash/bash-preexec.sh new file mode 100644 index 0000000..f20ece1 --- /dev/null +++ b/resources/shell-integration/bash/bash-preexec.sh @@ -0,0 +1,382 @@ +# bash-preexec.sh -- Bash support for ZSH-like 'preexec' and 'precmd' functions. +# https://github.com/rcaloras/bash-preexec +# +# +# 'preexec' functions are executed before each interactive command is +# executed, with the interactive command as its argument. The 'precmd' +# function is executed before each prompt is displayed. +# +# Author: Ryan Caloras (ryan@bashhub.com) +# Forked from Original Author: Glyph Lefkowitz +# +# V0.6.0 +# + +# General Usage: +# +# 1. Source this file at the end of your bash profile so as not to interfere +# with anything else that's using PROMPT_COMMAND. +# +# 2. Add any precmd or preexec functions by appending them to their arrays: +# e.g. +# precmd_functions+=(my_precmd_function) +# precmd_functions+=(some_other_precmd_function) +# +# preexec_functions+=(my_preexec_function) +# +# 3. Consider changing anything using the DEBUG trap or PROMPT_COMMAND +# to use preexec and precmd instead. Preexisting usages will be +# preserved, but doing so manually may be less surprising. +# +# Note: This module requires two Bash features which you must not otherwise be +# using: the "DEBUG" trap, and the "PROMPT_COMMAND" variable. If you override +# either of these after bash-preexec has been installed it will most likely break. + +# Tell shellcheck what kind of file this is. +# shellcheck shell=bash + +# Make sure this is bash that's running and return otherwise. +# Use POSIX syntax for this line: +if [ -z "${BASH_VERSION-}" ]; then + return 1 +fi + +# We only support Bash 3.1+. +# Note: BASH_VERSINFO is first available in Bash-2.0. +if [[ -z "${BASH_VERSINFO-}" ]] || (( BASH_VERSINFO[0] < 3 || (BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1) )); then + return 1 +fi + +# Avoid duplicate inclusion +if [[ -n "${bash_preexec_imported:-}" || -n "${__bp_imported:-}" ]]; then + return 0 +fi +bash_preexec_imported="defined" + +# WARNING: This variable is no longer used and should not be relied upon. +# Use ${bash_preexec_imported} instead. +# shellcheck disable=SC2034 +__bp_imported="${bash_preexec_imported}" + +# Should be available to each precmd and preexec +# functions, should they want it. $? and $_ are available as $? and $_, but +# $PIPESTATUS is available only in a copy, $BP_PIPESTATUS. +# TODO: Figure out how to restore PIPESTATUS before each precmd or preexec +# function. +__bp_last_ret_value="$?" +BP_PIPESTATUS=("${PIPESTATUS[@]}") +__bp_last_argument_prev_command="$_" + +__bp_inside_precmd=0 +__bp_inside_preexec=0 + +# Initial PROMPT_COMMAND string that is removed from PROMPT_COMMAND post __bp_install +__bp_install_string=$'__bp_trap_string="$(trap -p DEBUG)"\ntrap - DEBUG\n__bp_install' + +# Fails if any of the given variables are readonly +# Reference https://stackoverflow.com/a/4441178 +__bp_require_not_readonly() { + local var + for var; do + if ! ( unset "$var" 2> /dev/null ); then + echo "bash-preexec requires write access to ${var}" >&2 + return 1 + fi + done +} + +# Remove ignorespace and or replace ignoreboth from HISTCONTROL +# so we can accurately invoke preexec with a command from our +# history even if it starts with a space. +__bp_adjust_histcontrol() { + local histcontrol + histcontrol="${HISTCONTROL:-}" + histcontrol="${histcontrol//ignorespace}" + # Replace ignoreboth with ignoredups + if [[ "$histcontrol" == *"ignoreboth"* ]]; then + histcontrol="ignoredups:${histcontrol//ignoreboth}" + fi + export HISTCONTROL="$histcontrol" +} + +# This variable describes whether we are currently in "interactive mode"; +# i.e. whether this shell has just executed a prompt and is waiting for user +# input. It documents whether the current command invoked by the trace hook is +# run interactively by the user; it's set immediately after the prompt hook, +# and unset as soon as the trace hook is run. +__bp_preexec_interactive_mode="" + +# These arrays are used to add functions to be run before, or after, prompts. +declare -a precmd_functions +declare -a preexec_functions + +# Trims leading and trailing whitespace from $2 and writes it to the variable +# name passed as $1 +__bp_trim_whitespace() { + local var=${1:?} text=${2:-} + text="${text#"${text%%[![:space:]]*}"}" # remove leading whitespace characters + text="${text%"${text##*[![:space:]]}"}" # remove trailing whitespace characters + printf -v "$var" '%s' "$text" +} + + +# Trims whitespace and removes any leading or trailing semicolons from $2 and +# writes the resulting string to the variable name passed as $1. Used for +# manipulating substrings in PROMPT_COMMAND +__bp_sanitize_string() { + local var=${1:?} text=${2:-} sanitized + __bp_trim_whitespace sanitized "$text" + sanitized=${sanitized%;} + sanitized=${sanitized#;} + __bp_trim_whitespace sanitized "$sanitized" + printf -v "$var" '%s' "$sanitized" +} + +# This function is installed as part of the PROMPT_COMMAND; +# It sets a variable to indicate that the prompt was just displayed, +# to allow the DEBUG trap to know that the next command is likely interactive. +__bp_interactive_mode() { + __bp_preexec_interactive_mode="on" +} + + +# This function is installed as part of the PROMPT_COMMAND. +# It will invoke any functions defined in the precmd_functions array. +__bp_precmd_invoke_cmd() { + # Save the returned value from our last command, and from each process in + # its pipeline. Note: this MUST be the first thing done in this function. + # BP_PIPESTATUS may be unused, ignore + # shellcheck disable=SC2034 + + __bp_last_ret_value="$?" BP_PIPESTATUS=("${PIPESTATUS[@]}") + + # Don't invoke precmds if we are inside an execution of an "original + # prompt command" by another precmd execution loop. This avoids infinite + # recursion. + if (( __bp_inside_precmd > 0 )); then + return + fi + local __bp_inside_precmd=1 + + # Invoke every function defined in our function array. + local precmd_function + for precmd_function in "${precmd_functions[@]}"; do + + # Only execute this function if it actually exists. + # Test existence of functions with: declare -[Ff] + if type -t "$precmd_function" 1>/dev/null; then + __bp_set_ret_value "$__bp_last_ret_value" "$__bp_last_argument_prev_command" + # Quote our function invocation to prevent issues with IFS + "$precmd_function" + fi + done + + __bp_set_ret_value "$__bp_last_ret_value" +} + +# Sets a return value in $?. We may want to get access to the $? variable in our +# precmd functions. This is available for instance in zsh. We can simulate it in bash +# by setting the value here. +__bp_set_ret_value() { + return ${1:+"$1"} +} + +__bp_in_prompt_command() { + + local prompt_command_array IFS=$'\n;' + read -rd '' -a prompt_command_array <<< "${PROMPT_COMMAND[*]:-}" + + local trimmed_arg + __bp_trim_whitespace trimmed_arg "${1:-}" + + local command trimmed_command + for command in "${prompt_command_array[@]:-}"; do + __bp_trim_whitespace trimmed_command "$command" + if [[ "$trimmed_command" == "$trimmed_arg" ]]; then + return 0 + fi + done + + return 1 +} + +# This function is installed as the DEBUG trap. It is invoked before each +# interactive prompt display. Its purpose is to inspect the current +# environment to attempt to detect if the current command is being invoked +# interactively, and invoke 'preexec' if so. +__bp_preexec_invoke_exec() { + + # Save the contents of $_ so that it can be restored later on. + # https://stackoverflow.com/questions/40944532/bash-preserve-in-a-debug-trap#40944702 + __bp_last_argument_prev_command="${1:-}" + # Don't invoke preexecs if we are inside of another preexec. + if (( __bp_inside_preexec > 0 )); then + return + fi + local __bp_inside_preexec=1 + + # Checks if the file descriptor is not standard out (i.e. '1') + # __bp_delay_install checks if we're in test. Needed for bats to run. + # Prevents preexec from being invoked for functions in PS1 + if [[ ! -t 1 && -z "${__bp_delay_install:-}" ]]; then + return + fi + + if [[ -n "${COMP_POINT:-}" || -n "${READLINE_POINT:-}" ]]; then + # We're in the middle of a completer or a keybinding set up by "bind + # -x". This obviously can't be an interactively issued command. + return + fi + if [[ -z "${__bp_preexec_interactive_mode:-}" ]]; then + # We're doing something related to displaying the prompt. Let the + # prompt set the title instead of me. + return + else + # If we're in a subshell, then the prompt won't be re-displayed to put + # us back into interactive mode, so let's not set the variable back. + # In other words, if you have a subshell like + # (sleep 1; sleep 2) + # You want to see the 'sleep 2' as a set_command_title as well. + if [[ 0 -eq "${BASH_SUBSHELL:-}" ]]; then + __bp_preexec_interactive_mode="" + fi + fi + + if __bp_in_prompt_command "${BASH_COMMAND:-}"; then + # If we're executing something inside our prompt_command then we don't + # want to call preexec. Bash prior to 3.1 can't detect this at all :/ + __bp_preexec_interactive_mode="" + return + fi + + local this_command + this_command=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) + this_command="${this_command#*[[:digit:]][* ] }" + + # Sanity check to make sure we have something to invoke our function with. + if [[ -z "$this_command" ]]; then + return + fi + + # Invoke every function defined in our function array. + local preexec_function + local preexec_function_ret_value + local preexec_ret_value=0 + for preexec_function in "${preexec_functions[@]:-}"; do + + # Only execute each function if it actually exists. + # Test existence of function with: declare -[fF] + if type -t "$preexec_function" 1>/dev/null; then + __bp_set_ret_value "${__bp_last_ret_value:-}" + # Quote our function invocation to prevent issues with IFS + "$preexec_function" "$this_command" + preexec_function_ret_value="$?" + if [[ "$preexec_function_ret_value" != 0 ]]; then + preexec_ret_value="$preexec_function_ret_value" + fi + fi + done + + # Restore the last argument of the last executed command, and set the return + # value of the DEBUG trap to be the return code of the last preexec function + # to return an error. + # If `extdebug` is enabled a non-zero return value from any preexec function + # will cause the user's command not to execute. + # Run `shopt -s extdebug` to enable + __bp_set_ret_value "$preexec_ret_value" "$__bp_last_argument_prev_command" +} + +__bp_install() { + # Exit if we already have this installed. + if [[ "${PROMPT_COMMAND[*]:-}" == *"__bp_precmd_invoke_cmd"* ]]; then + return 1 + fi + + trap '__bp_preexec_invoke_exec "$_"' DEBUG + + # Preserve any prior DEBUG trap as a preexec function + eval "local trap_argv=(${__bp_trap_string:-})" + local prior_trap=${trap_argv[2]:-} + unset __bp_trap_string + if [[ -n "$prior_trap" ]]; then + eval '__bp_original_debug_trap() { + '"$prior_trap"' + }' + preexec_functions+=(__bp_original_debug_trap) + fi + + # Adjust our HISTCONTROL Variable if needed. + # + # GHOSTTY: Don't modify HISTCONTROL. This hack is only needed to improve the + # accuracy of the command argument passed to the preexec functions, and we + # don't use that argument in our bash shell integration script (and nor does + # the __bp_original_debug_trap function above, which is the only other active + # preexec function). + #__bp_adjust_histcontrol + + # Issue #25. Setting debug trap for subshells causes sessions to exit for + # backgrounded subshell commands (e.g. (pwd)& ). Believe this is a bug in Bash. + # + # Disabling this by default. It can be enabled by setting this variable. + if [[ -n "${__bp_enable_subshells:-}" ]]; then + + # Set so debug trap will work be invoked in subshells. + set -o functrace > /dev/null 2>&1 + shopt -s extdebug > /dev/null 2>&1 + fi + + local existing_prompt_command + # Remove setting our trap install string and sanitize the existing prompt command string + existing_prompt_command="${PROMPT_COMMAND:-}" + # Edge case of appending to PROMPT_COMMAND + existing_prompt_command="${existing_prompt_command//$__bp_install_string/:}" # no-op + existing_prompt_command="${existing_prompt_command//$'\n':$'\n'/$'\n'}" # remove known-token only + existing_prompt_command="${existing_prompt_command//$'\n':;/$'\n'}" # remove known-token only + __bp_sanitize_string existing_prompt_command "$existing_prompt_command" + if [[ "${existing_prompt_command:-:}" == ":" ]]; then + existing_prompt_command= + fi + + # Install our hooks in PROMPT_COMMAND to allow our trap to know when we've + # actually entered something. + PROMPT_COMMAND='__bp_precmd_invoke_cmd' + PROMPT_COMMAND+=${existing_prompt_command:+$'\n'$existing_prompt_command} + if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then + PROMPT_COMMAND+=('__bp_interactive_mode') + else + # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND+=$'\n__bp_interactive_mode' + fi + + # Add two functions to our arrays for convenience + # of definition. + precmd_functions+=(precmd) + preexec_functions+=(preexec) + + # Invoke our two functions manually that were added to $PROMPT_COMMAND + __bp_precmd_invoke_cmd + __bp_interactive_mode +} + +# Sets an installation string as part of our PROMPT_COMMAND to install +# after our session has started. This allows bash-preexec to be included +# at any point in our bash profile. +__bp_install_after_session_init() { + # bash-preexec needs to modify these variables in order to work correctly + # if it can't, just stop the installation + __bp_require_not_readonly PROMPT_COMMAND HISTCONTROL HISTTIMEFORMAT || return + + local sanitized_prompt_command + __bp_sanitize_string sanitized_prompt_command "${PROMPT_COMMAND:-}" + if [[ -n "$sanitized_prompt_command" ]]; then + # shellcheck disable=SC2178 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND=${sanitized_prompt_command}$'\n' + fi + # shellcheck disable=SC2179 # PROMPT_COMMAND is not an array in bash <= 5.0 + PROMPT_COMMAND+=${__bp_install_string} +} + +# Run our install so long as we're not delaying it. +if [[ -z "${__bp_delay_install:-}" ]]; then + __bp_install_after_session_init +fi diff --git a/resources/shell-integration/bash/ghostty.bash b/resources/shell-integration/bash/ghostty.bash new file mode 100644 index 0000000..48c8916 --- /dev/null +++ b/resources/shell-integration/bash/ghostty.bash @@ -0,0 +1,315 @@ +# Parts of this script are based on Kitty's bash integration. Kitty is +# distributed under GPLv3, so this file is also distributed under GPLv3. +# The license header is reproduced below: +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# We need to be in interactive mode to proceed. +if [[ "$-" != *i* ]]; then builtin return; fi + +# When automatic shell integration is active, we were started in POSIX +# mode and need to manually recreate the bash startup sequence. +if [ -n "$GHOSTTY_BASH_INJECT" ]; then + # Store a temporary copy of our startup flags and unset these global + # environment variables so we can safely handle reentrancy. + builtin declare __ghostty_bash_flags="$GHOSTTY_BASH_INJECT" + builtin unset ENV GHOSTTY_BASH_INJECT + + # Restore an existing ENV that was replaced by the shell integration code. + if [[ -n "$GHOSTTY_BASH_ENV" ]]; then + builtin export ENV=$GHOSTTY_BASH_ENV + builtin unset GHOSTTY_BASH_ENV + fi + + # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', + # which doesn't happen as part of the 'posix' reset. + builtin set +o posix + builtin shopt -u inherit_errexit 2>/dev/null + + # Unexport HISTFILE if it was set by the shell integration code. + if [[ -n "$GHOSTTY_BASH_UNEXPORT_HISTFILE" ]]; then + builtin export -n HISTFILE + builtin unset GHOSTTY_BASH_UNEXPORT_HISTFILE + fi + + # Manually source the startup files. See INVOCATION in bash(1) and + # run_startup_files() in shell.c in the Bash source code. + if builtin shopt -q login_shell; then + if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then + [ -r /etc/profile ] && builtin source "/etc/profile" + for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } + done + fi + else + if [[ $__ghostty_bash_flags != *"--norc"* ]]; then + # The location of the system bashrc is determined at bash build + # time via -DSYS_BASHRC and can therefore vary across distros: + # Arch, Debian, Ubuntu use /etc/bash.bashrc + # Fedora uses /etc/bashrc sourced from ~/.bashrc instead of SYS_BASHRC + # Void Linux uses /etc/bash/bashrc + # Nixos uses /etc/bashrc + for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } + done + if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi + [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" + fi + fi + + builtin unset __ghostty_rcfile + builtin unset __ghostty_bash_flags + builtin unset GHOSTTY_BASH_RCFILE +fi + +# Add Ghostty binary to PATH if the path feature is enabled +if [[ "$GHOSTTY_SHELL_FEATURES" == *"path"* && -n "$GHOSTTY_BIN_DIR" ]]; then + if [[ ":$PATH:" != *":$GHOSTTY_BIN_DIR:"* ]]; then + export PATH="$PATH:$GHOSTTY_BIN_DIR" + fi +fi + +# Sudo +if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then + # Wrap `sudo` command to ensure Ghostty terminfo is preserved. + # + # This approach supports wrapping a `sudo` alias, but the alias definition + # must come _after_ this function is defined. Otherwise, the alias expansion + # will take precedence over this function, and it won't be wrapped. + function sudo { + builtin local sudo_has_sudoedit_flags="no" + for arg in "$@"; do + # Check if argument is '-e' or '--edit' (sudoedit flags) + if [[ "$arg" == "-e" || $arg == "--edit" ]]; then + sudo_has_sudoedit_flags="yes" + builtin break + fi + # Check if argument is neither an option nor a key-value pair + if [[ "$arg" != -* && "$arg" != *=* ]]; then + builtin break + fi + done + if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then + builtin command sudo "$@" + else + builtin command sudo --preserve-env=TERMINFO "$@" + fi + } +fi + +# SSH Integration +if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then + function ssh() { + builtin local ssh_term ssh_opts + ssh_term="xterm-256color" + ssh_opts=() + + # Configure environment variables for remote session + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then + ssh_opts+=(-o "SetEnv COLORTERM=truecolor") + ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + fi + + # Install terminfo on remote host if needed + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then + builtin local ssh_user ssh_hostname + + while IFS=' ' read -r ssh_key ssh_value; do + case "$ssh_key" in + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; + esac + [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break + done < <(builtin command ssh -G "$@" 2>/dev/null) + + if [[ -n "$ssh_hostname" ]]; then + builtin local ssh_target="${ssh_user}@${ssh_hostname}" + + # Check if terminfo is already cached + if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then + ssh_term="xterm-ghostty" + elif builtin command -v infocmp >/dev/null 2>&1; then + builtin local ssh_terminfo ssh_cpath_dir ssh_cpath + + ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) + + if [[ -n "$ssh_terminfo" ]]; then + builtin echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 + + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" + + if builtin echo "$ssh_terminfo" | builtin command ssh -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + ssh_term="xterm-ghostty" + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + else + builtin echo "Warning: Failed to install terminfo." >&2 + fi + else + builtin echo "Warning: Could not generate terminfo data." >&2 + fi + else + builtin echo "Warning: ghostty command not available for cache management." >&2 + fi + fi + fi + + # Execute SSH with TERM environment variable + TERM="$ssh_term" builtin command ssh "${ssh_opts[@]}" "$@" + } +fi + +# This is set to 1 when we're executing a command so that we don't +# send prompt marks multiple times. +_ghostty_executing="" +_ghostty_last_reported_cwd="" + +function __ghostty_precmd() { + local ret="$?" + if test "$_ghostty_executing" != "0"; then + _GHOSTTY_SAVE_PS1="$PS1" + _GHOSTTY_SAVE_PS2="$PS2" + + # Use 133;P (not 133;A) inside PS1 to avoid fresh-line behavior on + # readline redraws (e.g., vi mode switches, Ctrl-L). The initial + # 133;A with fresh-line is emitted once via printf below. + PS1='\[\e]133;P;k=i\a\]'$PS1'\[\e]133;B\a\]' + PS2='\[\e]133;P;k=s\a\]'$PS2'\[\e]133;B\a\]' + + # Bash doesn't redraw the leading lines in a multiline prompt so we mark + # the start of each line (after each newline) as a secondary prompt. This + # correctly handles multiline prompts by setting the first to primary and + # the subsequent lines to secondary. + # + # We only replace the \n prompt escape, not literal newlines ($'\n'), + # because literal newlines may appear inside $(...) command substitutions + # where inserting escape sequences would break shell syntax. + if [[ "$PS1" == *"\n"* ]]; then + PS1="${PS1//\\n/\\n$'\\[\\e]133;P;k=s\\a\\]'}" + fi + + # Cursor + if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then + builtin local cursor=5 # blinking bar + [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && cursor=6 # steady bar + + [[ "$PS1" != *"\[\e[${cursor} q\]"* ]] && PS1=$PS1"\[\e[${cursor} q\]" + [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset + fi + + # Title (working directory) + if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + PS1=$PS1'\[\e]2;\w\a\]' + fi + fi + + if test "$_ghostty_executing" != ""; then + # End of current command. Report its status. + builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + fi + + # Fresh line and start of prompt. + builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID" + + # unfortunately bash provides no hooks to detect cwd changes + # in particular this means cwd reporting will not happen for a + # command like cd /test && cat. PS0 is evaluated before cd is run. + if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then + _ghostty_last_reported_cwd="$PWD" + builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + fi + + _ghostty_executing=0 +} + +function __ghostty_preexec() { + builtin local cmd="$1" + + PS1="$_GHOSTTY_SAVE_PS1" + PS2="$_GHOSTTY_SAVE_PS2" + + # Title (current command) + if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]/}" + fi + + # End of input, start of output. + builtin printf "\e]133;C;\a" + _ghostty_executing=1 +} + +if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then + __ghostty_preexec_hook() { + builtin local cmd + cmd=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1) + cmd="${cmd#*[[:digit:]][* ] }" # remove leading history number + [[ -n "$cmd" ]] && __ghostty_preexec "$cmd" + } + + # Use function substitution in 5.3+. Otherwise, use command substitution. + # Any output (including escape sequences) goes to the terminal. + # Only define if not already set (allows re-sourcing). + if [[ -z "${__ghostty_ps0+x}" ]]; then + if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then + # shellcheck disable=SC2016 + builtin readonly __ghostty_ps0='${ __ghostty_preexec_hook; }' + else + # shellcheck disable=SC2016 + builtin readonly __ghostty_ps0='$(__ghostty_preexec_hook >/dev/tty)' + fi + fi + + __ghostty_hook() { + builtin local ret=$? + __ghostty_precmd "$ret" + if [[ "$PS0" != *"$__ghostty_ps0"* ]]; then + PS0=$PS0"${__ghostty_ps0}" + fi + } + + # Append our hook to PROMPT_COMMAND, preserving its existing type. + # shellcheck disable=SC2128,SC2178,SC2179 + if [[ ";${PROMPT_COMMAND[*]:-};" != *";__ghostty_hook;"* ]]; then + if [[ -z "${PROMPT_COMMAND[*]}" ]]; then + if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then + PROMPT_COMMAND=(__ghostty_hook) + else + PROMPT_COMMAND="__ghostty_hook" + fi + elif [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == "declare -a "* ]]; then + PROMPT_COMMAND+=(__ghostty_hook) + else + [[ "${PROMPT_COMMAND}" =~ \;[[:space:]]*$ ]] || PROMPT_COMMAND+=";" + PROMPT_COMMAND+=" __ghostty_hook" + fi + fi +else + builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh" + preexec_functions+=(__ghostty_preexec) + precmd_functions+=(__ghostty_precmd) +fi diff --git a/resources/shell-integration/elvish/lib/ghostty-integration.elv b/resources/shell-integration/elvish/lib/ghostty-integration.elv new file mode 100644 index 0000000..87fd0a9 --- /dev/null +++ b/resources/shell-integration/elvish/lib/ghostty-integration.elv @@ -0,0 +1,191 @@ +{ + use platform + use str + + # Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR + if (and (has-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR) (has-env XDG_DATA_DIRS)) { + set-env XDG_DATA_DIRS (str:replace $E:GHOSTTY_SHELL_INTEGRATION_XDG_DIR":" "" $E:XDG_DATA_DIRS) + unset-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR + } + + # List of enabled shell integration features + var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)] + + # State tracking for semantic prompt sequences + # Values: 'prompt-start', 'pre-exec', 'post-exec' + fn set-prompt-state {|new| set-env __ghostty_prompt_state $new } + + fn mark-prompt-start { + if (not-eq $E:__ghostty_prompt_state 'prompt-start') { + printf "\e]133;D;aid="$pid"\a" + } + set-prompt-state 'prompt-start' + printf "\e]133;A;aid="$pid"\a" + } + + fn mark-output-start {|_| + set-prompt-state 'pre-exec' + printf "\e]133;C\a" + } + + fn mark-output-end {|cmd-info| + set-prompt-state 'post-exec' + + var exit-status = 0 + + # in case of error: retrieve exit status, + # unless does not exist (= builtin function failure), then default to 1 + if (not-eq $nil $cmd-info[error]) { + set exit-status = 1 + + if (has-key $cmd-info[error] reason) { + if (has-key $cmd-info[error][reason] exit-status) { + set exit-status = $cmd-info[error][reason][exit-status] + } + } + } + + printf "\e]133;D;"$exit-status";aid="$pid"\a" + } + + # NOTE: OSC 133;B (end of prompt, start of input) cannot be reliably + # implemented at the script level in Elvish. The prompt function's output is + # escaped, and writing to /dev/tty has timing issues because Elvish renders + # its prompts on a background thread. Full semantic prompt support requires a + # native implementation: https://github.com/elves/elvish/pull/1917 + + fn sudo-with-terminfo {|@args| + var sudoedit = $false + for arg $args { + if (str:has-prefix $arg --) { + if (eq $arg --edit) { + set sudoedit = $true + break + } + } elif (str:has-prefix $arg -) { + if (str:contains (str:trim-prefix $arg -) e) { + set sudoedit = $true + break + } + } elif (not (str:contains $arg =)) { + break + } + } + + if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] } + (external sudo) $@args + } + + fn ssh-integration {|@args| + var ssh-term = "xterm-256color" + var ssh-opts = [] + + # Configure environment variables for remote session + if (has-value $features ssh-env) { + set ssh-opts = (conj $ssh-opts ^ + -o "SetEnv COLORTERM=truecolor" ^ + -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + } + + if (has-value $features ssh-terminfo) { + var ssh-user = "" + var ssh-hostname = "" + + # Parse ssh config + for line [((external ssh) -G $@args)] { + var parts = [(str:fields $line)] + if (> (count $parts) 1) { + var ssh-key = $parts[0] + var ssh-value = $parts[1] + if (eq $ssh-key user) { + set ssh-user = $ssh-value + } elif (eq $ssh-key hostname) { + set ssh-hostname = $ssh-value + } + if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { + break + } + } + } + + if (not-eq $ssh-hostname "") { + var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty" + var ssh-target = $ssh-user"@"$ssh-hostname + + # Check if terminfo is already cached + if (bool ?($ghostty +ssh-cache --host=$ssh-target)) { + set ssh-term = "xterm-ghostty" + } elif (has-external infocmp) { + var ssh-terminfo = ((external infocmp) -0 -x xterm-ghostty 2>/dev/null | slurp) + + if (not-eq $ssh-terminfo "") { + echo "Setting up xterm-ghostty terminfo on "$ssh-hostname"..." >&2 + + use os + var ssh-cpath-dir = (os:temp-dir "ghostty-ssh-"$ssh-user".*") + var ssh-cpath = $ssh-cpath-dir"/socket" + + if (bool ?(echo $ssh-terminfo | (external ssh) $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null)) { + set ssh-term = "xterm-ghostty" + set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath) + + # Cache successful installation + $ghostty +ssh-cache --add=$ssh-target >/dev/null + } else { + echo "Warning: Failed to install terminfo." >&2 + } + } else { + echo "Warning: Could not generate terminfo data." >&2 + } + } else { + echo "Warning: ghostty command not available for cache management." >&2 + } + } + } + + with [E:TERM = $ssh-term] { + (external ssh) $@ssh-opts $@args + } + } + + defer { + mark-prompt-start + } + + set edit:before-readline = (conj $edit:before-readline $mark-prompt-start~) + set edit:after-readline = (conj $edit:after-readline $mark-output-start~) + set edit:after-command = (conj $edit:after-command $mark-output-end~) + + if (str:contains $E:GHOSTTY_SHELL_FEATURES "cursor") { + var cursor = "5" # blinking bar + if (has-value $features cursor:steady) { + set cursor = "6" # steady bar + } + + fn beam { printf "\e["$cursor" q" } + fn reset { printf "\e[0 q" } + set edit:before-readline = (conj $edit:before-readline $beam~) + set edit:after-readline = (conj $edit:after-readline {|_| reset }) + } + if (and (has-value $features path) (has-env GHOSTTY_BIN_DIR)) { + if (not (has-value $paths $E:GHOSTTY_BIN_DIR)) { + set paths = [$@paths $E:GHOSTTY_BIN_DIR] + } + } + if (and (has-value $features sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) { + edit:add-var sudo~ $sudo-with-terminfo~ + } + if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) (has-external ssh)) { + edit:add-var ssh~ $ssh-integration~ + } + + # Report changes to the current directory. + fn report-pwd { printf "\e]7;kitty-shell-cwd://%s%s\a" (platform:hostname) $pwd } + set after-chdir = (conj $after-chdir {|_| report-pwd }) + report-pwd +} diff --git a/resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish new file mode 100644 index 0000000..3f1f609 --- /dev/null +++ b/resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -0,0 +1,246 @@ +# This shell script aims to be written in a way where it can't really fail +# or all failure scenarios are handled, so that we never leave the shell in +# a weird state. If you find a way to break this, please report a bug! + +function ghostty_restore_xdg_data_dir -d "restore the original XDG_DATA_DIR value" + # If we don't have our own data dir then we don't need to do anything. + if not set -q GHOSTTY_SHELL_INTEGRATION_XDG_DIR + return + end + + # If the data dir isn't set at all then we don't need to do anything. + if not set -q XDG_DATA_DIRS + return + end + + # We need to do this so that XDG_DATA_DIRS turns into an array. + set --function --path xdg_data_dirs "$XDG_DATA_DIRS" + + # If our data dir is in the list then remove it. + if set --function index (contains --index "$GHOSTTY_SHELL_INTEGRATION_XDG_DIR" $xdg_data_dirs) + set --erase --function xdg_data_dirs[$index] + end + + # Re-export our data dir + if set -q xdg_data_dirs[1] + set --global --export --unpath XDG_DATA_DIRS "$xdg_data_dirs" + else + set --erase --global XDG_DATA_DIRS + end + + set --erase GHOSTTY_SHELL_INTEGRATION_XDG_DIR +end + +function ghostty_exit -d "exit the shell integration setup" + functions -e ghostty_restore_xdg_data_dir + functions -e ghostty_exit + exit 0 +end + +# We always try to restore the XDG data dir +ghostty_restore_xdg_data_dir + +# If we aren't interactive or we've already run, don't run. +status --is-interactive || ghostty_exit + +# We do the full setup on the first prompt render. We do this so that other +# shell integrations that setup the prompt and modify things are able to run +# first. We want to run _last_. +function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" + functions -e __ghostty_setup + + set --local features (string split , $GHOSTTY_SHELL_FEATURES) + + # Parse the fish version for feature detection. + # Default to 0.0 if version is unavailable or malformed. + set -l fish_major 0 + set -l fish_minor 0 + if set -q version[1] + set -l fish_ver (string match -r '(\d+)\.(\d+)' -- $version[1]) + if set -q fish_ver[2]; and test -n "$fish_ver[2]" + set fish_major "$fish_ver[2]" + end + if set -q fish_ver[3]; and test -n "$fish_ver[3]" + set fish_minor "$fish_ver[3]" + end + end + + # Our OSC133A (prompt start) sequence. If we're using Fish >= 4.1 + # then it supports click_events so we enable that. + set -g __ghostty_prompt_start_mark "\e]133;A\a" + if test "$fish_major" -gt 4; or test "$fish_major" -eq 4 -a "$fish_minor" -ge 1 + set -g __ghostty_prompt_start_mark "\e]133;A;click_events=1\a" + end + + if string match -q 'cursor*' -- $features + set -l cursor 5 # blinking bar + contains cursor:steady $features && set cursor 6 # steady bar + + # Change the cursor to a beam on prompt. + function __ghostty_set_cursor_beam --on-event fish_prompt -V cursor -d "Set cursor shape" + if not functions -q fish_vi_cursor_handle + echo -en "\e[$cursor q" + end + end + function __ghostty_reset_cursor --on-event fish_preexec -d "Reset cursor shape" + if not functions -q fish_vi_cursor_handle + echo -en "\e[0 q" + end + end + end + + # Add Ghostty binary to PATH if the path feature is enabled + if contains path $features; and test -n "$GHOSTTY_BIN_DIR" + fish_add_path --global --path --append "$GHOSTTY_BIN_DIR" + end + + # When using sudo shell integration feature, ensure $TERMINFO is set + # and `sudo` is not already a function or alias + if contains sudo $features; and test -n "$TERMINFO"; and test file = (type -t sudo 2> /dev/null; or echo "x") + # Wrap `sudo` command to ensure Ghostty terminfo is preserved + function sudo -d "Wrap sudo to preserve terminfo" + set --function sudo_has_sudoedit_flags no + for arg in $argv + # Check if argument is '-e' or '--edit' (sudoedit flags) + if string match -q -- -e "$arg"; or string match -q -- --edit "$arg" + set --function sudo_has_sudoedit_flags yes + break + end + # Check if argument is neither an option nor a key-value pair + if not string match -r -q -- "^-" "$arg"; and not string match -r -q -- "=" "$arg" + break + end + end + if test "$sudo_has_sudoedit_flags" = yes + command sudo $argv + else + command sudo --preserve-env=TERMINFO $argv + end + end + end + + # SSH Integration + set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") + if contains ssh-env $features; or contains ssh-terminfo $features + function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" + set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") + set -l ssh_term xterm-256color + set -l ssh_opts + + # Configure environment variables for remote session + if contains ssh-env $features + set -a ssh_opts -o "SetEnv COLORTERM=truecolor" + set -a ssh_opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" + end + + # Install terminfo on remote host if needed + if contains ssh-terminfo $features + set -l ssh_user + set -l ssh_hostname + + for line in (command ssh -G $argv 2>/dev/null) + set -l parts (string split ' ' -- $line) + if test (count $parts) -ge 2 + switch $parts[1] + case user + set ssh_user $parts[2] + case hostname + set ssh_hostname $parts[2] + end + if test -n "$ssh_user"; and test -n "$ssh_hostname" + break + end + end + end + + if test -n "$ssh_hostname" + set -l ssh_target "$ssh_user@$ssh_hostname" + + # Check if terminfo is already cached + if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + set ssh_term xterm-ghostty + else if command -q infocmp + set -l ssh_terminfo + set -l ssh_cpath_dir + set -l ssh_cpath + + set ssh_terminfo "$(infocmp -0 -x xterm-ghostty 2>/dev/null)" + + if test -n "$ssh_terminfo" + echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 + + set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) + set ssh_cpath "$ssh_cpath_dir/socket" + + if echo "$ssh_terminfo" | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null + set ssh_term xterm-ghostty + set -a ssh_opts -o "ControlPath=$ssh_cpath" + + # Cache successful installation + if test -x "$GHOSTTY_BIN_DIR/ghostty" + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true + end + else + echo "Warning: Failed to install terminfo." >&2 + end + else + echo "Warning: Could not generate terminfo data." >&2 + end + else + echo "Warning: ghostty command not available for cache management." >&2 + end + end + end + + # Execute SSH with TERM environment variable + TERM="$ssh_term" command ssh $ssh_opts $argv + end + end + + # Setup prompt marking + function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_posterror + # If we never got the output end event, then we need to send it now. + if test "$__ghostty_prompt_state" != prompt-start + echo -en "\e]133;D\a" + end + + set --global __ghostty_prompt_state prompt-start + echo -en $__ghostty_prompt_start_mark + end + + function __ghostty_mark_output_start --on-event fish_preexec + set --global __ghostty_prompt_state pre-exec + echo -en "\e]133;C\a" + end + + function __ghostty_mark_output_end --on-event fish_postexec + set --global __ghostty_prompt_state post-exec + echo -en "\e]133;D;$status\a" + end + + # Report pwd. This is actually built-in to fish but only for terminals + # that match an allowlist and that isn't us. + function __update_cwd_osc --on-variable PWD -d 'Notify capable terminals when $PWD changes' + if status --is-command-substitution || set -q INSIDE_EMACS + return + end + printf \e\]7\;file://%s%s\a $hostname (string escape --style=url $PWD) + end + + # Enable fish to handle reflow because Ghostty clears the prompt on resize. + set --global fish_handle_reflow 1 + + # Initial calls for first prompt + if string match -q 'cursor*' -- $features + __ghostty_set_cursor_beam + end + __ghostty_mark_prompt_start + __update_cwd_osc +end + +ghostty_exit diff --git a/resources/shell-integration/nushell/vendor/autoload/ghostty.nu b/resources/shell-integration/nushell/vendor/autoload/ghostty.nu new file mode 100644 index 0000000..78c981e --- /dev/null +++ b/resources/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -0,0 +1,110 @@ +# Ghostty shell integration +export module ghostty { + def has_feature [feature: string] { + $feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',') + } + + # Wrap `ssh` with Ghostty TERMINFO support + export def --wrapped ssh [...args] { + mut ssh_env = {} + mut ssh_opts = [] + + # `ssh-env`: use xterm-256color and propagate COLORTERM/TERM_PROGRAM vars + if (has_feature "ssh-env") { + $ssh_env.TERM = "xterm-256color" + $ssh_opts = [ + "-o" "SetEnv COLORTERM=truecolor" + "-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" + ] + } + + # `ssh-terminfo`: auto-install xterm-ghostty terminfo on remote hosts + if (has_feature "ssh-terminfo") { + let ghostty = ($env.GHOSTTY_BIN_DIR? | default "") | path join "ghostty" + + let ssh_cfg = ^ssh -G ...$args + | lines + | parse "{key} {value}" + | where key in ["user" "hostname"] + | select key value + | transpose -rd + | default {user: $env.USER hostname: "localhost"} + let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)" + + if (^$ghostty "+ssh-cache" $"--host=($ssh_id)" | complete | $in.exit_code == 0) { + $ssh_env.TERM = "xterm-ghostty" + } else { + $ssh_env.TERM = "xterm-256color" + + let terminfo = try { + ^infocmp -0 -x xterm-ghostty + } catch { + print -e "infocmp failed, using xterm-256color" + } + + if ($terminfo | is-not-empty) { + print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..." + + let ctrl_path = ( + mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX" + | path join "socket" + ) + + let remote_args = $ssh_opts ++ [ + "-o" "ControlMaster=yes" + "-o" $"ControlPath=($ctrl_path)" + "-o" "ControlPersist=60s" + ] ++ $args + + $terminfo | ^ssh ...$remote_args ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1' + | complete + | if $in.exit_code == 0 { + ^$ghostty "+ssh-cache" $"--add=($ssh_id)" e>| print -e + $ssh_env.TERM = "xterm-ghostty" + $ssh_opts = ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"]) + } else { + print -e "terminfo install failed, using xterm-256color" + } + } + } + } + + let ssh_args = $ssh_opts ++ $args + with-env $ssh_env { + ^ssh ...$ssh_args + } + } + + # Wrap `sudo` to preserve Ghostty's TERMINFO environment variable + export def --wrapped sudo [...args] { + mut sudo_args = $args + + if (has_feature "sudo") { + # Extract just the sudo options (before the command) + let sudo_options = ( + $args | take until {|arg| + not (($arg | str starts-with "-") or ($arg | str contains "=")) + } + ) + + # Prepend TERMINFO preservation flag if not using sudoedit + if (not ("-e" in $sudo_options or "--edit" in $sudo_options)) { + $sudo_args = ($args | prepend "--preserve-env=TERMINFO") + } + } + + ^sudo ...$sudo_args + } +} + +# Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR +if 'GHOSTTY_SHELL_INTEGRATION_XDG_DIR' in $env { + if 'XDG_DATA_DIRS' in $env { + $env.XDG_DATA_DIRS = ($env.XDG_DATA_DIRS | str replace $"($env.GHOSTTY_SHELL_INTEGRATION_XDG_DIR):" "") + } + hide-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR +} diff --git a/resources/shell-integration/zsh/.zshenv b/resources/shell-integration/zsh/.zshenv new file mode 100644 index 0000000..62dcf27 --- /dev/null +++ b/resources/shell-integration/zsh/.zshenv @@ -0,0 +1,61 @@ +# Based on (started as) a copy of Kitty's zsh integration. Kitty is +# distributed under GPLv3, so this file is also distributed under GPLv3. +# The license header is reproduced below: +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# This script is sourced automatically by zsh when ZDOTDIR is set to this +# directory. It therefore assumes it's running within our shell integration +# environment and should not be sourced manually (unlike ghostty-integration). +# +# This file can get sourced with aliases enabled. To avoid alias expansion +# we quote everything that can be quoted. Some aliases will still break us +# though. + +# Restore the original ZDOTDIR value if GHOSTTY_ZSH_ZDOTDIR is set. +# Otherwise, unset the ZDOTDIR that was set during shell injection. +if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then + 'builtin' 'export' ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" + 'builtin' 'unset' 'GHOSTTY_ZSH_ZDOTDIR' +else + 'builtin' 'unset' 'ZDOTDIR' +fi + +# Use try-always to have the right error code. +{ + # Zsh treats unset ZDOTDIR as if it was HOME. We do the same. + # + # Source the user's .zshenv before sourcing ghostty-integration because the + # former might set fpath and other things without which ghostty-integration + # won't work. + # + # Use typeset in case we are in a function with warn_create_global in + # effect. Unlikely but better safe than sorry. + 'builtin' 'typeset' _ghostty_file=${ZDOTDIR-$HOME}"/.zshenv" + # Zsh ignores unreadable rc files. We do the same. + # Zsh ignores rc files that are directories, and so does source. + [[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file" +} always { + if [[ -o 'interactive' ]]; then + # ${(%):-%x} is the path to the current file. + # On top of it we add :A:h to get the directory. + 'builtin' 'typeset' _ghostty_file="${${(%):-%x}:A:h}"/ghostty-integration + if [[ -r "$_ghostty_file" ]]; then + 'builtin' 'autoload' '-Uz' '--' "$_ghostty_file" + "${_ghostty_file:t}" + 'builtin' 'unfunction' '--' "${_ghostty_file:t}" + fi + fi + 'builtin' 'unset' '_ghostty_file' +} diff --git a/resources/shell-integration/zsh/ghostty-integration b/resources/shell-integration/zsh/ghostty-integration new file mode 100644 index 0000000..dc9bd16 --- /dev/null +++ b/resources/shell-integration/zsh/ghostty-integration @@ -0,0 +1,458 @@ +# vim:ft=zsh +# +# Based on (started as) a copy of Kitty's zsh integration. Kitty is +# distributed under GPLv3, so this file is also distributed under GPLv3. +# The license header is reproduced below: +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# +# Enables integration between zsh and ghostty. +# +# This is an autoloadable function. It's invoked automatically in shells +# directly spawned by Ghostty but not in any other shells. For example, running +# `exec zsh`, `sudo -E zsh`, `tmux`, or plain `zsh` will create a shell where +# ghostty-integration won't automatically run. Zsh users who want integration with +# Ghostty in all shells should add the following lines to their .zshrc: +# +# if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then +# source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration +# fi +# +# Implementation note: We can assume that alias expansion is disabled in this +# file, so no need to quote defensively. We still have to defensively prefix all +# builtins with `builtin` to avoid accidentally invoking user-defined functions. +# We avoid `function` reserved word as an additional defensive measure. + +# Note that updating options with `builtin emulate -L zsh` affects the global options +# if it's called outside of a function. So nearly all code has to be in functions. +_entrypoint() { + builtin emulate -L zsh -o no_warn_create_global -o no_aliases + + [[ -o interactive ]] || builtin return 0 # non-interactive shell + (( ! $+_ghostty_state )) || builtin return 0 # already initialized + + # We require zsh 5.1+ (released Sept 2015) for features like functions_source, + # introspection arrays, and array pattern substitution. + if ! { builtin autoload -- is-at-least 2>/dev/null && is-at-least 5.1; }; then + builtin echo "Zsh ${ZSH_VERSION} is too old for ghostty shell integration (5.1+ required)" >&2 + builtin return 1 + fi + + # 0: no OSC 133 [AC] marks have been written yet. + # 1: the last written OSC 133 C has not been closed with D yet. + # 2: none of the above. + builtin typeset -gi _ghostty_state + + # Attempt to create a writable file descriptor to the TTY so that we can print + # to the TTY later even when STDOUT is redirected. This code is fairly subtle. + # + # - It's tempting to do `[[ -t 1 ]] && exec {_ghostty_state}>&1` but we cannot do this + # because it'll create a file descriptor >= 10 without O_CLOEXEC. This file + # descriptor will leak to child processes. + # - If we do `exec {3}>&1`, the file descriptor won't leak to the child processes + # but it'll still leak if the current process is replaced with another. In + # addition, it'll break user code that relies on fd 3 being available. + # - Zsh doesn't expose dup3, which would have allowed us to copy STDOUT with + # O_CLOEXEC. The only way to create a file descriptor with O_CLOEXEC is via + # sysopen. + # - `zmodload zsh/system` and `sysopen -o cloexec -wu _ghostty_fd -- /dev/tty` can + # fail with an error message to STDERR (the latter can happen even if /dev/tty + # is writable), hence the redirection of STDERR. We do it for the whole block + # for performance reasons (redirections are slow). + # - We must open the file descriptor right here rather than in _ghostty_deferred_init + # because there are broken zsh plugins out there that run `exec {fd}< <(cmd)` + # and then close the file descriptor more than once while suppressing errors. + # This could end up closing our file descriptor if we opened it in + # _ghostty_deferred_init. + typeset -gi _ghostty_fd + { + builtin zmodload zsh/system && (( $+builtins[sysopen] )) && { + { [[ -w $TTY ]] && builtin sysopen -o cloexec -wu _ghostty_fd -- $TTY } || + { [[ -w /dev/tty ]] && builtin sysopen -o cloexec -wu _ghostty_fd -- /dev/tty } + } + } 2>/dev/null || (( _ghostty_fd = 1 )) + + # Defer initialization so that other zsh init files can be configure + # the integration. + builtin typeset -ag precmd_functions + precmd_functions+=(_ghostty_deferred_init) +} + +_ghostty_deferred_init() { + builtin emulate -L zsh -o no_warn_create_global -o no_aliases + + # Enable semantic markup with OSC 133. + _ghostty_precmd() { + builtin local -i cmd_status=$? + builtin emulate -L zsh -o no_warn_create_global -o no_aliases + + # Don't write OSC 133 D when our precmd handler is invoked from zle. + # Some plugins do that to update prompt on cd. + if ! builtin zle; then + # This code works incorrectly in the presence of a precmd or chpwd + # hook that prints. For example, sindresorhus/pure prints an empty + # line on precmd and marlonrichert/zsh-snap prints $PWD on chpwd. + # We'll end up writing our OSC 133 D mark too late. + # + # Another failure mode is when the output of a command doesn't end + # with LF and prompst_sp is set (it is by default). In this case + # we'll incorrectly state that '%' from prompt_sp is a part of the + # command's output. + if (( _ghostty_state == 1 )); then + # The last written OSC 133 C has not been closed with D yet. + # Close it and supply status. + builtin print -nu $_ghostty_fd '\e]133;D;'$cmd_status'\a' + (( _ghostty_state = 2 )) + elif (( _ghostty_state == 2 )); then + # There might be an unclosed OSC 133 C. Close that. + builtin print -nu $_ghostty_fd '\e]133;D\a' + fi + fi + + builtin local mark1=$'%{\e]133;A;cl=line\a%}' + if [[ -o prompt_percent ]]; then + builtin typeset -g precmd_functions + if [[ ${precmd_functions[-1]} == _ghostty_precmd ]]; then + # This is the best case for us: we can add our marks to PS1 and + # PS2. This way our marks will be printed whenever zsh + # redisplays prompt: on reset-prompt, on SIGWINCH, and on + # SIGCHLD if notify is set. Themes that update prompt + # asynchronously from a `zle -F` handler might still remove our + # marks. Oh well. + + # Restore PS1/PS2 to their pre-mark state if nothing else has + # modified them since we last added marks. This avoids exposing + # PS1 with our marks to other hooks (which can break themes like + # Pure that use pattern matching to strip/rebuild the prompt). + # If PS1 was modified (by a theme, async update, etc.), we + # keep the modified version, prioritizing the theme's changes. + builtin local ps1_changed=0 + if [[ -n ${_ghostty_saved_ps1+x} ]]; then + if [[ $PS1 == $_ghostty_marked_ps1 ]]; then + PS1=$_ghostty_saved_ps1 + PS2=$_ghostty_saved_ps2 + elif [[ $PS1 != $_ghostty_saved_ps1 ]]; then + ps1_changed=1 + fi + fi + + # Save the clean PS1/PS2 before we add marks. + _ghostty_saved_ps1=$PS1 + _ghostty_saved_ps2=$PS2 + + # Add our marks. Since we always start from a clean PS1 + # (either restored above or freshly set by a theme), we can + # unconditionally add mark1 and markB. + builtin local mark2=$'%{\e]133;A;k=s\a%}' + builtin local markB=$'%{\e]133;B\a%}' + PS1=${mark1}${PS1}${markB} + + # Handle multiline prompts by marking newline-separated + # continuation lines with k=s (mark2). We skip the newline + # immediately after mark1 to avoid introducing a double + # newline due to OSC 133;A's fresh-line behavior. + # + # We skip this when PS1 changed because injecting marks into + # newlines can break pattern matching in themes that + # strip/rebuild the prompt dynamically (e.g., Pure). + if (( ! ps1_changed )) && [[ $PS1 == *$'\n'* ]]; then + if [[ $PS1 == ${mark1}$'\n'* ]]; then + builtin local rest=${PS1#${mark1}$'\n'} + if [[ $rest == *$'\n'* ]]; then + PS1=${mark1}$'\n'${rest//$'\n'/$'\n'${mark2}} + fi + else + PS1=${PS1//$'\n'/$'\n'${mark2}} + fi + fi + + # PS2 mark is needed when clearing the prompt on resize + PS2=${mark2}${PS2}${markB} + + # Save the marked PS1 so we can detect modifications + # by other hooks in the next cycle. + _ghostty_marked_ps1=$PS1 + (( _ghostty_state = 2 )) + else + # If our precmd hook is not the last, we cannot rely on prompt + # changes to stick, so we don't even try. At least we can move + # our hook to the end to have better luck next time. If there is + # another piece of code that wants to take this privileged + # position, this won't work well. We'll break them as much as + # they are breaking us. + precmd_functions=(${precmd_functions:#_ghostty_precmd} _ghostty_precmd) + # Plugins that invoke precmd hooks from zle do that before zle + # is trashed. This means that the cursor is in the middle of + # BUFFER and we cannot print our mark there. Prompt might + # already have a mark, so the following reset-prompt will write + # it. If it doesn't, there is nothing we can do. + if ! builtin zle; then + builtin print -rnu $_ghostty_fd -- $mark1[3,-3] + (( _ghostty_state = 2 )) + fi + fi + elif ! builtin zle; then + # Without prompt_percent we cannot patch prompt. Just print the + # mark, except when we are invoked from zle. In the latter case we + # cannot do anything. + builtin print -rnu $_ghostty_fd -- $mark1[3,-3] + (( _ghostty_state = 2 )) + fi + } + + _ghostty_preexec() { + builtin emulate -L zsh -o no_warn_create_global -o no_aliases + + # Restore the original PS1/PS2 if nothing else has modified them + # since our precmd added marks. This ensures other preexec hooks + # see a clean PS1 without our marks. If PS1 was modified (e.g., + # by an async theme update), we leave it alone. + if [[ -n ${_ghostty_saved_ps1+x} && $PS1 == $_ghostty_marked_ps1 ]]; then + PS1=$_ghostty_saved_ps1 + PS2=$_ghostty_saved_ps2 + fi + + # This will work incorrectly in the presence of a preexec hook that + # prints. For example, if MichaelAquilina/zsh-you-should-use installs + # its preexec hook before us, we'll incorrectly mark its output as + # belonging to the command (as if the user typed it into zle) rather + # than command output. + builtin print -nu $_ghostty_fd '\e]133;C\a' + (( _ghostty_state = 1 )) + } + + # Enable reporting current working dir to terminal. Ghostty supports + # the kitty-shell-cwd format. + _ghostty_report_pwd() { builtin print -nu $_ghostty_fd '\e]7;kitty-shell-cwd://'"$HOST""$PWD"'\a'; } + chpwd_functions=(${chpwd_functions[@]} "_ghostty_report_pwd") + # An executed program could change cwd and report the changed cwd, so also report cwd at each new prompt + # as in this case chpwd_functions is insufficient. chpwd_functions is still needed for things like: cd x && something + functions[_ghostty_precmd]+=" + _ghostty_report_pwd" + _ghostty_report_pwd + + if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + # Enable terminal title changes, formatted for user-friendly display. + functions[_ghostty_precmd]+=" + builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${(%):-%(4~|…/%3~|%~)}\"\$'\\a'" + functions[_ghostty_preexec]+=" + builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${1//[[:cntrl:]]}\"\$'\\a'" + fi + + if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then + # Enable cursor shape changes depending on the current keymap. + # This implementation leaks blinking block cursor into external commands + # executed from zle. For example, users of fzf-based widgets may find + # themselves with a blinking block cursor within fzf. + _ghostty_zle_line_init _ghostty_zle_line_finish _ghostty_zle_keymap_select() { + builtin local steady=0 + [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && steady=1 + case ${KEYMAP-} in + vicmd|visual) builtin print -nu "$_ghostty_fd" "\e[$(( 1 + steady )) q" ;; # block + *) builtin print -nu "$_ghostty_fd" "\e[$(( 5 + steady )) q" ;; # bar + esac + } + # Restore the default shape before executing an external command + functions[_ghostty_preexec]+=" + builtin print -rnu $_ghostty_fd \$'\\e[0 q'" + fi + + # Emit semantic prompt markers at line-init if PS1 doesn't contain our + # marks. This ensures the terminal sees prompt markers even if another + # plugin (like zinit or oh-my-posh) regenerated PS1 after our precmd ran. + # We use 133;P instead of 133;A to avoid fresh-line behavior which would + # disrupt the display since the prompt has already been drawn. We also + # emit 133;B to mark the input area, which is needed for click-to-move. + (( $+functions[_ghostty_zle_line_init] )) || _ghostty_zle_line_init() { builtin true; } + functions[_ghostty_zle_line_init]=" + if [[ \$PS1 != *$'%{\\e]133;A'* ]]; then + builtin print -nu \$_ghostty_fd '\\e]133;P;k=i\\a\\e]133;B\\a' + fi + "${functions[_ghostty_zle_line_init]} + + # Add Ghostty binary to PATH if the path feature is enabled + if [[ "$GHOSTTY_SHELL_FEATURES" == *"path"* ]] && [[ -n "$GHOSTTY_BIN_DIR" ]]; then + if [[ ":$PATH:" != *":$GHOSTTY_BIN_DIR:"* ]]; then + builtin export PATH="$PATH:$GHOSTTY_BIN_DIR" + fi + fi + + # Sudo + if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* ]] && [[ -n "$TERMINFO" ]]; then + # Wrap `sudo` command to ensure Ghostty terminfo is preserved + function sudo() { + builtin local sudo_has_sudoedit_flags="no" + for arg in "$@"; do + # Check if argument is '-e' or '--edit' (sudoedit flags) + if [[ "$arg" == "-e" || $arg == "--edit" ]]; then + sudo_has_sudoedit_flags="yes" + builtin break + fi + # Check if argument is neither an option nor a key-value pair + if [[ "$arg" != -* && "$arg" != *=* ]]; then + builtin break + fi + done + if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then + builtin command sudo "$@"; + else + builtin command sudo --preserve-env=TERMINFO "$@"; + fi + } + fi + + # SSH Integration + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then + function ssh() { + emulate -L zsh + setopt local_options no_glob_subst + + local ssh_term ssh_opts + ssh_term="xterm-256color" + ssh_opts=() + + # Configure environment variables for remote session + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then + ssh_opts+=(-o "SetEnv COLORTERM=truecolor") + ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + fi + + # Install terminfo on remote host if needed + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then + local ssh_user ssh_hostname + + while IFS=' ' read -r ssh_key ssh_value; do + case "$ssh_key" in + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; + esac + [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break + done < <(command ssh -G "$@" 2>/dev/null) + + if [[ -n "$ssh_hostname" ]]; then + local ssh_target="${ssh_user}@${ssh_hostname}" + + # Check if terminfo is already cached + if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then + ssh_term="xterm-ghostty" + elif (( $+commands[infocmp] )); then + local ssh_terminfo ssh_cpath_dir ssh_cpath + + ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) + + if [[ -n "$ssh_terminfo" ]]; then + print "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 + + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" + + if builtin print -r "$ssh_terminfo" | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + ssh_term="xterm-ghostty" + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + else + print "Warning: Failed to install terminfo." >&2 + fi + else + print "Warning: Could not generate terminfo data." >&2 + fi + else + print "Warning: ghostty command not available for cache management." >&2 + fi + fi + fi + + # Execute SSH with TERM environment variable + TERM="$ssh_term" command ssh "${ssh_opts[@]}" "$@" + } + fi + + # Some zsh users manually run `source ~/.zshrc` in order to apply rc file + # changes to the current shell. This is a terrible practice that breaks many + # things, including our shell integration. For example, Oh My Zsh and Prezto + # (both very popular among zsh users) will remove zle-line-init and + # zle-line-finish hooks if .zshrc is manually sourced. Prezto will also remove + # zle-keymap-select. + # + # Another common (and much more robust) way to apply rc file changes to the + # current shell is `exec zsh`. This will remove our integration from the shell + # unless it's explicitly invoked from .zshrc. This is not an issue with + # `exec zsh` but rather with our implementation of automatic shell integration. + + # In the ideal world we would use add-zle-hook-widget to hook zle-line-init + # and similar widget. This breaks user configs though, so we have do this + # horrible thing instead. + builtin local hook func widget orig_widget flag + for hook in line-init line-finish keymap-select; do + func=_ghostty_zle_${hook/-/_} + (( $+functions[$func] )) || builtin continue + widget=zle-$hook + if [[ $widgets[$widget] == user:azhw:* && + $+functions[add-zle-hook-widget] -eq 1 ]]; then + # If the widget is already hooked by add-zle-hook-widget at the top + # level, add our hook at the end. We MUST do it this way. We cannot + # just wrap the widget ourselves in this case because it would + # trigger bugs in add-zle-hook-widget. + add-zle-hook-widget $hook $func + else + if (( $+widgets[$widget] )); then + # There is a widget but it's not from add-zle-hook-widget. We + # can rename the original widget, install our own and invoke + # the original when we are called. + # + # Note: The leading dot is to work around bugs in + # zsh-syntax-highlighting. + orig_widget=._ghostty_orig_$widget + builtin zle -A $widget $orig_widget + if [[ $widgets[$widget] == user:* ]]; then + # No -w here to preserve $WIDGET within the original widget. + flag= + else + flag=w + fi + functions[$func]+=" + builtin zle $orig_widget -N$flag -- \"\$@\"" + fi + builtin zle -N $widget $func + fi + done + + if (( $+functions[_ghostty_preexec] )); then + builtin typeset -ag preexec_functions + preexec_functions+=(_ghostty_preexec) + fi + + builtin typeset -ag precmd_functions + if (( $+functions[_ghostty_precmd] )); then + precmd_functions=(${precmd_functions:#_ghostty_deferred_init} _ghostty_precmd) + _ghostty_precmd + else + precmd_functions=(${precmd_functions:#_ghostty_deferred_init}) + fi + + # Unfunction _ghostty_deferred_init to save memory. Don't unfunction + # ghostty-integration though because decent public functions aren't supposed to + # to unfunction themselves when invoked. Unfunctioning is done by calling code. + builtin unfunction _ghostty_deferred_init +} + +_entrypoint