diff --git a/CLAUDE.md b/CLAUDE.md index fb17bd7..24b7145 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` +- TUI widget tests use `ratatui::backend::TestBackend` + `insta` snapshots - 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 bdbe061..3733849 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` +- **`tui`:** Widget rendering via Ratatui `TestBackend`, snapshot tests with `insta` crate for layout/mode indicator/token display ### 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,6 +78,11 @@ - **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 @@ -88,6 +93,7 @@ ### 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 9d3dbb7..0c3c97f 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -42,9 +42,6 @@ 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 03059df..0e3a1f2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,9 +1,77 @@ # 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 @@ -13,3 +81,8 @@ - `: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 71d90dc..cf55134 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,3 @@ - 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 fb2598a..45b0ffd 100644 --- a/src/core/history.rs +++ b/src/core/history.rs @@ -34,11 +34,6 @@ 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 { @@ -78,17 +73,6 @@ 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 de33be7..b924f51 100644 --- a/src/core/orchestrator.rs +++ b/src/core/orchestrator.rs @@ -65,9 +65,6 @@ 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 b0afd0f..7c83cfb 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -20,8 +20,6 @@ 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 baa29df..53332c8 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, Modifier, Style}; +use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Clear, Paragraph, Wrap}; +use ratatui::widgets::{Block, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; use tokio::sync::mpsc; use tracing::debug; @@ -37,24 +37,6 @@ 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. @@ -63,16 +45,6 @@ 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 { @@ -81,11 +53,6 @@ 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, } } } @@ -130,133 +97,27 @@ 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. /// -/// 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). +/// Returns `None` when the key is consumed with no further loop-level action needed. /// /// | Key | Effect | /// |------------------|-------------------------------------------------| -/// | Esc | Switch to Normal mode | -/// | Printable char | Append to input buffer | -/// | Backspace | Pop last char from input buffer | +/// | Printable (no CTRL) | `state.input.push(c)` | +/// | Backspace | `state.input.pop()` | /// | Enter (non-empty)| Take input, push User message, return `SendMessage` | /// | Enter (empty) | No-op | -fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option { +/// | Ctrl+C / Ctrl+D | Return `Quit` | +fn handle_key(key: Option, state: &mut AppState) -> Option { + let key = key?; match key.code { - KeyCode::Esc => { - state.mode = Mode::Normal; - None + 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::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { state.input.push(c); @@ -279,56 +140,6 @@ fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option } } -/// Execute a parsed command string and return the appropriate control signal. -/// -/// | Command | Effect | -/// |---------------|-------------------------------------------------| -/// | `quit` / `q` | Return `LoopControl::Quit` | -/// | `clear` | Clear messages and scroll, return `ClearHistory` | -/// | anything else | Set `status_error` on state | -fn execute_command(buf: &str, state: &mut AppState) -> 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 @@ -371,9 +182,8 @@ 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) { - // 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; + // 3 = height of the input pane (border top + content + border bottom) + let viewport_height = area.height.saturating_sub(3); let width = area.width.max(1) as usize; let mut total_lines: u16 = 0; @@ -387,32 +197,7 @@ fn update_scroll(state: &mut AppState, area: Rect) { total_lines = total_lines.saturating_add(1); // blank separator } - 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 + (output_area.height.saturating_sub(overlay_h)) / 2; - Rect { - x: overlay_x, - y: overlay_y, - width: overlay_w, - height: overlay_h.min(output_area.height), - } + state.scroll = total_lines.saturating_sub(viewport_height); } /// Render the full TUI into `frame`. @@ -426,19 +211,11 @@ fn command_overlay_rect(output_area: Rect) -> 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), - Constraint::Length(1), - ]) - .split(frame.area()); + let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).split(frame.area()); // --- Output pane --- let mut lines: Vec = Vec::new(); @@ -459,94 +236,10 @@ 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) - .style(input_style) - .block(Block::bordered().title(input_title)); + let input_widget = Paragraph::new(input_text).block(Block::bordered().title("Input")); 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. @@ -608,9 +301,6 @@ pub async fn run( let _ = action_tx.send(UserAction::Quit).await; break; } - Some(LoopControl::ClearHistory) => { - let _ = action_tx.send(UserAction::ClearHistory).await; - } None => {} } } @@ -681,13 +371,6 @@ 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] @@ -774,362 +457,4 @@ 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" - ); - } }