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
+ }
+}