diff --git a/IDEAS.md b/IDEAS.md index 0c3c97f..9d3dbb7 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -42,6 +42,9 @@ Notes based on ideas I've had. - JSONL `SessionWriter` with `Event` structure - Parent IDs, timestamps, token usage per event - Predictable file location with session IDs +- Ability to resume prior session. - **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. diff --git a/PLAN.md b/PLAN.md index 0e3a1f2..9b3f99c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,77 +1,40 @@ # 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` -- `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` 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 ` 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 -- Normal, Insert, Command modes with visual indicator -- `j`/`k` scroll in Normal mode -- `:quit`, `:clear` commands -- **Done when:** Fluid mode switching and scrolling feels vim-native + +**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 - `Tool` trait, `ToolRegistry`, core tools (`read_file`, `write_file`, `shell_exec`) - 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) - **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 - Graceful degradation on older kernels - **Done when:** Writes outside project dir fail; network toggle works - - - - - diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 53332c8..d8a3f86 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -37,6 +37,25 @@ pub enum TuiError { 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. pub struct AppState { /// All conversation turns rendered as (role, content) pairs. @@ -45,6 +64,12 @@ pub struct AppState { pub input: String, /// Vertical scroll offset for the output pane (lines from top). 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, + /// Last-known viewport height (output pane lines). Updated each frame by `update_scroll`. + pub viewport_height: u16, } impl AppState { @@ -53,6 +78,9 @@ impl AppState { messages: Vec::new(), input: String::new(), 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. /// -/// 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, state: &mut AppState) -> Option { + 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 { + 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 | /// |------------------|-------------------------------------------------| -/// | Printable (no CTRL) | `state.input.push(c)` | -/// | Backspace | `state.input.pop()` | +/// | Esc | Switch to Normal mode | +/// | Printable char | Append to input buffer | +/// | Backspace | Pop last char from input buffer | /// | Enter (non-empty)| Take input, push User message, return `SendMessage` | /// | Enter (empty) | No-op | -/// | Ctrl+C / Ctrl+D | Return `Quit` | -fn handle_key(key: Option, state: &mut AppState) -> Option { - let key = key?; +fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option { match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Some(LoopControl::Quit) + KeyCode::Esc => { + state.mode = Mode::Normal; + None } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - Some(LoopControl::Quit) - } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char(c) => { state.input.push(c); None } @@ -140,6 +269,46 @@ fn handle_key(key: Option, state: &mut AppState) -> Option Option { + // 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`. /// /// 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, state: &mut AppState) fn update_scroll(state: &mut AppState, area: Rect) { // 3 = height of the input pane (border top + content + border bottom) let viewport_height = area.height.saturating_sub(3); + state.viewport_height = viewport_height; let width = area.width.max(1) as usize; 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 } - 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`. @@ -457,4 +638,170 @@ mod tests { update_scroll(&mut state, area); 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); + } }