Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 318 additions & 2 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["logicshell-core", "logicshell-llm"]
members = ["logicshell-core", "logicshell-llm", "logicshell-tui"]
resolver = "2"

[workspace.package]
Expand Down
20 changes: 10 additions & 10 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
20 changes: 20 additions & 0 deletions logicshell-tui/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
36 changes: 36 additions & 0 deletions logicshell-tui/examples/phase11.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
222 changes: 222 additions & 0 deletions logicshell-tui/src/app.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

impl App {
pub fn new(cwd: impl Into<String>, safety_mode: impl Into<String>) -> 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, "");
}
}
11 changes: 11 additions & 0 deletions logicshell-tui/src/error.rs
Original file line number Diff line number Diff line change
@@ -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<T> = std::result::Result<T, TuiError>;
Loading
Loading