Add tool use without sandboxing. Currently available tools are list dir, read file, write file and exec bash. Reviewed-on: #4 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
534 lines
18 KiB
Rust
534 lines
18 KiB
Rust
//! Keyboard input handling: vim modes, chords, and command execution.
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
use super::{AppState, Mode};
|
|
use crate::core::types::Role;
|
|
|
|
/// Internal control flow signal returned by [`handle_key`].
|
|
pub(super) enum LoopControl {
|
|
/// The user pressed Enter with non-empty input; send this message to the core.
|
|
SendMessage(String),
|
|
/// The user pressed Ctrl+C or Ctrl+D; exit the event loop.
|
|
Quit,
|
|
/// The user ran `:clear`; wipe the conversation.
|
|
ClearHistory,
|
|
/// The user responded to a tool approval prompt.
|
|
ToolApproval { tool_use_id: String, approved: bool },
|
|
}
|
|
|
|
/// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect.
|
|
///
|
|
/// Ctrl+C / Ctrl+D quit from any mode. All other keys are dispatched to the
|
|
/// handler for the current [`Mode`].
|
|
pub(super) fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
|
|
let key = key?;
|
|
// Clear any transient status error on the next keypress.
|
|
state.status_error = None;
|
|
|
|
// If a tool approval is pending, intercept y/n before normal key handling.
|
|
if let Some(approval) = &state.pending_approval {
|
|
let tool_use_id = approval.tool_use_id.clone();
|
|
match key.code {
|
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
|
state.pending_approval = None;
|
|
return Some(LoopControl::ToolApproval {
|
|
tool_use_id,
|
|
approved: true,
|
|
});
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Char('N') => {
|
|
state.pending_approval = None;
|
|
return Some(LoopControl::ToolApproval {
|
|
tool_use_id,
|
|
approved: false,
|
|
});
|
|
}
|
|
_ => return None, // ignore other keys while approval pending
|
|
}
|
|
}
|
|
|
|
// 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.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);
|
|
}
|
|
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 |
|
|
/// |------------------|-------------------------------------------------|
|
|
/// | 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 |
|
|
fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
state.mode = Mode::Normal;
|
|
None
|
|
}
|
|
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
state.input.push(c);
|
|
None
|
|
}
|
|
KeyCode::Backspace => {
|
|
state.input.pop();
|
|
None
|
|
}
|
|
KeyCode::Enter => {
|
|
let msg = std::mem::take(&mut state.input);
|
|
if msg.is_empty() {
|
|
None
|
|
} else {
|
|
state.messages.push((Role::User, msg.clone()));
|
|
Some(LoopControl::SendMessage(msg))
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// | 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> {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
state.mode = Mode::Normal;
|
|
}
|
|
KeyCode::Enter => {
|
|
state.mode = Mode::Normal;
|
|
return execute_command(&state.command_buffer.clone(), state);
|
|
}
|
|
KeyCode::Backspace => {
|
|
state.command_buffer.pop();
|
|
}
|
|
KeyCode::Char(c) => {
|
|
state.command_buffer.push(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_key(code: KeyCode) -> Option<KeyEvent> {
|
|
Some(KeyEvent::new(code, KeyModifiers::empty()))
|
|
}
|
|
|
|
fn ctrl_key(c: char) -> Option<KeyEvent> {
|
|
Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
|
|
}
|
|
|
|
// --- handle_key tests ---
|
|
|
|
#[test]
|
|
fn handle_key_printable_appends() {
|
|
let mut state = AppState::new();
|
|
handle_key(make_key(KeyCode::Char('h')), &mut state);
|
|
assert_eq!(state.input, "h");
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_backspace_pops() {
|
|
let mut state = AppState::new();
|
|
state.input = "ab".to_string();
|
|
handle_key(make_key(KeyCode::Backspace), &mut state);
|
|
assert_eq!(state.input, "a");
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_backspace_empty_noop() {
|
|
let mut state = AppState::new();
|
|
handle_key(make_key(KeyCode::Backspace), &mut state);
|
|
assert_eq!(state.input, "");
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_enter_empty_noop() {
|
|
let mut state = AppState::new();
|
|
let result = handle_key(make_key(KeyCode::Enter), &mut state);
|
|
assert!(result.is_none());
|
|
assert!(state.messages.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_enter_sends_and_clears() {
|
|
let mut state = AppState::new();
|
|
state.input = "hello".to_string();
|
|
let result = handle_key(make_key(KeyCode::Enter), &mut state);
|
|
assert!(state.input.is_empty());
|
|
assert_eq!(state.messages.len(), 1);
|
|
assert!(matches!(result, Some(LoopControl::SendMessage(ref m)) if m == "hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn handle_key_ctrl_c_quits() {
|
|
let mut state = AppState::new();
|
|
let result = handle_key(ctrl_key('c'), &mut state);
|
|
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'");
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
#[test]
|
|
fn command_esc_enters_normal() {
|
|
let mut state = AppState::new();
|
|
state.mode = Mode::Command;
|
|
state.command_buffer = "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;
|
|
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;
|
|
handle_key(make_key(KeyCode::Char('q')), &mut state);
|
|
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;
|
|
state.command_buffer = "ab".to_string();
|
|
handle_key(make_key(KeyCode::Backspace), &mut state);
|
|
assert_eq!(state.command_buffer, "a");
|
|
handle_key(make_key(KeyCode::Backspace), &mut state);
|
|
assert_eq!(state.command_buffer, "");
|
|
handle_key(make_key(KeyCode::Backspace), &mut state);
|
|
assert_eq!(state.command_buffer, "");
|
|
handle_key(make_key(KeyCode::Backspace), &mut state);
|
|
assert_eq!(state.command_buffer, "");
|
|
}
|
|
|
|
#[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] {
|
|
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);
|
|
}
|
|
|
|
// --- 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;
|
|
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);
|
|
}
|
|
}
|