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::terminal_image::{
22    CapabilitySource, TerminalGraphicsProtocol, detect_graphics_protocol,
23};
24use opi_tui::{
25    AppState, Key, KeyCombo, Keybindings, Message as TuiMessage, Role as TuiRole, SelectListState,
26    Shell, Theme, ToolCallStatus, resolve_theme,
27};
28use opi_tui::{ImageData, ImagePayload, MediaType as TuiMediaType};
29
30use crate::harness::CodingHarness;
31
32/// Shared state mutated by the agent callback and read by the TUI render loop.
33struct TuiState {
34    messages: Vec<TuiMessage>,
35    input_text: String,
36    app_state: AppState,
37    model: String,
38    active_tool: Option<(String, String, ToolCallStatus)>,
39    /// True when a TextDelta has been received for the current streaming cycle.
40    /// Prevents MessageEnd from pushing a duplicate text message.
41    streaming_started: bool,
42    theme: Theme,
43    keybindings: Keybindings,
44    total_tokens: u64,
45    cost_usd: Option<f64>,
46    graphics_protocol: TerminalGraphicsProtocol,
47    picker: Option<PickerOverlay>,
48}
49
50#[derive(Clone)]
51struct PickerOverlay {
52    kind: PickerKind,
53    title: String,
54    state: SelectListState,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58enum PickerKind {
59    Model,
60    Session,
61}
62
63#[derive(Debug, PartialEq, Eq)]
64enum PickerAction {
65    SelectModel(String),
66    SelectSession(String),
67    Cancel,
68}
69
70pub async fn run_interactive_tui(
71    harness: CodingHarness,
72    model: String,
73    theme_name: &str,
74    keybindings: Keybindings,
75) -> Result<(), Box<dyn std::error::Error>> {
76    let theme = resolve_theme(theme_name);
77    if theme.name != theme_name {
78        eprintln!("opi: warning: unknown theme {theme_name:?}, using default");
79    }
80    let graphics_protocol = detect_graphics_protocol(
81        std::env::var("TERM").ok().as_deref(),
82        std::env::var("TERM_PROGRAM").ok().as_deref(),
83        std::env::var("TERM_FEATURES").ok().as_deref(),
84        &CapabilitySource::EnvVars,
85    );
86    let state = Arc::new(Mutex::new(TuiState {
87        messages: Vec::new(),
88        input_text: String::new(),
89        app_state: AppState::Idle,
90        model: model.clone(),
91        active_tool: None,
92        streaming_started: false,
93        theme,
94        keybindings,
95        total_tokens: 0,
96        cost_usd: None,
97        graphics_protocol,
98        picker: None,
99    }));
100
101    // Wire agent events into shared state before wrapping harness
102    let state_clone = state.clone();
103    let mut harness = harness;
104    harness.subscribe(Box::new(move |event| {
105        let mut s = state_clone.lock().unwrap();
106        match event {
107            AgentEvent::MessageStart { .. } => {
108                s.app_state = AppState::Streaming;
109                s.streaming_started = false;
110            }
111            AgentEvent::MessageUpdate {
112                assistant_event, ..
113            } => {
114                if let AssistantStreamEvent::TextDelta { delta, .. } = assistant_event.as_ref() {
115                    if !s.streaming_started {
116                        s.messages
117                            .push(TuiMessage::new(TuiRole::Assistant, delta.clone()));
118                        s.streaming_started = true;
119                    } else if let Some(msg) = s.messages.last_mut() {
120                        msg.content.push_str(delta);
121                    }
122                }
123            }
124            AgentEvent::MessageEnd {
125                message: AgentMessage::Llm(Message::Assistant(a)),
126            } => {
127                s.total_tokens += a.usage.total_tokens();
128                for content in &a.content {
129                    match content {
130                        AssistantContent::Text { text } if !s.streaming_started => {
131                            s.messages
132                                .push(TuiMessage::new(TuiRole::Assistant, text.clone()));
133                        }
134                        AssistantContent::ToolCall { tool_call } => {
135                            s.active_tool = Some((
136                                tool_call.name.clone(),
137                                tool_call.arguments.clone(),
138                                ToolCallStatus::Running,
139                            ));
140                        }
141                        _ => {}
142                    }
143                }
144                s.streaming_started = false;
145            }
146            AgentEvent::ToolExecutionStart {
147                tool_name, args, ..
148            } => {
149                s.app_state = AppState::ToolExecuting;
150                s.active_tool = Some((
151                    tool_name.clone(),
152                    format!("{args}"),
153                    ToolCallStatus::Running,
154                ));
155            }
156            AgentEvent::ToolExecutionEnd {
157                tool_name,
158                is_error,
159                details,
160                result,
161                ..
162            } => {
163                // Render diff for edit tool results that have before/after details.
164                if !is_error
165                    && tool_name == "edit"
166                    && let Some(d) = details
167                    && let (Some(path), Some(before), Some(after)) =
168                        (d.get("path"), d.get("before"), d.get("after"))
169                {
170                    let path_str = path.as_str().unwrap_or("unknown");
171                    let before_str = before.as_str().unwrap_or("");
172                    let after_str = after.as_str().unwrap_or("");
173                    s.messages
174                        .push(TuiMessage::diff(path_str, before_str, after_str));
175                }
176                // Extract image content from tool result.
177                let protocol = s.graphics_protocol;
178                if let Some(content_arr) = result.as_array() {
179                    for item in content_arr {
180                        if item.get("type").and_then(|v| v.as_str()) == Some("image")
181                            && let Some(source) = item.get("source")
182                        {
183                            let bytes = if source.get("type").and_then(|v| v.as_str())
184                                == Some("bytes")
185                            {
186                                source
187                                    .get("data")
188                                    .and_then(|v| v.as_array())
189                                    .map(|arr| {
190                                        arr.iter()
191                                            .filter_map(|v| v.as_u64().map(|n| n as u8))
192                                            .collect::<Vec<u8>>()
193                                    })
194                                    .unwrap_or_default()
195                            } else if source.get("type").and_then(|v| v.as_str()) == Some("base64")
196                            {
197                                use base64::Engine;
198                                source
199                                    .get("data")
200                                    .and_then(|v| v.as_str())
201                                    .and_then(|d| {
202                                        base64::engine::general_purpose::STANDARD.decode(d).ok()
203                                    })
204                                    .unwrap_or_default()
205                            } else {
206                                vec![]
207                            };
208                            if !bytes.is_empty() {
209                                let media_type = item.get("media_type").and_then(|v| v.as_str());
210                                let tui_media = match media_type {
211                                    Some("image/jpeg") => TuiMediaType::Jpeg,
212                                    Some("image/gif") => TuiMediaType::Gif,
213                                    Some("image/webp") => TuiMediaType::WebP,
214                                    _ => TuiMediaType::Png,
215                                };
216                                let image_data = ImageData {
217                                    bytes,
218                                    media_type: tui_media,
219                                    width: None,
220                                    height: None,
221                                };
222                                s.messages.push(TuiMessage::image(
223                                    TuiRole::Tool,
224                                    ImagePayload {
225                                        data: image_data,
226                                        protocol,
227                                    },
228                                ));
229                            }
230                        }
231                    }
232                }
233                if let Some((name, args, _)) = &s.active_tool
234                    && name == tool_name
235                {
236                    let status = if *is_error {
237                        ToolCallStatus::Error("failed".into())
238                    } else {
239                        ToolCallStatus::Success
240                    };
241                    s.active_tool = Some((name.clone(), args.clone(), status));
242                }
243                s.app_state = AppState::Streaming;
244            }
245            AgentEvent::AgentEnd { .. } => {
246                s.app_state = AppState::Idle;
247                s.active_tool = None;
248            }
249            AgentEvent::TurnStart => {
250                s.app_state = AppState::Thinking;
251            }
252            AgentEvent::CompactionStart { reason } => {
253                s.messages.push(TuiMessage::new(
254                    TuiRole::System,
255                    format!("[compaction started: {reason:?}]"),
256                ));
257            }
258            AgentEvent::CompactionEnd {
259                reason,
260                result,
261                aborted,
262                error_message,
263            } => {
264                let summary = if *aborted {
265                    format!(
266                        "[compaction aborted ({reason:?}): {}]",
267                        error_message.clone().unwrap_or_default()
268                    )
269                } else if let Some(r) = result {
270                    format!(
271                        "[compaction done ({reason:?}): {} -> {} tokens]",
272                        r.tokens_before, r.tokens_after
273                    )
274                } else {
275                    format!("[compaction done ({reason:?})]")
276                };
277                s.messages.push(TuiMessage::new(TuiRole::System, summary));
278            }
279            AgentEvent::SessionPersistError { message } => {
280                s.messages.push(TuiMessage::new(
281                    TuiRole::System,
282                    format!("[session persist error: {message}]"),
283                ));
284            }
285            _ => {}
286        }
287    }));
288
289    let harness = Arc::new(tokio::sync::Mutex::new(harness));
290
291    // Setup terminal
292    terminal::enable_raw_mode()?;
293    let mut stdout = io::stdout();
294    crossterm::execute!(stdout, EnterAlternateScreen)?;
295    let backend = CrosstermBackend::new(stdout);
296    let mut terminal = Terminal::new(backend)?;
297
298    // Main TUI loop
299    let result = tui_event_loop(&mut terminal, &harness, &state).await;
300
301    // Restore terminal
302    terminal::disable_raw_mode()?;
303    crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
304    terminal.show_cursor()?;
305
306    result
307}
308
309async fn tui_event_loop(
310    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
311    harness: &Arc<tokio::sync::Mutex<CodingHarness>>,
312    state: &Arc<Mutex<TuiState>>,
313) -> Result<(), Box<dyn std::error::Error>> {
314    let mut pending: Option<tokio::task::JoinHandle<Result<Vec<AgentMessage>, AgentError>>> = None;
315    let mut cancel_token = harness.lock().await.cancel_token();
316
317    loop {
318        // Render current state
319        {
320            let s = state.lock().unwrap();
321            let shell = build_shell(&s);
322            terminal.draw(|frame| frame.render_widget(shell, frame.area()))?;
323        }
324
325        // Check if pending prompt finished (non-blocking)
326        if let Some(handle) = &mut pending
327            && handle.is_finished()
328        {
329            match handle.await {
330                Ok(Ok(_messages)) => {
331                    let mut s = state.lock().unwrap();
332                    s.app_state = AppState::Idle;
333                }
334                Ok(Err(AgentError::Cancelled)) => {
335                    let mut s = state.lock().unwrap();
336                    s.app_state = AppState::Idle;
337                }
338                Ok(Err(e)) => {
339                    let mut s = state.lock().unwrap();
340                    s.messages
341                        .push(TuiMessage::new(TuiRole::System, format!("error: {e}")));
342                    s.app_state = AppState::Idle;
343                }
344                Err(e) => {
345                    let mut s = state.lock().unwrap();
346                    s.messages
347                        .push(TuiMessage::new(TuiRole::System, format!("error: {e}")));
348                    s.app_state = AppState::Idle;
349                }
350            }
351
352            // Refresh cost from the harness session (pricing lookup may yield
353            // a number; if the model isn't in the table we leave it as-is).
354            {
355                let h = harness.lock().await;
356                if let Some(session) = h.session()
357                    && let Some(cost) = session.cost_summary()
358                {
359                    state.lock().unwrap().cost_usd = Some(cost.total_cost());
360                }
361            }
362
363            // Refresh cancel token — Agent::maybe_reset_cancel() creates a new one
364            // after cancellation, so the old token would be stale.
365            cancel_token = harness.lock().await.cancel_token();
366            pending = None;
367        }
368
369        // Poll for terminal events (non-blocking with timeout)
370        if event::poll(std::time::Duration::from_millis(50))?
371            && let Event::Key(key) = event::read()?
372        {
373            if key.kind != KeyEventKind::Press {
374                continue;
375            }
376            let kb = state.lock().unwrap().keybindings.clone();
377            if let Some(action) = {
378                let mut s = state.lock().unwrap();
379                handle_picker_key(&mut s, key.code)
380            } {
381                match action {
382                    PickerAction::SelectModel(model) => {
383                        let mut h = harness.lock().await;
384                        h.set_model(model.clone());
385                        let mut s = state.lock().unwrap();
386                        s.model = model.clone();
387                        s.messages.push(TuiMessage::new(
388                            TuiRole::System,
389                            format!("[model switched: {model}]"),
390                        ));
391                    }
392                    PickerAction::SelectSession(session_id) => {
393                        let result = {
394                            let mut h = harness.lock().await;
395                            h.resume_session_id(&session_id)
396                        };
397                        let mut s = state.lock().unwrap();
398                        match result {
399                            Ok(count) => s.messages.push(TuiMessage::new(
400                                TuiRole::System,
401                                format!("[session resumed: {session_id}, {count} messages]"),
402                            )),
403                            Err(e) => s.messages.push(TuiMessage::new(
404                                TuiRole::System,
405                                format!("[session resume failed: {e}]"),
406                            )),
407                        }
408                    }
409                    PickerAction::Cancel => {}
410                }
411                continue;
412            }
413
414            if matches_key_combo(key.code, key.modifiers, &kb.submit) {
415                // Ignore submit while agent is running
416                if pending.is_some() {
417                    continue;
418                }
419
420                let input = {
421                    let mut s = state.lock().unwrap();
422                    let text = s.input_text.trim().to_string();
423                    s.input_text.clear();
424                    text
425                };
426
427                if input == "exit" || input == "quit" {
428                    // Cancel any pending task on exit
429                    if let Some(handle) = pending.take() {
430                        cancel_token.cancel();
431                        let _ = handle.await;
432                    }
433                    return Ok(());
434                }
435                if input.is_empty() {
436                    continue;
437                }
438
439                if input == "/model" {
440                    let items = {
441                        let h = harness.lock().await;
442                        h.model_picker_items()
443                    };
444                    let mut s = state.lock().unwrap();
445                    if items.is_empty() {
446                        s.messages.push(TuiMessage::new(
447                            TuiRole::System,
448                            "[model picker: no models available]",
449                        ));
450                    } else {
451                        s.picker = Some(PickerOverlay {
452                            kind: PickerKind::Model,
453                            title: "Select model".into(),
454                            state: SelectListState::new(items),
455                        });
456                    }
457                    continue;
458                }
459
460                if input == "/session" {
461                    let dir = crate::session_cli::session_dir();
462                    let items = crate::picker::session_picker_items(&dir).unwrap_or_default();
463                    let mut s = state.lock().unwrap();
464                    if items.is_empty() {
465                        s.messages.push(TuiMessage::new(
466                            TuiRole::System,
467                            "[session picker: no sessions available]",
468                        ));
469                    } else {
470                        s.picker = Some(PickerOverlay {
471                            kind: PickerKind::Session,
472                            title: "Resume session".into(),
473                            state: SelectListState::new(items),
474                        });
475                    }
476                    continue;
477                }
478
479                if let Some(rest) = input.strip_prefix("/image ") {
480                    let path = rest.trim();
481                    if path.is_empty() {
482                        let mut s = state.lock().unwrap();
483                        s.messages.push(TuiMessage::new(
484                            TuiRole::System,
485                            "[/image: usage: /image <path>]".to_string(),
486                        ));
487                    } else {
488                        let image_path = std::path::PathBuf::from(path);
489                        let max_bytes = {
490                            let h = harness.lock().await;
491                            h.config().defaults.max_image_bytes
492                        };
493                        match crate::image::load_image_with_limit(&image_path, max_bytes) {
494                            Ok(img) => {
495                                harness.lock().await.queue_images(vec![img]);
496                                let mut s = state.lock().unwrap();
497                                s.messages.push(TuiMessage::new(
498                                    TuiRole::System,
499                                    format!("[image queued: {}]", image_path.display()),
500                                ));
501                            }
502                            Err(e) => {
503                                let mut s = state.lock().unwrap();
504                                s.messages.push(TuiMessage::new(
505                                    TuiRole::System,
506                                    format!("[/image error: {e}]"),
507                                ));
508                            }
509                        }
510                    }
511                    continue;
512                }
513
514                // Add user message to display
515                {
516                    let mut s = state.lock().unwrap();
517                    s.messages
518                        .push(TuiMessage::new(TuiRole::User, input.clone()));
519                    s.app_state = AppState::Thinking;
520                }
521
522                // Spawn agent prompt in background task
523                let h = harness.clone();
524                let handle = tokio::spawn(async move {
525                    let mut h = h.lock().await;
526                    let pending = h.take_pending_images();
527                    if pending.is_empty() {
528                        h.prompt(&input).await
529                    } else {
530                        let mut content = vec![opi_ai::message::InputContent::Text { text: input }];
531                        content.extend(pending);
532                        h.prompt_with_content(content).await
533                    }
534                });
535                pending = Some(handle);
536            } else if matches_key_combo(key.code, key.modifiers, &kb.abort) {
537                if pending.is_some() {
538                    cancel_token.cancel();
539                } else {
540                    return Ok(());
541                }
542            } else if matches_key_combo(key.code, key.modifiers, &kb.new_line) {
543                if pending.is_none() {
544                    state.lock().unwrap().input_text.push('\n');
545                }
546            } else {
547                match key.code {
548                    KeyCode::Char(c) if pending.is_none() => {
549                        state.lock().unwrap().input_text.push(c);
550                    }
551                    KeyCode::Backspace if pending.is_none() => {
552                        state.lock().unwrap().input_text.pop();
553                    }
554                    _ => {}
555                }
556            }
557        }
558    }
559}
560
561fn build_shell(s: &TuiState) -> Shell {
562    let mut shell = Shell::new(s.model.clone())
563        .input_text(s.input_text.clone())
564        .state(s.app_state)
565        .theme(s.theme.clone());
566
567    if s.total_tokens > 0 {
568        shell = shell.token_count(s.total_tokens);
569    }
570
571    if let Some(cost) = s.cost_usd {
572        shell = shell.cost_usd(cost);
573    }
574
575    if !s.messages.is_empty() {
576        shell = shell.messages(s.messages.clone());
577    }
578
579    if let Some((name, args, status)) = &s.active_tool {
580        shell = shell.active_tool(name.clone(), args.clone(), status.clone());
581    }
582
583    if let Some(picker) = &s.picker {
584        shell = shell.picker(picker.title.clone(), picker.state.clone());
585    }
586
587    shell
588}
589
590fn handle_picker_key(s: &mut TuiState, code: KeyCode) -> Option<PickerAction> {
591    let picker = s.picker.as_mut()?;
592    match code {
593        KeyCode::Esc => {
594            s.picker = None;
595            Some(PickerAction::Cancel)
596        }
597        KeyCode::Enter => {
598            let item = picker.state.confirm().cloned();
599            let kind = picker.kind;
600            s.picker = None;
601            match (kind, item) {
602                (PickerKind::Model, Some(item)) => Some(PickerAction::SelectModel(item.id)),
603                (PickerKind::Session, Some(item)) => Some(PickerAction::SelectSession(item.id)),
604                (_, None) => Some(PickerAction::Cancel),
605            }
606        }
607        KeyCode::Down => {
608            picker.state.move_down();
609            None
610        }
611        KeyCode::Up => {
612            picker.state.move_up();
613            None
614        }
615        KeyCode::PageDown => {
616            picker.state.page_down(10);
617            None
618        }
619        KeyCode::PageUp => {
620            picker.state.page_up(10);
621            None
622        }
623        KeyCode::Backspace => {
624            let mut filter = picker.state.filter().to_string();
625            filter.pop();
626            picker.state.set_filter(filter);
627            None
628        }
629        KeyCode::Char(c) => {
630            let mut filter = picker.state.filter().to_string();
631            filter.push(c);
632            picker.state.set_filter(filter);
633            None
634        }
635        _ => None,
636    }
637}
638
639fn matches_key_combo(code: KeyCode, modifiers: KeyModifiers, combo: &KeyCombo) -> bool {
640    let key_matches = match (code, &combo.key) {
641        (KeyCode::Enter, Key::Enter) => true,
642        (KeyCode::Esc, Key::Escape) => true,
643        (KeyCode::Tab, Key::Tab) => true,
644        (KeyCode::Backspace, Key::Backspace) => true,
645        (KeyCode::Char(c), Key::Char(expected)) => c == *expected,
646        _ => false,
647    };
648    if !key_matches {
649        return false;
650    }
651    combo.modifiers.alt == modifiers.contains(KeyModifiers::ALT)
652        && combo.modifiers.ctrl == modifiers.contains(KeyModifiers::CONTROL)
653        && combo.modifiers.shift == modifiers.contains(KeyModifiers::SHIFT)
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use opi_tui::SelectItem;
660
661    fn state_with_picker(kind: PickerKind) -> TuiState {
662        TuiState {
663            messages: Vec::new(),
664            input_text: String::new(),
665            app_state: AppState::Idle,
666            model: "mock:old".into(),
667            active_tool: None,
668            streaming_started: false,
669            theme: Theme::default(),
670            keybindings: Keybindings::default(),
671            total_tokens: 0,
672            cost_usd: None,
673            graphics_protocol: TerminalGraphicsProtocol::Fallback,
674            picker: Some(PickerOverlay {
675                kind,
676                title: "Pick".into(),
677                state: SelectListState::new(vec![SelectItem {
678                    id: "mock:new".into(),
679                    display: "New".into(),
680                    metadata: "mock".into(),
681                }]),
682            }),
683        }
684    }
685
686    #[test]
687    fn model_picker_enter_returns_selected_model() {
688        let mut state = state_with_picker(PickerKind::Model);
689        let action = handle_picker_key(&mut state, KeyCode::Enter);
690        assert_eq!(action, Some(PickerAction::SelectModel("mock:new".into())));
691        assert!(state.picker.is_none());
692    }
693
694    #[test]
695    fn session_picker_enter_returns_selected_session() {
696        let mut state = state_with_picker(PickerKind::Session);
697        let action = handle_picker_key(&mut state, KeyCode::Enter);
698        assert_eq!(action, Some(PickerAction::SelectSession("mock:new".into())));
699        assert!(state.picker.is_none());
700    }
701}