Skeleton for the Coding Agent. (#1)
Reviewed-on: #1 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
parent
42e3ddacc2
commit
5d213b43d3
15 changed files with 5071 additions and 12 deletions
460
src/tui/mod.rs
Normal file
460
src/tui/mod.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
//! 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
|
||||
|
||||
use std::io::{self, Stdout};
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
use ratatui::{Frame, Terminal};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
input: String::new(),
|
||||
scroll: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Internal control flow signal returned by [`handle_key`].
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// | Key | Effect |
|
||||
/// |------------------|-------------------------------------------------|
|
||||
/// | Printable (no CTRL) | `state.input.push(c)` |
|
||||
/// | Backspace | `state.input.pop()` |
|
||||
/// | 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<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
|
||||
let key = key?;
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(LoopControl::Quit)
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(LoopControl::Quit)
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// immediately when the channel is empty.
|
||||
///
|
||||
/// | Event | Effect |
|
||||
/// |--------------------|------------------------------------------------------------|
|
||||
/// | `StreamDelta(s)` | Append `s` to last message if it's `Assistant`; else push new |
|
||||
/// | `TurnComplete` | No structural change; logged at debug level |
|
||||
/// | `Error(msg)` | Push `(Assistant, "[error] {msg}")` |
|
||||
fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mut AppState) {
|
||||
while let Ok(event) = event_rx.try_recv() {
|
||||
match event {
|
||||
UIEvent::StreamDelta(chunk) => {
|
||||
if let Some((Role::Assistant, content)) = state.messages.last_mut() {
|
||||
content.push_str(&chunk);
|
||||
} else {
|
||||
state.messages.push((Role::Assistant, chunk));
|
||||
}
|
||||
}
|
||||
UIEvent::TurnComplete => {
|
||||
debug!("turn complete");
|
||||
}
|
||||
UIEvent::Error(msg) => {
|
||||
state
|
||||
.messages
|
||||
.push((Role::Assistant, format!("[error] {msg}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate the total rendered line count for all messages and update `state.scroll`
|
||||
/// so the bottom of the content is visible.
|
||||
///
|
||||
/// When content fits within the viewport, `state.scroll` is set to 0.
|
||||
///
|
||||
/// Line estimation per message:
|
||||
/// - 1 line for the role header
|
||||
/// - `ceil(chars / width).max(1)` lines per newline-separated content line
|
||||
/// - 1 blank separator line
|
||||
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);
|
||||
let width = area.width.max(1) as usize;
|
||||
|
||||
let mut total_lines: u16 = 0;
|
||||
for (_, content) in &state.messages {
|
||||
total_lines = total_lines.saturating_add(1); // role header
|
||||
for line in content.lines() {
|
||||
let chars = line.chars().count();
|
||||
let wrapped = chars.div_ceil(width).max(1) as u16;
|
||||
total_lines = total_lines.saturating_add(wrapped);
|
||||
}
|
||||
total_lines = total_lines.saturating_add(1); // blank separator
|
||||
}
|
||||
|
||||
state.scroll = total_lines.saturating_sub(viewport_height);
|
||||
}
|
||||
|
||||
/// Render the full TUI into `frame`.
|
||||
///
|
||||
/// Layout (top to bottom):
|
||||
/// ```text
|
||||
/// +--------------------------------+
|
||||
/// | conversation history | Fill(1)
|
||||
/// | |
|
||||
/// +--------------------------------+
|
||||
/// | Input | Length(3)
|
||||
/// | > _ |
|
||||
/// +--------------------------------+
|
||||
/// ```
|
||||
///
|
||||
/// Role headers are coloured: `"You:"` in cyan, `"Assistant:"` in green.
|
||||
fn render(frame: &mut Frame, state: &AppState) {
|
||||
let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).split(frame.area());
|
||||
|
||||
// --- Output pane ---
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for (role, content) in &state.messages {
|
||||
let (label, color) = match role {
|
||||
Role::User => ("You:", Color::Cyan),
|
||||
Role::Assistant => ("Assistant:", Color::Green),
|
||||
};
|
||||
lines.push(Line::from(Span::styled(label, Style::default().fg(color))));
|
||||
for body_line in content.lines() {
|
||||
lines.push(Line::from(body_line.to_string()));
|
||||
}
|
||||
lines.push(Line::from("")); // blank separator
|
||||
}
|
||||
|
||||
let output = Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll, 0));
|
||||
frame.render_widget(output, chunks[0]);
|
||||
|
||||
// --- Input pane ---
|
||||
let input_text = format!("> {}", state.input);
|
||||
let input_widget = Paragraph::new(input_text).block(Block::bordered().title("Input"));
|
||||
frame.render_widget(input_widget, chunks[1]);
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
) -> Result<(), TuiError> {
|
||||
install_panic_hook();
|
||||
let mut terminal = init_terminal()?;
|
||||
let mut state = AppState::new();
|
||||
let mut event_stream = EventStream::new();
|
||||
|
||||
loop {
|
||||
// 1. Drain pending UI 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 = handle_key(key_event, &mut state);
|
||||
|
||||
// 4. Render (scroll updated inside draw closure to use current frame area).
|
||||
terminal.draw(|frame| {
|
||||
update_scroll(&mut state, frame.area());
|
||||
render(frame, &state);
|
||||
})?;
|
||||
|
||||
// 5. Act on control signal after render so the user sees the submitted message.
|
||||
match control {
|
||||
Some(LoopControl::SendMessage(msg)) => {
|
||||
if action_tx.send(UserAction::SendMessage(msg)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(LoopControl::Quit) => {
|
||||
let _ = action_tx.send(UserAction::Quit).await;
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
restore_terminal()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
// --- drain_ui_events tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_appends_to_existing_assistant() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
|
||||
let mut state = AppState::new();
|
||||
state.messages.push((Role::Assistant, "hello".to_string()));
|
||||
tx.send(UIEvent::StreamDelta(" world".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
drain_ui_events(&mut rx, &mut state);
|
||||
assert_eq!(state.messages.last().unwrap().1, "hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drain_creates_assistant_on_user_last() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
|
||||
let mut state = AppState::new();
|
||||
state.messages.push((Role::User, "hi".to_string()));
|
||||
tx.send(UIEvent::StreamDelta("hello".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
drain_ui_events(&mut rx, &mut state);
|
||||
assert_eq!(state.messages.len(), 2);
|
||||
assert_eq!(state.messages[1].0, Role::Assistant);
|
||||
assert_eq!(state.messages[1].1, "hello");
|
||||
}
|
||||
|
||||
// --- render tests ---
|
||||
|
||||
#[test]
|
||||
fn render_smoke_test() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let state = AppState::new();
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
// no panic is the assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_shows_role_prefixes() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.messages.push((Role::User, "hi".to_string()));
|
||||
state.messages.push((Role::Assistant, "hello".to_string()));
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
all_text.contains("You:"),
|
||||
"expected 'You:' in buffer: {all_text:.100}"
|
||||
);
|
||||
assert!(
|
||||
all_text.contains("Assistant:"),
|
||||
"expected 'Assistant:' in buffer"
|
||||
);
|
||||
}
|
||||
|
||||
// --- update_scroll tests ---
|
||||
|
||||
#[test]
|
||||
fn update_scroll_zero_when_fits() {
|
||||
let mut state = AppState::new();
|
||||
state.messages.push((Role::User, "hello".to_string()));
|
||||
let area = Rect::new(0, 0, 80, 24);
|
||||
update_scroll(&mut state, area);
|
||||
assert_eq!(state.scroll, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_scroll_positive_when_overflow() {
|
||||
let mut state = AppState::new();
|
||||
for i in 0..50 {
|
||||
state.messages.push((Role::User, format!("message {i}")));
|
||||
}
|
||||
let area = Rect::new(0, 0, 80, 24);
|
||||
update_scroll(&mut state, area);
|
||||
assert!(state.scroll > 0, "expected scroll > 0 with 50 messages");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue