use anyhow::Result;
use oxi_agent::{Agent, AgentEvent};
use oxi_tui::{
ChatMessageDisplay, ChatView, ContentBlockDisplay, Input, MessageRole,
Surface, Theme,
};
use oxi_tui::component::Component;
use std::sync::Arc;
use tokio::sync::mpsc;
#[derive(Debug)]
enum UiEvent {
Start,
Thinking,
TextDelta(String),
ToolCall { id: String, name: String, arguments: String },
ToolResult { tool_name: String, content: String, is_error: bool },
Complete,
Error(String),
}
pub async fn run_tui_interactive(app: crate::App) -> Result<()> {
let theme = Theme::dark();
let agent: Arc<Agent> = app.agent();
let (ui_tx, mut ui_rx) = mpsc::channel::<UiEvent>(256);
let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
let agent_for_thread: Arc<Agent> = Arc::clone(&agent);
let ui_tx_for_thread = ui_tx.clone();
let agent_handle = std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to build agent runtime");
rt.block_on(async {
let local = tokio::task::LocalSet::new();
local.run_until(async {
while let Some(prompt) = prompt_rx.recv().await {
let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(256);
let ui_fwd = ui_tx_for_thread.clone();
let event_forwarder = tokio::task::spawn_local(async move {
while let Some(event) = event_rx.recv().await {
let ui_event = match event {
AgentEvent::Start { .. } => UiEvent::Start,
AgentEvent::Thinking => UiEvent::Thinking,
AgentEvent::TextChunk { text } => UiEvent::TextDelta(text),
AgentEvent::ToolCall { tool_call } => UiEvent::ToolCall {
id: tool_call.id,
name: tool_call.name,
arguments: tool_call.arguments.to_string(),
},
AgentEvent::ToolStart { tool_name, .. } => UiEvent::TextDelta(
format!("\n\u{2699} Running: {}...\n", tool_name),
),
AgentEvent::ToolComplete { result } => UiEvent::ToolResult {
tool_name: String::new(),
content: result.content.chars().take(500).collect(),
is_error: false,
},
AgentEvent::ToolError { error, .. } => UiEvent::ToolResult {
tool_name: String::new(),
content: error.clone(),
is_error: true,
},
AgentEvent::Complete { .. } => UiEvent::Complete,
AgentEvent::Error { message } => UiEvent::Error(message),
_ => continue,
};
if ui_fwd.send(ui_event).await.is_err() {
break;
}
}
});
let a: Arc<Agent> = Arc::clone(&agent_for_thread);
let _ = a.run_with_channel(prompt, event_tx).await;
let _ = event_forwarder.await;
}
}).await;
});
});
let mut chat_view = ChatView::new(theme.clone());
let mut input = Input::with_placeholder("Type a message... (Ctrl+C to quit)");
input.on_focus();
let mut is_agent_busy = false;
use std::io::{self, Write};
crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture)?;
let mut running = true;
while running {
let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
let input_height: u16 = 3;
let chat_height = height.saturating_sub(input_height);
let mut surface = Surface::new(width, height);
let chat_area = oxi_tui::Rect::new(0, 0, width, chat_height);
chat_view.render(&mut surface, chat_area);
if chat_height < height {
let sep_y = chat_height;
for col in 0..width {
let cell = oxi_tui::Cell::new('\u{2500}').with_fg(theme.colors.border);
surface.set(sep_y, col, cell);
}
surface.set(
chat_height + 1, 0,
oxi_tui::Cell::new('\u{276F}').with_fg(theme.colors.primary),
);
let input_area = oxi_tui::Rect::new(2, chat_height + 1, width.saturating_sub(4), 1);
input.render(&mut surface, input_area);
let status_text = if is_agent_busy {
"\u{25CF} thinking..."
} else {
""
};
let status_fg = if is_agent_busy {
theme.colors.warning
} else {
theme.colors.muted
};
for (i, ch) in status_text.chars().enumerate() {
let col = width as usize - status_text.len() + i;
if col < width as usize {
surface.set(
chat_height + 2, col as u16,
oxi_tui::Cell::new(ch).with_fg(status_fg),
);
}
}
}
render_surface_to_terminal(&surface, width, height);
io::stdout().flush()?;
let timeout = std::time::Duration::from_millis(33);
if crossterm::event::poll(timeout)? {
let event = crossterm::event::read()?;
match event {
crossterm::event::Event::Key(key) => {
match key.code {
crossterm::event::KeyCode::Enter => {
if !is_agent_busy {
let value = input.value().to_string();
if !value.is_empty() {
chat_view.add_message(ChatMessageDisplay {
role: MessageRole::User,
content_blocks: vec![ContentBlockDisplay::Text {
content: value.clone(),
}],
timestamp: now_millis(),
});
chat_view.start_streaming();
is_agent_busy = true;
let _ = prompt_tx.send(value).await;
input.clear();
}
}
}
crossterm::event::KeyCode::Char('c')
if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
running = false;
}
crossterm::event::KeyCode::PageUp => {
chat_view.scroll_up(10);
}
crossterm::event::KeyCode::PageDown => {
chat_view.scroll_down(10);
}
_ => {
if let Some(tui_event) = convert_key_event(key) {
input.handle_event(&tui_event);
}
}
}
}
crossterm::event::Event::Mouse(mouse) => {
match mouse.kind {
crossterm::event::MouseEventKind::ScrollUp => {
if mouse.row < chat_height {
chat_view.scroll_up(3);
}
}
crossterm::event::MouseEventKind::ScrollDown => {
if mouse.row < chat_height {
chat_view.scroll_down(3);
}
}
_ => {}
}
}
crossterm::event::Event::Resize(_, _) => {
}
_ => {}
}
}
while let Ok(ui_event) = ui_rx.try_recv() {
match ui_event {
UiEvent::Start => {}
UiEvent::Thinking => {
chat_view.stream_thinking_start();
}
UiEvent::TextDelta(text) => {
chat_view.stream_text_delta(&text);
}
UiEvent::ToolCall { id, name, arguments } => {
chat_view.stream_thinking_end();
chat_view.stream_tool_call(id, name, arguments);
}
UiEvent::ToolResult { tool_name, content, is_error } => {
chat_view.stream_tool_result(tool_name, content, is_error);
}
UiEvent::Complete => {
chat_view.stream_thinking_end();
chat_view.finish_streaming();
is_agent_busy = false;
}
UiEvent::Error(msg) => {
chat_view.finish_streaming_error(&msg);
is_agent_busy = false;
}
}
}
chat_view.scroll_to_bottom();
}
drop(prompt_tx);
let _ = agent_handle.join();
crossterm::execute!(io::stdout(), crossterm::cursor::Show)?;
crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture)?;
crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
io::stdout().flush()?;
Ok(())
}
fn render_surface_to_terminal(surface: &Surface, width: u16, height: u16) {
print!("\x1b[?2026h");
print!("\x1b[H");
let mut last_fg = oxi_tui::Color::Default;
let mut last_bg = oxi_tui::Color::Default;
let mut last_bold = false;
let mut last_italic = false;
let mut last_underline = false;
let mut last_strike = false;
for row in 0..height {
if row > 0 {
print!("\r\n");
}
for col in 0..width {
if let Some(cell) = surface.get(row, col) {
let fg_changed = cell.fg != last_fg;
let bg_changed = cell.bg != last_bg;
let attrs_changed = cell.attrs.bold != last_bold
|| cell.attrs.italic != last_italic
|| cell.attrs.underline != last_underline
|| cell.attrs.strikethrough != last_strike;
if fg_changed || bg_changed || attrs_changed {
print!("\x1b[0m");
match cell.fg {
oxi_tui::Color::Default => {}
oxi_tui::Color::Black => print!("\x1b[30m"),
oxi_tui::Color::Red => print!("\x1b[31m"),
oxi_tui::Color::Green => print!("\x1b[32m"),
oxi_tui::Color::Yellow => print!("\x1b[33m"),
oxi_tui::Color::Blue => print!("\x1b[34m"),
oxi_tui::Color::Magenta => print!("\x1b[35m"),
oxi_tui::Color::Cyan => print!("\x1b[36m"),
oxi_tui::Color::White => print!("\x1b[37m"),
oxi_tui::Color::Indexed(n) => print!("\x1b[38;5;{}m", n),
oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[38;2;{};{};{}m", r, g, b),
}
match cell.bg {
oxi_tui::Color::Default => {}
oxi_tui::Color::Black => print!("\x1b[40m"),
oxi_tui::Color::Red => print!("\x1b[41m"),
oxi_tui::Color::Green => print!("\x1b[42m"),
oxi_tui::Color::Yellow => print!("\x1b[43m"),
oxi_tui::Color::Blue => print!("\x1b[44m"),
oxi_tui::Color::Magenta => print!("\x1b[45m"),
oxi_tui::Color::Cyan => print!("\x1b[46m"),
oxi_tui::Color::White => print!("\x1b[47m"),
oxi_tui::Color::Indexed(n) => print!("\x1b[48;5;{}m", n),
oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[48;2;{};{};{}m", r, g, b),
}
if cell.attrs.bold { print!("\x1b[1m"); }
if cell.attrs.italic { print!("\x1b[3m"); }
if cell.attrs.underline { print!("\x1b[4m"); }
if cell.attrs.strikethrough { print!("\x1b[9m"); }
last_fg = cell.fg;
last_bg = cell.bg;
last_bold = cell.attrs.bold;
last_italic = cell.attrs.italic;
last_underline = cell.attrs.underline;
last_strike = cell.attrs.strikethrough;
}
print!("{}", cell.char);
} else {
print!(" ");
}
}
}
print!("\x1b[0m");
print!("\x1b[?2026l"); }
fn convert_key_event(key: crossterm::event::KeyEvent) -> Option<oxi_tui::Event> {
use oxi_tui::event::KeyCode as KC;
let code = match key.code {
crossterm::event::KeyCode::Enter => return None,
crossterm::event::KeyCode::Char('c')
if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
return None
}
crossterm::event::KeyCode::Esc => KC::Escape,
crossterm::event::KeyCode::Tab => KC::Tab,
crossterm::event::KeyCode::Backspace => KC::Backspace,
crossterm::event::KeyCode::Delete => KC::Delete,
crossterm::event::KeyCode::Up => KC::Up,
crossterm::event::KeyCode::Down => KC::Down,
crossterm::event::KeyCode::Left => KC::Left,
crossterm::event::KeyCode::Right => KC::Right,
crossterm::event::KeyCode::Home => KC::Home,
crossterm::event::KeyCode::End => KC::End,
crossterm::event::KeyCode::Char(c) => KC::Char(c),
crossterm::event::KeyCode::F(n) => KC::F(n),
_ => return None,
};
let modifiers = oxi_tui::KeyModifiers {
shift: key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT),
ctrl: key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL),
alt: key.modifiers.contains(crossterm::event::KeyModifiers::ALT),
meta: key.modifiers.contains(crossterm::event::KeyModifiers::META),
};
Some(oxi_tui::Event::Key(oxi_tui::KeyEvent::with_modifiers(code, modifiers)))
}
fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}