Add modal editing to the agent TUI. #2
7 changed files with 358 additions and 22 deletions
|
|
@ -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`)
|
- Unit tests go in the same file as the code (`#[cfg(test)] mod tests`)
|
||||||
- Integration tests go in `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/`
|
- Provider tests replay recorded SSE fixtures from `tests/fixtures/`
|
||||||
- Sandbox tests use `tempdir` and skip Landlock-specific assertions if kernel < 5.13
|
- Sandbox tests use `tempdir` and skip Landlock-specific assertions if kernel < 5.13
|
||||||
- Run `cargo test` before every commit
|
- Run `cargo test` before every commit
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
- **`sandbox`:** Landlock policy construction, path validation logic (without applying kernel rules)
|
- **`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
|
- **`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
|
- **`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
|
### 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.
|
- **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.
|
- **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.
|
- **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
|
### Benchmarking — SWE-bench
|
||||||
- **Target:** SWE-bench Verified (500 curated problems) as primary benchmark
|
- **Target:** SWE-bench Verified (500 curated problems) as primary benchmark
|
||||||
- **Secondary:** SWE-bench Pro for testing planning mode on longer-horizon tasks
|
- **Secondary:** SWE-bench Pro for testing planning mode on longer-horizon tasks
|
||||||
|
|
@ -93,7 +88,6 @@
|
||||||
|
|
||||||
### Test Sequencing
|
### Test Sequencing
|
||||||
- Phase 1: Unit tests for SSE parser, event types, message serialization
|
- 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 4: Recorded session replay infrastructure (core loop complex enough to warrant it)
|
||||||
- Phase 6-7: Headless mode + first SWE-bench Verified run
|
- Phase 6-7: Headless mode + first SWE-bench Verified run
|
||||||
|
|
||||||
|
|
|
||||||
1
PLAN.md
1
PLAN.md
|
|
@ -28,7 +28,6 @@
|
||||||
- Key dispatch: correct handler called per mode
|
- Key dispatch: correct handler called per mode
|
||||||
- Command parser: `:quit`, `:clear`, `:q`, unknown command
|
- Command parser: `:quit`, `:clear`, `:q`, unknown command
|
||||||
- Scroll clamping: j/k at boundaries, G/gg
|
- 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)
|
- Existing Phase 1 input tests still pass (insert mode behavior unchanged)
|
||||||
|
|
||||||
## Phase 3: Tool Execution
|
## Phase 3: Tool Execution
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ impl ConversationHistory {
|
||||||
pub fn messages(&self) -> &[ConversationMessage] {
|
pub fn messages(&self) -> &[ConversationMessage] {
|
||||||
&self.messages
|
&self.messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove all messages from the history.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.messages.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ConversationHistory {
|
impl Default for ConversationHistory {
|
||||||
|
|
@ -73,6 +78,17 @@ mod tests {
|
||||||
assert_eq!(msgs[1].content, "hi there");
|
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]
|
#[test]
|
||||||
fn messages_preserves_insertion_order() {
|
fn messages_preserves_insertion_order() {
|
||||||
let mut history = ConversationHistory::new();
|
let mut history = ConversationHistory::new();
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,9 @@ impl<P: ModelProvider> Orchestrator<P> {
|
||||||
while let Some(action) = self.action_rx.recv().await {
|
while let Some(action) = self.action_rx.recv().await {
|
||||||
match action {
|
match action {
|
||||||
UserAction::Quit => break,
|
UserAction::Quit => break,
|
||||||
|
UserAction::ClearHistory => {
|
||||||
|
self.history.clear();
|
||||||
|
}
|
||||||
|
|
||||||
UserAction::SendMessage(text) => {
|
UserAction::SendMessage(text) => {
|
||||||
// Push the user message before snapshotting, so providers
|
// Push the user message before snapshotting, so providers
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ pub enum UserAction {
|
||||||
SendMessage(String),
|
SendMessage(String),
|
||||||
/// The user has requested to quit.
|
/// The user has requested to quit.
|
||||||
Quit,
|
Quit,
|
||||||
|
/// The user has requested to clear conversation history.
|
||||||
|
ClearHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An event sent from the core orchestrator to the TUI.
|
/// An event sent from the core orchestrator to the TUI.
|
||||||
|
|
|
||||||
348
src/tui/mod.rs
348
src/tui/mod.rs
|
|
@ -20,7 +20,7 @@ use crossterm::terminal::{
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Color, 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, Paragraph, Wrap};
|
||||||
use ratatui::{Frame, Terminal};
|
use ratatui::{Frame, Terminal};
|
||||||
|
|
@ -70,6 +70,8 @@ pub struct AppState {
|
||||||
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`.
|
||||||
pub viewport_height: u16,
|
pub viewport_height: u16,
|
||||||
|
/// Transient error message shown in the status bar, cleared on next keypress.
|
||||||
|
pub status_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -81,6 +83,7 @@ impl AppState {
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
pending_keys: Vec::new(),
|
pending_keys: Vec::new(),
|
||||||
viewport_height: 0,
|
viewport_height: 0,
|
||||||
|
status_error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +128,8 @@ enum LoopControl {
|
||||||
SendMessage(String),
|
SendMessage(String),
|
||||||
/// The user pressed Ctrl+C or Ctrl+D; exit the event loop.
|
/// The user pressed Ctrl+C or Ctrl+D; exit the event loop.
|
||||||
Quit,
|
Quit,
|
||||||
|
/// The user ran `:clear`; wipe the conversation.
|
||||||
|
ClearHistory,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect.
|
/// 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`].
|
/// handler for the current [`Mode`].
|
||||||
fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
|
fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
|
||||||
let key = key?;
|
let key = key?;
|
||||||
|
// Clear any transient status error on the next keypress.
|
||||||
|
state.status_error = None;
|
||||||
// Ctrl+C quits from any mode.
|
// Ctrl+C quits from any mode.
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||||
return Some(LoopControl::Quit);
|
return Some(LoopControl::Quit);
|
||||||
|
|
@ -269,6 +276,28 @@ fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<LoopControl> {
|
||||||
|
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).
|
/// Handle a keypress in Command mode (ex-style `:` command entry).
|
||||||
///
|
///
|
||||||
/// | Key | Effect |
|
/// | Key | Effect |
|
||||||
|
|
@ -289,14 +318,11 @@ fn handle_command_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl
|
||||||
// Already set to Normal above.
|
// Already set to Normal above.
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
// Command execution deferred to phase 2.3; just return to Normal.
|
return execute_command(&buf, state);
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
buf.pop();
|
buf.pop();
|
||||||
if !buf.is_empty() {
|
state.mode = Mode::Command(buf);
|
||||||
state.mode = Mode::Command(buf);
|
|
||||||
}
|
|
||||||
// else stay in Normal (already set)
|
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
buf.push(c);
|
buf.push(c);
|
||||||
|
|
@ -351,8 +377,8 @@ fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mut AppState)
|
||||||
/// - `ceil(chars / width).max(1)` lines per newline-separated content line
|
/// - `ceil(chars / width).max(1)` lines per newline-separated content line
|
||||||
/// - 1 blank separator line
|
/// - 1 blank separator line
|
||||||
fn update_scroll(state: &mut AppState, area: Rect) {
|
fn update_scroll(state: &mut AppState, area: Rect) {
|
||||||
// 3 = height of the input pane (border top + content + border bottom)
|
// 4 = input pane (3: border top + content + border bottom) + status bar (1)
|
||||||
let viewport_height = area.height.saturating_sub(3);
|
let viewport_height = area.height.saturating_sub(4);
|
||||||
state.viewport_height = viewport_height;
|
state.viewport_height = viewport_height;
|
||||||
let width = area.width.max(1) as usize;
|
let width = area.width.max(1) as usize;
|
||||||
|
|
||||||
|
|
@ -392,11 +418,19 @@ fn update_scroll(state: &mut AppState, area: Rect) {
|
||||||
/// | Input | Length(3)
|
/// | Input | Length(3)
|
||||||
/// | > _ |
|
/// | > _ |
|
||||||
/// +--------------------------------+
|
/// +--------------------------------+
|
||||||
|
/// | NORMAL tokens: -- | Length(1)
|
||||||
|
/// +--------------------------------+
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// Role headers are coloured: `"You:"` in cyan, `"Assistant:"` in green.
|
/// 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) {
|
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 ---
|
// --- Output pane ---
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
@ -417,10 +451,108 @@ fn render(frame: &mut Frame, state: &AppState) {
|
||||||
.scroll((state.scroll, 0));
|
.scroll((state.scroll, 0));
|
||||||
frame.render_widget(output, chunks[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 ---
|
// --- 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_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]);
|
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.
|
/// Run the TUI event loop.
|
||||||
|
|
@ -482,6 +614,9 @@ pub async fn run(
|
||||||
let _ = action_tx.send(UserAction::Quit).await;
|
let _ = action_tx.send(UserAction::Quit).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
Some(LoopControl::ClearHistory) => {
|
||||||
|
let _ = action_tx.send(UserAction::ClearHistory).await;
|
||||||
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -694,11 +829,17 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_backspace_empty_exits() {
|
fn command_backspace_empty_stays_in_command() {
|
||||||
let mut state = AppState::new();
|
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);
|
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]
|
#[test]
|
||||||
|
|
@ -804,4 +945,185 @@ mod tests {
|
||||||
handle_key(ctrl_key('u'), &mut state);
|
handle_key(ctrl_key('u'), &mut state);
|
||||||
assert_eq!(state.scroll, 0);
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue