This commit is contained in:
Drew 2026-02-24 15:53:54 -08:00
parent 7b9525ef95
commit daa4add5cc
3 changed files with 73 additions and 93 deletions

30
PLAN.md
View file

@ -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

View file

@ -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.

View file

@ -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<char>,
/// 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<KeyEvent>, state: &mut AppState) -> Option<LoopControl
match &state.mode {
Mode::Normal => 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<LoopControl>
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<LoopControl>
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<LoopControl> {
/// | 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<LoopControl> {
// 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 + 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