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},
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::{AppState, Message as TuiMessage, Role as TuiRole, Shell, ToolCallStatus};
22
23use crate::harness::CodingHarness;
24
25/// Shared state mutated by the agent callback and read by the TUI render loop.
26struct TuiState {
27    messages: Vec<TuiMessage>,
28    input_text: String,
29    app_state: AppState,
30    model: String,
31    active_tool: Option<(String, String, ToolCallStatus)>,
32    /// True when a TextDelta has been received for the current streaming cycle.
33    /// Prevents MessageEnd from pushing a duplicate text message.
34    streaming_started: bool,
35}
36
37pub async fn run_interactive_tui(
38    harness: CodingHarness,
39    model: String,
40) -> Result<(), Box<dyn std::error::Error>> {
41    let state = Arc::new(Mutex::new(TuiState {
42        messages: Vec::new(),
43        input_text: String::new(),
44        app_state: AppState::Idle,
45        model: model.clone(),
46        active_tool: None,
47        streaming_started: false,
48    }));
49
50    // Wire agent events into shared state before wrapping harness
51    let state_clone = state.clone();
52    let mut harness = harness;
53    harness.subscribe(Box::new(move |event| {
54        let mut s = state_clone.lock().unwrap();
55        match event {
56            AgentEvent::MessageStart { .. } => {
57                s.app_state = AppState::Streaming;
58                s.streaming_started = false;
59            }
60            AgentEvent::MessageUpdate {
61                assistant_event, ..
62            } => {
63                if let AssistantStreamEvent::TextDelta { delta, .. } = assistant_event.as_ref() {
64                    if !s.streaming_started {
65                        s.messages
66                            .push(TuiMessage::new(TuiRole::Assistant, delta.clone()));
67                        s.streaming_started = true;
68                    } else if let Some(msg) = s.messages.last_mut() {
69                        msg.content.push_str(delta);
70                    }
71                }
72            }
73            AgentEvent::MessageEnd {
74                message: AgentMessage::Llm(Message::Assistant(a)),
75            } => {
76                for content in &a.content {
77                    match content {
78                        AssistantContent::Text { text } if !s.streaming_started => {
79                            s.messages
80                                .push(TuiMessage::new(TuiRole::Assistant, text.clone()));
81                        }
82                        AssistantContent::ToolCall { tool_call } => {
83                            s.active_tool = Some((
84                                tool_call.name.clone(),
85                                tool_call.arguments.clone(),
86                                ToolCallStatus::Running,
87                            ));
88                        }
89                        _ => {}
90                    }
91                }
92                s.streaming_started = false;
93            }
94            AgentEvent::ToolExecutionStart {
95                tool_name, args, ..
96            } => {
97                s.app_state = AppState::ToolExecuting;
98                s.active_tool = Some((
99                    tool_name.clone(),
100                    format!("{args}"),
101                    ToolCallStatus::Running,
102                ));
103            }
104            AgentEvent::ToolExecutionEnd {
105                tool_name,
106                is_error,
107                ..
108            } => {
109                if let Some((name, args, _)) = &s.active_tool
110                    && name == tool_name
111                {
112                    let status = if *is_error {
113                        ToolCallStatus::Error("failed".into())
114                    } else {
115                        ToolCallStatus::Success
116                    };
117                    s.active_tool = Some((name.clone(), args.clone(), status));
118                }
119                s.app_state = AppState::Streaming;
120            }
121            AgentEvent::AgentEnd { .. } => {
122                s.app_state = AppState::Idle;
123                s.active_tool = None;
124            }
125            AgentEvent::TurnStart => {
126                s.app_state = AppState::Thinking;
127            }
128            _ => {}
129        }
130    }));
131
132    let harness = Arc::new(tokio::sync::Mutex::new(harness));
133
134    // Setup terminal
135    terminal::enable_raw_mode()?;
136    let mut stdout = io::stdout();
137    crossterm::execute!(stdout, EnterAlternateScreen)?;
138    let backend = CrosstermBackend::new(stdout);
139    let mut terminal = Terminal::new(backend)?;
140
141    // Main TUI loop
142    let result = tui_event_loop(&mut terminal, &harness, &state).await;
143
144    // Restore terminal
145    terminal::disable_raw_mode()?;
146    crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
147    terminal.show_cursor()?;
148
149    result
150}
151
152async fn tui_event_loop(
153    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
154    harness: &Arc<tokio::sync::Mutex<CodingHarness>>,
155    state: &Arc<Mutex<TuiState>>,
156) -> Result<(), Box<dyn std::error::Error>> {
157    let mut pending: Option<tokio::task::JoinHandle<Result<Vec<AgentMessage>, AgentError>>> = None;
158    let mut cancel_token = harness.lock().await.cancel_token();
159
160    loop {
161        // Render current state
162        {
163            let s = state.lock().unwrap();
164            let shell = build_shell(&s);
165            terminal.draw(|frame| frame.render_widget(shell, frame.area()))?;
166        }
167
168        // Check if pending prompt finished (non-blocking)
169        if let Some(handle) = &mut pending
170            && handle.is_finished()
171        {
172            match handle.await {
173                Ok(Ok(_messages)) => {
174                    let mut s = state.lock().unwrap();
175                    s.app_state = AppState::Idle;
176                }
177                Ok(Err(AgentError::Cancelled)) => {
178                    let mut s = state.lock().unwrap();
179                    s.app_state = AppState::Idle;
180                }
181                Ok(Err(e)) => {
182                    let mut s = state.lock().unwrap();
183                    s.messages
184                        .push(TuiMessage::new(TuiRole::System, format!("error: {e}")));
185                    s.app_state = AppState::Idle;
186                }
187                Err(e) => {
188                    let mut s = state.lock().unwrap();
189                    s.messages
190                        .push(TuiMessage::new(TuiRole::System, format!("error: {e}")));
191                    s.app_state = AppState::Idle;
192                }
193            }
194            // Refresh cancel token — Agent::maybe_reset_cancel() creates a new one
195            // after cancellation, so the old token would be stale.
196            cancel_token = harness.lock().await.cancel_token();
197            pending = None;
198        }
199
200        // Poll for terminal events (non-blocking with timeout)
201        if event::poll(std::time::Duration::from_millis(50))?
202            && let Event::Key(key) = event::read()?
203        {
204            if key.kind != KeyEventKind::Press {
205                continue;
206            }
207            match key.code {
208                KeyCode::Enter => {
209                    // Ignore Enter while agent is running
210                    if pending.is_some() {
211                        continue;
212                    }
213
214                    let input = {
215                        let mut s = state.lock().unwrap();
216                        let text = s.input_text.trim().to_string();
217                        s.input_text.clear();
218                        text
219                    };
220
221                    if input == "exit" || input == "quit" {
222                        // Cancel any pending task on exit
223                        if let Some(handle) = pending.take() {
224                            cancel_token.cancel();
225                            let _ = handle.await;
226                        }
227                        return Ok(());
228                    }
229                    if input.is_empty() {
230                        continue;
231                    }
232
233                    // Add user message to display
234                    {
235                        let mut s = state.lock().unwrap();
236                        s.messages
237                            .push(TuiMessage::new(TuiRole::User, input.clone()));
238                        s.app_state = AppState::Thinking;
239                    }
240
241                    // Spawn agent prompt in background task
242                    let h = harness.clone();
243                    let handle = tokio::spawn(async move {
244                        let mut h = h.lock().await;
245                        h.prompt(&input).await
246                    });
247                    pending = Some(handle);
248                }
249                KeyCode::Char(c) if pending.is_none() => {
250                    state.lock().unwrap().input_text.push(c);
251                }
252                KeyCode::Backspace if pending.is_none() => {
253                    state.lock().unwrap().input_text.pop();
254                }
255                KeyCode::Esc => {
256                    if pending.is_some() {
257                        cancel_token.cancel();
258                    } else {
259                        return Ok(());
260                    }
261                }
262                _ => {}
263            }
264        }
265    }
266}
267
268fn build_shell(s: &TuiState) -> Shell {
269    let mut shell = Shell::new(s.model.clone())
270        .input_text(s.input_text.clone())
271        .state(s.app_state);
272
273    if !s.messages.is_empty() {
274        shell = shell.messages(s.messages.clone());
275    }
276
277    if let Some((name, args, status)) = &s.active_tool {
278        shell = shell.active_tool(name.clone(), args.clone(), status.clone());
279    }
280
281    shell
282}