1use anyhow::Result;
7use oxi_agent::{Agent, AgentEvent};
8use oxi_tui::{
9 ChatMessageDisplay, ChatView, ContentBlockDisplay, Input, MessageRole,
10 Surface, Theme,
11};
12use oxi_tui::component::Component;
13use std::sync::Arc;
14use tokio::sync::mpsc;
15
16#[derive(Debug)]
18enum UiEvent {
19 Start,
21 Thinking,
23 TextDelta(String),
25 ToolCall { id: String, name: String, arguments: String },
27 ToolResult { tool_name: String, content: String, is_error: bool },
29 Complete,
31 Error(String),
33}
34
35pub async fn run_tui_interactive(app: crate::App) -> Result<()> {
37 let theme = Theme::dark();
38 let agent: Arc<Agent> = app.agent();
39
40 let (ui_tx, mut ui_rx) = mpsc::channel::<UiEvent>(256);
42
43 let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
45
46 let agent_for_thread: Arc<Agent> = Arc::clone(&agent);
50 let ui_tx_for_thread = ui_tx.clone();
51 let agent_handle = std::thread::spawn(move || {
52 let rt = tokio::runtime::Builder::new_current_thread()
53 .enable_all()
54 .build()
55 .expect("Failed to build agent runtime");
56 rt.block_on(async {
57 let local = tokio::task::LocalSet::new();
58 local.run_until(async {
59 while let Some(prompt) = prompt_rx.recv().await {
60 let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(256);
61
62 let ui_fwd = ui_tx_for_thread.clone();
64 let event_forwarder = tokio::task::spawn_local(async move {
65 while let Some(event) = event_rx.recv().await {
66 let ui_event = match event {
67 AgentEvent::Start { .. } => UiEvent::Start,
68 AgentEvent::Thinking => UiEvent::Thinking,
69 AgentEvent::TextChunk { text } => UiEvent::TextDelta(text),
70 AgentEvent::ToolCall { tool_call } => UiEvent::ToolCall {
71 id: tool_call.id,
72 name: tool_call.name,
73 arguments: tool_call.arguments.to_string(),
74 },
75 AgentEvent::ToolStart { tool_name, .. } => UiEvent::TextDelta(
76 format!("\n\u{2699} Running: {}...\n", tool_name),
77 ),
78 AgentEvent::ToolComplete { result } => UiEvent::ToolResult {
79 tool_name: String::new(),
80 content: result.content.chars().take(500).collect(),
81 is_error: false,
82 },
83 AgentEvent::ToolError { error, .. } => UiEvent::ToolResult {
84 tool_name: String::new(),
85 content: error.clone(),
86 is_error: true,
87 },
88 AgentEvent::Complete { .. } => UiEvent::Complete,
89 AgentEvent::Error { message } => UiEvent::Error(message),
90 _ => continue,
91 };
92 if ui_fwd.send(ui_event).await.is_err() {
93 break;
94 }
95 }
96 });
97
98 let a: Arc<Agent> = Arc::clone(&agent_for_thread);
100 let _ = a.run_with_channel(prompt, event_tx).await;
101 let _ = event_forwarder.await;
102 }
103 }).await;
104 });
105 });
106
107 let mut chat_view = ChatView::new(theme.clone());
109 let mut input = Input::with_placeholder("Type a message... (Ctrl+C to quit)");
110 input.on_focus();
111 let mut is_agent_busy = false;
112
113 use std::io::{self, Write};
114
115 crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?;
117 crossterm::execute!(io::stdout(), crossterm::cursor::Hide)?;
118 crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture)?;
119
120 let mut running = true;
121
122 while running {
123 let (width, height) = crossterm::terminal::size().unwrap_or((80, 24));
125 let input_height: u16 = 3;
126 let chat_height = height.saturating_sub(input_height);
127
128 let mut surface = Surface::new(width, height);
130
131 let chat_area = oxi_tui::Rect::new(0, 0, width, chat_height);
133 chat_view.render(&mut surface, chat_area);
134
135 if chat_height < height {
137 let sep_y = chat_height;
138 for col in 0..width {
139 let cell = oxi_tui::Cell::new('\u{2500}').with_fg(theme.colors.border);
140 surface.set(sep_y, col, cell);
141 }
142
143 surface.set(
145 chat_height + 1, 0,
146 oxi_tui::Cell::new('\u{276F}').with_fg(theme.colors.primary),
147 );
148
149 let input_area = oxi_tui::Rect::new(2, chat_height + 1, width.saturating_sub(4), 1);
151 input.render(&mut surface, input_area);
152
153 let status_text = if is_agent_busy {
155 "\u{25CF} thinking..."
156 } else {
157 ""
158 };
159 let status_fg = if is_agent_busy {
160 theme.colors.warning
161 } else {
162 theme.colors.muted
163 };
164 for (i, ch) in status_text.chars().enumerate() {
165 let col = width as usize - status_text.len() + i;
166 if col < width as usize {
167 surface.set(
168 chat_height + 2, col as u16,
169 oxi_tui::Cell::new(ch).with_fg(status_fg),
170 );
171 }
172 }
173 }
174
175 render_surface_to_terminal(&surface, width, height);
177 io::stdout().flush()?;
178
179 let timeout = std::time::Duration::from_millis(33);
181
182 if crossterm::event::poll(timeout)? {
183 let event = crossterm::event::read()?;
184 match event {
185 crossterm::event::Event::Key(key) => {
186 match key.code {
187 crossterm::event::KeyCode::Enter => {
188 if !is_agent_busy {
189 let value = input.value().to_string();
190 if !value.is_empty() {
191 chat_view.add_message(ChatMessageDisplay {
193 role: MessageRole::User,
194 content_blocks: vec![ContentBlockDisplay::Text {
195 content: value.clone(),
196 }],
197 timestamp: now_millis(),
198 });
199
200 chat_view.start_streaming();
202 is_agent_busy = true;
203
204 let _ = prompt_tx.send(value).await;
206
207 input.clear();
209 }
210 }
211 }
212 crossterm::event::KeyCode::Char('c')
213 if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) =>
214 {
215 running = false;
216 }
217 crossterm::event::KeyCode::PageUp => {
218 chat_view.scroll_up(10);
219 }
220 crossterm::event::KeyCode::PageDown => {
221 chat_view.scroll_down(10);
222 }
223 _ => {
224 if let Some(tui_event) = convert_key_event(key) {
226 input.handle_event(&tui_event);
227 }
228 }
229 }
230 }
231 crossterm::event::Event::Mouse(mouse) => {
232 match mouse.kind {
233 crossterm::event::MouseEventKind::ScrollUp => {
234 if mouse.row < chat_height {
235 chat_view.scroll_up(3);
236 }
237 }
238 crossterm::event::MouseEventKind::ScrollDown => {
239 if mouse.row < chat_height {
240 chat_view.scroll_down(3);
241 }
242 }
243 _ => {}
244 }
245 }
246 crossterm::event::Event::Resize(_, _) => {
247 }
249 _ => {}
250 }
251 }
252
253 while let Ok(ui_event) = ui_rx.try_recv() {
255 match ui_event {
256 UiEvent::Start => {}
257 UiEvent::Thinking => {
258 chat_view.stream_thinking_start();
259 }
260 UiEvent::TextDelta(text) => {
261 chat_view.stream_text_delta(&text);
262 }
263 UiEvent::ToolCall { id, name, arguments } => {
264 chat_view.stream_thinking_end();
265 chat_view.stream_tool_call(id, name, arguments);
266 }
267 UiEvent::ToolResult { tool_name, content, is_error } => {
268 chat_view.stream_tool_result(tool_name, content, is_error);
269 }
270 UiEvent::Complete => {
271 chat_view.stream_thinking_end();
272 chat_view.finish_streaming();
273 is_agent_busy = false;
274 }
275 UiEvent::Error(msg) => {
276 chat_view.finish_streaming_error(&msg);
277 is_agent_busy = false;
278 }
279 }
280 }
281
282 chat_view.scroll_to_bottom();
284 }
285
286 drop(prompt_tx);
288 let _ = agent_handle.join();
289 crossterm::execute!(io::stdout(), crossterm::cursor::Show)?;
290 crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture)?;
291 crossterm::execute!(io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
292 io::stdout().flush()?;
293
294 Ok(())
295}
296
297fn render_surface_to_terminal(surface: &Surface, width: u16, height: u16) {
303 print!("\x1b[?2026h");
305 print!("\x1b[H"); let mut last_fg = oxi_tui::Color::Default;
308 let mut last_bg = oxi_tui::Color::Default;
309 let mut last_bold = false;
310 let mut last_italic = false;
311 let mut last_underline = false;
312 let mut last_strike = false;
313
314 for row in 0..height {
315 if row > 0 {
316 print!("\r\n");
317 }
318 for col in 0..width {
319 if let Some(cell) = surface.get(row, col) {
320 let fg_changed = cell.fg != last_fg;
322 let bg_changed = cell.bg != last_bg;
323 let attrs_changed = cell.attrs.bold != last_bold
324 || cell.attrs.italic != last_italic
325 || cell.attrs.underline != last_underline
326 || cell.attrs.strikethrough != last_strike;
327
328 if fg_changed || bg_changed || attrs_changed {
329 print!("\x1b[0m");
330
331 match cell.fg {
333 oxi_tui::Color::Default => {}
334 oxi_tui::Color::Black => print!("\x1b[30m"),
335 oxi_tui::Color::Red => print!("\x1b[31m"),
336 oxi_tui::Color::Green => print!("\x1b[32m"),
337 oxi_tui::Color::Yellow => print!("\x1b[33m"),
338 oxi_tui::Color::Blue => print!("\x1b[34m"),
339 oxi_tui::Color::Magenta => print!("\x1b[35m"),
340 oxi_tui::Color::Cyan => print!("\x1b[36m"),
341 oxi_tui::Color::White => print!("\x1b[37m"),
342 oxi_tui::Color::Indexed(n) => print!("\x1b[38;5;{}m", n),
343 oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[38;2;{};{};{}m", r, g, b),
344 }
345
346 match cell.bg {
348 oxi_tui::Color::Default => {}
349 oxi_tui::Color::Black => print!("\x1b[40m"),
350 oxi_tui::Color::Red => print!("\x1b[41m"),
351 oxi_tui::Color::Green => print!("\x1b[42m"),
352 oxi_tui::Color::Yellow => print!("\x1b[43m"),
353 oxi_tui::Color::Blue => print!("\x1b[44m"),
354 oxi_tui::Color::Magenta => print!("\x1b[45m"),
355 oxi_tui::Color::Cyan => print!("\x1b[46m"),
356 oxi_tui::Color::White => print!("\x1b[47m"),
357 oxi_tui::Color::Indexed(n) => print!("\x1b[48;5;{}m", n),
358 oxi_tui::Color::Rgb(r, g, b) => print!("\x1b[48;2;{};{};{}m", r, g, b),
359 }
360
361 if cell.attrs.bold { print!("\x1b[1m"); }
362 if cell.attrs.italic { print!("\x1b[3m"); }
363 if cell.attrs.underline { print!("\x1b[4m"); }
364 if cell.attrs.strikethrough { print!("\x1b[9m"); }
365
366 last_fg = cell.fg;
367 last_bg = cell.bg;
368 last_bold = cell.attrs.bold;
369 last_italic = cell.attrs.italic;
370 last_underline = cell.attrs.underline;
371 last_strike = cell.attrs.strikethrough;
372 }
373
374 print!("{}", cell.char);
375 } else {
376 print!(" ");
377 }
378 }
379 }
380
381 print!("\x1b[0m");
382 print!("\x1b[?2026l"); }
384
385fn convert_key_event(key: crossterm::event::KeyEvent) -> Option<oxi_tui::Event> {
392 use oxi_tui::event::KeyCode as KC;
393
394 let code = match key.code {
395 crossterm::event::KeyCode::Enter => return None,
396 crossterm::event::KeyCode::Char('c')
397 if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) =>
398 {
399 return None
400 }
401 crossterm::event::KeyCode::Esc => KC::Escape,
402 crossterm::event::KeyCode::Tab => KC::Tab,
403 crossterm::event::KeyCode::Backspace => KC::Backspace,
404 crossterm::event::KeyCode::Delete => KC::Delete,
405 crossterm::event::KeyCode::Up => KC::Up,
406 crossterm::event::KeyCode::Down => KC::Down,
407 crossterm::event::KeyCode::Left => KC::Left,
408 crossterm::event::KeyCode::Right => KC::Right,
409 crossterm::event::KeyCode::Home => KC::Home,
410 crossterm::event::KeyCode::End => KC::End,
411 crossterm::event::KeyCode::Char(c) => KC::Char(c),
412 crossterm::event::KeyCode::F(n) => KC::F(n),
413 _ => return None,
414 };
415
416 let modifiers = oxi_tui::KeyModifiers {
417 shift: key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT),
418 ctrl: key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL),
419 alt: key.modifiers.contains(crossterm::event::KeyModifiers::ALT),
420 meta: key.modifiers.contains(crossterm::event::KeyModifiers::META),
421 };
422
423 Some(oxi_tui::Event::Key(oxi_tui::KeyEvent::with_modifiers(code, modifiers)))
424}
425
426fn now_millis() -> i64 {
428 std::time::SystemTime::now()
429 .duration_since(std::time::UNIX_EPOCH)
430 .unwrap_or_default()
431 .as_millis() as i64
432}