opi_coding_agent/
interactive.rs1use 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
25struct TuiState {
27 messages: Vec<TuiMessage>,
28 input_text: String,
29 app_state: AppState,
30 model: String,
31 active_tool: Option<(String, String, ToolCallStatus)>,
32 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 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 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 let result = tui_event_loop(&mut terminal, &harness, &state).await;
143
144 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 {
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 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 cancel_token = harness.lock().await.cancel_token();
197 pending = None;
198 }
199
200 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 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 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 {
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 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}