From 0bd9aead3f5db9fa735ea7eb2ee2acda45214d6a Mon Sep 17 00:00:00 2001 From: robcholz <84130577+robcholz@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:14:21 -0500 Subject: [PATCH] feat: added tui --- Cargo.lock | 927 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 6 + docs/tasks.md | 23 +- docs/tui.md | 28 ++ src/bin/vibebox-tui.rs | 215 ++++++++++ src/tui.rs | 675 ++++++++++++++++++++++++++++++ 6 files changed, 1866 insertions(+), 8 deletions(-) create mode 100644 docs/tui.md create mode 100644 src/bin/vibebox-tui.rs create mode 100644 src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index c3a50fd..6f5647f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,42 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "bitflags" version = "2.10.0" @@ -23,12 +59,134 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[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 = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "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 = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.5" @@ -51,6 +209,12 @@ dependencies = [ "objc2", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -64,7 +228,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", ] [[package]] @@ -73,6 +247,101 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -85,12 +354,47 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.13.0" @@ -98,7 +402,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", +] + +[[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", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", ] [[package]] @@ -117,6 +452,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lexopt" version = "0.3.1" @@ -129,12 +470,69 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.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 = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -206,12 +604,68 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "powerfmt" version = "0.2.0" @@ -242,6 +696,55 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[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.59.0", +] + [[package]] name = "rustix" version = "1.1.3" @@ -251,8 +754,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -261,6 +764,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -300,6 +815,102 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.114" @@ -320,8 +931,8 @@ dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] @@ -344,6 +955,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -375,6 +995,34 @@ dependencies = [ "time-core", ] +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.9.11+spec-1.1.0" @@ -414,12 +1062,92 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "tui-textarea" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e38ced1f941a9cfc923fbf2fe6858443c42cc5220bfd35bdd3648371e7bd8e" +dependencies = [ + "ratatui", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +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", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "uuid" version = "1.20.0" @@ -432,25 +1160,43 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vibebox" version = "0.1.0" dependencies = [ "block2", + "color-eyre", + "crossterm", "dispatch2", + "futures", "lexopt", "libc", "objc2", "objc2-foundation", "objc2-virtualization", + "ratatui", "serde", "tempfile", "thiserror", "time", + "tokio", "toml", + "tui-textarea", "uuid", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -505,12 +1251,52 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -520,6 +1306,135 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.14" diff --git a/Cargo.toml b/Cargo.toml index d683a90..efead0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,9 @@ thiserror = "2.0.18" time = { version = "0.3", features = ["serde", "formatting", "parsing"] } toml = "0.9.8" uuid = { version = "1", features = ["v7", "serde"] } +color-eyre = "0.6.3" +crossterm = { version = "0.28.1", features = ["event-stream"] } +futures = "0.3.31" +ratatui = "0.29.0" +tokio = { version = "1.40.0", features = ["full"] } +tui-textarea = { version = "0.4", default-features = false, features = ["ratatui"] } diff --git a/docs/tasks.md b/docs/tasks.md index 4a8ee9f..2c99afe 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -1,5 +1,7 @@ # Tasks +## SessionManager + 1. [x] Confirm requirements and scope from `implementations.md`. 2. [x] Define `SessionManager` responsibilities and public API (create, load, list, update, delete, bump last_active, refcount handling, cleanup orphaned index entries). 3. [x] Choose 3rd-party crates for UUIDv7, TOML persistence, and error handling (e.g., `uuid` with v7, `serde` + `toml`, `thiserror`). @@ -8,5 +10,22 @@ 6. [x] Add tests for edge cases (missing index, invalid TOML, duplicate sessions, refcount transitions, cleanup on missing instance dir). 7. [ ] Run tests and coverage; target >=80% line/branch coverage using a Rust coverage tool (e.g., `cargo llvm-cov`). 8. [x] Refactor for clarity and reliability while keeping tests green. -9. [ ] Add TUI interface. -10. [ ] Integrate VM and SessionManager together. + +## TUI + +1. [x] Review `docs/tui.md` requirements and translate into concrete UI sections and state model. +2. [x] Add required dependencies for ratatui/crossterm/tokio/color-eyre/futures and pick a text input widget crate. +3. [x] Write unit tests for layout calculations (header/terminal/input/status/completions), completion state transitions, and CLI argument parsing. +4. [x] Implement TUI state model (header info, terminal history, input area, completion list, status bar visibility). +5. [x] Implement rendering functions for header, terminal area, input area, completions, and status bar. +6. [x] Implement async event loop (keyboard, resize, tick) with crossterm EventStream + tokio. +7. [x] Add a standalone TUI CLI binary (no main.rs wiring) with placeholder VM info and TODOs for VM integration. +8. [ ] Run tests and validate coverage for the new module. + +## TUI + + + +## Integration + +1. [ ] Integrate VM and SessionManager together. diff --git a/docs/tui.md b/docs/tui.md new file mode 100644 index 0000000..8e09fba --- /dev/null +++ b/docs/tui.md @@ -0,0 +1,28 @@ +# TUI + +The end-user ui for the vibebox + +## TUI header + +- This is a welcome header. +- Shows text: "Welcome to Vibebox vX.X.XX" +- Shows the ASCII banner +- An outlined box + - Shows the current directory + - Shows current vm version and max memory, cpu cores +- The position is flex, so this will move with the VM terminal history. + +## Terminal Area + +- Shows all the VM terminal history + +## Vibebox input area + +- A text input area, user can input text in it, it can vertically expand depending on the text length, by default it + is a line high. +- it should be able to switch to auto-completion mode, which will display a list of available commands. When in this mode, the bottom + status bar will disappear. The auto completions are displayed right below the text input area. + +## Bottom status bar + +- Display texts in gray, a line high. on the left it shows `:help` for help. \ No newline at end of file diff --git a/src/bin/vibebox-tui.rs b/src/bin/vibebox-tui.rs new file mode 100644 index 0000000..027ada8 --- /dev/null +++ b/src/bin/vibebox-tui.rs @@ -0,0 +1,215 @@ +use std::{env, ffi::OsString, path::PathBuf}; + +use color_eyre::Result; +use lexopt::prelude::*; + +#[path = "../tui.rs"] +mod tui; + +use tui::{AppState, VmInfo}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TuiConfig { + cwd: PathBuf, + vm_version: String, + max_memory_mb: u64, + cpu_cores: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TuiCommand { + Run(TuiConfig), + Help, + Version, +} + +#[derive(Debug, thiserror::Error)] +enum CliError { + #[error("{0}")] + Message(String), + #[error(transparent)] + Lexopt(#[from] lexopt::Error), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +#[tokio::main] +async fn main() -> Result<()> { + color_eyre::install()?; + + let command = parse_args(env::args_os())?; + match command { + TuiCommand::Help => { + print_help(); + } + TuiCommand::Version => { + println!("vibebox-tui {}", env!("CARGO_PKG_VERSION")); + } + TuiCommand::Run(config) => { + let vm_info = VmInfo { + version: config.vm_version, + max_memory_mb: config.max_memory_mb, + cpu_cores: config.cpu_cores, + }; + let mut app = AppState::new(config.cwd, vm_info); + app.push_history("VM output will appear here."); + app.push_history("TODO: wire VM IO into the TUI event loop."); + tui::run_tui(app).await?; + } + } + + Ok(()) +} + +fn print_help() { + println!( + "vibebox-tui\n\nUsage:\n vibebox-tui [options]\n\nOptions:\n --help, -h Show this help\n --version Show version\n --cwd Working directory for the session header\n --vm-version VM version string for the header\n --max-memory Max memory in MB (default 2048)\n --cpu-cores CPU core count (default 2)\n" + ); +} + +fn parse_args(args: I) -> Result +where + I: IntoIterator, +{ + fn os_to_string(value: OsString, flag: &str) -> Result { + value + .into_string() + .map_err(|_| CliError::Message(format!("{flag} expects valid UTF-8"))) + } + + let mut parser = lexopt::Parser::from_iter(args); + let mut cwd: Option = None; + let mut vm_version = env!("CARGO_PKG_VERSION").to_string(); + let mut max_memory_mb: u64 = 2048; + let mut cpu_cores: usize = 2; + + while let Some(arg) = parser.next()? { + match arg { + Long("help") | Short('h') => return Ok(TuiCommand::Help), + Long("version") => return Ok(TuiCommand::Version), + Long("cwd") => { + let value = os_to_string(parser.value()?, "--cwd")?; + cwd = Some(PathBuf::from(value)); + } + Long("vm-version") => { + vm_version = os_to_string(parser.value()?, "--vm-version")?; + } + Long("max-memory") => { + let value: u64 = os_to_string(parser.value()?, "--max-memory")? + .parse() + .map_err(|_| { + CliError::Message("--max-memory expects an integer".to_string()) + })?; + if value == 0 { + return Err(CliError::Message("--max-memory must be >= 1".to_string())); + } + max_memory_mb = value; + } + Long("cpu-cores") => { + let value: usize = os_to_string(parser.value()?, "--cpu-cores")? + .parse() + .map_err(|_| CliError::Message("--cpu-cores expects an integer".to_string()))?; + if value == 0 { + return Err(CliError::Message("--cpu-cores must be >= 1".to_string())); + } + cpu_cores = value; + } + Value(value) => { + return Err(CliError::Message(format!( + "unexpected argument: {}", + value.to_string_lossy() + ))); + } + _ => return Err(CliError::Message(arg.unexpected().to_string())), + } + } + + let cwd = match cwd { + Some(dir) => dir, + None => env::current_dir()?, + }; + + Ok(TuiCommand::Run(TuiConfig { + cwd, + vm_version, + max_memory_mb, + cpu_cores, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_from(args: &[&str]) -> Result { + let mut argv = vec![OsString::from("vibebox-tui")]; + argv.extend(args.iter().map(OsString::from)); + parse_args(argv) + } + + #[test] + fn parse_help_short_circuit() { + let command = parse_from(&["--help"]).unwrap(); + assert!(matches!(command, TuiCommand::Help)); + } + + #[test] + fn parse_version_short_circuit() { + let command = parse_from(&["--version"]).unwrap(); + assert!(matches!(command, TuiCommand::Version)); + } + + #[test] + fn parse_defaults() { + let command = parse_from(&[]).unwrap(); + let TuiCommand::Run(config) = command else { + panic!("expected run command"); + }; + + assert_eq!(config.vm_version, env!("CARGO_PKG_VERSION")); + assert_eq!(config.max_memory_mb, 2048); + assert_eq!(config.cpu_cores, 2); + } + + #[test] + fn parse_overrides() { + let command = parse_from(&[ + "--cwd", + "/tmp", + "--vm-version", + "13.1", + "--max-memory", + "4096", + "--cpu-cores", + "4", + ]) + .unwrap(); + + let TuiCommand::Run(config) = command else { + panic!("expected run command"); + }; + + assert_eq!(config.cwd, PathBuf::from("/tmp")); + assert_eq!(config.vm_version, "13.1"); + assert_eq!(config.max_memory_mb, 4096); + assert_eq!(config.cpu_cores, 4); + } + + #[test] + fn parse_rejects_zero_cpu() { + let err = parse_from(&["--cpu-cores", "0"]).unwrap_err(); + assert!(err.to_string().contains("cpu-cores")); + } + + #[test] + fn parse_rejects_zero_memory() { + let err = parse_from(&["--max-memory", "0"]).unwrap_err(); + assert!(err.to_string().contains("max-memory")); + } + + #[test] + fn parse_rejects_unknown_argument() { + let err = parse_from(&["--unknown"]).unwrap_err(); + assert!(!err.to_string().is_empty()); + } +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..cca1436 --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,675 @@ +use std::{ + io::{self, Stdout}, + path::PathBuf, + time::Duration, +}; + +use color_eyre::Result; +use crossterm::{ + event::{ + DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, EventStream, KeyCode, + KeyEvent, KeyEventKind, KeyModifiers, + }, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use futures::StreamExt; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, +}; +use tui_textarea::{Input, Key, TextArea}; + +const ASCII_BANNER: [&str; 7] = [ + "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░", + "░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░", + " ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░", + " ░▒▓█▓▒▒▓█▓▒░░▒▓█▓▒░▒▓███████▓▒░░▒▓██████▓▒░", + " ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░", + " ░▒▓█▓▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░", + " ░▒▓██▓▒░ ░▒▓█▓▒░▒▓███████▓▒░░▒▓████████▓▒░", +]; + +const STATUS_BAR_HEIGHT: u16 = 1; +const COMPLETIONS_MAX_HEIGHT: u16 = 6; +const SPINNER_FRAMES: [&str; 4] = ["|", "/", "-", "\\"]; + +#[derive(Debug, Clone)] +pub struct VmInfo { + pub version: String, + pub max_memory_mb: u64, + pub cpu_cores: usize, +} + +#[derive(Debug)] +pub struct AppState { + pub cwd: PathBuf, + pub vm_info: VmInfo, + pub history: Vec, + pub input: TextArea<'static>, + pub completions: CompletionState, + pub should_quit: bool, + key_input_mode: KeyInputMode, + tick: u64, + spinner: usize, + terminal_scroll: usize, +} + +impl AppState { + pub fn new(cwd: PathBuf, vm_info: VmInfo) -> Self { + let mut input = TextArea::default(); + input.set_cursor_style(Style::default().fg(Color::Yellow)); + input.set_block(Block::default().borders(Borders::ALL).title("Input")); + + Self { + cwd, + vm_info, + history: Vec::new(), + input, + completions: CompletionState::default(), + should_quit: false, + key_input_mode: KeyInputMode::Unknown, + tick: 0, + spinner: 0, + terminal_scroll: 0, + } + } + + pub fn input_line_count(&self) -> u16 { + self.input.lines().len().max(1) as u16 + } + + pub fn input_height(&self) -> u16 { + let mut height = self.input_line_count(); + if self.input.block().is_some() { + height = height.saturating_add(2); + } + height.max(1) + } + + pub fn push_history(&mut self, line: impl Into) { + self.history.push(line.into()); + if self.history.len() > 2000 { + let excess = self.history.len() - 2000; + self.history.drain(0..excess); + } + } + + pub fn activate_completions(&mut self, items: Vec) { + self.completions.set_items(items); + self.completions.activate(); + } + + pub fn deactivate_completions(&mut self) { + self.completions.deactivate(); + } + + pub fn toggle_completions(&mut self, items: Vec) { + if self.completions.active { + self.deactivate_completions(); + } else { + self.activate_completions(items); + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct CompletionState { + items: Vec, + selected: usize, + active: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyInputMode { + Unknown, + Press, + Release, +} + +impl CompletionState { + pub fn set_items(&mut self, items: Vec) { + self.items = items; + self.selected = 0; + } + + pub fn activate(&mut self) { + self.active = !self.items.is_empty(); + self.selected = 0; + } + + pub fn deactivate(&mut self) { + self.active = false; + } + + pub fn next(&mut self) { + if !self.active || self.items.is_empty() { + return; + } + self.selected = (self.selected + 1) % self.items.len(); + } + + pub fn previous(&mut self) { + if !self.active || self.items.is_empty() { + return; + } + if self.selected == 0 { + self.selected = self.items.len() - 1; + } else { + self.selected -= 1; + } + } + + pub fn current(&self) -> Option<&str> { + if self.active { + self.items.get(self.selected).map(|s| s.as_str()) + } else { + None + } + } + + pub fn items(&self) -> &[String] { + &self.items + } + + pub fn is_active(&self) -> bool { + self.active + } + + pub fn selected(&self) -> usize { + self.selected + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LayoutAreas { + pub header: Rect, + pub terminal: Rect, + pub input: Rect, + pub completions: Rect, + pub status: Rect, +} + +pub fn compute_layout( + area: Rect, + input_height: u16, + completion_items: usize, + completion_active: bool, +) -> LayoutAreas { + let header_height = header_height().min(area.height); + let mut remaining = area.height.saturating_sub(header_height); + + let input_height = input_height.max(1).min(remaining); + remaining = remaining.saturating_sub(input_height); + + let (completion_height, status_height) = if completion_active { + let desired = (completion_items as u16).min(COMPLETIONS_MAX_HEIGHT); + let height = desired.min(remaining); + (height, 0) + } else { + let height = STATUS_BAR_HEIGHT.min(remaining); + (0, height) + }; + + remaining = remaining.saturating_sub(completion_height + status_height); + + let terminal_height = remaining; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(header_height), + Constraint::Length(terminal_height), + Constraint::Length(input_height), + Constraint::Length(completion_height), + Constraint::Length(status_height), + ]) + .split(area); + + LayoutAreas { + header: chunks[0], + terminal: chunks[1], + input: chunks[2], + completions: chunks[3], + status: chunks[4], + } +} + +fn header_height() -> u16 { + let banner_height = ASCII_BANNER.len() as u16; + let welcome_height = 1; + let info_height = 4; + welcome_height + banner_height + info_height +} + +pub async fn run_tui(mut app: AppState) -> Result<()> { + let mut terminal = TerminalGuard::init()?; + let mut events = EventStream::new(); + let mut tick = tokio::time::interval(Duration::from_millis(250)); + + loop { + terminal.draw(|frame| render(frame, &mut app))?; + + tokio::select! { + _ = tick.tick() => { + app.tick = app.tick.wrapping_add(1); + app.spinner = (app.spinner + 1) % SPINNER_FRAMES.len(); + }, + event = events.next() => { + if let Some(event) = event { + handle_event(event?, &mut app); + } + } + } + + if app.should_quit { + break; + } + } + + Ok(()) +} + +fn handle_event(event: CrosstermEvent, app: &mut AppState) { + match event { + CrosstermEvent::Key(key) => handle_key_event(key, app), + CrosstermEvent::Resize(_, _) => {} + CrosstermEvent::Mouse(event) => handle_mouse_event(event, app), + CrosstermEvent::FocusGained | CrosstermEvent::FocusLost => {} + CrosstermEvent::Paste(text) => { + app.input.insert_str(&text); + } + } +} + +fn handle_key_event(key: KeyEvent, app: &mut AppState) { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + app.should_quit = true; + return; + } + + if app.completions.is_active() { + match key.code { + KeyCode::Esc => app.deactivate_completions(), + KeyCode::Up => app.completions.previous(), + KeyCode::Down => app.completions.next(), + KeyCode::Enter => { + if let Some(selection) = app.completions.current() { + app.input.insert_str(selection); + } + app.deactivate_completions(); + } + _ => {} + } + return; + } + + if !should_handle_key_event(app, &key) { + return; + } + + match key.code { + KeyCode::PageUp => { + app.terminal_scroll = app.terminal_scroll.saturating_add(10); + } + KeyCode::PageDown => { + app.terminal_scroll = app.terminal_scroll.saturating_sub(10); + } + KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.terminal_scroll = app.terminal_scroll.saturating_add(1); + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.terminal_scroll = app.terminal_scroll.saturating_sub(1); + } + KeyCode::Tab => app.toggle_completions(default_completions()), + _ => { + app.input.input(input_from_key_event(key)); + } + } +} + +fn handle_mouse_event(event: crossterm::event::MouseEvent, app: &mut AppState) { + use crossterm::event::MouseEventKind; + match event.kind { + MouseEventKind::ScrollUp => { + app.terminal_scroll = app.terminal_scroll.saturating_add(3); + } + MouseEventKind::ScrollDown => { + app.terminal_scroll = app.terminal_scroll.saturating_sub(3); + } + _ => {} + } +} + +fn should_handle_key_event(app: &mut AppState, key: &KeyEvent) -> bool { + match key.kind { + KeyEventKind::Press | KeyEventKind::Repeat => { + app.key_input_mode = KeyInputMode::Press; + return true; + } + KeyEventKind::Release => {} + } + + if app.key_input_mode == KeyInputMode::Press { + return false; + } + + if key.code == KeyCode::Null { + return false; + } + + if app.key_input_mode == KeyInputMode::Unknown { + app.key_input_mode = KeyInputMode::Release; + } + + true +} + +fn input_from_key_event(key: KeyEvent) -> Input { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let alt = key.modifiers.contains(KeyModifiers::ALT); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + + let key = match key.code { + KeyCode::Char(c) => Key::Char(c), + KeyCode::Enter => Key::Enter, + KeyCode::Backspace => Key::Backspace, + KeyCode::Left => Key::Left, + KeyCode::Right => Key::Right, + KeyCode::Up => Key::Up, + KeyCode::Down => Key::Down, + KeyCode::Tab | KeyCode::BackTab => Key::Tab, + KeyCode::Delete => Key::Delete, + KeyCode::Home => Key::Home, + KeyCode::End => Key::End, + KeyCode::PageUp => Key::PageUp, + KeyCode::PageDown => Key::PageDown, + KeyCode::Esc => Key::Esc, + KeyCode::F(n) => Key::F(n), + _ => Key::Null, + }; + + Input { + key, + ctrl, + alt, + shift, + } +} + +fn default_completions() -> Vec { + vec![":help".to_string(), ":new".to_string(), ":exit".to_string()] +} + +fn render(frame: &mut Frame<'_>, app: &mut AppState) { + let area = frame.area(); + let layout = compute_layout( + area, + app.input_height(), + app.completions.items().len(), + app.completions.is_active(), + ); + + render_header(frame, layout.header, app); + render_terminal(frame, layout.terminal, app); + render_input(frame, layout.input, app); + + if app.completions.is_active() { + render_completions(frame, layout.completions, app); + } else { + render_status(frame, layout.status, app); + } +} + +fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + if area.height == 0 { + return; + } + + let header_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(ASCII_BANNER.len() as u16), + Constraint::Length(4), + ]) + .split(area); + + let welcome = Line::from(vec![ + Span::raw("Welcome to Vibebox v"), + Span::styled(&app.vm_info.version, Style::default().fg(Color::Yellow)), + ]); + + frame.render_widget(Paragraph::new(welcome), header_chunks[0]); + + let banner_lines = ASCII_BANNER.iter().map(|line| Line::from(*line)); + frame.render_widget( + Paragraph::new(Text::from_iter(banner_lines)), + header_chunks[1], + ); + + let info_block = Block::default().borders(Borders::ALL).title("Session"); + let info_lines = vec![ + Line::from(vec![ + Span::raw("Directory: "), + Span::styled(app.cwd.to_string_lossy(), Style::default().fg(Color::Cyan)), + ]), + Line::from(vec![ + Span::raw("VM Version: "), + Span::styled(&app.vm_info.version, Style::default().fg(Color::Green)), + ]), + Line::from(vec![ + Span::raw("CPU / Memory: "), + Span::styled( + format!( + "{} cores / {} MB", + app.vm_info.cpu_cores, app.vm_info.max_memory_mb + ), + Style::default().fg(Color::Green), + ), + ]), + ]; + + frame.render_widget( + Paragraph::new(info_lines).block(info_block), + header_chunks[2], + ); +} + +fn render_terminal(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let lines = app.history.iter().map(|line| Line::from(line.as_str())); + let block = Block::default().borders(Borders::ALL).title("Terminal"); + let inner = block.inner(area); + let inner_height = inner.height.max(1) as usize; + let total_lines = app.history.len(); + let max_top = total_lines.saturating_sub(inner_height); + let terminal_scroll = app.terminal_scroll.min(max_top); + let scroll_top = max_top.saturating_sub(terminal_scroll); + let paragraph = Paragraph::new(Text::from_iter(lines)) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((scroll_top.min(u16::MAX as usize) as u16, 0)); + + frame.render_widget(paragraph, area); +} + +fn render_input(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + frame.render_widget(app.input.widget(), area); + if area.height > 0 && area.width > 0 { + let cursor = app.input.cursor(); + let inner = match app.input.block() { + Some(block) => block.inner(area), + None => area, + }; + let x = inner.x.saturating_add(cursor.1 as u16); + let y = inner.y.saturating_add(cursor.0 as u16); + if x < inner.x.saturating_add(inner.width) && y < inner.y.saturating_add(inner.height) { + frame.set_cursor_position((x, y)); + } + } +} + +fn render_completions(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + if area.height == 0 { + return; + } + + let items: Vec> = app + .completions + .items() + .iter() + .map(|item| ListItem::new(Line::from(item.as_str()))) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Completions")) + .highlight_style(Style::default().fg(Color::Yellow)); + + let mut state = ListState::default(); + if app.completions.is_active() && !app.completions.items().is_empty() { + state.select(Some(app.completions.selected())); + } + + frame.render_stateful_widget(list, area, &mut state); +} + +fn render_status(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + if area.height == 0 { + return; + } + + let spinner = SPINNER_FRAMES[app.spinner % SPINNER_FRAMES.len()]; + let status = Paragraph::new(Line::from(vec![ + Span::styled(":help", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled( + format!("tick {} {}", app.tick, spinner), + Style::default().fg(Color::DarkGray), + ), + ])); + + frame.render_widget(status, area); +} + +struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + fn init() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(Self { terminal }) + } + + fn draw(&mut self, f: impl FnOnce(&mut Frame<'_>)) -> Result<()> { + self.terminal.draw(f)?; + Ok(()) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_without_completions_reserves_status_bar() { + let area = Rect::new(0, 0, 80, 30); + let layout = compute_layout(area, 3, 0, false); + + assert_eq!(layout.header.height, header_height()); + assert_eq!(layout.status.height, STATUS_BAR_HEIGHT); + assert_eq!(layout.completions.height, 0); + assert!(layout.terminal.height > 0); + } + + #[test] + fn layout_with_completions_hides_status_bar() { + let area = Rect::new(0, 0, 80, 30); + let layout = compute_layout(area, 4, 3, true); + + assert_eq!(layout.status.height, 0); + assert_eq!(layout.completions.height, 3); + } + + #[test] + fn layout_clamps_when_space_is_tight() { + let area = Rect::new(0, 0, 80, header_height() + 1); + let layout = compute_layout(area, 3, 10, true); + + assert_eq!(layout.header.height, header_height()); + assert_eq!(layout.input.height, 1); + assert_eq!(layout.terminal.height, 0); + } + + #[test] + fn completion_state_wraps_navigation() { + let mut completions = CompletionState::default(); + completions.set_items(vec!["a".into(), "b".into(), "c".into()]); + completions.activate(); + + assert_eq!(completions.current(), Some("a")); + + completions.next(); + assert_eq!(completions.current(), Some("b")); + + completions.next(); + completions.next(); + assert_eq!(completions.current(), Some("a")); + + completions.previous(); + assert_eq!(completions.current(), Some("c")); + } + + #[test] + fn completion_state_is_inactive_when_empty() { + let mut completions = CompletionState::default(); + completions.activate(); + + assert!(!completions.is_active()); + assert_eq!(completions.current(), None); + } + + #[test] + fn input_from_key_event_maps_char_and_modifiers() { + let key = KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + ); + let input = input_from_key_event(key); + + assert_eq!(input.key, Key::Char('x')); + assert!(input.ctrl); + assert!(input.alt); + assert!(!input.shift); + } + + #[test] + fn input_from_key_event_maps_special_keys() { + let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + let input = input_from_key_event(key); + + assert_eq!(input.key, Key::Backspace); + assert!(!input.ctrl); + assert!(!input.alt); + assert!(!input.shift); + } +}