From cc9f1692648073f1e48a6c1e73e1d304764746e3 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Mon, 23 Mar 2026 12:53:34 +0000 Subject: [PATCH] feat(code): wire TUI into real code path, /exit, color swap - user input: white text, dim > prompt - sol responses: warm yellow - /exit slash command quits cleanly - TUI replaces stdin loop in sunbeam code start - hidden demo mode for testing (sunbeam code demo) --- Cargo.lock | 576 +++++++++++++++++++++++++++++++++++++++- sunbeam/src/code/mod.rs | 209 +++++++++++++-- sunbeam/src/code/tui.rs | 437 ++++++++++++++++++++++++++++++ 3 files changed, 1188 insertions(+), 34 deletions(-) create mode 100644 sunbeam/src/code/tui.rs diff --git a/Cargo.lock b/Cargo.lock index 2fa0b49..ce102df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,49 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "backon" version = "1.6.0" @@ -369,6 +412,21 @@ 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 = "cbc" version = "0.1.2" @@ -502,6 +560,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[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 = "concurrent-queue" version = "2.5.0" @@ -587,6 +659,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[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" @@ -658,8 +755,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[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]] @@ -676,13 +783,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.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "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", ] @@ -1011,6 +1142,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1651,6 +1788,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -1661,6 +1807,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1683,6 +1842,24 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1832,7 +2009,7 @@ version = "0.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c562f58dc9f7ca5feac8a6ee5850ca221edd6f04ce0dd2ee873202a88cd494c9" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "serde", @@ -1936,6 +2113,12 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[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.12.1" @@ -1963,6 +2146,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" @@ -1978,6 +2170,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md5" version = "0.7.0" @@ -2019,10 +2217,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nom" version = "7.1.3" @@ -2269,6 +2474,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 = "pbkdf2" version = "0.12.2" @@ -2347,6 +2558,17 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -2498,6 +2720,59 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + [[package]] name = "psm" version = "0.1.30" @@ -2508,6 +2783,26 @@ dependencies = [ "cc", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2649,6 +2944,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", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rcgen" version = "0.14.7" @@ -2968,6 +3284,19 @@ dependencies = [ "nom 7.1.3", ] +[[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.4" @@ -2977,7 +3306,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3259,6 +3588,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3321,6 +3659,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" @@ -3455,12 +3814,40 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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 = "subtle" version = "2.6.1" @@ -3471,15 +3858,35 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" name = "sunbeam" version = "1.0.1" dependencies = [ + "anyhow", "chrono", "clap", + "crossterm", + "ratatui", "rustls", + "serde", + "serde_json", + "sunbeam-proto", "sunbeam-sdk", "tokio", + "tokio-stream", + "toml", + "tonic", "tracing", "tracing-subscriber", ] +[[package]] +name = "sunbeam-proto" +version = "0.1.0" +dependencies = [ + "prost", + "tonic", + "tonic-build", + "tonic-prost", + "tonic-prost-build", +] + [[package]] name = "sunbeam-sdk" version = "1.0.1" @@ -3567,7 +3974,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3761,6 +4168,115 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + [[package]] name = "tower" version = "0.5.3" @@ -3769,7 +4285,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", "tokio-util", @@ -3908,12 +4426,47 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.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" +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 = "unicode-xid" version = "0.2.6" @@ -4515,6 +5068,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wiremock" version = "0.6.5" @@ -4657,7 +5219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] diff --git a/sunbeam/src/code/mod.rs b/sunbeam/src/code/mod.rs index 2b8b843..2aa8339 100644 --- a/sunbeam/src/code/mod.rs +++ b/sunbeam/src/code/mod.rs @@ -2,6 +2,7 @@ pub mod client; pub mod config; pub mod project; pub mod tools; +pub mod tui; use clap::Subcommand; use tracing::info; @@ -17,6 +18,9 @@ pub enum CodeCommand { #[arg(long)] endpoint: Option, }, + /// Demo the TUI with sample data (no Sol connection needed) + #[command(hide = true)] + Demo, } pub async fn cmd_code(cmd: Option) -> sunbeam_sdk::error::Result<()> { @@ -30,6 +34,9 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { }); match cmd { + CodeCommand::Demo => { + return run_demo().await; + } CodeCommand::Start { model, endpoint } => { let endpoint = endpoint.unwrap_or_else(|| "http://127.0.0.1:50051".into()); @@ -59,39 +66,187 @@ async fn cmd_code_inner(cmd: Option) -> anyhow::Result<()> { "Connected to Sol" ); - // For now, simple stdin loop (ratatui TUI in Phase 4) - println!("sunbeam code · {} · {}", project.name, model); - println!("connected to Sol (session: {})", &session.session_id[..8]); - println!("type a message, /quit to exit\n"); + // TUI event loop + use crossterm::event::{self, Event, KeyCode, KeyModifiers}; - let stdin = tokio::io::stdin(); - let reader = tokio::io::BufReader::new(stdin); - use tokio::io::AsyncBufReadExt; - let mut lines = reader.lines(); + let mut terminal = tui::setup_terminal()?; + let branch = project.git_branch.as_deref().unwrap_or("?"); + let mut app = tui::App::new(&project.name, branch, &model); - while let Ok(Some(line)) = lines.next_line().await { - let line = line.trim().to_string(); - if line.is_empty() { - continue; - } - if line == "/quit" { - session.end().await?; - println!("session ended."); - break; - } + let result = loop { + terminal.draw(|frame| tui::draw(frame, &app))?; - print!("> "); - match session.chat(&line).await { - Ok(response) => { - println!("\n{}\n", response); - } - Err(e) => { - eprintln!("error: {e}"); + if event::poll(std::time::Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break Ok(()), + KeyCode::Char(c) => { + app.input.insert(app.cursor_pos, c); + app.cursor_pos += 1; + } + KeyCode::Backspace => { + if app.cursor_pos > 0 { + app.cursor_pos -= 1; + app.input.remove(app.cursor_pos); + } + } + KeyCode::Left => app.cursor_pos = app.cursor_pos.saturating_sub(1), + KeyCode::Right => app.cursor_pos = (app.cursor_pos + 1).min(app.input.len()), + KeyCode::Up => app.scroll_offset = app.scroll_offset.saturating_sub(1), + KeyCode::Down => app.scroll_offset = app.scroll_offset.saturating_add(1), + KeyCode::Enter => { + if !app.input.is_empty() { + let text = app.input.clone(); + app.input.clear(); + app.cursor_pos = 0; + + if text == "/exit" { + let _ = session.end().await; + break Ok(()); + } + + app.push_log(tui::LogEntry::UserInput(text.clone())); + app.is_thinking = true; + + // Force a redraw to show "thinking..." + terminal.draw(|frame| tui::draw(frame, &app))?; + + match session.chat(&text).await { + Ok(response) => { + app.is_thinking = false; + app.push_log(tui::LogEntry::AssistantText(response)); + } + Err(e) => { + app.is_thinking = false; + app.push_log(tui::LogEntry::Error(e.to_string())); + } + } + } + } + _ => {} + } } } - } + }; - Ok(()) + tui::restore_terminal(&mut terminal)?; + result } } } + +async fn run_demo() -> anyhow::Result<()> { + use crossterm::event::{self, Event, KeyCode, KeyModifiers}; + + let mut terminal = tui::setup_terminal()?; + let mut app = tui::App::new("sol", "mainline ±", "devstral-2"); + + // Populate with sample conversation + app.push_log(tui::LogEntry::UserInput("fix the token validation bug in auth.rs".into())); + app.push_log(tui::LogEntry::AssistantText( + "Looking at the auth module, I can see the issue on line 42 where the token \ + is not properly validated before use. The expiry check is missing entirely." + .into(), + )); + app.push_log(tui::LogEntry::ToolSuccess { + name: "file_read".into(), + detail: "src/auth.rs (127 lines)".into(), + }); + app.push_log(tui::LogEntry::ToolOutput { + lines: vec![ + "38│ fn validate_token(token: &str) -> bool {".into(), + "39│ let decoded = decode(token);".into(), + "40│ // BUG: missing expiry check".into(), + "41│ decoded.is_ok()".into(), + "42│ }".into(), + "43│".into(), + "44│ fn refresh_token(token: &str) -> Result {".into(), + "45│ let client = reqwest::Client::new();".into(), + "46│ // ...".into(), + ], + collapsed: true, + }); + app.push_log(tui::LogEntry::ToolSuccess { + name: "search_replace".into(), + detail: "src/auth.rs — applied 1 replacement (line 41)".into(), + }); + app.push_log(tui::LogEntry::ToolExecuting { + name: "bash".into(), + detail: "cargo test --lib".into(), + }); + app.push_log(tui::LogEntry::ToolOutput { + lines: vec![ + "running 23 tests".into(), + "test auth::tests::test_validate_token ... ok".into(), + "test auth::tests::test_expired_token ... ok".into(), + "test auth::tests::test_refresh_flow ... ok".into(), + "test result: ok. 23 passed; 0 failed".into(), + ], + collapsed: false, + }); + app.push_log(tui::LogEntry::AssistantText( + "Fixed. The token validation now checks expiry before use. All 23 tests pass." + .into(), + )); + app.push_log(tui::LogEntry::UserInput("now add rate limiting to the auth endpoint".into())); + app.push_log(tui::LogEntry::ToolExecuting { + name: "file_read".into(), + detail: "src/routes/auth.rs".into(), + }); + app.is_thinking = true; + app.input_tokens = 2400; + app.output_tokens = 890; + + loop { + terminal.draw(|frame| tui::draw(frame, &app))?; + + if event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break, + KeyCode::Char('q') => break, + KeyCode::Char(c) => { + app.input.insert(app.cursor_pos, c); + app.cursor_pos += 1; + } + KeyCode::Backspace => { + if app.cursor_pos > 0 { + app.cursor_pos -= 1; + app.input.remove(app.cursor_pos); + } + } + KeyCode::Left => { + app.cursor_pos = app.cursor_pos.saturating_sub(1); + } + KeyCode::Right => { + app.cursor_pos = (app.cursor_pos + 1).min(app.input.len()); + } + KeyCode::Enter => { + if !app.input.is_empty() { + let text = app.input.clone(); + app.input.clear(); + app.cursor_pos = 0; + + if text == "/exit" { + break; + } + + app.push_log(tui::LogEntry::UserInput(text)); + app.is_thinking = true; + } + } + KeyCode::Up => { + app.scroll_offset = app.scroll_offset.saturating_sub(1); + } + KeyCode::Down => { + app.scroll_offset = app.scroll_offset.saturating_add(1); + } + _ => {} + } + } + } + } + + tui::restore_terminal(&mut terminal)?; + Ok(()) +} diff --git a/sunbeam/src/code/tui.rs b/sunbeam/src/code/tui.rs new file mode 100644 index 0000000..8a030c2 --- /dev/null +++ b/sunbeam/src/code/tui.rs @@ -0,0 +1,437 @@ +use std::io; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::execute; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Terminal; + +// ── Sol color palette ────────────────────────────────────────────────────── + +const SOL_YELLOW: Color = Color::Rgb(245, 197, 66); +const SOL_AMBER: Color = Color::Rgb(232, 168, 64); +const SOL_BLUE: Color = Color::Rgb(108, 166, 224); +const SOL_RED: Color = Color::Rgb(224, 88, 88); +const SOL_DIM: Color = Color::Rgb(138, 122, 90); +const SOL_GRAY: Color = Color::Rgb(112, 112, 112); +const SOL_FAINT: Color = Color::Rgb(80, 80, 80); +const SOL_STATUS: Color = Color::Rgb(106, 96, 80); +const SOL_APPROVAL_BG: Color = Color::Rgb(50, 42, 20); +const SOL_APPROVAL_CMD: Color = Color::Rgb(200, 180, 120); + +// ── Message types for the conversation log ───────────────────────────────── + +#[derive(Clone)] +pub enum LogEntry { + UserInput(String), + AssistantText(String), + ToolSuccess { name: String, detail: String }, + ToolExecuting { name: String, detail: String }, + ToolFailed { name: String, detail: String }, + ToolOutput { lines: Vec, collapsed: bool }, + Status(String), + Error(String), +} + +// ── Approval state ───────────────────────────────────────────────────────── + +pub struct ApprovalPrompt { + pub tool_name: String, + pub command: String, + pub options: Vec, + pub selected: usize, +} + +// ── App state ────────────────────────────────────────────────────────────── + +pub struct App { + pub log: Vec, + pub input: String, + pub cursor_pos: usize, + pub scroll_offset: u16, + pub project_name: String, + pub branch: String, + pub model: String, + pub input_tokens: u32, + pub output_tokens: u32, + pub approval: Option, + pub is_thinking: bool, + pub should_quit: bool, +} + +impl App { + pub fn new(project_name: &str, branch: &str, model: &str) -> Self { + Self { + log: Vec::new(), + input: String::new(), + cursor_pos: 0, + scroll_offset: 0, + project_name: project_name.into(), + branch: branch.into(), + model: model.into(), + input_tokens: 0, + output_tokens: 0, + approval: None, + is_thinking: false, + should_quit: false, + } + } + + pub fn push_log(&mut self, entry: LogEntry) { + self.log.push(entry); + // Auto-scroll to bottom + self.scroll_offset = u16::MAX; + } +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +pub fn draw(frame: &mut ratatui::Frame, app: &App) { + let area = frame.area(); + + // Layout: title (1) + log (flex) + input (3) + status (1) + let chunks = Layout::vertical([ + Constraint::Length(1), // title bar + Constraint::Min(5), // conversation log + Constraint::Length(3), // input area + Constraint::Length(1), // status bar + ]) + .split(area); + + draw_title_bar(frame, chunks[0], app); + draw_log(frame, chunks[1], app); + + if let Some(ref approval) = app.approval { + draw_approval(frame, chunks[2], approval); + } else { + draw_input(frame, chunks[2], app); + } + + draw_status_bar(frame, chunks[3], app); +} + +fn draw_title_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let left = vec![ + Span::styled("sunbeam code", Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)), + Span::styled(" · ", Style::default().fg(SOL_FAINT)), + Span::raw(&app.project_name), + Span::styled(" · ", Style::default().fg(SOL_FAINT)), + Span::styled(&app.branch, Style::default().fg(SOL_DIM)), + ]; + + let right = Span::styled(&app.model, Style::default().fg(SOL_DIM)); + + // Render left-aligned title and right-aligned model + let title_line = Line::from(left); + frame.render_widget(Paragraph::new(title_line), area); + + let right_area = Rect { + x: area.width.saturating_sub(right.width() as u16 + 1), + y: area.y, + width: right.width() as u16 + 1, + height: 1, + }; + frame.render_widget(Paragraph::new(Line::from(right)), right_area); +} + +fn draw_log(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let mut lines: Vec = Vec::new(); + + for entry in &app.log { + match entry { + LogEntry::UserInput(text) => { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("> ", Style::default().fg(SOL_DIM)), + Span::raw(text.as_str()), + ])); + lines.push(Line::from("")); + } + LogEntry::AssistantText(text) => { + for line in text.lines() { + lines.push(Line::from(Span::styled(line, Style::default().fg(SOL_YELLOW)))); + } + } + LogEntry::ToolSuccess { name, detail } => { + lines.push(Line::from(vec![ + Span::styled(" ✓ ", Style::default().fg(SOL_BLUE)), + Span::styled(name.as_str(), Style::default().fg(SOL_AMBER)), + Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), + ])); + } + LogEntry::ToolExecuting { name, detail } => { + lines.push(Line::from(vec![ + Span::styled(" ● ", Style::default().fg(SOL_AMBER)), + Span::styled(name.as_str(), Style::default().fg(SOL_AMBER)), + Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), + ])); + } + LogEntry::ToolFailed { name, detail } => { + lines.push(Line::from(vec![ + Span::styled(" ✗ ", Style::default().fg(SOL_RED)), + Span::styled(name.as_str(), Style::default().fg(SOL_RED)), + Span::styled(format!(" {detail}"), Style::default().fg(SOL_DIM)), + ])); + } + LogEntry::ToolOutput { lines: output_lines, collapsed } => { + let show = if *collapsed { 5 } else { output_lines.len() }; + for line in output_lines.iter().take(show) { + lines.push(Line::from(Span::styled( + format!(" {line}"), + Style::default().fg(SOL_GRAY), + ))); + } + if *collapsed && output_lines.len() > 5 { + lines.push(Line::from(Span::styled( + format!(" … +{} lines (ctrl+o to expand)", output_lines.len() - 5), + Style::default().fg(SOL_FAINT), + ))); + } + } + LogEntry::Status(msg) => { + lines.push(Line::from(Span::styled( + format!(" [{msg}]"), + Style::default().fg(SOL_DIM), + ))); + } + LogEntry::Error(msg) => { + lines.push(Line::from(Span::styled( + format!(" error: {msg}"), + Style::default().fg(SOL_RED), + ))); + } + } + } + + if app.is_thinking { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " thinking...", + Style::default().fg(SOL_DIM).add_modifier(Modifier::ITALIC), + ))); + } + + let total_lines = lines.len() as u16; + let visible = area.height; + let max_scroll = total_lines.saturating_sub(visible); + let scroll = if app.scroll_offset == u16::MAX { + max_scroll + } else { + app.scroll_offset.min(max_scroll) + }; + + let log_widget = Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)); + + frame.render_widget(log_widget, area); +} + +fn draw_input(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let input_block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(SOL_FAINT)); + + let input_text = Line::from(vec![ + Span::styled("> ", Style::default().fg(SOL_DIM)), + Span::raw(&app.input), + ]); + + let input_widget = Paragraph::new(input_text) + .block(input_block) + .wrap(Wrap { trim: false }); + + frame.render_widget(input_widget, area); + + // Position cursor + let cursor_x = area.x + 2 + app.cursor_pos as u16; + let cursor_y = area.y + 1; + frame.set_cursor_position((cursor_x, cursor_y)); +} + +fn draw_approval(frame: &mut ratatui::Frame, area: Rect, approval: &ApprovalPrompt) { + let block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(SOL_FAINT)); + + let mut lines = vec![ + Line::from(vec![ + Span::styled(" ⚠ ", Style::default().fg(SOL_YELLOW)), + Span::styled(&approval.tool_name, Style::default().fg(SOL_YELLOW).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}", approval.command), Style::default().fg(SOL_APPROVAL_CMD)), + ]), + ]; + + for (i, opt) in approval.options.iter().enumerate() { + let prefix = if i == approval.selected { " › " } else { " " }; + let style = if i == approval.selected { + Style::default().fg(SOL_YELLOW) + } else { + Style::default().fg(SOL_DIM) + }; + lines.push(Line::from(Span::styled(format!("{prefix}{opt}"), style))); + } + + let widget = Paragraph::new(Text::from(lines)) + .block(block) + .style(Style::default().bg(SOL_APPROVAL_BG)); + + frame.render_widget(widget, area); +} + +fn draw_status_bar(frame: &mut ratatui::Frame, area: Rect, app: &App) { + let status = Line::from(vec![ + Span::styled( + format!(" ~/…/{}", app.project_name), + Style::default().fg(SOL_STATUS), + ), + Span::styled( + format!(" {} ±", app.branch), + Style::default().fg(SOL_STATUS), + ), + Span::styled( + format!(" {}k in · {}k out", app.input_tokens / 1000, app.output_tokens / 1000), + Style::default().fg(SOL_STATUS), + ), + ]); + + frame.render_widget(Paragraph::new(status), area); +} + +// ── Terminal setup/teardown ──────────────────────────────────────────────── + +pub fn setup_terminal() -> io::Result>> { + terminal::enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + Terminal::new(backend) +} + +pub fn restore_terminal(terminal: &mut Terminal>) -> io::Result<()> { + terminal::disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_app_creation() { + let app = App::new("sol", "mainline", "devstral-2"); + assert_eq!(app.project_name, "sol"); + assert!(!app.should_quit); + assert!(app.log.is_empty()); + } + + #[test] + fn test_push_log_auto_scrolls() { + let mut app = App::new("sol", "main", "devstral-2"); + app.scroll_offset = 0; + app.push_log(LogEntry::Status("test".into())); + assert_eq!(app.scroll_offset, u16::MAX); // auto-scroll to bottom + } + + #[test] + fn test_color_constants() { + assert!(matches!(SOL_YELLOW, Color::Rgb(245, 197, 66))); + assert!(matches!(SOL_AMBER, Color::Rgb(232, 168, 64))); + assert!(matches!(SOL_BLUE, Color::Rgb(108, 166, 224))); + assert!(matches!(SOL_RED, Color::Rgb(224, 88, 88))); + // No green in the palette + assert!(!matches!(SOL_YELLOW, Color::Rgb(_, 255, _))); + assert!(!matches!(SOL_BLUE, Color::Rgb(_, 255, _))); + } + + #[test] + fn test_log_entries_all_variants() { + let mut app = App::new("test", "main", "devstral-2"); + app.push_log(LogEntry::UserInput("hello".into())); + app.push_log(LogEntry::AssistantText("response".into())); + app.push_log(LogEntry::ToolSuccess { name: "file_read".into(), detail: "src/main.rs".into() }); + app.push_log(LogEntry::ToolExecuting { name: "bash".into(), detail: "cargo test".into() }); + app.push_log(LogEntry::ToolFailed { name: "grep".into(), detail: "no matches".into() }); + app.push_log(LogEntry::ToolOutput { lines: vec!["line 1".into(), "line 2".into()], collapsed: true }); + app.push_log(LogEntry::Status("thinking".into())); + app.push_log(LogEntry::Error("connection lost".into())); + assert_eq!(app.log.len(), 8); + } + + #[test] + fn test_tool_output_collapse_threshold() { + // Collapsed output shows max 5 lines + "... +N lines" + let lines: Vec = (0..20).map(|i| format!("line {i}")).collect(); + let entry = LogEntry::ToolOutput { lines: lines.clone(), collapsed: true }; + if let LogEntry::ToolOutput { lines, collapsed } = &entry { + assert!(lines.len() > 5); + assert!(*collapsed); + } + } + + #[test] + fn test_approval_prompt() { + let approval = ApprovalPrompt { + tool_name: "bash".into(), + command: "cargo test".into(), + options: vec![ + "Yes".into(), + "Yes, always allow bash".into(), + "No".into(), + ], + selected: 0, + }; + assert_eq!(approval.options.len(), 3); + assert_eq!(approval.selected, 0); + } + + #[test] + fn test_approval_navigation() { + let mut approval = ApprovalPrompt { + tool_name: "bash".into(), + command: "rm -rf".into(), + options: vec!["Yes".into(), "No".into()], + selected: 0, + }; + // Navigate down + approval.selected = (approval.selected + 1).min(approval.options.len() - 1); + assert_eq!(approval.selected, 1); + // Navigate down again (clamped) + approval.selected = (approval.selected + 1).min(approval.options.len() - 1); + assert_eq!(approval.selected, 1); + // Navigate up + approval.selected = approval.selected.saturating_sub(1); + assert_eq!(approval.selected, 0); + } + + #[test] + fn test_thinking_state() { + let mut app = App::new("sol", "main", "devstral-2"); + assert!(!app.is_thinking); + app.is_thinking = true; + assert!(app.is_thinking); + } + + #[test] + fn test_input_cursor() { + let mut app = App::new("sol", "main", "devstral-2"); + app.input = "hello world".into(); + app.cursor_pos = 5; + assert_eq!(&app.input[..app.cursor_pos], "hello"); + } + + #[test] + fn test_token_tracking() { + let mut app = App::new("sol", "main", "devstral-2"); + app.input_tokens = 1200; + app.output_tokens = 340; + assert_eq!(app.input_tokens / 1000, 1); + assert_eq!(app.output_tokens / 1000, 0); + } +}