@setoelkahfi / sigit / commits / c5b17d8

Add interactive chat UI with ratatui

- Implements a full-screen terminal chat interface in src/chat.rs - Updates Cargo.toml with documentation link - Updates dependencies in Cargo.lock - Updates main.rs to describe interactive and ACP modes

Seto Elkahfi committed Apr 12, 2026 at 11:30 UTC c5b17d837e7a2761d00f5a7e1cc2b365c91c3afb
4 files changed +899 -74
Cargo.lock
+223 -56
index d169f38..2c24312 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,13 +113,12 @@ dependencies = [ [[package]] name = "annotate-snippets" -version = "0.12.15" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92570a3f9c98e7e84df84b71d0965ac99b1871fcd75a3773a3bd1bad13f64cf7" +checksum = "96401ca08501972288ecbcde33902fce858bf73fbcbdf91dab8c3a9544e106bb" dependencies = [ "anstyle", - "memchr", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -725,6 +724,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[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" @@ -879,6 +884,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -912,7 +931,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "windows-sys 0.59.0", ] @@ -924,7 +943,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "windows-sys 0.61.2", ] @@ -1037,17 +1056,32 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" -version = "0.29.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.11.0", "crossterm_winapi", - "derive_more", - "document-features", - "mio", + "futures-core", + "mio 1.2.0", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -1401,15 +1435,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dtoa" version = "1.0.11" @@ -2155,7 +2180,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -2378,7 +2403,7 @@ dependencies = [ "html5ever 0.38.0", "tendril 0.5.0", "thiserror 2.0.18", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -2714,7 +2739,7 @@ dependencies = [ "console 0.15.11", "number_prefix", "portable-atomic", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "web-time", ] @@ -2727,11 +2752,33 @@ dependencies = [ "console 0.16.3", "portable-atomic", "rayon", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "unit-prefix", "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2778,6 +2825,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2942,6 +2998,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2954,12 +3016,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "llguidance" version = "1.7.2" @@ -3027,6 +3083,15 @@ dependencies = [ "vob", ] +[[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" @@ -3205,6 +3270,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -3220,7 +3297,8 @@ dependencies = [ [[package]] name = "mistralrs" version = "0.8.1" -source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb0a83340b4492ebba9760ba6845364de369c7e10c1f91af8733d574c7405fa" dependencies = [ "anyhow", "candle-core", @@ -3247,7 +3325,8 @@ dependencies = [ [[package]] name = "mistralrs-audio" version = "0.8.1" -source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac5d36f634c7c20c45845bc995be3b6387ab01048410386a5b180cfeaf89c72" dependencies = [ "anyhow", "apodize", @@ -3258,7 +3337,8 @@ dependencies = [ [[package]] name = "mistralrs-core" version = "0.8.1" -source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b8b9e5c94491d9ceeded3a30291cb6af6ae74810a5776dd55e3bbb8b3429d4" dependencies = [ "ahash", "akin", @@ -3294,7 +3374,7 @@ dependencies = [ "indexmap 2.14.0", "indicatif 0.18.4", "interprocess", - "itertools", + "itertools 0.14.0", "libc", "llguidance", "lrtable", @@ -3355,7 +3435,8 @@ dependencies = [ [[package]] name = "mistralrs-macros" version = "0.8.1" -source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa9b4794322d3f89fe61d21f33f67be494c16d5bd869604b111218f32544d32" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3366,7 +3447,8 @@ dependencies = [ [[package]] name = "mistralrs-mcp" version = "0.8.1" -source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa97d4e3189ed80ebbc730b7da5b2356df0ae004ab489066249340c33c089ab" dependencies = [ "anyhow", "async-trait", @@ -3386,7 +3468,8 @@ dependencies = [ [[package]] name = "mistralrs-paged-attn" version = "0.8.1" -source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e53ddf1537426997b46abdadbe6ca8a7ce7668004ed2b7cf00115016558bae8" dependencies = [ "anyhow", "candle-core", @@ -3402,7 +3485,8 @@ dependencies = [ [[package]] name = "mistralrs-quant" version = "0.8.1" -source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "980715493d252e9aaf0779c4c101576d67ef4dd9812bfd77a54987f6f17526f4" dependencies = [ "byteorder", "candle-core", @@ -3431,7 +3515,8 @@ dependencies = [ [[package]] name = "mistralrs-vision" version = "0.8.1" -source = "git+https://github.com/setoelkahfi/mistral.rs?branch=fix%2Fall-platform-fixes#a27af8ea01123e5d5777120619413345989f4006" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10046a3da2de5b702d3e829b5ddef7aa829ad454819dcc4876dea481a6cb5456" dependencies = [ "candle-core", "image", @@ -3745,7 +3830,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "onde" -version = "0.1.3" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5436633863ffc31311f529439353ba06a436dd661d2a5d93c8793c3500123a13" dependencies = [ "anyhow", "cc", @@ -4317,6 +4404,27 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.0", + "cassowary", + "compact_str 0.8.1", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -4333,7 +4441,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -4399,7 +4507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ "either", - "itertools", + "itertools 0.14.0", "rayon", ] @@ -4680,6 +4788,19 @@ dependencies = [ "transpose", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4689,7 +4810,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5181,10 +5302,12 @@ dependencies = [ "agent-client-protocol", "anyhow", "async-trait", + "crossterm 0.28.1", "env_logger", "futures", "log", "onde", + "ratatui", "tokio", "tokio-util", "uuid 1.23.0", @@ -5207,7 +5330,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.2.0", "signal-hook", ] @@ -5437,6 +5561,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" @@ -5455,6 +5588,19 @@ dependencies = [ "strum_macros 0.28.0", ] +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "strum_macros" version = "0.27.2" @@ -5720,7 +5866,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5751,7 +5897,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5900,13 +6046,13 @@ checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" dependencies = [ "ahash", "aho-corasick", - "compact_str", + "compact_str 0.9.0", "dary_heap", "derive_builder", "esaxx-rs", "fancy-regex 0.14.0", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -5933,12 +6079,12 @@ checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" dependencies = [ "ahash", "aho-corasick", - "compact_str", + "compact_str 0.9.0", "dary_heap", "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "itertools", + "itertools 0.14.0", "log", "macro_rules_attribute", "monostate", @@ -5966,7 +6112,7 @@ checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -6146,10 +6292,11 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tqdm" version = "0.8.0" -source = "git+https://github.com/setoelkahfi/tqdm?branch=deps%2Fbump-crossterm#41e4182829136e6dadbdd1c36af824d19219147a" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b316d5c2ac649ca856dacd487d0ebb94f3b746bada51355d93dd2c007ab62a2e" dependencies = [ "anyhow", - "crossterm", + "crossterm 0.25.0", "once_cell", ] @@ -6349,6 +6496,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -6357,9 +6515,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -7115,6 +7273,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0"
Cargo.toml
+6 -1
index ec656c1..550dc65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "sigit" version = "0.1.0" edition = "2024" description = "siGit Code — ACP-compatible AI coding agent for smbCloud platform." +documentation = "https://getsigit.5mb.app/" license = "MIT OR Apache-2.0" [[bin]] @@ -14,7 +15,7 @@ path = "src/main.rs" agent-client-protocol = "0.10.4" # Onde Inference engine (local LLM) -onde = { path = "../onde" } +onde = "0.1.1" # Async runtime async-trait = "0.1" @@ -22,6 +23,10 @@ tokio = { version = "1", features = ["rt", "macros", "io-std", "io-util", "sync" tokio-util = { version = "0.7", features = ["compat"] } futures = "0.3" +# Terminal UI +crossterm = { version = "0.28", features = ["event-stream"] } +ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } + # Utilities anyhow = "1" log = "0.4"
src/chat.rs
+615
new file mode 100644 index 0000000..2e475a7 --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,615 @@ +//! Full-screen terminal chat UI for siGit Code. +//! +//! Takes over the alternate screen and multiplexes terminal events with +//! streaming LLM tokens via `tokio::select!`. + +use std::future::pending; + +use anyhow::Result; +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use futures::StreamExt; +use onde::inference::{ChatEngine, StreamChunk}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Position}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use tokio::sync::mpsc; + +// ── Message types ──────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Role { + User, + Assistant, + System, +} + +struct ChatMessage { + role: Role, + text: String, +} + +impl ChatMessage { + fn user(text: impl Into<String>) -> Self { + Self { + role: Role::User, + text: text.into(), + } + } + + fn assistant(text: impl Into<String>) -> Self { + Self { + role: Role::Assistant, + text: text.into(), + } + } + + fn system(text: impl Into<String>) -> Self { + Self { + role: Role::System, + text: text.into(), + } + } +} + +// ── App state ──────────────────────────────────────────────────────────────── + +struct App { + messages: Vec<ChatMessage>, + input: String, + cursor: usize, + scroll_offset: u16, + stream_rx: Option<mpsc::Receiver<StreamChunk>>, + stream_buf: String, + quit: bool, + /// Toggled every other tick while streaming — drives the blinking cursor. + blink_on: bool, + blink_counter: u8, +} + +const BANNER_ART: &str = "\ +77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777 +77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777 +1111111125555555555555555555555511113222311159 5002 088 3081771691111111111111 +1111111111111111111111111111131136841 1482853332007 05 9043332891 400811111111111 +1111111111111111111111111111111201 109 304 40 00 79 100041111111111 +333333255555555555555555555552392 102 503 90 7000000005 903 0000023333333333 +333333245454545454545454545433381 7600000 302 61 780 109 20009533333333333 +3333333333333333333333333333333402 7001 08 761 202 902 90003333333333333 +2222255555555555555555555555250899901 49 304 403 08 108 300042222222222222 +2222222222222222222222222222269 106 03 901 06 505 402 000052222222222222 +2222255555555555555555555555299 708 1002 80 00 90852222222222222 +55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555 +88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888"; + +impl App { + fn new() -> Self { + let mut messages = Vec::new(); + for line in BANNER_ART.lines() { + messages.push(ChatMessage::system(line)); + } + messages.push(ChatMessage::system("")); + messages.push(ChatMessage::system(format!( + "siGit Code v{}", + env!("CARGO_PKG_VERSION"), + ))); + messages.push(ChatMessage::system( + "AI coding agent, powered by local LLM.", + )); + messages.push(ChatMessage::system("Type /help for commands.")); + + Self { + messages, + input: String::new(), + cursor: 0, + scroll_offset: 0, + stream_rx: None, + stream_buf: String::new(), + quit: false, + blink_on: true, + blink_counter: 0, + } + } + + fn is_streaming(&self) -> bool { + self.stream_rx.is_some() + } + + fn finalize_stream(&mut self) { + self.stream_rx = None; + if !self.stream_buf.is_empty() { + let text = std::mem::take(&mut self.stream_buf); + self.messages.push(ChatMessage::assistant(text)); + } + self.blink_on = false; + } + + fn push_stream_delta(&mut self, delta: &str) { + self.stream_buf.push_str(delta); + // tick the blink + self.blink_counter = self.blink_counter.wrapping_add(1); + self.blink_on = self.blink_counter % 4 < 2; + } + + /// Total lines the messages area would need (rough estimate for scrolling). + fn total_message_lines(&self, width: u16) -> u16 { + if width == 0 { + return 0; + } + let w = width.saturating_sub(2) as usize; // account for block borders + let mut lines: u16 = 0; + for msg in &self.messages { + lines += wrapped_line_count(&msg.text, msg.role, w); + } + // streaming buffer + if !self.stream_buf.is_empty() { + lines += wrapped_line_count(&self.stream_buf, Role::Assistant, w); + } + lines + } + + fn auto_scroll(&mut self, visible_height: u16, width: u16) { + let total = self.total_message_lines(width); + if total > visible_height { + self.scroll_offset = total - visible_height; + } else { + self.scroll_offset = 0; + } + } +} + +/// Estimate how many terminal rows a message takes once wrapped. +fn wrapped_line_count(text: &str, role: Role, width: usize) -> u16 { + let prefix_len = match role { + Role::User => 6, // "you > " + Role::Assistant => 7, // "siGit > " — wait, that's 8. Let's just use 7 for "siGit> " + Role::System => 0, + }; + let effective = if width > prefix_len { + width - prefix_len + } else { + 1 + }; + + let mut count: u16 = 0; + for line in text.split('\n') { + if line.is_empty() { + count += 1; + } else { + count += ((line.len() as f64) / (effective as f64)).ceil() as u16; + } + } + count.max(1) +} + +// ── Slash commands ─────────────────────────────────────────────────────────── + +enum SlashCommand { + Help, + Clear, + Status, + Exit, + Unknown(String), +} + +fn parse_slash(input: &str) -> Option<SlashCommand> { + let trimmed = input.trim(); + if !trimmed.starts_with('/') { + return None; + } + let cmd = trimmed.split_whitespace().next().unwrap_or(""); + Some(match cmd { + "/help" => SlashCommand::Help, + "/clear" => SlashCommand::Clear, + "/status" => SlashCommand::Status, + "/exit" | "/quit" | "/q" => SlashCommand::Exit, + other => SlashCommand::Unknown(other.to_string()), + }) +} + +// ── Rendering ──────────────────────────────────────────────────────────────── + +fn render(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + + // Layout: title(1) | messages(flex) | input(3) | footer(1) + let zones = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + render_title(frame, zones[0]); + render_messages(frame, app, zones[1]); + render_input(frame, app, zones[2]); + render_footer(frame, app, zones[3]); +} + +fn render_title(frame: &mut Frame, area: ratatui::layout::Rect) { + let title = Line::from(vec![ + Span::styled( + "siGit", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" Code", Style::default().fg(Color::White)), + Span::styled(" — AI Chat", Style::default().fg(Color::DarkGray)), + ]); + frame.render_widget( + Paragraph::new(title).style(Style::default().bg(Color::Black)), + area, + ); +} + +fn render_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { + let mut lines: Vec<Line<'_>> = Vec::new(); + + for msg in &app.messages { + render_chat_message(&mut lines, msg); + } + + // streaming partial response + if !app.stream_buf.is_empty() || app.is_streaming() { + let mut spans = vec![Span::styled( + "siGit > ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )]; + + // split on newlines so multi-line streaming renders correctly + let buf_lines: Vec<&str> = app.stream_buf.split('\n').collect(); + for (i, segment) in buf_lines.iter().enumerate() { + if i > 0 { + lines.push(Line::from(spans.drain(..).collect::<Vec<_>>())); + // continuation lines get no prefix + } + spans.push(Span::raw(segment.to_string())); + } + + // blinking cursor while streaming + if app.is_streaming() && app.blink_on { + spans.push(Span::styled("█", Style::default().fg(Color::Green))); + } + + lines.push(Line::from(spans)); + } + + // auto-scroll + app.auto_scroll(area.height, area.width); + + let paragraph = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((app.scroll_offset, 0)) + .style(Style::default()); + + frame.render_widget(paragraph, area); +} + +fn render_chat_message<'a>(lines: &mut Vec<Line<'a>>, msg: &ChatMessage) { + let text_lines: Vec<&str> = msg.text.split('\n').collect(); + + match msg.role { + Role::User => { + for (i, segment) in text_lines.iter().enumerate() { + let mut spans = Vec::new(); + if i == 0 { + spans.push(Span::styled( + "you > ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )); + } + spans.push(Span::styled( + segment.to_string(), + Style::default().fg(Color::White), + )); + lines.push(Line::from(spans)); + } + } + Role::Assistant => { + for (i, segment) in text_lines.iter().enumerate() { + let mut spans = Vec::new(); + if i == 0 { + spans.push(Span::styled( + "siGit > ", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )); + } + spans.push(Span::styled( + segment.to_string(), + Style::default().fg(Color::White), + )); + lines.push(Line::from(spans)); + } + } + Role::System => { + for segment in &text_lines { + lines.push(Line::from(Span::styled( + segment.to_string(), + Style::default().fg(Color::DarkGray), + ))); + } + } + } +} + +fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)) + .title(if app.is_streaming() { + " streaming… " + } else { + " message " + }) + .title_style(Style::default().fg(if app.is_streaming() { + Color::Yellow + } else { + Color::DarkGray + })); + + let input_text = Paragraph::new(app.input.as_str()) + .style(Style::default().fg(if app.is_streaming() { + Color::DarkGray + } else { + Color::White + })) + .block(block); + + frame.render_widget(input_text, area); + + // place cursor inside the input block (1 for border padding) + if !app.is_streaming() { + let x = area.x + app.cursor as u16 + 1; + let y = area.y + 1; + frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), y)); + } +} + +fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let hints: &[(&str, &str)] = if app.is_streaming() { + &[("Ctrl+C", "cancel")] + } else { + &[("Enter", "send"), ("/help", "commands"), ("Ctrl+C", "quit")] + }; + + let mut spans: Vec<Span<'_>> = Vec::new(); + for (i, (key, label)) in hints.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", Style::default())); + } + spans.push(Span::styled( + format!(" {key} "), + Style::default() + .fg(Color::Black) + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!(" {label}"), + Style::default().fg(Color::DarkGray), + )); + } + + frame.render_widget( + Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Black)), + area, + ); +} + +// ── Input handling ─────────────────────────────────────────────────────────── + +fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> { + if key.kind != KeyEventKind::Press { + return None; + } + + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.quit = true; + None + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.quit = true; + None + } + KeyCode::Enter => { + if app.input.trim().is_empty() { + return None; + } + let text = app.input.drain(..).collect::<String>(); + app.cursor = 0; + Some(text) + } + KeyCode::Backspace => { + if app.cursor > 0 { + app.cursor -= 1; + app.input.remove(app.cursor); + } + None + } + KeyCode::Delete => { + if app.cursor < app.input.len() { + app.input.remove(app.cursor); + } + None + } + KeyCode::Left => { + app.cursor = app.cursor.saturating_sub(1); + None + } + KeyCode::Right => { + if app.cursor < app.input.len() { + app.cursor += 1; + } + None + } + KeyCode::Home => { + app.cursor = 0; + None + } + KeyCode::End => { + app.cursor = app.input.len(); + None + } + KeyCode::Char(ch) => { + app.input.insert(app.cursor, ch); + app.cursor += 1; + None + } + _ => None, + } +} + +// ── Slash command execution ────────────────────────────────────────────────── + +async fn exec_slash(app: &mut App, cmd: SlashCommand, engine: &ChatEngine) { + match cmd { + SlashCommand::Help => { + app.messages.push(ChatMessage::system( + "/help — show this message\n\ + /clear — wipe conversation history\n\ + /status — show engine status\n\ + /exit — quit chat", + )); + } + SlashCommand::Clear => { + let cleared = engine.clear_history().await; + app.messages.clear(); + app.scroll_offset = 0; + app.messages.push(ChatMessage::system(format!( + "Cleared {cleared} turn(s). History is empty.", + ))); + } + SlashCommand::Status => { + let info = engine.info().await; + let model = info.model_name.as_deref().unwrap_or("(none)"); + let mem = info.approx_memory.as_deref().unwrap_or("unknown"); + app.messages.push(ChatMessage::system(format!( + "status: {:?} model: {} memory: {} history: {} turns", + info.status, model, mem, info.history_length, + ))); + } + SlashCommand::Exit => { + app.quit = true; + } + SlashCommand::Unknown(cmd) => { + app.messages + .push(ChatMessage::system(format!("unknown command: {cmd}"))); + } + } +} + +// ── Main loop ──────────────────────────────────────────────────────────────── + +/// Run the interactive chat UI. Blocks until the user quits. +/// +/// The caller must have already loaded a model into `engine`. +pub async fn run(engine: &ChatEngine) -> Result<()> { + let mut terminal = ratatui::init(); + let result = event_loop(&mut terminal, engine).await; + ratatui::restore(); + result +} + +async fn event_loop(terminal: &mut ratatui::DefaultTerminal, engine: &ChatEngine) -> Result<()> { + let mut app = App::new(); + let mut event_stream = EventStream::new(); + + loop { + // draw + terminal.draw(|frame| render(frame, &mut app))?; + + if app.quit { + break; + } + + // multiplex terminal events and streaming tokens + tokio::select! { + biased; + + // streaming chunks — only active when we have a receiver + chunk = async { + match app.stream_rx.as_mut() { + Some(rx) => rx.recv().await, + None => pending().await, + } + } => { + match chunk { + Some(chunk) => { + if !chunk.delta.is_empty() { + app.push_stream_delta(&chunk.delta); + } + if chunk.done { + app.finalize_stream(); + } + } + // sender dropped without done=true + None => { + app.finalize_stream(); + } + } + } + + // terminal events + maybe_event = event_stream.next() => { + let Some(Ok(event)) = maybe_event else { + // stream ended or error — bail + break; + }; + + if let Event::Key(key) = event { + // while streaming, only ctrl+c/d work + if app.is_streaming() { + if key.kind == KeyEventKind::Press { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) { + // drop the receiver to stop reading + app.finalize_stream(); + app.messages.push(ChatMessage::system("(cancelled)")); + } + } + continue; + } + + if let Some(text) = handle_key(&mut app, key) { + // check for slash command first + if let Some(cmd) = parse_slash(&text) { + exec_slash(&mut app, cmd, engine).await; + continue; + } + + // regular message — send to engine + app.messages.push(ChatMessage::user(&text)); + + match engine.stream_message(text).await { + Ok(rx) => { + app.stream_rx = Some(rx); + app.stream_buf.clear(); + app.blink_counter = 0; + app.blink_on = true; + } + Err(err) => { + app.messages.push(ChatMessage::system(format!( + "error: {err}" + ))); + } + } + } + } + } + } + } + + Ok(()) +}
src/main.rs
+55 -17
index 2f54d60..d91fc2f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,14 @@ -//! siGit — AI coding agent that runs a local LLM via Onde Inference and -//! speaks ACP over stdio so editors like Zed can talk to it. +//! siGit Code — AI coding agent powered by a local LLM via Onde Inference. +//! +//! Two modes of operation: +//! +//! - **Interactive** (stdin is a TTY): full-screen chat UI built on ratatui. +//! - **ACP server** (stdin is piped): JSON-RPC over stdio for editors like Zed. //! //! On macOS the model cache is shared with the siGit desktop app through an //! App Group container. See [`setup`]. //! -//! # Zed setup +//! # Zed setup (ACP mode) //! //! Add to `~/.config/zed/settings.json`: //! ```json @@ -18,8 +22,10 @@ //! } //! ``` +mod chat; mod setup; +use std::io::IsTerminal; use std::sync::Arc; use agent_client_protocol::{ @@ -230,24 +236,33 @@ fn print_banner() { eprintln!("{art}"); } -// ── Entry point ────────────────────────────────────────────────────────────── +// ── Interactive mode ───────────────────────────────────────────────────────── -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // stdout is the ACP wire, so logs go to stderr. - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) - .target(env_logger::Target::Stderr) - .init(); +/// Load the model, then hand off to the ratatui chat TUI. +async fn run_interactive() -> anyhow::Result<()> { + println!(" Loading model..."); - print_banner(); + let engine = ChatEngine::new(); + let config = GgufModelConfig::platform_default(); + engine + .load_gguf_model(config, Some(SYSTEM_PROMPT.to_string()), None) + .await + .map_err(|e| anyhow::anyhow!("model load failed: {e}"))?; - log::info!("siGit v{} starting", env!("CARGO_PKG_VERSION")); + let info = engine.info().await; + println!( + " \x1b[32m✓\x1b[0m {} ({})\n", + info.model_name.as_deref().unwrap_or("unknown"), + info.approx_memory.as_deref().unwrap_or("?"), + ); - // Point HF_HOME at the shared App Group container (macOS) so we pick up - // models the desktop app already downloaded. Must happen before anything - // touches hf-hub. - setup::setup_shared_model_cache(); + chat::run(&engine).await +} + +// ── ACP server mode ────────────────────────────────────────────────────────── +/// Speak ACP JSON-RPC over stdio. The editor spawns us as a subprocess. +async fn run_acp_server() -> anyhow::Result<()> { // Agent::prompt sends chunks here; the forwarder task writes them out. let (notification_tx, mut notification_rx) = mpsc::channel::<SessionNotification>(256); @@ -288,6 +303,29 @@ async fn main() -> anyhow::Result<()> { }) .await; - log::info!("siGit shutting down"); Ok(()) } + +// ── Entry point ────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Logs always go to stderr (stdout is either the TUI or the ACP wire). + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .target(env_logger::Target::Stderr) + .init(); + + // Shared model cache (macOS App Group) — must run before anything + // touches hf-hub or ChatEngine. + setup::setup_shared_model_cache(); + + if std::io::stdin().is_terminal() { + // Interactive mode — full-screen chat TUI. + print_banner(); + run_interactive().await + } else { + // Editor spawned us — speak ACP over stdio. + log::info!("siGit v{} starting (ACP mode)", env!("CARGO_PKG_VERSION")); + run_acp_server().await + } +}