Add modal editing to the agent TUI. #2

Merged
drew merged 3 commits from modes into main 2026-02-25 01:16:17 +00:00
3 changed files with 73 additions and 93 deletions
Showing only changes of commit daa4add5cc - Show all commits

30
PLAN.md
View file

@ -1,35 +1,5 @@
# Implementation Plan # 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 ## Phase 3: Tool Execution
- `Tool` trait, `ToolRegistry`, core tools (`read_file`, `write_file`, `shell_exec`) - `Tool` trait, `ToolRegistry`, core tools (`read_file`, `write_file`, `shell_exec`)
- Tool definitions in API requests, parse tool-use responses - 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 - 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.) - 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::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Wrap}; use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
use ratatui::{Frame, Terminal}; use ratatui::{Frame, Terminal};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::debug; use tracing::debug;
@ -43,17 +43,16 @@ pub enum TuiError {
/// Mode transitions: /// Mode transitions:
/// Insert --Esc--> Normal /// Insert --Esc--> Normal
/// Normal --i--> Insert /// Normal --i--> Insert
/// Normal --:--> Command(buffer) /// Normal --:--> Command
/// Command --Esc/Enter--> Normal /// Command --Esc/Enter--> Normal
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode { pub enum Mode {
/// Navigation and command entry. Keys are interpreted as commands, not text. /// Navigation and command entry. Keys are interpreted as commands, not text.
Normal, Normal,
/// Text input. Printable keys append to the input buffer. /// Text input. Printable keys append to the input buffer.
Insert, Insert,
/// Ex-style command entry. The `String` holds the partial command buffer /// Ex-style command entry. The command buffer lives in `AppState::command_buffer`.
/// (without the leading `:`). Command,
Command(String),
} }
/// The UI-layer view of a conversation: rendered messages and the current input buffer. /// The UI-layer view of a conversation: rendered messages and the current input buffer.
@ -66,6 +65,8 @@ pub struct AppState {
pub scroll: u16, pub scroll: u16,
/// Current vim-style editing mode. /// Current vim-style editing mode.
pub mode: 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`). /// Buffered keystrokes for multi-key chord resolution in Normal mode (e.g. `gg`).
pub pending_keys: Vec<char>, pub pending_keys: Vec<char>,
/// Last-known viewport height (output pane lines). Updated each frame by `update_scroll`. /// Last-known viewport height (output pane lines). Updated each frame by `update_scroll`.
@ -81,6 +82,7 @@ impl AppState {
input: String::new(), input: String::new(),
scroll: 0, scroll: 0,
mode: Mode::Insert, mode: Mode::Insert,
command_buffer: String::new(),
pending_keys: Vec::new(), pending_keys: Vec::new(),
viewport_height: 0, viewport_height: 0,
status_error: None, status_error: None,
@ -154,7 +156,7 @@ fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl
match &state.mode { match &state.mode {
Mode::Normal => handle_normal_key(key, state), Mode::Normal => handle_normal_key(key, state),
Mode::Insert => handle_insert_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; state.mode = Mode::Insert;
} }
KeyCode::Char(':') if !is_ctrl && state.pending_keys.is_empty() => { 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() => { KeyCode::Char('j') if !is_ctrl && state.pending_keys.is_empty() => {
state.scroll = state.scroll.saturating_add(1); 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; state.mode = Mode::Normal;
None None
} }
KeyCode::Char(c) => { KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
state.input.push(c); state.input.push(c);
None None
} }
@ -307,30 +310,21 @@ fn execute_command(buf: &str, state: &mut AppState) -> Option<LoopControl> {
/// | Backspace | Pop from buffer; if empty, return to Normal | /// | Backspace | Pop from buffer; if empty, return to Normal |
/// | Char(c) | Append to command buffer | /// | Char(c) | Append to command buffer |
fn handle_command_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> { 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 { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
// Already set to Normal above. state.mode = Mode::Normal;
} }
KeyCode::Enter => { KeyCode::Enter => {
return execute_command(&buf, state); state.mode = Mode::Normal;
return execute_command(&state.command_buffer.clone(), state);
} }
KeyCode::Backspace => { KeyCode::Backspace => {
buf.pop(); state.command_buffer.pop();
state.mode = Mode::Command(buf);
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
buf.push(c); state.command_buffer.push(c);
state.mode = Mode::Command(buf);
}
_ => {
state.mode = Mode::Command(buf);
} }
_ => {}
} }
None 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`. /// Render the full TUI into `frame`.
/// ///
/// Layout (top to bottom): /// Layout (top to bottom):
@ -452,22 +460,11 @@ fn render(frame: &mut Frame, state: &AppState) {
frame.render_widget(output, chunks[0]); frame.render_widget(output, chunks[0]);
// --- Command overlay (floating box centered on output pane) --- // --- Command overlay (floating box centered on output pane) ---
if let Mode::Command(ref buf) = state.mode { if state.mode == Mode::Command {
let out = chunks[0]; let overlay_area = command_overlay_rect(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. // Clear the area behind the overlay so it appears floating.
let clear = Paragraph::new(""); frame.render_widget(Clear, overlay_area);
frame.render_widget(clear, overlay_area); let overlay = Paragraph::new(format!(":{}", state.command_buffer)).block(
let overlay = Paragraph::new(format!(":{buf}")).block(
Block::bordered() Block::bordered()
.border_style(Style::default().fg(Color::Yellow)) .border_style(Style::default().fg(Color::Yellow))
.title("Command"), .title("Command"),
@ -477,7 +474,7 @@ fn render(frame: &mut Frame, state: &AppState) {
// --- Input pane --- // --- Input pane ---
let (input_title, input_style) = match state.mode { let (input_title, input_style) = match state.mode {
Mode::Normal | Mode::Command(_) => ( Mode::Normal | Mode::Command => (
"Input (normal)", "Input (normal)",
Style::default() Style::default()
.fg(Color::DarkGray) .fg(Color::DarkGray)
@ -499,15 +496,12 @@ fn render(frame: &mut Frame, state: &AppState) {
let cursor_y = chunks[1].y + 1; // inside the border let cursor_y = chunks[1].y + 1; // inside the border
frame.set_cursor_position((cursor_x, cursor_y)); frame.set_cursor_position((cursor_x, cursor_y));
} }
Mode::Command(ref buf) => { Mode::Command => {
// Cursor in the floating overlay: recalculate overlay position // Cursor in the floating overlay
let out = chunks[0]; let overlay = command_overlay_rect(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 // border(1) + ":" (1) + buf len
let cursor_x = overlay_x + 1 + 1 + buf.len() as u16; let cursor_x = overlay.x + 1 + 1 + state.command_buffer.len() as u16;
let cursor_y = overlay_y + 1; // inside the border let cursor_y = overlay.y + 1; // inside the border
frame.set_cursor_position((cursor_x, cursor_y)); frame.set_cursor_position((cursor_x, cursor_y));
} }
Mode::Normal => {} // no cursor Mode::Normal => {} // no cursor
@ -523,7 +517,7 @@ fn render(frame: &mut Frame, state: &AppState) {
" INSERT ", " INSERT ",
Style::default().bg(Color::Green).fg(Color::White), Style::default().bg(Color::Green).fg(Color::White),
), ),
Mode::Command(_) => ( Mode::Command => (
" COMMAND ", " COMMAND ",
Style::default().bg(Color::Yellow).fg(Color::Black), Style::default().bg(Color::Yellow).fg(Color::Black),
), ),
@ -687,6 +681,13 @@ mod tests {
assert!(matches!(result, Some(LoopControl::Quit))); 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 --- // --- drain_ui_events tests ---
#[tokio::test] #[tokio::test]
@ -801,13 +802,14 @@ mod tests {
let mut state = AppState::new(); let mut state = AppState::new();
state.mode = Mode::Normal; state.mode = Mode::Normal;
handle_key(make_key(KeyCode::Char(':')), &mut state); handle_key(make_key(KeyCode::Char(':')), &mut state);
assert_eq!(state.mode, Mode::Command(String::new())); assert_eq!(state.mode, Mode::Command);
} }
#[test] #[test]
fn command_esc_enters_normal() { fn command_esc_enters_normal() {
let mut state = AppState::new(); 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); handle_key(make_key(KeyCode::Esc), &mut state);
assert_eq!(state.mode, Mode::Normal); assert_eq!(state.mode, Mode::Normal);
} }
@ -815,7 +817,7 @@ mod tests {
#[test] #[test]
fn command_enter_enters_normal() { fn command_enter_enters_normal() {
let mut state = AppState::new(); let mut state = AppState::new();
state.mode = Mode::Command("q".to_string()); state.mode = Mode::Command;
handle_key(make_key(KeyCode::Enter), &mut state); handle_key(make_key(KeyCode::Enter), &mut state);
assert_eq!(state.mode, Mode::Normal); assert_eq!(state.mode, Mode::Normal);
} }
@ -823,23 +825,25 @@ mod tests {
#[test] #[test]
fn command_chars_append_to_buffer() { fn command_chars_append_to_buffer() {
let mut state = AppState::new(); let mut state = AppState::new();
state.mode = Mode::Command(String::new()); state.mode = Mode::Command;
handle_key(make_key(KeyCode::Char('q')), &mut state); 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] #[test]
fn command_backspace_empty_stays_in_command() { fn command_backspace_empty_stays_in_command() {
let mut state = AppState::new(); 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); 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); 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); 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); handle_key(make_key(KeyCode::Backspace), &mut state);
assert_eq!(state.mode, Mode::Command(String::new())); assert_eq!(state.command_buffer, "");
} }
#[test] #[test]
@ -900,7 +904,7 @@ mod tests {
#[test] #[test]
fn ctrl_c_quits_from_any_mode() { 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(); let mut state = AppState::new();
state.mode = mode; state.mode = mode;
let result = handle_key(ctrl_key('c'), &mut state); let result = handle_key(ctrl_key('c'), &mut state);
@ -993,7 +997,8 @@ mod tests {
#[test] #[test]
fn command_enter_executes_quit() { fn command_enter_executes_quit() {
let mut state = AppState::new(); 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); let result = handle_key(make_key(KeyCode::Enter), &mut state);
assert!(matches!(result, Some(LoopControl::Quit))); assert!(matches!(result, Some(LoopControl::Quit)));
assert_eq!(state.mode, Mode::Normal); assert_eq!(state.mode, Mode::Normal);
@ -1041,7 +1046,7 @@ mod tests {
let backend = TestBackend::new(80, 24); let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap(); let mut terminal = Terminal::new(backend).unwrap();
let mut state = AppState::new(); let mut state = AppState::new();
state.mode = Mode::Command("quit".to_string()); state.mode = Mode::Command;
terminal.draw(|frame| render(frame, &state)).unwrap(); terminal.draw(|frame| render(frame, &state)).unwrap();
let buf = terminal.backend().buffer().clone(); let buf = terminal.backend().buffer().clone();
let all_text: String = buf let all_text: String = buf
@ -1057,7 +1062,8 @@ mod tests {
let backend = TestBackend::new(80, 24); let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap(); let mut terminal = Terminal::new(backend).unwrap();
let mut state = AppState::new(); 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(); terminal.draw(|frame| render(frame, &state)).unwrap();
let buf = terminal.backend().buffer().clone(); let buf = terminal.backend().buffer().clone();
let all_text: String = buf let all_text: String = buf