Skip to main content

opi_coding_agent/
interactive.rs

1//! Interactive TUI mode using opi-tui for terminal rendering.
2//!
3//! The agent prompt runs in a spawned tokio task while the TUI render loop
4//! continues to poll crossterm events and redraw at ~20 fps. Agent callbacks
5//! update shared `TuiState`, which the render loop reads each frame.
6
7use std::io;
8use std::sync::{Arc, Mutex};
9
10use crossterm::{
11    event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
12    terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
13};
14use ratatui::prelude::*;
15
16use opi_agent::event::AgentEvent;
17use opi_agent::loop_types::AgentError;
18use opi_agent::message::AgentMessage;
19use opi_ai::message::{AssistantContent, Message};
20use opi_ai::stream::AssistantStreamEvent;
21use opi_tui::{
22    AppState, Key, KeyCombo, Keybindings, Message as TuiMessage, Role as TuiRole, Shell, Theme,
23    ToolCallStatus, resolve_theme,
24};
25
26use crate::harness::CodingHarness;
27
28/// Shared state mutated by the agent callback and read by the TUI render loop.
29struct TuiState {
30    messages: Vec<TuiMessage>,
31    input_text: String,
32    app_state: AppState,
33    model: String,
34    active_tool: Option<(String, String, ToolCallStatus)>,
35    /// True when a TextDelta has been received for the current streaming cycle.
36    /// Prevents MessageEnd from pushing a duplicate text message.
37    streaming_started: bool,
38    theme: Theme,
39    keybindings: Keybindings,
40    total_tokens: u64,
41    cost_usd: Option<f64>,
42}
43
44pub async fn run_interactive_tui(
45    harness: CodingHarness,
46    model: String,
47    theme_name: &str,
48    keybindings: Keybindings,
49) -> Result<(), Box<dyn std::error::Error>> {
50    let theme = resolve_theme(theme_name);
51    if theme.name != theme_name {
52        eprintln!("opi: warning: unknown theme {theme_name:?}, using default");
53    }
54    let state = Arc::new(Mutex::new(TuiState {
55        messages: Vec::new(),
56        input_text: String::new(),
57        app_state: AppState::Idle,
58        model: model.clone(),
59        active_tool: None,
60        streaming_started: false,
61        theme,
62        keybindings,
63        total_tokens: 0,
64        cost_usd: None,
65    }));
66
67    // Wire agent events into shared state before wrapping harness
68    let state_clone = state.clone();
69    let mut harness = harness;
70    harness.subscribe(Box::new(move |event| {
71        let mut s = state_clone.lock().unwrap();
72        match event {
73            AgentEvent::MessageStart { .. } => {
74                s.app_state = AppState::Streaming;
75                s.streaming_started = false;
76            }
77            AgentEvent::MessageUpdate {
78                assistant_event, ..
79            } => {
80                if let AssistantStreamEvent::TextDelta { delta, .. } = assistant_event.as_ref() {
81                    if !s.streaming_started {
82                        s.messages
83                            .push(TuiMessage::new(TuiRole::Assistant, delta.clone()));
84                        s.streaming_started = true;
85                    } else if let Some(msg) = s.messages.last_mut() {
86                        msg.content.push_str(delta);
87                    }
88                }
89            }
90            AgentEvent::MessageEnd {
91                message: AgentMessage::Llm(Message::Assistant(a)),
92            } => {
93                s.total_tokens += a.usage.total_tokens();
94                for content in &a.content {
95                    match content {
96                        AssistantContent::Text { text } if !s.streaming_started => {
97                            s.messages
98                                .push(TuiMessage::new(TuiRole::Assistant, text.clone()));
99                        }
100                        AssistantContent::ToolCall { tool_call } => {
101                            s.active_tool = Some((
102                                tool_call.name.clone(),
103                                tool_call.arguments.clone(),
104                                ToolCallStatus::Running,
105                            ));
106                        }
107                        _ => {}
108                    }
109                }
110                s.streaming_started = false;
111            }
112            AgentEvent::ToolExecutionStart {
113                tool_name, args, ..
114            } => {
115                s.app_state = AppState::ToolExecuting;
116                s.active_tool = Some((
117                    tool_name.clone(),
118                    format!("{args}"),
119                    ToolCallStatus::Running,
120                ));
121            }
122            AgentEvent::ToolExecutionEnd {
123                tool_name,
124                is_error,
125                details,
126                ..
127            } => {
128                // Render diff for edit tool results that have before/after details.
129                if !is_error
130                    && tool_name == "edit"
131                    && let Some(d) = details
132                    && let (Some(path), Some(before), Some(after)) =
133                        (d.get("path"), d.get("before"), d.get("after"))
134                {
135                    let path_str = path.as_str().unwrap_or("unknown");
136                    let before_str = before.as_str().unwrap_or("");
137                    let after_str = after.as_str().unwrap_or("");
138                    s.messages
139                        .push(TuiMessage::diff(path_str, before_str, after_str));
140                }
141                if let Some((name, args, _)) = &s.active_tool
142                    && name == tool_name
143                {
144                    let status = if *is_error {
145                        ToolCallStatus::Error("failed".into())
146                    } else {
147                        ToolCallStatus::Success
148                    };
149                    s.active_tool = Some((name.clone(), args.clone(), status));
150                }
151                s.app_state = AppState::Streaming;
152            }
153            AgentEvent::AgentEnd { .. } => {
154                s.app_state = AppState::Idle;
155                s.active_tool = None;
156            }
157            AgentEvent::TurnStart => {
158                s.app_state = AppState::Thinking;
159            }
160            AgentEvent::CompactionStart { reason } => {
161                s.messages.push(TuiMessage::new(
162                    TuiRole::System,
163                    format!("[compaction started: {reason:?}]"),
164                ));
165            }
166            AgentEvent::CompactionEnd {
167                reason,
168                result,
169                aborted,
170                error_message,
171            } => {
172                let summary = if *aborted {
173                    format!(
174                        "[compaction aborted ({reason:?}): {}]",
175                        error_message.clone().unwrap_or_default()
176                    )
177                } else if let Some(r) = result {
178                    format!(
179                        "[compaction done ({reason:?}): {} -> {} tokens]",
180                        r.tokens_before, r.tokens_after
181                    )
182                } else {
183                    format!("[compaction done ({reason:?})]")
184                };
185                s.messages.push(TuiMessage::new(TuiRole::System, summary));
186            }
187            AgentEvent::SessionPersistError { message } => {
188                s.messages.push(TuiMessage::new(
189                    TuiRole::System,
190                    format!("[session persist error: {message}]"),
191                ));
192            }
193            _ => {}
194        }
195    }));
196
197    let harness = Arc::new(tokio::sync::Mutex::new(harness));
198
199    // Setup terminal
200    terminal::enable_raw_mode()?;
201    let mut stdout = io::stdout();
202    crossterm::execute!(stdout, EnterAlternateScreen)?;
203    let backend = CrosstermBackend::new(stdout);
204    let mut terminal = Terminal::new(backend)?;
205
206    // Main TUI loop
207    let result = tui_event_loop(&mut terminal, &harness, &state).await;
208
209    // Restore terminal
210    terminal::disable_raw_mode()?;
211    crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
212    terminal.show_cursor()?;
213
214    result
215}
216
217async fn tui_event_loop(
218    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
219    harness: &Arc<tokio::sync::Mutex<CodingHarness>>,
220    state: &Arc<Mutex<TuiState>>,
221) -> Result<(), Box<dyn std::error::Error>> {
222    let mut pending: Option<tokio::task::JoinHandle<Result<Vec<AgentMessage>, AgentError>>> = None;
223    let mut cancel_token = harness.lock().await.cancel_token();
224
225    loop {
226        // Render current state
227        {
228            let s = state.lock().unwrap();
229            let shell = build_shell(&s);
230            terminal.draw(|frame| frame.render_widget(shell, frame.area()))?;
231        }
232
233        // Check if pending prompt finished (non-blocking)
234        if let Some(handle) = &mut pending
235            && handle.is_finished()
236        {
237            match handle.await {
238                Ok(Ok(_messages)) => {
239                    let mut s = state.lock().unwrap();
240                    s.app_state = AppState::Idle;
241                }
242                Ok(Err(AgentError::Cancelled)) => {
243                    let mut s = state.lock().unwrap();
244                    s.app_state = AppState::Idle;
245                }
246                Ok(Err(e)) => {
247                    let mut s = state.lock().unwrap();
248                    s.messages
249                        .push(TuiMessage::new(TuiRole::System, format!("error: {e}")));
250                    s.app_state = AppState::Idle;
251                }
252                Err(e) => {
253                    let mut s = state.lock().unwrap();
254                    s.messages
255                        .push(TuiMessage::new(TuiRole::System, format!("error: {e}")));
256                    s.app_state = AppState::Idle;
257                }
258            }
259
260            // Refresh cost from the harness session (pricing lookup may yield
261            // a number; if the model isn't in the table we leave it as-is).
262            {
263                let h = harness.lock().await;
264                if let Some(session) = h.session()
265                    && let Some(cost) = session.cost_summary()
266                {
267                    state.lock().unwrap().cost_usd = Some(cost.total_cost());
268                }
269            }
270
271            // Refresh cancel token — Agent::maybe_reset_cancel() creates a new one
272            // after cancellation, so the old token would be stale.
273            cancel_token = harness.lock().await.cancel_token();
274            pending = None;
275        }
276
277        // Poll for terminal events (non-blocking with timeout)
278        if event::poll(std::time::Duration::from_millis(50))?
279            && let Event::Key(key) = event::read()?
280        {
281            if key.kind != KeyEventKind::Press {
282                continue;
283            }
284            let kb = state.lock().unwrap().keybindings.clone();
285            if matches_key_combo(key.code, key.modifiers, &kb.submit) {
286                // Ignore submit while agent is running
287                if pending.is_some() {
288                    continue;
289                }
290
291                let input = {
292                    let mut s = state.lock().unwrap();
293                    let text = s.input_text.trim().to_string();
294                    s.input_text.clear();
295                    text
296                };
297
298                if input == "exit" || input == "quit" {
299                    // Cancel any pending task on exit
300                    if let Some(handle) = pending.take() {
301                        cancel_token.cancel();
302                        let _ = handle.await;
303                    }
304                    return Ok(());
305                }
306                if input.is_empty() {
307                    continue;
308                }
309
310                // Add user message to display
311                {
312                    let mut s = state.lock().unwrap();
313                    s.messages
314                        .push(TuiMessage::new(TuiRole::User, input.clone()));
315                    s.app_state = AppState::Thinking;
316                }
317
318                // Spawn agent prompt in background task
319                let h = harness.clone();
320                let handle = tokio::spawn(async move {
321                    let mut h = h.lock().await;
322                    h.prompt(&input).await
323                });
324                pending = Some(handle);
325            } else if matches_key_combo(key.code, key.modifiers, &kb.abort) {
326                if pending.is_some() {
327                    cancel_token.cancel();
328                } else {
329                    return Ok(());
330                }
331            } else if matches_key_combo(key.code, key.modifiers, &kb.new_line) {
332                if pending.is_none() {
333                    state.lock().unwrap().input_text.push('\n');
334                }
335            } else {
336                match key.code {
337                    KeyCode::Char(c) if pending.is_none() => {
338                        state.lock().unwrap().input_text.push(c);
339                    }
340                    KeyCode::Backspace if pending.is_none() => {
341                        state.lock().unwrap().input_text.pop();
342                    }
343                    _ => {}
344                }
345            }
346        }
347    }
348}
349
350fn build_shell(s: &TuiState) -> Shell {
351    let mut shell = Shell::new(s.model.clone())
352        .input_text(s.input_text.clone())
353        .state(s.app_state)
354        .theme(s.theme.clone());
355
356    if s.total_tokens > 0 {
357        shell = shell.token_count(s.total_tokens);
358    }
359
360    if let Some(cost) = s.cost_usd {
361        shell = shell.cost_usd(cost);
362    }
363
364    if !s.messages.is_empty() {
365        shell = shell.messages(s.messages.clone());
366    }
367
368    if let Some((name, args, status)) = &s.active_tool {
369        shell = shell.active_tool(name.clone(), args.clone(), status.clone());
370    }
371
372    shell
373}
374
375fn matches_key_combo(code: KeyCode, modifiers: KeyModifiers, combo: &KeyCombo) -> bool {
376    let key_matches = match (code, &combo.key) {
377        (KeyCode::Enter, Key::Enter) => true,
378        (KeyCode::Esc, Key::Escape) => true,
379        (KeyCode::Tab, Key::Tab) => true,
380        (KeyCode::Backspace, Key::Backspace) => true,
381        (KeyCode::Char(c), Key::Char(expected)) => c == *expected,
382        _ => false,
383    };
384    if !key_matches {
385        return false;
386    }
387    combo.modifiers.alt == modifiers.contains(KeyModifiers::ALT)
388        && combo.modifiers.ctrl == modifiers.contains(KeyModifiers::CONTROL)
389        && combo.modifiers.shift == modifiers.contains(KeyModifiers::SHIFT)
390}