Merge remote-tracking branch 'origin/main' into ui

# Conflicts:
#	src/channels/mod.rs
#	src/main.rs
This commit is contained in:
Illia Polosukhin
2026-02-06 13:22:26 -08:00
39 changed files with 1694 additions and 2420 deletions
-359
View File
@@ -1,359 +0,0 @@
//! Application state for the TUI.
use std::collections::VecDeque;
use crate::channels::cli::composer::ChatComposer;
use crate::channels::cli::model_selector::{ModelSelectorOverlay, ModelSelectorRequest};
use crate::channels::cli::overlay::{ApprovalOverlay, ApprovalRequest};
/// Events that can occur in the TUI.
#[derive(Debug, Clone)]
pub enum AppEvent {
/// Keyboard/mouse input event.
Input(crossterm::event::Event),
/// Response from the agent.
Response(String),
/// Tool execution started.
ToolStarted { name: String },
/// Tool execution completed.
ToolCompleted { name: String, success: bool },
/// Request approval for a tool.
ApprovalRequested(ApprovalRequest),
/// Streaming chunk received.
StreamChunk(String),
/// Log message from the application (shown in status line).
LogMessage(String),
/// Thinking/status message (shown in chat window).
ThinkingMessage(String),
/// Error message (shown in chat window).
ErrorMessage(String),
/// Available models fetched from API.
AvailableModels(Vec<String>),
/// Force a redraw.
Redraw,
/// Quit the application.
Quit,
}
/// Current input mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
/// Normal input mode.
Normal,
/// Editing input.
Editing,
/// Approval overlay is active.
Approval,
/// Model selector overlay is active.
ModelSelector,
}
/// Message in the chat history.
#[derive(Debug, Clone)]
pub struct ChatMessage {
/// Who sent this message.
pub role: MessageRole,
/// The message content.
pub content: String,
/// Optional status indicator.
pub status: Option<MessageStatus>,
}
/// Who sent a message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageRole {
User,
Agent,
System,
}
/// Status of a message (for in-progress indicators).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageStatus {
Pending,
InProgress,
Complete,
Error,
}
impl ChatMessage {
pub fn user(content: impl Into<String>) -> Self {
Self {
role: MessageRole::User,
content: content.into(),
status: None,
}
}
pub fn agent(content: impl Into<String>) -> Self {
Self {
role: MessageRole::Agent,
content: content.into(),
status: None,
}
}
pub fn system(content: impl Into<String>) -> Self {
Self {
role: MessageRole::System,
content: content.into(),
status: None,
}
}
pub fn with_status(mut self, status: MessageStatus) -> Self {
self.status = Some(status);
self
}
}
/// Application state.
pub struct AppState {
/// Current input mode.
pub mode: InputMode,
/// Chat message history.
pub messages: Vec<ChatMessage>,
/// Input composer.
pub composer: ChatComposer,
/// Approval overlay (if active).
pub approval: Option<ApprovalOverlay>,
/// Model selector overlay (if active).
pub model_selector: Option<ModelSelectorOverlay>,
/// Scroll offset for messages.
pub scroll_offset: u16,
/// Whether the app should quit.
pub should_quit: bool,
/// Pending approvals queue.
pub pending_approvals: VecDeque<ApprovalRequest>,
/// Current streaming response buffer.
pub streaming_buffer: Option<String>,
/// Status line message.
pub status_message: Option<String>,
/// Whether Ctrl+D was pressed (waiting for second press to quit).
pub ctrl_d_pending: bool,
/// Currently selected model.
pub current_model: String,
/// Available models (fetched from API).
pub available_models: Vec<String>,
}
impl AppState {
/// Create a new app state.
pub fn new() -> Self {
// Load saved model from settings
let settings = crate::settings::Settings::load();
let current_model = settings.model_or("claude-3-5-sonnet-20241022");
Self {
mode: InputMode::Editing,
messages: vec![ChatMessage::system(
"Welcome to IronClaw. Type a message or /help for commands.",
)],
composer: ChatComposer::new(),
approval: None,
model_selector: None,
scroll_offset: 0,
should_quit: false,
pending_approvals: VecDeque::new(),
streaming_buffer: None,
status_message: None,
ctrl_d_pending: false,
current_model,
available_models: Vec::new(),
}
}
/// Show the model selector.
pub fn show_model_selector(&mut self) {
let request = ModelSelectorRequest {
current_model: self.current_model.clone(),
available_models: self.available_models.clone(),
};
self.model_selector = Some(ModelSelectorOverlay::new(request));
self.mode = InputMode::ModelSelector;
}
/// Handle model selection.
pub fn handle_model_selection(&mut self, selected: Option<String>) {
self.model_selector = None;
self.mode = InputMode::Editing;
if let Some(model) = selected {
if model != self.current_model {
self.current_model = model.clone();
// Save to settings
let mut settings = crate::settings::Settings::load();
if let Err(e) = settings.set_model(&model) {
tracing::warn!("Failed to save model setting: {}", e);
}
self.messages.push(ChatMessage::system(format!(
"Switched to model: {}",
ModelSelectorOverlay::format_model_name(&model)
)));
}
}
}
/// Set available models (also updates selector if open).
pub fn set_available_models(&mut self, models: Vec<String>) {
self.available_models = models.clone();
// Update the selector if it's currently open
if let Some(ref mut selector) = self.model_selector {
selector.request.available_models = models;
// Reset selection index if it's out of bounds
if selector.selection_index >= selector.request.available_models.len() {
selector.selection_index = 0;
}
}
}
/// Add a user message to history.
pub fn add_user_message(&mut self, content: impl Into<String>) {
self.messages.push(ChatMessage::user(content));
self.scroll_to_bottom();
}
/// Add an agent response to history.
pub fn add_agent_message(&mut self, content: impl Into<String>) {
// If we were streaming, finalize it
if self.streaming_buffer.is_some() {
self.streaming_buffer = None;
}
// Remove any pending thinking message before adding the response
self.clear_thinking();
self.messages.push(ChatMessage::agent(content));
self.scroll_to_bottom();
}
/// Add an error message to the chat.
pub fn add_error_message(&mut self, content: impl Into<String>) {
self.messages.push(
ChatMessage::system(format!("Error: {}", content.into()))
.with_status(MessageStatus::Error),
);
self.scroll_to_bottom();
}
/// Add or update a thinking/status message (shown as system message).
pub fn set_thinking(&mut self, content: impl Into<String>) {
let content = content.into();
// Check if last message is a thinking message (system with InProgress status)
if let Some(last) = self.messages.last_mut() {
if last.role == MessageRole::System && last.status == Some(MessageStatus::InProgress) {
last.content = content;
return;
}
}
// Add new thinking message
self.messages
.push(ChatMessage::system(content).with_status(MessageStatus::InProgress));
self.scroll_to_bottom();
}
/// Clear any thinking/status message.
pub fn clear_thinking(&mut self) {
// Remove any thinking messages (system with InProgress status)
self.messages.retain(|msg| {
!(msg.role == MessageRole::System && msg.status == Some(MessageStatus::InProgress))
});
}
/// Start streaming a response.
pub fn start_streaming(&mut self) {
self.streaming_buffer = Some(String::new());
self.messages
.push(ChatMessage::agent("").with_status(MessageStatus::InProgress));
}
/// Append to the streaming buffer.
pub fn append_stream(&mut self, chunk: &str) {
if let Some(ref mut buffer) = self.streaming_buffer {
buffer.push_str(chunk);
// Update the last message
if let Some(last) = self.messages.last_mut() {
if last.role == MessageRole::Agent {
last.content = buffer.clone();
}
}
}
}
/// Finalize streaming.
pub fn finish_streaming(&mut self) {
if let Some(last) = self.messages.last_mut() {
if last.role == MessageRole::Agent {
last.status = Some(MessageStatus::Complete);
}
}
self.streaming_buffer = None;
}
/// Show an approval request.
pub fn show_approval(&mut self, request: ApprovalRequest) {
self.approval = Some(ApprovalOverlay::new(request));
self.mode = InputMode::Approval;
}
/// Queue an approval request.
pub fn queue_approval(&mut self, request: ApprovalRequest) {
if self.approval.is_none() {
self.show_approval(request);
} else {
self.pending_approvals.push_back(request);
}
}
/// Handle approval response.
pub fn handle_approval_response(&mut self, approved: bool) -> Option<ApprovalRequest> {
let request = self.approval.take().map(|o| o.request);
// Show next pending approval if any
if let Some(next) = self.pending_approvals.pop_front() {
self.show_approval(next);
} else {
self.mode = InputMode::Editing;
}
if approved { request } else { None }
}
/// Clear all pending approvals.
pub fn clear_approvals(&mut self) {
self.approval = None;
self.pending_approvals.clear();
self.mode = InputMode::Editing;
}
/// Set the status message.
pub fn set_status(&mut self, message: impl Into<String>) {
self.status_message = Some(message.into());
}
/// Clear the status message.
pub fn clear_status(&mut self) {
self.status_message = None;
}
/// Scroll to the bottom of messages.
pub fn scroll_to_bottom(&mut self) {
// Will be calculated based on render area in render.rs
self.scroll_offset = 0;
}
/// Scroll up.
pub fn scroll_up(&mut self, amount: u16) {
self.scroll_offset = self.scroll_offset.saturating_add(amount);
}
/// Scroll down.
pub fn scroll_down(&mut self, amount: u16) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
-318
View File
@@ -1,318 +0,0 @@
//! Input composer with history and completion.
use std::collections::VecDeque;
/// Maximum number of history entries to keep.
const MAX_HISTORY: usize = 100;
/// Available slash commands for completion.
const SLASH_COMMANDS: &[&str] = &[
"/help", "/job", "/status", "/cancel", "/list", "/tools", "/clear", "/quit",
];
/// Chat input composer with history navigation and slash command completion.
pub struct ChatComposer {
/// Current input buffer.
buffer: String,
/// Cursor position in the buffer.
cursor: usize,
/// Input history.
history: VecDeque<String>,
/// Current position in history (-1 = current input).
history_index: Option<usize>,
/// Saved current input when navigating history.
saved_input: String,
/// Completion candidates.
completions: Vec<String>,
/// Current completion index.
completion_index: Option<usize>,
}
impl ChatComposer {
/// Create a new composer.
pub fn new() -> Self {
Self {
buffer: String::new(),
cursor: 0,
history: VecDeque::with_capacity(MAX_HISTORY),
history_index: None,
saved_input: String::new(),
completions: Vec::new(),
completion_index: None,
}
}
/// Get the current input buffer.
pub fn buffer(&self) -> &str {
&self.buffer
}
/// Get the cursor position.
pub fn cursor(&self) -> usize {
self.cursor
}
/// Check if the buffer is empty.
pub fn is_empty(&self) -> bool {
self.buffer.is_empty()
}
/// Insert a character at the cursor.
pub fn insert(&mut self, c: char) {
self.clear_completion();
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
/// Insert a string at the cursor.
pub fn insert_str(&mut self, s: &str) {
self.clear_completion();
self.buffer.insert_str(self.cursor, s);
self.cursor += s.len();
}
/// Delete the character before the cursor (backspace).
pub fn backspace(&mut self) {
self.clear_completion();
if self.cursor > 0 {
// Find the previous character boundary
let prev = self.buffer[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.buffer.drain(prev..self.cursor);
self.cursor = prev;
}
}
/// Delete the character at the cursor (delete).
pub fn delete(&mut self) {
self.clear_completion();
if self.cursor < self.buffer.len() {
// Find the next character boundary
let next = self.buffer[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.buffer.len());
self.buffer.drain(self.cursor..next);
}
}
/// Move cursor left.
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor = self.buffer[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
}
/// Move cursor right.
pub fn move_right(&mut self) {
if self.cursor < self.buffer.len() {
self.cursor = self.buffer[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.buffer.len());
}
}
/// Move cursor to start.
pub fn move_home(&mut self) {
self.cursor = 0;
}
/// Move cursor to end.
pub fn move_end(&mut self) {
self.cursor = self.buffer.len();
}
/// Delete from cursor to end of line.
pub fn kill_line(&mut self) {
self.clear_completion();
self.buffer.truncate(self.cursor);
}
/// Delete from start to cursor.
pub fn kill_to_start(&mut self) {
self.clear_completion();
self.buffer.drain(..self.cursor);
self.cursor = 0;
}
/// Clear the entire buffer.
pub fn clear(&mut self) {
self.buffer.clear();
self.cursor = 0;
self.clear_completion();
}
/// Submit the current input and return it.
pub fn submit(&mut self) -> String {
let input = std::mem::take(&mut self.buffer);
self.cursor = 0;
self.clear_completion();
// Add to history if non-empty and different from last entry
if !input.is_empty() && self.history.front() != Some(&input) {
self.history.push_front(input.clone());
if self.history.len() > MAX_HISTORY {
self.history.pop_back();
}
}
self.history_index = None;
self.saved_input.clear();
input
}
/// Navigate to previous history entry.
pub fn history_prev(&mut self) {
if self.history.is_empty() {
return;
}
match self.history_index {
None => {
// Save current input and go to first history entry
self.saved_input = std::mem::take(&mut self.buffer);
self.history_index = Some(0);
self.buffer = self.history[0].clone();
}
Some(i) if i + 1 < self.history.len() => {
self.history_index = Some(i + 1);
self.buffer = self.history[i + 1].clone();
}
_ => {}
}
self.cursor = self.buffer.len();
self.clear_completion();
}
/// Navigate to next history entry.
pub fn history_next(&mut self) {
match self.history_index {
Some(0) => {
// Go back to saved input
self.history_index = None;
self.buffer = std::mem::take(&mut self.saved_input);
}
Some(i) => {
self.history_index = Some(i - 1);
self.buffer = self.history[i - 1].clone();
}
None => {}
}
self.cursor = self.buffer.len();
self.clear_completion();
}
/// Attempt tab completion.
pub fn complete(&mut self) {
// Only complete slash commands for now
if !self.buffer.starts_with('/') {
return;
}
if self.completions.is_empty() {
// Generate completions
let prefix = &self.buffer;
self.completions = SLASH_COMMANDS
.iter()
.filter(|cmd| cmd.starts_with(prefix))
.map(|s| s.to_string())
.collect();
if !self.completions.is_empty() {
self.completion_index = Some(0);
}
} else if let Some(i) = self.completion_index {
// Cycle through completions
self.completion_index = Some((i + 1) % self.completions.len());
}
// Apply completion
if let Some(i) = self.completion_index {
if let Some(completion) = self.completions.get(i) {
self.buffer = completion.clone();
self.cursor = self.buffer.len();
}
}
}
/// Clear completion state.
fn clear_completion(&mut self) {
self.completions.clear();
self.completion_index = None;
}
/// Get current completion hint (for display).
pub fn completion_hint(&self) -> Option<&str> {
if let Some(i) = self.completion_index {
self.completions.get(i).map(|s| s.as_str())
} else {
None
}
}
/// Get the number of completions available.
pub fn completion_count(&self) -> usize {
self.completions.len()
}
}
impl Default for ChatComposer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_and_backspace() {
let mut composer = ChatComposer::new();
composer.insert('h');
composer.insert('i');
assert_eq!(composer.buffer(), "hi");
composer.backspace();
assert_eq!(composer.buffer(), "h");
}
#[test]
fn test_history_navigation() {
let mut composer = ChatComposer::new();
composer.insert_str("first");
composer.submit();
composer.insert_str("second");
composer.submit();
composer.insert_str("current");
composer.history_prev();
assert_eq!(composer.buffer(), "second");
composer.history_prev();
assert_eq!(composer.buffer(), "first");
composer.history_next();
assert_eq!(composer.buffer(), "second");
composer.history_next();
assert_eq!(composer.buffer(), "current");
}
#[test]
fn test_completion() {
let mut composer = ChatComposer::new();
composer.insert_str("/hel");
composer.complete();
assert_eq!(composer.buffer(), "/help");
}
}
-333
View File
@@ -1,333 +0,0 @@
//! Event handling for the TUI.
use std::io;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use crate::channels::IncomingMessage;
use crate::channels::cli::app::{AppEvent, AppState, InputMode};
use crate::channels::cli::render;
/// Tick rate for the event loop (50ms = 20fps).
const TICK_RATE: Duration = Duration::from_millis(50);
/// Run the main event loop.
pub fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut AppState,
msg_tx: mpsc::Sender<IncomingMessage>,
mut event_rx: mpsc::Receiver<AppEvent>,
) -> io::Result<()> {
loop {
// Render
terminal.draw(|f| render::render(f, app))?;
// Check for quit - send shutdown signal and exit
if app.should_quit {
// Send a shutdown message so the agent loop knows to exit
let shutdown_msg = IncomingMessage::new("tui", "system", "/shutdown");
let _ = msg_tx.blocking_send(shutdown_msg);
// Explicitly drop to close the channel
drop(msg_tx);
return Ok(());
}
// Poll for terminal events
if event::poll(TICK_RATE)? {
let evt = event::read()?;
if let Err(e) = handle_event(app, evt, &msg_tx) {
tracing::error!("Event handling error: {}", e);
}
}
// Check for app events from agent (non-blocking)
while let Ok(app_event) = event_rx.try_recv() {
handle_app_event(app, app_event);
}
}
}
/// Handle a crossterm event.
fn handle_event(
app: &mut AppState,
event: Event,
msg_tx: &mpsc::Sender<IncomingMessage>,
) -> io::Result<()> {
match event {
Event::Key(key) => handle_key(app, key, msg_tx),
Event::Mouse(_) => Ok(()), // Could handle mouse scrolling here
Event::Resize(_, _) => Ok(()), // Terminal will handle resize
_ => Ok(()),
}
}
/// Handle a key event.
fn handle_key(
app: &mut AppState,
key: KeyEvent,
msg_tx: &mpsc::Sender<IncomingMessage>,
) -> io::Result<()> {
// Global keybindings
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => {
if app.mode == InputMode::Approval {
// Cancel all pending approvals
app.clear_approvals();
} else {
// Quit
app.should_quit = true;
}
app.ctrl_d_pending = false;
return Ok(());
}
KeyCode::Char('d') => {
if app.ctrl_d_pending {
// Second Ctrl+D, quit now
app.should_quit = true;
} else {
// First Ctrl+D, show hint
app.ctrl_d_pending = true;
app.set_status("Press Ctrl+D again to quit");
}
return Ok(());
}
_ => {
// Any other Ctrl+ combo clears the Ctrl+D pending state
app.ctrl_d_pending = false;
}
}
} else {
// Any non-Ctrl key clears the Ctrl+D pending state
app.ctrl_d_pending = false;
}
match app.mode {
InputMode::Normal => handle_normal_mode(app, key),
InputMode::Editing => handle_editing_mode(app, key, msg_tx),
InputMode::Approval => handle_approval_mode(app, key),
InputMode::ModelSelector => handle_model_selector_mode(app, key),
}
}
/// Handle keys in normal mode.
fn handle_normal_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
match key.code {
KeyCode::Char('i') | KeyCode::Char('a') => {
app.mode = InputMode::Editing;
}
KeyCode::Char('q') => {
app.should_quit = true;
}
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_up(1);
}
KeyCode::Down | KeyCode::Char('j') => {
app.scroll_down(1);
}
KeyCode::PageUp => {
app.scroll_up(10);
}
KeyCode::PageDown => {
app.scroll_down(10);
}
KeyCode::Char('G') => {
app.scroll_to_bottom();
}
_ => {}
}
Ok(())
}
/// Handle keys in editing mode.
fn handle_editing_mode(
app: &mut AppState,
key: KeyEvent,
msg_tx: &mpsc::Sender<IncomingMessage>,
) -> io::Result<()> {
match key.code {
KeyCode::Enter => {
if !app.composer.is_empty() {
let input = app.composer.submit();
// Handle /model command locally (TUI-specific)
if input.trim().eq_ignore_ascii_case("/model") {
app.show_model_selector();
return Ok(());
}
app.add_user_message(&input);
// Send message to agent
let msg = IncomingMessage::new("tui", "local-user", &input);
let _ = msg_tx.blocking_send(msg);
}
}
KeyCode::Esc => {
app.mode = InputMode::Normal;
}
KeyCode::Backspace => {
app.composer.backspace();
}
KeyCode::Delete => {
app.composer.delete();
}
KeyCode::Left => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
// Move word left (simplified: just move to start)
app.composer.move_home();
} else {
app.composer.move_left();
}
}
KeyCode::Right => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
// Move word right (simplified: just move to end)
app.composer.move_end();
} else {
app.composer.move_right();
}
}
KeyCode::Home => {
app.composer.move_home();
}
KeyCode::End => {
app.composer.move_end();
}
KeyCode::Up => {
app.composer.history_prev();
}
KeyCode::Down => {
app.composer.history_next();
}
KeyCode::Tab => {
app.composer.complete();
}
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match c {
'a' => app.composer.move_home(),
'e' => app.composer.move_end(),
'k' => app.composer.kill_line(),
'u' => app.composer.kill_to_start(),
'w' => {
// Delete word backwards (simplified: clear)
app.composer.clear();
}
_ => {}
}
} else {
app.composer.insert(c);
}
}
_ => {}
}
Ok(())
}
/// Handle keys in approval mode.
fn handle_approval_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
if let Some(ref mut overlay) = app.approval {
match key.code {
KeyCode::Left | KeyCode::Char('h') => {
overlay.select_prev();
}
KeyCode::Right | KeyCode::Char('l') => {
overlay.select_next();
}
KeyCode::Enter | KeyCode::Char(' ') => {
let (approved, _always) = overlay.confirm();
app.handle_approval_response(approved);
// TODO: If always, remember to auto-approve this tool
}
KeyCode::Char(c) => {
if let Some(approved) = overlay.handle_shortcut(c) {
app.handle_approval_response(approved);
}
}
KeyCode::Esc => {
// Deny this approval
app.handle_approval_response(false);
}
_ => {}
}
}
Ok(())
}
/// Handle keys in model selector mode.
fn handle_model_selector_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
if let Some(ref mut overlay) = app.model_selector {
match key.code {
KeyCode::Left | KeyCode::Char('h') => {
overlay.select_prev();
}
KeyCode::Right | KeyCode::Char('l') => {
overlay.select_next();
}
KeyCode::Enter | KeyCode::Char(' ') => {
let selected = overlay.selected_model().map(|s| s.to_string());
app.handle_model_selection(selected);
}
KeyCode::Esc => {
// Cancel without changing model
app.handle_model_selection(None);
}
_ => {}
}
}
Ok(())
}
/// Handle an application event.
fn handle_app_event(app: &mut AppState, event: AppEvent) {
match event {
AppEvent::Response(content) => {
app.add_agent_message(content);
}
AppEvent::ToolStarted { name } => {
app.set_thinking(format!("⚙️ Running tool: {}...", name));
}
AppEvent::ToolCompleted { name, success } => {
if success {
app.set_thinking(format!("✓ Tool {} completed", name));
} else {
app.set_thinking(format!("✗ Tool {} failed", name));
}
}
AppEvent::ApprovalRequested(request) => {
app.queue_approval(request);
}
AppEvent::StreamChunk(chunk) => {
if app.streaming_buffer.is_none() {
app.start_streaming();
}
app.append_stream(&chunk);
}
AppEvent::Redraw => {
// Just triggers a redraw on next loop iteration
}
AppEvent::Quit => {
app.should_quit = true;
}
AppEvent::Input(_) => {
// Already handled directly
}
AppEvent::LogMessage(msg) => {
app.set_status(msg);
}
AppEvent::ThinkingMessage(msg) => {
app.set_thinking(msg);
}
AppEvent::ErrorMessage(msg) => {
app.add_error_message(msg);
}
AppEvent::AvailableModels(models) => {
app.set_available_models(models);
}
}
}
-238
View File
@@ -1,238 +0,0 @@
//! Interactive TUI channel using Ratatui.
//!
//! Provides a rich terminal interface with:
//! - Input history navigation
//! - Slash command completion
//! - Approval overlays for tool execution
//! - Streaming response display
mod app;
mod composer;
mod events;
mod model_selector;
mod overlay;
mod render;
use std::io;
use std::sync::Arc;
use async_trait::async_trait;
use crossterm::{
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
pub use app::{AppEvent, AppState, InputMode};
pub use composer::ChatComposer;
pub use model_selector::{ModelSelectorOverlay, ModelSelectorRequest};
pub use overlay::{ApprovalOverlay, ApprovalRequest};
/// TUI channel for interactive terminal input with Ratatui.
pub struct TuiChannel {
/// Channel for sending events to the TUI (created upfront for logging).
event_tx: mpsc::Sender<AppEvent>,
/// Receiver end, taken when start() is called.
event_rx: Arc<Mutex<Option<mpsc::Receiver<AppEvent>>>>,
}
impl TuiChannel {
/// Create a new TUI channel.
pub fn new() -> Self {
let (event_tx, event_rx) = mpsc::channel(64);
Self {
event_tx,
event_rx: Arc::new(Mutex::new(Some(event_rx))),
}
}
/// Get a log writer that sends messages to the TUI status line.
/// Use this to redirect tracing output to the TUI.
pub fn log_writer(&self) -> TuiLogWriter {
TuiLogWriter::new(self.event_tx.clone())
}
/// Get a sender for sending events to the TUI.
/// Use this to send available models or other events from outside the channel.
pub fn event_sender(&self) -> mpsc::Sender<AppEvent> {
self.event_tx.clone()
}
}
impl Default for TuiChannel {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Channel for TuiChannel {
fn name(&self) -> &str {
"tui"
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (msg_tx, msg_rx) = mpsc::channel(32);
// Take the event receiver (can only start once)
let event_rx = {
let mut guard = self.event_rx.lock().await;
guard.take().ok_or_else(|| ChannelError::StartupFailed {
name: "tui".to_string(),
reason: "TUI channel already started".to_string(),
})?
};
tokio::task::spawn_blocking(move || {
if let Err(e) = run_tui(msg_tx, event_rx) {
// Try to restore terminal even on error
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
eprintln!("TUI error: {}", e);
}
});
Ok(Box::pin(ReceiverStream::new(msg_rx)))
}
async fn respond(
&self,
_msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
self.event_tx
.send(AppEvent::Response(response.content))
.await
.map_err(|e| ChannelError::SendFailed {
name: "tui".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
async fn send_status(
&self,
status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
let event = match status {
StatusUpdate::Thinking(msg) => AppEvent::ThinkingMessage(format!("🤔 {}", msg)),
StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted { name },
StatusUpdate::ToolCompleted { name, success } => {
AppEvent::ToolCompleted { name, success }
}
StatusUpdate::StreamChunk(chunk) => AppEvent::StreamChunk(chunk),
StatusUpdate::Status(msg) => AppEvent::ThinkingMessage(msg),
};
self.event_tx
.send(event)
.await
.map_err(|e| ChannelError::SendFailed {
name: "tui".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
async fn broadcast(
&self,
_user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
// For TUI, broadcasts appear as regular agent responses with a notification indicator
self.event_tx
.send(AppEvent::Response(response.content))
.await
.map_err(|e| ChannelError::SendFailed {
name: "tui".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
// Channel is healthy if we haven't been closed
if self.event_tx.is_closed() {
Err(ChannelError::HealthCheckFailed {
name: "tui".to_string(),
})
} else {
Ok(())
}
}
async fn shutdown(&self) -> Result<(), ChannelError> {
let _ = self.event_tx.send(AppEvent::Quit).await;
Ok(())
}
}
/// Run the TUI event loop (blocking).
fn run_tui(
msg_tx: mpsc::Sender<IncomingMessage>,
event_rx: mpsc::Receiver<AppEvent>,
) -> io::Result<()> {
// Setup terminal
// Note: We don't enable mouse capture so users can select text normally
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app state
let mut app = AppState::new();
// Run event loop
let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, event_rx);
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
/// TUI-compatible tracing writer that sends log messages to the TUI status line.
#[derive(Clone)]
pub struct TuiLogWriter {
tx: mpsc::Sender<AppEvent>,
}
impl TuiLogWriter {
pub fn new(tx: mpsc::Sender<AppEvent>) -> Self {
Self { tx }
}
}
impl std::io::Write for TuiLogWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if let Ok(s) = std::str::from_utf8(buf) {
let s = s.trim();
if !s.is_empty() {
// Fire and forget - don't block on logging
let _ = self.tx.try_send(AppEvent::LogMessage(s.to_string()));
}
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TuiLogWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
-156
View File
@@ -1,156 +0,0 @@
//! Model selector overlay for switching LLM models.
/// Request to show the model selector.
#[derive(Debug, Clone)]
pub struct ModelSelectorRequest {
/// Currently selected model.
pub current_model: String,
/// Available models to choose from.
pub available_models: Vec<String>,
}
/// Model selector overlay state.
#[derive(Debug, Clone)]
pub struct ModelSelectorOverlay {
/// The request that triggered this overlay.
pub request: ModelSelectorRequest,
/// Currently highlighted index.
pub selection_index: usize,
}
impl ModelSelectorOverlay {
/// Create a new model selector overlay.
pub fn new(request: ModelSelectorRequest) -> Self {
// Find the current model in the list, default to 0
let selection_index = request
.available_models
.iter()
.position(|m| m == &request.current_model)
.unwrap_or(0);
Self {
request,
selection_index,
}
}
/// Get the list of available models.
pub fn models(&self) -> &[String] {
&self.request.available_models
}
/// Move selection up.
pub fn select_prev(&mut self) {
let len = self.request.available_models.len();
if len == 0 {
return;
}
if self.selection_index > 0 {
self.selection_index -= 1;
} else {
// Wrap to bottom
self.selection_index = len - 1;
}
}
/// Move selection down.
pub fn select_next(&mut self) {
let len = self.request.available_models.len();
if len == 0 {
return;
}
if self.selection_index < len - 1 {
self.selection_index += 1;
} else {
// Wrap to top
self.selection_index = 0;
}
}
/// Get the currently selected model name.
pub fn selected_model(&self) -> Option<&str> {
self.request
.available_models
.get(self.selection_index)
.map(|s| s.as_str())
}
/// Check if the selection is the current model.
pub fn is_current(&self) -> bool {
self.selected_model() == Some(&self.request.current_model)
}
/// Format a model name for display (shorten long names).
pub fn format_model_name(model: &str) -> String {
// Shorten fireworks model names
if let Some(rest) = model.strip_prefix("fireworks::accounts/fireworks/models/") {
return format!("fireworks/{}", rest);
}
// Shorten other long prefixes
if let Some(rest) = model.strip_prefix("accounts/") {
return rest.to_string();
}
model.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_selector_navigation() {
let request = ModelSelectorRequest {
current_model: "gpt-4o".to_string(),
available_models: vec![
"claude-3-5-sonnet".to_string(),
"gpt-4o".to_string(),
"gpt-4o-mini".to_string(),
],
};
let mut overlay = ModelSelectorOverlay::new(request);
// Should start at gpt-4o index (1)
assert_eq!(overlay.selected_model(), Some("gpt-4o"));
// Navigate down
overlay.select_next();
assert_eq!(overlay.selected_model(), Some("gpt-4o-mini"));
// Navigate down (wrap)
overlay.select_next();
assert_eq!(overlay.selected_model(), Some("claude-3-5-sonnet"));
// Navigate up
overlay.select_prev();
assert_eq!(overlay.selected_model(), Some("gpt-4o-mini"));
}
#[test]
fn test_format_model_name() {
assert_eq!(
ModelSelectorOverlay::format_model_name("claude-3-5-sonnet-20241022"),
"claude-3-5-sonnet-20241022"
);
assert_eq!(
ModelSelectorOverlay::format_model_name(
"fireworks::accounts/fireworks/models/llama-v3p1-405b-instruct"
),
"fireworks/llama-v3p1-405b-instruct"
);
}
#[test]
fn test_empty_models() {
let request = ModelSelectorRequest {
current_model: "unknown".to_string(),
available_models: vec![],
};
let mut overlay = ModelSelectorOverlay::new(request);
assert_eq!(overlay.selected_model(), None);
// Should not panic
overlay.select_next();
overlay.select_prev();
}
}
-145
View File
@@ -1,145 +0,0 @@
//! Approval overlay modal.
use uuid::Uuid;
/// A request for user approval before executing a tool.
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
/// Unique ID for this request.
pub id: Uuid,
/// Name of the tool requesting approval.
pub tool_name: String,
/// Description of what the tool will do.
pub description: String,
/// Parameters being passed to the tool.
pub parameters: serde_json::Value,
/// Whether this is a destructive operation.
pub destructive: bool,
}
impl ApprovalRequest {
/// Create a new approval request.
pub fn new(
tool_name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
) -> Self {
Self {
id: Uuid::new_v4(),
tool_name: tool_name.into(),
description: description.into(),
parameters,
destructive: false,
}
}
/// Mark as destructive operation.
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
}
/// Current selection in the approval overlay.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalSelection {
/// Yes, approve this action.
Yes,
/// No, deny this action.
No,
/// Always approve this tool (for this session).
Always,
}
impl ApprovalSelection {
/// Get the next selection (cycling).
pub fn next(self) -> Self {
match self {
Self::Yes => Self::No,
Self::No => Self::Always,
Self::Always => Self::Yes,
}
}
/// Get the previous selection (cycling).
pub fn prev(self) -> Self {
match self {
Self::Yes => Self::Always,
Self::No => Self::Yes,
Self::Always => Self::No,
}
}
}
/// Approval overlay state.
pub struct ApprovalOverlay {
/// The request being shown.
pub request: ApprovalRequest,
/// Current selection.
pub selection: ApprovalSelection,
}
impl ApprovalOverlay {
/// Create a new approval overlay.
pub fn new(request: ApprovalRequest) -> Self {
Self {
request,
selection: ApprovalSelection::Yes,
}
}
/// Move selection left.
pub fn select_prev(&mut self) {
self.selection = self.selection.prev();
}
/// Move selection right.
pub fn select_next(&mut self) {
self.selection = self.selection.next();
}
/// Handle keyboard shortcut.
pub fn handle_shortcut(&mut self, c: char) -> Option<bool> {
match c.to_ascii_lowercase() {
'y' => Some(true),
'n' => Some(false),
'a' => {
self.selection = ApprovalSelection::Always;
Some(true)
}
_ => None,
}
}
/// Confirm the current selection.
pub fn confirm(&self) -> (bool, bool) {
match self.selection {
ApprovalSelection::Yes => (true, false),
ApprovalSelection::No => (false, false),
ApprovalSelection::Always => (true, true),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_approval_selection_cycle() {
let sel = ApprovalSelection::Yes;
assert_eq!(sel.next(), ApprovalSelection::No);
assert_eq!(sel.next().next(), ApprovalSelection::Always);
assert_eq!(sel.next().next().next(), ApprovalSelection::Yes);
}
#[test]
fn test_approval_shortcuts() {
let request = ApprovalRequest::new("test", "Test operation", serde_json::json!({}));
let mut overlay = ApprovalOverlay::new(request);
assert_eq!(overlay.handle_shortcut('y'), Some(true));
assert_eq!(overlay.handle_shortcut('n'), Some(false));
assert_eq!(overlay.handle_shortcut('x'), None);
}
}
-341
View File
@@ -1,341 +0,0 @@
//! TUI rendering with Ratatui.
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use crate::channels::cli::app::{AppState, InputMode, MessageRole, MessageStatus};
use crate::channels::cli::model_selector::ModelSelectorOverlay;
use crate::channels::cli::overlay::ApprovalSelection;
/// Render the entire UI.
pub fn render(frame: &mut Frame, app: &AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), // Messages
Constraint::Length(3), // Input
Constraint::Length(1), // Status
])
.split(frame.area());
render_messages(frame, app, chunks[0]);
render_input(frame, app, chunks[1]);
render_status(frame, app, chunks[2]);
// Render approval overlay if active
if app.mode == InputMode::Approval {
render_approval_overlay(frame, app);
}
}
/// Render the message history.
fn render_messages(frame: &mut Frame, app: &AppState, area: Rect) {
// Build all lines from all messages
let mut lines: Vec<Line> = Vec::new();
for msg in &app.messages {
let (prefix, style) = match msg.role {
MessageRole::User => ("You: ", Style::default().fg(Color::Cyan)),
MessageRole::Agent => ("Agent: ", Style::default().fg(Color::Green)),
MessageRole::System => (
"",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
};
let status_indicator = match msg.status {
Some(MessageStatus::Pending) => "",
Some(MessageStatus::InProgress) => " ⚙️",
Some(MessageStatus::Complete) => "",
Some(MessageStatus::Error) => "",
None => "",
};
// Split content by newlines and create a line for each
let content_lines: Vec<&str> = msg.content.lines().collect();
for (i, line_text) in content_lines.iter().enumerate() {
if i == 0 {
// First line gets the prefix
let line_content = if status_indicator.is_empty() {
format!("{}{}", prefix, line_text)
} else if content_lines.len() == 1 {
format!("{}{}{}", prefix, line_text, status_indicator)
} else {
format!("{}{}", prefix, line_text)
};
lines.push(Line::styled(line_content, style));
} else if i == content_lines.len() - 1 && !status_indicator.is_empty() {
// Last line gets status indicator
lines.push(Line::styled(
format!("{}{}", line_text, status_indicator),
style,
));
} else {
// Middle lines just get the content
lines.push(Line::styled(line_text.to_string(), style));
}
}
// Add empty line between messages for readability
lines.push(Line::from(""));
}
// Calculate scroll - show most recent messages
let visible_height = area.height.saturating_sub(2) as usize; // Account for borders
let total_lines = lines.len();
let scroll_offset = total_lines.saturating_sub(visible_height);
let text = Text::from(lines);
let messages = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title("Chat"))
.wrap(Wrap { trim: false })
.scroll((scroll_offset as u16, 0));
frame.render_widget(messages, area);
}
/// Render the input area (or model selector when in ModelSelector mode).
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) {
// In ModelSelector mode, render inline selector instead of input
if app.mode == InputMode::ModelSelector {
render_model_selector_inline(frame, app, area);
return;
}
let input_style = match app.mode {
InputMode::Editing => Style::default().fg(Color::Yellow),
InputMode::Normal => Style::default(),
InputMode::Approval | InputMode::ModelSelector => Style::default().fg(Color::DarkGray),
};
let buffer = app.composer.buffer();
let cursor = app.composer.cursor();
// Build the input text with cursor
let (before, after) = buffer.split_at(cursor.min(buffer.len()));
let cursor_char = after.chars().next().unwrap_or(' ');
let after_cursor = if after.is_empty() {
""
} else {
&after[cursor_char.len_utf8()..]
};
let input = Paragraph::new(Line::from(vec![
Span::raw(before),
Span::styled(
cursor_char.to_string(),
Style::default().bg(Color::White).fg(Color::Black),
),
Span::raw(after_cursor),
]))
.style(input_style)
.block(Block::default().borders(Borders::ALL).title("Input"));
frame.render_widget(input, area);
// Show cursor in editing mode
if app.mode == InputMode::Editing {
// Calculate cursor position accounting for the block border
let cursor_x = area.x + 1 + cursor as u16;
let cursor_y = area.y + 1;
frame.set_cursor_position((cursor_x, cursor_y));
}
}
/// Render inline model selector in the input area.
fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) {
let Some(ref overlay) = app.model_selector else {
return;
};
let models = overlay.models();
// Build horizontal list of models
let mut spans: Vec<Span> = Vec::new();
if models.is_empty() {
spans.push(Span::styled(
"Loading models...",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
));
} else {
for (i, model) in models.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
let display_name = ModelSelectorOverlay::format_model_name(model);
let is_selected = i == overlay.selection_index;
let is_current = model == &overlay.request.current_model;
let style = if is_selected {
Style::default().bg(Color::Blue).fg(Color::White)
} else if is_current {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_current { "" } else { " " };
spans.push(Span::styled(format!("{}{}", prefix, display_name), style));
}
}
let content = Paragraph::new(Line::from(spans))
.block(Block::default().borders(Borders::ALL).title(Span::styled(
"Select Model",
Style::default().fg(Color::Cyan),
)))
.scroll((
0,
calculate_model_scroll(overlay, area.width.saturating_sub(2)),
));
frame.render_widget(content, area);
}
/// Calculate horizontal scroll offset to keep selected model visible.
fn calculate_model_scroll(overlay: &ModelSelectorOverlay, visible_width: u16) -> u16 {
let models = overlay.models();
if models.is_empty() {
return 0;
}
// Estimate position of selected model (rough calculation)
let mut pos: u16 = 0;
for (i, model) in models.iter().enumerate() {
let name_len = ModelSelectorOverlay::format_model_name(model).len() as u16 + 3; // +3 for prefix and spacing
if i == overlay.selection_index {
// Check if selection is beyond visible area
if pos > visible_width {
return pos.saturating_sub(visible_width / 2);
}
return 0;
}
pos += name_len;
}
0
}
/// Render the status line.
fn render_status(frame: &mut Frame, app: &AppState, area: Rect) {
let status_text = if let Some(ref msg) = app.status_message {
msg.clone()
} else {
match app.mode {
InputMode::Normal | InputMode::Editing => {
let model = ModelSelectorOverlay::format_model_name(&app.current_model);
format!("{} | /model to switch", model)
}
InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel".to_string(),
InputMode::ModelSelector => "←/→ navigate, Enter=select, Esc=cancel".to_string(),
}
};
let status = Paragraph::new(status_text).style(Style::default().fg(Color::DarkGray));
frame.render_widget(status, area);
}
/// Render the approval overlay.
fn render_approval_overlay(frame: &mut Frame, app: &AppState) {
let Some(ref overlay) = app.approval else {
return;
};
let area = frame.area();
// Calculate overlay size and position
let overlay_width = (area.width * 60 / 100).min(60);
let overlay_height = 12;
let overlay_x = (area.width - overlay_width) / 2;
let overlay_y = (area.height - overlay_height) / 2;
let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
// Clear the area behind the overlay
frame.render_widget(Clear, overlay_area);
// Build overlay content
let title = if overlay.request.destructive {
"⚠️ Approval Required (Destructive)"
} else {
"Approval Required"
};
let title_style = if overlay.request.destructive {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
};
// Build the text content
let mut lines = vec![
Line::from(vec![
Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(&overlay.request.tool_name),
]),
Line::from(""),
Line::from(overlay.request.description.as_str()),
Line::from(""),
];
// Add parameters preview (truncated)
let params_str = serde_json::to_string_pretty(&overlay.request.parameters)
.unwrap_or_else(|_| "{}".to_string());
let params_preview: String = params_str.chars().take(100).collect();
lines.push(Line::from(vec![
Span::styled("Params: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(params_preview, Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(""));
// Add selection buttons
let yes_style = if overlay.selection == ApprovalSelection::Yes {
Style::default().bg(Color::Green).fg(Color::Black)
} else {
Style::default().fg(Color::Green)
};
let no_style = if overlay.selection == ApprovalSelection::No {
Style::default().bg(Color::Red).fg(Color::Black)
} else {
Style::default().fg(Color::Red)
};
let always_style = if overlay.selection == ApprovalSelection::Always {
Style::default().bg(Color::Blue).fg(Color::Black)
} else {
Style::default().fg(Color::Blue)
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(" [Y]es ", yes_style),
Span::raw(" "),
Span::styled(" [N]o ", no_style),
Span::raw(" "),
Span::styled(" [A]lways ", always_style),
]));
let content = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(title, title_style)),
)
.wrap(Wrap { trim: true });
frame.render_widget(content, overlay_area);
}
+23 -59
View File
@@ -1,6 +1,5 @@
//! HTTP webhook channel for receiving messages via HTTP POST.
use std::net::SocketAddr;
use std::sync::Arc;
use async_trait::async_trait;
@@ -32,8 +31,6 @@ struct HttpChannelState {
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
/// Pending responses keyed by message ID.
pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>,
/// Server shutdown signal.
shutdown_tx: RwLock<Option<oneshot::Sender<()>>>,
/// Expected webhook secret for authentication (if configured).
webhook_secret: Option<String>,
/// Fixed user ID for this HTTP channel.
@@ -74,7 +71,6 @@ impl HttpChannel {
state: Arc::new(HttpChannelState {
tx: RwLock::new(None),
pending_responses: RwLock::new(std::collections::HashMap::new()),
shutdown_tx: RwLock::new(None),
webhook_secret,
user_id,
rate_limit: tokio::sync::Mutex::new(RateLimitState {
@@ -84,6 +80,24 @@ impl HttpChannel {
}),
}
}
/// Return the channel's axum routes with state applied.
///
/// The returned `Router` shares the same `Arc<HttpChannelState>` that
/// `start()` later populates. Before `start()` is called the webhook
/// handler returns 503 ("Channel not started").
pub fn routes(&self) -> Router {
Router::new()
.route("/health", get(health_handler))
.route("/webhook", post(webhook_handler))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(self.state.clone())
}
/// Return the configured host and port for this channel.
pub fn addr(&self) -> (&str, u16) {
(&self.config.host, self.config.port)
}
}
#[derive(Debug, Deserialize)]
@@ -303,53 +317,11 @@ impl Channel for HttpChannel {
let (tx, rx) = mpsc::channel(256);
*self.state.tx.write().await = Some(tx);
let state = self.state.clone();
let host = self.config.host.clone();
let port = self.config.port;
// Parse address before spawning so we can return errors
let addr: SocketAddr =
format!("{}:{}", host, port)
.parse()
.map_err(|e| ChannelError::StartupFailed {
name: "http".to_string(),
reason: format!("Invalid address '{}:{}': {}", host, port, e),
})?;
// Bind listener before spawning so we can return errors
let listener =
tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| ChannelError::StartupFailed {
name: "http".to_string(),
reason: format!("Failed to bind to {}: {}", addr, e),
})?;
tracing::info!("HTTP channel listening on {}", addr);
// Create router
let app = Router::new()
.route("/health", get(health_handler))
.route("/webhook", post(webhook_handler))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state.clone());
// Create shutdown channel
let (shutdown_tx, shutdown_rx) = oneshot::channel();
*self.state.shutdown_tx.write().await = Some(shutdown_tx);
// Spawn server (listener is already bound, serve errors are logged)
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::info!("HTTP channel shutting down");
})
.await
{
tracing::error!("HTTP server error: {}", e);
}
});
tracing::info!(
"HTTP channel ready ({}:{})",
self.config.host,
self.config.port
);
Ok(Box::pin(ReceiverStream::new(rx)))
}
@@ -363,13 +335,10 @@ impl Channel for HttpChannel {
if let Some(tx) = self.state.pending_responses.write().await.remove(&msg.id) {
let _ = tx.send(response.content);
}
// For async webhooks, we'd need to make an HTTP callback here
// but that requires the caller to provide a callback URL
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
// Check if we have an active sender
if self.state.tx.read().await.is_some() {
Ok(())
} else {
@@ -380,11 +349,6 @@ impl Channel for HttpChannel {
}
async fn shutdown(&self) -> Result<(), ChannelError> {
// Send shutdown signal
if let Some(tx) = self.state.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
// Clear the message sender
*self.state.tx.write().await = None;
Ok(())
}
+5 -5
View File
@@ -9,9 +9,9 @@
//! ┌─────────────────────────────────────────────────────────────────────┐
//! │ ChannelManager │
//! │ │
//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
//! │ │ TuiChannel │ │ HttpChannel │ │ WasmChannel │ ...
//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
//! │ │ ReplChannel │ │ HttpChannel │ │ WasmChannel │ ... │
//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
//! │ │ │ │ │
//! │ └─────────────────┴─────────────────┘ │
//! │ │ │
@@ -28,16 +28,16 @@
//! See the [`wasm`] module for details.
mod channel;
pub mod cli;
mod http;
mod manager;
mod repl;
pub mod wasm;
pub mod web;
mod webhook_server;
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
pub use cli::{AppEvent, TuiChannel};
pub use http::HttpChannel;
pub use manager::ChannelManager;
pub use repl::ReplChannel;
pub use web::GatewayChannel;
pub use webhook_server::{WebhookServer, WebhookServerConfig};
+189 -47
View File
@@ -1,6 +1,8 @@
//! Interactive REPL channel for debugging and testing.
//! Interactive REPL channel with line editing and markdown rendering.
//!
//! Provides a command-line interface for interacting with the agent.
//! Provides the primary CLI interface for interacting with the agent.
//! Uses rustyline for line editing, history, and tab-completion.
//! Uses termimad for rendering markdown responses inline.
//!
//! ## Commands
//!
@@ -14,23 +16,113 @@
//! - `/new` - Start a new thread
//! - `yes`/`no`/`always` - Respond to tool approval prompts
use std::io::{self, BufRead, Write};
use std::borrow::Cow;
use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use rustyline::completion::Completer;
use rustyline::config::Config;
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{CompletionType, Editor, Helper};
use termimad::MadSkin;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
/// REPL channel for interactive agent debugging.
/// Slash commands available in the REPL.
const SLASH_COMMANDS: &[&str] = &[
"/help",
"/quit",
"/exit",
"/debug",
"/undo",
"/redo",
"/clear",
"/compact",
"/new",
"/interrupt",
];
/// Rustyline helper for slash-command tab completion.
struct ReplHelper;
impl Completer for ReplHelper {
type Candidate = String;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<String>)> {
if !line.starts_with('/') {
return Ok((0, vec![]));
}
let prefix = &line[..pos];
let matches: Vec<String> = SLASH_COMMANDS
.iter()
.filter(|cmd| cmd.starts_with(prefix))
.map(|cmd| cmd.to_string())
.collect();
Ok((0, matches))
}
}
impl Hinter for ReplHelper {
type Hint = String;
fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
if !line.starts_with('/') || pos < line.len() {
return None;
}
SLASH_COMMANDS
.iter()
.find(|cmd| cmd.starts_with(line) && **cmd != line)
.map(|cmd| cmd[line.len()..].to_string())
}
}
impl Highlighter for ReplHelper {
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
}
}
impl Validator for ReplHelper {}
impl Helper for ReplHelper {}
/// Build a termimad skin with our color scheme.
fn make_skin() -> MadSkin {
let mut skin = MadSkin::default();
skin.set_headers_fg(termimad::crossterm::style::Color::Yellow);
skin.bold.set_fg(termimad::crossterm::style::Color::White);
skin.italic
.set_fg(termimad::crossterm::style::Color::Magenta);
skin.inline_code
.set_fg(termimad::crossterm::style::Color::Green);
skin.code_block
.set_fg(termimad::crossterm::style::Color::Green);
skin
}
/// REPL channel with line editing and markdown rendering.
pub struct ReplChannel {
/// Optional single message to send (for -m flag).
single_message: Option<String>,
/// Debug mode flag (shared with input thread).
debug_mode: Arc<AtomicBool>,
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
is_streaming: Arc<AtomicBool>,
}
impl ReplChannel {
@@ -39,6 +131,7 @@ impl ReplChannel {
Self {
single_message: None,
debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)),
}
}
@@ -47,6 +140,7 @@ impl ReplChannel {
Self {
single_message: Some(message),
debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)),
}
}
@@ -64,7 +158,7 @@ impl Default for ReplChannel {
fn print_help() {
println!(
r#"
IronClaw REPL - Interactive debugging mode
IronClaw REPL
Commands:
/help Show this help message
@@ -90,6 +184,14 @@ Tips:
);
}
/// Get the history file path (~/.ironclaw/history).
fn history_path() -> std::path::PathBuf {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".ironclaw")
.join("history")
}
#[async_trait]
impl Channel for ReplChannel {
fn name(&self) -> &str {
@@ -102,38 +204,50 @@ impl Channel for ReplChannel {
let debug_mode = Arc::clone(&self.debug_mode);
std::thread::spawn(move || {
// If single message mode, send it and exit
// Single message mode: send it and return
if let Some(msg) = single_message {
let incoming = IncomingMessage::new("repl", "user", &msg);
if tx.blocking_send(incoming).is_err() {
return;
}
// Wait a bit for response, then the channel will close
let _ = tx.blocking_send(incoming);
return;
}
// Interactive REPL mode
let stdin = io::stdin();
let mut stdout = io::stdout();
// Set up rustyline
let config = Config::builder()
.history_ignore_dups(true)
.expect("valid config")
.auto_add_history(true)
.completion_type(CompletionType::List)
.build();
let mut rl = match Editor::with_config(config) {
Ok(editor) => editor,
Err(e) => {
eprintln!("Failed to initialize line editor: {e}");
return;
}
};
rl.set_helper(Some(ReplHelper));
// Load history
let hist_path = history_path();
if let Some(parent) = hist_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = rl.load_history(&hist_path);
println!("IronClaw REPL - Type /help for commands, /quit to exit");
println!();
loop {
// Print prompt
let prompt = if debug_mode.load(Ordering::Relaxed) {
"[debug] > "
"\x1b[33m[debug]\x1b[0m \x1b[36m>\x1b[0m "
} else {
"> "
"\x1b[36m>\x1b[0m "
};
print!("{}", prompt);
let _ = stdout.flush();
// Read line
let mut line = String::new();
match stdin.lock().read_line(&mut line) {
Ok(0) => break, // EOF
Ok(_) => {
match rl.readline(prompt) {
Ok(line) => {
let line = line.trim();
if line.is_empty() {
continue;
@@ -164,9 +278,26 @@ impl Channel for ReplChannel {
break;
}
}
Err(_) => break,
Err(ReadlineError::Interrupted) => {
// Ctrl+C: send /interrupt
let msg = IncomingMessage::new("repl", "user", "/interrupt");
if tx.blocking_send(msg).is_err() {
break;
}
}
Err(ReadlineError::Eof) => {
// Ctrl+D: quit
break;
}
Err(e) => {
eprintln!("Input error: {e}");
break;
}
}
}
// Save history on exit
let _ = rl.save_history(&history_path());
});
Ok(Box::pin(ReceiverStream::new(rx)))
@@ -177,8 +308,23 @@ impl Channel for ReplChannel {
_msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
// If we were streaming, the content was already printed via StreamChunk.
// Just finish the line and reset.
if self.is_streaming.swap(false, Ordering::Relaxed) {
println!();
println!();
return Ok(());
}
// Render markdown
let skin = make_skin();
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
println!();
println!("{}", response.content);
print!("{text}");
println!();
Ok(())
}
@@ -193,37 +339,27 @@ impl Channel for ReplChannel {
match status {
StatusUpdate::Thinking(msg) => {
if debug {
eprintln!("\x1b[90m[thinking] {}\x1b[0m", msg);
} else {
eprint!(".");
let _ = io::stderr().flush();
eprintln!("\x1b[90m[thinking] {msg}\x1b[0m");
}
}
StatusUpdate::ToolStarted { name } => {
if debug {
eprintln!("\x1b[33m[tool:start] {}\x1b[0m", name);
} else {
eprintln!("\x1b[33m⚡ {}\x1b[0m", name);
}
eprintln!(" \x1b[33m>> {name}\x1b[0m");
}
StatusUpdate::ToolCompleted { name, success } => {
if debug {
if success {
eprintln!("\x1b[32m[tool:done] {}\x1b[0m", name);
} else {
eprintln!("\x1b[31m[tool:fail] {}\x1b[0m", name);
}
} else if !success {
eprintln!("\x1b[31m✗ {} failed\x1b[0m", name);
if success {
eprintln!(" \x1b[32m<< {name}\x1b[0m");
} else {
eprintln!(" \x1b[31m<< {name} failed\x1b[0m");
}
}
StatusUpdate::StreamChunk(chunk) => {
print!("{}", chunk);
self.is_streaming.store(true, Ordering::Relaxed);
print!("{chunk}");
let _ = io::stdout().flush();
}
StatusUpdate::Status(msg) => {
if debug || msg.contains("approval") || msg.contains("Approval") {
eprintln!("\x1b[90m[status] {}\x1b[0m", msg);
eprintln!("\x1b[90m[status] {msg}\x1b[0m");
}
}
}
@@ -235,9 +371,15 @@ impl Channel for ReplChannel {
_user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
println!();
println!("\x1b[36m[notification]\x1b[0m {}", response.content);
println!();
let skin = make_skin();
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
eprintln!("\x1b[36m[notification]\x1b[0m");
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
eprint!("{text}");
eprintln!();
Ok(())
}
+1 -3
View File
@@ -95,9 +95,7 @@ pub use loader::{
DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir,
discover_channels,
};
pub use router::{
RegisteredEndpoint, WasmChannelRouter, WasmChannelServer, create_wasm_channel_router,
};
pub use router::{RegisteredEndpoint, WasmChannelRouter, create_wasm_channel_router};
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
pub use schema::{
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
-51
View File
@@ -4,7 +4,6 @@
//! registered paths. Handles secret validation at the host level.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
@@ -469,56 +468,6 @@ pub fn create_wasm_channel_router(
.with_state(state)
}
/// HTTP server for WASM channel webhooks.
pub struct WasmChannelServer {
router: Arc<WasmChannelRouter>,
extension_manager: Option<Arc<crate::extensions::ExtensionManager>>,
}
impl WasmChannelServer {
/// Create a new server.
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
Self {
router,
extension_manager: None,
}
}
/// Set the extension manager for OAuth callback handling.
pub fn with_extension_manager(
mut self,
manager: Arc<crate::extensions::ExtensionManager>,
) -> Self {
self.extension_manager = Some(manager);
self
}
/// Start the HTTP server.
///
/// Returns a handle that can be used to shut down the server.
pub async fn start(
&self,
addr: SocketAddr,
) -> Result<tokio::task::JoinHandle<()>, std::io::Error> {
let app = create_wasm_channel_router(self.router.clone(), self.extension_manager.clone());
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(
addr = %addr,
"WASM channel HTTP server started"
);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
tracing::error!("WASM channel HTTP server error: {}", e);
}
});
Ok(handle)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
+92
View File
@@ -0,0 +1,92 @@
//! Unified HTTP server for all webhook routes.
//!
//! Composes route fragments from HttpChannel, WASM channel router, etc.
//! into a single axum server. Channels define routes but never spawn servers.
use std::net::SocketAddr;
use axum::Router;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use crate::error::ChannelError;
/// Configuration for the unified webhook server.
pub struct WebhookServerConfig {
/// Address to bind the server to.
pub addr: SocketAddr,
}
/// A single HTTP server that hosts all webhook routes.
///
/// Channels contribute route fragments via `add_routes()`, then a single
/// `start()` call binds the listener and spawns the server task.
pub struct WebhookServer {
config: WebhookServerConfig,
routes: Vec<Router>,
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
}
impl WebhookServer {
/// Create a new webhook server with the given bind address.
pub fn new(config: WebhookServerConfig) -> Self {
Self {
config,
routes: Vec::new(),
shutdown_tx: None,
handle: None,
}
}
/// Accumulate a route fragment. Each fragment should already have its
/// state applied via `.with_state()`.
pub fn add_routes(&mut self, router: Router) {
self.routes.push(router);
}
/// Bind the listener, merge all route fragments, and spawn the server.
pub async fn start(&mut self) -> Result<(), ChannelError> {
let mut app = Router::new();
for fragment in self.routes.drain(..) {
app = app.merge(fragment);
}
let listener = tokio::net::TcpListener::bind(self.config.addr)
.await
.map_err(|e| ChannelError::StartupFailed {
name: "webhook_server".to_string(),
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
})?;
tracing::info!("Webhook server listening on {}", self.config.addr);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
self.shutdown_tx = Some(shutdown_tx);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::info!("Webhook server shutting down");
})
.await
{
tracing::error!("Webhook server error: {}", e);
}
});
self.handle = Some(handle);
Ok(())
}
/// Signal graceful shutdown and wait for the server task to finish.
pub async fn shutdown(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.handle.take() {
let _ = handle.await;
}
}
}