Skip to main content

oxi/
tui_interactive.rs

1//! TUI-based interactive mode using oxi-tui components.
2//!
3//! Wires together ChatView, Input, Markdown, and Image components
4//! into a cohesive terminal chat experience.
5
6use 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/// Messages sent from the agent task to the TUI event loop.
17#[derive(Debug)]
18enum UiEvent {
19    /// Agent started.
20    Start,
21    /// Agent is thinking.
22    Thinking,
23    /// Text delta from agent streaming.
24    TextDelta(String),
25    /// Tool call started.
26    ToolCall { id: String, name: String, arguments: String },
27    /// Tool completed.
28    ToolResult { tool_name: String, content: String, is_error: bool },
29    /// Agent response complete.
30    Complete,
31    /// Agent error.
32    Error(String),
33}
34
35/// Run the TUI-based interactive mode.
36pub async fn run_tui_interactive(app: crate::App) -> Result<()> {
37    let theme = Theme::dark();
38    let agent: Arc<Agent> = app.agent();
39
40    // Channel for agent → UI communication
41    let (ui_tx, mut ui_rx) = mpsc::channel::<UiEvent>(256);
42
43    // Channel for user input → agent execution
44    let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
45
46    // Spawn agent worker on a dedicated thread with its own LocalSet runtime.
47    // The agent uses non-Send futures internally, so it needs a single-threaded
48    // runtime with LocalSet.
49    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                    // Forward agent events to UI
63                    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                    // Run agent with channel on the local set
99                    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    // Build TUI components
108    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    // Enter alternate screen
116    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        // Get terminal size
124        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        // Create surface and render layout
129        let mut surface = Surface::new(width, height);
130
131        // Render chat view in upper area
132        let chat_area = oxi_tui::Rect::new(0, 0, width, chat_height);
133        chat_view.render(&mut surface, chat_area);
134
135        // Render separator
136        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            // Render prompt indicator
144            surface.set(
145                chat_height + 1, 0,
146                oxi_tui::Cell::new('\u{276F}').with_fg(theme.colors.primary),
147            );
148
149            // Render input area
150            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            // Status indicator in bottom-right
154            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
176        render_surface_to_terminal(&surface, width, height);
177        io::stdout().flush()?;
178
179        // Poll for events with timeout (~30fps)
180        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                                    // Add user message to chat view
192                                    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                                    // Start agent streaming
201                                    chat_view.start_streaming();
202                                    is_agent_busy = true;
203
204                                    // Send prompt to agent worker
205                                    let _ = prompt_tx.send(value).await;
206
207                                    // Clear input
208                                    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                            // Forward keyboard events to input component
225                            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                    // Handled on next render cycle via crossterm::terminal::size()
248                }
249                _ => {}
250            }
251        }
252
253        // Drain agent events from the channel
254        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        // Auto-scroll to bottom
283        chat_view.scroll_to_bottom();
284    }
285
286    // Cleanup
287    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
297// ---------------------------------------------------------------------------
298// Surface rendering
299// ---------------------------------------------------------------------------
300
301/// Render a surface to the terminal using efficient SGR sequences.
302fn render_surface_to_terminal(surface: &Surface, width: u16, height: u16) {
303    // Begin synchronized update
304    print!("\x1b[?2026h");
305    print!("\x1b[H"); // Move to top-left
306
307    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                // Check if style changed
321                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                    // Foreground
332                    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                    // Background
347                    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"); // End synchronized update
383}
384
385// ---------------------------------------------------------------------------
386// Event conversion helpers
387// ---------------------------------------------------------------------------
388
389/// Convert a crossterm key event to an oxi-tui Event.
390/// Returns None for special keys handled separately (Enter, Ctrl+C).
391fn 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
426/// Get current timestamp in milliseconds.
427fn 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}