Cleanup
This commit is contained in:
parent
7b9525ef95
commit
b043fed596
3 changed files with 73 additions and 93 deletions
30
PLAN.md
30
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
|
||||
|
|
|
|||
4
TODO.md
4
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.
|
||||
|
|
|
|||
132
src/tui/mod.rs
132
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<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 + (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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue