diff --git a/CLAUDE.md b/CLAUDE.md index 24b7145..fb17bd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ Events use parent IDs forming a tree (not a flat list). This enables future bran - Unit tests go in the same file as the code (`#[cfg(test)] mod tests`) - Integration tests go in `tests/` -- TUI widget tests use `ratatui::backend::TestBackend` + `insta` snapshots +- TUI widget tests use `ratatui::backend::TestBackend` - Provider tests replay recorded SSE fixtures from `tests/fixtures/` - Sandbox tests use `tempdir` and skip Landlock-specific assertions if kernel < 5.13 - Run `cargo test` before every commit diff --git a/DESIGN.md b/DESIGN.md index 3733849..bdbe061 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -67,7 +67,7 @@ - **`sandbox`:** Landlock policy construction, path validation logic (without applying kernel rules) - **`core`:** Conversation tree operations (insert, query by parent, turn computation, token totals), orchestrator state machine transitions against mock `StreamEvent` sequences - **`session`:** JSONL serialization roundtrips, parent ID chain reconstruction -- **`tui`:** Widget rendering via Ratatui `TestBackend`, snapshot tests with `insta` crate for layout/mode indicator/token display +- **`tui`:** Widget rendering via Ratatui `TestBackend` ### Integration Tests — Component Boundaries - **Core ↔ Provider:** Mock `ModelProvider` replaying recorded API sessions (full SSE streams with tool use). Tests the complete orchestration loop deterministically without network. @@ -78,11 +78,6 @@ - **Recorded session replay:** Capture real Claude API HTTP request/response pairs, replay deterministically. Exercises full stack (core + channel + mock TUI) without cost or network dependency. Primary E2E test strategy. - **Live API tests:** Small suite behind feature flag / env var. Verifies real API integration. Run manually before releases, not in CI. -### Snapshot Testing -- `insta` crate for TUI visual regression testing from Phase 2 onward -- Capture rendered `TestBackend` buffers as string snapshots -- Catches layout, mode indicator, and token display regressions - ### Benchmarking — SWE-bench - **Target:** SWE-bench Verified (500 curated problems) as primary benchmark - **Secondary:** SWE-bench Pro for testing planning mode on longer-horizon tasks @@ -93,7 +88,6 @@ ### Test Sequencing - Phase 1: Unit tests for SSE parser, event types, message serialization -- Phase 2: Snapshot tests for TUI with `insta` - Phase 4: Recorded session replay infrastructure (core loop complex enough to warrant it) - Phase 6-7: Headless mode + first SWE-bench Verified run diff --git a/IDEAS.md b/IDEAS.md index 0c3c97f..9d3dbb7 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -42,6 +42,9 @@ Notes based on ideas I've had. - JSONL `SessionWriter` with `Event` structure - Parent IDs, timestamps, token usage per event - Predictable file location with session IDs +- Ability to resume prior session. - **Done when:** Session files are coherent, parseable, with token counts per turn - +## Which-key like help +- Show command chording in normal mode. +- Help window slides up from bottom of screen. diff --git a/PLAN.md b/PLAN.md index 0e3a1f2..03059df 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,77 +1,9 @@ # Implementation Plan -## Phase 1: Minimal Conversation Loop - -**Done when:** Multi-turn streaming conversation with Claude works in terminal - -### 1.1 Project Scaffolding -- `Cargo.toml` with initial dependencies: - - `ratatui`, `crossterm` — TUI - - `tokio` (full features) — async runtime - - `serde`, `serde_json` — serialization - - `thiserror` — error types - - `tracing`, `tracing-subscriber` — structured logging - - `reqwest` (with `stream` feature) — HTTP client for SSE - - `futures` — stream combinators -- Establish `src/{app,tui,core,provider}/mod.rs` stubs -- `cargo build` passes; `cargo clippy -- -D warnings` passes on empty stubs - -### 1.2 Shared Types (`src/core/types.rs`) -- `StreamEvent` enum: `TextDelta(String)`, `InputTokens(u32)`, `OutputTokens(u32)`, `Done`, `Error(String)` -- `UserAction` enum (TUI → core channel): `SendMessage(String)`, `Quit` -- `UIEvent` enum (core → TUI channel): `StreamDelta(String)`, `TurnComplete`, `Error(String)` -- `ConversationMessage` struct: `role: Role`, `content: String` -- All types derive `Debug`; all public types have doc comments - -### 1.3 Provider: `ModelProvider` Trait + Claude SSE (`src/provider/`) -- `ModelProvider` trait: `async fn stream(&self, messages: &[ConversationMessage]) -> impl Stream` -- `ClaudeProvider` struct: API key from env, `reqwest` HTTP client -- Serialize messages to Anthropic Messages API JSON format -- Parse SSE byte stream → `StreamEvent` (handle `content_block_delta`, `message_delta` for tokens, `message_stop`) -- Unit tests: SSE parsing from hardcoded byte fixtures in `#[cfg(test)]` - -### 1.4 Core: Conversation State + Orchestrator Loop (`src/core/`) -- `ConversationHistory`: `Vec` with `push` and `messages()` (flat list, no tree yet) -- `Orchestrator` struct holding history, provider, channel senders/receivers -- Orchestrator loop: - 1. Await `UserAction` from TUI channel - 2. On `SendMessage`: append user message, call `provider.stream()` - 3. Forward each `StreamEvent` as `UIEvent` to TUI - 4. Accumulate deltas into assistant message; append to history on `Done` - 5. On `Quit`: break loop - -### 1.5 TUI: Layout + Input + Streaming Display (`src/tui/`) -- `AppState` struct: `messages: Vec<(Role, String)>`, `input: String`, `scroll: u16` -- Ratatui layout: full-height `Paragraph` output area (scrollable) + single-line `Paragraph` input -- Insert mode only — printable chars append to `input`, Enter sends `UserAction::SendMessage`, Backspace deletes -- On `UIEvent::StreamDelta`: append to last assistant message in `messages`, re-render -- On `UIEvent::TurnComplete`: finalize assistant message -- Crossterm raw mode enter/exit; restore terminal on panic or clean exit - -### 1.6 App Wiring + Entry Point (`src/app/`, `src/main.rs`) -- `main.rs`: parse `--project-dir ` CLI arg -- Initialize `tracing_subscriber` (log to file, not stdout — avoids TUI interference) -- Create `tokio::sync::mpsc` channel pair for `UserAction` and `UIEvent` -- Spawn `Orchestrator::run()` as a tokio task -- Run TUI event loop on main thread (Ratatui requires main thread for crossterm) -- On `UserAction::Quit` or Ctrl-C: signal orchestrator shutdown, restore terminal, exit cleanly - -### 1.7 Phase 1 Unit Tests -- Provider: SSE byte fixture → correct `StreamEvent` sequence -- Provider: `ConversationMessage` vec → correct Anthropic API JSON shape -- Core: `ConversationHistory` push/read roundtrip -- Core: Orchestrator state transitions against mock `StreamEvent` sequence (no real API) - -## Phase 2: Vim Modes and Navigation -- Normal, Insert, Command modes with visual indicator -- `j`/`k` scroll in Normal mode -- `:quit`, `:clear` commands -- **Done when:** Fluid mode switching and scrolling feels vim-native - ## Phase 3: Tool Execution - `Tool` trait, `ToolRegistry`, core tools (`read_file`, `write_file`, `shell_exec`) - Tool definitions in API requests, parse tool-use responses -- Approval gate: core → TUI pending event → user approve/deny → result back +- Approval gate: core -> TUI pending event -> user approve/deny -> result back - Working directory confinement + path validation (no Landlock yet) - **Done when:** Claude can read, modify files, and run commands with user approval @@ -81,8 +13,3 @@ - `:net on/off` toggle, state in status bar - Graceful degradation on older kernels - **Done when:** Writes outside project dir fail; network toggle works - - - - - diff --git a/TODO.md b/TODO.md index cf55134..71d90dc 100644 --- a/TODO.md +++ b/TODO.md @@ -2,3 +2,7 @@ - Move keyboard/event reads in the TUI to a separate thread or async/io loop - Keep UI and orchestrator in sync (i.e. messages display out of order if you queue up many.) +- `update_scroll` auto-follows in Insert mode, yanking viewport to bottom on mode switch. Only auto-follow when new content arrives (in `drain_ui_events`), not every frame. +- `G` sets scroll to `u16::MAX` and relies on `update_scroll` clamping. Compute actual max_scroll inline, or document the contract that `update_scroll` must always run before render. +- `:clear` clears TUI state immediately but sends `ClearHistory` to orchestrator async. A mid-stream response can ghost back in after clear. Need synchronization (e.g. clear on `TurnComplete`, or have orchestrator ack the clear). +- Command overlay width: `(out.width / 2).max(80)` makes overlay full-bleed on 80-col terminals. Consider `.max(40)` or a different minimum. diff --git a/src/core/history.rs b/src/core/history.rs index 45b0ffd..fb2598a 100644 --- a/src/core/history.rs +++ b/src/core/history.rs @@ -34,6 +34,11 @@ impl ConversationHistory { pub fn messages(&self) -> &[ConversationMessage] { &self.messages } + + /// Remove all messages from the history. + pub fn clear(&mut self) { + self.messages.clear(); + } } impl Default for ConversationHistory { @@ -73,6 +78,17 @@ mod tests { assert_eq!(msgs[1].content, "hi there"); } + #[test] + fn clear_empties_history() { + let mut history = ConversationHistory::new(); + history.push(ConversationMessage { + role: Role::User, + content: "hello".to_string(), + }); + history.clear(); + assert!(history.messages().is_empty()); + } + #[test] fn messages_preserves_insertion_order() { let mut history = ConversationHistory::new(); diff --git a/src/core/orchestrator.rs b/src/core/orchestrator.rs index b924f51..de33be7 100644 --- a/src/core/orchestrator.rs +++ b/src/core/orchestrator.rs @@ -65,6 +65,9 @@ impl Orchestrator

