diff --git a/Cargo.lock b/Cargo.lock index e759c433..c2e63064 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -107,7 +113,7 @@ dependencies = [ "bitflags 2.4.2", "cexpr", "clang-sys", - "itertools", + "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", @@ -142,6 +148,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.0.90" @@ -240,6 +261,32 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -261,6 +308,31 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.4.2", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.42", + "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 = "daemonize" version = "0.5.0" @@ -270,12 +342,53 @@ dependencies = [ "libc", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.1" @@ -327,10 +440,22 @@ checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.4.1", "windows-sys 0.52.0", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -375,6 +500,17 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -413,6 +549,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" version = "2.2.5" @@ -420,7 +562,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b0b929d511467233429c45a44ac1dcaa21ba0f5ba11e4879e6ed28ddb4f9df4" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.3", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", ] [[package]] @@ -443,6 +594,31 @@ dependencies = [ "libc", ] +[[package]] +name = "insta" +version = "1.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e9ffc4d4892617c50a928c52b2961cb5174b6fc6ebf252b2fac9d21955c48b8" +dependencies = [ + "console", + "lazy_static", + "linked-hash-map", + "similar", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "instant" version = "0.1.13" @@ -461,6 +637,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.10" @@ -545,7 +730,9 @@ dependencies = [ "chrono", "clap", "crossbeam-channel", + "crossterm", "daemonize", + "insta", "lazy_static", "libc", "libproc", @@ -554,6 +741,7 @@ dependencies = [ "nix", "notify", "ntest", + "ratatui", "rmp-serde", "serde", "serde_derive", @@ -572,6 +760,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -584,12 +778,30 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[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.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.7.4" @@ -735,6 +947,29 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[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 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" @@ -813,6 +1048,27 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.4.2", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -822,6 +1078,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.4.2", +] + [[package]] name = "regex" version = "1.12.2" @@ -904,6 +1169,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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.17" @@ -919,6 +1190,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -1029,7 +1306,7 @@ dependencies = [ "smallvec", "static_assertions", "tracing", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "vte 0.15.0", ] @@ -1066,6 +1343,17 @@ dependencies = [ "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.1" @@ -1075,6 +1363,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "smallvec" version = "1.13.1" @@ -1098,9 +1392,31 @@ dependencies = [ [[package]] name = "strsim" -version = "0.11.0" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] [[package]] name = "syn" @@ -1263,6 +1579,23 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[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 = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.11", +] + [[package]] name = "unicode-width" version = "0.1.11" @@ -1271,9 +1604,9 @@ checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "utf8parse" @@ -1449,6 +1782,12 @@ dependencies = [ "windows-targets", ] +[[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.52.0" diff --git a/libshpool/Cargo.toml b/libshpool/Cargo.toml index 486bd44c..917602d3 100644 --- a/libshpool/Cargo.toml +++ b/libshpool/Cargo.toml @@ -46,6 +46,8 @@ notify = { version = "7", features = ["crossbeam-channel"] } # watch config fil libproc = "0.14.8" # sniffing shells by examining the subprocess daemonize = "0.5" # autodaemonization shpool-protocol = { version = "0.3.4", path = "../shpool-protocol" } # client-server protocol +ratatui = "0.29" # TUI framework for `shpool tui` +crossterm = "0.28" # terminal backend for ratatui # rusty wrapper for unix apis [dependencies.nix] @@ -60,3 +62,4 @@ features = ["std", "fmt", "tracing-log", "smallvec"] [dev-dependencies] ntest = "0.9" # test timeouts assert_matches = "1.5" # assert_matches macro +insta = "1" # snapshot tests for TUI view rendering diff --git a/libshpool/src/kill.rs b/libshpool/src/kill.rs index 1eda0e8e..219794bc 100644 --- a/libshpool/src/kill.rs +++ b/libshpool/src/kill.rs @@ -12,31 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{io, path::Path}; +use std::path::Path; use anyhow::{anyhow, Context}; use shpool_protocol::{ConnectHeader, KillReply, KillRequest}; -use crate::{common, protocol, protocol::ClientResult}; +use crate::{common, protocol}; pub fn run

(mut sessions: Vec, socket: P) -> anyhow::Result<()> where P: AsRef, { - let mut client = match protocol::Client::new(socket) { - Ok(ClientResult::JustClient(c)) => c, - Ok(ClientResult::VersionMismatch { warning, client }) => { - eprintln!("warning: {warning}, try restarting your daemon"); - client - } - Err(err) => { - let io_err = err.downcast::()?; - if io_err.kind() == io::ErrorKind::NotFound { - eprintln!("could not connect to daemon"); - } - return Err(io_err).context("connecting to daemon"); - } - }; + let mut client = protocol::connect_cli(socket)?; common::resolve_sessions(&mut sessions, "kill")?; diff --git a/libshpool/src/lib.rs b/libshpool/src/lib.rs index 354ba851..5b6dc640 100644 --- a/libshpool/src/lib.rs +++ b/libshpool/src/lib.rs @@ -44,6 +44,7 @@ mod session_restore; mod set_log_level; mod test_hooks; mod tty; +mod tui; mod user; /// The command line arguments that shpool expects. @@ -209,6 +210,10 @@ needs debugging, but would be clobbered by a restart.")] #[clap(help = "new log level")] level: shpool_protocol::LogLevel, }, + + #[clap(about = "TUI session manager")] + #[non_exhaustive] + Tui {}, } impl Args { @@ -381,6 +386,13 @@ pub fn run(args: Args, hooks: Option>) -> an Commands::Kill { sessions } => kill::run(sessions, socket), Commands::List { json } => list::run(socket, json), Commands::SetLogLevel { level } => set_log_level::run(level, socket), + Commands::Tui {} => { + // Forward the parent invocation's top-level flags so the + // `shpool attach` children spawned from the TUI see the + // same config / log destination / verbosity. See + // tui::run's docstring. + tui::run(socket, args.config_file.clone(), args.log_file.clone(), args.verbose) + } }; if let Err(err) = res { diff --git a/libshpool/src/list.rs b/libshpool/src/list.rs index 2492ab05..e6b61d56 100644 --- a/libshpool/src/list.rs +++ b/libshpool/src/list.rs @@ -12,29 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{io, path::PathBuf, time}; +use std::{path::PathBuf, time}; use anyhow::Context; use chrono::{DateTime, Utc}; use shpool_protocol::{ConnectHeader, ListReply}; -use crate::{protocol, protocol::ClientResult}; +use crate::protocol; pub fn run(socket: PathBuf, json_output: bool) -> anyhow::Result<()> { - let mut client = match protocol::Client::new(socket) { - Ok(ClientResult::JustClient(c)) => c, - Ok(ClientResult::VersionMismatch { warning, client }) => { - eprintln!("warning: {warning}, try restarting your daemon"); - client - } - Err(err) => { - let io_err = err.downcast::()?; - if io_err.kind() == io::ErrorKind::NotFound { - eprintln!("could not connect to daemon"); - } - return Err(io_err).context("connecting to daemon"); - } - }; + let mut client = protocol::connect_cli(socket)?; client.write_connect_header(ConnectHeader::List).context("sending list connect header")?; let reply: ListReply = client.read_reply().context("reading reply")?; diff --git a/libshpool/src/protocol.rs b/libshpool/src/protocol.rs index 6051db8e..6a0c6a00 100644 --- a/libshpool/src/protocol.rs +++ b/libshpool/src/protocol.rs @@ -430,6 +430,35 @@ impl Client { } } +/// Connect to the shpool daemon, returning the Client and any +/// version-mismatch warning the caller should surface to the user. +pub fn connect(socket: impl AsRef) -> anyhow::Result<(Client, Option)> { + match Client::new(socket)? { + ClientResult::JustClient(c) => Ok((c, None)), + ClientResult::VersionMismatch { warning, client } => Ok((client, Some(warning))), + } +} + +/// CLI-style connect: warnings go to stderr, NotFound produces the +/// standard "could not connect to daemon" hint, and the wrapped error +/// is returned with a "connecting to daemon" context. +pub fn connect_cli(socket: impl AsRef) -> anyhow::Result { + match connect(socket) { + Ok((client, None)) => Ok(client), + Ok((client, Some(warning))) => { + eprintln!("warning: {warning}, try restarting your daemon"); + Ok(client) + } + Err(err) => { + let io_err = err.downcast::()?; + if io_err.kind() == io::ErrorKind::NotFound { + eprintln!("could not connect to daemon"); + } + Err(io_err).context("connecting to daemon") + } + } +} + #[cfg(test)] mod test { use super::*; diff --git a/libshpool/src/tui/attach.rs b/libshpool/src/tui/attach.rs new file mode 100644 index 00000000..e51feffb --- /dev/null +++ b/libshpool/src/tui/attach.rs @@ -0,0 +1,205 @@ +//! Spawn `shpool attach [-f] ` as a child process. +//! +//! We shell out (to ourselves, via `argv[0]`) rather than call +//! [`crate::attach::run`] in-process because attach owns the terminal +//! in raw mode and spawns its own stdin/stdout threads — see +//! libshpool/src/protocol.rs pipe_bytes(). A subprocess boundary is +//! the simplest way to make sure that all cleans up before the TUI +//! reclaims the screen. + +use std::{ + env, + path::PathBuf, + process::{Command, Stdio}, +}; + +use anyhow::{anyhow, Context, Result}; + +/// Parent-invocation flags to forward to the spawned `shpool attach`. +/// +/// When the user runs `shpool --config-file foo.toml tui`, the spawned +/// attaches need to see the same `--config-file foo.toml` so the +/// attached shell picks up the right config (prompt template, hooks, +/// TTL defaults, etc.). Same reasoning for `--log-file` and `-v`. +/// `--socket` lives here too so every child talks to the same daemon. +/// +/// What's deliberately *not* forwarded: `--daemonize`. The spawned +/// attach is always given `--no-daemonize`, even if the parent +/// `shpool tui` was launched with `--daemonize` on. Rationale: +/// silently auto-spawning a daemon from inside an alt-screen attach +/// is worse UX than a visible "daemon's gone, please restart" error, +/// and `shpool tui`'s framing is "manage existing sessions" — +/// starting new infrastructure mid-session doesn't fit. The CLI-flag +/// asymmetry here is small and deliberate. +#[derive(Debug)] +pub struct AttachEnv { + pub socket: PathBuf, + pub config_file: Option, + pub log_file: Option, + pub verbose: u8, +} + +/// Spawn `shpool attach [-f] ` and block until it exits. +/// +/// The caller (suspend.rs) is responsible for putting the terminal +/// back in cooked mode and leaving the alt screen first. Here we +/// just fork+exec+wait. +/// +/// Returns `true` if the child exited cleanly (status 0), `false` +/// otherwise. Errors are reserved for "we couldn't even spawn it". +pub fn spawn_attach(name: &str, force: bool, env: &AttachEnv) -> Result { + // Use argv[0] of the current process rather than a hardcoded + // "shpool". This matters for: + // 1. Running from a non-installed build (cargo run) where `shpool` on PATH + // might be an older system install. + // 2. Tests that invoke the binary with an explicit path. + // 3. Any packaging that names the binary differently. + // + // libshpool already does this in daemonize::maybe_fork_daemon, + // so we're consistent with the rest of the crate. + let arg0 = env::args() + .next() + .ok_or_else(|| anyhow!("argv[0] missing — cannot find shpool binary to re-exec"))?; + + let mut cmd = build_command(&arg0, name, force, env); + + // Inherit stdin/stdout/stderr so the child owns the terminal. + // (This is the default for Command, but being explicit prevents + // future confusion if someone adds `.stdout(Stdio::piped())` + // thinking it's harmless.) + cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit()).stderr(Stdio::inherit()); + + let status = cmd.status().context("spawning `shpool attach`")?; + Ok(status.success()) +} + +/// Build the `shpool attach` Command, including all forwarded flags. +/// +/// Split out from `spawn_attach` so we can unit-test the arg list +/// without actually fork+exec'ing. stdio inheritance is applied by +/// `spawn_attach`; this function does not touch it. +fn build_command(arg0: &str, name: &str, force: bool, env: &AttachEnv) -> Command { + let mut cmd = Command::new(arg0); + + // Forward top-level invocation flags so the child behaves the + // same as the parent `shpool tui` was launched with: + // --config-file: the child re-reads config for shell setup + // (prompt prefix, hooks, TTL defaults). Without + // forwarding, a user who ran + // `shpool --config-file custom.toml tui` sees + // attached sessions using the *default* config. + // --log-file: match the parent's log destination, else the + // child's diagnostics go nowhere useful. + // -v (xN): match the parent's verbosity. + // --socket: always required — the child has to talk to the + // same daemon we're managing. + // --no-daemonize: always forced; see the AttachEnv docstring. + if let Some(path) = &env.config_file { + cmd.arg("--config-file").arg(path); + } + if let Some(path) = &env.log_file { + cmd.arg("--log-file").arg(path); + } + for _ in 0..env.verbose { + cmd.arg("-v"); + } + cmd.arg("--socket").arg(&env.socket).arg("--no-daemonize").arg("attach"); + if force { + cmd.arg("-f"); + } + cmd.arg(name); + cmd +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args_of(cmd: &Command) -> Vec { + cmd.get_args().map(|s| s.to_string_lossy().into_owned()).collect() + } + + fn base_env() -> AttachEnv { + AttachEnv { + socket: PathBuf::from("/tmp/shpool.sock"), + config_file: None, + log_file: None, + verbose: 0, + } + } + + #[test] + fn build_command_minimal() { + let env = base_env(); + let args = args_of(&build_command("shpool", "main", false, &env)); + assert_eq!(args, vec!["--socket", "/tmp/shpool.sock", "--no-daemonize", "attach", "main"],); + } + + #[test] + fn build_command_with_force() { + let env = base_env(); + let args = args_of(&build_command("shpool", "main", true, &env)); + // -f comes after the `attach` subcommand; name is last. + assert!( + args.windows(2).any(|w| w[0] == "attach" && w[1] == "-f"), + "expected `attach -f` pair; got {args:?}", + ); + assert_eq!(args.last().map(String::as_str), Some("main")); + } + + #[test] + fn build_command_forwards_config_file() { + let mut env = base_env(); + env.config_file = Some("/etc/shpool/custom.toml".into()); + let args = args_of(&build_command("shpool", "main", false, &env)); + assert!( + args.windows(2).any(|w| w[0] == "--config-file" && w[1] == "/etc/shpool/custom.toml"), + "expected --config-file with path; got {args:?}", + ); + } + + #[test] + fn build_command_forwards_log_file() { + let mut env = base_env(); + env.log_file = Some("/var/log/shpool.log".into()); + let args = args_of(&build_command("shpool", "main", false, &env)); + assert!( + args.windows(2).any(|w| w[0] == "--log-file" && w[1] == "/var/log/shpool.log"), + "expected --log-file with path; got {args:?}", + ); + } + + #[test] + fn build_command_forwards_verbose_count() { + let mut env = base_env(); + env.verbose = 3; + let args = args_of(&build_command("shpool", "main", false, &env)); + assert_eq!(args.iter().filter(|a| *a == "-v").count(), 3); + } + + #[test] + fn build_command_no_verbose_when_zero() { + let env = base_env(); + let args = args_of(&build_command("shpool", "main", false, &env)); + assert!(!args.iter().any(|a| a == "-v")); + } + + #[test] + fn build_command_global_flags_precede_subcommand() { + // Clap requires global flags (those declared on `Args`) to + // appear before the subcommand (`attach`). Check the ordering + // holds when every forwardable flag is set. + let env = AttachEnv { + socket: PathBuf::from("/tmp/s"), + config_file: Some("/c".into()), + log_file: Some("/l".into()), + verbose: 1, + }; + let args = args_of(&build_command("shpool", "main", false, &env)); + let attach_pos = args.iter().position(|a| a == "attach").expect("attach present"); + for flag in ["--config-file", "--log-file", "-v", "--socket", "--no-daemonize"] { + let pos = args.iter().position(|a| a == flag).expect(flag); + assert!(pos < attach_pos, "{flag} should come before `attach`; got {args:?}"); + } + } +} diff --git a/libshpool/src/tui/command.rs b/libshpool/src/tui/command.rs new file mode 100644 index 00000000..b5159f8e --- /dev/null +++ b/libshpool/src/tui/command.rs @@ -0,0 +1,30 @@ +//! `Command` — side effects the executor carries out after `update`. + +/// A side effect the main loop should perform. Produced by `update`, +/// consumed by `mod.rs::execute`. +#[derive(Debug, PartialEq)] +pub enum Command { + /// Refetch the session list from the daemon. Results come back as + /// [`super::event::Event::SessionsRefreshed`] or + /// [`super::event::Event::RefreshFailed`]. + Refresh, + + /// Spawn `shpool attach [-f] ` as a child process, suspend + /// the TUI while it runs, and resume when it exits. Result comes + /// back as [`super::event::Event::AttachExited`]. + Attach { name: String, force: bool }, + + /// Like Attach but creates a brand-new session. In shpool this + /// is the same daemon call as Attach (the daemon create-or- + /// attaches), but we keep them distinct so update/view can + /// enforce "name must not already exist" vs "name must already + /// exist" pre-flight checks. + Create(String), + + /// Kill the named session via the shpool protocol. Result comes + /// back as [`super::event::Event::KillFinished`]. + Kill(String), + + /// Stop the main loop. No follow-up event. + Quit, +} diff --git a/libshpool/src/tui/event.rs b/libshpool/src/tui/event.rs new file mode 100644 index 00000000..62ca6c41 --- /dev/null +++ b/libshpool/src/tui/event.rs @@ -0,0 +1,35 @@ +//! The `Event` type — everything the `update` function can react to. + +use crossterm::event::KeyEvent; +use shpool_protocol::Session; + +/// Things that can happen to the TUI. The `update` function turns +/// each of these into a model mutation (and optionally a `Command` +/// for the executor to carry out). +#[derive(Debug)] +pub enum Event { + /// A keystroke from crossterm. Wraps crossterm's own KeyEvent + /// directly — `update` pattern-matches on key code + modifiers. + Key(KeyEvent), + + /// The terminal window regained focus. Triggers a refresh: if + /// the user switched away and came back, session state may have + /// changed while they weren't looking. + FocusGained, + + /// The daemon answered a List request; here's the fresh data. + SessionsRefreshed(Vec), + + /// The daemon list request failed. The string is a display-ready + /// error message for the UI layer to surface. + RefreshFailed(String), + + /// A child `shpool attach` process returned control to us. The + /// `bool` is whether it exited cleanly — false means we should + /// surface an error. + AttachExited { ok: bool, name: String }, + + /// A kill request to the daemon finished. Like AttachExited, we + /// report failure as an error in the footer. + KillFinished { ok: bool, name: String, err: Option }, +} diff --git a/libshpool/src/tui/keymap.rs b/libshpool/src/tui/keymap.rs new file mode 100644 index 00000000..395e04c3 --- /dev/null +++ b/libshpool/src/tui/keymap.rs @@ -0,0 +1,153 @@ +//! Key binding tables — single source of truth for both dispatch and +//! footer help text. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// The set of logical actions bound to keys in Normal mode. +/// +/// Why an enum rather than function pointers in the binding table: +/// some actions need to read the current model state (e.g., "attach +/// the selected session" needs `model.sessions[model.selected]`) +/// and some are stateless (e.g., "select previous"). An enum lets +/// update.rs match on the action and do whatever state-access each +/// variant needs. The compiler's exhaustiveness check then enforces +/// that every variant is handled — no silent drift when we add a +/// new action. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NormalAction { + SelectPrev, + SelectNext, + AttachSelected, + NewSession, + KillSelected, + Quit, +} + +/// One entry in the Normal-mode binding table. +pub struct NormalBinding { + /// Short key label shown in the footer (e.g. "j", "spc"). + pub label: &'static str, + /// Description shown in the footer (e.g. "down", "attach"). + pub desc: &'static str, + /// Every KeyCode that triggers this action. All entries get the + /// same label+desc — useful for binding arrows + vim letters to + /// the same action without cluttering the footer. + pub keys: &'static [KeyCode], + pub action: NormalAction, +} + +/// Normal-mode bindings. Order here is the order shown in the footer. +/// +/// Case synonyms (`'j'` and `'J'`) are listed explicitly — there's no +/// automatic case folding at lookup time. Treating Shift-letters as +/// the same action is then just a matter of whether you enumerate the +/// uppercase variant here. If a future binding wants distinct uppercase +/// semantics (Vim-style `G` = bottom), remove the uppercase entry from +/// the synonym list and add a separate binding for it. +pub const NORMAL_BINDINGS: &[NormalBinding] = &[ + NormalBinding { + label: "j", + desc: "down", + keys: &[KeyCode::Char('j'), KeyCode::Char('J'), KeyCode::Down], + action: NormalAction::SelectNext, + }, + NormalBinding { + label: "k", + desc: "up", + keys: &[KeyCode::Char('k'), KeyCode::Char('K'), KeyCode::Up], + action: NormalAction::SelectPrev, + }, + NormalBinding { + label: "spc", + desc: "attach", + keys: &[KeyCode::Char(' '), KeyCode::Enter], + action: NormalAction::AttachSelected, + }, + NormalBinding { + label: "n", + desc: "new", + keys: &[KeyCode::Char('n'), KeyCode::Char('N')], + action: NormalAction::NewSession, + }, + NormalBinding { + label: "d", + desc: "kill", + keys: &[KeyCode::Char('d'), KeyCode::Char('D'), KeyCode::Char('x'), KeyCode::Char('X')], + action: NormalAction::KillSelected, + }, + NormalBinding { + label: "q", + desc: "quit", + keys: &[KeyCode::Char('q'), KeyCode::Char('Q'), KeyCode::Esc], + action: NormalAction::Quit, + }, +]; + +/// Modal-mode footer hints. Display-only: dispatch for these modes +/// lives inline in update.rs because their accepted input set isn't +/// a finite list of keys (CreateInput accepts any printable char; +/// the confirm modes accept `{y, Y}` + `{n, N, Enter, Esc}` with +/// other keys ignored). +pub const CREATE_HINTS: &[(&str, &str)] = &[("ret", "create"), ("esc", "cancel")]; + +/// Shared by ConfirmKill and ConfirmForce. Split into per-mode +/// constants if the two ever need to diverge. +pub const CONFIRM_HINTS: &[(&str, &str)] = &[("y/N", "")]; + +/// Whether this keypress should dispatch to a binding / action. +/// +/// The whitelist is "modifiers are a subset of `{SHIFT}`". Chord +/// keys (Ctrl / Alt / Super / Hyper / Meta) are filtered out — they +/// shouldn't accidentally trigger plain bindings (e.g. Ctrl-D should +/// not fire `d`'s kill action). Ctrl-C is handled as a special-case +/// global quit in `update`'s key handler, earlier in the dispatch. +/// +/// SHIFT is allowed because terminals disagree on where Shift shows +/// up: some fold it into the KeyCode (Shift-j → `Char('J')`, +/// modifiers = NONE), while enhanced protocols report it separately +/// (`Char('J')`, modifiers = SHIFT). Accepting both shapes means +/// Shift-letter variants fire regardless of terminal. +pub fn is_dispatchable(key: &KeyEvent) -> bool { + (key.modifiers - KeyModifiers::SHIFT).is_empty() +} + +/// Look up which NormalAction (if any) this KeyEvent triggers. +/// +/// Policy: +/// - Chord keys are filtered via `is_dispatchable` — Ctrl-D, Alt-J, etc. +/// return None even when the underlying char is in the table. +/// - Case folding is explicit in the binding table (see the doc on +/// `NORMAL_BINDINGS`): nothing is folded at lookup time. +pub fn normal_action(key: &KeyEvent) -> Option { + if !is_dispatchable(key) { + return None; + } + NORMAL_BINDINGS.iter().find(|b| b.keys.contains(&key.code)).map(|b| b.action) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// Guard against drift: if a new binding accidentally re-uses a + /// key already claimed by an existing binding, `normal_action`'s + /// linear scan would silently dispatch to whichever comes first + /// and the new binding would never fire. Catch that at test time. + /// Also catches duplicates within a single binding's `keys` array. + #[test] + fn no_key_bound_to_multiple_actions() { + let mut claimed: HashMap = HashMap::new(); + for b in NORMAL_BINDINGS { + for &k in b.keys { + if let Some(existing) = claimed.insert(k, b.label) { + panic!( + "key {:?} is bound by both [{}] and [{}] — \ + NORMAL_BINDINGS entries must have disjoint keys", + k, existing, b.label, + ); + } + } + } + } +} diff --git a/libshpool/src/tui/mod.rs b/libshpool/src/tui/mod.rs new file mode 100644 index 00000000..ffd4910f --- /dev/null +++ b/libshpool/src/tui/mod.rs @@ -0,0 +1,375 @@ +//! `shpool tui` — interactive session manager. +//! +//! High-level architecture (elm-like): +//! +//! event loop: +//! 1. draw the current Model (view.rs) +//! 2. read the next Event (key, or a follow-up from a Command) +//! 3. fold Event into Model, possibly yielding a Command (update.rs) +//! 4. if there's a Command, execute it; its result becomes the next Event +//! +//! The split is deliberate: `update` is a pure function and trivially +//! tested; `view` is pure and snapshot-tested; all side effects +//! (socket, subprocess, terminal) live in this file's `execute`. + +mod attach; +mod command; +mod event; +mod keymap; +mod model; +mod store; +mod suspend; +mod update; +mod view; + +use std::{ + env, io, + path::PathBuf, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result}; +use crossterm::{ + cursor::Hide, + event::{self as xterm_event, DisableFocusChange, EnableFocusChange, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +use shpool_protocol::Session; + +use self::{ + attach::AttachEnv, + command::Command, + event::Event, + model::{is_attached, Mode, Model}, + store::SessionStore, +}; + +/// `config_file`, `log_file`, and `verbose` are the parent invocation's +/// top-level flags, forwarded to every `shpool attach` subprocess +/// spawned from the TUI so those children see the same config / +/// logging destination / verbosity as the TUI itself. +pub fn run( + socket: PathBuf, + config_file: Option, + log_file: Option, + verbose: u8, +) -> Result<()> { + // Refuse to run inside a shpool session. Nested sessions are + // confusing: a force-attach from inside bumps the outer client, + // SHPOOL_SESSION_NAME ends up inherited, and ^D leaves the user + // at the wrong layer. Print a hint and exit rather than open the + // TUI — the user will notice and detach first. + if let Ok(name) = env::var("SHPOOL_SESSION_NAME") { + eprintln!( + "shpool tui: refusing to run inside shpool session '{name}'.\n\ + Run `shpool detach` first." + ); + return Ok(()); + } + + // Bring up the terminal. Failure here is fatal — we can't do + // anything useful without it — but we make sure to tear it back + // down cleanly on any error path so the user's shell doesn't get + // left in raw mode. + let mut terminal = enter_tui().context("entering TUI")?; + + let attach_env = AttachEnv { socket, config_file, log_file, verbose }; + + // The actual loop is in its own function so we can always run + // `leave_tui` on exit, even on error. + let result = main_loop(&mut terminal, &attach_env); + + // Always tear down. If `result` is already an error we'll + // propagate that; teardown errors go to stderr after the terminal + // is restored so the user can read them. + if let Err(e) = leave_tui(terminal) { + eprintln!("shpool tui: error restoring terminal: {e:?}"); + } + + result +} + +/// Set up the terminal for TUI use. Returns a ratatui `Terminal` +/// bound to stdout. +fn enter_tui() -> Result>> { + enable_raw_mode().context("enabling raw mode")?; + // EnableFocusChange is best-effort: terminals that don't implement + // the escape sequence just ignore it, and we never receive + // FocusGained events from them — benign no-op. + execute!(io::stdout(), EnterAlternateScreen, Hide, EnableFocusChange) + .context("entering alt screen")?; + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend).context("creating terminal")?; + Ok(terminal) +} + +/// Reverse of `enter_tui`. We consume the Terminal because after this +/// it's invalid to draw to it. +fn leave_tui(mut terminal: Terminal>) -> Result<()> { + // Clear + show cursor first so the user's shell prompt lands in + // a sensible place. + use crossterm::cursor::Show; + execute!(terminal.backend_mut(), DisableFocusChange, LeaveAlternateScreen, Show) + .context("leaving alt screen")?; + disable_raw_mode().context("disabling raw mode")?; + Ok(()) +} + +/// The event loop. Runs until the model flags `quit` or we hit an +/// unrecoverable error. +fn main_loop( + terminal: &mut Terminal>, + attach_env: &AttachEnv, +) -> Result<()> { + // SessionStore only needs the socket — it doesn't care about the + // forwarded flags. Clone the PathBuf (cheap, one-time) rather than + // threading a lifetime through SessionStore. + let store = SessionStore::new(attach_env.socket.clone()); + let mut model = Model::new(); + + // Initial fetch: if the daemon is alive, show its session list + // immediately; if not, show the error and let the user try + // again (quit + retry). + // + // We feed the result through `update` rather than mutating model + // directly so the error-handling path stays in one place. + let initial = match store.list() { + Ok(sessions) => Event::SessionsRefreshed(sessions), + Err(e) => Event::RefreshFailed(format!("{e:#}")), + }; + update::update(&mut model, initial); + + // Surface the first protocol-version-mismatch warning (if any) in + // the footer so the user learns about daemon/client drift once. + // Subsequent connects may hit the same daemon and re-surface the + // warning; we only show it on the first occurrence. + if let Some(warning) = store.take_first_warning() { + model.set_error(format!("warning: {warning}")); + } + + loop { + // 1. Render the current state. `now_ms` drives the relative- + // time column in the session list ("2m" etc.); passing it + // in from here (rather than having view.rs call + // SystemTime::now itself) keeps view pure + snapshot-testable. + let now = now_ms(); + terminal.draw(|f| view::view(&model, now, f)).context("drawing frame")?; + + // `quit` is checked AFTER the draw, not before. The final + // pass is technically a wasted frame, but LeaveAlternateScreen + // wipes it on teardown so the user never sees it. Deliberate — + // inverting to check-first would subtly change which frame + // the model's visible state matches, and the saved work + // (one draw on exit) isn't worth that coupling. + if model.quit { + return Ok(()); + } + + // 2. Read the next key. crossterm handles SIGWINCH for us — + // a resize shows up as `Event::Resize`, which we treat as a + // no-op (the next draw picks up the new size). + let next = read_one_event().context("reading input")?; + + // 3. Fold it into the model and maybe get a Command back. + let mut next_cmd = update::update(&mut model, next); + + // 4. Execute the Command, possibly producing a follow-up + // Event we feed back into update. If update produces ANOTHER + // Command from that follow-up, we execute that too, and so + // on. This cascade is load-bearing: e.g. create flow is + // Key(Enter) -> Create -> AttachExited -> Refresh -> + // SessionsRefreshed + // where the Refresh step is the one that picks up the newly + // created session. If we only executed the first Command + // (Create), the new session would never appear. + // + // Drift note: the specific command/event wiring (e.g. + // "AttachExited produces Refresh") lives in + // `update::update`'s match arms — this comment is the + // narrative but not the source of truth. If that wiring + // changes, update this example too. + while let Some(cmd) = next_cmd.take() { + let follow_up = execute(cmd, &mut model, &store, terminal, attach_env)?; + let Some(ev) = follow_up else { break }; + next_cmd = update::update(&mut model, ev); + } + } +} + +/// Outcome of an attach pre-flight: what did a fresh `list` say +/// about the session the user wants to attach to? +/// +/// Split out from the [`Command::Attach`] executor arm because the +/// decision is meaningfully separate from the action. Each variant +/// maps to one response in `execute`. +/// +/// Why pre-flight at all: the model's view of "is this session +/// attached elsewhere" can be stale because we only refresh on +/// keystroke, and even after a refresh the daemon's own state can +/// lag by ~0.5s (e.g. the user detached from another terminal and +/// the daemon hasn't reflected it yet). We want that stale state +/// to flash through without popping a spurious ConfirmForce +/// prompt, so we re-check with fresh data right before acting. +enum AttachPreflight { + /// `store.list()` itself failed — the error is display-ready. + RefreshFailed(anyhow::Error), + /// Session no longer exists; another client killed it since + /// our last view. The fresh list is included so the caller can + /// feed it through [`Event::SessionsRefreshed`] — which routes + /// to [`Model::apply_refresh`] via [`update::update`]. + Gone { sessions: Vec }, + /// Session exists but is attached from another terminal, and + /// force was not set. Caller should pop a ConfirmForce prompt. + AttachedElsewhere { sessions: Vec }, + /// Session exists and is ready to attach. No `sessions` carried — + /// the AttachExited handler will cascade into a fresh Refresh. + ClearToAttach, +} + +fn preflight_attach(store: &SessionStore, name: &str, force: bool) -> AttachPreflight { + let sessions = match store.list() { + Ok(s) => s, + Err(e) => return AttachPreflight::RefreshFailed(e), + }; + // Two scans over the list: one to check presence (so we can move + // `sessions` into Gone without holding a borrow), one to check + // attached-state on the matching entry. Scan cost is trivial at + // session-list sizes. Structuring as a single-scan `.find()` + // creates a borrow that conflicts with moving `sessions` into + // the outcome variant. + if !sessions.iter().any(|s| s.name == name) { + return AttachPreflight::Gone { sessions }; + } + let attached_elsewhere = + !force && sessions.iter().find(|s| s.name == name).map(is_attached).unwrap_or(false); + if attached_elsewhere { + return AttachPreflight::AttachedElsewhere { sessions }; + } + AttachPreflight::ClearToAttach +} + +/// Current wall-clock time in unix milliseconds. Saturates to 0 if +/// the clock is before the unix epoch (shouldn't happen in practice, +/// but don't panic over it). +fn now_ms() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0) +} + +/// Block until crossterm delivers something we want to feed into +/// update. Resize events are absorbed here — we don't bother the +/// model with them. +/// +/// Returns `Event::Key(...)` or `Event::FocusGained`. RefreshFailed / +/// AttachExited / etc. all come from the executor; having the +/// read-key path return the same `Event` type as the executor keeps +/// the main loop simple. +fn read_one_event() -> Result { + loop { + match xterm_event::read()? { + // KeyEventKind::Press — only real presses, not releases + // or repeats. Some terminals send KeyEventKind::Release + // on kitty protocol; we'd double-fire without this + // filter. + xterm_event::Event::Key(k) if k.kind == KeyEventKind::Press => { + return Ok(Event::Key(k)); + } + xterm_event::Event::FocusGained => return Ok(Event::FocusGained), + // Resize: the next `terminal.draw` will pick up the new + // size on its own. Loop around to read another event. + xterm_event::Event::Resize(_, _) => continue, + // Mouse / paste / focus-lost / key-release: ignore. + _ => continue, + } + } +} + +/// Side-effect executor. Takes a Command from `update`, does the +/// thing, and returns the follow-up Event (if any) for the main loop +/// to feed back into update. +/// +/// All IO (socket, subprocess, terminal suspend) happens here. This +/// is the layer you look at when debugging "why didn't my kill +/// work" — all three possibilities (bad protocol call, silent daemon +/// error, botched UI state refresh) are visible in this function. +fn execute( + cmd: Command, + model: &mut Model, + store: &SessionStore, + terminal: &mut Terminal>, + attach_env: &AttachEnv, +) -> Result> { + match cmd { + Command::Quit => { + model.quit = true; + Ok(None) + } + Command::Refresh => { + let t0 = Instant::now(); + let ev = match store.list() { + Ok(sessions) => Event::SessionsRefreshed(sessions), + Err(e) => Event::RefreshFailed(format!("{e:#}")), + }; + // Log slow daemon responses so we can spot regressions + // without having to repro by eye. tracing integrates + // with libshpool's existing log setup. + let elapsed = t0.elapsed(); + if elapsed > Duration::from_millis(200) { + tracing::warn!(?elapsed, "tui: slow shpool list"); + } + Ok(Some(ev)) + } + Command::Attach { name, force } => match preflight_attach(store, &name, force) { + AttachPreflight::RefreshFailed(e) => { + // Route through Event::RefreshFailed so the + // "shpool list:" prefix is applied in exactly one + // place (update's RefreshFailed handler), not + // duplicated here. + Ok(Some(Event::RefreshFailed(format!("{e:#}")))) + } + AttachPreflight::Gone { sessions } => { + model.set_error(format!("session '{name}' is gone")); + Ok(Some(Event::SessionsRefreshed(sessions))) + } + AttachPreflight::AttachedElsewhere { sessions } => { + // Pop the confirm-force prompt rather than bumping the + // other client silently. The user can press 'y' to + // re-issue Attach with force=true, which skips the + // preflight attached-elsewhere check. + model.mode = Mode::ConfirmForce(name); + Ok(Some(Event::SessionsRefreshed(sessions))) + } + AttachPreflight::ClearToAttach => { + // Skip apply_refresh here — the AttachExited handler + // cascades into a fresh Refresh anyway, and we don't + // draw between here and the suspend, so any + // intermediate refresh state would be invisible. + let ok = suspend::with_tui_suspended(terminal, || { + attach::spawn_attach(&name, force, attach_env) + })?; + Ok(Some(Event::AttachExited { ok, name })) + } + }, + Command::Create(name) => { + // `shpool attach` with a fresh name creates + attaches + // atomically on the daemon side. We pre-flight the + // "already exists" check in update (see CreateInput + // Enter handler) so by the time we get here it's safe + // to just spawn. + let ok = suspend::with_tui_suspended(terminal, || { + attach::spawn_attach(&name, false, attach_env) + })?; + Ok(Some(Event::AttachExited { ok, name })) + } + Command::Kill(name) => { + let result = store.kill(&name); + let (ok, err) = match result { + Ok(()) => (true, None), + Err(e) => (false, Some(format!("kill {name}: {e:#}"))), + }; + Ok(Some(Event::KillFinished { ok, name, err })) + } + } +} diff --git a/libshpool/src/tui/model.rs b/libshpool/src/tui/model.rs new file mode 100644 index 00000000..e212a61f --- /dev/null +++ b/libshpool/src/tui/model.rs @@ -0,0 +1,130 @@ +//! The TUI's "model" — the plain-data state that the view reads from +//! and that `update` mutates. Everything in here is pure data + pure +//! methods; no I/O, no terminal, no sockets. + +use shpool_protocol::{Session, SessionStatus}; + +/// Which modal "screen" the TUI is currently in. +#[derive(Debug, PartialEq, Default)] +pub enum Mode { + /// Default view: session list with selection. + #[default] + Normal, + + /// The user is naming a new session. The `String` is the + /// in-progress edit buffer. + CreateInput(String), + + /// The user is confirming whether to kill a session. The `String` + /// is the session name. (Key dispatch — which keys confirm, + /// cancel, or are ignored — lives in `update`.) + ConfirmKill(String), + + /// Attach pre-flight found the session attached elsewhere; the + /// user is deciding whether to force-attach (which bumps the + /// other client). The `String` is the session name. (Key dispatch + /// lives in `update`.) + ConfirmForce(String), +} + +/// Everything the view needs to render a frame, plus the cursor +/// position in the list. +#[derive(Default)] +pub struct Model { + /// The full session list, sorted most-recently-active first. + pub sessions: Vec, + + /// Index into `sessions` of the currently-highlighted row. Stays + /// valid (< sessions.len()) unless `sessions` is empty, in which + /// case it's 0 and the view renders "no sessions". + pub selected: usize, + + /// Which modal screen is active. + pub mode: Mode, + + /// Transient error message shown in the footer until the next + /// keystroke. + pub error: Option, + + /// Set to true when the user's triggered a Quit command. The main + /// loop checks this after each `update` and exits if set. + pub quit: bool, +} + +impl Model { + /// Construct an empty model. Delegates to `Default::default()` — + /// kept as an explicit constructor for call-site clarity. + pub fn new() -> Self { + Self::default() + } + + /// Name of the currently-selected session, or None if the list is + /// empty. + pub fn selected_name(&self) -> Option<&str> { + self.sessions.get(self.selected).map(|s| s.name.as_str()) + } + + /// Move selection down, wrapping at the bottom. No-op if empty. + pub fn select_next(&mut self) { + if self.sessions.is_empty() { + return; + } + self.selected = (self.selected + 1) % self.sessions.len(); + } + + /// Move selection up, wrapping at the top. + pub fn select_prev(&mut self) { + if self.sessions.is_empty() { + return; + } + if self.selected == 0 { + self.selected = self.sessions.len() - 1; + } else { + self.selected -= 1; + } + } + + /// Replace the session list, preserving selection by name when + /// possible. If the previously-selected session disappeared, the + /// old index is clamped into the new list. Sessions are sorted + /// most-recently-active first. + pub fn apply_refresh(&mut self, mut new_sessions: Vec) { + new_sessions.sort_by_key(|s| std::cmp::Reverse(last_active_unix_ms(s))); + + // Capture old selection before replacing the list. + let prev_name = self.selected_name().map(str::to_string); + let prev_idx = self.selected; + + self.sessions = new_sessions; + + // saturating_sub avoids underflow when sessions is empty. + self.selected = prev_name + .and_then(|name| self.sessions.iter().position(|s| s.name == name)) + .unwrap_or_else(|| prev_idx.min(self.sessions.len().saturating_sub(1))); + } + + /// Set a transient error. The next keystroke clears it (handled + /// in `update`). + pub fn set_error(&mut self, msg: impl Into) { + self.error = Some(msg.into()); + } +} + +// Free helpers over shpool_protocol::Session. The orphan rule blocks +// inherent impls on foreign types, so we use free functions. + +/// Unix ms of the most recent state transition — the newer of +/// last-connected and last-disconnected, falling back to creation +/// time. Used for "last-active" sorting. +pub fn last_active_unix_ms(s: &Session) -> i64 { + s.last_connected_at_unix_ms + .unwrap_or(0) + .max(s.last_disconnected_at_unix_ms.unwrap_or(0)) + .max(s.started_at_unix_ms) +} + +/// Whether the session currently has a client attached (from the +/// daemon's perspective). +pub fn is_attached(s: &Session) -> bool { + matches!(s.status, SessionStatus::Attached) +} diff --git a/libshpool/src/tui/snapshots/libshpool__tui__view__tests__confirm_force_footer.snap b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__confirm_force_footer.snap new file mode 100644 index 00000000..ae64afc5 --- /dev/null +++ b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__confirm_force_footer.snap @@ -0,0 +1,10 @@ +--- +source: libshpool/src/tui/view.rs +expression: "render_to_string(&m, 70, 5)" +snapshot_kind: text +--- + shpool sessions (1) + name created active +*>main 5m 5m + +'main' is attached elsewhere — force attach? [y/N] diff --git a/libshpool/src/tui/snapshots/libshpool__tui__view__tests__confirm_kill_footer.snap b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__confirm_kill_footer.snap new file mode 100644 index 00000000..dffe3505 --- /dev/null +++ b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__confirm_kill_footer.snap @@ -0,0 +1,10 @@ +--- +source: libshpool/src/tui/view.rs +expression: "render_to_string(&m, 70, 5)" +snapshot_kind: text +--- + shpool sessions (1) + name created active + >main 10m 10m + +kill session 'main'? [y/N] diff --git a/libshpool/src/tui/snapshots/libshpool__tui__view__tests__create_input_midtyping.snap b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__create_input_midtyping.snap new file mode 100644 index 00000000..61f268a4 --- /dev/null +++ b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__create_input_midtyping.snap @@ -0,0 +1,10 @@ +--- +source: libshpool/src/tui/view.rs +expression: "render_to_string(&m, 70, 5)" +snapshot_kind: text +--- + shpool sessions (1) + name created active + >main 30s 30s + +new session name: foo_ [ret] create [esc] cancel diff --git a/libshpool/src/tui/snapshots/libshpool__tui__view__tests__empty_list_shows_hint.snap b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__empty_list_shows_hint.snap new file mode 100644 index 00000000..c5562594 --- /dev/null +++ b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__empty_list_shows_hint.snap @@ -0,0 +1,11 @@ +--- +source: libshpool/src/tui/view.rs +expression: "render_to_string(&m, 60, 6)" +snapshot_kind: text +--- + shpool sessions (0) + name created active +no sessions + + + [j] down [k] up [spc] attach [n] new [d] kill [q] quit diff --git a/libshpool/src/tui/snapshots/libshpool__tui__view__tests__error_replaces_footer.snap b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__error_replaces_footer.snap new file mode 100644 index 00000000..a85270f0 --- /dev/null +++ b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__error_replaces_footer.snap @@ -0,0 +1,10 @@ +--- +source: libshpool/src/tui/view.rs +expression: "render_to_string(&m, 60, 5)" +snapshot_kind: text +--- + shpool sessions (1) + name created active + >main 30s 30s + +! daemon gone diff --git a/libshpool/src/tui/snapshots/libshpool__tui__view__tests__list_with_selection.snap b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__list_with_selection.snap new file mode 100644 index 00000000..1917d1c8 --- /dev/null +++ b/libshpool/src/tui/snapshots/libshpool__tui__view__tests__list_with_selection.snap @@ -0,0 +1,11 @@ +--- +source: libshpool/src/tui/view.rs +expression: "render_to_string(&m, 70, 6)" +snapshot_kind: text +--- + shpool sessions (2) + name created active +* main 2m 2m + >build 3h 3h + + [j] down [k] up [spc] attach [n] new [d] kill [q] quit diff --git a/libshpool/src/tui/store.rs b/libshpool/src/tui/store.rs new file mode 100644 index 00000000..99a8fe49 --- /dev/null +++ b/libshpool/src/tui/store.rs @@ -0,0 +1,120 @@ +//! `SessionStore` — a thin wrapper around the shpool protocol Client +//! that the executor uses to talk to the daemon. +//! +//! We use the protocol directly (not shell-out to `shpool list`/ +//! `shpool kill`). Attach is NOT here — attach stays behind a +//! subprocess boundary so it can take over the terminal the way a +//! normal `shpool attach` does. See `attach.rs`. + +use std::{cell::RefCell, path::PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use shpool_protocol::{ConnectHeader, KillReply, KillRequest, ListReply, Session}; + +use crate::protocol; + +/// Owns the socket path and makes protocol requests on demand. +/// +/// We don't hold a long-lived connection — each request opens a fresh +/// one. The daemon's protocol is request/response and the Client +/// isn't designed for reuse, so one-shot is both simplest and +/// consistent with how the existing `list`/`kill` subcommands work. +/// +/// Combined with the update layer's auto-refresh (one `list` per +/// Normal-mode keystroke), this amounts to a connect + write + read +/// round-trip per keypress. Sub-millisecond on a local unix socket; +/// not perceivable. +pub struct SessionStore { + /// Path to the daemon's unix socket. Stored by value because we + /// hand it out by reference to spawn_attach too. + pub socket: PathBuf, + + /// First version-mismatch warning seen from any `connect`. The + /// TUI displays this once in the footer (via `take_first_warning` + /// from `mod.rs`) so the user learns about protocol drift without + /// the warning stuttering on every subsequent refresh. + /// + /// `RefCell` gives us interior mutability: `list` and `kill` + /// take `&self`, and we want to update the warning slot as a + /// side effect of a successful connect. + first_warning: RefCell>, +} + +impl SessionStore { + pub fn new(socket: PathBuf) -> Self { + Self { socket, first_warning: RefCell::new(None) } + } + + /// Consume and return the first version-mismatch warning we saw, + /// if any. Returns None thereafter — intended to be called once + /// at startup to surface the warning in the footer. + pub fn take_first_warning(&self) -> Option { + self.first_warning.borrow_mut().take() + } + + /// Fetch the session list. + pub fn list(&self) -> Result> { + let mut client = self.connect()?; + client.write_connect_header(ConnectHeader::List).context("sending list connect header")?; + let reply: ListReply = client.read_reply().context("reading list reply")?; + Ok(reply.sessions) + } + + /// Kill one session by name. Returns Ok(()) on success; Err with a + /// display-ready message otherwise. + pub fn kill(&self, name: &str) -> Result<()> { + let mut client = self.connect()?; + client + .write_connect_header(ConnectHeader::Kill(KillRequest { + sessions: vec![name.to_string()], + })) + .context("sending kill connect header")?; + let reply: KillReply = client.read_reply().context("reading kill reply")?; + // Single-session caller: `not_found_sessions` has at most one + // entry. The loop-like `join(" ")` is kept for symmetry with + // the CLI `kill` path rather than because multiple names are + // possible here. + if !reply.not_found_sessions.is_empty() { + return Err(anyhow!("not found: {}", reply.not_found_sessions.join(" "))); + } + Ok(()) + } + + /// Open a new protocol client. Uses the shared [`crate::protocol::connect`] + /// helper so the connect-dance (version-mismatch handling, + /// IO-error downcast) lives in one place. Any version-mismatch + /// warning is captured into `first_warning` on the *first* + /// occurrence and then ignored; see `take_first_warning`. + fn connect(&self) -> Result { + let (client, warning) = protocol::connect(&self.socket)?; + if let Some(w) = warning { + let mut slot = self.first_warning.borrow_mut(); + if slot.is_none() { + *slot = Some(w); + } + } + Ok(client) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn take_first_warning_is_idempotent() { + let store = SessionStore::new(PathBuf::from("/nonexistent")); + // Seed a warning directly (simulating a successful connect + // that returned a version-mismatch). + *store.first_warning.borrow_mut() = Some("stale daemon".into()); + assert_eq!(store.take_first_warning().as_deref(), Some("stale daemon")); + // Second call returns None. + assert_eq!(store.take_first_warning(), None); + } + + #[test] + fn no_warning_on_fresh_store() { + let store = SessionStore::new(PathBuf::from("/nonexistent")); + assert_eq!(store.take_first_warning(), None); + } +} diff --git a/libshpool/src/tui/suspend.rs b/libshpool/src/tui/suspend.rs new file mode 100644 index 00000000..343235cd --- /dev/null +++ b/libshpool/src/tui/suspend.rs @@ -0,0 +1,65 @@ +//! `with_tui_suspended` — run some code with the TUI's terminal state +//! torn down (raw mode off, alt screen left, cursor shown) and +//! automatically restore on return. +//! +//! Used for spawning `shpool attach`: the child needs a clean tty to +//! take over, and we need to make sure we come back to our alt +//! screen even if `f` returned an error. + +use std::io::{self, Stdout}; + +use anyhow::{Context, Result}; +use crossterm::{ + cursor::{Hide, MoveTo, Show}, + execute, + terminal::{ + disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, + LeaveAlternateScreen, + }, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +/// Tear the TUI down, run `f`, put the TUI back up. Restores on both +/// success and error-return paths of `f`. +pub fn with_tui_suspended( + terminal: &mut Terminal>, + f: F, +) -> Result +where + F: FnOnce() -> Result, +{ + // --- leave TUI mode --- + // Order matters: leave alt screen BEFORE disabling raw mode, so + // the alt-screen escape is sent while raw mode is still active + // (some terminals buffer differently otherwise). Show the cursor + // last; that's cosmetic. + // + // After leaving the alt screen we're back on the primary screen, + // which still holds whatever was there before the TUI opened + // (shell prompts, command history, etc.). We clear it before + // handing control to `f` so the child (typically `shpool + // attach`) starts on a blank viewport. `Clear::All` wipes the + // visible screen; scrollback is preserved. `MoveTo(0,0)` homes + // the cursor so the child's first output lands at the top-left. + execute!(io::stdout(), LeaveAlternateScreen, Clear(ClearType::All), MoveTo(0, 0), Show,) + .context("leaving alt screen")?; + disable_raw_mode().context("disabling raw mode")?; + + // --- run the caller's thing --- + // We capture the result so we can still restore the terminal even + // if `f` returned Err. We do NOT use `?` here because that would + // early-return and skip the restore. + let result = f(); + + // --- re-enter TUI mode --- + // Same ordering in reverse: raw mode first, then alt screen. + enable_raw_mode().context("re-enabling raw mode")?; + execute!(io::stdout(), EnterAlternateScreen, Hide).context("re-entering alt screen")?; + + // The child may have drawn arbitrary content onto the main screen; + // telling ratatui to forget its previous buffer forces a full + // redraw of the TUI on the next `.draw` call. + terminal.clear().context("clearing terminal after resume")?; + + result +} diff --git a/libshpool/src/tui/update.rs b/libshpool/src/tui/update.rs new file mode 100644 index 00000000..8310c8fd --- /dev/null +++ b/libshpool/src/tui/update.rs @@ -0,0 +1,555 @@ +//! The update function: `(&mut Model, Event) -> Option`. +//! +//! Deterministic and free of I/O — no socket calls, no terminal +//! writes, no subprocess spawns. It reads the event, mutates the +//! supplied model in place, and optionally emits a `Command` for +//! the executor to carry out. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use super::command::Command; +use super::event::Event; +use super::keymap::{is_dispatchable, normal_action, NormalAction}; +use super::model::{Mode, Model}; + +/// Fold one event into the model. Returns `Some(Command)` if the +/// event triggers a side effect (attach / kill / refresh / quit). +pub fn update(model: &mut Model, event: Event) -> Option { + // Any non-async event (i.e. a keystroke) clears the transient + // error. Async events (refresh finishing, kill finishing) can + // SET the error but shouldn't clear one the user hasn't seen. + let was_keystroke = matches!(event, Event::Key(_)); + if was_keystroke { + model.error = None; + } + + let cmd = match event { + Event::Key(key) => handle_key(model, key), + Event::FocusGained => Some(Command::Refresh), + Event::SessionsRefreshed(sessions) => { + model.apply_refresh(sessions); + None + } + Event::RefreshFailed(msg) => { + model.set_error(format!("shpool list: {msg}")); + None + } + Event::AttachExited { ok, name } => { + // Attach teardown is our chance to freshen the list — + // the session we just attached to may have changed state + // and other clients may have created/killed sessions + // while we were suspended. + if !ok { + model.set_error(format!("shpool attach {name} failed")); + } + // Reselect the session we just attached to, so when the + // user comes back they're looking at what they just left. + if let Some(i) = model.sessions.iter().position(|s| s.name == name) { + model.selected = i; + } + Some(Command::Refresh) + } + Event::KillFinished { ok, name, err } => { + if !ok { + let msg = err.unwrap_or_else(|| format!("kill {name} failed")); + model.set_error(msg); + } + Some(Command::Refresh) + } + }; + + // Auto-refresh: when a Normal-mode keystroke finishes without + // producing its own Command, request a refresh so the session + // list tracks daemon-side changes (sessions created/killed/ + // detached by other clients) without needing explicit user + // action. We skip this in modal modes so typing "foo" in + // CreateInput isn't three socket round-trips. + if was_keystroke && cmd.is_none() && matches!(model.mode, Mode::Normal) { + return Some(Command::Refresh); + } + cmd +} + +/// Dispatch a single key event based on the current mode. +fn handle_key(model: &mut Model, key: KeyEvent) -> Option { + // Ctrl-C is a global quit regardless of mode — matches most + // interactive tools. Checking it here means we don't have to + // duplicate the handler in every mode branch. + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Some(Command::Quit); + } + + match &mut model.mode { + Mode::Normal => handle_key_normal(model, key), + Mode::CreateInput(_) => handle_key_create(model, key), + Mode::ConfirmKill(_) => handle_key_confirm_kill(model, key), + Mode::ConfirmForce(_) => handle_key_confirm_force(model, key), + } +} + +fn handle_key_normal(model: &mut Model, key: KeyEvent) -> Option { + let action = normal_action(&key)?; + + match action { + NormalAction::SelectPrev => { + model.select_prev(); + None + } + NormalAction::SelectNext => { + model.select_next(); + None + } + NormalAction::AttachSelected => { + // We DON'T make the "is it already attached?" call + // here — the model's view of attached-state might be + // stale (e.g. the user just detached elsewhere ~0.5s + // ago and the daemon hasn't reflected it yet). Just + // emit Command::Attach and let the executor make that + // decision with fresh data right before spawning. + let session = model.sessions.get(model.selected)?; + Some(Command::Attach { name: session.name.clone(), force: false }) + } + NormalAction::NewSession => { + model.mode = Mode::CreateInput(String::new()); + None + } + NormalAction::KillSelected => { + let session = model.sessions.get(model.selected)?; + model.mode = Mode::ConfirmKill(session.name.clone()); + None + } + NormalAction::Quit => Some(Command::Quit), + } +} + +fn handle_key_create(model: &mut Model, key: KeyEvent) -> Option { + let Mode::CreateInput(buf) = &mut model.mode else { + // Unreachable because handle_key already matched Mode, but + // the compiler doesn't know that — we have to destructure + // again to access the buffer. + return None; + }; + + match key.code { + // Enter submits. + KeyCode::Enter => { + let name = std::mem::take(buf); + model.mode = Mode::Normal; + if name.is_empty() { + return None; + } + // Reject duplicates here rather than in the executor so + // the error surfaces in the TUI footer immediately + // instead of after a daemon round-trip. + if model.sessions.iter().any(|s| s.name == name) { + model.set_error(format!("session '{name}' already exists")); + return None; + } + Some(Command::Create(name)) + } + KeyCode::Esc => { + model.mode = Mode::Normal; + None + } + KeyCode::Backspace => { + buf.pop(); + None + } + // Any printable character gets appended. We skip: + // - chars with CONTROL or ALT modifiers (so Ctrl-X doesn't land in the name) + // - whitespace (space, tab) + KeyCode::Char(c) + if !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && !c.is_whitespace() => + { + buf.push(c); + None + } + _ => None, + } +} + +fn handle_key_confirm_kill(model: &mut Model, key: KeyEvent) -> Option { + let Mode::ConfirmKill(name) = &mut model.mode else { + return None; + }; + // y/Y confirms; n/N/Enter/Esc cancel. Any other key leaves the + // prompt open. Enter/Esc are convenience aliases not shown in + // the `[y/N]` hint. Chord keys are filtered via `is_dispatchable`. + if !is_dispatchable(&key) { + return None; + } + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let name = std::mem::take(name); + model.mode = Mode::Normal; + Some(Command::Kill(name)) + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Enter | KeyCode::Esc => { + model.mode = Mode::Normal; + None + } + _ => None, + } +} + +fn handle_key_confirm_force(model: &mut Model, key: KeyEvent) -> Option { + let Mode::ConfirmForce(name) = &mut model.mode else { + return None; + }; + if !is_dispatchable(&key) { + return None; + } + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let name = std::mem::take(name); + model.mode = Mode::Normal; + Some(Command::Attach { name, force: true }) + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Enter | KeyCode::Esc => { + model.mode = Mode::Normal; + None + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::super::keymap::CONFIRM_HINTS; + use super::*; + use shpool_protocol::{Session, SessionStatus}; + + // Helper: build a Session with just the fields our logic looks at. + fn session(name: &str, attached: bool) -> Session { + Session { + name: name.to_string(), + started_at_unix_ms: 0, + last_connected_at_unix_ms: None, + last_disconnected_at_unix_ms: None, + status: if attached { SessionStatus::Attached } else { SessionStatus::Disconnected }, + } + } + + // Helper: build a KeyEvent with no modifiers. + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn space_on_detached_session_attaches() { + let mut m = Model::new(); + m.sessions = vec![session("a", false)]; + let cmd = update(&mut m, Event::Key(key(KeyCode::Char(' ')))); + assert_eq!(cmd, Some(Command::Attach { name: "a".into(), force: false })); + } + + #[test] + fn enter_also_attaches() { + let mut m = Model::new(); + m.sessions = vec![session("a", false)]; + let cmd = update(&mut m, Event::Key(key(KeyCode::Enter))); + assert_eq!(cmd, Some(Command::Attach { name: "a".into(), force: false })); + } + + #[test] + fn space_on_attached_session_still_emits_attach() { + // The ConfirmForce decision moved to the executor (which + // refreshes first to avoid stale data). update unconditionally + // emits Command::Attach { force: false }; executor may turn + // it into a ConfirmForce mode transition if fresh data agrees + // the session is attached elsewhere. + let mut m = Model::new(); + m.sessions = vec![session("a", true)]; + let cmd = update(&mut m, Event::Key(key(KeyCode::Char(' ')))); + assert_eq!(cmd, Some(Command::Attach { name: "a".into(), force: false })); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn y_in_confirm_kill_issues_kill() { + let mut m = Model::new(); + m.sessions = vec![session("a", false)]; + m.mode = Mode::ConfirmKill("a".into()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('y')))); + assert_eq!(cmd, Some(Command::Kill("a".into()))); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn n_in_confirm_kill_cancels() { + let mut m = Model::new(); + m.mode = Mode::ConfirmKill("a".into()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('n')))); + // Cancel returns to Normal mode; auto-refresh then kicks in. + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn enter_in_confirm_kill_cancels() { + // Enter in a `[y/N]` prompt = accept the default (N) = cancel. + let mut m = Model::new(); + m.mode = Mode::ConfirmKill("a".into()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Enter))); + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn esc_in_confirm_kill_cancels() { + let mut m = Model::new(); + m.mode = Mode::ConfirmKill("a".into()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Esc))); + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn unrelated_key_in_confirm_kill_stays_open() { + // 'x' is not in {y, Y, n, N, Enter, Esc}. Should NOT dismiss + // the prompt — prevents stray keys from accidentally closing + // the modal. + let mut m = Model::new(); + m.mode = Mode::ConfirmKill("a".into()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('x')))); + assert_eq!(cmd, None, "stray key should not trigger a command"); + assert_eq!(m.mode, Mode::ConfirmKill("a".into()), "prompt should still be open"); + } + + #[test] + fn y_in_confirm_force_force_attaches() { + let mut m = Model::new(); + m.mode = Mode::ConfirmForce("main".into()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('y')))); + assert_eq!(cmd, Some(Command::Attach { name: "main".into(), force: true })); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn unrelated_key_in_confirm_force_stays_open() { + let mut m = Model::new(); + m.mode = Mode::ConfirmForce("main".into()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('x')))); + assert_eq!(cmd, None); + assert_eq!(m.mode, Mode::ConfirmForce("main".into())); + } + + #[test] + fn create_rejects_duplicate() { + let mut m = Model::new(); + m.sessions = vec![session("main", false)]; + m.mode = Mode::CreateInput("main".into()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Enter))); + // Reject-and-return-to-Normal triggers the auto-refresh. + assert_eq!(cmd, Some(Command::Refresh)); + assert!(m.error.as_deref().unwrap_or("").contains("already exists")); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn create_typing_accumulates_into_buffer() { + let mut m = Model::new(); + m.mode = Mode::CreateInput(String::new()); + update(&mut m, Event::Key(key(KeyCode::Char('f')))); + update(&mut m, Event::Key(key(KeyCode::Char('o')))); + update(&mut m, Event::Key(key(KeyCode::Char('o')))); + assert_eq!(m.mode, Mode::CreateInput("foo".into())); + } + + #[test] + fn create_rejects_whitespace_in_name() { + // Typing a space in CreateInput is a no-op — shpool stores + // the name verbatim into env vars / prompt prefixes, and + // spaces cause downstream pain. + let mut m = Model::new(); + m.mode = Mode::CreateInput("foo".into()); + update(&mut m, Event::Key(key(KeyCode::Char(' ')))); + update(&mut m, Event::Key(key(KeyCode::Char('\t')))); + assert_eq!(m.mode, Mode::CreateInput("foo".into())); + } + + #[test] + fn keystroke_clears_transient_error() { + let mut m = Model::new(); + m.set_error("boom"); + update(&mut m, Event::Key(key(KeyCode::Char('j')))); + assert!(m.error.is_none()); + } + + #[test] + fn ctrl_c_quits_in_any_mode() { + let mut m = Model::new(); + m.mode = Mode::CreateInput("half-typed".into()); + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + assert_eq!(update(&mut m, Event::Key(ctrl_c)), Some(Command::Quit)); + } + + #[test] + fn navigation_keystroke_triggers_auto_refresh() { + // In Normal mode, any no-op-ish keystroke (j/k/etc.) should + // request a Refresh so the list stays current without the + // user having to do anything explicit. + let mut m = Model::new(); + m.sessions = vec![session("a", false), session("b", false)]; + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('j')))); + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.selected, 1); + } + + #[test] + fn kill_on_empty_list_is_noop() { + // Pressing `d` on an empty list should not enter ConfirmKill + // mode (no session to confirm against). The `get(selected)` + // pre-check in handle_key_normal defends against this — lock + // it in so a future refactor doesn't regress. + let mut m = Model::new(); + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('d')))); + // No session-binding command, but the keystroke still triggers + // the auto-refresh since we're in Normal mode with no cmd. + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn attach_on_empty_list_is_noop() { + // Same shape as kill_on_empty_list_is_noop — pressing space + // (or Enter) on an empty list must not emit Command::Attach + // with an empty name. + let mut m = Model::new(); + let cmd = update(&mut m, Event::Key(key(KeyCode::Char(' ')))); + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn uppercase_letters_dispatch_same_as_lowercase() { + // Shift-J should move the selection down, just like j. + // keymap::NORMAL_BINDINGS enumerates both cases as synonyms. + let mut m = Model::new(); + m.sessions = vec![session("a", false), session("b", false)]; + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('J')))); + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.selected, 1); + } + + #[test] + fn ctrl_d_does_not_kill() { + // Ctrl-D is a shell-reflex keypress; it should NOT enter the + // kill-confirmation prompt even though 'd' alone would. + // `keymap::normal_action` filters out CONTROL-chord presses. + let mut m = Model::new(); + m.sessions = vec![session("a", false)]; + let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL); + let cmd = update(&mut m, Event::Key(ctrl_d)); + // No command from the binding → auto-refresh kicks in. + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn alt_j_does_not_navigate() { + // Alt-J is a chord; it should NOT move the selection. + let mut m = Model::new(); + m.sessions = vec![session("a", false), session("b", false)]; + let alt_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::ALT); + let cmd = update(&mut m, Event::Key(alt_j)); + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.selected, 0); + } + + #[test] + fn ctrl_n_does_not_open_create_mode() { + let mut m = Model::new(); + let ctrl_n = KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL); + let cmd = update(&mut m, Event::Key(ctrl_n)); + assert_eq!(cmd, Some(Command::Refresh)); + assert_eq!(m.mode, Mode::Normal); + } + + #[test] + fn refresh_event_does_not_trigger_another_refresh() { + // Guards against an infinite-refresh loop: SessionsRefreshed + // feeds back into update() but must not itself produce + // Command::Refresh. The `was_keystroke` gate in update.rs + // exists for exactly this reason. + let mut m = Model::new(); + let cmd = update(&mut m, Event::SessionsRefreshed(vec![])); + assert_eq!(cmd, None); + } + + #[test] + fn refresh_failed_event_does_not_trigger_another_refresh() { + // Same invariant for the failure path — otherwise a down + // daemon would produce a tight refresh-loop of failures. + let mut m = Model::new(); + let cmd = update(&mut m, Event::RefreshFailed("boom".into())); + assert_eq!(cmd, None); + } + + #[test] + fn focus_gained_triggers_refresh() { + let mut m = Model::new(); + let cmd = update(&mut m, Event::FocusGained); + assert_eq!(cmd, Some(Command::Refresh)); + } + + #[test] + fn focus_gained_does_not_clear_error() { + // Only keystrokes clear the transient error — an async event + // like FocusGained shouldn't dismiss a message the user + // hasn't acknowledged. + let mut m = Model::new(); + m.set_error("sticky"); + update(&mut m, Event::FocusGained); + assert_eq!(m.error.as_deref(), Some("sticky")); + } + + #[test] + fn typing_in_create_mode_does_not_auto_refresh() { + // Auto-refresh is skipped in modal modes so typing a name + // isn't a per-keystroke socket round-trip. + let mut m = Model::new(); + m.mode = Mode::CreateInput(String::new()); + let cmd = update(&mut m, Event::Key(key(KeyCode::Char('f')))); + assert_eq!(cmd, None); + } + + // Every key advertised in the CONFIRM_HINTS label must actually + // be dispatched (not fall through to the stay-open catch-all). + // Catches drift where the hint promises a key that the match + // arms no longer accept. + fn hint_chars() -> impl Iterator { + CONFIRM_HINTS.iter().flat_map(|(label, _)| { + label.split('/').filter_map(|piece| piece.chars().next()).collect::>() + }) + } + + #[test] + fn confirm_kill_dispatches_every_hint_key() { + for c in hint_chars() { + let mut m = Model::new(); + m.mode = Mode::ConfirmKill("a".into()); + update(&mut m, Event::Key(key(KeyCode::Char(c)))); + assert_ne!( + m.mode, + Mode::ConfirmKill("a".into()), + "hint advertises '{c}' but dispatch leaves ConfirmKill open", + ); + } + } + + #[test] + fn confirm_force_dispatches_every_hint_key() { + for c in hint_chars() { + let mut m = Model::new(); + m.mode = Mode::ConfirmForce("main".into()); + update(&mut m, Event::Key(key(KeyCode::Char(c)))); + assert_ne!( + m.mode, + Mode::ConfirmForce("main".into()), + "hint advertises '{c}' but dispatch leaves ConfirmForce open", + ); + } + } +} diff --git a/libshpool/src/tui/view.rs b/libshpool/src/tui/view.rs new file mode 100644 index 00000000..02521720 --- /dev/null +++ b/libshpool/src/tui/view.rs @@ -0,0 +1,431 @@ +//! The pure view function. Given a Model, paint a Frame. +//! +//! "Pure" here means: no I/O, no terminal side-effects outside what +//! ratatui does via the Frame. That lets us snapshot-test with +//! ratatui's TestBackend — see the tests at the bottom. +//! +//! Layout (unbordered — content fills the screen): +//! +//! ```text +//! shpool sessions (3) <- title (BOLD), droppable on tight screens +//! name created active <- column header (DIM) +//! *>main 3d 2m <- attached + selected (REVERSED via highlight_style) +//! * build 2h 2h <- attached elsewhere (`*` marker) +//! notes 10m 10m <- plain row +//! [j] down [k] up [spc] attach [n] new [d] kill [q] quit <- footer +//! ``` +//! +//! The row prefix is two ASCII columns: col 0 is `*` for sessions +//! attached elsewhere, col 1 is `>` for the currently-selected row. +//! +//! In modal states (CreateInput, ConfirmKill, ConfirmForce) the footer +//! is replaced by a prompt line. A transient `model.error` string +//! overrides all of the above. + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{List, ListItem, ListState, Paragraph}, + Frame, +}; +use shpool_protocol::Session; + +use super::keymap; +use super::model::{is_attached, last_active_unix_ms, Mode, Model}; + +/// Draw one frame. +/// +/// `now_ms` is the current wall-clock time in unix milliseconds. We +/// take it as a parameter so the relative-time rendering ("2m") is +/// deterministic — tests pass a fixed value, production passes +/// `SystemTime::now()` converted to ms. +pub fn view(model: &Model, now_ms: i64, frame: &mut Frame) { + let total_area = frame.area(); + let widths = ColWidths::compute(&model.sessions, now_ms); + + // Adaptive chrome: the title row is the first thing dropped when + // the terminal is tight (< 4 rows). Column header + list + footer + // stay as long as possible. Usable-tui-under-2-rows isn't a real + // scenario so no further cascade. + let show_title = total_area.height >= 4; + + let mut constraints = Vec::with_capacity(4); + if show_title { + constraints.push(Constraint::Length(1)); // title + } + constraints.push(Constraint::Length(1)); // column header + constraints.push(Constraint::Min(1)); // session list + constraints.push(Constraint::Length(1)); // footer + + let chunks = + Layout::default().direction(Direction::Vertical).constraints(constraints).split(total_area); + + let mut i = 0; + if show_title { + render_title(model, frame, chunks[i]); + i += 1; + } + render_column_header(&widths, frame, chunks[i]); + i += 1; + render_sessions(model, now_ms, &widths, frame, chunks[i]); + i += 1; + render_footer(model, frame, chunks[i]); +} + +/// Per-frame column widths: each column is sized to +/// `max(header.len(), widest value)`. Computed per frame so adding +/// or renaming sessions adapts without special-casing. +struct ColWidths { + name: usize, + created: usize, + active: usize, +} + +impl ColWidths { + fn compute(sessions: &[Session], now_ms: i64) -> Self { + let mut widths = + Self { name: "name".len(), created: "created".len(), active: "active".len() }; + for s in sessions { + widths.name = widths.name.max(s.name.chars().count()); + widths.created = + widths.created.max(format_age(now_ms, s.started_at_unix_ms).chars().count()); + widths.active = + widths.active.max(format_age(now_ms, last_active_unix_ms(s)).chars().count()); + } + widths + } +} + +fn render_title(model: &Model, frame: &mut Frame, area: Rect) { + let text = format!(" shpool sessions ({})", model.sessions.len()); + let p = Paragraph::new(Span::styled(text, Style::default().add_modifier(Modifier::BOLD))); + frame.render_widget(p, area); +} + +fn render_column_header(widths: &ColWidths, frame: &mut Frame, area: Rect) { + // 2 leading spaces to line up with the `*>` row prefix. + let text = format!( + " {name: = model + .sessions + .iter() + .enumerate() + .map(|(i, s)| render_row(s, i == model.selected, widths, now_ms)) + .collect(); + + // Stateful render: `ListState` tracks which row is selected; the + // widget scrolls automatically so the selection stays on-screen + // when the list is longer than the visible region. + // + // `highlight_style` applies to the whole selected line — the + // REVERSED modifier makes it pop without needing per-span styling + // inside `render_row`. + let mut state = ListState::default().with_selected(Some(model.selected)); + let list = List::new(items) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD)); + frame.render_stateful_widget(list, area, &mut state); +} + +/// One row in the sessions list. Two-char ASCII prefix, then name +/// padded to the name column width, then two age columns. +fn render_row(s: &Session, selected: bool, widths: &ColWidths, now_ms: i64) -> ListItem<'static> { + let attached_mark = if is_attached(s) { '*' } else { ' ' }; + let selected_mark = if selected { '>' } else { ' ' }; + let created = format_age(now_ms, s.started_at_unix_ms); + let active = format_age(now_ms, last_active_unix_ms(s)); + + // The line is plain-styled; whole-row reverse-video for the + // selected row is applied by `List::highlight_style` above. + let text = format!( + "{a}{sel}{name: Line::from(footer_bindings_normal()), + Mode::CreateInput(buf) => { + // ASCII cursor (`_`) with no blink — some terminals + // render blink poorly or ignore it entirely. + let mut spans = vec![ + Span::styled("new session name: ", bold), + Span::raw(buf.clone()), + Span::raw("_"), + ]; + spans.extend(hint_spans(keymap::CREATE_HINTS)); + Line::from(spans) + } + Mode::ConfirmKill(name) => { + let mut spans = vec![Span::styled(format!("kill session '{name}'? "), bold)]; + spans.extend(hint_spans(keymap::CONFIRM_HINTS)); + Line::from(spans) + } + Mode::ConfirmForce(name) => { + let mut spans = vec![Span::styled( + format!("'{name}' is attached elsewhere — force attach? "), + bold, + )]; + spans.extend(hint_spans(keymap::CONFIRM_HINTS)); + Line::from(spans) + } + }; + + frame.render_widget(Paragraph::new(line), area); +} + +/// Build footer spans for Normal mode by iterating +/// [`super::keymap::NORMAL_BINDINGS`]. This is the view side of the single +/// source of truth — any change to bindings or labels in keymap.rs +/// is automatically reflected here. +fn footer_bindings_normal() -> Vec> { + let bold = Style::default().add_modifier(Modifier::BOLD); + let mut spans = vec![Span::raw(" ")]; + for binding in keymap::NORMAL_BINDINGS { + spans.push(Span::styled(format!("[{}] ", binding.label), bold)); + spans.push(Span::raw(format!("{} ", binding.desc))); + } + spans +} + +/// Render a list of (label, desc) hint pairs as `[label] desc` spans +/// separated by spaces. Used for the modal-mode footers +/// (CreateInput, ConfirmKill, ConfirmForce). +fn hint_spans(hints: &'static [(&str, &str)]) -> Vec> { + let bold = Style::default().add_modifier(Modifier::BOLD); + let mut spans = Vec::new(); + for (label, desc) in hints { + spans.push(Span::raw(" ")); + spans.push(Span::styled(format!("[{label}] "), bold)); + spans.push(Span::raw(*desc)); + } + spans +} + +/// Render a past millisecond-timestamp relative to `now_ms` as a +/// short string: "now", "42s", "13m", "4h", "9d". Past-only — future +/// timestamps (clock skew) render as "now". +/// +/// Short format (no " ago" suffix) to keep the age columns compact. +/// The column headers ("created" / "active") supply the "ago" context. +fn format_age(now_ms: i64, then_ms: i64) -> String { + // All arithmetic in i64 so clock skew doesn't panic. + // `saturating_sub` clamps to 0 if then is in the future. + let delta_s = now_ms.saturating_sub(then_ms) / 1000; + if delta_s < 5 { + "now".to_string() + } else if delta_s < 60 { + format!("{delta_s}s") + } else if delta_s < 3600 { + format!("{}m", delta_s / 60) + } else if delta_s < 86_400 { + format!("{}h", delta_s / 3600) + } else { + format!("{}d", delta_s / 86_400) + } +} + +// --- snapshot tests --- +// +// These use ratatui's TestBackend: an in-memory buffer we can +// compare to a stored golden file. `insta` handles the file I/O + +// review workflow (`cargo insta review` accepts/rejects changes). + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{backend::TestBackend, Terminal}; + use shpool_protocol::SessionStatus; + + fn sess(name: &str, attached: bool, last_active_ms: i64) -> Session { + Session { + name: name.to_string(), + started_at_unix_ms: last_active_ms, + last_connected_at_unix_ms: Some(last_active_ms), + last_disconnected_at_unix_ms: None, + status: if attached { SessionStatus::Attached } else { SessionStatus::Disconnected }, + } + } + + // Fixed "now" used by all view tests so relative-time rendering + // is deterministic. 2026-01-15 22:30 UTC = 1768552200000. + const NOW_MS: i64 = 1_768_552_200_000; + + fn render_to_string(model: &Model, w: u16, h: u16) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| view(model, NOW_MS, f)).unwrap(); + let buffer = terminal.backend().buffer(); + let mut out = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + out.push_str(buffer[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn empty_list_shows_hint() { + let m = Model::new(); + insta::assert_snapshot!(render_to_string(&m, 60, 6)); + } + + #[test] + fn list_with_selection() { + // Build two sessions with different ages so the relative- + // time columns show varied output. "main" was active 2 + // minutes before NOW_MS; "build" 3 hours before. + let mut m = Model::new(); + m.sessions = vec![ + sess("main", true, NOW_MS - 2 * 60 * 1000), + sess("build", false, NOW_MS - 3 * 60 * 60 * 1000), + ]; + m.selected = 1; + insta::assert_snapshot!(render_to_string(&m, 70, 6)); + } + + #[test] + fn confirm_kill_footer() { + let mut m = Model::new(); + m.sessions = vec![sess("main", false, NOW_MS - 10 * 60 * 1000)]; + m.mode = Mode::ConfirmKill("main".into()); + insta::assert_snapshot!(render_to_string(&m, 70, 5)); + } + + #[test] + fn error_replaces_footer() { + let mut m = Model::new(); + m.sessions = vec![sess("main", false, NOW_MS - 30 * 1000)]; + m.set_error("daemon gone"); + insta::assert_snapshot!(render_to_string(&m, 60, 5)); + } + + #[test] + fn confirm_force_footer() { + let mut m = Model::new(); + m.sessions = vec![sess("main", true, NOW_MS - 5 * 60 * 1000)]; + m.mode = Mode::ConfirmForce("main".into()); + insta::assert_snapshot!(render_to_string(&m, 70, 5)); + } + + #[test] + fn create_input_midtyping() { + let mut m = Model::new(); + m.sessions = vec![sess("main", false, NOW_MS - 30 * 1000)]; + m.mode = Mode::CreateInput("foo".into()); + insta::assert_snapshot!(render_to_string(&m, 70, 5)); + } + + #[test] + fn title_dropped_on_tight_screen() { + // height 3 -> title should be dropped (column header + 1 + // list row + footer = 3). If the title is present, the list + // has 0 rows and the selection disappears, so a grep for + // "main" in the output tells us whether the session is + // visible. + let mut m = Model::new(); + m.sessions = vec![sess("main", false, NOW_MS - 30 * 1000)]; + let out = render_to_string(&m, 40, 3); + assert!(out.contains("main"), "session row should be visible at h=3; got:\n{out}"); + assert!(!out.contains("shpool sessions"), "title should be dropped at h=3; got:\n{out}"); + } + + #[test] + fn long_name_expands_column_not_clipped() { + // Dynamic name-column width: a long session name should fit + // without being clipped, and the column header should be + // pushed out correspondingly. Guards against regressions if + // `name_column_width` gets replaced with a fixed constant. + let mut m = Model::new(); + m.sessions = + vec![sess("a", false, NOW_MS), sess("very-long-session-name-here", false, NOW_MS)]; + let rendered = render_to_string(&m, 60, 6); + assert!( + rendered.contains("very-long-session-name-here"), + "long name should not be clipped; got:\n{rendered}" + ); + // The `created` and `active` headers should still be present + // (i.e., the header row wasn't itself truncated). + assert!( + rendered.contains("created") && rendered.contains("active"), + "column headers should survive the wide name; got:\n{rendered}" + ); + } + + #[test] + fn viewport_scrolls_to_keep_selection_visible() { + // 50 sessions, screen height 10. If we select row 40, it + // should still be visible in the rendered output thanks to + // ratatui's ListState scrolling. + let mut m = Model::new(); + m.sessions = (0..50).map(|i| sess(&format!("s{i}"), false, NOW_MS)).collect(); + m.selected = 40; + let rendered = render_to_string(&m, 50, 10); + assert!( + rendered.contains("s40"), + "selected session should stay on screen after scroll; got:\n{rendered}" + ); + } + + #[test] + fn format_age_buckets() { + let now = 1_000_000_000_000i64; + assert_eq!(format_age(now, now), "now"); + assert_eq!(format_age(now, now - 4_000), "now"); + assert_eq!(format_age(now, now - 42_000), "42s"); + assert_eq!(format_age(now, now - 3 * 60 * 1000), "3m"); + assert_eq!(format_age(now, now - 2 * 3600 * 1000), "2h"); + assert_eq!(format_age(now, now - 5 * 86_400 * 1000), "5d"); + // Future timestamps (clock skew) clamp to "now". + assert_eq!(format_age(now, now + 999_999), "now"); + } +}