skate/src/tui/mod.rs
Drew Galbraith 7efc6705d3 Use Landlock to restrict bash calls. (#5)
https://docs.kernel.org/userspace-api/landlock.html
Reviewed-on: #5
Co-authored-by: Drew Galbraith <drew@tiramisu.one>
Co-committed-by: Drew Galbraith <drew@tiramisu.one>
2026-03-02 03:51:46 +00:00

219 lines
7.6 KiB
Rust

//! TUI frontend: terminal lifecycle, rendering, and input handling.
//!
//! All communication with the core orchestrator flows through channels:
//! - [`UserAction`] sent via `action_tx` when the user submits input or quits
//! - [`UIEvent`] received via `event_rx` to display streaming assistant responses
//!
//! The terminal lifecycle follows the standard crossterm pattern:
//! 1. Enable raw mode
//! 2. Enter alternate screen
//! 3. On exit (or panic), disable raw mode and leave the alternate screen
mod events;
mod input;
mod render;
use std::io::{self, Stdout};
use std::time::Duration;
use crossterm::event::{Event, EventStream, KeyEvent};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use futures::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use crate::core::types::{Role, UIEvent, UserAction};
/// Errors that can occur in the TUI layer.
#[derive(Debug, thiserror::Error)]
pub enum TuiError {
/// An underlying terminal I/O error.
#[error("terminal IO error: {0}")]
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
/// Command --Esc/Enter--> Normal
#[derive(Debug, Clone, Copy, 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 command buffer lives in `AppState::command_buffer`.
Command,
}
/// 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.
pub messages: Vec<(Role, String)>,
/// The current contents of the input box.
pub input: String,
/// Vertical scroll offset for the output pane (lines from top).
pub scroll: u16,
/// Current vim-style editing mode.
pub mode: Mode,
/// Partial command text in Command mode (without the leading `:`).
pub command_buffer: String,
/// 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,
/// Transient error message shown in the status bar, cleared on next keypress.
pub status_error: Option<String>,
/// A tool approval request waiting for user input (y/n).
pub pending_approval: Option<events::PendingApproval>,
/// Whether the sandbox is in yolo (unsandboxed) mode.
pub sandbox_yolo: bool,
/// Whether network access is currently allowed.
pub network_allowed: bool,
}
impl AppState {
fn new() -> Self {
Self {
messages: Vec::new(),
input: String::new(),
scroll: 0,
mode: Mode::Insert,
command_buffer: String::new(),
pending_keys: Vec::new(),
viewport_height: 0,
status_error: None,
pending_approval: None,
sandbox_yolo: false,
network_allowed: false,
}
}
}
/// Initialise the terminal: enable raw mode and switch to the alternate screen.
///
/// Callers must pair this with [`restore_terminal`] (and [`install_panic_hook`]) to
/// guarantee cleanup even on abnormal exit.
pub fn init_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, TuiError> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
/// Restore the terminal to its pre-launch state: disable raw mode and leave the
/// alternate screen.
pub fn restore_terminal() -> io::Result<()> {
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen)?;
Ok(())
}
/// Install a panic hook that restores the terminal before printing the panic message.
///
/// Without this, a panic leaves the terminal in raw mode with the alternate screen
/// active, making the shell unusable until the user runs `reset`.
pub fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Best-effort restore; if it fails the original hook still runs.
let _ = restore_terminal();
original(info);
}));
}
/// Run the TUI event loop.
///
/// This function owns the terminal for its entire lifetime. It initialises the
/// terminal, installs the panic hook, then spins in a ~60 fps loop:
///
/// ```text
/// loop:
/// 1. drain UIEvents (non-blocking try_recv)
/// 2. poll keyboard for up to 16 ms via EventStream (async, no blocking thread)
/// 3. handle key event -> Option<LoopControl>
/// 4. render frame (scroll updated inside draw closure)
/// 5. act on LoopControl: send message or break
/// ```
///
/// On `Ctrl+C` / `Ctrl+D`: sends [`UserAction::Quit`], restores the terminal, and
/// returns `Ok(())`.
pub async fn run(
action_tx: mpsc::Sender<UserAction>,
mut event_rx: mpsc::Receiver<UIEvent>,
sandbox_yolo: bool,
) -> Result<(), TuiError> {
install_panic_hook();
let mut terminal = init_terminal()?;
let mut state = AppState::new();
state.sandbox_yolo = sandbox_yolo;
let mut event_stream = EventStream::new();
loop {
// 1. Drain pending UI events.
events::drain_ui_events(&mut event_rx, &mut state);
// 2. Poll keyboard for up to 16 ms. EventStream integrates with the
// Tokio runtime via futures::Stream, so no blocking thread is needed.
// Timeout expiry, stream end, non-key events, and I/O errors all map
// to None -- the frame is rendered regardless.
let key_event: Option<KeyEvent> =
match tokio::time::timeout(Duration::from_millis(16), event_stream.next()).await {
Ok(Some(Ok(Event::Key(k)))) => Some(k),
_ => None,
};
// 3. Handle key.
let control = input::handle_key(key_event, &mut state);
// 4. Render (scroll updated inside draw closure to use current frame area).
terminal.draw(|frame| {
render::update_scroll(&mut state, frame.area());
render::render(frame, &state);
})?;
// 5. Act on control signal after render so the user sees the submitted message.
match control {
Some(input::LoopControl::SendMessage(msg)) => {
if action_tx.send(UserAction::SendMessage(msg)).await.is_err() {
break;
}
}
Some(input::LoopControl::Quit) => {
let _ = action_tx.send(UserAction::Quit).await;
break;
}
Some(input::LoopControl::ClearHistory) => {
let _ = action_tx.send(UserAction::ClearHistory).await;
}
Some(input::LoopControl::ToolApproval {
tool_use_id,
approved,
}) => {
let _ = action_tx
.send(UserAction::ToolApprovalResponse {
tool_use_id,
approved,
})
.await;
}
Some(input::LoopControl::SetNetworkPolicy(allowed)) => {
let _ = action_tx.send(UserAction::SetNetworkPolicy(allowed)).await;
}
None => {}
}
}
restore_terminal()?;
Ok(())
}