collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use anyhow::Result;
use crossterm::event::{
    self, Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use std::time::Duration;
use tokio::sync::mpsc;

use crate::agent::r#loop::AgentEvent;

/// All events the TUI event loop handles.
pub enum AppEvent {
    /// Keyboard input.
    Key(KeyEvent),
    /// Mouse input.
    Mouse(MouseEvent),
    /// Bracketed paste — full text inserted without triggering Submit per line.
    Paste(String),
    /// Agent event from the background task.
    Agent(Box<AgentEvent>),
    /// Tick for periodic UI refresh.
    Tick,
}

/// Spawn a dedicated OS thread that reads crossterm events and forwards them
/// through a channel to the async event loop.
///
/// Using a separate thread avoids calling blocking syscalls (`poll(2)` /
/// `select(2)`) on the tokio worker thread, which can stall the entire runtime
/// and cause the TUI to freeze — especially while the agent is streaming.
/// Channel capacity for the crossterm event bridge.  Large enough that normal
/// typing / scrolling never drops, small enough to bound memory under
/// pathological paste storms.
const CROSSTERM_CHANNEL_CAP: usize = 1024;

pub fn spawn_crossterm_thread() -> mpsc::Receiver<AppEvent> {
    let (tx, rx) = mpsc::channel(CROSSTERM_CHANNEL_CAP);
    std::thread::spawn(move || {
        loop {
            // Poll with a short timeout so the thread can detect when the
            // receiver is dropped (TUI exit) without blocking forever.
            match event::poll(Duration::from_millis(50)) {
                Ok(true) => match event::read() {
                    Ok(Event::Key(k)) => {
                        // blocking_send is fine — this is a dedicated OS thread.
                        if tx.blocking_send(AppEvent::Key(k)).is_err() {
                            break;
                        }
                    }
                    Ok(Event::Mouse(m)) => {
                        // Mouse events are cheap; drop silently if backlogged.
                        let _ = tx.try_send(AppEvent::Mouse(m));
                        if tx.is_closed() {
                            break;
                        }
                    }
                    Ok(Event::Paste(t)) => {
                        // Truncate extremely large pastes to prevent OOM.
                        const MAX_PASTE_BYTES: usize = 2 * 1024 * 1024; // 2 MB
                        let t = if t.len() > MAX_PASTE_BYTES {
                            t[..MAX_PASTE_BYTES].to_string()
                        } else {
                            t
                        };
                        if tx.blocking_send(AppEvent::Paste(t)).is_err() {
                            break;
                        }
                    }
                    Ok(_) => {}
                    Err(_) => break,
                },
                Ok(false) => {
                    // Timeout — check if the receiver was dropped (TUI exited).
                    if tx.is_closed() {
                        break;
                    }
                }
                Err(_) => break,
            }
        }
    });
    rx
}

/// Merge keyboard, mouse, agent, and tick events into a single stream.
///
/// All channel receives are non-blocking awaits — no syscalls on the tokio
/// runtime thread.  Keyboard/mouse events are biased first so they are never
/// starved by a high-frequency agent streaming loop.
pub async fn next_event(
    key_rx: &mut mpsc::Receiver<AppEvent>,
    agent_rx: &mut mpsc::UnboundedReceiver<AgentEvent>,
) -> Result<AppEvent> {
    tokio::select! {
        biased; // keyboard/mouse checked before agent stream and tick

        Some(event) = key_rx.recv() => Ok(event),

        Some(agent_event) = agent_rx.recv() => Ok(AppEvent::Agent(Box::new(agent_event))),

        _ = tokio::time::sleep(Duration::from_millis(16)) => Ok(AppEvent::Tick),
    }
}

/// Interpret a key event into an action.
pub enum KeyAction {
    Char(char),
    Backspace,
    Delete,
    Submit,
    NewLine,
    Quit,
    ScrollUp,
    ScrollDown,
    HistoryUp,
    HistoryDown,
    NextAgent,
    PrevAgent,
    EscapePressed,
    // Cursor movement
    CursorLeft,
    CursorRight,
    CursorWordLeft,
    CursorWordRight,
    CursorLineStart,
    CursorLineEnd,
    // Deletion
    DeleteWordBack,
    DeleteToLineStart,
    DeleteToLineEnd,
    CycleApproveMode,
    None,
}

/// Interpret a mouse event into an action.
pub enum MouseAction {
    ScrollUp,
    ScrollDown,
    Click { column: u16, row: u16 },
    None,
}

pub fn interpret_key(key: KeyEvent) -> KeyAction {
    // ── Ctrl combinations ───────────────────────────────────────────────
    if key.modifiers.contains(KeyModifiers::CONTROL) {
        match key.code {
            KeyCode::Char('c' | 'd') => return KeyAction::Quit,
            KeyCode::Char('j') => return KeyAction::NewLine,
            // Readline / Emacs bindings
            KeyCode::Char('a') => return KeyAction::CursorLineStart,
            KeyCode::Char('e') => return KeyAction::CursorLineEnd,
            KeyCode::Char('w') => return KeyAction::DeleteWordBack,
            KeyCode::Char('u') => return KeyAction::DeleteToLineStart,
            KeyCode::Char('k') => return KeyAction::DeleteToLineEnd,
            KeyCode::Char('b') => return KeyAction::CursorLeft,
            KeyCode::Char('f') => return KeyAction::CursorRight,
            // Ctrl+Backspace: delete word back (Linux/Windows standard)
            KeyCode::Backspace => return KeyAction::DeleteWordBack,
            // Ctrl+Arrow: word movement
            KeyCode::Left => return KeyAction::CursorWordLeft,
            KeyCode::Right => return KeyAction::CursorWordRight,
            _ => return KeyAction::None,
        }
    }

    // ── Super/Cmd combinations (macOS Cmd key via kitty protocol / CSIu) ──
    // Terminals that support the Kitty keyboard protocol or CSI u report
    // Cmd as SUPER. Terminals that don't (iTerm2 legacy, Terminal.app) remap
    // Cmd+Backspace to \x15 (Ctrl+U) which is already handled above.
    if key.modifiers.contains(KeyModifiers::SUPER) {
        match key.code {
            // Cmd+Backspace: delete to line start (macOS standard)
            KeyCode::Backspace => return KeyAction::DeleteToLineStart,
            // Cmd+Delete: delete to line end
            KeyCode::Delete => return KeyAction::DeleteToLineEnd,
            // Cmd+Left/Right: jump to line start/end
            KeyCode::Left => return KeyAction::CursorLineStart,
            KeyCode::Right => return KeyAction::CursorLineEnd,
            _ => {}
        }
    }

    // ── Alt/Option combinations ─────────────────────────────────────────
    if key.modifiers.contains(KeyModifiers::ALT) {
        match key.code {
            // Option+Arrow: word movement (macOS standard)
            KeyCode::Left => return KeyAction::CursorWordLeft,
            KeyCode::Right => return KeyAction::CursorWordRight,
            // Alt+B/F: Emacs word movement
            KeyCode::Char('b') => return KeyAction::CursorWordLeft,
            KeyCode::Char('f') => return KeyAction::CursorWordRight,
            // Alt+Backspace / Option+Delete: delete word back
            KeyCode::Backspace => return KeyAction::DeleteWordBack,
            // Alt+D: delete word forward (Emacs)
            KeyCode::Char('d') => return KeyAction::DeleteToLineEnd,
            // Alt+Y: cycle approve mode (Manual → Auto → Yolo)
            KeyCode::Char('y') => return KeyAction::CycleApproveMode,
            // macOS terminals send ¥ (U+00A5) for Opt+Y without ALT modifier
            KeyCode::Char('¥') => return KeyAction::CycleApproveMode,
            _ => {}
        }
    }

    // ── macOS Option key fallbacks ──────────────────────────────────────
    // Many terminals (iTerm2 legacy, Terminal.app) strip the ALT modifier and
    // send the Unicode character produced by the Option key directly.
    if key.modifiers == KeyModifiers::NONE {
        match key.code {
            KeyCode::Char('¥') => return KeyAction::CycleApproveMode, // Opt+Y
            KeyCode::Char('') => return KeyAction::CursorWordLeft,   // Opt+B
            KeyCode::Char('ƒ') => return KeyAction::CursorWordRight,  // Opt+F
            KeyCode::Char('') => return KeyAction::DeleteToLineEnd,  // Opt+D
            _ => {}
        }
    }

    // ── Shift combinations ──────────────────────────────────────────────
    if key.modifiers.contains(KeyModifiers::SHIFT) {
        match key.code {
            KeyCode::Tab | KeyCode::BackTab => return KeyAction::PrevAgent,
            KeyCode::Enter => return KeyAction::NewLine,
            KeyCode::Up => return KeyAction::ScrollUp,
            KeyCode::Down => return KeyAction::ScrollDown,
            _ => {}
        }
    }

    // BackTab (Shift+Tab) is sent without SHIFT modifier on some terminals
    if key.code == KeyCode::BackTab {
        return KeyAction::PrevAgent;
    }

    // ── Plain keys ──────────────────────────────────────────────────────
    match key.code {
        KeyCode::Tab => KeyAction::NextAgent,
        KeyCode::Enter => KeyAction::Submit,
        KeyCode::Backspace => KeyAction::Backspace,
        KeyCode::Delete => KeyAction::Delete,
        KeyCode::Char(c) => KeyAction::Char(c),
        KeyCode::Up => KeyAction::HistoryUp,
        KeyCode::Down => KeyAction::HistoryDown,
        KeyCode::Left => KeyAction::CursorLeft,
        KeyCode::Right => KeyAction::CursorRight,
        KeyCode::Home => KeyAction::CursorLineStart,
        KeyCode::End => KeyAction::CursorLineEnd,
        KeyCode::PageUp => KeyAction::ScrollUp,
        KeyCode::PageDown => KeyAction::ScrollDown,
        KeyCode::Esc => KeyAction::EscapePressed,
        _ => KeyAction::None,
    }
}

pub fn interpret_mouse(mouse: MouseEvent) -> MouseAction {
    match mouse.kind {
        MouseEventKind::ScrollUp => MouseAction::ScrollUp,
        MouseEventKind::ScrollDown => MouseAction::ScrollDown,
        MouseEventKind::Down(MouseButton::Left) => MouseAction::Click {
            column: mouse.column,
            row: mouse.row,
        },
        _ => MouseAction::None,
    }
}