diff --git a/Cargo.lock b/Cargo.lock index 3b161e2..88c3385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,21 @@ 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_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -291,6 +306,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[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" @@ -330,6 +351,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.54" @@ -425,6 +459,20 @@ dependencies = [ "memchr", ] +[[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 = "compact_str" version = "0.9.0" @@ -488,7 +536,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "windows-sys 0.61.2", ] @@ -561,6 +609,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -583,8 +656,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -600,13 +683,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -740,7 +847,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" dependencies = [ - "darling", + "darling 0.21.3", "proc-macro2", "quote", "syn", @@ -987,6 +1094,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1117,6 +1226,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1243,11 +1376,33 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ "console", "portable-atomic", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "unit-prefix", "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1276,6 +1431,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1376,6 +1540,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1403,6 +1573,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[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 = "lru-slab" version = "0.1.2" @@ -1483,6 +1662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1584,7 +1764,7 @@ checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" dependencies = [ "bytecount", "fnv", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -1610,6 +1790,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -1820,7 +2006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -1926,6 +2112,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str 0.8.1", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2110,6 +2317,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -2119,7 +2339,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -2218,18 +2438,18 @@ checksum = "7dd1415b83de9008573bfc3764c73a6e92167f521ca39e389889a22aa251b679" dependencies = [ "base64ct", "bytes", - "compact_str", + "compact_str 0.9.0", "enum-ordinalize", "flate2", "futures", "http", - "itertools", + "itertools 0.14.0", "mime", "prost", "s2-common", "serde", "serde_json", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "time", "tokio-util", @@ -2244,10 +2464,12 @@ dependencies = [ "async-stream", "base64ct", "bytes", + "chrono", "clap", "color-print", "colored", "config", + "crossterm", "dirs", "futures", "http", @@ -2257,12 +2479,13 @@ dependencies = [ "miette", "predicates", "rand", + "ratatui", "rstest", "s2-sdk", "serde", "serde_json", "serial_test", - "strum", + "strum 0.27.2", "tabled", "tempfile", "thiserror 2.0.18", @@ -2283,12 +2506,12 @@ checksum = "d62d7b095f290ced3bec82814b129efd5fb1d974b950c3ce1e7879e439947d67" dependencies = [ "blake3", "bytes", - "compact_str", + "compact_str 0.9.0", "enum-ordinalize", "enumset", "http", "serde", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "time", ] @@ -2531,6 +2754,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2587,13 +2831,35 @@ 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 = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[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", ] [[package]] @@ -2699,7 +2965,7 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -2709,7 +2975,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix", + "rustix 1.1.3", "windows-sys 0.60.2", ] @@ -2725,7 +2991,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" dependencies = [ - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -2735,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "unicode-linebreak", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -3129,6 +3395,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[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.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -3137,9 +3414,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[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 = "unit-prefix" @@ -3351,6 +3628,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3360,12 +3653,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 48ab618..7d64d31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,12 @@ path = "src/main.rs" async-stream = "0.3.6" base64ct = { version = "1.8.3", features = ["alloc"] } bytes = "1.11.0" +chrono = "0.4" clap = { version = "4.5.54", features = ["derive"] } color-print = "0.3.7" colored = "3.1.1" config = "0.15.19" +crossterm = "0.28" dirs = "6.0.0" futures = "0.3.31" http = "1.4.0" @@ -28,6 +30,7 @@ indicatif = "0.18.3" json_to_table = "0.12.0" miette = { version = "7.6.0", features = ["fancy"] } rand = "0.9.2" +ratatui = "0.29" s2-sdk = { version = "0.23.0", features = ["_hidden"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.149", features = ["preserve_order"] } diff --git a/src/bench.rs b/src/bench.rs index 5618398..397d3c0 100644 --- a/src/bench.rs +++ b/src/bench.rs @@ -34,20 +34,20 @@ const WRITE_DONE_SENTINEL: u64 = u64::MAX; type PendingAck = Pin)> + Send>>; -struct BenchWriteSample { - bytes: u64, - records: u64, - elapsed: Duration, - ack_latencies: Vec, - chain_hash: Option, +pub struct BenchWriteSample { + pub bytes: u64, + pub records: u64, + pub elapsed: Duration, + pub ack_latencies: Vec, + pub chain_hash: Option, } -struct BenchReadSample { - bytes: u64, - records: u64, - elapsed: Duration, - e2e_latencies: Vec, - chain_hash: Option, +pub struct BenchReadSample { + pub bytes: u64, + pub records: u64, + pub elapsed: Duration, + pub e2e_latencies: Vec, + pub chain_hash: Option, } trait BenchSample { @@ -136,7 +136,7 @@ fn chain_hash(prev_hash: u64, body: &[u8]) -> u64 { hasher.digest() } -fn bench_write( +pub fn bench_write( stream: S2Stream, record_size: usize, target_mibps: NonZeroU64, @@ -258,7 +258,7 @@ fn bench_write( } } -fn bench_read( +pub fn bench_read( stream: S2Stream, record_size: usize, write_done_records: Arc, @@ -273,7 +273,7 @@ fn bench_read( ) } -fn bench_read_catchup( +pub fn bench_read_catchup( stream: S2Stream, record_size: usize, bench_start: Instant, @@ -656,19 +656,28 @@ pub async fn run( let mut catchup_chain_hash: Option = None; let catchup_stream = bench_read_catchup(stream.clone(), record_size, bench_start); let mut catchup_stream = std::pin::pin!(catchup_stream); - while let Some(result) = catchup_stream.next().await { - match result { - Ok(sample) => { + let catchup_timeout = Duration::from_secs(300); + let catchup_deadline = tokio::time::Instant::now() + catchup_timeout; + loop { + match tokio::time::timeout_at(catchup_deadline, catchup_stream.next()).await { + Ok(Some(Ok(sample))) => { update_bench_bar(&catchup_bar, &sample); if let Some(hash) = sample.chain_hash { catchup_chain_hash = Some(hash); } catchup_sample = Some(sample); } - Err(e) => { + Ok(Some(Err(e))) => { catchup_bar.finish_and_clear(); return Err(e); } + Ok(None) => break, + Err(_) => { + catchup_bar.finish_and_clear(); + return Err(CliError::BenchVerification( + "catchup read timed out after 5 minutes".to_string(), + )); + } } } @@ -690,14 +699,22 @@ pub async fn run( ); } - if let (Some(write_sample), Some(catchup_sample)) = - (write_sample.as_ref(), catchup_sample.as_ref()) - && write_sample.records != catchup_sample.records - { - return Err(CliError::BenchVerification(format!( - "catchup read record count mismatch: expected {}, got {}", - write_sample.records, catchup_sample.records - ))); + match (write_sample.as_ref(), catchup_sample.as_ref()) { + (Some(write_sample), Some(catchup_sample)) + if write_sample.records != catchup_sample.records => + { + return Err(CliError::BenchVerification(format!( + "catchup read record count mismatch: expected {}, got {}", + write_sample.records, catchup_sample.records + ))); + } + (Some(write_sample), None) if write_sample.records > 0 => { + return Err(CliError::BenchVerification(format!( + "catchup read returned no records but write produced {}", + write_sample.records + ))); + } + _ => {} } if let (Some(expected), Some(actual)) = (write_chain_hash, catchup_chain_hash) diff --git a/src/cli.rs b/src/cli.rs index 4c9c233..97d3f4c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,7 +31,11 @@ const GENERAL_USAGE: &str = color_print::cstr!( #[command(name = "s2", version, override_usage = GENERAL_USAGE, styles = STYLES)] pub struct Cli { #[command(subcommand)] - pub command: Command, + pub command: Option, + + /// Launch interactive TUI mode. + #[arg(short = 'i', long = "interactive")] + pub interactive: bool, } #[derive(Subcommand, Debug)] diff --git a/src/main.rs b/src/main.rs index c7bd43f..8910a96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod config; mod error; mod ops; mod record_format; +mod tui; mod types; use std::pin::Pin; @@ -45,7 +46,7 @@ async fn main() -> miette::Result<()> { } async fn run() -> Result<(), CliError> { - let commands = Cli::try_parse().unwrap_or_else(|e| { + let cli = Cli::try_parse().unwrap_or_else(|e| { // Customize error message for metric commands to say "metric" instead of "subcommand" let msg = e.to_string(); if msg.contains("requires a subcommand") && msg.contains("get-") && msg.contains("-metrics") @@ -59,6 +60,17 @@ async fn run() -> Result<(), CliError> { e.exit() }); + // Launch interactive TUI mode + if cli.interactive { + return tui::run().await; + } + + // Require a command when not in interactive mode + let Some(command) = cli.command else { + eprintln!("No command specified. Use --help for usage or -i for interactive mode."); + std::process::exit(1); + }; + tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() @@ -70,7 +82,7 @@ async fn run() -> Result<(), CliError> { .with(tracing_subscriber::EnvFilter::from_default_env()) .init(); - if let Command::Config(config_cmd) = &commands.command { + if let Command::Config(config_cmd) = &command { match config_cmd { ConfigCommand::List => { let config = load_config_file()?; @@ -112,7 +124,7 @@ async fn run() -> Result<(), CliError> { let sdk_config = sdk_config(&cli_config)?; let s2 = S2::new(sdk_config.clone()).map_err(CliError::SdkInit)?; - match commands.command { + match command { Command::Config(..) => unreachable!(), Command::Ls(args) => { diff --git a/src/ops.rs b/src/ops.rs index 8bd03f4..d4adfdc 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -562,7 +562,11 @@ pub async fn tail( let uri = args.uri.clone(); let stream = s2.basin(uri.basin).stream(uri.stream); - let start = ReadStart::new().with_from(ReadFrom::TailOffset(args.lines)); + // Use clamp_to_tail to handle empty streams gracefully - if we ask for + // TailOffset(10) but there are fewer records, clamp to the actual start + let start = ReadStart::new() + .with_from(ReadFrom::TailOffset(args.lines)) + .with_clamp_to_tail(true); let stop = if args.follow { ReadStop::new() } else { diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..2b9c756 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,7207 @@ +use std::collections::VecDeque; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use chrono::{Datelike, NaiveDate}; + +use base64ct::Encoding; +use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyModifiers}; +use futures::StreamExt; +use ratatui::{Terminal, prelude::Backend}; +use s2_sdk::types::{ + AccessTokenId, AccessTokenInfo, BasinInfo, BasinMetricSet, BasinName, StreamInfo, + StreamMetricSet, StreamName, StreamPosition, TimeRange, +}; +use tokio::sync::mpsc; + +use crate::cli::{ + CreateStreamArgs, IssueAccessTokenArgs, ListAccessTokensArgs, ListBasinsArgs, ListStreamsArgs, + ReadArgs, ReconfigureBasinArgs, ReconfigureStreamArgs, +}; +use crate::config::{self, Compression, ConfigKey}; +use crate::error::CliError; +use crate::ops; +use crate::record_format::{RecordFormat, RecordsOut}; +use crate::types::{ + BasinConfig, DeleteOnEmptyConfig, Operation, RetentionPolicy, S2BasinAndMaybeStreamUri, + S2BasinAndStreamUri, S2BasinUri, StorageClass, StreamConfig, TimestampingConfig, + TimestampingMode, +}; + +use super::event::{ + BasinConfigInfo, BenchFinalStats, BenchPhase, BenchSample, Event, StreamConfigInfo, +}; +use super::ui; + +/// Maximum records to keep in read view buffer +const MAX_RECORDS_BUFFER: usize = 1000; + +/// Maximum throughput history samples to keep (60 seconds at 1 sample/sec) +const MAX_THROUGHPUT_HISTORY: usize = 60; + +/// Splash screen display duration in milliseconds +const SPLASH_DURATION_MS: u64 = 1200; + +/// Target frame interval in milliseconds (~60fps) +const FRAME_INTERVAL_MS: u64 = 16; + +/// Calculate throughput rates from accumulated bytes/records over elapsed time. +/// Returns (MiB/s, records/s). +#[inline] +fn calculate_throughput(bytes: u64, records: u64, elapsed_secs: f64) -> (f64, f64) { + let mibps = (bytes as f64) / (1024.0 * 1024.0) / elapsed_secs; + let recps = (records as f64) / elapsed_secs; + (mibps, recps) +} + +/// Top-level navigation tabs +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Tab { + #[default] + Basins, + AccessTokens, + Settings, +} + +/// Current screen being displayed +#[derive(Debug, Clone)] +pub enum Screen { + Splash, + Setup(SetupState), + Basins(BasinsState), + Streams(StreamsState), + StreamDetail(StreamDetailState), + ReadView(ReadViewState), + AppendView(AppendViewState), + AccessTokens(AccessTokensState), + MetricsView(MetricsViewState), + Settings(SettingsState), + BenchView(BenchViewState), +} + +/// State for the setup screen (first-time token entry) +#[derive(Debug, Clone, Default)] +pub struct SetupState { + pub access_token: String, + pub error: Option, + pub validating: bool, +} + +/// State for the basins list screen +#[derive(Debug, Clone, Default)] +pub struct BasinsState { + pub basins: Vec, + pub selected: usize, + pub loading: bool, + pub filter: String, + pub filter_active: bool, +} + +/// State for the streams list screen +#[derive(Debug, Clone)] +pub struct StreamsState { + pub basin_name: BasinName, + pub streams: Vec, + pub selected: usize, + pub loading: bool, + pub filter: String, + pub filter_active: bool, +} + +/// State for the stream detail screen +#[derive(Debug, Clone)] +pub struct StreamDetailState { + pub basin_name: BasinName, + pub stream_name: StreamName, + pub config: Option, + pub tail_position: Option, + pub selected_action: usize, + pub loading: bool, +} + +/// State for the read/tail view +#[derive(Debug, Clone)] +pub struct ReadViewState { + pub basin_name: BasinName, + pub stream_name: StreamName, + pub records: VecDeque, + pub is_tailing: bool, + pub selected: usize, + pub paused: bool, + pub loading: bool, + pub show_detail: bool, + pub hide_list: bool, + pub output_file: Option, + // Throughput tracking for live sparklines + pub throughput_history: VecDeque, // MiB/s samples + pub records_per_sec_history: VecDeque, // records/s samples + pub current_mibps: f64, + pub current_recps: f64, + pub bytes_this_second: u64, + pub records_this_second: u64, + pub last_tick: Option, + // Timeline scrubber + pub show_timeline: bool, +} + +/// Maximum records to keep in PiP buffer (smaller than main view) +const MAX_PIP_RECORDS: usize = 50; + +/// Picture-in-Picture state for watching a stream while navigating elsewhere +#[derive(Debug, Clone)] +pub struct PipState { + pub basin_name: BasinName, + pub stream_name: StreamName, + pub records: VecDeque, + pub paused: bool, + pub minimized: bool, + // Throughput tracking + pub current_mibps: f64, + pub current_recps: f64, + pub bytes_this_second: u64, + pub records_this_second: u64, + pub last_tick: Option, +} + +/// State for the append view +#[derive(Debug, Clone)] +pub struct AppendViewState { + pub basin_name: BasinName, + pub stream_name: StreamName, + pub body: String, + pub headers: Vec<(String, String)>, // List of (key, value) pairs + pub match_seq_num: String, // Empty = none + pub fencing_token: String, + pub selected: usize, + pub editing: bool, + pub header_key_input: String, // For adding new header + pub header_value_input: String, + pub editing_header_key: bool, + pub history: Vec, + pub appending: bool, + // File append support + pub input_file: String, // Path to file to append from + pub input_format: InputFormat, // Format for file records (text, json, json-base64) + pub file_append_progress: Option<(usize, usize)>, // (done, total) during file append +} + +/// Input format for file append (mirrors CLI's RecordFormat) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InputFormat { + #[default] + Text, + Json, + JsonBase64, +} + +impl InputFormat { + pub fn next(self) -> Self { + match self { + Self::Text => Self::Json, + Self::Json => Self::JsonBase64, + Self::JsonBase64 => Self::Text, + } + } +} + +/// Result of an append operation +#[derive(Debug, Clone)] +pub struct AppendResult { + pub seq_num: u64, + pub body_preview: String, + pub header_count: usize, +} + +/// State for the access tokens list screen +#[derive(Debug, Clone, Default)] +pub struct AccessTokensState { + pub tokens: Vec, + pub selected: usize, + pub loading: bool, + pub filter: String, + pub filter_active: bool, +} + +/// Compression option for settings +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CompressionOption { + #[default] + None, + Gzip, + Zstd, +} + +impl CompressionOption { + pub fn as_str(&self) -> &'static str { + match self { + CompressionOption::None => "None", + CompressionOption::Gzip => "Gzip", + CompressionOption::Zstd => "Zstd", + } + } + + pub fn next(&self) -> Self { + match self { + CompressionOption::None => CompressionOption::Gzip, + CompressionOption::Gzip => CompressionOption::Zstd, + CompressionOption::Zstd => CompressionOption::None, + } + } + + pub fn prev(&self) -> Self { + match self { + CompressionOption::None => CompressionOption::Zstd, + CompressionOption::Gzip => CompressionOption::None, + CompressionOption::Zstd => CompressionOption::Gzip, + } + } +} + +/// State for the settings screen +#[derive(Debug, Clone)] +pub struct SettingsState { + pub access_token: String, + pub access_token_masked: bool, // Whether to show masked or plaintext + pub account_endpoint: String, + pub basin_endpoint: String, + pub compression: CompressionOption, + pub selected: usize, // 0=token, 1=account_endpoint, 2=basin_endpoint, 3=compression + pub editing: bool, + pub has_changes: bool, + pub message: Option, +} + +impl Default for SettingsState { + fn default() -> Self { + Self { + access_token: String::new(), + access_token_masked: true, + account_endpoint: String::new(), + basin_endpoint: String::new(), + compression: CompressionOption::None, + selected: 0, + editing: false, + has_changes: false, + message: None, + } + } +} + +/// Type of metrics being viewed +#[derive(Debug, Clone)] +pub enum MetricsType { + Account, + Basin { + basin_name: BasinName, + }, + Stream { + basin_name: BasinName, + stream_name: StreamName, + }, +} + +/// Which metric is currently selected (for basin/stream) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MetricCategory { + #[default] + Storage, + AppendOps, + ReadOps, + AppendThroughput, + ReadThroughput, + BasinOps, + ActiveBasins, + AccountOps, +} + +impl MetricCategory { + pub fn next(&self) -> Self { + match self { + Self::Storage => Self::AppendOps, + Self::AppendOps => Self::ReadOps, + Self::ReadOps => Self::AppendThroughput, + Self::AppendThroughput => Self::ReadThroughput, + Self::ReadThroughput => Self::BasinOps, + Self::BasinOps => Self::Storage, + Self::ActiveBasins => Self::AccountOps, + Self::AccountOps => Self::ActiveBasins, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::Storage => Self::BasinOps, + Self::AppendOps => Self::Storage, + Self::ReadOps => Self::AppendOps, + Self::AppendThroughput => Self::ReadOps, + Self::ReadThroughput => Self::AppendThroughput, + Self::BasinOps => Self::ReadThroughput, + Self::ActiveBasins => Self::AccountOps, + Self::AccountOps => Self::ActiveBasins, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Storage => "Storage", + Self::AppendOps => "Append Ops", + Self::ReadOps => "Read Ops", + Self::AppendThroughput => "Append Throughput", + Self::ReadThroughput => "Read Throughput", + Self::BasinOps => "Basin Ops", + Self::ActiveBasins => "Active Basins", + Self::AccountOps => "Account Ops", + } + } +} + +/// Time range options for metrics +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TimeRangeOption { + OneHour, + SixHours, + TwelveHours, + #[default] + TwentyFourHours, + ThreeDays, + SevenDays, + ThirtyDays, + Custom { + start: u32, + end: u32, + }, // Unix timestamps +} + +impl TimeRangeOption { + pub const PRESETS: &'static [TimeRangeOption] = &[ + TimeRangeOption::OneHour, + TimeRangeOption::SixHours, + TimeRangeOption::TwelveHours, + TimeRangeOption::TwentyFourHours, + TimeRangeOption::ThreeDays, + TimeRangeOption::SevenDays, + TimeRangeOption::ThirtyDays, + ]; + + pub fn as_str(&self) -> &'static str { + match self { + TimeRangeOption::OneHour => "1h", + TimeRangeOption::SixHours => "6h", + TimeRangeOption::TwelveHours => "12h", + TimeRangeOption::TwentyFourHours => "24h", + TimeRangeOption::ThreeDays => "3d", + TimeRangeOption::SevenDays => "7d", + TimeRangeOption::ThirtyDays => "30d", + TimeRangeOption::Custom { .. } => "Custom", + } + } + + pub fn as_label(&self) -> &'static str { + match self { + TimeRangeOption::OneHour => "Last hour", + TimeRangeOption::SixHours => "Last 6 hours", + TimeRangeOption::TwelveHours => "Last 12 hours", + TimeRangeOption::TwentyFourHours => "Last 24 hours", + TimeRangeOption::ThreeDays => "Last 3 days", + TimeRangeOption::SevenDays => "Last 7 days", + TimeRangeOption::ThirtyDays => "Last 30 days", + TimeRangeOption::Custom { .. } => "Custom range", + } + } + + pub fn as_duration(&self) -> Duration { + match self { + TimeRangeOption::OneHour => Duration::from_secs(60 * 60), + TimeRangeOption::SixHours => Duration::from_secs(6 * 60 * 60), + TimeRangeOption::TwelveHours => Duration::from_secs(12 * 60 * 60), + TimeRangeOption::TwentyFourHours => Duration::from_secs(24 * 60 * 60), + TimeRangeOption::ThreeDays => Duration::from_secs(3 * 24 * 60 * 60), + TimeRangeOption::SevenDays => Duration::from_secs(7 * 24 * 60 * 60), + TimeRangeOption::ThirtyDays => Duration::from_secs(30 * 24 * 60 * 60), + TimeRangeOption::Custom { start, end } => Duration::from_secs((end - start) as u64), + } + } + + pub fn get_range(&self) -> (u32, u32) { + match self { + TimeRangeOption::Custom { start, end } => (*start, *end), + _ => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32; + let range_secs = self.as_duration().as_secs() as u32; + (now.saturating_sub(range_secs), now) + } + } + } + + pub fn next(&self) -> Self { + match self { + TimeRangeOption::OneHour => TimeRangeOption::SixHours, + TimeRangeOption::SixHours => TimeRangeOption::TwelveHours, + TimeRangeOption::TwelveHours => TimeRangeOption::TwentyFourHours, + TimeRangeOption::TwentyFourHours => TimeRangeOption::ThreeDays, + TimeRangeOption::ThreeDays => TimeRangeOption::SevenDays, + TimeRangeOption::SevenDays => TimeRangeOption::ThirtyDays, + TimeRangeOption::ThirtyDays => TimeRangeOption::OneHour, + TimeRangeOption::Custom { .. } => TimeRangeOption::OneHour, + } + } + + pub fn prev(&self) -> Self { + match self { + TimeRangeOption::OneHour => TimeRangeOption::ThirtyDays, + TimeRangeOption::SixHours => TimeRangeOption::OneHour, + TimeRangeOption::TwelveHours => TimeRangeOption::SixHours, + TimeRangeOption::TwentyFourHours => TimeRangeOption::TwelveHours, + TimeRangeOption::ThreeDays => TimeRangeOption::TwentyFourHours, + TimeRangeOption::SevenDays => TimeRangeOption::ThreeDays, + TimeRangeOption::ThirtyDays => TimeRangeOption::SevenDays, + TimeRangeOption::Custom { .. } => TimeRangeOption::ThirtyDays, + } + } +} + +/// State for the metrics view +#[derive(Debug, Clone)] +pub struct MetricsViewState { + pub metrics_type: MetricsType, + pub metrics: Vec, + pub selected_category: MetricCategory, + pub time_range: TimeRangeOption, + pub loading: bool, + pub scroll: usize, + pub time_picker_open: bool, + pub time_picker_selected: usize, + pub calendar_open: bool, + pub calendar_year: i32, + pub calendar_month: u32, + pub calendar_day: u32, // Currently highlighted day + pub calendar_start: Option<(i32, u32, u32)>, // Selected start date (year, month, day) + pub calendar_end: Option<(i32, u32, u32)>, // Selected end date + pub calendar_selecting_end: bool, // true if selecting end date +} + +/// Benchmark configuration phase +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BenchConfigField { + #[default] + RecordSize, + TargetMibps, + Duration, + CatchupDelay, + Start, +} + +impl BenchConfigField { + pub fn next(&self) -> Self { + match self { + Self::RecordSize => Self::TargetMibps, + Self::TargetMibps => Self::Duration, + Self::Duration => Self::CatchupDelay, + Self::CatchupDelay => Self::Start, + Self::Start => Self::RecordSize, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::RecordSize => Self::Start, + Self::TargetMibps => Self::RecordSize, + Self::Duration => Self::TargetMibps, + Self::CatchupDelay => Self::Duration, + Self::Start => Self::CatchupDelay, + } + } +} + +/// State for the benchmark view +#[derive(Debug, Clone)] +pub struct BenchViewState { + pub basin_name: BasinName, + pub config_phase: bool, + pub config_field: BenchConfigField, + pub record_size: u32, // bytes (default 8KB) + pub target_mibps: u64, // MiB/s (default 1) + pub duration_secs: u64, // seconds (default 60) + pub catchup_delay_secs: u64, // seconds (default 20) + pub editing: bool, + pub edit_buffer: String, + pub stream_name: Option, + pub phase: BenchPhase, + pub running: bool, + pub stopping: bool, + pub elapsed_secs: f64, + pub progress_pct: f64, + pub write_mibps: f64, + pub write_recps: f64, + pub write_bytes: u64, + pub write_records: u64, + pub write_history: VecDeque, + pub read_mibps: f64, + pub read_recps: f64, + pub read_bytes: u64, + pub read_records: u64, + pub read_history: VecDeque, + pub catchup_mibps: f64, + pub catchup_recps: f64, + pub catchup_bytes: u64, + pub catchup_records: u64, + pub ack_latency: Option, + pub e2e_latency: Option, + pub error: Option, +} + +impl BenchViewState { + pub fn new(basin_name: BasinName) -> Self { + Self { + basin_name, + config_phase: true, + config_field: BenchConfigField::default(), + record_size: 8 * 1024, // 8 KB + target_mibps: 1, + duration_secs: 60, + catchup_delay_secs: 20, + editing: false, + edit_buffer: String::new(), + stream_name: None, + phase: BenchPhase::Write, + running: false, + stopping: false, + elapsed_secs: 0.0, + progress_pct: 0.0, + write_mibps: 0.0, + write_recps: 0.0, + write_bytes: 0, + write_records: 0, + write_history: VecDeque::new(), + read_mibps: 0.0, + read_recps: 0.0, + read_bytes: 0, + read_records: 0, + read_history: VecDeque::new(), + catchup_mibps: 0.0, + catchup_recps: 0.0, + catchup_bytes: 0, + catchup_records: 0, + ack_latency: None, + e2e_latency: None, + error: None, + } + } +} + +/// Status message level +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageLevel { + Info, + Success, + Error, +} + +/// Status message to display +#[derive(Debug, Clone)] +pub struct StatusMessage { + pub text: String, + pub level: MessageLevel, +} + +/// Input mode for text input dialogs +#[derive(Debug, Clone, Default)] +pub enum InputMode { + /// Not in input mode + #[default] + Normal, + /// Creating a new basin + CreateBasin { + name: String, + scope: BasinScopeOption, + create_stream_on_append: bool, + create_stream_on_read: bool, + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_input: String, + timestamping_mode: Option, + timestamping_uncapped: bool, + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, + selected: usize, + editing: bool, + }, + /// Creating a new stream + CreateStream { + basin: BasinName, + name: String, + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_input: String, + timestamping_mode: Option, + timestamping_uncapped: bool, + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, + selected: usize, + editing: bool, + }, + /// Confirming basin deletion + ConfirmDeleteBasin { basin: BasinName }, + /// Confirming stream deletion + ConfirmDeleteStream { + basin: BasinName, + stream: StreamName, + }, + /// Reconfiguring a basin + ReconfigureBasin { + basin: BasinName, + create_stream_on_append: Option, + create_stream_on_read: Option, + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_secs: u64, + timestamping_mode: Option, + timestamping_uncapped: Option, + selected: usize, + editing_age: bool, + age_input: String, + }, + /// Reconfiguring a stream + ReconfigureStream { + basin: BasinName, + stream: StreamName, + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_secs: u64, + timestamping_mode: Option, + timestamping_uncapped: Option, + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, + selected: usize, + editing_age: bool, + age_input: String, + }, + /// Custom read configuration + CustomRead { + basin: BasinName, + stream: StreamName, + start_from: ReadStartFrom, + seq_num_value: String, + timestamp_value: String, + ago_value: String, + ago_unit: AgoUnit, + tail_offset_value: String, + count_limit: String, + byte_limit: String, + until_timestamp: String, + clamp: bool, + format: ReadFormat, + output_file: String, + selected: usize, + editing: bool, + }, + /// Fence a stream (set new fencing token) + Fence { + basin: BasinName, + stream: StreamName, + new_token: String, + current_token: String, // Empty = no current token + selected: usize, // 0=new_token, 1=current_token, 2=submit + editing: bool, + }, + /// Trim a stream (delete records before seq num) + Trim { + basin: BasinName, + stream: StreamName, + trim_point: String, + fencing_token: String, // Empty = no fencing token + selected: usize, // 0=trim_point, 1=fencing_token, 2=submit + editing: bool, + }, + /// Issue a new access token + IssueAccessToken { + id: String, + expiry: ExpiryOption, + expiry_custom: String, + basins_scope: ScopeOption, + basins_value: String, + streams_scope: ScopeOption, + streams_value: String, + tokens_scope: ScopeOption, + tokens_value: String, + account_read: bool, + account_write: bool, + basin_read: bool, + basin_write: bool, + stream_read: bool, + stream_write: bool, + auto_prefix_streams: bool, + selected: usize, + editing: bool, + }, + /// Confirming access token revocation + ConfirmRevokeToken { token_id: String }, + /// Show issued token (one-time display) + ShowIssuedToken { token: String }, + /// View access token details + ViewTokenDetail { token: AccessTokenInfo }, +} + +/// Retention policy option for UI +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum RetentionPolicyOption { + #[default] + Infinite, + Age, +} + +impl RetentionPolicyOption { + pub fn toggle(&self) -> Self { + match self { + Self::Infinite => Self::Age, + Self::Age => Self::Infinite, + } + } +} + +/// Cycle storage class forward: None -> Standard -> Express -> None +fn storage_class_next(sc: &Option) -> Option { + match sc { + None => Some(StorageClass::Standard), + Some(StorageClass::Standard) => Some(StorageClass::Express), + Some(StorageClass::Express) => None, + } +} + +/// Cycle storage class backward: None -> Express -> Standard -> None +fn storage_class_prev(sc: &Option) -> Option { + match sc { + None => Some(StorageClass::Express), + Some(StorageClass::Standard) => None, + Some(StorageClass::Express) => Some(StorageClass::Standard), + } +} + +/// Cycle timestamping mode forward: None -> ClientPrefer -> ClientRequire -> Arrival -> None +fn timestamping_mode_next(tm: &Option) -> Option { + match tm { + None => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::ClientPrefer) => Some(TimestampingMode::ClientRequire), + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::Arrival), + Some(TimestampingMode::Arrival) => None, + } +} + +/// Cycle timestamping mode backward: None -> Arrival -> ClientRequire -> ClientPrefer -> None +fn timestamping_mode_prev(tm: &Option) -> Option { + match tm { + None => Some(TimestampingMode::Arrival), + Some(TimestampingMode::ClientPrefer) => None, + Some(TimestampingMode::ClientRequire) => Some(TimestampingMode::ClientPrefer), + Some(TimestampingMode::Arrival) => Some(TimestampingMode::ClientRequire), + } +} + +/// Basin scope option for UI (cloud provider/region) +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum BasinScopeOption { + #[default] + AwsUsEast1, +} + +/// Expiry options for access tokens +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ExpiryOption { + #[default] + Never, + OneDay, + SevenDays, + ThirtyDays, + NinetyDays, + OneYear, + Custom, +} + +impl ExpiryOption { + pub fn next(&self) -> Self { + match self { + ExpiryOption::Never => ExpiryOption::OneDay, + ExpiryOption::OneDay => ExpiryOption::SevenDays, + ExpiryOption::SevenDays => ExpiryOption::ThirtyDays, + ExpiryOption::ThirtyDays => ExpiryOption::NinetyDays, + ExpiryOption::NinetyDays => ExpiryOption::OneYear, + ExpiryOption::OneYear => ExpiryOption::Custom, + ExpiryOption::Custom => ExpiryOption::Never, + } + } + + pub fn prev(&self) -> Self { + match self { + ExpiryOption::Never => ExpiryOption::Custom, + ExpiryOption::OneDay => ExpiryOption::Never, + ExpiryOption::SevenDays => ExpiryOption::OneDay, + ExpiryOption::ThirtyDays => ExpiryOption::SevenDays, + ExpiryOption::NinetyDays => ExpiryOption::ThirtyDays, + ExpiryOption::OneYear => ExpiryOption::NinetyDays, + ExpiryOption::Custom => ExpiryOption::OneYear, + } + } + + pub fn duration_str(self) -> Option<&'static str> { + match self { + ExpiryOption::Never => None, + ExpiryOption::OneDay => Some("1d"), + ExpiryOption::SevenDays => Some("7d"), + ExpiryOption::ThirtyDays => Some("30d"), + ExpiryOption::NinetyDays => Some("90d"), + ExpiryOption::OneYear => Some("365d"), + ExpiryOption::Custom => None, + } + } +} + +/// Scope options for resource access +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ScopeOption { + #[default] + All, + Prefix, + Exact, + None, +} + +impl ScopeOption { + pub fn next(&self) -> Self { + match self { + ScopeOption::All => ScopeOption::Prefix, + ScopeOption::Prefix => ScopeOption::Exact, + ScopeOption::Exact => ScopeOption::None, + ScopeOption::None => ScopeOption::All, + } + } + + pub fn prev(&self) -> Self { + match self { + ScopeOption::All => ScopeOption::None, + ScopeOption::Prefix => ScopeOption::All, + ScopeOption::Exact => ScopeOption::Prefix, + ScopeOption::None => ScopeOption::Exact, + } + } +} + +/// Start position for read operation +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ReadStartFrom { + /// From current tail (live follow, no historical) + #[default] + Tail, + /// From specific sequence number + SeqNum, + /// From specific timestamp (ms) + Timestamp, + /// From N time ago + Ago, + /// From N records before tail + TailOffset, +} + +/// Time unit for "ago" option +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum AgoUnit { + Seconds, + #[default] + Minutes, + Hours, + Days, +} + +impl AgoUnit { + pub fn as_seconds(self, value: u64) -> u64 { + match self { + AgoUnit::Seconds => value, + AgoUnit::Minutes => value * 60, + AgoUnit::Hours => value * 3600, + AgoUnit::Days => value * 86400, + } + } + + pub fn next(self) -> Self { + match self { + AgoUnit::Seconds => AgoUnit::Minutes, + AgoUnit::Minutes => AgoUnit::Hours, + AgoUnit::Hours => AgoUnit::Days, + AgoUnit::Days => AgoUnit::Seconds, + } + } +} + +/// Output format for read operation +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ReadFormat { + #[default] + Text, + Json, + JsonBase64, +} + +impl ReadFormat { + pub fn next(&self) -> Self { + match self { + ReadFormat::Text => ReadFormat::Json, + ReadFormat::Json => ReadFormat::JsonBase64, + ReadFormat::JsonBase64 => ReadFormat::Text, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + ReadFormat::Text => "text", + ReadFormat::Json => "json", + ReadFormat::JsonBase64 => "json-base64", + } + } +} +/// Config for basin reconfiguration +#[derive(Debug, Clone)] +pub struct BasinReconfigureConfig { + pub create_stream_on_append: Option, + pub create_stream_on_read: Option, + pub storage_class: Option, + pub retention_policy: RetentionPolicyOption, + pub retention_age_secs: u64, + pub timestamping_mode: Option, + pub timestamping_uncapped: Option, +} + +/// Config for stream reconfiguration +#[derive(Debug, Clone)] +pub struct StreamReconfigureConfig { + pub storage_class: Option, + pub retention_policy: RetentionPolicyOption, + pub retention_age_secs: u64, + pub timestamping_mode: Option, + pub timestamping_uncapped: Option, + pub delete_on_empty_enabled: bool, + pub delete_on_empty_min_age: String, +} + +/// Main application state +pub struct App { + pub screen: Screen, + pub tab: Tab, + pub s2: Option, + pub message: Option, + pub show_help: bool, + pub input_mode: InputMode, + pub pip: Option, + should_quit: bool, + /// Stop signal for the benchmark task + bench_stop_signal: Option>, +} + +/// Build a basin config from form values +#[allow(clippy::too_many_arguments)] +fn build_basin_config( + create_stream_on_append: bool, + create_stream_on_read: bool, + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_input: String, + timestamping_mode: Option, + timestamping_uncapped: bool, + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, +) -> BasinConfig { + let retention = match retention_policy { + RetentionPolicyOption::Infinite => None, + RetentionPolicyOption::Age => humantime::parse_duration(&retention_age_input) + .ok() + .map(RetentionPolicy::Age), + }; + let timestamping = if timestamping_mode.is_some() || timestamping_uncapped { + Some(TimestampingConfig { + timestamping_mode, + timestamping_uncapped: if timestamping_uncapped { + Some(true) + } else { + None + }, + }) + } else { + None + }; + let delete_on_empty = if delete_on_empty_enabled { + humantime::parse_duration(&delete_on_empty_min_age) + .ok() + .map(|d| DeleteOnEmptyConfig { + delete_on_empty_min_age: d, + }) + } else { + None + }; + + BasinConfig { + default_stream_config: StreamConfig { + storage_class, + retention_policy: retention, + timestamping, + delete_on_empty, + }, + create_stream_on_append, + create_stream_on_read, + } +} + +fn build_stream_config( + storage_class: Option, + retention_policy: RetentionPolicyOption, + retention_age_input: String, + timestamping_mode: Option, + timestamping_uncapped: bool, + delete_on_empty_enabled: bool, + delete_on_empty_min_age: String, +) -> StreamConfig { + let retention = match retention_policy { + RetentionPolicyOption::Infinite => None, + RetentionPolicyOption::Age => humantime::parse_duration(&retention_age_input) + .ok() + .map(RetentionPolicy::Age), + }; + let timestamping = if timestamping_mode.is_some() || timestamping_uncapped { + Some(TimestampingConfig { + timestamping_mode, + timestamping_uncapped: if timestamping_uncapped { + Some(true) + } else { + None + }, + }) + } else { + None + }; + let delete_on_empty = if delete_on_empty_enabled { + humantime::parse_duration(&delete_on_empty_min_age) + .ok() + .map(|d| DeleteOnEmptyConfig { + delete_on_empty_min_age: d, + }) + } else { + None + }; + + StreamConfig { + storage_class, + retention_policy: retention, + timestamping, + delete_on_empty, + } +} + +impl App { + pub fn new(s2: Option) -> Self { + let screen = if s2.is_some() { + Screen::Splash + } else { + Screen::Setup(SetupState::default()) + }; + Self { + screen, + tab: Tab::Basins, + s2, + message: None, + show_help: false, + input_mode: InputMode::Normal, + pip: None, + should_quit: false, + bench_stop_signal: None, + } + } + + /// Create an S2 client from the given access token + fn create_s2_client(access_token: &str) -> Result { + let sdk_config = s2_sdk::types::S2Config::new(access_token) + .with_user_agent("s2-cli") + .map_err(|e| CliError::EndpointsFromEnv(e.to_string()))? + .with_request_timeout(Duration::from_secs(30)); + s2_sdk::S2::new(sdk_config).map_err(CliError::SdkInit) + } + + /// Load settings from config file + fn load_settings_state() -> SettingsState { + let file_config = config::load_config_file().unwrap_or_default(); + + let env_config = config::load_cli_config().unwrap_or_default(); + let access_token = file_config + .access_token + .clone() + .or_else(|| env_config.access_token.clone()) + .unwrap_or_default(); + let token_from_env = + file_config.access_token.is_none() && env_config.access_token.is_some(); + + SettingsState { + access_token, + access_token_masked: true, + account_endpoint: file_config.account_endpoint.unwrap_or_default(), + basin_endpoint: file_config.basin_endpoint.unwrap_or_default(), + compression: match file_config.compression { + Some(Compression::Gzip) => CompressionOption::Gzip, + Some(Compression::Zstd) => CompressionOption::Zstd, + None => CompressionOption::None, + }, + selected: 0, + editing: false, + has_changes: false, + message: if token_from_env { + Some("Token loaded from S2_ACCESS_TOKEN env var".to_string()) + } else { + None + }, + } + } + + /// Save settings to config file + fn save_settings_static(state: &SettingsState) -> Result<(), CliError> { + let mut cli_config = config::load_config_file().unwrap_or_default(); + if state.access_token.is_empty() { + cli_config.unset(ConfigKey::AccessToken); + } else { + cli_config + .set(ConfigKey::AccessToken, state.access_token.clone()) + .map_err(CliError::Config)?; + } + if state.account_endpoint.is_empty() { + cli_config.unset(ConfigKey::AccountEndpoint); + } else { + cli_config + .set(ConfigKey::AccountEndpoint, state.account_endpoint.clone()) + .map_err(CliError::Config)?; + } + if state.basin_endpoint.is_empty() { + cli_config.unset(ConfigKey::BasinEndpoint); + } else { + cli_config + .set(ConfigKey::BasinEndpoint, state.basin_endpoint.clone()) + .map_err(CliError::Config)?; + } + match state.compression { + CompressionOption::None => cli_config.unset(ConfigKey::Compression), + CompressionOption::Gzip => { + cli_config + .set(ConfigKey::Compression, "gzip".to_string()) + .map_err(CliError::Config)?; + } + CompressionOption::Zstd => { + cli_config + .set(ConfigKey::Compression, "zstd".to_string()) + .map_err(CliError::Config)?; + } + } + + config::save_cli_config(&cli_config).map_err(CliError::Config)?; + Ok(()) + } + + pub async fn run(mut self, terminal: &mut Terminal) -> Result<(), CliError> { + let (tx, mut rx) = mpsc::unbounded_channel(); + let splash_start = std::time::Instant::now(); + let splash_duration = Duration::from_millis(SPLASH_DURATION_MS); + if self.s2.is_some() { + self.load_basins(tx.clone()); + } + let mut pending_basins: Option, CliError>> = None; + + loop { + terminal + .draw(|f| ui::draw(f, &self)) + .map_err(|e| CliError::RecordWrite(format!("Failed to draw: {e}")))?; + if matches!(self.screen, Screen::Splash) && splash_start.elapsed() >= splash_duration { + let mut basins_state = BasinsState { + loading: pending_basins.is_none(), + ..Default::default() + }; + if let Some(result) = pending_basins.take() { + match result { + Ok(basins) => { + basins_state.basins = basins; + basins_state.loading = false; + } + Err(e) => { + basins_state.loading = false; + self.message = Some(StatusMessage { + text: format!("Failed to load basins: {e}"), + level: MessageLevel::Error, + }); + } + } + } + self.screen = Screen::Basins(basins_state); + } + + // Always check keyboard input first (non-blocking) to ensure responsiveness + // even when async events are flooding in + if event::poll(Duration::from_millis(0)) + .map_err(|e| CliError::RecordWrite(format!("Failed to poll events: {e}")))? + && let CrosstermEvent::Key(key) = event::read() + .map_err(|e| CliError::RecordWrite(format!("Failed to read event: {e}")))? + { + if matches!(self.screen, Screen::Splash) { + let mut basins_state = BasinsState { + loading: pending_basins.is_none(), + ..Default::default() + }; + if let Some(result) = pending_basins.take() { + match result { + Ok(basins) => { + basins_state.basins = basins; + basins_state.loading = false; + } + Err(e) => { + basins_state.loading = false; + self.message = Some(StatusMessage { + text: format!("Failed to load basins: {e}"), + level: MessageLevel::Error, + }); + } + } + } + self.screen = Screen::Basins(basins_state); + continue; + } + self.handle_key(key, tx.clone()); + } + if self.should_quit { + break; + } + + // Handle async events from background tasks with a short timeout + tokio::select! { + Some(event) = rx.recv() => { + + if matches!(self.screen, Screen::Splash) + && let Event::BasinsLoaded(result) = event { + pending_basins = Some(result); + continue; + } + self.handle_event(event); + } + _ = tokio::time::sleep(Duration::from_millis(FRAME_INTERVAL_MS)) => {} + } + + if self.should_quit { + break; + } + } + + Ok(()) + } + + fn handle_event(&mut self, event: Event) { + match event { + Event::BasinsLoaded(result) => { + if let Screen::Basins(state) = &mut self.screen { + state.loading = false; + match result { + Ok(basins) => { + state.basins = basins; + self.message = Some(StatusMessage { + text: format!("Loaded {} basins", state.basins.len()), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load basins: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::StreamsLoaded(result) => { + if let Screen::Streams(state) = &mut self.screen { + state.loading = false; + match result { + Ok(streams) => { + state.streams = streams; + self.message = Some(StatusMessage { + text: format!("Loaded {} streams", state.streams.len()), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load streams: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::StreamConfigLoaded(result) => { + if let Screen::StreamDetail(state) = &mut self.screen { + state.loading = false; + match result { + Ok(config) => { + state.config = Some(config); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load config: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::TailPositionLoaded(result) => { + if let Screen::StreamDetail(state) = &mut self.screen { + match result { + Ok(pos) => { + state.tail_position = Some(pos); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load tail position: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::RecordReceived(result) => { + if let Screen::ReadView(state) = &mut self.screen { + state.loading = false; + match result { + Ok(record) => { + if !state.paused { + // Deduplicate by seq_num - skip if we already have this or a later record + let dominated = state + .records + .back() + .map(|last| record.seq_num <= last.seq_num) + .unwrap_or(false); + if dominated { + return; + } + + // Track throughput + let record_bytes = record.body.len() as u64; + state.bytes_this_second += record_bytes; + state.records_this_second += 1; + + // Check if a second has passed + if let Some(last_tick) = state.last_tick { + let elapsed = last_tick.elapsed(); + if elapsed >= std::time::Duration::from_secs(1) { + let (mibps, recps) = calculate_throughput( + state.bytes_this_second, + state.records_this_second, + elapsed.as_secs_f64(), + ); + + state.current_mibps = mibps; + state.current_recps = recps; + state.throughput_history.push_back(mibps); + state.records_per_sec_history.push_back(recps); + + if state.throughput_history.len() > MAX_THROUGHPUT_HISTORY { + state.throughput_history.pop_front(); + } + if state.records_per_sec_history.len() + > MAX_THROUGHPUT_HISTORY + { + state.records_per_sec_history.pop_front(); + } + + state.bytes_this_second = 0; + state.records_this_second = 0; + state.last_tick = Some(std::time::Instant::now()); + } + } + + state.records.push_back(record); + + while state.records.len() > MAX_RECORDS_BUFFER { + state.records.pop_front(); + + if state.selected > 0 { + state.selected = state.selected.saturating_sub(1); + } + } + + if state.is_tailing { + state.selected = state.records.len().saturating_sub(1); + } + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Read error: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::ReadEnded => { + if let Screen::ReadView(state) = &mut self.screen { + state.loading = false; + if !state.is_tailing { + self.message = Some(StatusMessage { + text: "Read complete".to_string(), + level: MessageLevel::Info, + }); + } + } + } + + Event::PipRecordReceived(result) => { + if let Some(ref mut pip) = self.pip + && !pip.paused + { + match result { + Ok(record) => { + // Deduplicate by seq_num + let dominated = pip + .records + .back() + .map(|last| record.seq_num <= last.seq_num) + .unwrap_or(false); + if dominated { + return; + } + + // Track throughput + let record_bytes = record.body.len() as u64; + pip.bytes_this_second += record_bytes; + pip.records_this_second += 1; + + // Check if a second has passed + if let Some(last_tick) = pip.last_tick { + let elapsed = last_tick.elapsed(); + if elapsed >= std::time::Duration::from_secs(1) { + let (mibps, recps) = calculate_throughput( + pip.bytes_this_second, + pip.records_this_second, + elapsed.as_secs_f64(), + ); + pip.current_mibps = mibps; + pip.current_recps = recps; + pip.bytes_this_second = 0; + pip.records_this_second = 0; + pip.last_tick = Some(std::time::Instant::now()); + } + } + + pip.records.push_back(record); + + // Keep PiP buffer small + while pip.records.len() > MAX_PIP_RECORDS { + pip.records.pop_front(); + } + } + Err(_) => { + // Silently ignore errors in PiP to not disrupt main workflow + } + } + } + } + + Event::PipReadEnded => { + // PiP stream ended - could happen if stream is deleted + if self.pip.is_some() { + self.pip = None; + self.message = Some(StatusMessage { + text: "PiP stream ended".to_string(), + level: MessageLevel::Info, + }); + } + } + + Event::BasinCreated(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(basin) => { + self.message = Some(StatusMessage { + text: format!("Created basin '{}'", basin.name), + level: MessageLevel::Success, + }); + + if let Screen::Basins(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to create basin: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::BasinDeleted(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(name) => { + self.message = Some(StatusMessage { + text: format!("Deleted basin '{}'", name), + level: MessageLevel::Success, + }); + + if let Screen::Basins(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to delete basin: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamCreated(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(stream) => { + self.message = Some(StatusMessage { + text: format!("Created stream '{}'", stream.name), + level: MessageLevel::Success, + }); + + if let Screen::Streams(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to create stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamDeleted(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(name) => { + self.message = Some(StatusMessage { + text: format!("Deleted stream '{}'", name), + level: MessageLevel::Success, + }); + + if let Screen::Streams(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to delete stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::BasinConfigLoaded(result) => { + if let InputMode::ReconfigureBasin { + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + age_input, + .. + } = &mut self.input_mode + { + match result { + Ok(info) => { + *create_stream_on_append = Some(info.create_stream_on_append); + *create_stream_on_read = Some(info.create_stream_on_read); + *storage_class = info.storage_class; + if let Some(age) = info.retention_age_secs { + *retention_policy = RetentionPolicyOption::Age; + *retention_age_secs = age; + *age_input = age.to_string(); + } else { + *retention_policy = RetentionPolicyOption::Infinite; + } + *timestamping_mode = info.timestamping_mode; + *timestamping_uncapped = Some(info.timestamping_uncapped); + } + Err(e) => { + self.input_mode = InputMode::Normal; + self.message = Some(StatusMessage { + text: format!("Failed to load basin config: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::StreamConfigForReconfigLoaded(result) => { + if let InputMode::ReconfigureStream { + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + age_input, + .. + } = &mut self.input_mode + { + match result { + Ok(info) => { + *storage_class = info.storage_class; + if let Some(age) = info.retention_age_secs { + *retention_policy = RetentionPolicyOption::Age; + *retention_age_secs = age; + *age_input = age.to_string(); + } else { + *retention_policy = RetentionPolicyOption::Infinite; + } + *timestamping_mode = info.timestamping_mode; + *timestamping_uncapped = Some(info.timestamping_uncapped); + + if let Some(min_age_secs) = info.delete_on_empty_min_age_secs { + *delete_on_empty_enabled = true; + *delete_on_empty_min_age = format!("{}s", min_age_secs); + } else { + *delete_on_empty_enabled = false; + } + } + Err(e) => { + self.input_mode = InputMode::Normal; + self.message = Some(StatusMessage { + text: format!("Failed to load stream config: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::BasinReconfigured(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(()) => { + self.message = Some(StatusMessage { + text: "Basin reconfigured".to_string(), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to reconfigure basin: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamReconfigured(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(()) => { + self.message = Some(StatusMessage { + text: "Stream reconfigured".to_string(), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to reconfigure stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamFenced(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(token) => { + self.message = Some(StatusMessage { + text: format!("Stream fenced with token: {}", token), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to fence stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::StreamTrimmed(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok((trim_point, new_tail)) => { + self.message = Some(StatusMessage { + text: format!("Trimmed to {} (tail: {})", trim_point, new_tail), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to trim stream: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::RecordAppended(result) => { + if let Screen::AppendView(state) = &mut self.screen { + state.appending = false; + match result { + Ok((seq_num, body_preview, header_count)) => { + state.history.push(AppendResult { + seq_num, + body_preview, + header_count, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Append failed: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::FileAppendProgress { + appended, + total, + last_seq, + } => { + if let Screen::AppendView(state) = &mut self.screen { + state.file_append_progress = Some((appended, total)); + if let Some(seq) = last_seq { + // Add to history as we go + state.history.push(AppendResult { + seq_num: seq, + body_preview: format!("batch #{}", appended), + header_count: 0, + }); + } + } + } + + Event::FileAppendComplete(result) => { + if let Screen::AppendView(state) = &mut self.screen { + state.appending = false; + state.file_append_progress = None; + match result { + Ok((total, first_seq, last_seq)) => { + state.input_file.clear(); + self.message = Some(StatusMessage { + text: format!( + "Appended {} records (seq {}..{})", + total, first_seq, last_seq + ), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("File append failed: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::AccessTokensLoaded(result) => { + if let Screen::AccessTokens(state) = &mut self.screen { + state.loading = false; + match result { + Ok(tokens) => { + state.tokens = tokens; + self.message = Some(StatusMessage { + text: format!("Loaded {} access tokens", state.tokens.len()), + level: MessageLevel::Success, + }); + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load access tokens: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::AccessTokenIssued(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(token) => { + self.input_mode = InputMode::ShowIssuedToken { + token: token.clone(), + }; + self.message = Some(StatusMessage { + text: "Access token issued - copy it now, it won't be shown again!" + .to_string(), + level: MessageLevel::Success, + }); + + if let Screen::AccessTokens(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to issue access token: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::AccessTokenRevoked(result) => { + self.input_mode = InputMode::Normal; + match result { + Ok(id) => { + self.message = Some(StatusMessage { + text: format!("Revoked access token '{}'", id), + level: MessageLevel::Success, + }); + + if let Screen::AccessTokens(state) = &mut self.screen { + state.loading = true; + } + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to revoke access token: {e}"), + level: MessageLevel::Error, + }); + } + } + } + + Event::AccountMetricsLoaded(result) => { + if let Screen::MetricsView(state) = &mut self.screen { + state.loading = false; + match result { + Ok(metrics) => { + state.metrics = metrics; + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load account metrics: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::BasinMetricsLoaded(result) => { + if let Screen::MetricsView(state) = &mut self.screen { + state.loading = false; + match result { + Ok(metrics) => { + state.metrics = metrics; + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load basin metrics: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::StreamMetricsLoaded(result) => { + if let Screen::MetricsView(state) = &mut self.screen { + state.loading = false; + match result { + Ok(metrics) => { + state.metrics = metrics; + } + Err(e) => { + self.message = Some(StatusMessage { + text: format!("Failed to load stream metrics: {e}"), + level: MessageLevel::Error, + }); + } + } + } + } + + Event::Error(e) => { + self.message = Some(StatusMessage { + text: e.to_string(), + level: MessageLevel::Error, + }); + } + + Event::BenchStreamCreated(result) => { + if let Screen::BenchView(state) = &mut self.screen { + match result { + Ok(stream_name) => { + state.stream_name = Some(stream_name); + state.running = true; + self.message = Some(StatusMessage { + text: "Benchmark started".to_string(), + level: MessageLevel::Info, + }); + } + Err(e) => { + state.error = Some(e.to_string()); + state.running = false; + } + } + } + } + + Event::BenchWriteSample(sample) => { + if let Screen::BenchView(state) = &mut self.screen { + state.write_mibps = sample.mib_per_sec; + state.write_recps = sample.records_per_sec; + state.write_bytes = sample.bytes; + state.write_records = sample.records; + state.elapsed_secs = sample.elapsed.as_secs_f64(); + state.progress_pct = + ((state.elapsed_secs / state.duration_secs as f64) * 100.0).min(100.0); + + state.write_history.push_back(sample.mib_per_sec); + if state.write_history.len() > 60 { + state.write_history.pop_front(); + } + } + } + + Event::BenchReadSample(sample) => { + if let Screen::BenchView(state) = &mut self.screen { + state.read_mibps = sample.mib_per_sec; + state.read_recps = sample.records_per_sec; + state.read_bytes = sample.bytes; + state.read_records = sample.records; + state.elapsed_secs = sample.elapsed.as_secs_f64(); + + state.read_history.push_back(sample.mib_per_sec); + if state.read_history.len() > 60 { + state.read_history.pop_front(); + } + } + } + + Event::BenchCatchupSample(sample) => { + if let Screen::BenchView(state) = &mut self.screen { + state.catchup_mibps = sample.mib_per_sec; + state.catchup_recps = sample.records_per_sec; + state.catchup_bytes = sample.bytes; + state.catchup_records = sample.records; + state.elapsed_secs = sample.elapsed.as_secs_f64(); + } + } + + Event::BenchPhaseComplete(phase) => { + if let Screen::BenchView(state) = &mut self.screen { + state.phase = match phase { + BenchPhase::Write => BenchPhase::Read, + BenchPhase::Read => BenchPhase::CatchupWait, + BenchPhase::CatchupWait => BenchPhase::Catchup, + BenchPhase::Catchup => BenchPhase::Catchup, // Final + }; + } + } + + Event::BenchComplete(result) => { + if let Screen::BenchView(state) = &mut self.screen { + state.running = false; + match result { + Ok(stats) => { + state.ack_latency = stats.ack_latency; + state.e2e_latency = stats.e2e_latency; + self.message = Some(StatusMessage { + text: "Benchmark complete!".to_string(), + level: MessageLevel::Success, + }); + } + Err(e) => { + state.error = Some(e.to_string()); + } + } + } + } + } + } + + fn handle_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + self.message = None; + if !matches!(self.input_mode, InputMode::Normal) { + self.handle_input_key(key, tx); + return; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc if self.show_help => { + self.show_help = false; + return; + } + KeyCode::Char('?') => { + self.show_help = !self.show_help; + return; + } + KeyCode::Char('P') => { + // Toggle PiP visibility or close it + if let Some(ref mut pip) = self.pip { + if pip.minimized { + // Restore minimized PiP + pip.minimized = false; + } else { + // Close PiP entirely + self.pip = None; + self.message = Some(StatusMessage { + text: "PiP closed".to_string(), + level: MessageLevel::Info, + }); + } + } + return; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + return; + } + KeyCode::Char('q') if !matches!(self.screen, Screen::Basins(_)) => {} + KeyCode::Char('q') => { + self.should_quit = true; + return; + } + _ => {} + } + + if self.show_help { + return; + } + if key.code == KeyCode::Tab { + match &self.screen { + Screen::Basins(_) | Screen::AccessTokens(_) | Screen::Settings(_) => { + self.switch_tab(tx.clone()); + return; + } + _ => {} + } + } + match &self.screen { + Screen::Splash => {} // Keys handled in run loop + Screen::Setup(_) => self.handle_setup_key(key, tx), + Screen::Basins(_) => self.handle_basins_key(key, tx), + Screen::Streams(_) => self.handle_streams_key(key, tx), + Screen::StreamDetail(_) => self.handle_stream_detail_key(key, tx), + Screen::ReadView(_) => self.handle_read_view_key(key, tx), + Screen::AppendView(_) => self.handle_append_view_key(key, tx), + Screen::AccessTokens(_) => self.handle_access_tokens_key(key, tx), + Screen::MetricsView(_) => self.handle_metrics_view_key(key, tx), + Screen::Settings(_) => self.handle_settings_key(key, tx), + Screen::BenchView(_) => self.handle_bench_view_key(key, tx), + } + } + + fn handle_input_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + // Handle IssueAccessToken submit separately to avoid borrow issues. + // We need to extract values before calling the method since the match arm + // holds borrows that conflict with the method call. + if matches!(key.code, KeyCode::Char(' ') | KeyCode::Enter) + && let InputMode::IssueAccessToken { + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + selected, + editing, + } = &self.input_mode + && *selected == 16 + && !*editing + && !id.is_empty() + { + let id = id.clone(); + let expiry = *expiry; + let expiry_custom = expiry_custom.clone(); + let basins_scope = *basins_scope; + let basins_value = basins_value.clone(); + let streams_scope = *streams_scope; + let streams_value = streams_value.clone(); + let tokens_scope = *tokens_scope; + let tokens_value = tokens_value.clone(); + let account_read = *account_read; + let account_write = *account_write; + let basin_read = *basin_read; + let basin_write = *basin_write; + let stream_read = *stream_read; + let stream_write = *stream_write; + let auto_prefix_streams = *auto_prefix_streams; + self.issue_access_token_v2( + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + tx, + ); + return; + } + + match &mut self.input_mode { + InputMode::Normal => {} + + InputMode::CreateBasin { + name, + scope, + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_input, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + selected, + editing, + } => { + const FIELD_COUNT: usize = 12; + + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => { + if *selected == 0 { + name.pop(); + } else if *selected == 4 { + retention_age_input.pop(); + } else if *selected == 8 { + delete_on_empty_min_age.pop(); + } + } + KeyCode::Char(c) => { + if *selected == 0 { + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' { + name.push(c); + } + } else if *selected == 4 { + if c.is_ascii_alphanumeric() { + retention_age_input.push(c); + } + } else if *selected == 8 && c.is_ascii_alphanumeric() { + delete_on_empty_min_age.push(c); + } + } + _ => {} + } + } else { + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + + if *selected == 8 && !*delete_on_empty_enabled { + *selected = 7; + } + + if *selected == 4 && *retention_policy != RetentionPolicyOption::Age + { + *selected = 3; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < FIELD_COUNT - 1 { + *selected += 1; + + if *selected == 4 && *retention_policy != RetentionPolicyOption::Age + { + *selected = 5; + } + + if *selected == 8 && !*delete_on_empty_enabled { + *selected = 9; + } + } + } + KeyCode::Enter => { + match *selected { + 0 => *editing = true, // Edit name + 4 => { + if *retention_policy == RetentionPolicyOption::Age { + *editing = true; // Edit retention age + } + } + 8 => { + if *delete_on_empty_enabled { + *editing = true; // Edit delete-on-empty min age + } + } + 11 => { + if name.len() >= 8 { + let basin_name = name.clone(); + let basin_scope = *scope; + let csoa = *create_stream_on_append; + let csor = *create_stream_on_read; + let sc = storage_class.clone(); + let rp = *retention_policy; + let rai = retention_age_input.clone(); + let tm = timestamping_mode.clone(); + let tu = *timestamping_uncapped; + let doe = *delete_on_empty_enabled; + let doema = delete_on_empty_min_age.clone(); + + let config = build_basin_config( + csoa, csor, sc, rp, rai, tm, tu, doe, doema, + ); + self.create_basin_with_config( + basin_name, + basin_scope, + config, + tx.clone(), + ); + } + } + _ => {} + } + } + KeyCode::Char(' ') => match *selected { + 6 => *timestamping_uncapped = !*timestamping_uncapped, + 9 => *create_stream_on_append = !*create_stream_on_append, + 10 => *create_stream_on_read = !*create_stream_on_read, + _ => {} + }, + KeyCode::Left | KeyCode::Char('h') => match *selected { + 2 => *storage_class = storage_class_prev(storage_class), + 3 => *retention_policy = retention_policy.toggle(), + 5 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 7 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match *selected { + 2 => *storage_class = storage_class_next(storage_class), + 3 => *retention_policy = retention_policy.toggle(), + 5 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 7 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + _ => {} + } + } + } + + InputMode::CreateStream { + basin, + name, + storage_class, + retention_policy, + retention_age_input, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + selected, + editing, + } => { + const FIELD_COUNT: usize = 9; + + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => { + if *selected == 0 { + name.pop(); + } else if *selected == 3 { + retention_age_input.pop(); + } else if *selected == 7 { + delete_on_empty_min_age.pop(); + } + } + KeyCode::Char(c) => { + if *selected == 0 { + name.push(c); + } else if *selected == 3 { + if c.is_ascii_alphanumeric() { + retention_age_input.push(c); + } + } else if *selected == 7 && c.is_ascii_alphanumeric() { + delete_on_empty_min_age.push(c); + } + } + _ => {} + } + } else { + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + + if *selected == 7 && !*delete_on_empty_enabled { + *selected = 6; + } + + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age + { + *selected = 2; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < FIELD_COUNT - 1 { + *selected += 1; + + if *selected == 3 && *retention_policy != RetentionPolicyOption::Age + { + *selected = 4; + } + + if *selected == 7 && !*delete_on_empty_enabled { + *selected = 8; + } + } + } + KeyCode::Enter => { + match *selected { + 0 => *editing = true, // Edit name + 3 => { + if *retention_policy == RetentionPolicyOption::Age { + *editing = true; // Edit retention age + } + } + 7 => { + if *delete_on_empty_enabled { + *editing = true; // Edit delete-on-empty min age + } + } + 8 => { + if !name.is_empty() { + let basin_name = basin.clone(); + let stream_name = name.clone(); + let sc = storage_class.clone(); + let rp = *retention_policy; + let rai = retention_age_input.clone(); + let tm = timestamping_mode.clone(); + let tu = *timestamping_uncapped; + let doe = *delete_on_empty_enabled; + let doema = delete_on_empty_min_age.clone(); + + let config = + build_stream_config(sc, rp, rai, tm, tu, doe, doema); + self.create_stream_with_config( + basin_name, + stream_name, + config, + tx.clone(), + ); + } + } + _ => {} + } + } + KeyCode::Char(' ') => { + if *selected == 5 { + *timestamping_uncapped = !*timestamping_uncapped; + } + } + KeyCode::Left | KeyCode::Char('h') => match *selected { + 1 => *storage_class = storage_class_prev(storage_class), + 2 => *retention_policy = retention_policy.toggle(), + 4 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match *selected { + 1 => *storage_class = storage_class_next(storage_class), + 2 => *retention_policy = retention_policy.toggle(), + 4 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 6 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + _ => {} + } + } + } + + InputMode::ConfirmDeleteBasin { basin } => match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; + } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let basin = basin.clone(); + self.delete_basin(basin, tx.clone()); + } + _ => {} + }, + + InputMode::ConfirmDeleteStream { basin, stream } => match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; + } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let basin = basin.clone(); + let stream = stream.clone(); + self.delete_stream(basin, stream, tx.clone()); + } + _ => {} + }, + + InputMode::ReconfigureBasin { + basin, + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + selected, + editing_age, + age_input, + } => { + const BASIN_MAX_ROW: usize = 6; + if *editing_age { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + if let Ok(secs) = age_input.parse::() { + *retention_age_secs = secs; + } + *editing_age = false; + } + KeyCode::Backspace => { + age_input.pop(); + } + KeyCode::Char(c) if c.is_ascii_digit() => { + age_input.push(c); + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { + *selected = 1; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < BASIN_MAX_ROW { + *selected += 1; + + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { + *selected = 3; + } + } + } + KeyCode::Char(' ') => match *selected { + 4 => *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)), + 5 => { + *create_stream_on_append = + Some(!create_stream_on_append.unwrap_or(false)) + } + 6 => *create_stream_on_read = Some(!create_stream_on_read.unwrap_or(false)), + _ => {} + }, + KeyCode::Enter => { + if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { + *editing_age = true; + *age_input = retention_age_secs.to_string(); + } + } + KeyCode::Left | KeyCode::Char('h') => match *selected { + 0 => *storage_class = storage_class_prev(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match *selected { + 0 => *storage_class = storage_class_next(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + _ => {} + }, + KeyCode::Char('s') => { + let b = basin.clone(); + let config = BasinReconfigureConfig { + create_stream_on_append: *create_stream_on_append, + create_stream_on_read: *create_stream_on_read, + storage_class: storage_class.clone(), + retention_policy: *retention_policy, + retention_age_secs: *retention_age_secs, + timestamping_mode: timestamping_mode.clone(), + timestamping_uncapped: *timestamping_uncapped, + }; + self.reconfigure_basin(b, config, tx.clone()); + } + _ => {} + } + } + + InputMode::ReconfigureStream { + basin, + stream, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + selected, + editing_age, + age_input, + } => { + if *editing_age { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + if *selected == 2 { + if let Ok(secs) = age_input.parse::() { + *retention_age_secs = secs; + } + } else if *selected == 6 { + } + *editing_age = false; + } + KeyCode::Backspace => { + if *selected == 2 { + age_input.pop(); + } else if *selected == 6 { + delete_on_empty_min_age.pop(); + } + } + KeyCode::Char(c) => { + if *selected == 2 && c.is_ascii_digit() { + age_input.push(c); + } else if *selected == 6 && c.is_ascii_alphanumeric() { + delete_on_empty_min_age.push(c); + } + } + _ => {} + } + return; + } + const STREAM_MAX_ROW: usize = 6; + + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + + if *selected == 6 && !*delete_on_empty_enabled { + *selected = 5; + } + + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { + *selected = 1; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < STREAM_MAX_ROW { + *selected += 1; + + if *selected == 2 && *retention_policy != RetentionPolicyOption::Age { + *selected = 3; + } + + if *selected == 6 && !*delete_on_empty_enabled { + *selected = 5; + } + } + } + KeyCode::Char(' ') => { + if *selected == 4 { + *timestamping_uncapped = Some(!timestamping_uncapped.unwrap_or(false)); + } + } + KeyCode::Enter => { + if *selected == 2 && *retention_policy == RetentionPolicyOption::Age { + *editing_age = true; + *age_input = retention_age_secs.to_string(); + } else if *selected == 6 && *delete_on_empty_enabled { + *editing_age = true; + } + } + KeyCode::Left | KeyCode::Char('h') => match *selected { + 0 => *storage_class = storage_class_prev(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_prev(timestamping_mode), + 5 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match *selected { + 0 => *storage_class = storage_class_next(storage_class), + 1 => *retention_policy = retention_policy.toggle(), + 3 => *timestamping_mode = timestamping_mode_next(timestamping_mode), + 5 => *delete_on_empty_enabled = !*delete_on_empty_enabled, + _ => {} + }, + KeyCode::Char('s') => { + let b = basin.clone(); + let s = stream.clone(); + let config = StreamReconfigureConfig { + storage_class: storage_class.clone(), + retention_policy: *retention_policy, + retention_age_secs: *retention_age_secs, + timestamping_mode: timestamping_mode.clone(), + timestamping_uncapped: *timestamping_uncapped, + delete_on_empty_enabled: *delete_on_empty_enabled, + delete_on_empty_min_age: delete_on_empty_min_age.clone(), + }; + self.reconfigure_stream(b, s, config, tx.clone()); + } + _ => {} + } + } + + InputMode::CustomRead { + basin, + stream, + start_from, + seq_num_value, + timestamp_value, + ago_value, + ago_unit, + tail_offset_value, + count_limit, + byte_limit, + until_timestamp, + clamp, + format, + output_file, + selected, + editing, + } => { + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Tab if *selected == 2 => { + *ago_unit = ago_unit.next(); + } + KeyCode::Backspace => match *selected { + 0 => { + seq_num_value.pop(); + } + 1 => { + timestamp_value.pop(); + } + 2 => { + ago_value.pop(); + } + 3 => { + tail_offset_value.pop(); + } + 4 => { + count_limit.pop(); + } + 5 => { + byte_limit.pop(); + } + 6 => { + until_timestamp.pop(); + } + 9 => { + output_file.pop(); + } + _ => {} + }, + KeyCode::Char(c) if c.is_ascii_digit() => match *selected { + 0 => seq_num_value.push(c), + 1 => timestamp_value.push(c), + 2 => ago_value.push(c), + 3 => tail_offset_value.push(c), + 4 => count_limit.push(c), + 5 => byte_limit.push(c), + 6 => until_timestamp.push(c), + _ => {} + }, + KeyCode::Char(c) if *selected == 9 => { + // Output file accepts any printable char + output_file.push(c); + } + _ => {} + } + return; + } + + // Navigation layout: + const MAX_ROW: usize = 10; + + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < MAX_ROW { + *selected += 1; + } + } + KeyCode::Tab if *selected == 2 => { + // Cycle time unit for ago + *ago_unit = ago_unit.next(); + } + KeyCode::Char(' ') => { + // Space = select/toggle + match *selected { + 0 => *start_from = ReadStartFrom::SeqNum, + 1 => *start_from = ReadStartFrom::Timestamp, + 2 => *start_from = ReadStartFrom::Ago, + 3 => *start_from = ReadStartFrom::TailOffset, + 7 => *clamp = !*clamp, + 8 => *format = format.next(), + _ => {} + } + } + KeyCode::Enter => { + // Enter = select + edit value, toggle, or run + match *selected { + 0 => { + *start_from = ReadStartFrom::SeqNum; + *editing = true; + } + 1 => { + *start_from = ReadStartFrom::Timestamp; + *editing = true; + } + 2 => { + *start_from = ReadStartFrom::Ago; + *editing = true; + } + 3 => { + *start_from = ReadStartFrom::TailOffset; + *editing = true; + } + 4 => *editing = true, // count_limit + 5 => *editing = true, // byte_limit + 6 => *editing = true, // until_timestamp + 7 => *clamp = !*clamp, + 8 => *format = format.next(), + 9 => *editing = true, // output_file + 10 => { + // Start reading - clone all values first + let b = basin.clone(); + let s = stream.clone(); + let sf = *start_from; + let snv = seq_num_value.clone(); + let tsv = timestamp_value.clone(); + let agv = ago_value.clone(); + let agu = *ago_unit; + let tov = tail_offset_value.clone(); + let cl = count_limit.clone(); + let bl = byte_limit.clone(); + let ut = until_timestamp.clone(); + let clp = *clamp; + let fmt = *format; + let of = output_file.clone(); + self.input_mode = InputMode::Normal; + + if !of.is_empty() { + self.message = Some(StatusMessage { + text: format!("Writing to {}", of), + level: MessageLevel::Info, + }); + } + self.start_custom_read( + b, + s, + sf, + snv, + tsv, + agv, + agu, + tov, + cl, + bl, + ut, + clp, + fmt, + of, + tx.clone(), + ); + } + _ => {} + } + } + _ => {} + } + } + + InputMode::Fence { + basin, + stream, + new_token, + current_token, + selected, + editing, + } => { + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => match *selected { + 0 => { + new_token.pop(); + } + 1 => { + current_token.pop(); + } + _ => {} + }, + KeyCode::Char(c) => match *selected { + 0 => new_token.push(c), + 1 => current_token.push(c), + _ => {} + }, + _ => {} + } + return; + } + + // Navigation: 0=new_token, 1=current_token, 2=submit + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < 2 { + *selected += 1; + } + } + KeyCode::Enter => { + match *selected { + 0 | 1 => *editing = true, + 2 => { + // Submit fence + if !new_token.is_empty() { + let b = basin.clone(); + let s = stream.clone(); + let nt = new_token.clone(); + let ct = if current_token.is_empty() { + None + } else { + Some(current_token.clone()) + }; + self.fence_stream(b, s, nt, ct, tx.clone()); + } + } + _ => {} + } + } + _ => {} + } + } + + InputMode::Trim { + basin, + stream, + trim_point, + fencing_token, + selected, + editing, + } => { + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => match *selected { + 0 => { + trim_point.pop(); + } + 1 => { + fencing_token.pop(); + } + _ => {} + }, + KeyCode::Char(c) => match *selected { + 0 if c.is_ascii_digit() => trim_point.push(c), + 1 => fencing_token.push(c), + _ => {} + }, + _ => {} + } + return; + } + + // Navigation: 0=trim_point, 1=fencing_token, 2=submit + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < 2 { + *selected += 1; + } + } + KeyCode::Enter => { + match *selected { + 0 | 1 => *editing = true, + 2 => { + // Submit trim + if let Ok(tp) = trim_point.parse::() { + let b = basin.clone(); + let s = stream.clone(); + let ft = if fencing_token.is_empty() { + None + } else { + Some(fencing_token.clone()) + }; + self.trim_stream(b, s, tp, ft, tx.clone()); + } + } + _ => {} + } + } + _ => {} + } + } + + InputMode::IssueAccessToken { + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + selected, + editing, + } => { + // Fields: 0=id, 1=expiry, 2=expiry_custom, 3=basins_scope, 4=basins_value, + // 5=streams_scope, 6=streams_value, 7=tokens_scope, 8=tokens_value, + // 9=account_read, 10=account_write, 11=basin_read, 12=basin_write, + // 13=stream_read, 14=stream_write, 15=auto_prefix, 16=submit + const MAX_FIELD: usize = 16; + + if *editing { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + *editing = false; + } + KeyCode::Backspace => match *selected { + 0 => { + id.pop(); + } + 2 => { + expiry_custom.pop(); + } + 4 => { + basins_value.pop(); + } + 6 => { + streams_value.pop(); + } + 8 => { + tokens_value.pop(); + } + _ => {} + }, + KeyCode::Char(c) => { + match *selected { + 0 => { + // Token ID: letters, numbers, hyphens, underscores + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + id.push(c); + } + } + 2 => { + // Custom expiry: e.g., "30d", "1w", "24h" + if c.is_ascii_alphanumeric() { + expiry_custom.push(c); + } + } + 4 => basins_value.push(c), + 6 => streams_value.push(c), + 8 => tokens_value.push(c), + _ => {} + } + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc => { + self.input_mode = InputMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { + *selected -= 1; + // Skip value fields if scope doesn't need them + if *selected == 2 && *expiry != ExpiryOption::Custom { + *selected = 1; + } + if *selected == 4 + && !matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) + { + *selected = 3; + } + if *selected == 6 + && !matches!( + streams_scope, + ScopeOption::Prefix | ScopeOption::Exact + ) + { + *selected = 5; + } + if *selected == 8 + && !matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) + { + *selected = 7; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if *selected < MAX_FIELD { + *selected += 1; + // Skip value fields if scope doesn't need them + if *selected == 2 && *expiry != ExpiryOption::Custom { + *selected = 3; + } + if *selected == 4 + && !matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) + { + *selected = 5; + } + if *selected == 6 + && !matches!( + streams_scope, + ScopeOption::Prefix | ScopeOption::Exact + ) + { + *selected = 7; + } + if *selected == 8 + && !matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) + { + *selected = 9; + } + } + } + KeyCode::Left | KeyCode::Right => { + let forward = key.code == KeyCode::Right; + match *selected { + 1 => { + *expiry = if forward { + expiry.next() + } else { + expiry.prev() + } + } + 3 => { + *basins_scope = if forward { + basins_scope.next() + } else { + basins_scope.prev() + } + } + 5 => { + *streams_scope = if forward { + streams_scope.next() + } else { + streams_scope.prev() + } + } + 7 => { + *tokens_scope = if forward { + tokens_scope.next() + } else { + tokens_scope.prev() + } + } + _ => {} + } + } + KeyCode::Char(' ') | KeyCode::Enter => { + match *selected { + // Text inputs + 0 | 2 | 4 | 6 | 8 => *editing = true, + // Cycle options + 1 => *expiry = expiry.next(), + 3 => *basins_scope = basins_scope.next(), + 5 => *streams_scope = streams_scope.next(), + 7 => *tokens_scope = tokens_scope.next(), + // Toggle checkboxes + 9 => *account_read = !*account_read, + 10 => *account_write = !*account_write, + 11 => *basin_read = !*basin_read, + 12 => *basin_write = !*basin_write, + 13 => *stream_read = !*stream_read, + 14 => *stream_write = !*stream_write, + 15 => *auto_prefix_streams = !*auto_prefix_streams, + // Submit case (16) is handled before the match to avoid borrow issues + _ => {} + } + } + _ => {} + } + } + + InputMode::ConfirmRevokeToken { token_id } => match key.code { + KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => { + self.input_mode = InputMode::Normal; + } + KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { + let id = token_id.clone(); + self.revoke_access_token(id, tx.clone()); + } + _ => {} + }, + + InputMode::ShowIssuedToken { .. } => { + // Any key dismisses the token display + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char(_) => { + self.input_mode = InputMode::Normal; + } + _ => {} + } + } + + InputMode::ViewTokenDetail { .. } => { + // Esc or Enter to close detail view + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + self.input_mode = InputMode::Normal; + } + _ => {} + } + } + } + } + + fn handle_basins_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::Basins(state) = &mut self.screen else { + return; + }; + + // Handle filter mode + if state.filter_active { + match key.code { + KeyCode::Esc => { + state.filter_active = false; + state.filter.clear(); + state.selected = 0; + } + KeyCode::Enter => { + state.filter_active = false; + } + KeyCode::Backspace => { + state.filter.pop(); + state.selected = 0; + } + KeyCode::Char(c) => { + state.filter.push(c); + state.selected = 0; + } + _ => {} + } + return; + } + + // Get filtered list length for bounds checking + let filtered: Vec<_> = state + .basins + .iter() + .filter(|b| state.filter.is_empty() || b.name.to_string().contains(&state.filter)) + .collect(); + let filtered_len = filtered.len(); + + match key.code { + KeyCode::Char('/') => { + state.filter_active = true; + } + KeyCode::Up | KeyCode::Char('k') => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if filtered_len > 0 && state.selected < filtered_len - 1 { + state.selected += 1; + } + } + KeyCode::Char('g') => { + state.selected = 0; + } + KeyCode::Char('G') => { + if filtered_len > 0 { + state.selected = filtered_len - 1; + } + } + KeyCode::Enter => { + if let Some(basin) = filtered.get(state.selected) { + let basin_name = basin.name.clone(); + self.screen = Screen::Streams(StreamsState { + basin_name: basin_name.clone(), + streams: Vec::new(), + selected: 0, + loading: true, + filter: String::new(), + filter_active: false, + }); + self.load_streams(basin_name, tx); + } + } + KeyCode::Char('r') => { + state.loading = true; + state.filter.clear(); + state.selected = 0; + self.load_basins(tx); + } + KeyCode::Char('c') => { + self.input_mode = InputMode::CreateBasin { + name: String::new(), + scope: BasinScopeOption::AwsUsEast1, + create_stream_on_append: false, + create_stream_on_read: false, + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_input: "7d".to_string(), + timestamping_mode: None, + timestamping_uncapped: false, + delete_on_empty_enabled: false, + delete_on_empty_min_age: "7d".to_string(), + selected: 0, + editing: false, + }; + } + KeyCode::Char('d') => { + if let Some(basin) = filtered.get(state.selected) { + self.input_mode = InputMode::ConfirmDeleteBasin { + basin: basin.name.clone(), + }; + } + } + KeyCode::Char('e') => { + if let Some(basin) = filtered.get(state.selected) { + let basin_name = basin.name.clone(); + self.input_mode = InputMode::ReconfigureBasin { + basin: basin_name.clone(), + create_stream_on_append: None, + create_stream_on_read: None, + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_secs: 604800, // 1 week default + timestamping_mode: None, + timestamping_uncapped: None, + selected: 0, + editing_age: false, + age_input: String::new(), + }; + // Load current config + self.load_basin_config(basin_name, tx); + } + } + KeyCode::Char('M') => { + // Basin Metrics for selected basin + if let Some(basin) = filtered.get(state.selected) { + let basin_name = basin.name.clone(); + self.open_basin_metrics(basin_name, tx); + } + } + KeyCode::Char('A') => { + // Account Metrics + self.open_account_metrics(tx); + } + KeyCode::Char('B') => { + // Benchmark on selected basin + if let Some(basin) = filtered.get(state.selected) { + let basin_name = basin.name.clone(); + self.screen = Screen::BenchView(BenchViewState::new(basin_name)); + } + } + KeyCode::Esc => { + if !state.filter.is_empty() { + state.filter.clear(); + state.selected = 0; + } + } + _ => {} + } + } + + fn handle_streams_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::Streams(state) = &mut self.screen else { + return; + }; + + // Handle filter mode + if state.filter_active { + match key.code { + KeyCode::Esc => { + state.filter_active = false; + state.filter.clear(); + state.selected = 0; + } + KeyCode::Enter => { + state.filter_active = false; + } + KeyCode::Backspace => { + state.filter.pop(); + state.selected = 0; + } + KeyCode::Char(c) => { + state.filter.push(c); + state.selected = 0; + } + _ => {} + } + return; + } + + // Get filtered list length for bounds checking + let filtered: Vec<_> = state + .streams + .iter() + .filter(|s| state.filter.is_empty() || s.name.to_string().contains(&state.filter)) + .collect(); + let filtered_len = filtered.len(); + + match key.code { + KeyCode::Char('/') => { + state.filter_active = true; + } + KeyCode::Esc => { + if !state.filter.is_empty() { + state.filter.clear(); + state.selected = 0; + } else { + self.screen = Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }); + self.load_basins(tx); + } + } + KeyCode::Char('q') => { + self.screen = Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }); + self.load_basins(tx); + } + KeyCode::Up | KeyCode::Char('k') => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if filtered_len > 0 && state.selected < filtered_len - 1 { + state.selected += 1; + } + } + KeyCode::Char('g') => { + state.selected = 0; + } + KeyCode::Char('G') => { + if filtered_len > 0 { + state.selected = filtered_len - 1; + } + } + KeyCode::Enter => { + if let Some(stream) = filtered.get(state.selected) { + let stream_name = stream.name.clone(); + let basin_name = state.basin_name.clone(); + self.screen = Screen::StreamDetail(StreamDetailState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + config: None, + tail_position: None, + selected_action: 0, + loading: true, + }); + self.load_stream_detail(basin_name, stream_name, tx); + } + } + KeyCode::Char('r') => { + let basin_name = state.basin_name.clone(); + state.loading = true; + state.filter.clear(); + state.selected = 0; + self.load_streams(basin_name, tx); + } + KeyCode::Char('c') => { + self.input_mode = InputMode::CreateStream { + basin: state.basin_name.clone(), + name: String::new(), + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_input: "7d".to_string(), + timestamping_mode: None, + timestamping_uncapped: false, + delete_on_empty_enabled: false, + delete_on_empty_min_age: "7d".to_string(), + selected: 0, + editing: false, + }; + } + KeyCode::Char('d') => { + if let Some(stream) = filtered.get(state.selected) { + self.input_mode = InputMode::ConfirmDeleteStream { + basin: state.basin_name.clone(), + stream: stream.name.clone(), + }; + } + } + KeyCode::Char('e') => { + if let Some(stream) = filtered.get(state.selected) { + let basin_name = state.basin_name.clone(); + let stream_name = stream.name.clone(); + self.input_mode = InputMode::ReconfigureStream { + basin: basin_name.clone(), + stream: stream_name.clone(), + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_secs: 604800, + timestamping_mode: None, + timestamping_uncapped: None, + delete_on_empty_enabled: false, + delete_on_empty_min_age: "7d".to_string(), + selected: 0, + editing_age: false, + age_input: String::new(), + }; + // Load current config + self.load_stream_config_for_reconfig(basin_name, stream_name, tx); + } + } + KeyCode::Char('M') => { + // Basin Metrics + let basin_name = state.basin_name.clone(); + self.open_basin_metrics(basin_name, tx); + } + _ => {} + } + } + + fn handle_stream_detail_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::StreamDetail(state) = &mut self.screen else { + return; + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + let basin_name = state.basin_name.clone(); + self.screen = Screen::Streams(StreamsState { + basin_name: basin_name.clone(), + streams: Vec::new(), + selected: 0, + loading: true, + filter: String::new(), + filter_active: false, + }); + self.load_streams(basin_name, tx); + } + KeyCode::Up | KeyCode::Char('k') => { + if state.selected_action > 0 { + state.selected_action -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if state.selected_action < 4 { + // 5 actions: tail, custom read, append, fence, trim + state.selected_action += 1; + } + } + KeyCode::Enter => { + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + match state.selected_action { + 0 => self.start_tail(basin_name, stream_name, tx), // Tail + 1 => self.open_custom_read_dialog(basin_name, stream_name), // Custom read + 2 => self.open_append_view(basin_name, stream_name), // Append + 3 => self.open_fence_dialog(basin_name, stream_name), // Fence + 4 => self.open_trim_dialog(basin_name, stream_name), // Trim + _ => {} + } + } + KeyCode::Char('t') => { + // Simple tail - s2 read with no flags (live follow from current position) + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.start_tail(basin_name, stream_name, tx); + } + KeyCode::Char('r') => { + // Custom read - open configuration dialog + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_custom_read_dialog(basin_name, stream_name); + } + KeyCode::Char('a') => { + // Append records + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_append_view(basin_name, stream_name); + } + KeyCode::Char('e') => { + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.input_mode = InputMode::ReconfigureStream { + basin: basin_name.clone(), + stream: stream_name.clone(), + storage_class: None, + retention_policy: RetentionPolicyOption::Infinite, + retention_age_secs: 604800, + timestamping_mode: None, + timestamping_uncapped: None, + delete_on_empty_enabled: false, + delete_on_empty_min_age: "7d".to_string(), + selected: 0, + editing_age: false, + age_input: String::new(), + }; + self.load_stream_config_for_reconfig(basin_name, stream_name, tx); + } + KeyCode::Char('f') => { + // Fence stream + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_fence_dialog(basin_name, stream_name); + } + KeyCode::Char('m') => { + // Trim stream + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_trim_dialog(basin_name, stream_name); + } + KeyCode::Char('M') => { + // Stream Metrics + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.open_stream_metrics(basin_name, stream_name, tx); + } + KeyCode::Char('p') => { + // Pin stream to PiP (picture-in-picture) - start tailing in background + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.start_pip(basin_name, stream_name, tx); + self.message = Some(StatusMessage { + text: "Stream pinned to PiP".to_string(), + level: MessageLevel::Success, + }); + } + _ => {} + } + } + + fn handle_read_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::ReadView(state) = &mut self.screen else { + return; + }; + + // If showing detail panel, handle differently + if state.show_detail { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + state.show_detail = false; + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Go back to stream detail and reload data + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.screen = Screen::StreamDetail(StreamDetailState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + config: None, + tail_position: None, + selected_action: 0, + loading: true, + }); + self.load_stream_detail(basin_name, stream_name, tx); + } + KeyCode::Char(' ') => { + state.paused = !state.paused; + self.message = Some(StatusMessage { + text: if state.paused { + "Paused".to_string() + } else { + "Resumed".to_string() + }, + level: MessageLevel::Info, + }); + } + KeyCode::Up | KeyCode::Char('k') => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + let max_idx = state.records.len().saturating_sub(1); + if state.selected < max_idx { + state.selected += 1; + } + } + KeyCode::Char('g') => { + state.selected = 0; + } + KeyCode::Char('G') => { + state.selected = state.records.len().saturating_sub(1); + } + KeyCode::Tab | KeyCode::Char('l') => { + // Toggle list pane visibility + state.hide_list = !state.hide_list; + } + KeyCode::Enter | KeyCode::Char('h') => { + if !state.records.is_empty() { + state.show_detail = true; + } + } + KeyCode::Char('T') => { + // Toggle timeline scrubber + state.show_timeline = !state.show_timeline; + } + KeyCode::Char('[') => { + // Jump backward by 10% of records + let jump = (state.records.len() / 10).max(1); + state.selected = state.selected.saturating_sub(jump); + } + KeyCode::Char(']') => { + // Jump forward by 10% of records + let jump = (state.records.len() / 10).max(1); + let max_idx = state.records.len().saturating_sub(1); + state.selected = (state.selected + jump).min(max_idx); + } + KeyCode::Char('p') => { + // Pin current stream to PiP (picture-in-picture) + if state.is_tailing { + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.start_pip(basin_name, stream_name, tx); + self.message = Some(StatusMessage { + text: "Stream pinned to PiP".to_string(), + level: MessageLevel::Info, + }); + } else { + self.message = Some(StatusMessage { + text: "PiP only available when tailing".to_string(), + level: MessageLevel::Info, + }); + } + } + _ => {} + } + } + + fn load_basins(&self, tx: mpsc::UnboundedSender) { + let Some(s2) = self.s2.clone() else { + let _ = tx.send(Event::BasinsLoaded(Err(CliError::Config( + crate::error::CliConfigError::MissingAccessToken, + )))); + return; + }; + tokio::spawn(async move { + let args = ListBasinsArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + match ops::list_basins(&s2, args).await { + Ok(stream) => { + let basins: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx.send(Event::BasinsLoaded(Ok(basins))); + } + Err(e) => { + let _ = tx.send(Event::BasinsLoaded(Err(e))); + } + } + }); + } + + fn load_streams(&self, basin_name: BasinName, tx: mpsc::UnboundedSender) { + let Some(s2) = self.s2.clone() else { + let _ = tx.send(Event::StreamsLoaded(Err(CliError::Config( + crate::error::CliConfigError::MissingAccessToken, + )))); + return; + }; + tokio::spawn(async move { + let args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin_name, + stream: None, + }, + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + match ops::list_streams(&s2, args).await { + Ok(stream) => { + let streams: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx.send(Event::StreamsLoaded(Ok(streams))); + } + Err(e) => { + let _ = tx.send(Event::StreamsLoaded(Err(e))); + } + } + }); + } + + fn load_stream_detail( + &self, + basin_name: BasinName, + stream_name: StreamName, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let uri = S2BasinAndStreamUri { + basin: basin_name.clone(), + stream: stream_name.clone(), + }; + + // Load config + let tx_config = tx.clone(); + let uri_config = uri.clone(); + let s2_config = s2.clone(); + tokio::spawn(async move { + match ops::get_stream_config(&s2_config, uri_config).await { + Ok(config) => { + let _ = tx_config.send(Event::StreamConfigLoaded(Ok(config.into()))); + } + Err(e) => { + let _ = tx_config.send(Event::StreamConfigLoaded(Err(e))); + } + } + }); + + // Load tail position + let tx_tail = tx; + tokio::spawn(async move { + match ops::check_tail(&s2, uri).await { + Ok(pos) => { + let _ = tx_tail.send(Event::TailPositionLoaded(Ok(pos))); + } + Err(e) => { + let _ = tx_tail.send(Event::TailPositionLoaded(Err(e))); + } + } + }); + } + + fn create_basin_with_config( + &mut self, + name: String, + scope: BasinScopeOption, + config: BasinConfig, + tx: mpsc::UnboundedSender, + ) { + self.input_mode = InputMode::Normal; + let s2 = self.s2.clone().expect("S2 client not initialized"); + let tx_refresh = tx.clone(); + tokio::spawn(async move { + let basin_name: BasinName = match name.parse() { + Ok(n) => n, + Err(e) => { + let _ = tx.send(Event::BasinCreated(Err(CliError::RecordWrite(format!( + "Invalid basin name: {e}" + ))))); + return; + } + }; + let sdk_scope = match scope { + BasinScopeOption::AwsUsEast1 => s2_sdk::types::BasinScope::AwsUsEast1, + }; + let input = s2_sdk::types::CreateBasinInput::new(basin_name) + .with_config(config.into()) + .with_scope(sdk_scope); + + match s2 + .create_basin(input) + .await + .map_err(|e| CliError::op(crate::error::OpKind::CreateBasin, e)) + { + Ok(info) => { + let _ = tx.send(Event::BasinCreated(Ok(info))); + // Trigger refresh + let args = ListBasinsArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_basins(&s2, args).await { + let basins: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::BasinsLoaded(Ok(basins))); + } + } + Err(e) => { + let _ = tx.send(Event::BasinCreated(Err(e))); + } + } + }); + } + + fn delete_basin(&mut self, basin: BasinName, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let tx_refresh = tx.clone(); + let name = basin.to_string(); + tokio::spawn(async move { + match ops::delete_basin(&s2, &basin).await { + Ok(()) => { + let _ = tx.send(Event::BasinDeleted(Ok(name))); + // Trigger refresh + let args = ListBasinsArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_basins(&s2, args).await { + let basins: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::BasinsLoaded(Ok(basins))); + } + } + Err(e) => { + let _ = tx.send(Event::BasinDeleted(Err(e))); + } + } + }); + } + + fn create_stream_with_config( + &mut self, + basin: BasinName, + name: String, + config: StreamConfig, + tx: mpsc::UnboundedSender, + ) { + self.input_mode = InputMode::Normal; + let s2 = self.s2.clone().expect("S2 client not initialized"); + let tx_refresh = tx.clone(); + let basin_clone = basin.clone(); + tokio::spawn(async move { + let stream_name: StreamName = match name.parse() { + Ok(n) => n, + Err(e) => { + let _ = tx.send(Event::StreamCreated(Err(CliError::RecordWrite(format!( + "Invalid stream name: {e}" + ))))); + return; + } + }; + let args = CreateStreamArgs { + uri: S2BasinAndStreamUri { + basin: basin.clone(), + stream: stream_name, + }, + config, + }; + match ops::create_stream(&s2, args).await { + Ok(info) => { + let _ = tx.send(Event::StreamCreated(Ok(info))); + // Trigger refresh + let args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin_clone, + stream: None, + }, + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_streams(&s2, args).await { + let streams: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::StreamsLoaded(Ok(streams))); + } + } + Err(e) => { + let _ = tx.send(Event::StreamCreated(Err(e))); + } + } + }); + } + + fn delete_stream( + &mut self, + basin: BasinName, + stream: StreamName, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let tx_refresh = tx.clone(); + let name = stream.to_string(); + let basin_clone = basin.clone(); + tokio::spawn(async move { + let uri = S2BasinAndStreamUri { + basin: basin.clone(), + stream, + }; + match ops::delete_stream(&s2, uri).await { + Ok(()) => { + let _ = tx.send(Event::StreamDeleted(Ok(name))); + // Trigger refresh + let args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin_clone, + stream: None, + }, + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_streams(&s2, args).await { + let streams: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::StreamsLoaded(Ok(streams))); + } + } + Err(e) => { + let _ = tx.send(Event::StreamDeleted(Err(e))); + } + } + }); + } + + /// Simple tail - like `s2 read` with no flags (live follow from current position) + fn start_tail( + &mut self, + basin_name: BasinName, + stream_name: StreamName, + tx: mpsc::UnboundedSender, + ) { + self.screen = Screen::ReadView(ReadViewState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + records: VecDeque::new(), + is_tailing: true, + selected: 0, + paused: false, + loading: true, + show_detail: false, + hide_list: false, + output_file: None, + throughput_history: VecDeque::new(), + records_per_sec_history: VecDeque::new(), + current_mibps: 0.0, + current_recps: 0.0, + bytes_this_second: 0, + records_this_second: 0, + last_tick: Some(std::time::Instant::now()), + show_timeline: false, + }); + + let s2 = self.s2.clone().expect("S2 client not initialized"); + let uri = S2BasinAndStreamUri { + basin: basin_name, + stream: stream_name, + }; + + tokio::spawn(async move { + // Simple tail: no flags = TailOffset(0) = start at current tail, wait for new records + let args = ReadArgs { + uri, + seq_num: None, + timestamp: None, + ago: None, + tail_offset: None, // Defaults to TailOffset(0) in ops::read + count: None, + bytes: None, + clamp: true, + until: None, + format: RecordFormat::default(), + output: RecordsOut::Stdout, + }; + + match ops::read(&s2, &args).await { + Ok(mut batch_stream) => { + use futures::StreamExt; + while let Some(batch_result) = batch_stream.next().await { + match batch_result { + Ok(batch) => { + for record in batch.records { + if tx.send(Event::RecordReceived(Ok(record))).is_err() { + return; + } + } + } + Err(e) => { + let _ = tx.send(Event::RecordReceived(Err( + crate::error::CliError::op(crate::error::OpKind::Read, e), + ))); + return; + } + } + } + let _ = tx.send(Event::ReadEnded); + } + Err(e) => { + let _ = tx.send(Event::Error(e)); + } + } + }); + } + + /// Start a picture-in-picture tail for a stream + fn start_pip( + &mut self, + basin_name: BasinName, + stream_name: StreamName, + tx: mpsc::UnboundedSender, + ) { + // Initialize PiP state + self.pip = Some(PipState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + records: VecDeque::new(), + paused: false, + minimized: false, + current_mibps: 0.0, + current_recps: 0.0, + bytes_this_second: 0, + records_this_second: 0, + last_tick: Some(std::time::Instant::now()), + }); + + let s2 = self.s2.clone().expect("S2 client not initialized"); + let uri = S2BasinAndStreamUri { + basin: basin_name, + stream: stream_name, + }; + + tokio::spawn(async move { + // Simple tail for PiP: start at current tail, wait for new records + let args = ReadArgs { + uri, + seq_num: None, + timestamp: None, + ago: None, + tail_offset: None, + count: None, + bytes: None, + clamp: true, + until: None, + format: RecordFormat::default(), + output: RecordsOut::Stdout, + }; + + match ops::read(&s2, &args).await { + Ok(mut batch_stream) => { + use futures::StreamExt; + while let Some(batch_result) = batch_stream.next().await { + match batch_result { + Ok(batch) => { + for record in batch.records { + if tx.send(Event::PipRecordReceived(Ok(record))).is_err() { + return; + } + } + } + Err(e) => { + let _ = tx.send(Event::PipRecordReceived(Err( + crate::error::CliError::op(crate::error::OpKind::Read, e), + ))); + return; + } + } + } + let _ = tx.send(Event::PipReadEnded); + } + Err(e) => { + let _ = tx.send(Event::Error(e)); + } + } + }); + } + + /// Open custom read configuration dialog + fn open_custom_read_dialog(&mut self, basin: BasinName, stream: StreamName) { + self.input_mode = InputMode::CustomRead { + basin, + stream, + start_from: ReadStartFrom::SeqNum, // Default to reading from beginning + seq_num_value: "0".to_string(), + timestamp_value: String::new(), + ago_value: "5".to_string(), + ago_unit: AgoUnit::Minutes, + tail_offset_value: "10".to_string(), + count_limit: String::new(), + byte_limit: String::new(), + until_timestamp: String::new(), + clamp: true, + format: ReadFormat::Text, + output_file: String::new(), + selected: 0, + editing: false, + }; + } + + /// Start reading with custom configuration + #[allow(clippy::too_many_arguments)] + fn start_custom_read( + &mut self, + basin_name: BasinName, + stream_name: StreamName, + start_from: ReadStartFrom, + seq_num_value: String, + timestamp_value: String, + ago_value: String, + ago_unit: AgoUnit, + tail_offset_value: String, + count_limit: String, + byte_limit: String, + until_timestamp: String, + clamp: bool, + format: ReadFormat, + output_file: String, + tx: mpsc::UnboundedSender, + ) { + let has_output = !output_file.is_empty(); + self.screen = Screen::ReadView(ReadViewState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + records: VecDeque::new(), + is_tailing: true, + selected: 0, + paused: false, + loading: true, + show_detail: false, + hide_list: false, + output_file: if has_output { + Some(output_file.clone()) + } else { + None + }, + throughput_history: VecDeque::new(), + records_per_sec_history: VecDeque::new(), + current_mibps: 0.0, + current_recps: 0.0, + bytes_this_second: 0, + records_this_second: 0, + last_tick: Some(std::time::Instant::now()), + show_timeline: false, + }); + + let s2 = self.s2.clone().expect("S2 client not initialized"); + let uri = S2BasinAndStreamUri { + basin: basin_name, + stream: stream_name, + }; + + tokio::spawn(async move { + let seq_num = if start_from == ReadStartFrom::SeqNum { + seq_num_value.parse().ok() + } else { + None + }; + + let timestamp = if start_from == ReadStartFrom::Timestamp { + timestamp_value.parse().ok() + } else { + None + }; + + let ago = if start_from == ReadStartFrom::Ago { + ago_value.parse::().ok().map(|v| { + let secs = ago_unit.as_seconds(v); + humantime::Duration::from(std::time::Duration::from_secs(secs)) + }) + } else { + None + }; + + let tail_offset = if start_from == ReadStartFrom::TailOffset { + tail_offset_value.parse().ok() + } else { + None + }; + + let count = count_limit.parse().ok().filter(|&v| v > 0); + let bytes = byte_limit.parse().ok().filter(|&v| v > 0); + let until = until_timestamp.parse().ok().filter(|&v| v > 0); + + let record_format = match format { + ReadFormat::Text => RecordFormat::Text, + ReadFormat::Json => RecordFormat::Json, + ReadFormat::JsonBase64 => RecordFormat::JsonBase64, + }; + + // Set up output file if specified + let output = if output_file.is_empty() { + RecordsOut::Stdout + } else { + RecordsOut::File(std::path::PathBuf::from(&output_file)) + }; + + let args = ReadArgs { + uri, + seq_num, + timestamp, + ago, + tail_offset, + count, + bytes, + clamp, + until, + format: record_format, + output: output.clone(), + }; + + // Open file writer if output file is specified + let mut file_writer: Option = if !output_file.is_empty() { + match tokio::fs::File::create(&output_file).await { + Ok(f) => Some(f), + Err(e) => { + let _ = tx.send(Event::Error(crate::error::CliError::RecordWrite( + e.to_string(), + ))); + return; + } + } + } else { + None + }; + + match ops::read(&s2, &args).await { + Ok(mut batch_stream) => { + use futures::StreamExt; + use tokio::io::AsyncWriteExt; + while let Some(batch_result) = batch_stream.next().await { + match batch_result { + Ok(batch) => { + for record in batch.records { + // Write to file if specified + if let Some(ref mut writer) = file_writer { + let line = match record_format { + RecordFormat::Text => { + format!( + "{}\n", + String::from_utf8_lossy(&record.body) + ) + } + RecordFormat::Json => { + format!( + "{}\n", + serde_json::json!({ + "seq_num": record.seq_num, + "timestamp": record.timestamp, + "headers": record.headers.iter().map(|h| { + serde_json::json!({ + "name": String::from_utf8_lossy(&h.name), + "value": String::from_utf8_lossy(&h.value) + }) + }).collect::>(), + "body": String::from_utf8_lossy(&record.body).to_string() + }) + ) + } + RecordFormat::JsonBase64 => { + format!( + "{}\n", + serde_json::json!({ + "seq_num": record.seq_num, + "timestamp": record.timestamp, + "headers": record.headers.iter().map(|h| { + serde_json::json!({ + "name": String::from_utf8_lossy(&h.name), + "value": String::from_utf8_lossy(&h.value) + }) + }).collect::>(), + "body": base64ct::Base64::encode_string(&record.body) + }) + ) + } + }; + let _ = writer.write_all(line.as_bytes()).await; + } + + if tx.send(Event::RecordReceived(Ok(record))).is_err() { + return; + } + } + } + Err(e) => { + let _ = tx.send(Event::RecordReceived(Err( + crate::error::CliError::op(crate::error::OpKind::Read, e), + ))); + return; + } + } + } + let _ = tx.send(Event::ReadEnded); + } + Err(e) => { + let _ = tx.send(Event::Error(e)); + } + } + }); + } + + fn load_basin_config(&self, basin: BasinName, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + tokio::spawn(async move { + match ops::get_basin_config(&s2, &basin).await { + Ok(config) => { + // Extract default stream config info + let ( + storage_class, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + ) = if let Some(default_config) = &config.default_stream_config { + let sc = default_config.storage_class.map(StorageClass::from); + let age = match default_config.retention_policy { + Some(s2_sdk::types::RetentionPolicy::Age(secs)) => Some(secs), + _ => None, + }; + let ts_mode = default_config + .timestamping + .as_ref() + .and_then(|t| t.mode.map(TimestampingMode::from)); + let ts_uncapped = default_config + .timestamping + .as_ref() + .map(|t| t.uncapped) + .unwrap_or(false); + (sc, age, ts_mode, ts_uncapped) + } else { + (None, None, None, false) + }; + + let info = BasinConfigInfo { + create_stream_on_append: config.create_stream_on_append, + create_stream_on_read: config.create_stream_on_read, + storage_class, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + }; + let _ = tx.send(Event::BasinConfigLoaded(Ok(info))); + } + Err(e) => { + let _ = tx.send(Event::BasinConfigLoaded(Err(e))); + } + } + }); + } + + fn load_stream_config_for_reconfig( + &self, + basin: BasinName, + stream: StreamName, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let uri = S2BasinAndStreamUri { basin, stream }; + tokio::spawn(async move { + match ops::get_stream_config(&s2, uri).await { + Ok(config) => { + let storage_class = config.storage_class.map(StorageClass::from); + let retention_age_secs = match config.retention_policy { + Some(s2_sdk::types::RetentionPolicy::Age(secs)) => Some(secs), + _ => None, + }; + let timestamping_mode = config + .timestamping + .as_ref() + .and_then(|t| t.mode.map(TimestampingMode::from)); + let timestamping_uncapped = config + .timestamping + .as_ref() + .map(|t| t.uncapped) + .unwrap_or(false); + let delete_on_empty_min_age_secs = + config.delete_on_empty.map(|d| d.min_age_secs); + + let info = StreamConfigInfo { + storage_class, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_min_age_secs, + }; + let _ = tx.send(Event::StreamConfigForReconfigLoaded(Ok(info))); + } + Err(e) => { + let _ = tx.send(Event::StreamConfigForReconfigLoaded(Err(e))); + } + } + }); + } + + fn reconfigure_basin( + &mut self, + basin: BasinName, + config: BasinReconfigureConfig, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let tx_refresh = tx.clone(); + tokio::spawn(async move { + let retention_policy = match config.retention_policy { + RetentionPolicyOption::Infinite => Some(crate::types::RetentionPolicy::Infinite), + RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age( + Duration::from_secs(config.retention_age_secs), + )), + }; + + let timestamping = + if config.timestamping_mode.is_some() || config.timestamping_uncapped.is_some() { + Some(crate::types::TimestampingConfig { + timestamping_mode: config.timestamping_mode, + timestamping_uncapped: config.timestamping_uncapped, + }) + } else { + None + }; + + let default_stream_config = StreamConfig { + storage_class: config.storage_class, + retention_policy, + timestamping, + delete_on_empty: None, + }; + + let args = ReconfigureBasinArgs { + basin: S2BasinUri(basin), + create_stream_on_append: config.create_stream_on_append, + create_stream_on_read: config.create_stream_on_read, + default_stream_config, + }; + match ops::reconfigure_basin(&s2, args).await { + Ok(_) => { + let _ = tx.send(Event::BasinReconfigured(Ok(()))); + // Trigger refresh + let args = ListBasinsArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_basins(&s2, args).await { + let basins: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::BasinsLoaded(Ok(basins))); + } + } + Err(e) => { + let _ = tx.send(Event::BasinReconfigured(Err(e))); + } + } + }); + } + + fn reconfigure_stream( + &mut self, + basin: BasinName, + stream: StreamName, + config: StreamReconfigureConfig, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let basin_clone = basin.clone(); + let tx_refresh = tx.clone(); + tokio::spawn(async move { + let retention_policy = match config.retention_policy { + RetentionPolicyOption::Infinite => Some(crate::types::RetentionPolicy::Infinite), + RetentionPolicyOption::Age => Some(crate::types::RetentionPolicy::Age( + Duration::from_secs(config.retention_age_secs), + )), + }; + + let timestamping = + if config.timestamping_mode.is_some() || config.timestamping_uncapped.is_some() { + Some(crate::types::TimestampingConfig { + timestamping_mode: config.timestamping_mode, + timestamping_uncapped: config.timestamping_uncapped, + }) + } else { + None + }; + + let delete_on_empty = if config.delete_on_empty_enabled { + humantime::parse_duration(&config.delete_on_empty_min_age) + .ok() + .map(|d| crate::types::DeleteOnEmptyConfig { + delete_on_empty_min_age: d, + }) + } else { + None + }; + + let args = ReconfigureStreamArgs { + uri: S2BasinAndStreamUri { basin, stream }, + config: StreamConfig { + storage_class: config.storage_class, + retention_policy, + timestamping, + delete_on_empty, + }, + }; + match ops::reconfigure_stream(&s2, args).await { + Ok(_) => { + let _ = tx.send(Event::StreamReconfigured(Ok(()))); + // Trigger refresh + let args = ListStreamsArgs { + uri: S2BasinAndMaybeStreamUri { + basin: basin_clone, + stream: None, + }, + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_streams(&s2, args).await { + let streams: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::StreamsLoaded(Ok(streams))); + } + } + Err(e) => { + let _ = tx.send(Event::StreamReconfigured(Err(e))); + } + } + }); + } + + /// Open the append view + fn open_append_view(&mut self, basin_name: BasinName, stream_name: StreamName) { + self.screen = Screen::AppendView(AppendViewState { + basin_name, + stream_name, + body: String::new(), + headers: Vec::new(), + match_seq_num: String::new(), + fencing_token: String::new(), + selected: 0, + editing: false, + header_key_input: String::new(), + header_value_input: String::new(), + editing_header_key: true, + history: Vec::new(), + appending: false, + input_file: String::new(), + input_format: InputFormat::Text, + file_append_progress: None, + }); + } + + /// Handle keys in append view + /// Layout: 0=body, 1=headers, 2=match_seq, 3=fencing, 4=send + fn handle_append_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::AppendView(state) = &mut self.screen else { + return; + }; + + // Don't handle keys while appending + if state.appending { + return; + } + + // If editing a field, handle text input + if state.editing { + match key.code { + KeyCode::Esc => { + state.editing = false; + } + KeyCode::Enter => { + if state.selected == 1 { + // Headers: if editing key, move to value; if editing value, add header + if state.editing_header_key { + if !state.header_key_input.is_empty() { + state.editing_header_key = false; + } + } else { + if !state.header_key_input.is_empty() { + state.headers.push(( + state.header_key_input.clone(), + state.header_value_input.clone(), + )); + state.header_key_input.clear(); + state.header_value_input.clear(); + state.editing_header_key = true; + } + state.editing = false; + } + } else { + state.editing = false; + } + } + KeyCode::Tab if state.selected == 1 => { + // Toggle between key and value in headers + state.editing_header_key = !state.editing_header_key; + } + KeyCode::Backspace => match state.selected { + 0 => { + state.body.pop(); + } + 1 => { + if state.editing_header_key { + state.header_key_input.pop(); + } else { + state.header_value_input.pop(); + } + } + 2 => { + state.match_seq_num.pop(); + } + 3 => { + state.fencing_token.pop(); + } + 4 => { + state.input_file.pop(); + } + _ => {} + }, + KeyCode::Char(c) => match state.selected { + 0 => { + state.body.push(c); + } + 1 => { + if state.editing_header_key { + state.header_key_input.push(c); + } else { + state.header_value_input.push(c); + } + } + 2 => { + if c.is_ascii_digit() { + state.match_seq_num.push(c); + } + } + 3 => { + state.fencing_token.push(c); + } + 4 => { + state.input_file.push(c); + } + _ => {} + }, + _ => {} + } + return; + } + + // Not editing - handle navigation + match key.code { + KeyCode::Esc => { + // Go back to stream detail + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + self.screen = Screen::StreamDetail(StreamDetailState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + config: None, + tail_position: None, + selected_action: 2, // Append action + loading: true, + }); + self.load_stream_detail(basin_name, stream_name, tx); + } + KeyCode::Char('j') | KeyCode::Down => { + state.selected = (state.selected + 1).min(6); + } + KeyCode::Char('k') | KeyCode::Up => { + state.selected = state.selected.saturating_sub(1); + } + KeyCode::Char('d') if state.selected == 1 => { + state.headers.pop(); + } + // Cycle format with h/l or space when on format field + KeyCode::Char('h') | KeyCode::Char('l') | KeyCode::Char(' ') if state.selected == 5 => { + state.input_format = state.input_format.next(); + } + KeyCode::Enter => { + if state.selected == 6 { + // Send button - check if we have file input or body + if !state.input_file.is_empty() { + // Append from file + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + let file_path = state.input_file.clone(); + let input_format = state.input_format; + let fencing_token = if state.fencing_token.is_empty() { + None + } else { + Some(state.fencing_token.clone()) + }; + state.appending = true; + state.file_append_progress = Some((0, 0)); + self.append_from_file( + basin_name, + stream_name, + file_path, + input_format, + fencing_token, + tx, + ); + } else if !state.body.is_empty() { + // Append single record + let basin_name = state.basin_name.clone(); + let stream_name = state.stream_name.clone(); + let body = state.body.clone(); + let headers = state.headers.clone(); + let match_seq_num = state.match_seq_num.parse::().ok(); + let fencing_token = if state.fencing_token.is_empty() { + None + } else { + Some(state.fencing_token.clone()) + }; + state.body.clear(); + state.appending = true; + self.append_record( + basin_name, + stream_name, + body, + headers, + match_seq_num, + fencing_token, + tx, + ); + } + } else { + // Start editing the selected field + state.editing = true; + if state.selected == 1 { + state.editing_header_key = true; + } + } + } + _ => {} + } + } + + /// Append a single record to the stream + #[allow(clippy::too_many_arguments)] + fn append_record( + &self, + basin_name: BasinName, + stream_name: StreamName, + body: String, + headers: Vec<(String, String)>, + match_seq_num: Option, + fencing_token: Option, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let body_preview = if body.len() > 50 { + format!("{}...", &body[..50]) + } else { + body.clone() + }; + let header_count = headers.len(); + + tokio::spawn(async move { + use s2_sdk::types::{ + AppendInput, AppendRecord, AppendRecordBatch, FencingToken, Header, + }; + + let stream = s2.basin(basin_name).stream(stream_name); + + let mut record = match AppendRecord::new(body.into_bytes()) { + Ok(r) => r, + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); + return; + } + }; + if !headers.is_empty() { + let parsed_headers: Vec
= headers + .into_iter() + .map(|(k, v)| Header::new(k.into_bytes(), v.into_bytes())) + .collect(); + record = match record.with_headers(parsed_headers) { + Ok(r) => r, + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); + return; + } + }; + } + + let records = match AppendRecordBatch::try_from_iter([record]) { + Ok(batch) => batch, + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); + return; + } + }; + + let mut input = AppendInput::new(records); + if let Some(seq) = match_seq_num { + input = input.with_match_seq_num(seq); + } + if let Some(token_str) = fencing_token { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid fencing token: {}", + e + )), + ))); + return; + } + } + } + + match stream.append(input).await { + Ok(output) => { + let _ = tx.send(Event::RecordAppended(Ok(( + output.start.seq_num, + body_preview, + header_count, + )))); + } + Err(e) => { + let _ = tx.send(Event::RecordAppended(Err(crate::error::CliError::op( + crate::error::OpKind::Append, + e, + )))); + } + } + }); + } + + /// Append records from a file (one record per line) + fn append_from_file( + &self, + basin_name: BasinName, + stream_name: StreamName, + file_path: String, + input_format: InputFormat, + fencing_token: Option, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + + tokio::spawn(async move { + use base64ct::{Base64, Encoding}; + use s2_sdk::types::{ + AppendInput, AppendRecord, AppendRecordBatch, FencingToken, Header, + }; + use tokio::io::{AsyncBufReadExt, BufReader}; + + // Open and read the file + let file = match tokio::fs::File::open(&file_path).await { + Ok(f) => f, + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordReaderInit(format!( + "Failed to open file '{}': {}", + file_path, e + )), + ))); + return; + } + }; + + let reader = BufReader::new(file); + let mut lines = reader.lines(); + + // Collect all lines first to get total count + let mut all_lines = Vec::new(); + while let Ok(Some(line)) = lines.next_line().await { + if !line.is_empty() { + all_lines.push(line); + } + } + + let total = all_lines.len(); + if total == 0 { + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordReaderInit( + "File is empty or contains no valid records".to_string(), + ), + ))); + return; + } + + let stream = s2.basin(basin_name).stream(stream_name); + + // Helper to parse a line into an AppendRecord based on format + let parse_line = |line: &str, format: InputFormat| -> Result { + match format { + InputFormat::Text => { + AppendRecord::new(line.as_bytes().to_vec()).map_err(|e| e.to_string()) + } + InputFormat::Json | InputFormat::JsonBase64 => { + // Parse JSON: {"body": "...", "headers": [["key", "value"], ...], "timestamp": ...} + #[derive(serde::Deserialize)] + struct JsonRecord { + #[serde(default)] + body: String, + #[serde(default)] + headers: Vec<(String, String)>, + #[serde(default)] + timestamp: Option, + } + + let parsed: JsonRecord = serde_json::from_str(line) + .map_err(|e| format!("Invalid JSON: {}", e))?; + + // Decode body (base64 if json-base64, otherwise UTF-8) + let body_bytes = if format == InputFormat::JsonBase64 { + Base64::decode_vec(&parsed.body) + .map_err(|_| format!("Invalid base64 in body: {}", parsed.body))? + } else { + parsed.body.into_bytes() + }; + + let mut record = + AppendRecord::new(body_bytes).map_err(|e| e.to_string())?; + + // Add headers + if !parsed.headers.is_empty() { + let headers: Result, String> = parsed + .headers + .into_iter() + .map(|(k, v)| { + let key_bytes = if format == InputFormat::JsonBase64 { + Base64::decode_vec(&k).map_err(|_| { + format!("Invalid base64 in header key: {}", k) + })? + } else { + k.into_bytes() + }; + let val_bytes = if format == InputFormat::JsonBase64 { + Base64::decode_vec(&v).map_err(|_| { + format!("Invalid base64 in header value: {}", v) + })? + } else { + v.into_bytes() + }; + Ok(Header::new(key_bytes, val_bytes)) + }) + .collect(); + record = record.with_headers(headers?).map_err(|e| e.to_string())?; + } + + // Add timestamp if provided + if let Some(ts) = parsed.timestamp { + record = record.with_timestamp(ts); + } + + Ok(record) + } + } + }; + + // Process in batches + let batch_size = 100; + let mut appended = 0; + let mut first_seq: Option = None; + let mut last_seq: u64 = 0; + + for chunk in all_lines.chunks(batch_size) { + // Create records from lines + let records: Result, String> = chunk + .iter() + .map(|line| parse_line(line, input_format)) + .collect(); + + let records = match records { + Ok(r) => r, + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordWrite(format!("Invalid record: {}", e)), + ))); + return; + } + }; + + let batch = match AppendRecordBatch::try_from_iter(records) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordWrite(format!( + "Failed to create batch: {}", + e + )), + ))); + return; + } + }; + + let mut input = AppendInput::new(batch); + + // Apply fencing token if provided + if let Some(ref token_str) = fencing_token { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid fencing token: {}", + e + )), + ))); + return; + } + } + } + + match stream.append(input).await { + Ok(output) => { + if first_seq.is_none() { + first_seq = Some(output.start.seq_num); + } + last_seq = output.end.seq_num; + appended += chunk.len(); + + // Send progress update + let _ = tx.send(Event::FileAppendProgress { + appended, + total, + last_seq: Some(last_seq), + }); + } + Err(e) => { + let _ = tx.send(Event::FileAppendComplete(Err( + crate::error::CliError::op(crate::error::OpKind::Append, e), + ))); + return; + } + } + } + + // Send completion + let _ = tx.send(Event::FileAppendComplete(Ok(( + total, + first_seq.unwrap_or(0), + last_seq, + )))); + }); + } + + /// Open fence dialog + fn open_fence_dialog(&mut self, basin: BasinName, stream: StreamName) { + self.input_mode = InputMode::Fence { + basin, + stream, + new_token: String::new(), + current_token: String::new(), + selected: 0, + editing: false, + }; + } + + /// Open trim dialog + fn open_trim_dialog(&mut self, basin: BasinName, stream: StreamName) { + self.input_mode = InputMode::Trim { + basin, + stream, + trim_point: String::new(), + fencing_token: String::new(), + selected: 0, + editing: false, + }; + } + + /// Fence a stream + fn fence_stream( + &self, + basin: BasinName, + stream: StreamName, + new_token: String, + current_token: Option, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let new_token_clone = new_token.clone(); + + tokio::spawn(async move { + use s2_sdk::types::{AppendInput, AppendRecordBatch, CommandRecord, FencingToken}; + + let stream_client = s2.basin(basin).stream(stream); + let new_fencing_token = match new_token.parse::() { + Ok(token) => token, + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid new fencing token: {}", + e + )), + ))); + return; + } + }; + let command = CommandRecord::fence(new_fencing_token); + let record: s2_sdk::types::AppendRecord = command.into(); + let records = match AppendRecordBatch::try_from_iter([record]) { + Ok(batch) => batch, + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); + return; + } + }; + + let mut input = AppendInput::new(records); + if let Some(token_str) = current_token + && !token_str.is_empty() + { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid current fencing token: {}", + e + )), + ))); + return; + } + } + } + + match stream_client.append(input).await { + Ok(_) => { + let _ = tx.send(Event::StreamFenced(Ok(new_token_clone))); + } + Err(e) => { + let _ = tx.send(Event::StreamFenced(Err(crate::error::CliError::op( + crate::error::OpKind::Fence, + e, + )))); + } + } + }); + } + + /// Trim a stream + fn trim_stream( + &self, + basin: BasinName, + stream: StreamName, + trim_point: u64, + fencing_token: Option, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + + tokio::spawn(async move { + use s2_sdk::types::{AppendInput, AppendRecordBatch, CommandRecord, FencingToken}; + + let stream_client = s2.basin(basin).stream(stream); + let command = CommandRecord::trim(trim_point); + let record: s2_sdk::types::AppendRecord = command.into(); + let records = match AppendRecordBatch::try_from_iter([record]) { + Ok(batch) => batch, + Err(e) => { + let _ = tx.send(Event::StreamTrimmed(Err( + crate::error::CliError::RecordWrite(e.to_string()), + ))); + return; + } + }; + + let mut input = AppendInput::new(records); + if let Some(token_str) = fencing_token + && !token_str.is_empty() + { + match token_str.parse::() { + Ok(token) => { + input = input.with_fencing_token(token); + } + Err(e) => { + let _ = tx.send(Event::StreamTrimmed(Err( + crate::error::CliError::RecordWrite(format!( + "Invalid fencing token: {}", + e + )), + ))); + return; + } + } + } + + match stream_client.append(input).await { + Ok(output) => { + let _ = tx.send(Event::StreamTrimmed(Ok((trim_point, output.tail.seq_num)))); + } + Err(e) => { + let _ = tx.send(Event::StreamTrimmed(Err(crate::error::CliError::op( + crate::error::OpKind::Trim, + e, + )))); + } + } + }); + } + + /// Switch between tabs + fn switch_tab(&mut self, tx: mpsc::UnboundedSender) { + match self.tab { + Tab::Basins => { + self.tab = Tab::AccessTokens; + self.screen = Screen::AccessTokens(AccessTokensState { + loading: true, + ..Default::default() + }); + self.load_access_tokens(tx); + } + Tab::AccessTokens => { + self.tab = Tab::Settings; + self.screen = Screen::Settings(Self::load_settings_state()); + } + Tab::Settings => { + self.tab = Tab::Basins; + self.screen = Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }); + self.load_basins(tx); + } + } + } + + /// Handle keys on access tokens screen + fn handle_access_tokens_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::AccessTokens(state) = &mut self.screen else { + return; + }; + + // Handle filter mode + if state.filter_active { + match key.code { + KeyCode::Esc => { + state.filter_active = false; + state.filter.clear(); + state.selected = 0; + } + KeyCode::Enter => { + state.filter_active = false; + } + KeyCode::Backspace => { + state.filter.pop(); + state.selected = 0; + } + KeyCode::Char(c) => { + state.filter.push(c); + state.selected = 0; + } + _ => {} + } + return; + } + + // Get filtered tokens for navigation + let filtered_tokens: Vec<_> = state + .tokens + .iter() + .filter(|t| { + state.filter.is_empty() + || t.id + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) + .collect(); + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + } + KeyCode::Char('j') | KeyCode::Down => { + if !filtered_tokens.is_empty() && state.selected < filtered_tokens.len() - 1 { + state.selected += 1; + } + } + KeyCode::Char('k') | KeyCode::Up => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Char('g') | KeyCode::Home => { + state.selected = 0; + } + KeyCode::Char('G') | KeyCode::End => { + if !filtered_tokens.is_empty() { + state.selected = filtered_tokens.len() - 1; + } + } + KeyCode::Char('/') => { + state.filter_active = true; + } + KeyCode::Char('c') => { + self.input_mode = InputMode::IssueAccessToken { + id: String::new(), + expiry: ExpiryOption::ThirtyDays, + expiry_custom: String::new(), + basins_scope: ScopeOption::All, + basins_value: String::new(), + streams_scope: ScopeOption::All, + streams_value: String::new(), + tokens_scope: ScopeOption::All, + tokens_value: String::new(), + account_read: true, + account_write: false, + basin_read: true, + basin_write: false, + stream_read: true, + stream_write: false, + auto_prefix_streams: false, + selected: 0, + editing: false, + }; + } + KeyCode::Char('d') => { + if let Some(token) = filtered_tokens.get(state.selected) { + self.input_mode = InputMode::ConfirmRevokeToken { + token_id: token.id.to_string(), + }; + } + } + KeyCode::Char('r') => { + state.loading = true; + self.load_access_tokens(tx); + } + KeyCode::Char('i') | KeyCode::Enter => { + // View token details + if let Some(token) = filtered_tokens.get(state.selected) { + self.input_mode = InputMode::ViewTokenDetail { + token: (*token).clone(), + }; + } + } + _ => {} + } + } + + /// Handle keys on setup screen (first-time token entry) + fn handle_setup_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::Setup(state) = &mut self.screen else { + return; + }; + + match key.code { + KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Esc => { + self.should_quit = true; + } + KeyCode::Enter => { + if state.access_token.is_empty() { + state.error = Some("Access token is required".to_string()); + return; + } + + // Try to create S2 client with the token + state.validating = true; + state.error = None; + + match Self::create_s2_client(&state.access_token) { + Ok(s2) => { + // Save the token to config + if let Err(e) = config::set_config_value( + ConfigKey::AccessToken, + state.access_token.clone(), + ) { + state.error = Some(format!("Failed to save config: {}", e)); + state.validating = false; + return; + } + + // Set the S2 client and transition to basins + self.s2 = Some(s2); + self.screen = Screen::Basins(BasinsState { + loading: true, + ..Default::default() + }); + self.load_basins(tx); + self.message = Some(StatusMessage { + text: "Access token configured successfully".to_string(), + level: MessageLevel::Success, + }); + } + Err(e) => { + state.error = Some(format!("Invalid token: {}", e)); + state.validating = false; + } + } + } + KeyCode::Backspace => { + state.access_token.pop(); + state.error = None; + } + KeyCode::Char(c) => { + state.access_token.push(c); + state.error = None; + } + _ => {} + } + } + + /// Handle keys on settings screen + fn handle_settings_key(&mut self, key: KeyEvent, _tx: mpsc::UnboundedSender) { + let Screen::Settings(state) = &mut self.screen else { + return; + }; + + // Handle editing mode + if state.editing { + match key.code { + KeyCode::Esc => { + state.editing = false; + } + KeyCode::Enter => { + state.editing = false; + state.has_changes = true; + } + KeyCode::Backspace => { + match state.selected { + 0 => { + state.access_token.pop(); + } + 1 => { + state.account_endpoint.pop(); + } + 2 => { + state.basin_endpoint.pop(); + } + _ => {} + } + state.has_changes = true; + } + KeyCode::Char(c) => { + match state.selected { + 0 => state.access_token.push(c), + 1 => state.account_endpoint.push(c), + 2 => state.basin_endpoint.push(c), + _ => {} + } + state.has_changes = true; + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + } + KeyCode::Char('j') | KeyCode::Down => { + if state.selected < 4 { + // 0=token, 1=account, 2=basin, 3=compression, 4=save + state.selected += 1; + } + } + KeyCode::Char('k') | KeyCode::Up => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::Char('e') | KeyCode::Enter if state.selected < 3 => { + state.editing = true; + } + KeyCode::Char('h') | KeyCode::Left if state.selected == 3 => { + // Cycle compression option backwards + state.compression = state.compression.prev(); + state.has_changes = true; + } + KeyCode::Char('l') | KeyCode::Right if state.selected == 3 => { + // Cycle compression option forwards + state.compression = state.compression.next(); + state.has_changes = true; + } + KeyCode::Char(' ') if state.selected == 0 => { + // Toggle token visibility + state.access_token_masked = !state.access_token_masked; + } + KeyCode::Enter if state.selected == 4 => { + // Save settings - clone state to avoid borrow issues + let state_clone = state.clone(); + match Self::save_settings_static(&state_clone) { + Err(e) => { + state.message = Some(format!("Failed to save: {}", e)); + } + Ok(()) => { + state.has_changes = false; + state.message = Some("Settings saved successfully".to_string()); + + // If access token changed, recreate S2 client + if !state.access_token.is_empty() { + match Self::create_s2_client(&state.access_token) { + Ok(s2) => { + self.s2 = Some(s2); + } + Err(e) => { + state.message = + Some(format!("Token saved but client error: {}", e)); + } + } + } + } + } + } + KeyCode::Char('r') => { + // Reload settings from file + *state = Self::load_settings_state(); + state.message = Some("Settings reloaded".to_string()); + } + _ => {} + } + } + + /// Load access tokens + fn load_access_tokens(&self, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + tokio::spawn(async move { + let args = ListAccessTokensArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + match ops::list_access_tokens(&s2, args).await { + Ok(stream) => { + let tokens: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx.send(Event::AccessTokensLoaded(Ok(tokens))); + } + Err(e) => { + let _ = tx.send(Event::AccessTokensLoaded(Err(e))); + } + } + }); + } + + /// Issue a new access token (v2 with full options) + #[allow(clippy::too_many_arguments)] + fn issue_access_token_v2( + &self, + id: String, + expiry: ExpiryOption, + expiry_custom: String, + basins_scope: ScopeOption, + basins_value: String, + streams_scope: ScopeOption, + streams_value: String, + tokens_scope: ScopeOption, + tokens_value: String, + account_read: bool, + account_write: bool, + basin_read: bool, + basin_write: bool, + stream_read: bool, + stream_write: bool, + auto_prefix_streams: bool, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let tx_refresh = tx.clone(); + + tokio::spawn(async move { + let token_id: AccessTokenId = match id.parse() { + Ok(id) => id, + Err(e) => { + let _ = tx.send(Event::AccessTokenIssued(Err(CliError::InvalidArgs( + miette::miette!("Invalid token ID: {}", e), + )))); + return; + } + }; + let mut operations: Vec = Vec::new(); + + // Account level operations + if account_read { + operations.push(Operation::ListBasins); + operations.push(Operation::GetAccountMetrics); + } + // (No account-write ops at account level) + + // Basin level operations + if basin_read { + operations.push(Operation::GetBasinConfig); + operations.push(Operation::GetBasinMetrics); + operations.push(Operation::ListStreams); + } + if basin_write { + operations.push(Operation::CreateBasin); + operations.push(Operation::DeleteBasin); + operations.push(Operation::ReconfigureBasin); + } + + // Stream level operations + if stream_read { + operations.push(Operation::GetStreamConfig); + operations.push(Operation::GetStreamMetrics); + operations.push(Operation::Read); + operations.push(Operation::CheckTail); + } + if stream_write { + operations.push(Operation::CreateStream); + operations.push(Operation::DeleteStream); + operations.push(Operation::ReconfigureStream); + operations.push(Operation::Append); + operations.push(Operation::Fence); + operations.push(Operation::Trim); + } + + // Token operations (based on tokens scope) + if !matches!(tokens_scope, ScopeOption::None) { + if account_read { + operations.push(Operation::ListAccessTokens); + } + if account_write { + operations.push(Operation::IssueAccessToken); + operations.push(Operation::RevokeAccessToken); + } + } + let expires_in_str = match expiry { + ExpiryOption::Never => None, + ExpiryOption::Custom => { + if expiry_custom.is_empty() { + None + } else { + Some(expiry_custom.clone()) + } + } + _ => expiry.duration_str().map(|s| s.to_string()), + }; + let basins_matcher = match basins_scope { + ScopeOption::All => None, + ScopeOption::None => Some("".to_string()), // Empty string = no basins + ScopeOption::Prefix => Some(basins_value.clone()), + ScopeOption::Exact => Some(format!("={}", basins_value)), + }; + + let streams_matcher = match streams_scope { + ScopeOption::All => None, + ScopeOption::None => Some("".to_string()), + ScopeOption::Prefix => Some(streams_value.clone()), + ScopeOption::Exact => Some(format!("={}", streams_value)), + }; + + let tokens_matcher = match tokens_scope { + ScopeOption::All => None, + ScopeOption::None => Some("".to_string()), + ScopeOption::Prefix => Some(tokens_value.clone()), + ScopeOption::Exact => Some(format!("={}", tokens_value)), + }; + let args = IssueAccessTokenArgs { + id: token_id, + expires_in: expires_in_str.and_then(|s| s.parse().ok()), + expires_at: None, + auto_prefix_streams, + basins: basins_matcher.and_then(|s| { + if s.is_empty() && matches!(basins_scope, ScopeOption::None) { + // For "None" scope, we don't pass anything (API default is all) + // Actually, to restrict to none, we need special handling + None + } else if s.is_empty() { + None + } else { + s.parse().ok() + } + }), + streams: streams_matcher + .and_then(|s| if s.is_empty() { None } else { s.parse().ok() }), + access_tokens: tokens_matcher + .and_then(|s| if s.is_empty() { None } else { s.parse().ok() }), + op_group_perms: None, + ops: operations, + }; + + match ops::issue_access_token(&s2, args).await { + Ok(token) => { + let _ = tx.send(Event::AccessTokenIssued(Ok(token))); + // Trigger refresh + let list_args = ListAccessTokensArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_access_tokens(&s2, list_args).await { + let tokens: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::AccessTokensLoaded(Ok(tokens))); + } + } + Err(e) => { + let _ = tx.send(Event::AccessTokenIssued(Err(e))); + } + } + }); + } + + /// Revoke an access token + fn revoke_access_token(&self, id: String, tx: mpsc::UnboundedSender) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let tx_refresh = tx.clone(); + + tokio::spawn(async move { + let token_id: AccessTokenId = match id.parse() { + Ok(id) => id, + Err(e) => { + let _ = tx.send(Event::AccessTokenRevoked(Err(CliError::InvalidArgs( + miette::miette!("Invalid token ID: {}", e), + )))); + return; + } + }; + + match ops::revoke_access_token(&s2, token_id.clone()).await { + Ok(()) => { + let _ = tx.send(Event::AccessTokenRevoked(Ok(id))); + // Trigger refresh + let list_args = ListAccessTokensArgs { + prefix: None, + start_after: None, + limit: Some(100), + no_auto_paginate: false, + }; + if let Ok(stream) = ops::list_access_tokens(&s2, list_args).await { + let tokens: Vec<_> = stream + .take(100) + .filter_map(|r| async { r.ok() }) + .collect() + .await; + let _ = tx_refresh.send(Event::AccessTokensLoaded(Ok(tokens))); + } + } + Err(e) => { + let _ = tx.send(Event::AccessTokenRevoked(Err(e))); + } + } + }); + } + + /// Open basin metrics view + /// Open account metrics view + fn open_account_metrics(&mut self, tx: mpsc::UnboundedSender) { + let (year, month, day) = Self::today(); + self.screen = Screen::MetricsView(MetricsViewState { + metrics_type: MetricsType::Account, + metrics: Vec::new(), + selected_category: MetricCategory::ActiveBasins, + time_range: TimeRangeOption::default(), + loading: true, + scroll: 0, + time_picker_open: false, + time_picker_selected: 3, // Default to 24h (index 3) + calendar_open: false, + calendar_year: year, + calendar_month: month, + calendar_day: day, + calendar_start: None, + calendar_end: None, + calendar_selecting_end: false, + }); + self.load_account_metrics(MetricCategory::ActiveBasins, TimeRangeOption::default(), tx); + } + + fn open_basin_metrics(&mut self, basin_name: BasinName, tx: mpsc::UnboundedSender) { + let (year, month, day) = Self::today(); + self.screen = Screen::MetricsView(MetricsViewState { + metrics_type: MetricsType::Basin { + basin_name: basin_name.clone(), + }, + metrics: Vec::new(), + selected_category: MetricCategory::Storage, + time_range: TimeRangeOption::default(), + loading: true, + scroll: 0, + time_picker_open: false, + time_picker_selected: 3, + calendar_open: false, + calendar_year: year, + calendar_month: month, + calendar_day: day, + calendar_start: None, + calendar_end: None, + calendar_selecting_end: false, + }); + self.load_basin_metrics( + basin_name, + MetricCategory::Storage, + TimeRangeOption::default(), + tx, + ); + } + + /// Open stream metrics view + fn open_stream_metrics( + &mut self, + basin_name: BasinName, + stream_name: StreamName, + tx: mpsc::UnboundedSender, + ) { + let (year, month, day) = Self::today(); + self.screen = Screen::MetricsView(MetricsViewState { + metrics_type: MetricsType::Stream { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + }, + metrics: Vec::new(), + selected_category: MetricCategory::Storage, + time_range: TimeRangeOption::default(), + loading: true, + scroll: 0, + time_picker_open: false, + time_picker_selected: 3, + calendar_open: false, + calendar_year: year, + calendar_month: month, + calendar_day: day, + calendar_start: None, + calendar_end: None, + calendar_selecting_end: false, + }); + self.load_stream_metrics(basin_name, stream_name, TimeRangeOption::default(), tx); + } + + /// Get today's date as (year, month, day) + fn today() -> (i32, u32, u32) { + use chrono::{Datelike, Local}; + let today = Local::now(); + (today.year(), today.month(), today.day()) + } + + /// Load basin metrics + /// Load account metrics + fn load_account_metrics( + &self, + category: MetricCategory, + time_range: TimeRangeOption, + tx: mpsc::UnboundedSender, + ) { + use s2_sdk::types::AccountMetricSet; + + let s2 = self.s2.clone().expect("S2 client not initialized"); + let (start, end) = time_range.get_range(); + + tokio::spawn(async move { + let set = match category { + MetricCategory::ActiveBasins => { + AccountMetricSet::ActiveBasins(TimeRange::new(start, end)) + } + MetricCategory::AccountOps => AccountMetricSet::AccountOps( + s2_sdk::types::TimeRangeAndInterval::new(start, end), + ), + _ => return, // Other categories not valid for account + }; + + let input = s2_sdk::types::GetAccountMetricsInput::new(set); + match s2.get_account_metrics(input).await { + Ok(metrics) => { + let _ = tx.send(Event::AccountMetricsLoaded(Ok(metrics))); + } + Err(e) => { + let _ = tx.send(Event::AccountMetricsLoaded(Err(CliError::op( + crate::error::OpKind::GetAccountMetrics, + e, + )))); + } + } + }); + } + + fn load_basin_metrics( + &self, + basin_name: BasinName, + category: MetricCategory, + time_range: TimeRangeOption, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let (start, end) = time_range.get_range(); + + tokio::spawn(async move { + let set = match category { + MetricCategory::Storage => BasinMetricSet::Storage(TimeRange::new(start, end)), + MetricCategory::AppendOps => { + BasinMetricSet::AppendOps(s2_sdk::types::TimeRangeAndInterval::new(start, end)) + } + MetricCategory::ReadOps => { + BasinMetricSet::ReadOps(s2_sdk::types::TimeRangeAndInterval::new(start, end)) + } + MetricCategory::AppendThroughput => BasinMetricSet::AppendThroughput( + s2_sdk::types::TimeRangeAndInterval::new(start, end), + ), + MetricCategory::ReadThroughput => BasinMetricSet::ReadThroughput( + s2_sdk::types::TimeRangeAndInterval::new(start, end), + ), + MetricCategory::BasinOps => { + BasinMetricSet::BasinOps(s2_sdk::types::TimeRangeAndInterval::new(start, end)) + } + _ => return, + }; + + let input = s2_sdk::types::GetBasinMetricsInput::new(basin_name, set); + match s2.get_basin_metrics(input).await { + Ok(metrics) => { + let _ = tx.send(Event::BasinMetricsLoaded(Ok(metrics))); + } + Err(e) => { + let _ = tx.send(Event::BasinMetricsLoaded(Err(CliError::op( + crate::error::OpKind::GetBasinMetrics, + e, + )))); + } + } + }); + } + + /// Load stream metrics + fn load_stream_metrics( + &self, + basin_name: BasinName, + stream_name: StreamName, + time_range: TimeRangeOption, + tx: mpsc::UnboundedSender, + ) { + let s2 = self.s2.clone().expect("S2 client not initialized"); + let (start, end) = time_range.get_range(); + + tokio::spawn(async move { + let set = StreamMetricSet::Storage(TimeRange::new(start, end)); + + let input = s2_sdk::types::GetStreamMetricsInput::new(basin_name, stream_name, set); + match s2.get_stream_metrics(input).await { + Ok(metrics) => { + let _ = tx.send(Event::StreamMetricsLoaded(Ok(metrics))); + } + Err(e) => { + let _ = tx.send(Event::StreamMetricsLoaded(Err(CliError::op( + crate::error::OpKind::GetStreamMetrics, + e, + )))); + } + } + }); + } + + /// Handle keys in metrics view + fn handle_metrics_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + // Check if time picker or calendar is open first + let (time_picker_open, calendar_open) = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + (state.time_picker_open, state.calendar_open) + }; + + if time_picker_open { + self.handle_time_picker_key(key, tx); + return; + } + + if calendar_open { + self.handle_calendar_key(key, tx); + return; + } + + // Extract data from state first to avoid borrow issues + let (metrics_type, selected_category, time_range) = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + ( + state.metrics_type.clone(), + state.selected_category, + state.time_range, + ) + }; + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Go back to previous screen + match &metrics_type { + MetricsType::Account => { + // Go back to basins list + self.screen = Screen::Basins(BasinsState { + basins: Vec::new(), + selected: 0, + loading: true, + filter: String::new(), + filter_active: false, + }); + self.load_basins(tx); + } + MetricsType::Basin { basin_name } => { + let basin_name = basin_name.clone(); + self.screen = Screen::Streams(StreamsState { + basin_name: basin_name.clone(), + streams: Vec::new(), + selected: 0, + loading: true, + filter: String::new(), + filter_active: false, + }); + self.load_streams(basin_name, tx); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + let basin_name = basin_name.clone(); + let stream_name = stream_name.clone(); + self.screen = Screen::StreamDetail(StreamDetailState { + basin_name: basin_name.clone(), + stream_name: stream_name.clone(), + config: None, + tail_position: None, + selected_action: 0, + loading: true, + }); + self.load_stream_detail(basin_name, stream_name, tx); + } + } + } + KeyCode::Char('t') => { + // Open time picker + if let Screen::MetricsView(state) = &mut self.screen { + state.time_picker_open = true; + // Set picker selection to current time range + state.time_picker_selected = TimeRangeOption::PRESETS + .iter() + .position(|p| { + std::mem::discriminant(p) == std::mem::discriminant(&state.time_range) + }) + .unwrap_or(3); + } + } + KeyCode::Left | KeyCode::Char('h') => { + // Previous metric category (for basin or account metrics) + match &metrics_type { + MetricsType::Account => { + let new_category = selected_category.prev(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_account_metrics(new_category, time_range, tx); + } + MetricsType::Basin { basin_name } => { + let basin_name = basin_name.clone(); + let new_category = selected_category.prev(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_basin_metrics(basin_name, new_category, time_range, tx); + } + MetricsType::Stream { .. } => {} // No category switching for stream + } + } + KeyCode::Right | KeyCode::Char('l') => { + // Next metric category (for basin or account metrics) + match &metrics_type { + MetricsType::Account => { + let new_category = selected_category.next(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_account_metrics(new_category, time_range, tx); + } + MetricsType::Basin { basin_name } => { + let basin_name = basin_name.clone(); + let new_category = selected_category.next(); + if let Screen::MetricsView(state) = &mut self.screen { + state.selected_category = new_category; + state.loading = true; + state.metrics.clear(); + } + self.load_basin_metrics(basin_name, new_category, time_range, tx); + } + MetricsType::Stream { .. } => {} // No category switching for stream + } + } + KeyCode::Up | KeyCode::Char('k') => { + if let Screen::MetricsView(state) = &mut self.screen + && state.scroll > 0 + { + state.scroll -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Screen::MetricsView(state) = &mut self.screen { + state.scroll += 1; + } + } + KeyCode::Char('r') => { + if let Screen::MetricsView(state) = &mut self.screen { + state.loading = true; + state.metrics.clear(); + } + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics( + basin_name.clone(), + selected_category, + time_range, + tx, + ); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + time_range, + tx, + ); + } + } + } + KeyCode::Char('[') => { + // Previous time range + let new_time_range = time_range.prev(); + if let Screen::MetricsView(state) = &mut self.screen { + state.time_range = new_time_range; + state.loading = true; + state.metrics.clear(); + } + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, new_time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics( + basin_name.clone(), + selected_category, + new_time_range, + tx, + ); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + new_time_range, + tx, + ); + } + } + } + KeyCode::Char(']') => { + // Next time range + let new_time_range = time_range.next(); + if let Screen::MetricsView(state) = &mut self.screen { + state.time_range = new_time_range; + state.loading = true; + state.metrics.clear(); + } + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, new_time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics( + basin_name.clone(), + selected_category, + new_time_range, + tx, + ); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + new_time_range, + tx, + ); + } + } + } + _ => {} + } + } + + /// Handle keys when time picker popup is open + fn handle_time_picker_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + // PRESETS.len() is 7 (indices 0-6), index 7 is "Custom" + const CUSTOM_INDEX: usize = 7; + + let (metrics_type, selected_category) = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + (state.metrics_type.clone(), state.selected_category) + }; + + match key.code { + KeyCode::Esc => { + // Close picker without changing + if let Screen::MetricsView(state) = &mut self.screen { + state.time_picker_open = false; + } + } + KeyCode::Up | KeyCode::Char('k') => { + if let Screen::MetricsView(state) = &mut self.screen + && state.time_picker_selected > 0 + { + state.time_picker_selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Screen::MetricsView(state) = &mut self.screen + && state.time_picker_selected < CUSTOM_INDEX + { + state.time_picker_selected += 1; + } + } + KeyCode::Enter => { + let time_picker_selected = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + state.time_picker_selected + }; + + if time_picker_selected == CUSTOM_INDEX { + // Open calendar picker + if let Screen::MetricsView(state) = &mut self.screen { + state.time_picker_open = false; + state.calendar_open = true; + state.calendar_start = None; + state.calendar_end = None; + state.calendar_selecting_end = false; + } + } else { + // Select preset time range and close picker + let new_time_range = { + let Screen::MetricsView(state) = &mut self.screen else { + return; + }; + let selected = TimeRangeOption::PRESETS + .get(state.time_picker_selected) + .cloned() + .unwrap_or_default(); + state.time_range = selected; + state.time_picker_open = false; + state.loading = true; + state.metrics.clear(); + selected + }; + + // Reload metrics with new time range + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, new_time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics( + basin_name.clone(), + selected_category, + new_time_range, + tx, + ); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + new_time_range, + tx, + ); + } + } + } + } + _ => {} + } + } + + /// Handle keys when calendar picker is open + fn handle_calendar_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let (metrics_type, selected_category) = { + let Screen::MetricsView(state) = &self.screen else { + return; + }; + (state.metrics_type.clone(), state.selected_category) + }; + + match key.code { + KeyCode::Esc => { + // Close calendar without changing + if let Screen::MetricsView(state) = &mut self.screen { + state.calendar_open = false; + state.calendar_start = None; + state.calendar_end = None; + } + } + KeyCode::Left | KeyCode::Char('h') => { + if let Screen::MetricsView(state) = &mut self.screen { + // Move to previous day + if state.calendar_day > 1 { + state.calendar_day -= 1; + } else { + // Go to previous month + if state.calendar_month > 1 { + state.calendar_month -= 1; + } else { + state.calendar_month = 12; + state.calendar_year -= 1; + } + state.calendar_day = + Self::days_in_month(state.calendar_year, state.calendar_month); + } + } + } + KeyCode::Right | KeyCode::Char('l') => { + if let Screen::MetricsView(state) = &mut self.screen { + let max_day = Self::days_in_month(state.calendar_year, state.calendar_month); + if state.calendar_day < max_day { + state.calendar_day += 1; + } else { + // Go to next month + if state.calendar_month < 12 { + state.calendar_month += 1; + } else { + state.calendar_month = 1; + state.calendar_year += 1; + } + state.calendar_day = 1; + } + } + } + KeyCode::Up | KeyCode::Char('k') => { + if let Screen::MetricsView(state) = &mut self.screen { + // Move up one week (7 days) + if state.calendar_day > 7 { + state.calendar_day -= 7; + } else { + // Go to previous month + if state.calendar_month > 1 { + state.calendar_month -= 1; + } else { + state.calendar_month = 12; + state.calendar_year -= 1; + } + let prev_month_days = + Self::days_in_month(state.calendar_year, state.calendar_month); + state.calendar_day = prev_month_days.saturating_sub(7 - state.calendar_day); + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Screen::MetricsView(state) = &mut self.screen { + let max_day = Self::days_in_month(state.calendar_year, state.calendar_month); + if state.calendar_day + 7 <= max_day { + state.calendar_day += 7; + } else { + let overflow = state.calendar_day + 7 - max_day; + // Go to next month + if state.calendar_month < 12 { + state.calendar_month += 1; + } else { + state.calendar_month = 1; + state.calendar_year += 1; + } + state.calendar_day = overflow.min(Self::days_in_month( + state.calendar_year, + state.calendar_month, + )); + } + } + } + KeyCode::Char('[') => { + // Previous month + if let Screen::MetricsView(state) = &mut self.screen { + if state.calendar_month > 1 { + state.calendar_month -= 1; + } else { + state.calendar_month = 12; + state.calendar_year -= 1; + } + let max_day = Self::days_in_month(state.calendar_year, state.calendar_month); + state.calendar_day = state.calendar_day.min(max_day); + } + } + KeyCode::Char(']') => { + // Next month + if let Screen::MetricsView(state) = &mut self.screen { + if state.calendar_month < 12 { + state.calendar_month += 1; + } else { + state.calendar_month = 1; + state.calendar_year += 1; + } + let max_day = Self::days_in_month(state.calendar_year, state.calendar_month); + state.calendar_day = state.calendar_day.min(max_day); + } + } + KeyCode::Enter => { + // Select date + let should_apply = { + let Screen::MetricsView(state) = &mut self.screen else { + return; + }; + let selected_date = ( + state.calendar_year, + state.calendar_month, + state.calendar_day, + ); + + if state.calendar_start.is_none() { + // First selection: set start date + state.calendar_start = Some(selected_date); + state.calendar_selecting_end = true; + false + } else if !state.calendar_selecting_end { + // Start date already set, selecting again resets + state.calendar_start = Some(selected_date); + state.calendar_selecting_end = true; + false + } else { + // Second selection: set end date and apply + state.calendar_end = Some(selected_date); + true + } + }; + + if should_apply { + // Apply custom date range + let new_time_range = { + let Screen::MetricsView(state) = &mut self.screen else { + return; + }; + + let start_date = state + .calendar_start + .expect("calendar_start set before should_apply"); + let end_date = state + .calendar_end + .expect("calendar_end set before should_apply"); + + let (start, end) = if start_date <= end_date { + (start_date, end_date) + } else { + (end_date, start_date) + }; + + let start_ts = Self::date_to_timestamp(start.0, start.1, start.2, true); + let end_ts = Self::date_to_timestamp(end.0, end.1, end.2, false); + + let Some((start_ts, end_ts)) = start_ts.zip(end_ts) else { + state.calendar_open = false; + return; + }; + + let time_range = TimeRangeOption::Custom { + start: start_ts, + end: end_ts, + }; + state.time_range = time_range; + state.calendar_open = false; + state.loading = true; + state.metrics.clear(); + time_range + }; + + // Reload metrics + match &metrics_type { + MetricsType::Account => { + self.load_account_metrics(selected_category, new_time_range, tx); + } + MetricsType::Basin { basin_name } => { + self.load_basin_metrics( + basin_name.clone(), + selected_category, + new_time_range, + tx, + ); + } + MetricsType::Stream { + basin_name, + stream_name, + } => { + self.load_stream_metrics( + basin_name.clone(), + stream_name.clone(), + new_time_range, + tx, + ); + } + } + } + } + _ => {} + } + } + + fn days_in_month(year: i32, month: u32) -> u32 { + let next_month_start = if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1) + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1) + }; + next_month_start + .and_then(|d| d.pred_opt()) + .map(|d| d.day()) + .unwrap_or(28) + } + + fn date_to_timestamp(year: i32, month: u32, day: u32, start_of_day: bool) -> Option { + use chrono::{TimeZone, Utc}; + let (h, m, s) = if start_of_day { + (0, 0, 0) + } else { + (23, 59, 59) + }; + Utc.with_ymd_and_hms(year, month, day, h, m, s) + .single() + .map(|dt| dt.timestamp() as u32) + } + + fn handle_bench_view_key(&mut self, key: KeyEvent, tx: mpsc::UnboundedSender) { + let Screen::BenchView(state) = &mut self.screen else { + return; + }; + + // If running, only allow stop + if state.running { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + state.stopping = true; + // Signal the benchmark task to stop + if let Some(stop_signal) = &self.bench_stop_signal { + stop_signal.store(true, Ordering::Relaxed); + } + self.message = Some(StatusMessage { + text: "Stopping benchmark...".to_string(), + level: MessageLevel::Info, + }); + } + _ => {} + } + return; + } + + // If showing results, allow going back + if !state.config_phase && !state.running { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Go back to basins + self.screen = Screen::Basins(BasinsState::default()); + self.load_basins(tx); + } + KeyCode::Char('r') => { + // Reset to config phase + let basin_name = state.basin_name.clone(); + self.screen = Screen::BenchView(BenchViewState::new(basin_name)); + } + _ => {} + } + return; + } + + // Config phase + if state.editing { + match key.code { + KeyCode::Esc => { + state.editing = false; + state.edit_buffer.clear(); + } + KeyCode::Enter => { + // Apply the edit + match state.edit_buffer.parse::() { + Ok(val) if val > 0 => { + match state.config_field { + BenchConfigField::RecordSize => { + let val = val.clamp(128, 1024 * 1024) as u32; + state.record_size = val; + } + BenchConfigField::TargetMibps => { + state.target_mibps = val.clamp(1, 100); + } + BenchConfigField::Duration => { + state.duration_secs = val.clamp(10, 600); + } + BenchConfigField::CatchupDelay => { + state.catchup_delay_secs = val.min(120); + } + BenchConfigField::Start => {} + } + state.editing = false; + state.edit_buffer.clear(); + } + Ok(_) => { + // Value is 0, show error + self.message = Some(StatusMessage { + text: "Value must be greater than 0".to_string(), + level: MessageLevel::Error, + }); + } + Err(_) => { + // Invalid number, show error + self.message = Some(StatusMessage { + text: "Invalid number".to_string(), + level: MessageLevel::Error, + }); + } + } + } + KeyCode::Char(c) if c.is_ascii_digit() => { + state.edit_buffer.push(c); + } + KeyCode::Backspace => { + state.edit_buffer.pop(); + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Go back to basins + self.screen = Screen::Basins(BasinsState::default()); + self.load_basins(tx); + } + KeyCode::Up | KeyCode::Char('k') => { + state.config_field = state.config_field.prev(); + } + KeyCode::Down | KeyCode::Char('j') => { + state.config_field = state.config_field.next(); + } + KeyCode::Enter => { + if state.config_field == BenchConfigField::Start { + // Start the benchmark + let basin_name = state.basin_name.clone(); + let record_size = state.record_size; + let target_mibps = state.target_mibps; + let duration_secs = state.duration_secs; + let catchup_delay_secs = state.catchup_delay_secs; + state.config_phase = false; + self.start_benchmark( + basin_name, + record_size, + target_mibps, + duration_secs, + catchup_delay_secs, + tx, + ); + } else { + // Edit the field + state.editing = true; + state.edit_buffer = match state.config_field { + BenchConfigField::RecordSize => state.record_size.to_string(), + BenchConfigField::TargetMibps => state.target_mibps.to_string(), + BenchConfigField::Duration => state.duration_secs.to_string(), + BenchConfigField::CatchupDelay => state.catchup_delay_secs.to_string(), + BenchConfigField::Start => String::new(), + }; + } + } + KeyCode::Left | KeyCode::Char('h') => { + // Decrease value + match state.config_field { + BenchConfigField::RecordSize => { + state.record_size = (state.record_size / 2).max(128); + } + BenchConfigField::TargetMibps => { + state.target_mibps = (state.target_mibps.saturating_sub(1)).max(1); + } + BenchConfigField::Duration => { + state.duration_secs = (state.duration_secs.saturating_sub(10)).max(10); + } + BenchConfigField::CatchupDelay => { + state.catchup_delay_secs = state.catchup_delay_secs.saturating_sub(5); + } + BenchConfigField::Start => {} + } + } + KeyCode::Right | KeyCode::Char('l') => { + // Increase value + match state.config_field { + BenchConfigField::RecordSize => { + state.record_size = (state.record_size * 2).min(1024 * 1024); + } + BenchConfigField::TargetMibps => { + state.target_mibps = state.target_mibps.saturating_add(1).min(100); + } + BenchConfigField::Duration => { + state.duration_secs = state.duration_secs.saturating_add(10).min(600); + } + BenchConfigField::CatchupDelay => { + state.catchup_delay_secs = + state.catchup_delay_secs.saturating_add(5).min(120); + } + BenchConfigField::Start => {} + } + } + _ => {} + } + } + + fn start_benchmark( + &mut self, + basin_name: BasinName, + record_size: u32, + target_mibps: u64, + duration_secs: u64, + catchup_delay_secs: u64, + tx: mpsc::UnboundedSender, + ) { + let Some(s2) = self.s2.clone() else { + let _ = tx.send(Event::BenchStreamCreated(Err(CliError::Config( + crate::error::CliConfigError::MissingAccessToken, + )))); + return; + }; + + // Create a stop signal that can be triggered from the UI + let user_stop = Arc::new(AtomicBool::new(false)); + self.bench_stop_signal = Some(user_stop.clone()); + + tokio::spawn(async move { + use s2_sdk::types::{ + CreateStreamInput, DeleteOnEmptyConfig, DeleteStreamInput, RetentionPolicy, + StreamConfig as SdkStreamConfig, StreamName, TimestampingConfig, TimestampingMode, + }; + use std::num::NonZeroU64; + use std::time::Duration; + let stream_name: StreamName = format!("_bench_{}", uuid::Uuid::new_v4()) + .parse() + .expect("valid stream name"); + let stream_name_str = stream_name.to_string(); + + let stream_config = SdkStreamConfig::new() + .with_retention_policy(RetentionPolicy::Age(3600)) + .with_delete_on_empty( + DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(60)), + ) + .with_timestamping( + TimestampingConfig::new() + .with_mode(TimestampingMode::ClientRequire) + .with_uncapped(true), + ); + + let basin = s2.basin(basin_name.clone()); + if let Err(e) = basin + .create_stream( + CreateStreamInput::new(stream_name.clone()).with_config(stream_config), + ) + .await + { + let _ = tx.send(Event::BenchStreamCreated(Err(CliError::op( + crate::error::OpKind::Bench, + e, + )))); + return; + } + + let _ = tx.send(Event::BenchStreamCreated(Ok(stream_name_str))); + + // Get stream handle + let stream = basin.stream(stream_name.clone()); + + // Run the benchmark with events + let result = run_bench_with_events( + stream, + record_size as usize, + NonZeroU64::new(target_mibps).unwrap_or(NonZeroU64::MIN), + Duration::from_secs(duration_secs), + Duration::from_secs(catchup_delay_secs), + user_stop, + tx.clone(), + ) + .await; + + // Clean up the stream + let _ = basin + .delete_stream(DeleteStreamInput::new(stream_name)) + .await; + + let _ = tx.send(Event::BenchComplete(result)); + }); + } +} + +/// Run the benchmark and send events to the TUI +async fn run_bench_with_events( + stream: s2_sdk::S2Stream, + record_size: usize, + target_mibps: std::num::NonZeroU64, + duration: std::time::Duration, + catchup_delay: std::time::Duration, + user_stop: Arc, + tx: mpsc::UnboundedSender, +) -> Result { + use crate::bench::*; + use crate::types::LatencyStats; + use futures::StreamExt; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::Duration; + use tokio::time::Instant; + + const WRITE_DONE_SENTINEL: u64 = u64::MAX; + + let bench_start = Instant::now(); + // Use the user_stop signal to control the benchmark - this allows the UI to stop it + let stop = user_stop; + let write_done_records = Arc::new(AtomicU64::new(WRITE_DONE_SENTINEL)); + + // We need to re-implement the bench logic here to send events + // For now, let's use a simplified version that calls into bench.rs + // and extracts the stats + + let mut all_ack_latencies: Vec = Vec::new(); + let mut all_e2e_latencies: Vec = Vec::new(); + + // Run write and read streams concurrently + let write_stream = bench_write( + stream.clone(), + record_size, + target_mibps, + stop.clone(), + write_done_records.clone(), + bench_start, + ); + + let read_stream = bench_read( + stream.clone(), + record_size, + write_done_records.clone(), + bench_start, + ); + + enum BenchEvent { + Write(Result), + Read(Result), + WriteDone, + ReadDone, + } + + let (btx, mut brx) = tokio::sync::mpsc::unbounded_channel(); + let write_tx = btx.clone(); + let write_handle = tokio::spawn(async move { + let mut write_stream = std::pin::pin!(write_stream); + while let Some(sample) = write_stream.next().await { + if write_tx.send(BenchEvent::Write(sample)).is_err() { + return; + } + } + let _ = write_tx.send(BenchEvent::WriteDone); + }); + let read_tx = btx.clone(); + let read_handle = tokio::spawn(async move { + let mut read_stream = std::pin::pin!(read_stream); + while let Some(sample) = read_stream.next().await { + if read_tx.send(BenchEvent::Read(sample)).is_err() { + return; + } + } + let _ = read_tx.send(BenchEvent::ReadDone); + }); + drop(btx); + + let deadline = bench_start + duration; + let mut write_done = false; + let mut read_done = false; + + loop { + if write_done && read_done { + break; + } + tokio::select! { + _ = tokio::time::sleep_until(deadline), if !stop.load(Ordering::Relaxed) => { + stop.store(true, Ordering::Relaxed); + let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::Write)); + } + event = brx.recv() => { + match event { + Some(BenchEvent::Write(Ok(sample))) => { + all_ack_latencies.extend(sample.ack_latencies.iter().copied()); + let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); + let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); + let _ = tx.send(Event::BenchWriteSample(BenchSample { + bytes: sample.bytes, + records: sample.records, + elapsed: sample.elapsed, + mib_per_sec: mibps, + records_per_sec: recps, + })); + } + Some(BenchEvent::Write(Err(e))) => { + stop.store(true, Ordering::Relaxed); + write_handle.abort(); + read_handle.abort(); + return Err(e); + } + Some(BenchEvent::WriteDone) => { + write_done = true; + } + Some(BenchEvent::Read(Ok(sample))) => { + all_e2e_latencies.extend(sample.e2e_latencies.iter().copied()); + let mibps = sample.bytes as f64 / (1024.0 * 1024.0) / sample.elapsed.as_secs_f64().max(0.001); + let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); + let _ = tx.send(Event::BenchReadSample(BenchSample { + bytes: sample.bytes, + records: sample.records, + elapsed: sample.elapsed, + mib_per_sec: mibps, + records_per_sec: recps, + })); + } + Some(BenchEvent::Read(Err(e))) => { + stop.store(true, Ordering::Relaxed); + write_handle.abort(); + read_handle.abort(); + return Err(e); + } + Some(BenchEvent::ReadDone) => { + read_done = true; + let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::Read)); + } + None => { + write_done = true; + read_done = true; + } + } + } + } + } + + let _ = write_handle.await; + let _ = read_handle.await; + + // Check if user stopped before starting catchup + if stop.load(Ordering::Relaxed) { + return Ok(BenchFinalStats { + ack_latency: if all_ack_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_ack_latencies)) + }, + e2e_latency: if all_e2e_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_e2e_latencies)) + }, + }); + } + + // Catchup phase - wait with periodic stop checks + let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::CatchupWait)); + let catchup_wait_start = Instant::now(); + while catchup_wait_start.elapsed() < catchup_delay { + if stop.load(Ordering::Relaxed) { + return Ok(BenchFinalStats { + ack_latency: if all_ack_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_ack_latencies)) + }, + e2e_latency: if all_e2e_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_e2e_latencies)) + }, + }); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + let catchup_stream = bench_read_catchup(stream.clone(), record_size, bench_start); + let mut catchup_stream = std::pin::pin!(catchup_stream); + let catchup_timeout = Duration::from_secs(300); + let catchup_deadline = tokio::time::Instant::now() + catchup_timeout; + loop { + if stop.load(Ordering::Relaxed) { + break; + } + match tokio::time::timeout_at(catchup_deadline, catchup_stream.next()).await { + Ok(Some(Ok(sample))) => { + let mibps = sample.bytes as f64 + / (1024.0 * 1024.0) + / sample.elapsed.as_secs_f64().max(0.001); + let recps = sample.records as f64 / sample.elapsed.as_secs_f64().max(0.001); + let _ = tx.send(Event::BenchCatchupSample(BenchSample { + bytes: sample.bytes, + records: sample.records, + elapsed: sample.elapsed, + mib_per_sec: mibps, + records_per_sec: recps, + })); + } + Ok(Some(Err(e))) => { + return Err(e); + } + Ok(None) => break, + Err(_) => { + return Err(CliError::BenchVerification( + "catchup read timed out after 5 minutes".to_string(), + )); + } + } + } + let _ = tx.send(Event::BenchPhaseComplete(BenchPhase::Catchup)); + + Ok(BenchFinalStats { + ack_latency: if all_ack_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_ack_latencies)) + }, + e2e_latency: if all_e2e_latencies.is_empty() { + None + } else { + Some(LatencyStats::compute(all_e2e_latencies)) + }, + }) +} diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..2106412 --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,165 @@ +use std::time::Duration; + +use s2_sdk::types::{ + AccessTokenInfo, BasinInfo, Metric, SequencedRecord, StreamInfo, StreamPosition, +}; + +use crate::error::CliError; +use crate::types::{LatencyStats, StorageClass, StreamConfig, TimestampingMode}; + +/// Basin config info for reconfiguration +#[derive(Debug, Clone)] +pub struct BasinConfigInfo { + pub create_stream_on_append: bool, + pub create_stream_on_read: bool, + // Default stream config + pub storage_class: Option, + pub retention_age_secs: Option, // None = infinite + pub timestamping_mode: Option, + pub timestamping_uncapped: bool, +} + +/// Stream config info for reconfiguration +#[derive(Debug, Clone)] +pub struct StreamConfigInfo { + pub storage_class: Option, + pub retention_age_secs: Option, // None = infinite + pub timestamping_mode: Option, + pub timestamping_uncapped: bool, + pub delete_on_empty_min_age_secs: Option, // None = disabled +} + +/// Events that can occur in the TUI +#[derive(Debug)] +pub enum Event { + /// Basins have been loaded from the API + BasinsLoaded(Result, CliError>), + + /// Streams have been loaded from the API + StreamsLoaded(Result, CliError>), + + /// Stream configuration loaded + StreamConfigLoaded(Result), + + /// Tail position loaded + TailPositionLoaded(Result), + + /// A record was received during read/tail + RecordReceived(Result), + + /// Read stream ended + ReadEnded, + + /// A record was received for the PiP (picture-in-picture) tail + PipRecordReceived(Result), + + /// PiP read stream ended + PipReadEnded, + + /// Basin created successfully + BasinCreated(Result), + + /// Basin deleted successfully + BasinDeleted(Result), + + /// Stream created successfully + StreamCreated(Result), + + /// Stream deleted successfully + StreamDeleted(Result), + + /// Basin config loaded for reconfiguration + BasinConfigLoaded(Result), + + /// Stream config loaded for reconfiguration + StreamConfigForReconfigLoaded(Result), + + /// Basin reconfigured successfully + BasinReconfigured(Result<(), CliError>), + + /// Stream reconfigured successfully + StreamReconfigured(Result<(), CliError>), + + /// Record appended successfully (seq_num, body_preview, header_count) + RecordAppended(Result<(u64, String, usize), CliError>), + + /// File append progress update (appended_count, total_lines, last_seq_num) + FileAppendProgress { + appended: usize, + total: usize, + last_seq: Option, + }, + + /// File append completed (total_records, first_seq, last_seq) + FileAppendComplete(Result<(usize, u64, u64), CliError>), + + /// Stream fenced successfully (new token) + StreamFenced(Result), + + /// Stream trimmed successfully (trim_point, new_tail_seq_num) + StreamTrimmed(Result<(u64, u64), CliError>), + + /// Access tokens have been loaded from the API + AccessTokensLoaded(Result, CliError>), + + /// Access token issued successfully (token string) + AccessTokenIssued(Result), + + /// Access token revoked successfully (token id) + AccessTokenRevoked(Result), + + /// Account metrics loaded + AccountMetricsLoaded(Result, CliError>), + + /// Basin metrics loaded + BasinMetricsLoaded(Result, CliError>), + + /// Stream metrics loaded + StreamMetricsLoaded(Result, CliError>), + + /// An error occurred in a background task + Error(CliError), + + /// Benchmark stream created + BenchStreamCreated(Result), + + /// Benchmark write sample received + BenchWriteSample(BenchSample), + + /// Benchmark read sample received + BenchReadSample(BenchSample), + + /// Benchmark catchup sample received + BenchCatchupSample(BenchSample), + + /// Benchmark phase completed + BenchPhaseComplete(BenchPhase), + + /// Benchmark finished with final stats + BenchComplete(Result), +} + +/// A sample from the benchmark (write, read, or catchup) +#[derive(Debug, Clone)] +pub struct BenchSample { + pub bytes: u64, + pub records: u64, + pub elapsed: Duration, + pub mib_per_sec: f64, + pub records_per_sec: f64, +} + +/// Which phase of the benchmark +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BenchPhase { + Write, + Read, + CatchupWait, + Catchup, +} + +#[derive(Debug, Clone)] +pub struct BenchFinalStats { + pub ack_latency: Option, + pub e2e_latency: Option, +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..3c6ab5a --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,64 @@ +mod app; +mod event; +mod ui; + +use std::io; + +use crossterm::{ + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, prelude::CrosstermBackend}; + +use crate::config::{load_cli_config, sdk_config}; +use crate::error::CliError; +use app::App; + +pub async fn run() -> Result<(), CliError> { + // Load config and try to create SDK client + // If access token is missing, we'll start with Setup screen instead of failing + let cli_config = load_cli_config()?; + let s2 = match sdk_config(&cli_config) { + Ok(sdk_cfg) => Some(s2_sdk::S2::new(sdk_cfg).map_err(CliError::SdkInit)?), + Err(_) => None, // No access token - will show setup screen + }; + + // Setup terminal + enable_raw_mode().map_err(|e| CliError::RecordReaderInit(format!("terminal setup: {e}")))?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen) + .map_err(|e| CliError::RecordReaderInit(format!("terminal setup: {e}")))?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend) + .map_err(|e| CliError::RecordReaderInit(format!("terminal setup: {e}")))?; + + // Create and run app + let app = App::new(s2); + let result = app.run(&mut terminal).await; + + // Restore terminal - attempt all cleanup steps even if some fail + // This is critical: a partially restored terminal leaves the user's shell broken + let mut cleanup_errors = Vec::new(); + + if let Err(e) = disable_raw_mode() { + cleanup_errors.push(format!("disable_raw_mode: {e}")); + } + + if let Err(e) = execute!(terminal.backend_mut(), LeaveAlternateScreen) { + cleanup_errors.push(format!("leave_alternate_screen: {e}")); + } + + if let Err(e) = terminal.show_cursor() { + cleanup_errors.push(format!("show_cursor: {e}")); + } + + // Log cleanup errors to stderr (won't be visible in alternate screen anyway) + if !cleanup_errors.is_empty() { + eprintln!( + "Warning: terminal cleanup errors: {}", + cleanup_errors.join(", ") + ); + } + + result +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs new file mode 100644 index 0000000..2e209eb --- /dev/null +++ b/src/tui/ui.rs @@ -0,0 +1,7204 @@ +use std::collections::VecDeque; + +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, Padding, Paragraph}, +}; + +use crate::types::{StorageClass, TimestampingMode}; + +use super::app::{ + AccessTokensState, AgoUnit, App, AppendViewState, BasinsState, BenchViewState, + CompressionOption, ExpiryOption, InputMode, MessageLevel, MetricCategory, MetricsType, + MetricsViewState, PipState, ReadStartFrom, ReadViewState, RetentionPolicyOption, Screen, + SettingsState, SetupState, StreamDetailState, StreamsState, Tab, +}; + +const GREEN: Color = Color::Rgb(34, 197, 94); +const YELLOW: Color = Color::Rgb(250, 204, 21); +const RED: Color = Color::Rgb(239, 68, 68); +const CYAN: Color = Color::Rgb(34, 211, 238); +const BLUE: Color = Color::Rgb(59, 130, 246); +const PURPLE: Color = Color::Rgb(167, 139, 250); +const ORANGE: Color = Color::Rgb(251, 146, 60); +const WHITE: Color = Color::Rgb(255, 255, 255); + +const GRAY_100: Color = Color::Rgb(243, 244, 246); +const GRAY_200: Color = Color::Rgb(180, 180, 180); +const GRAY_300: Color = Color::Rgb(150, 150, 150); +const GRAY_400: Color = Color::Rgb(120, 120, 120); +const GRAY_500: Color = Color::Rgb(107, 114, 128); +const GRAY_600: Color = Color::Rgb(100, 100, 100); +const GRAY_700: Color = Color::Rgb(80, 80, 80); +const GRAY_750: Color = Color::Rgb(63, 63, 70); +const GRAY_800: Color = Color::Rgb(60, 60, 60); +const GRAY_850: Color = Color::Rgb(50, 50, 50); +const GRAY_900: Color = Color::Rgb(40, 40, 40); + +const BG_DARK: Color = Color::Rgb(17, 17, 17); +const BG_PANEL: Color = Color::Rgb(24, 24, 27); +const BG_SELECTED: Color = Color::Rgb(39, 39, 42); + +const ACCENT: Color = WHITE; +const SUCCESS: Color = GREEN; +const WARNING: Color = YELLOW; +const ERROR: Color = RED; +const TEXT_PRIMARY: Color = WHITE; +const TEXT_SECONDARY: Color = GRAY_100; +const TEXT_MUTED: Color = GRAY_500; +const BORDER: Color = GRAY_750; +const BORDER_DIM: Color = GRAY_850; +const BORDER_TITLE: Color = GRAY_900; + +const BADGE_ACTIVE: Color = Color::Rgb(22, 101, 52); +const BADGE_PENDING: Color = Color::Rgb(113, 63, 18); +const BADGE_DANGER: Color = Color::Rgb(127, 29, 29); + +const STAT_MIN: Color = Color::Rgb(96, 165, 250); +const STAT_MAX: Color = Color::Rgb(251, 191, 36); +const STAT_AVG: Color = PURPLE; + +const GREEN_BRIGHT: Color = Color::Rgb(34, 197, 94); +const GREEN_LIGHT: Color = Color::Rgb(74, 222, 128); +const GREEN_LIGHTER: Color = Color::Rgb(134, 239, 172); +const GREEN_PALE: Color = Color::Rgb(187, 247, 208); +const GREEN_PALEST: Color = Color::Rgb(220, 252, 231); + +const CHART_PURPLE: Color = Color::Rgb(139, 92, 246); +const CHART_VIOLET: Color = Color::Rgb(124, 58, 237); +const CHART_INDIGO: Color = Color::Rgb(99, 102, 241); +const CHART_DEEP_INDIGO: Color = Color::Rgb(79, 70, 229); +const CHART_BLUE: Color = Color::Rgb(59, 130, 246); +const CHART_ROYAL_BLUE: Color = Color::Rgb(37, 99, 235); +const CHART_LIGHT_BLUE: Color = Color::Rgb(96, 165, 250); +const CHART_PALE_BLUE: Color = Color::Rgb(147, 197, 253); +const CHART_YELLOW: Color = Color::Rgb(250, 204, 21); +const CHART_ORANGE: Color = Color::Rgb(251, 146, 60); + +const STORAGE_EXPRESS: Color = ORANGE; +const STORAGE_STANDARD: Color = Color::Rgb(147, 197, 253); + +const TIME_RECENT: Color = GREEN_LIGHT; +const TIME_MODERATE: Color = YELLOW; +const TIME_OLD: Color = GRAY_200; + +const CURSOR: &str = "▎"; +const SELECTED_INDICATOR: &str = " ▸ "; +const UNSELECTED_INDICATOR: &str = " "; + +/// Minimum dialog dimensions to ensure readability +const MIN_DIALOG_WIDTH: u16 = 60; +const MIN_DIALOG_HEIGHT: u16 = 20; + +/// Help descriptions for TUI options. +/// Source: https://github.com/s2-streamstore/s2-specs/blob/main/s2/v1/openapi.json +mod help_text { + // Record format (s2-format header) - exact from spec + pub const FORMAT_TEXT: &str = "Plain text, one record per line. No headers."; + pub const FORMAT_JSON: &str = "Efficient transmission and storage of Unicode data (UTF-8)."; // "raw" format + pub const FORMAT_JSON_BASE64: &str = "Safe transmission with efficient storage of binary data."; // "base64" format + + // Storage class - spec has no descriptions, these are functional + pub const STORAGE_DEFAULT: &str = "Use basin's default storage class."; + pub const STORAGE_STANDARD: &str = "Standard storage class for recent writes."; + pub const STORAGE_EXPRESS: &str = "Express storage class, optimized for performance."; + + // Timestamping mode - spec has no descriptions, these are functional + pub const TS_DEFAULT: &str = "Use basin's default timestamping mode."; + pub const TS_CLIENT_PREFER: &str = "Use client timestamp if provided, else arrival time."; + pub const TS_CLIENT_REQUIRE: &str = "Require client timestamp, reject if missing."; + pub const TS_ARRIVAL: &str = "Always use server arrival time."; + + // Uncapped - exact from spec + pub const TS_UNCAPPED: &str = "Allow client timestamps to exceed arrival time."; + pub const TS_CAPPED: &str = "Client timestamps capped at arrival time."; + + // Retention - exact from spec + pub const RETENTION_INFINITE: &str = "Retain records unless explicitly trimmed."; + pub const RETENTION_AGE: &str = "Auto-trim records older than threshold (seconds)."; + + // Delete on empty - exact from spec + pub const DELETE_NEVER: &str = "Set to 0 to disable delete-on-empty."; + pub const DELETE_THRESHOLD: &str = "Minimum age (seconds) before empty stream can be deleted."; + + // Clamp - functional description + pub const CLAMP_ON: &str = "Clamp start position to stream bounds."; + pub const CLAMP_OFF: &str = "Error if start position out of bounds."; + + // Auto-create streams - exact from spec + pub const AUTO_CREATE_APPEND: &str = "Create stream on append if it doesn't exist."; + pub const AUTO_CREATE_READ: &str = "Create stream on read if it doesn't exist."; + + // Fencing - exact from spec + pub const FENCE_TOKEN: &str = "Fencing token which can be overridden by a fence command."; + pub const FENCE_CURRENT: &str = "Current fencing token (empty string if unfenced)."; + + // Trim - functional description + pub const TRIM_SEQ_NUM: &str = "Remove all records before this sequence number."; + + // Match seq_num - exact from spec + pub const MATCH_SEQ_NUM: &str = "Enforce that the first record's sequence number matches."; + + // Access tokens + pub const TOKEN_EXPIRY: &str = "When the token becomes invalid."; + + // Append fencing - exact from spec + pub const APPEND_FENCING: &str = "Enforce fencing token match for this operation."; +} + +/// Safely truncate a string to max_len characters, adding suffix if truncated. +/// Returns the original string if it fits, otherwise truncates and adds suffix. +fn truncate_str(s: &str, max_len: usize, suffix: &str) -> String { + if s.len() <= max_len { + s.to_string() + } else { + let truncate_at = max_len.saturating_sub(suffix.len()); + // Find a valid char boundary + let mut end = truncate_at.min(s.len()); + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}{}", &s[..end], suffix) + } +} + +// S2 Logo (shared between splash and setup screens) +const S2_LOGO: &[&str] = &[ + " █████████████████████████ ", + " ██████████████████████████████ ", + " ███████████████████████████████ ", + "█████████████████████████████████", + "█████████████████████████████████ ", + "███████████████ ", + "███████████████ ", + "██████████████ ████████████████", + "██████████████ ████████████████", + "██████████████ ████████████████", + "███████████████ ███████", + "██████████████████ █████", + "█████████████████████████ ████", + "█████████████████████████ █████", + "██████ ██████", + "█████ ████████", + " ███ ██████████████████████ ", + " ██ ██████████████████████ ", + " ████████████████████ ", +]; + +/// Render the S2 logo as styled lines +fn render_logo() -> Vec> { + S2_LOGO + .iter() + .map(|&line| Line::from(Span::styled(line, Style::default().fg(WHITE)))) + .collect() +} + +/// Render a toggle switch with consistent styling +fn render_toggle(on: bool, is_selected: bool) -> Vec> { + if on { + vec![ + Span::styled( + "", + Style::default().fg(if is_selected { GREEN } else { GRAY_800 }), + ), + Span::styled(" ON ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled("", Style::default().fg(GREEN)), + ] + } else { + vec![ + Span::styled( + "", + Style::default().fg(if is_selected { TEXT_MUTED } else { GRAY_800 }), + ), + Span::styled(" OFF ", Style::default().fg(TEXT_MUTED).bg(GRAY_800)), + Span::styled("", Style::default().fg(GRAY_800)), + ] + } +} + +/// Render a pill-style option selector +fn render_pill(label: &str, is_row_selected: bool, is_active: bool) -> Span<'static> { + let label = label.to_string(); + if is_active { + Span::styled( + format!(" {} ", label), + Style::default().fg(BG_DARK).bg(GREEN).bold(), + ) + } else if is_row_selected { + Span::styled( + format!(" {} ", label), + Style::default().fg(TEXT_PRIMARY).bg(GRAY_750), + ) + } else { + Span::styled(format!(" {} ", label), Style::default().fg(TEXT_MUTED)) + } +} + +/// Render a form field row with selection indicator and label +fn render_field_row( + field_idx: usize, + label: &str, + current_selected: usize, +) -> (Span<'static>, Span<'static>) { + let is_selected = field_idx == current_selected; + let indicator = if is_selected { + Span::styled(SELECTED_INDICATOR, Style::default().fg(GREEN).bold()) + } else { + Span::raw(UNSELECTED_INDICATOR) + }; + let label_span = Span::styled( + format!("{:<15}", label), + Style::default().fg(if is_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ); + (indicator, label_span) +} + +/// Render a form field row with bold label when selected +fn render_field_row_bold( + field_idx: usize, + label: &str, + current_selected: usize, +) -> (Span<'static>, Span<'static>) { + let is_selected = field_idx == current_selected; + let indicator = if is_selected { + Span::styled(SELECTED_INDICATOR, Style::default().fg(GREEN).bold()) + } else { + Span::raw(UNSELECTED_INDICATOR) + }; + let label_style = if is_selected { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + (indicator, Span::styled(label.to_string(), label_style)) +} + +/// Render a primary action button +fn render_button(label: &str, is_selected: bool, is_enabled: bool, color: Color) -> Line<'static> { + let (btn_fg, btn_bg) = if is_selected && is_enabled { + (BG_DARK, color) + } else if is_enabled { + (color, BG_PANEL) + } else { + (GRAY_600, BG_PANEL) + }; + + let indicator = if is_selected { + Span::styled(SELECTED_INDICATOR, Style::default().fg(GREEN).bold()) + } else { + Span::raw(UNSELECTED_INDICATOR) + }; + + Line::from(vec![ + indicator, + Span::styled( + format!(" ▶ {} ", label), + Style::default().fg(btn_fg).bg(btn_bg).bold(), + ), + ]) +} + +/// Render a section header with divider line +fn render_section_header(title: &str, width: usize) -> Line<'static> { + let title_with_spaces = format!(" {} ", title); + let divider_len = width.saturating_sub(title_with_spaces.len()); + Line::from(vec![ + Span::styled(title_with_spaces, Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(divider_len), Style::default().fg(GRAY_750)), + ]) +} + +/// Render text input with cursor +fn render_text_input( + value: &str, + is_editing: bool, + placeholder: &str, + color: Color, +) -> Vec> { + if value.is_empty() && !is_editing { + vec![Span::styled( + placeholder.to_string(), + Style::default().fg(GRAY_600).italic(), + )] + } else { + let mut spans = vec![Span::styled(value.to_string(), Style::default().fg(color))]; + if is_editing { + spans.push(Span::styled(CURSOR, Style::default().fg(GREEN))); + } + spans + } +} + +/// Render a search/filter bar with consistent styling +fn render_search_bar( + filter: &str, + filter_active: bool, + placeholder: &str, +) -> (Block<'static>, Line<'static>) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if filter_active { GREEN } else { BORDER })) + .style(Style::default().bg(BG_PANEL)); + + let line = if filter_active { + Line::from(vec![ + Span::styled(" [/] ", Style::default().fg(GREEN)), + Span::styled(filter.to_string(), Style::default().fg(TEXT_PRIMARY)), + Span::styled(CURSOR, Style::default().fg(GREEN)), + ]) + } else if filter.is_empty() { + Line::from(vec![Span::styled( + format!(" [/] {}...", placeholder), + Style::default().fg(TEXT_MUTED), + )]) + } else { + Line::from(vec![ + Span::styled(" [/] ", Style::default().fg(TEXT_MUTED)), + Span::styled(filter.to_string(), Style::default().fg(TEXT_PRIMARY)), + ]) + }; + + (block, line) +} + +pub fn draw(f: &mut Frame, app: &App) { + let area = f.area(); + f.render_widget(Block::default().style(Style::default().bg(BG_DARK)), area); + if matches!(app.screen, Screen::Splash) { + draw_splash(f, area); + return; + } + if matches!(app.screen, Screen::Setup(_)) { + if let Screen::Setup(state) = &app.screen { + draw_setup(f, area, state); + } + return; + } + let show_tabs = matches!( + app.screen, + Screen::Basins(_) | Screen::AccessTokens(_) | Screen::Settings(_) + ); + + let chunks = if show_tabs { + Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(1), // Tab bar + Constraint::Min(3), + Constraint::Length(1), // Status bar + ]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(0), // No tab bar + Constraint::Min(3), + Constraint::Length(1), // Status bar + ]) + .split(area) + }; + if show_tabs { + draw_tab_bar(f, chunks[0], app.tab); + } + match &app.screen { + Screen::Splash => unreachable!(), + Screen::Setup(_) => unreachable!(), // Handled above + Screen::Basins(state) => draw_basins(f, chunks[1], state), + Screen::Streams(state) => draw_streams(f, chunks[1], state), + Screen::StreamDetail(state) => draw_stream_detail(f, chunks[1], state), + Screen::ReadView(state) => draw_read_view(f, chunks[1], state), + Screen::AppendView(state) => draw_append_view(f, chunks[1], state), + Screen::AccessTokens(state) => draw_access_tokens(f, chunks[1], state), + Screen::MetricsView(state) => draw_metrics_view(f, chunks[1], state), + Screen::Settings(state) => draw_settings(f, chunks[1], state), + Screen::BenchView(state) => draw_bench_view(f, chunks[1], state), + } + if let Screen::MetricsView(state) = &app.screen { + if state.time_picker_open { + draw_time_picker(f, state); + } + if state.calendar_open { + draw_calendar_picker(f, state); + } + } + draw_status_bar(f, chunks[2], app); + if app.show_help { + draw_help_overlay(f, &app.screen); + } + if !matches!(app.input_mode, InputMode::Normal) { + draw_input_dialog(f, &app.input_mode); + } + // Draw PiP overlay last so it's on top + if let Some(ref pip) = app.pip { + if !pip.minimized { + draw_pip(f, pip); + } else { + draw_pip_minimized(f, pip); + } + } +} + +fn draw_splash(f: &mut Frame, area: Rect) { + draw_aurora_background(f, area); + + let mut lines = render_logo(); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Streams as a cloud", + Style::default().fg(WHITE).bold(), + ))); + lines.push(Line::from(Span::styled( + "storage primitive", + Style::default().fg(WHITE).bold(), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "The serverless API for unlimited, durable, real-time streams.", + Style::default().fg(TEXT_MUTED), + ))); + + let content_height = lines.len() as u16; + let y = area.y + area.height.saturating_sub(content_height) / 2; + + let centered_area = Rect::new(area.x, y, area.width, content_height); + let logo_widget = Paragraph::new(lines).alignment(Alignment::Center); + f.render_widget(logo_widget, centered_area); +} + +/// Draw the setup screen (first-time token entry) +fn draw_setup(f: &mut Frame, area: Rect, state: &SetupState) { + draw_aurora_background(f, area); + + let mut lines = render_logo(); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Streams as a cloud storage primitive", + Style::default().fg(WHITE).bold(), + ))); + lines.push(Line::from(Span::styled( + "The serverless API for unlimited, durable, real-time streams.", + Style::default().fg(TEXT_MUTED), + ))); + lines.push(Line::from("")); + lines.push(Line::from("")); + let token_display = if state.access_token.is_empty() { + vec![ + Span::styled("Token ", Style::default().fg(TEXT_MUTED)), + Span::styled("› ", Style::default().fg(BORDER)), + Span::styled(CURSOR, Style::default().fg(CYAN)), + ] + } else { + let display = truncate_str(&state.access_token, 40, "..."); + vec![ + Span::styled("Token ", Style::default().fg(TEXT_MUTED)), + Span::styled("› ", Style::default().fg(GREEN)), + Span::styled(display, Style::default().fg(WHITE)), + Span::styled(CURSOR, Style::default().fg(CYAN)), + ] + }; + lines.push(Line::from(token_display)); + lines.push(Line::from("")); + if let Some(error) = &state.error { + lines.push(Line::from(Span::styled( + error.as_str(), + Style::default().fg(ERROR), + ))); + } else if state.validating { + lines.push(Line::from(Span::styled( + "Validating...", + Style::default().fg(YELLOW), + ))); + } else { + lines.push(Line::from(vec![ + Span::styled("Get token: ", Style::default().fg(TEXT_MUTED)), + Span::styled("s2.dev/dashboard/access-tokens", Style::default().fg(CYAN)), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Enter to continue · Esc to quit", + Style::default().fg(BORDER), + ))); + + let content_height = lines.len() as u16; + let y = area.y + area.height.saturating_sub(content_height) / 2; + let centered_area = Rect::new(area.x, y, area.width, content_height); + + let content = Paragraph::new(lines).alignment(Alignment::Center); + f.render_widget(content, centered_area); +} + +/// Draw the settings screen +fn draw_settings(f: &mut Frame, area: Rect, state: &SettingsState) { + use ratatui::widgets::BorderType; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title bar (consistent height) + Constraint::Min(1), // Content + ]) + .split(area); + let title_block = Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + let title_content = Paragraph::new(Line::from(vec![ + Span::styled(" ⚙ ", Style::default().fg(GREEN)), + Span::styled("Settings", Style::default().fg(TEXT_PRIMARY).bold()), + ])) + .block(title_block); + f.render_widget(title_content, chunks[0]); + let content_area = chunks[1]; + let panel_width = 70.min(content_area.width.saturating_sub(4)); + let panel_x = content_area.x + (content_area.width.saturating_sub(panel_width)) / 2; + let panel_area = Rect::new( + panel_x, + content_area.y + 1, + panel_width, + content_area.height.saturating_sub(2), + ); + + let settings_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)) + .padding(Padding::new(2, 2, 1, 1)); + let inner = settings_block.inner(panel_area); + f.render_widget(settings_block, panel_area); + let field_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), // Access Token (label + 3-line bordered input) + Constraint::Length(4), // Account Endpoint + Constraint::Length(4), // Basin Endpoint + Constraint::Length(3), // Compression (label + pills, no border) + Constraint::Length(1), // Spacer + Constraint::Length(3), // Save button + Constraint::Min(1), // Message/footer + ]) + .split(inner); + + let is_editing_token = state.editing && state.selected == 0; + let token_display = if is_editing_token { + state.access_token.clone() + } else if state.access_token_masked && !state.access_token.is_empty() { + format!("{}...", "*".repeat(20.min(state.access_token.len()))) + } else if state.access_token.is_empty() { + "(not set)".to_string() + } else { + state.access_token.clone() + }; + + draw_settings_field( + f, + field_chunks[0], + "Access Token", + token_display, + state.selected == 0, + is_editing_token, + Some("Space to toggle visibility"), + ); + draw_settings_field( + f, + field_chunks[1], + "Account Endpoint", + if state.account_endpoint.is_empty() { + "(default)".to_string() + } else { + state.account_endpoint.clone() + }, + state.selected == 1, + state.editing && state.selected == 1, + None, + ); + draw_settings_field( + f, + field_chunks[2], + "Basin Endpoint", + if state.basin_endpoint.is_empty() { + "(default)".to_string() + } else { + state.basin_endpoint.clone() + }, + state.selected == 2, + state.editing && state.selected == 2, + None, + ); + let compression_label = Line::from(vec![Span::styled( + "Compression", + Style::default().fg(TEXT_SECONDARY), + )]); + f.render_widget( + Paragraph::new(compression_label), + Rect::new( + field_chunks[3].x, + field_chunks[3].y, + field_chunks[3].width, + 1, + ), + ); + + let options = [ + CompressionOption::None, + CompressionOption::Gzip, + CompressionOption::Zstd, + ]; + let pills: Vec = options + .iter() + .map(|opt| { + let is_selected = *opt == state.compression; + let style = if is_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED).bg(BG_DARK) + }; + Span::styled(format!(" {} ", opt.as_str()), style) + }) + .collect(); + + let mut pill_line = vec![Span::styled(" ", Style::default())]; + for (i, pill) in pills.into_iter().enumerate() { + if i > 0 { + pill_line.push(Span::styled(" ", Style::default())); + } + pill_line.push(pill); + } + if state.selected == 3 { + pill_line.push(Span::styled(" ← h/l →", Style::default().fg(TEXT_MUTED))); + } + + let compression_row = Rect::new( + field_chunks[3].x, + field_chunks[3].y + 1, + field_chunks[3].width, + 1, + ); + f.render_widget( + Paragraph::new(Line::from(pill_line)).style(Style::default().bg(BG_DARK)), + compression_row, + ); + let save_style = if state.selected == 4 { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if state.has_changes { + Style::default().fg(GREEN) + } else { + Style::default().fg(TEXT_MUTED) + }; + let save_text = if state.has_changes { + " ● Save Changes " + } else { + " Save Changes " + }; + let save_button = Paragraph::new(Line::from(Span::styled(save_text, save_style))) + .alignment(Alignment::Center); + f.render_widget(save_button, field_chunks[5]); + if let Some(msg) = &state.message { + let msg_lower = msg.to_lowercase(); + let is_error = msg_lower.contains("error") + || msg_lower.contains("fail") + || msg_lower.contains("invalid"); + let msg_style = if is_error { + Style::default().fg(ERROR) + } else { + Style::default().fg(SUCCESS) + }; + let msg_para = Paragraph::new(Line::from(Span::styled(msg.as_str(), msg_style))) + .alignment(Alignment::Center); + f.render_widget(msg_para, field_chunks[6]); + } else { + let footer = Paragraph::new(Line::from(Span::styled( + "j/k navigate • e/Enter edit • r reload", + Style::default().fg(TEXT_MUTED), + ))) + .alignment(Alignment::Center); + f.render_widget(footer, field_chunks[6]); + } +} + +/// Helper to draw a settings field +fn draw_settings_field( + f: &mut Frame, + area: Rect, + label: &str, + value: String, + selected: bool, + editing: bool, + hint: Option<&str>, +) { + let label_line = Line::from(vec![ + Span::styled(label, Style::default().fg(TEXT_SECONDARY)), + if let Some(h) = hint { + Span::styled(format!(" ({})", h), Style::default().fg(TEXT_MUTED)) + } else { + Span::raw("") + }, + ]); + f.render_widget( + Paragraph::new(label_line), + Rect::new(area.x, area.y, area.width, 1), + ); + + let border_style = if selected { + Style::default().fg(GREEN) + } else { + Style::default().fg(BORDER) + }; + + let value_display = if editing { + format!("{}█", value) + } else { + value + }; + + let value_style = if value_display.starts_with('(') { + Style::default().fg(TEXT_MUTED) + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + let value_block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .style(Style::default().bg(BG_DARK)); + let value_para = Paragraph::new(Span::styled(value_display, value_style)) + .block(value_block) + .wrap(ratatui::widgets::Wrap { trim: false }); + + f.render_widget(value_para, Rect::new(area.x, area.y + 1, area.width, 3)); +} + +/// Draw a subtle aurora/gradient background effect +/// Optimized to compute one color per row (at center) rather than per-cell +fn draw_aurora_background(f: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + + let height = area.height as f64; + + for row in 0..area.height { + let y = row as f64 / height; + + // Build the row string efficiently - all same character + let row_str: String = " ".repeat(area.width as usize); + + // Use the row's center color for performance + // This reduces allocations from O(width) to O(1) per row + let color = aurora_color_at(0.5, y); + + let line = Line::from(Span::styled(row_str, Style::default().bg(color))); + let row_area = Rect::new(area.x, area.y + row, area.width, 1); + f.render_widget(Paragraph::new(line), row_area); + } +} + +/// Compute aurora color at normalized coordinates (0.0-1.0) +fn aurora_color_at(x: f64, y: f64) -> Color { + // Distance from bottom-right corner + let dist_br = ((x - 0.8).powi(2) + (y - 0.9).powi(2)).sqrt(); + // Distance from center-bottom + let dist_cb = ((x - 0.5).powi(2) + (y - 0.85).powi(2)).sqrt(); + + // Aurora intensity (stronger near bottom) + let intensity_br = (1.0 - dist_br * 1.5).max(0.0) * 0.4; + let intensity_cb = (1.0 - dist_cb * 1.8).max(0.0) * 0.3; + let intensity = (intensity_br + intensity_cb).min(1.0); + + // Base dark color with subtle blue/teal tint + let base_r: i32 = 8; + let base_g: i32 = 12; + let base_b: i32 = 18; + + // Aurora colors (teal/cyan) + let aurora_r: i32 = 0; + let aurora_g: i32 = 40; + let aurora_b: i32 = 60; + + let r = (base_r as f64 + (aurora_r - base_r) as f64 * intensity) as u8; + let g = (base_g as f64 + (aurora_g - base_g) as f64 * intensity) as u8; + let b = (base_b as f64 + (aurora_b - base_b) as f64 * intensity) as u8; + + Color::Rgb(r, g, b) +} + +fn draw_tab_bar(f: &mut Frame, area: Rect, current_tab: Tab) { + let basins_style = if current_tab == Tab::Basins { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + + let tokens_style = if current_tab == Tab::AccessTokens { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + + let settings_style = if current_tab == Tab::Settings { + Style::default().fg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + + let line = Line::from(vec![ + Span::styled("Basins", basins_style), + Span::styled(" │ ", Style::default().fg(BORDER)), + Span::styled("Access Tokens", tokens_style), + Span::styled(" │ ", Style::default().fg(BORDER)), + Span::styled("Settings", settings_style), + Span::styled(" (Tab to switch)", Style::default().fg(TEXT_MUTED)), + ]); + + let paragraph = Paragraph::new(line); + f.render_widget(paragraph, area); +} + +fn draw_access_tokens(f: &mut Frame, area: Rect, state: &AccessTokensState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title bar (consistent height) + Constraint::Length(3), // Search bar + Constraint::Length(2), // Header + Constraint::Min(1), // Table rows + ]) + .split(area); + let count_text = if state.loading { + " loading...".to_string() + } else { + let filtered_count = state + .tokens + .iter() + .filter(|t| { + state.filter.is_empty() + || t.id.to_lowercase().contains(&state.filter.to_lowercase()) + }) + .count(); + if filtered_count != state.tokens.len() { + format!(" {}/{} tokens", filtered_count, state.tokens.len()) + } else { + format!(" {} tokens", state.tokens.len()) + } + }; + let title_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Access Tokens", Style::default().fg(GREEN).bold()), + Span::styled(&count_text, Style::default().fg(GRAY_700)), + ]), + ]; + let title_block = Paragraph::new(title_lines).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(GRAY_800)), + ); + f.render_widget(title_block, chunks[0]); + + let (search_block, search_text) = + render_search_bar(&state.filter, state.filter_active, "Filter by token ID"); + f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); + + let header = Line::from(vec![ + Span::styled(" ", Style::default()), // Space for selection prefix + Span::styled( + format!("{:<30}", "TOKEN ID"), + Style::default().fg(TEXT_MUTED).bold(), + ), + Span::styled( + format!("{:<28}", "EXPIRES AT"), + Style::default().fg(TEXT_MUTED).bold(), + ), + Span::styled("SCOPE", Style::default().fg(TEXT_MUTED).bold()), + ]); + let header_para = Paragraph::new(header); + f.render_widget(header_para, chunks[2]); + + let filtered_tokens: Vec<_> = state + .tokens + .iter() + .filter(|t| { + state.filter.is_empty() + || t.id + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) + .collect(); + + if state.loading { + let loading = Paragraph::new(Line::from(Span::styled( + "Loading access tokens...", + Style::default().fg(TEXT_MUTED), + ))); + f.render_widget(loading, chunks[3]); + } else if filtered_tokens.is_empty() { + let empty_msg = if state.tokens.is_empty() { + "No access tokens yet. Press c to issue your first token." + } else { + "No tokens match the filter. Press Esc to clear." + }; + let empty = Paragraph::new(Line::from(Span::styled( + empty_msg, + Style::default().fg(TEXT_MUTED), + ))); + f.render_widget(empty, chunks[3]); + } else { + let table_area = chunks[3]; + let visible_height = table_area.height as usize; + let total = filtered_tokens.len(); + let selected = state.selected.min(total.saturating_sub(1)); + + let scroll_offset = if selected >= visible_height { + selected - visible_height + 1 + } else { + 0 + }; + + for (view_idx, token) in filtered_tokens + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + { + let y = table_area.y + (view_idx - scroll_offset) as u16; + if y >= table_area.y + table_area.height { + break; + } + + let is_selected = view_idx == selected; + let row_area = Rect::new(table_area.x, y, table_area.width, 1); + + if is_selected { + f.render_widget( + Block::default().style(Style::default().bg(BG_SELECTED)), + row_area, + ); + } + + let scope_summary = format_scope_summary(token); + let prefix = if is_selected { "▸ " } else { " " }; + let token_id_str = token.id.to_string(); + let token_id_display = truncate_str(&token_id_str, 28, "…"); + let expires_str = token.expires_at.to_string(); + let expires_display = truncate_str(&expires_str, 26, "…"); + + let name_style = if is_selected { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + let line = Line::from(vec![ + Span::styled( + prefix, + Style::default().fg(if is_selected { GREEN } else { TEXT_PRIMARY }), + ), + Span::styled(format!("{:<30}", token_id_display), name_style), + Span::styled( + format!("{:<28}", expires_display), + Style::default().fg(TEXT_MUTED), + ), + Span::styled(scope_summary, Style::default().fg(TEXT_MUTED)), + ]); + + f.render_widget(Paragraph::new(line), row_area); + } + } +} + +/// Format a summary of the token scope +fn format_scope_summary(token: &s2_sdk::types::AccessTokenInfo) -> String { + let ops_count = token.scope.ops.len(); + let has_basins = token.scope.basins.is_some(); + let has_streams = token.scope.streams.is_some(); + + let mut parts = vec![format!("{} ops", ops_count)]; + if has_basins { + parts.push("basins".to_string()); + } + if has_streams { + parts.push("streams".to_string()); + } + parts.join(", ") +} + +/// Format a basin matcher for display +fn format_basin_matcher(matcher: &Option) -> String { + use s2_sdk::types::BasinMatcher; + match matcher { + None => "All".to_string(), + Some(BasinMatcher::None) => "None".to_string(), + Some(BasinMatcher::Prefix(p)) => format!("Prefix: {}", p), + Some(BasinMatcher::Exact(e)) => format!("Exact: {}", e), + } +} + +/// Format a stream matcher for display +fn format_stream_matcher(matcher: &Option) -> String { + use s2_sdk::types::StreamMatcher; + match matcher { + None => "All".to_string(), + Some(StreamMatcher::None) => "None".to_string(), + Some(StreamMatcher::Prefix(p)) => format!("Prefix: {}", p), + Some(StreamMatcher::Exact(e)) => format!("Exact: {}", e), + } +} + +/// Format an access token matcher for display +fn format_token_matcher(matcher: &Option) -> String { + use s2_sdk::types::AccessTokenMatcher; + match matcher { + None => "All".to_string(), + Some(AccessTokenMatcher::None) => "None".to_string(), + Some(AccessTokenMatcher::Prefix(p)) => format!("Prefix: {}", p), + Some(AccessTokenMatcher::Exact(e)) => format!("Exact: {}", e), + } +} + +/// Format an operation for display +fn format_operation(op: &s2_sdk::types::Operation) -> String { + use s2_sdk::types::Operation as SdkOp; + match op { + SdkOp::ListBasins => "list_basins", + SdkOp::CreateBasin => "create_basin", + SdkOp::DeleteBasin => "delete_basin", + SdkOp::GetBasinConfig => "get_basin_config", + SdkOp::ReconfigureBasin => "reconfigure_basin", + SdkOp::GetBasinMetrics => "get_basin_metrics", + SdkOp::ListStreams => "list_streams", + SdkOp::CreateStream => "create_stream", + SdkOp::DeleteStream => "delete_stream", + SdkOp::GetStreamConfig => "get_stream_config", + SdkOp::ReconfigureStream => "reconfigure_stream", + SdkOp::GetStreamMetrics => "get_stream_metrics", + SdkOp::CheckTail => "check_tail", + SdkOp::Read => "read", + SdkOp::Append => "append", + SdkOp::Fence => "fence", + SdkOp::Trim => "trim", + SdkOp::GetAccountMetrics => "get_account_metrics", + SdkOp::ListAccessTokens => "list_access_tokens", + SdkOp::IssueAccessToken => "issue_access_token", + SdkOp::RevokeAccessToken => "revoke_access_token", + } + .to_string() +} + +/// Check if operation is account-level +fn is_account_op(op: &s2_sdk::types::Operation) -> bool { + use s2_sdk::types::Operation as SdkOp; + matches!(op, SdkOp::ListBasins | SdkOp::GetAccountMetrics) +} + +/// Check if operation is basin-level +fn is_basin_op(op: &s2_sdk::types::Operation) -> bool { + use s2_sdk::types::Operation as SdkOp; + matches!( + op, + SdkOp::CreateBasin + | SdkOp::DeleteBasin + | SdkOp::GetBasinConfig + | SdkOp::ReconfigureBasin + | SdkOp::ListStreams + | SdkOp::GetBasinMetrics + ) +} + +/// Check if operation is stream-level +fn is_stream_op(op: &s2_sdk::types::Operation) -> bool { + use s2_sdk::types::Operation as SdkOp; + matches!( + op, + SdkOp::CreateStream + | SdkOp::DeleteStream + | SdkOp::GetStreamConfig + | SdkOp::ReconfigureStream + | SdkOp::Read + | SdkOp::Append + | SdkOp::CheckTail + | SdkOp::Fence + | SdkOp::Trim + | SdkOp::GetStreamMetrics + ) +} + +/// Check if operation is token-related +fn is_token_op(op: &s2_sdk::types::Operation) -> bool { + use s2_sdk::types::Operation as SdkOp; + matches!( + op, + SdkOp::ListAccessTokens | SdkOp::IssueAccessToken | SdkOp::RevokeAccessToken + ) +} + +fn draw_metrics_view(f: &mut Frame, area: Rect, state: &MetricsViewState) { + use s2_sdk::types::Metric; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title with tabs + Constraint::Length(3), // Stats header row + Constraint::Min(12), + Constraint::Length(6), // Timeline (scrollable) + ]) + .split(area); + let title = match &state.metrics_type { + MetricsType::Account => "Account".to_string(), + MetricsType::Basin { basin_name } => basin_name.to_string(), + MetricsType::Stream { + basin_name, + stream_name, + } => format!("{}/{}", basin_name, stream_name), + }; + + if matches!(state.metrics_type, MetricsType::Account) { + let categories = [MetricCategory::ActiveBasins, MetricCategory::AccountOps]; + + let mut title_spans: Vec = vec![ + Span::styled(" [ ", Style::default().fg(BORDER)), + Span::styled(&title, Style::default().fg(GREEN).bold()), + Span::styled(" ] ", Style::default().fg(BORDER)), + ]; + + for (i, cat) in categories.iter().enumerate() { + if i > 0 { + title_spans.push(Span::styled(" | ", Style::default().fg(BORDER))); + } + let style = if *cat == state.selected_category { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); + } + title_spans.push(Span::styled(" ", Style::default())); + title_spans.push(Span::styled( + format!("[{}]", state.time_range.as_str()), + Style::default().fg(CYAN), + )); + + let title_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title_bottom(Line::from(Span::styled( + " ←/→ category t time picker ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_PANEL)); + + let title_para = Paragraph::new(Line::from(title_spans)) + .block(title_block) + .alignment(Alignment::Center); + f.render_widget(title_para, chunks[0]); + } else if matches!(state.metrics_type, MetricsType::Basin { .. }) { + let categories = [ + MetricCategory::Storage, + MetricCategory::AppendOps, + MetricCategory::ReadOps, + MetricCategory::AppendThroughput, + MetricCategory::ReadThroughput, + MetricCategory::BasinOps, + ]; + + let mut title_spans: Vec = vec![ + Span::styled(" [ ", Style::default().fg(BORDER)), + Span::styled(&title, Style::default().fg(GREEN).bold()), + Span::styled(" ] ", Style::default().fg(BORDER)), + ]; + + for (i, cat) in categories.iter().enumerate() { + if i > 0 { + title_spans.push(Span::styled(" | ", Style::default().fg(BORDER))); + } + let style = if *cat == state.selected_category { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(TEXT_MUTED) + }; + title_spans.push(Span::styled(format!(" {} ", cat.as_str()), style)); + } + title_spans.push(Span::styled(" ", Style::default())); + title_spans.push(Span::styled( + format!("[{}]", state.time_range.as_str()), + Style::default().fg(CYAN), + )); + + let title_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title_bottom(Line::from(Span::styled( + " ←/→ category t time picker ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_PANEL)); + + let title_para = Paragraph::new(Line::from(title_spans)) + .block(title_block) + .alignment(Alignment::Center); + f.render_widget(title_para, chunks[0]); + } else { + let title_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title_bottom(Line::from(Span::styled( + " t time picker ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_PANEL)); + + let title_para = Paragraph::new(Line::from(vec![ + Span::styled(" [ ", Style::default().fg(BORDER)), + Span::styled(&title, Style::default().fg(GREEN).bold()), + Span::styled(" ] ", Style::default().fg(BORDER)), + Span::styled(" Storage ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled(" ", Style::default()), + Span::styled( + format!("[{}]", state.time_range.as_str()), + Style::default().fg(CYAN), + ), + ])) + .block(title_block) + .alignment(Alignment::Center); + f.render_widget(title_para, chunks[0]); + } + if state.loading { + let loading_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_DARK)); + let loading = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "Loading metrics...", + Style::default().fg(TEXT_MUTED), + )), + ]) + .block(loading_block) + .alignment(Alignment::Center); + + let remaining = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1)]) + .split(chunks[1]); + f.render_widget(loading, remaining[0]); + return; + } + + if state.metrics.is_empty() { + let empty_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_DARK)); + let empty = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "No data in the last 24 hours", + Style::default().fg(TEXT_MUTED), + )), + ]) + .block(empty_block) + .alignment(Alignment::Center); + + let remaining = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1)]) + .split(chunks[1]); + f.render_widget(empty, remaining[0]); + return; + } + let mut label_values: Vec = Vec::new(); + let mut label_name = String::new(); + + for metric in &state.metrics { + if let Metric::Label(m) = metric { + label_name = m.name.clone(); + label_values.extend(m.values.iter().cloned()); + } + } + if !label_values.is_empty() { + render_label_metric(f, chunks, &label_name, &label_values, state); + return; + } + let accumulation_metrics: Vec<_> = state + .metrics + .iter() + .filter_map(|m| { + if let Metric::Accumulation(a) = m { + Some(a) + } else { + None + } + }) + .collect(); + + if accumulation_metrics.len() > 1 { + render_multi_metric(f, chunks, &accumulation_metrics, state); + return; + } + let mut all_values: Vec<(u32, f64)> = Vec::new(); + let mut metric_name = String::new(); + let mut metric_unit = s2_sdk::types::MetricUnit::Bytes; + + for metric in &state.metrics { + match metric { + Metric::Gauge(m) => { + metric_name = m.name.clone(); + metric_unit = m.unit; + all_values.extend(m.values.iter().cloned()); + } + Metric::Accumulation(m) => { + metric_name = m.name.clone(); + metric_unit = m.unit; + all_values.extend(m.values.iter().cloned()); + } + Metric::Scalar(m) => { + metric_name = m.name.clone(); + metric_unit = m.unit; + all_values.push((0, m.value)); + } + Metric::Label(_) => {} // Handled above + } + } + + if all_values.is_empty() { + return; + } + all_values.sort_by_key(|(ts, _)| *ts); + let values_only: Vec = all_values.iter().map(|(_, v)| *v).collect(); + let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); + let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); + let avg_val = if !values_only.is_empty() { + values_only.iter().sum::() / values_only.len() as f64 + } else { + 0.0 + }; + let latest_val = values_only.last().cloned().unwrap_or(0.0); + let first_val = values_only.first().cloned().unwrap_or(0.0); + let change = if first_val > 0.0 { + ((latest_val - first_val) / first_val) * 100.0 + } else if latest_val > 0.0 { + 100.0 + } else { + 0.0 + }; + let first_ts = all_values.first().map(|(ts, _)| *ts).unwrap_or(0); + let last_ts = all_values.last().map(|(ts, _)| *ts).unwrap_or(0); + let stats_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + + let stats_inner = stats_block.inner(chunks[1]); + f.render_widget(stats_block, chunks[1]); + let (trend_arrow, trend_color) = if change > 1.0 { + ("↑", GREEN) + } else if change < -1.0 { + ("↓", ERROR) + } else { + ("→", TEXT_MUTED) + }; + let trend_text = if change.abs() > 0.1 { + format!("{:+.1}%", change) + } else { + "stable".to_string() + }; + + let stats_line = Line::from(vec![ + Span::styled(" NOW ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled( + format!(" {} ", format_metric_value_f64(latest_val, metric_unit)), + Style::default().fg(GREEN).bold(), + ), + Span::styled(trend_arrow, Style::default().fg(trend_color).bold()), + Span::styled(format!("{} ", trend_text), Style::default().fg(trend_color)), + Span::styled(" | ", Style::default().fg(BORDER)), + Span::styled("min ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format_metric_value_f64(min_val, metric_unit), + Style::default().fg(STAT_MIN), + ), + Span::styled(" max ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format_metric_value_f64(max_val, metric_unit), + Style::default().fg(STAT_MAX), + ), + Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format_metric_value_f64(avg_val, metric_unit), + Style::default().fg(STAT_AVG), + ), + Span::styled( + format!(" | {} pts", all_values.len()), + Style::default().fg(TEXT_MUTED), + ), + ]); + let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); + f.render_widget(stats_para, stats_inner); + let chart_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(&metric_name, Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .style(Style::default().bg(BG_DARK)); + + let chart_inner = chart_block.inner(chunks[2]); + f.render_widget(chart_block, chunks[2]); + render_area_chart( + f, + chart_inner, + &all_values, + min_val, + max_val, + metric_unit, + first_ts, + last_ts, + ); + let timeline_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled(" Data Points ", Style::default().fg(TEXT_PRIMARY)), + Span::styled( + format!("[{}/{}]", state.scroll + 1, all_values.len()), + Style::default().fg(TEXT_MUTED), + ), + ])) + .title_bottom(Line::from(Span::styled( + " j/k scroll ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_DARK)); + + let timeline_inner = timeline_block.inner(chunks[3]); + f.render_widget(timeline_block, chunks[3]); + let bar_width = timeline_inner.width.saturating_sub(26) as usize; + let visible_rows = timeline_inner.height as usize; + + let bars: Vec = all_values + .iter() + .skip(state.scroll) + .take(visible_rows) + .map(|(ts, value)| { + let bar_len = if max_val > 0.0 { + ((*value / max_val) * bar_width as f64) as usize + } else { + 0 + }; + let intensity = if max_val > 0.0 { *value / max_val } else { 0.0 }; + let bar_color = intensity_to_color(intensity); + + let bar: String = (0..bar_len) + .map(|i| { + let pos = i as f64 / bar_len.max(1) as f64; + if pos > 0.9 { + '█' + } else if pos > 0.7 { + '▓' + } else if pos > 0.4 { + '▒' + } else { + '░' + } + }) + .collect(); + + let time_str = format_metric_timestamp_short(*ts); + + Line::from(vec![ + Span::styled( + format!(" {:>8} ", time_str), + Style::default().fg(TEXT_MUTED), + ), + Span::styled(bar, Style::default().fg(bar_color)), + Span::styled( + format!(" {:>10}", format_metric_value_f64(*value, metric_unit)), + Style::default().fg(TEXT_SECONDARY), + ), + ]) + }) + .collect(); + + let bars_para = Paragraph::new(bars); + f.render_widget(bars_para, timeline_inner); +} + +/// Convert intensity (0.0-1.0) to a green gradient color +fn intensity_to_color(intensity: f64) -> Color { + if intensity > 0.8 { + GREEN_BRIGHT + } else if intensity > 0.6 { + GREEN_LIGHT + } else if intensity > 0.4 { + GREEN_LIGHTER + } else if intensity > 0.2 { + GREEN_PALE + } else { + GREEN_PALEST + } +} + +/// Render multiple accumulation metrics (like Account Ops breakdown) +fn render_multi_metric( + f: &mut Frame, + chunks: std::rc::Rc<[Rect]>, + metrics: &[&s2_sdk::types::AccumulationMetric], + state: &MetricsViewState, +) { + use std::collections::BTreeMap; + let colors = [ + CHART_PURPLE, + CHART_VIOLET, + CHART_INDIGO, + CHART_DEEP_INDIGO, + CHART_BLUE, + CHART_ROYAL_BLUE, + CHART_LIGHT_BLUE, + CHART_PALE_BLUE, + CHART_YELLOW, + CHART_ORANGE, + ]; + let mut metric_totals: Vec<(String, f64, usize)> = metrics + .iter() + .enumerate() + .map(|(i, m)| { + let total: f64 = m.values.iter().map(|(_, v)| v).sum(); + (m.name.clone(), total, i) + }) + .collect(); + metric_totals.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + let mut time_buckets: BTreeMap = BTreeMap::new(); + for metric in metrics.iter() { + for (ts, val) in &metric.values { + *time_buckets.entry(*ts).or_default() += val; + } + } + + let all_values: Vec<(u32, f64)> = time_buckets.into_iter().collect(); + let values_only: Vec = all_values.iter().map(|(_, v)| *v).collect(); + + let grand_total: f64 = values_only.iter().sum(); + let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); + let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); + let avg_val = if !values_only.is_empty() { + grand_total / values_only.len() as f64 + } else { + 0.0 + }; + let latest_val = values_only.last().cloned().unwrap_or(0.0); + let first_val = values_only.first().cloned().unwrap_or(0.0); + let first_ts = all_values.first().map(|(ts, _)| *ts).unwrap_or(0); + let last_ts = all_values.last().map(|(ts, _)| *ts).unwrap_or(0); + let change = if first_val > 0.0 { + ((latest_val - first_val) / first_val) * 100.0 + } else if latest_val > 0.0 { + 100.0 + } else { + 0.0 + }; + let stats_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + + let stats_inner = stats_block.inner(chunks[1]); + f.render_widget(stats_block, chunks[1]); + + let (trend_arrow, trend_color) = if change > 1.0 { + ("↑", GREEN) + } else if change < -1.0 { + ("↓", ERROR) + } else { + ("→", TEXT_MUTED) + }; + let trend_text = if change.abs() > 0.1 { + format!("{:+.1}%", change) + } else { + "stable".to_string() + }; + + let stats_line = Line::from(vec![ + Span::styled(" NOW ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled( + format!(" {} ", format_count(latest_val as u64)), + Style::default().fg(GREEN).bold(), + ), + Span::styled(trend_arrow, Style::default().fg(trend_color).bold()), + Span::styled(format!("{} ", trend_text), Style::default().fg(trend_color)), + Span::styled(" | ", Style::default().fg(BORDER)), + Span::styled("min ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_count(min_val as u64), Style::default().fg(STAT_MIN)), + Span::styled(" max ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_count(max_val as u64), Style::default().fg(STAT_MAX)), + Span::styled(" avg ", Style::default().fg(TEXT_MUTED)), + Span::styled(format_count(avg_val as u64), Style::default().fg(STAT_AVG)), + Span::styled( + format!(" | {} pts", all_values.len()), + Style::default().fg(TEXT_MUTED), + ), + ]); + let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); + f.render_widget(stats_para, stats_inner); + let chart_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Total Operations", Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .style(Style::default().bg(BG_DARK)); + + let chart_inner = chart_block.inner(chunks[2]); + f.render_widget(chart_block, chunks[2]); + + if !all_values.is_empty() { + render_area_chart( + f, + chart_inner, + &all_values, + min_val, + max_val, + s2_sdk::types::MetricUnit::Operations, + first_ts, + last_ts, + ); + } + let timeline_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled(" Breakdown ", Style::default().fg(TEXT_PRIMARY)), + Span::styled( + format!("({} operation types)", metrics.len()), + Style::default().fg(TEXT_MUTED), + ), + ])) + .title_bottom(Line::from(Span::styled( + " j/k scroll ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_DARK)); + + let timeline_inner = timeline_block.inner(chunks[3]); + f.render_widget(timeline_block, chunks[3]); + let visible_rows = timeline_inner.height as usize; + let bar_width = timeline_inner.width.saturating_sub(28) as usize; + let max_total = metric_totals.iter().map(|(_, t, _)| *t).fold(0.0, f64::max); + + let lines: Vec = metric_totals + .iter() + .enumerate() + .skip(state.scroll) + .take(visible_rows) + .map(|(_i, (name, total, orig_idx))| { + let color = colors[*orig_idx % colors.len()]; + let bar_len = if max_total > 0.0 { + ((total / max_total) * bar_width as f64) as usize + } else { + 0 + }; + + let bar: String = "█".repeat(bar_len); + let percentage = if grand_total > 0.0 { + (total / grand_total) * 100.0 + } else { + 0.0 + }; + + let display_name = name.replace('_', " "); + let display_name = if display_name.len() > 14 { + format!("{}…", &display_name[..13]) + } else { + display_name + }; + + Line::from(vec![ + Span::styled( + format!(" {:>14} ", display_name), + Style::default().fg(TEXT_PRIMARY), + ), + Span::styled(bar, Style::default().fg(color)), + Span::styled( + format!(" {:>6} ({:>4.1}%)", format_count(*total as u64), percentage), + Style::default().fg(TEXT_SECONDARY), + ), + ]) + }) + .collect(); + + let bars_para = Paragraph::new(lines); + f.render_widget(bars_para, timeline_inner); +} + +/// Render a label metric (list of string values, like Active Basins) +fn render_label_metric( + f: &mut Frame, + chunks: std::rc::Rc<[Rect]>, + metric_name: &str, + values: &[String], + state: &MetricsViewState, +) { + let stats_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + + let stats_inner = stats_block.inner(chunks[1]); + f.render_widget(stats_block, chunks[1]); + + let stats_line = Line::from(vec![ + Span::styled(" TOTAL ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + Span::styled( + format!(" {} ", values.len()), + Style::default().fg(GREEN).bold(), + ), + Span::styled( + format!(" {} in selected time range", metric_name.to_lowercase()), + Style::default().fg(TEXT_MUTED), + ), + ]); + let stats_para = Paragraph::new(stats_line).alignment(Alignment::Center); + f.render_widget(stats_para, stats_inner); + let list_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(metric_name, Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .style(Style::default().bg(BG_DARK)); + + let list_inner = list_block.inner(chunks[2]); + f.render_widget(list_block, chunks[2]); + + if values.is_empty() { + let empty = Paragraph::new(Span::styled("No data", Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget(empty, list_inner); + } else { + let visible_rows = list_inner.height as usize; + let total_items = values.len(); + + let items: Vec = values + .iter() + .enumerate() + .skip(state.scroll) + .take(visible_rows) + .map(|(i, value)| { + Line::from(vec![ + Span::styled(format!(" {:>3}. ", i + 1), Style::default().fg(TEXT_MUTED)), + Span::styled(value, Style::default().fg(GREEN)), + ]) + }) + .collect(); + + let list_para = Paragraph::new(items); + f.render_widget(list_para, list_inner); + let scroll_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![Span::styled( + format!( + " Showing {}-{} of {} ", + state.scroll + 1, + (state.scroll + visible_rows).min(total_items), + total_items + ), + Style::default().fg(TEXT_MUTED), + )])) + .title_bottom(Line::from(Span::styled( + " j/k scroll ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_DARK)); + + f.render_widget(scroll_block, chunks[3]); + } +} + +/// Render a beautiful area chart with Y-axis, filled area, and X-axis +#[allow(clippy::too_many_arguments)] +fn render_area_chart( + f: &mut Frame, + area: Rect, + values: &[(u32, f64)], + min_val: f64, + max_val: f64, + unit: s2_sdk::types::MetricUnit, + first_ts: u32, + last_ts: u32, +) { + let height = area.height.saturating_sub(1) as usize; // Leave room for X-axis + let y_axis_width = 10u16; + let width = area.width.saturating_sub(y_axis_width + 1) as usize; + + if height < 2 || width < 10 { + return; + } + let chart_min = if min_val > 0.0 { 0.0 } else { min_val }; + let chart_max = max_val * 1.1; // 10% headroom + let chart_range = chart_max - chart_min; + let values_only: Vec = values.iter().map(|(_, v)| *v).collect(); + let step = values_only.len() as f64 / width as f64; + + // Build the chart row by row (top to bottom) + let mut lines: Vec = Vec::new(); + + // Block characters for smooth area fill + // Using vertical eighths: ' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█' + let fill_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + for row in 0..height { + let y_frac_top = 1.0 - (row as f64 / height as f64); + let y_frac_bot = 1.0 - ((row + 1) as f64 / height as f64); + + // Y-axis label (only on certain rows) + let y_label: String = if row == 0 { + format!("{:>9} ", format_metric_value_f64(chart_max, unit)) + } else if row == height / 2 { + format!( + "{:>9} ", + format_metric_value_f64((chart_max + chart_min) / 2.0, unit) + ) + } else if row == height - 1 { + format!("{:>9} ", format_metric_value_f64(chart_min, unit)) + } else { + " ".to_string() + }; + + let mut spans: Vec = vec![Span::styled(y_label, Style::default().fg(TEXT_MUTED))]; + + // Draw each column + for col in 0..width { + let idx = ((col as f64) * step) as usize; + let val = values_only.get(idx).cloned().unwrap_or(0.0); + + // Normalize value to chart coordinates + let val_norm = (val - chart_min) / chart_range; + let val_y = val_norm; // 0.0 = bottom, 1.0 = top + + // Determine what character to draw + let char_and_color = if val_y >= y_frac_top { + // Value is above this row - full fill + ('█', intensity_to_color(val_norm)) + } else if val_y > y_frac_bot { + // Value is within this row - partial fill + let fill_frac = (val_y - y_frac_bot) / (y_frac_top - y_frac_bot); + let char_idx = (fill_frac * 8.0) as usize; + (fill_chars[char_idx.min(8)], intensity_to_color(val_norm)) + } else { + // Value is below this row - empty or grid + if col % 10 == 0 { + ('·', GRAY_750) + } else { + (' ', BG_DARK) + } + }; + + spans.push(Span::styled( + char_and_color.0.to_string(), + Style::default().fg(char_and_color.1), + )); + } + + lines.push(Line::from(spans)); + } + + // X-axis with time labels + let first_time = format_metric_timestamp_short(first_ts); + let last_time = format_metric_timestamp_short(last_ts); + let mid_ts = first_ts + (last_ts - first_ts) / 2; + let mid_time = format_metric_timestamp_short(mid_ts); + + let x_axis_padding = " ".repeat(y_axis_width as usize); + + let mut x_axis_spans = vec![ + Span::styled(&x_axis_padding, Style::default()), + Span::styled(&first_time, Style::default().fg(TEXT_MUTED)), + ]; + + let remaining_after_first = width.saturating_sub(first_time.len() + mid_time.len() / 2); + let padding_to_mid = remaining_after_first / 2; + x_axis_spans.push(Span::styled(" ".repeat(padding_to_mid), Style::default())); + x_axis_spans.push(Span::styled(&mid_time, Style::default().fg(TEXT_MUTED))); + + let remaining_after_mid = + width.saturating_sub(first_time.len() + padding_to_mid + mid_time.len() + last_time.len()); + x_axis_spans.push(Span::styled( + " ".repeat(remaining_after_mid), + Style::default(), + )); + x_axis_spans.push(Span::styled(&last_time, Style::default().fg(TEXT_MUTED))); + + lines.push(Line::from(x_axis_spans)); + + let chart_para = Paragraph::new(lines); + f.render_widget(chart_para, area); +} + +/// Render a sparkline with gradient coloring (unused but kept for reference) +#[allow(dead_code)] +fn render_sparkline_gradient(values: &[(u32, f64)], width: usize) -> String { + if values.is_empty() { + return "-".repeat(width); + } + let spark_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + let values_only: Vec = values.iter().map(|(_, v)| *v).collect(); + let min_val = values_only.iter().cloned().fold(f64::MAX, f64::min); + let max_val = values_only.iter().cloned().fold(f64::MIN, f64::max); + let range = max_val - min_val; + let step = values_only.len() as f64 / width as f64; + let mut sparkline = String::new(); + + for i in 0..width { + let idx = (i as f64 * step) as usize; + let val = values_only.get(idx).cloned().unwrap_or(0.0); + + let normalized = if range > 0.0 { + ((val - min_val) / range).clamp(0.0, 1.0) + } else { + 0.5 + }; + + let char_idx = (normalized * (spark_chars.len() - 1) as f64) as usize; + sparkline.push(spark_chars[char_idx]); + } + + sparkline +} +/// Format timestamp in short form for bar chart +fn format_metric_timestamp_short(ts: u32) -> String { + use std::time::{Duration, UNIX_EPOCH}; + let time = UNIX_EPOCH + Duration::from_secs(ts as u64); + + humantime::format_rfc3339_seconds(time) + .to_string() + .chars() + .skip(11) // Skip date portion + .take(8) // Take HH:MM:SS + .collect() +} + +/// Format a metric value (f64) with appropriate unit +fn format_metric_value_f64(value: f64, unit: s2_sdk::types::MetricUnit) -> String { + use s2_sdk::types::MetricUnit; + match unit { + MetricUnit::Bytes => format_bytes(value as u64), + MetricUnit::Operations => format_count(value as u64), + } +} + +/// Format bytes with appropriate unit +fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + const TB: u64 = GB * 1024; + + if bytes >= TB { + format!("{:.2} TB", bytes as f64 / TB as f64) + } else if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + +/// Format count with K/M suffixes +fn format_count(count: u64) -> String { + if count >= 1_000_000 { + format!("{:.1}M", count as f64 / 1_000_000.0) + } else if count >= 1_000 { + format!("{:.1}K", count as f64 / 1_000.0) + } else { + count.to_string() + } +} + +/// Draw time range picker popup +fn draw_time_picker(f: &mut Frame, state: &MetricsViewState) { + use super::app::TimeRangeOption; + + let area = f.area(); + + let item_count = TimeRangeOption::PRESETS.len() + 1; + + let popup_width = 30u16; + let popup_height = (item_count as u16) + 4; // Items + borders + title + + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); + + f.render_widget(Clear, popup_area); + + let mut items: Vec = TimeRangeOption::PRESETS + .iter() + .enumerate() + .map(|(i, option)| { + let is_selected = i == state.time_picker_selected; + let is_current = + std::mem::discriminant(option) == std::mem::discriminant(&state.time_range); + + let style = if is_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if is_current { + Style::default().fg(GREEN) + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + let marker = if is_current { " ✓" } else { "" }; + ListItem::new(format!(" {}{} ", option.as_label(), marker)).style(style) + }) + .collect(); + + let custom_index = TimeRangeOption::PRESETS.len(); + let is_custom_selected = state.time_picker_selected == custom_index; + let is_custom_current = matches!(state.time_range, TimeRangeOption::Custom { .. }); + let custom_style = if is_custom_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if is_custom_current { + Style::default().fg(GREEN) + } else { + Style::default().fg(CYAN) + }; + let custom_marker = if is_custom_current { " ✓" } else { "" }; + items.push(ListItem::new(format!(" Custom range...{} ", custom_marker)).style(custom_style)); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Time Range", Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .title_bottom(Line::from(Span::styled( + " Enter select Esc close ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_PANEL)), + ); + + f.render_widget(list, popup_area); +} + +/// Draw calendar date picker +fn draw_calendar_picker(f: &mut Frame, state: &MetricsViewState) { + use chrono::{Datelike, Local, NaiveDate}; + + let area = f.area(); + + let popup_width = 36u16; + let popup_height = 14u16; + + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); + + f.render_widget(Clear, popup_area); + + // Month names + let month_names = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + let month_name = month_names + .get(state.calendar_month.saturating_sub(1) as usize) + .unwrap_or(&"???"); + + // Calculate first day of month and days in month (with safe fallbacks) + let today = Local::now().date_naive(); + let first_of_month = NaiveDate::from_ymd_opt(state.calendar_year, state.calendar_month, 1) + .unwrap_or(today.with_day(1).unwrap_or(today)); + let first_weekday = first_of_month.weekday().num_days_from_sunday() as usize; + let days_in_month = { + let next_month = if state.calendar_month == 12 { + NaiveDate::from_ymd_opt(state.calendar_year + 1, 1, 1) + } else { + NaiveDate::from_ymd_opt(state.calendar_year, state.calendar_month + 1, 1) + }; + next_month + .and_then(|d| d.pred_opt()) + .map(|d| d.day()) + .unwrap_or(28) // Safe fallback - February minimum + }; + + // Build calendar lines + let mut lines: Vec = Vec::new(); + + // Month/Year header with navigation hints + lines.push(Line::from(vec![ + Span::styled(" [", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("{} {}", month_name, state.calendar_year), + Style::default().fg(GREEN).bold(), + ), + Span::styled("] ", Style::default().fg(TEXT_MUTED)), + ])); + + // Day headers + lines.push(Line::from(vec![ + Span::styled(" Su ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Mo ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Tu ", Style::default().fg(TEXT_MUTED)), + Span::styled(" We ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Th ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Fr ", Style::default().fg(TEXT_MUTED)), + Span::styled(" Sa ", Style::default().fg(TEXT_MUTED)), + ])); + + // Calendar grid + let mut day = 1u32; + for week in 0..6 { + let mut spans: Vec = Vec::new(); + for weekday in 0..7 { + let cell_idx = week * 7 + weekday; + if cell_idx < first_weekday || day > days_in_month { + spans.push(Span::styled(" ", Style::default())); + } else { + let is_selected = day == state.calendar_day; + let current_date = (state.calendar_year, state.calendar_month, day); + + // Check if this day is the start or end of selection + let is_start = state.calendar_start == Some(current_date); + let is_end = state.calendar_end == Some(current_date); + + // Check if this day is in the selected range + let in_range = match (state.calendar_start, state.calendar_end) { + (Some(start), Some(end)) => { + let (s, e) = if start <= end { + (start, end) + } else { + (end, start) + }; + current_date >= s && current_date <= e + } + (Some(start), None) if state.calendar_selecting_end => { + // Show range preview while selecting end + let (s, e) = if start <= current_date { + (start, current_date) + } else { + (current_date, start) + }; + current_date >= s && current_date <= e && is_selected + } + _ => false, + }; + + let style = if is_selected { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else if is_start || is_end { + Style::default().fg(BG_DARK).bg(CYAN).bold() + } else if in_range { + Style::default().fg(GREEN).bg(BG_PANEL) + } else { + Style::default().fg(TEXT_PRIMARY) + }; + + spans.push(Span::styled(format!("{:>3} ", day), style)); + day += 1; + } + } + lines.push(Line::from(spans)); + if day > days_in_month { + break; + } + } + + // Selection status + let status = match (state.calendar_start, state.calendar_end) { + (Some((sy, sm, sd)), Some((ey, em, ed))) => { + format!("{:02}/{:02}/{} - {:02}/{:02}/{}", sm, sd, sy, em, ed, ey) + } + (Some((sy, sm, sd)), None) => { + if state.calendar_selecting_end { + format!("{:02}/{:02}/{} - select end", sm, sd, sy) + } else { + "Select start date".to_string() + } + } + _ => "Select start date".to_string(), + }; + lines.push(Line::from(Span::styled( + format!(" {} ", status), + Style::default().fg(CYAN), + ))); + + let calendar_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .title(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled("Select Date Range", Style::default().fg(GREEN).bold()), + Span::styled(" ", Style::default()), + ])) + .title_bottom(Line::from(Span::styled( + " ←→↑↓ nav [/] month Enter select Esc cancel ", + Style::default().fg(TEXT_MUTED), + ))) + .style(Style::default().bg(BG_PANEL)); + + let calendar_para = Paragraph::new(lines) + .block(calendar_block) + .alignment(Alignment::Center); + + f.render_widget(calendar_para, popup_area); +} + +fn draw_basins(f: &mut Frame, area: Rect, state: &BasinsState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title bar (consistent height) + Constraint::Length(3), // Search bar + Constraint::Length(2), // Header + Constraint::Min(1), // Table rows + ]) + .split(area); + let count_text = if state.loading { + " loading...".to_string() + } else { + let filtered_count = state + .basins + .iter() + .filter(|b| { + state.filter.is_empty() + || b.name + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) + .count(); + if filtered_count != state.basins.len() { + format!(" {}/{} basins", filtered_count, state.basins.len()) + } else { + format!(" {} basins", state.basins.len()) + } + }; + let title_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Basins", Style::default().fg(GREEN).bold()), + Span::styled(&count_text, Style::default().fg(GRAY_700)), + ]), + ]; + let title_block = Paragraph::new(title_lines).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(GRAY_800)), + ); + f.render_widget(title_block, chunks[0]); + + let (search_block, search_text) = + render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); + f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); + + let header_area = chunks[2]; + let total_width = header_area.width as usize; + let state_col = 12; + let scope_col = 16; + let name_col = total_width.saturating_sub(state_col + scope_col + 4); + + let header = Line::from(vec![ + Span::styled( + format!(" {: = state + .basins + .iter() + .filter(|b| { + state.filter.is_empty() + || b.name + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) + .collect(); + + let table_area = chunks[3]; + + if filtered.is_empty() && !state.loading { + let msg = if state.filter.is_empty() { + "No basins yet. Press c to create your first basin." + } else { + "No basins match the filter. Press Esc to clear." + }; + let text = Paragraph::new(Span::styled(msg, Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget( + text, + Rect::new(table_area.x, table_area.y + 2, table_area.width, 1), + ); + return; + } + + if state.loading { + let text = Paragraph::new(Span::styled( + "Loading basins...", + Style::default().fg(TEXT_MUTED), + )) + .alignment(Alignment::Center); + f.render_widget( + text, + Rect::new(table_area.x, table_area.y + 2, table_area.width, 1), + ); + return; + } + + let visible_height = table_area.height as usize; + let total = filtered.len(); + let selected = state.selected.min(total.saturating_sub(1)); + + let scroll_offset = if selected >= visible_height { + selected - visible_height + 1 + } else { + 0 + }; + + for (view_idx, basin) in filtered + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + { + let y = table_area.y + (view_idx - scroll_offset) as u16; + if y >= table_area.y + table_area.height { + break; + } + + let is_selected = view_idx == selected; + let row_area = Rect::new(table_area.x, y, table_area.width, 1); + + if is_selected { + f.render_widget( + Block::default().style(Style::default().bg(BG_SELECTED)), + row_area, + ); + } + + let name = basin.name.to_string(); + let max_name_len = name_col.saturating_sub(2); + let display_name = truncate_str(&name, max_name_len, "…"); + + let (state_text, state_bg) = match basin.state { + s2_sdk::types::BasinState::Active => ("Active", BADGE_ACTIVE), + s2_sdk::types::BasinState::Creating => ("Creating", BADGE_PENDING), + s2_sdk::types::BasinState::Deleting => ("Deleting", BADGE_DANGER), + }; + + let scope = basin + .scope + .as_ref() + .map(|s| match s { + s2_sdk::types::BasinScope::AwsUsEast1 => "aws:us-east-1", + }) + .unwrap_or("—"); + + let prefix = if is_selected { "▸ " } else { " " }; + let name_style = if is_selected { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_SECONDARY) + }; + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + prefix, + Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY }), + ), + Span::styled(display_name, name_style), + ])), + Rect::new(row_area.x, y, name_col as u16, 1), + ); + + let badge_x = row_area.x + name_col as u16; + f.render_widget( + Paragraph::new(Span::styled( + format!(" {} ", state_text), + Style::default().fg(WHITE).bg(state_bg), + )), + Rect::new(badge_x, y, state_col as u16, 1), + ); + + let scope_x = badge_x + state_col as u16; + f.render_widget( + Paragraph::new(Span::styled(scope, Style::default().fg(TEXT_MUTED))), + Rect::new(scope_x, y, scope_col as u16, 1), + ); + } +} + +fn draw_streams(f: &mut Frame, area: Rect, state: &StreamsState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title bar with basin name (consistent height) + Constraint::Length(3), // Search bar + Constraint::Length(2), // Header + Constraint::Min(1), // Table rows + ]) + .split(area); + let count_text = if state.loading { + " loading...".to_string() + } else { + let filtered_count = state + .streams + .iter() + .filter(|s| { + state.filter.is_empty() + || s.name + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) + .count(); + if filtered_count != state.streams.len() { + format!(" {}/{} streams", filtered_count, state.streams.len()) + } else { + format!(" {} streams", state.streams.len()) + } + }; + + let basin_name_str = state.basin_name.to_string(); + let title_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" ← ", Style::default().fg(GRAY_700)), + Span::styled(&basin_name_str, Style::default().fg(GREEN).bold()), + Span::styled(&count_text, Style::default().fg(GRAY_700)), + ]), + ]; + let title_block = Paragraph::new(title_lines).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(GRAY_800)), + ); + f.render_widget(title_block, chunks[0]); + + let (search_block, search_text) = + render_search_bar(&state.filter, state.filter_active, "Filter by prefix"); + f.render_widget(Paragraph::new(search_text).block(search_block), chunks[1]); + + let header_area = chunks[2]; + let total_width = header_area.width as usize; + let created_col = 24; + let name_col = total_width.saturating_sub(created_col + 4); + + let header = Line::from(vec![ + Span::styled( + format!(" {: = state + .streams + .iter() + .filter(|s| { + state.filter.is_empty() + || s.name + .to_string() + .to_lowercase() + .contains(&state.filter.to_lowercase()) + }) + .collect(); + + let table_area = chunks[3]; + + if filtered.is_empty() && !state.loading { + let msg = if state.filter.is_empty() { + "No streams in this basin. Press c to create your first stream." + } else { + "No streams match the filter. Press Esc to clear." + }; + let text = Paragraph::new(Span::styled(msg, Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget( + text, + Rect::new(table_area.x, table_area.y + 2, table_area.width, 1), + ); + return; + } + + if state.loading { + let text = Paragraph::new(Span::styled( + "Loading streams...", + Style::default().fg(TEXT_MUTED), + )) + .alignment(Alignment::Center); + f.render_widget( + text, + Rect::new(table_area.x, table_area.y + 2, table_area.width, 1), + ); + return; + } + + let visible_height = table_area.height as usize; + let total = filtered.len(); + let selected = state.selected.min(total.saturating_sub(1)); + + let scroll_offset = if selected >= visible_height { + selected - visible_height + 1 + } else { + 0 + }; + + for (view_idx, stream) in filtered + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + { + let y = table_area.y + (view_idx - scroll_offset) as u16; + if y >= table_area.y + table_area.height { + break; + } + + let is_selected = view_idx == selected; + let row_area = Rect::new(table_area.x, y, table_area.width, 1); + + if is_selected { + f.render_widget( + Block::default().style(Style::default().bg(BG_SELECTED)), + row_area, + ); + } + + let name = stream.name.to_string(); + let max_name_len = name_col.saturating_sub(2); + let display_name = truncate_str(&name, max_name_len, "…"); + + let created = stream.created_at.to_string(); + + let prefix = if is_selected { "▸ " } else { " " }; + let name_style = if is_selected { + Style::default().fg(TEXT_PRIMARY).bold() + } else { + Style::default().fg(TEXT_SECONDARY) + }; + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + prefix, + Style::default().fg(if is_selected { GREEN } else { TEXT_SECONDARY }), + ), + Span::styled(display_name, name_style), + ])), + Rect::new(row_area.x, y, name_col as u16, 1), + ); + + let created_x = row_area.x + name_col as u16; + f.render_widget( + Paragraph::new(Span::styled(created, Style::default().fg(TEXT_MUTED))), + Rect::new(created_x, y, created_col as u16, 1), + ); + } +} + +fn draw_stream_detail(f: &mut Frame, area: Rect, state: &StreamDetailState) { + // Vertical layout: Header, Stats row, Actions + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(5), // Stats cards + Constraint::Min(12), + ]) + .split(area); + + let basin_str = state.basin_name.to_string(); + let stream_str = state.stream_name.to_string(); + let header_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" ← ", Style::default().fg(GRAY_600)), + Span::styled(&basin_str, Style::default().fg(GRAY_300)), + Span::styled(" / ", Style::default().fg(GRAY_700)), + Span::styled(&stream_str, Style::default().fg(GREEN).bold()), + ]), + ]; + let header = Paragraph::new(header_lines).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(BORDER_TITLE)), + ); + f.render_widget(header, chunks[0]); + + let stats_area = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Min(20), // Stats content + Constraint::Length(2), + ]) + .split(chunks[1])[1]; + + let stats_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + ]) + .split(stats_area); + + fn render_stat_card_v2( + f: &mut Frame, + area: Rect, + icon: &str, + label: &str, + value: &str, + value_color: Color, + ) { + let lines = vec![ + Line::from(vec![ + Span::styled(icon, Style::default().fg(value_color)), + Span::styled(format!(" {}", label), Style::default().fg(GRAY_400)), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(value, Style::default().fg(value_color).bold()), + ]), + ]; + let widget = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER_DIM)) + .border_type(ratatui::widgets::BorderType::Rounded), + ); + f.render_widget(widget, area); + } + + // Tail Position + let (tail_val, tail_color) = if let Some(pos) = &state.tail_position { + if pos.seq_num > 0 { + (format!("{}", pos.seq_num), CYAN) + } else { + ("0".to_string(), GRAY_600) + } + } else if state.loading { + ("...".to_string(), GRAY_600) + } else { + ("--".to_string(), GRAY_600) + }; + render_stat_card_v2(f, stats_chunks[0], "▌", "Records", &tail_val, tail_color); + + // Last Write + let (ts_val, ts_color) = if let Some(pos) = &state.tail_position { + if pos.timestamp > 0 { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let age_secs = now_ms.saturating_sub(pos.timestamp) / 1000; + let val = if age_secs < 60 { + format!("{}s ago", age_secs) + } else if age_secs < 3600 { + format!("{}m ago", age_secs / 60) + } else if age_secs < 86400 { + format!("{}h ago", age_secs / 3600) + } else { + format!("{}d ago", age_secs / 86400) + }; + let color = if age_secs < 60 { + TIME_RECENT + } else if age_secs < 3600 { + TIME_MODERATE + } else { + TIME_OLD + }; + (val, color) + } else { + ("never".to_string(), GRAY_600) + } + } else { + ("--".to_string(), GRAY_600) + }; + render_stat_card_v2(f, stats_chunks[1], "◷", "Last Write", &ts_val, ts_color); + + // Storage Class + let (storage_val, storage_color) = if let Some(config) = &state.config { + let val = config + .storage_class + .as_ref() + .map(|s| format!("{:?}", s).to_lowercase()) + .unwrap_or_else(|| "default".to_string()); + let color = match val.as_str() { + "express" => STORAGE_EXPRESS, + "standard" => STORAGE_STANDARD, + _ => GRAY_200, + }; + (val, color) + } else { + ("--".to_string(), GRAY_600) + }; + render_stat_card_v2( + f, + stats_chunks[2], + "◈", + "Storage", + &storage_val, + storage_color, + ); + let (retention_val, retention_color) = if let Some(config) = &state.config { + let val = config + .retention_policy + .as_ref() + .map(|p| match p { + crate::types::RetentionPolicy::Age(dur) => { + let secs = dur.as_secs(); + if secs >= 86400 * 365 { + format!("{}y", secs / (86400 * 365)) + } else if secs >= 86400 { + format!("{}d", secs / 86400) + } else if secs >= 3600 { + format!("{}h", secs / 3600) + } else { + format!("{}s", secs) + } + } + crate::types::RetentionPolicy::Infinite => "∞".to_string(), + }) + .unwrap_or_else(|| "∞".to_string()); + let color = if val == "∞" { PURPLE } else { GRAY_200 }; + (val, color) + } else { + ("--".to_string(), GRAY_600) + }; + render_stat_card_v2( + f, + stats_chunks[3], + "◔", + "Retention", + &retention_val, + retention_color, + ); + + let actions_outer = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Min(20), + Constraint::Length(2), + ]) + .split(chunks[2])[1]; + + let action_cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .split(actions_outer); + let data_ops: Vec<(&str, &str, &str, &str)> = vec![ + ("t", "Tail", "Live stream, see records as they arrive", "◉"), + ("r", "Read", "Read with custom start position & limits", "◎"), + ("a", "Append", "Write new records to stream", "◆"), + ]; + + // Stream management (right column) + let mgmt_ops: Vec<(&str, &str, &str, &str)> = vec![ + ("f", "Fence", "Set token for exclusive writes", "⊘"), + ("m", "Trim", "Delete records before seq number", "✂"), + ]; + + fn render_action_column( + f: &mut Frame, + area: Rect, + title: &str, + actions: &[(&str, &str, &str, &str)], + selected: usize, + offset: usize, + ) { + let title_width = title.len() + 4; + let line_width = area.width.saturating_sub(title_width as u16 + 2) as usize; + + let mut lines = vec![ + Line::from(vec![ + Span::styled(format!(" {} ", title), Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(line_width), Style::default().fg(BORDER_TITLE)), + ]), + Line::from(""), + ]; + + for (i, (key, name, desc, icon)) in actions.iter().enumerate() { + let actual_idx = i + offset; + let is_selected = actual_idx == selected; + + if is_selected { + // Selected action - highlighted card style + lines.push(Line::from(vec![ + Span::styled(" ▶ ", Style::default().fg(GREEN)), + Span::styled(*icon, Style::default().fg(GREEN)), + Span::styled( + format!(" {} ", name), + Style::default().fg(Color::White).bold(), + ), + Span::styled(format!("[{}]", key), Style::default().fg(GREEN).bold()), + ])); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(*desc, Style::default().fg(GRAY_200).italic()), + ])); + } else { + // Unselected action - dimmed + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(*icon, Style::default().fg(GRAY_700)), + Span::styled(format!(" {} ", name), Style::default().fg(GRAY_400)), + Span::styled(format!("[{}]", key), Style::default().fg(GRAY_700)), + ])); + } + lines.push(Line::from("")); + } + + let widget = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GRAY_750)) + .border_type(ratatui::widgets::BorderType::Rounded), + ); + f.render_widget(widget, area); + } + + render_action_column( + f, + action_cols[0], + "Data Operations", + &data_ops, + state.selected_action, + 0, + ); + render_action_column( + f, + action_cols[1], + "Stream Management", + &mgmt_ops, + state.selected_action, + 3, + ); +} + +fn draw_read_view(f: &mut Frame, area: Rect, state: &ReadViewState) { + let (mode_text, mode_color) = if state.is_tailing { + if state.paused { + ("PAUSED", WARNING) + } else { + ("LIVE", SUCCESS) + } + } else { + ("READING", ACCENT) + }; + + // Show sparklines when tailing with throughput data + let show_sparklines = state.is_tailing && !state.throughput_history.is_empty(); + let sparkline_height = if show_sparklines { 4 } else { 0 }; + let timeline_height = if state.show_timeline { 3 } else { 0 }; + + // Split into header, optional sparklines, content, and optional timeline + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(sparkline_height), + Constraint::Min(1), // Content + Constraint::Length(timeline_height), + ]) + .split(area); + + let basin_str = state.basin_name.to_string(); + let stream_str = state.stream_name.to_string(); + let record_count = format!(" {} records", state.records.len()); + + let mut header_spans = vec![ + Span::styled(" ← ", Style::default().fg(GRAY_600)), + Span::styled(&basin_str, Style::default().fg(GRAY_300)), + Span::styled(" / ", Style::default().fg(GRAY_700)), + Span::styled(&stream_str, Style::default().fg(GRAY_200)), + Span::styled(" ", Style::default()), + Span::styled( + format!(" {} ", mode_text), + Style::default().fg(BG_DARK).bg(mode_color).bold(), + ), + Span::styled(&record_count, Style::default().fg(GRAY_700)), + ]; + + if state.is_tailing && state.current_mibps > 0.0 { + header_spans.push(Span::styled(" ", Style::default())); + header_spans.push(Span::styled( + format!("{:.1} MiB/s", state.current_mibps), + Style::default().fg(CYAN).bold(), + )); + header_spans.push(Span::styled( + format!(" {:.0} rec/s", state.current_recps), + Style::default().fg(TEXT_MUTED), + )); + } + + if let Some(ref output) = state.output_file { + header_spans.push(Span::styled(" → ", Style::default().fg(GRAY_700))); + header_spans.push(Span::styled(output, Style::default().fg(YELLOW))); + } + + let header_lines = vec![Line::from(""), Line::from(header_spans)]; + let header = Paragraph::new(header_lines).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(GRAY_800)), + ); + f.render_widget(header, main_chunks[0]); + + if show_sparklines { + draw_tail_sparklines( + f, + main_chunks[1], + &state.throughput_history, + &state.records_per_sec_history, + ); + } + + let content_area = main_chunks[2]; + let outer_block = Block::default().borders(Borders::NONE); + + let inner_area = outer_block.inner(content_area); + f.render_widget(outer_block, content_area); + + if state.records.is_empty() { + let text = if state.loading { + "Waiting for records..." + } else { + "No records" + }; + let para = Paragraph::new(Span::styled(text, Style::default().fg(TEXT_MUTED))) + .alignment(Alignment::Center); + f.render_widget( + para, + Rect::new(inner_area.x, inner_area.y + 2, inner_area.width, 1), + ); + return; + } + + let total_records = state.records.len(); + let selected = state.selected.min(total_records.saturating_sub(1)); + + // Layout depends on whether list is hidden + let body_area = if state.hide_list { + // Full width for body when list hidden + inner_area + } else { + // Split into left (record list) and right (body preview) panes + let panes = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(28), + Constraint::Min(20), // Body preview - takes remaining space + ]) + .split(inner_area); + + let list_area = panes[0]; + let visible_height = list_area.height as usize; + + // Keep selected record in view + let scroll_offset = if state.is_tailing && !state.paused { + // Auto-scroll to show latest + total_records.saturating_sub(visible_height) + } else if selected >= visible_height { + selected - visible_height + 1 + } else { + 0 + }; + + for (view_idx, record) in state + .records + .iter() + .enumerate() + .skip(scroll_offset) + .take(visible_height) + { + let y = list_area.y + (view_idx - scroll_offset) as u16; + if y >= list_area.y + list_area.height { + break; + } + + let is_selected = view_idx == selected; + let has_headers = !record.headers.is_empty(); + let row_area = Rect::new(list_area.x, y, list_area.width, 1); + + if is_selected { + f.render_widget( + Block::default().style(Style::default().bg(BG_SELECTED)), + row_area, + ); + } + + let sel_indicator = if is_selected { "▸" } else { " " }; + let header_indicator = if has_headers { "⌘" } else { " " }; + + let line = Line::from(vec![ + Span::styled(sel_indicator, Style::default().fg(GREEN)), + Span::styled( + format!("#{:<8}", record.seq_num), + Style::default() + .fg(if is_selected { GREEN } else { TEXT_SECONDARY }) + .bold(), + ), + Span::styled( + format!("{:>13}", record.timestamp), + Style::default().fg(TEXT_MUTED), + ), + Span::styled( + format!(" {}", header_indicator), + Style::default().fg(if has_headers { YELLOW } else { BORDER }), + ), + ]); + f.render_widget(Paragraph::new(line), row_area); + } + + // Vertical separator - single widget instead of per-row loop + let sep_x = panes[1].x.saturating_sub(1); + let sep_lines: Vec = (0..inner_area.height) + .map(|_| Line::from(Span::styled("│", Style::default().fg(BORDER)))) + .collect(); + f.render_widget( + Paragraph::new(sep_lines), + Rect::new(sep_x, inner_area.y, 1, inner_area.height), + ); + + panes[1] + }; + + if let Some(record) = state.records.get(selected) { + let body = String::from_utf8_lossy(&record.body); + let body_width = body_area.width.saturating_sub(2) as usize; + let body_height = body_area.height as usize; + + // Cinema mode: when list is hidden and tailing, show raw body without chrome + let cinema_mode = state.hide_list && state.is_tailing && !state.paused; + + let (content_start_y, content_height) = if cinema_mode { + (body_area.y, body_height) + } else { + let header_line = Line::from(vec![ + Span::styled( + format!(" #{}", record.seq_num), + Style::default().fg(GREEN).bold(), + ), + Span::styled( + format!(" {}ms", record.timestamp), + Style::default().fg(TEXT_MUTED), + ), + Span::styled( + format!(" {} bytes", record.body.len()), + Style::default().fg(TEXT_MUTED), + ), + if !record.headers.is_empty() { + Span::styled( + format!(" ⌘{}", record.headers.len()), + Style::default().fg(YELLOW), + ) + } else { + Span::styled("", Style::default()) + }, + ]); + f.render_widget( + Paragraph::new(header_line), + Rect::new(body_area.x, body_area.y, body_area.width, 1), + ); + + let sep = "─".repeat(body_width); + f.render_widget( + Paragraph::new(Span::styled( + format!(" {}", sep), + Style::default().fg(BORDER), + )), + Rect::new(body_area.x, body_area.y + 1, body_area.width, 1), + ); + + (body_area.y + 2, body_height.saturating_sub(2)) + }; + + if body.is_empty() { + f.render_widget( + Paragraph::new(Span::styled( + " (empty body)", + Style::default().fg(TEXT_MUTED).italic(), + )), + Rect::new(body_area.x, content_start_y, body_area.width, 1), + ); + } else { + let mut display_lines: Vec = Vec::new(); + + for line in body.lines().take(content_height) { + if cinema_mode { + display_lines.push(Line::from(Span::styled( + line.to_string(), + Style::default().fg(TEXT_PRIMARY), + ))); + } else { + let chars: Vec = line.chars().collect(); + if chars.is_empty() { + display_lines.push(Line::from("")); + } else { + for chunk in chars.chunks(body_width.max(1)) { + let text: String = chunk.iter().collect(); + display_lines.push(Line::from(Span::styled( + text, + Style::default().fg(TEXT_PRIMARY), + ))); + if display_lines.len() >= content_height { + break; + } + } + } + } + if display_lines.len() >= content_height { + break; + } + } + + let body_para = Paragraph::new(display_lines).block( + Block::default().padding(Padding::horizontal(if cinema_mode { 0 } else { 1 })), + ); + f.render_widget( + body_para, + Rect::new( + body_area.x, + content_start_y, + body_area.width, + content_height as u16, + ), + ); + } + } + + // Draw timeline scrubber if enabled + if state.show_timeline && !state.records.is_empty() { + draw_timeline_scrubber(f, main_chunks[3], state); + } + + // Draw headers popup if showing + if state.show_detail + && let Some(record) = state.records.get(selected) + { + draw_headers_popup(f, record); + } +} + +fn draw_timeline_scrubber(f: &mut Frame, area: Rect, state: &ReadViewState) { + let total = state.records.len(); + if total == 0 { + return; + } + + let selected = state.selected.min(total.saturating_sub(1)); + let width = area.width.saturating_sub(4) as usize; + + // Calculate record density histogram + let bucket_count = width.max(1); + let mut buckets = vec![0u64; bucket_count]; + + for (i, _record) in state.records.iter().enumerate() { + let bucket = (i * bucket_count) / total.max(1); + let bucket = bucket.min(bucket_count - 1); + buckets[bucket] += 1; + } + + // Normalize buckets for display + let max_bucket = buckets.iter().copied().max().unwrap_or(1).max(1); + + // Build histogram line + let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + let mut histogram_spans = Vec::new(); + + let current_bucket = (selected * bucket_count) / total.max(1); + let current_bucket = current_bucket.min(bucket_count - 1); + + for (i, &count) in buckets.iter().enumerate() { + let level = ((count as f64 / max_bucket as f64) * 7.0).round() as usize; + let level = level.min(7); + let ch = bar_chars[level]; + + let color = if i == current_bucket { + GREEN + } else if count > 0 { + CYAN + } else { + BORDER + }; + + histogram_spans.push(Span::styled(ch.to_string(), Style::default().fg(color))); + } + + // Position indicator + let position_pct = (selected as f64 / (total - 1).max(1) as f64) * 100.0; + + // Time info from records + let (first_ts, last_ts) = + if let (Some(first), Some(last)) = (state.records.front(), state.records.back()) { + (first.timestamp, last.timestamp) + } else { + (0, 0) + }; + + let time_span = if last_ts > first_ts { + let span_ms = last_ts - first_ts; + if span_ms >= 3600000 { + format!("{:.1}h span", span_ms as f64 / 3600000.0) + } else if span_ms >= 60000 { + format!("{:.1}m span", span_ms as f64 / 60000.0) + } else { + format!("{:.1}s span", span_ms as f64 / 1000.0) + } + } else { + "".to_string() + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .title(Line::from(vec![ + Span::styled(" Timeline ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!( + "#{}", + state.records.get(selected).map(|r| r.seq_num).unwrap_or(0) + ), + Style::default().fg(GREEN).bold(), + ), + Span::styled( + format!(" ({:.0}%) ", position_pct), + Style::default().fg(TEXT_MUTED), + ), + Span::styled(time_span, Style::default().fg(CYAN)), + ])) + .title_bottom(Line::from(Span::styled( + " [ ] seek T toggle ", + Style::default().fg(TEXT_MUTED), + ))); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Draw histogram + f.render_widget( + Paragraph::new(Line::from(histogram_spans)).alignment(Alignment::Center), + inner, + ); +} + +fn draw_headers_popup(f: &mut Frame, record: &s2_sdk::types::SequencedRecord) { + // Size popup based on number of headers (min height for "no headers" message) + let content_lines = if record.headers.is_empty() { + 1 + } else { + record.headers.len() + }; + // Compact sizing: 5 lines overhead (title, record#, spacing, border) + content + let height = ((content_lines + 5) as u16).min(20).min(f.area().height); + let width = 45_u16.min(f.area().width); + let x = f.area().x + f.area().width.saturating_sub(width) / 2; + let y = f.area().y + f.area().height.saturating_sub(height) / 2; + let area = Rect::new(x, y, width, height); + + let mut lines = vec![ + Line::from(""), + Line::from(vec![Span::styled( + format!(" Record #{}", record.seq_num), + Style::default().fg(GREEN).bold(), + )]), + Line::from(""), + ]; + + if record.headers.is_empty() { + lines.push(Line::from(vec![Span::styled( + " No headers", + Style::default().fg(TEXT_MUTED).italic(), + )])); + } else { + for header in &record.headers { + let name = String::from_utf8_lossy(&header.name); + let value = String::from_utf8_lossy(&header.value); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("{}", name), Style::default().fg(YELLOW)), + Span::styled(" = ", Style::default().fg(BORDER)), + Span::styled(format!("{}", value), Style::default().fg(TEXT_PRIMARY)), + ])); + } + } + + let (title, border_color) = if record.headers.is_empty() { + (" Headers ", BORDER) + } else { + (" Headers ", YELLOW) + }; + + let block = Block::default() + .title(Line::from(Span::styled( + title, + Style::default().fg(border_color).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .style(Style::default().bg(BG_DARK)); + + f.render_widget(Clear, area); + let para = Paragraph::new(lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(para, area); +} + +fn draw_append_view(f: &mut Frame, area: Rect, state: &AppendViewState) { + // Split into header and content + let outer_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1)]) + .split(area); + + let basin_str = state.basin_name.to_string(); + let stream_str = state.stream_name.to_string(); + let header_lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" ← ", Style::default().fg(GRAY_600)), + Span::styled(&basin_str, Style::default().fg(GRAY_300)), + Span::styled(" / ", Style::default().fg(GRAY_700)), + Span::styled(&stream_str, Style::default().fg(GRAY_200)), + Span::styled(" ", Style::default()), + Span::styled(" APPEND ", Style::default().fg(BG_DARK).bg(GREEN).bold()), + ]), + ]; + let header = Paragraph::new(header_lines).block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(BORDER_TITLE)), + ); + f.render_widget(header, outer_chunks[0]); + + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(outer_chunks[1]); + + let form_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(GRAY_750)) + .border_type(ratatui::widgets::BorderType::Rounded) + .padding(Padding::new(2, 2, 1, 1)); + + let form_inner = form_block.inner(main_chunks[0]); + f.render_widget(form_block, main_chunks[0]); + + let cursor = |editing: bool| if editing { "▎" } else { "" }; + let selected_marker = |sel: bool| if sel { "▸ " } else { " " }; + + let mut lines: Vec = Vec::new(); + + let body_selected = state.selected == 0; + let body_editing = body_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(body_selected), Style::default().fg(GREEN)), + Span::styled( + "Body", + Style::default().fg(if body_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + ])); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + if state.body.is_empty() && !body_editing { + "(empty)".to_string() + } else { + format!("{}{}", &state.body, cursor(body_editing)) + }, + Style::default().fg(if body_editing { + GREEN + } else if state.body.is_empty() { + TEXT_MUTED + } else { + TEXT_SECONDARY + }), + ), + ])); + lines.push(Line::from("")); + + let headers_selected = state.selected == 1; + let headers_editing = headers_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled( + selected_marker(headers_selected), + Style::default().fg(GREEN), + ), + Span::styled( + "Headers", + Style::default().fg(if headers_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled( + format!(" ({} added)", state.headers.len()), + Style::default().fg(TEXT_MUTED), + ), + if headers_selected && !headers_editing { + Span::styled(" d=del", Style::default().fg(BORDER)) + } else { + Span::raw("") + }, + ])); + + for (key, value) in &state.headers { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(key, Style::default().fg(YELLOW)), + Span::styled(": ", Style::default().fg(TEXT_MUTED)), + Span::styled(value, Style::default().fg(TEXT_SECONDARY)), + ])); + } + + if headers_editing { + lines.push(Line::from(vec![ + Span::styled(" + ", Style::default().fg(GREEN)), + Span::styled( + format!( + "{}{}", + &state.header_key_input, + if state.editing_header_key { "▎" } else { "" } + ), + Style::default().fg(if state.editing_header_key { + GREEN + } else { + YELLOW + }), + ), + Span::styled(": ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!( + "{}{}", + &state.header_value_input, + if !state.editing_header_key { "▎" } else { "" } + ), + Style::default().fg(if !state.editing_header_key { + GREEN + } else { + TEXT_SECONDARY + }), + ), + Span::styled(" ⇥=switch", Style::default().fg(BORDER)), + ])); + } else if headers_selected { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + "Press Enter to add header", + Style::default().fg(TEXT_MUTED).italic(), + ), + ])); + } + lines.push(Line::from("")); + + let match_selected = state.selected == 2; + let match_editing = match_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(match_selected), Style::default().fg(GREEN)), + Span::styled( + "Match Seq#", + Style::default().fg(if match_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled(" ", Style::default()), + Span::styled( + if state.match_seq_num.is_empty() && !match_editing { + "(none)".to_string() + } else { + format!("{}{}", &state.match_seq_num, cursor(match_editing)) + }, + Style::default().fg(if match_editing { + GREEN + } else if state.match_seq_num.is_empty() { + TEXT_MUTED + } else { + TEXT_SECONDARY + }), + ), + ])); + if match_selected { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + help_text::MATCH_SEQ_NUM, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + lines.push(Line::from("")); + + let fence_selected = state.selected == 3; + let fence_editing = fence_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(fence_selected), Style::default().fg(GREEN)), + Span::styled( + "Fencing Token", + Style::default().fg(if fence_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled(" ", Style::default()), + Span::styled( + if state.fencing_token.is_empty() && !fence_editing { + "(none)".to_string() + } else { + format!("{}{}", &state.fencing_token, cursor(fence_editing)) + }, + Style::default().fg(if fence_editing { + GREEN + } else if state.fencing_token.is_empty() { + TEXT_MUTED + } else { + TEXT_SECONDARY + }), + ), + ])); + if fence_selected { + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + help_text::APPEND_FENCING, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + lines.push(Line::from("")); + + // Separator between single record and batch mode + lines.push(Line::from(vec![ + Span::styled(" ─── ", Style::default().fg(GRAY_800)), + Span::styled("or batch from file", Style::default().fg(TEXT_MUTED)), + Span::styled(" ───────────────", Style::default().fg(GRAY_800)), + ])); + lines.push(Line::from("")); + + // Input file field + let file_selected = state.selected == 4; + let file_editing = file_selected && state.editing; + lines.push(Line::from(vec![ + Span::styled(selected_marker(file_selected), Style::default().fg(GREEN)), + Span::styled( + "Input File", + Style::default().fg(if file_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled(" ", Style::default()), + Span::styled( + if state.input_file.is_empty() && !file_editing { + "(none)".to_string() + } else { + format!("{}{}", &state.input_file, cursor(file_editing)) + }, + Style::default().fg(if file_editing { + GREEN + } else if state.input_file.is_empty() { + TEXT_MUTED + } else { + CYAN + }), + ), + ])); + + // Format selector (only shown when file is set) + let format_selected = state.selected == 5; + let format_opts = [ + ("Text", state.input_format == super::app::InputFormat::Text), + ("JSON", state.input_format == super::app::InputFormat::Json), + ( + "JSON+Base64", + state.input_format == super::app::InputFormat::JsonBase64, + ), + ]; + lines.push(Line::from(vec![ + Span::styled(selected_marker(format_selected), Style::default().fg(GREEN)), + Span::styled( + "Format", + Style::default().fg(if format_selected { + TEXT_PRIMARY + } else { + TEXT_MUTED + }), + ), + Span::styled(" ", Style::default()), + render_pill(format_opts[0].0, format_selected, format_opts[0].1), + Span::raw(" "), + render_pill(format_opts[1].0, format_selected, format_opts[1].1), + Span::raw(" "), + render_pill(format_opts[2].0, format_selected, format_opts[2].1), + ])); + if format_selected { + let format_help = match state.input_format { + super::app::InputFormat::Text => help_text::FORMAT_TEXT, + super::app::InputFormat::Json => help_text::FORMAT_JSON, + super::app::InputFormat::JsonBase64 => help_text::FORMAT_JSON_BASE64, + }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Show progress if appending from file + if let Some((done, total)) = state.file_append_progress { + let pct = if total > 0 { (done * 100) / total } else { 0 }; + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + format!("Progress: {}/{} records ({}%)", done, total, pct), + Style::default().fg(YELLOW), + ), + ])); + } + lines.push(Line::from("")); + + let send_selected = state.selected == 6; + let can_send = (!state.body.is_empty() || !state.input_file.is_empty()) && !state.appending; + let (btn_fg, btn_bg) = if state.appending { + (BG_DARK, YELLOW) + } else if send_selected && can_send { + (BG_DARK, GREEN) + } else { + (if can_send { GREEN } else { TEXT_MUTED }, BG_PANEL) + }; + let btn_text = if state.appending { + if state.file_append_progress.is_some() { + " ◌ APPENDING FILE... " + } else { + " ◌ SENDING... " + } + } else if !state.input_file.is_empty() { + " ▶ APPEND FILE " + } else { + " ▶ SEND " + }; + lines.push(Line::from(vec![ + Span::styled(selected_marker(send_selected), Style::default().fg(GREEN)), + Span::styled(btn_text, Style::default().fg(btn_fg).bg(btn_bg).bold()), + ])); + + let form_para = Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(form_para, form_inner); + + let history_block = Block::default() + .title(Line::from(vec![ + Span::styled(" History ", Style::default().fg(TEXT_PRIMARY)), + Span::styled( + format!(" {} appended", state.history.len()), + Style::default().fg(TEXT_MUTED), + ), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + if state.history.is_empty() { + let text = Paragraph::new(Span::styled( + "No records appended yet", + Style::default().fg(TEXT_MUTED).italic(), + )) + .alignment(Alignment::Center) + .block(history_block); + f.render_widget(text, main_chunks[1]); + } else { + let history_inner = history_block.inner(main_chunks[1]); + f.render_widget(history_block, main_chunks[1]); + + let visible_height = history_inner.height as usize; + let start = state.history.len().saturating_sub(visible_height); + + let mut history_lines: Vec = Vec::new(); + for result in state.history.iter().skip(start) { + let mut spans = vec![Span::styled( + format!("#{:<8}", result.seq_num), + Style::default().fg(GREEN), + )]; + if result.header_count > 0 { + spans.push(Span::styled( + format!(" ⌘{}", result.header_count), + Style::default().fg(YELLOW), + )); + } + spans.push(Span::styled( + format!(" {}", &result.body_preview), + Style::default().fg(TEXT_SECONDARY), + )); + history_lines.push(Line::from(spans)); + } + + let history_para = + Paragraph::new(history_lines).wrap(ratatui::widgets::Wrap { trim: false }); + f.render_widget(history_para, history_inner); + } +} + +fn draw_status_bar(f: &mut Frame, area: Rect, app: &App) { + let width = area.width as usize; + + // Get hints based on available width - full, medium, or compact + let hints = get_responsive_hints(&app.screen, width); + + // Create message spans with accessible text prefixes (not just colors) + let message_spans: Option> = app.message.as_ref().map(|m| { + let (prefix, prefix_color, text_color) = match m.level { + MessageLevel::Info => ("ℹ ", CYAN, ACCENT), + MessageLevel::Success => ("✓ ", SUCCESS, SUCCESS), + MessageLevel::Error => ("✗ ", ERROR, ERROR), + }; + vec![ + Span::styled(prefix, Style::default().fg(prefix_color).bold()), + Span::styled(&m.text, Style::default().fg(text_color)), + ] + }); + + // PiP indicator + let pip_indicator: Option> = app.pip.as_ref().map(|pip| { + vec![ + Span::styled(" PiP:", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("{}", pip.stream_name), Style::default().fg(CYAN)), + Span::styled(" ", Style::default()), + ] + }); + + // Calculate available width for hints after message and PiP indicator + let msg_len = app.message.as_ref().map(|m| m.text.len() + 2).unwrap_or(0); + let pip_len = app + .pip + .as_ref() + .map(|p| p.stream_name.to_string().len() + 7) + .unwrap_or(0); + let available = width.saturating_sub(msg_len + pip_len); + + // Truncate hints if needed + let display_hints: String = if hints.len() > available && available > 3 { + format!("{}...", &hints[..available.saturating_sub(3)]) + } else { + hints + }; + + let mut spans = Vec::new(); + + if let Some(msg_spans) = message_spans { + spans.extend(msg_spans); + spans.push(Span::styled(" ", Style::default())); + } + + if let Some(pip_spans) = pip_indicator { + spans.extend(pip_spans); + } + + spans.push(Span::styled(display_hints, Style::default().fg(TEXT_MUTED))); + + // Add persistent help hint indicator (always visible, stands out) + let show_help_hint = !matches!(app.screen, Screen::Splash | Screen::Setup(_)); + if show_help_hint { + // Calculate padding to right-align the help hint + let current_len: usize = spans.iter().map(|s| s.content.len()).sum(); + let help_hint = " ? help"; + let padding_needed = width.saturating_sub(current_len + help_hint.len()); + if padding_needed > 0 { + spans.push(Span::styled(" ".repeat(padding_needed), Style::default())); + } + spans.push(Span::styled("?", Style::default().fg(CYAN).bold())); + spans.push(Span::styled(" help", Style::default().fg(TEXT_MUTED))); + } + + let line = Line::from(spans); + let status = Paragraph::new(line); + f.render_widget(status, area); +} + +/// Get responsive hints based on screen width +fn get_responsive_hints(screen: &Screen, width: usize) -> String { + // Width thresholds + let wide = width >= 100; + let medium = width >= 60; + + match screen { + Screen::Splash | Screen::Setup(_) => String::new(), + Screen::Settings(_) => { + if wide { + "jk nav | e edit | hl compression | space toggle | ⏎ save | r reload | ⇥ switch | q" + .to_string() + } else if medium { + "jk e hl space ⏎ r ⇥ q".to_string() + } else { + "jk e ⏎ r ⇥ q".to_string() + } + } + Screen::Basins(_) => { + if wide { + "/ filter | jk nav | ⏎ open | B bench | M metrics | A acct | c new | e cfg | d del | r ref | ?".to_string() + } else if medium { + "/ | jk ⏎ | B bench | M A | c d e r ?".to_string() + } else { + "jk ⏎ B M A c d ?".to_string() + } + } + Screen::Streams(_) => { + if wide { + "/ filter | jk nav | ⏎ open | M metrics | c new | e cfg | d del | esc".to_string() + } else if medium { + "/ filter | jk nav | ⏎ open | M c e d | esc".to_string() + } else { + "jk ⏎ M c d esc".to_string() + } + } + Screen::StreamDetail(_) => { + if wide { + "t tail | r read | a append | f fence | m trim | p pip | M metrics | e cfg | esc" + .to_string() + } else if medium { + "t tail | r read | a append | p pip | f m M e | esc".to_string() + } else { + "t r a p f m M esc".to_string() + } + } + Screen::ReadView(s) => { + if s.show_detail { + "esc/⏎ close".to_string() + } else if s.is_tailing { + if wide { + "jk nav | [] seek | h headers | T timeline | ⇥ list | space pause | esc" + .to_string() + } else if medium { + "jk [] nav | h | T time | ⇥ | space | esc".to_string() + } else { + "jk [] h T ⇥ space esc".to_string() + } + } else if wide { + "jk nav | [] seek | h headers | T timeline | ⇥ list | esc".to_string() + } else if medium { + "jk [] nav | h | T time | ⇥ | esc".to_string() + } else { + "jk [] h T ⇥ esc".to_string() + } + } + Screen::AppendView(s) => { + if s.editing { + if s.selected == 1 { + "type | ⇥ key/val | ⏎ add | esc done".to_string() + } else { + "type | ⏎ done | esc cancel".to_string() + } + } else if wide { + "jk nav | ⏎ edit/send | d del header | esc back".to_string() + } else { + "jk ⏎ edit | d del | esc".to_string() + } + } + Screen::AccessTokens(_) => { + if wide { + "/ filter | jk nav | c issue | d revoke | r ref | ⇥ switch | ? | q".to_string() + } else if medium { + "/ | jk | c issue | d rev | r | ⇥ | ? q".to_string() + } else { + "jk c d r ⇥ ? q".to_string() + } + } + Screen::MetricsView(state) => { + if matches!( + state.metrics_type, + MetricsType::Basin { .. } | MetricsType::Account + ) { + if wide { + "←→ category | jk scroll | t time range | r refresh | esc back | q quit" + .to_string() + } else { + "←→ cat | jk | t time | r | esc q".to_string() + } + } else if wide { + "jk scroll | t time range | r refresh | esc back | q quit".to_string() + } else { + "jk | t time | r | esc q".to_string() + } + } + Screen::BenchView(state) => { + if state.config_phase { + if wide { + "jk nav | ←→ adjust | ⏎ edit/start | esc back | q quit".to_string() + } else { + "jk ←→ ⏎ esc q".to_string() + } + } else if state.running { + if wide { + "space pause | q stop".to_string() + } else { + "space q".to_string() + } + } else if wide { + "r restart | esc back | q quit".to_string() + } else { + "r esc q".to_string() + } + } + } +} + +fn draw_help_overlay(f: &mut Frame, screen: &Screen) { + let area = centered_rect(60, 70, f.area()); + + // Helper to create a section header + fn section(title: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("─── {} ", title), Style::default().fg(CYAN).bold()), + Span::styled("─".repeat(20), Style::default().fg(GRAY_800)), + ]) + } + + // Helper to create a key binding line with description + fn key(keys: &str, action: &str, desc: &str) -> Line<'static> { + let mut spans = vec![ + Span::styled(format!("{:>6} ", keys), Style::default().fg(GREEN).bold()), + Span::styled( + format!("{:<20}", action), + Style::default().fg(TEXT_SECONDARY), + ), + ]; + if !desc.is_empty() { + spans.push(Span::styled( + format!(" {}", desc), + Style::default().fg(TEXT_MUTED).italic(), + )); + } + Line::from(spans) + } + + // Get the screen title for the help header + let screen_title = match screen { + Screen::Splash | Screen::Setup(_) => "", + Screen::Settings(_) => "Settings", + Screen::Basins(_) => "Basins", + Screen::Streams(_) => "Streams", + Screen::StreamDetail(_) => "Stream Detail", + Screen::ReadView(_) => "Read View", + Screen::AppendView(_) => "Append Records", + Screen::AccessTokens(_) => "Access Tokens", + Screen::MetricsView(_) => "Metrics", + Screen::BenchView(_) => "Benchmark", + }; + + let mut help_text = match screen { + Screen::Splash | Screen::Setup(_) => vec![], + Screen::Settings(_) => vec![ + Line::from(""), + section("Navigation"), + key("j / k", "Move down / up", "Navigate between settings"), + key("g / G", "Jump to top / bottom", ""), + Line::from(""), + section("Editing"), + key("e", "Edit field", "Modify the selected setting"), + key( + "h / l", + "Cycle option left / right", + "Change compression level", + ), + key("space", "Toggle visibility", "Show/hide auth token"), + key("enter", "Save changes", "Write settings to config file"), + key("r", "Reload", "Discard changes, reload from file"), + Line::from(""), + section("Application"), + key( + "tab", + "Switch tab", + "Navigate between Basins/Tokens/Settings", + ), + key("q", "Quit", "Exit the application"), + Line::from(""), + ], + Screen::Basins(_) => vec![ + Line::from(""), + section("Navigation"), + key("j / k", "Move down / up", "Navigate basin list"), + key("g / G", "Jump to top / bottom", ""), + key("/", "Filter", "Search basins by name"), + key("enter", "Open basin", "View streams in selected basin"), + Line::from(""), + section("Basin Actions"), + key("c", "Create basin", "Create a new basin"), + key("e", "Configure", "Modify basin settings"), + key("d", "Delete", "Remove selected basin (requires confirm)"), + key("r", "Refresh", "Reload basin list from server"), + Line::from(""), + section("Analytics"), + key("B", "Benchmark", "Run performance benchmark on basin"), + key("M", "Basin metrics", "View metrics for selected basin"), + key("A", "Account metrics", "View account-level metrics"), + Line::from(""), + section("Application"), + key("tab", "Switch tab", "Go to Access Tokens or Settings"), + key("q", "Quit", "Exit the application"), + Line::from(""), + ], + Screen::Streams(_) => vec![ + Line::from(""), + section("Navigation"), + key("j / k", "Move down / up", "Navigate stream list"), + key("g / G", "Jump to top / bottom", ""), + key("/", "Filter", "Search streams by name"), + key("enter", "Open stream", "View stream details and actions"), + Line::from(""), + section("Stream Actions"), + key("c", "Create stream", "Create a new stream in this basin"), + key("e", "Configure", "Modify stream settings"), + key("d", "Delete", "Remove selected stream (requires confirm)"), + key("r", "Refresh", "Reload stream list from server"), + key("M", "Metrics", "View metrics for selected stream"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to basins list"), + Line::from(""), + ], + Screen::StreamDetail(_) => vec![ + Line::from(""), + section("Navigation"), + key("j / k", "Move down / up", "Navigate action menu"), + key("enter", "Execute", "Run the selected action"), + Line::from(""), + section("Data Operations"), + key("t", "Tail", "Follow stream in real-time (live updates)"), + key("r", "Read", "Read records from a specific position"), + key("a", "Append", "Add new records to the stream"), + Line::from(""), + section("Stream Management"), + key("f", "Fence", "Create a fencing token for coordination"), + key("m", "Trim", "Remove old records up to a sequence"), + key("e", "Configure", "Modify stream settings"), + key("M", "Metrics", "View stream performance metrics"), + Line::from(""), + section("Multi-tasking"), + key("p", "Pin to PiP", "Monitor stream in picture-in-picture"), + key("P", "Toggle PiP", "Show/hide the PiP window"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to streams list"), + Line::from(""), + ], + Screen::ReadView(state) => { + let mut lines = vec![ + Line::from(""), + section("Scrolling"), + key("j / k", "Scroll down / up", "Move through records"), + key("g / G", "Jump to top / bottom", ""), + key( + "[ / ]", + "Seek backward / forward", + "Jump by larger increments", + ), + ]; + if state.is_tailing { + lines.push(Line::from("")); + lines.push(section("Live Tailing")); + lines.push(key( + "space", + "Pause / Resume", + "Temporarily stop live updates", + )); + } + lines.extend(vec![ + Line::from(""), + section("Display"), + key("h", "Toggle headers", "Show/hide record headers"), + key("T", "Timeline", "Open timeline scrubber"), + key("tab", "Toggle record list", "Switch focus to record list"), + key("enter", "Record detail", "View full record content"), + Line::from(""), + section("Multi-tasking"), + key("p", "Pin to PiP", "Continue monitoring in background"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to stream detail"), + Line::from(""), + ]); + lines + } + Screen::AppendView(state) => { + if state.editing { + vec![ + Line::from(""), + section("Text Input"), + key("type", "Enter text", "Type your content"), + key("enter", "Confirm", "Save the current field"), + key("esc", "Cancel", "Discard changes to field"), + Line::from(""), + section("Header Fields"), + key( + "tab", + "Switch key/value", + "Toggle between header key and value", + ), + Line::from(""), + ] + } else { + vec![ + Line::from(""), + section("Navigation"), + key("j / k", "Move down / up", "Navigate between fields"), + Line::from(""), + section("Single Record"), + key("enter", "Edit / Send", "Edit field or send the record"), + key("d", "Delete header", "Remove the last header entry"), + Line::from(""), + section("Batch from File"), + key("enter", "Edit file path", "Enter path to file with records"), + key( + "", + "(one record per line)", + "Each line becomes a record body", + ), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to stream detail"), + Line::from(""), + ] + } + } + Screen::AccessTokens(_) => vec![ + Line::from(""), + section("Navigation"), + key("j / k", "Move down / up", "Navigate token list"), + key("g / G", "Jump to top / bottom", ""), + key("/", "Filter", "Search tokens by ID"), + Line::from(""), + section("Token Actions"), + key("c", "Issue token", "Create a new access token"), + key("d", "Revoke", "Invalidate the selected token"), + key("r", "Refresh", "Reload token list from server"), + Line::from(""), + section("Application"), + key("tab", "Switch tab", "Go to Basins or Settings"), + key("q", "Quit", "Exit the application"), + Line::from(""), + ], + Screen::MetricsView(state) => { + let mut lines = vec![ + Line::from(""), + section("Navigation"), + key("j / k", "Scroll down / up", "Navigate metrics display"), + ]; + if matches!( + state.metrics_type, + MetricsType::Basin { .. } | MetricsType::Account + ) { + lines.push(key( + "← / →", + "Change category", + "Switch between metric types", + )); + } + lines.extend(vec![ + Line::from(""), + section("Actions"), + key("r", "Refresh", "Reload metrics from server"), + key("t", "Time range", "Open time range picker"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to previous screen"), + key("q", "Quit", "Exit the application"), + Line::from(""), + ]); + lines + } + Screen::BenchView(state) => { + if state.config_phase { + vec![ + Line::from(""), + section("Configuration"), + key("j / k", "Move down / up", "Navigate benchmark settings"), + key("h / l", "Decrease / Increase", "Adjust numeric values"), + key("enter", "Edit / Start", "Edit field or start benchmark"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to basin view"), + key("q", "Quit", "Exit the application"), + Line::from(""), + ] + } else if state.running { + vec![ + Line::from(""), + section("Benchmark Control"), + key( + "space", + "Pause / Resume", + "Temporarily stop/continue benchmark", + ), + key("q", "Stop", "End benchmark and show results"), + Line::from(""), + ] + } else { + vec![ + Line::from(""), + section("Results"), + key("r", "Restart", "Run the benchmark again"), + Line::from(""), + section("Navigation"), + key("esc", "Back", "Return to basin view"), + key("q", "Quit", "Exit the application"), + Line::from(""), + ] + } + } + }; + + // Add dismiss hint at the bottom + if !help_text.is_empty() { + help_text.push(Line::from(vec![ + Span::styled(" Press ", Style::default().fg(TEXT_MUTED)), + Span::styled("?", Style::default().fg(CYAN).bold()), + Span::styled(" or ", Style::default().fg(TEXT_MUTED)), + Span::styled("Esc", Style::default().fg(CYAN).bold()), + Span::styled(" to close this help", Style::default().fg(TEXT_MUTED)), + ])); + } + + let title = format!(" {} · Keyboard Shortcuts ", screen_title); + let block = Block::default() + .title(Line::from(Span::styled( + title, + Style::default().fg(TEXT_PRIMARY).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT)) + .style(Style::default().bg(BG_DARK)) + .padding(Padding::horizontal(1)); + + let help = Paragraph::new(help_text).block(block); + + f.render_widget(Clear, area); + f.render_widget(help, area); +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + // Calculate target size from percentage + let target_width = (area.width as u32 * percent_x as u32 / 100) as u16; + let target_height = (area.height as u32 * percent_y as u32 / 100) as u16; + + // Apply minimum sizes to ensure readability on small terminals + let width = target_width.max(MIN_DIALOG_WIDTH).min(area.width); + let height = target_height.max(MIN_DIALOG_HEIGHT).min(area.height); + + // Center the rectangle + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height) / 2; + + Rect::new(x, y, width, height) +} + +fn get_selected_line_hint(mode: &InputMode) -> usize { + match mode { + InputMode::Normal => 0, + InputMode::CreateBasin { selected, .. } => match selected { + 0 => 3, // Name + 1 => 7, // Region + 2 => 12, // Storage + 3 => 16, // Retention + 4 => 19, // Duration + 5 => 22, // Timestamps + 6 => 26, // Uncapped + 7 => 30, // Delete on empty + 8 => 34, // Threshold + 9 => 40, // On append + 10 => 44, // On read + _ => 48, // Button + } + InputMode::CreateStream { selected, .. } => match selected { + 0 => 5, // Name + 1 => 12, // Storage + 2 => 16, // Retention + 3 => 19, // Duration + 4 => 22, // Timestamps + 5 => 26, // Uncapped + 6 => 30, // Delete on empty + 7 => 34, // Threshold + _ => 38, // Button + }, + InputMode::ReconfigureBasin { selected, .. } => match selected { + 0 => 6, // Storage + 1 => 10, // Retention + 2 => 14, // Duration + 3 => 18, // Timestamps + 4 => 22, // Uncapped + 5 => 28, // On append + 6 => 32, // On read + _ => 36, + }, + InputMode::ReconfigureStream { selected, .. } => match selected { + 0 => 6, // Storage + 1 => 10, // Retention + 2 => 14, // Duration + 3 => 18, // Timestamps + 4 => 22, // Uncapped + 5 => 26, // Delete on empty + 6 => 30, // Threshold + _ => 34, + }, + InputMode::CustomRead { selected, .. } => match selected { + 0 => 6, // Seq num + 1 => 9, // Timestamp + 2 => 12, // Time ago + 3 => 15, // Tail offset + 4 => 20, // Max records + 5 => 23, // Max bytes + 6 => 26, // Until + 7 => 31, // Clamp + 8 => 35, // Format + 9 => 40, // Output file + _ => 45, // Button + }, + InputMode::IssueAccessToken { selected, .. } => match selected { + 0 => 2, // Token ID + 1 => 5, // Expiration + 2 => 9, // Custom duration + 3 => 14, // Basins scope + 4 => 17, // Basins pattern + 5 => 20, // Streams scope + 6 => 23, // Streams pattern + 7 => 26, // Tokens scope + 8 => 29, // Tokens pattern + 9 => 34, // Account read + 10 => 36, // Account write + 11 => 38, // Basin read + 12 => 40, // Basin write + 13 => 42, // Stream read + 14 => 44, // Stream write + 15 => 48, // Auto prefix + _ => 52, // Button + } + InputMode::Fence { selected, .. } => *selected * 4 + 5, + InputMode::Trim { selected, .. } => *selected * 4 + 5, + InputMode::ConfirmDeleteBasin { .. } + | InputMode::ConfirmDeleteStream { .. } + | InputMode::ConfirmRevokeToken { .. } + | InputMode::ShowIssuedToken { .. } + | InputMode::ViewTokenDetail { .. } => 0, + } +} + +fn draw_input_dialog(f: &mut Frame, mode: &InputMode) { + let (title, content, hint) = match mode { + InputMode::Normal => return, + + InputMode::CreateBasin { + name, + scope, + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_input, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + selected, + editing, + } => { + use crate::tui::app::BasinScopeOption; + + let name_valid = name.len() >= 8 && name.len() <= 48; + + // Scope options + let scope_opts = [("AWS us-east-1", *scope == BasinScopeOption::AwsUsEast1)]; + + // Storage class options + let storage_opts = [ + ("Default", storage_class.is_none()), + ( + "Standard", + matches!(storage_class, Some(StorageClass::Standard)), + ), + ( + "Express", + matches!(storage_class, Some(StorageClass::Express)), + ), + ]; + + // Timestamping mode options + let ts_opts = [ + ("Default", timestamping_mode.is_none()), + ( + "ClientPrefer", + matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer)), + ), + ( + "ClientRequire", + matches!(timestamping_mode, Some(TimestampingMode::ClientRequire)), + ), + ( + "Arrival", + matches!(timestamping_mode, Some(TimestampingMode::Arrival)), + ), + ]; + let ret_opts = [ + ( + "Infinite", + *retention_policy == RetentionPolicyOption::Infinite, + ), + ("Age-based", *retention_policy == RetentionPolicyOption::Age), + ]; + + let mut lines = vec![]; + + // Basin name section + lines.push(Line::from("")); + lines.push(render_section_header("Basin name", 48)); + lines.push(Line::from("")); + + // Basin Name field + let (ind, lbl) = render_field_row_bold(0, "Name", *selected); + let name_color = if name.is_empty() { + GRAY_600 + } else if name_valid { + GREEN + } else { + YELLOW + }; + let mut name_spans = vec![ind, lbl, Span::raw(" ")]; + name_spans.extend(render_text_input( + name, + *selected == 0 && *editing, + "enter name...", + name_color, + )); + lines.push(Line::from(name_spans)); + + // Validation hint + let hint_text = if name.is_empty() { + "lowercase, numbers, hyphens (8-48 chars)".to_string() + } else if name.len() < 8 { + format!("{} more chars needed", 8 - name.len()) + } else if name.len() > 48 { + "name too long".to_string() + } else { + format!("{}/48 chars", name.len()) + }; + let hint_color = if name_valid || name.is_empty() { + GRAY_600 + } else { + YELLOW + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(hint_text, Style::default().fg(hint_color).italic()), + ])); + + // Basin Scope (Cloud Provider/Region) + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(1, "Region", *selected); + let mut scope_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &scope_opts { + scope_spans.push(render_pill(label, *selected == 1, *active)); + scope_spans.push(Span::raw(" ")); + } + lines.push(Line::from(scope_spans)); + + // Default stream configuration section + lines.push(Line::from("")); + lines.push(render_section_header("Default stream configuration", 48)); + lines.push(Line::from("")); + + // Storage Class + let (ind, lbl) = render_field_row_bold(2, "Storage", *selected); + let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &storage_opts { + storage_spans.push(render_pill(label, *selected == 2, *active)); + storage_spans.push(Span::raw(" ")); + } + lines.push(Line::from(storage_spans)); + + // Storage class help text + if *selected == 2 { + let storage_help = match storage_class { + None => help_text::STORAGE_DEFAULT, + Some(StorageClass::Standard) => help_text::STORAGE_STANDARD, + Some(StorageClass::Express) => help_text::STORAGE_EXPRESS, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(storage_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(3, "Retention", *selected); + let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ret_opts { + ret_spans.push(render_pill(label, *selected == 3, *active)); + ret_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ret_spans)); + + // Retention help text + if *selected == 3 { + let ret_help = match retention_policy { + RetentionPolicyOption::Infinite => help_text::RETENTION_INFINITE, + RetentionPolicyOption::Age => help_text::RETENTION_AGE, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ret_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + if *retention_policy == RetentionPolicyOption::Age { + let (ind, lbl) = render_field_row_bold(4, " Duration", *selected); + let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; + duration_spans.extend(render_text_input( + retention_age_input, + *selected == 4 && *editing, + "", + YELLOW, + )); + duration_spans.push(Span::styled( + " e.g. 7d, 30d, 1y", + Style::default().fg(GRAY_600).italic(), + )); + lines.push(Line::from(duration_spans)); + } + + // Timestamping Mode + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(5, "Timestamps", *selected); + let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ts_opts { + ts_spans.push(render_pill(label, *selected == 5, *active)); + ts_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ts_spans)); + + // Timestamping mode help text + if *selected == 5 { + let ts_help = match timestamping_mode { + None => help_text::TS_DEFAULT, + Some(TimestampingMode::ClientPrefer) => help_text::TS_CLIENT_PREFER, + Some(TimestampingMode::ClientRequire) => help_text::TS_CLIENT_REQUIRE, + Some(TimestampingMode::Arrival) => help_text::TS_ARRIVAL, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ts_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Uncapped Timestamps + let (ind, lbl) = render_field_row_bold(6, " Uncapped", *selected); + let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; + uncapped_spans.extend(render_toggle(*timestamping_uncapped, *selected == 6)); + lines.push(Line::from(uncapped_spans)); + + // Uncapped help text + if *selected == 6 { + let uncapped_help = if *timestamping_uncapped { + help_text::TS_UNCAPPED + } else { + help_text::TS_CAPPED + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(uncapped_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Delete on Empty + let delete_opts = [ + ("Never", !*delete_on_empty_enabled), + ("After threshold", *delete_on_empty_enabled), + ]; + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(7, "Delete on empty", *selected); + let mut del_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &delete_opts { + del_spans.push(render_pill(label, *selected == 7, *active)); + del_spans.push(Span::raw(" ")); + } + lines.push(Line::from(del_spans)); + + // Delete on empty help text + if *selected == 7 { + let del_help = if *delete_on_empty_enabled { + help_text::DELETE_THRESHOLD + } else { + help_text::DELETE_NEVER + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(del_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Delete on Empty Threshold (conditional) + if *delete_on_empty_enabled { + let (ind, lbl) = render_field_row_bold(8, " Threshold", *selected); + let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; + threshold_spans.extend(render_text_input( + delete_on_empty_min_age, + *selected == 8 && *editing, + "", + YELLOW, + )); + threshold_spans.push(Span::styled( + " e.g. 1h, 7d", + Style::default().fg(GRAY_600).italic(), + )); + lines.push(Line::from(threshold_spans)); + } + + // Create streams automatically section + lines.push(Line::from("")); + lines.push(render_section_header("Create streams automatically", 48)); + lines.push(Line::from("")); + + // On Append + let (ind, lbl) = render_field_row_bold(9, "On append", *selected); + let mut append_spans = vec![ind, lbl, Span::raw(" ")]; + append_spans.extend(render_toggle(*create_stream_on_append, *selected == 9)); + lines.push(Line::from(append_spans)); + + // On append help text + if *selected == 9 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::AUTO_CREATE_APPEND, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + // On Read + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(10, "On read", *selected); + let mut read_spans = vec![ind, lbl, Span::raw(" ")]; + read_spans.extend(render_toggle(*create_stream_on_read, *selected == 10)); + lines.push(Line::from(read_spans)); + + // On read help text + if *selected == 10 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::AUTO_CREATE_READ, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + // Create button section + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(52), + Style::default().fg(GRAY_750), + )])); + lines.push(Line::from("")); + + let can_create = name_valid; + lines.push(render_button( + "CREATE BASIN", + *selected == 11, + can_create, + GREEN, + )); + if !can_create { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("(enter valid name)", Style::default().fg(GRAY_600).italic()), + ])); + } + + lines.push(Line::from("")); + + ( + " Create Basin ", + lines, + "j/k navigate · h/l cycle · Space toggle · Enter edit · Esc cancel", + ) + } + + InputMode::CreateStream { + basin, + name, + storage_class, + retention_policy, + retention_age_input, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + selected, + editing, + } => { + // Storage options + let storage_opts = [ + ("Default", storage_class.is_none()), + ( + "Standard", + matches!(storage_class, Some(StorageClass::Standard)), + ), + ( + "Express", + matches!(storage_class, Some(StorageClass::Express)), + ), + ]; + + // Timestamping mode options + let ts_opts = [ + ("Default", timestamping_mode.is_none()), + ( + "ClientPrefer", + matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer)), + ), + ( + "ClientRequire", + matches!(timestamping_mode, Some(TimestampingMode::ClientRequire)), + ), + ( + "Arrival", + matches!(timestamping_mode, Some(TimestampingMode::Arrival)), + ), + ]; + let ret_opts = [ + ( + "Infinite", + *retention_policy == RetentionPolicyOption::Infinite, + ), + ("Age-based", *retention_policy == RetentionPolicyOption::Age), + ]; + + let mut lines = vec![ + Line::from(""), + render_section_header("Stream name", 48), + Line::from(""), + ]; + + // Show which basin this stream will be created in + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("in basin: ", Style::default().fg(GRAY_600)), + Span::styled(basin.to_string(), Style::default().fg(TEXT_SECONDARY)), + ])); + lines.push(Line::from("")); + + // Stream Name field + let (ind, lbl) = render_field_row(0, "Name", *selected); + let name_color = if name.is_empty() { GRAY_600 } else { GREEN }; + let mut name_spans = vec![ind, lbl, Span::raw(" ")]; + name_spans.extend(render_text_input( + name, + *selected == 0 && *editing, + "enter name...", + name_color, + )); + lines.push(Line::from(name_spans)); + + // Stream configuration section + lines.push(Line::from("")); + lines.push(render_section_header("Stream configuration", 48)); + lines.push(Line::from("")); + + // Storage Class + let (ind, lbl) = render_field_row(1, "Storage", *selected); + let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &storage_opts { + storage_spans.push(render_pill(label, *selected == 1, *active)); + storage_spans.push(Span::raw(" ")); + } + lines.push(Line::from(storage_spans)); + + // Storage class help text + if *selected == 1 { + let storage_help = match storage_class { + None => help_text::STORAGE_DEFAULT, + Some(StorageClass::Standard) => help_text::STORAGE_STANDARD, + Some(StorageClass::Express) => help_text::STORAGE_EXPRESS, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(storage_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + lines.push(Line::from("")); + let (ind, lbl) = render_field_row(2, "Retention", *selected); + let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ret_opts { + ret_spans.push(render_pill(label, *selected == 2, *active)); + ret_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ret_spans)); + + // Retention help text + if *selected == 2 { + let ret_help = match retention_policy { + RetentionPolicyOption::Infinite => help_text::RETENTION_INFINITE, + RetentionPolicyOption::Age => help_text::RETENTION_AGE, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ret_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + if *retention_policy == RetentionPolicyOption::Age { + let (ind, lbl) = render_field_row(3, " Duration", *selected); + let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; + duration_spans.extend(render_text_input( + retention_age_input, + *selected == 3 && *editing, + "", + YELLOW, + )); + duration_spans.push(Span::styled( + " e.g. 7d, 30d, 1y", + Style::default().fg(GRAY_600).italic(), + )); + lines.push(Line::from(duration_spans)); + } + + // Timestamping Mode + lines.push(Line::from("")); + let (ind, lbl) = render_field_row(4, "Timestamps", *selected); + let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ts_opts { + ts_spans.push(render_pill(label, *selected == 4, *active)); + ts_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ts_spans)); + + // Timestamping mode help text + if *selected == 4 { + let ts_help = match timestamping_mode { + None => help_text::TS_DEFAULT, + Some(TimestampingMode::ClientPrefer) => help_text::TS_CLIENT_PREFER, + Some(TimestampingMode::ClientRequire) => help_text::TS_CLIENT_REQUIRE, + Some(TimestampingMode::Arrival) => help_text::TS_ARRIVAL, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ts_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Uncapped Timestamps + let (ind, lbl) = render_field_row(5, " Uncapped", *selected); + let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; + uncapped_spans.extend(render_toggle(*timestamping_uncapped, *selected == 5)); + lines.push(Line::from(uncapped_spans)); + + // Uncapped help text + if *selected == 5 { + let uncapped_help = if *timestamping_uncapped { + help_text::TS_UNCAPPED + } else { + help_text::TS_CAPPED + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(uncapped_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Delete on Empty + let delete_opts = [ + ("Never", !*delete_on_empty_enabled), + ("After threshold", *delete_on_empty_enabled), + ]; + lines.push(Line::from("")); + let (ind, lbl) = render_field_row(6, "Delete on empty", *selected); + let mut del_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &delete_opts { + del_spans.push(render_pill(label, *selected == 6, *active)); + del_spans.push(Span::raw(" ")); + } + lines.push(Line::from(del_spans)); + + // Delete on empty help text + if *selected == 6 { + let del_help = if *delete_on_empty_enabled { + help_text::DELETE_THRESHOLD + } else { + help_text::DELETE_NEVER + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(del_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Delete on Empty Threshold (conditional) + if *delete_on_empty_enabled { + let (ind, lbl) = render_field_row(7, " Threshold", *selected); + let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; + threshold_spans.extend(render_text_input( + delete_on_empty_min_age, + *selected == 7 && *editing, + "", + YELLOW, + )); + threshold_spans.push(Span::styled( + " e.g. 1h, 7d", + Style::default().fg(GRAY_600).italic(), + )); + lines.push(Line::from(threshold_spans)); + } + + // Create button section + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(52), + Style::default().fg(GRAY_750), + )])); + lines.push(Line::from("")); + + let can_create = !name.is_empty(); + lines.push(render_button( + "CREATE STREAM", + *selected == 8, + can_create, + GREEN, + )); + if !can_create { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + "(enter stream name)", + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + lines.push(Line::from("")); + + ( + " Create Stream ", + lines, + "j/k navigate · h/l cycle · Space toggle · Enter edit · Esc cancel", + ) + } + + InputMode::ConfirmDeleteBasin { basin } => ( + " Delete Basin ", + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Delete basin ", Style::default().fg(TEXT_SECONDARY)), + Span::styled(basin.to_string(), Style::default().fg(ERROR).bold()), + Span::styled("?", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "This will delete all streams in this basin.", + Style::default().fg(TEXT_MUTED), + )]), + Line::from(vec![Span::styled( + "This action cannot be undone.", + Style::default().fg(ERROR), + )]), + ], + "y confirm n/esc cancel", + ), + + InputMode::ConfirmDeleteStream { basin, stream } => ( + " Delete Stream ", + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Delete stream ", Style::default().fg(TEXT_SECONDARY)), + Span::styled(stream.to_string(), Style::default().fg(ERROR).bold()), + Span::styled("?", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("from basin ", Style::default().fg(TEXT_MUTED)), + Span::styled(basin.to_string(), Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "This action cannot be undone.", + Style::default().fg(ERROR), + )]), + ], + "y confirm n/esc cancel", + ), + + InputMode::ReconfigureBasin { + basin, + create_stream_on_append, + create_stream_on_read, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + selected, + editing_age, + age_input, + } => { + // Options + let storage_opts = [ + ("Default", storage_class.is_none()), + ( + "Standard", + matches!(storage_class, Some(StorageClass::Standard)), + ), + ( + "Express", + matches!(storage_class, Some(StorageClass::Express)), + ), + ]; + let ret_opts = [ + ( + "Infinite", + *retention_policy == RetentionPolicyOption::Infinite, + ), + ("Age-based", *retention_policy == RetentionPolicyOption::Age), + ]; + let ts_opts = [ + ("Default", timestamping_mode.is_none()), + ( + "ClientPrefer", + matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer)), + ), + ( + "ClientRequire", + matches!(timestamping_mode, Some(TimestampingMode::ClientRequire)), + ), + ( + "Arrival", + matches!(timestamping_mode, Some(TimestampingMode::Arrival)), + ), + ]; + + let mut lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(basin.to_string(), Style::default().fg(GREEN).bold()), + ]), + Line::from(""), + render_section_header("Default stream configuration", 48), + Line::from(""), + ]; + + // Storage Class + let (ind, lbl) = render_field_row_bold(0, "Storage", *selected); + let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &storage_opts { + storage_spans.push(render_pill(label, *selected == 0, *active)); + storage_spans.push(Span::raw(" ")); + } + lines.push(Line::from(storage_spans)); + + // Storage class help text + if *selected == 0 { + let storage_help = match storage_class { + None => help_text::STORAGE_DEFAULT, + Some(StorageClass::Standard) => help_text::STORAGE_STANDARD, + Some(StorageClass::Express) => help_text::STORAGE_EXPRESS, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(storage_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(1, "Retention", *selected); + let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ret_opts { + ret_spans.push(render_pill(label, *selected == 1, *active)); + ret_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ret_spans)); + + // Retention help text + if *selected == 1 { + let ret_help = match retention_policy { + RetentionPolicyOption::Infinite => help_text::RETENTION_INFINITE, + RetentionPolicyOption::Age => help_text::RETENTION_AGE, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ret_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + if *retention_policy == RetentionPolicyOption::Age { + let (ind, lbl) = render_field_row_bold(2, " Duration", *selected); + let age_display = if *editing_age { + age_input.clone() + } else { + format!("{}s", retention_age_secs) + }; + let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; + duration_spans.extend(render_text_input( + &age_display, + *selected == 2 && *editing_age, + "", + YELLOW, + )); + duration_spans.push(Span::styled( + " e.g. 604800 (7 days)", + Style::default().fg(GRAY_600).italic(), + )); + lines.push(Line::from(duration_spans)); + } + + // Timestamping Mode + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(3, "Timestamps", *selected); + let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ts_opts { + ts_spans.push(render_pill(label, *selected == 3, *active)); + ts_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ts_spans)); + + // Timestamping mode help text + if *selected == 3 { + let ts_help = match timestamping_mode { + None => help_text::TS_DEFAULT, + Some(TimestampingMode::ClientPrefer) => help_text::TS_CLIENT_PREFER, + Some(TimestampingMode::ClientRequire) => help_text::TS_CLIENT_REQUIRE, + Some(TimestampingMode::Arrival) => help_text::TS_ARRIVAL, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ts_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Uncapped Timestamps + let (ind, lbl) = render_field_row_bold(4, " Uncapped", *selected); + let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; + uncapped_spans.extend(render_toggle( + timestamping_uncapped.unwrap_or(false), + *selected == 4, + )); + lines.push(Line::from(uncapped_spans)); + + // Uncapped help text + if *selected == 4 { + let uncapped_help = if timestamping_uncapped.unwrap_or(false) { + help_text::TS_UNCAPPED + } else { + help_text::TS_CAPPED + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(uncapped_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Create streams automatically section + lines.push(Line::from("")); + lines.push(render_section_header("Create streams automatically", 48)); + lines.push(Line::from("")); + + // On Append + let (ind, lbl) = render_field_row_bold(5, "On append", *selected); + let mut append_spans = vec![ind, lbl, Span::raw(" ")]; + append_spans.extend(render_toggle( + create_stream_on_append.unwrap_or(false), + *selected == 5, + )); + lines.push(Line::from(append_spans)); + + // On append help text + if *selected == 5 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::AUTO_CREATE_APPEND, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + // On Read + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(6, "On read", *selected); + let mut read_spans = vec![ind, lbl, Span::raw(" ")]; + read_spans.extend(render_toggle( + create_stream_on_read.unwrap_or(false), + *selected == 6, + )); + lines.push(Line::from(read_spans)); + + // On read help text + if *selected == 6 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::AUTO_CREATE_READ, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + lines.push(Line::from("")); + + ( + " Reconfigure Basin ", + lines, + "j/k navigate · h/l cycle · Space toggle · Enter edit · s save · Esc cancel", + ) + } + + InputMode::ReconfigureStream { + basin, + stream, + storage_class, + retention_policy, + retention_age_secs, + timestamping_mode, + timestamping_uncapped, + delete_on_empty_enabled, + delete_on_empty_min_age, + selected, + editing_age, + age_input, + } => { + // Options + let storage_opts = [ + ("Default", storage_class.is_none()), + ( + "Standard", + matches!(storage_class, Some(StorageClass::Standard)), + ), + ( + "Express", + matches!(storage_class, Some(StorageClass::Express)), + ), + ]; + let ret_opts = [ + ( + "Infinite", + *retention_policy == RetentionPolicyOption::Infinite, + ), + ("Age-based", *retention_policy == RetentionPolicyOption::Age), + ]; + let ts_opts = [ + ("Default", timestamping_mode.is_none()), + ( + "ClientPrefer", + matches!(timestamping_mode, Some(TimestampingMode::ClientPrefer)), + ), + ( + "ClientRequire", + matches!(timestamping_mode, Some(TimestampingMode::ClientRequire)), + ), + ( + "Arrival", + matches!(timestamping_mode, Some(TimestampingMode::Arrival)), + ), + ]; + + let mut lines = vec![]; + + // Stream name header + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + format!("{}/{}", basin, stream), + Style::default().fg(GREEN).bold(), + ), + ])); + + // Stream configuration section + lines.push(Line::from("")); + lines.push(render_section_header("Stream configuration", 48)); + lines.push(Line::from("")); + + // Storage Class + let (ind, lbl) = render_field_row(0, "Storage", *selected); + let mut storage_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &storage_opts { + storage_spans.push(render_pill(label, *selected == 0, *active)); + storage_spans.push(Span::raw(" ")); + } + lines.push(Line::from(storage_spans)); + + // Storage class help text + if *selected == 0 { + let storage_help = match storage_class { + None => help_text::STORAGE_DEFAULT, + Some(StorageClass::Standard) => help_text::STORAGE_STANDARD, + Some(StorageClass::Express) => help_text::STORAGE_EXPRESS, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(storage_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + lines.push(Line::from("")); + let (ind, lbl) = render_field_row(1, "Retention", *selected); + let mut ret_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ret_opts { + ret_spans.push(render_pill(label, *selected == 1, *active)); + ret_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ret_spans)); + + // Retention help text + if *selected == 1 { + let ret_help = match retention_policy { + RetentionPolicyOption::Infinite => help_text::RETENTION_INFINITE, + RetentionPolicyOption::Age => help_text::RETENTION_AGE, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ret_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + if *retention_policy == RetentionPolicyOption::Age { + let (ind, lbl) = render_field_row(2, " Duration", *selected); + let age_display = if *selected == 2 && *editing_age { + age_input.clone() + } else { + format!("{}s", retention_age_secs) + }; + let mut duration_spans = vec![ind, lbl, Span::raw(" ")]; + duration_spans.extend(render_text_input( + &age_display, + *selected == 2 && *editing_age, + "", + YELLOW, + )); + duration_spans.push(Span::styled( + " e.g. 604800 (7 days)", + Style::default().fg(GRAY_600).italic(), + )); + lines.push(Line::from(duration_spans)); + } + + // Timestamping Mode + lines.push(Line::from("")); + let (ind, lbl) = render_field_row(3, "Timestamps", *selected); + let mut ts_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &ts_opts { + ts_spans.push(render_pill(label, *selected == 3, *active)); + ts_spans.push(Span::raw(" ")); + } + lines.push(Line::from(ts_spans)); + + // Timestamping mode help text + if *selected == 3 { + let ts_help = match timestamping_mode { + None => help_text::TS_DEFAULT, + Some(TimestampingMode::ClientPrefer) => help_text::TS_CLIENT_PREFER, + Some(TimestampingMode::ClientRequire) => help_text::TS_CLIENT_REQUIRE, + Some(TimestampingMode::Arrival) => help_text::TS_ARRIVAL, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(ts_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Uncapped Timestamps + let (ind, lbl) = render_field_row(4, " Uncapped", *selected); + let mut uncapped_spans = vec![ind, lbl, Span::raw(" ")]; + uncapped_spans.extend(render_toggle( + timestamping_uncapped.unwrap_or(false), + *selected == 4, + )); + lines.push(Line::from(uncapped_spans)); + + // Uncapped help text + if *selected == 4 { + let uncapped_help = if timestamping_uncapped.unwrap_or(false) { + help_text::TS_UNCAPPED + } else { + help_text::TS_CAPPED + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(uncapped_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Delete on Empty + let delete_opts = [ + ("Never", !*delete_on_empty_enabled), + ("After threshold", *delete_on_empty_enabled), + ]; + lines.push(Line::from("")); + let (ind, lbl) = render_field_row(5, "Delete on empty", *selected); + let mut del_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &delete_opts { + del_spans.push(render_pill(label, *selected == 5, *active)); + del_spans.push(Span::raw(" ")); + } + lines.push(Line::from(del_spans)); + + // Delete on empty help text + if *selected == 5 { + let del_help = if *delete_on_empty_enabled { + help_text::DELETE_THRESHOLD + } else { + help_text::DELETE_NEVER + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(del_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Delete on Empty Threshold (conditional) + if *delete_on_empty_enabled { + let (ind, lbl) = render_field_row(6, " Threshold", *selected); + let mut threshold_spans = vec![ind, lbl, Span::raw(" ")]; + threshold_spans.extend(render_text_input( + delete_on_empty_min_age, + *selected == 6 && *editing_age, + "", + YELLOW, + )); + threshold_spans.push(Span::styled( + " e.g. 1h, 7d", + Style::default().fg(GRAY_600).italic(), + )); + lines.push(Line::from(threshold_spans)); + } + + lines.push(Line::from("")); + + ( + " Reconfigure Stream ", + lines, + "j/k navigate · h/l cycle · Space toggle · Enter edit · s save · Esc cancel", + ) + } + + InputMode::CustomRead { + basin, + stream, + start_from, + seq_num_value, + timestamp_value, + ago_value, + ago_unit, + tail_offset_value, + count_limit, + byte_limit, + until_timestamp, + clamp, + format, + output_file, + selected, + editing, + } => { + // Unit options for "time ago" + let unit_str = match ago_unit { + AgoUnit::Seconds => "sec", + AgoUnit::Minutes => "min", + AgoUnit::Hours => "hr", + AgoUnit::Days => "day", + }; + + // Format options + let format_opts = [ + ("Text", format.as_str() == "text"), + ("JSON", format.as_str() == "json"), + ("JSON+Base64", format.as_str() == "json-base64"), + ]; + + let mut lines = vec![]; + + // Stream info header + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" Reading from: ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("s2://{}/{}", basin, stream), + Style::default().fg(GREEN).bold(), + ), + ])); + + // Start position section + lines.push(Line::from("")); + lines.push(render_section_header("Start position", 48)); + lines.push(Line::from("")); + + // Row 0: Sequence number option + let is_seq = *start_from == ReadStartFrom::SeqNum; + let (ind, lbl) = render_field_row(0, "Sequence #", *selected); + let mut seq_spans = vec![ind]; + seq_spans.push(Span::styled( + if is_seq { "● " } else { "○ " }, + Style::default().fg(if is_seq { GREEN } else { GRAY_800 }), + )); + seq_spans.push(lbl); + seq_spans.push(Span::raw(" ")); + seq_spans.extend(render_text_input( + seq_num_value, + *selected == 0 && *editing, + "0", + if is_seq { GREEN } else { TEXT_MUTED }, + )); + lines.push(Line::from(seq_spans)); + + // Row 1: Timestamp option + let is_ts = *start_from == ReadStartFrom::Timestamp; + let (ind, lbl) = render_field_row(1, "Timestamp", *selected); + let mut ts_spans = vec![ind]; + ts_spans.push(Span::styled( + if is_ts { "● " } else { "○ " }, + Style::default().fg(if is_ts { GREEN } else { GRAY_800 }), + )); + ts_spans.push(lbl); + ts_spans.push(Span::raw(" ")); + ts_spans.extend(render_text_input( + timestamp_value, + *selected == 1 && *editing, + "0", + if is_ts { GREEN } else { TEXT_MUTED }, + )); + ts_spans.push(Span::styled(" ms", Style::default().fg(TEXT_MUTED))); + lines.push(Line::from(ts_spans)); + + // Row 2: Time ago option + let is_ago = *start_from == ReadStartFrom::Ago; + let (ind, lbl) = render_field_row(2, "Time ago", *selected); + let mut ago_spans = vec![ind]; + ago_spans.push(Span::styled( + if is_ago { "● " } else { "○ " }, + Style::default().fg(if is_ago { GREEN } else { GRAY_800 }), + )); + ago_spans.push(lbl); + ago_spans.push(Span::raw(" ")); + ago_spans.extend(render_text_input( + ago_value, + *selected == 2 && *editing, + "5", + if is_ago { GREEN } else { TEXT_MUTED }, + )); + ago_spans.push(Span::styled( + format!(" {}", unit_str), + Style::default().fg(if is_ago { TEXT_SECONDARY } else { TEXT_MUTED }), + )); + ago_spans.push(Span::styled( + " ‹tab› cycle", + Style::default().fg(GRAY_600).italic(), + )); + lines.push(Line::from(ago_spans)); + + // Row 3: Tail offset option + let is_off = *start_from == ReadStartFrom::TailOffset; + let (ind, lbl) = render_field_row(3, "Tail offset", *selected); + let mut off_spans = vec![ind]; + off_spans.push(Span::styled( + if is_off { "● " } else { "○ " }, + Style::default().fg(if is_off { GREEN } else { GRAY_800 }), + )); + off_spans.push(lbl); + off_spans.push(Span::raw(" ")); + off_spans.extend(render_text_input( + tail_offset_value, + *selected == 3 && *editing, + "10", + if is_off { GREEN } else { TEXT_MUTED }, + )); + off_spans.push(Span::styled(" back", Style::default().fg(TEXT_MUTED))); + lines.push(Line::from(off_spans)); + + // Limits section + lines.push(Line::from("")); + lines.push(render_section_header("Limits", 48)); + lines.push(Line::from("")); + + // Row 4: Max records + let (ind, lbl) = render_field_row(4, "Max records", *selected); + let mut count_spans = vec![ind, lbl, Span::raw(" ")]; + count_spans.extend(render_text_input( + count_limit, + *selected == 4 && *editing, + "∞ unlimited", + YELLOW, + )); + lines.push(Line::from(count_spans)); + + // Row 5: Max bytes + let (ind, lbl) = render_field_row(5, "Max bytes", *selected); + let mut bytes_spans = vec![ind, lbl, Span::raw(" ")]; + bytes_spans.extend(render_text_input( + byte_limit, + *selected == 5 && *editing, + "∞ unlimited", + YELLOW, + )); + lines.push(Line::from(bytes_spans)); + + // Row 6: Until timestamp + let (ind, lbl) = render_field_row(6, "Until", *selected); + let mut until_spans = vec![ind, lbl, Span::raw(" ")]; + until_spans.extend(render_text_input( + until_timestamp, + *selected == 6 && *editing, + "∞ unlimited", + YELLOW, + )); + until_spans.push(Span::styled(" ms", Style::default().fg(TEXT_MUTED))); + lines.push(Line::from(until_spans)); + + // Options section + lines.push(Line::from("")); + lines.push(render_section_header("Options", 48)); + lines.push(Line::from("")); + + // Row 7: Clamp toggle + let (ind, lbl) = render_field_row(7, "Clamp to tail", *selected); + let mut clamp_spans = vec![ind, lbl, Span::raw(" ")]; + clamp_spans.extend(render_toggle(*clamp, *selected == 7)); + lines.push(Line::from(clamp_spans)); + + // Clamp help text + if *selected == 7 { + let clamp_help = if *clamp { + help_text::CLAMP_ON + } else { + help_text::CLAMP_OFF + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(clamp_help, Style::default().fg(GRAY_600).italic()), + ])); + } + + // Row 8: Format + let (ind, lbl) = render_field_row(8, "Format", *selected); + let mut format_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &format_opts { + format_spans.push(render_pill(label, *selected == 8, *active)); + format_spans.push(Span::raw(" ")); + } + lines.push(Line::from(format_spans)); + + // Format help text - shows description of currently selected format + let format_help = match format.as_str() { + "text" => help_text::FORMAT_TEXT, + "json" => help_text::FORMAT_JSON, + _ => help_text::FORMAT_JSON_BASE64, + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format_help, Style::default().fg(GRAY_600).italic()), + ])); + + // Row 9: Output file + lines.push(Line::from("")); + let (ind, lbl) = render_field_row(9, "Output file", *selected); + let mut output_spans = vec![ind, lbl, Span::raw(" ")]; + output_spans.extend(render_text_input( + output_file, + *selected == 9 && *editing, + "display only", + TEXT_SECONDARY, + )); + lines.push(Line::from(output_spans)); + + // Divider and button + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(52), + Style::default().fg(GRAY_750), + )])); + lines.push(Line::from("")); + + // Row 10: Start button + lines.push(render_button("START READING", *selected == 10, true, GREEN)); + + lines.push(Line::from("")); + + ( + " Read Stream ", + lines, + "j/k navigate · Enter edit/select · Space toggle · Tab unit · Esc cancel", + ) + } + + InputMode::Fence { + basin, + stream, + new_token, + current_token, + selected, + editing, + } => { + let mut lines = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("s2://{}/{}", basin, stream), + Style::default().fg(GREEN).bold(), + ), + ]), + Line::from(""), + Line::from(Span::styled( + " Set a new fencing token to block other writers.", + Style::default().fg(TEXT_MUTED), + )), + Line::from(""), + ]; + + // Row 0: New token + let (ind, lbl) = render_field_row_bold(0, "New Token", *selected); + let new_color = if new_token.is_empty() { WARNING } else { GREEN }; + let mut new_spans = vec![ind, lbl, Span::raw(" ")]; + new_spans.extend(render_text_input( + new_token, + *selected == 0 && *editing, + "(required)", + new_color, + )); + lines.push(Line::from(new_spans)); + + // New token help text + if *selected == 0 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::FENCE_TOKEN, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + // Row 1: Current token + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(1, "Current", *selected); + let mut cur_spans = vec![ind, lbl, Span::raw(" ")]; + cur_spans.extend(render_text_input( + current_token, + *selected == 1 && *editing, + "(none)", + TEXT_SECONDARY, + )); + lines.push(Line::from(cur_spans)); + + // Current token help text + if *selected == 1 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::FENCE_CURRENT, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + // Divider and button + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(44), + Style::default().fg(GRAY_750), + )])); + lines.push(Line::from("")); + + // Row 2: Submit button + let can_submit = !new_token.is_empty(); + lines.push(render_button("FENCE", *selected == 2, can_submit, GREEN)); + + lines.push(Line::from("")); + + ( + " Fence Stream ", + lines, + "j/k navigate · Enter edit · Esc cancel", + ) + } + + InputMode::Trim { + basin, + stream, + trim_point, + fencing_token, + selected, + editing, + } => { + let mut lines = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("s2://{}/{}", basin, stream), + Style::default().fg(GREEN).bold(), + ), + ]), + Line::from(""), + Line::from(Span::styled( + " Delete all records before the trim point.", + Style::default().fg(TEXT_MUTED), + )), + Line::from(Span::styled( + " This is eventually consistent.", + Style::default().fg(TEXT_MUTED), + )), + Line::from(""), + ]; + + // Row 0: Trim point + let (ind, lbl) = render_field_row_bold(0, "Trim Point", *selected); + let trim_color = if trim_point.is_empty() { + WARNING + } else { + YELLOW + }; + let mut trim_spans = vec![ind, lbl, Span::raw(" ")]; + trim_spans.extend(render_text_input( + trim_point, + *selected == 0 && *editing, + "(seq num)", + trim_color, + )); + lines.push(Line::from(trim_spans)); + + // Trim point help text + if *selected == 0 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::TRIM_SEQ_NUM, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + // Row 1: Fencing token + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(1, "Fence Token", *selected); + let mut fence_spans = vec![ind, lbl, Span::raw(" ")]; + fence_spans.extend(render_text_input( + fencing_token, + *selected == 1 && *editing, + "(none)", + TEXT_SECONDARY, + )); + lines.push(Line::from(fence_spans)); + + // Fence token help text + if *selected == 1 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::APPEND_FENCING, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + // Divider and button + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(44), + Style::default().fg(GRAY_750), + )])); + lines.push(Line::from("")); + + // Row 2: Submit button + let can_submit = !trim_point.is_empty() && trim_point.parse::().is_ok(); + lines.push(render_button("TRIM", *selected == 2, can_submit, WARNING)); + + lines.push(Line::from("")); + + ( + " Trim Stream ", + lines, + "j/k navigate · Enter edit · Esc cancel", + ) + } + + InputMode::IssueAccessToken { + id, + expiry, + expiry_custom, + basins_scope, + basins_value, + streams_scope, + streams_value, + tokens_scope, + tokens_value, + account_read, + account_write, + basin_read, + basin_write, + stream_read, + stream_write, + auto_prefix_streams, + selected, + editing, + } => { + use crate::tui::app::ScopeOption; + + let expiry_opts = [ + ("Never", *expiry == ExpiryOption::Never), + ("1d", *expiry == ExpiryOption::OneDay), + ("7d", *expiry == ExpiryOption::SevenDays), + ("30d", *expiry == ExpiryOption::ThirtyDays), + ("90d", *expiry == ExpiryOption::NinetyDays), + ("1y", *expiry == ExpiryOption::OneYear), + ("Custom", *expiry == ExpiryOption::Custom), + ]; + + let scope_opts = |scope: &ScopeOption| { + [ + ("All", *scope == ScopeOption::All), + ("Prefix", *scope == ScopeOption::Prefix), + ("Exact", *scope == ScopeOption::Exact), + ] + }; + + let mut lines = vec![]; + lines.push(Line::from("")); + + // Row 0: Token ID + let (ind, lbl) = render_field_row_bold(0, "Token ID", *selected); + let id_color = if id.is_empty() { WARNING } else { GREEN }; + let mut id_spans = vec![ind, lbl, Span::raw(" ")]; + id_spans.extend(render_text_input( + id, + *selected == 0 && *editing, + "(required)", + id_color, + )); + lines.push(Line::from(id_spans)); + + // Row 1: Expiration + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(1, "Expiration", *selected); + let mut expiry_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in &expiry_opts { + expiry_spans.push(render_pill(label, *selected == 1, *active)); + expiry_spans.push(Span::raw(" ")); + } + lines.push(Line::from(expiry_spans)); + + // Expiration help text + if *selected == 1 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + help_text::TOKEN_EXPIRY, + Style::default().fg(GRAY_600).italic(), + ), + ])); + } + + // Row 2: Custom expiration (only if Custom selected) + if *expiry == ExpiryOption::Custom { + let (ind, lbl) = render_field_row_bold(2, " Duration", *selected); + let mut custom_spans = vec![ind, lbl, Span::raw(" ")]; + custom_spans.extend(render_text_input( + expiry_custom, + *selected == 2 && *editing, + "e.g. 30d, 1w", + YELLOW, + )); + lines.push(Line::from(custom_spans)); + } + + // Resources section + lines.push(Line::from("")); + lines.push(render_section_header("Resources", 48)); + lines.push(Line::from("")); + + // Row 3: Basins scope + let (ind, lbl) = render_field_row_bold(3, "Basins", *selected); + let mut basins_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in scope_opts(basins_scope) { + basins_spans.push(render_pill(label, *selected == 3, active)); + basins_spans.push(Span::raw(" ")); + } + lines.push(Line::from(basins_spans)); + + // Row 4: Basins value (only if Prefix/Exact) + if matches!(basins_scope, ScopeOption::Prefix | ScopeOption::Exact) { + let (ind, lbl) = render_field_row_bold(4, " Pattern", *selected); + let mut pattern_spans = vec![ind, lbl, Span::raw(" ")]; + pattern_spans.extend(render_text_input( + basins_value, + *selected == 4 && *editing, + "enter pattern", + TEXT_SECONDARY, + )); + lines.push(Line::from(pattern_spans)); + } + + // Row 5: Streams scope + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(5, "Streams", *selected); + let mut streams_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in scope_opts(streams_scope) { + streams_spans.push(render_pill(label, *selected == 5, active)); + streams_spans.push(Span::raw(" ")); + } + lines.push(Line::from(streams_spans)); + + // Row 6: Streams value (only if Prefix/Exact) + if matches!(streams_scope, ScopeOption::Prefix | ScopeOption::Exact) { + let (ind, lbl) = render_field_row_bold(6, " Pattern", *selected); + let mut pattern_spans = vec![ind, lbl, Span::raw(" ")]; + pattern_spans.extend(render_text_input( + streams_value, + *selected == 6 && *editing, + "enter pattern", + TEXT_SECONDARY, + )); + lines.push(Line::from(pattern_spans)); + } + + // Row 7: Access Tokens scope + lines.push(Line::from("")); + let (ind, lbl) = render_field_row_bold(7, "Tokens", *selected); + let mut tokens_spans = vec![ind, lbl, Span::raw(" ")]; + for (label, active) in scope_opts(tokens_scope) { + tokens_spans.push(render_pill(label, *selected == 7, active)); + tokens_spans.push(Span::raw(" ")); + } + lines.push(Line::from(tokens_spans)); + + // Row 8: Tokens value (only if Prefix/Exact) + if matches!(tokens_scope, ScopeOption::Prefix | ScopeOption::Exact) { + let (ind, lbl) = render_field_row_bold(8, " Pattern", *selected); + let mut pattern_spans = vec![ind, lbl, Span::raw(" ")]; + pattern_spans.extend(render_text_input( + tokens_value, + *selected == 8 && *editing, + "enter pattern", + TEXT_SECONDARY, + )); + lines.push(Line::from(pattern_spans)); + } + + // Operations section + lines.push(Line::from("")); + lines.push(render_section_header("Operations", 48)); + lines.push(Line::from("")); + + // Row 9: Account Read + let (ind, lbl) = render_field_row_bold(9, "Account Read", *selected); + let mut acc_read_spans = vec![ind, lbl, Span::raw(" ")]; + acc_read_spans.extend(render_toggle(*account_read, *selected == 9)); + lines.push(Line::from(acc_read_spans)); + + // Row 10: Account Write + let (ind, lbl) = render_field_row_bold(10, "Account Write", *selected); + let mut acc_write_spans = vec![ind, lbl, Span::raw(" ")]; + acc_write_spans.extend(render_toggle(*account_write, *selected == 10)); + lines.push(Line::from(acc_write_spans)); + + lines.push(Line::from("")); + + // Row 11: Basin Read + let (ind, lbl) = render_field_row_bold(11, "Basin Read", *selected); + let mut basin_read_spans = vec![ind, lbl, Span::raw(" ")]; + basin_read_spans.extend(render_toggle(*basin_read, *selected == 11)); + lines.push(Line::from(basin_read_spans)); + + // Row 12: Basin Write + let (ind, lbl) = render_field_row_bold(12, "Basin Write", *selected); + let mut basin_write_spans = vec![ind, lbl, Span::raw(" ")]; + basin_write_spans.extend(render_toggle(*basin_write, *selected == 12)); + lines.push(Line::from(basin_write_spans)); + + lines.push(Line::from("")); + + // Row 13: Stream Read + let (ind, lbl) = render_field_row_bold(13, "Stream Read", *selected); + let mut stream_read_spans = vec![ind, lbl, Span::raw(" ")]; + stream_read_spans.extend(render_toggle(*stream_read, *selected == 13)); + lines.push(Line::from(stream_read_spans)); + + // Row 14: Stream Write + let (ind, lbl) = render_field_row_bold(14, "Stream Write", *selected); + let mut stream_write_spans = vec![ind, lbl, Span::raw(" ")]; + stream_write_spans.extend(render_toggle(*stream_write, *selected == 14)); + lines.push(Line::from(stream_write_spans)); + + // Options section + lines.push(Line::from("")); + lines.push(render_section_header("Options", 48)); + lines.push(Line::from("")); + + // Row 15: Auto-prefix streams + let (ind, lbl) = render_field_row_bold(15, "Auto-prefix", *selected); + let mut prefix_spans = vec![ind, lbl, Span::raw(" ")]; + prefix_spans.extend(render_toggle(*auto_prefix_streams, *selected == 15)); + lines.push(Line::from(prefix_spans)); + + // Divider and button + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "─".repeat(52), + Style::default().fg(GRAY_750), + )])); + lines.push(Line::from("")); + + // Row 16: Submit button + let can_submit = !id.is_empty(); + lines.push(render_button( + "ISSUE TOKEN", + *selected == 16, + can_submit, + SUCCESS, + )); + + lines.push(Line::from("")); + + ( + " Issue Access Token ", + lines, + "j/k navigate · h/l cycle · Space toggle · Enter edit · Esc cancel", + ) + } + + InputMode::ConfirmRevokeToken { token_id } => ( + " Revoke Access Token ", + vec![ + Line::from(""), + Line::from(vec![ + Span::styled("Revoke token ", Style::default().fg(TEXT_SECONDARY)), + Span::styled(token_id, Style::default().fg(ERROR).bold()), + Span::styled("?", Style::default().fg(TEXT_SECONDARY)), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "The token will be immediately invalidated.", + Style::default().fg(TEXT_MUTED), + )]), + Line::from(vec![Span::styled( + "This action cannot be undone.", + Style::default().fg(ERROR), + )]), + ], + "y confirm n/esc cancel", + ), + + InputMode::ShowIssuedToken { token } => ( + " Access Token Issued ", + vec![ + Line::from(""), + Line::from(Span::styled( + "Copy this token now - it won't be shown again!", + Style::default().fg(WARNING).bold(), + )), + Line::from(""), + Line::from(Span::styled(token, Style::default().fg(GREEN))), + Line::from(""), + ], + "press any key to dismiss", + ), + + InputMode::ViewTokenDetail { token } => { + let mut lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Token ID: ", Style::default().fg(TEXT_MUTED)), + Span::styled( + token.id.to_string(), + Style::default().fg(TEXT_PRIMARY).bold(), + ), + ]), + Line::from(vec![ + Span::styled(" Expires At: ", Style::default().fg(TEXT_MUTED)), + Span::styled( + token.expires_at.to_string(), + Style::default().fg(TEXT_PRIMARY), + ), + ]), + Line::from(vec![ + Span::styled(" Auto-prefix: ", Style::default().fg(TEXT_MUTED)), + Span::styled( + if token.auto_prefix_streams { + "Yes" + } else { + "No" + }, + Style::default().fg(if token.auto_prefix_streams { + GREEN + } else { + TEXT_MUTED + }), + ), + ]), + Line::from(""), + render_section_header("Resource Scope", 44), + ]; + + // Basins scope + let basins_str = format_basin_matcher(&token.scope.basins); + lines.push(Line::from(vec![ + Span::styled(" Basins: ", Style::default().fg(TEXT_MUTED)), + Span::styled(basins_str, Style::default().fg(TEXT_PRIMARY)), + ])); + + // Streams scope + let streams_str = format_stream_matcher(&token.scope.streams); + lines.push(Line::from(vec![ + Span::styled(" Streams: ", Style::default().fg(TEXT_MUTED)), + Span::styled(streams_str, Style::default().fg(TEXT_PRIMARY)), + ])); + + // Access tokens scope + let tokens_str = format_token_matcher(&token.scope.access_tokens); + lines.push(Line::from(vec![ + Span::styled(" Tokens: ", Style::default().fg(TEXT_MUTED)), + Span::styled(tokens_str, Style::default().fg(TEXT_PRIMARY)), + ])); + + lines.push(Line::from("")); + lines.push(render_section_header("Operations", 44)); + + // Group operations by category + let ops = &token.scope.ops; + + // Account operations + let account_ops: Vec<_> = ops + .iter() + .filter(|o| is_account_op(o)) + .map(format_operation) + .collect(); + if !account_ops.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" Account: ", Style::default().fg(TEXT_MUTED)), + Span::styled(account_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), + ])); + } + + // Basin operations + let basin_ops: Vec<_> = ops + .iter() + .filter(|o| is_basin_op(o)) + .map(format_operation) + .collect(); + if !basin_ops.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" Basin: ", Style::default().fg(TEXT_MUTED)), + Span::styled(basin_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), + ])); + } + + // Stream operations + let stream_ops: Vec<_> = ops + .iter() + .filter(|o| is_stream_op(o)) + .map(format_operation) + .collect(); + if !stream_ops.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" Stream: ", Style::default().fg(TEXT_MUTED)), + Span::styled(stream_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), + ])); + } + + // Token operations + let token_ops: Vec<_> = ops + .iter() + .filter(|o| is_token_op(o)) + .map(format_operation) + .collect(); + if !token_ops.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" Tokens: ", Style::default().fg(TEXT_MUTED)), + Span::styled(token_ops.join(", "), Style::default().fg(TEXT_PRIMARY)), + ])); + } + + lines.push(Line::from("")); + + (" Access Token Details ", lines, "esc/enter close") + } + }; + + let area = centered_rect(60, 85, f.area()); + + let block = Block::default() + .title(Line::from(Span::styled( + title, + Style::default().fg(TEXT_PRIMARY).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(GREEN)) + .style(Style::default().bg(BG_DARK)) + .padding(Padding::horizontal(2)); + + // Split area for content and hint + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(area); + + f.render_widget(Clear, area); + + // Calculate scroll offset to keep selected item visible + // Inner height = area height - borders (2) - padding (0) - hint line (1) + let inner_height = chunks[0].height.saturating_sub(4) as usize; + let content_height = content.len(); + let scroll_offset = if content_height > inner_height { + // Find the selected field's approximate line position + let selected_line = get_selected_line_hint(mode); + // Scroll so selected item is in the middle-ish of visible area + let half_visible = inner_height / 2; + if selected_line > half_visible { + (selected_line - half_visible).min(content_height.saturating_sub(inner_height)) + } else { + 0 + } + } else { + 0 + }; + + let dialog = Paragraph::new(content.clone()) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((scroll_offset as u16, 0)); + f.render_widget(dialog, chunks[0]); + + if content_height > inner_height { + let inner_area = chunks[0].inner(Margin::new(1, 1)); + if scroll_offset > 0 { + let up_indicator = Paragraph::new("▲") + .style(Style::default().fg(GRAY_600)) + .alignment(Alignment::Right); + let up_area = Rect::new(inner_area.x, inner_area.y, inner_area.width, 1); + f.render_widget(up_indicator, up_area); + } + if scroll_offset + inner_height < content_height { + let down_indicator = Paragraph::new("▼") + .style(Style::default().fg(GRAY_600)) + .alignment(Alignment::Right); + let down_area = Rect::new( + inner_area.x, + inner_area.y + inner_area.height.saturating_sub(1), + inner_area.width, + 1, + ); + f.render_widget(down_indicator, down_area); + } + } + + // Parse and render hint with highlighted keys for better accessibility + let hint_spans = render_hint_with_keys(hint); + let hint_line = Line::from(hint_spans); + let hint_para = Paragraph::new(hint_line).alignment(Alignment::Center); + f.render_widget(hint_para, chunks[1]); +} + +/// Parse a hint string and render keys (before descriptions) with highlighting. +/// Format expected: "key1 desc1 · key2 desc2 · ..." +fn render_hint_with_keys(hint: &str) -> Vec> { + let mut spans = Vec::new(); + let parts: Vec<&str> = hint.split(" · ").collect(); + + for (i, part) in parts.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" · ", Style::default().fg(GRAY_800))); + } + + // Split into key and description (first word is the key) + if let Some(space_idx) = part.find(' ') { + let key = &part[..space_idx]; + let desc = &part[space_idx..]; + spans.push(Span::styled( + key.to_string(), + Style::default().fg(CYAN).bold(), + )); + spans.push(Span::styled( + desc.to_string(), + Style::default().fg(TEXT_MUTED), + )); + } else { + // No space, treat whole thing as key + spans.push(Span::styled( + part.to_string(), + Style::default().fg(CYAN).bold(), + )); + } + } + + spans +} + +fn draw_bench_view(f: &mut Frame, area: Rect, state: &BenchViewState) { + let block = Block::default() + .title(Line::from(vec![ + Span::styled(" Benchmark ", Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled( + format!("• {} ", state.basin_name), + Style::default().fg(ACCENT), + ), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_DARK)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if state.config_phase { + draw_bench_config(f, inner, state); + } else { + draw_bench_running(f, inner, state); + } +} + +fn draw_bench_config(f: &mut Frame, area: Rect, state: &BenchViewState) { + use crate::tui::app::BenchConfigField; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(3), + Constraint::Length(3), // Target MiB/s + Constraint::Length(3), // Duration + Constraint::Length(3), // Catchup delay + Constraint::Length(3), // Start button + Constraint::Min(1), // Spacer + ]) + .margin(1) + .split(area); + + // Title + let title = Paragraph::new(Line::from(vec![Span::styled( + "Configure Benchmark", + Style::default().fg(TEXT_PRIMARY).bold(), + )])) + .alignment(Alignment::Center); + f.render_widget(title, chunks[0]); + + // Helper to draw a config field + let draw_field = + |f: &mut Frame, area: Rect, label: &str, value: &str, selected: bool, editing: bool| { + let style = if selected { + Style::default() + .fg(if editing { YELLOW } else { GREEN }) + .bold() + } else { + Style::default().fg(TEXT_SECONDARY) + }; + + let prefix = if selected { "▸ " } else { " " }; + let suffix = if selected && !editing { " ◂" } else { "" }; + + let line = Line::from(vec![ + Span::styled(prefix, style), + Span::styled(format!("{}: ", label), Style::default().fg(TEXT_MUTED)), + Span::styled(value, style), + Span::styled(suffix, style), + ]); + f.render_widget(Paragraph::new(line), area); + }; + let record_size_str = if state.editing && state.config_field == BenchConfigField::RecordSize { + format!("{}_", state.edit_buffer) + } else { + format_bytes(state.record_size as u64) + }; + draw_field( + f, + chunks[1], + "Record Size", + &record_size_str, + state.config_field == BenchConfigField::RecordSize, + state.editing, + ); + + // Target MiB/s + let target_str = if state.editing && state.config_field == BenchConfigField::TargetMibps { + format!("{}_", state.edit_buffer) + } else { + format!("{} MiB/s", state.target_mibps) + }; + draw_field( + f, + chunks[2], + "Target Throughput", + &target_str, + state.config_field == BenchConfigField::TargetMibps, + state.editing, + ); + + // Duration + let duration_str = if state.editing && state.config_field == BenchConfigField::Duration { + format!("{}_", state.edit_buffer) + } else { + format!("{}s", state.duration_secs) + }; + draw_field( + f, + chunks[3], + "Duration", + &duration_str, + state.config_field == BenchConfigField::Duration, + state.editing, + ); + + // Catchup delay + let catchup_str = if state.editing && state.config_field == BenchConfigField::CatchupDelay { + format!("{}_", state.edit_buffer) + } else { + format!("{}s", state.catchup_delay_secs) + }; + draw_field( + f, + chunks[4], + "Catchup Delay", + &catchup_str, + state.config_field == BenchConfigField::CatchupDelay, + state.editing, + ); + + // Start button + let start_style = if state.config_field == BenchConfigField::Start { + Style::default().fg(BG_DARK).bg(GREEN).bold() + } else { + Style::default().fg(GREEN) + }; + let start = Paragraph::new(Line::from(Span::styled(" ▶ Start Benchmark ", start_style))) + .alignment(Alignment::Center); + f.render_widget(start, chunks[5]); +} + +fn draw_bench_running(f: &mut Frame, area: Rect, state: &BenchViewState) { + use crate::tui::event::BenchPhase; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Progress bar + Constraint::Length(5), // Write stats + Constraint::Length(5), + Constraint::Length(5), // Catchup stats (or waiting) + Constraint::Min(3), // Latency stats or chart + ]) + .margin(1) + .split(area); + + // Progress bar + let progress_pct = state.progress_pct.min(100.0); + let phase_text = match state.phase { + BenchPhase::Write => "Writing", + BenchPhase::Read => "Reading", + BenchPhase::CatchupWait => "Waiting for catchup", + BenchPhase::Catchup => "Catchup read", + }; + + let progress_block = Block::default() + .title(Line::from(vec![ + Span::styled(" Progress ", Style::default().fg(TEXT_PRIMARY).bold()), + Span::styled(format!("• {} ", phase_text), Style::default().fg(YELLOW)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let progress_inner = progress_block.inner(chunks[0]); + f.render_widget(progress_block, chunks[0]); + + // Progress bar using block characters + let bar_width = progress_inner.width.saturating_sub(20) as usize; + let filled = ((progress_pct / 100.0) * bar_width as f64) as usize; + let empty = bar_width.saturating_sub(filled); + + let time_display = if state.phase == BenchPhase::Write { + format!( + " {:.1}s / {}s", + state.elapsed_secs.min(state.duration_secs as f64), + state.duration_secs + ) + } else { + format!(" {:.1}s", state.elapsed_secs) + }; + let progress_line = Line::from(vec![ + Span::styled( + format!("{:>5.1}% ", progress_pct), + Style::default().fg(TEXT_PRIMARY), + ), + Span::styled("█".repeat(filled), Style::default().fg(GREEN)), + Span::styled("░".repeat(empty), Style::default().fg(TEXT_MUTED)), + Span::styled(time_display, Style::default().fg(TEXT_SECONDARY)), + ]); + f.render_widget(Paragraph::new(progress_line), progress_inner); + + // Write stats + draw_bench_stat_box( + f, + chunks[1], + "Write", + BLUE, + state.write_mibps, + state.write_recps, + state.write_bytes, + state.write_records, + &state.write_history, + ); + draw_bench_stat_box( + f, + chunks[2], + "Read", + GREEN, + state.read_mibps, + state.read_recps, + state.read_bytes, + state.read_records, + &state.read_history, + ); + + // Catchup stats or waiting message + if matches!(state.phase, BenchPhase::CatchupWait) { + let wait_block = Block::default() + .title(Line::from(Span::styled( + " Catchup ", + Style::default().fg(TEXT_PRIMARY).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + let wait_inner = wait_block.inner(chunks[3]); + f.render_widget(wait_block, chunks[3]); + + let wait_text = Paragraph::new(Line::from(vec![ + Span::styled("Waiting ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("{}s", state.catchup_delay_secs), + Style::default().fg(CYAN), + ), + Span::styled(" before catchup read...", Style::default().fg(TEXT_MUTED)), + ])) + .alignment(Alignment::Center); + f.render_widget(wait_text, wait_inner); + } else { + let empty_history = VecDeque::new(); + draw_bench_stat_box( + f, + chunks[3], + "Catchup", + CYAN, + state.catchup_mibps, + state.catchup_recps, + state.catchup_bytes, + state.catchup_records, + &empty_history, + ); + } + + // Latency stats (only show after completion) + if !state.running && (state.ack_latency.is_some() || state.e2e_latency.is_some()) { + draw_latency_stats(f, chunks[4], &state.ack_latency, &state.e2e_latency); + } else if let Some(error) = &state.error { + let error_block = Block::default() + .title(Line::from(Span::styled( + " Error ", + Style::default().fg(RED).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(RED)); + let error_inner = error_block.inner(chunks[4]); + f.render_widget(error_block, chunks[4]); + + let error_text = Paragraph::new(error.as_str()) + .style(Style::default().fg(RED)) + .wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(error_text, error_inner); + } else { + // Show sparkline area for throughput history + draw_throughput_sparklines(f, chunks[4], &state.write_history, &state.read_history); + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_bench_stat_box( + f: &mut Frame, + area: Rect, + label: &str, + color: Color, + mibps: f64, + recps: f64, + bytes: u64, + records: u64, + history: &VecDeque, +) { + let block = Block::default() + .title(Line::from(Span::styled( + format!(" {} ", label), + Style::default().fg(color).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Layout: stats on left, sparkline on right + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(30), Constraint::Length(25)]) + .split(inner); + + // Stats + let stats = vec![ + Line::from(vec![ + Span::styled(format!("{:>8.2}", mibps), Style::default().fg(color).bold()), + Span::styled(" MiB/s ", Style::default().fg(TEXT_MUTED)), + Span::styled(format!("{:>8.0}", recps), Style::default().fg(color).bold()), + Span::styled(" rec/s", Style::default().fg(TEXT_MUTED)), + ]), + Line::from(vec![ + Span::styled( + format!("{:>8}", format_bytes(bytes)), + Style::default().fg(TEXT_SECONDARY), + ), + Span::styled(" total ", Style::default().fg(TEXT_MUTED)), + Span::styled( + format!("{:>8}", format_number(records)), + Style::default().fg(TEXT_SECONDARY), + ), + Span::styled(" records", Style::default().fg(TEXT_MUTED)), + ]), + ]; + f.render_widget(Paragraph::new(stats), chunks[0]); + + // Sparkline + if !history.is_empty() { + let sparkline_data: Vec = history.iter().map(|v| (*v * 100.0) as u64).collect(); + let sparkline = ratatui::widgets::Sparkline::default() + .data(&sparkline_data) + .style(Style::default().fg(color)); + f.render_widget(sparkline, chunks[1]); + } +} + +fn draw_throughput_sparklines( + f: &mut Frame, + area: Rect, + write_history: &VecDeque, + read_history: &VecDeque, +) { + let block = Block::default() + .title(Line::from(Span::styled( + " Throughput ", + Style::default().fg(TEXT_PRIMARY).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if write_history.is_empty() && read_history.is_empty() { + let waiting = Paragraph::new("Collecting data...") + .style(Style::default().fg(TEXT_MUTED)) + .alignment(Alignment::Center); + f.render_widget(waiting, inner); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner); + + // Write sparkline + if !write_history.is_empty() { + let write_data: Vec = write_history.iter().map(|v| (*v * 100.0) as u64).collect(); + let write_spark = ratatui::widgets::Sparkline::default() + .block(Block::default().title(Span::styled("Write", Style::default().fg(BLUE)))) + .data(&write_data) + .style(Style::default().fg(BLUE)); + f.render_widget(write_spark, chunks[0]); + } + if !read_history.is_empty() { + let read_data: Vec = read_history.iter().map(|v| (*v * 100.0) as u64).collect(); + let read_spark = ratatui::widgets::Sparkline::default() + .block(Block::default().title(Span::styled("Read", Style::default().fg(GREEN)))) + .data(&read_data) + .style(Style::default().fg(GREEN)); + f.render_widget(read_spark, chunks[1]); + } +} + +fn draw_latency_stats( + f: &mut Frame, + area: Rect, + ack_latency: &Option, + e2e_latency: &Option, +) { + let block = Block::default() + .title(Line::from(Span::styled( + " Latency Statistics ", + Style::default().fg(TEXT_PRIMARY).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner); + + // Ack latency + if let Some(stats) = ack_latency { + draw_latency_box(f, chunks[0], "Ack Latency", BLUE, stats); + } + + // E2E latency + if let Some(stats) = e2e_latency { + draw_latency_box(f, chunks[1], "E2E Latency", GREEN, stats); + } +} + +fn draw_latency_box( + f: &mut Frame, + area: Rect, + title: &str, + color: Color, + stats: &crate::types::LatencyStats, +) { + let block = Block::default() + .title(Line::from(Span::styled( + title, + Style::default().fg(color).bold(), + ))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(area); + f.render_widget(block, area); + + let stats_vec = stats.clone().into_vec(); + let max_val = stats_vec + .iter() + .map(|(_, d)| d.as_millis()) + .max() + .unwrap_or(1) as f64; + + let bar_width = inner.width.saturating_sub(20) as f64; + + let lines: Vec = stats_vec + .iter() + .map(|(name, duration)| { + let ms = duration.as_millis(); + let bar_len = ((ms as f64 / max_val) * bar_width).round() as usize; + Line::from(vec![ + Span::styled(format!("{:>5}: ", name), Style::default().fg(TEXT_MUTED)), + Span::styled(format!("{:>4}ms ", ms), Style::default().fg(color).bold()), + Span::styled("█".repeat(bar_len), Style::default().fg(color)), + ]) + }) + .collect(); + + f.render_widget(Paragraph::new(lines), inner); +} + +fn draw_tail_sparklines( + f: &mut Frame, + area: Rect, + throughput_history: &VecDeque, + records_history: &VecDeque, +) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(2), + Constraint::Percentage(50), + Constraint::Percentage(50), + Constraint::Length(2), + ]) + .split(area); + + // Throughput sparkline (MiB/s) + if !throughput_history.is_empty() { + let max_val = throughput_history.iter().cloned().fold(0.1_f64, f64::max); + let data: Vec = throughput_history + .iter() + .map(|v| ((v / max_val) * 100.0) as u64) + .collect(); + + let current = throughput_history.back().copied().unwrap_or(0.0); + let block = Block::default() + .title(Line::from(vec![ + Span::styled(" ▲ ", Style::default().fg(CYAN)), + Span::styled( + format!("{:.2} MiB/s ", current), + Style::default().fg(CYAN).bold(), + ), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(chunks[1]); + f.render_widget(block, chunks[1]); + + let sparkline = ratatui::widgets::Sparkline::default() + .data(&data) + .style(Style::default().fg(CYAN)); + f.render_widget(sparkline, inner); + } + + // Records/s sparkline + if !records_history.is_empty() { + let max_val = records_history.iter().cloned().fold(1.0_f64, f64::max); + let data: Vec = records_history + .iter() + .map(|v| ((v / max_val) * 100.0) as u64) + .collect(); + + let current = records_history.back().copied().unwrap_or(0.0); + let block = Block::default() + .title(Line::from(vec![ + Span::styled(" ◆ ", Style::default().fg(GREEN)), + Span::styled( + format!("{:.0} rec/s ", current), + Style::default().fg(GREEN).bold(), + ), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)); + + let inner = block.inner(chunks[2]); + f.render_widget(block, chunks[2]); + + let sparkline = ratatui::widgets::Sparkline::default() + .data(&data) + .style(Style::default().fg(GREEN)); + f.render_widget(sparkline, inner); + } +} + +fn format_number(n: u64) -> String { + if n >= 1_000_000_000 { + format!("{:.2}B", n as f64 / 1_000_000_000.0) + } else if n >= 1_000_000 { + format!("{:.2}M", n as f64 / 1_000_000.0) + } else if n >= 1_000 { + format!("{:.2}K", n as f64 / 1_000.0) + } else { + n.to_string() + } +} + +/// Draw Picture-in-Picture overlay in bottom-right corner +fn draw_pip(f: &mut Frame, pip: &PipState) { + let area = f.area(); + + // PiP window size: 40 chars wide, 12 lines tall + let pip_width = 44.min(area.width.saturating_sub(4)); + let pip_height = 14.min(area.height.saturating_sub(4)); + + // Position in bottom-right corner with some margin + let pip_area = Rect::new( + area.width.saturating_sub(pip_width + 2), + area.height.saturating_sub(pip_height + 2), + pip_width, + pip_height, + ); + + // Clear background + f.render_widget(Clear, pip_area); + + // Create title with stream info + let title = format!(" {}/{} ", pip.basin_name, pip.stream_name); + let status = if pip.paused { " PAUSED " } else { " LIVE " }; + let status_color = if pip.paused { WARNING } else { SUCCESS }; + + let block = Block::default() + .title(Line::from(vec![ + Span::styled(&title, Style::default().fg(CYAN).bold()), + Span::styled(status, Style::default().fg(BG_DARK).bg(status_color).bold()), + ])) + .title_bottom(Line::from(vec![ + Span::styled(" P", Style::default().fg(TEXT_MUTED)), + Span::styled("=close ", Style::default().fg(TEXT_MUTED)), + ])) + .borders(Borders::ALL) + .border_style(Style::default().fg(CYAN)) + .style(Style::default().bg(BG_PANEL)); + + let inner = block.inner(pip_area); + f.render_widget(block, pip_area); + + // Split inner area: header line + records list + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Stats line + Constraint::Min(1), // Records + ]) + .split(inner); + + // Stats line + let stats = if pip.current_mibps > 0.0 { + format!( + "{:.1} MiB/s {:.0} rec/s {} records", + pip.current_mibps, + pip.current_recps, + pip.records.len() + ) + } else { + format!("{} records", pip.records.len()) + }; + let stats_para = Paragraph::new(Span::styled(&stats, Style::default().fg(TEXT_MUTED))); + f.render_widget(stats_para, chunks[0]); + + // Records list (show last N that fit) + let visible_height = chunks[1].height as usize; + let records_to_show: Vec<_> = pip + .records + .iter() + .rev() + .take(visible_height) + .collect::>() + .into_iter() + .rev() + .collect(); + + if records_to_show.is_empty() { + let waiting = Paragraph::new(Span::styled( + "Waiting for records...", + Style::default().fg(TEXT_MUTED).italic(), + )) + .alignment(Alignment::Center); + f.render_widget(waiting, chunks[1]); + } else { + let items: Vec = records_to_show + .iter() + .map(|record| { + let seq = format!("#{:<6}", record.seq_num); + let body_preview: String = String::from_utf8_lossy(&record.body) + .chars() + .take(28) + .filter(|c| !c.is_control()) + .collect(); + + ListItem::new(Line::from(vec![ + Span::styled(seq, Style::default().fg(TEXT_MUTED)), + Span::styled(" ", Style::default()), + Span::styled(body_preview, Style::default().fg(TEXT_PRIMARY)), + ])) + }) + .collect(); + + let list = List::new(items); + f.render_widget(list, chunks[1]); + } +} + +/// Draw minimized PiP indicator in bottom-right corner +fn draw_pip_minimized(f: &mut Frame, pip: &PipState) { + let area = f.area(); + + // Small indicator: just shows stream name and record count + let indicator_width = 24.min(area.width.saturating_sub(4)); + let indicator_height = 3; + + let indicator_area = Rect::new( + area.width.saturating_sub(indicator_width + 2), + area.height.saturating_sub(indicator_height + 2), + indicator_width, + indicator_height, + ); + + f.render_widget(Clear, indicator_area); + + let status_char = if pip.paused { "⏸" } else { "●" }; + let status_color = if pip.paused { WARNING } else { SUCCESS }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(BORDER)) + .style(Style::default().bg(BG_PANEL)); + + let para = Paragraph::new(Line::from(vec![ + Span::styled(status_char, Style::default().fg(status_color)), + Span::styled(" ", Style::default()), + Span::styled( + format!("{} ({})", pip.stream_name, pip.records.len()), + Style::default().fg(TEXT_SECONDARY), + ), + ])) + .block(block); + + f.render_widget(para, indicator_area); +} diff --git a/src/types.rs b/src/types.rs index a196e67..6a5eec2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -695,7 +695,9 @@ impl From for AccessTokenScope { } } -#[derive(Debug, Clone, Serialize, clap::ValueEnum, strum::Display, strum::EnumString)] +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, clap::ValueEnum, strum::Display, strum::EnumString, +)] #[serde(rename_all = "snake_case")] #[clap(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] @@ -799,6 +801,7 @@ impl From for TimeseriesInterval { } } +#[derive(Debug, Clone)] pub struct LatencyStats { pub min: std::time::Duration, pub median: std::time::Duration,