{ while let Some(action) = self.action_rx.recv().await { match action { UserAction::Quit => break, + UserAction::ClearHistory => { + self.history.clear(); + } UserAction::SendMessage(text) => { // Push the user message before snapshotting, so providers diff --git a/src/core/types.rs b/src/core/types.rs index 7c83cfb..b0afd0f 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -20,6 +20,8 @@ pub enum UserAction { SendMessage(String), /// The user has requested to quit. Quit, + /// The user has requested to clear conversation history. + ClearHistory, } /// An event sent from the core orchestrator to the TUI. diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 53332c8..143d84b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -20,9 +20,9 @@ use crossterm::terminal::{ use futures::StreamExt; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Paragraph, Wrap}; +use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; use tokio::sync::mpsc; use tracing::debug; @@ -37,6 +37,24 @@ pub enum TuiError { Io(#[from] std::io::Error), } +/// Vim-style editing mode for the TUI. +/// +/// The TUI starts in Insert mode so first-time users can type immediately. +/// Mode transitions: +/// Insert --Esc--> Normal +/// Normal --i--> Insert +/// Normal --:--> Command +/// Command --Esc/Enter--> Normal +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// Navigation and command entry. Keys are interpreted as commands, not text. + Normal, + /// Text input. Printable keys append to the input buffer. + Insert, + /// Ex-style command entry. The command buffer lives in `AppState::command_buffer`. + Command, +} + /// The UI-layer view of a conversation: rendered messages and the current input buffer. pub struct AppState { /// All conversation turns rendered as (role, content) pairs. @@ -45,6 +63,16 @@ pub struct AppState { pub input: String, /// Vertical scroll offset for the output pane (lines from top). pub scroll: u16, + /// Current vim-style editing mode. + pub mode: Mode, + /// Partial command text in Command mode (without the leading `:`). + pub command_buffer: String, + /// Buffered keystrokes for multi-key chord resolution in Normal mode (e.g. `gg`). + pub pending_keys: Vec, + /// Last-known viewport height (output pane lines). Updated each frame by `update_scroll`. + pub viewport_height: u16, + /// Transient error message shown in the status bar, cleared on next keypress. + pub status_error: Option, } impl AppState { @@ -53,6 +81,11 @@ impl AppState { messages: Vec::new(), input: String::new(), scroll: 0, + mode: Mode::Insert, + command_buffer: String::new(), + pending_keys: Vec::new(), + viewport_height: 0, + status_error: None, } } } @@ -97,27 +130,133 @@ enum LoopControl { SendMessage(String), /// The user pressed Ctrl+C or Ctrl+D; exit the event loop. Quit, + /// The user ran `:clear`; wipe the conversation. + ClearHistory, } /// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect. /// -/// Returns `None` when the key is consumed with no further loop-level action needed. +/// Ctrl+C / Ctrl+D quit from any mode. All other keys are dispatched to the +/// handler for the current [`Mode`]. +fn handle_key(key: Option, state: &mut AppState) -> Option { + let key = key?; + // Clear any transient status error on the next keypress. + state.status_error = None; + // Ctrl+C quits from any mode. + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return Some(LoopControl::Quit); + } + // Ctrl+D quits only from Insert/Command mode. In Normal mode it scrolls. + if key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('d') + && !matches!(state.mode, Mode::Normal) + { + return Some(LoopControl::Quit); + } + match &state.mode { + Mode::Normal => handle_normal_key(key, state), + Mode::Insert => handle_insert_key(key, state), + Mode::Command => handle_command_key(key, state), + } +} + +enum ChordResult { + /// The chord was fully matched; the returned closure applies the effect. + Executed, + /// The keys so far are a valid prefix of at least one chord. + Prefix, + /// The keys don't match any chord or prefix. + NoMatch, +} + +/// Check `keys` against known multi-key chords in Normal mode. +/// +/// Currently only `gg` (scroll to top) is defined. Returns the match status +/// without mutating state -- the caller applies effects for `Executed`. +fn resolve_chord(keys: &[char]) -> ChordResult { + match keys { + [.., 'g', 'g'] => ChordResult::Executed, + [.., 'g'] => ChordResult::Prefix, + _ => ChordResult::NoMatch, + } +} + +/// Handle a keypress in Normal mode. +/// +/// | Key | Effect | +/// |-----|-------------------------------------------| +/// | `i` | Switch to Insert mode | +/// | `:` | Switch to Command mode (empty buffer) | +/// | `j` | Scroll down one line | +/// | `k` | Scroll up one line | +/// | `G` | Scroll to bottom | +/// | `g` | Begin chord; `gg` scrolls to top | +/// | Ctrl+d | Scroll down half a page | +/// | Ctrl+u | Scroll up half a page | +fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> Option { + let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char('d') if is_ctrl => { + let half = (state.viewport_height / 2).max(1); + state.scroll = state.scroll.saturating_add(half); + state.pending_keys.clear(); + } + KeyCode::Char('u') if is_ctrl => { + let half = (state.viewport_height / 2).max(1); + state.scroll = state.scroll.saturating_sub(half); + state.pending_keys.clear(); + } + KeyCode::Char('i') if !is_ctrl && state.pending_keys.is_empty() => { + state.mode = Mode::Insert; + } + KeyCode::Char(':') if !is_ctrl && state.pending_keys.is_empty() => { + state.command_buffer.clear(); + state.mode = Mode::Command; + } + KeyCode::Char('j') if !is_ctrl && state.pending_keys.is_empty() => { + state.scroll = state.scroll.saturating_add(1); + } + KeyCode::Char('k') if !is_ctrl && state.pending_keys.is_empty() => { + state.scroll = state.scroll.saturating_sub(1); + } + KeyCode::Char('G') if !is_ctrl && state.pending_keys.is_empty() => { + state.scroll = u16::MAX; + } + KeyCode::Char(c) => { + state.pending_keys.push(c); + match resolve_chord(&state.pending_keys) { + ChordResult::Executed => { + // Apply the chord effect. Currently only `gg`. + state.scroll = 0; + state.pending_keys.clear(); + } + ChordResult::NoMatch => { + state.pending_keys.clear(); + } + ChordResult::Prefix => {} // wait for more keys + } + } + _ => { + state.pending_keys.clear(); + } + } + None +} + +/// Handle a keypress in Insert mode (the default text-entry mode). /// /// | Key | Effect | /// |------------------|-------------------------------------------------| -/// | Printable (no CTRL) | `state.input.push(c)` | -/// | Backspace | `state.input.pop()` | +/// | Esc | Switch to Normal mode | +/// | Printable char | Append to input buffer | +/// | Backspace | Pop last char from input buffer | /// | Enter (non-empty)| Take input, push User message, return `SendMessage` | /// | Enter (empty) | No-op | -/// | Ctrl+C / Ctrl+D | Return `Quit` | -fn handle_key(key: Option, state: &mut AppState) -> Option { - let key = key?; +fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option { match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Some(LoopControl::Quit) - } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Some(LoopControl::Quit) + KeyCode::Esc => { + state.mode = Mode::Normal; + None } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { state.input.push(c); @@ -140,6 +279,56 @@ fn handle_key(key: Option, state: &mut AppState) -> Option Option { + match buf.trim() { + "quit" | "q" => Some(LoopControl::Quit), + "clear" => { + state.messages.clear(); + state.scroll = 0; + Some(LoopControl::ClearHistory) + } + other => { + state.status_error = Some(format!("Unknown command: {other}")); + None + } + } +} + +/// Handle a keypress in Command mode (ex-style `:` command entry). +/// +/// | Key | Effect | +/// |-----------|-----------------------------------------------------| +/// | Esc | Abandon command, return to Normal | +/// | Enter | Accept command (execution deferred to 2.3), Normal | +/// | Backspace | Pop from buffer; if empty, return to Normal | +/// | Char(c) | Append to command buffer | +fn handle_command_key(key: KeyEvent, state: &mut AppState) -> Option { + match key.code { + KeyCode::Esc => { + state.mode = Mode::Normal; + } + KeyCode::Enter => { + state.mode = Mode::Normal; + return execute_command(&state.command_buffer.clone(), state); + } + KeyCode::Backspace => { + state.command_buffer.pop(); + } + KeyCode::Char(c) => { + state.command_buffer.push(c); + } + _ => {} + } + None +} + /// Drain all pending [`UIEvent`]s from `event_rx` and apply them to `state`. /// /// This is non-blocking: it processes all currently-available events and returns @@ -182,8 +371,9 @@ fn drain_ui_events(event_rx: &mut mpsc::Receiver, state: &mut AppState) /// - `ceil(chars / width).max(1)` lines per newline-separated content line /// - 1 blank separator line fn update_scroll(state: &mut AppState, area: Rect) { - // 3 = height of the input pane (border top + content + border bottom) - let viewport_height = area.height.saturating_sub(3); + // 4 = input pane (3: border top + content + border bottom) + status bar (1) + let viewport_height = area.height.saturating_sub(4); + state.viewport_height = viewport_height; let width = area.width.max(1) as usize; let mut total_lines: u16 = 0; @@ -197,7 +387,32 @@ fn update_scroll(state: &mut AppState, area: Rect) { total_lines = total_lines.saturating_add(1); // blank separator } - state.scroll = total_lines.saturating_sub(viewport_height); + let max_scroll = total_lines.saturating_sub(viewport_height); + + match state.mode { + // In Insert mode, auto-follow the bottom of the conversation. + Mode::Insert => { + state.scroll = max_scroll; + } + // In Normal/Command mode, the user controls scroll -- just clamp to bounds. + _ => { + state.scroll = state.scroll.min(max_scroll); + } + } +} + +/// Compute the overlay rectangle for the command palette, centered on `output_area`. +fn command_overlay_rect(output_area: Rect) -> Rect { + let overlay_w = (output_area.width / 2).max(80).min(output_area.width); + let overlay_h: u16 = 3; // border + content + border + let overlay_x = output_area.x + (output_area.width.saturating_sub(overlay_w)) / 2; + let overlay_y = output_area.y + 2; + Rect { + x: overlay_x, + y: overlay_y, + width: overlay_w, + height: overlay_h.min(output_area.height), + } } /// Render the full TUI into `frame`. @@ -211,11 +426,19 @@ fn update_scroll(state: &mut AppState, area: Rect) { /// | Input | Length(3) /// | > _ | /// +--------------------------------+ +/// | NORMAL tokens: -- | Length(1) +/// +--------------------------------+ /// ``` /// /// Role headers are coloured: `"You:"` in cyan, `"Assistant:"` in green. +/// In Command mode, a one-line overlay appears at row 1 of the output pane. fn render(frame: &mut Frame, state: &AppState) { - let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).split(frame.area()); + let chunks = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(frame.area()); // --- Output pane --- let mut lines: Vec = Vec::new(); @@ -236,10 +459,94 @@ fn render(frame: &mut Frame, state: &AppState) { .scroll((state.scroll, 0)); frame.render_widget(output, chunks[0]); + // --- Command overlay (floating box centered on output pane) --- + if state.mode == Mode::Command { + let overlay_area = command_overlay_rect(chunks[0]); + // Clear the area behind the overlay so it appears floating. + frame.render_widget(Clear, overlay_area); + let overlay = Paragraph::new(format!(":{}", state.command_buffer)).block( + Block::bordered() + .border_style(Style::default().fg(Color::Yellow)) + .title("Command"), + ); + frame.render_widget(overlay, overlay_area); + } + // --- Input pane --- + let (input_title, input_style) = match state.mode { + Mode::Normal | Mode::Command => ( + "Input (normal)", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + Mode::Insert => ("Input", Style::default()), + }; let input_text = format!("> {}", state.input); - let input_widget = Paragraph::new(input_text).block(Block::bordered().title("Input")); + let input_widget = Paragraph::new(input_text) + .style(input_style) + .block(Block::bordered().title(input_title)); frame.render_widget(input_widget, chunks[1]); + + // --- Cursor positioning --- + match state.mode { + Mode::Insert => { + // Cursor at end of input text: border(1) + "> " (2) + input len + let cursor_x = chunks[1].x + 1 + 2 + state.input.len() as u16; + let cursor_y = chunks[1].y + 1; // inside the border + frame.set_cursor_position((cursor_x, cursor_y)); + } + Mode::Command => { + // Cursor in the floating overlay + let overlay = command_overlay_rect(chunks[0]); + // border(1) + ":" (1) + buf len + let cursor_x = overlay.x + 1 + 1 + state.command_buffer.len() as u16; + let cursor_y = overlay.y + 1; // inside the border + frame.set_cursor_position((cursor_x, cursor_y)); + } + Mode::Normal => {} // no cursor + } + + // --- Status bar --- + let (mode_label, mode_style) = match state.mode { + Mode::Normal => ( + " NORMAL ", + Style::default().bg(Color::Blue).fg(Color::White), + ), + Mode::Insert => ( + " INSERT ", + Style::default().bg(Color::Green).fg(Color::White), + ), + Mode::Command => ( + " COMMAND ", + Style::default().bg(Color::Yellow).fg(Color::Black), + ), + }; + + let right_text = if let Some(ref err) = state.status_error { + err.clone() + } else { + "tokens: --".to_string() + }; + + let right_style = if state.status_error.is_some() { + Style::default().fg(Color::Red) + } else { + Style::default() + }; + + let bar_width = chunks[2].width as usize; + let left_len = mode_label.len(); + let right_len = right_text.len(); + let pad = bar_width.saturating_sub(left_len + right_len); + + let status_line = Line::from(vec![ + Span::styled(mode_label, mode_style), + Span::raw(" ".repeat(pad)), + Span::styled(right_text, right_style), + ]); + let status_widget = Paragraph::new(status_line); + frame.render_widget(status_widget, chunks[2]); } /// Run the TUI event loop. @@ -301,6 +608,9 @@ pub async fn run( let _ = action_tx.send(UserAction::Quit).await; break; } + Some(LoopControl::ClearHistory) => { + let _ = action_tx.send(UserAction::ClearHistory).await; + } None => {} } } @@ -371,6 +681,13 @@ mod tests { assert!(matches!(result, Some(LoopControl::Quit))); } + #[test] + fn insert_ctrl_char_is_noop() { + let mut state = AppState::new(); + handle_key(ctrl_key('a'), &mut state); + assert_eq!(state.input, "", "Ctrl+A should not insert 'a'"); + } + // --- drain_ui_events tests --- #[tokio::test] @@ -457,4 +774,362 @@ mod tests { update_scroll(&mut state, area); assert!(state.scroll > 0, "expected scroll > 0 with 50 messages"); } + + // --- mode tests --- + + #[test] + fn mode_starts_insert() { + assert_eq!(AppState::new().mode, Mode::Insert); + } + + #[test] + fn insert_esc_enters_normal() { + let mut state = AppState::new(); + handle_key(make_key(KeyCode::Esc), &mut state); + assert_eq!(state.mode, Mode::Normal); + } + + #[test] + fn normal_i_enters_insert() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + handle_key(make_key(KeyCode::Char('i')), &mut state); + assert_eq!(state.mode, Mode::Insert); + } + + #[test] + fn normal_colon_enters_command() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + handle_key(make_key(KeyCode::Char(':')), &mut state); + assert_eq!(state.mode, Mode::Command); + } + + #[test] + fn command_esc_enters_normal() { + let mut state = AppState::new(); + state.mode = Mode::Command; + state.command_buffer = "q".to_string(); + handle_key(make_key(KeyCode::Esc), &mut state); + assert_eq!(state.mode, Mode::Normal); + } + + #[test] + fn command_enter_enters_normal() { + let mut state = AppState::new(); + state.mode = Mode::Command; + handle_key(make_key(KeyCode::Enter), &mut state); + assert_eq!(state.mode, Mode::Normal); + } + + #[test] + fn command_chars_append_to_buffer() { + let mut state = AppState::new(); + state.mode = Mode::Command; + handle_key(make_key(KeyCode::Char('q')), &mut state); + assert_eq!(state.mode, Mode::Command); + assert_eq!(state.command_buffer, "q"); + } + + #[test] + fn command_backspace_empty_stays_in_command() { + let mut state = AppState::new(); + state.mode = Mode::Command; + state.command_buffer = "ab".to_string(); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.command_buffer, "a"); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.command_buffer, ""); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.command_buffer, ""); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.command_buffer, ""); + } + + #[test] + fn normal_j_increments_scroll() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + handle_key(make_key(KeyCode::Char('j')), &mut state); + assert_eq!(state.scroll, 1); + } + + #[test] + fn normal_k_decrements_scroll() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + state.scroll = 5; + handle_key(make_key(KeyCode::Char('k')), &mut state); + assert_eq!(state.scroll, 4); + } + + #[test] + fn normal_k_clamps_at_zero() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + handle_key(make_key(KeyCode::Char('k')), &mut state); + assert_eq!(state.scroll, 0); + } + + #[test] + fn normal_big_g_scrolls_bottom() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + handle_key(make_key(KeyCode::Char('G')), &mut state); + assert_eq!(state.scroll, u16::MAX); + } + + #[test] + fn normal_gg_scrolls_top() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + state.scroll = 50; + handle_key(make_key(KeyCode::Char('g')), &mut state); + assert_eq!(state.pending_keys, vec!['g']); // prefix, waiting + handle_key(make_key(KeyCode::Char('g')), &mut state); + assert_eq!(state.scroll, 0); + assert!(state.pending_keys.is_empty()); + } + + #[test] + fn normal_g_then_other_clears_pending() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + state.scroll = 50; + handle_key(make_key(KeyCode::Char('g')), &mut state); + handle_key(make_key(KeyCode::Char('x')), &mut state); + assert!(state.pending_keys.is_empty()); + assert_eq!(state.scroll, 50); // unchanged + } + + #[test] + fn ctrl_c_quits_from_any_mode() { + for mode in [Mode::Normal, Mode::Insert, Mode::Command] { + let mut state = AppState::new(); + state.mode = mode; + let result = handle_key(ctrl_key('c'), &mut state); + assert!(matches!(result, Some(LoopControl::Quit))); + } + } + + #[test] + fn ctrl_d_quits_from_insert() { + let mut state = AppState::new(); + let result = handle_key(ctrl_key('d'), &mut state); + assert!(matches!(result, Some(LoopControl::Quit))); + } + + #[test] + fn ctrl_d_scrolls_in_normal() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + state.viewport_height = 20; + let result = handle_key(ctrl_key('d'), &mut state); + assert!(result.is_none()); + assert_eq!(state.scroll, 10); + } + + #[test] + fn ctrl_u_scrolls_up_in_normal() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + state.viewport_height = 20; + state.scroll = 15; + let result = handle_key(ctrl_key('u'), &mut state); + assert!(result.is_none()); + assert_eq!(state.scroll, 5); + } + + #[test] + fn ctrl_u_clamps_at_zero() { + let mut state = AppState::new(); + state.mode = Mode::Normal; + state.viewport_height = 20; + state.scroll = 3; + handle_key(ctrl_key('u'), &mut state); + assert_eq!(state.scroll, 0); + } + + // --- execute_command tests --- + + #[test] + fn command_quit_returns_quit() { + let mut state = AppState::new(); + let result = execute_command("quit", &mut state); + assert!(matches!(result, Some(LoopControl::Quit))); + } + + #[test] + fn command_q_returns_quit() { + let mut state = AppState::new(); + let result = execute_command("q", &mut state); + assert!(matches!(result, Some(LoopControl::Quit))); + } + + #[test] + fn command_clear_empties_messages() { + let mut state = AppState::new(); + state.messages.push((Role::User, "hi".to_string())); + state.messages.push((Role::Assistant, "hello".to_string())); + state.scroll = 10; + let result = execute_command("clear", &mut state); + assert!(matches!(result, Some(LoopControl::ClearHistory))); + assert!(state.messages.is_empty()); + assert_eq!(state.scroll, 0); + } + + #[test] + fn command_unknown_sets_status_error() { + let mut state = AppState::new(); + let result = execute_command("foo", &mut state); + assert!(result.is_none()); + assert_eq!(state.status_error.as_deref(), Some("Unknown command: foo")); + } + + #[test] + fn status_error_cleared_on_next_keypress() { + let mut state = AppState::new(); + state.status_error = Some("some error".to_string()); + handle_key(make_key(KeyCode::Char('h')), &mut state); + assert!(state.status_error.is_none()); + } + + #[test] + fn command_enter_executes_quit() { + let mut state = AppState::new(); + state.mode = Mode::Command; + state.command_buffer = "q".to_string(); + let result = handle_key(make_key(KeyCode::Enter), &mut state); + assert!(matches!(result, Some(LoopControl::Quit))); + assert_eq!(state.mode, Mode::Normal); + } + + // --- render snapshot tests --- + + #[test] + fn render_status_bar_normal_mode() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + state.mode = Mode::Normal; + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!(all_text.contains("NORMAL"), "expected 'NORMAL' in buffer"); + assert!( + all_text.contains("tokens: --"), + "expected 'tokens: --' in buffer" + ); + } + + #[test] + fn render_status_bar_insert_mode() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let state = AppState::new(); // starts in Insert + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!(all_text.contains("INSERT"), "expected 'INSERT' in buffer"); + } + + #[test] + fn render_status_bar_command_mode() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + state.mode = Mode::Command; + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!(all_text.contains("COMMAND"), "expected 'COMMAND' in buffer"); + } + + #[test] + fn render_command_overlay_visible_in_command_mode() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + state.mode = Mode::Command; + state.command_buffer = "quit".to_string(); + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!( + all_text.contains(":quit"), + "expected ':quit' overlay in buffer" + ); + } + + #[test] + fn render_no_overlay_in_normal_mode() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + state.mode = Mode::Normal; + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + // Row 1 should not have a ":" prefix from the overlay + let row1: String = (0..80) + .map(|x| buf.cell((x, 1)).unwrap().symbol().to_string()) + .collect(); + assert!( + !row1.starts_with(':'), + "overlay should not appear in Normal mode" + ); + } + + #[test] + fn render_input_pane_dimmed_in_normal() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + state.mode = Mode::Normal; + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!( + all_text.contains("Input (normal)"), + "expected 'Input (normal)' title in Normal mode" + ); + } + + #[test] + fn render_status_bar_shows_error() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + state.status_error = Some("Unknown command: foo".to_string()); + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!( + all_text.contains("Unknown command: foo"), + "expected error in status bar" + ); + } }