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/PLAN.md b/PLAN.md index 9b3f99c..aadf359 100644 --- a/PLAN.md +++ b/PLAN.md @@ -28,7 +28,6 @@ - Key dispatch: correct handler called per mode - Command parser: `:quit`, `:clear`, `:q`, unknown command - Scroll clamping: j/k at boundaries, G/gg -- `insta` snapshot tests: mode indicator rendering for each mode, layout with status bar - Existing Phase 1 input tests still pass (insert mode behavior unchanged) ## Phase 3: Tool Execution 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 d8a3f86..3f0681d 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -20,7 +20,7 @@ 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::{Frame, Terminal}; @@ -70,6 +70,8 @@ pub struct AppState { 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,6 +83,7 @@ impl AppState { mode: Mode::Insert, pending_keys: Vec::new(), viewport_height: 0, + status_error: None, } } } @@ -125,6 +128,8 @@ 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. @@ -133,6 +138,8 @@ enum LoopControl { /// 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); @@ -269,6 +276,28 @@ 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 | @@ -289,14 +318,11 @@ fn handle_command_key(key: KeyEvent, state: &mut AppState) -> Option { - // Command execution deferred to phase 2.3; just return to Normal. + return execute_command(&buf, state); } KeyCode::Backspace => { buf.pop(); - if !buf.is_empty() { - state.mode = Mode::Command(buf); - } - // else stay in Normal (already set) + state.mode = Mode::Command(buf); } KeyCode::Char(c) => { buf.push(c); @@ -351,8 +377,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) { - // 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; @@ -392,11 +418,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(); @@ -417,10 +451,108 @@ 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 let Mode::Command(ref buf) = state.mode { + let out = chunks[0]; + let overlay_w = (out.width / 2).max(80).min(out.width); + let overlay_h = 3; // border + content + border + let overlay_x = out.x + (out.width.saturating_sub(overlay_w)) / 2; + let overlay_y = out.y + (out.height.saturating_sub(overlay_h)) / 2; + let overlay_area = Rect { + x: overlay_x, + y: overlay_y, + width: overlay_w, + height: overlay_h.min(out.height), + }; + // Clear the area behind the overlay so it appears floating. + let clear = Paragraph::new(""); + frame.render_widget(clear, overlay_area); + let overlay = Paragraph::new(format!(":{buf}")).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(ref buf) => { + // Cursor in the floating overlay: recalculate overlay position + let out = chunks[0]; + let overlay_w = (out.width / 2).max(80).min(out.width); + let overlay_x = out.x + (out.width.saturating_sub(overlay_w)) / 2; + let overlay_y = out.y + (out.height.saturating_sub(3)) / 2; + // border(1) + ":" (1) + buf len + let cursor_x = overlay_x + 1 + 1 + buf.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. @@ -482,6 +614,9 @@ pub async fn run( let _ = action_tx.send(UserAction::Quit).await; break; } + Some(LoopControl::ClearHistory) => { + let _ = action_tx.send(UserAction::ClearHistory).await; + } None => {} } } @@ -694,11 +829,17 @@ mod tests { } #[test] - fn command_backspace_empty_exits() { + fn command_backspace_empty_stays_in_command() { let mut state = AppState::new(); - state.mode = Mode::Command(String::new()); + state.mode = Mode::Command("ab".to_string()); handle_key(make_key(KeyCode::Backspace), &mut state); - assert_eq!(state.mode, Mode::Normal); + assert_eq!(state.mode, Mode::Command("a".to_string())); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.mode, Mode::Command(String::new())); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.mode, Mode::Command(String::new())); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.mode, Mode::Command(String::new())); } #[test] @@ -804,4 +945,185 @@ mod tests { 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("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("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("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("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" + ); + } }