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::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
32struct TuiState {
34 messages: Vec<TuiMessage>,
35 input_text: String,
36 app_state: AppState,
37 model: String,
38 active_tool: Option<(String, String, ToolCallStatus)>,
39 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 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 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 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 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 let result = tui_event_loop(&mut terminal, &harness, &state).await;
300
301 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 {
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 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 {
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 cancel_token = harness.lock().await.cancel_token();
366 pending = None;
367 }
368
369 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 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 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 {
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 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}