From 5cb66475131de38b6b35183d144c90242e934949 Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Tue, 24 Feb 2026 13:51:52 -0800 Subject: [PATCH 1/3] Add mode switching. --- IDEAS.md | 5 +- PLAN.md | 102 ++++---------- src/tui/mod.rs | 373 +++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 394 insertions(+), 86 deletions(-) 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..9b3f99c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,77 +1,40 @@ # 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 + +**Done when:** Fluid mode switching and scrolling feels vim-native + +### 2.3 Command Parser + Execution +- Parse command buffer on enter: `:quit` -> `Quit`, `:clear` -> clear messages and history, `:q` alias for `:quit` +- Return appropriate `LoopControl` or mutate `AppState` directly for display-only commands +- Unknown commands -> show error in a transient status area + +### 2.4 Status Bar + Command Overlay +- Add a status bar row at the bottom of the layout (below input pane) +- Status bar shows: current mode (`-- NORMAL --`, `-- INSERT --`), token totals (placeholder for now) +- Style: bold, color-coded per mode (following vim convention) +- Command input renders as a floating bar near the top of the screen (overlay on the output pane), not in the status bar +- Command overlay: shows `:` prefix + partial command buffer, only visible in Command mode +- Overlay dismisses on Esc or Enter + +### 2.6 Input Pane Behavior by Mode +- Normal mode: input pane shows last draft (unsent) message or is empty, not editable +- Insert mode: input pane is editable (current behavior) +- Command mode: floating overlay near top shows `:` prefix + command buffer; input pane unchanged +- Cursor visibility: show cursor in Insert and Command modes, hide in Normal + +### 2.7 Phase 2 Unit Tests +- Mode transitions: Normal->Insert->Normal, Normal->Command->Normal, Command execute +- 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 - `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 +44,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/src/tui/mod.rs b/src/tui/mod.rs index 53332c8..d8a3f86 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -37,6 +37,25 @@ 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(buffer) +/// Command --Esc/Enter--> Normal +#[derive(Debug, Clone, 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 `String` holds the partial command buffer + /// (without the leading `:`). + Command(String), +} + /// 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 +64,12 @@ 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, + /// 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, } impl AppState { @@ -53,6 +78,9 @@ impl AppState { messages: Vec::new(), input: String::new(), scroll: 0, + mode: Mode::Insert, + pending_keys: Vec::new(), + viewport_height: 0, } } } @@ -101,25 +129,126 @@ enum LoopControl { /// 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?; + // 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.mode = Mode::Command(String::new()); + } + 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::Esc => { + state.mode = Mode::Normal; + None } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Some(LoopControl::Quit) - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char(c) => { state.input.push(c); None } @@ -140,6 +269,46 @@ fn handle_key(key: Option, state: &mut AppState) -> Option Option { + // Extract the command buffer. We need ownership to mutate it. + let mut buf = match std::mem::replace(&mut state.mode, Mode::Normal) { + Mode::Command(b) => b, + _ => unreachable!(), + }; + + match key.code { + KeyCode::Esc => { + // Already set to Normal above. + } + KeyCode::Enter => { + // Command execution deferred to phase 2.3; just return to Normal. + } + KeyCode::Backspace => { + buf.pop(); + if !buf.is_empty() { + state.mode = Mode::Command(buf); + } + // else stay in Normal (already set) + } + KeyCode::Char(c) => { + buf.push(c); + state.mode = Mode::Command(buf); + } + _ => { + state.mode = Mode::Command(buf); + } + } + 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 @@ -184,6 +353,7 @@ fn drain_ui_events(event_rx: &mut mpsc::Receiver, state: &mut AppState) 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); + state.viewport_height = viewport_height; let width = area.width.max(1) as usize; let mut total_lines: u16 = 0; @@ -197,7 +367,18 @@ 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); + } + } } /// Render the full TUI into `frame`. @@ -457,4 +638,170 @@ 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(String::new())); + } + + #[test] + fn command_esc_enters_normal() { + let mut state = AppState::new(); + state.mode = Mode::Command("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("q".to_string()); + 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(String::new()); + handle_key(make_key(KeyCode::Char('q')), &mut state); + assert_eq!(state.mode, Mode::Command("q".to_string())); + } + + #[test] + fn command_backspace_empty_exits() { + let mut state = AppState::new(); + state.mode = Mode::Command(String::new()); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.mode, Mode::Normal); + } + + #[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("q".to_string())] { + 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); + } } From 7b9525ef95d78d780c502d152003267d753c2ea2 Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Tue, 24 Feb 2026 14:23:33 -0800 Subject: [PATCH 2/3] Add command interface and status indicator. --- CLAUDE.md | 2 +- DESIGN.md | 8 +- PLAN.md | 1 - src/core/history.rs | 16 ++ src/core/orchestrator.rs | 3 + src/core/types.rs | 2 + src/tui/mod.rs | 348 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 358 insertions(+), 22 deletions(-) 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" + ); + } } From b043fed59630d62e145c7abcc2146666fcaf4dfd Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Tue, 24 Feb 2026 15:53:54 -0800 Subject: [PATCH 3/3] Cleanup --- PLAN.md | 30 ----------- TODO.md | 4 ++ src/tui/mod.rs | 132 ++++++++++++++++++++++++++----------------------- 3 files changed, 73 insertions(+), 93 deletions(-) diff --git a/PLAN.md b/PLAN.md index aadf359..03059df 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,35 +1,5 @@ # Implementation Plan -## Phase 2: Vim Modes and Navigation - -**Done when:** Fluid mode switching and scrolling feels vim-native - -### 2.3 Command Parser + Execution -- Parse command buffer on enter: `:quit` -> `Quit`, `:clear` -> clear messages and history, `:q` alias for `:quit` -- Return appropriate `LoopControl` or mutate `AppState` directly for display-only commands -- Unknown commands -> show error in a transient status area - -### 2.4 Status Bar + Command Overlay -- Add a status bar row at the bottom of the layout (below input pane) -- Status bar shows: current mode (`-- NORMAL --`, `-- INSERT --`), token totals (placeholder for now) -- Style: bold, color-coded per mode (following vim convention) -- Command input renders as a floating bar near the top of the screen (overlay on the output pane), not in the status bar -- Command overlay: shows `:` prefix + partial command buffer, only visible in Command mode -- Overlay dismisses on Esc or Enter - -### 2.6 Input Pane Behavior by Mode -- Normal mode: input pane shows last draft (unsent) message or is empty, not editable -- Insert mode: input pane is editable (current behavior) -- Command mode: floating overlay near top shows `:` prefix + command buffer; input pane unchanged -- Cursor visibility: show cursor in Insert and Command modes, hide in Normal - -### 2.7 Phase 2 Unit Tests -- Mode transitions: Normal->Insert->Normal, Normal->Command->Normal, Command execute -- Key dispatch: correct handler called per mode -- Command parser: `:quit`, `:clear`, `:q`, unknown command -- Scroll clamping: j/k at boundaries, G/gg -- Existing Phase 1 input tests still pass (insert mode behavior unchanged) - ## Phase 3: Tool Execution - `Tool` trait, `ToolRegistry`, core tools (`read_file`, `write_file`, `shell_exec`) - Tool definitions in API requests, parse tool-use responses 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/tui/mod.rs b/src/tui/mod.rs index 3f0681d..baa29df 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -22,7 +22,7 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Layout, Rect}; 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; @@ -43,17 +43,16 @@ pub enum TuiError { /// Mode transitions: /// Insert --Esc--> Normal /// Normal --i--> Insert -/// Normal --:--> Command(buffer) +/// Normal --:--> Command /// Command --Esc/Enter--> Normal -#[derive(Debug, Clone, PartialEq, Eq)] +#[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 `String` holds the partial command buffer - /// (without the leading `:`). - Command(String), + /// 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. @@ -66,6 +65,8 @@ pub struct AppState { 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`. @@ -81,6 +82,7 @@ impl AppState { input: String::new(), scroll: 0, mode: Mode::Insert, + command_buffer: String::new(), pending_keys: Vec::new(), viewport_height: 0, status_error: None, @@ -154,7 +156,7 @@ fn handle_key(key: Option, state: &mut AppState) -> Option handle_normal_key(key, state), Mode::Insert => handle_insert_key(key, state), - Mode::Command(_) => handle_command_key(key, state), + Mode::Command => handle_command_key(key, state), } } @@ -208,7 +210,8 @@ fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> Option state.mode = Mode::Insert; } KeyCode::Char(':') if !is_ctrl && state.pending_keys.is_empty() => { - state.mode = Mode::Command(String::new()); + 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); @@ -255,7 +258,7 @@ fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option state.mode = Mode::Normal; None } - KeyCode::Char(c) => { + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { state.input.push(c); None } @@ -307,30 +310,21 @@ fn execute_command(buf: &str, state: &mut AppState) -> Option { /// | 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 { - // Extract the command buffer. We need ownership to mutate it. - let mut buf = match std::mem::replace(&mut state.mode, Mode::Normal) { - Mode::Command(b) => b, - _ => unreachable!(), - }; - match key.code { KeyCode::Esc => { - // Already set to Normal above. + state.mode = Mode::Normal; } KeyCode::Enter => { - return execute_command(&buf, state); + state.mode = Mode::Normal; + return execute_command(&state.command_buffer.clone(), state); } KeyCode::Backspace => { - buf.pop(); - state.mode = Mode::Command(buf); + state.command_buffer.pop(); } KeyCode::Char(c) => { - buf.push(c); - state.mode = Mode::Command(buf); - } - _ => { - state.mode = Mode::Command(buf); + state.command_buffer.push(c); } + _ => {} } None } @@ -407,6 +401,20 @@ fn update_scroll(state: &mut AppState, area: Rect) { } } +/// 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), + } +} + /// Render the full TUI into `frame`. /// /// Layout (top to bottom): @@ -452,22 +460,11 @@ fn render(frame: &mut Frame, state: &AppState) { 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), - }; + if state.mode == Mode::Command { + let overlay_area = command_overlay_rect(chunks[0]); // 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( + 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"), @@ -477,7 +474,7 @@ fn render(frame: &mut Frame, state: &AppState) { // --- Input pane --- let (input_title, input_style) = match state.mode { - Mode::Normal | Mode::Command(_) => ( + Mode::Normal | Mode::Command => ( "Input (normal)", Style::default() .fg(Color::DarkGray) @@ -499,15 +496,12 @@ fn render(frame: &mut Frame, state: &AppState) { 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; + 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 + buf.len() as u16; - let cursor_y = overlay_y + 1; // inside the border + 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 @@ -523,7 +517,7 @@ fn render(frame: &mut Frame, state: &AppState) { " INSERT ", Style::default().bg(Color::Green).fg(Color::White), ), - Mode::Command(_) => ( + Mode::Command => ( " COMMAND ", Style::default().bg(Color::Yellow).fg(Color::Black), ), @@ -687,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] @@ -801,13 +802,14 @@ mod tests { let mut state = AppState::new(); state.mode = Mode::Normal; handle_key(make_key(KeyCode::Char(':')), &mut state); - assert_eq!(state.mode, Mode::Command(String::new())); + assert_eq!(state.mode, Mode::Command); } #[test] fn command_esc_enters_normal() { let mut state = AppState::new(); - state.mode = Mode::Command("q".to_string()); + state.mode = Mode::Command; + state.command_buffer = "q".to_string(); handle_key(make_key(KeyCode::Esc), &mut state); assert_eq!(state.mode, Mode::Normal); } @@ -815,7 +817,7 @@ mod tests { #[test] fn command_enter_enters_normal() { let mut state = AppState::new(); - state.mode = Mode::Command("q".to_string()); + state.mode = Mode::Command; handle_key(make_key(KeyCode::Enter), &mut state); assert_eq!(state.mode, Mode::Normal); } @@ -823,23 +825,25 @@ mod tests { #[test] fn command_chars_append_to_buffer() { let mut state = AppState::new(); - state.mode = Mode::Command(String::new()); + state.mode = Mode::Command; handle_key(make_key(KeyCode::Char('q')), &mut state); - assert_eq!(state.mode, Mode::Command("q".to_string())); + 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("ab".to_string()); + state.mode = Mode::Command; + state.command_buffer = "ab".to_string(); handle_key(make_key(KeyCode::Backspace), &mut state); - assert_eq!(state.mode, Mode::Command("a".to_string())); + assert_eq!(state.command_buffer, "a"); handle_key(make_key(KeyCode::Backspace), &mut state); - assert_eq!(state.mode, Mode::Command(String::new())); + assert_eq!(state.command_buffer, ""); handle_key(make_key(KeyCode::Backspace), &mut state); - assert_eq!(state.mode, Mode::Command(String::new())); + assert_eq!(state.command_buffer, ""); handle_key(make_key(KeyCode::Backspace), &mut state); - assert_eq!(state.mode, Mode::Command(String::new())); + assert_eq!(state.command_buffer, ""); } #[test] @@ -900,7 +904,7 @@ mod tests { #[test] fn ctrl_c_quits_from_any_mode() { - for mode in [Mode::Normal, Mode::Insert, Mode::Command("q".to_string())] { + 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); @@ -993,7 +997,8 @@ mod tests { #[test] fn command_enter_executes_quit() { let mut state = AppState::new(); - state.mode = Mode::Command("q".to_string()); + 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); @@ -1041,7 +1046,7 @@ mod tests { 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()); + state.mode = Mode::Command; terminal.draw(|frame| render(frame, &state)).unwrap(); let buf = terminal.backend().buffer().clone(); let all_text: String = buf @@ -1057,7 +1062,8 @@ mod tests { 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()); + 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