diff --git a/Cargo.lock b/Cargo.lock index 658bbac..dad362c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstyle" version = "1.0.14" @@ -63,6 +69,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 = "cc" version = "1.2.60" @@ -88,6 +109,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "core-foundation" version = "0.9.4" @@ -114,6 +149,66 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -131,6 +226,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -313,6 +414,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -539,6 +642,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -572,6 +681,28 @@ 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 = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -588,6 +719,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -618,6 +758,12 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[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" @@ -676,6 +822,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "logicshell-tui" +version = "0.1.0" +dependencies = [ + "crossterm", + "logicshell-core", + "ratatui", + "serde", + "tempfile", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.0" @@ -695,6 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -840,6 +1010,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 = "percent-encoding" version = "2.3.2" @@ -971,6 +1147,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", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1063,6 +1260,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1072,7 +1282,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1235,6 +1445,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" @@ -1279,6 +1510,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[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" @@ -1346,7 +1611,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1576,6 +1841,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +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", + "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" @@ -1744,6 +2038,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index ae3d3a1..1f1d364 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["logicshell-core", "logicshell-llm"] +members = ["logicshell-core", "logicshell-llm", "logicshell-tui"] resolver = "2" [workspace.package] diff --git a/PLAN.md b/PLAN.md index 386008a..a1ae47c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -33,20 +33,20 @@ ## M4 — Ratatui TUI (Phases 11–14) -### Phase 11 — TUI foundation +### Phase 11 — TUI foundation ✅ **Goal:** Introduce an interactive terminal UI shell powered by [Ratatui](https://ratatui.rs/) that wraps the `LogicShell` façade. The TUI is a thin presentation layer — all business logic stays in `logicshell-core` and `logicshell-llm`. **Deliverables:** -- New crate `logicshell-tui` in the workspace. -- `App` struct: terminal state machine with `Running` / `Quitting` lifecycle. -- Raw-mode terminal setup / teardown via `crossterm`. -- Minimal event loop: keyboard input (`Ctrl-C` / `q` to quit, `Enter` to submit). -- Configurable prompt widget showing current working directory. -- Static "welcome" layout with status bar (phase, version, safety mode). -- Unit-testable event dispatch without a real terminal (mock backend). - -**Tests:** App state machine, event routing, layout rendering to buffer. +- New crate `logicshell-tui` in the workspace. ✅ +- `App` struct: terminal state machine with `Running` / `Quitting` lifecycle. ✅ +- Raw-mode terminal setup / teardown via `crossterm`. ✅ +- Minimal event loop: keyboard input (`Ctrl-C` / `q` to quit, `Enter` to submit). ✅ +- Configurable prompt widget showing current working directory. ✅ +- Static "welcome" layout with status bar (phase, version, safety mode). ✅ +- Unit-testable event dispatch without a real terminal (mock backend). ✅ + +**Tests:** App state machine, event routing, layout rendering to buffer. ✅ (47 tests, 94.18% coverage) --- diff --git a/logicshell-tui/Cargo.toml b/logicshell-tui/Cargo.toml new file mode 100644 index 0000000..c94c706 --- /dev/null +++ b/logicshell-tui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "logicshell-tui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "LogicShell TUI: Ratatui-powered interactive terminal shell" + +[dependencies] +logicshell-core = { path = "../logicshell-core" } +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } +tokio = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +tempfile = { workspace = true } diff --git a/logicshell-tui/examples/phase11.rs b/logicshell-tui/examples/phase11.rs new file mode 100644 index 0000000..4f97c4d --- /dev/null +++ b/logicshell-tui/examples/phase11.rs @@ -0,0 +1,36 @@ +/// Phase 11 demo — run the TUI shell interactively. +/// +/// Usage: cargo run -p logicshell-tui --example phase11 +/// +/// Controls: +/// Type characters — append to the input buffer +/// Backspace — delete last character +/// Enter — submit the current line +/// q (empty input) — quit +/// Ctrl-C — quit immediately +use logicshell_tui::{terminal, App, Event, EventHandler}; +use std::time::Duration; + +#[tokio::main] +async fn main() -> logicshell_tui::Result<()> { + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "?".to_string()); + + let mut app = App::new(cwd, "balanced"); + let mut term = terminal::init()?; + let mut events = EventHandler::new(Duration::from_millis(250)); + + while app.is_running() { + term.draw(|f| logicshell_tui::ui::draw(f, &app))?; + + if let Some(event) = events.next().await { + if let Event::Key(key) = event { + app.handle_key(key); + } + } + } + + terminal::restore(&mut term)?; + Ok(()) +} diff --git a/logicshell-tui/src/app.rs b/logicshell-tui/src/app.rs new file mode 100644 index 0000000..625c77c --- /dev/null +++ b/logicshell-tui/src/app.rs @@ -0,0 +1,222 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Lifecycle state of the TUI application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppState { + Running, + Quitting, +} + +/// Top-level TUI application state — owns the input buffer and rendered message history. +/// +/// Designed to be fully testable without a real terminal: all transitions happen +/// through `handle_key` which takes plain `KeyEvent` values. +pub struct App { + pub state: AppState, + /// Current line the user is typing. + pub input: String, + /// Working directory shown in the prompt. + pub cwd: String, + /// Safety mode label displayed in the status bar. + pub safety_mode: String, + /// Submitted commands and output lines shown in the main panel. + pub messages: Vec, +} + +impl App { + pub fn new(cwd: impl Into, safety_mode: impl Into) -> Self { + Self { + state: AppState::Running, + input: String::new(), + cwd: cwd.into(), + safety_mode: safety_mode.into(), + messages: Vec::new(), + } + } + + /// Returns `true` while the event loop should keep running. + pub fn is_running(&self) -> bool { + self.state == AppState::Running + } + + /// Process a single key event, updating state in place. + pub fn handle_key(&mut self, key: KeyEvent) { + match key.code { + // Ctrl-C always quits, regardless of modifier state. + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.state = AppState::Quitting; + } + // 'q' quits when input is empty (not mid-typing). + KeyCode::Char('q') if self.input.is_empty() => { + self.state = AppState::Quitting; + } + // Enter submits the current input line. + KeyCode::Enter => { + let line = self.input.trim().to_string(); + if !line.is_empty() { + self.messages.push(format!("{} > {}", self.cwd, line)); + self.input.clear(); + } + } + // Backspace removes the last character. + KeyCode::Backspace => { + self.input.pop(); + } + // Printable characters append to the input buffer. + KeyCode::Char(c) => { + self.input.push(c); + } + _ => {} + } + } +} + +impl Default for App { + fn default() -> Self { + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "?".to_string()); + Self::new(cwd, "balanced") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + // ── lifecycle ────────────────────────────────────────────────────────────── + + #[test] + fn new_app_is_running() { + let app = App::new("/home/user", "balanced"); + assert_eq!(app.state, AppState::Running); + assert!(app.is_running()); + } + + #[test] + fn ctrl_c_quits() { + let mut app = App::new("/", "balanced"); + app.handle_key(ctrl('c')); + assert_eq!(app.state, AppState::Quitting); + assert!(!app.is_running()); + } + + #[test] + fn q_quits_when_input_empty() { + let mut app = App::new("/", "balanced"); + app.handle_key(key(KeyCode::Char('q'))); + assert_eq!(app.state, AppState::Quitting); + } + + #[test] + fn q_does_not_quit_when_input_non_empty() { + let mut app = App::new("/", "balanced"); + app.handle_key(key(KeyCode::Char('l'))); + app.handle_key(key(KeyCode::Char('s'))); + // 'q' should append, not quit, because input is non-empty + app.handle_key(key(KeyCode::Char('q'))); + assert_eq!(app.state, AppState::Running); + assert_eq!(app.input, "lsq"); + } + + // ── input buffer ─────────────────────────────────────────────────────────── + + #[test] + fn char_keys_append_to_input() { + let mut app = App::new("/", "balanced"); + for c in "ls -la".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(app.input, "ls -la"); + } + + #[test] + fn backspace_removes_last_char() { + let mut app = App::new("/", "balanced"); + app.handle_key(key(KeyCode::Char('l'))); + app.handle_key(key(KeyCode::Char('s'))); + app.handle_key(key(KeyCode::Backspace)); + assert_eq!(app.input, "l"); + } + + #[test] + fn backspace_on_empty_input_is_noop() { + let mut app = App::new("/", "balanced"); + app.handle_key(key(KeyCode::Backspace)); + assert_eq!(app.input, ""); + assert_eq!(app.state, AppState::Running); + } + + // ── enter / submit ───────────────────────────────────────────────────────── + + #[test] + fn enter_submits_input_and_clears_buffer() { + let mut app = App::new("/home", "balanced"); + app.handle_key(key(KeyCode::Char('l'))); + app.handle_key(key(KeyCode::Char('s'))); + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.input, ""); + assert_eq!(app.messages.len(), 1); + assert!(app.messages[0].contains("ls")); + } + + #[test] + fn enter_on_empty_input_adds_no_message() { + let mut app = App::new("/", "balanced"); + app.handle_key(key(KeyCode::Enter)); + assert!(app.messages.is_empty()); + } + + #[test] + fn enter_whitespace_only_adds_no_message() { + let mut app = App::new("/", "balanced"); + app.handle_key(key(KeyCode::Char(' '))); + app.handle_key(key(KeyCode::Enter)); + assert!(app.messages.is_empty()); + } + + #[test] + fn multiple_submits_accumulate_messages() { + let mut app = App::new("/", "balanced"); + for cmd in &["ls", "pwd", "echo hello"] { + for c in cmd.chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Enter)); + } + assert_eq!(app.messages.len(), 3); + } + + // ── miscellaneous ────────────────────────────────────────────────────────── + + #[test] + fn app_stores_cwd_and_safety_mode() { + let app = App::new("/var/log", "strict"); + assert_eq!(app.cwd, "/var/log"); + assert_eq!(app.safety_mode, "strict"); + } + + #[test] + fn default_app_is_running_with_balanced_mode() { + let app = App::default(); + assert!(app.is_running()); + assert_eq!(app.safety_mode, "balanced"); + } + + #[test] + fn unknown_keys_are_ignored() { + let mut app = App::new("/", "balanced"); + app.handle_key(key(KeyCode::F(1))); + app.handle_key(key(KeyCode::Null)); + assert_eq!(app.state, AppState::Running); + assert_eq!(app.input, ""); + } +} diff --git a/logicshell-tui/src/error.rs b/logicshell-tui/src/error.rs new file mode 100644 index 0000000..aedd1d1 --- /dev/null +++ b/logicshell-tui/src/error.rs @@ -0,0 +1,11 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TuiError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("core error: {0}")] + Core(#[from] logicshell_core::LogicShellError), +} + +pub type Result = std::result::Result; diff --git a/logicshell-tui/src/event.rs b/logicshell-tui/src/event.rs new file mode 100644 index 0000000..c17ec98 --- /dev/null +++ b/logicshell-tui/src/event.rs @@ -0,0 +1,114 @@ +use crossterm::event::{self, Event as CrosstermEvent, KeyEvent}; +use std::time::Duration; +use tokio::sync::mpsc; + +/// Internal event enum for the TUI event loop. +#[derive(Debug, Clone)] +pub enum Event { + /// A keyboard event forwarded from crossterm. + Key(KeyEvent), + /// Periodic tick for animations or polling. + Tick, + /// Terminal resize notification. + Resize(u16, u16), +} + +/// Spawns a background task that forwards crossterm events and periodic ticks +/// to the returned receiver channel. +/// +/// Callers drain `rx` in their event loop and pass `Event::Key` to `App::handle_key`. +/// The returned `EventHandler` must be kept alive for as long as the receiver is in use. +pub struct EventHandler { + rx: mpsc::UnboundedReceiver, + /// Keep the sender alive so the background task is not dropped immediately. + _tx: mpsc::UnboundedSender, +} + +impl EventHandler { + /// Start the event forwarding task with the given tick rate. + /// + /// `tick_rate` controls how often `Event::Tick` is emitted when no real + /// terminal events arrive. + pub fn new(tick_rate: Duration) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let tx_clone = tx.clone(); + + tokio::spawn(async move { + loop { + if event::poll(tick_rate).unwrap_or(false) { + match event::read() { + Ok(CrosstermEvent::Key(key)) => { + let _ = tx_clone.send(Event::Key(key)); + } + Ok(CrosstermEvent::Resize(w, h)) => { + let _ = tx_clone.send(Event::Resize(w, h)); + } + _ => {} + } + } else { + let _ = tx_clone.send(Event::Tick); + } + } + }); + + Self { rx, _tx: tx } + } + + /// Receive the next event, waiting until one is available. + pub async fn next(&mut self) -> Option { + self.rx.recv().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyModifiers}; + + fn make_key_event(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn event_key_variant_stores_key_event() { + let ke = make_key_event(KeyCode::Enter); + let ev = Event::Key(ke); + assert!(matches!(ev, Event::Key(_))); + } + + #[test] + fn event_tick_variant() { + let ev = Event::Tick; + assert!(matches!(ev, Event::Tick)); + } + + #[test] + fn event_resize_variant_stores_dimensions() { + let ev = Event::Resize(80, 24); + match ev { + Event::Resize(w, h) => { + assert_eq!(w, 80); + assert_eq!(h, 24); + } + _ => panic!("expected Resize"), + } + } + + #[test] + fn event_clone_produces_equal_tick() { + let ev = Event::Tick; + let cloned = ev.clone(); + assert!(matches!(cloned, Event::Tick)); + } + + #[test] + fn event_key_clone_preserves_key_code() { + let ke = make_key_event(KeyCode::Char('x')); + let ev = Event::Key(ke); + let cloned = ev.clone(); + match cloned { + Event::Key(k) => assert_eq!(k.code, KeyCode::Char('x')), + _ => panic!("expected Key"), + } + } +} diff --git a/logicshell-tui/src/lib.rs b/logicshell-tui/src/lib.rs new file mode 100644 index 0000000..b4860e3 --- /dev/null +++ b/logicshell-tui/src/lib.rs @@ -0,0 +1,11 @@ +// logicshell-tui: Ratatui-powered interactive shell TUI — Phase 11 foundation + +pub mod app; +pub mod error; +pub mod event; +pub mod terminal; +pub mod ui; + +pub use app::{App, AppState}; +pub use error::{Result, TuiError}; +pub use event::{Event, EventHandler}; diff --git a/logicshell-tui/src/terminal.rs b/logicshell-tui/src/terminal.rs new file mode 100644 index 0000000..8178e4e --- /dev/null +++ b/logicshell-tui/src/terminal.rs @@ -0,0 +1,29 @@ +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io::{stdout, Stdout}; + +use crate::error::TuiError; + +pub type CrosstermTerminal = Terminal>; + +/// Enter alternate screen and raw mode, returning a configured terminal. +/// +/// Must be paired with [`restore`] on the same terminal to leave the alternate +/// screen and restore line-buffered mode. +pub fn init() -> Result { + enable_raw_mode().map_err(TuiError::Io)?; + let mut out = stdout(); + execute!(out, EnterAlternateScreen).map_err(TuiError::Io)?; + let backend = CrosstermBackend::new(out); + Terminal::new(backend).map_err(TuiError::Io) +} + +/// Leave alternate screen, disable raw mode, and show the cursor. +pub fn restore(terminal: &mut CrosstermTerminal) -> Result<(), TuiError> { + disable_raw_mode().map_err(TuiError::Io)?; + execute!(terminal.backend_mut(), LeaveAlternateScreen).map_err(TuiError::Io)?; + terminal.show_cursor().map_err(TuiError::Io) +} diff --git a/logicshell-tui/src/ui.rs b/logicshell-tui/src/ui.rs new file mode 100644 index 0000000..94d4086 --- /dev/null +++ b/logicshell-tui/src/ui.rs @@ -0,0 +1,206 @@ +use crate::app::App; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const PHASE: &str = "11"; + +/// Render the full TUI layout into the given [`Frame`]. +/// +/// Layout (top to bottom): +/// 1. Title banner — single line +/// 2. Welcome / message area — fills remaining space +/// 3. Prompt input line — single line +/// 4. Status bar — single line +pub fn draw(frame: &mut Frame, app: &App) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title + Constraint::Min(1), // messages + Constraint::Length(1), // prompt input + Constraint::Length(1), // status bar + ]) + .split(area); + + draw_title(frame, chunks[0]); + draw_messages(frame, app, chunks[1]); + draw_prompt(frame, app, chunks[2]); + draw_status_bar(frame, app, chunks[3]); +} + +fn draw_title(frame: &mut Frame, area: Rect) { + let title = Paragraph::new(format!( + " LogicShell v{VERSION} — Phase {PHASE} TUI Foundation" + )) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Left); + frame.render_widget(title, area); +} + +fn draw_messages(frame: &mut Frame, app: &App, area: Rect) { + let lines: Vec = if app.messages.is_empty() { + vec![ + Line::from(""), + Line::from(Span::styled( + " Welcome to LogicShell!", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(" Type a command and press Enter to submit."), + Line::from(" Press 'q' or Ctrl-C to quit."), + ] + } else { + app.messages + .iter() + .map(|m| Line::from(m.as_str())) + .collect() + }; + + let block = Block::default().borders(Borders::NONE); + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +fn draw_prompt(frame: &mut Frame, app: &App, area: Rect) { + let prompt_text = format!(" {} > {}_", app.cwd, app.input); + let prompt = Paragraph::new(prompt_text).style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(prompt, area); +} + +fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { + let status = Paragraph::new(format!( + " Phase {PHASE} | v{VERSION} | Safety: {}", + app.safety_mode + )) + .style(Style::default().bg(Color::DarkGray).fg(Color::White)); + frame.render_widget(status, area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::{backend::TestBackend, Terminal}; + + fn render_to_buffer(app: &App, width: u16, height: u16) -> ratatui::buffer::Buffer { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, app)).unwrap(); + terminal.backend().buffer().clone() + } + + fn buf_row(buf: &ratatui::buffer::Buffer, y: u16, width: u16) -> String { + (0..width) + .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect() + } + + fn buf_rows(buf: &ratatui::buffer::Buffer, y_start: u16, y_end: u16, width: u16) -> String { + (y_start..y_end) + .flat_map(|y| (0..width).map(move |x| (x, y))) + .map(|(x, y)| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect() + } + + #[test] + fn title_row_contains_logicshell() { + let app = App::new("/home/user", "balanced"); + let buf = render_to_buffer(&app, 80, 10); + let row = buf_row(&buf, 0, 80); + assert!(row.contains("LogicShell"), "title row: {row:?}"); + } + + #[test] + fn status_bar_contains_phase_and_version() { + let app = App::new("/home/user", "balanced"); + let buf = render_to_buffer(&app, 80, 10); + // Status bar is the last row (index 9) + let row = buf_row(&buf, 9, 80); + assert!(row.contains("Phase"), "status bar row: {row:?}"); + assert!(row.contains("Safety"), "status bar row: {row:?}"); + } + + #[test] + fn status_bar_shows_safety_mode() { + let app = App::new("/", "strict"); + let buf = render_to_buffer(&app, 80, 10); + let row = buf_row(&buf, 9, 80); + assert!( + row.contains("strict"), + "status bar should show safety mode: {row:?}" + ); + } + + #[test] + fn welcome_message_shown_when_no_messages() { + let app = App::new("/", "balanced"); + let buf = render_to_buffer(&app, 80, 10); + let interior = buf_rows(&buf, 1, 9, 80); + assert!(interior.contains("Welcome"), "interior: {interior:?}"); + } + + #[test] + fn prompt_shows_cwd_and_input() { + let mut app = App::new("/home/aero", "balanced"); + app.input = "ls -la".to_string(); + let buf = render_to_buffer(&app, 80, 10); + // Prompt is row index 8 (height=10: title=0, msgs=1..7, prompt=8, status=9) + let row = buf_row(&buf, 8, 80); + assert!(row.contains("/home/aero"), "prompt row: {row:?}"); + assert!(row.contains("ls -la"), "prompt row: {row:?}"); + } + + #[test] + fn submitted_messages_replace_welcome_text() { + let mut app = App::new("/", "balanced"); + app.messages.push("/ > ls".to_string()); + let buf = render_to_buffer(&app, 80, 10); + let interior = buf_rows(&buf, 1, 9, 80); + assert!(interior.contains("ls"), "messages area: {interior:?}"); + } + + #[test] + fn title_contains_phase_number() { + let app = App::new("/", "balanced"); + let buf = render_to_buffer(&app, 80, 10); + let row = buf_row(&buf, 0, 80); + assert!(row.contains("11"), "title should contain phase 11: {row:?}"); + } + + #[test] + fn render_does_not_panic_on_small_terminal() { + let app = App::new("/", "balanced"); + // Minimum viable terminal: 20x5 + let backend = TestBackend::new(20, 5); + let mut terminal = Terminal::new(backend).unwrap(); + // Should not panic + terminal.draw(|frame| draw(frame, &app)).unwrap(); + } + + #[test] + fn render_does_not_panic_on_large_terminal() { + let app = App::new("/very/long/path/that/might/overflow", "loose"); + let backend = TestBackend::new(200, 50); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, &app)).unwrap(); + } +} diff --git a/logicshell-tui/tests/phase11_integration.rs b/logicshell-tui/tests/phase11_integration.rs new file mode 100644 index 0000000..67c2cd6 --- /dev/null +++ b/logicshell-tui/tests/phase11_integration.rs @@ -0,0 +1,240 @@ +// Phase 11 integration tests — App + UI pipeline without a real terminal + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use logicshell_tui::{ui, App, AppState}; +use ratatui::{backend::TestBackend, Terminal}; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) +} + +fn render(app: &App, w: u16, h: u16) -> ratatui::buffer::Buffer { + let backend = TestBackend::new(w, h); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| ui::draw(f, app)).unwrap(); + term.backend().buffer().clone() +} + +fn row(buf: &ratatui::buffer::Buffer, y: u16, w: u16) -> String { + (0..w) + .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect() +} + +fn rows(buf: &ratatui::buffer::Buffer, y0: u16, y1: u16, w: u16) -> String { + (y0..y1) + .flat_map(|y| (0..w).map(move |x| (x, y))) + .map(|(x, y)| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect() +} + +// ── state machine ───────────────────────────────────────────────────────────── + +#[test] +fn initial_state_is_running() { + let app = App::new("/tmp", "balanced"); + assert_eq!(app.state, AppState::Running); +} + +#[test] +fn ctrl_c_transitions_to_quitting() { + let mut app = App::new("/tmp", "balanced"); + app.handle_key(ctrl('c')); + assert_eq!(app.state, AppState::Quitting); + assert!(!app.is_running()); +} + +#[test] +fn q_key_on_empty_input_transitions_to_quitting() { + let mut app = App::new("/tmp", "balanced"); + app.handle_key(key(KeyCode::Char('q'))); + assert_eq!(app.state, AppState::Quitting); +} + +#[test] +fn quit_is_idempotent() { + let mut app = App::new("/tmp", "balanced"); + app.handle_key(ctrl('c')); + app.handle_key(ctrl('c')); + assert_eq!(app.state, AppState::Quitting); +} + +// ── event routing ───────────────────────────────────────────────────────────── + +#[test] +fn typing_sequence_builds_correct_input() { + let mut app = App::new("/", "balanced"); + for c in "git status".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(app.input, "git status"); +} + +#[test] +fn backspace_corrects_input() { + let mut app = App::new("/", "balanced"); + // Type "gii" then backspace to get "gi", then type "t" to get "git" + for c in "gii".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Backspace)); + app.handle_key(key(KeyCode::Char('t'))); + assert_eq!(app.input, "git"); +} + +#[test] +fn enter_clears_input_after_submit() { + let mut app = App::new("/", "balanced"); + for c in "ls".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.input, ""); +} + +#[test] +fn enter_appends_to_messages() { + let mut app = App::new("/home", "balanced"); + for c in "echo hello".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.messages.len(), 1); + assert!(app.messages[0].contains("echo hello")); +} + +#[test] +fn multiple_commands_accumulate_in_order() { + let mut app = App::new("/", "balanced"); + let cmds = ["ls", "pwd", "whoami"]; + for cmd in &cmds { + for c in cmd.chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Enter)); + } + assert_eq!(app.messages.len(), 3); + assert!(app.messages[0].contains("ls")); + assert!(app.messages[1].contains("pwd")); + assert!(app.messages[2].contains("whoami")); +} + +#[test] +fn q_mid_word_does_not_quit() { + let mut app = App::new("/", "balanced"); + // type "git" — 'q' is not present but test with 'q' inside input + app.handle_key(key(KeyCode::Char('s'))); + app.handle_key(key(KeyCode::Char('q'))); + assert_eq!(app.state, AppState::Running); + assert_eq!(app.input, "sq"); +} + +// ── layout rendering to buffer ──────────────────────────────────────────────── + +#[test] +fn rendered_title_contains_logicshell_and_phase() { + let app = App::new("/home/user", "balanced"); + let buf = render(&app, 80, 10); + let title = row(&buf, 0, 80); + assert!(title.contains("LogicShell"), "title: {title:?}"); + assert!( + title.contains("11"), + "title should mention phase 11: {title:?}" + ); +} + +#[test] +fn rendered_status_bar_shows_all_fields() { + let app = App::new("/", "strict"); + let buf = render(&app, 80, 10); + let status = row(&buf, 9, 80); + assert!(status.contains("Phase"), "status: {status:?}"); + assert!(status.contains("Safety"), "status: {status:?}"); + assert!(status.contains("strict"), "status: {status:?}"); +} + +#[test] +fn welcome_shown_before_any_commands() { + let app = App::new("/", "balanced"); + let buf = render(&app, 80, 10); + let body = rows(&buf, 1, 9, 80); + assert!(body.contains("Welcome"), "body: {body:?}"); +} + +#[test] +fn prompt_renders_cwd() { + let app = App::new("/var/log", "balanced"); + let buf = render(&app, 80, 10); + let prompt = row(&buf, 8, 80); + assert!(prompt.contains("/var/log"), "prompt: {prompt:?}"); +} + +#[test] +fn prompt_renders_current_input() { + let mut app = App::new("/", "balanced"); + app.input = "cat foo.txt".to_string(); + let buf = render(&app, 80, 10); + let prompt = row(&buf, 8, 80); + assert!(prompt.contains("cat foo.txt"), "prompt: {prompt:?}"); +} + +#[test] +fn messages_area_shows_submitted_commands() { + let mut app = App::new("/", "balanced"); + app.messages.push("/ > ls -la".to_string()); + let buf = render(&app, 80, 10); + let body = rows(&buf, 1, 9, 80); + assert!(body.contains("ls -la"), "body: {body:?}"); + // Welcome should no longer appear when there are messages + assert!( + !body.contains("Welcome"), + "Welcome should be gone: {body:?}" + ); +} + +#[test] +fn render_survives_narrow_terminal() { + let app = App::new("/long/path/here", "balanced"); + // Should not panic on narrow terminal + let backend = TestBackend::new(15, 5); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| ui::draw(f, &app)).unwrap(); +} + +#[test] +fn render_survives_wide_terminal() { + let app = App::new("/", "loose"); + let backend = TestBackend::new(300, 60); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| ui::draw(f, &app)).unwrap(); +} + +// ── full simulate-and-render round trip ─────────────────────────────────────── + +#[test] +fn full_interaction_round_trip() { + let mut app = App::new("/home/aero", "balanced"); + + // Simulate typing "ls -la" and submitting + for c in "ls -la".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(app.input, "ls -la"); + + app.handle_key(key(KeyCode::Enter)); + assert_eq!(app.input, ""); + assert_eq!(app.messages.len(), 1); + + // Render after submit — messages should appear in body + let buf = render(&app, 80, 10); + let body = rows(&buf, 1, 9, 80); + assert!(body.contains("ls -la"), "body after submit: {body:?}"); + + // Now quit + app.handle_key(ctrl('c')); + assert_eq!(app.state, AppState::Quitting); +}