Implement tool executor as a separate tarpc service to improve isolation and create sandboxing opportunities. Reviewed-on: #12 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
233 lines
8.4 KiB
Rust
233 lines
8.4 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;
|
|
pub(crate) mod tool_display;
|
|
|
|
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, StampedEvent, 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,
|
|
}
|
|
|
|
/// A single message in the TUI's display list.
|
|
///
|
|
/// Unlike `ConversationMessage` (which models the API wire format), this struct
|
|
/// represents a rendered row in the conversation pane. Tool invocations get their
|
|
/// own `DisplayMessage` with a `tool_use_id` so that in-place replacement works:
|
|
/// the approval message becomes the executing message, then the result message.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DisplayMessage {
|
|
/// Who authored this message (tool messages use `Assistant`).
|
|
pub role: Role,
|
|
/// Pre-formatted content for rendering.
|
|
pub content: String,
|
|
/// When set, this message can be replaced in-place by a later event carrying
|
|
/// the same tool-use ID (e.g. executing -> result).
|
|
pub tool_use_id: Option<String>,
|
|
}
|
|
|
|
/// The UI-layer view of a conversation: rendered messages and the current input buffer.
|
|
pub struct AppState {
|
|
/// All conversation turns rendered as display messages.
|
|
pub messages: Vec<DisplayMessage>,
|
|
/// 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>,
|
|
/// Whether the sandbox is in yolo (unsandboxed) mode.
|
|
pub sandbox_yolo: bool,
|
|
/// Whether network access is currently allowed.
|
|
pub network_allowed: bool,
|
|
/// Monotonic epoch incremented on `:clear`. Events with an older epoch are
|
|
/// discarded by `drain_ui_events` to prevent ghost messages.
|
|
pub epoch: u64,
|
|
/// Set by `drain_ui_events` when message content changes; consumed by
|
|
/// `update_scroll` to auto-follow only when new content arrives.
|
|
pub content_changed: 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,
|
|
sandbox_yolo: false,
|
|
network_allowed: false,
|
|
epoch: 0,
|
|
content_changed: 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<StampedEvent>,
|
|
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 { epoch: state.epoch })
|
|
.await;
|
|
}
|
|
Some(input::LoopControl::SetNetworkPolicy(allowed)) => {
|
|
let _ = action_tx.send(UserAction::SetNetworkPolicy(allowed)).await;
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
restore_terminal()?;
|
|
Ok(())
|
|
}
|