1use 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
28struct TuiState {
30 messages: Vec<TuiMessage>,
31 input_text: String,
32 app_state: AppState,
33 model: String,
34 active_tool: Option<(String, String, ToolCallStatus)>,
35 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 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 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 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 let result = tui_event_loop(&mut terminal, &harness, &state).await;
208
209 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 {
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 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 {
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 cancel_token = harness.lock().await.cancel_token();
274 pending = None;
275 }
276
277 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 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 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 {
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 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}