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