From cd94afa0c7dc1b48d074c84c5442c991dbb67d41 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 09:54:59 +0530 Subject: [PATCH 01/48] feat(tui): add native Ratatui shell and owned engine sessions --- Cargo.lock | 819 ++++++++++++++++++++++++ Cargo.toml | 6 +- crates/oxytype-tui/Cargo.toml | 30 + crates/oxytype-tui/src/engine.rs | 185 ++++++ crates/oxytype-tui/src/lib.rs | 14 + crates/oxytype-tui/src/main.rs | 317 +++++++++ crates/oxytype-tui/src/terminal.rs | 94 +++ crates/oxytype-tui/tests/native_host.rs | 47 ++ 8 files changed, 1511 insertions(+), 1 deletion(-) create mode 100644 crates/oxytype-tui/Cargo.toml create mode 100644 crates/oxytype-tui/src/engine.rs create mode 100644 crates/oxytype-tui/src/lib.rs create mode 100644 crates/oxytype-tui/src/main.rs create mode 100644 crates/oxytype-tui/src/terminal.rs create mode 100644 crates/oxytype-tui/tests/native_host.rs diff --git a/Cargo.lock b/Cargo.lock index 2e6b37e02..ba20b24ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,24 +2,311 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "bumpalo" 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 = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook 0.3.18", + "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 = "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.9", +] + +[[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.9", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "equivalent" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -31,6 +318,23 @@ 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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" @@ -42,24 +346,265 @@ dependencies = [ "hashbrown", ] +[[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.9", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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 = "kasuari" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +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", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[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.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oxytype-tui" +version = "0.1.0" +dependencies = [ + "clap", + "crossterm", + "nix", + "ratatui", + "serde", + "serde_json", + "signal-hook 0.4.5", + "typing-engine", +] + +[[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_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[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 = "proc-macro2" version = "1.0.107" @@ -78,12 +623,128 @@ dependencies = [ "proc-macro2", ] +[[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-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags", + "compact_str", + "critical-section", + "hashbrown", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror", + "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-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags", + "hashbrown", + "indoc", + "instability", + "itertools", + "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", +] + +[[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", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" 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" @@ -129,6 +790,86 @@ dependencies = [ "zmij", ] +[[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" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +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 0.3.18", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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 = "2.0.119" @@ -180,6 +921,27 @@ dependencies = [ "syn 3.0.9", ] +[[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 = "ts-rs" version = "12.0.1" @@ -228,6 +990,41 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[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", + "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 = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -273,6 +1070,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -282,6 +1095,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index c1fd99039..0d649a9c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,9 @@ [workspace] -members = ["crates/typing-engine", "crates/typing-engine-wasm"] +members = [ + "crates/typing-engine", + "crates/typing-engine-wasm", + "crates/oxytype-tui", +] resolver = "3" [workspace.package] diff --git a/crates/oxytype-tui/Cargo.toml b/crates/oxytype-tui/Cargo.toml new file mode 100644 index 000000000..4c9d0bd89 --- /dev/null +++ b/crates/oxytype-tui/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "oxytype-tui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Offline Oxytype terminal client using the shared Rust typing engine" + +[[bin]] +name = "oxytype" +path = "src/main.rs" + +[dependencies] +typing-engine = { path = "../typing-engine" } +serde.workspace = true +serde_json.workspace = true +ratatui = { version = "=0.30.2", default-features = false, features = [ + "crossterm", + "layout-cache", +] } +crossterm = "=0.29.0" +clap = { version = "4.5", features = ["derive", "env", "string"] } +signal-hook = "=0.4.5" + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.30", features = ["signal", "process"] } + +[lints] +workspace = true diff --git a/crates/oxytype-tui/src/engine.rs b/crates/oxytype-tui/src/engine.rs new file mode 100644 index 000000000..f6bc780ee --- /dev/null +++ b/crates/oxytype-tui/src/engine.rs @@ -0,0 +1,185 @@ +//! Terminal observations and display projection. All typing rules stay in core. +use crate::Result; +use typing_engine::{ + Command, Config, Session, Text, + generation::{Content, CustomText, Language, Prepare}, + random::Random, + session::{LiveMetrics, State}, + stats::{self, Log, Summary}, +}; + +pub struct EngineHost { + pub config: Config, + pub words: Vec, + pub inputs: Vec, + pub result: Option, + pub result_error: Option, + pub seed: u32, + content: Content, + session: Option, + language: Language, + custom: Option, +} +impl EngineHost { + pub fn new( + config: Config, + language: Language, + custom: Option, + seed: u32, + ) -> Result { + let mut host = Self { + config, + language, + custom, + seed, + content: Content::default(), + session: None, + words: vec![], + inputs: vec![], + result: None, + result_error: None, + }; + host.restart(false, 0.0, 0.0)?; + Ok(host) + } + pub fn session(&self) -> &Session { + self.session.as_ref().expect("Prepared terminal session") + } + pub fn state(&self) -> State { + self.session().state() + } + pub fn live(&self, now: f64) -> Result { + Ok(self.session().live_stats(now)?) + } + pub fn log(&self) -> Result { + Ok(serde_json::from_value(self.session().log())?) + } + pub fn restart(&mut self, repeat: bool, now: f64, date: f64) -> Result<()> { + let mut same = repeat; + if let Some(mut session) = self.session.take() { + let policy = session.restart_policy(typing_engine::restart::RestartOptions { + with_same_wordset: repeat, + ..Default::default() + }); + if !policy.allowed { + self.session = Some(session); + return Err("Restart unavailable during this test".into()); + } + same = policy.with_same_wordset; + if session.state().active { + session.apply(Command::Abandon { now, date })?; + } + self.content.recycle_session(session); + if !same { + self.seed = self.seed.wrapping_add(1); + } + } + self.content.prepare(Prepare { + config: self.config.clone(), + language: self.language.clone(), + custom: self.custom.clone(), + random: Random::seeded(self.seed), + quotes: None, + polyglot: vec![], + selected_quote_id: 0, + favorite_quote_ids: vec![], + sections: vec![], + })?; + let session = self.content.start_session(same)?; + self.words = session.snapshot().words; + self.inputs.clear(); + self.result = None; + self.result_error = None; + self.session = Some(session); + Ok(()) + } + pub fn command(&mut self, command: Command) -> Result<()> { + let update = self + .session + .as_mut() + .expect("Prepared terminal session") + .apply(command)?; + for change in update.words { + self.words + .resize(self.words.len().max(change.index + 1), Text::default()); + self.words[change.index] = change.word; + } + // Mechanical transport projection; no input reconstruction or score formulas. + for event in update.events.iter().filter(|event| event.kind == "input") { + let index = event.data["wordIndex"] + .as_u64() + .expect("Native input index") as usize; + self.inputs + .resize(self.inputs.len().max(index + 1), Text::default()); + self.inputs[index] = stats::text(&event.data["inputValue"]); + if event.data["clearedNextWord"] == true + && let Some(next) = self.inputs.get_mut(index + 1) + { + *next = Text::default(); + } + } + if update.state.ended && self.result.is_none() && self.result_error.is_none() { + match stats::typed_summary(&self.log()?) { + Ok(summary) => self.result = Some(summary), + Err(error) => self.result_error = Some(error.to_string()), + } + } + Ok(()) + } + pub fn insert(&mut self, text: &str, now: f64, date: f64) -> Result<()> { + self.command(Command::Insert { + data: Text::from(text), + now, + date, + correct_shift_used: None, + automatic: None, + is_composition_ending: None, + layout: None, + }) + } +} + +/// Explicit synthetic smoke path: never records invented observations in history. +pub fn smoke() -> Result<()> { + let mut host = EngineHost::new( + Config { + mode: "words".into(), + words: 2, + ..Default::default() + }, + Language { + name: "english".into(), + words: ["one", "two", "three", "four"] + .into_iter() + .map(Text::from) + .collect(), + ..Default::default() + }, + None, + 42, + )?; + for index in 0..2 { + let word = host.words[index].display_lossy(); + host.insert( + &word, + 100.0 + index as f64 * 1000.0, + 1_000_100.0 + index as f64 * 1000.0, + )?; + } + if !host.state().ended || host.result.is_none() { + return Err("Native smoke session did not complete".into()); + } + let log = host.log()?; + if log + .events + .iter() + .any(|event| event.kind == "keydown" || event.kind == "keyup") + { + return Err("Smoke inserted unobserved keyboard telemetry".into()); + } + println!( + "{}", + serde_json::json!({"syntheticSmoke":true,"completed":true,"engineVersion":typing_engine::BEHAVIOR_VERSION}) + ); + Ok(()) +} diff --git a/crates/oxytype-tui/src/lib.rs b/crates/oxytype-tui/src/lib.rs new file mode 100644 index 000000000..434ceeba6 --- /dev/null +++ b/crates/oxytype-tui/src/lib.rs @@ -0,0 +1,14 @@ +pub mod engine; +pub mod terminal; + +pub type Error = Box; +pub type Result = std::result::Result; + +pub fn version() -> String { + format!( + "{} (engine {}; log {})", + env!("CARGO_PKG_VERSION"), + typing_engine::BEHAVIOR_VERSION, + typing_engine::EVENT_LOG_VERSION + ) +} diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs new file mode 100644 index 000000000..76457a4e0 --- /dev/null +++ b/crates/oxytype-tui/src/main.rs @@ -0,0 +1,317 @@ +use clap::Parser; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use oxytype_tui::{Result, engine::EngineHost, terminal::TerminalGuard}; +use ratatui::{ + Frame, + layout::{Constraint, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph, Wrap}, +}; +use std::{ + sync::atomic::Ordering, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use typing_engine::{Command, Config, Text, generation::Language}; + +#[derive(Parser)] +#[command( + name = "oxytype", + version = oxytype_tui::version(), + about = "Offline typing with the shared Oxytype Rust engine" +)] +struct Cli { + /// Exercise a deterministic synthetic session without a terminal or saved data. + #[arg(long)] + smoke_test: bool, +} +#[derive(Clone, Copy, PartialEq)] +enum Screen { + Test, + Help, + Settings, + History, +} +fn date() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as f64 +} +fn main() { + if let Err(error) = run() { + eprintln!("oxytype: {error}"); + std::process::exit(1); + } +} +fn run() -> Result<()> { + let cli = Cli::parse(); + if cli.smoke_test { + return oxytype_tui::engine::smoke(); + } + let mut host = EngineHost::new( + Config::default(), + Language { + name: "english".into(), + words: ["one", "two", "three", "four"] + .into_iter() + .map(Text::from) + .collect(), + ..Default::default() + }, + None, + date() as u32, + )?; + let (guard, mut terminal) = TerminalGuard::enter()?; + #[cfg(debug_assertions)] + match std::env::var("OXYTYPE_TERMINAL_TEST_FAILURE").as_deref() { + Ok("panic") => panic!("Injected terminal cleanup test panic"), + Ok("error") => return Err("Injected terminal cleanup test error".into()), + _ => {} + } + let origin = Instant::now(); + let mut screen = Screen::Test; + let mut notice = String::new(); + let mut history = vec![]; + let mut last_draw = Instant::now(); + let mut dirty = true; + loop { + if guard.resume_if_requested(&mut terminal)? { + dirty = true; + } + if guard.quit.load(Ordering::SeqCst) { + break; + } + let now = origin.elapsed().as_secs_f64() * 1000.0; + if host.state().active { + host.command(Command::Advance { now, date: date() })?; + } + if dirty || (host.state().active && last_draw.elapsed() >= Duration::from_millis(100)) { + terminal.draw(|frame| draw(frame, &host, screen, ¬ice, &history, now))?; + dirty = false; + last_draw = Instant::now(); + } + match event::poll(Duration::from_millis(50)) { + Ok(false) => continue, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.into()), + Ok(true) => {} + } + dirty = true; + let event = event::read()?; + // Capture delivery time after poll/read, never the earlier polling time. + let now = origin.elapsed().as_secs_f64() * 1000.0; + match event { + // Fullscreen draw autoresizes and invalidates its viewport. An extra + // clear queries cursor position, which basic terminals need not report. + Event::Resize(_, _) => {} + Event::Paste(_) => notice = "Paste blocked during typing.".into(), + Event::Key(key) if key.kind != KeyEventKind::Release => { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + if (control && matches!(key.code, KeyCode::Char('q' | 'c'))) + || key.code == KeyCode::F(10) + { + break; + } + if control && key.code == KeyCode::Char('z') { + guard.suspend.store(true, Ordering::SeqCst); + continue; + } + match key.code { + KeyCode::F(1) => screen = Screen::Help, + KeyCode::F(2) if !host.state().active => screen = Screen::Settings, + KeyCode::F(3) if !host.state().active => screen = Screen::History, + KeyCode::Esc if screen != Screen::Test => screen = Screen::Test, + KeyCode::F(5) | KeyCode::F(6) => { + if let Some(summary) = host.result.clone() { + history.push(format!( + "{}: {:.1} WPM / {:.1}%", + host.config.mode, summary.wpm, summary.acc + )); + } + host.restart(key.code == KeyCode::F(6), now, date())?; + screen = Screen::Test; + notice.clear(); + } + KeyCode::Enter if screen == Screen::Settings => { + host.restart(false, now, date())?; + screen = Screen::Test; + } + KeyCode::Char('m') if screen == Screen::Settings => { + host.config.mode = match host.config.mode.as_str() { + "time" => "words", + "words" => "zen", + _ => "time", + } + .into() + } + KeyCode::Char('+') if screen == Screen::Settings => { + host.config.words += 10; + host.config.time += 15.0; + } + KeyCode::F(8) if screen == Screen::Test => { + host.command(Command::Bailout { now, date: date() })?; + } + KeyCode::Enter if control && screen == Screen::Test => { + host.command(Command::Bailout { now, date: date() })?; + } + KeyCode::Backspace if screen == Screen::Test && !host.state().ended => { + host.command(Command::Delete { + kind: if control || key.modifiers.contains(KeyModifiers::ALT) { + "deleteWordBackward" + } else { + "deleteContentBackward" + } + .into(), + now, + date: date(), + observed_input: None, + sentinel_removed: false, + })?; + } + KeyCode::Char(char) + if screen == Screen::Test + && !host.state().ended + && !control + && !key.modifiers.contains(KeyModifiers::ALT) => + { + host.insert(&char.to_string(), now, date())?; + } + KeyCode::Enter if screen == Screen::Test && !host.state().ended => { + host.insert("\n", now, date())? + } + KeyCode::Tab if screen == Screen::Test && !host.state().ended => { + host.insert("\t", now, date())? + } + _ => {} + } + } + _ => {} + } + } + Ok(()) +} +fn draw( + frame: &mut Frame, + host: &EngineHost, + screen: Screen, + notice: &str, + history: &[String], + now: f64, +) { + let area = frame.area(); + if area.width < 60 || area.height < 18 { + frame.render_widget( + Paragraph::new("Resize to at least 60×18. Active test clock continues. Ctrl+Q quits."), + area, + ); + return; + } + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(area); + frame.render_widget( + Paragraph::new(format!( + "Oxytype {} | {}", + env!("CARGO_PKG_VERSION"), + host.config.mode + )) + .block(Block::bordered()), + rows[0], + ); + let state = host.state(); + let body = match screen { + Screen::Help => vec![ + Line::from("F5 new · F6 repeat · F2 settings · F3 history · Esc return"), + Line::from("F8 finish · Backspace delete · Ctrl+Backspace delete word"), + Line::from("Ctrl+Q / Ctrl+C quit · Ctrl+Z suspend (Unix)"), + Line::from("Physical-key timing is unavailable; no key releases are invented."), + ], + Screen::Settings => vec![ + Line::from(format!( + "m: mode {} · +: increase length · Enter: start", + host.config.mode + )), + Line::from(format!( + "Words {} · Time {} seconds", + host.config.words, host.config.time + )), + ], + Screen::History => history + .iter() + .rev() + .map(|row| Line::from(row.clone())) + .chain(std::iter::once(Line::from("Session history"))) + .collect(), + Screen::Test if state.ended => vec![ + Line::from(host.result.as_ref().map_or_else( + || { + host.result_error + .clone() + .unwrap_or_else(|| "Test ended".into()) + }, + |score| { + format!( + "{:.1} WPM · {:.1}% accuracy · {:.2}s", + score.wpm, score.acc, score.test_duration + ) + }, + )), + Line::from(state.reason.unwrap_or_else(|| "Completed".into())), + Line::from("F5 new test · F6 repeat"), + ], + Screen::Test => { + let mut lines = vec![]; + if let Ok(live) = host.live(now) { + lines.push(Line::from(format!( + "{:.1} WPM · {:.1}% · {:.0}s", + live.wpm, live.acc, live.seconds + ))); + } + let mut spans = vec![]; + let targets = if host.config.mode == "zen" { + &host.inputs + } else { + &host.words + }; + for (index, word) in targets + .iter() + .enumerate() + .skip(state.word_index.saturating_sub(5)) + .take(35) + { + spans.push(Span::styled( + word.display_lossy(), + if index == state.word_index { + Style::new() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED) + } else { + Style::new() + }, + )); + } + lines.push(Line::from(spans)); + lines.push(Line::from(format!( + "Input: {}▌", + state.input_value.display_lossy() + ))); + lines + } + }; + frame.render_widget( + Paragraph::new(body) + .wrap(Wrap { trim: false }) + .block(Block::bordered()), + rows[1], + ); + frame.render_widget( + Paragraph::new(format!( + "F1 help · F5 new · F6 repeat · Ctrl+Q quit\n{notice}" + )), + rows[2], + ); +} diff --git a/crates/oxytype-tui/src/terminal.rs b/crates/oxytype-tui/src/terminal.rs new file mode 100644 index 000000000..91df005ce --- /dev/null +++ b/crates/oxytype-tui/src/terminal.rs @@ -0,0 +1,94 @@ +use crate::Result; +use crossterm::{ + cursor::Show, + event::{DisableBracketedPaste, EnableBracketedPaste}, + execute, + terminal::{EnterAlternateScreen, enable_raw_mode}, +}; +use ratatui::{DefaultTerminal, backend::CrosstermBackend}; +use std::{ + io::{self, IsTerminal}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +pub struct TerminalGuard { + pub quit: Arc, + pub suspend: Arc, + signals: Vec, +} +pub fn restore() { + let _ = execute!(io::stdout(), DisableBracketedPaste, Show); + ratatui::restore(); +} + +fn initialize() -> std::io::Result { + // Install our cleanup hook once, rather than nesting Ratatui hooks on every + // suspend/resume cycle. The guard already exists if any setup step fails. + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?; + ratatui::Terminal::new(CrosstermBackend::new(io::stdout())) +} +impl TerminalGuard { + pub fn enter() -> Result<(Self, DefaultTerminal)> { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err( + "Oxytype requires an interactive terminal; use --help or --smoke-test without one" + .into(), + ); + } + if std::env::var("TERM").is_ok_and(|term| term == "dumb") { + return Err("TERM=dumb cannot render Oxytype".into()); + } + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore(); + hook(info); + })); + let mut guard = Self { + quit: Arc::new(AtomicBool::new(false)), + suspend: Arc::new(AtomicBool::new(false)), + signals: vec![], + }; + for signal in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] { + guard + .signals + .push(signal_hook::flag::register(signal, guard.quit.clone())?); + } + #[cfg(unix)] + for (signal, flag) in [ + (signal_hook::consts::SIGHUP, guard.quit.clone()), + (signal_hook::consts::SIGTSTP, guard.suspend.clone()), + ] { + guard + .signals + .push(signal_hook::flag::register(signal, flag)?); + } + let terminal = initialize()?; + Ok((guard, terminal)) + } + pub fn resume_if_requested(&self, terminal: &mut DefaultTerminal) -> Result { + if !self.suspend.swap(false, Ordering::SeqCst) { + return Ok(false); + } + #[cfg(unix)] + { + restore(); + // Suspend the foreground job too when launched through vp/Cargo; + // stopping only the binary leaves its launcher holding the terminal. + nix::sys::signal::killpg(nix::unistd::getpgrp(), nix::sys::signal::Signal::SIGSTOP)?; + *terminal = initialize()?; + } + Ok(true) + } +} +impl Drop for TerminalGuard { + fn drop(&mut self) { + restore(); + for signal in self.signals.drain(..) { + signal_hook::low_level::unregister(signal); + } + } +} diff --git a/crates/oxytype-tui/tests/native_host.rs b/crates/oxytype-tui/tests/native_host.rs new file mode 100644 index 000000000..fb57eca72 --- /dev/null +++ b/crates/oxytype-tui/tests/native_host.rs @@ -0,0 +1,47 @@ +use oxytype_tui::engine::EngineHost; +use typing_engine::{Config, Text, generation::Language}; + +#[test] +fn native_host_preserves_input_clocks_repeat_targets_and_missing_keyboard_data() { + let mut host = EngineHost::new( + Config { + mode: "words".into(), + words: 2, + ..Default::default() + }, + Language { + name: "english".into(), + words: ["a", "b", "c", "d"].into_iter().map(Text::from).collect(), + ..Default::default() + }, + None, + 42, + ) + .unwrap(); + let original = host.words.clone(); + host.insert(&original[0].display_lossy(), 100.0, 10_100.0) + .unwrap(); + assert_eq!(host.state().word_index, 1); + assert_eq!(host.inputs[0], original[0]); + host.insert(&original[1].display_lossy(), 1100.0, 11_100.0) + .unwrap(); + assert!(host.state().ended); + let result = host.result.as_ref().unwrap(); + assert_eq!(result.wpm, 36.0); + assert_eq!(result.acc, 100.0); + assert_eq!(result.test_duration, 1.0); + assert!(result.key_duration.is_empty()); + assert!( + host.log() + .unwrap() + .events + .iter() + .all(|event| event.kind != "keydown" && event.kind != "keyup") + ); + host.restart(true, 1200.0, 11_200.0).unwrap(); + assert_eq!(host.words, original); + assert_eq!(host.seed, 42); + assert!(!host.state().active); + assert!(!host.state().ended); + assert!(host.inputs.is_empty()); +} From 92c1a52034431b4f74b3c1976ae282d1a5e231a3 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 09:55:08 +0530 Subject: [PATCH 02/48] test(tui): add vp tasks and real PTY lifecycle checks --- package.json | 6 +- scripts/terminal.ts | 32 ++++++++++ scripts/terminal_pty.py | 134 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 scripts/terminal.ts create mode 100644 scripts/terminal_pty.py diff --git a/package.json b/package.json index 03c613163..0451cc380 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,11 @@ "engine:fmt": "node scripts/engine.ts fmt", "engine:fmt-fix": "node scripts/engine.ts fmt-fix", "engine:test:wasm": "node scripts/engine.ts browser", - "engine:parity": "node scripts/engine.ts parity" + "engine:parity": "node scripts/engine.ts parity", + "terminal:dev": "node scripts/terminal.ts dev", + "terminal:build": "node scripts/terminal.ts build", + "terminal:test": "node scripts/terminal.ts test", + "terminal:test:pty": "node scripts/terminal.ts pty" }, "devDependencies": { "@commitlint/cli": "21.2.3", diff --git a/scripts/terminal.ts b/scripts/terminal.ts new file mode 100644 index 000000000..4d1978495 --- /dev/null +++ b/scripts/terminal.ts @@ -0,0 +1,32 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +const command = process.argv[2]; +const args = process.argv.slice(3); +const tasks: Record = { + dev: ["run", "--locked", "-p", "oxytype-tui", "--", ...args], + build: ["build", "--locked", "--release", "-p", "oxytype-tui", ...args], + test: ["test", "--locked", "-p", "oxytype-tui", ...args], + pty: ["build", "--locked", "-p", "oxytype-tui"], +}; +if (command === undefined || tasks[command] === undefined) { + throw new Error("Usage: vp run terminal:dev|terminal:build|terminal:test"); +} +const result = spawnSync("cargo", tasks[command], { + cwd: resolve(import.meta.dirname, ".."), + stdio: "inherit", +}); +if (result.error !== undefined) throw result.error; +if (command === "pty" && result.status === 0) { + const check = spawnSync( + "python3", + ["scripts/terminal_pty.py", "target/debug/oxytype"], + { + cwd: resolve(import.meta.dirname, ".."), + stdio: "inherit", + }, + ); + if (check.error !== undefined) throw check.error; + process.exit(check.status ?? 1); +} +process.exit(result.status ?? 1); diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py new file mode 100644 index 000000000..633be7dbf --- /dev/null +++ b/scripts/terminal_pty.py @@ -0,0 +1,134 @@ +"""Real POSIX PTY lifecycle checks; no claimed hardware-terminal coverage.""" +import errno +import fcntl +import os +import pathlib +import pty +import select +import signal +import struct +import subprocess +import sys +import termios +import time + + +def dimensions(slave, columns=100, rows=24): + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) + + +class Child: + def __init__(self, binary, failure=None): + self.master, self.slave = pty.openpty() + dimensions(self.slave) + self.original = termios.tcgetattr(self.slave) + self.output = bytearray() + env = dict(os.environ, TERM="xterm-256color") + if failure: + env["OXYTYPE_TERMINAL_TEST_FAILURE"] = failure + + def controlling_terminal(): + os.setsid() + fcntl.ioctl(self.slave, termios.TIOCSCTTY, 0) + + self.process = subprocess.Popen([binary], stdin=self.slave, stdout=self.slave, + stderr=self.slave, env=env, + preexec_fn=controlling_terminal) + + def read(self, seconds=0.05): + if select.select([self.master], [], [], seconds)[0]: + try: + self.output.extend(os.read(self.master, 65536)) + except OSError as error: + if error.errno != errno.EIO: + raise + + def until(self, text, seconds=5): + deadline = time.monotonic() + seconds + start = len(self.output) + while time.monotonic() < deadline: + self.read() + if text in self.output[start:]: + return + if self.process.poll() is not None: + break + raise AssertionError(f"Missing {text!r}; process={self.process.poll()}; output={bytes(self.output[-2000:])!r}") + + def send(self, text): + os.write(self.master, text) + + def restored(self): + current = termios.tcgetattr(self.slave) + mask = termios.ICANON | termios.ECHO + assert current[3] & mask == self.original[3] & mask, "raw/echo state leaked" + while select.select([self.master], [], [], 0)[0]: + self.read(0) + assert b"\x1b[?1049l" in self.output, "alternate screen not restored" + assert b"\x1b[?2004l" in self.output, "bracketed paste not disabled" + + def close(self): + if self.process.poll() is None: + os.kill(self.process.pid, signal.SIGCONT) + self.process.kill() + self.process.wait(timeout=3) + os.close(self.master) + os.close(self.slave) + + +def run(binary): + child = Child(binary) + try: + child.until(b"Oxytype") + assert termios.tcgetattr(child.slave)[3] & termios.ICANON == 0 + child.send(b"one ") + time.sleep(0.15) + child.send(b"\x1b[19~") # F8 is available without enhanced Ctrl+Enter. + child.until(b"F5 new test") + child.send(b"\x1b[15~") + child.until(b"Input:") + dimensions(child.slave, 30, 8) + os.kill(child.process.pid, signal.SIGWINCH) + # Ratatui skips blank cells with cursor moves; words need not be emitted + # as one contiguous byte string in a real incremental terminal frame. + child.until(b"Resize") + dimensions(child.slave) + os.kill(child.process.pid, signal.SIGWINCH) + child.until(b"Oxytype") + child.send(b"\x11") + assert child.process.wait(timeout=5) == 0 + child.restored() + finally: + child.close() + + for failure in ("error", "panic", "signal", "suspend"): + child = Child(binary, failure if failure in ("error", "panic") else None) + try: + if failure in ("error", "panic"): + assert child.process.wait(timeout=5) != 0 + else: + child.until(b"Oxytype") + if failure == "signal": + os.kill(child.process.pid, signal.SIGTERM) + else: + os.kill(child.process.pid, signal.SIGTSTP) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + pid, status = os.waitpid(child.process.pid, os.WNOHANG | os.WUNTRACED) + if pid and os.WIFSTOPPED(status): + break + time.sleep(0.02) + else: + raise AssertionError("Did not suspend") + child.restored() + os.kill(child.process.pid, signal.SIGCONT) + child.until(b"Oxytype") + child.send(b"\x11") + assert child.process.wait(timeout=5) == 0 + child.restored() + finally: + child.close() + print("PTY: typing/finish/restart/resize/quit, error, panic, SIGTERM, suspend/resume pass") + + +if __name__ == "__main__": + run(str(pathlib.Path(sys.argv[1]).resolve())) From c04b80de1e52970d889c60877f30c7a9667f4f54 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 09:59:53 +0530 Subject: [PATCH 03/48] docs(tui): record native shell lifecycle gate --- docs/rust-engine/progress.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index ee71a14a4..69699ece9 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -12,7 +12,7 @@ empty commits to inflate count. Do not publish production or releases implicitly ## Current position -Stage 1 and Stage 2 gates passed. Stage 3 begins next on a branch stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration +Stage 1 and Stage 2 gates passed. Stage 3 Phase 3.2 active on `stack/rust-engine-terminal`, stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration plan before edits. Installed workspace dependencies with frozen lockfile. Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen oracle and mismatch detection. Existing website engine remains authoritative until Stage 2. @@ -31,7 +31,9 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 2.4 Results / replay | Complete | Native validity/progress/history/charts/inspector/playback; unit/JSX, frozen parity, save retry and real Start/D1 persistence pass. | | 2.5 Delivery / performance | Complete | Fresh Cloudflare build/preview/types, 86 Start checks, update/rollback, full website and controlled Wasm budgets pass. | | 2.6 TypeScript retirement | Complete | Legacy package removed; full native/Wasm/website/quality CI, fresh build/types and frozen performance gates pass. | -| 3.1–3.5 Terminal | Pending | Requires Stage 2 gate. | +| 3.1 Terminal shell | Complete | Native smoke/score test; real PTY typing, finish/restart, resize, error/panic, SIGTERM and suspend/resume pass. | +| 3.2 Offline content | Active | Bundled licensed words/quotes, local packs and full modes/settings next. | +| 3.3–3.5 Terminal | Pending | Input/capabilities, durable history and release gates remain. | ## Verification log @@ -533,3 +535,21 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro claimed. Local matched-artifact rollback/offline/save checks pass. - Continue Stage 3 immediately. Native Ratatui implementation, capabilities, local history and platform release gates remain required; do not stop at this stage. + +## Phase 3.1 gate passed + +- `cd94afa0c`: native Ratatui shell, direct owned engine sessions and display deltas; + input timestamps captured after terminal read using `Instant`. No physical-key + events or durations invented. `92c1a5203`: vp terminal tasks and real PTY checks. +- `vp run terminal:test`: native score/projection/repeat regression passes. + `vp run terminal:dev -- --smoke-test`: deterministic synthetic smoke passes, + never persisted. `vp exec cargo clippy -p oxytype-tui --all-targets -- -D warnings`: pass. +- `vp run terminal:test:pty`: actual POSIX PTY passes typing/finish/restart/resize, + normal quit, injected error/panic, SIGTERM and suspend/resume. Raw/canonical + mode and alternate screen restored. Process-group suspend covers vp launcher. +- CLI help/version pass without a terminal; interactive invocation without a + terminal fails clearly (exit 1). Idle loop polls and redraws only when dirty; + active timer draws at 10 Hz. Minimum size is 60×18. +- This is the shell gate only. Current settings/history are temporary in-memory + screens. Full offline content, input/rendering, durable storage and packaging + remain required. Linux PTY coverage does not claim real macOS/Windows hardware. From 04cf9e3e9697350a1794cce48930cdc8081bcff9 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:03:36 +0530 Subject: [PATCH 04/48] feat(tui): bundle offline content and shared mode options --- crates/oxytype-tui/assets/ATTRIBUTION.md | 12 ++ crates/oxytype-tui/assets/english.json | 207 ++++++++++++++++++++ crates/oxytype-tui/assets/quotes.json | 42 ++++ crates/oxytype-tui/src/content.rs | 175 +++++++++++++++++ crates/oxytype-tui/src/engine.rs | 22 ++- crates/oxytype-tui/src/lib.rs | 2 + crates/oxytype-tui/src/main.rs | 33 +--- crates/oxytype-tui/src/options.rs | 193 ++++++++++++++++++ crates/oxytype-tui/tests/offline_content.rs | 156 +++++++++++++++ docs/rust-engine/terminal-content.md | 50 +++++ 10 files changed, 866 insertions(+), 26 deletions(-) create mode 100644 crates/oxytype-tui/assets/ATTRIBUTION.md create mode 100644 crates/oxytype-tui/assets/english.json create mode 100644 crates/oxytype-tui/assets/quotes.json create mode 100644 crates/oxytype-tui/src/content.rs create mode 100644 crates/oxytype-tui/src/options.rs create mode 100644 crates/oxytype-tui/tests/offline_content.rs create mode 100644 docs/rust-engine/terminal-content.md diff --git a/crates/oxytype-tui/assets/ATTRIBUTION.md b/crates/oxytype-tui/assets/ATTRIBUTION.md new file mode 100644 index 000000000..f5eb555d4 --- /dev/null +++ b/crates/oxytype-tui/assets/ATTRIBUTION.md @@ -0,0 +1,12 @@ +# Bundled offline content + +English words: exact copy of `frontend/static/languages/english.json` in the +Oxytype repository, derived from Monkeytype. Distributed under this repository's +GPL-3.0-only license; see the root LICENSE and upstream attribution. + +Original Oxytype practice prose in quotes.json was written for this terminal app, +licensed GPL-3.0-only. It is not attributed to historical authors. + +English source SHA-256: `9eff3c4563d4409f4c18d6bd35963654570c72d688b4e01453eac916d614be1e`. +Both assets are compiled into the executable; no download or source checkout +is needed at runtime. diff --git a/crates/oxytype-tui/assets/english.json b/crates/oxytype-tui/assets/english.json new file mode 100644 index 000000000..b5b43a667 --- /dev/null +++ b/crates/oxytype-tui/assets/english.json @@ -0,0 +1,207 @@ +{ + "name": "english", + "noLazyMode": true, + "orderedByFrequency": true, + "words": [ + "the", + "be", + "of", + "and", + "a", + "to", + "in", + "he", + "have", + "it", + "that", + "for", + "they", + "I", + "with", + "as", + "not", + "on", + "she", + "at", + "by", + "this", + "we", + "you", + "do", + "but", + "from", + "or", + "which", + "one", + "would", + "all", + "will", + "there", + "say", + "who", + "make", + "when", + "can", + "more", + "if", + "no", + "man", + "out", + "other", + "so", + "what", + "time", + "up", + "go", + "about", + "than", + "into", + "could", + "state", + "only", + "new", + "year", + "some", + "take", + "come", + "these", + "know", + "see", + "use", + "get", + "like", + "then", + "first", + "any", + "work", + "now", + "may", + "such", + "give", + "over", + "think", + "most", + "even", + "find", + "day", + "also", + "after", + "way", + "many", + "must", + "look", + "before", + "great", + "back", + "through", + "long", + "where", + "much", + "should", + "well", + "people", + "down", + "own", + "just", + "because", + "good", + "each", + "those", + "feel", + "seem", + "how", + "high", + "too", + "place", + "little", + "world", + "very", + "still", + "nation", + "hand", + "old", + "life", + "tell", + "write", + "become", + "here", + "show", + "house", + "both", + "between", + "need", + "mean", + "call", + "develop", + "under", + "last", + "right", + "move", + "thing", + "general", + "school", + "never", + "same", + "another", + "begin", + "while", + "number", + "part", + "turn", + "real", + "leave", + "might", + "want", + "point", + "form", + "off", + "child", + "few", + "small", + "since", + "against", + "ask", + "late", + "home", + "interest", + "large", + "person", + "end", + "open", + "public", + "follow", + "during", + "present", + "without", + "again", + "hold", + "govern", + "around", + "possible", + "head", + "consider", + "word", + "program", + "problem", + "however", + "lead", + "system", + "set", + "order", + "eye", + "plan", + "run", + "keep", + "face", + "fact", + "group", + "play", + "stand", + "increase", + "early", + "course", + "change", + "help", + "line" + ] +} diff --git a/crates/oxytype-tui/assets/quotes.json b/crates/oxytype-tui/assets/quotes.json new file mode 100644 index 000000000..8ab35b24f --- /dev/null +++ b/crates/oxytype-tui/assets/quotes.json @@ -0,0 +1,42 @@ +[ + { + "id": 100001, + "text": "A quiet room leaves enough space to hear the rhythm of the keys.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100002, + "text": "The last light crossed the desk as another page began to fill.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100003, + "text": "Every morning, the station clock met the same patient crowd. Some carried letters, others carried lunch, and one carried a bright blue umbrella beneath a clear sky.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100004, + "text": "A small garden can teach a useful kind of patience. Water the soil, watch the changing light, and leave room for the roots to find their own way through the dark.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100005, + "text": "The map on the table showed a narrow road beside a river. We followed it slowly, stopping whenever the trees opened onto a view of the water. By evening, the road had become a path, and the path had brought us to a bridge we could hear long before we could see it.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100006, + "text": "When the workshop opened, each tool already had a place. The new apprentice spent a week learning those places before making anything at all. Years later, she would remember that week whenever a difficult job became easier simply because everything she needed was within reach.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100007, + "text": "On the first cool evening of autumn, the neighbors brought their chairs into the courtyard. Someone carried a pot of tea, someone else brought a bowl of apples, and a child arranged fallen leaves along the edge of the fountain. For a while, the conversation moved from the weather to the old shops on the corner and then to the names of people who had lived there before. Nothing remarkable happened. Yet when the lights came on in the windows above them, nobody was in a hurry to go inside. The courtyard had become a room that belonged to everyone.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + }, + { + "id": 100008, + "text": "The library kept a box of unfinished drawings beside the window. Visitors could add a line, a color, or a whole new corner of the scene before returning it to the box. Over the years, a sketch of a single house grew into a street, then a town, and finally a landscape with fields beyond the last row of roofs. No one knew who had drawn the first house. No one needed to know. Each new visitor found a little space that had been left open, made a choice, and passed the drawing along to someone who would see a different possibility.", + "source": "Original Oxytype practice prose (GPL-3.0-only)" + } +] diff --git a/crates/oxytype-tui/src/content.rs b/crates/oxytype-tui/src/content.rs new file mode 100644 index 000000000..ffb8ed1a0 --- /dev/null +++ b/crates/oxytype-tui/src/content.rs @@ -0,0 +1,175 @@ +//! Local UTF-8 asset loading only. Generation and normalization belong to core. +use crate::Result; +use serde::Deserialize; +use std::{fs::File, io::Read, path::Path}; +use typing_engine::{ + Text, + generation::Language, + quotes::{Quote, QuoteAsset}, +}; + +pub const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +pub fn read_utf8(path: &Path) -> Result { + let mut bytes = vec![]; + File::open(path)? + .take(MAX_FILE_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_FILE_BYTES { + return Err("Local file exceeds 8 MiB".into()); + } + Ok(String::from_utf8(bytes).map_err(|_| "Local file must be valid UTF-8")?) +} +pub fn safe_text(value: &str) -> Result<()> { + if value.chars().any(|c| { + (c.is_control() && c != '\t' && c != '\n') + || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') + }) { + return Err( + "Content contains terminal controls or directional overrides (only Tab/LF allowed)" + .into(), + ); + } + Ok(()) +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalLanguage { + name: String, + words: Vec, + #[serde(default)] + bcp47: Option, + #[serde(default)] + right_to_left: Option, + #[serde(default)] + joining_script: bool, + #[serde(default)] + original_punctuation: bool, + #[serde(default)] + no_lazy_mode: bool, + #[serde(default)] + additional_accents: Vec<(String, String)>, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalQuote { + id: u32, + text: String, + source: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Pack { + format_version: u32, + license: String, + attribution: String, + language: LocalLanguage, + #[serde(default)] + quotes: Vec, +} +#[derive(Clone)] +pub struct OfflineContent { + pub language: Language, + pub quotes: QuoteAsset, + pub attribution: String, +} +impl OfflineContent { + pub fn bundled() -> Result { + Self::convert( + serde_json::from_str(include_str!("../assets/english.json"))?, + serde_json::from_str(include_str!("../assets/quotes.json"))?, + "Oxytype / Monkeytype English words; original Oxytype prose; GPL-3.0-only".into(), + ) + } + pub fn load(path: &Path) -> Result { + Self::parse(&read_utf8(path)?) + } + pub fn parse(json: &str) -> Result { + if json.len() as u64 > MAX_FILE_BYTES { + return Err("Local pack exceeds 8 MiB".into()); + } + let pack: Pack = serde_json::from_str(json)?; + if pack.format_version != 1 { + return Err("Unsupported local content pack version (expected 1)".into()); + } + if pack.license.trim().is_empty() || pack.attribution.trim().is_empty() { + return Err("Content pack requires license and attribution".into()); + } + safe_text(&pack.license)?; + safe_text(&pack.attribution)?; + Self::convert( + pack.language, + pack.quotes, + format!("{}; {}", pack.attribution, pack.license), + ) + } + fn convert(raw: LocalLanguage, quotes: Vec, attribution: String) -> Result { + if raw.words.is_empty() || raw.words.iter().any(|w| w.trim().is_empty()) { + return Err("Language requires nonempty words".into()); + } + for word in &raw.words { + safe_text(word)?; + } + for (a, b) in &raw.additional_accents { + safe_text(a)?; + safe_text(b)?; + } + if raw.name.is_empty() + || raw.name.len() > 128 + || !raw + .name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_') + { + return Err("Invalid content language identifier".into()); + } + if let Some(bcp47) = &raw.bcp47 { + safe_text(bcp47)?; + } + let language = Language { + name: raw.name.clone(), + bcp47: raw.bcp47, + words: raw.words.iter().map(|w| Text::from(w.as_str())).collect(), + right_to_left: raw.right_to_left, + joining_script: raw.joining_script, + original_punctuation: raw.original_punctuation, + no_lazy_mode: raw.no_lazy_mode, + additional_accents: raw + .additional_accents + .into_iter() + .map(|(a, b)| (Text::from(a.as_str()), Text::from(b.as_str()))) + .collect(), + }; + typing_engine::validation::language(&language)?; + let mut ids = std::collections::BTreeSet::new(); + let mut converted = vec![]; + for quote in quotes { + safe_text("e.text)?; + safe_text("e.source)?; + if quote.text.trim().is_empty() || !ids.insert(quote.id) { + return Err("Quotes require nonempty text and unique IDs".into()); + } + let text = Text::from(quote.text.as_str()); + converted.push(Quote { + length: text.len(), + text, + british_text: None, + source: quote.source, + id: quote.id, + group: 0, + language: raw.name.clone(), + text_split: vec![], + }); + } + let quotes = QuoteAsset { + language: raw.name, + groups: vec![(0, 124), (125, 249), (250, 499), (500, 1_000_000)], + quotes: converted, + }; + typing_engine::validation::quote_asset("es)?; + Ok(Self { + language, + quotes, + attribution, + }) + } +} diff --git a/crates/oxytype-tui/src/engine.rs b/crates/oxytype-tui/src/engine.rs index f6bc780ee..8a1c74242 100644 --- a/crates/oxytype-tui/src/engine.rs +++ b/crates/oxytype-tui/src/engine.rs @@ -19,6 +19,7 @@ pub struct EngineHost { session: Option, language: Language, custom: Option, + quotes: Option, } impl EngineHost { pub fn new( @@ -26,11 +27,30 @@ impl EngineHost { language: Language, custom: Option, seed: u32, + ) -> Result { + Self::with_assets(config, language, custom, seed, None) + } + pub fn offline( + config: Config, + content: crate::content::OfflineContent, + custom: Option, + seed: u32, + ) -> Result { + crate::options::validate(&config, &content, custom.as_ref())?; + Self::with_assets(config, content.language, custom, seed, Some(content.quotes)) + } + fn with_assets( + config: Config, + language: Language, + custom: Option, + seed: u32, + quotes: Option, ) -> Result { let mut host = Self { config, language, custom, + quotes, seed, content: Content::default(), session: None, @@ -79,7 +99,7 @@ impl EngineHost { language: self.language.clone(), custom: self.custom.clone(), random: Random::seeded(self.seed), - quotes: None, + quotes: self.quotes.clone(), polyglot: vec![], selected_quote_id: 0, favorite_quote_ids: vec![], diff --git a/crates/oxytype-tui/src/lib.rs b/crates/oxytype-tui/src/lib.rs index 434ceeba6..25208f1d7 100644 --- a/crates/oxytype-tui/src/lib.rs +++ b/crates/oxytype-tui/src/lib.rs @@ -1,4 +1,6 @@ +pub mod content; pub mod engine; +pub mod options; pub mod terminal; pub type Error = Box; diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index 76457a4e0..acfbed3c7 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -1,5 +1,6 @@ use clap::Parser; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use oxytype_tui::options::Cli; use oxytype_tui::{Result, engine::EngineHost, terminal::TerminalGuard}; use ratatui::{ Frame, @@ -12,19 +13,8 @@ use std::{ sync::atomic::Ordering, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use typing_engine::{Command, Config, Text, generation::Language}; +use typing_engine::{Command, Config}; -#[derive(Parser)] -#[command( - name = "oxytype", - version = oxytype_tui::version(), - about = "Offline typing with the shared Oxytype Rust engine" -)] -struct Cli { - /// Exercise a deterministic synthetic session without a terminal or saved data. - #[arg(long)] - smoke_test: bool, -} #[derive(Clone, Copy, PartialEq)] enum Screen { Test, @@ -49,19 +39,12 @@ fn run() -> Result<()> { if cli.smoke_test { return oxytype_tui::engine::smoke(); } - let mut host = EngineHost::new( - Config::default(), - Language { - name: "english".into(), - words: ["one", "two", "three", "four"] - .into_iter() - .map(Text::from) - .collect(), - ..Default::default() - }, - None, - date() as u32, - )?; + if cli.capabilities { + println!("{}", oxytype_tui::options::capabilities()); + return Ok(()); + } + let (config, content, custom) = cli.prepare(Config::default())?; + let mut host = EngineHost::offline(config, content, custom, cli.seed.unwrap_or(date() as u32))?; let (guard, mut terminal) = TerminalGuard::enter()?; #[cfg(debug_assertions)] match std::env::var("OXYTYPE_TERMINAL_TEST_FAILURE").as_deref() { diff --git a/crates/oxytype-tui/src/options.rs b/crates/oxytype-tui/src/options.rs new file mode 100644 index 000000000..df5c502c0 --- /dev/null +++ b/crates/oxytype-tui/src/options.rs @@ -0,0 +1,193 @@ +//! CLI overrides apply to the very same core Config edited by the settings UI. +use crate::{ + Result, + content::{OfflineContent, read_utf8, safe_text}, +}; +use clap::Parser; +use std::path::PathBuf; +use typing_engine::{ + Config, Text, + generation::{CustomLimit, CustomText}, +}; + +pub const FUNBOXES: &[&str] = &["capitals", "rAnDoMcAsE", "backwards", "IPv4", "IPv6"]; +#[derive(Parser, Default)] +#[command(name="oxytype", version=crate::version(), about="Offline typing with the shared Oxytype Rust engine")] +pub struct Cli { + /// Deterministic synthetic smoke; no terminal or saved data. + #[arg(long)] + pub smoke_test: bool, + /// time, words, quote, custom, or zen. Defaults come from the shared engine. + #[arg(long)] + pub mode: Option, + #[arg(long)] + pub words: Option, + #[arg(long)] + pub time: Option, + /// Must match bundled English or the explicitly supplied pack. + #[arg(long)] + pub language: Option, + #[arg(long)] + pub seed: Option, + /// Version-1 local JSON language/quote pack; never downloaded. + #[arg(long)] + pub pack: Option, + /// Private custom text; never saved in settings/history by default. + #[arg(long, conflicts_with = "file")] + pub text: Option, + #[arg(long, conflicts_with = "text")] + pub file: Option, + #[arg(long, default_value = "repeat")] + pub custom_order: String, + #[arg(long, default_value = "section")] + pub custom_limit: String, + #[arg(long, default_value_t = 1.0)] + pub custom_length: f64, + /// Comma-separated quote groups: 0 short, 1 medium, 2 long, 3 very long. + #[arg(long, value_delimiter = ',')] + pub quote_length: Option>, + #[arg(long)] + pub punctuation: bool, + #[arg(long)] + pub numbers: bool, + #[arg(long)] + pub difficulty: Option, + #[arg(long)] + pub stop_on_error: Option, + #[arg(long)] + pub delete_on_error: Option, + #[arg(long)] + pub confidence: Option, + #[arg(long)] + pub freedom: bool, + #[arg(long)] + pub strict_space: bool, + /// Explicit supported subset; see --capabilities. + #[arg(long, value_delimiter = ',')] + pub funbox: Option>, + #[arg(long)] + pub capabilities: bool, +} +impl Cli { + pub fn prepare( + &self, + mut config: Config, + ) -> Result<(Config, OfflineContent, Option)> { + let content = match &self.pack { + Some(path) => OfflineContent::load(path)?, + None => OfflineContent::bundled()?, + }; + macro_rules! project { ($($name:ident),*) => { $(if let Some(value) = &self.$name { config.$name = value.clone(); })* }; } + project!( + mode, + words, + time, + language, + quote_length, + difficulty, + stop_on_error, + delete_on_error, + funbox + ); + if let Some(value) = &self.confidence { + config.confidence_mode = value.clone(); + } + if self.language.is_none() { + config.language = content.language.name.clone(); + } + config.punctuation |= self.punctuation; + config.numbers |= self.numbers; + config.freedom_mode |= self.freedom; + config.strict_space |= self.strict_space; + let text = match (&self.text, &self.file) { + (Some(text), _) => Some(text.clone()), + (_, Some(path)) => Some(read_utf8(path)?), + _ => None, + }; + if text.is_some() && self.mode.is_none() { + config.mode = "custom".into(); + } + if text.is_some() && config.mode != "custom" { + return Err("--text/--file requires custom mode".into()); + } + let custom = text + .map(|text| { + custom_text( + &text, + &self.custom_order, + &self.custom_limit, + self.custom_length, + ) + }) + .transpose()?; + validate(&config, &content, custom.as_ref())?; + Ok((config, content, custom)) + } +} +pub fn custom_text(text: &str, order: &str, limit: &str, value: f64) -> Result { + safe_text(text)?; + if text.trim().is_empty() { + return Err("Custom text is empty".into()); + } + if !["repeat", "random", "shuffle"].contains(&order) + || !["word", "time", "section"].contains(&limit) + { + return Err("Invalid custom order/limit".into()); + } + let custom = CustomText { + text: text + .split(' ') + .filter(|part| !part.is_empty()) + .map(Text::from) + .collect(), + mode: order.into(), + limit: CustomLimit { + mode: limit.into(), + value, + }, + pipe_delimiter: false, + }; + typing_engine::validation::custom(&custom)?; + Ok(custom) +} +pub fn validate( + config: &Config, + content: &OfflineContent, + custom: Option<&CustomText>, +) -> Result<()> { + config.validate()?; + if config.language != content.language.name { + return Err("Selected language is unavailable; supply a matching --pack".into()); + } + if config.mode == "custom" && custom.is_none() { + return Err("Custom mode requires --text, --file, or the custom-text editor".into()); + } + if config.quote_length.is_empty() || config.quote_length.iter().any(|g| !(0..=3).contains(g)) { + return Err("Terminal quote lengths must be groups 0,1,2,3".into()); + } + for name in &config.funbox { + if !FUNBOXES.contains(&name.as_str()) { + return Err( + format!("Terminal does not support funbox {name}; see --capabilities").into(), + ); + } + } + if !typing_engine::funboxes::compatible(&config.funbox) { + return Err("Incompatible funbox combination".into()); + } + // Ask shared metadata about forced settings; never silently substitute config. + if let serde_json::Value::Object(values) = serde_json::to_value(config)? { + for (key, value) in values { + if !typing_engine::funboxes::can_set(&key, &value, &config.funbox)? { + return Err(format!("Funbox disallows setting {key}={value}").into()); + } + } + } + Ok(()) +} +pub fn capabilities() -> String { + format!( + "Modes: time, words, quote, custom text/file, zen\nOffline English + original quotes; local v1 packs\nFunboxes: {}\nOther web-only visual/audio/network/layout hooks: unavailable (rejected)\nInput: committed characters, Backspace/word delete, Enter, Tab, repeats\nPhysical-key identity/composition lifecycle: unavailable\nKey duration/spacing/overlap: unavailable; no synthetic releases\nPaste: blocked during tests; unmarked paste cannot be detected\n", + FUNBOXES.join(", ") + ) +} diff --git a/crates/oxytype-tui/tests/offline_content.rs b/crates/oxytype-tui/tests/offline_content.rs new file mode 100644 index 000000000..e10592805 --- /dev/null +++ b/crates/oxytype-tui/tests/offline_content.rs @@ -0,0 +1,156 @@ +use clap::Parser; +use oxytype_tui::{ + content::OfflineContent, + engine::EngineHost, + options::{Cli, FUNBOXES, custom_text}, +}; +use typing_engine::{Command, Config}; + +#[test] +fn bundled_modes_run_without_files_or_network() { + for mode in ["words", "time", "quote", "custom", "zen"] { + let content = OfflineContent::bundled().unwrap(); + assert_eq!(content.language.words.len(), 200); + let custom = (mode == "custom") + .then(|| custom_text("one two three", "repeat", "section", 1.0).unwrap()); + let mut host = EngineHost::offline( + Config { + mode: mode.into(), + words: 3, + time: 1.0, + ..Default::default() + }, + content, + custom, + 42, + ) + .unwrap(); + if mode == "zen" { + host.insert("hello ", 100.0, 1000.0).unwrap(); + host.command(Command::Bailout { + now: 1100.0, + date: 2000.0, + }) + .unwrap(); + } else if mode == "time" { + let word = host.words[0].display_lossy(); + host.insert(&word, 100.0, 1000.0).unwrap(); + host.command(Command::Advance { + now: 1200.0, + date: 2100.0, + }) + .unwrap(); + } else { + for index in 0..100 { + if host.state().ended { + break; + } + let word = host.words[index].display_lossy(); + host.insert( + &word, + 100.0 + index as f64 * 1000.0, + 1000.0 + index as f64 * 1000.0, + ) + .unwrap(); + } + } + assert!(host.state().ended, "{mode}"); + assert!(host.result.is_some(), "{mode}: {:?}", host.result_error); + let log = host.log().unwrap(); + assert!( + log.events + .iter() + .all(|e| !["keydown", "keyup"].contains(&e.kind.as_str())) + ); + assert_eq!( + serde_json::to_value(host.result.unwrap()).unwrap(), + serde_json::to_value(typing_engine::stats::typed_summary(&log).unwrap()).unwrap() + ); + } +} +#[test] +fn all_bundled_quote_groups_and_advertised_transforms_prepare() { + for group in 0..=3 { + let host = EngineHost::offline( + Config { + mode: "quote".into(), + quote_length: vec![group], + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + assert!(!host.words.is_empty()); + } + for name in FUNBOXES { + let host = EngineHost::offline( + Config { + mode: "words".into(), + words: 5, + funbox: vec![(*name).into()], + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + assert_eq!(host.words.len(), 5, "{name}"); + } +} +#[test] +fn cli_uses_core_defaults_and_rejects_unavailable_features() { + let (config, _, _) = Cli::parse_from(["oxytype"]) + .prepare(Config::default()) + .unwrap(); + assert_eq!( + serde_json::to_value(config).unwrap(), + serde_json::to_value(Config::default()).unwrap() + ); + for args in [ + vec!["oxytype", "--language", "missing"], + vec!["oxytype", "--mode", "custom"], + vec!["oxytype", "--funbox", "tts"], + vec!["oxytype", "--time", "NaN"], + vec!["oxytype", "--quote-length", "-2"], + ] { + let parsed = Cli::try_parse_from(args); + assert!(parsed.is_err() || parsed.unwrap().prepare(Config::default()).is_err()); + } + let (config, _, custom) = Cli::parse_from([ + "oxytype", + "--text", + "one two", + "--custom-order", + "shuffle", + "--custom-limit", + "word", + "--custom-length", + "7", + ]) + .prepare(Config::default()) + .unwrap(); + assert_eq!(config.mode, "custom"); + assert_eq!(custom.unwrap().limit.value, 7.0); +} +#[test] +fn local_pack_validation_is_lossless_utf16_and_bounded() { + let pack = serde_json::json!({"formatVersion":1,"license":"CC0-1.0","attribution":"Test","language":{"name":"test","words":["e\u{301}","界","😀"]},"quotes":[{"id":1,"text":"😀 界","source":"Test"}]}); + let content = OfflineContent::parse(&pack.to_string()).unwrap(); + assert_eq!(content.language.words[2].0, vec![0xd83d, 0xde00]); + for patch in [ + serde_json::json!({"formatVersion":2}), + serde_json::json!({"language":{"name":"test","words":["\u{1b}[31m"]}}), + serde_json::json!({"license":""}), + serde_json::json!({"quotes":[{"id":1,"text":"a","source":"x"},{"id":1,"text":"b","source":"x"}]}), + ] { + let mut bad = pack.clone(); + for (key, value) in patch.as_object().unwrap() { + bad[key] = value.clone(); + } + assert!(OfflineContent::parse(&bad.to_string()).is_err()); + } + assert!(OfflineContent::parse(&" ".repeat(8 * 1024 * 1024 + 1)).is_err()); +} diff --git a/docs/rust-engine/terminal-content.md b/docs/rust-engine/terminal-content.md new file mode 100644 index 000000000..337c15c4b --- /dev/null +++ b/docs/rust-engine/terminal-content.md @@ -0,0 +1,50 @@ +# Offline terminal content + +The binary embeds the existing 200-word English asset and eight original Oxytype +practice passages. Both are GPL-3.0-only; attribution and the English source hash +are in `crates/oxytype-tui/assets/ATTRIBUTION.md`. Nothing downloads at startup. + +`oxytype --mode words --words 25 --seed 42` uses the shared native generator. +Other modes: `time`, `quote`, `custom`, `zen`. Defaults come from core Config. +`--punctuation`, `--numbers`, `--difficulty`, `--stop-on-error`, +`--delete-on-error`, `--confidence`, `--freedom`, `--strict-space` project into +that same Config. `--capabilities` lists the explicit terminal funbox subset; +other hooks fail rather than silently substituting content. Incompatible forced +settings are rejected using native metadata. + +Custom text: `--text 'one two three'` or `--file practice.txt` (UTF-8, ≤8 MiB). +This implies custom mode unless another mode is explicitly supplied (an error). +`--custom-order repeat|random|shuffle`, `--custom-limit word|time|section`, and +`--custom-length N` feed the core's custom content contract. Zero means infinite +where defined by core. Literal spaces separate custom sections; Tab/LF remain +in sections. Native rules retain all normalization and scoring responsibility. + +Extend offline content with `--pack pack.json`. Version 1: + +```json +{ + "formatVersion": 1, + "license": "CC0-1.0", + "attribution": "Your content author", + "language": { + "name": "example", + "bcp47": "en", + "words": ["alpha", "beta", "gamma"] + }, + "quotes": [{"id": 1, "text": "An original practice passage.", "source": "Your author"}] +} +``` + +The file must be valid UTF-8 JSON, ≤8 MiB. License/attribution required; unique +quote IDs and nonempty words/text. Language names: ASCII alphanumeric/underscore, +≤128 bytes. Optional language fields: `rightToLeft`, `joiningScript`, +`originalPunctuation`, `noLazyMode`, `additionalAccents` (pairs of strings). +Language metadata passes shared engine bounds; terminal control sequences and +direction overrides are rejected. Only Tab/LF control characters are accepted. +Terminal shaping limitations still apply; a pack cannot grant keyboard/display +capabilities. No fetching or silent fallback to English for a missing language. + +Quote lengths count UTF-16 units: group 0 = 0–124, 1 = 125–249, 2 = 250–499, +3 = 500–1,000,000. `--quote-length 0,1,2,3` selects groups; default group 1 is the +native default. Missing selected groups fail clearly. Packs need not contain +quotes when used for word/time tests. Quotes are selected/normalized by core. From 0ae75a880e12143e48fe437148410c06c22d2213 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:07:18 +0530 Subject: [PATCH 05/48] feat(tui): add validated settings and private custom text editor --- crates/oxytype-tui/src/lib.rs | 1 + crates/oxytype-tui/src/main.rs | 114 +++++++++++++++++------- crates/oxytype-tui/src/setup.rs | 139 ++++++++++++++++++++++++++++++ crates/oxytype-tui/tests/setup.rs | 26 ++++++ docs/rust-engine/progress.md | 27 +++++- scripts/terminal_pty.py | 93 ++++++++++++++++++-- 6 files changed, 363 insertions(+), 37 deletions(-) create mode 100644 crates/oxytype-tui/src/setup.rs create mode 100644 crates/oxytype-tui/tests/setup.rs diff --git a/crates/oxytype-tui/src/lib.rs b/crates/oxytype-tui/src/lib.rs index 25208f1d7..b8bf276a6 100644 --- a/crates/oxytype-tui/src/lib.rs +++ b/crates/oxytype-tui/src/lib.rs @@ -1,6 +1,7 @@ pub mod content; pub mod engine; pub mod options; +pub mod setup; pub mod terminal; pub type Error = Box; diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index acfbed3c7..4b08ed679 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -1,7 +1,7 @@ use clap::Parser; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use oxytype_tui::options::Cli; use oxytype_tui::{Result, engine::EngineHost, terminal::TerminalGuard}; +use oxytype_tui::{options::Cli, setup::Setup}; use ratatui::{ Frame, layout::{Constraint, Layout}, @@ -21,6 +21,7 @@ enum Screen { Help, Settings, History, + Editor, } fn date() -> f64 { SystemTime::now() @@ -44,7 +45,13 @@ fn run() -> Result<()> { return Ok(()); } let (config, content, custom) = cli.prepare(Config::default())?; - let mut host = EngineHost::offline(config, content, custom, cli.seed.unwrap_or(date() as u32))?; + let mut setup = Setup::new(config.clone(), custom.as_ref()); + let mut host = EngineHost::offline( + config, + content.clone(), + custom, + cli.seed.unwrap_or(date() as u32), + )?; let (guard, mut terminal) = TerminalGuard::enter()?; #[cfg(debug_assertions)] match std::env::var("OXYTYPE_TERMINAL_TEST_FAILURE").as_deref() { @@ -70,7 +77,7 @@ fn run() -> Result<()> { host.command(Command::Advance { now, date: date() })?; } if dirty || (host.state().active && last_draw.elapsed() >= Duration::from_millis(100)) { - terminal.draw(|frame| draw(frame, &host, screen, ¬ice, &history, now))?; + terminal.draw(|frame| draw(frame, &host, screen, ¬ice, &history, now, &setup))?; dirty = false; last_draw = Instant::now(); } @@ -88,6 +95,15 @@ fn run() -> Result<()> { // Fullscreen draw autoresizes and invalidates its viewport. An extra // clear queries cursor position, which basic terminals need not report. Event::Resize(_, _) => {} + Event::Paste(text) if screen == Screen::Editor => { + match oxytype_tui::content::safe_text(&text) { + Ok(()) if setup.text.len() + text.len() <= 8 * 1024 * 1024 => { + setup.text.push_str(&text) + } + Ok(()) => notice = "Custom text exceeds 8 MiB".into(), + Err(error) => notice = error.to_string(), + } + } Event::Paste(_) => notice = "Paste blocked during typing.".into(), Event::Key(key) if key.kind != KeyEventKind::Release => { let control = key.modifiers.contains(KeyModifiers::CONTROL); @@ -102,7 +118,43 @@ fn run() -> Result<()> { } match key.code { KeyCode::F(1) => screen = Screen::Help, - KeyCode::F(2) if !host.state().active => screen = Screen::Settings, + KeyCode::F(2) if !host.state().active => { + setup.config = host.config.clone(); + screen = Screen::Settings; + } + KeyCode::F(4) if !host.state().active => screen = Screen::Editor, + KeyCode::F(6) if screen == Screen::Editor => setup.cycle_order(), + KeyCode::F(7) if screen == Screen::Editor => setup.cycle_limit(), + KeyCode::F(9) if screen == Screen::Editor => setup.length += 1.0, + KeyCode::F(8) if screen == Screen::Editor => { + setup.config.mode = "custom".into(); + match setup.build(&content, host.seed.wrapping_add(1)) { + Ok(next) => { + host = next; + screen = Screen::Test; + notice.clear(); + } + Err(error) => notice = error.to_string(), + } + } + KeyCode::Char(c) + if screen == Screen::Editor + && !control + && !key.modifiers.contains(KeyModifiers::ALT) => + { + if !c.is_control() && setup.text.len() < 8 * 1024 * 1024 { + setup.text.push(c); + } + } + KeyCode::Backspace if screen == Screen::Editor => { + setup.text.pop(); + } + KeyCode::Enter if screen == Screen::Editor => setup.text.push('\n'), + KeyCode::Tab if screen == Screen::Editor => setup.text.push('\t'), + KeyCode::Up if screen == Screen::Settings => setup.select(-1), + KeyCode::Down if screen == Screen::Settings => setup.select(1), + KeyCode::Left if screen == Screen::Settings => setup.adjust(-1), + KeyCode::Right if screen == Screen::Settings => setup.adjust(1), KeyCode::F(3) if !host.state().active => screen = Screen::History, KeyCode::Esc if screen != Screen::Test => screen = Screen::Test, KeyCode::F(5) | KeyCode::F(6) => { @@ -117,20 +169,18 @@ fn run() -> Result<()> { notice.clear(); } KeyCode::Enter if screen == Screen::Settings => { - host.restart(false, now, date())?; - screen = Screen::Test; - } - KeyCode::Char('m') if screen == Screen::Settings => { - host.config.mode = match host.config.mode.as_str() { - "time" => "words", - "words" => "zen", - _ => "time", + if setup.config.mode == "custom" && setup.text.trim().is_empty() { + screen = Screen::Editor; + } else { + match setup.build(&content, host.seed.wrapping_add(1)) { + Ok(next) => { + host = next; + screen = Screen::Test; + notice.clear(); + } + Err(error) => notice = error.to_string(), + } } - .into() - } - KeyCode::Char('+') if screen == Screen::Settings => { - host.config.words += 10; - host.config.time += 15.0; } KeyCode::F(8) if screen == Screen::Test => { host.command(Command::Bailout { now, date: date() })?; @@ -181,6 +231,7 @@ fn draw( notice: &str, history: &[String], now: f64, + setup: &Setup, ) { let area = frame.area(); if area.width < 60 || area.height < 18 { @@ -208,21 +259,20 @@ fn draw( let state = host.state(); let body = match screen { Screen::Help => vec![ - Line::from("F5 new · F6 repeat · F2 settings · F3 history · Esc return"), + Line::from("F5 new · F6 repeat · F2 settings · F3 history · F4 custom · Esc return"), Line::from("F8 finish · Backspace delete · Ctrl+Backspace delete word"), Line::from("Ctrl+Q / Ctrl+C quit · Ctrl+Z suspend (Unix)"), Line::from("Physical-key timing is unavailable; no key releases are invented."), ], - Screen::Settings => vec![ - Line::from(format!( - "m: mode {} · +: increase length · Enter: start", - host.config.mode - )), - Line::from(format!( - "Words {} · Time {} seconds", - host.config.words, host.config.time - )), - ], + Screen::Settings => { + let lines = setup.lines(); + let visible = rows[1].height.saturating_sub(3) as usize; + let skip = setup.selected.saturating_sub(visible.saturating_sub(1)); + std::iter::once(lines[0].clone()) + .chain(lines.into_iter().skip(skip + 1).take(visible)) + .collect() + } + Screen::Editor => setup.editor_lines(), Screen::History => history .iter() .rev() @@ -243,7 +293,13 @@ fn draw( ) }, )), - Line::from(state.reason.unwrap_or_else(|| "Completed".into())), + Line::from(state.reason.unwrap_or_else(|| { + if host.log().is_ok_and(|log| log.context.bailed_out) { + "Finished manually".into() + } else { + "Completed".into() + } + })), Line::from("F5 new test · F6 repeat"), ], Screen::Test => { diff --git a/crates/oxytype-tui/src/setup.rs b/crates/oxytype-tui/src/setup.rs new file mode 100644 index 000000000..06a1623f7 --- /dev/null +++ b/crates/oxytype-tui/src/setup.rs @@ -0,0 +1,139 @@ +//! Presentation controls edit a draft core configuration, validated on apply. +use crate::{Result, content::OfflineContent, engine::EngineHost, options}; +use ratatui::text::Line; +use typing_engine::{Config, generation::CustomText}; + +pub struct Setup { + pub config: Config, + pub selected: usize, + pub text: String, + pub order: String, + pub limit: String, + pub length: f64, +} +fn cycle(value: &mut String, values: &[&str], direction: i32) { + let index = values.iter().position(|v| v == value).unwrap_or(0) as i32; + *value = values[(index + direction).rem_euclid(values.len() as i32) as usize].into(); +} +impl Setup { + pub fn new(config: Config, custom: Option<&CustomText>) -> Self { + Self { + config, + selected: 0, + text: custom + .map(|c| { + c.text + .iter() + .map(|t| t.display_lossy()) + .collect::>() + .join(" ") + }) + .unwrap_or_default(), + order: custom.map_or("repeat", |c| c.mode.as_str()).into(), + limit: custom.map_or("section", |c| c.limit.mode.as_str()).into(), + length: custom.map_or(1.0, |c| c.limit.value), + } + } + pub fn custom(&self) -> Result> { + if self.config.mode != "custom" { + return Ok(None); + } + Ok(Some(options::custom_text( + &self.text, + &self.order, + &self.limit, + self.length, + )?)) + } + pub fn build(&self, content: &OfflineContent, seed: u32) -> Result { + EngineHost::offline(self.config.clone(), content.clone(), self.custom()?, seed) + } + pub fn select(&mut self, direction: i32) { + self.selected = (self.selected as i32 + direction).rem_euclid(13) as usize; + } + pub fn adjust(&mut self, direction: i32) { + let c = &mut self.config; + match self.selected { + 0 => cycle( + &mut c.mode, + &["time", "words", "quote", "custom", "zen"], + direction, + ), + 1 => { + c.words = + (i64::from(c.words) + i64::from(direction) * 10).clamp(0, 1_000_000) as u32 + } + 2 => c.time = (c.time + f64::from(direction) * 15.0).clamp(0.0, 1_000_000.0), + 3 => c.punctuation = !c.punctuation, + 4 => c.numbers = !c.numbers, + 5 => cycle( + &mut c.difficulty, + &["normal", "expert", "master"], + direction, + ), + 6 => cycle(&mut c.stop_on_error, &["off", "word", "letter"], direction), + 7 => cycle( + &mut c.delete_on_error, + &["off", "word", "letter", "word_hard", "letter_hard"], + direction, + ), + 8 => cycle(&mut c.confidence_mode, &["off", "on", "max"], direction), + 9 => c.freedom_mode = !c.freedom_mode, + 10 => c.strict_space = !c.strict_space, + 11 => c.quote_length = vec![(c.quote_length[0] + direction).rem_euclid(4)], + 12 => { + let choices: Vec<_> = std::iter::once("") + .chain(options::FUNBOXES.iter().copied()) + .collect(); + let mut name = c.funbox.first().cloned().unwrap_or_default(); + cycle(&mut name, &choices, direction); + c.funbox = if name.is_empty() { vec![] } else { vec![name] }; + } + _ => {} + } + } + pub fn lines(&self) -> Vec> { + let c = &self.config; + let labels = [ + format!("Mode: {}", c.mode), + format!("Words: {} (0 infinite)", c.words), + format!("Time: {}s (0 infinite)", c.time), + format!("Punctuation: {}", c.punctuation), + format!("Numbers: {}", c.numbers), + format!("Difficulty: {}", c.difficulty), + format!("Stop on error: {}", c.stop_on_error), + format!("Delete on error: {}", c.delete_on_error), + format!("Confidence: {}", c.confidence_mode), + format!("Freedom: {}", c.freedom_mode), + format!("Strict space: {}", c.strict_space), + format!("Quote group: {:?}", c.quote_length), + format!("Funbox: {}", c.funbox.join(", ")), + ]; + std::iter::once(Line::from( + "↑↓ select · ←→ change · Enter apply · F4 custom text · Esc cancel", + )) + .chain(labels.into_iter().enumerate().map(|(i, s)| { + Line::from(format!( + "{} {s}", + if i == self.selected { ">" } else { " " } + )) + })) + .collect() + } + pub fn editor_lines(&self) -> Vec> { + vec![ + Line::from("Private custom text · type or paste · F8 apply · Esc cancel"), + Line::from(format!( + "F6 order: {} · F7 limit: {} · F9 length: {}", + self.order, self.limit, self.length + )), + Line::from(format!("{}▌", self.text)), + ] + } + pub fn cycle_order(&mut self) { + cycle(&mut self.order, &["repeat", "random", "shuffle"], 1); + } + pub fn cycle_limit(&mut self) { + cycle(&mut self.limit, &["section", "word", "time"], 1); + } +} diff --git a/crates/oxytype-tui/tests/setup.rs b/crates/oxytype-tui/tests/setup.rs new file mode 100644 index 000000000..07d6e22b5 --- /dev/null +++ b/crates/oxytype-tui/tests/setup.rs @@ -0,0 +1,26 @@ +use oxytype_tui::{content::OfflineContent, setup::Setup}; +use typing_engine::Config; + +#[test] +fn settings_edit_drafts_and_apply_the_same_native_configuration() { + let content = OfflineContent::bundled().unwrap(); + let mut setup = Setup::new(Config::default(), None); + let original = setup.build(&content, 42).unwrap(); + setup.adjust(1); // time -> words + setup.select(1); + setup.adjust(-1); // 50 -> 40 + let changed = setup.build(&content, 42).unwrap(); + assert_eq!(original.config.mode, "time"); + assert_eq!(changed.config.mode, "words"); + assert_eq!(changed.words.len(), 40); + setup.config.mode = "custom".into(); + assert!(setup.build(&content, 42).is_err()); + setup.text = "one two".into(); + setup.cycle_order(); + setup.cycle_limit(); + setup.length = 7.0; + assert_eq!(setup.build(&content, 42).unwrap().words.len(), 7); + setup.config.funbox = vec!["tts".into()]; + assert!(setup.build(&content, 42).is_err()); + assert_eq!(changed.words.len(), 40); // Failed draft never replaces running host. +} diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index 69699ece9..262c5fcd1 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -12,7 +12,7 @@ empty commits to inflate count. Do not publish production or releases implicitly ## Current position -Stage 1 and Stage 2 gates passed. Stage 3 Phase 3.2 active on `stack/rust-engine-terminal`, stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration +Stage 1 and Stage 2 gates passed. Stage 3 Phase 3.3 active on `stack/rust-engine-terminal`, stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration plan before edits. Installed workspace dependencies with frozen lockfile. Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen oracle and mismatch detection. Existing website engine remains authoritative until Stage 2. @@ -32,8 +32,9 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 2.5 Delivery / performance | Complete | Fresh Cloudflare build/preview/types, 86 Start checks, update/rollback, full website and controlled Wasm budgets pass. | | 2.6 TypeScript retirement | Complete | Legacy package removed; full native/Wasm/website/quality CI, fresh build/types and frozen performance gates pass. | | 3.1 Terminal shell | Complete | Native smoke/score test; real PTY typing, finish/restart, resize, error/panic, SIGTERM and suspend/resume pass. | -| 3.2 Offline content | Active | Bundled licensed words/quotes, local packs and full modes/settings next. | -| 3.3–3.5 Terminal | Pending | Input/capabilities, durable history and release gates remain. | +| 3.2 Offline content | Complete | Bundled words/eight original quotes; five modes, CLI/settings/editor, bounded v1 packs; native/full-fixture and actual PTY checks pass. | +| 3.3 Input / Unicode | Active | Capability negotiation and grapheme/cell rendering next. | +| 3.4–3.5 Terminal | Pending | Durable history and release gates remain. | ## Verification log @@ -553,3 +554,23 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro - This is the shell gate only. Current settings/history are temporary in-memory screens. Full offline content, input/rendering, durable storage and packaging remain required. Linux PTY coverage does not claim real macOS/Windows hardware. + +## Phase 3.2 gate passed + +- Bundled exact 200-word English asset plus eight original GPL-3.0-only practice + passages spanning all four native quote groups. Attribution/source hash retained; + binary embeds content. No first-run download. Local UTF-8 v1 packs/text files + bounded at 8 MiB; invalid encoding, controls, metadata, duplicate IDs and unknown + versions rejected. No unavailable-language fallback. +- Time/words/quote/custom/zen, custom ordering/limits, punctuation/numbers and + applicable difficulty/error settings use native Config/generation/session/stats. + CLI and settings draft share validation. Private editor accepts setup paste; + unsupported web hooks and conflicting forced settings rejected explicitly. +- `vp run engine:test`: full native workspace/frozen-fixture suite passes unchanged. + Six terminal integration tests pass (all modes/groups/transforms, exact known + score/repeat, invalid packs/options, draft application). Clippy passes. +- `vp run terminal:test:pty`: five actual offline modes, settings/editor/paste and + lifecycle checks pass. Test reader now reconstructs incremental VT cells because + unchanged characters are not re-emitted by Ratatui; no app behavior weakened. +- Accurate grapheme/cell rendering, negotiated capabilities and durable history + remain next. No native physical-key timing is inferred from committed input. diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 633be7dbf..6711d6256 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -4,6 +4,7 @@ import os import pathlib import pty +import re import select import signal import struct @@ -11,6 +12,7 @@ import sys import termios import time +import unicodedata def dimensions(slave, columns=100, rows=24): @@ -18,7 +20,7 @@ def dimensions(slave, columns=100, rows=24): class Child: - def __init__(self, binary, failure=None): + def __init__(self, binary, failure=None, args=()): self.master, self.slave = pty.openpty() dimensions(self.slave) self.original = termios.tcgetattr(self.slave) @@ -31,7 +33,7 @@ def controlling_terminal(): os.setsid() fcntl.ioctl(self.slave, termios.TIOCSCTTY, 0) - self.process = subprocess.Popen([binary], stdin=self.slave, stdout=self.slave, + self.process = subprocess.Popen([binary, *args], stdin=self.slave, stdout=self.slave, stderr=self.slave, env=env, preexec_fn=controlling_terminal) @@ -45,15 +47,58 @@ def read(self, seconds=0.05): def until(self, text, seconds=5): deadline = time.monotonic() + seconds - start = len(self.output) while time.monotonic() < deadline: self.read() - if text in self.output[start:]: + if text.decode("utf-8") in self.screen_text(): return if self.process.poll() is not None: break raise AssertionError(f"Missing {text!r}; process={self.process.poll()}; output={bytes(self.output[-2000:])!r}") + def screen_text(self): + # Minimal VT screen reader for assertions. Preserve unchanged cells and + # cursor moves between Ratatui frames, unlike stripping ANSI bytes. + grid, row, col = {}, 0, 0 + data = self.output.decode("utf-8", errors="replace") + i = 0 + while i < len(data): + if data[i] == "\x1b": + match = re.match(r"\x1b\[([0-?]*)([ -/]*)([@-~])", data[i:]) + if match: + params, _, command = match.groups() + private = params.startswith("?") + values = [int(v or 0) for v in params.lstrip("?>").split(";") if v.isdigit() or not v] + first = (values or [0])[0] + amount = first or 1 + if command in ("H", "f"): + row = amount - 1 + col = (values[1] or 1) - 1 if len(values) > 1 else 0 + elif command == "G": col = amount - 1 + elif command == "d": row = amount - 1 + elif command == "A": row = max(0, row - amount) + elif command == "B": row += amount + elif command == "C": col += amount + elif command == "D": col = max(0, col - amount) + elif command == "J" and first in (2, 3): grid.clear() + elif command == "K": + for key in list(grid): + if key[0] == row and (first == 2 or (first == 0 and key[1] >= col) or (first == 1 and key[1] <= col)): + del grid[key] + elif command == "h" and private and first == 1049: + grid.clear(); row = col = 0 + i += len(match.group()) + continue + i += 2 + continue + char = data[i] + if char == "\r": col = 0 + elif char == "\n": row += 1 + elif not unicodedata.category(char).startswith("C"): + grid[row, col] = char + col += 0 if unicodedata.combining(char) else (2 if unicodedata.east_asian_width(char) in ("W", "F") else 1) + i += 1 + return "\n".join("".join(grid.get((y, x), " ") for x in range(200)) for y in range(100)) + def send(self, text): os.write(self.master, text) @@ -100,6 +145,44 @@ def run(binary): finally: child.close() + for mode in ("time", "words", "quote", "custom", "zen"): + args = ["--mode", mode] + if mode == "custom": + args += ["--text", "one two three"] + child = Child(binary, args=args) + try: + child.until(b"Oxytype") + child.send(b"one ") + time.sleep(0.15) + child.send(b"\x1b[19~") + child.until(b"F5 new test") + child.send(b"\x11") + assert child.process.wait(timeout=5) == 0 + child.restored() + finally: + child.close() + + child = Child(binary) + try: + child.until(b"Oxytype") + child.send(b"\x1bOQ") # F2: settings + child.until(b"select") + child.send(b"\x1bOS") # F4: private editor + child.until(b"Private") + child.send(b"\x1b[200~one two\x1b[201~") + time.sleep(0.1) + child.send(b"\x1b[19~") + child.until(b"Input:") + child.send(b"one ") + time.sleep(0.15) + child.send(b"two") + child.until(b"F5 new test") + child.send(b"\x11") + assert child.process.wait(timeout=5) == 0 + child.restored() + finally: + child.close() + for failure in ("error", "panic", "signal", "suspend"): child = Child(binary, failure if failure in ("error", "panic") else None) try: @@ -127,7 +210,7 @@ def run(binary): child.restored() finally: child.close() - print("PTY: typing/finish/restart/resize/quit, error, panic, SIGTERM, suspend/resume pass") + print("PTY: five modes, settings/custom editor, typing/finish/restart/resize/quit, error, panic, SIGTERM, suspend/resume pass") if __name__ == "__main__": From e2cb2850f1401604f253044ad8fc40b618776efc Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:11:47 +0530 Subject: [PATCH 06/48] feat(tui): map keyboard capabilities and Unicode display coordinates --- Cargo.lock | 2 + crates/oxytype-tui/Cargo.toml | 2 + crates/oxytype-tui/src/engine.rs | 11 ++ crates/oxytype-tui/src/input.rs | 67 +++++++++++ crates/oxytype-tui/src/lib.rs | 2 + crates/oxytype-tui/src/main.rs | 143 ++++++++++++----------- crates/oxytype-tui/src/options.rs | 3 + crates/oxytype-tui/src/render.rs | 116 ++++++++++++++++++ crates/oxytype-tui/src/terminal.rs | 43 +++++-- crates/oxytype-tui/tests/input_render.rs | 121 +++++++++++++++++++ docs/rust-engine/progress.md | 20 ++++ scripts/terminal_pty.py | 26 ++++- 12 files changed, 479 insertions(+), 77 deletions(-) create mode 100644 crates/oxytype-tui/src/input.rs create mode 100644 crates/oxytype-tui/src/render.rs create mode 100644 crates/oxytype-tui/tests/input_render.rs diff --git a/Cargo.lock b/Cargo.lock index ba20b24ec..571a83611 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -535,6 +535,8 @@ dependencies = [ "serde_json", "signal-hook 0.4.5", "typing-engine", + "unicode-segmentation", + "unicode-width", ] [[package]] diff --git a/crates/oxytype-tui/Cargo.toml b/crates/oxytype-tui/Cargo.toml index 4c9d0bd89..d76194420 100644 --- a/crates/oxytype-tui/Cargo.toml +++ b/crates/oxytype-tui/Cargo.toml @@ -22,6 +22,8 @@ ratatui = { version = "=0.30.2", default-features = false, features = [ crossterm = "=0.29.0" clap = { version = "4.5", features = ["derive", "env", "string"] } signal-hook = "=0.4.5" +unicode-segmentation = "=1.13.3" +unicode-width = "=0.2.2" [target.'cfg(unix)'.dependencies] nix = { version = "0.30", features = ["signal", "process"] } diff --git a/crates/oxytype-tui/src/engine.rs b/crates/oxytype-tui/src/engine.rs index 8a1c74242..9712079e9 100644 --- a/crates/oxytype-tui/src/engine.rs +++ b/crates/oxytype-tui/src/engine.rs @@ -107,6 +107,9 @@ impl EngineHost { })?; let session = self.content.start_session(same)?; self.words = session.snapshot().words; + for word in &self.words { + terminal_text(word)?; + } self.inputs.clear(); self.result = None; self.result_error = None; @@ -120,6 +123,7 @@ impl EngineHost { .expect("Prepared terminal session") .apply(command)?; for change in update.words { + terminal_text(&change.word)?; self.words .resize(self.words.len().max(change.index + 1), Text::default()); self.words[change.index] = change.word; @@ -203,3 +207,10 @@ pub fn smoke() -> Result<()> { ); Ok(()) } + +fn terminal_text(text: &Text) -> Result<()> { + let text = String::from_utf16(&text.0).map_err( + |_| "Generated content contains isolated UTF-16 surrogates; unavailable in terminal input", + )?; + crate::content::safe_text(&text) +} diff --git a/crates/oxytype-tui/src/input.rs b/crates/oxytype-tui/src/input.rs new file mode 100644 index 000000000..dff6dd6fd --- /dev/null +++ b/crates/oxytype-tui/src/input.rs @@ -0,0 +1,67 @@ +//! Terminal delivery observations, never physical keyboard telemetry. +use crate::{Result, engine::EngineHost}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use serde::{Deserialize, Serialize}; +use typing_engine::{Command, Text}; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Capabilities { + pub enhanced_keyboard_negotiated: bool, + pub release_observed: bool, + pub repeat_observed: bool, + pub physical_key_identity: bool, + pub composition_lifecycle: bool, + pub physical_key_timing: bool, +} +impl Capabilities { + pub fn observe(&mut self, key: KeyEvent) { + self.release_observed |= key.kind == KeyEventKind::Release; + self.repeat_observed |= key.kind == KeyEventKind::Repeat; + } +} +pub fn pop_grapheme(value: &mut String) { + if let Some((offset, _)) = value.grapheme_indices(true).next_back() { + value.truncate(offset); + } +} +/// Mechanical text-control edit, expressed losslessly in UTF-16 for the core. +/// Core still decides deletion eligibility, regression and event accounting. +pub fn delete_observation(input: &Text) -> Result { + let mut value = String::from_utf16(&input.0)?; + pop_grapheme(&mut value); + Ok(Text::from(value.as_str())) +} +pub fn apply(host: &mut EngineHost, key: KeyEvent, now: f64, date: f64) -> Result<()> { + if key.kind == KeyEventKind::Release || host.state().ended { + return Ok(()); + } + let control = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + match key.code { + KeyCode::Backspace => host.command(Command::Delete { + kind: if control || alt { + "deleteWordBackward" + } else { + "deleteContentBackward" + } + .into(), + now, + date, + observed_input: if control || alt { + None + } else { + Some(delete_observation(&host.state().input_value)?) + }, + sentinel_removed: false, + })?, + KeyCode::Char(c) if !control && !alt && !c.is_control() => { + host.insert(&c.to_string(), now, date)? + } + KeyCode::Enter if !control && !alt => host.insert("\n", now, date)?, + KeyCode::Tab if !control && !alt => host.insert("\t", now, date)?, + _ => {} + } + Ok(()) +} diff --git a/crates/oxytype-tui/src/lib.rs b/crates/oxytype-tui/src/lib.rs index b8bf276a6..5ebe0b3f0 100644 --- a/crates/oxytype-tui/src/lib.rs +++ b/crates/oxytype-tui/src/lib.rs @@ -1,6 +1,8 @@ pub mod content; pub mod engine; +pub mod input; pub mod options; +pub mod render; pub mod setup; pub mod terminal; diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index 4b08ed679..0dc3699fb 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -5,8 +5,7 @@ use oxytype_tui::{options::Cli, setup::Setup}; use ratatui::{ Frame, layout::{Constraint, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span}, + text::Line, widgets::{Block, Paragraph, Wrap}, }; use std::{ @@ -52,13 +51,17 @@ fn run() -> Result<()> { custom, cli.seed.unwrap_or(date() as u32), )?; - let (guard, mut terminal) = TerminalGuard::enter()?; + let (guard, mut terminal) = TerminalGuard::enter(cli.keyboard != "basic")?; #[cfg(debug_assertions)] match std::env::var("OXYTYPE_TERMINAL_TEST_FAILURE").as_deref() { Ok("panic") => panic!("Injected terminal cleanup test panic"), Ok("error") => return Err("Injected terminal cleanup test error".into()), _ => {} } + let mut capabilities = oxytype_tui::input::Capabilities { + enhanced_keyboard_negotiated: guard.enhanced.load(Ordering::SeqCst), + ..Default::default() + }; let origin = Instant::now(); let mut screen = Screen::Test; let mut notice = String::new(); @@ -67,6 +70,7 @@ fn run() -> Result<()> { let mut dirty = true; loop { if guard.resume_if_requested(&mut terminal)? { + capabilities.enhanced_keyboard_negotiated = guard.enhanced.load(Ordering::SeqCst); dirty = true; } if guard.quit.load(Ordering::SeqCst) { @@ -77,7 +81,20 @@ fn run() -> Result<()> { host.command(Command::Advance { now, date: date() })?; } if dirty || (host.state().active && last_draw.elapsed() >= Duration::from_millis(100)) { - terminal.draw(|frame| draw(frame, &host, screen, ¬ice, &history, now, &setup))?; + terminal.draw(|frame| { + draw( + frame, + &host, + now, + View { + screen, + notice: ¬ice, + history: &history, + setup: &setup, + capabilities: &capabilities, + }, + ) + })?; dirty = false; last_draw = Instant::now(); } @@ -91,6 +108,9 @@ fn run() -> Result<()> { let event = event::read()?; // Capture delivery time after poll/read, never the earlier polling time. let now = origin.elapsed().as_secs_f64() * 1000.0; + if let Event::Key(key) = event { + capabilities.observe(key); + } match event { // Fullscreen draw autoresizes and invalidates its viewport. An extra // clear queries cursor position, which basic terminals need not report. @@ -147,7 +167,7 @@ fn run() -> Result<()> { } } KeyCode::Backspace if screen == Screen::Editor => { - setup.text.pop(); + oxytype_tui::input::pop_grapheme(&mut setup.text); } KeyCode::Enter if screen == Screen::Editor => setup.text.push('\n'), KeyCode::Tab if screen == Screen::Editor => setup.text.push('\t'), @@ -188,33 +208,11 @@ fn run() -> Result<()> { KeyCode::Enter if control && screen == Screen::Test => { host.command(Command::Bailout { now, date: date() })?; } - KeyCode::Backspace if screen == Screen::Test && !host.state().ended => { - host.command(Command::Delete { - kind: if control || key.modifiers.contains(KeyModifiers::ALT) { - "deleteWordBackward" - } else { - "deleteContentBackward" - } - .into(), - now, - date: date(), - observed_input: None, - sentinel_removed: false, - })?; - } - KeyCode::Char(char) - if screen == Screen::Test - && !host.state().ended - && !control - && !key.modifiers.contains(KeyModifiers::ALT) => - { - host.insert(&char.to_string(), now, date())?; - } - KeyCode::Enter if screen == Screen::Test && !host.state().ended => { - host.insert("\n", now, date())? - } - KeyCode::Tab if screen == Screen::Test && !host.state().ended => { - host.insert("\t", now, date())? + _ if screen == Screen::Test && !host.state().ended => { + let (columns, rows) = crossterm::terminal::size()?; + if columns >= 60 && rows >= 18 { + oxytype_tui::input::apply(&mut host, key, now, date())?; + } } _ => {} } @@ -224,15 +222,21 @@ fn run() -> Result<()> { } Ok(()) } -fn draw( - frame: &mut Frame, - host: &EngineHost, +struct View<'a> { screen: Screen, - notice: &str, - history: &[String], - now: f64, - setup: &Setup, -) { + notice: &'a str, + history: &'a [String], + setup: &'a Setup, + capabilities: &'a oxytype_tui::input::Capabilities, +} +fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { + let View { + screen, + notice, + history, + setup, + capabilities, + } = view; let area = frame.area(); if area.width < 60 || area.height < 18 { frame.render_widget( @@ -263,6 +267,16 @@ fn draw( Line::from("F8 finish · Backspace delete · Ctrl+Backspace delete word"), Line::from("Ctrl+Q / Ctrl+C quit · Ctrl+Z suspend (Unix)"), Line::from("Physical-key timing is unavailable; no key releases are invented."), + Line::from(format!( + "Keyboard: {} · release observed: {} · repeat observed: {}", + if capabilities.enhanced_keyboard_negotiated { + "enhanced" + } else { + "basic" + }, + capabilities.release_observed, + capabilities.repeat_observed + )), ], Screen::Settings => { let lines = setup.lines(); @@ -310,33 +324,32 @@ fn draw( live.wpm, live.acc, live.seconds ))); } - let mut spans = vec![]; - let targets = if host.config.mode == "zen" { - &host.inputs - } else { - &host.words - }; - for (index, word) in targets - .iter() - .enumerate() - .skip(state.word_index.saturating_sub(5)) - .take(35) - { - spans.push(Span::styled( - word.display_lossy(), - if index == state.word_index { - Style::new() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED) - } else { - Style::new() - }, - )); + let input = state.input_value; + let mut target = typing_engine::Text::default(); + if host.config.mode != "zen" { + for word in host.words.iter().skip(state.word_index).take(35) { + target.0.extend(&word.0); + } + } + let view = oxytype_tui::render::text_view( + &target, + &input, + &host.config.language, + rows[1].width.saturating_sub(2), + rows[1].height.saturating_sub(5), + ); + if view.errors { + lines.push(Line::from("! Incorrect text is underlined")); } - lines.push(Line::from(spans)); + let caret_y = rows[1].y + 1 + lines.len() as u16 + view.caret.1; + frame.set_cursor_position((rows[1].x + 1 + view.caret.0, caret_y)); + lines.extend(view.lines); lines.push(Line::from(format!( - "Input: {}▌", - state.input_value.display_lossy() + "Input: {}", + input + .display_lossy() + .replace('\t', "→ ") + .replace('\n', "↵") ))); lines } diff --git a/crates/oxytype-tui/src/options.rs b/crates/oxytype-tui/src/options.rs index df5c502c0..e39b41eb5 100644 --- a/crates/oxytype-tui/src/options.rs +++ b/crates/oxytype-tui/src/options.rs @@ -67,6 +67,9 @@ pub struct Cli { pub funbox: Option>, #[arg(long)] pub capabilities: bool, + /// auto negotiates enhanced events; basic avoids protocol queries. + #[arg(long, default_value="auto", value_parser=["auto","basic"])] + pub keyboard: String, } impl Cli { pub fn prepare( diff --git a/crates/oxytype-tui/src/render.rs b/crates/oxytype-tui/src/render.rs new file mode 100644 index 000000000..ebaca0c50 --- /dev/null +++ b/crates/oxytype-tui/src/render.rs @@ -0,0 +1,116 @@ +//! Display coordinates are cells; engine/event positions remain UTF-16 units. +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use typing_engine::Text; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +pub struct TextView { + pub lines: Vec>, + pub caret: (u16, u16), + pub errors: bool, +} +/// Expand controls visibly and wrap whole graphemes without splitting wide cells. +pub fn text_view(target: &Text, input: &Text, language: &str, width: u16, height: u16) -> TextView { + let width = width.max(2) as usize; + let mut lines = vec![Line::default()]; + let mut x = 0usize; + let mut offset_utf16 = 0; + let mut caret = None; + let mut errors = false; + let target_string = target.display_lossy(); + let input_string = input.display_lossy(); + let source = if target.is_empty() { + &input_string + } else { + &target_string + }; + for grapheme in source.graphemes(true) { + let units = grapheme.encode_utf16().count(); + let mut shown = match grapheme { + "\t" => "→ ".to_string(), + "\n" => "↵".to_string(), + _ => grapheme.to_string(), + }; + if UnicodeWidthStr::width(shown.as_str()) == 0 { + shown.insert(0, '◌'); + } + let cells = UnicodeWidthStr::width(shown.as_str()).max(1).min(width); + if x + cells > width { + lines.push(Line::default()); + x = 0; + } + if caret.is_none() && offset_utf16 + units > input.len() { + caret = Some((x, lines.len() - 1)); + } + let typed_end = (offset_utf16 + units).min(input.len()); + let wrong = (offset_utf16..typed_end).any(|i| { + target.0.get(i).is_some_and(|t| { + typing_engine::text::normalize(input.0[i], Some(*t), language) != *t + }) + }); + errors |= wrong; + let style = if wrong { + Style::new() + .fg(Color::Red) + .add_modifier(Modifier::UNDERLINED) + } else if typed_end > offset_utf16 { + Style::new().fg(Color::Green) + } else { + Style::default() + }; + lines + .last_mut() + .unwrap() + .spans + .push(Span::styled(shown, style)); + x += cells; + offset_utf16 += units; + if grapheme == "\n" { + lines.push(Line::default()); + x = 0; + } + } + // Extra input is visible and marked; it never changes the target or score. + if !target.is_empty() && input.len() > target.len() { + errors = true; + let mut utf16 = 0; + for grapheme in input_string.graphemes(true) { + utf16 += grapheme.encode_utf16().count(); + if utf16 <= target.len() { + continue; + } + let cells = UnicodeWidthStr::width(grapheme).max(1).min(width); + if x + cells > width { + lines.push(Line::default()); + x = 0; + } + lines.last_mut().unwrap().spans.push(Span::styled( + grapheme.to_string(), + Style::new() + .fg(Color::Red) + .add_modifier(Modifier::UNDERLINED), + )); + x += cells; + } + } + let (mut cx, mut cy) = caret.unwrap_or((x, lines.len() - 1)); + if cx >= width { + lines.push(Line::default()); + cx = 0; + cy += 1; + } + let start = cy.saturating_sub(height.max(1) as usize - 1); + let lines = lines + .into_iter() + .skip(start) + .take(height as usize) + .collect(); + TextView { + lines, + caret: (cx as u16, (cy - start) as u16), + errors, + } +} diff --git a/crates/oxytype-tui/src/terminal.rs b/crates/oxytype-tui/src/terminal.rs index 91df005ce..2791e996f 100644 --- a/crates/oxytype-tui/src/terminal.rs +++ b/crates/oxytype-tui/src/terminal.rs @@ -1,9 +1,12 @@ use crate::Result; use crossterm::{ cursor::Show, - event::{DisableBracketedPaste, EnableBracketedPaste}, + event::{ + DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + }, execute, - terminal::{EnterAlternateScreen, enable_raw_mode}, + terminal::{EnterAlternateScreen, enable_raw_mode, supports_keyboard_enhancement}, }; use ratatui::{DefaultTerminal, backend::CrosstermBackend}; use std::{ @@ -18,21 +21,37 @@ pub struct TerminalGuard { pub quit: Arc, pub suspend: Arc, signals: Vec, + pub enhanced: Arc, + auto_keyboard: bool, } -pub fn restore() { +fn restore(enhanced: &AtomicBool) { + if enhanced.swap(false, Ordering::SeqCst) { + let _ = execute!(io::stdout(), PopKeyboardEnhancementFlags); + } let _ = execute!(io::stdout(), DisableBracketedPaste, Show); ratatui::restore(); } -fn initialize() -> std::io::Result { +fn initialize(enhanced: &AtomicBool, auto_keyboard: bool) -> std::io::Result { // Install our cleanup hook once, rather than nesting Ratatui hooks on every // suspend/resume cycle. The guard already exists if any setup step fails. enable_raw_mode()?; execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?; + // Query before any event poll/read. Basic terminals may time out and use F8. + if auto_keyboard && supports_keyboard_enhancement().unwrap_or(false) { + enhanced.store(true, Ordering::SeqCst); + execute!( + io::stdout(), + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + | KeyboardEnhancementFlags::REPORT_EVENT_TYPES + ) + )?; + } ratatui::Terminal::new(CrosstermBackend::new(io::stdout())) } impl TerminalGuard { - pub fn enter() -> Result<(Self, DefaultTerminal)> { + pub fn enter(auto_keyboard: bool) -> Result<(Self, DefaultTerminal)> { if !io::stdin().is_terminal() || !io::stdout().is_terminal() { return Err( "Oxytype requires an interactive terminal; use --help or --smoke-test without one" @@ -42,15 +61,19 @@ impl TerminalGuard { if std::env::var("TERM").is_ok_and(|term| term == "dumb") { return Err("TERM=dumb cannot render Oxytype".into()); } + let enhanced = Arc::new(AtomicBool::new(false)); + let cleanup_enhanced = enhanced.clone(); let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - restore(); + restore(&cleanup_enhanced); hook(info); })); let mut guard = Self { quit: Arc::new(AtomicBool::new(false)), suspend: Arc::new(AtomicBool::new(false)), signals: vec![], + enhanced, + auto_keyboard, }; for signal in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] { guard @@ -66,7 +89,7 @@ impl TerminalGuard { .signals .push(signal_hook::flag::register(signal, flag)?); } - let terminal = initialize()?; + let terminal = initialize(&guard.enhanced, guard.auto_keyboard)?; Ok((guard, terminal)) } pub fn resume_if_requested(&self, terminal: &mut DefaultTerminal) -> Result { @@ -75,18 +98,18 @@ impl TerminalGuard { } #[cfg(unix)] { - restore(); + restore(&self.enhanced); // Suspend the foreground job too when launched through vp/Cargo; // stopping only the binary leaves its launcher holding the terminal. nix::sys::signal::killpg(nix::unistd::getpgrp(), nix::sys::signal::Signal::SIGSTOP)?; - *terminal = initialize()?; + *terminal = initialize(&self.enhanced, self.auto_keyboard)?; } Ok(true) } } impl Drop for TerminalGuard { fn drop(&mut self) { - restore(); + restore(&self.enhanced); for signal in self.signals.drain(..) { signal_hook::low_level::unregister(signal); } diff --git a/crates/oxytype-tui/tests/input_render.rs b/crates/oxytype-tui/tests/input_render.rs new file mode 100644 index 000000000..4e3e34e02 --- /dev/null +++ b/crates/oxytype-tui/tests/input_render.rs @@ -0,0 +1,121 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use oxytype_tui::{ + content::OfflineContent, + engine::EngineHost, + input::{self, Capabilities}, + render::text_view, +}; +use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph}; +use typing_engine::{Config, Text}; + +#[test] +fn terminal_commits_repeats_once_ignores_releases_and_deletes_graphemes_in_utf16() { + let mut host = EngineHost::offline( + Config { + mode: "zen".into(), + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + host.insert("e\u{301}😀", 100.0, 1000.0).unwrap(); + assert_eq!(host.state().input_value.len(), 4); + let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + input::apply(&mut host, backspace, 200.0, 1100.0).unwrap(); + assert_eq!(host.state().input_value, Text::from("e\u{301}")); + input::apply(&mut host, backspace, 300.0, 1200.0).unwrap(); + assert!(host.state().input_value.is_empty()); + let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE); + input::apply(&mut host, key, 400.0, 1300.0).unwrap(); + input::apply( + &mut host, + KeyEvent { + kind: KeyEventKind::Repeat, + ..key + }, + 500.0, + 1400.0, + ) + .unwrap(); + let before = host.log().unwrap().events.len(); + input::apply( + &mut host, + KeyEvent { + kind: KeyEventKind::Release, + ..key + }, + 600.0, + 1500.0, + ) + .unwrap(); + assert_eq!(host.log().unwrap().events.len(), before); + assert_eq!(host.state().input_value, Text::from("xx")); + assert!( + host.log() + .unwrap() + .events + .iter() + .all(|e| !["keydown", "keyup"].contains(&e.kind.as_str())) + ); + let mut capabilities = Capabilities::default(); + capabilities.observe(KeyEvent { + kind: KeyEventKind::Release, + ..key + }); + assert!(capabilities.release_observed); + assert!(!capabilities.physical_key_timing); +} +#[test] +fn ratatui_cells_and_caret_keep_graphemes_separate_from_utf16_positions() { + let target = Text::from("e\u{301}界😀a"); + let view = text_view(&target, &Text::from("e\u{301}界"), "english", 4, 4); + assert_eq!(view.caret, (0, 1)); + let mut terminal = Terminal::new(TestBackend::new(4, 4)).unwrap(); + terminal + .draw(|frame| { + frame.render_widget(Paragraph::new(view.lines), frame.area()); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "e\u{301}"); + assert_eq!(buffer[(1, 0)].symbol(), "界"); + assert_eq!(buffer[(0, 1)].symbol(), "😀"); + assert_eq!(buffer[(2, 1)].symbol(), "a"); + let control = text_view( + &Text::from("a\tb\n界"), + &Text::from("a\tb\n"), + "english", + 8, + 4, + ); + assert_eq!(control.caret, (0, 1)); + assert!(control.lines[0].to_string().contains("→ b↵")); + let scrolled = text_view( + &Text::from("abcdefghijk"), + &Text::from("abcdefghij"), + "english", + 2, + 2, + ); + assert_eq!(scrolled.caret, (0, 1)); + assert_eq!(scrolled.lines.len(), 2); +} +#[test] +fn extra_emoji_and_error_markers_never_split_surrogates() { + let view = text_view(&Text::from("a"), &Text::from("😀"), "english", 8, 4); + assert!(view.errors); + assert!(view.lines[0].to_string().contains("😀")); + assert!(!view.lines[0].to_string().contains('\u{fffd}')); + let normalized = text_view(&Text::from("’"), &Text::from("'"), "english", 8, 4); + assert!(!normalized.errors); + let config = Config { + mode: "words".into(), + words: 5, + funbox: vec!["backwards".into()], + ..Default::default() + }; + let content = OfflineContent::parse(r#"{"formatVersion":1,"license":"CC0","attribution":"test","language":{"name":"english","words":["😀","abc"]}}"#).unwrap(); + assert!(EngineHost::offline(config, content, None, 42).is_err()); +} diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index 262c5fcd1..5206f9c7b 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -574,3 +574,23 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro unchanged characters are not re-emitted by Ratatui; no app behavior weakened. - Accurate grapheme/cell rendering, negotiated capabilities and durable history remain next. No native physical-key timing is inferred from committed input. + +## Phase 3.3 implementation / Linux checks + +- Grapheme Backspace supplies observed UTF-16 text to native deletion; releases + never become input and each repeat delivery commits once. Native rules retain + eligibility/progression. Isolated-surrogate generated targets fail explicitly. +- Cell-aware target rendering handles combining marks, CJK, emoji, visible Tab/LF, + wrapping, caret scrolling and color-independent underlined error notice. Input + disabled below minimum dimensions; native clock continues. +- Auto keyboard negotiation runs before event polling. Owned enhanced flags are + popped on exit/error/panic/suspend; basic mode avoids queries. Negotiated event + support and observed releases/repeats are distinct from physical timing, which + remains unavailable. F8 provides finish without modified Enter support. +- `vp run terminal:test:pty` passes five modes/editor/lifecycle plus simulated + enhanced negotiation/press/repeat/release/cleanup. `cargo test -p oxytype-tui` + (via vp) passes nine integration tests, including Ratatui TestBackend Unicode + cells/caret/extra emoji and UTF-16 deletion. Clippy passes. +- Real macOS/Windows Terminal and enhanced hardware-terminal manual matrix is + unperformed, not a passed acceptance gate. Asked for machine/test access; + continue independent durable history and packaging work while awaiting it. diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 6711d6256..42c14b5bf 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -20,7 +20,9 @@ def dimensions(slave, columns=100, rows=24): class Child: - def __init__(self, binary, failure=None, args=()): + def __init__(self, binary, failure=None, args=(), enhanced=False): + self.enhanced = enhanced + self.query_replied = False self.master, self.slave = pty.openpty() dimensions(self.slave) self.original = termios.tcgetattr(self.slave) @@ -33,7 +35,7 @@ def controlling_terminal(): os.setsid() fcntl.ioctl(self.slave, termios.TIOCSCTTY, 0) - self.process = subprocess.Popen([binary, *args], stdin=self.slave, stdout=self.slave, + self.process = subprocess.Popen([binary, "--keyboard", "auto" if enhanced else "basic", *args], stdin=self.slave, stdout=self.slave, stderr=self.slave, env=env, preexec_fn=controlling_terminal) @@ -41,6 +43,10 @@ def read(self, seconds=0.05): if select.select([self.master], [], [], seconds)[0]: try: self.output.extend(os.read(self.master, 65536)) + if self.enhanced and not self.query_replied and b"\x1b[?u" in self.output: + # Protocol simulation, not a claim of hardware-terminal coverage. + self.send(b"\x1b[?0u\x1b[?1;2c") + self.query_replied = True except OSError as error: if error.errno != errno.EIO: raise @@ -183,6 +189,22 @@ def run(binary): finally: child.close() + child = Child(binary, args=["--mode", "zen"], enhanced=True) + try: + child.until(b"Oxytype") + assert b"\x1b[>3u" in child.output, "enhanced flags not pushed" + child.send(b"\x1b[97;1u\x1b[97;1:2u\x1b[97;1:3u") # press/repeat/release + child.until(b"Input: aa") + child.send(b"\x1bOP") + child.until(b"Keyboard: enhanced") + child.until(b"release observed: true") + child.send(b"\x11") + assert child.process.wait(timeout=5) == 0 + child.restored() + assert b"\x1b[<1u" in child.output, "enhanced flags not popped" + finally: + child.close() + for failure in ("error", "panic", "signal", "suspend"): child = Child(binary, failure if failure in ("error", "panic") else None) try: From 93edbaf1b6673dbcad5b9dc750ed2bc433f0b77b Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:15:48 +0530 Subject: [PATCH 07/48] feat(tui): persist versioned settings and private local results --- Cargo.lock | 338 ++++++++++++++++++++++++++- crates/oxytype-tui/Cargo.toml | 5 + crates/oxytype-tui/src/engine.rs | 16 ++ crates/oxytype-tui/src/lib.rs | 1 + crates/oxytype-tui/src/storage.rs | 316 +++++++++++++++++++++++++ crates/oxytype-tui/tests/storage.rs | 170 ++++++++++++++ docs/rust-engine/terminal-storage.md | 45 ++++ 7 files changed, 886 insertions(+), 5 deletions(-) create mode 100644 crates/oxytype-tui/src/storage.rs create mode 100644 crates/oxytype-tui/tests/storage.rs create mode 100644 docs/rust-engine/terminal-storage.md diff --git a/Cargo.lock b/Cargo.lock index 571a83611..760eb48b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[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 = "bumpalo" version = "3.20.3" @@ -100,6 +109,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -181,6 +200,15 @@ 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 = "critical-section" version = "1.2.0" @@ -214,6 +242,16 @@ dependencies = [ "winapi", ] +[[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 = "darling" version = "0.24.1" @@ -276,6 +314,37 @@ dependencies = [ "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 = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "document-features" version = "0.2.12" @@ -307,12 +376,90 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -324,6 +471,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -343,7 +499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -389,13 +545,24 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kasuari" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", "portable-atomic", "thiserror", ] @@ -412,6 +579,26 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.8" @@ -454,7 +641,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -523,20 +710,31 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "oxytype-tui" version = "0.1.0" dependencies = [ "clap", "crossterm", + "directories", "nix", "ratatui", + "rusqlite", "serde", "serde_json", + "sha2", "signal-hook 0.4.5", + "tempfile", "typing-engine", "unicode-segmentation", "unicode-width", + "uuid", ] [[package]] @@ -595,6 +793,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -625,6 +835,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ratatui" version = "0.30.2" @@ -647,7 +863,7 @@ dependencies = [ "bitflags", "compact_str", "critical-section", - "hashbrown", + "hashbrown 0.17.1", "itertools", "kasuari", "lru", @@ -679,7 +895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ "bitflags", - "hashbrown", + "hashbrown 0.17.1", "indoc", "instability", "itertools", @@ -701,6 +917,41 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -792,6 +1043,23 @@ dependencies = [ "zmij", ] +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -833,12 +1101,30 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -894,6 +1180,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -967,6 +1266,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typing-engine" version = "0.1.0" @@ -1021,6 +1326,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/crates/oxytype-tui/Cargo.toml b/crates/oxytype-tui/Cargo.toml index d76194420..471353cc9 100644 --- a/crates/oxytype-tui/Cargo.toml +++ b/crates/oxytype-tui/Cargo.toml @@ -24,6 +24,11 @@ clap = { version = "4.5", features = ["derive", "env", "string"] } signal-hook = "=0.4.5" unicode-segmentation = "=1.13.3" unicode-width = "=0.2.2" +directories = "=6.0.0" +rusqlite = { version = "=0.40.2", features = ["bundled"] } +sha2 = "0.10.9" +uuid = { version = "1", features = ["v4"] } +tempfile = "3" [target.'cfg(unix)'.dependencies] nix = { version = "0.30", features = ["signal", "process"] } diff --git a/crates/oxytype-tui/src/engine.rs b/crates/oxytype-tui/src/engine.rs index 9712079e9..8e72bd161 100644 --- a/crates/oxytype-tui/src/engine.rs +++ b/crates/oxytype-tui/src/engine.rs @@ -15,6 +15,8 @@ pub struct EngineHost { pub result: Option, pub result_error: Option, pub seed: u32, + pub content_identity: String, + pub repeated: bool, content: Content, session: Option, language: Language, @@ -52,6 +54,8 @@ impl EngineHost { custom, quotes, seed, + content_identity: String::new(), + repeated: false, content: Content::default(), session: None, words: vec![], @@ -59,6 +63,11 @@ impl EngineHost { result: None, result_error: None, }; + host.content_identity = crate::storage::hash(&( + &host.language.words, + &host.quotes.as_ref().map(|q| &q.quotes), + &host.custom.as_ref().map(|c| &c.text), + ))?; host.restart(false, 0.0, 0.0)?; Ok(host) } @@ -74,6 +83,12 @@ impl EngineHost { pub fn log(&self) -> Result { Ok(serde_json::from_value(self.session().log())?) } + pub fn custom_limit(&self) -> Option { + self.custom.as_ref().map(|c| typing_engine::results::Limit { + mode: c.limit.mode.clone(), + value: c.limit.value, + }) + } pub fn restart(&mut self, repeat: bool, now: f64, date: f64) -> Result<()> { let mut same = repeat; if let Some(mut session) = self.session.take() { @@ -106,6 +121,7 @@ impl EngineHost { sections: vec![], })?; let session = self.content.start_session(same)?; + self.repeated = same; self.words = session.snapshot().words; for word in &self.words { terminal_text(word)?; diff --git a/crates/oxytype-tui/src/lib.rs b/crates/oxytype-tui/src/lib.rs index 5ebe0b3f0..a74578539 100644 --- a/crates/oxytype-tui/src/lib.rs +++ b/crates/oxytype-tui/src/lib.rs @@ -4,6 +4,7 @@ pub mod input; pub mod options; pub mod render; pub mod setup; +pub mod storage; pub mod terminal; pub type Error = Box; diff --git a/crates/oxytype-tui/src/storage.rs b/crates/oxytype-tui/src/storage.rs new file mode 100644 index 000000000..0941c25e5 --- /dev/null +++ b/crates/oxytype-tui/src/storage.rs @@ -0,0 +1,316 @@ +//! Offline durable storage. SQLite transactions arbitrate concurrent instances. +use crate::{Result, engine::EngineHost, input::Capabilities}; +use directories::ProjectDirs; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + time::Duration, +}; +use typing_engine::{ + Config, + results::{Limit, Validity, ValidityResult}, +}; + +#[derive(Clone, Debug)] +pub struct Paths { + pub config: PathBuf, + pub data: PathBuf, +} +impl Paths { + pub fn resolve(config: Option<&Path>, data: Option<&Path>) -> Result { + let dirs = ProjectDirs::from("com", "Oxytype", "Oxytype"); + Ok(Self { + config: config + .map(Path::to_path_buf) + .or_else(|| dirs.as_ref().map(|d| d.config_dir().into())) + .ok_or("No config directory; use --config-dir")?, + data: data + .map(Path::to_path_buf) + .or_else(|| dirs.as_ref().map(|d| d.data_local_dir().into())) + .ok_or("No data directory; use --data-dir")?, + }) + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Settings { + pub format_version: u32, + pub config: Config, + pub theme: String, + pub reduced_animation: bool, + pub record_replay: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + format_version: 1, + config: Config::default(), + theme: "terminal".into(), + reduced_animation: true, + record_replay: false, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<()> { + if self.format_version != 1 { + return Err("Unsupported settings version; existing file preserved".into()); + } + self.config.validate()?; + if !["terminal", "high-contrast", "light"].contains(&self.theme.as_str()) { + return Err("Unknown terminal theme".into()); + } + Ok(()) + } +} +pub struct Store { + config: Connection, + history: Connection, + pub revision: i64, +} +fn open(path: &Path, schema: &str) -> Result { + let dir = path.parent().ok_or("Missing storage directory")?; + if !dir.exists() { + fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + } + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(path) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + let mut db = Connection::open(path)?; + db.busy_timeout(Duration::from_secs(2))?; + let version: i64 = db.pragma_query_value(None, "user_version", |r| r.get(0))?; + if version > 1 { + return Err(format!( + "Unsupported database version {version}; file preserved at {}", + path.display() + ) + .into()); + } + db.pragma_update(None, "journal_mode", "WAL")?; + db.pragma_update(None, "synchronous", "FULL")?; + let transaction = db.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(schema)?; + transaction.pragma_update(None, "user_version", 1)?; + transaction.commit()?; + Ok(db) +} +impl Store { + pub fn open(paths: &Paths) -> Result { + Ok(Self { + config: open( + &paths.config.join("settings.sqlite3"), + "CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL, json TEXT NOT NULL);", + )?, + history: open( + &paths.data.join("history.sqlite3"), + "CREATE TABLE IF NOT EXISTS results (id TEXT PRIMARY KEY NOT NULL, created_ms REAL NOT NULL, mode TEXT NOT NULL, json TEXT NOT NULL); CREATE INDEX IF NOT EXISTS results_mode_time ON results(mode,created_ms);", + )?, + revision: 0, + }) + } + pub fn settings(&mut self) -> Result { + let row: Option<(i64, String)> = self + .config + .query_row("SELECT revision,json FROM settings WHERE id=1", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) + .optional()?; + let settings = match row { + Some((revision, json)) => { + self.revision = revision; + serde_json::from_str(&json)? + } + None => Settings::default(), + }; + settings.validate()?; + Ok(settings) + } + pub fn save_settings(&mut self, settings: &Settings) -> Result<()> { + settings.validate()?; + let transaction = self + .config + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let current: i64 = transaction + .query_row("SELECT revision FROM settings WHERE id=1", [], |r| r.get(0)) + .optional()? + .unwrap_or(0); + if current != self.revision { + return Err("Settings changed in another instance; reload before saving (existing data preserved)".into()); + } + let revision = current + 1; + transaction.execute("INSERT INTO settings(id,revision,json) VALUES(1,?1,?2) ON CONFLICT(id) DO UPDATE SET revision=excluded.revision,json=excluded.json",params![revision,serde_json::to_string(settings)?])?; + transaction.commit()?; + self.revision = revision; + Ok(()) + } + pub fn save(&mut self, record: &Value) -> Result<()> { + let id = record["id"].as_str().ok_or("Result missing ID")?; + let json = serde_json::to_string(record)?; + let transaction = self + .history + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing: Option = transaction + .query_row("SELECT json FROM results WHERE id=?1", [id], |r| r.get(0)) + .optional()?; + if let Some(existing) = existing { + if existing != json { + return Err("Result ID collision; existing result preserved".into()); + } + } else { + transaction.execute( + "INSERT INTO results(id,created_ms,mode,json) VALUES(?1,?2,?3,?4)", + params![ + id, + record["createdMs"] + .as_f64() + .ok_or("Missing result timestamp")?, + record["config"]["mode"] + .as_str() + .ok_or("Missing result mode")?, + json + ], + )?; + } + transaction.commit()?; + Ok(()) + } + pub fn history(&self, mode: Option<&str>, limit: usize) -> Result> { + let mut query=self.history.prepare("SELECT json FROM results WHERE (?1 IS NULL OR mode=?1) ORDER BY created_ms DESC,id DESC LIMIT ?2")?; + let rows = query.query_map(params![mode, limit.min(100_000) as i64], |r| { + r.get::<_, String>(0) + })?; + rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect() + } + pub fn best(&self, group: &str) -> Result> { + Ok(self.history.query_row("SELECT MAX(json_extract(json,'$.summary.wpm')) FROM results WHERE json_extract(json,'$.formatVersion')=1 AND json_extract(json,'$.personalBestEligible')=1 AND json_extract(json,'$.personalBestGroup')=?1", [group], |r|r.get(0))?) + } + /// Export the stored bytes/fields semantically; never recompute old scores. + pub fn export(&self, path: &Path, mode: Option<&str>) -> Result<()> { + let mut query = self.history.prepare( + "SELECT json FROM results WHERE (?1 IS NULL OR mode=?1) ORDER BY created_ms,id", + )?; + let rows = query.query_map([mode], |r| r.get::<_, String>(0))?; + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(b"{\"formatVersion\":1,\"results\":[")?; + for (i, row) in rows.enumerate() { + let raw = row?; + let _: Value = serde_json::from_str(&raw)?; + if i > 0 { + temporary.write_all(b",")?; + } + temporary.write_all(raw.as_bytes())?; + } + temporary.write_all(b"]}\n")?; + temporary.as_file().sync_all()?; + temporary.persist_noclobber(path).map_err(|e| e.error)?; + #[cfg(unix)] + { + fs::File::open(parent)?.sync_all()?; + } + Ok(()) + } +} +pub fn hash(value: &impl Serialize) -> Result { + Ok(format!("{:x}", Sha256::digest(serde_json::to_vec(value)?))) +} +pub fn record( + host: &EngineHost, + capabilities: &Capabilities, + replay: bool, + created: f64, +) -> Result { + if !host.state().ended { + return Err("Cannot save unfinished result".into()); + } + let log = host.log()?; + let quote = host.session().snapshot().current_quote; + let mode2 = log.context.mode2.clone(); + let limit = host.custom_limit(); + let invalid = host + .result + .as_ref() + .map(|summary| { + typing_engine::results::invalid_reason( + &log, + &Validity { + result: ValidityResult { + mode: host.config.mode.clone(), + mode2: mode2.clone(), + test_duration: summary.test_duration, + wpm: summary.wpm, + raw_wpm: summary.raw_wpm, + acc: summary.acc, + }, + bailed_out: log.context.bailed_out, + failed: host.state().reason.is_some(), + repeated: host.repeated, + lb_opt_out: false, + custom_limit: limit.clone().unwrap_or(Limit { + mode: "word".into(), + value: 0.0, + }), + }, + ) + .map(str::to_string) + }) + .unwrap_or_else(|| Some("unavailable metrics".into())); + let mut summary = serde_json::to_value(&host.result)?; + if let Value::Object(values) = &mut summary { + for field in [ + "keySpacing", + "keyDuration", + "keyOverlap", + "keyConsistency", + "startToFirstKey", + "lastKeyToEnd", + ] { + values.insert(field.into(), Value::Null); + } + } + let pb_eligible = invalid.is_none() + && host + .config + .funbox + .iter() + .all(|n| typing_engine::funboxes::get(n).is_some_and(|f| f.can_get_pb)); + Ok( + json!({ "formatVersion":1,"id":uuid::Uuid::new_v4().to_string(),"createdMs":created,"engineBehaviorVersion":typing_engine::BEHAVIOR_VERSION,"eventLogVersion":typing_engine::EVENT_LOG_VERSION,"buildVersion":crate::version(),"config":host.config,"mode2":mode2,"seed":host.seed,"contentIdentity":host.content_identity,"quoteId":quote.map(|q|q.id),"customLimit":limit,"capabilities":capabilities,"measurements":{"clock":"monotonic terminal event delivery","physicalKeyTiming":null},"summary":summary,"summaryError":host.result_error,"invalidReason":invalid,"personalBestEligible":pb_eligible,"personalBestGroup":hash(&(&host.config,&host.content_identity,&mode2))?,"repeated":host.repeated,"bailedOut":log.context.bailed_out,"failureReason":host.state().reason,"replayLog":if replay { Some(log) } else { None } }), + ) +} +pub fn is_personal_best(record: &Value, history: &[Value]) -> bool { + record["personalBestEligible"] == true + && record["summary"]["wpm"].as_f64().is_some_and(|wpm| { + history + .iter() + .filter(|r| { + r["personalBestEligible"] == true + && r["personalBestGroup"] == record["personalBestGroup"] + && r["id"] != record["id"] + }) + .filter_map(|r| r["summary"]["wpm"].as_f64()) + .all(|prior| wpm > prior) + }) +} diff --git a/crates/oxytype-tui/tests/storage.rs b/crates/oxytype-tui/tests/storage.rs new file mode 100644 index 000000000..79fd03aaf --- /dev/null +++ b/crates/oxytype-tui/tests/storage.rs @@ -0,0 +1,170 @@ +use oxytype_tui::{ + content::OfflineContent, + engine::EngineHost, + input::Capabilities, + storage::{self, Paths, Store}, +}; +use serde_json::{Value, json}; +use typing_engine::Config; + +fn paths(root: &std::path::Path) -> Paths { + Paths { + config: root.join("config"), + data: root.join("data"), + } +} +fn finished() -> EngineHost { + let mut host = EngineHost::offline( + Config { + mode: "words".into(), + words: 10, + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + for i in 0..10 { + let word = host.words[i].display_lossy(); + host.insert(&word, 100.0 + i as f64 * 1000.0, 1100.0 + i as f64 * 1000.0) + .unwrap(); + } + assert!(host.state().ended); + host +} +#[test] +fn restart_persistence_idempotency_private_defaults_and_lossless_export() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(temp.path()); + let mut store = Store::open(&paths).unwrap(); + let mut settings = store.settings().unwrap(); + settings.config.words = 25; + store.save_settings(&settings).unwrap(); + let host = finished(); + let record = storage::record(&host, &Capabilities::default(), false, 10000.0).unwrap(); + assert!(record["replayLog"].is_null()); + for field in [ + "keySpacing", + "keyDuration", + "keyOverlap", + "keyConsistency", + "startToFirstKey", + "lastKeyToEnd", + ] { + assert!(record["summary"][field].is_null()); + } + assert_eq!( + record["summary"]["wpm"], + serde_json::to_value(host.result.as_ref().unwrap().wpm).unwrap() + ); + assert_eq!(record["personalBestEligible"], true); + store.save(&record).unwrap(); + assert_eq!( + store + .best(record["personalBestGroup"].as_str().unwrap()) + .unwrap(), + host.result.as_ref().map(|s| s.wpm) + ); + store.save(&record).unwrap(); + drop(store); + let mut store = Store::open(&paths).unwrap(); + assert_eq!(store.settings().unwrap().config.words, 25); + let history = store.history(Some("words"), 100).unwrap(); + assert_eq!(history, vec![record.clone()]); + assert!(store.history(Some("zen"), 100).unwrap().is_empty()); + let mut future = record.clone(); + future["id"] = json!("future"); + future["formatVersion"] = json!(99); + future["unknownFutureField"] = json!({"preserve":true}); + store.save(&future).unwrap(); + let output = temp.path().join("export.json"); + store.export(&output, None).unwrap(); + let exported: Value = serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap(); + assert!(exported["results"].as_array().unwrap().contains(&future)); + assert!(store.export(&output, None).is_err()); // Never overwrite a prior export. + let mut collision = record.clone(); + collision["seed"] = json!(99); + assert!(store.save(&collision).is_err()); + assert_eq!(store.history(None, 100).unwrap().len(), 2); + let replay = storage::record(&host, &Capabilities::default(), true, 11000.0).unwrap(); + assert_eq!( + replay["replayLog"], + serde_json::to_value(host.log().unwrap()).unwrap() + ); +} +#[test] +fn concurrent_settings_use_revision_checks_and_result_writes_do_not_clobber() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(temp.path()); + let mut a = Store::open(&paths).unwrap(); + let mut b = Store::open(&paths).unwrap(); + let mut settings = a.settings().unwrap(); + b.settings().unwrap(); + settings.config.words = 25; + a.save_settings(&settings).unwrap(); + assert!(b.save_settings(&settings).is_err()); + assert_eq!(b.settings().unwrap().config.words, 25); + let base = storage::record(&finished(), &Capabilities::default(), false, 10000.0).unwrap(); + let workers: Vec<_> = (0..8) + .map(|i| { + let paths = paths.clone(); + let mut record = base.clone(); + record["id"] = json!(format!("worker-{i}")); + std::thread::spawn(move || { + Store::open(&paths).unwrap().save(&record).unwrap(); + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + assert_eq!(a.history(None, 100).unwrap().len(), 8); +} +#[test] +fn malformed_future_and_interrupted_storage_preserve_prior_data() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(temp.path()); + let mut store = Store::open(&paths).unwrap(); + store.settings().unwrap(); + drop(store); + let dbpath = paths.data.join("history.sqlite3"); + let mut db = rusqlite::Connection::open(&dbpath).unwrap(); + { + let tx = db.transaction().unwrap(); + tx.execute( + "INSERT INTO results VALUES('interrupted',1,'words','{}')", + [], + ) + .unwrap(); + } // Uncommitted write rolled back. + assert!( + Store::open(&paths) + .unwrap() + .history(None, 100) + .unwrap() + .is_empty() + ); + db.pragma_update(None, "user_version", 99).unwrap(); + drop(db); + assert!(Store::open(&paths).is_err()); + assert_eq!( + rusqlite::Connection::open(&dbpath) + .unwrap() + .pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)) + .unwrap(), + 99 + ); + let bad = temp.path().join("malformed"); + std::fs::create_dir_all(&bad).unwrap(); + let path = bad.join("settings.sqlite3"); + std::fs::write(&path, b"not a database").unwrap(); + assert!( + Store::open(&Paths { + config: bad, + data: paths.data + }) + .is_err() + ); + assert_eq!(std::fs::read(path).unwrap(), b"not a database"); +} diff --git a/docs/rust-engine/terminal-storage.md b/docs/rust-engine/terminal-storage.md new file mode 100644 index 000000000..902e3c1d3 --- /dev/null +++ b/docs/rust-engine/terminal-storage.md @@ -0,0 +1,45 @@ +# Offline local data + +Settings use `settings.sqlite3` under the OS config directory. History uses +`history.sqlite3` under the OS local data directory. Paths are resolved with +`directories::ProjectDirs("com", "Oxytype", "Oxytype")`; explicit `--config-dir` +and `--data-dir` overrides support portable installs/testing. No account, tokens, +telemetry or transport. Data remains until the user removes these directories. + +SQLite is bundled into the executable. Each database uses `user_version=1`, WAL, +FULL synchronization and a two-second busy timeout. Transactions keep completed +writes atomic; concurrent app instances append distinct result IDs. Settings use +a revision comparison so one instance cannot silently overwrite another's edits. +New Unix directories/files are private (0700/0600); platform ACLs apply elsewhere. +Do not copy only the main file while the app runs: WAL may contain committed data. +Use JSON export or close all instances before copying the entire data directory. + +Malformed files and future database/settings versions fail clearly, preserving +the original file. No automatic deletion, repair, reset or score recalculation. +Back up a damaged directory before attempting external recovery. Disk errors +must be reported; the current unsaved result can be retried while the app runs. + +Result JSON envelopes independently carry `formatVersion=1`, UUID, timestamp, +effective native config, native mode2, seed, content SHA-256 identity, quote ID, +custom limit, engine behavior/log/build versions, native validity, capabilities +and summary. Content identity fingerprints source assets/custom text without +storing its path or raw text; a hash is not encryption. Replay logging is off by +default because target words and input events can contain private text. + +Physical identity, hold durations, spacing, overlap, key consistency and +first/last physical-key timing are unavailable. Those summary fields are null, +including when the terminal reports releases: terminal delivery is not a +measurement of physical switch timing. WPM/accuracy/duration/charts remain the +native engine's values based on actual committed input and monotonic delivery +timestamps. No fabricated releases or wall-clock input intervals. + +Stored summaries are authoritative historical values. Export preserves unknown +record versions/fields as JSON; it does not reinterpret them using today's core. +Exports write a synced temporary file and publish without replacing an existing +path. Result UUID retries are idempotent; a collision with different content fails. +Optional replay is offered only when an actual compatible event log was recorded. + +Local personal bests compare stored native WPM within the effective config, +native mode2 and content identity. Shared validity and funbox PB metadata decide +eligibility; repeated, failed or otherwise invalid results remain in history. +No score formula or acceptance policy is reimplemented by storage. From 2f754865d6ac5d56a4f2c4b1a156df3d248998ae Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:20:59 +0530 Subject: [PATCH 08/48] feat(tui): connect local history export and opt-in native replay --- crates/oxytype-tui/src/history.rs | 112 ++++++++++++ crates/oxytype-tui/src/lib.rs | 3 + crates/oxytype-tui/src/local.rs | 77 ++++++++ crates/oxytype-tui/src/main.rs | 212 +++++++++++++++++++--- crates/oxytype-tui/src/options.rs | 19 +- crates/oxytype-tui/src/replay.rs | 111 +++++++++++ crates/oxytype-tui/src/setup.rs | 21 ++- crates/oxytype-tui/src/storage.rs | 33 ++-- crates/oxytype-tui/tests/local_history.rs | 92 ++++++++++ scripts/terminal_pty.py | 39 +++- 10 files changed, 669 insertions(+), 50 deletions(-) create mode 100644 crates/oxytype-tui/src/history.rs create mode 100644 crates/oxytype-tui/src/local.rs create mode 100644 crates/oxytype-tui/src/replay.rs create mode 100644 crates/oxytype-tui/tests/local_history.rs diff --git a/crates/oxytype-tui/src/history.rs b/crates/oxytype-tui/src/history.rs new file mode 100644 index 000000000..a346f603c --- /dev/null +++ b/crates/oxytype-tui/src/history.rs @@ -0,0 +1,112 @@ +//! Historical summaries are displayed/exported as stored, never recalculated. +use crate::{Result, storage::Store}; +use ratatui::text::Line; +use serde_json::Value; + +const MODES: &[Option<&str>] = &[ + None, + Some("time"), + Some("words"), + Some("quote"), + Some("custom"), + Some("zen"), +]; +#[derive(Default)] +pub struct History { + pub rows: Vec, + pub selected: usize, + pub details: bool, + pub scroll: usize, + filter: usize, + page: usize, +} +impl History { + pub fn mode(&self) -> Option<&'static str> { + MODES[self.filter] + } + pub fn refresh(&mut self, store: &Store) -> Result<()> { + self.rows = store.history_page(self.mode(), 100, self.page * 100)?; + self.selected = self.selected.min(self.rows.len().saturating_sub(1)); + Ok(()) + } + pub fn filter(&mut self, store: &Store) -> Result<()> { + self.filter = (self.filter + 1) % MODES.len(); + self.page = 0; + self.selected = 0; + self.details = false; + self.refresh(store) + } + pub fn page(&mut self, direction: i32, store: &Store) -> Result<()> { + let page = self.page; + self.page = (self.page as i64 + i64::from(direction)).max(0) as usize; + self.refresh(store)?; + if self.rows.is_empty() && self.page > 0 { + self.page = page; + self.refresh(store)?; + } + self.selected = 0; + Ok(()) + } + pub fn select(&mut self, direction: i32) { + if self.details { + self.scroll = (self.scroll as i64 + i64::from(direction)).max(0) as usize; + } else { + self.selected = (self.selected as i64 + i64::from(direction)) + .clamp(0, self.rows.len().saturating_sub(1) as i64) + as usize; + } + } + pub fn current(&self) -> Option<&Value> { + self.rows.get(self.selected) + } + pub fn lines(&self, store: &Store, height: usize) -> Vec> { + let mut lines = vec![ + Line::from(format!( + "Local history · {} · page {}", + self.mode().unwrap_or("all modes"), + self.page + 1 + )), + Line::from( + "↑↓ select/scroll · Enter details · m filter · PgUp/PgDn · F7 export · F8 replay", + ), + ]; + if self.details { + if let Some(row) = self.current() { + let details = serde_json::to_string_pretty(row).unwrap_or_default(); + lines.extend( + details + .lines() + .skip(self.scroll) + .take(height.saturating_sub(2)) + .map(|s| Line::from(s.to_string())), + ); + } + } else { + let visible = height.saturating_sub(2).max(1); + let skip = self.selected.saturating_sub(visible - 1); + for (index, row) in self.rows.iter().enumerate().skip(skip).take(visible) { + let summary = &row["summary"]; + let best = row["formatVersion"] == 1 + && row["personalBestEligible"] == true + && row["personalBestGroup"] + .as_str() + .and_then(|g| store.best(g).ok().flatten()) + == summary["wpm"].as_f64(); + lines.push(Line::from(format!( + "{} {} {} · {} WPM / {}% · {}{}", + if index == self.selected { ">" } else { " " }, + row["createdMs"], + row["config"]["mode"].as_str().unwrap_or("unknown"), + summary["wpm"], + summary["acc"], + row["invalidReason"].as_str().unwrap_or("valid"), + if best { " · PB" } else { "" } + ))); + } + if self.rows.is_empty() { + lines.push(Line::from("No saved results for this filter.")); + } + } + lines + } +} diff --git a/crates/oxytype-tui/src/lib.rs b/crates/oxytype-tui/src/lib.rs index a74578539..0906556d9 100644 --- a/crates/oxytype-tui/src/lib.rs +++ b/crates/oxytype-tui/src/lib.rs @@ -1,8 +1,11 @@ pub mod content; pub mod engine; +pub mod history; pub mod input; +pub mod local; pub mod options; pub mod render; +pub mod replay; pub mod setup; pub mod storage; pub mod terminal; diff --git a/crates/oxytype-tui/src/local.rs b/crates/oxytype-tui/src/local.rs new file mode 100644 index 000000000..1d8661a02 --- /dev/null +++ b/crates/oxytype-tui/src/local.rs @@ -0,0 +1,77 @@ +//! App-owned persistence state and recoverable write queue. +use crate::{ + Result, + engine::EngineHost, + history::History, + input::Capabilities, + setup::Setup, + storage::{self, Settings, Store}, +}; +use serde_json::Value; + +pub struct Local { + pub store: Store, + pub preferences: Settings, + pub history: History, + pub pending: Vec, + pub latest_result: Option, + pub replay: Option, + captured: bool, +} +impl Local { + pub fn new(store: Store, preferences: Settings) -> Self { + Self { + store, + preferences, + history: History::default(), + pending: vec![], + latest_result: None, + replay: None, + captured: false, + } + } + pub fn capture( + &mut self, + host: &EngineHost, + capabilities: &Capabilities, + date: f64, + ) -> Result { + if !host.state().ended || self.captured { + return Ok(false); + } + let record = storage::record(host, capabilities, self.preferences.record_replay, date)?; + self.captured = true; + self.latest_result = Some(record.clone()); + self.pending.push(record); + self.retry()?; + Ok(true) + } + pub fn retry(&mut self) -> Result<()> { + while let Some(record) = self.pending.first() { + self.store.save(record)?; + self.pending.remove(0); + } + Ok(()) + } + pub fn restarted(&mut self) { + self.captured = false; + self.latest_result = None; + } + pub fn configure_setup(&self, setup: &mut Setup) { + setup.theme = self.preferences.theme.clone(); + setup.reduced_animation = self.preferences.reduced_animation; + setup.record_replay = self.preferences.record_replay; + } + pub fn apply(&mut self, setup: &Setup) -> Result<()> { + let preferences = Settings { + config: setup.config.clone(), + theme: setup.theme.clone(), + reduced_animation: setup.reduced_animation, + record_replay: setup.record_replay, + format_version: 1, + }; + self.store.save_settings(&preferences)?; + self.preferences = preferences; + Ok(()) + } +} diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index 0dc3699fb..9cfd1083c 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -1,6 +1,10 @@ use clap::Parser; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use oxytype_tui::{Result, engine::EngineHost, terminal::TerminalGuard}; +use oxytype_tui::{ + local::Local, + storage::{Paths, Store}, +}; use oxytype_tui::{options::Cli, setup::Setup}; use ratatui::{ Frame, @@ -12,7 +16,7 @@ use std::{ sync::atomic::Ordering, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use typing_engine::{Command, Config}; +use typing_engine::Command; #[derive(Clone, Copy, PartialEq)] enum Screen { @@ -21,6 +25,7 @@ enum Screen { Settings, History, Editor, + Replay, } fn date() -> f64 { SystemTime::now() @@ -43,8 +48,30 @@ fn run() -> Result<()> { println!("{}", oxytype_tui::options::capabilities()); return Ok(()); } - let (config, content, custom) = cli.prepare(Config::default())?; + let paths = Paths::resolve(cli.config_dir.as_deref(), cli.data_dir.as_deref())?; + if cli.paths { + println!( + "Config: {}\nData: {}", + paths.config.display(), + paths.data.display() + ); + return Ok(()); + } + let mut store = Store::open(&paths)?; + if let Some(output) = &cli.export { + store.export(output, cli.history_mode.as_deref())?; + println!("Exported to {}", output.display()); + return Ok(()); + } + let mut preferences = store.settings()?; + preferences.record_replay |= cli.record_replay; + if let Some(theme) = &cli.theme { + preferences.theme = theme.clone(); + } + let (config, content, custom) = cli.prepare(preferences.config.clone())?; + let mut local = Local::new(store, preferences); let mut setup = Setup::new(config.clone(), custom.as_ref()); + local.configure_setup(&mut setup); let mut host = EngineHost::offline( config, content.clone(), @@ -65,7 +92,6 @@ fn run() -> Result<()> { let origin = Instant::now(); let mut screen = Screen::Test; let mut notice = String::new(); - let mut history = vec![]; let mut last_draw = Instant::now(); let mut dirty = true; loop { @@ -73,14 +99,34 @@ fn run() -> Result<()> { capabilities.enhanced_keyboard_negotiated = guard.enhanced.load(Ordering::SeqCst); dirty = true; } - if guard.quit.load(Ordering::SeqCst) { - break; - } let now = origin.elapsed().as_secs_f64() * 1000.0; if host.state().active { host.command(Command::Advance { now, date: date() })?; } - if dirty || (host.state().active && last_draw.elapsed() >= Duration::from_millis(100)) { + match local.capture(&host, &capabilities, date()) { + Ok(true) => { + notice = "Result saved locally.".into(); + dirty = true; + } + Ok(false) => {} + Err(error) => { + notice = + format!("UNSAVED result: {error}. F9 retries; quitting loses pending data."); + dirty = true; + } + } + if guard.quit.load(Ordering::SeqCst) { + break; + } + if dirty + || (host.state().active + && last_draw.elapsed() + >= Duration::from_millis(if local.preferences.reduced_animation { + 250 + } else { + 100 + })) + { terminal.draw(|frame| { draw( frame, @@ -89,7 +135,7 @@ fn run() -> Result<()> { View { screen, notice: ¬ice, - history: &history, + local: &local, setup: &setup, capabilities: &capabilities, }, @@ -140,6 +186,7 @@ fn run() -> Result<()> { KeyCode::F(1) => screen = Screen::Help, KeyCode::F(2) if !host.state().active => { setup.config = host.config.clone(); + local.configure_setup(&mut setup); screen = Screen::Settings; } KeyCode::F(4) if !host.state().active => screen = Screen::Editor, @@ -151,8 +198,13 @@ fn run() -> Result<()> { match setup.build(&content, host.seed.wrapping_add(1)) { Ok(next) => { host = next; + local.restarted(); screen = Screen::Test; - notice.clear(); + notice = local + .apply(&setup) + .err() + .map(|e| format!("Settings not saved: {e}")) + .unwrap_or_default(); } Err(error) => notice = error.to_string(), } @@ -175,16 +227,78 @@ fn run() -> Result<()> { KeyCode::Down if screen == Screen::Settings => setup.select(1), KeyCode::Left if screen == Screen::Settings => setup.adjust(-1), KeyCode::Right if screen == Screen::Settings => setup.adjust(1), - KeyCode::F(3) if !host.state().active => screen = Screen::History, + KeyCode::F(3) if !host.state().active => { + local.history.refresh(&local.store)?; + screen = Screen::History; + } + KeyCode::Up if screen == Screen::History => local.history.select(-1), + KeyCode::Down if screen == Screen::History => local.history.select(1), + KeyCode::PageUp if screen == Screen::History => { + local.history.page(-1, &local.store)? + } + KeyCode::PageDown if screen == Screen::History => { + local.history.page(1, &local.store)? + } + KeyCode::Enter if screen == Screen::History => { + local.history.details = !local.history.details; + local.history.scroll = 0; + } + KeyCode::Char('m') if screen == Screen::History => { + local.history.filter(&local.store)? + } + KeyCode::F(7) if screen == Screen::History => { + let output = paths + .data + .join(format!("export-{}.json", uuid::Uuid::new_v4())); + notice = match local.store.export(&output, local.history.mode()) { + Ok(()) => format!("Exported {}", output.display()), + Err(e) => format!("Export failed: {e}"), + }; + } + KeyCode::F(8) if screen == Screen::History => { + if let Some(record) = local.history.current() { + match oxytype_tui::replay::Replay::new(record) { + Ok(replay) => { + local.replay = Some(replay); + screen = Screen::Replay; + } + Err(e) => notice = e.to_string(), + } + } + } + KeyCode::Right | KeyCode::Char(' ') if screen == Screen::Replay => { + if let Some(replay) = &mut local.replay { + replay.step(); + } + } + KeyCode::Home if screen == Screen::Replay => { + if let Some(replay) = &mut local.replay { + replay.seek(typing_engine::replay::Cursor::default()); + } + } + KeyCode::PageUp | KeyCode::PageDown if screen == Screen::Replay => { + if let Some(replay) = &mut local.replay { + let direction = if key.code == KeyCode::PageUp { -1 } else { 1 }; + replay.seek(typing_engine::replay::Cursor { + word_index: (replay.frame.cursor.word_index + direction).max(0), + letter_index: 0, + }); + } + } + KeyCode::Esc if screen == Screen::Replay => { + local.replay = None; + screen = Screen::History; + } + KeyCode::F(9) => { + notice = match local.retry() { + Ok(()) => "Pending results saved.".into(), + Err(e) => format!("Still unsaved: {e}"), + } + } KeyCode::Esc if screen != Screen::Test => screen = Screen::Test, KeyCode::F(5) | KeyCode::F(6) => { - if let Some(summary) = host.result.clone() { - history.push(format!( - "{}: {:.1} WPM / {:.1}%", - host.config.mode, summary.wpm, summary.acc - )); - } host.restart(key.code == KeyCode::F(6), now, date())?; + local.restarted(); screen = Screen::Test; notice.clear(); } @@ -195,8 +309,13 @@ fn run() -> Result<()> { match setup.build(&content, host.seed.wrapping_add(1)) { Ok(next) => { host = next; + local.restarted(); screen = Screen::Test; - notice.clear(); + notice = local + .apply(&setup) + .err() + .map(|e| format!("Settings not saved: {e}")) + .unwrap_or_default(); } Err(error) => notice = error.to_string(), } @@ -220,12 +339,19 @@ fn run() -> Result<()> { _ => {} } } + if !local.pending.is_empty() { + return Err(format!( + "{} result(s) could not be saved; prior history is intact", + local.pending.len() + ) + .into()); + } Ok(()) } struct View<'a> { screen: Screen, notice: &'a str, - history: &'a [String], + local: &'a Local, setup: &'a Setup, capabilities: &'a oxytype_tui::input::Capabilities, } @@ -233,11 +359,21 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { let View { screen, notice, - history, + local, setup, capabilities, } = view; let area = frame.area(); + let theme = match local.preferences.theme.as_str() { + "high-contrast" => ratatui::style::Style::new() + .fg(ratatui::style::Color::White) + .bg(ratatui::style::Color::Black), + "light" => ratatui::style::Style::new() + .fg(ratatui::style::Color::Black) + .bg(ratatui::style::Color::White), + _ => ratatui::style::Style::default(), + }; + frame.render_widget(Block::default().style(theme), area); if area.width < 60 || area.height < 18 { frame.render_widget( Paragraph::new("Resize to at least 60×18. Active test clock continues. Ctrl+Q quits."), @@ -253,9 +389,19 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { .split(area); frame.render_widget( Paragraph::new(format!( - "Oxytype {} | {}", + "Oxytype {} | {}{}{}", env!("CARGO_PKG_VERSION"), - host.config.mode + host.config.mode, + if local.preferences.record_replay { + " | RECORDING PRIVATE TEXT" + } else { + "" + }, + if local.pending.is_empty() { + String::new() + } else { + format!(" | {} UNSAVED", local.pending.len()) + } )) .block(Block::bordered()), rows[0], @@ -287,12 +433,10 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { .collect() } Screen::Editor => setup.editor_lines(), - Screen::History => history - .iter() - .rev() - .map(|row| Line::from(row.clone())) - .chain(std::iter::once(Line::from("Session history"))) - .collect(), + Screen::Replay => local.replay.as_ref().map(|r| r.lines()).unwrap_or_default(), + Screen::History => local + .history + .lines(&local.store, rows[1].height.saturating_sub(2) as usize), Screen::Test if state.ended => vec![ Line::from(host.result.as_ref().map_or_else( || { @@ -314,6 +458,18 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { "Completed".into() } })), + Line::from( + local + .latest_result + .as_ref() + .map(|r| { + format!( + "Local validity: {}", + r["invalidReason"].as_str().unwrap_or("valid") + ) + }) + .unwrap_or_else(|| "Preparing local result…".into()), + ), Line::from("F5 new test · F6 repeat"), ], Screen::Test => { @@ -362,7 +518,7 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { ); frame.render_widget( Paragraph::new(format!( - "F1 help · F5 new · F6 repeat · Ctrl+Q quit\n{notice}" + "F1 help · F5 new · F6 repeat · Ctrl+Q quit · F9 retry saves\n{notice}" )), rows[2], ); diff --git a/crates/oxytype-tui/src/options.rs b/crates/oxytype-tui/src/options.rs index e39b41eb5..a8584eb25 100644 --- a/crates/oxytype-tui/src/options.rs +++ b/crates/oxytype-tui/src/options.rs @@ -70,6 +70,23 @@ pub struct Cli { /// auto negotiates enhanced events; basic avoids protocol queries. #[arg(long, default_value="auto", value_parser=["auto","basic"])] pub keyboard: String, + #[arg(long)] + pub config_dir: Option, + #[arg(long)] + pub data_dir: Option, + /// Export all stored results (or --history-mode) without entering a terminal. + #[arg(long)] + pub export: Option, + #[arg(long, value_parser=["time","words","quote","custom","zen"])] + pub history_mode: Option, + /// Print effective storage directories without opening databases. + #[arg(long)] + pub paths: bool, + /// Explicit opt-in: persist target text and full input log for replay. + #[arg(long)] + pub record_replay: bool, + #[arg(long, value_parser=["terminal","high-contrast","light"])] + pub theme: Option, } impl Cli { pub fn prepare( @@ -163,7 +180,7 @@ pub fn validate( return Err("Selected language is unavailable; supply a matching --pack".into()); } if config.mode == "custom" && custom.is_none() { - return Err("Custom mode requires --text, --file, or the custom-text editor".into()); + return Err("Custom mode requires private text: use --text/--file, or --mode words then F4 for the editor".into()); } if config.quote_length.is_empty() || config.quote_length.iter().any(|g| !(0..=3).contains(g)) { return Err("Terminal quote lengths must be groups 0,1,2,3".into()); diff --git a/crates/oxytype-tui/src/replay.rs b/crates/oxytype-tui/src/replay.rs new file mode 100644 index 000000000..eb94daf2f --- /dev/null +++ b/crates/oxytype-tui/src/replay.rs @@ -0,0 +1,111 @@ +//! Optional recorded replay; core owns every step, cursor and correctness flag. +use crate::Result; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use serde_json::Value; +use typing_engine::{ + Text, + replay::{Cursor, Playback, PlaybackSnapshot}, + stats::Log, +}; +use unicode_segmentation::UnicodeSegmentation; + +pub struct Replay { + playback: Playback, + pub frame: PlaybackSnapshot, +} +impl Replay { + pub fn new(record: &Value) -> Result { + if record["formatVersion"] != 1 + || record["engineBehaviorVersion"] != typing_engine::BEHAVIOR_VERSION + || record["eventLogVersion"] != typing_engine::EVENT_LOG_VERSION + { + return Err( + "Replay needs a compatible engine/log version; stored summary remains available" + .into(), + ); + } + if record["replayLog"].is_null() { + return Err( + "Replay unavailable: this result did not record its private input log".into(), + ); + } + let log: Log = serde_json::from_value(record["replayLog"].clone())?; + let playback = Playback::new(&log, record["config"]["mode"] == "zen")?; + let frame = playback.snapshot(); + Ok(Self { playback, frame }) + } + pub fn step(&mut self) { + let update = self.playback.step(); + for change in update.words { + self.frame.words[change.index] = change.word; + } + self.frame.cursor = update.cursor; + self.frame.next_index = update.next_index; + self.frame.next_time = update.next_time; + } + pub fn seek(&mut self, cursor: Cursor) { + self.frame = self.playback.seek(cursor); + } + pub fn lines(&self) -> Vec> { + let cursor = self.frame.cursor; + let mut lines = vec![ + Line::from(format!( + "Recorded replay · word {} / UTF-16 unit {} · step {}", + cursor.word_index, cursor.letter_index, self.frame.next_index + )), + Line::from("→ / Space step · Home restart · PgUp/PgDn seek word · Esc history"), + ]; + let mut spans = vec![]; + for (index, word) in self + .frame + .words + .iter() + .enumerate() + .skip(cursor.word_index.max(0) as usize) + .take(35) + { + let units: Vec<_> = word + .letters + .iter() + .flat_map(|l| l.character.0.iter().copied()) + .collect(); + let text = Text(units).display_lossy(); + let mut unit = 0; + for grapheme in text.graphemes(true) { + let len = grapheme.encode_utf16().count(); + let letters = &word.letters + [unit.min(word.letters.len())..(unit + len).min(word.letters.len())]; + let wrong = letters.iter().any(|l| l.incorrect || l.extra); + let correct = letters.iter().all(|l| l.correct); + let mut style = if wrong { + Style::new() + .fg(Color::Red) + .add_modifier(Modifier::UNDERLINED) + } else if correct { + Style::new().fg(Color::Green) + } else { + Style::default() + }; + if index as i32 == cursor.word_index + && unit <= cursor.letter_index + && cursor.letter_index < unit + len + { + style = style.add_modifier(Modifier::REVERSED); + } + spans.push(Span::styled( + grapheme.replace('\t', "→ ").replace('\n', "↵\n"), + style, + )); + unit += len; + } + if word.error { + spans.push(Span::from("! ")); + } + } + lines.push(Line::from(spans)); + lines + } +} diff --git a/crates/oxytype-tui/src/setup.rs b/crates/oxytype-tui/src/setup.rs index 06a1623f7..e30e5cfbd 100644 --- a/crates/oxytype-tui/src/setup.rs +++ b/crates/oxytype-tui/src/setup.rs @@ -10,6 +10,9 @@ pub struct Setup { pub order: String, pub limit: String, pub length: f64, + pub theme: String, + pub reduced_animation: bool, + pub record_replay: bool, } fn cycle(value: &mut String, values: &[&str], direction: i32) { let index = values.iter().position(|v| v == value).unwrap_or(0) as i32; @@ -32,6 +35,9 @@ impl Setup { order: custom.map_or("repeat", |c| c.mode.as_str()).into(), limit: custom.map_or("section", |c| c.limit.mode.as_str()).into(), length: custom.map_or(1.0, |c| c.limit.value), + theme: "terminal".into(), + reduced_animation: true, + record_replay: false, } } pub fn custom(&self) -> Result> { @@ -49,7 +55,7 @@ impl Setup { EngineHost::offline(self.config.clone(), content.clone(), self.custom()?, seed) } pub fn select(&mut self, direction: i32) { - self.selected = (self.selected as i32 + direction).rem_euclid(13) as usize; + self.selected = (self.selected as i32 + direction).rem_euclid(16) as usize; } pub fn adjust(&mut self, direction: i32) { let c = &mut self.config; @@ -89,6 +95,13 @@ impl Setup { cycle(&mut name, &choices, direction); c.funbox = if name.is_empty() { vec![] } else { vec![name] }; } + 13 => cycle( + &mut self.theme, + &["terminal", "high-contrast", "light"], + direction, + ), + 14 => self.reduced_animation = !self.reduced_animation, + 15 => self.record_replay = !self.record_replay, _ => {} } } @@ -108,6 +121,12 @@ impl Setup { format!("Strict space: {}", c.strict_space), format!("Quote group: {:?}", c.quote_length), format!("Funbox: {}", c.funbox.join(", ")), + format!("Theme: {}", self.theme), + format!("Reduced animation: {}", self.reduced_animation), + format!( + "Record replay (stores PRIVATE text/input): {}", + self.record_replay + ), ]; std::iter::once(Line::from( "↑↓ select · ←→ change · Enter apply · F4 custom text · Esc cancel", diff --git a/crates/oxytype-tui/src/storage.rs b/crates/oxytype-tui/src/storage.rs index 0941c25e5..dd7867ab1 100644 --- a/crates/oxytype-tui/src/storage.rs +++ b/crates/oxytype-tui/src/storage.rs @@ -194,10 +194,19 @@ impl Store { Ok(()) } pub fn history(&self, mode: Option<&str>, limit: usize) -> Result> { - let mut query=self.history.prepare("SELECT json FROM results WHERE (?1 IS NULL OR mode=?1) ORDER BY created_ms DESC,id DESC LIMIT ?2")?; - let rows = query.query_map(params![mode, limit.min(100_000) as i64], |r| { - r.get::<_, String>(0) - })?; + self.history_page(mode, limit, 0) + } + pub fn history_page( + &self, + mode: Option<&str>, + limit: usize, + offset: usize, + ) -> Result> { + let mut query=self.history.prepare("SELECT json FROM results WHERE (?1 IS NULL OR mode=?1) ORDER BY created_ms DESC,id DESC LIMIT ?2 OFFSET ?3")?; + let rows = query.query_map( + params![mode, limit.min(100_000) as i64, offset as i64], + |r| r.get::<_, String>(0), + )?; rows.map(|row| Ok(serde_json::from_str(&row?)?)).collect() } pub fn best(&self, group: &str) -> Result> { @@ -297,20 +306,6 @@ pub fn record( .iter() .all(|n| typing_engine::funboxes::get(n).is_some_and(|f| f.can_get_pb)); Ok( - json!({ "formatVersion":1,"id":uuid::Uuid::new_v4().to_string(),"createdMs":created,"engineBehaviorVersion":typing_engine::BEHAVIOR_VERSION,"eventLogVersion":typing_engine::EVENT_LOG_VERSION,"buildVersion":crate::version(),"config":host.config,"mode2":mode2,"seed":host.seed,"contentIdentity":host.content_identity,"quoteId":quote.map(|q|q.id),"customLimit":limit,"capabilities":capabilities,"measurements":{"clock":"monotonic terminal event delivery","physicalKeyTiming":null},"summary":summary,"summaryError":host.result_error,"invalidReason":invalid,"personalBestEligible":pb_eligible,"personalBestGroup":hash(&(&host.config,&host.content_identity,&mode2))?,"repeated":host.repeated,"bailedOut":log.context.bailed_out,"failureReason":host.state().reason,"replayLog":if replay { Some(log) } else { None } }), + json!({ "formatVersion":1,"id":uuid::Uuid::new_v4().to_string(),"createdMs":created,"startedMs":log.events.iter().find(|e|e.kind=="timer" && e.data["event"]=="start").map(|e|e.data["date"].clone()),"endedMs":log.events.iter().find(|e|e.kind=="timer" && e.data["event"]=="end").map(|e|e.data["date"].clone()),"engineBehaviorVersion":typing_engine::BEHAVIOR_VERSION,"eventLogVersion":typing_engine::EVENT_LOG_VERSION,"buildVersion":crate::version(),"config":host.config,"mode2":mode2,"seed":host.seed,"contentIdentity":host.content_identity,"quoteId":quote.map(|q|q.id),"customLimit":limit,"capabilities":capabilities,"measurements":{"clock":"monotonic terminal event delivery","physicalKeyTiming":null},"summary":summary,"summaryError":host.result_error,"invalidReason":invalid,"personalBestEligible":pb_eligible,"personalBestGroup":hash(&(typing_engine::BEHAVIOR_VERSION,&host.config,&host.content_identity,&mode2))?,"repeated":host.repeated,"bailedOut":log.context.bailed_out,"failureReason":host.state().reason,"replayLog":if replay { Some(log) } else { None } }), ) } -pub fn is_personal_best(record: &Value, history: &[Value]) -> bool { - record["personalBestEligible"] == true - && record["summary"]["wpm"].as_f64().is_some_and(|wpm| { - history - .iter() - .filter(|r| { - r["personalBestEligible"] == true - && r["personalBestGroup"] == record["personalBestGroup"] - && r["id"] != record["id"] - }) - .filter_map(|r| r["summary"]["wpm"].as_f64()) - .all(|prior| wpm > prior) - }) -} diff --git a/crates/oxytype-tui/tests/local_history.rs b/crates/oxytype-tui/tests/local_history.rs new file mode 100644 index 000000000..6adbc205a --- /dev/null +++ b/crates/oxytype-tui/tests/local_history.rs @@ -0,0 +1,92 @@ +use oxytype_tui::{ + content::OfflineContent, + engine::EngineHost, + input::Capabilities, + local::Local, + replay::Replay, + storage::{Paths, Settings, Store}, +}; +use typing_engine::{Config, replay::Cursor}; + +fn finished() -> EngineHost { + let mut host = EngineHost::offline( + Config { + mode: "words".into(), + words: 10, + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + for i in 0..10 { + let word = host.words[i].display_lossy(); + host.insert(&word, 100.0 + i as f64 * 1000.0, 1100.0 + i as f64 * 1000.0) + .unwrap(); + } + host +} +#[test] +fn failed_write_queue_retries_the_same_uuid_once_and_history_filters_page() { + let temp = tempfile::tempdir().unwrap(); + let paths = Paths { + config: temp.path().join("config"), + data: temp.path().join("data"), + }; + let store = Store::open(&paths).unwrap(); + let mut local = Local::new(store, Settings::default()); + let host = finished(); + let lock = rusqlite::Connection::open(paths.data.join("history.sqlite3")).unwrap(); + lock.execute_batch("BEGIN IMMEDIATE").unwrap(); + assert!( + local + .capture(&host, &Capabilities::default(), 10000.0) + .is_err() + ); + assert_eq!(local.pending.len(), 1); + let id = local.pending[0]["id"].clone(); + assert!( + !local + .capture(&host, &Capabilities::default(), 10001.0) + .unwrap() + ); + lock.execute_batch("ROLLBACK").unwrap(); + local.retry().unwrap(); + assert!(local.pending.is_empty()); + local.history.refresh(&local.store).unwrap(); + assert_eq!(local.history.rows[0]["id"], id); + local.history.filter(&local.store).unwrap(); + assert!(local.history.rows.is_empty()); // time + local.history.filter(&local.store).unwrap(); + assert_eq!(local.history.rows.len(), 1); // words + local.history.page(1, &local.store).unwrap(); + assert_eq!(local.history.rows.len(), 1); + assert!(local.store.history_page(None, 100, 1).unwrap().is_empty()); +} +#[test] +fn replay_requires_opt_in_and_compatible_versions_and_uses_native_steps_and_seek() { + let host = finished(); + let cap = Capabilities::default(); + let summary_only = oxytype_tui::storage::record(&host, &cap, false, 10000.0).unwrap(); + assert!(Replay::new(&summary_only).is_err()); + let record = oxytype_tui::storage::record(&host, &cap, true, 10000.0).unwrap(); + let mut replay = Replay::new(&record).unwrap(); + let mut steps = 0; + while replay.frame.next_time.is_some() { + replay.step(); + steps += 1; + } + assert!(steps > 10); + assert_eq!(replay.frame.next_index, steps); + replay.seek(Cursor::default()); + assert_eq!(replay.frame.next_index, 0); + replay.seek(Cursor { + word_index: 5, + letter_index: 0, + }); + assert_eq!(replay.frame.cursor.word_index, 5); + let mut future = record; + future["engineBehaviorVersion"] = serde_json::json!(99); + assert!(Replay::new(&future).is_err()); +} diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 42c14b5bf..2380b44a7 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -7,10 +7,13 @@ import re import select import signal +import sqlite3 +import json import struct import subprocess import sys import termios +import tempfile import time import unicodedata @@ -21,6 +24,7 @@ def dimensions(slave, columns=100, rows=24): class Child: def __init__(self, binary, failure=None, args=(), enhanced=False): + self.profile = tempfile.TemporaryDirectory(prefix="oxytype-pty-") self.enhanced = enhanced self.query_replied = False self.master, self.slave = pty.openpty() @@ -35,7 +39,7 @@ def controlling_terminal(): os.setsid() fcntl.ioctl(self.slave, termios.TIOCSCTTY, 0) - self.process = subprocess.Popen([binary, "--keyboard", "auto" if enhanced else "basic", *args], stdin=self.slave, stdout=self.slave, + self.process = subprocess.Popen([binary, "--config-dir", self.profile.name + "/config", "--data-dir", self.profile.name + "/data", "--keyboard", "auto" if enhanced else "basic", *args], stdin=self.slave, stdout=self.slave, stderr=self.slave, env=env, preexec_fn=controlling_terminal) @@ -124,6 +128,7 @@ def close(self): self.process.wait(timeout=3) os.close(self.master) os.close(self.slave) + self.profile.cleanup() def run(binary): @@ -135,6 +140,19 @@ def run(binary): time.sleep(0.15) child.send(b"\x1b[19~") # F8 is available without enhanced Ctrl+Enter. child.until(b"F5 new test") + child.until(b"Result saved locally.") + history_path = pathlib.Path(child.profile.name) / "data/history.sqlite3" + with sqlite3.connect(history_path) as db: + record = json.loads(db.execute("SELECT json FROM results").fetchone()[0]) + assert record["replayLog"] is None + assert record["summary"]["keyDuration"] is None + child.send(b"\x1bOR") # F3 history + child.until(b"Local history") + child.send(b"\x1b[18~") # F7 export + child.until(b"Exported") + exported = list((pathlib.Path(child.profile.name) / "data").glob("export-*.json")) + assert len(exported) == 1 + assert json.loads(exported[0].read_text())["results"][0]["id"] == record["id"] child.send(b"\x1b[15~") child.until(b"Input:") dimensions(child.slave, 30, 8) @@ -189,6 +207,25 @@ def run(binary): finally: child.close() + child = Child(binary, args=["--mode", "custom", "--text", "one two", "--record-replay"]) + try: + child.until(b"RECORDING PRIVATE TEXT") + child.send(b"one ") + time.sleep(0.15) + child.send(b"two") + child.until(b"Result saved locally.") + child.send(b"\x1bOR") + child.until(b"Local history") + child.send(b"\x1b[19~") + child.until(b"Recorded replay") + child.send(b"\x1b[C") + child.until(b"step 1") + child.send(b"\x11") + assert child.process.wait(timeout=5) == 0 + child.restored() + finally: + child.close() + child = Child(binary, args=["--mode", "zen"], enhanced=True) try: child.until(b"Oxytype") From 6a78b0e0d5e5b98c875f1c8e3e8523a996a37664 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:22:07 +0530 Subject: [PATCH 09/48] docs(tui): record durable history and privacy acceptance checks --- docs/rust-engine/progress.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index 5206f9c7b..f86d1888a 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -12,7 +12,7 @@ empty commits to inflate count. Do not publish production or releases implicitly ## Current position -Stage 1 and Stage 2 gates passed. Stage 3 Phase 3.3 active on `stack/rust-engine-terminal`, stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration +Stage 1 and Stage 2 gates passed. Stage 3 Phase 3.5 active on `stack/rust-engine-terminal`, stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration plan before edits. Installed workspace dependencies with frozen lockfile. Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen oracle and mismatch detection. Existing website engine remains authoritative until Stage 2. @@ -33,8 +33,9 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 2.6 TypeScript retirement | Complete | Legacy package removed; full native/Wasm/website/quality CI, fresh build/types and frozen performance gates pass. | | 3.1 Terminal shell | Complete | Native smoke/score test; real PTY typing, finish/restart, resize, error/panic, SIGTERM and suspend/resume pass. | | 3.2 Offline content | Complete | Bundled words/eight original quotes; five modes, CLI/settings/editor, bounded v1 packs; native/full-fixture and actual PTY checks pass. | -| 3.3 Input / Unicode | Active | Capability negotiation and grapheme/cell rendering next. | -| 3.4–3.5 Terminal | Pending | Durable history and release gates remain. | +| 3.3 Input / Unicode | Implemented; manual gate pending | Native Unicode/TestBackend + basic/enhanced-protocol PTY pass; real macOS/Windows/enhanced terminal records unavailable. | +| 3.4 Settings / history | Complete | SQLite reopen/concurrency/corruption/retry/export and actual history/replay PTY pass. | +| 3.5 Packaging / release | Active | Platform build/smoke archives, fresh install and release docs next. | ## Verification log @@ -594,3 +595,25 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro - Real macOS/Windows Terminal and enhanced hardware-terminal manual matrix is unperformed, not a passed acceptance gate. Asked for machine/test access; continue independent durable history and packaging work while awaiting it. + +## Phase 3.4 gate passed + +- SQLite settings/history with OS directories and explicit overrides, WAL/FULL + transactions, settings revision conflict detection, append-only UUID results and + idempotent retry. No network/accounts/tokens. New Unix data is private. +- Native summaries/config/content identity/seed/quote/timestamps/engine versions, + capabilities and native validity stored. Unavailable physical timing fields null. + Raw custom text/logs absent by default; explicit replay opt-in warns in UI. +- History pages/filter/details/PBs/export use stored summaries; native PB validity + and funbox metadata preserved. Compatible recorded replay steps/seeks call core. + Unknown record fields/versions export unchanged, with no score recomputation. +- Fourteen terminal integration tests pass: reopen, concurrent instances, malformed + and future formats, dropped transaction, write-lock queue retry, native PB value, + no-clobber export, privacy defaults and replay compatibility. Clippy passes. +- Actual PTY passes saved-result/history/export and opted-in replay stepping, plus + prior input/settings/lifecycle cases. PTY profiles isolated in temporary dirs. + Readable themes, reduced redraws and color-independent errors implemented. +- Private custom text is intentionally not restored from settings; a saved custom + mode requires new --text/--file (or choose --mode words and F4 editor). Local + packs must be explicitly supplied again. No silent content substitution. +- Platform/manual terminal gate remains unperformed; continue packaging. From e3c89d043c78de1f5ea902792e9e86f3b8180738 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:28:34 +0530 Subject: [PATCH 10/48] build(tui): stamp source versions and smoke all bundled modes --- crates/oxytype-tui/build.rs | 31 +++++++++++++++++++ crates/oxytype-tui/src/engine.rs | 51 ++++++++++++++++++++++++++++++- crates/oxytype-tui/src/lib.rs | 5 +-- crates/oxytype-tui/src/main.rs | 7 +++++ crates/oxytype-tui/src/options.rs | 3 ++ 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 crates/oxytype-tui/build.rs diff --git a/crates/oxytype-tui/build.rs b/crates/oxytype-tui/build.rs new file mode 100644 index 000000000..7e5a6577e --- /dev/null +++ b/crates/oxytype-tui/build.rs @@ -0,0 +1,31 @@ +use std::process::Command; +fn git(args: &[&str]) -> Option { + let result = Command::new("git").args(args).output().ok()?; + result + .status + .success() + .then(|| String::from_utf8_lossy(&result.stdout).trim().to_string()) +} +fn main() { + println!("cargo:rerun-if-env-changed=OXYTYPE_BUILD_REV"); + if let Some(path) = git(&["rev-parse", "--git-path", "HEAD"]) { + println!("cargo:rerun-if-changed={path}"); + } + if let Some(branch) = git(&["symbolic-ref", "-q", "HEAD"]) + && let Some(path) = git(&["rev-parse", "--git-path", &branch]) + { + println!("cargo:rerun-if-changed={path}"); + } + let revision = std::env::var("OXYTYPE_BUILD_REV") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| git(&["rev-parse", "HEAD"])) + .unwrap_or_else(|| "unknown-source-revision".into()); + assert!( + revision + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-'), + "Invalid OXYTYPE_BUILD_REV" + ); + println!("cargo:rustc-env=OXYTYPE_BUILD_REV={revision}"); +} diff --git a/crates/oxytype-tui/src/engine.rs b/crates/oxytype-tui/src/engine.rs index 8e72bd161..c72655f8a 100644 --- a/crates/oxytype-tui/src/engine.rs +++ b/crates/oxytype-tui/src/engine.rs @@ -217,9 +217,58 @@ pub fn smoke() -> Result<()> { { return Err("Smoke inserted unobserved keyboard telemetry".into()); } + let modes = ["time", "words", "quote", "custom", "zen"]; + for mode in modes { + let custom = if mode == "custom" { + Some(crate::options::custom_text( + "alpha beta", + "repeat", + "section", + 1.0, + )?) + } else { + None + }; + let mut host = EngineHost::offline( + Config { + mode: mode.into(), + words: 2, + time: 1.0, + ..Default::default() + }, + crate::content::OfflineContent::bundled()?, + custom, + 42, + )?; + if mode == "zen" { + host.insert("alpha ", 100.0, 1000.0)?; + host.command(Command::Bailout { + now: 1100.0, + date: 2000.0, + })?; + } else if mode == "time" { + let word = host.words[0].display_lossy(); + host.insert(&word, 100.0, 1000.0)?; + host.command(Command::Advance { + now: 1200.0, + date: 2100.0, + })?; + } else { + for i in 0..1000 { + if host.state().ended { + break; + } + let word = host.words[i].display_lossy(); + host.insert(&word, 100.0 + i as f64 * 1000.0, 1000.0 + i as f64 * 1000.0)?; + } + } + if !host.state().ended || host.result.is_none() { + return Err(format!("Bundled offline {mode} smoke did not complete").into()); + } + } println!( "{}", - serde_json::json!({"syntheticSmoke":true,"completed":true,"engineVersion":typing_engine::BEHAVIOR_VERSION}) + serde_json::json!({"syntheticSmoke":true,"completed":true,"bundledModes":modes,"engineVersion":typing_engine::BEHAVIOR_VERSION}) ); Ok(()) } diff --git a/crates/oxytype-tui/src/lib.rs b/crates/oxytype-tui/src/lib.rs index 0906556d9..2b63d7be7 100644 --- a/crates/oxytype-tui/src/lib.rs +++ b/crates/oxytype-tui/src/lib.rs @@ -15,9 +15,10 @@ pub type Result = std::result::Result; pub fn version() -> String { format!( - "{} (engine {}; log {})", + "{} (engine {}; log {}; build {})", env!("CARGO_PKG_VERSION"), typing_engine::BEHAVIOR_VERSION, - typing_engine::EVENT_LOG_VERSION + typing_engine::EVENT_LOG_VERSION, + env!("OXYTYPE_BUILD_REV") ) } diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index 9cfd1083c..8e9498977 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -48,6 +48,13 @@ fn run() -> Result<()> { println!("{}", oxytype_tui::options::capabilities()); return Ok(()); } + if cli.diagnostics { + println!( + "{}", + serde_json::json!({"version":oxytype_tui::version(),"os":std::env::consts::OS,"architecture":std::env::consts::ARCH,"bindingVersion":typing_engine::BINDING_VERSION,"offlinePackVersion":1,"keyboard":"Inspect F1 after terminal negotiation","physicalKeyTiming":null}) + ); + return Ok(()); + } let paths = Paths::resolve(cli.config_dir.as_deref(), cli.data_dir.as_deref())?; if cli.paths { println!( diff --git a/crates/oxytype-tui/src/options.rs b/crates/oxytype-tui/src/options.rs index a8584eb25..01cfd5b05 100644 --- a/crates/oxytype-tui/src/options.rs +++ b/crates/oxytype-tui/src/options.rs @@ -87,6 +87,9 @@ pub struct Cli { pub record_replay: bool, #[arg(long, value_parser=["terminal","high-contrast","light"])] pub theme: Option, + /// Version/platform diagnostics; no environment variables or private text. + #[arg(long)] + pub diagnostics: bool, } impl Cli { pub fn prepare( From 791c790e51085e2367bb3eb7a0a3b9bc9545a035 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:28:55 +0530 Subject: [PATCH 11/48] build(tui): package licensed archives and verify isolated installs --- docs/rust-engine/terminal-verification.json | 8 + docs/rust-engine/terminal.md | 102 ++++++++++ .../third-party/license-overrides.json | 16 ++ .../third-party/ts-rs-12.0.1.LICENSE | 21 +++ package.json | 6 +- scripts/terminal.ts | 16 ++ scripts/terminal_pty.py | 8 +- scripts/terminal_release.py | 175 ++++++++++++++++++ 8 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 docs/rust-engine/terminal-verification.json create mode 100644 docs/rust-engine/terminal.md create mode 100644 docs/rust-engine/third-party/license-overrides.json create mode 100644 docs/rust-engine/third-party/ts-rs-12.0.1.LICENSE create mode 100644 scripts/terminal_release.py diff --git a/docs/rust-engine/terminal-verification.json b/docs/rust-engine/terminal-verification.json new file mode 100644 index 000000000..55aa604ed --- /dev/null +++ b/docs/rust-engine/terminal-verification.json @@ -0,0 +1,8 @@ +{ + "formatVersion": 1, + "manualTerminals": { + "linuxEnhanced": { "status": "pending", "evidence": null }, + "macOS": { "status": "pending", "evidence": null }, + "windowsTerminal": { "status": "pending", "evidence": null } + } +} diff --git a/docs/rust-engine/terminal.md b/docs/rust-engine/terminal.md new file mode 100644 index 000000000..ce40de6d9 --- /dev/null +++ b/docs/rust-engine/terminal.md @@ -0,0 +1,102 @@ +# Oxytype offline terminal + +First native release: offline typing and local history, using the same Rust engine +as the website. No account login, cloud sync, telemetry or first-run download. + +Download the matching `oxytype-vVERSION-TARGET` archive from the repository's +GitHub Releases when a maintainer publishes one. Verify its SHA-256 against +`SHA256SUMS`, extract, and put `oxytype` (`oxytype.exe` on Windows) on PATH. +Users need no Rust, Node, vp, source checkout or network access. Keep LICENSE, +attribution and third-party notices with redistributed copies. + +```sh +oxytype --help +oxytype --mode words --words 25 +oxytype --mode time --time 60 --punctuation --numbers +oxytype --mode quote --quote-length 0,1 +oxytype --text 'Your private practice text.' +oxytype --file practice.txt --custom-order shuffle --custom-limit word --custom-length 50 +oxytype --mode zen +oxytype --paths +oxytype --diagnostics +oxytype --export history.json +``` + +Binary invocation above is for installed users. Repository development commands: + +```sh +vp run terminal:dev +vp run terminal:test +vp run terminal:test:pty +vp run terminal:build +vp run terminal:package +vp run engine:lint +vp run engine:fmt +``` + +Use F1 for help. F5 starts a new test; F6 repeats the native wordset; F8 finishes +manually. F2 edits settings when inactive; arrows select/change, Enter validates +and applies. F4 opens private custom text setup (paste allowed there). F3 browses +history: arrows select, Enter details, m filters, PgUp/PgDn pages, F7 exports the +filter, F8 opens a recorded compatible replay. Replay: Right/Space step, Home +restart, PgUp/PgDn seek by word. Ctrl+Q/C or F10 quit. Unix Ctrl+Z suspends the +foreground job; resume with the shell. Native elapsed time continues while +suspended or too small. Typing is disabled below 60×18; resizing does not pause. + +Input maps characters, Backspace, Ctrl/Alt+Backspace word deletion, Enter and Tab. +Enhanced keyboard reporting is queried before reading events; fallback is basic. +`--keyboard basic` skips queries for troublesome terminals/multiplexers. Modified +Enter may be indistinguishable in basic mode; F8 is always available. Repeat +deliveries type once each; release events never type. Bracketed paste is blocked +during tests. Terminals that send unmarked paste cannot be distinguished from +typing. IME committed text works when delivered as ordinary characters; terminal +preedit/composition lifecycle and physical key identity are unavailable. + +The renderer keeps UTF-16 engine offsets separate from grapheme clusters and +terminal columns. It handles combining marks, CJK, emoji, tabs/newlines and wraps; +font/emulator Unicode width versions can differ. RTL ordering, Arabic/Indic +shaping, emoji appearance and terminal IME behavior are not browser-equivalent +guarantees. See the tested matrix below. Generated isolated surrogates are rejected +as untypable rather than replaced. Underline plus `!` indicates errors without +relying on color. Terminal/default, light and high-contrast themes are available; +reduced animation lowers active redraw frequency. + +Physical-key timing remains unavailable even if releases are reported. History +stores null for absent measurements. WPM, accuracy, duration and validity come +from native core with actual committed text and monotonic delivery timestamps. +Short/failed/repeated results remain in history with their native invalid reason. + +[Content packs and supported rules](terminal-content.md) document offline assets, +CLI settings and explicit unavailable hooks. [Local storage](terminal-storage.md) +documents paths, format, concurrency, privacy and export. Replay recording is an +explicit opt-in in settings or `--record-replay`; it stores private target text and +input. The header warns while enabled. Default summaries contain neither. Saved +custom mode requires private text again; use `--text`, `--file`, or launch with +`--mode words` and open F4. Packs must be supplied again with `--pack`. + +Failed result writes stay queued in memory with an UNSAVED indicator; F9 retries. +Quitting with pending results reports the loss and leaves prior history intact. +Malformed/future data is preserved with a clear error. No automatic deletion or +silent reset. Export never replaces an existing path. For uninstall, remove the +binary; optionally remove the config/data directories printed by `--paths` after +closing all instances. Copy whole closed databases/directories when backing up. + +Cleanup covers normal exit, errors, panic and supported signals (Unix HUP/TERM/INT, +suspend/resume). SIGKILL, power loss and forced process termination cannot run +cleanup; use the shell's terminal reset command if needed. SQLite transactions +protect prior committed data; a result not yet committed can be lost. + +## Verification matrix + +| Environment | Evidence | Status | +| --- | --- | --- | +| Linux ARM64, POSIX PTY/basic protocol | Five modes, settings/editor, history/export/replay, resize, cleanup, SIGTERM, suspend/resume | Pass | +| Linux ARM64, simulated enhanced protocol in PTY | Negotiation, press/repeat/release, owned flag cleanup | Pass; protocol simulation | +| Ratatui TestBackend | Combining/CJK/emoji/tab/newline/cell wrapping/caret/UTF-16 deletion | Pass; simulated display | +| Real Linux enhanced terminal | Rapid input, scrollback, Unicode/IME, resize | Pending | +| Real macOS terminal | Basic/enhanced input, Unicode/IME, resize, suspend | Pending | +| Real Windows Terminal | Input/repeats/releases, Unicode/IME, resize, cleanup | Pending | + +Build/smoke archive verification is recorded separately in terminal-release.md. +A runner build or PTY simulation does not satisfy real emulator/manual coverage. +Do not claim the untested combinations supported until their records exist. diff --git a/docs/rust-engine/third-party/license-overrides.json b/docs/rust-engine/third-party/license-overrides.json new file mode 100644 index 000000000..0d8407a23 --- /dev/null +++ b/docs/rust-engine/third-party/license-overrides.json @@ -0,0 +1,16 @@ +{ + "ts-rs@12.0.1": { + "source": "https://raw.githubusercontent.com/Aleph-Alpha/ts-rs/7182ad8289596097235406b715fa04506443b4ad/LICENSE", + "vcsRevision": "7182ad8289596097235406b715fa04506443b4ad", + "license": "MIT", + "file": "ts-rs-12.0.1.LICENSE", + "sha256": "db7f7f8e7236a2d0b41b855b2501b7b913caebbcda6ce498637a304ad1706ce1" + }, + "ts-rs-macros@12.0.1": { + "source": "https://raw.githubusercontent.com/Aleph-Alpha/ts-rs/7182ad8289596097235406b715fa04506443b4ad/LICENSE", + "vcsRevision": "7182ad8289596097235406b715fa04506443b4ad", + "license": "MIT", + "file": "ts-rs-12.0.1.LICENSE", + "sha256": "db7f7f8e7236a2d0b41b855b2501b7b913caebbcda6ce498637a304ad1706ce1" + } +} diff --git a/docs/rust-engine/third-party/ts-rs-12.0.1.LICENSE b/docs/rust-engine/third-party/ts-rs-12.0.1.LICENSE new file mode 100644 index 000000000..c23d24d80 --- /dev/null +++ b/docs/rust-engine/third-party/ts-rs-12.0.1.LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Aleph Alpha GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package.json b/package.json index 0451cc380..9b07f4b37 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,11 @@ "terminal:dev": "node scripts/terminal.ts dev", "terminal:build": "node scripts/terminal.ts build", "terminal:test": "node scripts/terminal.ts test", - "terminal:test:pty": "node scripts/terminal.ts pty" + "terminal:test:pty": "node scripts/terminal.ts pty", + "terminal:package": "node scripts/terminal.ts package", + "terminal:source": "node scripts/terminal.ts source", + "terminal:archive-smoke": "node scripts/terminal.ts archive-smoke", + "terminal:release-gate": "node scripts/terminal.ts release-gate" }, "devDependencies": { "@commitlint/cli": "21.2.3", diff --git a/scripts/terminal.ts b/scripts/terminal.ts index 4d1978495..d1e16d995 100644 --- a/scripts/terminal.ts +++ b/scripts/terminal.ts @@ -3,6 +3,22 @@ import { resolve } from "node:path"; const command = process.argv[2]; const args = process.argv.slice(3); +if ( + command !== undefined && + ["package", "source", "archive-smoke", "release-gate"].includes(command) +) { + const result = spawnSync( + process.platform === "win32" ? "python" : "python3", + [ + "scripts/terminal_release.py", + command, + ...args.filter((arg) => arg !== "--"), + ], + { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit" }, + ); + if (result.error !== undefined) throw result.error; + process.exit(result.status ?? 1); +} const tasks: Record = { dev: ["run", "--locked", "-p", "oxytype-tui", "--", ...args], build: ["build", "--locked", "--release", "-p", "oxytype-tui", ...args], diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 2380b44a7..8cbcc5d90 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -131,7 +131,7 @@ def close(self): self.profile.cleanup() -def run(binary): +def run(binary, release=False): child = Child(binary) try: child.until(b"Oxytype") @@ -242,7 +242,7 @@ def run(binary): finally: child.close() - for failure in ("error", "panic", "signal", "suspend"): + for failure in (("signal", "suspend") if release else ("error", "panic", "signal", "suspend")): child = Child(binary, failure if failure in ("error", "panic") else None) try: if failure in ("error", "panic"): @@ -269,8 +269,8 @@ def run(binary): child.restored() finally: child.close() - print("PTY: five modes, settings/custom editor, typing/finish/restart/resize/quit, error, panic, SIGTERM, suspend/resume pass") + print("PTY: five modes, settings/editor, history/export/replay, basic/enhanced protocol, typing/finish/restart/resize/quit, SIGTERM, suspend/resume pass" + ("; debug error/panic cleanup pass" if not release else "")) if __name__ == "__main__": - run(str(pathlib.Path(sys.argv[1]).resolve())) + run(str(pathlib.Path(sys.argv[1]).resolve()), "--release" in sys.argv) diff --git a/scripts/terminal_release.py b/scripts/terminal_release.py new file mode 100644 index 000000000..843afe485 --- /dev/null +++ b/scripts/terminal_release.py @@ -0,0 +1,175 @@ +"""Native release tooling. No publication; platform workflow owns authorized tags.""" +import argparse +import hashlib +import json +import os +import pathlib +import shutil +import subprocess +import sys +import tarfile +import tempfile +import tomllib +import zipfile + +ROOT = pathlib.Path(__file__).resolve().parent.parent +OUT = ROOT / "target/terminal-release" +TARGETS = ("x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "aarch64-apple-darwin", "x86_64-pc-windows-msvc") + + +def run(args, **kwargs): + return subprocess.run(args, cwd=ROOT, check=True, **kwargs) + + +def capture(args): + return run(args, stdout=subprocess.PIPE, text=True).stdout + + +def version(): + return tomllib.loads((ROOT / "Cargo.toml").read_text())["workspace"]["package"]["version"] + + +def checksum(path): + digest = hashlib.file_digest(path.open("rb"), "sha256").hexdigest() + path.with_name(path.name + ".sha256").write_text(f"{digest} {path.name}\n") + + +def archive(directory, windows=False): + output = directory.with_name(directory.name + (".zip" if windows else ".tar.gz")) + if windows: + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as package: + for path in sorted(directory.rglob("*")): + if path.is_file(): package.write(path, path.relative_to(directory.parent)) + else: + with tarfile.open(output, "w:gz") as package: + package.add(directory, arcname=directory.name) + checksum(output) + return output + + +def licenses(directory, target): + metadata = json.loads(capture(["cargo", "metadata", "--locked", "--format-version", "1", "--filter-platform", target])) + notices = [] + override_dir=ROOT / "docs/rust-engine/third-party" + overrides=json.loads((override_dir / "license-overrides.json").read_text()) + for package in sorted(metadata["packages"], key=lambda p: (p["name"], p["version"])): + if package["source"] is None: continue + source = pathlib.Path(package["manifest_path"]).parent + dest = directory / "licenses" / f'{package["name"]}-{package["version"]}' + dest.mkdir(parents=True) + files = [p for p in source.iterdir() if p.name.upper().startswith(("LICENSE", "LICENCE", "COPYING", "COPYRIGHT", "NOTICE"))] + if package.get("license_file"): + explicit = source / package["license_file"] + if explicit not in files: files.append(explicit) + override=overrides.get(f'{package["name"]}@{package["version"]}') + if override: + vcs=json.loads((source / ".cargo_vcs_info.json").read_text()) + license_path=override_dir / override["file"] + assert vcs["git"]["sha1"]==override["vcsRevision"] + assert package["license"]==override["license"] + assert hashlib.sha256(license_path.read_bytes()).hexdigest()==override["sha256"] + files.append(license_path) + if not files: raise RuntimeError(f'No distributable license/notice file for {package["name"]}') + for path in files: + if path.is_dir(): shutil.copytree(path, dest / path.name, dirs_exist_ok=True) + else: shutil.copy2(path, dest / path.name) + notices.append({"name":package["name"],"version":package["version"],"license":package["license"],"repository":package["repository"]}) + (directory / "THIRD_PARTY.json").write_text(json.dumps({"scope":"Locked workspace dependency notices (superset of linked native crates)","packages":notices}, indent=2) + "\n") + + +def package(target): + if target is None: + target = next(line.split(": ", 1)[1] for line in capture(["rustc", "-vV"]).splitlines() if line.startswith("host: ")) + if target not in TARGETS: raise RuntimeError(f"Unsupported archive target: {target}") + tag = os.environ.get("GITHUB_REF_NAME", "") + if os.environ.get("GITHUB_REF_TYPE") == "tag" and tag != f"terminal-v{version()}": + raise RuntimeError("Terminal tag must match Cargo workspace version") + run(["cargo", "build", "--locked", "--release", "-p", "oxytype-tui", "--target", target]) + name = f"oxytype-v{version()}-{target}" + directory = OUT / name + if directory.exists(): shutil.rmtree(directory) + directory.mkdir(parents=True) + binary = "oxytype.exe" if "windows" in target else "oxytype" + shutil.copy2(ROOT / "target" / target / "release" / binary, directory / binary) + for source, dest in [("LICENSE","LICENSE"),("crates/oxytype-tui/assets/ATTRIBUTION.md","ATTRIBUTION.md"),("docs/rust-engine/terminal.md","README.md"),("docs/rust-engine/terminal-content.md","terminal-content.md"),("docs/rust-engine/terminal-storage.md","terminal-storage.md")]: + shutil.copy2(ROOT / source,directory / dest) + revision = os.environ.get("OXYTYPE_BUILD_REV") or capture(["git","rev-parse","HEAD"]).strip() + (directory / "SOURCE.md").write_text(f"Source revision: {revision}\n\nGPL-3.0-only corresponding source is the matching oxytype-v{version()}-source.tar.gz release asset, including locked vendored dependencies.\n\nRepository: https://github.com/voltcrash/oxytype/tree/{revision}\n") + licenses(directory,target) + output = archive(directory,"windows" in target) + print(f"Archive: {output}") + return output + + +def smoke(path): + expected=path.with_name(path.name+".sha256").read_text().split()[0] + assert hashlib.sha256(path.read_bytes()).hexdigest()==expected, "Archive checksum mismatch" + with tempfile.TemporaryDirectory(prefix="oxytype-install-") as temporary: + directory=pathlib.Path(temporary) + if path.suffix == ".zip": + with zipfile.ZipFile(path) as package: package.extractall(directory) + else: + with tarfile.open(path) as package: package.extractall(directory,filter="data") + binaries = list(directory.rglob("oxytype.exe" if os.name == "nt" else "oxytype")) + if len(binaries)!=1: raise RuntimeError("Expected one installed executable") + binary=binaries[0] + isolated=directory / "empty";isolated.mkdir() + env=dict(os.environ,PATH="") + for args in [["--version"],["--help"],["--diagnostics"],["--smoke-test"]]: + subprocess.run([str(binary),*args],cwd=isolated,env=env,check=True) + export=isolated / "history.json" + subprocess.run([str(binary),"--config-dir",str(isolated / "config"),"--data-dir",str(isolated / "data"),"--export",str(export)],cwd=isolated,env=env,check=True) + assert json.loads(export.read_text())["results"]==[] + if os.name != "nt": + run([sys.executable,"scripts/terminal_pty.py",str(binary),"--release"]) + print("Installed archive: empty PATH/cwd, offline smoke, storage/export and supported POSIX PTY pass") + + +def source(): + OUT.mkdir(parents=True,exist_ok=True) + directory=OUT / f"oxytype-v{version()}-source" + if directory.exists(): shutil.rmtree(directory) + directory.mkdir() + snapshot=run(["git","archive","--format=tar","HEAD"],stdout=subprocess.PIPE).stdout + import io + with tarfile.open(fileobj=io.BytesIO(snapshot)) as package: package.extractall(directory,filter="data") + config=subprocess.run(["cargo","vendor","--locked","--versioned-dirs","vendor"],cwd=directory,check=True,stdout=subprocess.PIPE,text=True).stdout + cargo=directory / ".cargo";cargo.mkdir(exist_ok=True) + with (cargo / "config.toml").open("a") as output: output.write("\n"+config) + revision=os.environ.get("OXYTYPE_BUILD_REV") or capture(["git","rev-parse","HEAD"]).strip() + (directory / "SOURCE_REVISION").write_text(revision+"\n") + # Prove corresponding source can build without registry/network access. + subprocess.run(["cargo","check","--offline","--locked","-p","oxytype-tui","--target-dir",str(ROOT / "target/terminal-source-check")],cwd=directory,env=dict(os.environ,OXYTYPE_BUILD_REV=revision),check=True) + print(f"Corresponding source: {archive(directory)}") + + +def gate(): + matrix=json.loads((ROOT / "docs/rust-engine/terminal-verification.json").read_text()) + pending=[name for name,record in matrix["manualTerminals"].items() if record.get("status")!="pass" or not record.get("evidence")] + if pending: raise RuntimeError("Release manual terminal gate pending: "+", ".join(pending)) + + +def checksums(directory): + expected=[f"oxytype-v{version()}-{target}"+(".zip" if "windows" in target else ".tar.gz") for target in TARGETS]+[f"oxytype-v{version()}-source.tar.gz"] + rows=[] + for name in expected: + path=directory / name + digest=hashlib.sha256(path.read_bytes()).hexdigest() + assert path.with_name(name+".sha256").read_text().split()[0]==digest + rows.append(f"{digest} {name}\n") + (directory / "SHA256SUMS").write_text("".join(rows)) + + +if __name__=="__main__": + parser=argparse.ArgumentParser() + parser.add_argument("command",choices=["package","source","archive-smoke","release-gate","checksums"]) + parser.add_argument("--target") + parser.add_argument("--archive",type=pathlib.Path) + args=parser.parse_args() + if args.command=="package": package(args.target) + elif args.command=="source": source() + elif args.command=="release-gate": gate() + elif args.command=="checksums": checksums(args.archive.resolve()) + else: + if args.archive is None: parser.error("--archive required") + smoke(args.archive.resolve()) From ba66f8ebb5fd9f0e12a2624bd2f2129f97a860f4 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:29:47 +0530 Subject: [PATCH 12/48] ci(tui): build five native targets separately from website delivery --- .github/workflows/oxytype-ci.yml | 4 +- .github/workflows/terminal-release.yml | 144 ++++++++++++++++++ README.md | 11 ++ docs/rust-engine/terminal-release-notes.md | 11 ++ docs/rust-engine/terminal-release.md | 60 ++++++++ packages/release/__tests__/ci-routing.spec.js | 10 ++ 6 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/terminal-release.yml create mode 100644 docs/rust-engine/terminal-release-notes.md create mode 100644 docs/rust-engine/terminal-release.md diff --git a/.github/workflows/oxytype-ci.yml b/.github/workflows/oxytype-ci.yml index 75ee28b8f..f903bc03b 100644 --- a/.github/workflows/oxytype-ci.yml +++ b/.github/workflows/oxytype-ci.yml @@ -74,7 +74,7 @@ jobs: # Workspace packages bundled into the backend and frontend. runtime-packages: - 'packages/{challenges,contracts,funbox,schemas,typing-engine,typing-fixtures,util,web-shared}/**' - - 'crates/**' + - 'crates/{typing-engine,typing-engine-wasm}/**' - 'Cargo.{toml,lock}' - 'rust-toolchain.toml' - 'scripts/{engine,prepare-engine}.ts' @@ -84,7 +84,7 @@ jobs: - 'packages/**' - '!**/*.md' engine: - - 'crates/**' + - 'crates/{typing-engine,typing-engine-wasm}/**' - 'Cargo.{toml,lock}' - 'rust-toolchain.toml' - 'scripts/{engine,prepare-engine}.ts' diff --git a/.github/workflows/terminal-release.yml b/.github/workflows/terminal-release.yml new file mode 100644 index 000000000..784c1821d --- /dev/null +++ b/.github/workflows/terminal-release.yml @@ -0,0 +1,144 @@ +name: Terminal binaries + +on: + pull_request: + branches: [main, "stack/**", "t3/rust-engine-migration"] + paths: + - "crates/typing-engine/**" + - "crates/oxytype-tui/**" + - "Cargo.*" + - "rust-toolchain.toml" + - ".cargo/**" + - ".node-version" + - "package.json" + - "pnpm-workspace.yaml" + - "pnpm-lock.yaml" + - "scripts/terminal*" + - "docs/rust-engine/terminal*" + - "docs/rust-engine/third-party/**" + - ".github/workflows/terminal-release.yml" + push: + tags: ["terminal-v*"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: terminal-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +env: + OXYTYPE_BUILD_REV: ${{ github.sha }} + CARGO_TERM_COLOR: always + +jobs: + binaries: + name: native (${{ matrix.target }}) + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, target: x86_64-unknown-linux-gnu } + - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu } + - { os: macos-15-intel, target: x86_64-apple-darwin } + - { os: macos-15, target: aarch64-apple-darwin } + - { os: windows-2025, target: x86_64-pc-windows-msvc } + env: + MACOSX_DEPLOYMENT_TARGET: "15.0" + RUSTFLAGS: ${{ matrix.os == 'windows-2025' && '-C target-feature=+crt-static' || '' }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: .node-version + run-install: false + - uses: actions/cache@v5.0.3 + with: + path: | + ~/.cargo/registry + target + key: terminal-${{ matrix.target }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + - name: Native formatting, lint and shared fixture tests + shell: bash + run: | + vp run engine:fmt + vp exec cargo clippy --locked -p typing-engine -p oxytype-tui --all-targets -- -D warnings + vp exec cargo test --locked -p typing-engine -p oxytype-tui + - name: POSIX terminal lifecycle and persistence + if: runner.os != 'Windows' + run: vp run terminal:test:pty + - name: Build licensed versioned archive + run: vp run terminal:package -- --target ${{ matrix.target }} + - name: Verify extracted binary without runtime tools or checkout files + shell: bash + run: | + archive=$(vp exec find target/terminal-release -maxdepth 1 -type f \( -name '*-${{ matrix.target }}.tar.gz' -o -name '*-${{ matrix.target }}.zip' \)) + vp run terminal:archive-smoke -- --archive "$archive" + - name: Upload candidate archives and checksums + uses: actions/upload-artifact@v7.0.2 + with: + name: terminal-${{ matrix.target }} + path: | + target/terminal-release/*.tar.gz + target/terminal-release/*.zip + target/terminal-release/*.sha256 + retention-days: 14 + if-no-files-found: error + + source: + name: corresponding source + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: .node-version + run-install: false + - name: Vendor and prove an offline source build + run: vp run terminal:source + - uses: actions/upload-artifact@v7.0.2 + with: + name: terminal-source + path: | + target/terminal-release/*-source.tar.gz + target/terminal-release/*-source.tar.gz.sha256 + retention-days: 14 + if-no-files-found: error + + publish: + name: authorized tagged release + if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'voltcrash/oxytype' + needs: [binaries, source] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: .node-version + run-install: false + - name: Require recorded real terminal verification + run: vp run terminal:release-gate + - uses: actions/download-artifact@v8.0.2 + with: + pattern: terminal-* + path: release + merge-multiple: true + - name: Verify all platform/source checksums + run: vp exec python3 scripts/terminal_release.py checksums --archive release + - name: Publish the explicitly pushed terminal tag + env: + GH_TOKEN: ${{ github.token }} + run: vp exec gh release create "$GITHUB_REF_NAME" release/*.tar.gz release/*.zip release/SHA256SUMS --verify-tag --title "Oxytype terminal ${GITHUB_REF_NAME#terminal-v}" --notes-file docs/rust-engine/terminal-release-notes.md diff --git a/README.md b/README.md index f7bb79d4d..e04861c72 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,17 @@ behavior against immutable original references. `vp run engine:lint` and `vp run engine:fmt` check Rust. Reference provenance and measured budgets are in [migration progress](./docs/rust-engine/progress.md). +## Offline terminal + +The native [Ratatui app](./docs/rust-engine/terminal.md) uses the same engine +without Wasm. It includes English words/quotes, five typing modes, settings, +durable local history and JSON export. No account, sync or first-run download. +`vp run terminal:dev` runs it; `vp run terminal:test` and +`vp run terminal:test:pty` check native behavior and terminal lifecycle. +`vp run terminal:package` prepares a licensed binary archive. Installed users +need no development runtime. See [release verification/status](./docs/rust-engine/terminal-release.md) +for tested platforms and pending real-terminal checks; no release published yet. + ## Contributing See [CONTRIBUTING.md](./docs/CONTRIBUTING.md) and the [code of conduct](./docs/CODE_OF_CONDUCT.md). Report bugs in the [Oxytype repository](https://github.com/voltcrash/oxytype/issues). diff --git a/docs/rust-engine/terminal-release-notes.md b/docs/rust-engine/terminal-release-notes.md new file mode 100644 index 000000000..a2783a956 --- /dev/null +++ b/docs/rust-engine/terminal-release-notes.md @@ -0,0 +1,11 @@ +Offline Oxytype terminal release using the same Rust typing engine as the website. + +- Bundled English words/original quotes; time, words, quote, custom/file and zen. +- Native input/scoring, Unicode cell rendering, explicit keyboard limitations. +- Durable private local history, settings, native validity/PBs and JSON export. +- Replay logs opt in explicitly; no accounts, sync, telemetry or first-run download. + +Verify SHA256SUMS, extract the matching platform archive and run oxytype --help. +No Rust/Node/vp or source checkout required. LICENSE, attribution, third-party +notices and corresponding source accompany the release. See included README for +tested OS/terminal requirements, data paths and uninstall instructions. diff --git a/docs/rust-engine/terminal-release.md b/docs/rust-engine/terminal-release.md new file mode 100644 index 000000000..39259647a --- /dev/null +++ b/docs/rust-engine/terminal-release.md @@ -0,0 +1,60 @@ +# Native release procedure + +No terminal release has been published by this migration. Candidate archives and +their build/smoke evidence must pass review before a maintainer creates a release +tag. Website production authorization/workflow remains separate. + +`terminal-release.yml` builds/test/smokes five native candidates on PRs touching +native/core/build inputs. It does not run for unrelated website edits. Each job +uses pinned Rust 1.99.1, the lockfile, vp tasks and a native runner. Core fixtures, +Clippy and terminal tests run before release compilation. POSIX jobs also test +debug lifecycle and extracted release binaries in a real PTY. Windows runs native +tests and headless archive smoke; Windows Terminal manual input remains separate. + +| Candidate target | Runner / conservative requirements | Build/smoke status | +| --- | --- | --- | +| x86_64-unknown-linux-gnu | Ubuntu 24.04; GNU libc 2.39 baseline; not static musl | Pending CI | +| aarch64-unknown-linux-gnu | Ubuntu 24.04 ARM; GNU libc 2.39 baseline; not static musl | Local build/smoke passes on GNU libc 2.43; CI baseline pending | +| x86_64-apple-darwin | macOS 15 Intel; deployment target explicitly 15.0 | Pending CI | +| aarch64-apple-darwin | macOS 15 Apple Silicon; deployment target explicitly 15.0 | Pending CI | +| x86_64-pc-windows-msvc | Windows Server 2025 native runner; static CRT | Pending CI; Windows Terminal manual pending | + +Do not claim lower OS/libc versions or untested terminals supported. Runner labels +were checked against [GitHub's runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). +Current manual coverage lives in terminal.md and terminal-verification.json. +Only real emulator records can change those pending entries to pass. + +Build locally: `vp run terminal:package -- --target TARGET`; use the matching native +toolchain/runner. Archives go under `target/terminal-release/` with individual +`.sha256` files. `vp run terminal:archive-smoke -- --archive PATH` verifies the hash, +extracts, and runs the executable with an empty PATH/working directory. It checks +version/help/diagnostics, explicitly synthetic bundled-mode smoke, local storage +and export; supported POSIX hosts additionally test actual interactive input. +No saved result is fabricated by the synthetic smoke path. + +Each archive includes GPL LICENSE, bundled content attribution, third-party +notices/license text, install/data docs and exact source revision. Notices cover +the complete locked workspace dependency set (a conservative superset). The two +ts-rs packages omit their upstream LICENSE from crates.io packages: the exact +upstream revision/license/hash is pinned under third-party and mechanically +checked against cargo VCS metadata. Missing notices stop packaging. + +`vp run terminal:source` archives committed source, vendors locked dependencies, +adds SOURCE_REVISION, and proves `cargo check --offline --locked` from that tree +with a separate target directory. The resulting corresponding-source archive is +published beside binaries. Its `.cargo/config.toml` uses a relative vendor path. +Source builds use the pinned toolchain; set OXYTYPE_BUILD_REV from SOURCE_REVISION +to retain provenance. No source registry access is needed once tools are installed. + +After all five native jobs, source build and the real terminal matrix pass, review +the PR and final source commit. Follow maintainer authorization, update the Cargo +workspace version/release notes, then push the matching `terminal-vVERSION` tag. +Tag mismatch fails. The tag rebuilds all artifacts, and publication additionally +requires `vp run terminal:release-gate` to accept recorded manual evidence. +Publication alone gets contents:write; PR/build jobs have contents:read, checkout +credentials are not persisted. No account or deployment secrets are used. + +The publishing job verifies all six platform/source checksums and produces +SHA256SUMS before creating the GitHub Release. Development PRs and manual workflow +runs upload temporary CI candidates only. They do not publish releases or deploy +the website. Never bypass the manual gate by marking unperformed checks passed. diff --git a/packages/release/__tests__/ci-routing.spec.js b/packages/release/__tests__/ci-routing.spec.js index b64f3d97b..f63c91988 100644 --- a/packages/release/__tests__/ci-routing.spec.js +++ b/packages/release/__tests__/ci-routing.spec.js @@ -309,3 +309,13 @@ it("builds the production Worker once and shares it with SSR and browser shards" } expect(workflow.jobs["ci-ok"].needs).toContain("start-build"); }); + +// Native UI changes belong to the separate terminal matrix, not web deployments. +it.each([ + "crates/oxytype-tui/src/main.rs", + "crates/oxytype-tui/tests/storage.rs", + "scripts/terminal_release.py", + ".github/workflows/terminal-release.yml", +])("keeps terminal-only %s independent from web consumers", (file) => { + expect(selected([file])).toEqual(["code"]); +}); From 3bb6bd421ab6fed500b856f618a7ca1205322296 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:36:26 +0530 Subject: [PATCH 13/48] fix(tui): complete custom passages and preserve multiline input --- crates/oxytype-tui/src/content.rs | 4 ++ crates/oxytype-tui/src/engine.rs | 6 +-- crates/oxytype-tui/src/main.rs | 27 ++++++++++++- crates/oxytype-tui/src/options.rs | 31 ++++++++++++--- crates/oxytype-tui/src/setup.rs | 20 +++++++--- crates/oxytype-tui/tests/offline_content.rs | 42 +++++++++++++++++++++ crates/oxytype-tui/tests/setup.rs | 4 +- docs/rust-engine/terminal-content.md | 9 +++-- 8 files changed, 124 insertions(+), 19 deletions(-) diff --git a/crates/oxytype-tui/src/content.rs b/crates/oxytype-tui/src/content.rs index ffb8ed1a0..247370ec2 100644 --- a/crates/oxytype-tui/src/content.rs +++ b/crates/oxytype-tui/src/content.rs @@ -9,6 +9,10 @@ use typing_engine::{ }; pub const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; +/// Decode text-file/clipboard line endings as logical terminal Enter characters. +pub fn line_endings(value: &str) -> String { + value.replace("\r\n", "\n").replace('\r', "\n") +} pub fn read_utf8(path: &Path) -> Result { let mut bytes = vec![]; File::open(path)? diff --git a/crates/oxytype-tui/src/engine.rs b/crates/oxytype-tui/src/engine.rs index c72655f8a..b12b6be4d 100644 --- a/crates/oxytype-tui/src/engine.rs +++ b/crates/oxytype-tui/src/engine.rs @@ -220,11 +220,11 @@ pub fn smoke() -> Result<()> { let modes = ["time", "words", "quote", "custom", "zen"]; for mode in modes { let custom = if mode == "custom" { - Some(crate::options::custom_text( + Some(crate::options::custom_text_auto( "alpha beta", "repeat", - "section", - 1.0, + "word", + None, )?) } else { None diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index 8e9498977..abbbdb273 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -169,6 +169,7 @@ fn run() -> Result<()> { // clear queries cursor position, which basic terminals need not report. Event::Resize(_, _) => {} Event::Paste(text) if screen == Screen::Editor => { + let text = oxytype_tui::content::line_endings(&text); match oxytype_tui::content::safe_text(&text) { Ok(()) if setup.text.len() + text.len() <= 8 * 1024 * 1024 => { setup.text.push_str(&text) @@ -186,7 +187,11 @@ fn run() -> Result<()> { break; } if control && key.code == KeyCode::Char('z') { - guard.suspend.store(true, Ordering::SeqCst); + if cfg!(unix) { + guard.suspend.store(true, Ordering::SeqCst); + } else { + notice = "Suspend is unavailable on this platform.".into(); + } continue; } match key.code { @@ -199,8 +204,14 @@ fn run() -> Result<()> { KeyCode::F(4) if !host.state().active => screen = Screen::Editor, KeyCode::F(6) if screen == Screen::Editor => setup.cycle_order(), KeyCode::F(7) if screen == Screen::Editor => setup.cycle_limit(), - KeyCode::F(9) if screen == Screen::Editor => setup.length += 1.0, + KeyCode::F(9) if screen == Screen::Editor => setup.increase_length(), KeyCode::F(8) if screen == Screen::Editor => { + if !local.pending.is_empty() { + notice = + "Save the pending result with F9 before starting another test." + .into(); + continue; + } setup.config.mode = "custom".into(); match setup.build(&content, host.seed.wrapping_add(1)) { Ok(next) => { @@ -304,12 +315,24 @@ fn run() -> Result<()> { } KeyCode::Esc if screen != Screen::Test => screen = Screen::Test, KeyCode::F(5) | KeyCode::F(6) => { + if !local.pending.is_empty() { + notice = + "Save the pending result with F9 before starting another test." + .into(); + continue; + } host.restart(key.code == KeyCode::F(6), now, date())?; local.restarted(); screen = Screen::Test; notice.clear(); } KeyCode::Enter if screen == Screen::Settings => { + if !local.pending.is_empty() { + notice = + "Save the pending result with F9 before starting another test." + .into(); + continue; + } if setup.config.mode == "custom" && setup.text.trim().is_empty() { screen = Screen::Editor; } else { diff --git a/crates/oxytype-tui/src/options.rs b/crates/oxytype-tui/src/options.rs index 01cfd5b05..1620d293a 100644 --- a/crates/oxytype-tui/src/options.rs +++ b/crates/oxytype-tui/src/options.rs @@ -39,10 +39,10 @@ pub struct Cli { pub file: Option, #[arg(long, default_value = "repeat")] pub custom_order: String, - #[arg(long, default_value = "section")] + #[arg(long, default_value = "word")] pub custom_limit: String, - #[arg(long, default_value_t = 1.0)] - pub custom_length: f64, + #[arg(long)] + pub custom_length: Option, /// Comma-separated quote groups: 0 short, 1 medium, 2 long, 3 very long. #[arg(long, value_delimiter = ',')] pub quote_length: Option>, @@ -135,7 +135,7 @@ impl Cli { } let custom = text .map(|text| { - custom_text( + custom_text_auto( &text, &self.custom_order, &self.custom_limit, @@ -148,7 +148,8 @@ impl Cli { } } pub fn custom_text(text: &str, order: &str, limit: &str, value: f64) -> Result { - safe_text(text)?; + let text = crate::content::line_endings(text); + safe_text(&text)?; if text.trim().is_empty() { return Err("Custom text is empty".into()); } @@ -157,6 +158,9 @@ pub fn custom_text(text: &str, order: &str, limit: &str, value: f64) -> Result Result, +) -> Result { + let mut custom = custom_text(text, order, limit, 0.0)?; + custom.limit.value = value.unwrap_or_else(|| { + if limit == "time" { + Config::default().time + } else { + custom.text.len() as f64 + } + }); + typing_engine::validation::custom(&custom)?; + Ok(custom) +} pub fn validate( config: &Config, content: &OfflineContent, diff --git a/crates/oxytype-tui/src/setup.rs b/crates/oxytype-tui/src/setup.rs index e30e5cfbd..38f6ac6d1 100644 --- a/crates/oxytype-tui/src/setup.rs +++ b/crates/oxytype-tui/src/setup.rs @@ -9,7 +9,7 @@ pub struct Setup { pub text: String, pub order: String, pub limit: String, - pub length: f64, + pub length: Option, pub theme: String, pub reduced_animation: bool, pub record_replay: bool, @@ -33,8 +33,8 @@ impl Setup { }) .unwrap_or_default(), order: custom.map_or("repeat", |c| c.mode.as_str()).into(), - limit: custom.map_or("section", |c| c.limit.mode.as_str()).into(), - length: custom.map_or(1.0, |c| c.limit.value), + limit: custom.map_or("word", |c| c.limit.mode.as_str()).into(), + length: custom.map(|c| c.limit.value), theme: "terminal".into(), reduced_animation: true, record_replay: false, @@ -44,7 +44,7 @@ impl Setup { if self.config.mode != "custom" { return Ok(None); } - Ok(Some(options::custom_text( + Ok(Some(options::custom_text_auto( &self.text, &self.order, &self.limit, @@ -144,11 +144,21 @@ impl Setup { Line::from("Private custom text · type or paste · F8 apply · Esc cancel"), Line::from(format!( "F6 order: {} · F7 limit: {} · F9 length: {}", - self.order, self.limit, self.length + self.order, + self.limit, + self.length + .map(|v| v.to_string()) + .unwrap_or_else(|| "auto".into()) )), Line::from(format!("{}▌", self.text)), ] } + pub fn increase_length(&mut self) { + let value = options::custom_text_auto(&self.text, &self.order, &self.limit, self.length) + .map(|c| c.limit.value) + .unwrap_or(0.0); + self.length = Some(value + 1.0); + } pub fn cycle_order(&mut self) { cycle(&mut self.order, &["repeat", "random", "shuffle"], 1); } diff --git a/crates/oxytype-tui/tests/offline_content.rs b/crates/oxytype-tui/tests/offline_content.rs index e10592805..8b5dad368 100644 --- a/crates/oxytype-tui/tests/offline_content.rs +++ b/crates/oxytype-tui/tests/offline_content.rs @@ -154,3 +154,45 @@ fn local_pack_validation_is_lossless_utf16_and_bounded() { } assert!(OfflineContent::parse(&" ".repeat(8 * 1024 * 1024 + 1)).is_err()); } + +#[test] +fn custom_crlf_newline_tab_and_non_bmp_text_remain_typable() { + let custom = + oxytype_tui::options::custom_text_auto("one\r\n\t界😀", "repeat", "word", None).unwrap(); + let mut host = EngineHost::offline( + Config { + mode: "custom".into(), + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + Some(custom), + 42, + ) + .unwrap(); + assert_eq!(host.words[0], typing_engine::Text::from("one\n")); + host.insert("one", 100.0, 1000.0).unwrap(); + host.insert("\n", 1100.0, 2000.0).unwrap(); + assert_eq!(host.state().word_index, 1); + host.insert("\t界😀", 2100.0, 3000.0).unwrap(); + assert!(host.state().ended); + assert_eq!(host.result.unwrap().acc, 100.0); +} + +#[test] +fn default_custom_uses_the_whole_passage_and_explicit_zero_stays_infinite() { + let (config, content, custom) = Cli::parse_from(["oxytype", "--text", "one two three"]) + .prepare(Config::default()) + .unwrap(); + assert_eq!(custom.as_ref().unwrap().limit.value, 3.0); + assert_eq!( + EngineHost::offline(config, content, custom, 42) + .unwrap() + .words + .len(), + 3 + ); + let (_, _, custom) = Cli::parse_from(["oxytype", "--text", "one two", "--custom-length", "0"]) + .prepare(Config::default()) + .unwrap(); + assert_eq!(custom.unwrap().limit.value, 0.0); +} diff --git a/crates/oxytype-tui/tests/setup.rs b/crates/oxytype-tui/tests/setup.rs index 07d6e22b5..b2fb80c9d 100644 --- a/crates/oxytype-tui/tests/setup.rs +++ b/crates/oxytype-tui/tests/setup.rs @@ -18,7 +18,9 @@ fn settings_edit_drafts_and_apply_the_same_native_configuration() { setup.text = "one two".into(); setup.cycle_order(); setup.cycle_limit(); - setup.length = 7.0; + assert_eq!(setup.limit, "time"); + setup.limit = "word".into(); + setup.length = Some(7.0); assert_eq!(setup.build(&content, 42).unwrap().words.len(), 7); setup.config.funbox = vec!["tts".into()]; assert!(setup.build(&content, 42).is_err()); diff --git a/docs/rust-engine/terminal-content.md b/docs/rust-engine/terminal-content.md index 337c15c4b..a052e48a4 100644 --- a/docs/rust-engine/terminal-content.md +++ b/docs/rust-engine/terminal-content.md @@ -15,9 +15,12 @@ settings are rejected using native metadata. Custom text: `--text 'one two three'` or `--file practice.txt` (UTF-8, ≤8 MiB). This implies custom mode unless another mode is explicitly supplied (an error). `--custom-order repeat|random|shuffle`, `--custom-limit word|time|section`, and -`--custom-length N` feed the core's custom content contract. Zero means infinite -where defined by core. Literal spaces separate custom sections; Tab/LF remain -in sections. Native rules retain all normalization and scoring responsibility. +`--custom-length N` feed the core's custom content contract. Default order is repeat; +default word limit covers all supplied words. An omitted time length uses core's +default time. Explicit zero retains native infinite behavior. Zero means infinite +where defined by core. Literal spaces separate supplied entries. CRLF/CR decode as LF; LF terminates +an entry so Enter commits it. Tab and Unicode text retain their literal units. +No NFC rewrite is imposed by the terminal adapter. Native rules retain all normalization and scoring responsibility. Extend offline content with `--pack pack.json`. Version 1: From a0bc1dab7821cd91d4a00f8994648b0dfe15d1d6 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:36:32 +0530 Subject: [PATCH 14/48] fix(tui): handle Windows suspend limits and drain macOS PTY output --- crates/oxytype-tui/src/terminal.rs | 2 ++ scripts/terminal_pty.py | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/crates/oxytype-tui/src/terminal.rs b/crates/oxytype-tui/src/terminal.rs index 2791e996f..7ac61dbfd 100644 --- a/crates/oxytype-tui/src/terminal.rs +++ b/crates/oxytype-tui/src/terminal.rs @@ -93,6 +93,8 @@ impl TerminalGuard { Ok((guard, terminal)) } pub fn resume_if_requested(&self, terminal: &mut DefaultTerminal) -> Result { + #[cfg(not(unix))] + let _ = terminal; if !self.suspend.swap(false, Ordering::SeqCst) { return Ok(false); } diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 8cbcc5d90..8150a3a16 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -112,6 +112,14 @@ def screen_text(self): def send(self, text): os.write(self.master, text) + def wait_exit(self, timeout=5): + # A real emulator continuously consumes output. macOS PTYs have a small + # output buffer; waiting without reading can block the app before quit. + deadline = time.monotonic() + timeout + while self.process.poll() is None and time.monotonic() < deadline: + self.read(0.02) + return self.process.wait(timeout=0.1) + def restored(self): current = termios.tcgetattr(self.slave) mask = termios.ICANON | termios.ECHO @@ -125,7 +133,7 @@ def close(self): if self.process.poll() is None: os.kill(self.process.pid, signal.SIGCONT) self.process.kill() - self.process.wait(timeout=3) + self.wait_exit(timeout=3) os.close(self.master) os.close(self.slave) self.profile.cleanup() @@ -164,7 +172,7 @@ def run(binary, release=False): os.kill(child.process.pid, signal.SIGWINCH) child.until(b"Oxytype") child.send(b"\x11") - assert child.process.wait(timeout=5) == 0 + assert child.wait_exit() == 0 child.restored() finally: child.close() @@ -181,7 +189,7 @@ def run(binary, release=False): child.send(b"\x1b[19~") child.until(b"F5 new test") child.send(b"\x11") - assert child.process.wait(timeout=5) == 0 + assert child.wait_exit() == 0 child.restored() finally: child.close() @@ -202,7 +210,7 @@ def run(binary, release=False): child.send(b"two") child.until(b"F5 new test") child.send(b"\x11") - assert child.process.wait(timeout=5) == 0 + assert child.wait_exit() == 0 child.restored() finally: child.close() @@ -221,7 +229,7 @@ def run(binary, release=False): child.send(b"\x1b[C") child.until(b"step 1") child.send(b"\x11") - assert child.process.wait(timeout=5) == 0 + assert child.wait_exit() == 0 child.restored() finally: child.close() @@ -236,7 +244,7 @@ def run(binary, release=False): child.until(b"Keyboard: enhanced") child.until(b"release observed: true") child.send(b"\x11") - assert child.process.wait(timeout=5) == 0 + assert child.wait_exit() == 0 child.restored() assert b"\x1b[<1u" in child.output, "enhanced flags not popped" finally: @@ -246,7 +254,7 @@ def run(binary, release=False): child = Child(binary, failure if failure in ("error", "panic") else None) try: if failure in ("error", "panic"): - assert child.process.wait(timeout=5) != 0 + assert child.wait_exit() != 0 else: child.until(b"Oxytype") if failure == "signal": @@ -265,7 +273,7 @@ def run(binary, release=False): os.kill(child.process.pid, signal.SIGCONT) child.until(b"Oxytype") child.send(b"\x11") - assert child.process.wait(timeout=5) == 0 + assert child.wait_exit() == 0 child.restored() finally: child.close() From 8ec5bad5e801f9ee87ccced165d0c1cc301aa080 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:40:40 +0530 Subject: [PATCH 15/48] fix(tui): project stopped-input feedback without losing UTF-16 units --- crates/oxytype-tui/src/engine.rs | 4 +++ crates/oxytype-tui/src/input.rs | 7 +++-- crates/oxytype-tui/src/main.rs | 2 +- crates/oxytype-tui/tests/input_render.rs | 35 ++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/oxytype-tui/src/engine.rs b/crates/oxytype-tui/src/engine.rs index b12b6be4d..e340d48ec 100644 --- a/crates/oxytype-tui/src/engine.rs +++ b/crates/oxytype-tui/src/engine.rs @@ -12,6 +12,7 @@ pub struct EngineHost { pub config: Config, pub words: Vec, pub inputs: Vec, + pub visual_input: Option, pub result: Option, pub result_error: Option, pub seed: u32, @@ -60,6 +61,7 @@ impl EngineHost { session: None, words: vec![], inputs: vec![], + visual_input: None, result: None, result_error: None, }; @@ -127,6 +129,7 @@ impl EngineHost { terminal_text(word)?; } self.inputs.clear(); + self.visual_input = None; self.result = None; self.result_error = None; self.session = Some(session); @@ -138,6 +141,7 @@ impl EngineHost { .as_mut() .expect("Prepared terminal session") .apply(command)?; + self.visual_input = update.visual_input_override; for change in update.words { terminal_text(&change.word)?; self.words diff --git a/crates/oxytype-tui/src/input.rs b/crates/oxytype-tui/src/input.rs index dff6dd6fd..03a2d0b33 100644 --- a/crates/oxytype-tui/src/input.rs +++ b/crates/oxytype-tui/src/input.rs @@ -29,9 +29,12 @@ pub fn pop_grapheme(value: &mut String) { /// Mechanical text-control edit, expressed losslessly in UTF-16 for the core. /// Core still decides deletion eligibility, regression and event accounting. pub fn delete_observation(input: &Text) -> Result { - let mut value = String::from_utf16(&input.0)?; + // Legacy per-unit error policies can leave a partial surrogate. The lossy + // view preserves UTF-16 length (one replacement per isolated unit); use its + // grapheme boundary only, then slice original units without round-tripping. + let mut value = input.display_lossy(); pop_grapheme(&mut value); - Ok(Text::from(value.as_str())) + Ok(Text(input.0[..value.encode_utf16().count()].to_vec())) } pub fn apply(host: &mut EngineHost, key: KeyEvent, now: f64, date: f64) -> Result<()> { if key.kind == KeyEventKind::Release || host.state().ended { diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index abbbdb273..bf02b525b 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -510,7 +510,7 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { live.wpm, live.acc, live.seconds ))); } - let input = state.input_value; + let input = host.visual_input.clone().unwrap_or(state.input_value); let mut target = typing_engine::Text::default(); if host.config.mode != "zen" { for word in host.words.iter().skip(state.word_index).take(35) { diff --git a/crates/oxytype-tui/tests/input_render.rs b/crates/oxytype-tui/tests/input_render.rs index 4e3e34e02..84afba550 100644 --- a/crates/oxytype-tui/tests/input_render.rs +++ b/crates/oxytype-tui/tests/input_render.rs @@ -119,3 +119,38 @@ fn extra_emoji_and_error_markers_never_split_surrogates() { let content = OfflineContent::parse(r#"{"formatVersion":1,"license":"CC0","attribution":"test","language":{"name":"english","words":["😀","abc"]}}"#).unwrap(); assert!(EngineHost::offline(config, content, None, 42).is_err()); } + +#[test] +fn stopped_letter_projects_native_visual_feedback_without_mutating_input() { + let mut host = EngineHost::offline( + Config { + stop_on_error: "letter".into(), + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + None, + 42, + ) + .unwrap(); + host.insert("☃", 100.0, 1000.0).unwrap(); + assert!(host.state().input_value.is_empty()); + assert_eq!(host.visual_input, Some(Text::from("☃"))); + assert!( + text_view( + &host.words[0], + host.visual_input.as_ref().unwrap(), + "english", + 20, + 4 + ) + .errors + ); +} + +#[test] +fn deleting_partial_surrogate_feedback_preserves_original_utf16_prefix() { + let original = Text(vec![0xd83d, 97, 0x301]); + let after = input::delete_observation(&original).unwrap(); + assert_eq!(after, Text(vec![0xd83d])); + assert!(input::delete_observation(&after).unwrap().is_empty()); +} From fdcbc64c2f8daf04dc0ffa22f4b2d5373132dc58 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:40:46 +0530 Subject: [PATCH 16/48] test(tui): prove crash-safe history and label unknown stored formats --- crates/oxytype-tui/src/history.rs | 7 ++++- crates/oxytype-tui/tests/storage.rs | 48 +++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/oxytype-tui/src/history.rs b/crates/oxytype-tui/src/history.rs index a346f603c..f3f0f7efa 100644 --- a/crates/oxytype-tui/src/history.rs +++ b/crates/oxytype-tui/src/history.rs @@ -86,6 +86,11 @@ impl History { let skip = self.selected.saturating_sub(visible - 1); for (index, row) in self.rows.iter().enumerate().skip(skip).take(visible) { let summary = &row["summary"]; + let validity = if row["formatVersion"] == 1 { + row["invalidReason"].as_str().unwrap_or("valid") + } else { + "unknown format; details/export" + }; let best = row["formatVersion"] == 1 && row["personalBestEligible"] == true && row["personalBestGroup"] @@ -99,7 +104,7 @@ impl History { row["config"]["mode"].as_str().unwrap_or("unknown"), summary["wpm"], summary["acc"], - row["invalidReason"].as_str().unwrap_or("valid"), + validity, if best { " · PB" } else { "" } ))); } diff --git a/crates/oxytype-tui/tests/storage.rs b/crates/oxytype-tui/tests/storage.rs index 79fd03aaf..fd4388843 100644 --- a/crates/oxytype-tui/tests/storage.rs +++ b/crates/oxytype-tui/tests/storage.rs @@ -168,3 +168,51 @@ fn malformed_future_and_interrupted_storage_preserve_prior_data() { ); assert_eq!(std::fs::read(path).unwrap(), b"not a database"); } + +#[test] +#[ignore = "Helper launched and terminated by abrupt_process_interruption_keeps_committed_history"] +fn interrupted_writer_process() { + let path = std::env::var_os("OXYTYPE_INTERRUPTION_DB").expect("Test database path"); + let ready = std::env::var_os("OXYTYPE_INTERRUPTION_READY").expect("Test readiness path"); + let db = rusqlite::Connection::open(path).unwrap(); + db.execute_batch( + "BEGIN IMMEDIATE; INSERT INTO results VALUES('interrupted-process',1,'words','{}');", + ) + .unwrap(); + std::fs::write(ready, b"transaction open").unwrap(); + loop { + std::thread::park(); + } +} + +#[test] +fn abrupt_process_interruption_keeps_committed_history() { + let temp = tempfile::tempdir().unwrap(); + let paths = paths(temp.path()); + let mut store = Store::open(&paths).unwrap(); + let record = storage::record(&finished(), &Capabilities::default(), false, 10000.0).unwrap(); + store.save(&record).unwrap(); + drop(store); + let ready = temp.path().join("writer-ready"); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--ignored", "--exact", "interrupted_writer_process"]) + .env( + "OXYTYPE_INTERRUPTION_DB", + paths.data.join("history.sqlite3"), + ) + .env("OXYTYPE_INTERRUPTION_READY", &ready) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !ready.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let initialized = ready.exists(); + child.kill().unwrap(); + child.wait().unwrap(); + assert!(initialized, "Writer did not begin transaction"); + assert_eq!( + Store::open(&paths).unwrap().history(None, 100).unwrap(), + vec![record] + ); +} From 93393d619a4fde9aa1918daa2c8597718cd6b4ae Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:43:00 +0530 Subject: [PATCH 17/48] test(tui): retain PTY sessions while verifying macOS cleanup --- scripts/terminal_pty.py | 64 ++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 8150a3a16..2e645cced 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -31,7 +31,8 @@ def __init__(self, binary, failure=None, args=(), enhanced=False): dimensions(self.slave) self.original = termios.tcgetattr(self.slave) self.output = bytearray() - env = dict(os.environ, TERM="xterm-256color") + self.status_path = pathlib.Path(self.profile.name) / "exit-status" + env = dict(os.environ, TERM="xterm-256color", OXYTYPE_PTY_STATUS=str(self.status_path)) if failure: env["OXYTYPE_TERMINAL_TEST_FAILURE"] = failure @@ -39,7 +40,7 @@ def controlling_terminal(): os.setsid() fcntl.ioctl(self.slave, termios.TIOCSCTTY, 0) - self.process = subprocess.Popen([binary, "--config-dir", self.profile.name + "/config", "--data-dir", self.profile.name + "/data", "--keyboard", "auto" if enhanced else "basic", *args], stdin=self.slave, stdout=self.slave, + self.process = subprocess.Popen([sys.executable, str(pathlib.Path(__file__).resolve()), "--keeper", binary, "--config-dir", self.profile.name + "/config", "--data-dir", self.profile.name + "/data", "--keyboard", "auto" if enhanced else "basic", *args], stdin=self.slave, stdout=self.slave, stderr=self.slave, env=env, preexec_fn=controlling_terminal) @@ -55,13 +56,14 @@ def read(self, seconds=0.05): if error.errno != errno.EIO: raise - def until(self, text, seconds=5): + def until(self, text, seconds=5, fresh=False): deadline = time.monotonic() + seconds + start = len(self.output) while time.monotonic() < deadline: self.read() - if text.decode("utf-8") in self.screen_text(): + if (not fresh or len(self.output) > start) and text.decode("utf-8") in self.screen_text(): return - if self.process.poll() is not None: + if self.status_path.exists() or self.process.poll() is not None: break raise AssertionError(f"Missing {text!r}; process={self.process.poll()}; output={bytes(self.output[-2000:])!r}") @@ -116,9 +118,13 @@ def wait_exit(self, timeout=5): # A real emulator continuously consumes output. macOS PTYs have a small # output buffer; waiting without reading can block the app before quit. deadline = time.monotonic() + timeout - while self.process.poll() is None and time.monotonic() < deadline: + while not self.status_path.exists() and time.monotonic() < deadline: self.read(0.02) - return self.process.wait(timeout=0.1) + if self.process.poll() is not None: + raise AssertionError("PTY session keeper exited before app status") + if not self.status_path.exists(): + raise subprocess.TimeoutExpired(self.process.args, timeout) + return int(self.status_path.read_text()) def restored(self): current = termios.tcgetattr(self.slave) @@ -130,13 +136,15 @@ def restored(self): assert b"\x1b[?2004l" in self.output, "bracketed paste not disabled" def close(self): - if self.process.poll() is None: - os.kill(self.process.pid, signal.SIGCONT) - self.process.kill() - self.wait_exit(timeout=3) - os.close(self.master) - os.close(self.slave) - self.profile.cleanup() + try: + if self.process.poll() is None: + os.killpg(self.process.pid, signal.SIGCONT) + os.killpg(self.process.pid, signal.SIGKILL) + self.process.wait(timeout=3) + finally: + os.close(self.master) + os.close(self.slave) + self.profile.cleanup() def run(binary, release=False): @@ -270,8 +278,8 @@ def run(binary, release=False): else: raise AssertionError("Did not suspend") child.restored() - os.kill(child.process.pid, signal.SIGCONT) - child.until(b"Oxytype") + os.killpg(child.process.pid, signal.SIGCONT) + child.until(b"Oxytype", fresh=True) child.send(b"\x11") assert child.wait_exit() == 0 child.restored() @@ -280,5 +288,27 @@ def run(binary, release=False): print("PTY: five modes, settings/editor, history/export/replay, basic/enhanced protocol, typing/finish/restart/resize/quit, SIGTERM, suspend/resume pass" + ("; debug error/panic cleanup pass" if not release else "")) +def keep_session(binary_args): + # Like a real shell, remain the session leader after the app exits. Darwin + # revokes a controlling PTY when its session leader dies, making tcgetattr + # impossible; retaining the session lets us verify actual cleanup unchanged. + child = subprocess.Popen(binary_args) + def forward(signum, _frame): + if child.poll() is None: + child.send_signal(signum) + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGTSTP): + signal.signal(signum, forward) + status = child.wait() + status_path=pathlib.Path(os.environ["OXYTYPE_PTY_STATUS"]) + temporary=status_path.with_suffix(".tmp") + temporary.write_text(str(status)) + temporary.replace(status_path) + while True: + signal.pause() + + if __name__ == "__main__": - run(str(pathlib.Path(sys.argv[1]).resolve()), "--release" in sys.argv) + if sys.argv[1] == "--keeper": + keep_session(sys.argv[2:]) + else: + run(str(pathlib.Path(sys.argv[1]).resolve()), "--release" in sys.argv) From 2ea0cd4a141d50225ae4b6a40a6c37125c05f3f6 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:43:27 +0530 Subject: [PATCH 18/48] docs(tui): record fresh builds archives and platform findings --- docs/rust-engine/progress.md | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index f86d1888a..500e65f61 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -617,3 +617,42 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro mode requires new --text/--file (or choose --mode words and F4 editor). Local packs must be explicitly supplied again. No silent content substitution. - Platform/manual terminal gate remains unperformed; continue packaging. + +## Phase 3.5 verification checkpoint (platform gate still active) + +- Source `ba66f8ebb`: isolated fresh checkout `/tmp/oxytype-stage3-clean`, empty + target/generated/dist, frozen install, native tests, actual Cloudflare build, + Wrangler dry-run and all nine workspace typechecks pass. Whole-repo format/Knip + pass. Full website/native/Wasm CI `40064612700` succeeds at this source. +- Wasm remains 765,852 bytes, exact SHA-256 matches current/fresh checkout. Shared + core/browser runtime is unchanged by terminal implementation; original web + performance budgets and references remain untouched. +- Native release tooling produces GPL/attribution/third-party-notice archives and + checksums; Linux ARM64 local archive 1.6 MiB passes installed binary checks with + empty PATH/cwd, bundled synthetic modes, storage/export and release PTY. Host + GNU libc 2.43; do not infer lower compatibility from that local check. +- `vp run terminal:source`: 69 MiB corresponding-source archive, locked vendored + dependencies, relative Cargo vendor config and separate offline native compile + pass. Pinned upstream ts-rs LICENSE supplies text omitted by its crates.io + packages; revision/license/hash mechanically checked. Missing notices fail. +- Five native jobs plus source job added separately from website CI. Narrowed + Rust runtime paths; 64 routing checks pass, including terminal-only independence. + PR #186 draft: https://github.com/voltcrash/oxytype/pull/186, stacked on #185. +- Initial platform CI `40064530451`: both Linux archives/source pass. Windows + found an unused Unix-only suspend parameter; fixed. macOS PTY output required + continuous draining; then Darwin revoked the PTY after session-leader exit. + Harness now retains a shell-like session leader to inspect actual cleanup; + Linux revised lifecycle checks pass. macOS rerun is required, not claimed. +- Additional terminal review fixed full-passage custom defaults (previous section-1 + default stopped after first word), CRLF/LF entry preparation, native stopped + letter visual feedback and lossless deletion of partial UTF-16 feedback. Tests + require all multiline/emoji text, not only completion. No shared fixture changed. +- Nineteen terminal integration tests pass (plus one ignored subprocess helper + explicitly launched/killed by its parent test). Actual process termination during + a write preserves committed history. Clippy passes. Pending writes block new + sessions until retry; unknown stored formats are labelled without validation + claims. Latest native platform rerun follows the PTY keeper fix. +- Real Linux enhanced/macOS/Windows Terminal manual checks remain unavailable. + Requested access/test records asynchronously; no answer yet. Publish workflow + refuses the pending manual matrix. No release tag/publication or production + deployment performed. Continue all available checks before stopping. From 8ac5964a3e2c592351c08ab48d2ec139b3b3877e Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:47:47 +0530 Subject: [PATCH 19/48] fix(tui): keep current-word input and editor caret inside their viewports --- crates/oxytype-tui/src/history.rs | 2 + crates/oxytype-tui/src/main.rs | 72 ++++++++++++++++++++++++++----- crates/oxytype-tui/src/render.rs | 13 ++++++ crates/oxytype-tui/src/setup.rs | 1 - scripts/terminal_pty.py | 9 ++++ 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/crates/oxytype-tui/src/history.rs b/crates/oxytype-tui/src/history.rs index f3f0f7efa..966b7dfd8 100644 --- a/crates/oxytype-tui/src/history.rs +++ b/crates/oxytype-tui/src/history.rs @@ -45,6 +45,8 @@ impl History { self.refresh(store)?; } self.selected = 0; + self.details = false; + self.scroll = 0; Ok(()) } pub fn select(&mut self, direction: i32) { diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index bf02b525b..ed8caf51c 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -462,7 +462,34 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { .chain(lines.into_iter().skip(skip + 1).take(visible)) .collect() } - Screen::Editor => setup.editor_lines(), + Screen::Editor => { + let mut lines = setup.editor_lines(); + for line in &mut lines { + *line = Line::from(oxytype_tui::render::clip_cells( + &line.to_string(), + rows[1].width.saturating_sub(2) as usize, + )); + } + let input = typing_engine::Text::from(setup.text.as_str()); + let mut view = oxytype_tui::render::text_view( + &typing_engine::Text::default(), + &input, + &host.config.language, + rows[1].width.saturating_sub(2), + rows[1].height.saturating_sub(4), + ); + frame.set_cursor_position(( + rows[1].x + 1 + view.caret.0, + rows[1].y + 1 + lines.len() as u16 + view.caret.1, + )); + for line in &mut view.lines { + for span in &mut line.spans { + span.style = ratatui::style::Style::default(); + } + } + lines.extend(view.lines); + lines + } Screen::Replay => local.replay.as_ref().map(|r| r.lines()).unwrap_or_default(), Screen::History => local .history @@ -513,29 +540,54 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { let input = host.visual_input.clone().unwrap_or(state.input_value); let mut target = typing_engine::Text::default(); if host.config.mode != "zen" { - for word in host.words.iter().skip(state.word_index).take(35) { - target.0.extend(&word.0); - } + target = host + .words + .get(state.word_index) + .cloned() + .unwrap_or_default(); } let view = oxytype_tui::render::text_view( &target, &input, &host.config.language, rows[1].width.saturating_sub(2), - rows[1].height.saturating_sub(5), + rows[1].height.saturating_sub(6), ); if view.errors { lines.push(Line::from("! Incorrect text is underlined")); } let caret_y = rows[1].y + 1 + lines.len() as u16 + view.caret.1; frame.set_cursor_position((rows[1].x + 1 + view.caret.0, caret_y)); - lines.extend(view.lines); + lines.extend(view.lines.into_iter().map(|line| { + line.style( + ratatui::style::Style::new().add_modifier(ratatui::style::Modifier::BOLD), + ) + })); + if host.config.mode != "zen" { + let next = host + .words + .iter() + .skip(state.word_index + 1) + .take(5) + .map(|w| w.display_lossy().replace('\n', "↵").replace('\t', "→")) + .collect::(); + lines.push(Line::from(format!( + "Next: {}", + oxytype_tui::render::clip_cells( + &next, + rows[1].width.saturating_sub(8) as usize + ) + ))); + } lines.push(Line::from(format!( "Input: {}", - input - .display_lossy() - .replace('\t', "→ ") - .replace('\n', "↵") + oxytype_tui::render::clip_cells( + &input + .display_lossy() + .replace('\t', "→ ") + .replace('\n', "↵"), + rows[1].width.saturating_sub(9) as usize + ) ))); lines } diff --git a/crates/oxytype-tui/src/render.rs b/crates/oxytype-tui/src/render.rs index ebaca0c50..361e07005 100644 --- a/crates/oxytype-tui/src/render.rs +++ b/crates/oxytype-tui/src/render.rs @@ -12,6 +12,19 @@ pub struct TextView { pub caret: (u16, u16), pub errors: bool, } +pub fn clip_cells(value: &str, width: usize) -> String { + let mut result = String::new(); + let mut used = 0; + for grapheme in value.graphemes(true) { + let cells = UnicodeWidthStr::width(grapheme); + if used + cells > width { + break; + } + result.push_str(grapheme); + used += cells; + } + result +} /// Expand controls visibly and wrap whole graphemes without splitting wide cells. pub fn text_view(target: &Text, input: &Text, language: &str, width: u16, height: u16) -> TextView { let width = width.max(2) as usize; diff --git a/crates/oxytype-tui/src/setup.rs b/crates/oxytype-tui/src/setup.rs index 38f6ac6d1..fcf57f070 100644 --- a/crates/oxytype-tui/src/setup.rs +++ b/crates/oxytype-tui/src/setup.rs @@ -150,7 +150,6 @@ impl Setup { .map(|v| v.to_string()) .unwrap_or_else(|| "auto".into()) )), - Line::from(format!("{}▌", self.text)), ] } pub fn increase_length(&mut self) { diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 2e645cced..54c5164c3 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -176,9 +176,12 @@ def run(binary, release=False): # Ratatui skips blank cells with cursor moves; words need not be emitted # as one contiguous byte string in a real incremental terminal frame. child.until(b"Resize") + child.send(b"lost") + time.sleep(0.1) dimensions(child.slave) os.kill(child.process.pid, signal.SIGWINCH) child.until(b"Oxytype") + assert "Input: lost" not in child.screen_text() child.send(b"\x11") assert child.wait_exit() == 0 child.restored() @@ -226,6 +229,8 @@ def run(binary, release=False): child = Child(binary, args=["--mode", "custom", "--text", "one two", "--record-replay"]) try: child.until(b"RECORDING PRIVATE TEXT") + child.send(b"\x1b[200~PASTE_BLOCKED\x1b[201~") + child.until(b"Paste blocked during typing.") child.send(b"one ") time.sleep(0.15) child.send(b"two") @@ -234,6 +239,10 @@ def run(binary, release=False): child.until(b"Local history") child.send(b"\x1b[19~") child.until(b"Recorded replay") + with sqlite3.connect(pathlib.Path(child.profile.name) / "data/history.sqlite3") as db: + saved = json.loads(db.execute("SELECT json FROM results").fetchone()[0]) + committed = [event for event in saved["replayLog"]["events"] if event["type"] == "input" and event["data"]["inputType"] == "insertText"] + assert len(committed) == 7, "Paste entered the native log or custom text did not complete" child.send(b"\x1b[C") child.until(b"step 1") child.send(b"\x11") From ec8bd2c9c30ec7ec4fa6121aef708c430e96b7f2 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:48:49 +0530 Subject: [PATCH 20/48] test(tui): drain terminal restoration output before waiting for suspend --- scripts/terminal_pty.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 54c5164c3..7bedd6863 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -140,7 +140,10 @@ def close(self): if self.process.poll() is None: os.killpg(self.process.pid, signal.SIGCONT) os.killpg(self.process.pid, signal.SIGKILL) - self.process.wait(timeout=3) + deadline=time.monotonic()+3 + while self.process.poll() is None and time.monotonic() Date: Sat, 24 Oct 2026 10:49:46 +0530 Subject: [PATCH 21/48] build(tui): preserve pinned license bytes on Windows checkouts --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..940856d39 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the verified upstream license bytes on Windows checkouts. +docs/rust-engine/third-party/*.LICENSE text eol=lf From 90b023186a28329550eeea644c835cb598410990 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:50:58 +0530 Subject: [PATCH 22/48] build(tui): resolve native archive paths without Unix find --- .github/workflows/terminal-release.yml | 5 +---- scripts/terminal_release.py | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/terminal-release.yml b/.github/workflows/terminal-release.yml index 784c1821d..5576ecf31 100644 --- a/.github/workflows/terminal-release.yml +++ b/.github/workflows/terminal-release.yml @@ -75,10 +75,7 @@ jobs: - name: Build licensed versioned archive run: vp run terminal:package -- --target ${{ matrix.target }} - name: Verify extracted binary without runtime tools or checkout files - shell: bash - run: | - archive=$(vp exec find target/terminal-release -maxdepth 1 -type f \( -name '*-${{ matrix.target }}.tar.gz' -o -name '*-${{ matrix.target }}.zip' \)) - vp run terminal:archive-smoke -- --archive "$archive" + run: vp run terminal:archive-smoke -- --target ${{ matrix.target }} - name: Upload candidate archives and checksums uses: actions/upload-artifact@v7.0.2 with: diff --git a/scripts/terminal_release.py b/scripts/terminal_release.py index 843afe485..631642178 100644 --- a/scripts/terminal_release.py +++ b/scripts/terminal_release.py @@ -171,5 +171,7 @@ def checksums(directory): elif args.command=="release-gate": gate() elif args.command=="checksums": checksums(args.archive.resolve()) else: - if args.archive is None: parser.error("--archive required") + if args.archive is None: + if args.target not in TARGETS: parser.error("--archive or a supported --target required") + args.archive=OUT / (f"oxytype-v{version()}-{args.target}"+(".zip" if "windows" in args.target else ".tar.gz")) smoke(args.archive.resolve()) From febeeb16ea32bea6e3e2ca4fc30e9cf26e8daad2 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:51:43 +0530 Subject: [PATCH 23/48] build(tui): freeze archive provenance before native compilation --- scripts/terminal_release.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/terminal_release.py b/scripts/terminal_release.py index 631642178..728610452 100644 --- a/scripts/terminal_release.py +++ b/scripts/terminal_release.py @@ -84,7 +84,10 @@ def package(target): tag = os.environ.get("GITHUB_REF_NAME", "") if os.environ.get("GITHUB_REF_TYPE") == "tag" and tag != f"terminal-v{version()}": raise RuntimeError("Terminal tag must match Cargo workspace version") - run(["cargo", "build", "--locked", "--release", "-p", "oxytype-tui", "--target", target]) + revision = os.environ.get("OXYTYPE_BUILD_REV") or capture(["git","rev-parse","HEAD"]).strip() + if revision != capture(["git","rev-parse","HEAD"]).strip(): raise RuntimeError("Archive build revision must match checked-out source") + run(["git","diff","--quiet","HEAD","--"]) + run(["cargo", "build", "--locked", "--release", "-p", "oxytype-tui", "--target", target],env=dict(os.environ,OXYTYPE_BUILD_REV=revision)) name = f"oxytype-v{version()}-{target}" directory = OUT / name if directory.exists(): shutil.rmtree(directory) @@ -93,7 +96,6 @@ def package(target): shutil.copy2(ROOT / "target" / target / "release" / binary, directory / binary) for source, dest in [("LICENSE","LICENSE"),("crates/oxytype-tui/assets/ATTRIBUTION.md","ATTRIBUTION.md"),("docs/rust-engine/terminal.md","README.md"),("docs/rust-engine/terminal-content.md","terminal-content.md"),("docs/rust-engine/terminal-storage.md","terminal-storage.md")]: shutil.copy2(ROOT / source,directory / dest) - revision = os.environ.get("OXYTYPE_BUILD_REV") or capture(["git","rev-parse","HEAD"]).strip() (directory / "SOURCE.md").write_text(f"Source revision: {revision}\n\nGPL-3.0-only corresponding source is the matching oxytype-v{version()}-source.tar.gz release asset, including locked vendored dependencies.\n\nRepository: https://github.com/voltcrash/oxytype/tree/{revision}\n") licenses(directory,target) output = archive(directory,"windows" in target) From 0ee6b5eec71fa3a0193a21e1d8cf681e46135cbd Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 10:52:21 +0530 Subject: [PATCH 24/48] test(tui): verify extracted binary and source revisions match --- scripts/terminal_release.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/terminal_release.py b/scripts/terminal_release.py index 728610452..6afe15fd5 100644 --- a/scripts/terminal_release.py +++ b/scripts/terminal_release.py @@ -119,6 +119,11 @@ def smoke(path): env=dict(os.environ,PATH="") for args in [["--version"],["--help"],["--diagnostics"],["--smoke-test"]]: subprocess.run([str(binary),*args],cwd=isolated,env=env,check=True) + diagnostic=subprocess.run([str(binary),"--diagnostics"],cwd=isolated,env=env,check=True,stdout=subprocess.PIPE,text=True,encoding="utf-8") + version_info=json.loads(diagnostic.stdout) + revision=(binary.parent / "SOURCE.md").read_text(encoding="utf-8").splitlines()[0].removeprefix("Source revision: ") + assert f"build {revision})" in version_info["version"], "Binary/source provenance mismatch" + assert version_info["architecture"]==("aarch64" if "aarch64" in binary.parent.name else "x86_64") export=isolated / "history.json" subprocess.run([str(binary),"--config-dir",str(isolated / "config"),"--data-dir",str(isolated / "data"),"--export",str(export)],cwd=isolated,env=env,check=True) assert json.loads(export.read_text())["results"]==[] From 5e22b3b379716575362abfb9513f7c9658d336b2 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 11:01:36 +0530 Subject: [PATCH 25/48] test(tui): exercise native input and history through portable PTYs --- Cargo.lock | 191 ++++++++++++++++-- crates/oxytype-tui/Cargo.toml | 4 + crates/oxytype-tui/tests/portable_terminal.rs | 124 ++++++++++++ 3 files changed, 303 insertions(+), 16 deletions(-) create mode 100644 crates/oxytype-tui/tests/portable_terminal.rs diff --git a/Cargo.lock b/Cargo.lock index 760eb48b4..b44df931f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,12 @@ dependencies = [ "windows-sys", ] +[[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" @@ -67,12 +73,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[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" @@ -125,6 +143,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.2" @@ -221,7 +245,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.13.2", "crossterm_winapi", "derive_more", "document-features", @@ -354,6 +378,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.19.0" @@ -394,6 +424,17 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[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.15" @@ -564,9 +605,15 @@ checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209" dependencies = [ "hashbrown 0.17.1", "portable-atomic", - "thiserror", + "thiserror 2.0.21", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.191" @@ -605,7 +652,7 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -662,15 +709,27 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.13.2", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", ] @@ -723,7 +782,8 @@ dependencies = [ "clap", "crossterm", "directories", - "nix", + "nix 0.30.1", + "portable-pty", "ratatui", "rusqlite", "serde", @@ -735,6 +795,7 @@ dependencies = [ "unicode-segmentation", "unicode-width", "uuid", + "vt100", ] [[package]] @@ -811,6 +872,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "powerfmt" version = "0.2.1" @@ -860,7 +942,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags", + "bitflags 2.13.2", "compact_str", "critical-section", "hashbrown 0.17.1", @@ -870,7 +952,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror", + "thiserror 2.0.21", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -894,7 +976,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags", + "bitflags 2.13.2", "hashbrown 0.17.1", "indoc", "instability", @@ -914,7 +996,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.2", ] [[package]] @@ -924,7 +1006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -934,7 +1016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -943,7 +1025,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -967,7 +1049,7 @@ 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", @@ -1043,6 +1125,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1054,6 +1147,22 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -1202,13 +1311,33 @@ dependencies = [ "winapi-util", ] +[[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]] @@ -1250,7 +1379,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" dependencies = [ "serde_json", - "thiserror", + "thiserror 2.0.21", "ts-rs-macros", ] @@ -1349,6 +1478,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1446,6 +1596,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/oxytype-tui/Cargo.toml b/crates/oxytype-tui/Cargo.toml index 471353cc9..624a8a2af 100644 --- a/crates/oxytype-tui/Cargo.toml +++ b/crates/oxytype-tui/Cargo.toml @@ -33,5 +33,9 @@ tempfile = "3" [target.'cfg(unix)'.dependencies] nix = { version = "0.30", features = ["signal", "process"] } +[dev-dependencies] +portable-pty = "=0.9.0" +vt100 = "=0.16.2" + [lints] workspace = true diff --git a/crates/oxytype-tui/tests/portable_terminal.rs b/crates/oxytype-tui/tests/portable_terminal.rs new file mode 100644 index 000000000..5ccafa3b7 --- /dev/null +++ b/crates/oxytype-tui/tests/portable_terminal.rs @@ -0,0 +1,124 @@ +//! Actual native terminal event decoding, including Windows ConPTY. +use oxytype_tui::storage::{Paths, Store}; +use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use std::{ + io::{Read, Write}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +fn wait_screen(parser: &Arc>, needle: &str) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let screen = parser.lock().unwrap().screen().contents(); + if screen.contains(needle) { + return; + } + assert!(Instant::now() < deadline, "Missing {needle:?}: {screen}"); + std::thread::sleep(Duration::from_millis(10)); + } +} +struct ChildGuard(Box); +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[test] +fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_telemetry() { + let temp = tempfile::tempdir().unwrap(); + let paths = Paths { + config: temp.path().join("config"), + data: temp.path().join("data"), + }; + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 100, + pixel_width: 0, + pixel_height: 0, + }) + .unwrap(); + let parser = Arc::new(Mutex::new(vt100::Parser::new(24, 100, 0))); + let mut reader = pair.master.try_clone_reader().unwrap(); + let output = parser.clone(); + let reading = std::thread::spawn(move || { + let mut buffer = [0u8; 8192]; + while let Ok(count) = reader.read(&mut buffer) { + if count == 0 { + break; + } + output.lock().unwrap().process(&buffer[..count]); + } + }); + let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_oxytype")); + command.args([ + "--keyboard", + "basic", + "--mode", + "custom", + "--text", + "one two", + "--record-replay", + "--config-dir", + ]); + command.arg(&paths.config); + command.arg("--data-dir"); + command.arg(&paths.data); + command.env("TERM", "xterm-256color"); + let mut child = ChildGuard(pair.slave.spawn_command(command).unwrap()); + drop(pair.slave); + let mut writer = pair.master.take_writer().unwrap(); + wait_screen(&parser, "Input:"); + writer.write_all(b"one twx").unwrap(); + writer.flush().unwrap(); + wait_screen(&parser, "Input: twx"); + writer.write_all(b"\x7f").unwrap(); + writer.flush().unwrap(); + wait_screen(&parser, "Input: tw "); + assert!( + !parser + .lock() + .unwrap() + .screen() + .contents() + .contains("Input: twx") + ); + std::thread::sleep(Duration::from_millis(1100)); + writer.write_all(b"o").unwrap(); + writer.flush().unwrap(); + wait_screen(&parser, "Result saved locally."); + writer.write_all(b"\x1bOR").unwrap(); + writer.flush().unwrap(); + wait_screen(&parser, "Local history"); + writer.write_all(b"\x1b[21~").unwrap(); + writer.flush().unwrap(); // F10 works with basic keyboard reports. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + assert!(status.success()); + break; + } + assert!(Instant::now() < deadline, "App did not quit"); + std::thread::sleep(Duration::from_millis(10)); + } + drop(writer); + drop(pair.master); + drop(child); + reading.join().unwrap(); + let records = Store::open(&paths).unwrap().history(None, 100).unwrap(); + assert_eq!(records.len(), 1); + let record = &records[0]; + assert_eq!(record["summary"]["acc"], 87.5); + assert!(record["summary"]["keyDuration"].is_null()); + let log: typing_engine::stats::Log = + serde_json::from_value(record["replayLog"].clone()).unwrap(); + assert!( + log.events + .iter() + .all(|event| !["keydown", "keyup"].contains(&event.kind.as_str())) + ); + assert_eq!(typing_engine::stats::typed_summary(&log).unwrap().acc, 87.5); +} From 1495a30fc75e4a21a38c2d5a2b9518ef169a6e44 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 11:02:20 +0530 Subject: [PATCH 26/48] fix(tui): archive epoch-dated license files in Windows zip bundles --- .github/workflows/terminal-release.yml | 1 + .gitignore | 3 +++ package.json | 3 ++- scripts/terminal.ts | 9 ++++++++ scripts/terminal_release.py | 5 +++-- scripts/terminal_release_test.py | 31 ++++++++++++++++++++++++++ 6 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 scripts/terminal_release_test.py diff --git a/.github/workflows/terminal-release.yml b/.github/workflows/terminal-release.yml index 5576ecf31..83b3e0e6b 100644 --- a/.github/workflows/terminal-release.yml +++ b/.github/workflows/terminal-release.yml @@ -67,6 +67,7 @@ jobs: shell: bash run: | vp run engine:fmt + vp run terminal:test:release-tools vp exec cargo clippy --locked -p typing-engine -p oxytype-tui --all-targets -- -D warnings vp exec cargo test --locked -p typing-engine -p oxytype-tui - name: POSIX terminal lifecycle and persistence diff --git a/.gitignore b/.gitignore index 2f6a763df..4e10f7e00 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ frontend/playwright-report/ # Rust / generated Wasm and type bindings /target/ /packages/typing-engine/generated/ + +# Python terminal verification/release tooling +__pycache__/ diff --git a/package.json b/package.json index 9b07f4b37..36e93510a 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ "terminal:package": "node scripts/terminal.ts package", "terminal:source": "node scripts/terminal.ts source", "terminal:archive-smoke": "node scripts/terminal.ts archive-smoke", - "terminal:release-gate": "node scripts/terminal.ts release-gate" + "terminal:release-gate": "node scripts/terminal.ts release-gate", + "terminal:test:release-tools": "node scripts/terminal.ts tools-test" }, "devDependencies": { "@commitlint/cli": "21.2.3", diff --git a/scripts/terminal.ts b/scripts/terminal.ts index d1e16d995..dd8c795e7 100644 --- a/scripts/terminal.ts +++ b/scripts/terminal.ts @@ -3,6 +3,15 @@ import { resolve } from "node:path"; const command = process.argv[2]; const args = process.argv.slice(3); +if (command === "tools-test") { + const result = spawnSync( + process.platform === "win32" ? "python" : "python3", + ["-m", "unittest", "scripts/terminal_release_test.py"], + { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit" }, + ); + if (result.error !== undefined) throw result.error; + process.exit(result.status ?? 1); +} if ( command !== undefined && ["package", "source", "archive-smoke", "release-gate"].includes(command) diff --git a/scripts/terminal_release.py b/scripts/terminal_release.py index 6afe15fd5..7081911ba 100644 --- a/scripts/terminal_release.py +++ b/scripts/terminal_release.py @@ -30,14 +30,15 @@ def version(): def checksum(path): - digest = hashlib.file_digest(path.open("rb"), "sha256").hexdigest() + with path.open("rb") as source: + digest = hashlib.file_digest(source, "sha256").hexdigest() path.with_name(path.name + ".sha256").write_text(f"{digest} {path.name}\n") def archive(directory, windows=False): output = directory.with_name(directory.name + (".zip" if windows else ".tar.gz")) if windows: - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as package: + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, strict_timestamps=False) as package: for path in sorted(directory.rglob("*")): if path.is_file(): package.write(path, path.relative_to(directory.parent)) else: diff --git a/scripts/terminal_release_test.py b/scripts/terminal_release_test.py new file mode 100644 index 000000000..b8c7e5ddd --- /dev/null +++ b/scripts/terminal_release_test.py @@ -0,0 +1,31 @@ +"""Archive portability regressions using actual files and extraction.""" +import hashlib +import os +import pathlib +import tempfile +import unittest +import zipfile +from scripts import terminal_release + + +class ArchiveTest(unittest.TestCase): + def test_windows_zip_accepts_registry_epoch_timestamps_without_changing_bytes(self): + with tempfile.TemporaryDirectory() as temporary: + directory=pathlib.Path(temporary) / "candidate" + directory.mkdir() + source=directory / "LICENSE" + content=b"License bytes\n\x00\xff" + source.write_bytes(content) + os.utime(source,(1,1)) # Cargo registry files can predate ZIP's 1980 epoch. + archive=terminal_release.archive(directory,windows=True) + with zipfile.ZipFile(archive) as package: + self.assertEqual(package.read("candidate/LICENSE"),content) + self.assertEqual(package.getinfo("candidate/LICENSE").date_time[0],1980) + self.assertEqual(source.read_bytes(),content) + self.assertEqual(int(source.stat().st_mtime),1) + expected=archive.with_name(archive.name+".sha256").read_text().split()[0] + self.assertEqual(hashlib.sha256(archive.read_bytes()).hexdigest(),expected) + + +if __name__=="__main__": + unittest.main() From 69ffa1fb4cea26770d3398d00c4ce61813d9b2de Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 11:09:41 +0530 Subject: [PATCH 27/48] test(tui): answer ConPTY cursor queries from parsed terminal state --- crates/oxytype-tui/tests/portable_terminal.rs | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/oxytype-tui/tests/portable_terminal.rs b/crates/oxytype-tui/tests/portable_terminal.rs index 5ccafa3b7..c9d82f44f 100644 --- a/crates/oxytype-tui/tests/portable_terminal.rs +++ b/crates/oxytype-tui/tests/portable_terminal.rs @@ -44,13 +44,32 @@ fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_tele let parser = Arc::new(Mutex::new(vt100::Parser::new(24, 100, 0))); let mut reader = pair.master.try_clone_reader().unwrap(); let output = parser.clone(); + let writer = Arc::new(Mutex::new(pair.master.take_writer().unwrap())); + let responses = writer.clone(); let reading = std::thread::spawn(move || { let mut buffer = [0u8; 8192]; + let mut query = std::collections::VecDeque::new(); while let Ok(count) = reader.read(&mut buffer) { if count == 0 { break; } - output.lock().unwrap().process(&buffer[..count]); + let mut parser = output.lock().unwrap(); + for byte in &buffer[..count] { + parser.process(&[*byte]); + query.push_back(*byte); + if query.len() > 4 { + query.pop_front(); + } + if query.make_contiguous() == b"\x1b[6n" { + // ConPTY inherits its host cursor and asks the emulator for + // its actual position. Reply from the parsed screen state; + // this is terminal protocol, never typing/timing telemetry. + let (row, col) = parser.screen().cursor_position(); + let mut reply = responses.lock().unwrap(); + write!(reply, "\x1b[{};{}R", row + 1, col + 1).unwrap(); + reply.flush().unwrap(); + } + } } }); let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_oxytype")); @@ -70,13 +89,12 @@ fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_tele command.env("TERM", "xterm-256color"); let mut child = ChildGuard(pair.slave.spawn_command(command).unwrap()); drop(pair.slave); - let mut writer = pair.master.take_writer().unwrap(); wait_screen(&parser, "Input:"); - writer.write_all(b"one twx").unwrap(); - writer.flush().unwrap(); + writer.lock().unwrap().write_all(b"one twx").unwrap(); + writer.lock().unwrap().flush().unwrap(); wait_screen(&parser, "Input: twx"); - writer.write_all(b"\x7f").unwrap(); - writer.flush().unwrap(); + writer.lock().unwrap().write_all(b"\x7f").unwrap(); + writer.lock().unwrap().flush().unwrap(); wait_screen(&parser, "Input: tw "); assert!( !parser @@ -87,14 +105,14 @@ fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_tele .contains("Input: twx") ); std::thread::sleep(Duration::from_millis(1100)); - writer.write_all(b"o").unwrap(); - writer.flush().unwrap(); + writer.lock().unwrap().write_all(b"o").unwrap(); + writer.lock().unwrap().flush().unwrap(); wait_screen(&parser, "Result saved locally."); - writer.write_all(b"\x1bOR").unwrap(); - writer.flush().unwrap(); + writer.lock().unwrap().write_all(b"\x1bOR").unwrap(); + writer.lock().unwrap().flush().unwrap(); wait_screen(&parser, "Local history"); - writer.write_all(b"\x1b[21~").unwrap(); - writer.flush().unwrap(); // F10 works with basic keyboard reports. + writer.lock().unwrap().write_all(b"\x1b[21~").unwrap(); + writer.lock().unwrap().flush().unwrap(); // F10 works with basic keyboard reports. let deadline = Instant::now() + Duration::from_secs(10); loop { if let Some(status) = child.0.try_wait().unwrap() { From 832ea3ca7b5f6bb5e4cf63a005a7538598e1a90e Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 11:09:42 +0530 Subject: [PATCH 28/48] ci(tui): separate shared parity from native terminal flow checks --- .github/workflows/terminal-release.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/terminal-release.yml b/.github/workflows/terminal-release.yml index 83b3e0e6b..ea912d7c7 100644 --- a/.github/workflows/terminal-release.yml +++ b/.github/workflows/terminal-release.yml @@ -63,13 +63,16 @@ jobs: ~/.cargo/registry target key: terminal-${{ matrix.target }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} - - name: Native formatting, lint and shared fixture tests - shell: bash - run: | - vp run engine:fmt - vp run terminal:test:release-tools - vp exec cargo clippy --locked -p typing-engine -p oxytype-tui --all-targets -- -D warnings - vp exec cargo test --locked -p typing-engine -p oxytype-tui + - name: Native formatting + run: vp run engine:fmt + - name: Archive portability regressions + run: vp run terminal:test:release-tools + - name: Native lint + run: vp exec cargo clippy --locked -p typing-engine -p oxytype-tui --all-targets -- -D warnings + - name: Shared engine fixtures + run: vp exec cargo test --locked -p typing-engine + - name: Native terminal input and durable history + run: vp run terminal:test - name: POSIX terminal lifecycle and persistence if: runner.os != 'Windows' run: vp run terminal:test:pty From a496c78c26e201004dd3c31b4a97ce259101fedc Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 11:11:20 +0530 Subject: [PATCH 29/48] docs(tui): record source proof and native terminal portability checks --- docs/rust-engine/progress.md | 28 ++++++++++++++++++++++++++++ docs/rust-engine/terminal-storage.md | 7 +++++++ 2 files changed, 35 insertions(+) diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index 500e65f61..0e540e628 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -656,3 +656,31 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro Requested access/test records asynchronously; no answer yet. Publish workflow refuses the pending manual matrix. No release tag/publication or production deployment performed. Continue all available checks before stopping. + +## Latest terminal portability checkpoint + +- Terminal runtime source is implemented through all modes, Unicode/input, local + history/export/replay and packaging. Twenty native integration tests pass locally + (one intentionally ignored helper is launched/killed by the durability parent). + Native Clippy, format, package routing and ZIP regression checks pass. +- Native/core tests also pass fully offline from the extracted vendored source at + `1495a30fc`, using a fresh separate target directory. Current local Linux archive + matches its SOURCE revision and passes empty-PATH install plus full release PTY. +- Wasm matches the Stage 2 clean checkout byte-for-byte: SHA-256 + `4b1a0c8d5d006b367888b7cf4c76b104ab5037a7473ff7b46983a9109aba9e3d`, + 765,852 raw / 303,619 Node gzip6 bytes. No shared runtime/fixture/budget changes. +- Platform CI `40065958315`: Linux x86/ARM and both macOS candidates pass archive + and PTY checks; Windows native tests/build pass, ZIP failed on registry files + predating 1980. ZIP now clamps only unsupported timestamps; real byte/checksum + regression passes. Pinned license EOL and source-provenance issues also fixed. +- Added portable PTY integration to exercise native event decoding on all targets, + including actual Windows ConPTY, with edits, known native accuracy, saved history + and no invented physical telemetry. Linux passes. Windows first run waited for + ConPTY's inherited-cursor query; test emulator now answers DSR from actual parsed + screen coordinates. This is protocol handling, not fabricated key/timing data. +- Latest platform rerun includes the ConPTY query fix; check GitHub runs for current + `stack/rust-engine-terminal` head. Shared fixtures/native terminal/packaging steps + are separated for useful diagnostics. Do not skip failed checks. +- Manual Linux enhanced/macOS emulator/Windows Terminal records remain pending; + no machine/test record supplied to the asynchronous question yet. Publication + gate correctly rejects the pending matrix. PR #186 stays draft. diff --git a/docs/rust-engine/terminal-storage.md b/docs/rust-engine/terminal-storage.md index 902e3c1d3..415087bb0 100644 --- a/docs/rust-engine/terminal-storage.md +++ b/docs/rust-engine/terminal-storage.md @@ -43,3 +43,10 @@ Local personal bests compare stored native WPM within the effective config, native mode2 and content identity. Shared validity and funbox PB metadata decide eligibility; repeated, failed or otherwise invalid results remain in history. No score formula or acceptance policy is reimplemented by storage. + +Optional replay logs use the native Rust DTO: Text fields retain arrays of UTF-16 +units, including partial surrogates where legacy rules produce them. The local +envelope version distinguishes this representation from browser transport JSON. +Core event semantics/log version remain shared; converting stored web records +into local terminal history is outside this release. Never interpret Rust scalar +or byte offsets as JavaScript event indices. From 9878d57e87def48cf0e0c842f4da759efc016705 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 11:20:21 +0530 Subject: [PATCH 30/48] test(tui): capture bounded ConPTY startup diagnostics and exit status --- crates/oxytype-tui/tests/portable_terminal.rs | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/oxytype-tui/tests/portable_terminal.rs b/crates/oxytype-tui/tests/portable_terminal.rs index c9d82f44f..362246fc4 100644 --- a/crates/oxytype-tui/tests/portable_terminal.rs +++ b/crates/oxytype-tui/tests/portable_terminal.rs @@ -7,14 +7,24 @@ use std::{ time::{Duration, Instant}, }; -fn wait_screen(parser: &Arc>, needle: &str) { +fn wait_screen( + parser: &Arc>, + trace: &Arc>>, + child: &mut ChildGuard, + needle: &str, +) { let deadline = Instant::now() + Duration::from_secs(10); loop { let screen = parser.lock().unwrap().screen().contents(); if screen.contains(needle) { return; } - assert!(Instant::now() < deadline, "Missing {needle:?}: {screen}"); + let exit = child.0.try_wait().unwrap(); + assert!( + exit.is_none() && Instant::now() < deadline, + "Missing {needle:?}; exit={exit:?}; screen={screen:?}; raw={:?}", + String::from_utf8_lossy(&trace.lock().unwrap()) + ); std::thread::sleep(Duration::from_millis(10)); } } @@ -44,15 +54,30 @@ fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_tele let parser = Arc::new(Mutex::new(vt100::Parser::new(24, 100, 0))); let mut reader = pair.master.try_clone_reader().unwrap(); let output = parser.clone(); + let trace = Arc::new(Mutex::new(Vec::new())); + let raw = trace.clone(); let writer = Arc::new(Mutex::new(pair.master.take_writer().unwrap())); let responses = writer.clone(); let reading = std::thread::spawn(move || { let mut buffer = [0u8; 8192]; let mut query = std::collections::VecDeque::new(); - while let Ok(count) = reader.read(&mut buffer) { + loop { + let count = match reader.read(&mut buffer) { + Ok(count) => count, + Err(error) => { + eprintln!("PTY reader ended: {error}"); + break; + } + }; if count == 0 { break; } + { + let mut trace = raw.lock().unwrap(); + trace.extend_from_slice(&buffer[..count]); + let excess = trace.len().saturating_sub(16384); + trace.drain(..excess); + } let mut parser = output.lock().unwrap(); for byte in &buffer[..count] { parser.process(&[*byte]); @@ -89,13 +114,13 @@ fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_tele command.env("TERM", "xterm-256color"); let mut child = ChildGuard(pair.slave.spawn_command(command).unwrap()); drop(pair.slave); - wait_screen(&parser, "Input:"); + wait_screen(&parser, &trace, &mut child, "Input:"); writer.lock().unwrap().write_all(b"one twx").unwrap(); writer.lock().unwrap().flush().unwrap(); - wait_screen(&parser, "Input: twx"); + wait_screen(&parser, &trace, &mut child, "Input: twx"); writer.lock().unwrap().write_all(b"\x7f").unwrap(); writer.lock().unwrap().flush().unwrap(); - wait_screen(&parser, "Input: tw "); + wait_screen(&parser, &trace, &mut child, "Input: tw "); assert!( !parser .lock() @@ -107,10 +132,10 @@ fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_tele std::thread::sleep(Duration::from_millis(1100)); writer.lock().unwrap().write_all(b"o").unwrap(); writer.lock().unwrap().flush().unwrap(); - wait_screen(&parser, "Result saved locally."); + wait_screen(&parser, &trace, &mut child, "Result saved locally."); writer.lock().unwrap().write_all(b"\x1bOR").unwrap(); writer.lock().unwrap().flush().unwrap(); - wait_screen(&parser, "Local history"); + wait_screen(&parser, &trace, &mut child, "Local history"); writer.lock().unwrap().write_all(b"\x1b[21~").unwrap(); writer.lock().unwrap().flush().unwrap(); // F10 works with basic keyboard reports. let deadline = Instant::now() + Duration::from_secs(10); From 52a3a3e722afe51ab6bc25520a777ef11d47b9a0 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 11:29:28 +0530 Subject: [PATCH 31/48] fix(tui): decode plain-text separators without truncating Unicode units --- crates/oxytype-tui/src/options.rs | 14 ++++++++++ crates/oxytype-tui/tests/offline_content.rs | 29 +++++++++++++++++++++ docs/rust-engine/terminal-content.md | 3 ++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/oxytype-tui/src/options.rs b/crates/oxytype-tui/src/options.rs index 1620d293a..66eb90e3c 100644 --- a/crates/oxytype-tui/src/options.rs +++ b/crates/oxytype-tui/src/options.rs @@ -149,6 +149,20 @@ impl Cli { } pub fn custom_text(text: &str, order: &str, limit: &str, value: f64) -> Result { let text = crate::content::line_endings(text); + // Decode plain-text entry boundaries using the core's separator set. Leaving + // a nonbreaking space inside one entry would make Space commit it early. + // Work in UTF-16 so non-BMP scalars cannot alias a BMP separator by truncation. + let units: Vec<_> = text + .encode_utf16() + .map(|unit| { + if typing_engine::text::is_space(unit) { + 32 + } else { + unit + } + }) + .collect(); + let text = String::from_utf16(&units)?; safe_text(&text)?; if text.trim().is_empty() { return Err("Custom text is empty".into()); diff --git a/crates/oxytype-tui/tests/offline_content.rs b/crates/oxytype-tui/tests/offline_content.rs index 8b5dad368..d9cbd12bf 100644 --- a/crates/oxytype-tui/tests/offline_content.rs +++ b/crates/oxytype-tui/tests/offline_content.rs @@ -196,3 +196,32 @@ fn default_custom_uses_the_whole_passage_and_explicit_zero_stays_infinite() { .unwrap(); assert_eq!(custom.unwrap().limit.value, 0.0); } + +#[test] +fn plain_text_separators_and_bom_do_not_truncate_non_bmp_units() { + let custom = oxytype_tui::options::custom_text_auto( + "\u{feff}one\u{a0}two\u{200b}\u{12000}", + "repeat", + "word", + None, + ) + .unwrap(); + assert_eq!(custom.text.len(), 3); + assert_eq!(custom.text[2], typing_engine::Text::from("\u{12000}")); + let mut host = EngineHost::offline( + Config { + mode: "custom".into(), + ..Default::default() + }, + OfflineContent::bundled().unwrap(), + Some(custom), + 42, + ) + .unwrap(); + for (i, text) in ["one ", "two ", "\u{12000}"].iter().enumerate() { + host.insert(text, 100.0 + i as f64 * 1000.0, 1000.0 + i as f64 * 1000.0) + .unwrap(); + } + assert!(host.state().ended); + assert_eq!(host.result.unwrap().acc, 100.0); +} diff --git a/docs/rust-engine/terminal-content.md b/docs/rust-engine/terminal-content.md index a052e48a4..63bff68c8 100644 --- a/docs/rust-engine/terminal-content.md +++ b/docs/rust-engine/terminal-content.md @@ -19,7 +19,8 @@ This implies custom mode unless another mode is explicitly supplied (an error). default word limit covers all supplied words. An omitted time length uses core's default time. Explicit zero retains native infinite behavior. Zero means infinite where defined by core. Literal spaces separate supplied entries. CRLF/CR decode as LF; LF terminates -an entry so Enter commits it. Tab and Unicode text retain their literal units. +an entry so Enter commits it. The core's space equivalents (including NBSP and a UTF-8 BOM) become entry +separators. Tab and other Unicode text retain their literal UTF-16 units. No NFC rewrite is imposed by the terminal adapter. Native rules retain all normalization and scoring responsibility. Extend offline content with `--pack pack.json`. Version 1: From 5703c1f10bd949478d3801d027965ac2214f3738 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 11:29:29 +0530 Subject: [PATCH 32/48] test(tui): write complete ConPTY cursor-response packets atomically --- crates/oxytype-tui/tests/portable_terminal.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/oxytype-tui/tests/portable_terminal.rs b/crates/oxytype-tui/tests/portable_terminal.rs index 362246fc4..1fb330eba 100644 --- a/crates/oxytype-tui/tests/portable_terminal.rs +++ b/crates/oxytype-tui/tests/portable_terminal.rs @@ -91,8 +91,12 @@ fn native_terminal_decodes_edits_and_persists_actual_input_without_physical_tele // this is terminal protocol, never typing/timing telemetry. let (row, col) = parser.screen().cursor_position(); let mut reply = responses.lock().unwrap(); - write!(reply, "\x1b[{};{}R", row + 1, col + 1).unwrap(); + let response = format!("\x1b[{};{}R", row + 1, col + 1); + // One complete protocol packet, rather than write_fmt's + // separate writes for CSI prefix, numbers and terminator. + reply.write_all(response.as_bytes()).unwrap(); reply.flush().unwrap(); + eprintln!("PTY cursor response: {response:?}"); } } } From 34519d9fb0f8732d97d77bc8b9a555ca59721246 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 14:57:49 +0530 Subject: [PATCH 33/48] docs(tui): record verified five-platform candidate artifacts --- docs/rust-engine/terminal-artifacts.json | 39 ++++++++++++++++++++++++ docs/rust-engine/terminal-release.md | 26 ++++++++++++---- docs/rust-engine/terminal.md | 4 +++ 3 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 docs/rust-engine/terminal-artifacts.json diff --git a/docs/rust-engine/terminal-artifacts.json b/docs/rust-engine/terminal-artifacts.json new file mode 100644 index 000000000..81a5c7956 --- /dev/null +++ b/docs/rust-engine/terminal-artifacts.json @@ -0,0 +1,39 @@ +{ + "formatVersion": 1, + "workflowRun": "https://github.com/voltcrash/oxytype/actions/runs/40068412137", + "sourceHead": "5703c1f10bd949478d3801d027965ac2214f3738", + "artifactSourceRevision": "b62151798e1c1f469d13501a1cf9479a83bdca0b", + "status": "All five native build/test/archive smoke jobs and corresponding-source job passed; downloaded hashes and cross-archive source pairing verified. Candidate CI artifacts only; no release published.", + "artifacts": [ + { + "file": "oxytype-v0.1.0-aarch64-apple-darwin.tar.gz", + "bytes": 1579709, + "sha256": "4cb2dfea5e35ea3564a490c33c60dd92493fb03680ed3a5aa6c9f82213622c48" + }, + { + "file": "oxytype-v0.1.0-aarch64-unknown-linux-gnu.tar.gz", + "bytes": 1635357, + "sha256": "786ec2a486c8de0903e89e261b7112ee41baf73649cbfcc937561dc87fd0502a" + }, + { + "file": "oxytype-v0.1.0-source.tar.gz", + "bytes": 72362837, + "sha256": "5e42065bf844eeb2be2bef9b419ca2b23558f6c57f8425e7f32d1dffcf78fcb9" + }, + { + "file": "oxytype-v0.1.0-x86_64-apple-darwin.tar.gz", + "bytes": 1651285, + "sha256": "9d97b757d2e47886c24e07eb51e1c57b21425535ad713788ede29787664a75a7" + }, + { + "file": "oxytype-v0.1.0-x86_64-pc-windows-msvc.zip", + "bytes": 2077503, + "sha256": "db476da1059a4f1605907d89e7ef0e36b2f5e6b04d8822fff0e6c1fcb700c43b" + }, + { + "file": "oxytype-v0.1.0-x86_64-unknown-linux-gnu.tar.gz", + "bytes": 1640282, + "sha256": "34aa336e8a50b18bc84d07e04ae01657f5192b4dee025bc6cd1869c4134c6db8" + } + ] +} diff --git a/docs/rust-engine/terminal-release.md b/docs/rust-engine/terminal-release.md index 39259647a..05b83380c 100644 --- a/docs/rust-engine/terminal-release.md +++ b/docs/rust-engine/terminal-release.md @@ -9,20 +9,34 @@ native/core/build inputs. It does not run for unrelated website edits. Each job uses pinned Rust 1.99.1, the lockfile, vp tasks and a native runner. Core fixtures, Clippy and terminal tests run before release compilation. POSIX jobs also test debug lifecycle and extracted release binaries in a real PTY. Windows runs native -tests and headless archive smoke; Windows Terminal manual input remains separate. +tests including actual ConPTY typing/editing/history and headless archive smoke; +Windows Terminal manual input remains separate. | Candidate target | Runner / conservative requirements | Build/smoke status | | --- | --- | --- | -| x86_64-unknown-linux-gnu | Ubuntu 24.04; GNU libc 2.39 baseline; not static musl | Pending CI | -| aarch64-unknown-linux-gnu | Ubuntu 24.04 ARM; GNU libc 2.39 baseline; not static musl | Local build/smoke passes on GNU libc 2.43; CI baseline pending | -| x86_64-apple-darwin | macOS 15 Intel; deployment target explicitly 15.0 | Pending CI | -| aarch64-apple-darwin | macOS 15 Apple Silicon; deployment target explicitly 15.0 | Pending CI | -| x86_64-pc-windows-msvc | Windows Server 2025 native runner; static CRT | Pending CI; Windows Terminal manual pending | +| x86_64-unknown-linux-gnu | Ubuntu 24.04; GNU libc 2.39 baseline; not static musl | Pass | +| aarch64-unknown-linux-gnu | Ubuntu 24.04 ARM; GNU libc 2.39 baseline; not static musl | Pass | +| x86_64-apple-darwin | macOS 15 Intel; deployment target explicitly 15.0 | Pass | +| aarch64-apple-darwin | macOS 15 Apple Silicon; deployment target explicitly 15.0 | Pass | +| x86_64-pc-windows-msvc | Windows Server 2025 native runner; static CRT | Pass; Windows Terminal manual pending | + +All five candidates and corresponding source passed in [run 40068412137](https://github.com/voltcrash/oxytype/actions/runs/40068412137) +for source head `5703c1f10bd949478d3801d027965ac2214f3738`. +[terminal-artifacts.json](terminal-artifacts.json) records downloaded archive sizes +and SHA-256 hashes. All six individual checksums and binary/source revision pairing +were verified with the publishing checksum collector. PR jobs check out GitHub's +merge revision: all binary SOURCE.md files and source SOURCE_REVISION identify +`b62151798e1c1f469d13501a1cf9479a83bdca0b`. These are temporary CI candidates +(14-day retention), not published releases. Documentation-only commits do not +retroactively change this immutable evidence. Do not claim lower OS/libc versions or untested terminals supported. Runner labels were checked against [GitHub's runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Current manual coverage lives in terminal.md and terminal-verification.json. Only real emulator records can change those pending entries to pass. +The implementation workspace is Linux only; the user confirmed no other test +machine is available. Real Linux enhanced, macOS emulator and Windows Terminal +font/IME/input checks remain unperformed. Publication remains gated. Build locally: `vp run terminal:package -- --target TARGET`; use the matching native toolchain/runner. Archives go under `target/terminal-release/` with individual diff --git a/docs/rust-engine/terminal.md b/docs/rust-engine/terminal.md index ce40de6d9..27557b1da 100644 --- a/docs/rust-engine/terminal.md +++ b/docs/rust-engine/terminal.md @@ -92,11 +92,15 @@ protect prior committed data; a result not yet committed can be lost. | --- | --- | --- | | Linux ARM64, POSIX PTY/basic protocol | Five modes, settings/editor, history/export/replay, resize, cleanup, SIGTERM, suspend/resume | Pass | | Linux ARM64, simulated enhanced protocol in PTY | Negotiation, press/repeat/release, owned flag cleanup | Pass; protocol simulation | +| Linux x86_64/ARM64 and macOS Intel/Apple Silicon CI PTYs | Native input/editing/history; POSIX lifecycle, resize, suspend/resume, extracted release input | Pass; automated PTY | +| Windows Server 2025 CI ConPTY | Actual native typing/editing, native accuracy, history, quit | Pass; automated ConPTY | | Ratatui TestBackend | Combining/CJK/emoji/tab/newline/cell wrapping/caret/UTF-16 deletion | Pass; simulated display | | Real Linux enhanced terminal | Rapid input, scrollback, Unicode/IME, resize | Pending | | Real macOS terminal | Basic/enhanced input, Unicode/IME, resize, suspend | Pending | | Real Windows Terminal | Input/repeats/releases, Unicode/IME, resize, cleanup | Pending | Build/smoke archive verification is recorded separately in terminal-release.md. +Cross-platform automated evidence: [run 40068412137](https://github.com/voltcrash/oxytype/actions/runs/40068412137). A runner build or PTY simulation does not satisfy real emulator/manual coverage. Do not claim the untested combinations supported until their records exist. +No macOS/Windows test machine is available; the manual entries remain pending. From 21e1e4b244a2734c1f97a2797f751b1c7e05ddf5 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 14:58:23 +0530 Subject: [PATCH 34/48] docs(migration): close automated gates and record terminal blocker --- docs/rust-engine/progress.md | 69 +++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index 0e540e628..476eb367a 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -12,9 +12,13 @@ empty commits to inflate count. Do not publish production or releases implicitly ## Current position -Stage 1 and Stage 2 gates passed. Stage 3 Phase 3.5 active on `stack/rust-engine-terminal`, stacked above `stack/rust-engine-website`. Read root AGENTS.md and the complete migration -plan before edits. Installed workspace dependencies with frozen lockfile. -Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen oracle and mismatch detection. Existing website engine remains authoritative until Stage 2. +Stage 1 and Stage 2 gates passed. Stage 3 implementation and automated native +packaging gates passed on `stack/rust-engine-terminal`, stacked above +`stack/rust-engine-website`. Required real-terminal verification remains blocked: +this workspace is Linux only and the user confirmed no other test machine is +available. PR #186 stays draft; no manual gate is waived. No release or production +deployment published. Read the final checkpoint below before resuming; earlier +entries are chronological records, not current outstanding work. | Phase | Status | Evidence / remaining gate | | --- | --- | --- | @@ -24,7 +28,7 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 1.4 Input / lifecycle / timing | Complete | Native/built-Wasm traces, observed browser input, delayed completion, stale sources, reset/restart and 12,800 randomized commands. | | 1.5 Generation / configuration | Complete | 61 original snapshots; section/fractional/live generation; all funbox policies; quote refresh RNG; typed recovery and bounded malformed content. | | 1.6 Events / results / replay | Complete | Eight exact original CompletedEvents/hashes/schema/backend outcomes, replay/seek, Unicode history, 1,387 live prefixes and malformed-log validation. | -| 1.7 Binding / engine gate | Complete | 24 built-browser cases, unchanged results/hashes, frozen payload/performance limits, fresh production build/typechecks; CI engine/native/Wasm and quality/package/SSR/delivery/update jobs pass at e7b7ad864. Existing website browser shards still running when this record was written. | +| 1.7 Binding / engine gate | Complete | Native/browser frozen parity, payload/performance, fresh builds/types and full CI passed; final Stage 1 CI 39932792599. | | 2.1 Browser owner / loading | Complete | App-owned loader/content/session resource; 51 unit cases; actual Cloudflare delayed-download/503/retry browser checks pass; server bundles contain no Wasm initializer. | | 2.2 Input / timer projection | Complete | Native website loop; 1,575 unit cases, 19 built typing parity cases and 8 loading/input/IME/commit browser checks pass. | | 2.3 Generation / settings consumers | Complete | Native generation/catalog/settings/restart/practice; source cancellation/recovery; SSR-safe settings extraction; 1,577 unit tests and built-browser gates pass. | @@ -33,9 +37,9 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o | 2.6 TypeScript retirement | Complete | Legacy package removed; full native/Wasm/website/quality CI, fresh build/types and frozen performance gates pass. | | 3.1 Terminal shell | Complete | Native smoke/score test; real PTY typing, finish/restart, resize, error/panic, SIGTERM and suspend/resume pass. | | 3.2 Offline content | Complete | Bundled words/eight original quotes; five modes, CLI/settings/editor, bounded v1 packs; native/full-fixture and actual PTY checks pass. | -| 3.3 Input / Unicode | Implemented; manual gate pending | Native Unicode/TestBackend + basic/enhanced-protocol PTY pass; real macOS/Windows/enhanced terminal records unavailable. | +| 3.3 Input / Unicode | Implemented; manual gate blocked | Native Unicode/TestBackend, Linux/macOS PTY and Windows ConPTY pass; real emulator/font/IME checks unavailable. | | 3.4 Settings / history | Complete | SQLite reopen/concurrency/corruption/retry/export and actual history/replay PTY pass. | -| 3.5 Packaging / release | Active | Platform build/smoke archives, fresh install and release docs next. | +| 3.5 Packaging / release | Automated gates complete; publication gated | All five native candidates and corresponding source pass CI 40068412137; downloaded checksums/source pairing verified. Required manual matrix pending. | ## Verification log @@ -68,13 +72,20 @@ Phase 1.1 and 1.2 gates passed: inventory/baselines, lossless contract, frozen o ## Blockers -None established. Missing verification is pending work, not a passed gate. +Required real Linux enhanced terminal, macOS emulator and Windows Terminal +verification is unperformed. The Linux-only workspace cannot provide the latter +two machines; user confirmed no other machine available. Automated PTY/ConPTY and +all five build/archive jobs pass, but do not establish emulator/font/IME behavior. +Keep terminal-verification.json pending and PR #186 draft. Stage 3 cannot honestly +be declared fully accepted or release-ready until the recorded manual matrix +passes. No remaining implementation or automated failure is established. ## Resume Read this file, inventory, contract, decisions, then `vp exec git status --short`. -Continue the first incomplete gate. Record commands and actual outcomes here; -do not rerun completed checks without a new reason. +Resume the real-terminal matrix when actual test access/records become available. +Do not repeat completed implementation or automated checks without source changes +or new evidence. Record actual outcomes; never infer manual passes from CI. ## Latest implementation evidence @@ -684,3 +695,43 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro - Manual Linux enhanced/macOS emulator/Windows Terminal records remain pending; no machine/test record supplied to the asynchronous question yet. Publication gate correctly rejects the pending matrix. PR #186 stays draft. + + +## Final automated verification / remaining external gate + +- Runtime source `5703c1f10bd949478d3801d027965ac2214f3738` passes full + [website/native/Wasm CI 40068412135](https://github.com/voltcrash/oxytype/actions/runs/40068412135): + frozen parity, frontend/backend/packages, lint/types/Knip, actual Cloudflare + SSR/workerd/delivery, all four browser shards and updates/rollback. No frozen + reference, backend acceptance rule or performance budget changed. +- [Terminal CI 40068412137](https://github.com/voltcrash/oxytype/actions/runs/40068412137) + succeeds on Linux x86_64/ARM64, macOS Intel/Apple Silicon and Windows x86_64. + All five run native format/Clippy/core fixtures, 21 terminal integration tests, + archive portability regression, native compilation and extracted archive smoke + without runtime tools/source files. The one ignored durability helper is + explicitly launched and killed by its parent test, not omitted coverage. +- Linux/macOS jobs additionally pass debug and extracted-release POSIX PTY checks + (input, five modes, paste policy, resize, history/replay, cleanup and supported + signals/suspend). Windows passes actual ConPTY typing/editing/history/native + accuracy and quit. ConPTY cursor replies must be one write_all call; fragmented + formatted writes caused the earlier harness stall. No physical timing invented. +- Corresponding-source job passes vendoring and a separate offline locked build. + Downloaded all six archives, verified every sidecar hash and all five binary + SOURCE.md revisions against source SOURCE_REVISION; publisher checksum collector + passes. Exact sizes/hashes and GitHub PR merge revision are recorded in + terminal-artifacts.json. This immutable record describes CI candidates, not a + published release; subsequent documentation commits do not replace it. +- Existing fresh-checkout native/Cloudflare build, Wrangler dry-run, all nine + typechecks and vendored full offline tests remain valid. Browser Wasm remains + exactly Stage 2: 765,852 raw / 303,619 Node gzip6 bytes, SHA-256 + `4b1a0c8d5d006b367888b7cf4c76b104ab5037a7473ff7b46983a9109aba9e3d`. +- User confirmed no other test machine is available. Real Linux enhanced, + macOS emulator and Windows Terminal font/IME/rapid-input/scrollback verification + remains unperformed. `vp run terminal:release-gate` correctly exits 1 and names + linuxEnhanced, macOS, windowsTerminal. terminal-verification.json stays pending; + no waiver, release tag, artifact publication or production deployment made. +- Review stack: [#184 engine](https://github.com/voltcrash/oxytype/pull/184) → + [#185 website](https://github.com/voltcrash/oxytype/pull/185) → + [#186 terminal](https://github.com/voltcrash/oxytype/pull/186). First two ready; + terminal draft solely for the unmet real-terminal gate. All available independent + work is complete; resume that gate when actual machine access/records exist. From 13e75c8c751b731dc963f769eeada274a70a6698 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:09:38 +0530 Subject: [PATCH 35/48] build(tui): support Linux and macOS release targets only --- .github/workflows/terminal-release.yml | 6 +-- crates/oxytype-tui/tests/portable_terminal.rs | 2 +- docs/rust-engine/decisions.md | 14 ++++++ docs/rust-engine/terminal-verification.json | 3 +- scripts/terminal.ts | 4 +- scripts/terminal_release.py | 34 +++++--------- scripts/terminal_release_test.py | 47 +++++++++++++++---- 7 files changed, 68 insertions(+), 42 deletions(-) diff --git a/.github/workflows/terminal-release.yml b/.github/workflows/terminal-release.yml index ea912d7c7..b16e19642 100644 --- a/.github/workflows/terminal-release.yml +++ b/.github/workflows/terminal-release.yml @@ -45,10 +45,8 @@ jobs: - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu } - { os: macos-15-intel, target: x86_64-apple-darwin } - { os: macos-15, target: aarch64-apple-darwin } - - { os: windows-2025, target: x86_64-pc-windows-msvc } env: MACOSX_DEPLOYMENT_TARGET: "15.0" - RUSTFLAGS: ${{ matrix.os == 'windows-2025' && '-C target-feature=+crt-static' || '' }} steps: - uses: actions/checkout@v7.0.1 with: @@ -74,7 +72,6 @@ jobs: - name: Native terminal input and durable history run: vp run terminal:test - name: POSIX terminal lifecycle and persistence - if: runner.os != 'Windows' run: vp run terminal:test:pty - name: Build licensed versioned archive run: vp run terminal:package -- --target ${{ matrix.target }} @@ -86,7 +83,6 @@ jobs: name: terminal-${{ matrix.target }} path: | target/terminal-release/*.tar.gz - target/terminal-release/*.zip target/terminal-release/*.sha256 retention-days: 14 if-no-files-found: error @@ -142,4 +138,4 @@ jobs: - name: Publish the explicitly pushed terminal tag env: GH_TOKEN: ${{ github.token }} - run: vp exec gh release create "$GITHUB_REF_NAME" release/*.tar.gz release/*.zip release/SHA256SUMS --verify-tag --title "Oxytype terminal ${GITHUB_REF_NAME#terminal-v}" --notes-file docs/rust-engine/terminal-release-notes.md + run: vp exec gh release create "$GITHUB_REF_NAME" release/*.tar.gz release/SHA256SUMS --verify-tag --title "Oxytype terminal ${GITHUB_REF_NAME#terminal-v}" --notes-file docs/rust-engine/terminal-release-notes.md diff --git a/crates/oxytype-tui/tests/portable_terminal.rs b/crates/oxytype-tui/tests/portable_terminal.rs index 1fb330eba..ba9e8c9a8 100644 --- a/crates/oxytype-tui/tests/portable_terminal.rs +++ b/crates/oxytype-tui/tests/portable_terminal.rs @@ -1,4 +1,4 @@ -//! Actual native terminal event decoding, including Windows ConPTY. +//! Actual native terminal event decoding on the supported Linux/macOS PTYs. use oxytype_tui::storage::{Paths, Store}; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use std::{ diff --git a/docs/rust-engine/decisions.md b/docs/rust-engine/decisions.md index 4cb2c64fa..74b451520 100644 --- a/docs/rust-engine/decisions.md +++ b/docs/rust-engine/decisions.md @@ -114,3 +114,17 @@ restores Infinity only for presentation. Completed metrics retain strict finite validation. Do not allocate an intermediate Option array solely for JSON conversion. Historical capture sources that refer to removed functions are archived as inert text; parity CI runs directly against immutable outputs without the old engine. + + +### Terminal scope and merge gate superseded by owner + +After merging PRs #184/#185, the owner explicitly requested Linux/macOS only, +Kitty-compatible terminal coverage, and PR #186 ready for merge. Windows is removed +from release targets and verification requirements; historical CI evidence remains +an immutable record of earlier scope. Linux/macOS Intel/ARM64 are the four targets. +The owner will verify macOS manually after merge; recruiting external testers is +not a pre-merge requirement. Automated native/archive and real Linux emulator +checks govern merge readiness. Unperformed macOS/IME checks stay pending and are +not reported as passed. Release publication remains separately authorized/gated. +The application renders text; Kitty graphics support is independent of keyboard +protocol support, negotiated without terminal-brand or TERM-name assumptions. diff --git a/docs/rust-engine/terminal-verification.json b/docs/rust-engine/terminal-verification.json index 55aa604ed..89da0aaad 100644 --- a/docs/rust-engine/terminal-verification.json +++ b/docs/rust-engine/terminal-verification.json @@ -2,7 +2,6 @@ "formatVersion": 1, "manualTerminals": { "linuxEnhanced": { "status": "pending", "evidence": null }, - "macOS": { "status": "pending", "evidence": null }, - "windowsTerminal": { "status": "pending", "evidence": null } + "macOS": { "status": "pending", "evidence": null } } } diff --git a/scripts/terminal.ts b/scripts/terminal.ts index dd8c795e7..4804c90f0 100644 --- a/scripts/terminal.ts +++ b/scripts/terminal.ts @@ -5,7 +5,7 @@ const command = process.argv[2]; const args = process.argv.slice(3); if (command === "tools-test") { const result = spawnSync( - process.platform === "win32" ? "python" : "python3", + "python3", ["-m", "unittest", "scripts/terminal_release_test.py"], { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit" }, ); @@ -17,7 +17,7 @@ if ( ["package", "source", "archive-smoke", "release-gate"].includes(command) ) { const result = spawnSync( - process.platform === "win32" ? "python" : "python3", + "python3", [ "scripts/terminal_release.py", command, diff --git a/scripts/terminal_release.py b/scripts/terminal_release.py index 7081911ba..093922ec4 100644 --- a/scripts/terminal_release.py +++ b/scripts/terminal_release.py @@ -10,11 +10,10 @@ import tarfile import tempfile import tomllib -import zipfile ROOT = pathlib.Path(__file__).resolve().parent.parent OUT = ROOT / "target/terminal-release" -TARGETS = ("x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "aarch64-apple-darwin", "x86_64-pc-windows-msvc") +TARGETS = ("x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "aarch64-apple-darwin") def run(args, **kwargs): @@ -35,15 +34,10 @@ def checksum(path): path.with_name(path.name + ".sha256").write_text(f"{digest} {path.name}\n") -def archive(directory, windows=False): - output = directory.with_name(directory.name + (".zip" if windows else ".tar.gz")) - if windows: - with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, strict_timestamps=False) as package: - for path in sorted(directory.rglob("*")): - if path.is_file(): package.write(path, path.relative_to(directory.parent)) - else: - with tarfile.open(output, "w:gz") as package: - package.add(directory, arcname=directory.name) +def archive(directory): + output = directory.with_name(directory.name + ".tar.gz") + with tarfile.open(output, "w:gz") as package: + package.add(directory, arcname=directory.name) checksum(output) return output @@ -93,13 +87,13 @@ def package(target): directory = OUT / name if directory.exists(): shutil.rmtree(directory) directory.mkdir(parents=True) - binary = "oxytype.exe" if "windows" in target else "oxytype" + binary = "oxytype" shutil.copy2(ROOT / "target" / target / "release" / binary, directory / binary) for source, dest in [("LICENSE","LICENSE"),("crates/oxytype-tui/assets/ATTRIBUTION.md","ATTRIBUTION.md"),("docs/rust-engine/terminal.md","README.md"),("docs/rust-engine/terminal-content.md","terminal-content.md"),("docs/rust-engine/terminal-storage.md","terminal-storage.md")]: shutil.copy2(ROOT / source,directory / dest) (directory / "SOURCE.md").write_text(f"Source revision: {revision}\n\nGPL-3.0-only corresponding source is the matching oxytype-v{version()}-source.tar.gz release asset, including locked vendored dependencies.\n\nRepository: https://github.com/voltcrash/oxytype/tree/{revision}\n") licenses(directory,target) - output = archive(directory,"windows" in target) + output = archive(directory) print(f"Archive: {output}") return output @@ -109,11 +103,8 @@ def smoke(path): assert hashlib.sha256(path.read_bytes()).hexdigest()==expected, "Archive checksum mismatch" with tempfile.TemporaryDirectory(prefix="oxytype-install-") as temporary: directory=pathlib.Path(temporary) - if path.suffix == ".zip": - with zipfile.ZipFile(path) as package: package.extractall(directory) - else: - with tarfile.open(path) as package: package.extractall(directory,filter="data") - binaries = list(directory.rglob("oxytype.exe" if os.name == "nt" else "oxytype")) + with tarfile.open(path) as package: package.extractall(directory,filter="data") + binaries = list(directory.rglob("oxytype")) if len(binaries)!=1: raise RuntimeError("Expected one installed executable") binary=binaries[0] isolated=directory / "empty";isolated.mkdir() @@ -128,8 +119,7 @@ def smoke(path): export=isolated / "history.json" subprocess.run([str(binary),"--config-dir",str(isolated / "config"),"--data-dir",str(isolated / "data"),"--export",str(export)],cwd=isolated,env=env,check=True) assert json.loads(export.read_text())["results"]==[] - if os.name != "nt": - run([sys.executable,"scripts/terminal_pty.py",str(binary),"--release"]) + run([sys.executable,"scripts/terminal_pty.py",str(binary),"--release"]) print("Installed archive: empty PATH/cwd, offline smoke, storage/export and supported POSIX PTY pass") @@ -158,7 +148,7 @@ def gate(): def checksums(directory): - expected=[f"oxytype-v{version()}-{target}"+(".zip" if "windows" in target else ".tar.gz") for target in TARGETS]+[f"oxytype-v{version()}-source.tar.gz"] + expected=[f"oxytype-v{version()}-{target}.tar.gz" for target in TARGETS]+[f"oxytype-v{version()}-source.tar.gz"] rows=[] for name in expected: path=directory / name @@ -181,5 +171,5 @@ def checksums(directory): else: if args.archive is None: if args.target not in TARGETS: parser.error("--archive or a supported --target required") - args.archive=OUT / (f"oxytype-v{version()}-{args.target}"+(".zip" if "windows" in args.target else ".tar.gz")) + args.archive=OUT / f"oxytype-v{version()}-{args.target}.tar.gz" smoke(args.archive.resolve()) diff --git a/scripts/terminal_release_test.py b/scripts/terminal_release_test.py index b8c7e5ddd..8fed9455a 100644 --- a/scripts/terminal_release_test.py +++ b/scripts/terminal_release_test.py @@ -1,31 +1,58 @@ -"""Archive portability regressions using actual files and extraction.""" +"""Archive integrity and supported release targets, using actual files.""" import hashlib import os import pathlib +import tarfile import tempfile import unittest -import zipfile +from unittest.mock import patch from scripts import terminal_release class ArchiveTest(unittest.TestCase): - def test_windows_zip_accepts_registry_epoch_timestamps_without_changing_bytes(self): + def test_tar_preserves_binary_bytes_permissions_and_registry_timestamps(self): with tempfile.TemporaryDirectory() as temporary: directory=pathlib.Path(temporary) / "candidate" directory.mkdir() - source=directory / "LICENSE" - content=b"License bytes\n\x00\xff" + source=directory / "oxytype" + content=b"Binary bytes\n\x00\xff" source.write_bytes(content) - os.utime(source,(1,1)) # Cargo registry files can predate ZIP's 1980 epoch. - archive=terminal_release.archive(directory,windows=True) - with zipfile.ZipFile(archive) as package: - self.assertEqual(package.read("candidate/LICENSE"),content) - self.assertEqual(package.getinfo("candidate/LICENSE").date_time[0],1980) + source.chmod(0o755) + os.utime(source,(1,1)) + archive=terminal_release.archive(directory) + with tarfile.open(archive) as package: + member=package.getmember("candidate/oxytype") + self.assertEqual(package.extractfile(member).read(),content) + self.assertEqual(member.mode,0o755) + self.assertEqual(member.mtime,1) self.assertEqual(source.read_bytes(),content) self.assertEqual(int(source.stat().st_mtime),1) expected=archive.with_name(archive.name+".sha256").read_text().split()[0] self.assertEqual(hashlib.sha256(archive.read_bytes()).hexdigest(),expected) + def test_unsupported_target_rejected_before_building(self): + with patch.object(terminal_release,"run") as run: + with self.assertRaisesRegex(RuntimeError,"Unsupported archive target"): + terminal_release.package("x86_64-pc-windows-msvc") + run.assert_not_called() + + def test_checksums_require_four_unix_binaries_and_corresponding_source(self): + self.assertEqual(set(terminal_release.TARGETS),{ + "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", "aarch64-apple-darwin", + }) + with tempfile.TemporaryDirectory() as temporary: + root=pathlib.Path(temporary) + names=[f"oxytype-v{terminal_release.version()}-{target}.tar.gz" for target in (*terminal_release.TARGETS,"source")] + for name in names: + path=root / name + path.write_bytes(name.encode()) + terminal_release.checksum(path) + terminal_release.checksums(root) + self.assertEqual(len((root / "SHA256SUMS").read_text().splitlines()),5) + (root / names[0]).write_bytes(b"corruption") + with self.assertRaises(AssertionError): terminal_release.checksums(root) + if __name__=="__main__": unittest.main() From b57438613e4ce395791b4f907a884466db5a06cb Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:17:15 +0530 Subject: [PATCH 36/48] test(tui): cover Kitty key encoding fallback and resumed cleanup --- scripts/terminal_pty.py | 168 ++++++++++++++++++++++++++++------------ 1 file changed, 118 insertions(+), 50 deletions(-) diff --git a/scripts/terminal_pty.py b/scripts/terminal_pty.py index 7bedd6863..33bf9d6db 100644 --- a/scripts/terminal_pty.py +++ b/scripts/terminal_pty.py @@ -23,16 +23,16 @@ def dimensions(slave, columns=100, rows=24): class Child: - def __init__(self, binary, failure=None, args=(), enhanced=False): + def __init__(self, binary, failure=None, args=(), enhanced=False, keyboard=None, term="xterm-256color"): self.profile = tempfile.TemporaryDirectory(prefix="oxytype-pty-") self.enhanced = enhanced - self.query_replied = False + self.queries_replied = 0 self.master, self.slave = pty.openpty() dimensions(self.slave) self.original = termios.tcgetattr(self.slave) self.output = bytearray() self.status_path = pathlib.Path(self.profile.name) / "exit-status" - env = dict(os.environ, TERM="xterm-256color", OXYTYPE_PTY_STATUS=str(self.status_path)) + env = dict(os.environ, TERM=term, OXYTYPE_PTY_STATUS=str(self.status_path)) if failure: env["OXYTYPE_TERMINAL_TEST_FAILURE"] = failure @@ -40,7 +40,7 @@ def controlling_terminal(): os.setsid() fcntl.ioctl(self.slave, termios.TIOCSCTTY, 0) - self.process = subprocess.Popen([sys.executable, str(pathlib.Path(__file__).resolve()), "--keeper", binary, "--config-dir", self.profile.name + "/config", "--data-dir", self.profile.name + "/data", "--keyboard", "auto" if enhanced else "basic", *args], stdin=self.slave, stdout=self.slave, + self.process = subprocess.Popen([sys.executable, str(pathlib.Path(__file__).resolve()), "--keeper", binary, "--config-dir", self.profile.name + "/config", "--data-dir", self.profile.name + "/data", "--keyboard", keyboard or ("auto" if enhanced else "basic"), *args], stdin=self.slave, stdout=self.slave, stderr=self.slave, env=env, preexec_fn=controlling_terminal) @@ -48,10 +48,10 @@ def read(self, seconds=0.05): if select.select([self.master], [], [], seconds)[0]: try: self.output.extend(os.read(self.master, 65536)) - if self.enhanced and not self.query_replied and b"\x1b[?u" in self.output: + if self.enhanced and self.output.count(b"\x1b[?u") > self.queries_replied: # Protocol simulation, not a claim of hardware-terminal coverage. self.send(b"\x1b[?0u\x1b[?1;2c") - self.query_replied = True + self.queries_replied += 1 except OSError as error: if error.errno != errno.EIO: raise @@ -68,48 +68,7 @@ def until(self, text, seconds=5, fresh=False): raise AssertionError(f"Missing {text!r}; process={self.process.poll()}; output={bytes(self.output[-2000:])!r}") def screen_text(self): - # Minimal VT screen reader for assertions. Preserve unchanged cells and - # cursor moves between Ratatui frames, unlike stripping ANSI bytes. - grid, row, col = {}, 0, 0 - data = self.output.decode("utf-8", errors="replace") - i = 0 - while i < len(data): - if data[i] == "\x1b": - match = re.match(r"\x1b\[([0-?]*)([ -/]*)([@-~])", data[i:]) - if match: - params, _, command = match.groups() - private = params.startswith("?") - values = [int(v or 0) for v in params.lstrip("?>").split(";") if v.isdigit() or not v] - first = (values or [0])[0] - amount = first or 1 - if command in ("H", "f"): - row = amount - 1 - col = (values[1] or 1) - 1 if len(values) > 1 else 0 - elif command == "G": col = amount - 1 - elif command == "d": row = amount - 1 - elif command == "A": row = max(0, row - amount) - elif command == "B": row += amount - elif command == "C": col += amount - elif command == "D": col = max(0, col - amount) - elif command == "J" and first in (2, 3): grid.clear() - elif command == "K": - for key in list(grid): - if key[0] == row and (first == 2 or (first == 0 and key[1] >= col) or (first == 1 and key[1] <= col)): - del grid[key] - elif command == "h" and private and first == 1049: - grid.clear(); row = col = 0 - i += len(match.group()) - continue - i += 2 - continue - char = data[i] - if char == "\r": col = 0 - elif char == "\n": row += 1 - elif not unicodedata.category(char).startswith("C"): - grid[row, col] = char - col += 0 if unicodedata.combining(char) else (2 if unicodedata.east_asian_width(char) in ("W", "F") else 1) - i += 1 - return "\n".join("".join(grid.get((y, x), " ") for x in range(200)) for y in range(100)) + return screen_text(self.output) def send(self, text): os.write(self.master, text) @@ -150,6 +109,58 @@ def close(self): self.profile.cleanup() +def screen_text(output): + # Minimal VT screen reader for assertions. Preserve unchanged cells and + # cursor moves between Ratatui frames, unlike stripping ANSI bytes. + grid, row, col = {}, 0, 0 + data = output.decode("utf-8", errors="replace") + i = 0 + while i < len(data): + if data[i] == "\x1b": + match = re.match(r"\x1b\[([0-?]*)([ -/]*)([@-~])", data[i:]) + if match: + params, _, command = match.groups() + private = params.startswith("?") + values = [int(v or 0) for v in params.lstrip("?>").split(";") if v.isdigit() or not v] + first = (values or [0])[0] + amount = first or 1 + if command in ("H", "f"): + row = amount - 1 + col = (values[1] or 1) - 1 if len(values) > 1 else 0 + elif command == "G": col = amount - 1 + elif command == "d": row = amount - 1 + elif command == "A": row = max(0, row - amount) + elif command == "B": row += amount + elif command == "C": col += amount + elif command == "D": col = max(0, col - amount) + elif command == "J" and first in (2, 3): grid.clear() + elif command == "K": + for key in list(grid): + if key[0] == row and (first == 2 or (first == 0 and key[1] >= col) or (first == 1 and key[1] <= col)): + del grid[key] + elif command == "h" and private and first == 1049: + grid.clear(); row = col = 0 + i += len(match.group()) + continue + i += 2 + continue + char = data[i] + if char == "\r": col = 0 + elif char == "\n": row += 1 + elif not unicodedata.category(char).startswith("C"): + if unicodedata.category(char).startswith("M"): + previous=col-1 + while previous>0 and grid.get((row,previous))=="": previous-=1 + grid[row,previous]=grid.get((row,previous),"")+char + else: + grid[row,col]=char + width=2 if unicodedata.east_asian_width(char) in ("W","F") else 1 + if width==2: grid[row,col+1]="" # Occupied continuation cell, not a space. + col+=width + i += 1 + return "\n".join("".join(grid.get((y, x), " ") for x in range(200)) for y in range(100)) + + def run(binary, release=False): child = Child(binary) try: @@ -270,8 +281,65 @@ def run(binary, release=False): finally: child.close() + # CSI-u protocol boundaries are shared by Kitty-compatible implementations. + # Deliberately fragment sequences and UTF-8; do not assume one read per key. + child = Child(binary, args=["--mode", "zen", "--record-replay"], enhanced=True) + try: + child.until(b"Input:") + for packet in (b"\x1b[65;2u", b"\x1b[98:66;2u", b"\x1b[33;2u", "e\u0301界😀".encode()): + # A bare ESC is ambiguous with Esc; terminal CSI introducers are + # atomic. Exercise buffering after the introducer and within UTF-8. + if packet.startswith(b"\x1b["): + child.send(packet[:2]) + packet=packet[2:] + for octet in packet: + child.send(bytes([octet])) + time.sleep(0.002) + child.until("Input: AB!e\u0301界😀".encode()) + child.send(b"\x1b[127u\x1b[127;1:3u") # Emoji grapheme once, ignore release. + child.until("Input: AB!e\u0301界".encode()) + child.send(b"\x1b[120;5u\x1b[120;3u") # Ctrl/Alt shortcuts never type x. + child.send(b"\x1b[13;5:3u\x1b[19;1:3~") # Finish releases never finish. + child.send(b"\x1b[97;65u") # Caps-lock modifier does not discard committed text. + child.until("Input: AB!e\u0301界a".encode()) + child.send(b"\x1b[127;5u") # Ctrl+Backspace deletes word through the core. + child.until(b"Input:") + child.send(b"\x1b[122;1u\x1b[122;1:2u\x1b[122;1:3u") + child.until(b"Input: zz") + child.send(b"\x1b[13;5u") + child.until(b"Result saved locally.") + with sqlite3.connect(pathlib.Path(child.profile.name) / "data/history.sqlite3") as db: + saved=json.loads(db.execute("SELECT json FROM results").fetchone()[0]) + assert saved["capabilities"]["releaseObserved"] + assert saved["capabilities"]["repeatObserved"] + assert saved["summary"]["keyDuration"] is None + inputs=[e["data"] for e in saved["replayLog"]["events"] if e["type"]=="input"] + assert inputs[-1]["inputValue"]==[122,122], inputs + assert not any(e.get("data")==[120] for e in inputs), "Shortcut became typing" + child.send(b"\x1b[21~") # F10 legacy functional encoding. + assert child.wait_exit()==0 + child.restored() + finally: + child.close() + + # A graphics-capable or unknown terminal need not support enhanced keys. + # The unanswered bounded query falls back without swallowing ordinary input. + child = Child(binary, args=["--mode", "zen"], keyboard="auto", term="xterm-kitty") + try: + child.until(b"Input:") + assert b"\x1b[>3u" not in child.output + child.send(b"fallback") + child.until(b"Input: fallback") + child.send(b"\x1bOP") + child.until(b"Keyboard: basic") + child.send(b"\x11") + assert child.wait_exit()==0 + child.restored() + finally: + child.close() + for failure in (("signal", "suspend") if release else ("error", "panic", "signal", "suspend")): - child = Child(binary, failure if failure in ("error", "panic") else None) + child = Child(binary, failure if failure in ("error", "panic") else None, enhanced=True) try: if failure in ("error", "panic"): assert child.wait_exit() != 0 @@ -298,7 +366,7 @@ def run(binary, release=False): child.restored() finally: child.close() - print("PTY: five modes, settings/editor, history/export/replay, basic/enhanced protocol, typing/finish/restart/resize/quit, SIGTERM, suspend/resume pass" + ("; debug error/panic cleanup pass" if not release else "")) + print("PTY: five modes, settings/editor, history/export/replay, basic/enhanced/fragmented/fallback protocol, typing/finish/restart/resize/quit, SIGTERM, suspend/resume pass" + ("; debug error/panic cleanup pass" if not release else "")) def keep_session(binary_args): From 407ff9a6e97d654b0756fd26ca2c469a4f523cef Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:19:23 +0530 Subject: [PATCH 37/48] fix(tui): explain Kitty negotiation and basic word-delete fallback --- crates/oxytype-tui/src/main.rs | 2 +- crates/oxytype-tui/src/options.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/oxytype-tui/src/main.rs b/crates/oxytype-tui/src/main.rs index ed8caf51c..6e8b5d85b 100644 --- a/crates/oxytype-tui/src/main.rs +++ b/crates/oxytype-tui/src/main.rs @@ -440,7 +440,7 @@ fn draw(frame: &mut Frame, host: &EngineHost, now: f64, view: View<'_>) { let body = match screen { Screen::Help => vec![ Line::from("F5 new · F6 repeat · F2 settings · F3 history · F4 custom · Esc return"), - Line::from("F8 finish · Backspace delete · Ctrl+Backspace delete word"), + Line::from("F8 finish · Backspace delete · Ctrl/Alt+Backspace delete word"), Line::from("Ctrl+Q / Ctrl+C quit · Ctrl+Z suspend (Unix)"), Line::from("Physical-key timing is unavailable; no key releases are invented."), Line::from(format!( diff --git a/crates/oxytype-tui/src/options.rs b/crates/oxytype-tui/src/options.rs index 66eb90e3c..68ed11e13 100644 --- a/crates/oxytype-tui/src/options.rs +++ b/crates/oxytype-tui/src/options.rs @@ -67,7 +67,7 @@ pub struct Cli { pub funbox: Option>, #[arg(long)] pub capabilities: bool, - /// auto negotiates enhanced events; basic avoids protocol queries. + /// auto negotiates Kitty keyboard events; basic avoids protocol queries. #[arg(long, default_value="auto", value_parser=["auto","basic"])] pub keyboard: String, #[arg(long)] @@ -245,7 +245,7 @@ pub fn validate( } pub fn capabilities() -> String { format!( - "Modes: time, words, quote, custom text/file, zen\nOffline English + original quotes; local v1 packs\nFunboxes: {}\nOther web-only visual/audio/network/layout hooks: unavailable (rejected)\nInput: committed characters, Backspace/word delete, Enter, Tab, repeats\nPhysical-key identity/composition lifecycle: unavailable\nKey duration/spacing/overlap: unavailable; no synthetic releases\nPaste: blocked during tests; unmarked paste cannot be detected\n", + "Platforms: Linux, macOS\nModes: time, words, quote, custom text/file, zen\nOffline English + original quotes; local v1 packs\nFunboxes: {}\nOther web-only visual/audio/network/layout hooks: unavailable (rejected)\nInput: committed characters, Backspace, Enter, Tab, repeats\nKeyboard: auto negotiates Kitty enhancements; --keyboard basic skips queries\nWord delete: Ctrl/Alt+Backspace when distinguishable; try Alt in basic mode\nGraphics: text only; no image protocol required\nPhysical-key identity/composition lifecycle: unavailable\nKey duration/spacing/overlap: unavailable; no synthetic releases\nPaste: blocked during tests; unmarked paste cannot be detected\n", FUNBOXES.join(", ") ) } From b65f0d1d55aecd0d3dd33488e91a075ec398bd71 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:24:45 +0530 Subject: [PATCH 38/48] docs(tui): adopt owner Linux macOS merge and verification scope --- README.md | 5 ++- docs/rust-engine/progress.md | 57 +++++++++++++++++++--------- docs/rust-engine/terminal-release.md | 38 ++++++++----------- docs/rust-engine/terminal.md | 43 +++++++++++++++------ 4 files changed, 90 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index e04861c72..9bb98f778 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,13 @@ behavior against immutable original references. `vp run engine:lint` and ## Offline terminal The native [Ratatui app](./docs/rust-engine/terminal.md) uses the same engine -without Wasm. It includes English words/quotes, five typing modes, settings, +on Linux/macOS without Wasm. It includes English words/quotes, five typing modes, settings, durable local history and JSON export. No account, sync or first-run download. `vp run terminal:dev` runs it; `vp run terminal:test` and `vp run terminal:test:pty` check native behavior and terminal lifecycle. +`vp run terminal:test:emulator -- --emulator kitty --command /usr/bin/kitty` +checks actual Kitty keyboard input in a private Linux display; Ghostty and +Alacritty are also supported by the harness. See the terminal docs for tools. `vp run terminal:package` prepares a licensed binary archive. Installed users need no development runtime. See [release verification/status](./docs/rust-engine/terminal-release.md) for tested platforms and pending real-terminal checks; no release published yet. diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index 476eb367a..c92dea831 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -12,13 +12,13 @@ empty commits to inflate count. Do not publish production or releases implicitly ## Current position -Stage 1 and Stage 2 gates passed. Stage 3 implementation and automated native -packaging gates passed on `stack/rust-engine-terminal`, stacked above -`stack/rust-engine-website`. Required real-terminal verification remains blocked: -this workspace is Linux only and the user confirmed no other test machine is -available. PR #186 stays draft; no manual gate is waived. No release or production -deployment published. Read the final checkpoint below before resuming; earlier -entries are chronological records, not current outstanding work. +Stages 1/2 merged into main (#184/#185). PR #186 now targets main. Owner revised +terminal scope to Linux/macOS only and explicitly requested merge readiness with +macOS manual verification after merge. Windows targets removed; no contributor +or external-machine requirement blocks merge. Real Linux Kitty/Alacritty/Ghostty +regressions and four-target CI are in progress. Release publication remains a +separate authorized action; pending manual checks are never fabricated. +Earlier entries below are chronological evidence, superseded where scope changed. | Phase | Status | Evidence / remaining gate | | --- | --- | --- | @@ -72,20 +72,17 @@ entries are chronological records, not current outstanding work. ## Blockers -Required real Linux enhanced terminal, macOS emulator and Windows Terminal -verification is unperformed. The Linux-only workspace cannot provide the latter -two machines; user confirmed no other machine available. Automated PTY/ConPTY and -all five build/archive jobs pass, but do not establish emulator/font/IME behavior. -Keep terminal-verification.json pending and PR #186 draft. Stage 3 cannot honestly -be declared fully accepted or release-ready until the recorded manual matrix -passes. No remaining implementation or automated failure is established. +No external blocker under the owner's revised merge requirements. macOS manual +checks will be performed by the owner after merge; Windows is out of scope. +Finish the real Linux emulator suite and four-target build/archive CI before +marking #186 ready. Keep unperformed macOS/font/IME checks explicit. Publication +continues to require its separate recorded release gate. ## Resume -Read this file, inventory, contract, decisions, then `vp exec git status --short`. -Resume the real-terminal matrix when actual test access/records become available. -Do not repeat completed implementation or automated checks without source changes -or new evidence. Record actual outcomes; never infer manual passes from CI. +Read current position and latest checkpoint, then `vp exec git status --short`. +Complete the real emulator regressions, document exact versions/capabilities, +verify CI and make PR #186 ready. Do not redo completed engine/website stages. ## Latest implementation evidence @@ -735,3 +732,27 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro [#186 terminal](https://github.com/voltcrash/oxytype/pull/186). First two ready; terminal draft solely for the unmet real-terminal gate. All available independent work is complete; resume that gate when actual machine access/records exist. + + +## Owner-directed merge readiness follow-up + +- PRs #184/#185 merged; integrated origin/main with a merge commit and retargeted + PR #186 to main, preserving all coherent implementation commits. +- Owner removed Windows support entirely. Four release targets now: Linux GNU + x86_64/ARM64, macOS Intel/Apple Silicon. Removed Windows archive/CI machinery; + actual tar integrity, unsupported-target and four-binary/source checks pass. +- Owner will check macOS after merge. Original external-contributor/manual + pre-merge gate is superseded by this explicit instruction. Manual evidence is + still pending; release publication is neither requested nor performed. +- Extended real PTY suite passes CSI-u shifted/Unicode/modifier/repeat/release, + fragmented CSI payload/UTF-8, unknown TERM fallback, and enhanced error/panic/ + signal/suspend cleanup. Query simulation now handles every resume handshake. + The test screen reader correctly joins combining marks and wide continuations. +- Investigated a bare ESC split from its CSI introducer: Crossterm treats an + isolated ESC as Esc immediately. Fragmentation checks retain the atomic CSI + introducer and split its payload/UTF-8; no SSH/transport-wide robustness claim. +- Real Kitty 0.49.2/Alacritty 0.17.0/Ghostty 1.3.1 launch on Linux. Added a private + Xvfb/XTest harness with untouched emulator input/query bytes, native results, + Unicode, repeats, resize/history/editor/replay and terminal cleanup assertions. + Kitty auto/basic pass; Alacritty follow-up still under investigation. Do not + mark the new emulator/CI gates passed until final runs complete. diff --git a/docs/rust-engine/terminal-release.md b/docs/rust-engine/terminal-release.md index 05b83380c..f6f8e16ff 100644 --- a/docs/rust-engine/terminal-release.md +++ b/docs/rust-engine/terminal-release.md @@ -4,13 +4,13 @@ No terminal release has been published by this migration. Candidate archives and their build/smoke evidence must pass review before a maintainer creates a release tag. Website production authorization/workflow remains separate. -`terminal-release.yml` builds/test/smokes five native candidates on PRs touching +`terminal-release.yml` builds/test/smokes four native candidates on PRs touching native/core/build inputs. It does not run for unrelated website edits. Each job uses pinned Rust 1.99.1, the lockfile, vp tasks and a native runner. Core fixtures, -Clippy and terminal tests run before release compilation. POSIX jobs also test -debug lifecycle and extracted release binaries in a real PTY. Windows runs native -tests including actual ConPTY typing/editing/history and headless archive smoke; -Windows Terminal manual input remains separate. +Clippy and terminal tests run before release compilation. Every native job also +tests debug lifecycle and extracted release binaries in a real PTY. Separate +Linux emulator jobs exercise Kitty and Alacritty through actual XTest events in +private Xvfb displays, retaining exact versions and input/output/cleanup evidence. | Candidate target | Runner / conservative requirements | Build/smoke status | | --- | --- | --- | @@ -18,25 +18,19 @@ Windows Terminal manual input remains separate. | aarch64-unknown-linux-gnu | Ubuntu 24.04 ARM; GNU libc 2.39 baseline; not static musl | Pass | | x86_64-apple-darwin | macOS 15 Intel; deployment target explicitly 15.0 | Pass | | aarch64-apple-darwin | macOS 15 Apple Silicon; deployment target explicitly 15.0 | Pass | -| x86_64-pc-windows-msvc | Windows Server 2025 native runner; static CRT | Pass; Windows Terminal manual pending | -All five candidates and corresponding source passed in [run 40068412137](https://github.com/voltcrash/oxytype/actions/runs/40068412137) -for source head `5703c1f10bd949478d3801d027965ac2214f3738`. -[terminal-artifacts.json](terminal-artifacts.json) records downloaded archive sizes -and SHA-256 hashes. All six individual checksums and binary/source revision pairing -were verified with the publishing checksum collector. PR jobs check out GitHub's -merge revision: all binary SOURCE.md files and source SOURCE_REVISION identify -`b62151798e1c1f469d13501a1cf9479a83bdca0b`. These are temporary CI candidates -(14-day retention), not published releases. Documentation-only commits do not -retroactively change this immutable evidence. +The owner narrowed support to Linux/macOS after #184/#185 merged; Windows is out +of scope. The four retained targets passed [run 40068412137](https://github.com/voltcrash/oxytype/actions/runs/40068412137) +under the earlier matrix. [terminal-artifacts.json](terminal-artifacts.json) is an +immutable historical record, including the then-supported Windows archive; it is +not the current target list. Current-scope evidence is recorded in progress.md. Do not claim lower OS/libc versions or untested terminals supported. Runner labels were checked against [GitHub's runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). -Current manual coverage lives in terminal.md and terminal-verification.json. -Only real emulator records can change those pending entries to pass. -The implementation workspace is Linux only; the user confirmed no other test -machine is available. Real Linux enhanced, macOS emulator and Windows Terminal -font/IME/input checks remain unperformed. Publication remains gated. +Linux emulator checks plus four native/archive jobs govern merge readiness. +The owner will perform macOS manual verification after merge. Unperformed +font/IME checks stay pending in terminal-verification.json. This does not block PR +review/merge under the owner's revised scope; publication remains a separate gate. Build locally: `vp run terminal:package -- --target TARGET`; use the matching native toolchain/runner. Archives go under `target/terminal-release/` with individual @@ -60,7 +54,7 @@ published beside binaries. Its `.cargo/config.toml` uses a relative vendor path. Source builds use the pinned toolchain; set OXYTYPE_BUILD_REV from SOURCE_REVISION to retain provenance. No source registry access is needed once tools are installed. -After all five native jobs, source build and the real terminal matrix pass, review +After all four native jobs, emulator jobs, source build and the release matrix pass, review the PR and final source commit. Follow maintainer authorization, update the Cargo workspace version/release notes, then push the matching `terminal-vVERSION` tag. Tag mismatch fails. The tag rebuilds all artifacts, and publication additionally @@ -68,7 +62,7 @@ requires `vp run terminal:release-gate` to accept recorded manual evidence. Publication alone gets contents:write; PR/build jobs have contents:read, checkout credentials are not persisted. No account or deployment secrets are used. -The publishing job verifies all six platform/source checksums and produces +The publishing job verifies all five platform/source checksums and produces SHA256SUMS before creating the GitHub Release. Development PRs and manual workflow runs upload temporary CI candidates only. They do not publish releases or deploy the website. Never bypass the manual gate by marking unperformed checks passed. diff --git a/docs/rust-engine/terminal.md b/docs/rust-engine/terminal.md index 27557b1da..efbbd55d9 100644 --- a/docs/rust-engine/terminal.md +++ b/docs/rust-engine/terminal.md @@ -1,11 +1,11 @@ # Oxytype offline terminal -First native release: offline typing and local history, using the same Rust engine -as the website. No account login, cloud sync, telemetry or first-run download. +First native release: Linux/macOS offline typing and local history, using the same +Rust engine as the website. No account login, cloud sync, telemetry or first-run download. Download the matching `oxytype-vVERSION-TARGET` archive from the repository's GitHub Releases when a maintainer publishes one. Verify its SHA-256 against -`SHA256SUMS`, extract, and put `oxytype` (`oxytype.exe` on Windows) on PATH. +`SHA256SUMS`, extract, and put `oxytype` on PATH. Users need no Rust, Node, vp, source checkout or network access. Keep LICENSE, attribution and third-party notices with redistributed copies. @@ -28,6 +28,7 @@ Binary invocation above is for installed users. Repository development commands: vp run terminal:dev vp run terminal:test vp run terminal:test:pty +vp run terminal:test:emulator -- --emulator kitty --command /usr/bin/kitty vp run terminal:build vp run terminal:package vp run engine:lint @@ -44,7 +45,14 @@ foreground job; resume with the shell. Native elapsed time continues while suspended or too small. Typing is disabled below 60×18; resizing does not pause. Input maps characters, Backspace, Ctrl/Alt+Backspace word deletion, Enter and Tab. -Enhanced keyboard reporting is queried before reading events; fallback is basic. +Ctrl+Backspace can be indistinguishable from Backspace in basic mode; use +Alt+Backspace when the terminal reports that combination. Kitty keyboard reporting +is queried before reading events; fallback is basic. Kitty, Ghostty, Alacritty and +other implementations use the same negotiated protocol, without a terminal-brand +allowlist or reliance on TERM naming. Graphics/image support is independent: this +app renders text and does not require or send Kitty graphics commands. Protocol +reference: [Kitty keyboard](https://sw.kovidgoyal.net/kitty/keyboard-protocol/). + `--keyboard basic` skips queries for troublesome terminals/multiplexers. Modified Enter may be indistinguishable in basic mode; F8 is always available. Repeat deliveries type once each; release events never type. Bracketed paste is blocked @@ -93,14 +101,25 @@ protect prior committed data; a result not yet committed can be lost. | Linux ARM64, POSIX PTY/basic protocol | Five modes, settings/editor, history/export/replay, resize, cleanup, SIGTERM, suspend/resume | Pass | | Linux ARM64, simulated enhanced protocol in PTY | Negotiation, press/repeat/release, owned flag cleanup | Pass; protocol simulation | | Linux x86_64/ARM64 and macOS Intel/Apple Silicon CI PTYs | Native input/editing/history; POSIX lifecycle, resize, suspend/resume, extracted release input | Pass; automated PTY | -| Windows Server 2025 CI ConPTY | Actual native typing/editing, native accuracy, history, quit | Pass; automated ConPTY | | Ratatui TestBackend | Combining/CJK/emoji/tab/newline/cell wrapping/caret/UTF-16 deletion | Pass; simulated display | -| Real Linux enhanced terminal | Rapid input, scrollback, Unicode/IME, resize | Pending | -| Real macOS terminal | Basic/enhanced input, Unicode/IME, resize, suspend | Pending | -| Real Windows Terminal | Input/repeats/releases, Unicode/IME, resize, cleanup | Pending | +| Physical Linux input/IME | Hardware/IME, font appearance and scrollback | Unperformed; not a merge requirement | +| Real macOS terminal | Hardware/IME, font appearance, resize, suspend | Owner will check after merge | Build/smoke archive verification is recorded separately in terminal-release.md. -Cross-platform automated evidence: [run 40068412137](https://github.com/voltcrash/oxytype/actions/runs/40068412137). -A runner build or PTY simulation does not satisfy real emulator/manual coverage. -Do not claim the untested combinations supported until their records exist. -No macOS/Windows test machine is available; the manual entries remain pending. +Real Linux emulator tests run actual Kitty/Ghostty/Alacritty processes with +XTest input in private Xvfb displays. They exercise key encoding, combining/CJK/ +emoji UTF-16/grapheme deletion, repeats/releases, resize/input blocking, native +scores, history/replay/editor and actual keyboard-stack/raw-mode cleanup. They +are automated emulator checks; hardware IME and visual font inspection remain +unperformed. Exact versions/results are recorded in progress.md. + +`terminal:test:emulator` requires Linux Xvfb, xdotool, libX11 and the selected +emulator. It creates its own display, fresh emulator configuration and app profile; +it never targets the user's desktop windows. `--emulator` accepts kitty, alacritty +or ghostty; `--command` names the installed executable. `--evidence DIR` retains +version/capability JSON and private test input/output traces. Distribution Kitty +and Alacritty are checked in native CI; local checks also cover Ghostty. + +Compatibility depends on the implemented text/keyboard protocol, terminal version +and configuration. Do not infer certification of every version/fork from graphics +support alone. Basic text fallback and unavailable observations remain explicit. From a663ed3600c01200039b49db07a19ce5ba25d08c Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:27:45 +0530 Subject: [PATCH 39/48] test(tui): exercise real Linux emulator input and keyboard restoration --- package.json | 1 + scripts/terminal.ts | 11 +- scripts/terminal_emulator.py | 266 +++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 scripts/terminal_emulator.py diff --git a/package.json b/package.json index 36e93510a..3c6261fed 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "terminal:build": "node scripts/terminal.ts build", "terminal:test": "node scripts/terminal.ts test", "terminal:test:pty": "node scripts/terminal.ts pty", + "terminal:test:emulator": "node scripts/terminal.ts emulator", "terminal:package": "node scripts/terminal.ts package", "terminal:source": "node scripts/terminal.ts source", "terminal:archive-smoke": "node scripts/terminal.ts archive-smoke", diff --git a/scripts/terminal.ts b/scripts/terminal.ts index 4804c90f0..df502f16c 100644 --- a/scripts/terminal.ts +++ b/scripts/terminal.ts @@ -33,19 +33,24 @@ const tasks: Record = { build: ["build", "--locked", "--release", "-p", "oxytype-tui", ...args], test: ["test", "--locked", "-p", "oxytype-tui", ...args], pty: ["build", "--locked", "-p", "oxytype-tui"], + emulator: ["build", "--locked", "-p", "oxytype-tui"], }; if (command === undefined || tasks[command] === undefined) { - throw new Error("Usage: vp run terminal:dev|terminal:build|terminal:test"); + throw new Error( + "Usage: vp run terminal:dev|terminal:build|terminal:test|terminal:test:emulator", + ); } const result = spawnSync("cargo", tasks[command], { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit", }); if (result.error !== undefined) throw result.error; -if (command === "pty" && result.status === 0) { +if ((command === "pty" || command === "emulator") && result.status === 0) { const check = spawnSync( "python3", - ["scripts/terminal_pty.py", "target/debug/oxytype"], + command === "pty" + ? ["scripts/terminal_pty.py", "target/debug/oxytype"] + : ["scripts/terminal_emulator.py", ...args.filter((arg) => arg !== "--")], { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit", diff --git a/scripts/terminal_emulator.py b/scripts/terminal_emulator.py new file mode 100644 index 000000000..02fa03709 --- /dev/null +++ b/scripts/terminal_emulator.py @@ -0,0 +1,266 @@ +"""Real Linux X11 emulator checks via XTest; no simulated keyboard replies. + +Starts its own private Xvfb server; requires Xvfb, xdotool and an emulator. +The recorder forwards bytes unchanged through a PTY, retaining the app output +and cleanup evidence. Only test-owned windows, profiles and processes are used. +""" +import argparse +import ctypes +import json +import os +import pathlib +import re +import select +import signal +import sqlite3 +import subprocess +import sys +import tempfile +import termios +import time +import tty +from terminal_pty import Child, dimensions, screen_text + + +def write_all(fd, data): + while data: + count=os.write(fd,data) + data=data[count:] + + +def record(binary, profile, keyboard): + original=termios.tcgetattr(0) + child=Child(binary, args=["--mode","zen","--record-replay"], + keyboard=keyboard, term=os.environ.get("TERM","xterm-256color")) + written=0 + def resized(*_): + size=os.get_terminal_size(0) + dimensions(child.slave,size.columns,size.lines) + try: + tty.setraw(0) + signal.signal(signal.SIGWINCH,resized) + resized() + (profile / "ready.json").write_text(json.dumps({"pid":child.process.pid,"term":os.environ.get("TERM"),"dataDir":child.profile.name+"/data"})) + with (profile / "output.vt").open("wb",buffering=0) as trace, (profile / "input.vt").open("wb",buffering=0) as input_trace: + while not child.status_path.exists(): + child.read(0.01) + if len(child.output)>written: + data=child.output[written:] + trace.write(data) + write_all(1,data) + written=len(child.output) + if select.select([0],[],[],0)[0]: + data=os.read(0,65536) + if not data: raise RuntimeError("Emulator input closed before app exit") + input_trace.write(data) + child.send(data) + status=child.wait_exit() + child.restored() + data=child.output[written:] + trace.write(data) + write_all(1,data) + # Ask the real emulator after the app popped its owned alternate-screen + # keyboard frame. A fresh query must report zero, not leaked flags. + write_all(1,b"\x1b[?u\x1b[c") + reply=bytearray() + deadline=time.monotonic()+2 + while time.monotonic()7,final + assert all(c==ord("x") for c in final[6:]),final + assert zen["capabilities"]["enhancedKeyboardNegotiated"]==(keyboard=="auto") + assert zen["summary"]["keyDuration"] is None + assert custom["summary"]["acc"]==87.5,custom + evidence.append({"emulator":args.emulator,"version":version,"keyboard":keyboard, + "term":json.loads((profile / "ready.json").read_text())["term"], + "exitCode":0,"keyboardFlagsAfterExit":0,"customAccuracy":87.5, + "repeatDeliveries":len(final)-6,"capabilities":zen["capabilities"]}) + print(f"{args.emulator}/{keyboard}: real keys, Unicode/grapheme delete, repeat/release, resize, finish, history/replay/editor, native score, cleanup pass",flush=True) + finally: + if output: + import shutil + shutil.copytree(profile,output / (args.emulator+"-"+keyboard),dirs_exist_ok=True) + if (profile / "ready.json").exists(): + pid=json.loads((profile / "ready.json").read_text())["pid"] + try: os.killpg(pid,signal.SIGCONT);os.killpg(pid,signal.SIGKILL) + except ProcessLookupError: pass + if process.poll() is None: process.terminate() + try: process.wait(timeout=5) + except subprocess.TimeoutExpired: process.kill();process.wait(timeout=5) + if output: (output / (args.emulator+".json")).write_text(json.dumps(evidence,indent=2)+"\n") + + +if __name__=="__main__": + if len(sys.argv)>1 and sys.argv[1]=="--record": + record(sys.argv[2],pathlib.Path(sys.argv[3]),sys.argv[4]) + else: + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument("--emulator",choices=["kitty","alacritty","ghostty"],required=True) + parser.add_argument("--command",required=True) + parser.add_argument("--binary",default="target/debug/oxytype") + parser.add_argument("--xdotool",default="xdotool") + parser.add_argument("--xvfb",default="Xvfb") + parser.add_argument("--evidence") + check(parser.parse_args()) From b147a12f7b9f63a5e6b3b3995733220fbbd97a65 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:28:12 +0530 Subject: [PATCH 40/48] ci(tui): require real Kitty and Alacritty emulator regressions --- .github/workflows/terminal-release.yml | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/terminal-release.yml b/.github/workflows/terminal-release.yml index b16e19642..d468375fd 100644 --- a/.github/workflows/terminal-release.yml +++ b/.github/workflows/terminal-release.yml @@ -87,6 +87,45 @@ jobs: retention-days: 14 if-no-files-found: error + emulators: + name: Linux emulator (${{ matrix.emulator }}) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + emulator: [kitty, alacritty] + env: + LIBGL_ALWAYS_SOFTWARE: "true" + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@v1.21.2 + with: + node-version-file: .node-version + run-install: false + - uses: actions/cache@v5.0.3 + with: + path: | + ~/.cargo/registry + target + key: terminal-emulator-${{ matrix.emulator }}-${{ hashFiles('Cargo.lock', 'rust-toolchain.toml') }} + - name: Install distribution emulator and isolated display tools + run: | + vp exec sudo apt-get update + vp exec sudo apt-get install --no-install-recommends -y ${{ matrix.emulator }} xvfb xdotool fonts-dejavu-core fonts-noto-cjk fonts-noto-color-emoji + - name: Real key encoding, Unicode, resize, history and cleanup + run: vp run terminal:test:emulator -- --emulator ${{ matrix.emulator }} --command /usr/bin/${{ matrix.emulator }} --evidence target/terminal-emulator-evidence + - name: Retain exact emulator versions and test traces + if: always() + uses: actions/upload-artifact@v7.0.2 + with: + name: emulator-${{ matrix.emulator }} + path: target/terminal-emulator-evidence + retention-days: 14 + if-no-files-found: warn + source: name: corresponding source runs-on: ubuntu-24.04 @@ -113,7 +152,7 @@ jobs: publish: name: authorized tagged release if: github.event_name == 'push' && github.ref_type == 'tag' && github.repository == 'voltcrash/oxytype' - needs: [binaries, source] + needs: [binaries, emulators, source] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: From 6523d49cda4cb7cade700892766d8de89a173437 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:32:09 +0530 Subject: [PATCH 41/48] ci(tui): install Alacritty XKB runtime dependency --- .github/workflows/terminal-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/terminal-release.yml b/.github/workflows/terminal-release.yml index d468375fd..a92336c0a 100644 --- a/.github/workflows/terminal-release.yml +++ b/.github/workflows/terminal-release.yml @@ -114,7 +114,7 @@ jobs: - name: Install distribution emulator and isolated display tools run: | vp exec sudo apt-get update - vp exec sudo apt-get install --no-install-recommends -y ${{ matrix.emulator }} xvfb xdotool fonts-dejavu-core fonts-noto-cjk fonts-noto-color-emoji + vp exec sudo apt-get install --no-install-recommends -y ${{ matrix.emulator }} xvfb xdotool libxkbcommon-x11-0 fonts-dejavu-core fonts-noto-cjk fonts-noto-color-emoji - name: Real key encoding, Unicode, resize, history and cleanup run: vp run terminal:test:emulator -- --emulator ${{ matrix.emulator }} --command /usr/bin/${{ matrix.emulator }} --evidence target/terminal-emulator-evidence - name: Retain exact emulator versions and test traces From 1ebdc376c3340298a3e1c6122b29887bfa9379de Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:35:46 +0530 Subject: [PATCH 42/48] fix(tui): forward vp development and build arguments correctly --- scripts/terminal.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/terminal.ts b/scripts/terminal.ts index df502f16c..fb15dc363 100644 --- a/scripts/terminal.ts +++ b/scripts/terminal.ts @@ -3,6 +3,8 @@ import { resolve } from "node:path"; const command = process.argv[2]; const args = process.argv.slice(3); +// vp forwards its separator; strip only that first token, preserving values. +if (args[0] === "--") args.shift(); if (command === "tools-test") { const result = spawnSync( "python3", @@ -18,11 +20,7 @@ if ( ) { const result = spawnSync( "python3", - [ - "scripts/terminal_release.py", - command, - ...args.filter((arg) => arg !== "--"), - ], + ["scripts/terminal_release.py", command, ...args], { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit" }, ); if (result.error !== undefined) throw result.error; @@ -50,7 +48,7 @@ if ((command === "pty" || command === "emulator") && result.status === 0) { "python3", command === "pty" ? ["scripts/terminal_pty.py", "target/debug/oxytype"] - : ["scripts/terminal_emulator.py", ...args.filter((arg) => arg !== "--")], + : ["scripts/terminal_emulator.py", ...args], { cwd: resolve(import.meta.dirname, ".."), stdio: "inherit", From 593ffa7674db11d40fdf250dca5062961a9aee84 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:36:53 +0530 Subject: [PATCH 43/48] test(tui): stabilize emulator keymaps and cover Ghostty defaults --- docs/rust-engine/terminal.md | 3 +- scripts/terminal_emulator.py | 64 ++++++++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/docs/rust-engine/terminal.md b/docs/rust-engine/terminal.md index efbbd55d9..7d7d1523f 100644 --- a/docs/rust-engine/terminal.md +++ b/docs/rust-engine/terminal.md @@ -54,7 +54,8 @@ app renders text and does not require or send Kitty graphics commands. Protocol reference: [Kitty keyboard](https://sw.kovidgoyal.net/kitty/keyboard-protocol/). `--keyboard basic` skips queries for troublesome terminals/multiplexers. Modified -Enter may be indistinguishable in basic mode; F8 is always available. Repeat +Enter may be indistinguishable in basic mode or intercepted by emulator bindings +(e.g. Ghostty fullscreen); use F8 to finish. Repeat deliveries type once each; release events never type. Bracketed paste is blocked during tests. Terminals that send unmarked paste cannot be distinguished from typing. IME committed text works when delivered as ordinary characters; terminal diff --git a/scripts/terminal_emulator.py b/scripts/terminal_emulator.py index 02fa03709..eb8560a7f 100644 --- a/scripts/terminal_emulator.py +++ b/scripts/terminal_emulator.py @@ -86,7 +86,7 @@ def check(args): with tempfile.TemporaryDirectory(prefix="oxytype-xserver-") as temporary: reader,writer=os.pipe() with (pathlib.Path(temporary) / "xserver.log").open("w") as log: - server=subprocess.Popen([args.xvfb,"-displayfd",str(writer),"-screen","0","1280x800x24","-nolisten","tcp"], + server=subprocess.Popen([args.xvfb,"-displayfd",str(writer),"-noreset","-screen","0","1280x800x24","-nolisten","tcp"], pass_fds=[writer],stdout=log,stderr=log) os.close(writer) try: @@ -105,8 +105,11 @@ def check(args): x11.XChangeKeyboardMapping.argtypes=[ctypes.c_void_p,ctypes.c_int,ctypes.c_int,ctypes.POINTER(ctypes.c_ulong),ctypes.c_int] x11.XSync.argtypes=[ctypes.c_void_p,ctypes.c_int] x11.XCloseDisplay.argtypes=[ctypes.c_void_p] - symbols=(ctypes.c_ulong*3)(0x01000301,0x0100754c,0x0101f600) - x11.XChangeKeyboardMapping(display,240,1,symbols,3) + # Use ordinary letter positions: some GLFW versions ignore text + # from unrecognized multimedia scancodes even with Unicode keysyms. + for code,symbol in [(24,0x010000e9),(29,0x01000301),(30,0x0100754c),(31,0x0101f600)]: + symbols=(ctypes.c_ulong*1)(symbol) + x11.XChangeKeyboardMapping(display,code,1,symbols,1) x11.XSync(display,False) x11.XCloseDisplay(display) exercise(args) @@ -157,26 +160,51 @@ def visible(text): wait(lambda: (profile / "output.vt").exists() and text in screen_text((profile / "output.vt").read_bytes()),text) def xdo(*command): return subprocess.run([args.xdotool,*command],capture_output=True,text=True,check=True,timeout=10).stdout.strip() - visible("Input:") - window=xdo("search","--name","^"+title+"$").splitlines()[-1] + # Older Kitty versions defer PTY query handling until their + # window is focused. Focus the mapped window before waiting + # for the app's keyboard handshake and first frame. + window=xdo("search","--sync","--name","^"+title+"$").splitlines()[-1] xdo("windowfocus",window) + visible("Input:") def key(*keys): - # Explicit physical function-key codes avoid duplicate XKB - # F-key aliases (Alt+Fx) selected by xdotool's name lookup. - keys=[str(66+int(k[1:])) if re.fullmatch(r"F\d+",k) else k for k in keys] - xdo("key","--delay","20",*keys) + # XTest physical codes avoid xdotool's duplicate F-key + # aliases and numeric/Unicode name parsing ambiguities. + unicode_codes={"U00E9":24,"U0301":29,"U754C":30,"U1F600":31} + for k in keys: + code=unicode_codes.get(k,66+int(k[1:]) if re.fullmatch(r"F\d+",k) else None) + if code is None: + xdo("key","--delay","20",k) + continue + x11=ctypes.CDLL("libX11.so.6") + x11.XOpenDisplay.restype=ctypes.c_void_p + display=x11.XOpenDisplay(None) + if not display: raise RuntimeError("Private X display closed") + xtst=ctypes.CDLL("libXtst.so.6") + xtst.XTestFakeKeyEvent.argtypes=[ctypes.c_void_p,ctypes.c_uint,ctypes.c_int,ctypes.c_ulong] + assert xtst.XTestFakeKeyEvent(display,code,True,0) + assert xtst.XTestFakeKeyEvent(display,code,False,0) + x11.XSync.argtypes=[ctypes.c_void_p,ctypes.c_int] + x11.XSync(display,False) + x11.XCloseDisplay.argtypes=[ctypes.c_void_p] + x11.XCloseDisplay(display) def type_text(text): xdo("type","--delay","10",text) key("A","B","exclam") visible("Input: AB!") - type_text("e") - key("U0301") - visible("Input: AB!e\u0301") + # GTK consumes a standalone combining-key press as IME + # preedit in Ghostty basic mode. Test its committed accented + # character; all enhanced paths exercise combining clusters. + accent="é" if args.emulator=="ghostty" and keyboard=="basic" else "e\u0301" + if accent=="é": key("U00E9") + else: + type_text("e") + key("U0301") + visible("Input: AB!"+accent) key("U754C") - visible("Input: AB!e\u0301界") + visible("Input: AB!"+accent+"界") key("U1F600") - visible("Input: AB!e\u0301界😀") + visible("Input: AB!"+accent+"界😀") key("BackSpace") - visible("Input: AB!e\u0301界") + visible("Input: AB!"+accent+"界") key("BackSpace","BackSpace") visible("Input: AB!") key("ctrl+BackSpace" if keyboard=="auto" else "alt+BackSpace") @@ -194,7 +222,9 @@ def type_text(text): xdo("type","--delay","10",text) xdo("windowsize",window,"380","180") visible("Resize") type_text("BLOCKED") - key("ctrl+Return" if keyboard=="auto" else "F8") + # Ghostty's default Ctrl+Enter binding toggles fullscreen and + # never reaches the app. F8 is the portable finish binding. + key("ctrl+Return" if keyboard=="auto" and args.emulator!="ghostty" else "F8") # Finish while undersized, then await the durable result before # resizing: XTest delivery is asynchronous, and enlarging early # would allow queued keys to arrive in a correctly enabled UI. @@ -236,7 +266,7 @@ def saved(): evidence.append({"emulator":args.emulator,"version":version,"keyboard":keyboard, "term":json.loads((profile / "ready.json").read_text())["term"], "exitCode":0,"keyboardFlagsAfterExit":0,"customAccuracy":87.5, - "repeatDeliveries":len(final)-6,"capabilities":zen["capabilities"]}) + "repeatDeliveries":len(final)-6,"accentInput":accent,"capabilities":zen["capabilities"]}) print(f"{args.emulator}/{keyboard}: real keys, Unicode/grapheme delete, repeat/release, resize, finish, history/replay/editor, native score, cleanup pass",flush=True) finally: if output: From 1d0f6d94133a132c653f61a8819aa9eb11a0210d Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:41:38 +0530 Subject: [PATCH 44/48] fix(tui): avoid legacy Backspace duplication in enhanced terminals --- crates/oxytype-tui/src/terminal.rs | 8 ++++---- docs/rust-engine/terminal.md | 6 +++++- scripts/terminal_emulator.py | 5 +++-- scripts/terminal_pty.py | 4 ++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/oxytype-tui/src/terminal.rs b/crates/oxytype-tui/src/terminal.rs index 7ac61dbfd..0c7d0a30b 100644 --- a/crates/oxytype-tui/src/terminal.rs +++ b/crates/oxytype-tui/src/terminal.rs @@ -42,10 +42,10 @@ fn initialize(enhanced: &AtomicBool, auto_keyboard: bool) -> std::io::Result3u" in child.output, "enhanced flags not pushed" + assert b"\x1b[>1u" in child.output, "enhanced flags not pushed" child.send(b"\x1b[97;1u\x1b[97;1:2u\x1b[97;1:3u") # press/repeat/release child.until(b"Input: aa") child.send(b"\x1bOP") @@ -327,7 +327,7 @@ def run(binary, release=False): child = Child(binary, args=["--mode", "zen"], keyboard="auto", term="xterm-kitty") try: child.until(b"Input:") - assert b"\x1b[>3u" not in child.output + assert b"\x1b[>1u" not in child.output child.send(b"fallback") child.until(b"Input: fallback") child.send(b"\x1bOP") From b745dc57dfe1d16c9f745c6b32d6ef7060aee216 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:44:00 +0530 Subject: [PATCH 45/48] docs(tui): define owner macOS check after merge and publication gate --- docs/rust-engine/decisions.md | 15 +++++++ docs/rust-engine/terminal-macos-check.md | 45 +++++++++++++++++++++ docs/rust-engine/terminal-release.md | 6 ++- docs/rust-engine/terminal-verification.json | 6 ++- scripts/terminal_release.py | 4 +- scripts/terminal_release_test.py | 15 +++++++ 6 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 docs/rust-engine/terminal-macos-check.md diff --git a/docs/rust-engine/decisions.md b/docs/rust-engine/decisions.md index 74b451520..78efceaf3 100644 --- a/docs/rust-engine/decisions.md +++ b/docs/rust-engine/decisions.md @@ -128,3 +128,18 @@ checks govern merge readiness. Unperformed macOS/IME checks stay pending and are not reported as passed. Release publication remains separately authorized/gated. The application renders text; Kitty graphics support is independent of keyboard protocol support, negotiated without terminal-brand or TERM-name assumptions. + +Linux's former manual/contributor gate is replaced by required real-emulator CI; +macOS is the sole manual publication gate, owned by the user after merge. The +publisher depends on native builds, both Linux emulator jobs and corresponding +source, then checks the nonempty macOS record. Physical Linux IME/font inspection +remains explicitly unperformed; it is not an implicit external tester requirement. + +### Minimal Kitty keyboard enhancement + +Real Kitty 0.32.2 emits duplicate legacy Backspace deliveries when release reports +are requested. The app only needs key disambiguation; auto now requests flag 1, +leaving text/IME on ordinary UTF-8. It does not request event types/all keys/text +association. Repeats remain one insertion per delivery; unobserved event kinds +remain false/null in history. Explicit release/repeat wire fixtures still verify +correct handling if such packets arrive. Core input/scoring rules are unchanged. diff --git a/docs/rust-engine/terminal-macos-check.md b/docs/rust-engine/terminal-macos-check.md new file mode 100644 index 000000000..7b838d1f8 --- /dev/null +++ b/docs/rust-engine/terminal-macos-check.md @@ -0,0 +1,45 @@ +# macOS check after merge + +The owner will run this check after PR #186 merges. It does not block merge. +Linux emulator CI covers the shared keyboard/text paths; macOS native CI covers +both CPU architectures and PTY lifecycle. This check adds actual Mac emulator, +font, function-key and input-method observations. No Windows check is required. + +Use the merged checkout and pinned tools, or the matching native CI/release +candidate. Keep its `--version` output so the source revision is identifiable. +Repository commands below use fresh temporary profiles; select your preferred +Kitty-compatible emulator (Kitty, Ghostty or Alacritty). + +```sh +vp run terminal:dev -- --version +vp run terminal:dev -- --config-dir /tmp/oxytype-mac-check/config --data-dir /tmp/oxytype-mac-check/data --mode custom --text 'one two' +``` + +- Type `one twx`, Backspace, then `o`. Result accuracy should be 87.5%; duration + and WPM reflect your actual typing. F3 opens history; relaunch with the same + profile and confirm persistence. F7 exports from history without overwriting. +- Run words, time, quote and zen (`--mode MODE`); F8 finishes, F5 starts new, + F6 repeats. F2 edits settings, F4 opens custom text; paste is allowed there and + blocked during typing. Use Fn with function keys if macOS reserves them. + Ghostty may capture Ctrl+Enter for fullscreen; F8 remains the finish binding. +- F1 shows enhanced/basic negotiation. Repeat a held key and delete it. Releases + need not be reported: auto requests disambiguation only and physical timing + stays unavailable. `--keyboard basic` checks fallback; try Option/Alt+Backspace + if Ctrl+Backspace is indistinguishable. The emulator's Option-as-Alt setting + determines whether that combination reaches the app. +- Check combining text (`e` plus U+0301), CJK and emoji with your actual input + method and font. Committed text should remain intact; Backspace removes one + grapheme. Note preedit/complex-shaping limitations instead of inferring support. +- Resize below 60×18 and back; typing is blocked while small, clock continues. + Check rapid input and scrollback. Quit normally and with Ctrl+C; verify shell + echo/cursor remain usable. Ctrl+Z then shell `fg` checks suspend/resume. + +Record macOS version, CPU, emulator/version, font, source revision, keyboard mode, +input method, exact cases performed and any failures in a new verification record. +Only after actual success, set `manualTerminals.macOS.status` to `pass` and its +`evidence` to that record in terminal-verification.json. `vp run terminal:release-gate` +then checks it alongside the publishing workflow's native/emulator/source jobs. +A failed case needs investigation; do not mark an unperformed case passed. + +No account, deployment, release tag or publication is required for this check. +Delete the temporary config/data directories afterwards if no longer needed. diff --git a/docs/rust-engine/terminal-release.md b/docs/rust-engine/terminal-release.md index f6f8e16ff..4bec04d46 100644 --- a/docs/rust-engine/terminal-release.md +++ b/docs/rust-engine/terminal-release.md @@ -28,8 +28,10 @@ not the current target list. Current-scope evidence is recorded in progress.md. Do not claim lower OS/libc versions or untested terminals supported. Runner labels were checked against [GitHub's runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Linux emulator checks plus four native/archive jobs govern merge readiness. -The owner will perform macOS manual verification after merge. Unperformed -font/IME checks stay pending in terminal-verification.json. This does not block PR +The owner will perform [macOS manual verification](terminal-macos-check.md) after +merge; that remains the sole manual publication record in terminal-verification.json. +Linux automated emulator jobs replace the former external-contributor requirement. +Unperformed physical IME/font checks remain explicit. This does not block PR review/merge under the owner's revised scope; publication remains a separate gate. Build locally: `vp run terminal:package -- --target TARGET`; use the matching native diff --git a/docs/rust-engine/terminal-verification.json b/docs/rust-engine/terminal-verification.json index 89da0aaad..ad20a4972 100644 --- a/docs/rust-engine/terminal-verification.json +++ b/docs/rust-engine/terminal-verification.json @@ -1,7 +1,9 @@ { "formatVersion": 1, "manualTerminals": { - "linuxEnhanced": { "status": "pending", "evidence": null }, - "macOS": { "status": "pending", "evidence": null } + "macOS": { + "status": "pending", + "evidence": null + } } } diff --git a/scripts/terminal_release.py b/scripts/terminal_release.py index 093922ec4..16228a611 100644 --- a/scripts/terminal_release.py +++ b/scripts/terminal_release.py @@ -143,7 +143,9 @@ def source(): def gate(): matrix=json.loads((ROOT / "docs/rust-engine/terminal-verification.json").read_text()) - pending=[name for name,record in matrix["manualTerminals"].items() if record.get("status")!="pass" or not record.get("evidence")] + if matrix.get("formatVersion")!=1 or set(matrix.get("manualTerminals",{}))!={"macOS"}: + raise RuntimeError("Release verification requires the current macOS manual record; Linux emulator CI is a separate required job") + pending=[name for name,record in matrix["manualTerminals"].items() if record.get("status")!="pass" or not isinstance(record.get("evidence"),str) or not record["evidence"].strip()] if pending: raise RuntimeError("Release manual terminal gate pending: "+", ".join(pending)) diff --git a/scripts/terminal_release_test.py b/scripts/terminal_release_test.py index 8fed9455a..f18896f37 100644 --- a/scripts/terminal_release_test.py +++ b/scripts/terminal_release_test.py @@ -1,5 +1,6 @@ """Archive integrity and supported release targets, using actual files.""" import hashlib +import json import os import pathlib import tarfile @@ -53,6 +54,20 @@ def test_checksums_require_four_unix_binaries_and_corresponding_source(self): (root / names[0]).write_bytes(b"corruption") with self.assertRaises(AssertionError): terminal_release.checksums(root) + def test_release_gate_requires_the_owner_mac_record_and_never_accepts_an_empty_matrix(self): + with tempfile.TemporaryDirectory() as temporary: + root=pathlib.Path(temporary) + matrix=root / "docs/rust-engine/terminal-verification.json" + matrix.parent.mkdir(parents=True) + with patch.object(terminal_release,"ROOT",root): + for record in [{}, {"status":"pending","evidence":None}, {"status":"pass","evidence":" "}]: + matrix.write_text(json.dumps({"formatVersion":1,"manualTerminals":{"macOS":record}})) + with self.assertRaises(RuntimeError): terminal_release.gate() + matrix.write_text(json.dumps({"formatVersion":1,"manualTerminals":{}})) + with self.assertRaisesRegex(RuntimeError,"requires the current macOS"): terminal_release.gate() + matrix.write_text(json.dumps({"formatVersion":1,"manualTerminals":{"macOS":{"status":"pass","evidence":"test record only"}}})) + terminal_release.gate() + if __name__=="__main__": unittest.main() From 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:46:19 +0530 Subject: [PATCH 46/48] test(tui): await mapped emulator windows and stamp binary evidence --- scripts/terminal_emulator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/terminal_emulator.py b/scripts/terminal_emulator.py index 3791ccadc..43dd9a8c4 100644 --- a/scripts/terminal_emulator.py +++ b/scripts/terminal_emulator.py @@ -6,6 +6,7 @@ """ import argparse import ctypes +import hashlib import json import os import pathlib @@ -124,6 +125,8 @@ def exercise(args): emulator=str(pathlib.Path(args.command).resolve()) binary=str(pathlib.Path(args.binary).resolve()) version=subprocess.run([emulator,"--version"],capture_output=True,text=True,check=True,timeout=10).stdout.strip() + binary_version=subprocess.run([binary,"--version"],capture_output=True,text=True,check=True,timeout=10).stdout.strip() + binary_sha256=hashlib.sha256(pathlib.Path(binary).read_bytes()).hexdigest() evidence=[] output=pathlib.Path(args.evidence).resolve() if args.evidence else None if output: output.mkdir(parents=True,exist_ok=True) @@ -163,7 +166,7 @@ def xdo(*command): # Older Kitty versions defer PTY query handling until their # window is focused. Focus the mapped window before waiting # for the app's keyboard handshake and first frame. - window=xdo("search","--sync","--name","^"+title+"$").splitlines()[-1] + window=xdo("search","--sync","--onlyvisible","--name","^"+title+"$").splitlines()[-1] xdo("windowfocus",window) visible("Input:") def key(*keys): @@ -264,7 +267,7 @@ def saved(): assert zen["capabilities"]["enhancedKeyboardNegotiated"]==(keyboard=="auto") assert zen["summary"]["keyDuration"] is None assert custom["summary"]["acc"]==87.5,custom - evidence.append({"emulator":args.emulator,"version":version,"keyboard":keyboard, + evidence.append({"emulator":args.emulator,"version":version,"appVersion":binary_version,"binarySha256":binary_sha256,"keyboard":keyboard, "term":json.loads((profile / "ready.json").read_text())["term"], "exitCode":0,"keyboardFlagsAfterExit":0,"customAccuracy":87.5, "repeatDeliveries":len(final)-6,"accentInput":accent,"capabilities":zen["capabilities"]}) From 60cf7ce55edf628080f778370ca9fe5474d22659 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:48:22 +0530 Subject: [PATCH 47/48] docs(tui): retain eight real emulator compatibility runs --- docs/rust-engine/progress.md | 38 ++++- docs/rust-engine/terminal-emulators.json | 192 +++++++++++++++++++++++ docs/rust-engine/terminal.md | 6 +- 3 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 docs/rust-engine/terminal-emulators.json diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index c92dea831..835f94df2 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -37,9 +37,9 @@ Earlier entries below are chronological evidence, superseded where scope changed | 2.6 TypeScript retirement | Complete | Legacy package removed; full native/Wasm/website/quality CI, fresh build/types and frozen performance gates pass. | | 3.1 Terminal shell | Complete | Native smoke/score test; real PTY typing, finish/restart, resize, error/panic, SIGTERM and suspend/resume pass. | | 3.2 Offline content | Complete | Bundled words/eight original quotes; five modes, CLI/settings/editor, bounded v1 packs; native/full-fixture and actual PTY checks pass. | -| 3.3 Input / Unicode | Implemented; manual gate blocked | Native Unicode/TestBackend, Linux/macOS PTY and Windows ConPTY pass; real emulator/font/IME checks unavailable. | +| 3.3 Input / Unicode | Complete for revised merge scope | Real Kitty 0.32.2/0.49.2, Alacritty 0.17.0 and Ghostty 1.3.1 auto/basic pass; native UTF-16/TestBackend/wire/PTY pass. Mac manual deferred by owner. | | 3.4 Settings / history | Complete | SQLite reopen/concurrency/corruption/retry/export and actual history/replay PTY pass. | -| 3.5 Packaging / release | Automated gates complete; publication gated | All five native candidates and corresponding source pass CI 40068412137; downloaded checksums/source pairing verified. Required manual matrix pending. | +| 3.5 Packaging / release | Current-scope CI active; publication gated | Four Linux/macOS targets and required Linux emulator CI running. Owner Mac manual record required only for later publication. | ## Verification log @@ -756,3 +756,37 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro Unicode, repeats, resize/history/editor/replay and terminal cleanup assertions. Kitty auto/basic pass; Alacritty follow-up still under investigation. Do not mark the new emulator/CI gates passed until final runs complete. + + +## Linux emulator acceptance checkpoint (revised scope) + +- Runtime/harness `6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f`: all eight real + auto/basic runs pass on Kitty 0.32.2 and 0.49.2, Alacritty 0.17.0 and Ghostty + 1.3.1. Exact binary version/hash, host, capabilities, Unicode fixture and repeat + deliveries recorded in terminal-emulators.json. Each run uses an isolated Xvfb + server and fresh app/emulator config; no user windows or profiles are targeted. +- Older Kitty reproduced two legacy Backspace deliveries per action with release + reporting enabled. Fixed native auto negotiation to request disambiguation only + (flag 1), preserving ordinary committed UTF-8. Real old/new Kitty now pass with + one grapheme deleted. No scoring/input rules moved out of shared Rust core. +- All runs verify actual native result/history/replay/editor flow, 87.5% edited + custom accuracy, resize blocking, held repeat deliveries, unavailable physical + timing, raw mode restored and the real terminal's keyboard query returns zero + after exit. Release/repeat kinds remain false when unobserved; explicit CSI-u + release/repeat/fallback/fragmented-payload behavior is still covered by PTY tests. +- Harness fixes: Xvfb -noreset retains the preloaded Unicode keymap; direct XTest + function/Unicode codes avoid xdotool aliases; await mapped/visible focus before + handshake. Unknown multimedia scancodes, dynamic keymap races and async resize + were test-driver problems, not evidence of app typing support or failures. +- Ghostty's default Ctrl+Enter is intercepted for fullscreen; F8 is the portable + finish action. GTK basic standalone combining-key input can remain preedit; + its basic case uses committed é. Enhanced cases verify combining clusters; + hardware/IME/font appearance remains unperformed and is not falsely certified. +- Current local native 21 integration tests, engine Clippy/format, all shared core + tests, enhanced lifecycle PTY, four release-tool/gate regressions and task-wrapper + diagnostics pass. `terminal:release-gate` correctly fails only on pending macOS. +- CI 40085257392 (four native targets, two emulator jobs, source) and full website + CI 40085257400 started at this source. Finish these checks and fix failures; + then record packaging evidence and make #186 ready. Earlier CI failures were + investigated: missing Alacritty XKB runtime, map-before-focus race and old Kitty + key-up duplication. No check skipped or fixture/budget changed to pass. diff --git a/docs/rust-engine/terminal-emulators.json b/docs/rust-engine/terminal-emulators.json new file mode 100644 index 000000000..7da1df696 --- /dev/null +++ b/docs/rust-engine/terminal-emulators.json @@ -0,0 +1,192 @@ +{ + "formatVersion": 1, + "sourceHead": "6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f", + "platform": "Linux-7.1.13-402.asahi.fc44.aarch64+16k-aarch64-with-glibc2.43", + "testHost": "Fedora Asahi Remix 44 / Linux ARM64; private Xvfb 21.1.25 displays; XTest input", + "coverage": [ + "auto/basic negotiation", + "actual emulator text/key encoding", + "native UTF-16/grapheme deletion", + "held-key repeat deliveries", + "resize input blocking", + "native finish and 87.5% edited custom accuracy", + "durable history, editor and replay", + "raw-mode restoration and zero keyboard flags after exit" + ], + "limitations": [ + "Automated emulator checks; no physical keyboard/IME or pixel/font inspection claimed", + "Ghostty basic fixture uses committed é; standalone combining-key preedit is not a committed-text observation", + "Ghostty default Ctrl+Enter is intercepted for fullscreen; F8 is tested instead", + "macOS manual verification is owned by the user after merge" + ], + "runs": [ + { + "emulator": "kitty", + "version": "kitty 0.49.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "auto", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.49.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "basic", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "alacritty", + "version": "alacritty 0.17.0", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "auto", + "term": "alacritty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 6, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "alacritty", + "version": "alacritty 0.17.0", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "basic", + "term": "alacritty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 6, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "ghostty", + "version": "Ghostty 1.3.1-4.fc44\n\nVersion\n - version: 1.3.1-4.fc44\n - channel: tip\nBuild Config\n - Zig version : 0.15.2\n - build mode : .ReleaseFast\n - app runtime : .gtk\n - font engine : .fontconfig_freetype\n - renderer : renderer.generic.Renderer(renderer.OpenGL)\n - libxev : io_uring\n - kernel version: 7.1.13-402.asahi.fc44.aarch64+16k\n - desktop env : other\n - GTK version :\n build : 4.22.4\n runtime : 4.22.5\n - libadwaita : enabled\n build : 1.9.3\n runtime : 1.9.4\n - libX11 : enabled\n - libwayland : enabled", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "auto", + "term": "xterm-ghostty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "ghostty", + "version": "Ghostty 1.3.1-4.fc44\n\nVersion\n - version: 1.3.1-4.fc44\n - channel: tip\nBuild Config\n - Zig version : 0.15.2\n - build mode : .ReleaseFast\n - app runtime : .gtk\n - font engine : .fontconfig_freetype\n - renderer : renderer.generic.Renderer(renderer.OpenGL)\n - libxev : io_uring\n - kernel version: 7.1.13-402.asahi.fc44.aarch64+16k\n - desktop env : other\n - GTK version :\n build : 4.22.4\n runtime : 4.22.5\n - libadwaita : enabled\n build : 1.9.3\n runtime : 1.9.4\n - libX11 : enabled\n - libwayland : enabled", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "basic", + "term": "xterm-ghostty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.32.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "auto", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 6, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.32.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f)", + "binarySha256": "ebd2cb1b7388de9fcc4a100b2e0b311f26c45b7aa1a0b59c1a79b7f4febd6178", + "keyboard": "basic", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + } + ] +} diff --git a/docs/rust-engine/terminal.md b/docs/rust-engine/terminal.md index 08cf08a2b..0110d7531 100644 --- a/docs/rust-engine/terminal.md +++ b/docs/rust-engine/terminal.md @@ -106,6 +106,9 @@ protect prior committed data; a result not yet committed can be lost. | Linux ARM64, POSIX PTY/basic protocol | Five modes, settings/editor, history/export/replay, resize, cleanup, SIGTERM, suspend/resume | Pass | | Linux ARM64, simulated enhanced protocol in PTY | Negotiation, press/repeat/release, owned flag cleanup | Pass; protocol simulation | | Linux x86_64/ARM64 and macOS Intel/Apple Silicon CI PTYs | Native input/editing/history; POSIX lifecycle, resize, suspend/resume, extracted release input | Pass; automated PTY | +| Kitty 0.32.2 and 0.49.2, Linux Xvfb | Auto/basic, real text/key encoding, Unicode, repeats, resize, history/editor/replay, native results, cleanup | Pass; automated real emulator | +| Alacritty 0.17.0, Linux Xvfb | Same auto/basic flow; native UTF-16/grapheme input and cleanup | Pass; automated real emulator | +| Ghostty 1.3.1, Linux Xvfb | Same auto/basic flow; basic uses committed é; F8 avoids default fullscreen binding | Pass; automated real emulator | | Ratatui TestBackend | Combining/CJK/emoji/tab/newline/cell wrapping/caret/UTF-16 deletion | Pass; simulated display | | Physical Linux input/IME | Hardware/IME, font appearance and scrollback | Unperformed; not a merge requirement | | Real macOS terminal | Hardware/IME, font appearance, resize, suspend | Owner will check after merge | @@ -115,7 +118,8 @@ Real Linux emulator tests run actual Kitty/Ghostty/Alacritty processes with XTest input in private Xvfb displays. They exercise key encoding, combining/CJK/ emoji UTF-16/grapheme deletion, repeats/releases, resize/input blocking, native scores, history/replay/editor and actual keyboard-stack/raw-mode cleanup. They -are automated emulator checks; hardware IME and visual font inspection remain +are automated emulator checks; [terminal-emulators.json](terminal-emulators.json) +records exact binary/emulator versions and capabilities. Hardware IME and visual font inspection remain unperformed. Exact versions/results are recorded in progress.md. `terminal:test:emulator` requires Linux Xvfb, xdotool, libX11 and the selected From d8c2c3e76a041e01582cab5cef61dd76e2705033 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sat, 24 Oct 2026 15:58:02 +0530 Subject: [PATCH 48/48] docs(tui): pass four-target merge gates and retain release candidates --- .../terminal-artifacts-40068412137.json | 39 ++++++++ docs/rust-engine/progress.md | 49 ++++++++-- docs/rust-engine/terminal-artifacts.json | 33 +++---- docs/rust-engine/terminal-emulators.json | 94 ++++++++++++++++++- docs/rust-engine/terminal-release.md | 17 +++- docs/rust-engine/terminal.md | 2 +- 6 files changed, 201 insertions(+), 33 deletions(-) create mode 100644 docs/rust-engine/archive/terminal-artifacts-40068412137.json diff --git a/docs/rust-engine/archive/terminal-artifacts-40068412137.json b/docs/rust-engine/archive/terminal-artifacts-40068412137.json new file mode 100644 index 000000000..81a5c7956 --- /dev/null +++ b/docs/rust-engine/archive/terminal-artifacts-40068412137.json @@ -0,0 +1,39 @@ +{ + "formatVersion": 1, + "workflowRun": "https://github.com/voltcrash/oxytype/actions/runs/40068412137", + "sourceHead": "5703c1f10bd949478d3801d027965ac2214f3738", + "artifactSourceRevision": "b62151798e1c1f469d13501a1cf9479a83bdca0b", + "status": "All five native build/test/archive smoke jobs and corresponding-source job passed; downloaded hashes and cross-archive source pairing verified. Candidate CI artifacts only; no release published.", + "artifacts": [ + { + "file": "oxytype-v0.1.0-aarch64-apple-darwin.tar.gz", + "bytes": 1579709, + "sha256": "4cb2dfea5e35ea3564a490c33c60dd92493fb03680ed3a5aa6c9f82213622c48" + }, + { + "file": "oxytype-v0.1.0-aarch64-unknown-linux-gnu.tar.gz", + "bytes": 1635357, + "sha256": "786ec2a486c8de0903e89e261b7112ee41baf73649cbfcc937561dc87fd0502a" + }, + { + "file": "oxytype-v0.1.0-source.tar.gz", + "bytes": 72362837, + "sha256": "5e42065bf844eeb2be2bef9b419ca2b23558f6c57f8425e7f32d1dffcf78fcb9" + }, + { + "file": "oxytype-v0.1.0-x86_64-apple-darwin.tar.gz", + "bytes": 1651285, + "sha256": "9d97b757d2e47886c24e07eb51e1c57b21425535ad713788ede29787664a75a7" + }, + { + "file": "oxytype-v0.1.0-x86_64-pc-windows-msvc.zip", + "bytes": 2077503, + "sha256": "db476da1059a4f1605907d89e7ef0e36b2f5e6b04d8822fff0e6c1fcb700c43b" + }, + { + "file": "oxytype-v0.1.0-x86_64-unknown-linux-gnu.tar.gz", + "bytes": 1640282, + "sha256": "34aa336e8a50b18bc84d07e04ae01657f5192b4dee025bc6cd1869c4134c6db8" + } + ] +} diff --git a/docs/rust-engine/progress.md b/docs/rust-engine/progress.md index 835f94df2..455099921 100644 --- a/docs/rust-engine/progress.md +++ b/docs/rust-engine/progress.md @@ -16,7 +16,7 @@ Stages 1/2 merged into main (#184/#185). PR #186 now targets main. Owner revised terminal scope to Linux/macOS only and explicitly requested merge readiness with macOS manual verification after merge. Windows targets removed; no contributor or external-machine requirement blocks merge. Real Linux Kitty/Alacritty/Ghostty -regressions and four-target CI are in progress. Release publication remains a +regressions, four-target CI and full website CI pass at runtime source 6f6b25f7f. Release publication remains a separate authorized action; pending manual checks are never fabricated. Earlier entries below are chronological evidence, superseded where scope changed. @@ -39,7 +39,7 @@ Earlier entries below are chronological evidence, superseded where scope changed | 3.2 Offline content | Complete | Bundled words/eight original quotes; five modes, CLI/settings/editor, bounded v1 packs; native/full-fixture and actual PTY checks pass. | | 3.3 Input / Unicode | Complete for revised merge scope | Real Kitty 0.32.2/0.49.2, Alacritty 0.17.0 and Ghostty 1.3.1 auto/basic pass; native UTF-16/TestBackend/wire/PTY pass. Mac manual deferred by owner. | | 3.4 Settings / history | Complete | SQLite reopen/concurrency/corruption/retry/export and actual history/replay PTY pass. | -| 3.5 Packaging / release | Current-scope CI active; publication gated | Four Linux/macOS targets and required Linux emulator CI running. Owner Mac manual record required only for later publication. | +| 3.5 Packaging / release | Merge gates complete; publication gated | Four Linux/macOS targets, two real emulator jobs/source pass CI 40085257392; full CI 40085257400 passes. Hash/source pairing verified. Owner Mac manual record remains pending only for publication. | ## Verification log @@ -74,15 +74,17 @@ Earlier entries below are chronological evidence, superseded where scope changed No external blocker under the owner's revised merge requirements. macOS manual checks will be performed by the owner after merge; Windows is out of scope. -Finish the real Linux emulator suite and four-target build/archive CI before -marking #186 ready. Keep unperformed macOS/font/IME checks explicit. Publication -continues to require its separate recorded release gate. +All available merge checks pass at runtime source 6f6b25f7f. macOS manual +verification remains pending for later publication, owned by the user after +merge. No external contributor, Windows machine or physical Linux tester is +required by the revised merge scope. No manual check is falsely marked passed. ## Resume -Read current position and latest checkpoint, then `vp exec git status --short`. -Complete the real emulator regressions, document exact versions/capabilities, -verify CI and make PR #186 ready. Do not redo completed engine/website stages. +Implementation and verification are complete for the revised merge scope. +Only final evidence commits/PR readiness and their CI remain to settle. Do not +redo completed engine/website/native work. After merge, follow terminal-macos-check.md; +publication still requires its actual record and separately authorized version tag. ## Latest implementation evidence @@ -790,3 +792,34 @@ Stage 2 begins on a child branch/PR based on the Stage 1 branch. First work: bro then record packaging evidence and make #186 ready. Earlier CI failures were investigated: missing Alacritty XKB runtime, map-before-focus race and old Kitty key-up duplication. No check skipped or fixture/budget changed to pass. + + +## Current-scope merge acceptance + +- Runtime/harness source `6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f`: + [native CI 40085257392](https://github.com/voltcrash/oxytype/actions/runs/40085257392) + passes all four Linux/macOS native format/Clippy/shared fixtures/terminal tests, + debug and extracted-release PTY checks, packaging and empty-PATH install smoke. + Both real Linux emulator jobs (Kitty 0.32.2 and Alacritty 0.13.2 auto/basic) and + vendored corresponding-source offline build pass. Publication job is skipped + because no authorized tag was pushed. +- [Full website CI 40085257400](https://github.com/voltcrash/oxytype/actions/runs/40085257400) + succeeds: native/Wasm parity, frontend/backend/packages, lint/types/Knip, + Cloudflare SSR/workerd/delivery, all four browser shards and update/rollback. + Final local full format (2,753 files) and Knip pass. Wasm is byte-identical to + Stage 2: 765,852 raw / 303,619 Node gzip6 bytes, SHA-256 + `4b1a0c8d5d006b367888b7cf4c76b104ab5037a7473ff7b46983a9109aba9e3d`. +- Downloaded all four native archives plus source. Every sidecar hash and all + binary/source revision pairs match tested GitHub merge revision + `1258809f217c112e5566f33e803b9a19d6b6aefb`; actual publisher checksum collector + succeeds. terminal-artifacts.json records exact five candidates. Prior Windows + evidence is retained unchanged under archive/terminal-artifacts-40068412137.json, + separate from current supported targets. No release published. +- terminal-emulators.json retains eight exact local runs plus four CI runs, + including binary version/hash and real observed capabilities. Native key-up + fields stay unavailable; explicit release/repeat packet regressions still pass. +- Stage 3 satisfies the owner's revised merge scope. Physical macOS/IME/font + checks are explicitly unperformed; owner will perform them after merge using + terminal-macos-check.md. Linux emulator CI replaces the external tester gate; + macOS is the sole manual publication gate. No requested production deployment, + release publication, account or cloud sync added. diff --git a/docs/rust-engine/terminal-artifacts.json b/docs/rust-engine/terminal-artifacts.json index 81a5c7956..d274718ca 100644 --- a/docs/rust-engine/terminal-artifacts.json +++ b/docs/rust-engine/terminal-artifacts.json @@ -1,39 +1,34 @@ { "formatVersion": 1, - "workflowRun": "https://github.com/voltcrash/oxytype/actions/runs/40068412137", - "sourceHead": "5703c1f10bd949478d3801d027965ac2214f3738", - "artifactSourceRevision": "b62151798e1c1f469d13501a1cf9479a83bdca0b", - "status": "All five native build/test/archive smoke jobs and corresponding-source job passed; downloaded hashes and cross-archive source pairing verified. Candidate CI artifacts only; no release published.", + "workflowRun": "https://github.com/voltcrash/oxytype/actions/runs/40085257392", + "sourceHead": "6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f", + "artifactSourceRevision": "1258809f217c112e5566f33e803b9a19d6b6aefb", + "status": "Four Linux/macOS native build/test/archive smoke jobs, both real Linux emulator jobs and corresponding-source job passed. Downloaded checksums and binary/source revision pairing verified. CI candidates only; no release published.", "artifacts": [ { "file": "oxytype-v0.1.0-aarch64-apple-darwin.tar.gz", - "bytes": 1579709, - "sha256": "4cb2dfea5e35ea3564a490c33c60dd92493fb03680ed3a5aa6c9f82213622c48" + "bytes": 1580840, + "sha256": "626bbbabffabeada070b392f1480ab32378ebe69a7de36bf2ccf081d228abce6" }, { "file": "oxytype-v0.1.0-aarch64-unknown-linux-gnu.tar.gz", - "bytes": 1635357, - "sha256": "786ec2a486c8de0903e89e261b7112ee41baf73649cbfcc937561dc87fd0502a" + "bytes": 1636398, + "sha256": "22d8edd98ed5dcf3491be22636e03d7fe98adaeddd9cf2e712185df224cb0a0d" }, { "file": "oxytype-v0.1.0-source.tar.gz", - "bytes": 72362837, - "sha256": "5e42065bf844eeb2be2bef9b419ca2b23558f6c57f8425e7f32d1dffcf78fcb9" + "bytes": 72373083, + "sha256": "4589632e679d1ff55ee76d52d0a2f9357fb92375b60c5d10fd72b1837faa8464" }, { "file": "oxytype-v0.1.0-x86_64-apple-darwin.tar.gz", - "bytes": 1651285, - "sha256": "9d97b757d2e47886c24e07eb51e1c57b21425535ad713788ede29787664a75a7" - }, - { - "file": "oxytype-v0.1.0-x86_64-pc-windows-msvc.zip", - "bytes": 2077503, - "sha256": "db476da1059a4f1605907d89e7ef0e36b2f5e6b04d8822fff0e6c1fcb700c43b" + "bytes": 1651751, + "sha256": "fa32a8b12023f87c2221f172e916c779b975db1ee92553e72df312e1440c1068" }, { "file": "oxytype-v0.1.0-x86_64-unknown-linux-gnu.tar.gz", - "bytes": 1640282, - "sha256": "34aa336e8a50b18bc84d07e04ae01657f5192b4dee025bc6cd1869c4134c6db8" + "bytes": 1641068, + "sha256": "3cec443a921e6c986f3e3cb1c0c732f0d7c174a8edb3addb252ae70cd05a2c11" } ] } diff --git a/docs/rust-engine/terminal-emulators.json b/docs/rust-engine/terminal-emulators.json index 7da1df696..533ab50ee 100644 --- a/docs/rust-engine/terminal-emulators.json +++ b/docs/rust-engine/terminal-emulators.json @@ -188,5 +188,97 @@ "physicalKeyTiming": false } } - ] + ], + "ciRuns": { + "workflowRun": "https://github.com/voltcrash/oxytype/actions/runs/40085257392", + "sourceHead": "6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f", + "artifactSourceRevision": "1258809f217c112e5566f33e803b9a19d6b6aefb", + "platform": "Ubuntu 24.04 / Linux x86_64; distribution Kitty/Alacritty; private Xvfb/XTest", + "runs": [ + { + "emulator": "alacritty", + "version": "alacritty 0.13.2", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 1258809f217c112e5566f33e803b9a19d6b6aefb)", + "binarySha256": "cf1f6706af8e34ac9836af2566dbfe870fc554a6fcfaa04723be20684f8d0218", + "keyboard": "auto", + "term": "alacritty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "alacritty", + "version": "alacritty 0.13.2", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 1258809f217c112e5566f33e803b9a19d6b6aefb)", + "binarySha256": "cf1f6706af8e34ac9836af2566dbfe870fc554a6fcfaa04723be20684f8d0218", + "keyboard": "basic", + "term": "alacritty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.32.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 1258809f217c112e5566f33e803b9a19d6b6aefb)", + "binarySha256": "cf1f6706af8e34ac9836af2566dbfe870fc554a6fcfaa04723be20684f8d0218", + "keyboard": "auto", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": true, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + }, + { + "emulator": "kitty", + "version": "kitty 0.32.2 created by Kovid Goyal", + "appVersion": "oxytype 0.1.0 (engine 1; log 1; build 1258809f217c112e5566f33e803b9a19d6b6aefb)", + "binarySha256": "cf1f6706af8e34ac9836af2566dbfe870fc554a6fcfaa04723be20684f8d0218", + "keyboard": "basic", + "term": "xterm-kitty", + "exitCode": 0, + "keyboardFlagsAfterExit": 0, + "customAccuracy": 87.5, + "repeatDeliveries": 5, + "accentInput": "é", + "capabilities": { + "enhancedKeyboardNegotiated": false, + "releaseObserved": false, + "repeatObserved": false, + "physicalKeyIdentity": false, + "compositionLifecycle": false, + "physicalKeyTiming": false + } + } + ] + } } diff --git a/docs/rust-engine/terminal-release.md b/docs/rust-engine/terminal-release.md index 4bec04d46..af917c188 100644 --- a/docs/rust-engine/terminal-release.md +++ b/docs/rust-engine/terminal-release.md @@ -19,11 +19,20 @@ private Xvfb displays, retaining exact versions and input/output/cleanup evidenc | x86_64-apple-darwin | macOS 15 Intel; deployment target explicitly 15.0 | Pass | | aarch64-apple-darwin | macOS 15 Apple Silicon; deployment target explicitly 15.0 | Pass | +All four native candidates, both real Linux emulator jobs and corresponding +source passed [run 40085257392](https://github.com/voltcrash/oxytype/actions/runs/40085257392) +for source head `6f6b25f7f4c2dc63958b3c3ccfa84d47676a4f1f`. +[terminal-artifacts.json](terminal-artifacts.json) records exact downloaded sizes +and SHA-256 hashes. All five checksums and binary/source revision pairs were +verified with the publisher's checksum collector. All SOURCE.md/SOURCE_REVISION +files identify GitHub's tested PR merge revision +`1258809f217c112e5566f33e803b9a19d6b6aefb`. +These are temporary CI candidates (14-day retention), not published releases. +Subsequent evidence-only commits do not retroactively change this record. + The owner narrowed support to Linux/macOS after #184/#185 merged; Windows is out -of scope. The four retained targets passed [run 40068412137](https://github.com/voltcrash/oxytype/actions/runs/40068412137) -under the earlier matrix. [terminal-artifacts.json](terminal-artifacts.json) is an -immutable historical record, including the then-supported Windows archive; it is -not the current target list. Current-scope evidence is recorded in progress.md. +of scope. [Earlier five-target evidence](archive/terminal-artifacts-40068412137.json) +is archived unchanged for provenance and is not the supported target list. Do not claim lower OS/libc versions or untested terminals supported. Runner labels were checked against [GitHub's runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). diff --git a/docs/rust-engine/terminal.md b/docs/rust-engine/terminal.md index 0110d7531..adf6f02f3 100644 --- a/docs/rust-engine/terminal.md +++ b/docs/rust-engine/terminal.md @@ -107,7 +107,7 @@ protect prior committed data; a result not yet committed can be lost. | Linux ARM64, simulated enhanced protocol in PTY | Negotiation, press/repeat/release, owned flag cleanup | Pass; protocol simulation | | Linux x86_64/ARM64 and macOS Intel/Apple Silicon CI PTYs | Native input/editing/history; POSIX lifecycle, resize, suspend/resume, extracted release input | Pass; automated PTY | | Kitty 0.32.2 and 0.49.2, Linux Xvfb | Auto/basic, real text/key encoding, Unicode, repeats, resize, history/editor/replay, native results, cleanup | Pass; automated real emulator | -| Alacritty 0.17.0, Linux Xvfb | Same auto/basic flow; native UTF-16/grapheme input and cleanup | Pass; automated real emulator | +| Alacritty 0.13.2 (CI) / 0.17.0, Linux Xvfb | Same auto/basic flow; native UTF-16/grapheme input and cleanup | Pass; automated real emulator | | Ghostty 1.3.1, Linux Xvfb | Same auto/basic flow; basic uses committed é; F8 avoids default fullscreen binding | Pass; automated real emulator | | Ratatui TestBackend | Combining/CJK/emoji/tab/newline/cell wrapping/caret/UTF-16 deletion | Pass; simulated display | | Physical Linux input/IME | Hardware/IME, font appearance and scrollback | Unperformed; not a merge requirement |