Add mode switching.
This commit is contained in:
parent
5d213b43d3
commit
5cb6647513
3 changed files with 394 additions and 86 deletions
5
IDEAS.md
5
IDEAS.md
|
|
@ -42,6 +42,9 @@ Notes based on ideas I've had.
|
||||||
- JSONL `SessionWriter` with `Event` structure
|
- JSONL `SessionWriter` with `Event` structure
|
||||||
- Parent IDs, timestamps, token usage per event
|
- Parent IDs, timestamps, token usage per event
|
||||||
- Predictable file location with session IDs
|
- Predictable file location with session IDs
|
||||||
|
- Ability to resume prior session.
|
||||||
- **Done when:** Session files are coherent, parseable, with token counts per turn
|
- **Done when:** Session files are coherent, parseable, with token counts per turn
|
||||||
|
|
||||||
|
## Which-key like help
|
||||||
|
- Show command chording in normal mode.
|
||||||
|
- Help window slides up from bottom of screen.
|
||||||
|
|
|
||||||
102
PLAN.md
102
PLAN.md
|
|
@ -1,77 +1,40 @@
|
||||||
# Implementation Plan
|
# Implementation Plan
|
||||||
|
|
||||||
## Phase 1: Minimal Conversation Loop
|
|
||||||
|
|
||||||
**Done when:** Multi-turn streaming conversation with Claude works in terminal
|
|
||||||
|
|
||||||
### 1.1 Project Scaffolding
|
|
||||||
- `Cargo.toml` with initial dependencies:
|
|
||||||
- `ratatui`, `crossterm` — TUI
|
|
||||||
- `tokio` (full features) — async runtime
|
|
||||||
- `serde`, `serde_json` — serialization
|
|
||||||
- `thiserror` — error types
|
|
||||||
- `tracing`, `tracing-subscriber` — structured logging
|
|
||||||
- `reqwest` (with `stream` feature) — HTTP client for SSE
|
|
||||||
- `futures` — stream combinators
|
|
||||||
- Establish `src/{app,tui,core,provider}/mod.rs` stubs
|
|
||||||
- `cargo build` passes; `cargo clippy -- -D warnings` passes on empty stubs
|
|
||||||
|
|
||||||
### 1.2 Shared Types (`src/core/types.rs`)
|
|
||||||
- `StreamEvent` enum: `TextDelta(String)`, `InputTokens(u32)`, `OutputTokens(u32)`, `Done`, `Error(String)`
|
|
||||||
- `UserAction` enum (TUI → core channel): `SendMessage(String)`, `Quit`
|
|
||||||
- `UIEvent` enum (core → TUI channel): `StreamDelta(String)`, `TurnComplete`, `Error(String)`
|
|
||||||
- `ConversationMessage` struct: `role: Role`, `content: String`
|
|
||||||
- All types derive `Debug`; all public types have doc comments
|
|
||||||
|
|
||||||
### 1.3 Provider: `ModelProvider` Trait + Claude SSE (`src/provider/`)
|
|
||||||
- `ModelProvider` trait: `async fn stream(&self, messages: &[ConversationMessage]) -> impl Stream<Item = StreamEvent>`
|
|
||||||
- `ClaudeProvider` struct: API key from env, `reqwest` HTTP client
|
|
||||||
- Serialize messages to Anthropic Messages API JSON format
|
|
||||||
- Parse SSE byte stream → `StreamEvent` (handle `content_block_delta`, `message_delta` for tokens, `message_stop`)
|
|
||||||
- Unit tests: SSE parsing from hardcoded byte fixtures in `#[cfg(test)]`
|
|
||||||
|
|
||||||
### 1.4 Core: Conversation State + Orchestrator Loop (`src/core/`)
|
|
||||||
- `ConversationHistory`: `Vec<ConversationMessage>` with `push` and `messages()` (flat list, no tree yet)
|
|
||||||
- `Orchestrator` struct holding history, provider, channel senders/receivers
|
|
||||||
- Orchestrator loop:
|
|
||||||
1. Await `UserAction` from TUI channel
|
|
||||||
2. On `SendMessage`: append user message, call `provider.stream()`
|
|
||||||
3. Forward each `StreamEvent` as `UIEvent` to TUI
|
|
||||||
4. Accumulate deltas into assistant message; append to history on `Done`
|
|
||||||
5. On `Quit`: break loop
|
|
||||||
|
|
||||||
### 1.5 TUI: Layout + Input + Streaming Display (`src/tui/`)
|
|
||||||
- `AppState` struct: `messages: Vec<(Role, String)>`, `input: String`, `scroll: u16`
|
|
||||||
- Ratatui layout: full-height `Paragraph` output area (scrollable) + single-line `Paragraph` input
|
|
||||||
- Insert mode only — printable chars append to `input`, Enter sends `UserAction::SendMessage`, Backspace deletes
|
|
||||||
- On `UIEvent::StreamDelta`: append to last assistant message in `messages`, re-render
|
|
||||||
- On `UIEvent::TurnComplete`: finalize assistant message
|
|
||||||
- Crossterm raw mode enter/exit; restore terminal on panic or clean exit
|
|
||||||
|
|
||||||
### 1.6 App Wiring + Entry Point (`src/app/`, `src/main.rs`)
|
|
||||||
- `main.rs`: parse `--project-dir <path>` CLI arg
|
|
||||||
- Initialize `tracing_subscriber` (log to file, not stdout — avoids TUI interference)
|
|
||||||
- Create `tokio::sync::mpsc` channel pair for `UserAction` and `UIEvent`
|
|
||||||
- Spawn `Orchestrator::run()` as a tokio task
|
|
||||||
- Run TUI event loop on main thread (Ratatui requires main thread for crossterm)
|
|
||||||
- On `UserAction::Quit` or Ctrl-C: signal orchestrator shutdown, restore terminal, exit cleanly
|
|
||||||
|
|
||||||
### 1.7 Phase 1 Unit Tests
|
|
||||||
- Provider: SSE byte fixture → correct `StreamEvent` sequence
|
|
||||||
- Provider: `ConversationMessage` vec → correct Anthropic API JSON shape
|
|
||||||
- Core: `ConversationHistory` push/read roundtrip
|
|
||||||
- Core: Orchestrator state transitions against mock `StreamEvent` sequence (no real API)
|
|
||||||
|
|
||||||
## Phase 2: Vim Modes and Navigation
|
## Phase 2: Vim Modes and Navigation
|
||||||
- Normal, Insert, Command modes with visual indicator
|
|
||||||
- `j`/`k` scroll in Normal mode
|
**Done when:** Fluid mode switching and scrolling feels vim-native
|
||||||
- `:quit`, `:clear` commands
|
|
||||||
- **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
|
||||||
|
- `insta` snapshot tests: mode indicator rendering for each mode, layout with status bar
|
||||||
|
- 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
|
||||||
- Approval gate: core → TUI pending event → user approve/deny → result back
|
- Approval gate: core -> TUI pending event -> user approve/deny -> result back
|
||||||
- Working directory confinement + path validation (no Landlock yet)
|
- Working directory confinement + path validation (no Landlock yet)
|
||||||
- **Done when:** Claude can read, modify files, and run commands with user approval
|
- **Done when:** Claude can read, modify files, and run commands with user approval
|
||||||
|
|
||||||
|
|
@ -81,8 +44,3 @@
|
||||||
- `:net on/off` toggle, state in status bar
|
- `:net on/off` toggle, state in status bar
|
||||||
- Graceful degradation on older kernels
|
- Graceful degradation on older kernels
|
||||||
- **Done when:** Writes outside project dir fail; network toggle works
|
- **Done when:** Writes outside project dir fail; network toggle works
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
373
src/tui/mod.rs
373
src/tui/mod.rs
|
|
@ -37,6 +37,25 @@ pub enum TuiError {
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vim-style editing mode for the TUI.
|
||||||
|
///
|
||||||
|
/// The TUI starts in Insert mode so first-time users can type immediately.
|
||||||
|
/// Mode transitions:
|
||||||
|
/// Insert --Esc--> Normal
|
||||||
|
/// Normal --i--> Insert
|
||||||
|
/// Normal --:--> Command(buffer)
|
||||||
|
/// Command --Esc/Enter--> Normal
|
||||||
|
#[derive(Debug, Clone, 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),
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
/// All conversation turns rendered as (role, content) pairs.
|
/// All conversation turns rendered as (role, content) pairs.
|
||||||
|
|
@ -45,6 +64,12 @@ pub struct AppState {
|
||||||
pub input: String,
|
pub input: String,
|
||||||
/// Vertical scroll offset for the output pane (lines from top).
|
/// Vertical scroll offset for the output pane (lines from top).
|
||||||
pub scroll: u16,
|
pub scroll: u16,
|
||||||
|
/// Current vim-style editing mode.
|
||||||
|
pub mode: Mode,
|
||||||
|
/// 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`.
|
||||||
|
pub viewport_height: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -53,6 +78,9 @@ impl AppState {
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
|
mode: Mode::Insert,
|
||||||
|
pending_keys: Vec::new(),
|
||||||
|
viewport_height: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,25 +129,126 @@ enum LoopControl {
|
||||||
|
|
||||||
/// 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.
|
||||||
///
|
///
|
||||||
/// Returns `None` when the key is consumed with no further loop-level action needed.
|
/// Ctrl+C / Ctrl+D quit from any mode. All other keys are dispatched to the
|
||||||
|
/// handler for the current [`Mode`].
|
||||||
|
fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
|
||||||
|
let key = key?;
|
||||||
|
// Ctrl+C quits from any mode.
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||||
|
return Some(LoopControl::Quit);
|
||||||
|
}
|
||||||
|
// Ctrl+D quits only from Insert/Command mode. In Normal mode it scrolls.
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& key.code == KeyCode::Char('d')
|
||||||
|
&& !matches!(state.mode, Mode::Normal)
|
||||||
|
{
|
||||||
|
return Some(LoopControl::Quit);
|
||||||
|
}
|
||||||
|
match &state.mode {
|
||||||
|
Mode::Normal => handle_normal_key(key, state),
|
||||||
|
Mode::Insert => handle_insert_key(key, state),
|
||||||
|
Mode::Command(_) => handle_command_key(key, state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChordResult {
|
||||||
|
/// The chord was fully matched; the returned closure applies the effect.
|
||||||
|
Executed,
|
||||||
|
/// The keys so far are a valid prefix of at least one chord.
|
||||||
|
Prefix,
|
||||||
|
/// The keys don't match any chord or prefix.
|
||||||
|
NoMatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check `keys` against known multi-key chords in Normal mode.
|
||||||
|
///
|
||||||
|
/// Currently only `gg` (scroll to top) is defined. Returns the match status
|
||||||
|
/// without mutating state -- the caller applies effects for `Executed`.
|
||||||
|
fn resolve_chord(keys: &[char]) -> ChordResult {
|
||||||
|
match keys {
|
||||||
|
[.., 'g', 'g'] => ChordResult::Executed,
|
||||||
|
[.., 'g'] => ChordResult::Prefix,
|
||||||
|
_ => ChordResult::NoMatch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a keypress in Normal mode.
|
||||||
|
///
|
||||||
|
/// | Key | Effect |
|
||||||
|
/// |-----|-------------------------------------------|
|
||||||
|
/// | `i` | Switch to Insert mode |
|
||||||
|
/// | `:` | Switch to Command mode (empty buffer) |
|
||||||
|
/// | `j` | Scroll down one line |
|
||||||
|
/// | `k` | Scroll up one line |
|
||||||
|
/// | `G` | Scroll to bottom |
|
||||||
|
/// | `g` | Begin chord; `gg` scrolls to top |
|
||||||
|
/// | Ctrl+d | Scroll down half a page |
|
||||||
|
/// | Ctrl+u | Scroll up half a page |
|
||||||
|
fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
|
||||||
|
let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('d') if is_ctrl => {
|
||||||
|
let half = (state.viewport_height / 2).max(1);
|
||||||
|
state.scroll = state.scroll.saturating_add(half);
|
||||||
|
state.pending_keys.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Char('u') if is_ctrl => {
|
||||||
|
let half = (state.viewport_height / 2).max(1);
|
||||||
|
state.scroll = state.scroll.saturating_sub(half);
|
||||||
|
state.pending_keys.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Char('i') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||||
|
state.mode = Mode::Insert;
|
||||||
|
}
|
||||||
|
KeyCode::Char(':') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||||
|
state.mode = Mode::Command(String::new());
|
||||||
|
}
|
||||||
|
KeyCode::Char('j') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||||
|
state.scroll = state.scroll.saturating_add(1);
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||||
|
state.scroll = state.scroll.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Char('G') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||||
|
state.scroll = u16::MAX;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
state.pending_keys.push(c);
|
||||||
|
match resolve_chord(&state.pending_keys) {
|
||||||
|
ChordResult::Executed => {
|
||||||
|
// Apply the chord effect. Currently only `gg`.
|
||||||
|
state.scroll = 0;
|
||||||
|
state.pending_keys.clear();
|
||||||
|
}
|
||||||
|
ChordResult::NoMatch => {
|
||||||
|
state.pending_keys.clear();
|
||||||
|
}
|
||||||
|
ChordResult::Prefix => {} // wait for more keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.pending_keys.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a keypress in Insert mode (the default text-entry mode).
|
||||||
///
|
///
|
||||||
/// | Key | Effect |
|
/// | Key | Effect |
|
||||||
/// |------------------|-------------------------------------------------|
|
/// |------------------|-------------------------------------------------|
|
||||||
/// | Printable (no CTRL) | `state.input.push(c)` |
|
/// | Esc | Switch to Normal mode |
|
||||||
/// | Backspace | `state.input.pop()` |
|
/// | Printable char | Append to input buffer |
|
||||||
|
/// | Backspace | Pop last char from input buffer |
|
||||||
/// | Enter (non-empty)| Take input, push User message, return `SendMessage` |
|
/// | Enter (non-empty)| Take input, push User message, return `SendMessage` |
|
||||||
/// | Enter (empty) | No-op |
|
/// | Enter (empty) | No-op |
|
||||||
/// | Ctrl+C / Ctrl+D | Return `Quit` |
|
fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
|
||||||
fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
|
|
||||||
let key = key?;
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Esc => {
|
||||||
Some(LoopControl::Quit)
|
state.mode = Mode::Normal;
|
||||||
|
None
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char(c) => {
|
||||||
Some(LoopControl::Quit)
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
||||||
state.input.push(c);
|
state.input.push(c);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +269,46 @@ fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle a keypress in Command mode (ex-style `:` command entry).
|
||||||
|
///
|
||||||
|
/// | Key | Effect |
|
||||||
|
/// |-----------|-----------------------------------------------------|
|
||||||
|
/// | Esc | Abandon command, return to Normal |
|
||||||
|
/// | Enter | Accept command (execution deferred to 2.3), Normal |
|
||||||
|
/// | 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.
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Command execution deferred to phase 2.3; just return to Normal.
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
buf.pop();
|
||||||
|
if !buf.is_empty() {
|
||||||
|
state.mode = Mode::Command(buf);
|
||||||
|
}
|
||||||
|
// else stay in Normal (already set)
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
buf.push(c);
|
||||||
|
state.mode = Mode::Command(buf);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
state.mode = Mode::Command(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain all pending [`UIEvent`]s from `event_rx` and apply them to `state`.
|
/// Drain all pending [`UIEvent`]s from `event_rx` and apply them to `state`.
|
||||||
///
|
///
|
||||||
/// This is non-blocking: it processes all currently-available events and returns
|
/// This is non-blocking: it processes all currently-available events and returns
|
||||||
|
|
@ -184,6 +353,7 @@ fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mut AppState)
|
||||||
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)
|
// 3 = height of the input pane (border top + content + border bottom)
|
||||||
let viewport_height = area.height.saturating_sub(3);
|
let viewport_height = area.height.saturating_sub(3);
|
||||||
|
state.viewport_height = viewport_height;
|
||||||
let width = area.width.max(1) as usize;
|
let width = area.width.max(1) as usize;
|
||||||
|
|
||||||
let mut total_lines: u16 = 0;
|
let mut total_lines: u16 = 0;
|
||||||
|
|
@ -197,7 +367,18 @@ fn update_scroll(state: &mut AppState, area: Rect) {
|
||||||
total_lines = total_lines.saturating_add(1); // blank separator
|
total_lines = total_lines.saturating_add(1); // blank separator
|
||||||
}
|
}
|
||||||
|
|
||||||
state.scroll = total_lines.saturating_sub(viewport_height);
|
let max_scroll = total_lines.saturating_sub(viewport_height);
|
||||||
|
|
||||||
|
match state.mode {
|
||||||
|
// In Insert mode, auto-follow the bottom of the conversation.
|
||||||
|
Mode::Insert => {
|
||||||
|
state.scroll = max_scroll;
|
||||||
|
}
|
||||||
|
// In Normal/Command mode, the user controls scroll -- just clamp to bounds.
|
||||||
|
_ => {
|
||||||
|
state.scroll = state.scroll.min(max_scroll);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the full TUI into `frame`.
|
/// Render the full TUI into `frame`.
|
||||||
|
|
@ -457,4 +638,170 @@ mod tests {
|
||||||
update_scroll(&mut state, area);
|
update_scroll(&mut state, area);
|
||||||
assert!(state.scroll > 0, "expected scroll > 0 with 50 messages");
|
assert!(state.scroll > 0, "expected scroll > 0 with 50 messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- mode tests ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mode_starts_insert() {
|
||||||
|
assert_eq!(AppState::new().mode, Mode::Insert);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_esc_enters_normal() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
handle_key(make_key(KeyCode::Esc), &mut state);
|
||||||
|
assert_eq!(state.mode, Mode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_i_enters_insert() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
handle_key(make_key(KeyCode::Char('i')), &mut state);
|
||||||
|
assert_eq!(state.mode, Mode::Insert);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_colon_enters_command() {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_esc_enters_normal() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Command("q".to_string());
|
||||||
|
handle_key(make_key(KeyCode::Esc), &mut state);
|
||||||
|
assert_eq!(state.mode, Mode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_enter_enters_normal() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Command("q".to_string());
|
||||||
|
handle_key(make_key(KeyCode::Enter), &mut state);
|
||||||
|
assert_eq!(state.mode, Mode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_chars_append_to_buffer() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Command(String::new());
|
||||||
|
handle_key(make_key(KeyCode::Char('q')), &mut state);
|
||||||
|
assert_eq!(state.mode, Mode::Command("q".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_backspace_empty_exits() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Command(String::new());
|
||||||
|
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||||
|
assert_eq!(state.mode, Mode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_j_increments_scroll() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
handle_key(make_key(KeyCode::Char('j')), &mut state);
|
||||||
|
assert_eq!(state.scroll, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_k_decrements_scroll() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
state.scroll = 5;
|
||||||
|
handle_key(make_key(KeyCode::Char('k')), &mut state);
|
||||||
|
assert_eq!(state.scroll, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_k_clamps_at_zero() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
handle_key(make_key(KeyCode::Char('k')), &mut state);
|
||||||
|
assert_eq!(state.scroll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_big_g_scrolls_bottom() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
handle_key(make_key(KeyCode::Char('G')), &mut state);
|
||||||
|
assert_eq!(state.scroll, u16::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_gg_scrolls_top() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
state.scroll = 50;
|
||||||
|
handle_key(make_key(KeyCode::Char('g')), &mut state);
|
||||||
|
assert_eq!(state.pending_keys, vec!['g']); // prefix, waiting
|
||||||
|
handle_key(make_key(KeyCode::Char('g')), &mut state);
|
||||||
|
assert_eq!(state.scroll, 0);
|
||||||
|
assert!(state.pending_keys.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_g_then_other_clears_pending() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
state.scroll = 50;
|
||||||
|
handle_key(make_key(KeyCode::Char('g')), &mut state);
|
||||||
|
handle_key(make_key(KeyCode::Char('x')), &mut state);
|
||||||
|
assert!(state.pending_keys.is_empty());
|
||||||
|
assert_eq!(state.scroll, 50); // unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_c_quits_from_any_mode() {
|
||||||
|
for mode in [Mode::Normal, Mode::Insert, Mode::Command("q".to_string())] {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = mode;
|
||||||
|
let result = handle_key(ctrl_key('c'), &mut state);
|
||||||
|
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_d_quits_from_insert() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
let result = handle_key(ctrl_key('d'), &mut state);
|
||||||
|
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_d_scrolls_in_normal() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
state.viewport_height = 20;
|
||||||
|
let result = handle_key(ctrl_key('d'), &mut state);
|
||||||
|
assert!(result.is_none());
|
||||||
|
assert_eq!(state.scroll, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_u_scrolls_up_in_normal() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
state.viewport_height = 20;
|
||||||
|
state.scroll = 15;
|
||||||
|
let result = handle_key(ctrl_key('u'), &mut state);
|
||||||
|
assert!(result.is_none());
|
||||||
|
assert_eq!(state.scroll, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_u_clamps_at_zero() {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.mode = Mode::Normal;
|
||||||
|
state.viewport_height = 20;
|
||||||
|
state.scroll = 3;
|
||||||
|
handle_key(ctrl_key('u'), &mut state);
|
||||||
|
assert_eq!(state.scroll, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue