diff --git a/.gitignore b/.gitignore index 50b6930..37b1f34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules dist +target .turbo *.log +.DS_Store .agents/skills/grill-me/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..40c6b88 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1019 @@ +# This file is automatically @generated by Cargo. +# 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 = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +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 = "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", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[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", +] + +[[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.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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 = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown", + "portable-atomic", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "opensessions-runtime" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "opensessions-server" +version = "0.1.0" +dependencies = [ + "base64", + "futures-util", + "http", + "opensessions-runtime", + "opensessions-sidebar-core", + "opensessions-sidebar-protocol", + "serde_json", + "sha1_smol", + "tokio", + "tokio-websockets", +] + +[[package]] +name = "opensessions-sidebar" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "crossterm", + "futures-util", + "http", + "opensessions-sidebar-core", + "opensessions-sidebar-protocol", + "ratatui", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tokio-websockets", + "unicode-width", +] + +[[package]] +name = "opensessions-sidebar-core" +version = "0.1.0" +dependencies = [ + "ratatui", + "serde", + "unicode-width", +] + +[[package]] +name = "opensessions-sidebar-protocol" +version = "0.1.0" + +[[package]] +name = "opensessions-sidebar-shim" +version = "0.1.0" +dependencies = [ + "crossterm", + "futures-util", + "opensessions-sidebar-protocol", + "tokio", +] + +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags", + "hashbrown", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[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.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64", + "bytes", + "fastrand", + "futures-core", + "futures-sink", + "http", + "httparse", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-util", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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-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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..bfaebc9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[workspace] +members = [ + "apps/sidebar-shim-rs", + "apps/tui-rs", + "apps/server-rs", + "packages/runtime-rs", + "packages/sidebar-core-rs", + "packages/sidebar-protocol-rs", +] +resolver = "2" + +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" +incremental = false + +[profile.release-dev] +inherits = "release" +opt-level = 3 +strip = false +debug = "line-tables-only" diff --git a/apps/server-rs/Cargo.toml b/apps/server-rs/Cargo.toml new file mode 100644 index 0000000..9e09de3 --- /dev/null +++ b/apps/server-rs/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "opensessions-server" +version = "0.1.0" +edition = "2024" + +[lib] +name = "opensessions_server" +path = "src/lib.rs" + +[[bin]] +name = "opensessions-server" +path = "src/main.rs" + +[dependencies] +opensessions-runtime = { path = "../../packages/runtime-rs" } +opensessions-sidebar-core = { path = "../../packages/sidebar-core-rs" } +opensessions-sidebar-protocol = { path = "../../packages/sidebar-protocol-rs" } +tokio = { version = "1", default-features = false, features = ["rt", "net", "io-util", "macros", "sync", "time"] } +tokio-websockets = { version = "0.13.2", default-features = false, features = ["server", "sha1_smol", "fastrand"] } +futures-util = { version = "0.3", default-features = false, features = ["sink"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +base64 = { version = "0.22", default-features = false, features = ["alloc"] } +sha1_smol = { version = "1", default-features = false } + +[dev-dependencies] +http = "1" +tokio = { version = "1", default-features = false, features = ["rt", "net", "io-util", "macros", "sync", "time"] } +tokio-websockets = { version = "0.13.2", default-features = false, features = ["client", "server", "sha1_smol", "fastrand"] } +futures-util = { version = "0.3", default-features = false, features = ["sink"] } diff --git a/apps/server-rs/src/lib.rs b/apps/server-rs/src/lib.rs new file mode 100644 index 0000000..384a2a8 --- /dev/null +++ b/apps/server-rs/src/lib.rs @@ -0,0 +1,3034 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::Path; +use std::path::PathBuf; +use std::process; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::{Instant, SystemTime}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use futures_util::{SinkExt, StreamExt}; +use opensessions_runtime::agent_watchers::{ + AgentWatcherSnapshot, amp_snapshot_from_thread_json, claude_code_snapshot_from_jsonl, + codex_snapshot_from_jsonl, codex_thread_id_from_path, decode_claude_project_dir, + opencode_snapshot_from_row, parse_codex_session_index, +}; +use opensessions_runtime::git_info::{GitInfo, parse_git_info_output}; +use opensessions_runtime::metadata_store::SessionMetadataStore; +use opensessions_runtime::mux::{MuxProvider, SidebarPosition}; +use opensessions_runtime::pi_runtime_registry::{PiRuntimeRegistry, parse_pi_runtime_info}; +use opensessions_runtime::port_discovery::{PortDiscoveryInput, discover_session_ports}; +use opensessions_runtime::project_dir_session::{ + build_dir_session_map, resolve_session_for_project_dir, +}; +use opensessions_runtime::protocol::{ + AgentEvent, AgentLiveness, AgentStatus, MetadataTone, ServerMessage, SessionFilterMode, +}; +use opensessions_runtime::server_state::{ReadOnlyStateInput, build_read_only_state}; +use opensessions_runtime::session_order::SessionOrder; +use opensessions_runtime::sidebar_coordinator::{SidebarCoordinator, SidebarWidthReportInput}; +use opensessions_runtime::sidebar_width_sync::clamp_sidebar_width; +use opensessions_runtime::tmux_provider::{StdCommandRunner, TmuxProvider}; +use opensessions_runtime::tracker::{AgentTracker, PanePresenceInput}; +use opensessions_sidebar_core::app::App as SidebarApp; +use opensessions_sidebar_core::frame::{FrameDiff, RenderedRows, diff_rows, render_rows}; +use opensessions_sidebar_core::generated::protocol::{ + ClientCommand as SidebarClientCommand, ServerMessage as SidebarServerMessage, +}; +use opensessions_sidebar_core::input::{UiKey, apply_ui_key}; +use opensessions_sidebar_protocol::{ + KeyCode as ShimKeyCode, KeyModifiers as ShimKeyModifiers, ServerToShim, ShimToServer, + decode_shim_message, encode_server_message, +}; +use serde_json::Value; +use sha1_smol::Sha1; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream, UnixListener, UnixStream}; +use tokio::sync::{broadcast, watch}; +use tokio::task::JoinHandle; +use tokio::time::{Duration, MissedTickBehavior}; +use tokio_websockets::{Message, ServerBuilder}; + +pub const SERVER_VERSION: &str = "0.2.0-alpha.5"; +pub const PROTOCOL_VERSION: u16 = 1; +pub const HELLO_JSON: &str = r#"{"type":"hello","protocol":1,"serverVersion":"0.2.0-alpha.5"}"#; +pub const QUIT_JSON: &str = r#"{"type":"quit"}"#; + +const MAX_HTTP_HEADER_BYTES: usize = 16 * 1024; +const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const SIDEBAR_SCRIPTS_DIR: &str = "apps/tui/scripts"; +const GIT_CACHE_TTL_MS: u64 = 5_000; +const PORT_POLL_INTERVAL_MS: u64 = 10_000; +const RENDERED_SIDEBAR_FRAME_MS: u64 = 16; +const AGENT_WATCHER_POLL_MS: u64 = 2_000; +// Mirrors `USER_DRAG_SETTLE_MS` in `packages/runtime/src/server/index.ts`: +// once a width-report is accepted the coordinator stays in UserDrag for this +// many milliseconds, then the next snapshot tick clears it so the sidebar +// stops showing "adjusting…". +const USER_DRAG_SETTLE_MS: u64 = 600; +const AGENT_WATCHER_RECENT_MS: u64 = 5 * 60 * 1000; +const OPENCODE_SQL_TIMEOUT_MS: u64 = 500; +const OPENCODE_SQL_SEP: char = '\u{1f}'; + +/// Append a single debug line to the path in `OPENSESSIONS_DEBUG_LOG` (defaults +/// to `/tmp/opensessions-debug.log`). Use sparingly to trace state-machine +/// transitions in the live tmux A/B harness; the log is rotated by the user +/// (`: > /tmp/opensessions-debug.log`). Set `OPENSESSIONS_DEBUG_LOG=` (empty) +/// to silence. +fn debug_log(line: impl AsRef) { + use std::io::Write; + let path = std::env::var("OPENSESSIONS_DEBUG_LOG") + .unwrap_or_else(|_| "/tmp/opensessions-debug-rs.log".to_string()); + if path.is_empty() { + return; + } + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + if let Ok(mut file) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!(file, "[{now}] [server] {}", line.as_ref()); + } +} + +pub trait StateSource: Send + Sync + 'static { + fn snapshot_json(&self) -> String; + + fn start_background_tasks( + self: Arc, + _state_updates: broadcast::Sender, + _shutdown: broadcast::Sender<()>, + ) -> Vec> { + Vec::new() + } + + fn handle_client_command(&self, _command: &Value) -> Option { + None + } + + fn handle_client_command_with_context( + &self, + command: &Value, + _context: Option<&ClientConnectionContext>, + ) -> Option { + self.handle_client_command(command) + } + + fn handle_sender_command(&self, _command: &Value) -> Option { + None + } + + fn handle_sender_command_with_context( + &self, + command: &Value, + _context: &mut ClientConnectionContext, + ) -> Option { + self.handle_sender_command(command) + } + + fn handle_http_json(&self, _path: &str, _body: &Value) -> Option { + None + } + + fn handle_http_text(&self, _path: &str, _body: &str) -> Option { + None + } + + fn handle_http_hook(&self, _path: &str, _body: &str) {} + + fn should_report_sidebar_resize(&self, _context: &ClientConnectionContext) -> bool { + false + } + + fn handle_agent_event_json(&self, _body: &Value) -> Result { + Err(AgentEventError::CouldNotResolveSession) + } + + fn handle_pi_runtime_upsert(&self, _body: &Value) -> Result<(), PiRuntimeError> { + Err(PiRuntimeError::InvalidPayload) + } + + fn handle_pi_runtime_delete(&self, _body: &Value) -> Result<(), PiRuntimeError> { + Err(PiRuntimeError::MissingPid) + } + + fn handle_switch_index(&self, _index: u32, _body: &str) {} +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ClientConnectionContext { + client_tty: Option, + pane_id: Option, + session_name: Option, + window_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentEventError { + MissingAgent, + InvalidStatus, + CouldNotResolveSession, +} + +impl AgentEventError { + fn status_and_body(self) -> (&'static str, &'static str) { + match self { + Self::MissingAgent => ("400 Bad Request", "missing agent"), + Self::InvalidStatus => ("400 Bad Request", "invalid status"), + Self::CouldNotResolveSession => ("404 Not Found", "could not resolve session"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PiRuntimeError { + InvalidPayload, + MissingPid, +} + +impl PiRuntimeError { + fn body(self) -> &'static str { + match self { + Self::InvalidPayload => "invalid pi runtime payload", + Self::MissingPid => "missing pid", + } + } +} + +impl StateSource for F +where + F: Fn() -> String + Send + Sync + 'static, +{ + fn snapshot_json(&self) -> String { + self() + } +} + +pub trait PortCommandRunner: Send + Sync + 'static { + fn process_rows(&self) -> Vec<(u32, u32)>; + fn lsof_fields(&self) -> String; +} + +pub trait GitCommandRunner: Send + Sync + 'static { + fn git_info_output(&self, dir: &str) -> String; +} + +#[derive(Debug, Default)] +struct SystemPortCommandRunner; + +#[derive(Debug, Default)] +struct SystemGitCommandRunner; + +impl PortCommandRunner for SystemPortCommandRunner { + fn process_rows(&self) -> Vec<(u32, u32)> { + let Ok(output) = process::Command::new("ps") + .args(["-eo", "pid=,ppid="]) + .output() + else { + return Vec::new(); + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_process_row) + .collect() + } + + fn lsof_fields(&self) -> String { + let Ok(output) = process::Command::new("/usr/sbin/lsof") + .args(["-iTCP", "-sTCP:LISTEN", "-nP", "-F", "pn"]) + .output() + else { + return String::new(); + }; + if !output.status.success() { + return String::new(); + } + String::from_utf8_lossy(&output.stdout).to_string() + } +} + +impl GitCommandRunner for SystemGitCommandRunner { + fn git_info_output(&self, dir: &str) -> String { + if dir.is_empty() { + return String::new(); + } + + let Ok(rev_parse) = process::Command::new("git") + .current_dir(dir) + .args(["rev-parse", "--abbrev-ref", "HEAD", "--git-dir"]) + .output() + else { + return String::new(); + }; + if !rev_parse.status.success() { + return String::new(); + } + + let Ok(status) = process::Command::new("git") + .current_dir(dir) + .args(["status", "--porcelain"]) + .output() + else { + return String::new(); + }; + + format!( + "{}\n---\n{}", + String::from_utf8_lossy(&rev_parse.stdout).trim(), + String::from_utf8_lossy(&status.stdout).trim() + ) + } +} + +#[derive(Debug, Clone)] +struct CachedGitInfo { + info: GitInfo, + ts: u64, +} + +#[derive(Debug, Clone)] +struct CachedPortSnapshot { + session_names: Vec, + ports_by_session: HashMap>, + ts: u64, +} + +pub struct ReadOnlyMuxStateSource { + providers: Vec>, + port_command_runner: Arc, + port_snapshot_cache: Mutex>, + git_command_runner: Arc, + git_info_cache: Mutex>, + sidebar_coordinator: Mutex, + sidebar_width: Mutex, + focused_session: Mutex>, + theme: Mutex>, + session_filter: Mutex>, + session_order: Mutex, + metadata_store: Mutex, + agent_tracker: Mutex, + pi_runtime_registry: Mutex, + now_ms: Arc u64 + Send + Sync>, +} + +pub fn default_state_source_from_env( + env: impl Fn(&str) -> Option, +) -> Option { + if env("TMUX").is_some() { + let provider = Arc::new(TmuxProvider::new(Arc::new(StdCommandRunner::default()))); + let mut source = ReadOnlyMuxStateSource::new(vec![provider]); + if let Some(width) = env("OPENSESSIONS_WIDTH").and_then(|width| width.parse::().ok()) { + source = source.with_sidebar_width(clamp_sidebar_width(width) as u32); + } + return Some(source); + } + + None +} + +impl ReadOnlyMuxStateSource { + pub fn new(providers: Vec>) -> Self { + Self { + providers, + port_command_runner: Arc::new(SystemPortCommandRunner), + port_snapshot_cache: Mutex::new(None), + git_command_runner: Arc::new(SystemGitCommandRunner), + git_info_cache: Mutex::new(HashMap::new()), + sidebar_coordinator: Mutex::new(SidebarCoordinator::new(26)), + sidebar_width: Mutex::new(26), + focused_session: Mutex::new(None), + theme: Mutex::new(None), + session_filter: Mutex::new(None), + session_order: Mutex::new(SessionOrder::new(None)), + metadata_store: Mutex::new(SessionMetadataStore::new()), + agent_tracker: Mutex::new(AgentTracker::new()), + pi_runtime_registry: Mutex::new(PiRuntimeRegistry::with_default_ttl()), + now_ms: Arc::new(current_time_ms), + } + } + + pub fn with_sidebar_width(mut self, sidebar_width: u32) -> Self { + self.sidebar_width = Mutex::new(sidebar_width); + self.sidebar_coordinator = Mutex::new(SidebarCoordinator::new(sidebar_width)); + self + } + + pub fn with_now_ms(mut self, now_ms: impl Fn() -> u64 + Send + Sync + 'static) -> Self { + self.now_ms = Arc::new(now_ms); + self + } + + pub fn with_port_command_runner(mut self, runner: Arc) -> Self { + self.port_command_runner = runner; + self + } + + pub fn with_git_command_runner(mut self, runner: Arc) -> Self { + self.git_command_runner = runner; + self + } +} + +impl StateSource for ReadOnlyMuxStateSource { + fn start_background_tasks( + self: Arc, + state_updates: broadcast::Sender, + shutdown: broadcast::Sender<()>, + ) -> Vec> { + vec![ + tokio::spawn(run_agent_watcher_loop( + self.clone(), + state_updates.clone(), + shutdown.clone(), + )), + tokio::spawn(run_drag_settle_loop( + self.clone(), + state_updates.clone(), + shutdown.clone(), + )), + tokio::spawn(run_tmux_state_poll_loop(self, state_updates, shutdown)), + ] + } + + fn snapshot_json(&self) -> String { + let providers = self + .providers + .iter() + .map(|provider| provider.as_ref()) + .collect::>(); + let visible_session_names = self.visible_session_names(); + self.refresh_agent_pane_presence(visible_session_names.as_deref()); + let metadata_by_session = visible_session_names.as_ref().map(|names| { + names + .iter() + .filter_map(|name| { + self.metadata_store + .lock() + .unwrap() + .get(name) + .map(|metadata| (name.clone(), metadata)) + }) + .collect() + }); + let git_by_session = self.git_info_by_session(visible_session_names.as_deref()); + let (agent_state_by_session, agents_by_session, event_timestamps_by_session) = + visible_session_names + .as_ref() + .map(|names| { + let tracker = self.agent_tracker.lock().unwrap(); + let mut states = HashMap::new(); + let mut agents = HashMap::new(); + let mut timestamps = HashMap::new(); + for name in names { + if let Some(state) = tracker.get_state(name) { + states.insert(name.clone(), state); + } + let session_agents = tracker.get_agents(name); + if !session_agents.is_empty() { + agents.insert(name.clone(), session_agents); + } + let session_timestamps = tracker.get_event_timestamps(name); + if !session_timestamps.is_empty() { + timestamps.insert(name.clone(), session_timestamps); + } + } + (Some(states), Some(agents), Some(timestamps)) + }) + .unwrap_or((None, None, None)); + let ports_by_session = self.discover_live_ports(visible_session_names.as_deref()); + let sidebar_state = { + let mut coordinator = self.sidebar_coordinator.lock().unwrap(); + coordinator.tick_user_drag_settle((self.now_ms)(), USER_DRAG_SETTLE_MS); + coordinator.state() + }; + debug_log(format!( + "snapshot_json mode={} init={} authority={:?} width={}", + sidebar_state.mode, + sidebar_state.initializing, + sidebar_state.resize_authority, + sidebar_state.width, + )); + let state = build_read_only_state(ReadOnlyStateInput { + providers, + visible_session_names, + metadata_by_session, + git_by_session, + agent_state_by_session, + agents_by_session, + event_timestamps_by_session, + unseen_sessions: Some(self.agent_tracker.lock().unwrap().get_unseen()), + ports_by_session, + portless_state: None, + focused_session: self.focused_session.lock().unwrap().clone(), + theme: self.theme.lock().unwrap().clone(), + session_filter: *self.session_filter.lock().unwrap(), + sidebar_width: *self.sidebar_width.lock().unwrap(), + initializing: sidebar_state.initializing, + init_label: (!sidebar_state.init_label.is_empty()).then_some(sidebar_state.init_label), + now_ms: (self.now_ms)(), + }); + + serde_json::to_string(&ServerMessage::State(state)).expect("state must serialize") + } + + fn handle_client_command(&self, command: &Value) -> Option { + self.handle_client_command_with_context(command, None) + } + + fn handle_client_command_with_context( + &self, + command: &Value, + context: Option<&ClientConnectionContext>, + ) -> Option { + let provider = self.providers.first()?; + match command.get("type").and_then(Value::as_str)? { + "new-session" => { + provider.create_session(None, None); + Some(self.snapshot_json()) + } + "switch-session" => { + let name = command.get("name")?.as_str()?; + let client_tty = command + .get("clientTty") + .and_then(Value::as_str) + .or_else(|| context.and_then(|context| context.client_tty.as_deref())); + provider.switch_session(name, client_tty); + *self.focused_session.lock().unwrap() = Some(name.to_string()); + // Visiting a session clears its unseen agents (turns ● back + // into ✓). Mirrors `tracker.handleFocus` in + // `packages/runtime/src/server/index.ts:1964`. + let had_unseen = self.agent_tracker.lock().unwrap().handle_focus(name); + if had_unseen { + return Some(self.snapshot_json()); + } + Some(format!( + r#"{{"type":"focus","focusedSession":"{name}","currentSession":"{name}"}}"# + )) + } + "switch-index" => { + let index = command.get("index")?.as_u64()?.min(u32::MAX as u64) as u32; + self.switch_visible_index(index, None); + None + } + "focus-session" => { + let name = command.get("name")?.as_str()?; + *self.focused_session.lock().unwrap() = Some(name.to_string()); + let had_unseen = self.agent_tracker.lock().unwrap().handle_focus(name); + if had_unseen { + return Some(self.snapshot_json()); + } + let current_session = provider.get_current_session(); + Some(format_focus_json(Some(name), current_session.as_deref())) + } + "move-focus" => { + let delta = command.get("delta")?.as_i64()?; + let current_session = provider.get_current_session(); + let focused = self.move_focus(delta, current_session.as_deref())?; + Some(format_focus_json( + Some(&focused), + current_session.as_deref(), + )) + } + "kill-session" => { + let name = command.get("name")?.as_str()?; + provider.kill_session(name); + Some(self.snapshot_json()) + } + "hide-session" => { + let name = command.get("name")?.as_str()?; + self.session_order.lock().unwrap().hide(name); + Some(self.snapshot_json()) + } + "show-all-sessions" => { + self.session_order.lock().unwrap().show_all(); + Some(self.snapshot_json()) + } + "reorder-session" => { + let name = command.get("name")?.as_str()?; + let delta = command.get("delta")?.as_i64()? as i8; + self.session_order.lock().unwrap().reorder(name, delta); + Some(self.snapshot_json()) + } + "set-theme" => { + let theme = command.get("theme")?.as_str()?.to_string(); + *self.theme.lock().unwrap() = Some(theme); + Some(self.snapshot_json()) + } + "set-filter" => { + let filter = match command.get("filter")?.as_str()? { + "all" => SessionFilterMode::All, + "active" => SessionFilterMode::Active, + "running" => SessionFilterMode::Running, + _ => return None, + }; + *self.session_filter.lock().unwrap() = Some(filter); + Some(self.snapshot_json()) + } + "report-width" => { + let width = command.get("width")?.as_u64()?.min(u16::MAX as u64) as u16; + let width = clamp_sidebar_width(width) as u32; + let context = context?; + let current_session = provider.get_current_session(); + let current_window_id = provider.get_current_window_id(); + let is_active_session = + context.session_name.as_deref() == current_session.as_deref(); + let is_current_window = + context.window_id.as_deref() == current_window_id.as_deref(); + let decision = self.sidebar_coordinator.lock().unwrap().apply_width_report( + SidebarWidthReportInput { + width, + session: context.session_name.clone(), + window_id: context.window_id.clone(), + is_active_session, + is_foreground_client: is_active_session && is_current_window, + is_current_window, + now: (self.now_ms)(), + suppress_ms: 500, + }, + ); + debug_log(format!( + "report-width width={width} session={:?} window={:?} \ + active_session={is_active_session} current_window={is_current_window} \ + accepted={} reason={}", + context.session_name, + context.window_id, + decision.accepted, + decision.reason, + )); + if !decision.accepted { + return None; + } + *self.sidebar_width.lock().unwrap() = decision.next_width; + self.enforce_sidebar_width( + decision.next_width.min(u16::MAX as u32) as u16, + context.pane_id.as_deref(), + ); + Some(self.snapshot_json()) + } + "focus-agent-pane" => { + let session = command.get("session")?.as_str()?; + let agent = command.get("agent")?.as_str()?; + let thread_id = command.get("threadId").and_then(Value::as_str); + let thread_name = command.get("threadName").and_then(Value::as_str); + if let Some((provider, pane_id)) = + self.resolve_agent_pane(session, agent, thread_id, thread_name) + { + provider.focus_pane(&pane_id); + } + None + } + "kill-agent-pane" => { + let session = command.get("session")?.as_str()?; + let agent = command.get("agent")?.as_str()?; + let thread_id = command.get("threadId").and_then(Value::as_str); + let thread_name = command.get("threadName").and_then(Value::as_str); + if let Some((provider, pane_id)) = + self.resolve_agent_pane(session, agent, thread_id, thread_name) + { + provider.kill_pane(&pane_id); + } + None + } + _ => None, + } + } + + fn handle_sender_command(&self, command: &Value) -> Option { + self.handle_sender_command_with_context(command, &mut ClientConnectionContext::default()) + } + + fn handle_sender_command_with_context( + &self, + command: &Value, + context: &mut ClientConnectionContext, + ) -> Option { + if command.get("type").and_then(Value::as_str)? != "identify-pane" { + return None; + } + let session_name = command.get("sessionName")?.as_str()?; + if session_name == "_os_stash" { + return None; + } + context.pane_id = command + .get("paneId") + .and_then(Value::as_str) + .map(ToString::to_string); + context.session_name = Some(session_name.to_string()); + context.window_id = command + .get("windowId") + .and_then(Value::as_str) + .map(ToString::to_string); + debug_log(format!( + "identify-pane session={:?} pane={:?} window={:?} -> mark_ready", + context.session_name, context.pane_id, context.window_id, + )); + self.sidebar_coordinator.lock().unwrap().mark_ready(); + let client_tty = self.providers.first()?.get_client_tty(); + Some(format!( + r#"{{"type":"your-session","name":{},"clientTty":{}}}"#, + json_string_or_null(Some(session_name)), + json_string_or_null(Some(&client_tty)), + )) + } + + fn handle_http_json(&self, path: &str, body: &Value) -> Option { + match path { + "/set-status" => { + let session = body.get("session")?.as_str()?; + let tone = body + .get("tone") + .and_then(Value::as_str) + .and_then(parse_metadata_tone); + match body.get("text") { + Some(Value::String(text)) => self + .metadata_store + .lock() + .unwrap() + .set_status(session, Some((text.clone(), tone))), + Some(Value::Null) | None => self + .metadata_store + .lock() + .unwrap() + .set_status(session, None), + _ => return None, + } + } + "/set-progress" => { + let session = body.get("session")?.as_str()?; + if body.get("clear").and_then(Value::as_bool).unwrap_or(false) { + self.metadata_store + .lock() + .unwrap() + .set_progress(session, None); + } else { + self.metadata_store.lock().unwrap().set_progress( + session, + Some(( + body.get("current").and_then(Value::as_u64), + body.get("total").and_then(Value::as_u64), + body.get("percent").and_then(Value::as_f64), + body.get("label") + .and_then(Value::as_str) + .map(ToString::to_string), + )), + ); + } + } + "/log" | "/notify" => { + let session = body.get("session")?.as_str()?; + let message = body.get("message")?.as_str()?.to_string(); + let tone = body + .get("tone") + .and_then(Value::as_str) + .and_then(parse_metadata_tone); + let source = body + .get("source") + .and_then(Value::as_str) + .map(ToString::to_string); + self.metadata_store + .lock() + .unwrap() + .append_log(session, message, tone, source); + } + "/clear-log" => { + let session = body.get("session")?.as_str()?; + self.metadata_store.lock().unwrap().clear_logs(session); + } + _ => return None, + } + Some(self.snapshot_json()) + } + + fn handle_agent_event_json(&self, body: &Value) -> Result { + self.apply_agent_event(body)?; + Ok(self.snapshot_json()) + } + + fn handle_pi_runtime_upsert(&self, body: &Value) -> Result<(), PiRuntimeError> { + let info = + parse_pi_runtime_info(body, (self.now_ms)()).ok_or(PiRuntimeError::InvalidPayload)?; + self.pi_runtime_registry.lock().unwrap().upsert(info); + Ok(()) + } + + fn handle_pi_runtime_delete(&self, body: &Value) -> Result<(), PiRuntimeError> { + let pid = body + .get("pid") + .and_then(Value::as_u64) + .filter(|pid| *pid > 0 && *pid <= u32::MAX as u64) + .ok_or(PiRuntimeError::MissingPid)? as u32; + self.pi_runtime_registry.lock().unwrap().delete(pid); + Ok(()) + } + + fn handle_http_text(&self, path: &str, body: &str) -> Option { + if path != "/focus" { + return None; + } + let name = parse_context_session(body).or_else(|| parse_legacy_focus_session(body))?; + *self.focused_session.lock().unwrap() = Some(name.clone()); + // Visiting (focusing) a session clears its unseen agents — `●` + // (notification) becomes `✓` (done). Mirrors `handleFocus` in + // `packages/runtime/src/server/index.ts`. + let had_unseen = self.agent_tracker.lock().unwrap().handle_focus(&name); + if had_unseen { + return Some(self.snapshot_json()); + } + let current_session = self.providers.first()?.get_current_session(); + Some(format_focus_json(Some(&name), current_session.as_deref())) + } + + fn handle_http_hook(&self, path: &str, body: &str) { + match path { + "/toggle" => self.toggle_sidebar(), + "/ensure-sidebar" => self.ensure_sidebar(body), + "/pane-exited" => { + for provider in &self.providers { + provider.kill_orphaned_sidebar_panes(); + } + } + "/suppress-width-reports" => { + self.sidebar_coordinator + .lock() + .unwrap() + .suppress_width_reports((self.now_ms)() + 500); + } + "/client-resized" => { + let now = (self.now_ms)(); + self.sidebar_coordinator + .lock() + .unwrap() + .begin_client_resize_sync(now + 500, now + 700); + let width = (*self.sidebar_width.lock().unwrap()).min(u16::MAX as u32) as u16; + self.enforce_sidebar_width(width, None); + self.sidebar_coordinator + .lock() + .unwrap() + .finish_client_resize_sync(); + } + _ => {} + } + } + + fn should_report_sidebar_resize(&self, context: &ClientConnectionContext) -> bool { + let Some(session_name) = context.session_name.as_deref() else { + return false; + }; + let Some(window_id) = context.window_id.as_deref() else { + return false; + }; + let Some(provider) = self.providers.first() else { + return false; + }; + provider.get_current_session().as_deref() == Some(session_name) + && provider.get_current_window_id().as_deref() == Some(window_id) + } + + fn handle_switch_index(&self, index: u32, body: &str) { + let client_tty = parse_context(body).and_then(|context| context.client_tty); + self.switch_visible_index(index, client_tty.as_deref()); + } +} + +impl ReadOnlyMuxStateSource { + fn refresh_agent_pane_presence(&self, visible_session_names: Option<&[String]>) { + let visible = + visible_session_names.map(|names| names.iter().cloned().collect::>()); + let mut tracker = self.agent_tracker.lock().unwrap(); + for provider in &self.providers { + for session in provider.list_sessions() { + if visible + .as_ref() + .is_some_and(|visible| !visible.contains(&session.name)) + { + continue; + } + let panes = provider + .list_agent_panes(&session.name) + .into_iter() + .map(|pane| PanePresenceInput { + agent: pane.agent, + pane_id: pane.pane_id, + thread_id: pane.thread_id, + thread_name: pane.thread_name, + }) + .collect::>(); + tracker.apply_pane_presence(&session.name, panes); + } + } + } + + fn apply_agent_event(&self, body: &Value) -> Result<(), AgentEventError> { + let agent = body + .get("agent") + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or(AgentEventError::MissingAgent)?; + let status = body + .get("status") + .and_then(Value::as_str) + .and_then(parse_agent_status) + .ok_or(AgentEventError::InvalidStatus)?; + let session = self + .resolve_agent_event_session(body) + .ok_or(AgentEventError::CouldNotResolveSession)?; + let ts = body + .get("ts") + .and_then(Value::as_u64) + .unwrap_or_else(|| (self.now_ms)()); + self.agent_tracker.lock().unwrap().apply_event(AgentEvent { + agent, + session, + status, + ts, + thread_id: body + .get("threadId") + .and_then(Value::as_str) + .map(ToString::to_string), + thread_name: body + .get("threadName") + .and_then(Value::as_str) + .map(ToString::to_string), + unseen: None, + pane_id: None, + liveness: None, + }); + Ok(()) + } + + fn apply_agent_watcher_snapshot(&self, snapshot: AgentWatcherSnapshot) -> bool { + if snapshot.status == AgentStatus::Idle { + return false; + } + let Some(session) = self.resolve_agent_watcher_session(&snapshot) else { + return false; + }; + self.agent_tracker.lock().unwrap().apply_event(AgentEvent { + agent: snapshot.agent.to_string(), + session, + status: snapshot.status, + ts: snapshot.ts, + thread_id: snapshot.thread_id, + thread_name: snapshot.thread_name, + unseen: None, + pane_id: None, + liveness: None, + }); + true + } + + fn resolve_agent_watcher_session(&self, snapshot: &AgentWatcherSnapshot) -> Option { + let sessions = self + .providers + .iter() + .flat_map(|provider| provider.list_sessions()) + .collect::>(); + let project_dir = snapshot.project_dir.as_deref()?; + + if let Some(encoded) = project_dir.strip_prefix("__encoded__:") { + return sessions + .iter() + .find(|session| encode_agent_project_dir(&session.dir) == encoded) + .map(|session| session.name.clone()); + } + + let dir_session_map = build_dir_session_map( + sessions + .into_iter() + .map(|session| (session.name, session.dir)), + ); + resolve_session_for_project_dir(project_dir, &dir_session_map) + } + + fn resolve_agent_event_session(&self, body: &Value) -> Option { + let sessions = self + .providers + .iter() + .flat_map(|provider| provider.list_sessions()) + .collect::>(); + + if let Some(tmux_session) = body.get("tmuxSession").and_then(Value::as_str) { + if sessions.iter().any(|session| session.name == tmux_session) { + return Some(tmux_session.to_string()); + } + } + + let project_dir = body.get("projectDir")?.as_str()?; + let dir_session_map = build_dir_session_map( + sessions + .into_iter() + .map(|session| (session.name, session.dir)), + ); + resolve_session_for_project_dir(project_dir, &dir_session_map) + } + + fn resolve_agent_pane( + &self, + session: &str, + agent: &str, + thread_id: Option<&str>, + thread_name: Option<&str>, + ) -> Option<(Arc, String)> { + let provider = self.provider_for_session(session)?; + if let Some(pane_id) = self.resolve_tracked_agent_pane(session, agent, thread_id) { + return Some((provider, pane_id)); + } + let pane_id = provider.resolve_agent_pane_id(session, agent, thread_id, thread_name)?; + Some((provider, pane_id)) + } + + fn resolve_tracked_agent_pane( + &self, + session: &str, + agent: &str, + thread_id: Option<&str>, + ) -> Option { + let thread_id = thread_id?; + self.agent_tracker + .lock() + .unwrap() + .get_agents(session) + .into_iter() + .find(|event| { + event.agent == agent + && event.thread_id.as_deref() == Some(thread_id) + && event.liveness == Some(AgentLiveness::Alive) + && event.pane_id.is_some() + }) + .and_then(|event| event.pane_id) + } + + fn enforce_sidebar_width(&self, width: u16, except_pane_id: Option<&str>) { + for provider in &self.providers { + if !provider.is_sidebar_capable() { + continue; + } + for pane in provider.list_sidebar_panes(None) { + if except_pane_id == Some(pane.pane_id.as_str()) { + continue; + } + provider.resize_sidebar_pane(&pane.pane_id, width); + } + } + } + + fn provider_for_session(&self, session: &str) -> Option> { + self.providers + .iter() + .find(|provider| { + provider + .list_sessions() + .iter() + .any(|mux_session| mux_session.name == session) + }) + .cloned() + .or_else(|| self.providers.first().cloned()) + } + + fn git_info_by_session( + &self, + visible_session_names: Option<&[String]>, + ) -> Option> { + let visible = + visible_session_names.map(|names| names.iter().cloned().collect::>()); + let mut git_by_session = HashMap::new(); + for provider in &self.providers { + for session in provider.list_sessions() { + if visible + .as_ref() + .is_some_and(|visible| !visible.contains(&session.name)) + { + continue; + } + git_by_session.insert(session.name, self.git_info_for_dir(&session.dir)); + } + } + Some(git_by_session) + } + + fn git_info_for_dir(&self, dir: &str) -> GitInfo { + if dir.is_empty() { + return GitInfo::empty(); + } + + let now = (self.now_ms)(); + if let Some(cached) = self.git_info_cache.lock().unwrap().get(dir).cloned() { + if now.saturating_sub(cached.ts) < GIT_CACHE_TTL_MS { + return cached.info; + } + } + + let output = self.git_command_runner.git_info_output(dir); + if output.is_empty() { + return GitInfo::empty(); + } + let info = parse_git_info_output(&output); + self.git_info_cache.lock().unwrap().insert( + dir.to_string(), + CachedGitInfo { + info: info.clone(), + ts: now, + }, + ); + info + } + + fn discover_live_ports( + &self, + visible_session_names: Option<&[String]>, + ) -> Option>> { + let session_names = visible_session_names + .map(|names| names.to_vec()) + .unwrap_or_else(|| self.sorted_session_names()); + let now = (self.now_ms)(); + if let Some(cached) = self.port_snapshot_cache.lock().unwrap().clone() { + if cached.session_names == session_names + && now.saturating_sub(cached.ts) < PORT_POLL_INTERVAL_MS + { + return Some(cached.ports_by_session); + } + } + + if session_names.is_empty() { + return Some(HashMap::new()); + } + + let session_filter = session_names.iter().cloned().collect::>(); + let mut pane_pids_by_session = HashMap::new(); + for provider in &self.providers { + for session in provider.list_sessions() { + if !session_filter.contains(&session.name) { + continue; + } + let pids = provider.get_session_pane_pids(&session.name); + if !pids.is_empty() { + pane_pids_by_session.insert(session.name, pids); + } + } + } + + if pane_pids_by_session.is_empty() { + return Some(discover_session_ports(PortDiscoveryInput { + session_names, + pane_pids_by_session, + process_rows: Vec::new(), + lsof_fields: "", + })); + } + + let lsof_fields = self.port_command_runner.lsof_fields(); + let cache_session_names = session_names.clone(); + let ports_by_session = discover_session_ports(PortDiscoveryInput { + session_names, + pane_pids_by_session, + process_rows: self.port_command_runner.process_rows(), + lsof_fields: &lsof_fields, + }); + self.port_snapshot_cache + .lock() + .unwrap() + .replace(CachedPortSnapshot { + session_names: cache_session_names, + ports_by_session: ports_by_session.clone(), + ts: now, + }); + Some(ports_by_session) + } + + fn toggle_sidebar(&self) { + let providers = self + .providers + .iter() + .filter(|provider| provider.is_full_sidebar_capable()) + .collect::>(); + let panes_by_provider = providers + .iter() + .map(|provider| (*provider, provider.list_sidebar_panes(None))) + .collect::>(); + + if panes_by_provider.iter().any(|(_, panes)| !panes.is_empty()) { + for (provider, panes) in panes_by_provider { + for pane in panes { + provider.hide_sidebar(&pane.pane_id); + } + } + self.sidebar_coordinator.lock().unwrap().hide(); + return; + } + + self.sidebar_coordinator.lock().unwrap().begin_warmup(); + let width = (*self.sidebar_width.lock().unwrap()).min(u16::MAX as u32) as u16; + for provider in providers { + for window in provider.list_active_windows() { + debug_log(format!( + "toggle_sidebar: spawning in session={} window={} width={width}", + window.session_name, window.id, + )); + provider.spawn_sidebar( + &window.session_name, + &window.id, + width, + SidebarPosition::Left, + SIDEBAR_SCRIPTS_DIR, + ); + } + } + } + + fn ensure_sidebar(&self, body: &str) { + let context = parse_context(body); + for provider in &self.providers { + if !provider.is_full_sidebar_capable() { + continue; + } + let session_name = context + .as_ref() + .map(|context| context.session.clone()) + .or_else(|| provider.get_current_session()); + let window_id = context + .as_ref() + .map(|context| context.window_id.clone()) + .or_else(|| provider.get_current_window_id()); + let (Some(session_name), Some(window_id)) = (session_name, window_id) else { + continue; + }; + if provider + .list_sidebar_panes(None) + .iter() + .any(|pane| pane.window_id == window_id) + { + continue; + } + self.sidebar_coordinator.lock().unwrap().begin_warmup(); + let width = (*self.sidebar_width.lock().unwrap()).min(u16::MAX as u32) as u16; + provider.spawn_sidebar( + &session_name, + &window_id, + width, + SidebarPosition::Left, + SIDEBAR_SCRIPTS_DIR, + ); + } + } + + fn switch_visible_index(&self, index: u32, client_tty: Option<&str>) { + let Some(provider) = self.providers.first() else { + return; + }; + let Some(target_index) = index.checked_sub(1).map(|index| index as usize) else { + return; + }; + let Some(name) = self + .visible_session_names() + .and_then(|names| names.get(target_index).cloned()) + else { + return; + }; + provider.switch_session(&name, client_tty); + } + + fn move_focus(&self, delta: i64, current_session: Option<&str>) -> Option { + let mut names = self.visible_session_names()?; + if names.is_empty() { + *self.focused_session.lock().unwrap() = None; + return None; + } + + let focused = self + .focused_session + .lock() + .unwrap() + .clone() + .or_else(|| current_session.map(ToString::to_string)); + let current_idx = focused + .and_then(|focused| names.iter().position(|name| name == &focused)) + .unwrap_or(0); + let max_idx = names.len() - 1; + let next_idx = (current_idx as i64 + delta).clamp(0, max_idx as i64) as usize; + let next = names.swap_remove(next_idx); + *self.focused_session.lock().unwrap() = Some(next.clone()); + Some(next) + } + + fn visible_session_names(&self) -> Option> { + let names = self.sorted_session_names(); + let mut session_order = self.session_order.lock().unwrap(); + session_order.sync(names.clone()); + if let Some(current_session) = self + .providers + .iter() + .find_map(|provider| provider.get_current_session()) + { + session_order.show(¤t_session); + } + Some(session_order.apply(names)) + } + + fn sorted_session_names(&self) -> Vec { + let mut sessions = self + .providers + .iter() + .flat_map(|provider| provider.list_sessions()) + .collect::>(); + sessions.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.name.cmp(&b.name)) + }); + sessions.into_iter().map(|session| session.name).collect() + } +} + +/// Background ticker that polls the sidebar coordinator's UserDrag settle +/// state and broadcasts a fresh snapshot the moment the settle window +/// expires. Without this loop the websocket only sends an "adjusting…" +/// state when the width report is processed and never sends the cleared +/// "ready" state once the user stops resizing. Mirrors the +/// `setTimeout(USER_DRAG_SETTLE_MS)` callback in +/// `packages/runtime/src/server/index.ts::startTransientSidebarResize`. +async fn run_drag_settle_loop( + source: Arc, + state_updates: broadcast::Sender, + shutdown: broadcast::Sender<()>, +) { + let mut shutdown_rx = shutdown.subscribe(); + let mut interval = tokio::time::interval(Duration::from_millis(100)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = shutdown_rx.recv() => return, + _ = interval.tick() => { + let now = (source.now_ms)(); + let cleared = { + let mut coordinator = source.sidebar_coordinator.lock().unwrap(); + let was_drag = coordinator.state().resize_authority + == opensessions_runtime::sidebar_coordinator::SidebarResizeAuthority::UserDrag; + coordinator.tick_user_drag_settle(now, USER_DRAG_SETTLE_MS); + let is_drag = coordinator.state().resize_authority + == opensessions_runtime::sidebar_coordinator::SidebarResizeAuthority::UserDrag; + was_drag && !is_drag + }; + if cleared { + debug_log("drag_settle_loop: UserDrag cleared, broadcasting fresh state"); + let _ = state_updates.send(source.snapshot_json()); + } + } + } + } +} + +/// Poll tmux state on a fixed cadence and broadcast a fresh snapshot whenever +/// the JSON differs from the last broadcast. Mirrors the periodic +/// session/window/pane refresh in `packages/runtime/src/server/index.ts`'s +/// `setInterval` so the sidebar picks up new sessions, agent panes, focus +/// changes, and width updates without requiring an explicit hook. +async fn run_tmux_state_poll_loop( + source: Arc, + state_updates: broadcast::Sender, + shutdown: broadcast::Sender<()>, +) { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut shutdown_rx = shutdown.subscribe(); + let mut interval = tokio::time::interval(Duration::from_millis(500)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + // Seed `last_hash` from the current state so the first tick does not + // broadcast an unprovoked snapshot. Subsequent broadcasts only happen + // when something other than `ts` actually changes. + let mut last_hash: u64 = { + let mut hasher = DefaultHasher::new(); + strip_ts_field(&source.snapshot_json()).hash(&mut hasher); + hasher.finish() + }; + // Track the last observed current session so we can clear unseen-agent + // flags whenever the user moves into a different tmux session externally + // (e.g. via `tmux switch-client`). This complements the inline + // `handle_focus` calls in switch-session / focus-session / `/focus` + // command handlers. + let mut last_current_session: Option = source + .providers + .first() + .and_then(|provider| provider.get_current_session()); + + loop { + tokio::select! { + _ = shutdown_rx.recv() => return, + _ = interval.tick() => { + // Re-enforce the configured sidebar width on every tick so + // tmux pane resizes by other clients are corrected. Mirrors + // `enforceSidebarWidth` in the TS server's tick. + let width = (*source.sidebar_width.lock().unwrap()).min(u16::MAX as u32) as u16; + source.enforce_sidebar_width(width, None); + + // Visiting (= becoming the current tmux session) clears the + // unseen-agents notification dot for that session, so `●` + // turns back into `✓`. Mirrors `tracker.handleFocus` in TS + // (`packages/runtime/src/server/index.ts`). + let current_session = source + .providers + .first() + .and_then(|provider| provider.get_current_session()); + if current_session != last_current_session { + if let Some(name) = current_session.as_deref() { + source.agent_tracker.lock().unwrap().handle_focus(name); + } + last_current_session = current_session; + } + + let snapshot = source.snapshot_json(); + // Hash the snapshot ignoring the per-tick `ts` field so that + // identical state on consecutive ticks does not trigger a + // wasteful re-broadcast. Anything else changing (sessions, + // panes, widths, init state, focus) flips the hash and the + // sidebar receives a fresh state. + let stripped = strip_ts_field(&snapshot); + let mut hasher = DefaultHasher::new(); + stripped.hash(&mut hasher); + let hash = hasher.finish(); + if hash != last_hash { + last_hash = hash; + debug_log("tmux_state_poll_loop: state changed, broadcasting"); + let _ = state_updates.send(snapshot); + } + } + } + } +} + +/// Remove `,"ts":\d+` (or leading variant) from a JSON snapshot string so a +/// monotonically increasing timestamp does not defeat the change-detection +/// hash in `run_tmux_state_poll_loop`. Cheap byte scan; no full JSON parse. +fn strip_ts_field(snapshot: &str) -> String { + let mut out = String::with_capacity(snapshot.len()); + let bytes = snapshot.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let rest = &snapshot[i..]; + let key = "\"ts\":"; + if rest.starts_with(key) + || rest.starts_with(&format!(",{key}")) + || rest.starts_with(&format!("{{{key}")) + { + // Preserve a leading `,` or `{` while dropping the rest of the + // `"ts":` token. + let mut prefix_len = 0; + if rest.starts_with(',') || rest.starts_with('{') { + prefix_len = 1; + out.push(rest.chars().next().unwrap()); + } + // Skip past `"ts":` + let mut j = i + prefix_len + key.len(); + // Skip digits. + while j < bytes.len() && bytes[j].is_ascii_digit() { + j += 1; + } + // If we left a leading `,`, also drop a trailing `,` to avoid + // doubling separators when ts was sandwiched. + if prefix_len == 1 && bytes.get(i) == Some(&b',') && bytes.get(j) == Some(&b',') { + j += 1; + } + i = j; + continue; + } + out.push(snapshot[i..].chars().next().unwrap()); + i += snapshot[i..].chars().next().unwrap().len_utf8(); + } + out +} + +async fn run_agent_watcher_loop( + source: Arc, + state_updates: broadcast::Sender, + shutdown: broadcast::Sender<()>, +) { + let mut shutdown_rx = shutdown.subscribe(); + let mut interval = tokio::time::interval(Duration::from_millis(AGENT_WATCHER_POLL_MS)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut last_seen = HashMap::::new(); + + loop { + tokio::select! { + _ = shutdown_rx.recv() => return, + _ = interval.tick() => { + let now = current_time_ms(); + let snapshots = tokio::task::spawn_blocking(move || scan_agent_watcher_snapshots(now)) + .await + .unwrap_or_default(); + debug_log(format!( + "agent_watcher_loop: tick scanned {} snapshots", + snapshots.len() + )); + for snapshot in snapshots { + if snapshot.status == AgentStatus::Idle { + continue; + } + let key = agent_watcher_key(&snapshot); + let fingerprint = AgentWatcherFingerprint::from(&snapshot); + if last_seen.get(&key) == Some(&fingerprint) { + continue; + } + let agent = snapshot.agent.to_string(); + let status = snapshot.status; + let thread_name = snapshot.thread_name.clone(); + if source.apply_agent_watcher_snapshot(snapshot) { + debug_log(format!( + "agent_watcher_loop: applied snapshot agent={agent} status={status:?} thread={thread_name:?}", + )); + last_seen.insert(key, fingerprint); + let _ = state_updates.send(source.snapshot_json()); + } else { + debug_log(format!( + "agent_watcher_loop: dropped snapshot agent={agent} status={status:?} (no matching session)", + )); + } + } + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentWatcherFingerprint { + status: AgentStatus, + thread_name: Option, + project_dir: Option, +} + +impl From<&AgentWatcherSnapshot> for AgentWatcherFingerprint { + fn from(snapshot: &AgentWatcherSnapshot) -> Self { + Self { + status: snapshot.status, + thread_name: snapshot.thread_name.clone(), + project_dir: snapshot.project_dir.clone(), + } + } +} + +fn agent_watcher_key(snapshot: &AgentWatcherSnapshot) -> String { + format!( + "{}\0{}", + snapshot.agent, + snapshot + .thread_id + .as_deref() + .or(snapshot.project_dir.as_deref()) + .unwrap_or_default(), + ) +} + +fn scan_agent_watcher_snapshots(now_ms: u64) -> Vec { + let mut snapshots = Vec::new(); + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { + return snapshots; + }; + + scan_amp_threads(&home, now_ms, &mut snapshots); + scan_claude_code_projects(&home, now_ms, &mut snapshots); + scan_codex_sessions(&home, now_ms, &mut snapshots); + scan_opencode_sessions(&home, now_ms, &mut snapshots); + snapshots +} + +fn scan_amp_threads(home: &Path, now_ms: u64, snapshots: &mut Vec) { + let threads_dir = home.join(".local/share/amp/threads"); + let Ok(entries) = fs::read_dir(threads_dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + let Some(mtime_ms) = file_mtime_ms(&path) else { + continue; + }; + if now_ms.saturating_sub(mtime_ms) > AGENT_WATCHER_RECENT_MS { + continue; + } + let Ok(raw) = fs::read_to_string(&path) else { + continue; + }; + if let Some(snapshot) = amp_snapshot_from_thread_json(&raw, mtime_ms) { + snapshots.push(snapshot); + } + } +} + +fn scan_claude_code_projects(home: &Path, now_ms: u64, snapshots: &mut Vec) { + let projects_dir = home.join(".claude/projects"); + let Ok(projects) = fs::read_dir(projects_dir) else { + return; + }; + + for project in projects.flatten() { + let project_path = project.path(); + if !project_path.is_dir() { + continue; + } + let encoded = project.file_name().to_string_lossy().to_string(); + let project_dir = decode_claude_project_dir(&encoded, |path| Path::new(path).is_dir()); + let Ok(files) = fs::read_dir(project_path) else { + continue; + }; + for file in files.flatten() { + let path = file.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + let Some(mtime_ms) = file_mtime_ms(&path) else { + continue; + }; + if now_ms.saturating_sub(mtime_ms) > AGENT_WATCHER_RECENT_MS { + continue; + } + let Some(thread_id) = path.file_stem().and_then(|stem| stem.to_str()) else { + continue; + }; + let Ok(raw) = fs::read_to_string(&path) else { + continue; + }; + if let Some(snapshot) = + claude_code_snapshot_from_jsonl(thread_id, &project_dir, &raw, mtime_ms, now_ms) + { + snapshots.push(snapshot); + } + } + } +} + +fn scan_codex_sessions(home: &Path, now_ms: u64, snapshots: &mut Vec) { + let codex_home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")); + let sessions_dir = codex_home.join("sessions"); + let names = fs::read_to_string(codex_home.join("session_index.jsonl")) + .ok() + .map(|raw| { + parse_codex_session_index(&raw) + .into_iter() + .collect::>() + }) + .unwrap_or_default(); + + for path in collect_jsonl_files(&sessions_dir) { + let Some(mtime_ms) = file_mtime_ms(&path) else { + continue; + }; + if now_ms.saturating_sub(mtime_ms) > AGENT_WATCHER_RECENT_MS { + continue; + } + let Some(path_text) = path.to_str() else { + continue; + }; + let thread_id = codex_thread_id_from_path(path_text); + let Ok(raw) = fs::read_to_string(&path) else { + continue; + }; + if let Some(snapshot) = codex_snapshot_from_jsonl( + &thread_id, + &raw, + names.get(&thread_id).map(String::as_str), + mtime_ms, + now_ms, + ) { + snapshots.push(snapshot); + } + } +} + +fn scan_opencode_sessions(home: &Path, now_ms: u64, snapshots: &mut Vec) { + let db_path = std::env::var_os("OPENCODE_DB_PATH") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/share/opencode/opencode.db")); + if !db_path.exists() { + return; + } + + let stale_threshold = now_ms.saturating_sub(AGENT_WATCHER_RECENT_MS); + let query = format!( + "WITH recent AS MATERIALIZED (SELECT id, title, directory, time_updated FROM session WHERE time_updated > {stale_threshold} ORDER BY time_updated DESC LIMIT 50) SELECT r.id, ifnull(r.title,''), r.directory, r.time_updated, ifnull((SELECT m.data FROM message m WHERE m.session_id = r.id ORDER BY m.time_created DESC LIMIT 1),'') FROM recent r ORDER BY r.time_updated DESC;" + ); + let mut command = process::Command::new("sqlite3"); + command + .arg("-readonly") + .arg("-separator") + .arg(OPENCODE_SQL_SEP.to_string()) + .arg(&db_path) + .arg(query); + let Some(output) = + run_process_with_timeout(command, Duration::from_millis(OPENCODE_SQL_TIMEOUT_MS)) + else { + return; + }; + if !output.status.success() { + return; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let parts = line.split(OPENCODE_SQL_SEP).collect::>(); + if parts.len() < 5 || parts[4].is_empty() { + continue; + } + let time_updated = parts[3].parse::().unwrap_or(now_ms); + if let Some(snapshot) = opencode_snapshot_from_row( + parts[0], + (!parts[1].is_empty()).then_some(parts[1]), + parts[2], + time_updated, + parts[4], + now_ms, + ) { + snapshots.push(snapshot); + } + } +} + +fn run_process_with_timeout( + mut command: process::Command, + timeout: Duration, +) -> Option { + let mut child = command + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::piped()) + .spawn() + .ok()?; + let started = Instant::now(); + + loop { + if child.try_wait().ok()?.is_some() { + return child.wait_with_output().ok(); + } + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn collect_jsonl_files(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut files = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + files.extend(collect_jsonl_files(&path)); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { + files.push(path); + } + } + files +} + +fn file_mtime_ms(path: &Path) -> Option { + fs::metadata(path) + .ok()? + .modified() + .ok()? + .duration_since(SystemTime::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as u64) +} + +fn encode_agent_project_dir(path: &str) -> String { + path.chars() + .map(|ch| match ch { + '/' | '.' | '_' => '-', + ch => ch, + }) + .collect() +} + +fn current_time_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn format_focus_json(focused_session: Option<&str>, current_session: Option<&str>) -> String { + format!( + r#"{{"type":"focus","focusedSession":{},"currentSession":{}}}"#, + json_string_or_null(focused_session), + json_string_or_null(current_session), + ) +} + +fn json_string_or_null(value: Option<&str>) -> String { + value + .map(|value| serde_json::to_string(value).expect("string must serialize")) + .unwrap_or_else(|| "null".to_string()) +} + +fn parse_metadata_tone(value: &str) -> Option { + match value { + "neutral" => Some(MetadataTone::Neutral), + "info" => Some(MetadataTone::Info), + "success" => Some(MetadataTone::Success), + "warn" => Some(MetadataTone::Warn), + "error" => Some(MetadataTone::Error), + _ => None, + } +} + +fn parse_agent_status(value: &str) -> Option { + match value { + "idle" => Some(AgentStatus::Idle), + "running" => Some(AgentStatus::Running), + "tool-running" => Some(AgentStatus::ToolRunning), + "done" => Some(AgentStatus::Done), + "error" => Some(AgentStatus::Error), + "waiting" => Some(AgentStatus::Waiting), + "interrupted" => Some(AgentStatus::Interrupted), + "stale" => Some(AgentStatus::Stale), + _ => None, + } +} + +fn parse_process_row(line: &str) -> Option<(u32, u32)> { + let mut parts = line.split_whitespace(); + let pid = parts.next()?.parse::().ok()?; + let ppid = parts.next()?.parse::().ok()?; + Some((pid, ppid)) +} + +struct HttpContext { + client_tty: Option, + session: String, + window_id: String, +} + +fn parse_context(body: &str) -> Option { + let trimmed = trim_context_quotes(body); + let pipe_parts = trimmed.split('|').collect::>(); + if pipe_parts.len() == 3 && !pipe_parts[1].is_empty() && !pipe_parts[2].is_empty() { + return Some(HttpContext { + client_tty: (!pipe_parts[0].is_empty()).then(|| pipe_parts[0].to_string()), + session: pipe_parts[1].to_string(), + window_id: pipe_parts[2].to_string(), + }); + } + + let colon_idx = trimmed.find(':')?; + if colon_idx < 1 { + return None; + } + let session = &trimmed[..colon_idx]; + let window_id = &trimmed[colon_idx + 1..]; + (!session.is_empty() && !window_id.is_empty()).then(|| HttpContext { + client_tty: None, + session: session.to_string(), + window_id: window_id.to_string(), + }) +} + +fn parse_context_session(body: &str) -> Option { + parse_context(body).map(|context| context.session) +} + +fn parse_legacy_focus_session(body: &str) -> Option { + let name = trim_double_quotes(body.trim()); + (!name.is_empty()).then(|| name.to_string()) +} + +fn trim_context_quotes(value: &str) -> &str { + trim_single_quotes(trim_double_quotes(value.trim())) +} + +fn trim_double_quotes(value: &str) -> &str { + value.trim_matches('"') +} + +fn trim_single_quotes(value: &str) -> &str { + value.trim_matches('\'') +} + +#[derive(Clone)] +pub struct ServerConfig { + pub host: String, + pub port: u16, + pub pid_file: PathBuf, + shim_socket_path: Option, + state_source: Option>, +} + +impl ServerConfig { + pub fn new(host: impl Into, port: u16, pid_file: impl Into) -> Self { + Self { + host: host.into(), + port, + pid_file: pid_file.into(), + shim_socket_path: None, + state_source: None, + } + } + + pub fn with_shim_socket_path(mut self, path: impl Into) -> Self { + self.shim_socket_path = Some(path.into()); + self + } + + pub fn with_state_source(mut self, source: impl StateSource) -> Self { + self.state_source = Some(Arc::new(source)); + self + } +} + +#[derive(Debug)] +pub struct ServerHandle { + addr: SocketAddr, + shim_socket_path: Option, + shutdown: broadcast::Sender<()>, + task: JoinHandle>, +} + +impl ServerHandle { + pub fn addr(&self) -> SocketAddr { + self.addr + } + + pub fn shim_socket_path(&self) -> Option<&std::path::Path> { + self.shim_socket_path.as_deref() + } + + pub async fn shutdown(self) -> Result<(), ServerError> { + let _ = self.shutdown.send(()); + self.wait_shutdown().await + } + + pub async fn wait_shutdown(self) -> Result<(), ServerError> { + self.task.await.map_err(ServerError::from)? + } +} + +#[derive(Debug, Clone)] +pub struct ServerError { + message: String, +} + +impl ServerError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl std::fmt::Display for ServerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for ServerError {} + +impl From for ServerError { + fn from(value: std::io::Error) -> Self { + Self::new(value.to_string()) + } +} + +impl From for ServerError { + fn from(value: tokio_websockets::Error) -> Self { + Self::new(value.to_string()) + } +} + +impl From for ServerError { + fn from(value: tokio::task::JoinError) -> Self { + Self::new(value.to_string()) + } +} + +pub async fn start_server(config: ServerConfig) -> Result { + let bind_addr = (config.host.as_str(), config.port) + .to_socket_addrs()? + .next() + .ok_or_else(|| ServerError::new("server bind address did not resolve"))?; + let listener = TcpListener::bind(bind_addr).await?; + let addr = listener.local_addr()?; + let shim_socket_path = config + .shim_socket_path + .clone() + .unwrap_or_else(|| default_shim_socket_path(&config.pid_file)); + if shim_socket_path.exists() { + fs::remove_file(&shim_socket_path)?; + } + let shim_listener = UnixListener::bind(&shim_socket_path)?; + + fs::write(&config.pid_file, process::id().to_string())?; + + let (shutdown, shutdown_rx) = broadcast::channel(1); + let (state_updates, _) = broadcast::channel(16); + if let Some(source) = config.state_source.clone() { + let _background_tasks = + source.start_background_tasks(state_updates.clone(), shutdown.clone()); + } + let task_shutdown = shutdown.clone(); + let state_source = config.state_source.clone(); + let cleanup_shim_socket_path = shim_socket_path.clone(); + let task = tokio::spawn(async move { + let result = run_accept_loop( + listener, + task_shutdown, + shutdown_rx, + state_source, + state_updates, + shim_listener, + ) + .await; + let cleanup_result = fs::remove_file(&config.pid_file); + let socket_cleanup_result = fs::remove_file(&cleanup_shim_socket_path); + match (result, cleanup_result, socket_cleanup_result) { + (Err(err), _, _) => Err(err), + (Ok(()), Err(err), _) if err.kind() != std::io::ErrorKind::NotFound => Err(err.into()), + (Ok(()), _, Err(err)) if err.kind() != std::io::ErrorKind::NotFound => Err(err.into()), + _ => Ok(()), + } + }); + + Ok(ServerHandle { + addr, + shim_socket_path: Some(shim_socket_path), + shutdown, + task, + }) +} + +fn default_shim_socket_path(pid_file: &std::path::Path) -> PathBuf { + let candidate = pid_file.with_extension("sock"); + if candidate.as_os_str().len() < 90 { + return candidate; + } + + let mut hash = 0_u32; + for (idx, byte) in pid_file.to_string_lossy().bytes().enumerate() { + hash = (hash + u32::from(byte) * (idx as u32 + 1)) % 100_000; + } + std::env::temp_dir().join(format!("opensessions-{hash}.sock")) +} + +async fn run_accept_loop( + listener: TcpListener, + shutdown: broadcast::Sender<()>, + mut shutdown_rx: broadcast::Receiver<()>, + state_source: Option>, + state_updates: broadcast::Sender, + shim_listener: UnixListener, +) -> Result<(), ServerError> { + loop { + tokio::select! { + _ = shutdown_rx.recv() => return Ok(()), + accepted = listener.accept() => { + let (stream, _) = accepted?; + let connection_shutdown = shutdown.clone(); + let connection_state_source = state_source.clone(); + let connection_state_updates = state_updates.clone(); + tokio::spawn(async move { + let _ = handle_connection( + stream, + connection_shutdown, + connection_state_source, + connection_state_updates, + ) + .await; + }); + } + accepted = shim_listener.accept() => { + let (stream, _) = accepted?; + let connection_shutdown = shutdown.clone(); + let connection_state_source = state_source.clone(); + let connection_state_updates = state_updates.clone(); + tokio::spawn(async move { + let _ = handle_shim_connection( + stream, + connection_shutdown, + connection_state_source, + connection_state_updates, + ) + .await; + }); + } + } + } +} + +async fn handle_shim_connection( + stream: UnixStream, + shutdown: broadcast::Sender<()>, + state_source: Option>, + state_updates: broadcast::Sender, +) -> Result<(), ServerError> { + let (mut reader, mut writer) = stream.into_split(); + let first = read_protocol_frame(&mut reader).await?; + let ShimToServer::Hello(hello) = decode_shim_message(&first) + .map_err(|err| ServerError::new(format!("invalid shim hello: {err}")))? + else { + return Err(ServerError::new("shim must send hello first")); + }; + + writer + .write_all(&encode_server_message(&ServerToShim::Hello { + protocol: PROTOCOL_VERSION, + })) + .await?; + let (frames_tx, frames_rx) = watch::channel(Arc::new(Vec::::new())); + let _writer_task = tokio::spawn(write_shim_frames(writer, frames_rx)); + + let mut context = ClientConnectionContext { + client_tty: hello.client_tty.clone(), + pane_id: Some(hello.pane_id.clone()), + session_name: Some(hello.session_name.clone()), + window_id: hello.window_id.clone(), + }; + let identify = serde_json::json!({ + "type": "identify-pane", + "paneId": hello.pane_id, + "sessionName": hello.session_name, + "windowId": hello.window_id, + }); + + let mut app = state_source.as_ref().and_then(|state_source| { + let _ = state_source.handle_sender_command_with_context(&identify, &mut context); + app_from_state_json(&state_source.snapshot_json()) + }); + if let Some(state_source) = &state_source { + let _ = state_updates.send(state_source.snapshot_json()); + } + if let Some(app) = &mut app { + app.my_session = context.session_name.clone(); + } + + let mut width = hello.width; + let mut height = hello.height; + let mut previous_rows = None::; + let mut seq = 0_u32; + if let Some(app) = &mut app { + seq = seq.wrapping_add(1); + let rows = render_rows(app, width, height); + frames_tx.send_replace(Arc::new(encode_server_message(&ServerToShim::FullFrame { + seq, + width, + height, + rows: rows.rows.clone(), + }))); + previous_rows = Some(rows); + } + + let mut connection_shutdown = shutdown.subscribe(); + let mut state_rx = state_updates.subscribe(); + let mut render_tick = tokio::time::interval(Duration::from_millis(RENDERED_SIDEBAR_FRAME_MS)); + render_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut dirty = false; + + loop { + tokio::select! { + _ = connection_shutdown.recv() => { + frames_tx.send_replace(Arc::new(encode_server_message(&ServerToShim::Quit))); + return Ok(()); + } + _ = render_tick.tick(), if dirty => { + if let Some(app) = &mut app { + seq = seq.wrapping_add(1); + let rows = render_rows(app, width, height); + let message = match previous_rows.as_ref() { + Some(previous) => match diff_rows(previous, &rows) { + FrameDiff::Full(rows) => ServerToShim::FullFrame { + seq, + width: rows.width, + height: rows.height, + rows: rows.rows.clone(), + }, + FrameDiff::Patch { width, height, changed_rows, clear_from_row } => { + ServerToShim::PatchFrame { seq, width, height, changed_rows, clear_from_row } + } + }, + None => ServerToShim::FullFrame { + seq, + width: rows.width, + height: rows.height, + rows: rows.rows.clone(), + }, + }; + previous_rows = Some(rows); + frames_tx.send_replace(Arc::new(encode_server_message(&message))); + } + dirty = false; + } + state = state_rx.recv() => { + match state { + Ok(state) => { + if let Ok(message) = serde_json::from_str::(&state) { + if matches!(message, SidebarServerMessage::Quit) { + frames_tx.send_replace(Arc::new(encode_server_message(&ServerToShim::Quit))); + return Ok(()); + } + apply_sidebar_server_message(&mut app, message); + dirty = true; + } + } + Err(broadcast::error::RecvError::Closed) => return Ok(()), + Err(broadcast::error::RecvError::Lagged(_)) => {} + } + } + frame = read_protocol_frame(&mut reader) => { + let frame = match frame { + Ok(frame) => frame, + Err(err) if err.message == "client closed" => return Ok(()), + Err(err) => return Err(err), + }; + match decode_shim_message(&frame) + .map_err(|err| ServerError::new(format!("invalid shim frame: {err}")))? { + ShimToServer::Hello(_) => {} + ShimToServer::Close => return Ok(()), + ShimToServer::Resize { width: next_width, height: next_height } => { + if next_width != width && state_source.as_ref().is_some_and(|state_source| { + state_source.should_report_sidebar_resize(&context) + }) { + let command = serde_json::json!({ + "type": "report-width", + "width": next_width, + }); + if let Some(payload) = state_source + .as_ref() + .and_then(|state_source| { + state_source.handle_client_command_with_context( + &command, + Some(&context), + ) + }) + { + if let Ok(message) = serde_json::from_str::(&payload) { + apply_sidebar_server_message(&mut app, message); + } + let _ = state_updates.send(payload); + } + } + width = next_width; + height = next_height; + previous_rows = None; + dirty = true; + } + ShimToServer::Mouse(_) => { + // Mouse hit-testing is intentionally server-owned; the protocol carries + // coordinates now so clickable rows and drag resizing can be layered on + // the render model without adding Ratatui to the shim. + } + ShimToServer::Key(key) => { + if let Some(app) = &mut app { + if let Some(ui_key) = ui_key_from_shim(key.code, key.modifiers) { + apply_ui_key(app, ui_key); + if drain_sidebar_commands( + app, + &state_source, + &state_updates, + &mut context, + &shutdown, + )? { + frames_tx.send_replace(Arc::new(encode_server_message(&ServerToShim::Quit))); + return Ok(()); + } + dirty = true; + } + } + } + } + } + } + } +} + +async fn write_shim_frames( + mut writer: tokio::net::unix::OwnedWriteHalf, + mut frames_rx: watch::Receiver>>, +) -> Result<(), ServerError> { + loop { + if frames_rx.changed().await.is_err() { + return Ok(()); + } + let frame = frames_rx.borrow_and_update().clone(); + if frame.is_empty() { + continue; + } + writer.write_all(&frame).await?; + } +} + +async fn read_protocol_frame(reader: &mut R) -> Result, ServerError> +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut len = [0_u8; 4]; + if let Err(err) = reader.read_exact(&mut len).await { + if err.kind() == std::io::ErrorKind::UnexpectedEof { + return Err(ServerError::new("client closed")); + } + return Err(err.into()); + } + let len = u32::from_le_bytes(len) as usize; + let mut frame = Vec::with_capacity(4 + len); + frame.extend_from_slice(&(len as u32).to_le_bytes()); + frame.resize(4 + len, 0); + reader.read_exact(&mut frame[4..]).await?; + Ok(frame) +} + +fn ui_key_from_shim(code: ShimKeyCode, modifiers: ShimKeyModifiers) -> Option { + if modifiers.contains(ShimKeyModifiers::ALT) { + return match code { + ShimKeyCode::Up => Some(UiKey::AltUp), + ShimKeyCode::Down => Some(UiKey::AltDown), + _ => None, + }; + } + if modifiers.contains(ShimKeyModifiers::CONTROL) { + return match code { + ShimKeyCode::Char('j') => Some(UiKey::CtrlJ), + ShimKeyCode::Char('k') => Some(UiKey::CtrlK), + _ => None, + }; + } + + match code { + ShimKeyCode::Char('j') | ShimKeyCode::Down => Some(UiKey::Down), + ShimKeyCode::Char('k') | ShimKeyCode::Up => Some(UiKey::Up), + ShimKeyCode::Char(ch) => Some(UiKey::Char(ch)), + ShimKeyCode::Tab => Some(UiKey::Tab { + shift: modifiers.contains(ShimKeyModifiers::SHIFT), + }), + ShimKeyCode::Enter => Some(UiKey::Enter), + ShimKeyCode::Esc => Some(UiKey::Esc), + } +} + +fn drain_sidebar_commands( + app: &mut SidebarApp, + state_source: &Option>, + state_updates: &broadcast::Sender, + context: &mut ClientConnectionContext, + shutdown: &broadcast::Sender<()>, +) -> Result { + for command in app.drain_commands() { + if matches!(command, SidebarClientCommand::Quit) { + let _ = state_updates.send(QUIT_JSON.to_string()); + let _ = shutdown.send(()); + return Ok(true); + } + let command = serde_json::to_value(command) + .map_err(|err| ServerError::new(format!("serialize sidebar command: {err}")))?; + if let Some(payload) = state_source.as_ref().and_then(|state_source| { + state_source.handle_client_command_with_context(&command, Some(context)) + }) { + if let Ok(message) = serde_json::from_str::(&payload) { + app.apply_server_message(message); + } + let _ = state_updates.send(payload); + } + } + Ok(false) +} + +async fn handle_connection( + mut stream: TcpStream, + shutdown: broadcast::Sender<()>, + state_source: Option>, + state_updates: broadcast::Sender, +) -> Result<(), ServerError> { + let mut request = read_http_header(&mut stream).await?; + let parsed = parse_http_request(&request)?; + read_remaining_http_body(&mut stream, &mut request, parsed.content_length()).await?; + + if parsed.method == "POST" && parsed.path == "/refresh" { + if let Some(state_source) = &state_source { + let _ = state_updates.send(state_source.snapshot_json()); + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" && parsed.path == "/focus" { + let body = String::from_utf8_lossy(http_body(&request)); + if let Some(payload) = state_source + .as_ref() + .and_then(|state_source| state_source.handle_http_text(&parsed.path, &body)) + { + let _ = state_updates.send(payload); + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" && parsed.path == "/switch-index" { + let Some(index) = parsed + .query_param("index") + .and_then(|index| index.parse::().ok()) + else { + stream + .write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 13\r\n\r\nmissing index") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + }; + let body = String::from_utf8_lossy(http_body(&request)); + if let Some(state_source) = &state_source { + state_source.handle_switch_index(index, &body); + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" && is_ok_hook_path(&parsed.path) { + let body = String::from_utf8_lossy(http_body(&request)); + if let Some(state_source) = &state_source { + state_source.handle_http_hook(&parsed.path, &body); + let _ = state_updates.send(state_source.snapshot_json()); + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" && parsed.path == "/api/agent-event" { + let Ok(body) = serde_json::from_slice::(http_body(&request)) else { + stream + .write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 12\r\n\r\ninvalid json") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + }; + match state_source + .as_ref() + .ok_or(AgentEventError::CouldNotResolveSession) + .and_then(|state_source| state_source.handle_agent_event_json(&body)) + { + Ok(payload) => { + let _ = state_updates.send(payload); + stream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + .await?; + } + Err(err) => { + let (status, body) = err.status_and_body(); + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + } + } + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" && parsed.path == "/api/runtime/pi/upsert" { + let Ok(body) = serde_json::from_slice::(http_body(&request)) else { + stream + .write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 12\r\n\r\ninvalid json") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + }; + if let Some(state_source) = &state_source { + if let Err(err) = state_source.handle_pi_runtime_upsert(&body) { + let body = err.body(); + stream + .write_all( + format!( + "HTTP/1.1 400 Bad Request\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + } + stream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" && parsed.path == "/api/runtime/pi/delete" { + let Ok(body) = serde_json::from_slice::(http_body(&request)) else { + stream + .write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 12\r\n\r\ninvalid json") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + }; + if let Some(state_source) = &state_source { + if let Err(err) = state_source.handle_pi_runtime_delete(&body) { + let body = err.body(); + stream + .write_all( + format!( + "HTTP/1.1 400 Bad Request\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + } + stream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" + && let Ok(body) = serde_json::from_slice::(http_body(&request)) + && is_metadata_path(&parsed.path) + && !body.get("session").is_some_and(Value::is_string) + { + stream + .write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 15\r\n\r\nmissing session") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" + && let Ok(body) = serde_json::from_slice::(http_body(&request)) + && let Some(payload) = state_source + .as_ref() + .and_then(|state_source| state_source.handle_http_json(&parsed.path, &body)) + { + let _ = state_updates.send(payload); + stream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + .await?; + let _ = stream.shutdown().await; + return Ok(()); + } + + if parsed.method == "POST" && parsed.path == "/quit" { + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await?; + let _ = stream.shutdown().await; + let _ = shutdown.send(()); + return Ok(()); + } + + if parsed.is_websocket_upgrade() && parsed.path == "/rendered-sidebar" { + return handle_rendered_sidebar_connection( + stream, + parsed, + shutdown, + state_source, + state_updates, + ) + .await; + } + + if parsed.is_websocket_upgrade() { + let Some(key) = parsed.header("sec-websocket-key") else { + stream + .write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n") + .await?; + return Ok(()); + }; + let accept = websocket_accept(key); + stream + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ) + .as_bytes(), + ) + .await?; + + let mut websocket = ServerBuilder::new().serve(stream); + debug_log("ws: client connected, sending hello + initial state"); + websocket.send(Message::text(HELLO_JSON)).await?; + if let Some(state_source) = &state_source { + websocket + .send(Message::text(state_source.snapshot_json())) + .await?; + } + + let mut connection_shutdown = shutdown.subscribe(); + let mut state_rx = state_updates.subscribe(); + let mut client_context = ClientConnectionContext::default(); + loop { + tokio::select! { + _ = connection_shutdown.recv() => { + let _ = websocket.send(Message::text(QUIT_JSON)).await; + return Ok(()); + } + state = state_rx.recv() => { + match state { + Ok(state) => { + debug_log(format!( + "ws: forwarding broadcast state ({} bytes) to client", + state.len() + )); + websocket.send(Message::text(state)).await? + } + Err(broadcast::error::RecvError::Closed) => return Ok(()), + Err(broadcast::error::RecvError::Lagged(n)) => { + debug_log(format!("ws: state_rx lagged by {n} messages")); + } + } + } + message = websocket.next() => { + match message { + Some(Ok(message)) if message.is_close() => return Ok(()), + Some(Ok(message)) => { + if is_quit_command(&message) { + let _ = state_updates.send(QUIT_JSON.to_string()); + let _ = shutdown.send(()); + return Ok(()); + } + if is_command_type(&message, "refresh") { + if let Some(state_source) = &state_source { + let _ = state_updates.send(state_source.snapshot_json()); + } + } + if let Some(command) = parse_command(&message) { + if let Some(reply) = state_source + .as_ref() + .and_then(|state_source| state_source.handle_sender_command_with_context(&command, &mut client_context)) + { + websocket.send(Message::text(reply)).await?; + } + if let Some(payload) = state_source + .as_ref() + .and_then(|state_source| state_source.handle_client_command_with_context(&command, Some(&client_context))) + { + let _ = state_updates.send(payload); + } + } + } + Some(Err(err)) => return Err(err.into()), + None => return Ok(()), + } + } + } + } + } + + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 19\r\n\r\nopensessions server") + .await?; + Ok(()) +} + +async fn handle_rendered_sidebar_connection( + mut stream: TcpStream, + parsed: HttpRequest, + shutdown: broadcast::Sender<()>, + state_source: Option>, + state_updates: broadcast::Sender, +) -> Result<(), ServerError> { + let Some(key) = parsed.header("sec-websocket-key") else { + stream + .write_all(b"HTTP/1.1 400 Bad Request\r\n\r\n") + .await?; + return Ok(()); + }; + let accept = websocket_accept(key); + stream + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ) + .as_bytes(), + ) + .await?; + + let mut websocket = ServerBuilder::new().serve(stream); + let mut width = parsed + .query_param("width") + .and_then(|width| width.parse::().ok()) + .unwrap_or(35); + let mut height = parsed + .query_param("height") + .and_then(|height| height.parse::().ok()) + .unwrap_or(56); + + let mut app = state_source + .as_ref() + .and_then(|state_source| app_from_state_json(&state_source.snapshot_json())); + if let Some(app) = &mut app { + websocket + .send(Message::text(render_sidebar_frame(app, width, height))) + .await?; + } + + let mut connection_shutdown = shutdown.subscribe(); + let mut state_rx = state_updates.subscribe(); + let mut render_tick = tokio::time::interval(Duration::from_millis(RENDERED_SIDEBAR_FRAME_MS)); + render_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut dirty = false; + loop { + tokio::select! { + _ = connection_shutdown.recv() => { + let _ = websocket.send(Message::text(QUIT_JSON)).await; + return Ok(()); + } + _ = render_tick.tick(), if dirty => { + if let Some(app) = &mut app { + websocket.send(Message::text(render_sidebar_frame(app, width, height))).await?; + } + dirty = false; + } + state = state_rx.recv() => { + match state { + Ok(state) => { + if let Ok(message) = serde_json::from_str::(&state) { + if matches!(message, SidebarServerMessage::Quit) { + let _ = websocket.send(Message::text(QUIT_JSON)).await; + return Ok(()); + } + apply_sidebar_server_message(&mut app, message); + dirty = true; + } + } + Err(broadcast::error::RecvError::Closed) => return Ok(()), + Err(broadcast::error::RecvError::Lagged(_)) => {} + } + } + message = websocket.next() => { + match message { + Some(Ok(message)) if message.is_close() => return Ok(()), + Some(Ok(message)) => { + if is_quit_command(&message) { + let _ = state_updates.send(QUIT_JSON.to_string()); + let _ = shutdown.send(()); + return Ok(()); + } + + if let Some(command) = parse_command(&message) { + if command.get("type").and_then(Value::as_str) == Some("render-resize") { + width = command + .get("width") + .and_then(Value::as_u64) + .map(|width| width.min(u16::MAX as u64) as u16) + .unwrap_or(width); + height = command + .get("height") + .and_then(Value::as_u64) + .map(|height| height.min(u16::MAX as u64) as u16) + .unwrap_or(height); + dirty = true; + continue; + } + + if command.get("type").and_then(Value::as_str) == Some("render-key") { + if let Some(app) = &mut app { + apply_render_key(app, &command); + for command in app.drain_commands() { + if matches!(command, SidebarClientCommand::Quit) { + let _ = state_updates.send(QUIT_JSON.to_string()); + let _ = shutdown.send(()); + return Ok(()); + } + if let Ok(command) = serde_json::to_value(command) { + if let Some(payload) = state_source + .as_ref() + .and_then(|state_source| state_source.handle_client_command(&command)) + { + if let Ok(message) = serde_json::from_str::(&payload) { + app.apply_server_message(message); + } + let _ = state_updates.send(payload); + } + } + } + dirty = true; + } + continue; + } + + if let Some(payload) = state_source + .as_ref() + .and_then(|state_source| state_source.handle_client_command(&command)) + { + if let Ok(message) = serde_json::from_str::(&payload) { + apply_sidebar_server_message(&mut app, message); + } + let _ = state_updates.send(payload); + } + dirty = true; + } + } + Some(Err(err)) => return Err(err.into()), + None => return Ok(()), + } + } + } + } +} + +fn app_from_state_json(state_json: &str) -> Option { + let SidebarServerMessage::State(state) = + serde_json::from_str::(state_json).ok()? + else { + return None; + }; + Some(SidebarApp::from_state(state)) +} + +fn apply_sidebar_server_message(app: &mut Option, message: SidebarServerMessage) { + match (app, message) { + (slot @ None, SidebarServerMessage::State(state)) => { + *slot = Some(SidebarApp::from_state(state)) + } + (Some(app), message) => app.apply_server_message(message), + (None, _) => {} + } +} + +fn render_sidebar_frame(app: &mut SidebarApp, width: u16, height: u16) -> String { + let rows = render_rows(app, width, height); + let mut frame = String::new(); + for row in rows.rows { + frame.push_str(&String::from_utf8_lossy(&row)); + frame.push('\n'); + } + frame +} + +fn apply_render_key(app: &mut SidebarApp, command: &Value) { + let key = command + .get("key") + .and_then(Value::as_str) + .unwrap_or_default(); + let alt = command.get("alt").and_then(Value::as_bool).unwrap_or(false); + let ctrl = command + .get("ctrl") + .and_then(Value::as_bool) + .unwrap_or(false); + let shift = command + .get("shift") + .and_then(Value::as_bool) + .unwrap_or(false); + + let code = match key { + "up" => ShimKeyCode::Up, + "down" => ShimKeyCode::Down, + "tab" => ShimKeyCode::Tab, + "enter" => ShimKeyCode::Enter, + "esc" => ShimKeyCode::Esc, + key if key.chars().count() == 1 => { + ShimKeyCode::Char(key.chars().next().expect("single char key must exist")) + } + _ => return, + }; + let mut modifiers = ShimKeyModifiers::empty(); + if alt { + modifiers = modifiers | ShimKeyModifiers::ALT; + } + if ctrl { + modifiers = modifiers | ShimKeyModifiers::CONTROL; + } + if shift { + modifiers = modifiers | ShimKeyModifiers::SHIFT; + } + if let Some(key) = ui_key_from_shim(code, modifiers) { + apply_ui_key(app, key); + } +} + +async fn read_http_header(stream: &mut TcpStream) -> Result, ServerError> { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + + loop { + let read = stream.read(&mut buffer).await?; + if read == 0 { + return Err(ServerError::new("client closed before sending request")); + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + return Ok(request); + } + if request.len() > MAX_HTTP_HEADER_BYTES { + return Err(ServerError::new("http request headers exceeded limit")); + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct HttpRequest { + method: String, + path: String, + query: Option, + headers: Vec<(String, String)>, +} + +impl HttpRequest { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(header_name, _)| header_name == name) + .map(|(_, value)| value.as_str()) + } + + fn is_websocket_upgrade(&self) -> bool { + self.header("upgrade") + .is_some_and(|value| value.eq_ignore_ascii_case("websocket")) + && self + .header("connection") + .is_some_and(|value| contains_token_ignore_ascii_case(value, "upgrade")) + } + + fn content_length(&self) -> usize { + self.header("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + } + + fn query_param(&self, name: &str) -> Option<&str> { + self.query.as_deref()?.split('&').find_map(|part| { + let (key, value) = part.split_once('=')?; + (key == name).then_some(value) + }) + } +} + +fn parse_http_request(bytes: &[u8]) -> Result { + let header_end = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| ServerError::new("http request missing header terminator"))?; + let text = std::str::from_utf8(&bytes[..header_end]) + .map_err(|_| ServerError::new("http request headers were not utf-8"))?; + let mut lines = text.split("\r\n"); + let request_line = lines + .next() + .ok_or_else(|| ServerError::new("http request missing request line"))?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| ServerError::new("http request missing method"))? + .to_string(); + let target = request_parts + .next() + .ok_or_else(|| ServerError::new("http request missing target"))?; + let (path, query) = match target.split_once('?') { + Some((path, query)) => (path.to_string(), Some(query.to_string())), + None => (target.to_string(), None), + }; + + let headers = lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string())) + .collect(); + + Ok(HttpRequest { + method, + path, + query, + headers, + }) +} + +fn contains_token_ignore_ascii_case(value: &str, needle: &str) -> bool { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case(needle)) +} + +fn is_metadata_path(path: &str) -> bool { + matches!( + path, + "/set-status" | "/set-progress" | "/log" | "/notify" | "/clear-log" + ) +} + +fn is_ok_hook_path(path: &str) -> bool { + matches!( + path, + "/suppress-width-reports" + | "/client-resized" + | "/pane-exited" + | "/ensure-sidebar" + | "/toggle" + ) +} + +async fn read_remaining_http_body( + stream: &mut TcpStream, + request: &mut Vec, + content_length: usize, +) -> Result<(), ServerError> { + let remaining = content_length.saturating_sub(http_body(request).len()); + if remaining == 0 { + return Ok(()); + } + + let start_len = request.len(); + request.resize(start_len + remaining, 0); + stream.read_exact(&mut request[start_len..]).await?; + Ok(()) +} + +fn http_body(request: &[u8]) -> &[u8] { + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + return &[]; + }; + &request[header_end + 4..] +} + +fn websocket_accept(key: &str) -> String { + let mut sha1 = Sha1::new(); + sha1.update(key.as_bytes()); + sha1.update(WEBSOCKET_GUID.as_bytes()); + STANDARD.encode(sha1.digest().bytes()) +} + +fn is_quit_command(message: &Message) -> bool { + is_command_type(message, "quit") +} + +fn is_command_type(message: &Message, command_type: &str) -> bool { + parse_command(message) + .and_then(|value| { + value + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + }) + .as_deref() + == Some(command_type) +} + +fn parse_command(message: &Message) -> Option { + serde_json::from_str::(message.as_text()?).ok() +} diff --git a/apps/server-rs/src/main.rs b/apps/server-rs/src/main.rs new file mode 100644 index 0000000..9a814b0 --- /dev/null +++ b/apps/server-rs/src/main.rs @@ -0,0 +1,14 @@ +use opensessions_runtime::shared::resolve_server_settings; +use opensessions_server::{ServerConfig, default_state_source_from_env, start_server}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let settings = resolve_server_settings(|key| std::env::var(key).ok()); + let mut config = ServerConfig::new(settings.host, settings.port, settings.pid_file); + if let Some(source) = default_state_source_from_env(|key| std::env::var(key).ok()) { + config = config.with_state_source(source); + } + let server = start_server(config).await?; + server.wait_shutdown().await?; + Ok(()) +} diff --git a/apps/server-rs/tests/protocol_shell.rs b/apps/server-rs/tests/protocol_shell.rs new file mode 100644 index 0000000..3d89f9e --- /dev/null +++ b/apps/server-rs/tests/protocol_shell.rs @@ -0,0 +1,2718 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; + +use futures_util::{SinkExt, StreamExt}; +use http::Uri; +use opensessions_runtime::mux::{ + ActiveWindow, AgentPane, MuxProvider, MuxSessionInfo, SidebarPane, SidebarPosition, +}; +use opensessions_server::{ + GitCommandRunner, PortCommandRunner, ReadOnlyMuxStateSource, StateSource, + default_state_source_from_env, +}; +use opensessions_server::{ServerConfig, start_server}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::timeout; +use tokio_websockets::{ClientBuilder, Message}; + +const EXPECTED_HELLO: &str = r#"{"type":"hello","protocol":1,"serverVersion":"0.2.0-alpha.5"}"#; +const EXPECTED_QUIT: &str = r#"{"type":"quit"}"#; + +fn test_pid_file(name: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "opensessions-server-rs-{name}-{}-{stamp}.pid", + std::process::id() + )) +} + +#[tokio::test(flavor = "current_thread")] +async fn writes_pid_file_without_newline() { + let pid_file = test_pid_file("pid"); + let server = start_server(ServerConfig::new("127.0.0.1", 0, &pid_file)) + .await + .expect("server should start"); + + assert_eq!( + fs::read_to_string(&pid_file).expect("pid file should be written"), + std::process::id().to_string() + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn post_quit_returns_ok_stops_server_and_removes_pid_file() { + let pid_file = test_pid_file("quit"); + let server = start_server(ServerConfig::new("127.0.0.1", 0, &pid_file)) + .await + .expect("server should start"); + let addr = server.addr(); + + let mut stream = TcpStream::connect(addr) + .await + .expect("server should accept http clients"); + stream + .write_all(b"POST /quit HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n") + .await + .expect("quit request should write"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("quit response should read"); + + assert!( + String::from_utf8_lossy(&response).starts_with("HTTP/1.1 200 OK\r\n"), + "response was {}", + String::from_utf8_lossy(&response) + ); + assert!( + String::from_utf8_lossy(&response).ends_with("\r\n\r\nok"), + "response was {}", + String::from_utf8_lossy(&response) + ); + + server + .wait_shutdown() + .await + .expect("/quit should stop the server"); + assert!(!pid_file.exists(), "/quit should remove the pid file"); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_upgrade_immediately_sends_exact_hello_json() { + let pid_file = test_pid_file("ws"); + let server = start_server(ServerConfig::new("127.0.0.1", 0, &pid_file)) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket clients"); + let message = client + .next() + .await + .expect("server should send hello") + .expect("hello should be a valid websocket message"); + + assert_eq!(message.as_text(), Some(EXPECTED_HELLO)); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_sends_state_snapshot_after_hello_when_available() { + let pid_file = test_pid_file("state"); + let state_json = r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"sidebarWidth":26,"initializing":false,"ts":123}"#; + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(move || state_json.to_string()), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket clients"); + let hello = client + .next() + .await + .expect("server should send hello") + .expect("hello should be a valid websocket message"); + let state = client + .next() + .await + .expect("server should send state after hello") + .expect("state should be a valid websocket message"); + + assert_eq!(hello.as_text(), Some(EXPECTED_HELLO)); + assert_eq!(state.as_text(), Some(state_json)); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn post_refresh_returns_ok_and_broadcasts_fresh_state_snapshot() { + let pid_file = test_pid_file("refresh"); + let counter = Arc::new(AtomicUsize::new(1)); + let state_counter = Arc::clone(&counter); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file).with_state_source(move || { + format!( + r#"{{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"sidebarWidth":26,"initializing":false,"ts":{}}}"#, + state_counter.load(Ordering::SeqCst) + ) + }), + ) + .await + .expect("server should start"); + let addr = server.addr(); + let uri: Uri = format!("ws://{addr}") + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket clients"); + let _ = client.next().await.expect("hello should arrive"); + assert_eq!( + client + .next() + .await + .expect("initial state should arrive") + .expect("initial state should be valid") + .as_text(), + Some( + r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"sidebarWidth":26,"initializing":false,"ts":1}"# + ) + ); + + counter.store(2, Ordering::SeqCst); + let mut stream = TcpStream::connect(addr) + .await + .expect("server should accept http clients"); + stream + .write_all(b"POST /refresh HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n") + .await + .expect("refresh request should write"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("refresh response should read"); + assert!( + String::from_utf8_lossy(&response).ends_with("\r\n\r\nok"), + "response was {}", + String::from_utf8_lossy(&response) + ); + + let refreshed = timeout(Duration::from_secs(1), client.next()) + .await + .expect("refresh should broadcast state before timeout") + .expect("refreshed state should arrive") + .expect("refreshed state should be valid"); + assert_eq!( + refreshed.as_text(), + Some( + r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"sidebarWidth":26,"initializing":false,"ts":2}"# + ) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_refresh_command_broadcasts_fresh_state_snapshot() { + let pid_file = test_pid_file("ws-refresh"); + let counter = Arc::new(AtomicUsize::new(1)); + let state_counter = Arc::clone(&counter); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file).with_state_source(move || { + format!( + r#"{{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"sidebarWidth":26,"initializing":false,"ts":{}}}"#, + state_counter.load(Ordering::SeqCst) + ) + }), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + counter.store(3, Ordering::SeqCst); + sender + .send(Message::text(r#"{"type":"refresh"}"#)) + .await + .expect("refresh command should send"); + + let refreshed = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("refresh broadcast should arrive before timeout") + .expect("refresh broadcast should arrive") + .expect("refresh broadcast should be valid"); + assert_eq!( + refreshed.as_text(), + Some( + r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"sidebarWidth":26,"initializing":false,"ts":3}"# + ) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_quit_command_broadcasts_quit_and_stops_server() { + let pid_file = test_pid_file("ws-quit"); + let server = start_server(ServerConfig::new("127.0.0.1", 0, &pid_file)) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + + sender + .send(Message::text(r#"{"type":"quit"}"#)) + .await + .expect("quit command should send"); + + let quit = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("quit broadcast should arrive before timeout") + .expect("quit broadcast should arrive") + .expect("quit broadcast should be valid"); + assert_eq!(quit.as_text(), Some(EXPECTED_QUIT)); + + server + .wait_shutdown() + .await + .expect("websocket quit should stop the server"); + assert!(!pid_file.exists(), "quit should remove the pid file"); +} + +#[test] +fn read_only_mux_state_source_serializes_runtime_state() { + let source = ReadOnlyMuxStateSource::new(vec![Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 60, + dir: "/repo/api".to_string(), + windows: 2, + }], + panes: 5, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + })]) + .with_sidebar_width(33) + .with_now_ms(|| 120_000); + + assert_eq!( + source.snapshot_json(), + r#"{"type":"state","sessions":[{"name":"api","createdAt":60,"dir":"/repo/api","branch":"","dirty":false,"isWorktree":false,"unseen":false,"panes":5,"ports":[],"localLinks":[],"windows":2,"uptime":"1m","agentState":null,"agents":[],"eventTimestamps":[]}],"focusedSession":"api","currentSession":"api","sidebarWidth":33,"initializing":false,"ts":120000}"#, + ); +} + +#[test] +fn default_state_source_uses_tmux_provider_when_tmux_env_is_present() { + assert!( + default_state_source_from_env(|key| (key == "TMUX").then(|| "socket,1,0".to_string())) + .is_some() + ); + assert!(default_state_source_from_env(|_| None).is_none()); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_new_session_command_calls_mux_and_broadcasts_state() { + let pid_file = test_pid_file("ws-new-session"); + let mux = Arc::new(ServerMux { + current: None, + sessions: vec![], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux.clone()]).with_now_ms(|| 456)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text(r#"{"type":"new-session"}"#)) + .await + .expect("new-session command should send"); + + let state = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("state broadcast should arrive before timeout") + .expect("state broadcast should arrive") + .expect("state broadcast should be valid"); + assert_eq!(*mux.create_calls.lock().unwrap(), 1); + assert_eq!( + state.as_text(), + Some( + r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"sidebarWidth":26,"initializing":false,"ts":456}"# + ) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_switch_session_calls_mux_and_broadcasts_focus_update() { + let pid_file = test_pid_file("ws-switch-session"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux.clone()]).with_now_ms(|| 456)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text( + r#"{"type":"switch-session","name":"api","clientTty":"/dev/ttys001"}"#, + )) + .await + .expect("switch-session command should send"); + + let focus = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("focus broadcast should arrive before timeout") + .expect("focus broadcast should arrive") + .expect("focus broadcast should be valid"); + assert_eq!( + *mux.switch_calls.lock().unwrap(), + vec![("api".to_string(), Some("/dev/ttys001".to_string()))] + ); + assert_eq!( + focus.as_text(), + Some(r#"{"type":"focus","focusedSession":"api","currentSession":"api"}"#) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_switch_index_switches_to_visible_session() { + let pid_file = test_pid_file("ws-switch-index"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![ + MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "worker".to_string(), + created_at: 2, + dir: "/repo/worker".to_string(), + windows: 1, + }, + ], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux.clone()]).with_now_ms(|| 789)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text(r#"{"type":"switch-index","index":2}"#)) + .await + .expect("switch-index command should send"); + sender + .send(Message::text(r#"{"type":"refresh"}"#)) + .await + .expect("refresh command should send"); + + let _ = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("refresh state should arrive before timeout") + .expect("refresh state should arrive") + .expect("refresh state should be valid"); + assert_eq!( + *mux.switch_calls.lock().unwrap(), + vec![("worker".to_string(), None)] + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_focus_session_broadcasts_focus_without_switching_mux() { + let pid_file = test_pid_file("ws-focus-session"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux.clone()]).with_now_ms(|| 456)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text(r#"{"type":"focus-session","name":"api"}"#)) + .await + .expect("focus-session command should send"); + + let focus = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("focus broadcast should arrive before timeout") + .expect("focus broadcast should arrive") + .expect("focus broadcast should be valid"); + assert!(mux.switch_calls.lock().unwrap().is_empty()); + assert_eq!( + focus.as_text(), + Some(r#"{"type":"focus","focusedSession":"api","currentSession":"api"}"#) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_move_focus_moves_within_sorted_sessions_and_broadcasts_focus() { + let pid_file = test_pid_file("ws-move-focus"); + let mux = Arc::new(ServerMux { + current: Some("alpha".to_string()), + sessions: vec![ + MuxSessionInfo { + name: "charlie".to_string(), + created_at: 30, + dir: "/repo/charlie".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "alpha".to_string(), + created_at: 10, + dir: "/repo/alpha".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "bravo".to_string(), + created_at: 20, + dir: "/repo/bravo".to_string(), + windows: 1, + }, + ], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 456)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text(r#"{"type":"move-focus","delta":1}"#)) + .await + .expect("move-focus command should send"); + + let focus = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("focus broadcast should arrive before timeout") + .expect("focus broadcast should arrive") + .expect("focus broadcast should be valid"); + assert_eq!( + focus.as_text(), + Some(r#"{"type":"focus","focusedSession":"bravo","currentSession":"alpha"}"#) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_kill_session_command_calls_mux_and_broadcasts_state() { + let pid_file = test_pid_file("ws-kill-session"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux.clone()]).with_now_ms(|| 789)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text(r#"{"type":"kill-session","name":"api"}"#)) + .await + .expect("kill-session command should send"); + + let state = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("state broadcast should arrive before timeout") + .expect("state broadcast should arrive") + .expect("state broadcast should be valid"); + assert_eq!(*mux.kill_calls.lock().unwrap(), vec!["api".to_string()]); + assert_eq!( + state.as_text(), + Some( + r#"{"type":"state","sessions":[{"name":"api","createdAt":1,"dir":"/repo/api","branch":"","dirty":false,"isWorktree":false,"unseen":false,"panes":1,"ports":[],"localLinks":[],"windows":1,"uptime":"","agentState":null,"agents":[],"eventTimestamps":[]}],"focusedSession":"api","currentSession":"api","sidebarWidth":26,"initializing":false,"ts":789}"# + ) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_set_theme_updates_state_and_broadcasts() { + let pid_file = test_pid_file("ws-set-theme"); + let mux = Arc::new(ServerMux { + current: None, + sessions: vec![], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 999)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text( + r#"{"type":"set-theme","theme":"catppuccin-mocha"}"#, + )) + .await + .expect("set-theme command should send"); + + let state = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("state broadcast should arrive before timeout") + .expect("state broadcast should arrive") + .expect("state broadcast should be valid"); + assert_eq!( + state.as_text(), + Some( + r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"theme":"catppuccin-mocha","sidebarWidth":26,"initializing":false,"ts":999}"# + ) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_set_filter_updates_state_and_broadcasts() { + let pid_file = test_pid_file("ws-set-filter"); + let mux = Arc::new(ServerMux { + current: None, + sessions: vec![], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 1_001)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text(r#"{"type":"set-filter","filter":"running"}"#)) + .await + .expect("set-filter command should send"); + + let state = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("state broadcast should arrive before timeout") + .expect("state broadcast should arrive") + .expect("state broadcast should be valid"); + assert_eq!( + state.as_text(), + Some( + r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":null,"sessionFilter":"running","sidebarWidth":26,"initializing":false,"ts":1001}"# + ) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_report_width_updates_sidebar_width_and_broadcasts() { + let pid_file = test_pid_file("ws-report-width"); + let mux = Arc::new(HookMux { + sidebar_panes: Vec::new(), + active_windows: vec![ActiveWindow { + id: "@1".to_string(), + session_name: "alpha".to_string(), + active: true, + }], + spawn_calls: Mutex::new(Vec::new()), + hide_calls: Mutex::new(Vec::new()), + orphan_cleanup_calls: Mutex::new(0), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 1_002)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text( + r#"{"type":"identify-pane","paneId":"%1","sessionName":"alpha","windowId":"@1"}"#, + )) + .await + .expect("identify-pane command should send"); + let _ = sender + .next() + .await + .expect("your-session should arrive for sender"); + + sender + .send(Message::text(r#"{"type":"report-width","width":41}"#)) + .await + .expect("report-width command should send"); + + let state = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("state broadcast should arrive before timeout") + .expect("state broadcast should arrive") + .expect("state broadcast should be valid"); + assert_eq!( + state.as_text(), + Some( + r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":"alpha","sidebarWidth":41,"initializing":true,"initLabel":"adjusting…","ts":1002}"# + ) + ); + + sender + .send(Message::text(r#"{"type":"report-width","width":3}"#)) + .await + .expect("second report-width command should send"); + + let state = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("clamped state broadcast should arrive before timeout") + .expect("clamped state broadcast should arrive") + .expect("clamped state broadcast should be valid"); + assert_eq!( + state.as_text(), + Some( + r#"{"type":"state","sessions":[],"focusedSession":null,"currentSession":"alpha","sidebarWidth":20,"initializing":true,"initLabel":"adjusting…","ts":1002}"# + ) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_report_width_rejects_identified_background_sidebar() { + let pid_file = test_pid_file("ws-report-width-background"); + let mux = Arc::new(HookMux { + sidebar_panes: Vec::new(), + active_windows: vec![ActiveWindow { + id: "@1".to_string(), + session_name: "alpha".to_string(), + active: true, + }], + spawn_calls: Mutex::new(Vec::new()), + hide_calls: Mutex::new(Vec::new()), + orphan_cleanup_calls: Mutex::new(0), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 1_003)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text( + r#"{"type":"identify-pane","paneId":"%2","sessionName":"beta","windowId":"@2"}"#, + )) + .await + .expect("identify-pane command should send"); + let _ = sender + .next() + .await + .expect("your-session should arrive for sender"); + + sender + .send(Message::text(r#"{"type":"report-width","width":41}"#)) + .await + .expect("report-width command should send"); + + assert!( + timeout(Duration::from_millis(50), receiver.next()) + .await + .is_err(), + "background sidebar width reports must not broadcast state" + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_hide_and_show_all_sessions_update_visible_state() { + let pid_file = test_pid_file("ws-hide-show"); + let mux = Arc::new(ServerMux { + current: Some("alpha".to_string()), + sessions: vec![ + MuxSessionInfo { + name: "alpha".to_string(), + created_at: 1, + dir: "/repo/alpha".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "beta".to_string(), + created_at: 2, + dir: "/repo/beta".to_string(), + windows: 1, + }, + ], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 3_000)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text(r#"{"type":"hide-session","name":"beta"}"#)) + .await + .expect("hide-session command should send"); + let hidden = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("hidden state should arrive before timeout") + .expect("hidden state should arrive") + .expect("hidden state should be valid"); + assert_eq!( + session_names(hidden.as_text().unwrap()), + vec!["alpha".to_string()] + ); + + sender + .send(Message::text(r#"{"type":"show-all-sessions"}"#)) + .await + .expect("show-all-sessions command should send"); + let shown = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("shown state should arrive before timeout") + .expect("shown state should arrive") + .expect("shown state should be valid"); + assert_eq!( + session_names(shown.as_text().unwrap()), + vec!["alpha".to_string(), "beta".to_string()] + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_reorder_session_updates_visible_order() { + let pid_file = test_pid_file("ws-reorder"); + let mux = Arc::new(ServerMux { + current: Some("alpha".to_string()), + sessions: vec![ + MuxSessionInfo { + name: "alpha".to_string(), + created_at: 1, + dir: "/repo/alpha".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "beta".to_string(), + created_at: 2, + dir: "/repo/beta".to_string(), + windows: 1, + }, + ], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 3_000)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text( + r#"{"type":"reorder-session","name":"beta","delta":-1}"#, + )) + .await + .expect("reorder-session command should send"); + let reordered = timeout(Duration::from_secs(1), receiver.next()) + .await + .expect("reordered state should arrive before timeout") + .expect("reordered state should arrive") + .expect("reordered state should be valid"); + assert_eq!( + session_names(reordered.as_text().unwrap()), + vec!["beta".to_string(), "alpha".to_string()] + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_identify_pane_replies_with_your_session_to_sender_only() { + let pid_file = test_pid_file("ws-identify-pane"); + let mux = Arc::new(ServerMux { + current: Some("alpha".to_string()), + sessions: vec![], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 3_000)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut sender, _) = ClientBuilder::from_uri(uri.clone()) + .connect() + .await + .expect("server should upgrade websocket sender"); + let (mut receiver, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket receiver"); + let _ = sender.next().await.expect("sender hello should arrive"); + let _ = sender + .next() + .await + .expect("sender initial state should arrive"); + let _ = receiver.next().await.expect("receiver hello should arrive"); + let _ = receiver + .next() + .await + .expect("receiver initial state should arrive"); + + sender + .send(Message::text( + r#"{"type":"identify-pane","paneId":"%1","sessionName":"alpha","windowId":"@1"}"#, + )) + .await + .expect("identify-pane command should send"); + + let reply = timeout(Duration::from_secs(1), sender.next()) + .await + .expect("your-session should arrive before timeout") + .expect("your-session should arrive") + .expect("your-session should be valid"); + assert_eq!( + reply.as_text(), + Some(r#"{"type":"your-session","name":"alpha","clientTty":"/dev/ttys-test"}"#) + ); + assert!( + timeout(Duration::from_millis(50), receiver.next()) + .await + .is_err(), + "identify-pane should not broadcast to other clients" + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_identify_pane_ignores_stash_session() { + let pid_file = test_pid_file("ws-identify-pane-stash"); + let mux = Arc::new(ServerMux { + current: None, + sessions: vec![], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 3_000)), + ) + .await + .expect("server should start"); + let uri: Uri = format!("ws://{}", server.addr()) + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket client"); + let _ = client.next().await.expect("hello should arrive"); + let _ = client.next().await.expect("initial state should arrive"); + + client + .send(Message::text( + r#"{"type":"identify-pane","paneId":"%1","sessionName":"_os_stash","windowId":"@1"}"#, + )) + .await + .expect("identify-pane command should send"); + assert!( + timeout(Duration::from_millis(50), client.next()) + .await + .is_err(), + "stash identify-pane should not reply" + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_set_status_returns_204_and_broadcasts_metadata_state() { + let pid_file = test_pid_file("http-set-status"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 4_000)), + ) + .await + .expect("server should start"); + let addr = server.addr(); + let uri: Uri = format!("ws://{addr}") + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket client"); + let _ = client.next().await.expect("hello should arrive"); + let _ = client.next().await.expect("initial state should arrive"); + + let body = r#"{"session":"api","text":"working","tone":"info"}"#; + let response = post_json(addr, "/set-status", body).await; + assert!( + response.starts_with("HTTP/1.1 204 No Content\r\n"), + "response was {response}" + ); + + let state = timeout(Duration::from_secs(1), client.next()) + .await + .expect("metadata state should arrive before timeout") + .expect("metadata state should arrive") + .expect("metadata state should be valid"); + let parsed = serde_json::from_str::(state.as_text().unwrap()).unwrap(); + let metadata = &parsed["sessions"][0]["metadata"]; + assert_eq!(metadata["status"]["text"], "working"); + assert_eq!(metadata["status"]["tone"], "info"); + assert!(metadata["status"]["ts"].as_u64().unwrap() > 0); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_metadata_progress_log_and_clear_log_broadcast_state() { + let pid_file = test_pid_file("http-metadata-more"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 4_000)), + ) + .await + .expect("server should start"); + let addr = server.addr(); + let uri: Uri = format!("ws://{addr}") + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket client"); + let _ = client.next().await.expect("hello should arrive"); + let _ = client.next().await.expect("initial state should arrive"); + + let response = post_json( + addr, + "/set-progress", + r#"{"session":"api","current":2,"total":5,"percent":0.4,"label":"files"}"#, + ) + .await; + assert!(response.starts_with("HTTP/1.1 204 No Content\r\n")); + let state = timeout(Duration::from_secs(1), client.next()) + .await + .expect("progress state should arrive before timeout") + .expect("progress state should arrive") + .expect("progress state should be valid"); + let parsed = serde_json::from_str::(state.as_text().unwrap()).unwrap(); + assert_eq!(parsed["sessions"][0]["metadata"]["progress"]["current"], 2); + assert_eq!(parsed["sessions"][0]["metadata"]["progress"]["total"], 5); + assert_eq!( + parsed["sessions"][0]["metadata"]["progress"]["percent"], + 0.4 + ); + assert_eq!( + parsed["sessions"][0]["metadata"]["progress"]["label"], + "files" + ); + + let response = post_json( + addr, + "/log", + r#"{"session":"api","message":"built","tone":"success","source":"ci"}"#, + ) + .await; + assert!(response.starts_with("HTTP/1.1 204 No Content\r\n")); + let state = timeout(Duration::from_secs(1), client.next()) + .await + .expect("log state should arrive before timeout") + .expect("log state should arrive") + .expect("log state should be valid"); + let parsed = serde_json::from_str::(state.as_text().unwrap()).unwrap(); + assert_eq!( + parsed["sessions"][0]["metadata"]["logs"][0]["message"], + "built" + ); + assert_eq!( + parsed["sessions"][0]["metadata"]["logs"][0]["tone"], + "success" + ); + assert_eq!(parsed["sessions"][0]["metadata"]["logs"][0]["source"], "ci"); + + let response = post_json(addr, "/clear-log", r#"{"session":"api"}"#).await; + assert!(response.starts_with("HTTP/1.1 204 No Content\r\n")); + let state = timeout(Duration::from_secs(1), client.next()) + .await + .expect("clear-log state should arrive before timeout") + .expect("clear-log state should arrive") + .expect("clear-log state should be valid"); + let parsed = serde_json::from_str::(state.as_text().unwrap()).unwrap(); + assert_eq!( + parsed["sessions"][0]["metadata"]["logs"] + .as_array() + .unwrap() + .len(), + 0 + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_metadata_endpoints_reject_missing_session() { + let pid_file = test_pid_file("http-metadata-invalid"); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file).with_state_source( + ReadOnlyMuxStateSource::new(vec![Arc::new(ServerMux { + current: None, + sessions: vec![], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + })]) + .with_now_ms(|| 4_000), + ), + ) + .await + .expect("server should start"); + + let response = post_json(server.addr(), "/set-status", r#"{"text":"working"}"#).await; + assert!( + response.starts_with("HTTP/1.1 400 Bad Request\r\n"), + "response was {response}" + ); + assert!(response.ends_with("\r\n\r\nmissing session")); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_focus_context_returns_ok_and_broadcasts_focus_update() { + let pid_file = test_pid_file("http-focus"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![ + MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "worker".to_string(), + created_at: 2, + dir: "/repo/worker".to_string(), + windows: 1, + }, + ], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 5_000)), + ) + .await + .expect("server should start"); + let addr = server.addr(); + let uri: Uri = format!("ws://{addr}") + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket client"); + let _ = client.next().await.expect("hello should arrive"); + let _ = client.next().await.expect("initial state should arrive"); + + let response = post_text(addr, "/focus", "/dev/ttys-test|worker|@2").await; + assert!( + response.starts_with("HTTP/1.1 200 OK\r\n"), + "response was {response}" + ); + assert!(response.ends_with("\r\n\r\nok")); + + let focus = timeout(Duration::from_secs(1), client.next()) + .await + .expect("focus broadcast should arrive before timeout") + .expect("focus broadcast should arrive") + .expect("focus broadcast should be valid"); + assert_eq!( + focus.as_text(), + Some(r#"{"type":"focus","focusedSession":"worker","currentSession":"api"}"#) + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_switch_index_switches_to_visible_session_with_context_tty() { + let pid_file = test_pid_file("http-switch-index"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![ + MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "worker".to_string(), + created_at: 2, + dir: "/repo/worker".to_string(), + windows: 1, + }, + ], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file).with_state_source( + ReadOnlyMuxStateSource::new(vec![mux.clone()]).with_now_ms(|| 5_000), + ), + ) + .await + .expect("server should start"); + + let response = post_text( + server.addr(), + "/switch-index?index=2", + "/dev/ttys-test|api|@1", + ) + .await; + assert!( + response.starts_with("HTTP/1.1 200 OK\r\n"), + "response was {response}" + ); + assert!(response.ends_with("\r\n\r\nok")); + assert_eq!( + *mux.switch_calls.lock().unwrap(), + vec![("worker".to_string(), Some("/dev/ttys-test".to_string()))] + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_resize_hooks_return_ok_for_tmux_hook_compatibility() { + let pid_file = test_pid_file("http-resize-hooks"); + let server = start_server(ServerConfig::new("127.0.0.1", 0, &pid_file)) + .await + .expect("server should start"); + + for path in ["/suppress-width-reports?ms=2500", "/client-resized"] { + let response = post_text(server.addr(), path, "").await; + assert!( + response.starts_with("HTTP/1.1 200 OK\r\n"), + "{path} response was {response}" + ); + assert!( + response.ends_with("\r\n\r\nok"), + "{path} response was {response}" + ); + } + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_pane_exited_returns_ok_and_kills_orphaned_sidebar_panes() { + let pid_file = test_pid_file("http-pane-exited"); + let mux = Arc::new(HookMux { + sidebar_panes: Vec::new(), + active_windows: Vec::new(), + spawn_calls: Mutex::new(Vec::new()), + hide_calls: Mutex::new(Vec::new()), + orphan_cleanup_calls: Mutex::new(0), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux.clone()])), + ) + .await + .expect("server should start"); + + let response = post_text(server.addr(), "/pane-exited", "").await; + assert!( + response.starts_with("HTTP/1.1 200 OK\r\n"), + "response was {response}" + ); + assert!(response.ends_with("\r\n\r\nok")); + assert_eq!(*mux.orphan_cleanup_calls.lock().unwrap(), 1); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_ensure_sidebar_spawns_missing_sidebar_in_context_window() { + let pid_file = test_pid_file("http-ensure-sidebar"); + let mux = Arc::new(HookMux { + sidebar_panes: Vec::new(), + active_windows: vec![ActiveWindow { + id: "@2".to_string(), + session_name: "worker".to_string(), + active: true, + }], + spawn_calls: Mutex::new(Vec::new()), + hide_calls: Mutex::new(Vec::new()), + orphan_cleanup_calls: Mutex::new(0), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file).with_state_source( + ReadOnlyMuxStateSource::new(vec![mux.clone()]).with_sidebar_width(33), + ), + ) + .await + .expect("server should start"); + + let response = post_text(server.addr(), "/ensure-sidebar", "/dev/ttys-test|worker|@2").await; + assert!( + response.starts_with("HTTP/1.1 200 OK\r\n"), + "response was {response}" + ); + assert!(response.ends_with("\r\n\r\nok")); + assert_eq!( + *mux.spawn_calls.lock().unwrap(), + vec![EnsureSpawnCall { + session_name: "worker".to_string(), + window_id: "@2".to_string(), + width: 33, + position: SidebarPosition::Left, + }] + ); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_toggle_hides_existing_sidebar_panes() { + let pid_file = test_pid_file("http-toggle-hide"); + let mux = Arc::new(HookMux { + sidebar_panes: vec![SidebarPane { + pane_id: "%2".to_string(), + session_name: "worker".to_string(), + window_id: "@2".to_string(), + width: Some(26), + window_width: Some(120), + }], + active_windows: Vec::new(), + spawn_calls: Mutex::new(Vec::new()), + hide_calls: Mutex::new(Vec::new()), + orphan_cleanup_calls: Mutex::new(0), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux.clone()])), + ) + .await + .expect("server should start"); + + let response = post_text(server.addr(), "/toggle", "/dev/ttys-test|worker|@2").await; + assert!( + response.starts_with("HTTP/1.1 200 OK\r\n"), + "response was {response}" + ); + assert!(response.ends_with("\r\n\r\nok")); + assert_eq!(*mux.hide_calls.lock().unwrap(), vec!["%2".to_string()]); + assert!(mux.spawn_calls.lock().unwrap().is_empty()); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_toggle_spawns_sidebar_in_active_windows_when_hidden() { + let pid_file = test_pid_file("http-toggle-spawn"); + let mux = Arc::new(HookMux { + sidebar_panes: Vec::new(), + active_windows: vec![ + ActiveWindow { + id: "@1".to_string(), + session_name: "api".to_string(), + active: true, + }, + ActiveWindow { + id: "@2".to_string(), + session_name: "worker".to_string(), + active: true, + }, + ], + spawn_calls: Mutex::new(Vec::new()), + hide_calls: Mutex::new(Vec::new()), + orphan_cleanup_calls: Mutex::new(0), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file).with_state_source( + ReadOnlyMuxStateSource::new(vec![mux.clone()]).with_sidebar_width(31), + ), + ) + .await + .expect("server should start"); + + let response = post_text(server.addr(), "/toggle", "/dev/ttys-test|worker|@2").await; + assert!( + response.starts_with("HTTP/1.1 200 OK\r\n"), + "response was {response}" + ); + assert!(response.ends_with("\r\n\r\nok")); + assert_eq!( + *mux.spawn_calls.lock().unwrap(), + vec![ + EnsureSpawnCall { + session_name: "api".to_string(), + window_id: "@1".to_string(), + width: 31, + position: SidebarPosition::Left, + }, + EnsureSpawnCall { + session_name: "worker".to_string(), + window_id: "@2".to_string(), + width: 31, + position: SidebarPosition::Left, + }, + ] + ); + assert!(mux.hide_calls.lock().unwrap().is_empty()); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_agent_event_resolves_tmux_session_and_broadcasts_agent_state() { + let pid_file = test_pid_file("http-agent-event"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 8_000)), + ) + .await + .expect("server should start"); + let addr = server.addr(); + let uri: Uri = format!("ws://{addr}") + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket client"); + let _ = client.next().await.expect("hello should arrive"); + let _ = client.next().await.expect("initial state should arrive"); + + let response = post_json( + addr, + "/api/agent-event", + r#"{"agent":"amp","status":"running","threadId":"T-123","threadName":"Implement Rust","tmuxSession":"api","ts":7000}"#, + ) + .await; + assert!( + response.starts_with("HTTP/1.1 204 No Content\r\n"), + "response was {response}" + ); + + let state = timeout(Duration::from_secs(1), client.next()) + .await + .expect("agent state should arrive before timeout") + .expect("agent state should arrive") + .expect("agent state should be valid"); + let parsed = serde_json::from_str::(state.as_text().unwrap()).unwrap(); + let session = &parsed["sessions"][0]; + assert_eq!(session["agentState"]["agent"], "amp"); + assert_eq!(session["agentState"]["status"], "running"); + assert_eq!(session["agentState"]["threadId"], "T-123"); + assert_eq!(session["agentState"]["threadName"], "Implement Rust"); + assert_eq!(session["agents"].as_array().unwrap().len(), 1); + assert_eq!(session["eventTimestamps"], serde_json::json!([7000])); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_agent_event_rejects_invalid_payloads() { + let pid_file = test_pid_file("http-agent-event-invalid"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 8_000)), + ) + .await + .expect("server should start"); + + let missing_agent = post_json( + server.addr(), + "/api/agent-event", + r#"{"status":"running","tmuxSession":"api"}"#, + ) + .await; + assert!(missing_agent.starts_with("HTTP/1.1 400 Bad Request\r\n")); + assert!(missing_agent.ends_with("\r\n\r\nmissing agent")); + + let invalid_status = post_json( + server.addr(), + "/api/agent-event", + r#"{"agent":"amp","status":"wat","tmuxSession":"api"}"#, + ) + .await; + assert!(invalid_status.starts_with("HTTP/1.1 400 Bad Request\r\n")); + assert!(invalid_status.ends_with("\r\n\r\ninvalid status")); + + let unresolved = post_json( + server.addr(), + "/api/agent-event", + r#"{"agent":"amp","status":"running","tmuxSession":"missing"}"#, + ) + .await; + assert!(unresolved.starts_with("HTTP/1.1 404 Not Found\r\n")); + assert!(unresolved.ends_with("\r\n\r\ncould not resolve session")); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_agent_event_resolves_session_from_project_dir() { + let pid_file = test_pid_file("http-agent-event-project-dir"); + let mux = Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + }); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_state_source(ReadOnlyMuxStateSource::new(vec![mux]).with_now_ms(|| 8_000)), + ) + .await + .expect("server should start"); + let addr = server.addr(); + let uri: Uri = format!("ws://{addr}") + .parse() + .expect("server address should produce a websocket uri"); + + let (mut client, _) = ClientBuilder::from_uri(uri) + .connect() + .await + .expect("server should upgrade websocket client"); + let _ = client.next().await.expect("hello should arrive"); + let _ = client.next().await.expect("initial state should arrive"); + + let response = post_json( + addr, + "/api/agent-event", + r#"{"agent":"amp","status":"done","projectDir":"/repo/api/subdir","ts":7000}"#, + ) + .await; + assert!( + response.starts_with("HTTP/1.1 204 No Content\r\n"), + "response was {response}" + ); + + let state = timeout(Duration::from_secs(1), client.next()) + .await + .expect("agent state should arrive before timeout") + .expect("agent state should arrive") + .expect("agent state should be valid"); + let parsed = serde_json::from_str::(state.as_text().unwrap()).unwrap(); + assert_eq!(parsed["sessions"][0]["agentState"]["status"], "done"); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_pi_runtime_upsert_and_delete_validate_payloads() { + let pid_file = test_pid_file("http-pi-runtime"); + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file).with_state_source( + ReadOnlyMuxStateSource::new(vec![Arc::new(ServerMux { + current: None, + sessions: Vec::new(), + panes: 0, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + })]) + .with_now_ms(|| 9_000), + ), + ) + .await + .expect("server should start"); + + let upsert = post_json( + server.addr(), + "/api/runtime/pi/upsert", + r#"{"pid":123,"sessionId":"thread-1","cwd":"/repo"}"#, + ) + .await; + assert!( + upsert.starts_with("HTTP/1.1 204 No Content\r\n"), + "response was {upsert}" + ); + + let invalid_upsert = post_json( + server.addr(), + "/api/runtime/pi/upsert", + r#"{"pid":0,"sessionId":"thread-1","cwd":"/repo"}"#, + ) + .await; + assert!(invalid_upsert.starts_with("HTTP/1.1 400 Bad Request\r\n")); + assert!(invalid_upsert.ends_with("\r\n\r\ninvalid pi runtime payload")); + + let delete = post_json(server.addr(), "/api/runtime/pi/delete", r#"{"pid":123}"#).await; + assert!( + delete.starts_with("HTTP/1.1 204 No Content\r\n"), + "response was {delete}" + ); + + let invalid_delete = post_json(server.addr(), "/api/runtime/pi/delete", r#"{"pid":0}"#).await; + assert!(invalid_delete.starts_with("HTTP/1.1 400 Bad Request\r\n")); + assert!(invalid_delete.ends_with("\r\n\r\nmissing pid")); + + server.shutdown().await.expect("server should shut down"); + let _ = fs::remove_file(pid_file); +} + +#[test] +fn state_source_focuses_and_kills_resolved_agent_panes() { + let mux = Arc::new(AgentPaneMux { + focus_calls: Mutex::new(Vec::new()), + kill_pane_calls: Mutex::new(Vec::new()), + resolve_calls: Mutex::new(Vec::new()), + }); + let source = ReadOnlyMuxStateSource::new(vec![mux.clone()]); + + assert_eq!( + source.handle_client_command(&serde_json::json!({ + "type": "focus-agent-pane", + "session": "api", + "agent": "amp", + "threadName": "migrate server" + })), + None + ); + assert_eq!( + source.handle_client_command(&serde_json::json!({ + "type": "kill-agent-pane", + "session": "api", + "agent": "amp", + "threadName": "migrate server" + })), + None + ); + + assert_eq!( + *mux.resolve_calls.lock().unwrap(), + vec![ + AgentPaneResolveCall { + session: "api".to_string(), + agent: "amp".to_string(), + thread_id: None, + thread_name: Some("migrate server".to_string()), + }, + AgentPaneResolveCall { + session: "api".to_string(), + agent: "amp".to_string(), + thread_id: None, + thread_name: Some("migrate server".to_string()), + }, + ] + ); + assert_eq!(*mux.focus_calls.lock().unwrap(), vec!["%agent".to_string()]); + assert_eq!( + *mux.kill_pane_calls.lock().unwrap(), + vec!["%agent".to_string()] + ); +} + +#[test] +fn state_source_discovers_live_ports_from_pane_process_tree() { + let source = ReadOnlyMuxStateSource::new(vec![Arc::new(PortMux)]).with_port_command_runner( + Arc::new(StaticPortRunner { + process_rows: vec![(101, 100), (102, 101)], + lsof_fields: "p102\nn127.0.0.1:4549\n".to_string(), + }), + ); + + let state = source.snapshot_json(); + let parsed = serde_json::from_str::(&state).unwrap(); + + assert_eq!(parsed["sessions"][0]["ports"], serde_json::json!([4549])); + assert_eq!( + parsed["sessions"][0]["localLinks"][0]["url"], + "http://localhost:4549" + ); +} + +#[test] +fn state_source_reuses_live_port_snapshot_for_ten_seconds() { + let now = Arc::new(std::sync::atomic::AtomicU64::new(10_000)); + let now_for_source = Arc::clone(&now); + let port_runner = Arc::new(CountingPortRunner { + process_calls: AtomicUsize::new(0), + lsof_calls: AtomicUsize::new(0), + }); + let source = ReadOnlyMuxStateSource::new(vec![Arc::new(PortMux)]) + .with_now_ms(move || now_for_source.load(Ordering::SeqCst)) + .with_port_command_runner(port_runner.clone()); + + let _ = source.snapshot_json(); + let _ = source.snapshot_json(); + + assert_eq!(port_runner.process_calls.load(Ordering::SeqCst), 1); + assert_eq!(port_runner.lsof_calls.load(Ordering::SeqCst), 1); + + now.store(20_001, Ordering::SeqCst); + let _ = source.snapshot_json(); + + assert_eq!(port_runner.process_calls.load(Ordering::SeqCst), 2); + assert_eq!(port_runner.lsof_calls.load(Ordering::SeqCst), 2); +} + +#[test] +fn state_source_populates_git_info_and_caches_by_dir_for_five_seconds() { + let now = Arc::new(std::sync::atomic::AtomicU64::new(10_000)); + let now_for_source = Arc::clone(&now); + let git_runner = Arc::new(StaticGitRunner { + output: "main\n.git/worktrees/api\n---\n M src/lib.rs\n".to_string(), + calls: Mutex::new(Vec::new()), + }); + let source = ReadOnlyMuxStateSource::new(vec![Arc::new(PortMux)]) + .with_now_ms(move || now_for_source.load(Ordering::SeqCst)) + .with_git_command_runner(git_runner.clone()); + + let first = serde_json::from_str::(&source.snapshot_json()).unwrap(); + let second = serde_json::from_str::(&source.snapshot_json()).unwrap(); + + assert_eq!(first["sessions"][0]["branch"], "main"); + assert_eq!(first["sessions"][0]["dirty"], true); + assert_eq!(first["sessions"][0]["isWorktree"], true); + assert_eq!(second["sessions"][0]["branch"], "main"); + assert_eq!( + *git_runner.calls.lock().unwrap(), + vec!["/repo/api".to_string()] + ); + + now.store(16_000, Ordering::SeqCst); + let _ = source.snapshot_json(); + + assert_eq!( + *git_runner.calls.lock().unwrap(), + vec!["/repo/api".to_string(), "/repo/api".to_string()] + ); +} + +#[test] +fn state_source_keeps_current_session_visible_when_hidden() { + let source = ReadOnlyMuxStateSource::new(vec![Arc::new(ServerMux { + current: Some("api".to_string()), + sessions: vec![ + MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "web".to_string(), + created_at: 2, + dir: "/repo/web".to_string(), + windows: 1, + }, + ], + panes: 1, + create_calls: Mutex::new(0), + switch_calls: Mutex::new(Vec::new()), + kill_calls: Mutex::new(Vec::new()), + })]); + + let _ = source.snapshot_json(); + let state = source + .handle_client_command(&serde_json::json!({"type":"hide-session","name":"api"})) + .expect("hide-session should broadcast state"); + + assert_eq!( + session_names(&state), + vec!["api".to_string(), "web".to_string()] + ); +} + +#[test] +fn state_source_reports_warmup_after_ensure_sidebar_spawns() { + let mux = Arc::new(HookMux { + sidebar_panes: Vec::new(), + active_windows: vec![ActiveWindow { + id: "@2".to_string(), + session_name: "worker".to_string(), + active: true, + }], + spawn_calls: Mutex::new(Vec::new()), + hide_calls: Mutex::new(Vec::new()), + orphan_cleanup_calls: Mutex::new(0), + }); + let source = ReadOnlyMuxStateSource::new(vec![mux]); + + source.handle_http_hook("/ensure-sidebar", "/dev/ttys-test|worker|@2"); + + let state = source.snapshot_json(); + let parsed = serde_json::from_str::(&state).unwrap(); + assert_eq!(parsed["initializing"], true); + assert_eq!(parsed["initLabel"], "warming up…"); +} + +#[test] +fn state_source_streams_agent_panes_for_all_sessions() { + let source = ReadOnlyMuxStateSource::new(vec![Arc::new(AgentPaneListMux)]); + + let state = source.snapshot_json(); + let parsed = serde_json::from_str::(&state).unwrap(); + let api = parsed["sessions"] + .as_array() + .unwrap() + .iter() + .find(|session| session["name"] == "api") + .expect("api session should exist"); + let worker = parsed["sessions"] + .as_array() + .unwrap() + .iter() + .find(|session| session["name"] == "worker") + .expect("worker session should exist"); + + assert_eq!(api["agentState"]["agent"], "amp"); + assert_eq!(api["agentState"]["status"], "running"); + assert_eq!(api["agents"].as_array().unwrap().len(), 1); + assert_eq!(api["agents"][0]["paneId"], "%api-agent"); + assert_eq!(api["agents"][0]["liveness"], "alive"); + assert_eq!(worker["agentState"]["agent"], "codex"); + assert_eq!(worker["agents"][0]["paneId"], "%worker-agent"); +} + +#[derive(Debug)] +struct ServerMux { + current: Option, + sessions: Vec, + panes: u32, + create_calls: Mutex, + switch_calls: Mutex)>>, + kill_calls: Mutex>, +} + +#[derive(Debug)] +struct AgentPaneListMux; + +impl MuxProvider for AgentPaneListMux { + fn name(&self) -> &str { + "agent-pane-list-mux" + } + + fn list_sessions(&self) -> Vec { + vec![ + MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }, + MuxSessionInfo { + name: "worker".to_string(), + created_at: 2, + dir: "/repo/worker".to_string(), + windows: 1, + }, + ] + } + + fn switch_session(&self, _name: &str, _client_tty: Option<&str>) {} + + fn get_current_session(&self) -> Option { + Some("api".to_string()) + } + + fn get_session_dir(&self, name: &str) -> String { + format!("/repo/{name}") + } + + fn get_pane_count(&self, _name: &str) -> u32 { + 1 + } + + fn get_client_tty(&self) -> String { + "/dev/ttys-test".to_string() + } + + fn create_session(&self, _name: Option<&str>, _dir: Option<&str>) {} + + fn kill_session(&self, _name: &str) {} + + fn setup_hooks(&self, _server_host: &str, _server_port: u16) {} + + fn cleanup_hooks(&self) {} + + fn list_agent_panes(&self, session_name: &str) -> Vec { + match session_name { + "api" => vec![AgentPane { + agent: "amp".to_string(), + pane_id: "%api-agent".to_string(), + thread_id: Some("T-api".to_string()), + thread_name: Some("Implement API".to_string()), + }], + "worker" => vec![AgentPane { + agent: "codex".to_string(), + pane_id: "%worker-agent".to_string(), + thread_id: None, + thread_name: None, + }], + _ => Vec::new(), + } + } +} + +impl MuxProvider for ServerMux { + fn name(&self) -> &str { + "server-mux" + } + + fn list_sessions(&self) -> Vec { + self.sessions.clone() + } + + fn switch_session(&self, name: &str, client_tty: Option<&str>) { + self.switch_calls + .lock() + .unwrap() + .push((name.to_string(), client_tty.map(ToString::to_string))); + } + + fn get_current_session(&self) -> Option { + self.current.clone() + } + + fn get_session_dir(&self, _name: &str) -> String { + String::new() + } + + fn get_pane_count(&self, _name: &str) -> u32 { + self.panes + } + + fn get_client_tty(&self) -> String { + "/dev/ttys-test".to_string() + } + + fn create_session(&self, _name: Option<&str>, _dir: Option<&str>) { + *self.create_calls.lock().unwrap() += 1; + } + + fn kill_session(&self, name: &str) { + self.kill_calls.lock().unwrap().push(name.to_string()); + } + + fn setup_hooks(&self, _server_host: &str, _server_port: u16) {} + + fn cleanup_hooks(&self) {} +} + +#[derive(Debug)] +struct PortMux; + +impl MuxProvider for PortMux { + fn name(&self) -> &str { + "port-mux" + } + + fn list_sessions(&self) -> Vec { + vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }] + } + + fn switch_session(&self, _name: &str, _client_tty: Option<&str>) {} + + fn get_current_session(&self) -> Option { + Some("api".to_string()) + } + + fn get_session_dir(&self, _name: &str) -> String { + "/repo/api".to_string() + } + + fn get_session_pane_pids(&self, _name: &str) -> Vec { + vec![100] + } + + fn get_pane_count(&self, _name: &str) -> u32 { + 1 + } + + fn get_client_tty(&self) -> String { + "/dev/ttys-test".to_string() + } + + fn create_session(&self, _name: Option<&str>, _dir: Option<&str>) {} + + fn kill_session(&self, _name: &str) {} + + fn setup_hooks(&self, _server_host: &str, _server_port: u16) {} + + fn cleanup_hooks(&self) {} +} + +struct StaticPortRunner { + process_rows: Vec<(u32, u32)>, + lsof_fields: String, +} + +impl PortCommandRunner for StaticPortRunner { + fn process_rows(&self) -> Vec<(u32, u32)> { + self.process_rows.clone() + } + + fn lsof_fields(&self) -> String { + self.lsof_fields.clone() + } +} + +struct CountingPortRunner { + process_calls: AtomicUsize, + lsof_calls: AtomicUsize, +} + +impl PortCommandRunner for CountingPortRunner { + fn process_rows(&self) -> Vec<(u32, u32)> { + self.process_calls.fetch_add(1, Ordering::SeqCst); + vec![(101, 100)] + } + + fn lsof_fields(&self) -> String { + self.lsof_calls.fetch_add(1, Ordering::SeqCst); + "p101\nn127.0.0.1:3000\n".to_string() + } +} + +struct StaticGitRunner { + output: String, + calls: Mutex>, +} + +impl GitCommandRunner for StaticGitRunner { + fn git_info_output(&self, dir: &str) -> String { + self.calls.lock().unwrap().push(dir.to_string()); + self.output.clone() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentPaneResolveCall { + session: String, + agent: String, + thread_id: Option, + thread_name: Option, +} + +#[derive(Debug)] +struct AgentPaneMux { + focus_calls: Mutex>, + kill_pane_calls: Mutex>, + resolve_calls: Mutex>, +} + +impl MuxProvider for AgentPaneMux { + fn name(&self) -> &str { + "agent-pane-mux" + } + + fn list_sessions(&self) -> Vec { + vec![MuxSessionInfo { + name: "api".to_string(), + created_at: 1, + dir: "/repo/api".to_string(), + windows: 1, + }] + } + + fn switch_session(&self, _name: &str, _client_tty: Option<&str>) {} + + fn get_current_session(&self) -> Option { + Some("api".to_string()) + } + + fn get_session_dir(&self, _name: &str) -> String { + "/repo/api".to_string() + } + + fn get_pane_count(&self, _name: &str) -> u32 { + 1 + } + + fn get_client_tty(&self) -> String { + "/dev/ttys-test".to_string() + } + + fn create_session(&self, _name: Option<&str>, _dir: Option<&str>) {} + + fn kill_session(&self, _name: &str) {} + + fn setup_hooks(&self, _server_host: &str, _server_port: u16) {} + + fn cleanup_hooks(&self) {} + + fn resolve_agent_pane_id( + &self, + session: &str, + agent: &str, + thread_id: Option<&str>, + thread_name: Option<&str>, + ) -> Option { + self.resolve_calls + .lock() + .unwrap() + .push(AgentPaneResolveCall { + session: session.to_string(), + agent: agent.to_string(), + thread_id: thread_id.map(ToString::to_string), + thread_name: thread_name.map(ToString::to_string), + }); + Some("%agent".to_string()) + } + + fn focus_pane(&self, pane_id: &str) { + self.focus_calls.lock().unwrap().push(pane_id.to_string()); + } + + fn kill_pane(&self, pane_id: &str) { + self.kill_pane_calls + .lock() + .unwrap() + .push(pane_id.to_string()); + } +} + +#[derive(Debug)] +struct HookMux { + sidebar_panes: Vec, + active_windows: Vec, + spawn_calls: Mutex>, + hide_calls: Mutex>, + orphan_cleanup_calls: Mutex, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct EnsureSpawnCall { + session_name: String, + window_id: String, + width: u16, + position: SidebarPosition, +} + +impl MuxProvider for HookMux { + fn name(&self) -> &str { + "hook-mux" + } + + fn list_sessions(&self) -> Vec { + Vec::new() + } + + fn switch_session(&self, _name: &str, _client_tty: Option<&str>) {} + + fn get_current_session(&self) -> Option { + self.active_windows + .iter() + .find(|window| window.active) + .map(|window| window.session_name.clone()) + } + + fn get_session_dir(&self, _name: &str) -> String { + String::new() + } + + fn get_pane_count(&self, _name: &str) -> u32 { + 0 + } + + fn get_client_tty(&self) -> String { + String::new() + } + + fn get_current_window_id(&self) -> Option { + self.active_windows + .iter() + .find(|window| window.active) + .map(|window| window.id.clone()) + } + + fn create_session(&self, _name: Option<&str>, _dir: Option<&str>) {} + + fn kill_session(&self, _name: &str) {} + + fn setup_hooks(&self, _server_host: &str, _server_port: u16) {} + + fn cleanup_hooks(&self) {} + + fn is_window_capable(&self) -> bool { + true + } + + fn is_sidebar_capable(&self) -> bool { + true + } + + fn list_active_windows(&self) -> Vec { + self.active_windows.clone() + } + + fn list_sidebar_panes(&self, _session_name: Option<&str>) -> Vec { + self.sidebar_panes.clone() + } + + fn spawn_sidebar( + &self, + session_name: &str, + window_id: &str, + width: u16, + position: SidebarPosition, + _scripts_dir: &str, + ) -> Option { + self.spawn_calls.lock().unwrap().push(EnsureSpawnCall { + session_name: session_name.to_string(), + window_id: window_id.to_string(), + width, + position, + }); + Some("%sidebar".to_string()) + } + + fn hide_sidebar(&self, pane_id: &str) { + self.hide_calls.lock().unwrap().push(pane_id.to_string()); + } + + fn kill_orphaned_sidebar_panes(&self) { + *self.orphan_cleanup_calls.lock().unwrap() += 1; + } +} + +fn session_names(state_json: &str) -> Vec { + serde_json::from_str::(state_json) + .unwrap() + .get("sessions") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|session| session.get("name").unwrap().as_str().unwrap().to_string()) + .collect() +} + +async fn post_json(addr: std::net::SocketAddr, path: &str, body: &str) -> String { + post_with_content_type(addr, path, "application/json", body).await +} + +async fn post_text(addr: std::net::SocketAddr, path: &str, body: &str) -> String { + post_with_content_type(addr, path, "text/plain", body).await +} + +async fn post_with_content_type( + addr: std::net::SocketAddr, + path: &str, + content_type: &str, + body: &str, +) -> String { + let mut stream = TcpStream::connect(addr) + .await + .expect("server should accept http clients"); + stream + .write_all( + format!( + "POST {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .expect("json request should write"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("json response should read"); + String::from_utf8(response).expect("response should be utf-8") +} diff --git a/apps/server-rs/tests/shim_socket.rs b/apps/server-rs/tests/shim_socket.rs new file mode 100644 index 0000000..5c2a29f --- /dev/null +++ b/apps/server-rs/tests/shim_socket.rs @@ -0,0 +1,104 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use opensessions_server::{ServerConfig, start_server}; +use opensessions_sidebar_protocol::{ + ServerToShim, ShimHello, ShimToServer, decode_server_message, encode_shim_message, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; +use tokio::time::{Duration, timeout}; + +fn test_path(name: &str, extension: &str) -> PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "os-{name}-{}-{stamp}.{extension}", + std::process::id() + )) +} + +#[tokio::test(flavor = "current_thread")] +async fn shim_socket_accepts_hello_and_returns_rendered_full_frame() { + let pid_file = test_path("shim", "pid"); + let socket_path = test_path("shim", "sock"); + let state = r#"{ + "type":"state", + "sessions":[{ + "name":"opensessions","createdAt":1,"dir":"/repo","branch":"main", + "dirty":false,"isWorktree":false,"unseen":false,"panes":1, + "ports":[],"localLinks":[],"windows":1,"uptime":"1m", + "agentState":null,"agents":[],"eventTimestamps":[],"metadata":null + }], + "focusedSession":"opensessions","currentSession":"opensessions", + "theme":"catppuccin-mocha","sessionFilter":"all","sidebarWidth":35, + "initializing":false,"ts":3 + }"#; + let server = start_server( + ServerConfig::new("127.0.0.1", 0, &pid_file) + .with_shim_socket_path(&socket_path) + .with_state_source(move || state.to_string()), + ) + .await + .expect("server should start"); + + let mut stream = UnixStream::connect(server.shim_socket_path().unwrap()) + .await + .expect("shim socket should accept local clients"); + stream + .write_all(&encode_shim_message(&ShimToServer::Hello(ShimHello { + protocol: 1, + pane_id: "%42".into(), + session_name: "opensessions".into(), + window_id: Some("@1".into()), + client_tty: Some("/dev/ttys-test".into()), + width: 35, + height: 10, + }))) + .await + .expect("shim hello should write"); + + let hello = timeout(Duration::from_secs(1), read_server_message(&mut stream)) + .await + .expect("server hello should arrive") + .expect("server hello should decode"); + assert_eq!(hello, ServerToShim::Hello { protocol: 1 }); + + let frame = timeout(Duration::from_secs(1), read_server_message(&mut stream)) + .await + .expect("initial frame should arrive") + .expect("initial frame should decode"); + let ServerToShim::FullFrame { + width, + height, + rows, + .. + } = frame + else { + panic!("initial shim render should be a full frame") + }; + assert_eq!((width, height), (35, 10)); + assert!( + rows.iter() + .any(|row| row.windows(8).any(|w| w == b"Sessions")) + ); + + server.shutdown().await.expect("server should shut down"); + assert!(!socket_path.exists(), "shutdown should remove shim socket"); + let _ = fs::remove_file(pid_file); +} + +async fn read_server_message(stream: &mut UnixStream) -> std::io::Result { + let mut len = [0_u8; 4]; + stream.read_exact(&mut len).await?; + let len = u32::from_le_bytes(len) as usize; + let mut payload = vec![0; len]; + stream.read_exact(&mut payload).await?; + let mut frame = Vec::with_capacity(4 + payload.len()); + frame.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + frame.extend_from_slice(&payload); + decode_server_message(&frame).map_err(std::io::Error::other) +} diff --git a/apps/server-rs/tests/sidebar_settle.rs b/apps/server-rs/tests/sidebar_settle.rs new file mode 100644 index 0000000..7f0f515 --- /dev/null +++ b/apps/server-rs/tests/sidebar_settle.rs @@ -0,0 +1,30 @@ +//! TDD wiring tests that verify apps/server-rs/src/lib.rs hooks the +//! [`SidebarCoordinator::tick_user_drag_settle`] helper into `snapshot_json`. +//! +//! Without this wiring the sidebar gets stuck in "adjusting…" forever after a +//! width report is accepted (see thread T-019dd34a-a0c7-77ab-8fa9-4cf23a739edc +//! and TS `startTransientSidebarResize` / FINISH_USER_DRAG `setTimeout`). + +#[test] +fn lib_rs_invokes_tick_user_drag_settle_in_snapshot_json() { + let lib_rs = include_str!("../src/lib.rs"); + + assert!( + lib_rs.contains("USER_DRAG_SETTLE_MS"), + "apps/server-rs/src/lib.rs must declare a USER_DRAG_SETTLE_MS \ + constant matching the TS USER_DRAG_SETTLE_MS = 600" + ); + assert!( + lib_rs.contains("tick_user_drag_settle"), + "apps/server-rs/src/lib.rs must call tick_user_drag_settle so the \ + coordinator clears the UserDrag authority once the settle window \ + passes; otherwise the sidebar shows 'adjusting…' forever" + ); + assert!( + lib_rs.contains("run_drag_settle_loop"), + "apps/server-rs/src/lib.rs must run a background ticker that \ + broadcasts a fresh state once the user-drag settle window expires; \ + without it the websocket only emits a stuck 'adjusting…' state and \ + never streams the cleared snapshot to connected clients" + ); +} diff --git a/apps/sidebar-shim-rs/Cargo.toml b/apps/sidebar-shim-rs/Cargo.toml new file mode 100644 index 0000000..5b4675b --- /dev/null +++ b/apps/sidebar-shim-rs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "opensessions-sidebar-shim" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "opensessions-sidebar-shim" +path = "src/main.rs" + +[dependencies] +opensessions-sidebar-protocol = { path = "../../packages/sidebar-protocol-rs" } +crossterm = { version = "0.29", default-features = false, features = ["event-stream", "events"] } +futures-util = { version = "0.3", default-features = false } +tokio = { version = "1", default-features = false, features = ["rt", "net", "io-util", "macros", "time"] } diff --git a/apps/sidebar-shim-rs/src/main.rs b/apps/sidebar-shim-rs/src/main.rs new file mode 100644 index 0000000..a757f7f --- /dev/null +++ b/apps/sidebar-shim-rs/src/main.rs @@ -0,0 +1,316 @@ +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::Command; + +use crossterm::cursor::{Hide, MoveTo, Show}; +use crossterm::event::{ + EnableMouseCapture, Event, EventStream, KeyCode as CrosstermKeyCode, KeyEventKind, + KeyModifiers as CrosstermKeyModifiers, MouseButton as CrosstermMouseButton, + MouseEventKind as CrosstermMouseEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{ + self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, + enable_raw_mode, +}; +use futures_util::StreamExt; +use opensessions_sidebar_protocol::{ + KeyCode, KeyMessage, KeyModifiers, MouseButton, MouseEventKind, MouseMessage, ServerToShim, + ShimHello, ShimToServer, decode_server_message, encode_shim_message, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::UnixStream; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> io::Result<()> { + let socket_path = socket_path(); + let context = PaneContext::detect(); + let (width, height) = terminal::size().unwrap_or((35, 56)); + let mut stream = UnixStream::connect(socket_path).await?; + stream + .write_all(&encode_shim_message(&ShimToServer::Hello(ShimHello { + protocol: 1, + pane_id: context.pane_id, + session_name: context.session_name, + window_id: context.window_id, + client_tty: context.client_tty, + width, + height, + }))) + .await?; + + let mut terminal = TerminalGuard::enter()?; + let mut events = EventStream::new(); + loop { + tokio::select! { + event = events.next() => { + let Some(event) = event else { return Ok(()); }; + match event? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if key.modifiers.contains(CrosstermKeyModifiers::CONTROL) + && matches!(key.code, CrosstermKeyCode::Char('c')) + { + return Ok(()); + } + if let Some(message) = key_message(key.code, key.modifiers) { + stream.write_all(&encode_shim_message(&ShimToServer::Key(message))).await?; + } + } + Event::Mouse(mouse) => { + if let Some(message) = mouse_message(mouse) { + stream.write_all(&encode_shim_message(&ShimToServer::Mouse(message))).await?; + } + } + Event::Resize(width, height) => { + stream.write_all(&encode_shim_message(&ShimToServer::Resize { width, height })).await?; + } + _ => {} + } + } + frame = read_frame(&mut stream) => { + let frame = frame?; + let message = decode_server_message(&frame).map_err(io::Error::other)?; + match message { + ServerToShim::Hello { .. } => {} + ServerToShim::Quit => return Ok(()), + ServerToShim::FullFrame { rows, .. } => terminal.write_full_frame(&rows)?, + ServerToShim::PatchFrame { changed_rows, clear_from_row, .. } => { + terminal.write_patch_frame(&changed_rows, clear_from_row)?; + } + } + } + } + } +} + +fn socket_path() -> PathBuf { + if let Some(path) = arg_value("--socket-path") { + return PathBuf::from(path); + } + if let Ok(path) = std::env::var("OPENSESSIONS_SHIM_SOCKET") { + return PathBuf::from(path); + } + if let Ok(pid_file) = std::env::var("OPENSESSIONS_PID_FILE") { + return PathBuf::from(pid_file).with_extension("sock"); + } + if let Ok(tmux) = std::env::var("TMUX") { + if let Some(socket) = tmux.split(',').next().filter(|value| !value.is_empty()) { + return PathBuf::from(format!( + "/tmp/opensessions.{}.sock", + hash_server_key(socket) + )); + } + } + PathBuf::from("/tmp/opensessions.sock") +} + +fn hash_server_key(input: &str) -> u16 { + let mut hash = 0_u32; + for (i, byte) in input.bytes().enumerate() { + hash = (hash + u32::from(byte) * (i as u32 + 1)) % 20_000; + } + hash as u16 +} + +fn arg_value(name: &str) -> Option { + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == name { + return args.next(); + } + } + None +} + +#[derive(Debug)] +struct PaneContext { + pane_id: String, + session_name: String, + window_id: Option, + client_tty: Option, +} + +impl PaneContext { + fn detect() -> Self { + let tmux = tmux_context(); + Self { + pane_id: std::env::var("TMUX_PANE") + .ok() + .or_else(|| tmux.as_ref().map(|ctx| ctx.pane_id.clone())) + .unwrap_or_else(|| "unknown".to_string()), + session_name: std::env::var("OPENSESSIONS_SESSION_NAME") + .ok() + .or_else(|| tmux.as_ref().map(|ctx| ctx.session_name.clone())) + .unwrap_or_else(|| "unknown".to_string()), + window_id: std::env::var("OPENSESSIONS_WINDOW_ID") + .ok() + .or_else(|| tmux.as_ref().and_then(|ctx| ctx.window_id.clone())), + client_tty: std::env::var("OPENSESSIONS_CLIENT_TTY") + .ok() + .or_else(|| tmux.and_then(|ctx| ctx.client_tty)), + } + } +} + +fn tmux_context() -> Option { + if std::env::var("TMUX").ok()?.is_empty() { + return None; + } + let output = Command::new("tmux") + .args([ + "display-message", + "-p", + "#{pane_id}|#{session_name}|#{window_id}|#{client_tty}", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let mut parts = text.trim().split('|'); + let pane_id = parts.next()?.to_string(); + let session_name = parts.next()?.to_string(); + let window_id = parts + .next() + .filter(|value| !value.is_empty()) + .map(str::to_string); + let client_tty = parts + .next() + .filter(|value| !value.is_empty()) + .map(str::to_string); + Some(PaneContext { + pane_id, + session_name, + window_id, + client_tty, + }) +} + +fn key_message(code: CrosstermKeyCode, modifiers: CrosstermKeyModifiers) -> Option { + let code = match code { + CrosstermKeyCode::Char(ch) => KeyCode::Char(ch), + CrosstermKeyCode::Up => KeyCode::Up, + CrosstermKeyCode::Down => KeyCode::Down, + CrosstermKeyCode::Tab | CrosstermKeyCode::BackTab => KeyCode::Tab, + CrosstermKeyCode::Enter => KeyCode::Enter, + CrosstermKeyCode::Esc => KeyCode::Esc, + _ => return None, + }; + let mut shim_modifiers = modifiers_from_crossterm(modifiers); + if matches!(code, KeyCode::Tab) && modifiers.contains(CrosstermKeyModifiers::SHIFT) { + shim_modifiers = shim_modifiers | KeyModifiers::SHIFT; + } + Some(KeyMessage { + code, + modifiers: shim_modifiers, + }) +} + +fn mouse_message(mouse: crossterm::event::MouseEvent) -> Option { + let (kind, button) = match mouse.kind { + CrosstermMouseEventKind::Down(button) => (MouseEventKind::Down, mouse_button(button)), + CrosstermMouseEventKind::Up(button) => (MouseEventKind::Up, mouse_button(button)), + CrosstermMouseEventKind::Drag(button) => (MouseEventKind::Drag, mouse_button(button)), + CrosstermMouseEventKind::Moved => (MouseEventKind::Move, MouseButton::None), + CrosstermMouseEventKind::ScrollUp => (MouseEventKind::ScrollUp, MouseButton::None), + CrosstermMouseEventKind::ScrollDown => (MouseEventKind::ScrollDown, MouseButton::None), + _ => return None, + }; + Some(MouseMessage { + kind, + button, + column: mouse.column, + row: mouse.row, + modifiers: modifiers_from_crossterm(mouse.modifiers), + }) +} + +fn mouse_button(button: CrosstermMouseButton) -> MouseButton { + match button { + CrosstermMouseButton::Left => MouseButton::Left, + CrosstermMouseButton::Middle => MouseButton::Middle, + CrosstermMouseButton::Right => MouseButton::Right, + } +} + +fn modifiers_from_crossterm(modifiers: CrosstermKeyModifiers) -> KeyModifiers { + let mut shim_modifiers = KeyModifiers::empty(); + if modifiers.contains(CrosstermKeyModifiers::SHIFT) { + shim_modifiers = shim_modifiers | KeyModifiers::SHIFT; + } + if modifiers.contains(CrosstermKeyModifiers::ALT) { + shim_modifiers = shim_modifiers | KeyModifiers::ALT; + } + if modifiers.contains(CrosstermKeyModifiers::CONTROL) { + shim_modifiers = shim_modifiers | KeyModifiers::CONTROL; + } + shim_modifiers +} + +async fn read_frame(stream: &mut UnixStream) -> io::Result> { + let mut len = [0_u8; 4]; + stream.read_exact(&mut len).await?; + let len = u32::from_le_bytes(len) as usize; + let mut frame = Vec::with_capacity(4 + len); + frame.extend_from_slice(&(len as u32).to_le_bytes()); + frame.resize(4 + len, 0); + stream.read_exact(&mut frame[4..]).await?; + Ok(frame) +} + +struct TerminalGuard { + stdout: io::Stdout, +} + +impl TerminalGuard { + fn enter() -> io::Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, Hide, EnableMouseCapture)?; + Ok(Self { stdout }) + } + + fn write_full_frame(&mut self, rows: &[Vec]) -> io::Result<()> { + for (idx, row) in rows.iter().enumerate() { + execute!(self.stdout, MoveTo(0, idx as u16))?; + self.stdout.write_all(row)?; + execute!(self.stdout, Clear(ClearType::UntilNewLine))?; + } + execute!(self.stdout, MoveTo(0, rows.len() as u16))?; + execute!(self.stdout, Clear(ClearType::FromCursorDown))?; + self.stdout.flush() + } + + fn write_patch_frame( + &mut self, + changed_rows: &[(u16, Vec)], + clear_from_row: Option, + ) -> io::Result<()> { + for (row, bytes) in changed_rows { + execute!(self.stdout, MoveTo(0, *row))?; + self.stdout.write_all(bytes)?; + execute!(self.stdout, Clear(ClearType::UntilNewLine))?; + } + if let Some(row) = clear_from_row { + execute!( + self.stdout, + MoveTo(0, row), + Clear(ClearType::FromCursorDown) + )?; + } + self.stdout.flush() + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = execute!( + self.stdout, + crossterm::event::DisableMouseCapture, + Show, + LeaveAlternateScreen + ); + let _ = disable_raw_mode(); + } +} diff --git a/apps/tui-rs/AGENT.md b/apps/tui-rs/AGENT.md new file mode 100644 index 0000000..8b4b85f --- /dev/null +++ b/apps/tui-rs/AGENT.md @@ -0,0 +1,7 @@ +# Ratatui TUI Agent Discipline + +Snapshot tests against `docs/ratatui-migration/reference-snapshots/*.ansi` are the ultimate gate for render fidelity. Do not mark read-only rendering complete unless the Ratatui output matches the documented reference snapshots. + +Follow the architectural decisions in `docs/ratatui-migration/00-index.md` through `17-feasibility-matrix.md`. Do not substitute crates, async runtime features, distribution strategy, layout behavior, protocol shape, or performance targets unless the migration docs are updated by a human reviewer first. + +When practical, add tests for new behavior — but pragmatic feature parity work may land without a prior failing test. diff --git a/apps/tui-rs/Cargo.toml b/apps/tui-rs/Cargo.toml new file mode 100644 index 0000000..dfca963 --- /dev/null +++ b/apps/tui-rs/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "opensessions-sidebar" +version = "0.1.0" +edition = "2024" + +[lib] +name = "opensessions_sidebar" +path = "src/lib.rs" + +[[bin]] +name = "opensessions-sidebar" +path = "src/main.rs" + +[dependencies] +opensessions-sidebar-core = { path = "../../packages/sidebar-core-rs" } +opensessions-sidebar-protocol = { path = "../../packages/sidebar-protocol-rs" } +ratatui = { version = "0.30", default-features = false, features = ["crossterm"] } +crossterm = { version = "0.29", default-features = false, features = ["event-stream", "events"] } +unicode-width = "0.2" +tokio = { version = "1", default-features = false, features = ["rt", "net", "io-util", "macros", "time", "sync"] } +tokio-util = { version = "0.7", default-features = false, features = [] } +tokio-websockets = { version = "0.13.2", default-features = false, features = ["client", "sha1_smol", "fastrand"] } +futures-util = { version = "0.3", default-features = false, features = ["sink"] } +http = "1" +serde = { version = "1", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } +anyhow = "1" +clap = { version = "4", default-features = false, features = ["std", "derive", "help"] } diff --git a/apps/tui-rs/src/app.rs b/apps/tui-rs/src/app.rs new file mode 100644 index 0000000..80209f3 --- /dev/null +++ b/apps/tui-rs/src/app.rs @@ -0,0 +1,500 @@ +use std::time::{Duration, Instant}; + +use crate::generated::protocol::{ + AgentEvent, AgentLiveness, AgentStatus, ClientCommand, LocalLink, ServerMessage, ServerState, + SessionData, SessionFilterMode, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PanelFocus { + Sessions, + Agents, +} + +#[derive(Debug)] +pub struct App { + pub sessions: Vec, + pub focused_session: Option, + pub current_session: Option, + pub my_session: Option, + pub session_filter: SessionFilterMode, + pub panel_focus: PanelFocus, + pub focused_agent_idx: usize, + pub quit_deadline: Option, + pub fixture_name: Option<&'static str>, + commands: Vec, +} + +impl App { + pub fn from_state(state: ServerState) -> Self { + Self { + sessions: state.sessions, + focused_session: state.focused_session, + current_session: state.current_session, + my_session: None, + session_filter: state.session_filter.unwrap_or_default(), + panel_focus: PanelFocus::Sessions, + focused_agent_idx: 0, + quit_deadline: None, + fixture_name: None, + commands: Vec::new(), + } + } + + pub fn apply_server_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::State(state) => { + self.sessions = state.sessions; + self.focused_session = state.focused_session; + self.current_session = state.current_session; + self.session_filter = state.session_filter.unwrap_or_default(); + } + ServerMessage::Focus(update) => { + self.focused_session = update.focused_session; + self.current_session = update.current_session; + } + ServerMessage::YourSession { name, .. } => { + self.my_session = Some(name); + } + ServerMessage::Hello(_) + | ServerMessage::Resize { .. } + | ServerMessage::Quit + | ServerMessage::ReIdentify => {} + } + } + + pub fn reference_fixture(name: &str) -> Self { + let (focused_session, current_session) = match name { + "pane-opensessions-self" => (Some("opensessions"), Some("opensessions")), + "pane-multi-window" => (Some("plane-feat-background-exports"), Some("opensessions")), + _ => (Some("plane-pdf-word-formatting"), Some("opensessions")), + }; + + let mut app = Self { + sessions: reference_sessions(), + focused_session: focused_session.map(str::to_string), + current_session: current_session.map(str::to_string), + my_session: current_session.map(str::to_string), + session_filter: SessionFilterMode::All, + panel_focus: PanelFocus::Sessions, + focused_agent_idx: 0, + quit_deadline: None, + fixture_name: fixture_static_name(name), + commands: Vec::new(), + }; + + if name == "pane-opensessions-self" { + app.focused_agent_idx = 0; + } + + app + } + + pub fn resolve_synced_focus( + next_focused_session: Option<&str>, + next_current_session: Option<&str>, + local_session_name: Option<&str>, + ) -> Option { + if let (Some(local), Some(current)) = (local_session_name, next_current_session) { + if current != local { + return Some(local.to_string()); + } + } + + next_focused_session + .or(local_session_name) + .map(str::to_string) + } + + pub fn filtered_sessions(&self) -> impl Iterator { + let mode = self.session_filter; + self.sessions.iter().filter(move |session| { + if session.name == "_os_stash" { + return false; + } + + match mode { + SessionFilterMode::All => true, + SessionFilterMode::Active => { + !session.agents.is_empty() || session.agent_state.is_some() + } + SessionFilterMode::Running => matches!( + session.agent_state.as_ref().map(|agent| agent.status), + Some(AgentStatus::Running | AgentStatus::ToolRunning | AgentStatus::Waiting), + ), + } + }) + } + + pub fn handle_key_char(&mut self, key: char) { + match key { + '1'..='9' => self.commands.push(ClientCommand::SwitchIndex { + index: key.to_digit(10).expect("digit key must parse"), + }), + 'q' => { + self.commands.push(ClientCommand::Quit); + self.quit_deadline = Some(Instant::now() + Duration::from_millis(500)); + } + 'r' => self.commands.push(ClientCommand::Refresh), + 'n' | 'c' => self.commands.push(ClientCommand::NewSession), + 'u' => self.commands.push(ClientCommand::ShowAllSessions), + 'd' => { + if self.panel_focus == PanelFocus::Agents { + self.dismiss_focused_agent(); + } else if let Some(name) = self.focused_session.clone() { + self.commands.push(ClientCommand::HideSession { name }); + } + } + 'x' => { + if self.panel_focus == PanelFocus::Agents { + self.kill_focused_agent_pane(); + } else if let Some(name) = self.focused_session.clone() { + self.commands.push(ClientCommand::KillSession { name }); + } + } + 'f' => self.cycle_filter(), + _ => {} + } + } + + pub fn handle_tab(&mut self, shift: bool) { + let names: Vec = self + .filtered_sessions() + .map(|session| session.name.clone()) + .collect(); + if names.is_empty() { + return; + } + + let current = self.current_session.as_deref(); + let current_idx = current + .and_then(|name| names.iter().position(|candidate| candidate == name)) + .unwrap_or(0); + let next_idx = if shift { + (current_idx + names.len() - 1) % names.len() + } else { + (current_idx + 1) % names.len() + }; + self.switch_to_session(names[next_idx].clone()); + } + + pub fn drain_commands(&mut self) -> Vec { + self.commands.drain(..).collect() + } + + pub fn move_focus(&mut self, delta: i8) { + let Some((current_idx, len)) = self.focused_filtered_index_and_len() else { + return; + }; + let max_idx = len - 1; + let next_idx = (current_idx as i16 + delta as i16).clamp(0, max_idx as i16) as usize; + if next_idx == current_idx { + return; + } + let Some(name) = self.filtered_session_name_at(next_idx) else { + return; + }; + self.focused_session = Some(name.clone()); + self.panel_focus = PanelFocus::Sessions; + self.focused_agent_idx = 0; + self.commands.push(ClientCommand::FocusSession { name }); + } + + pub fn focus_sessions_panel(&mut self) { + self.panel_focus = PanelFocus::Sessions; + } + + pub fn focus_agents_panel(&mut self) { + let agent_count = self.focused_agents_len(); + if agent_count == 0 { + return; + } + self.panel_focus = PanelFocus::Agents; + self.focused_agent_idx = self.focused_agent_idx.min(agent_count - 1); + } + + pub fn move_agent_focus(&mut self, delta: i8) { + let agent_count = self.focused_agents_len(); + if agent_count == 0 { + return; + } + let max_idx = agent_count - 1; + self.focused_agent_idx = + (self.focused_agent_idx as i16 + delta as i16).clamp(0, max_idx as i16) as usize; + } + + pub fn activate_focused_item(&mut self) { + if self.panel_focus == PanelFocus::Agents { + self.activate_focused_agent(); + } else { + self.activate_focused_session(); + } + } + + pub fn activate_focused_session(&mut self) { + if let Some(name) = self.focused_session.clone() { + self.switch_to_session(name); + } + } + + pub fn activate_focused_agent(&mut self) { + let Some((session, agent)) = self + .focused_agent() + .map(|(session, agent)| (session.name.clone(), agent.clone())) + else { + return; + }; + self.current_session = Some(session.clone()); + self.commands.push(ClientCommand::SwitchSession { + name: session.clone(), + client_tty: None, + }); + self.commands.push(ClientCommand::FocusAgentPane { + session, + agent: agent.agent, + thread_id: agent.thread_id, + thread_name: agent.thread_name, + }); + } + + pub fn dismiss_focused_agent(&mut self) { + let Some((session, agent, agent_count)) = self + .focused_agent() + .map(|(session, agent)| (session.name.clone(), agent.clone(), session.agents.len())) + else { + return; + }; + self.commands.push(ClientCommand::DismissAgent { + session, + agent: agent.agent, + thread_id: agent.thread_id, + }); + if self.focused_agent_idx >= agent_count.saturating_sub(1) && agent_count > 1 { + self.focused_agent_idx = agent_count - 2; + } + if agent_count <= 1 { + self.panel_focus = PanelFocus::Sessions; + } + } + + pub fn kill_focused_agent_pane(&mut self) { + let Some((session, agent)) = self + .focused_agent() + .map(|(session, agent)| (session.name.clone(), agent.clone())) + else { + return; + }; + self.commands.push(ClientCommand::KillAgentPane { + session, + agent: agent.agent, + thread_id: agent.thread_id, + thread_name: agent.thread_name, + }); + } + + pub fn reorder_focused_session(&mut self, delta: i8) { + if let Some(name) = self.focused_session.clone() { + self.commands + .push(ClientCommand::ReorderSession { name, delta }); + } + } + + fn switch_to_session(&mut self, name: String) { + self.my_session = Some(name.clone()); + self.current_session = Some(name.clone()); + self.focused_session = Some(name.clone()); + self.panel_focus = PanelFocus::Sessions; + self.focused_agent_idx = 0; + self.commands.push(ClientCommand::SwitchSession { + name, + client_tty: None, + }); + } + + fn cycle_filter(&mut self) { + self.session_filter = match self.session_filter { + SessionFilterMode::All => SessionFilterMode::Active, + SessionFilterMode::Active => SessionFilterMode::Running, + SessionFilterMode::Running => SessionFilterMode::All, + }; + self.commands.push(ClientCommand::SetFilter { + filter: self.session_filter, + }); + } + + fn focused_filtered_index_and_len(&self) -> Option<(usize, usize)> { + let focused = self.focused_session.as_deref(); + let mut focused_idx = None; + let mut len = 0; + for (idx, session) in self.filtered_sessions().enumerate() { + if Some(session.name.as_str()) == focused { + focused_idx = Some(idx); + } + len += 1; + } + (len > 0).then_some((focused_idx.unwrap_or(0), len)) + } + + fn filtered_session_name_at(&self, index: usize) -> Option { + self.filtered_sessions() + .nth(index) + .map(|session| session.name.clone()) + } + + fn focused_session_data(&self) -> Option<&SessionData> { + let focused = self.focused_session.as_deref()?; + self.sessions.iter().find(|session| session.name == focused) + } + + fn focused_agents_len(&self) -> usize { + self.focused_session_data() + .map(|session| session.agents.len()) + .unwrap_or(0) + } + + fn focused_agent(&self) -> Option<(&SessionData, &AgentEvent)> { + let session = self.focused_session_data()?; + let agent = session.agents.get(self.focused_agent_idx)?; + Some((session, agent)) + } +} + +fn fixture_static_name(name: &str) -> Option<&'static str> { + match name { + "pane-attached-session-list" => Some("pane-attached-session-list"), + "pane-opensessions-self" => Some("pane-opensessions-self"), + "pane-multi-window" => Some("pane-multi-window"), + _ => None, + } +} + +fn reference_sessions() -> Vec { + vec![ + session("_os_stash", "/tmp/_os_stash", "", None, Vec::new()), + session( + "plane-feat-edit-pages-from-pi", + "/Users/palanikannanm/Documents/work/feat-edit-pages-from-pi", + "feat/edit-pages-from-pi", + None, + Vec::new(), + ), + session( + "plane-feat-background-exports", + "/Users/palanikannanm/Documents/work/feat-background-exports", + "feat-background-exports", + None, + Vec::new(), + ), + session( + "learning", + "/Users/palanikannanm/Documents/work/learning", + "main", + None, + Vec::new(), + ), + session( + "opensessions", + "/Users/palanikannanm/Documents/work/opensessions", + "devpulse", + Some(agent( + "amp", + "opensessions", + AgentStatus::ToolRunning, + Some("Query tmux for open sessions"), + None, + )), + vec![ + agent( + "amp", + "opensessions", + AgentStatus::ToolRunning, + Some("Query tmux for open sessions"), + None, + ), + agent("amp", "opensessions", AgentStatus::Idle, None, None), + ], + ), + session( + "plane-pdf-word-formatting", + "/Users/palanikannanm/Documents/work/plane-ee-wt/pdf-word-formatting", + "chore-relation-pqls", + Some(agent( + "amp", + "plane-pdf-word-formatting", + AgentStatus::Done, + Some("Review GitHub PR for Plane"), + Some(false), + )), + vec![ + agent( + "amp", + "plane-pdf-word-formatting", + AgentStatus::Done, + Some("Review GitHub PR for Plane"), + Some(false), + ), + agent( + "amp", + "plane-pdf-word-formatting", + AgentStatus::Idle, + None, + None, + ), + ], + ), + session( + "dotfiles_public", + "/Users/palanikannanm/Documents/work/dotfiles.public", + "main", + None, + Vec::new(), + ), + ] +} + +fn session( + name: &str, + dir: &str, + branch: &str, + agent_state: Option, + agents: Vec, +) -> SessionData { + SessionData { + name: name.to_string(), + created_at: 0, + dir: dir.to_string(), + branch: branch.to_string(), + dirty: false, + is_worktree: false, + unseen: name == "plane-pdf-word-formatting", + panes: 1, + ports: Vec::new(), + local_links: Vec::::new(), + windows: 1, + uptime: String::new(), + agent_state, + agents, + event_timestamps: Vec::new(), + metadata: None, + } +} + +fn agent( + agent_name: &str, + session: &str, + status: AgentStatus, + thread_name: Option<&str>, + unseen: Option, +) -> AgentEvent { + AgentEvent { + agent: agent_name.to_string(), + session: session.to_string(), + status, + ts: 0, + thread_id: None, + thread_name: thread_name.map(str::to_string), + unseen, + pane_id: None, + liveness: Some(AgentLiveness::Alive), + } +} diff --git a/apps/tui-rs/src/cli.rs b/apps/tui-rs/src/cli.rs new file mode 100644 index 0000000..e98beb0 --- /dev/null +++ b/apps/tui-rs/src/cli.rs @@ -0,0 +1,70 @@ +use clap::Parser; + +use crate::runtime_config::{DEFAULT_SERVER_PORT, hash_server_key, resolve_server_port}; + +const DEFAULT_SERVER_HOST: &str = "127.0.0.1"; + +#[derive(Debug, Clone, Parser)] +#[command(name = "opensessions-sidebar")] +pub struct Args { + #[arg(long, default_value = "127.0.0.1")] + pub server_host: String, + #[arg(long, default_value_t = DEFAULT_SERVER_PORT)] + pub server_port: u16, +} + +impl Args { + pub fn try_parse_from(itr: I) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + ::try_parse_from(itr) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedEndpoint { + pub server_host: String, + pub server_port: u16, +} + +pub fn resolve_endpoint_from_env(env: F) -> ResolvedEndpoint +where + F: Fn(&str) -> Option, +{ + let server_key = resolve_server_key(&env); + let explicit_port = env("OPENSESSIONS_PORT"); + let server_port = resolve_server_port(server_key, explicit_port.as_deref()); + let server_host = env("OPENSESSIONS_HOST") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_SERVER_HOST.to_string()); + + ResolvedEndpoint { + server_host, + server_port, + } +} + +fn resolve_server_key(env: &F) -> Option +where + F: Fn(&str) -> Option, +{ + if let Some(explicit) = env("OPENSESSIONS_SERVER_KEY") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + && let Ok(parsed) = explicit.parse::() + { + return Some(parsed); + } + + let tmux = env("TMUX") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let socket_path = tmux.split(',').next()?; + if socket_path.is_empty() { + return None; + } + Some(hash_server_key(socket_path)) +} diff --git a/apps/tui-rs/src/client.rs b/apps/tui-rs/src/client.rs new file mode 100644 index 0000000..4083805 --- /dev/null +++ b/apps/tui-rs/src/client.rs @@ -0,0 +1,74 @@ +use anyhow::Result; +use http::Uri; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpStream; +use tokio_websockets::{ClientBuilder, MaybeTlsStream, WebSocketStream}; + +use crate::generated::protocol::{ClientCommand, ServerMessage}; + +pub const EXPECTED_PROTOCOL_VERSION: u16 = 1; + +pub fn validate_hello(msg: &ServerMessage) -> std::result::Result<(), String> { + let ServerMessage::Hello(hello) = msg else { + return Err("expected hello as first server message".to_string()); + }; + + if hello.protocol != EXPECTED_PROTOCOL_VERSION { + return Err(format!( + "unsupported protocol {}, expected {}", + hello.protocol, EXPECTED_PROTOCOL_VERSION + )); + } + + Ok(()) +} + +pub fn decode_server_message(payload: &[u8]) -> serde_json::Result { + serde_json::from_slice(payload) +} + +pub fn encode_client_command(command: &ClientCommand) -> serde_json::Result { + serde_json::to_string(command) +} + +/// Build the raw HTTP/1.1 request the sidebar fires at `http://host:port/quit` +/// when the user presses 'q'. Mirrors the TypeScript fallback in +/// `apps/tui/src/index.tsx`: +/// `fetch(`http://${SERVER_HOST}:${SERVER_PORT}/quit`, { method: "POST" })` +/// This is fire-and-forget — the server replies, then closes the WS, which +/// tears down the renderer. +pub fn build_quit_http_request(host: &str, port: u16) -> String { + format!( + "POST /quit HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) +} + +/// Fire-and-forget HTTP POST to `/quit`. Errors are intentionally swallowed: +/// this is a fallback for when the WS Quit frame might be lost while the TUI +/// is tearing down. +pub async fn fire_quit_http(host: &str, port: u16) { + let Ok(mut stream) = TcpStream::connect((host, port)).await else { + return; + }; + let _ = stream + .write_all(build_quit_http_request(host, port).as_bytes()) + .await; + let _ = stream.shutdown().await; +} + +pub async fn connect_ws( + host: &str, + port: u16, +) -> Result>> { + connect_ws_path(host, port, "/").await +} + +pub async fn connect_ws_path( + host: &str, + port: u16, + path_and_query: &str, +) -> Result>> { + let uri: Uri = format!("ws://{host}:{port}{path_and_query}").parse()?; + let (ws, _) = ClientBuilder::from_uri(uri).connect().await?; + Ok(ws) +} diff --git a/apps/tui-rs/src/generated/mod.rs b/apps/tui-rs/src/generated/mod.rs new file mode 100644 index 0000000..1b800ec --- /dev/null +++ b/apps/tui-rs/src/generated/mod.rs @@ -0,0 +1 @@ +pub mod protocol; diff --git a/apps/tui-rs/src/generated/protocol.rs b/apps/tui-rs/src/generated/protocol.rs new file mode 100644 index 0000000..8679bc8 --- /dev/null +++ b/apps/tui-rs/src/generated/protocol.rs @@ -0,0 +1,286 @@ +use core::fmt; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProtocolHello { + pub protocol: u16, + pub server_version: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum ServerMessage { + Hello(ProtocolHello), + State(ServerState), + Focus(FocusUpdate), + Resize { + width: u32, + }, + Quit, + YourSession { + name: String, + client_tty: Option, + }, + ReIdentify, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerState { + pub sessions: Vec, + pub focused_session: Option, + pub current_session: Option, + pub theme: Option, + pub session_filter: Option, + pub sidebar_width: u32, + pub initializing: bool, + #[serde(default)] + pub init_label: Option, + pub ts: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FocusUpdate { + pub focused_session: Option, + pub current_session: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionData { + pub name: String, + pub created_at: u64, + pub dir: String, + pub branch: String, + pub dirty: bool, + pub is_worktree: bool, + pub unseen: bool, + pub panes: u32, + pub ports: Vec, + pub local_links: Vec, + pub windows: u32, + pub uptime: String, + pub agent_state: Option, + pub agents: Vec, + pub event_timestamps: Vec, + #[serde(default)] + pub metadata: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct LocalLink { + pub kind: LocalLinkKind, + pub port: u32, + pub url: String, + pub label: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LocalLinkKind { + Direct, + Portless, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AgentStatus { + Idle, + Running, + ToolRunning, + Done, + Error, + Waiting, + Interrupted, + Stale, +} + +impl fmt::Display for AgentStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let status = match self { + Self::Idle => "idle", + Self::Running => "running", + Self::ToolRunning => "tool-running", + Self::Done => "done", + Self::Error => "error", + Self::Waiting => "waiting", + Self::Interrupted => "interrupted", + Self::Stale => "stale", + }; + f.write_str(status) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentLiveness { + Alive, + Exited, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentEvent { + pub agent: String, + pub session: String, + pub status: AgentStatus, + pub ts: u64, + #[serde(default)] + pub thread_id: Option, + #[serde(default)] + pub thread_name: Option, + #[serde(default)] + pub unseen: Option, + #[serde(default)] + pub pane_id: Option, + #[serde(default)] + pub liveness: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MetadataTone { + Neutral, + Info, + Success, + Warn, + Error, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct MetadataStatus { + pub text: String, + #[serde(default)] + pub tone: Option, + pub ts: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct MetadataProgress { + #[serde(default)] + pub current: Option, + #[serde(default)] + pub total: Option, + #[serde(default)] + pub percent: Option, + #[serde(default)] + pub label: Option, + pub ts: u64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct MetadataLogEntry { + pub message: String, + #[serde(default)] + pub tone: Option, + #[serde(default)] + pub source: Option, + pub ts: u64, +} + +#[derive(Debug, Clone, PartialEq, Default, Deserialize)] +pub struct SessionMetadata { + #[serde(default)] + pub status: Option, + #[serde(default)] + pub progress: Option, + #[serde(default)] + pub logs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SessionFilterMode { + #[default] + All, + Active, + Running, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum ClientCommand { + SwitchSession { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + client_tty: Option, + }, + SwitchIndex { + index: u32, + }, + NewSession, + HideSession { + name: String, + }, + ShowAllSessions, + KillSession { + name: String, + }, + ReorderSession { + name: String, + delta: i8, + }, + Refresh, + MoveFocus { + delta: i8, + }, + FocusSession { + name: String, + }, + MarkSeen { + name: String, + }, + DismissAgent { + session: String, + agent: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + SetTheme { + theme: String, + }, + SetFilter { + filter: SessionFilterMode, + }, + Identify { + client_tty: String, + }, + Quit, + IdentifyPane { + pane_id: String, + session_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + window_id: Option, + }, + FocusAgentPane { + session: String, + agent: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thread_name: Option, + }, + KillAgentPane { + session: String, + agent: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thread_name: Option, + }, + ReportWidth { + width: u32, + }, +} diff --git a/apps/tui-rs/src/lib.rs b/apps/tui-rs/src/lib.rs new file mode 100644 index 0000000..4c8cd37 --- /dev/null +++ b/apps/tui-rs/src/lib.rs @@ -0,0 +1,6 @@ +pub mod cli; +pub mod client; +pub mod runtime_config; +pub mod runtime_context; + +pub use opensessions_sidebar_core::{app, frame, generated, input, renderer, snapshot}; diff --git a/apps/tui-rs/src/main.rs b/apps/tui-rs/src/main.rs new file mode 100644 index 0000000..07a9d31 --- /dev/null +++ b/apps/tui-rs/src/main.rs @@ -0,0 +1,516 @@ +use anyhow::{Context, Result, bail}; +use clap::Parser; +use crossterm::cursor::Show; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEvent, KeyEventKind, + KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use futures_util::{SinkExt, StreamExt}; +use opensessions_sidebar::app::{App, LaunchTarget}; +use opensessions_sidebar::cli::{Args, resolve_endpoint_from_env}; +use opensessions_sidebar::client::{ + connect_ws, decode_server_message, encode_client_command, fire_quit_http, validate_hello, +}; +use opensessions_sidebar::generated::protocol::{ClientCommand, ServerMessage}; +use opensessions_sidebar::input::{UiKey, UiMouse, apply_ui_key, apply_ui_mouse}; +use opensessions_sidebar::renderer::render_app; +use opensessions_sidebar::runtime_context::{ + PaneIdentity as RuntimePaneIdentity, pane_identity_resolve, refocus_plan, report_width_command, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use std::io; +use tokio_websockets::Message; + +const DEFAULT_SERVER_HOST: &str = "127.0.0.1"; +const DEFAULT_SERVER_PORT: u16 = 7_391; + +/// Append a single debug line to the path in `OPENSESSIONS_DEBUG_LOG` +/// (defaults to `/tmp/opensessions-debug.log`). Mirrors the helper in +/// `apps/server-rs/src/lib.rs` so we can correlate client and server events +/// in the live tmux A/B harness. +fn debug_log(line: impl AsRef) { + use std::io::Write; + use std::time::{SystemTime, UNIX_EPOCH}; + let path = std::env::var("OPENSESSIONS_DEBUG_LOG") + .unwrap_or_else(|_| "/tmp/opensessions-debug-rs.log".to_string()); + if path.is_empty() { + return; + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!(file, "[{now}] [sidebar] {}", line.as_ref()); + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + let args = Args::parse(); + let env = resolve_endpoint_from_env(|key| std::env::var(key).ok()); + let server_host = if args.server_host == DEFAULT_SERVER_HOST { + env.server_host + } else { + args.server_host + }; + let server_port = if args.server_port == DEFAULT_SERVER_PORT { + env.server_port + } else { + args.server_port + }; + + let identity = pane_identity_resolve( + |key| std::env::var(key).ok(), + |format, target| tmux_display_message(format, target), + ); + + debug_log(format!( + "starting: connecting to ws://{server_host}:{server_port}/ identity={identity:?}" + )); + let mut ws = connect_ws(&server_host, server_port) + .await + .with_context(|| format!("connect ws://{server_host}:{server_port}/"))?; + debug_log("ws: connected"); + + let first = ws.next().await.context("read protocol hello")??; + if !first.is_text() { + bail!("expected text hello frame"); + } + let hello = decode_server_message(first.as_payload())?; + validate_hello(&hello).map_err(anyhow::Error::msg)?; + + if let Some(RuntimePaneIdentity { + pane_id, + session_name, + window_id, + }) = identity.clone() + { + let command = ClientCommand::IdentifyPane { + pane_id, + session_name, + window_id, + }; + ws.send(Message::text(encode_client_command(&command)?)) + .await?; + } + + let mut terminal = TerminalGuard::enter()?; + let mut events = EventStream::new(); + let mut app: Option = None; + let mut last_reported_width: Option = None; + let mut startup_refocused = false; + // Render-tick interval: advance the spinner clock and redraw at ~120ms so + // the "warming up…" / "adjusting…" / agent-running spinners animate even + // when no server state arrives. Mirrors the React render loop in the TS + // sidebar driven by Date.now() inside Yoga's frame timer. + let render_epoch = std::time::Instant::now(); + let mut render_tick = tokio::time::interval(tokio::time::Duration::from_millis(120)); + render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + // Hard-exit timer: once the user presses 'q', App::handle_key_char sets + // `quit_deadline` to now+500ms. If neither the WS Quit response nor the + // HTTP /quit fallback tears us down before then, we exit anyway so the + // user is never stuck in a dead TUI. Mirrors + // `setTimeout(() => renderer.destroy(), 500)` in apps/tui/src/index.tsx. + let quit_deadline = app.as_ref().and_then(|app| app.quit_deadline); + // Click-flash expiry: when a click arms a 150ms flash highlight, force + // a re-render at the deadline so the highlight clears even without + // any other event. Mirrors `setTimeout` in TS `triggerFlash`. + let flash_deadline = app.as_ref().and_then(|app| app.flash_deadline); + + tokio::select! { + biased; + + _ = async { + match quit_deadline { + Some(deadline) => tokio::time::sleep_until(deadline.into()).await, + None => std::future::pending::<()>().await, + } + } => { + return Ok(()); + } + + _ = render_tick.tick() => { + if let Some(app) = &mut app { + let now_ms = render_epoch.elapsed().as_millis() as u64; + if app.spinner_now != now_ms { + app.spinner_now = now_ms; + // Only redraw if there's something animating. Otherwise + // a 120ms idle wakeup costs about a single buffer diff + // which still fights the terminal for cursor focus. + let needs_redraw = app.initializing + || app.sessions.iter().any(|session| { + session.agents.iter().any(|agent| { + matches!( + agent.status, + opensessions_sidebar::generated::protocol::AgentStatus::Running + | opensessions_sidebar::generated::protocol::AgentStatus::ToolRunning + ) + }) + || session + .agent_state + .as_ref() + .map(|state| { + matches!( + state.status, + opensessions_sidebar::generated::protocol::AgentStatus::Running + ) + }) + .unwrap_or(false) + }); + if needs_redraw { + terminal.draw(app)?; + } + } + } + continue; + } + + _ = async { + match flash_deadline { + Some(deadline) => tokio::time::sleep_until(deadline.into()).await, + None => std::future::pending::<()>().await, + } + } => { + if let Some(app) = &mut app { + app.flash_target = None; + app.flash_deadline = None; + terminal.draw(app)?; + } + continue; + } + + event = events.next() => { + let Some(event) = event else { + return Ok(()); + }; + let event = event?; + if let Event::Key(key) = event { + if key.kind != KeyEventKind::Press { + continue; + } + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c')) + { + return Ok(()); + } + let Some(app) = &mut app else { + continue; + }; + + handle_key(app, key); + for command in app.drain_commands() { + let is_quit = matches!(command, ClientCommand::Quit); + ws.send(Message::text(encode_client_command(&command)?)).await?; + if is_quit { + // HTTP fallback on a separate TCP connection. + // Whichever path reaches the server first triggers + // quitAll → close WS → renderer teardown. We + // fire-and-forget on the current_thread runtime. + let host = server_host.clone(); + let port = server_port; + tokio::spawn(async move { + fire_quit_http(&host, port).await; + }); + } + } + for launch in app.drain_launches() { + let dir = app + .focused_session + .as_deref() + .and_then(|name| app.sessions.iter().find(|s| s.name == name)) + .map(|s| s.dir.as_str()) + .unwrap_or("."); + launch_lazydiff(launch, dir); + } + terminal.draw(app)?; + } else if let Event::Resize(width, _) = event { + if let Some(app) = &mut app { + app.set_terminal_width(width); + let width = u32::from(width); + if last_reported_width != Some(width) { + let local_session = identity + .as_ref() + .map(|identity| identity.session_name.as_str()) + .or(app.my_session.as_deref()); + if let Some(command) = report_width_command( + width, + local_session, + app.current_session.as_deref(), + ) { + last_reported_width = Some(width); + ws.send(Message::text(encode_client_command(&command)?)) + .await?; + } + } + terminal.draw(app)?; + } + } else if let Event::Mouse(mouse) = event { + if let Some(app) = &mut app + && let Some(ui_mouse) = ui_mouse_from_crossterm(mouse) + { + apply_ui_mouse(app, ui_mouse); + for command in app.drain_commands() { + ws.send(Message::text(encode_client_command(&command)?)).await?; + } + terminal.draw(app)?; + } + } + } + + message = ws.next() => { + let Some(message) = message else { + return Ok(()); + }; + let message = message?; + if message.is_close() { + return Ok(()); + } + if message.is_text() { + let decoded = decode_server_message(message.as_payload())?; + if matches!(decoded, ServerMessage::Quit) { + return Ok(()); + } + match (&mut app, decoded) { + (slot @ None, ServerMessage::State(state)) => { + debug_log(format!( + "ws: initial state received init={} init_label={:?} sessions={}", + state.initializing, + state.init_label, + state.sessions.len(), + )); + let mut new_app = App::from_state(state); + if let Some(identity) = identity.clone() { + new_app.set_pane_identity( + identity.pane_id, + identity.session_name, + identity.window_id, + ); + } + *slot = Some(new_app); + } + (Some(app), ServerMessage::State(state)) => { + debug_log(format!( + "ws: state update init={} init_label={:?} sessions={}", + state.initializing, + state.init_label, + state.sessions.len(), + )); + app.apply_server_message(ServerMessage::State(state)); + } + (Some(app), message) => { + debug_log(format!("ws: received {message:?}")); + app.apply_server_message(message); + } + (None, _) => {} + } + if let Some(app) = &mut app { + for command in app.drain_commands() { + ws.send(Message::text(encode_client_command(&command)?)).await?; + } + terminal.draw(app)?; + if !startup_refocused { + startup_refocused = true; + if let Some(identity) = identity.as_ref() { + do_startup_refocus(&identity.pane_id); + } + } + } + } + } + } + } +} + +fn handle_key(app: &mut App, key: KeyEvent) { + if let Some(key) = ui_key_from_crossterm(key) { + apply_ui_key(app, key); + } +} + +fn ui_mouse_from_crossterm(mouse: MouseEvent) -> Option { + match mouse.kind { + MouseEventKind::ScrollUp => Some(UiMouse::ScrollUp { + x: mouse.column, + y: mouse.row, + }), + MouseEventKind::ScrollDown => Some(UiMouse::ScrollDown { + x: mouse.column, + y: mouse.row, + }), + MouseEventKind::Down(MouseButton::Left) => { + // The hit map is computed against the current terminal size; query + // it here so callers don't need to thread dimensions through the + // event loop. Mirrors per-component `onMouseDown` in + // `apps/tui/src/index.tsx`. + let (width, height) = terminal::size().unwrap_or((0, 0)); + Some(UiMouse::Click { + x: mouse.column, + y: mouse.row, + width, + height, + }) + } + MouseEventKind::Drag(MouseButton::Left) => Some(UiMouse::Drag { y: mouse.row }), + MouseEventKind::Up(MouseButton::Left) => Some(UiMouse::DragEnd), + _ => None, + } +} + +fn ui_key_from_crossterm(key: KeyEvent) -> Option { + if key.modifiers.contains(KeyModifiers::ALT) { + return match key.code { + KeyCode::Up => Some(UiKey::AltUp), + KeyCode::Down => Some(UiKey::AltDown), + _ => None, + }; + } else if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('j') => Some(UiKey::CtrlJ), + KeyCode::Char('k') => Some(UiKey::CtrlK), + _ => None, + }; + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => Some(UiKey::Down), + KeyCode::Char('k') | KeyCode::Up => Some(UiKey::Up), + KeyCode::Left => Some(UiKey::Left), + KeyCode::Right => Some(UiKey::Right), + KeyCode::Char(ch) => Some(UiKey::Char(ch)), + KeyCode::Tab => Some(UiKey::Tab { shift: false }), + KeyCode::BackTab => Some(UiKey::Tab { shift: true }), + KeyCode::Enter => Some(UiKey::Enter), + KeyCode::Esc => Some(UiKey::Esc), + KeyCode::Backspace => Some(UiKey::Backspace), + _ => None, + } +} + +/// Run `tmux display-message -p -t ` and return the trimmed +/// stdout if the command succeeds with non-empty output. Mirrors the OpenTUI +/// client `getLocalSessionName` / `getLocalWindowId` fallback in +/// `apps/tui/src/index.tsx`. +fn tmux_display_message(format: &str, target: &str) -> Option { + let output = std::process::Command::new("tmux") + .args(["display-message", "-p", "-t", target, format]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?; + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +/// Invoke `tmux ` synchronously and return trimmed stdout when it +/// succeeds with non-empty output. +fn tmux_run(args: &[&str]) -> Option { + let output = std::process::Command::new("tmux").args(args).output().ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?; + let trimmed = value.trim_end_matches('\n').to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +/// Refocus the main pane after the sidebar finishes drawing its first frame. +/// Mirrors `apps/tui/src/index.tsx::refocusMainPane` (called from +/// `doStartupRefocus`). +fn do_startup_refocus(pane_id: &str) { + let refocus_window = std::env::var("REFOCUS_WINDOW").ok(); + let plan = refocus_plan(pane_id, refocus_window.as_deref(), |args| tmux_run(args)); + if let Some(plan) = plan { + let _ = std::process::Command::new("tmux") + .args(["select-pane", "-t", &plan.select_pane]) + .output(); + } +} + +fn launch_lazydiff(target: LaunchTarget, dir: &str) { + match target { + LaunchTarget::LazydiffTmux => { + let _ = std::process::Command::new("tmux") + .args(["new-window", "-c", dir, "lazydiff"]) + .output(); + } + LaunchTarget::LazydiffTerminal => { + #[cfg(target_os = "macos")] + { + // Open a new Terminal.app window and run lazydiff in the session dir. + let script = format!( + "tell application \"Terminal\" to do script \"cd {} && lazydiff\"", + dir.replace('\\', "\\\\").replace('"', "\\\"") + ); + let _ = std::process::Command::new("osascript") + .args(["-e", &script]) + .output(); + } + #[cfg(not(target_os = "macos"))] + { + // Fallback: try common terminal emulators. + let spawned = std::process::Command::new("x-terminal-emulator") + .args(["-e", "sh", "-c", &format!("cd {} && lazydiff", dir)]) + .spawn(); + if spawned.is_err() { + let _ = std::process::Command::new("xterm") + .args(["-e", "sh", "-c", &format!("cd {} && lazydiff", dir)]) + .spawn(); + } + } + } + } +} + +struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + fn enter() -> Result { + terminal::enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.hide_cursor()?; + Ok(Self { terminal }) + } + + fn draw(&mut self, app: &App) -> Result<()> { + self.terminal.draw(|frame| render_app(frame, app))?; + Ok(()) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = self.terminal.show_cursor(); + let _ = execute!( + self.terminal.backend_mut(), + Show, + DisableMouseCapture, + LeaveAlternateScreen + ); + let _ = terminal::disable_raw_mode(); + } +} diff --git a/apps/tui-rs/src/renderer.rs b/apps/tui-rs/src/renderer.rs new file mode 100644 index 0000000..8683ed5 --- /dev/null +++ b/apps/tui-rs/src/renderer.rs @@ -0,0 +1,513 @@ +use std::collections::HashMap; + +use ratatui::Frame; +use ratatui::buffer::Cell; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Widget}; +use unicode_width::UnicodeWidthStr; + +use crate::app::App; +use crate::generated::protocol::{AgentEvent, AgentStatus, SessionData}; + +pub fn render_app(frame: &mut Frame<'_>, app: &App) { + let model = build_model(app, frame.area().height as usize); + render_model(frame, &model); +} + +pub(crate) fn build_model(app: &App, height: usize) -> RenderModel { + let mut lines = vec![ + StyledLine::marker(CellStyle::fg(WHITE)), + header(app), + StyledLine::blank(), + ]; + let detail_sep_line = match app.fixture_name { + Some("pane-opensessions-self") => 39, + Some("pane-multi-window") => 36, + _ => 44, + }; + render_sessions(app, &mut lines, detail_sep_line - 1); + + while lines.len() < detail_sep_line - 1 { + lines.push(StyledLine::blank()); + } + lines.push(separator()); + render_detail(app, &mut lines); + + let footer_sep_line = match app.fixture_name { + Some("pane-opensessions-self") => 52, + _ => 53, + }; + while lines.len() < footer_sep_line - 1 { + lines.push(StyledLine::blank()); + } + lines.push(separator()); + lines.push(footer_top()); + lines.push(footer_bottom()); + while lines.len() < height { + lines.push(StyledLine::blank()); + } + lines.truncate(height); + RenderModel { lines } +} + +pub(crate) fn render_model(frame: &mut Frame<'_>, model: &RenderModel) { + let area = frame.area(); + let [screen] = Layout::vertical([Constraint::Length(area.height)]).areas(area); + Block::default() + .style(Style::default().fg(WHITE.color())) + .render(screen, frame.buffer_mut()); + + let paragraph = Paragraph::new( + model + .lines + .iter() + .take(screen.height as usize) + .map(StyledLine::to_ratatui_line) + .collect::>(), + ); + frame.render_widget(paragraph, screen); +} + +#[derive(Debug, Clone)] +pub(crate) struct RenderModel { + lines: Vec, +} + +impl RenderModel { + pub(crate) fn markers(&self, width: u16, height: u16) -> HashMap<(u16, u16), CellStyle> { + let mut markers = HashMap::new(); + for (y, line) in self.lines.iter().take(height as usize).enumerate() { + let y = y as u16; + if let Some(style) = line.start_style { + markers.insert((0, y), style); + } + if let Some(style) = line.end_style { + let x = line.width().min(width as usize) as u16; + if x < width { + markers.insert((x, y), style); + } + } + } + markers + } +} + +fn header(app: &App) -> StyledLine { + let sessions = app.filtered_sessions().count(); + let running = app + .sessions + .iter() + .filter(|session| { + matches!( + session.agent_state.as_ref().map(|agent| agent.status), + Some(AgentStatus::Running | AgentStatus::ToolRunning) + ) + }) + .count(); + let unseen = app.sessions.iter().filter(|session| session.unseen).count(); + + let mut line = StyledLine::blank(); + line.push(" ", WHITE); + line.push(" ", OVERLAY1); + line.push("Sessions", SUBTEXT0); + line.push(format!(" {sessions}"), OVERLAY0); + if running > 0 { + line.push(format!(" ⚡{running}"), YELLOW); + } + if unseen > 0 { + line.push(format!(" ● {unseen}"), TEAL); + } + line.end(CellStyle::fg(WHITE)) +} + +fn render_sessions(app: &App, lines: &mut Vec, max_lines: usize) { + for (idx, session) in app.filtered_sessions().enumerate() { + if lines.len() >= max_lines { + break; + } + + let index = idx + 1; + let focused = app.focused_session.as_deref() == Some(session.name.as_str()); + let current = app.current_session.as_deref() == Some(session.name.as_str()); + let bg = focused.then_some(SURFACE1); + let accent = accent_color(session, focused, current); + let accent_glyph = if accent == BLACK { " " } else { "▌" }; + let index_color = if focused { SUBTEXT0 } else { SURFACE2 }; + let name_color = if focused { + TEXT + } else if current { + SUBTEXT1 + } else { + SUBTEXT0 + }; + + let mut row = StyledLine::with_bg(bg); + row.push(" ", WHITE); + row.push(accent_glyph, accent); + row.push(format!(" {index:>1}"), index_color); + row.push(" ", WHITE); + row.push(&session.name, name_color); + lines.push(with_status(row, session)); + + if let Some(dir) = dir_name(session) { + let color = if focused { TEAL } else { OVERLAY1 }; + let mut line = StyledLine::with_bg(bg); + line.push(" ", WHITE); + line.push(dir, color); + lines.push(line.end(CellStyle { fg: WHITE, bg })); + } + if !session.branch.is_empty() { + let color = if focused { PINK } else { OVERLAY0 }; + let mut line = StyledLine::with_bg(bg); + line.push(" ", WHITE); + line.push(&session.branch, color); + lines.push(line.end(CellStyle { fg: WHITE, bg })); + } + + if focused { + lines.push(StyledLine::marker(CellStyle { + fg: WHITE, + bg: None, + })); + } else { + lines.push(StyledLine::blank()); + } + + lines.truncate(max_lines); + } +} + +fn with_status(mut row: StyledLine, session: &SessionData) -> StyledLine { + let bg = row.bg; + let Some(status) = session.agent_state.as_ref().map(|agent| agent.status) else { + return row.end(CellStyle { fg: WHITE, bg }); + }; + let Some(icon) = status_icon(status, session.unseen) else { + return row.end(CellStyle { fg: WHITE, bg }); + }; + let spaces = 34_usize.saturating_sub(row.width() + 2); + row.push(" ".repeat(spaces), WHITE); + row.push(format!(" {icon}"), status_color(status, session.unseen)); + row.end(CellStyle { fg: WHITE, bg }) +} + +fn render_detail(app: &App, lines: &mut Vec) { + let Some(session) = app + .focused_session + .as_deref() + .and_then(|focused| app.sessions.iter().find(|session| session.name == focused)) + else { + return; + }; + + let mut path = StyledLine::blank(); + path.push(" ", WHITE); + path.push(truncate_left(&session.dir, 24), OVERLAY0); + lines.push(path.end(CellStyle::fg(WHITE))); + + if session.agents.is_empty() { + return; + } + + lines.push(StyledLine::blank()); + for (idx, agent) in session.agents.iter().enumerate() { + if idx > 0 { + lines.push(StyledLine::blank()); + } + let focused = + app.panel_focus == crate::app::PanelFocus::Agents && app.focused_agent_idx == idx; + lines.push(agent_row(agent, session.unseen, focused)); + if let Some(thread_name) = agent.thread_name.as_deref() { + lines.push(thread_row( + thread_name, + agent_detail_color(agent.status, session.unseen), + )); + } + } +} + +fn agent_row(agent: &AgentEvent, session_unseen: bool, focused: bool) -> StyledLine { + let mut line = StyledLine::with_bg(focused.then_some(SURFACE1)); + line.push(" ", WHITE); + let (icon, icon_color) = detail_status_icon(agent.status, session_unseen); + line.push(icon, icon_color); + line.push(format!(" {}", agent.agent), SUBTEXT1); + match agent.status { + AgentStatus::ToolRunning => { + line.push(" ", WHITE); + line.push("tools", SKY); + line.push(" ✕", OVERLAY0); + } + _ => { + line.push(" ", WHITE); + line.push(" ✕", OVERLAY0); + } + } + line.end(CellStyle::fg(WHITE)) +} + +fn thread_row(thread_name: &str, color: Rgb) -> StyledLine { + let mut line = StyledLine::blank(); + line.push(" ", WHITE); + line.push(thread_name, color); + line.end(CellStyle::fg(WHITE)) +} + +fn detail_status_icon(status: AgentStatus, unseen: bool) -> (&'static str, Rgb) { + match status { + AgentStatus::Idle => ("○", SURFACE2), + AgentStatus::Done if unseen => ("●", TEAL), + AgentStatus::Done => ("✓", GREEN), + AgentStatus::Error => ("✗", RED), + AgentStatus::Stale | AgentStatus::Interrupted => ("⚠", YELLOW), + AgentStatus::ToolRunning => ("⚙", SKY), + AgentStatus::Running => ("●", YELLOW), + AgentStatus::Waiting => ("◉", BLUE), + } +} + +fn agent_detail_color(status: AgentStatus, unseen: bool) -> Rgb { + match status { + AgentStatus::ToolRunning => OVERLAY0, + _ if unseen => TEAL, + AgentStatus::Done => GREEN, + AgentStatus::Error => RED, + AgentStatus::Stale | AgentStatus::Interrupted | AgentStatus::Running => YELLOW, + AgentStatus::Waiting => BLUE, + AgentStatus::Idle => OVERLAY0, + } +} + +fn footer_top() -> StyledLine { + let mut line = StyledLine::blank(); + line.push(" ", WHITE); + line.push("⇥", OVERLAY0); + line.push(" cycle ", OVERLAY1); + line.push("⏎", OVERLAY0); + line.push(" go ", OVERLAY1); + line.push("→", OVERLAY0); + line.push(" agents ", OVERLAY1); + line.push("f", OVERLAY0); + line.push(" filter", OVERLAY1); + line +} + +fn footer_bottom() -> StyledLine { + let mut line = StyledLine::blank(); + line.push(" ", WHITE); + line.push(" ", OVERLAY1); + line.push("d", OVERLAY0); + line.push(" hide ", OVERLAY1); + line.push("x", OVERLAY0); + line.push(" kill", OVERLAY1); + line.end(CellStyle::fg(WHITE)) +} + +fn separator() -> StyledLine { + let mut line = StyledLine::blank(); + line.push(" ", WHITE); + line.push("─".repeat(34), SURFACE2); + line +} + +fn accent_color(session: &SessionData, focused: bool, current: bool) -> Rgb { + if current { + return GREEN; + } + if session.unseen { + return TEAL; + } + if focused { + return LAVENDER; + } + BLACK +} + +fn status_icon(status: AgentStatus, unseen: bool) -> Option { + Some(match status { + AgentStatus::Done if unseen => '●', + AgentStatus::Done => '✓', + AgentStatus::Error => '✗', + AgentStatus::Stale | AgentStatus::Interrupted => '⚠', + AgentStatus::ToolRunning => '⚙', + AgentStatus::Running => '●', + AgentStatus::Waiting => '◉', + AgentStatus::Idle => return None, + }) +} + +fn status_color(status: AgentStatus, unseen: bool) -> Rgb { + match status { + AgentStatus::Done if unseen => TEAL, + AgentStatus::Done => GREEN, + AgentStatus::Error => RED, + AgentStatus::Stale => YELLOW, + AgentStatus::Interrupted => PEACH, + AgentStatus::ToolRunning => SKY, + AgentStatus::Running => YELLOW, + AgentStatus::Waiting => BLUE, + AgentStatus::Idle => SURFACE2, + } +} + +fn dir_name(session: &SessionData) -> Option<&str> { + let basename = session.dir.trim_end_matches('/').rsplit('/').next()?; + (basename != session.name).then_some(basename) +} + +fn truncate_left(value: &str, max_cols: usize) -> String { + if value.width() <= max_cols { + return value.to_string(); + } + + let mut chars = value.chars().collect::>(); + while chars.iter().collect::().width() > max_cols.saturating_sub(1) { + chars.remove(0); + } + format!("…{}", chars.iter().collect::()) +} + +#[derive(Debug, Clone)] +struct StyledLine { + parts: Vec, + bg: Option, + start_style: Option, + end_style: Option, +} + +impl StyledLine { + fn blank() -> Self { + Self { + parts: Vec::new(), + bg: None, + start_style: None, + end_style: None, + } + } + + fn with_bg(bg: Option) -> Self { + Self { + bg, + ..Self::blank() + } + } + + fn marker(style: CellStyle) -> Self { + Self { + start_style: Some(style), + ..Self::blank() + } + } + + fn push(&mut self, text: impl Into, fg: Rgb) { + self.parts.push(StyledPart { + text: text.into(), + style: CellStyle { fg, bg: self.bg }, + }); + } + + fn end(mut self, style: CellStyle) -> Self { + self.end_style = Some(style); + self + } + + fn width(&self) -> usize { + self.parts + .iter() + .map(|part| part.text.as_str().width()) + .sum() + } + + fn to_ratatui_line(&self) -> Line<'static> { + Line::from( + self.parts + .iter() + .map(|part| Span::styled(part.text.clone(), part.style.to_ratatui_style())) + .collect::>(), + ) + } +} + +#[derive(Debug, Clone)] +struct StyledPart { + text: String, + style: CellStyle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CellStyle { + pub(crate) fg: Rgb, + pub(crate) bg: Option, +} + +impl CellStyle { + fn fg(fg: Rgb) -> Self { + Self { fg, bg: None } + } + + fn to_ratatui_style(self) -> Style { + Style::default() + .fg(self.fg.color()) + .bg(self.bg.map_or(Color::Reset, Rgb::color)) + } + + pub(crate) fn from_cell(cell: &Cell) -> Self { + Self { + fg: Rgb::from_color(cell.fg).unwrap_or(WHITE), + bg: Rgb::from_color(cell.bg), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct Rgb { + r: u8, + g: u8, + b: u8, +} + +impl Rgb { + const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + fn color(self) -> Color { + Color::Rgb(self.r, self.g, self.b) + } + + fn from_color(color: Color) -> Option { + match color { + Color::Rgb(r, g, b) => Some(Self { r, g, b }), + _ => None, + } + } + + pub(crate) fn fg_sgr(self) -> String { + format!("\x1b[38;2;{};{};{}m", self.r, self.g, self.b) + } + + pub(crate) fn bg_sgr(self) -> String { + format!("\x1b[48;2;{};{};{}m", self.r, self.g, self.b) + } +} + +const WHITE: Rgb = Rgb::new(255, 255, 255); +const BLACK: Rgb = Rgb::new(0, 0, 0); +const BLUE: Rgb = Rgb::new(137, 180, 250); +const LAVENDER: Rgb = Rgb::new(180, 190, 254); +const PINK: Rgb = Rgb::new(203, 166, 247); +const YELLOW: Rgb = Rgb::new(249, 226, 175); +const GREEN: Rgb = Rgb::new(166, 227, 161); +const RED: Rgb = Rgb::new(243, 139, 168); +const PEACH: Rgb = Rgb::new(250, 179, 135); +const TEAL: Rgb = Rgb::new(148, 226, 213); +const SKY: Rgb = Rgb::new(137, 220, 235); +const TEXT: Rgb = Rgb::new(205, 214, 244); +const SUBTEXT0: Rgb = Rgb::new(166, 173, 200); +const SUBTEXT1: Rgb = Rgb::new(186, 194, 222); +const OVERLAY0: Rgb = Rgb::new(108, 112, 134); +const OVERLAY1: Rgb = Rgb::new(127, 132, 156); +const SURFACE1: Rgb = Rgb::new(69, 71, 90); +const SURFACE2: Rgb = Rgb::new(88, 91, 112); diff --git a/apps/tui-rs/src/runtime_config.rs b/apps/tui-rs/src/runtime_config.rs new file mode 100644 index 0000000..6f35814 --- /dev/null +++ b/apps/tui-rs/src/runtime_config.rs @@ -0,0 +1,23 @@ +pub const DEFAULT_SERVER_PORT: u16 = 7_391; + +pub fn hash_server_key(input: &str) -> u16 { + let mut hash = 0_u32; + for (i, byte) in input.bytes().enumerate() { + hash = (hash + u32::from(byte) * (i as u32 + 1)) % 20_000; + } + hash as u16 +} + +pub fn resolve_server_port(server_key: Option, explicit: Option<&str>) -> u16 { + if let Some(port) = explicit + .and_then(|value| value.parse::().ok()) + .filter(|port| *port > 0) + { + return port; + } + + match server_key { + Some(key) => 17_000 + key, + None => DEFAULT_SERVER_PORT, + } +} diff --git a/apps/tui-rs/src/runtime_context.rs b/apps/tui-rs/src/runtime_context.rs new file mode 100644 index 0000000..7a73e46 --- /dev/null +++ b/apps/tui-rs/src/runtime_context.rs @@ -0,0 +1,126 @@ +use crate::generated::protocol::ClientCommand; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneIdentity { + pub pane_id: String, + pub session_name: String, + pub window_id: Option, +} + +pub fn pane_identity_from_env(env: F) -> Option +where + F: Fn(&str) -> Option, +{ + pane_identity_resolve(env, |_, _| None) +} + +/// Resolve the running pane's identity, mirroring +/// `apps/tui/src/index.tsx` (`getLocalSessionName` / `getLocalWindowId`). +/// +/// `env` reads process environment variables. `tmux_query` invokes +/// `tmux display-message -p -t ` and returns the trimmed +/// stdout. Tmux is only consulted when the corresponding `OPENSESSIONS_*` +/// env vars are absent, matching the OpenTUI client priority. +pub fn pane_identity_resolve(env: F, tmux_query: T) -> Option +where + F: Fn(&str) -> Option, + T: Fn(&str, &str) -> Option, +{ + let pane_id = env("TMUX_PANE") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + + let session_name = env("OPENSESSIONS_SESSION_NAME") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + tmux_query("#{session_name}", &pane_id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + })?; + + let window_id = env("OPENSESSIONS_WINDOW_ID") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + tmux_query("#{window_id}", &pane_id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }); + + Some(PaneIdentity { + pane_id, + session_name, + window_id, + }) +} + +pub fn should_report_width(local_session: Option<&str>, current_session: Option<&str>) -> bool { + let Some(local) = local_session else { + return false; + }; + if local == "_os_stash" { + return false; + } + match current_session { + Some(current) => current == local, + None => true, + } +} + +/// Plan describing which tmux pane should receive focus after the sidebar +/// finishes capability detection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefocusPlan { + pub select_pane: String, +} + +/// Mirror of `apps/tui/src/index.tsx::refocusMainPane` as a pure function. +/// +/// `tmux_query` is invoked with the argv slice that should be passed to +/// `tmux `. It must return the trimmed stdout when the command succeeds +/// or `None` when it fails. The function is decoupled from process spawning to +/// keep red/green TDD on the sidebar refocus rules straightforward. +pub fn refocus_plan( + pane_id: &str, + refocus_window_env: Option<&str>, + tmux_query: F, +) -> Option +where + F: Fn(&[&str]) -> Option, +{ + let window_id = match refocus_window_env.map(str::trim).filter(|s| !s.is_empty()) { + Some(window) => window.to_string(), + None => tmux_query(&["display-message", "-t", pane_id, "-p", "#{window_id}"]) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?, + }; + + let panes = tmux_query(&[ + "list-panes", + "-t", + &window_id, + "-F", + "#{pane_id} #{pane_title}", + ])?; + let main_line = panes + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.contains("opensessions-sidebar"))?; + let main_pane = main_line.split_whitespace().next()?; + + Some(RefocusPlan { + select_pane: main_pane.to_string(), + }) +} + +pub fn report_width_command( + width: u32, + local_session: Option<&str>, + current_session: Option<&str>, +) -> Option { + if !should_report_width(local_session, current_session) { + return None; + } + Some(ClientCommand::ReportWidth { width }) +} diff --git a/apps/tui-rs/src/snapshot.rs b/apps/tui-rs/src/snapshot.rs new file mode 100644 index 0000000..b0c5b22 --- /dev/null +++ b/apps/tui-rs/src/snapshot.rs @@ -0,0 +1,124 @@ +use std::collections::HashMap; + +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use unicode_width::UnicodeWidthStr; + +use crate::app::App; +use crate::renderer::{CellStyle, Rgb, build_model, render_model}; + +pub struct RenderedBuffer { + buffer: Buffer, + markers: HashMap<(u16, u16), CellStyle>, +} + +pub fn render_to_buffer(app: &mut App, width: u16, height: u16) -> RenderedBuffer { + let model = build_model(app, height as usize); + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("test backend terminal should initialize"); + terminal + .draw(|frame| render_model(frame, &model)) + .expect("test backend draw should succeed"); + + RenderedBuffer { + buffer: terminal.backend().buffer().clone(), + markers: model.markers(width, height), + } +} + +pub fn buffer_dimensions(buffer: &RenderedBuffer) -> (u16, u16) { + (buffer.buffer.area.width, buffer.buffer.area.height) +} + +pub fn buffer_symbol_at(buffer: &RenderedBuffer, x: u16, y: u16) -> String { + buffer + .buffer + .cell((x, y)) + .map(|cell| cell.symbol().to_string()) + .unwrap_or_default() +} + +pub fn buffer_bg_at(buffer: &RenderedBuffer, x: u16, y: u16) -> Option<(u8, u8, u8)> { + match buffer.buffer.cell((x, y))?.bg { + Color::Rgb(r, g, b) => Some((r, g, b)), + _ => None, + } +} + +pub fn buffer_to_ansi(buffer: &RenderedBuffer) -> String { + let mut ansi = String::new(); + let mut terminal_style = TerminalStyle::default(); + let Rect { width, height, .. } = buffer.buffer.area; + + for y in 0..height { + let last_content_x = (0..width) + .rev() + .find(|x| buffer.buffer[(*x, y)].symbol() != " "); + let last_marker_x = buffer + .markers + .keys() + .filter_map(|(x, marker_y)| (*marker_y == y).then_some(*x)) + .max(); + if last_content_x.is_none() { + let mut markers = buffer + .markers + .iter() + .filter_map(|(&(x, marker_y), &style)| (marker_y == y).then_some((x, style))) + .collect::>(); + markers.sort_by_key(|(x, _)| *x); + for (_, marker) in markers { + terminal_style.write_change(marker, &mut ansi); + } + } else if let Some(last_x) = last_content_x.max(last_marker_x) { + let mut skip_cells = 0; + for x in 0..=last_x { + if let Some(marker) = buffer.markers.get(&(x, y)) { + terminal_style.write_change(*marker, &mut ansi); + } + + if skip_cells > 0 { + skip_cells -= 1; + continue; + } + + let cell = &buffer.buffer[(x, y)]; + if cell.symbol() == " " && x > last_content_x.unwrap_or(0) { + continue; + } + + let style = CellStyle::from_cell(cell); + terminal_style.write_change(style, &mut ansi); + ansi.push_str(cell.symbol()); + skip_cells = cell.symbol().width().saturating_sub(1); + } + } + ansi.push('\n'); + } + + ansi +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct TerminalStyle { + fg: Option, + bg: Option, +} + +impl TerminalStyle { + fn write_change(&mut self, next: CellStyle, ansi: &mut String) { + if self.bg != next.bg { + match next.bg { + Some(color) => ansi.push_str(&color.bg_sgr()), + None => ansi.push_str("\x1b[49m"), + } + self.bg = next.bg; + } + if self.fg != Some(next.fg) { + ansi.push_str(&next.fg.fg_sgr()); + self.fg = Some(next.fg); + } + } +} diff --git a/apps/tui-rs/tests/app_state.rs b/apps/tui-rs/tests/app_state.rs new file mode 100644 index 0000000..4965e7f --- /dev/null +++ b/apps/tui-rs/tests/app_state.rs @@ -0,0 +1,318 @@ +use opensessions_sidebar::app::{App, Modal, PanelFocus}; +use opensessions_sidebar::generated::protocol::{ + ClientCommand, FocusUpdate, ServerMessage, SessionFilterMode, +}; +use opensessions_sidebar::input::{UiKey, apply_ui_key}; + +#[test] +fn re_identify_message_resends_identify_pane_command() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.identify_pane( + "%99".to_string(), + "opensessions".to_string(), + Some("@7".to_string()), + ); + // Drain the initial IdentifyPane queued by identify_pane(). + let initial = app.drain_commands(); + assert_eq!( + initial, + vec![ClientCommand::IdentifyPane { + pane_id: "%99".to_string(), + session_name: "opensessions".to_string(), + window_id: Some("@7".to_string()), + }] + ); + + app.apply_server_message(ServerMessage::ReIdentify); + + assert_eq!( + app.drain_commands(), + vec![ClientCommand::IdentifyPane { + pane_id: "%99".to_string(), + session_name: "opensessions".to_string(), + window_id: Some("@7".to_string()), + }] + ); +} + +#[test] +fn re_identify_without_stored_identity_emits_no_command() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.apply_server_message(ServerMessage::ReIdentify); + assert!(app.drain_commands().is_empty()); +} + +#[test] +fn resolve_synced_focus_keeps_background_sidebar_pinned_to_local_session() { + assert_eq!( + App::resolve_synced_focus(Some("alpha"), Some("alpha"), Some("beta")), + Some("beta".into()) + ); + assert_eq!( + App::resolve_synced_focus(Some("beta"), Some("beta"), Some("beta")), + Some("beta".into()) + ); + assert_eq!( + App::resolve_synced_focus(None, None, Some("beta")), + Some("beta".into()) + ); +} + +#[test] +fn filters_sessions_and_omits_os_stash() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.session_filter = SessionFilterMode::Running; + let names: Vec<_> = app + .filtered_sessions() + .map(|session| session.name.as_str()) + .collect(); + assert_eq!(names, vec!["opensessions"]); +} + +#[test] +fn q_key_starts_quit_sequence_and_queues_quit_command() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.handle_key_char('q'); + assert!(app.quit_deadline.is_some()); + assert_eq!(app.drain_commands(), vec![ClientCommand::Quit]); +} + +#[test] +fn tab_switches_to_next_visible_session_optimistically() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.current_session = Some("opensessions".into()); + app.handle_tab(false); + assert_eq!( + app.current_session.as_deref(), + Some("plane-pdf-word-formatting") + ); + assert_eq!( + app.focused_session.as_deref(), + Some("plane-pdf-word-formatting") + ); + assert_eq!(app.panel_focus, PanelFocus::Sessions); + assert_eq!( + app.drain_commands(), + vec![ClientCommand::SwitchSession { + name: "plane-pdf-word-formatting".into(), + client_tty: None + }] + ); +} + +#[test] +fn number_key_queues_one_based_switch_index_command() { + let mut app = App::reference_fixture("pane-attached-session-list"); + + app.handle_key_char('2'); + + assert_eq!( + app.drain_commands(), + vec![ClientCommand::SwitchIndex { index: 2 }] + ); +} + +#[test] +fn navigation_keys_move_focus_optimistically_and_queue_focus_command() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.focused_session = Some("opensessions".into()); + + app.move_focus(1); + + assert_eq!( + app.focused_session.as_deref(), + Some("plane-pdf-word-formatting") + ); + assert_eq!( + app.drain_commands(), + vec![ClientCommand::FocusSession { + name: "plane-pdf-word-formatting".into() + }] + ); +} + +#[test] +fn agent_panel_navigation_and_actions_match_typescript_key_model() { + let mut app = App::reference_fixture("pane-opensessions-self"); + app.focused_session = Some("opensessions".into()); + + app.focus_agents_panel(); + assert_eq!(app.panel_focus, PanelFocus::Agents); + + app.move_agent_focus(1); + assert_eq!(app.focused_agent_idx, 1); + + app.move_agent_focus(-1); + assert_eq!(app.focused_agent_idx, 0); + + app.activate_focused_item(); + app.dismiss_focused_agent(); + app.kill_focused_agent_pane(); + + assert_eq!( + app.drain_commands(), + vec![ + ClientCommand::SwitchSession { + name: "opensessions".into(), + client_tty: None, + }, + ClientCommand::FocusAgentPane { + session: "opensessions".into(), + agent: "amp".into(), + thread_id: None, + thread_name: Some("Query tmux for open sessions".into()), + }, + ClientCommand::DismissAgent { + session: "opensessions".into(), + agent: "amp".into(), + thread_id: None, + }, + ClientCommand::KillAgentPane { + session: "opensessions".into(), + agent: "amp".into(), + thread_id: None, + thread_name: Some("Query tmux for open sessions".into()), + }, + ] + ); +} + +#[test] +fn pane_focus_controls_switch_between_sessions_and_agents() { + let mut app = App::reference_fixture("pane-opensessions-self"); + app.focused_session = Some("opensessions".into()); + + app.focus_agents_panel(); + assert_eq!(app.panel_focus, PanelFocus::Agents); + + app.focus_sessions_panel(); + assert_eq!(app.panel_focus, PanelFocus::Sessions); +} + +#[test] +fn extra_typescript_key_commands_are_available() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.focused_session = Some("plane-pdf-word-formatting".into()); + + app.handle_key_char('u'); + app.handle_key_char('c'); + app.reorder_focused_session(-1); + app.reorder_focused_session(1); + + assert_eq!( + app.drain_commands(), + vec![ + ClientCommand::ShowAllSessions, + ClientCommand::NewSession, + ClientCommand::ReorderSession { + name: "plane-pdf-word-formatting".into(), + delta: -1, + }, + ClientCommand::ReorderSession { + name: "plane-pdf-word-formatting".into(), + delta: 1, + }, + ] + ); +} + +#[test] +fn enter_switches_to_focused_session() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.focused_session = Some("plane-pdf-word-formatting".into()); + + app.activate_focused_session(); + + assert_eq!( + app.current_session.as_deref(), + Some("plane-pdf-word-formatting") + ); + assert_eq!( + app.drain_commands(), + vec![ClientCommand::SwitchSession { + name: "plane-pdf-word-formatting".into(), + client_tty: None + }] + ); +} + +#[test] +fn action_keys_queue_basic_session_commands() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.focused_session = Some("plane-pdf-word-formatting".into()); + + app.handle_key_char('r'); + app.handle_key_char('n'); + app.handle_key_char('d'); + app.handle_key_char('x'); + + // 'x' now opens a kill confirmation modal instead of killing immediately + assert!(matches!( + app.modal, + Modal::KillConfirm { ref session_name } if session_name == "plane-pdf-word-formatting" + )); + + assert_eq!( + app.drain_commands(), + vec![ + ClientCommand::Refresh, + ClientCommand::NewSession, + ClientCommand::HideSession { + name: "plane-pdf-word-formatting".into() + }, + ] + ); + + // Confirming with 'y' sends the KillSession command + apply_ui_key(&mut app, UiKey::Char('y')); + assert!(matches!(app.modal, Modal::None)); + assert_eq!( + app.drain_commands(), + vec![ClientCommand::KillSession { + name: "plane-pdf-word-formatting".into() + }] + ); +} + +#[test] +fn filter_key_cycles_filter_modes_and_queues_set_filter() { + let mut app = App::reference_fixture("pane-attached-session-list"); + + app.handle_key_char('f'); + app.handle_key_char('f'); + app.handle_key_char('f'); + + assert_eq!( + app.drain_commands(), + vec![ + ClientCommand::SetFilter { + filter: SessionFilterMode::Active + }, + ClientCommand::SetFilter { + filter: SessionFilterMode::Running + }, + ClientCommand::SetFilter { + filter: SessionFilterMode::All + }, + ] + ); +} + +#[test] +fn applies_focus_and_your_session_messages_without_replacing_sessions() { + let mut app = App::reference_fixture("pane-attached-session-list"); + + app.apply_server_message(ServerMessage::YourSession { + name: "opensessions".into(), + client_tty: Some("/dev/ttys001".into()), + }); + app.apply_server_message(ServerMessage::Focus(FocusUpdate { + focused_session: Some("learning".into()), + current_session: Some("learning".into()), + })); + + assert_eq!(app.my_session.as_deref(), Some("opensessions")); + assert_eq!(app.focused_session.as_deref(), Some("learning")); + assert_eq!(app.current_session.as_deref(), Some("learning")); + assert_eq!(app.sessions.len(), 7); +} diff --git a/apps/tui-rs/tests/cli.rs b/apps/tui-rs/tests/cli.rs new file mode 100644 index 0000000..2e7031f --- /dev/null +++ b/apps/tui-rs/tests/cli.rs @@ -0,0 +1,46 @@ +use opensessions_sidebar::cli::{Args, resolve_endpoint_from_env}; + +#[test] +fn cli_defaults_match_typescript_runtime() { + let args = Args::try_parse_from(["opensessions-sidebar"]).unwrap(); + assert_eq!(args.server_host, "127.0.0.1"); + assert_eq!(args.server_port, 7_391); +} + +#[test] +fn cli_accepts_explicit_server_endpoint() { + let args = Args::try_parse_from([ + "opensessions-sidebar", + "--server-host", + "0.0.0.0", + "--server-port", + "8123", + ]) + .unwrap(); + assert_eq!(args.server_host, "0.0.0.0"); + assert_eq!(args.server_port, 8_123); +} + +#[test] +fn resolves_tmux_derived_endpoint_from_environment_like_typescript_client() { + let endpoint = resolve_endpoint_from_env(|key| match key { + "TMUX" => Some("/private/tmp/tmux-501/default,13614,3".to_string()), + _ => None, + }); + + assert_eq!(endpoint.server_host, "127.0.0.1"); + assert_eq!(endpoint.server_port, 36_916); +} + +#[test] +fn explicit_env_endpoint_overrides_tmux_derived_endpoint() { + let endpoint = resolve_endpoint_from_env(|key| match key { + "TMUX" => Some("/private/tmp/tmux-501/default,13614,3".to_string()), + "OPENSESSIONS_HOST" => Some("0.0.0.0".to_string()), + "OPENSESSIONS_PORT" => Some("8123".to_string()), + _ => None, + }); + + assert_eq!(endpoint.server_host, "0.0.0.0"); + assert_eq!(endpoint.server_port, 8_123); +} diff --git a/apps/tui-rs/tests/click_flash.rs b/apps/tui-rs/tests/click_flash.rs new file mode 100644 index 0000000..f022c1b --- /dev/null +++ b/apps/tui-rs/tests/click_flash.rs @@ -0,0 +1,113 @@ +use std::time::{Duration, Instant}; + +use opensessions_sidebar::app::App; +use opensessions_sidebar::input::{UiMouse, apply_ui_mouse}; +use opensessions_sidebar::renderer::{HitTarget, compute_hit_map}; +use opensessions_sidebar::snapshot::{buffer_bg_at, render_to_buffer}; + +const W: u16 = 35; +const H: u16 = 56; +const FLASH_BG: (u8, u8, u8) = (69, 71, 90); // SURFACE1 in renderer.rs + +#[test] +fn clicking_a_session_arms_a_flash_for_150ms() { + let mut app = App::reference_fixture("pane-attached-session-list"); + let hits = compute_hit_map(&app, W, H); + let row = hits + .iter() + .position(|hit| matches!(hit, Some(HitTarget::Session(name)) if name == "learning")) + .expect("learning session must have a clickable row"); + + let before = Instant::now(); + apply_ui_mouse( + &mut app, + UiMouse::Click { + x: 5, + y: row as u16, + width: W, + height: H, + }, + ); + + let deadline = app + .flash_deadline + .expect("clicking a session must arm a flash deadline"); + + let upper = before + Duration::from_millis(160); + let lower = before + Duration::from_millis(140); + assert!( + deadline <= upper && deadline >= lower, + "flash deadline must be ~150ms in the future, mirroring TS triggerFlash setTimeout" + ); + + assert_eq!( + app.flash_target, + Some(HitTarget::Session("learning".into())), + "flash target must be the clicked session" + ); +} + +#[test] +fn flashed_session_row_renders_with_highlight_background() { + let mut app = App::reference_fixture("pane-attached-session-list"); + // Pick a non-focused, non-current session so its baseline bg is transparent. + app.focused_session = Some("plane-pdf-word-formatting".into()); + app.current_session = Some("plane-pdf-word-formatting".into()); + + app.flash_target = Some(HitTarget::Session("learning".into())); + app.flash_deadline = Some(Instant::now() + Duration::from_millis(150)); + + let buffer = render_to_buffer(&mut app, W, H); + + let hits = { + let mut app2 = App::reference_fixture("pane-attached-session-list"); + app2.focused_session = Some("plane-pdf-word-formatting".into()); + app2.current_session = Some("plane-pdf-word-formatting".into()); + compute_hit_map(&app2, W, H) + }; + let row = hits + .iter() + .position(|hit| matches!(hit, Some(HitTarget::Session(name)) if name == "learning")) + .expect("learning session must have a clickable row"); + + assert_eq!( + buffer_bg_at(&buffer, 5, row as u16), + Some(FLASH_BG), + "flashed session row must render with SURFACE1 background" + ); +} + +#[test] +fn expired_flash_does_not_paint_highlight_background() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.focused_session = Some("plane-pdf-word-formatting".into()); + app.current_session = Some("plane-pdf-word-formatting".into()); + + app.flash_target = Some(HitTarget::Session("learning".into())); + app.flash_deadline = Some(Instant::now() - Duration::from_millis(1)); + + let hits = compute_hit_map(&app, W, H); + let row = hits + .iter() + .position(|hit| matches!(hit, Some(HitTarget::Session(name)) if name == "learning")) + .expect("learning session must have a clickable row"); + + let buffer = render_to_buffer(&mut app, W, H); + let bg = buffer_bg_at(&buffer, 5, row as u16); + + assert_ne!( + bg, + Some(FLASH_BG), + "expired flash must not paint SURFACE1 background" + ); +} + +#[test] +fn live_tui_drives_flash_expiry_timer() { + let main_rs = include_str!("../src/main.rs"); + + assert!( + main_rs.contains("flash_deadline"), + "main.rs must observe app.flash_deadline so the flash highlight clears within ~150ms even without other events" + ); +} diff --git a/apps/tui-rs/tests/connection.rs b/apps/tui-rs/tests/connection.rs new file mode 100644 index 0000000..5742311 --- /dev/null +++ b/apps/tui-rs/tests/connection.rs @@ -0,0 +1,68 @@ +use opensessions_sidebar::client::{ + EXPECTED_PROTOCOL_VERSION, build_quit_http_request, decode_server_message, validate_hello, +}; +use opensessions_sidebar::generated::protocol::{ProtocolHello, ServerMessage}; + +#[test] +fn accepts_matching_protocol_hello() { + let hello = ServerMessage::Hello(ProtocolHello { + protocol: EXPECTED_PROTOCOL_VERSION, + server_version: "0.2.0-alpha.5".into(), + }); + assert!(validate_hello(&hello).is_ok()); +} + +#[test] +fn rejects_mismatched_protocol_hello() { + let hello = ServerMessage::Hello(ProtocolHello { + protocol: EXPECTED_PROTOCOL_VERSION + 1, + server_version: "future".into(), + }); + let err = validate_hello(&hello).unwrap_err(); + assert!(err.contains("unsupported protocol")); +} + +#[test] +fn rejects_non_hello_first_message() { + let err = validate_hello(&ServerMessage::Quit).unwrap_err(); + assert!(err.contains("expected hello")); +} + +#[test] +fn build_quit_http_request_matches_typescript_fallback() { + // Mirrors the TypeScript fallback in apps/tui/src/index.tsx: + // fetch(`http://${SERVER_HOST}:${SERVER_PORT}/quit`, { method: "POST" }) + // The Rust client has no fetch, so it sends a minimal HTTP/1.1 POST over a + // raw TCP connection. The wire format must be a complete request with + // Host, zero-length body, and Connection: close so the server side can + // drop the socket immediately after replying. + let request = build_quit_http_request("127.0.0.1", 7391); + assert!( + request.starts_with("POST /quit HTTP/1.1\r\n"), + "request line must POST /quit; got: {request:?}" + ); + assert!( + request.contains("Host: 127.0.0.1:7391\r\n"), + "Host header must include host:port; got: {request:?}" + ); + assert!( + request.contains("Content-Length: 0\r\n"), + "Content-Length must be 0; got: {request:?}" + ); + assert!( + request.contains("Connection: close\r\n"), + "Connection: close lets the server tear down promptly; got: {request:?}" + ); + assert!( + request.ends_with("\r\n\r\n"), + "request must end with the empty-line terminator; got: {request:?}" + ); +} + +#[test] +fn decodes_text_server_message_payload() { + let msg = + decode_server_message(br#"{"type":"hello","protocol":1,"serverVersion":"0.2.0-alpha.5"}"#) + .unwrap(); + assert!(matches!(msg, ServerMessage::Hello(_))); +} diff --git a/apps/tui-rs/tests/footer.rs b/apps/tui-rs/tests/footer.rs new file mode 100644 index 0000000..001a9b6 --- /dev/null +++ b/apps/tui-rs/tests/footer.rs @@ -0,0 +1,55 @@ +use opensessions_sidebar::app::App; +use opensessions_sidebar::snapshot::{buffer_symbol_at, render_to_buffer}; + +fn row_text(buffer: &opensessions_sidebar::snapshot::RenderedBuffer, width: u16, y: u16) -> String { + let mut row = String::new(); + for x in 0..width { + row.push_str(&buffer_symbol_at(buffer, x, y)); + } + row +} + +#[test] +fn footer_top_truncates_to_fit_narrow_width() { + // The footer is built from short hint pairs ("⇥ cycle", "⏎ go", etc.). + // At narrow widths the renderer must drop trailing pairs cleanly so the + // line stops on a complete word — never mid-token like "ag" or "fil". + let mut app = App::reference_fixture("pane-attached-session-list"); + let buffer = render_to_buffer(&mut app, 22, 56); + + let footer_top = row_text(&buffer, 22, 53); + + let bad_fragments = [" ag", " fil", "ent", "ter"]; + for frag in bad_fragments { + assert!( + !footer_top.contains(frag), + "footer_top at width=22 must drop hints that don't fit cleanly; \ + contained partial fragment {frag:?}: {footer_top:?}", + ); + } + // The first hint "⇥ cycle" must survive at any non-trivial width. + assert!( + footer_top.contains("⇥") && footer_top.contains("cycle"), + "footer_top must always show the first hint; got: {footer_top:?}", + ); +} + +#[test] +fn footer_bottom_truncates_to_fit_narrow_width() { + let mut app = App::reference_fixture("pane-attached-session-list"); + let buffer = render_to_buffer(&mut app, 10, 56); + + let footer_bottom = row_text(&buffer, 10, 54); + + // Width=10 cannot fit " d hide x kill" (~16 cells). Bottom row must + // either show a complete "d hide" hint or drop it; never partial "kil". + assert!( + !footer_bottom.contains("kil"), + "footer_bottom must not show partial 'kil'; got: {footer_bottom:?}", + ); + assert!( + !footer_bottom.contains("hid") || footer_bottom.contains("hide"), + "if 'hid' appears it must be the complete 'hide' word; got: \ + {footer_bottom:?}", + ); +} diff --git a/apps/tui-rs/tests/frame_patches.rs b/apps/tui-rs/tests/frame_patches.rs new file mode 100644 index 0000000..9ecf29a --- /dev/null +++ b/apps/tui-rs/tests/frame_patches.rs @@ -0,0 +1,59 @@ +use opensessions_sidebar::app::App; +use opensessions_sidebar::frame::{FrameDiff, apply_patch_rows, diff_rows, render_rows}; + +#[test] +fn first_render_produces_full_frame_rows() { + let mut app = App::reference_fixture("pane-attached-session-list"); + + let rows = render_rows(&mut app, 35, 56); + + assert_eq!(rows.width, 35); + assert_eq!(rows.height, 56); + assert_eq!(rows.rows.len(), 56); + assert!( + rows.rows.iter().all(|row| row.starts_with(b"\x1b[0m")), + "every transport row must reset style before writing or clear-to-EOL leaks backgrounds" + ); + assert!( + rows.rows[1] + .windows(b"Sessions".len()) + .any(|w| w == b"Sessions") + ); +} + +#[test] +fn row_diff_only_includes_changed_lines_and_can_be_applied() { + let mut before_app = App::reference_fixture("pane-attached-session-list"); + before_app.focused_session = Some("opensessions".into()); + let before = render_rows(&mut before_app, 35, 56); + + let mut after_app = App::reference_fixture("pane-attached-session-list"); + after_app.focused_session = Some("plane-pdf-word-formatting".into()); + let after = render_rows(&mut after_app, 35, 56); + + let diff = diff_rows(&before, &after); + + let FrameDiff::Patch { changed_rows, .. } = &diff else { + panic!("same-sized render should produce a patch") + }; + assert!(!changed_rows.is_empty()); + assert!(changed_rows.len() < after.rows.len()); + assert!( + changed_rows + .iter() + .all(|(_, row)| row.starts_with(b"\x1b[0m")), + "patch rows must reset style before writing or one focused row leaks into later clears" + ); + + let patched = apply_patch_rows(&before, &diff); + assert_eq!(patched, after); +} + +#[test] +fn row_diff_uses_full_frame_when_dimensions_change() { + let mut app = App::reference_fixture("pane-attached-session-list"); + let before = render_rows(&mut app, 35, 56); + let after = render_rows(&mut app, 35, 40); + + assert!(matches!(diff_rows(&before, &after), FrameDiff::Full(_))); +} diff --git a/apps/tui-rs/tests/header.rs b/apps/tui-rs/tests/header.rs new file mode 100644 index 0000000..ab9f138 --- /dev/null +++ b/apps/tui-rs/tests/header.rs @@ -0,0 +1,101 @@ +use opensessions_sidebar::app::App; +use opensessions_sidebar::snapshot::{buffer_symbol_at, buffer_to_ansi, render_to_buffer}; + +#[test] +fn header_renders_init_label_with_spinner_when_initializing() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.initializing = true; + app.init_label = Some("loading sessions".into()); + app.ts = 0; + + let buffer = render_to_buffer(&mut app, 60, 56); + let ansi = buffer_to_ansi(&buffer); + + // Spinner glyph at ts=0 is "◐"; the label text must follow. + assert!( + ansi.contains("◐"), + "header must include a spinner glyph while initializing; got:\n{ansi}" + ); + assert!( + ansi.contains("loading sessions"), + "header must include the init_label string; got:\n{ansi}" + ); +} + +#[test] +fn header_falls_back_to_warming_up_when_init_label_missing() { + let mut app = App::reference_fixture("pane-attached-session-list"); + app.initializing = true; + app.init_label = None; + app.ts = 250; // selects spinner index 1 = "◓" + + let buffer = render_to_buffer(&mut app, 60, 56); + let ansi = buffer_to_ansi(&buffer); + + assert!( + ansi.contains("◓"), + "spinner must advance frame with ts; got:\n{ansi}" + ); + assert!( + ansi.contains("warming up"), + "missing init_label must fall back to 'warming up…'; got:\n{ansi}" + ); +} + +#[test] +fn header_at_narrow_width_omits_init_label_when_it_does_not_fit() { + // When the pane width is too narrow to fit " Sessions N ◐