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 Branch,
62}
63
64#[derive(Debug, PartialEq, Eq)]
65enum PickerAction {
66 SelectModel(String),
67 SelectSession(String),
68 SelectBranch(String),
69 Cancel,
70}
71
72pub async fn run_interactive_tui(
73 harness: CodingHarness,
74 model: String,
75 theme_name: &str,
76 keybindings: Keybindings,
77) -> Result<(), Box<dyn std::error::Error>> {
78 let theme = resolve_interactive_theme(&harness, theme_name);
79 if theme.name != theme_name {
80 eprintln!("opi: warning: unknown theme {theme_name:?}, using default");
81 }
82 let graphics_protocol = detect_graphics_protocol(
83 std::env::var("TERM").ok().as_deref(),
84 std::env::var("TERM_PROGRAM").ok().as_deref(),
85 std::env::var("TERM_FEATURES").ok().as_deref(),
86 &CapabilitySource::EnvVars,
87 );
88 let state = Arc::new(Mutex::new(TuiState {
89 messages: Vec::new(),
90 input_text: String::new(),
91 app_state: AppState::Idle,
92 model: model.clone(),
93 active_tool: None,
94 streaming_started: false,
95 theme,
96 keybindings,
97 total_tokens: 0,
98 cost_usd: None,
99 graphics_protocol,
100 picker: None,
101 }));
102
103 let state_clone = state.clone();
105 let mut harness = harness;
106 harness.subscribe(Box::new(move |event| {
107 let mut s = state_clone.lock().unwrap();
108 match event {
109 AgentEvent::MessageStart { .. } => {
110 s.app_state = AppState::Streaming;
111 s.streaming_started = false;
112 }
113 AgentEvent::MessageUpdate {
114 assistant_event, ..
115 } => {
116 if let AssistantStreamEvent::TextDelta { delta, .. } = assistant_event.as_ref() {
117 if !s.streaming_started {
118 s.messages
119 .push(TuiMessage::new(TuiRole::Assistant, delta.clone()));
120 s.streaming_started = true;
121 } else if let Some(msg) = s.messages.last_mut() {
122 msg.content.push_str(delta);
123 }
124 }
125 }
126 AgentEvent::MessageEnd {
127 message: AgentMessage::Llm(Message::Assistant(a)),
128 } => {
129 s.total_tokens += a.usage.total_tokens();
130 for content in &a.content {
131 match content {
132 AssistantContent::Text { text } if !s.streaming_started => {
133 s.messages
134 .push(TuiMessage::new(TuiRole::Assistant, text.clone()));
135 }
136 AssistantContent::ToolCall { tool_call } => {
137 s.active_tool = Some((
138 tool_call.name.clone(),
139 tool_call.arguments.clone(),
140 ToolCallStatus::Running,
141 ));
142 }
143 _ => {}
144 }
145 }
146 s.streaming_started = false;
147 }
148 AgentEvent::ToolExecutionStart {
149 tool_name, args, ..
150 } => {
151 s.app_state = AppState::ToolExecuting;
152 s.active_tool = Some((
153 tool_name.clone(),
154 format!("{args}"),
155 ToolCallStatus::Running,
156 ));
157 }
158 AgentEvent::ToolExecutionEnd {
159 tool_name,
160 is_error,
161 details,
162 result,
163 ..
164 } => {
165 if !is_error
167 && tool_name == "edit"
168 && let Some(d) = details
169 && let (Some(path), Some(before), Some(after)) =
170 (d.get("path"), d.get("before"), d.get("after"))
171 {
172 let path_str = path.as_str().unwrap_or("unknown");
173 let before_str = before.as_str().unwrap_or("");
174 let after_str = after.as_str().unwrap_or("");
175 s.messages
176 .push(TuiMessage::diff(path_str, before_str, after_str));
177 }
178 let protocol = s.graphics_protocol;
180 if let Some(content_arr) = result.as_array() {
181 for item in content_arr {
182 if item.get("type").and_then(|v| v.as_str()) == Some("image")
183 && let Some(source) = item.get("source")
184 {
185 let bytes = if source.get("type").and_then(|v| v.as_str())
186 == Some("bytes")
187 {
188 source
189 .get("data")
190 .and_then(|v| v.as_array())
191 .map(|arr| {
192 arr.iter()
193 .filter_map(|v| v.as_u64().map(|n| n as u8))
194 .collect::<Vec<u8>>()
195 })
196 .unwrap_or_default()
197 } else if source.get("type").and_then(|v| v.as_str()) == Some("base64")
198 {
199 use base64::Engine;
200 source
201 .get("data")
202 .and_then(|v| v.as_str())
203 .and_then(|d| {
204 base64::engine::general_purpose::STANDARD.decode(d).ok()
205 })
206 .unwrap_or_default()
207 } else {
208 vec![]
209 };
210 if !bytes.is_empty() {
211 let media_type = item.get("media_type").and_then(|v| v.as_str());
212 let tui_media = match media_type {
213 Some("image/jpeg") => TuiMediaType::Jpeg,
214 Some("image/gif") => TuiMediaType::Gif,
215 Some("image/webp") => TuiMediaType::WebP,
216 _ => TuiMediaType::Png,
217 };
218 let image_data = ImageData {
219 bytes,
220 media_type: tui_media,
221 width: None,
222 height: None,
223 };
224 s.messages.push(TuiMessage::image(
225 TuiRole::Tool,
226 ImagePayload {
227 data: image_data,
228 protocol,
229 },
230 ));
231 }
232 }
233 }
234 }
235 if let Some((name, args, _)) = &s.active_tool
236 && name == tool_name
237 {
238 let status = if *is_error {
239 ToolCallStatus::Error("failed".into())
240 } else {
241 ToolCallStatus::Success
242 };
243 s.active_tool = Some((name.clone(), args.clone(), status));
244 }
245 s.app_state = AppState::Streaming;
246 }
247 AgentEvent::AgentEnd { .. } => {
248 s.app_state = AppState::Idle;
249 s.active_tool = None;
250 }
251 AgentEvent::TurnStart => {
252 s.app_state = AppState::Thinking;
253 }
254 AgentEvent::CompactionStart { reason } => {
255 s.messages.push(TuiMessage::new(
256 TuiRole::System,
257 format!("[compaction started: {reason:?}]"),
258 ));
259 }
260 AgentEvent::CompactionEnd {
261 reason,
262 result,
263 aborted,
264 error_message,
265 } => {
266 let summary = if *aborted {
267 format!(
268 "[compaction aborted ({reason:?}): {}]",
269 error_message.clone().unwrap_or_default()
270 )
271 } else if let Some(r) = result {
272 format!(
273 "[compaction done ({reason:?}): {} -> {} tokens]",
274 r.tokens_before, r.tokens_after
275 )
276 } else {
277 format!("[compaction done ({reason:?})]")
278 };
279 s.messages.push(TuiMessage::new(TuiRole::System, summary));
280 }
281 AgentEvent::SessionPersistError { message } => {
282 s.messages.push(TuiMessage::new(
283 TuiRole::System,
284 format!("[session persist error: {message}]"),
285 ));
286 }
287 _ => {}
288 }
289 }));
290
291 let harness = Arc::new(tokio::sync::Mutex::new(harness));
292
293 terminal::enable_raw_mode()?;
295 let mut stdout = io::stdout();
296 crossterm::execute!(stdout, EnterAlternateScreen)?;
297 let backend = CrosstermBackend::new(stdout);
298 let mut terminal = Terminal::new(backend)?;
299
300 let result = tui_event_loop(&mut terminal, &harness, &state).await;
302
303 terminal::disable_raw_mode()?;
305 crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
306 terminal.show_cursor()?;
307
308 result
309}
310
311async fn tui_event_loop(
312 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
313 harness: &Arc<tokio::sync::Mutex<CodingHarness>>,
314 state: &Arc<Mutex<TuiState>>,
315) -> Result<(), Box<dyn std::error::Error>> {
316 let mut pending: Option<tokio::task::JoinHandle<Result<Vec<AgentMessage>, AgentError>>> = None;
317 let mut cancel_token = harness.lock().await.cancel_token();
318
319 loop {
320 {
322 let s = state.lock().unwrap();
323 let shell = build_shell(&s);
324 terminal.draw(|frame| frame.render_widget(shell, frame.area()))?;
325 }
326
327 if let Some(handle) = &mut pending
329 && handle.is_finished()
330 {
331 match handle.await {
332 Ok(Ok(_messages)) => {
333 let mut s = state.lock().unwrap();
334 s.app_state = AppState::Idle;
335 }
336 Ok(Err(AgentError::Cancelled)) => {
337 let mut s = state.lock().unwrap();
338 s.app_state = AppState::Idle;
339 }
340 Ok(Err(e)) => {
341 let mut s = state.lock().unwrap();
342 s.messages
343 .push(TuiMessage::new(TuiRole::System, format!("error: {e}")));
344 s.app_state = AppState::Idle;
345 }
346 Err(e) => {
347 let mut s = state.lock().unwrap();
348 s.messages
349 .push(TuiMessage::new(TuiRole::System, format!("error: {e}")));
350 s.app_state = AppState::Idle;
351 }
352 }
353
354 {
357 let h = harness.lock().await;
358 if let Some(session) = h.session()
359 && let Some(cost) = session.cost_summary()
360 {
361 state.lock().unwrap().cost_usd = Some(cost.total_cost());
362 }
363 }
364
365 cancel_token = harness.lock().await.cancel_token();
368 pending = None;
369 }
370
371 if event::poll(std::time::Duration::from_millis(50))?
373 && let Event::Key(key) = event::read()?
374 {
375 if key.kind != KeyEventKind::Press {
376 continue;
377 }
378 let kb = state.lock().unwrap().keybindings.clone();
379 if let Some(action) = {
380 let mut s = state.lock().unwrap();
381 handle_picker_key(&mut s, key.code)
382 } {
383 match action {
384 PickerAction::SelectModel(model) => {
385 let mut h = harness.lock().await;
386 h.set_model(model.clone());
387 let mut s = state.lock().unwrap();
388 s.model = model.clone();
389 s.messages.push(TuiMessage::new(
390 TuiRole::System,
391 format!("[model switched: {model}]"),
392 ));
393 }
394 PickerAction::SelectSession(session_id) => {
395 let result = {
396 let mut h = harness.lock().await;
397 h.resume_session_id(&session_id)
398 };
399 let mut s = state.lock().unwrap();
400 match result {
401 Ok(count) => s.messages.push(TuiMessage::new(
402 TuiRole::System,
403 format!("[session resumed: {session_id}, {count} messages]"),
404 )),
405 Err(e) => s.messages.push(TuiMessage::new(
406 TuiRole::System,
407 format!("[session resume failed: {e}]"),
408 )),
409 }
410 }
411 PickerAction::SelectBranch(tip_id) => {
412 let result = {
413 let mut h = harness.lock().await;
414 h.resume_session_branch_tip(&tip_id)
415 };
416 let mut s = state.lock().unwrap();
417 match result {
418 Ok(count) => s.messages.push(TuiMessage::new(
419 TuiRole::System,
420 format!("[branch selected: {tip_id}, {count} messages]"),
421 )),
422 Err(e) => s.messages.push(TuiMessage::new(
423 TuiRole::System,
424 format!("[branch select failed: {e}]"),
425 )),
426 }
427 }
428 PickerAction::Cancel => {}
429 }
430 continue;
431 }
432
433 if matches_key_combo(key.code, key.modifiers, &kb.submit) {
434 if pending.is_some() {
436 continue;
437 }
438
439 let input = {
440 let mut s = state.lock().unwrap();
441 let text = s.input_text.trim().to_string();
442 s.input_text.clear();
443 text
444 };
445
446 if input == "exit" || input == "quit" {
447 if let Some(handle) = pending.take() {
449 cancel_token.cancel();
450 let _ = handle.await;
451 }
452 return Ok(());
453 }
454 if input.is_empty() {
455 continue;
456 }
457
458 if input == "/model" {
459 let items = {
460 let h = harness.lock().await;
461 h.model_picker_items()
462 };
463 let mut s = state.lock().unwrap();
464 if items.is_empty() {
465 s.messages.push(TuiMessage::new(
466 TuiRole::System,
467 "[model picker: no models available]",
468 ));
469 } else {
470 s.picker = Some(PickerOverlay {
471 kind: PickerKind::Model,
472 title: "Select model".into(),
473 state: SelectListState::new(items),
474 });
475 }
476 continue;
477 }
478
479 if input == "/session" {
480 let dir = crate::session_cli::session_dir();
481 let items = crate::picker::session_picker_items(&dir).unwrap_or_default();
482 let mut s = state.lock().unwrap();
483 if items.is_empty() {
484 s.messages.push(TuiMessage::new(
485 TuiRole::System,
486 "[session picker: no sessions available]",
487 ));
488 } else {
489 s.picker = Some(PickerOverlay {
490 kind: PickerKind::Session,
491 title: "Resume session".into(),
492 state: SelectListState::new(items),
493 });
494 }
495 continue;
496 }
497
498 if input == "/branch" {
499 let items_result = {
500 let h = harness.lock().await;
501 h.branch_picker_items()
502 };
503 let mut s = state.lock().unwrap();
504 match items_result {
505 Ok(items) if items.is_empty() => {
506 s.messages.push(TuiMessage::new(
507 TuiRole::System,
508 "[branch picker: no branches available]",
509 ));
510 }
511 Ok(items) => {
512 s.picker = Some(PickerOverlay {
513 kind: PickerKind::Branch,
514 title: "Select branch".into(),
515 state: SelectListState::new(items),
516 });
517 }
518 Err(e) => {
519 s.messages.push(TuiMessage::new(
520 TuiRole::System,
521 format!("[branch picker failed: {e}]"),
522 ));
523 }
524 }
525 continue;
526 }
527
528 if let Some(rest) = input.strip_prefix("/image ") {
529 let path = rest.trim();
530 if path.is_empty() {
531 let mut s = state.lock().unwrap();
532 s.messages.push(TuiMessage::new(
533 TuiRole::System,
534 "[/image: usage: /image <path>]".to_string(),
535 ));
536 } else {
537 let image_path = std::path::PathBuf::from(path);
538 let max_bytes = {
539 let h = harness.lock().await;
540 h.config().defaults.max_image_bytes
541 };
542 match crate::image::load_image_with_limit(&image_path, max_bytes) {
543 Ok(img) => {
544 harness.lock().await.queue_images(vec![img]);
545 let mut s = state.lock().unwrap();
546 s.messages.push(TuiMessage::new(
547 TuiRole::System,
548 format!("[image queued: {}]", image_path.display()),
549 ));
550 }
551 Err(e) => {
552 let mut s = state.lock().unwrap();
553 s.messages.push(TuiMessage::new(
554 TuiRole::System,
555 format!("[/image error: {e}]"),
556 ));
557 }
558 }
559 }
560 continue;
561 }
562
563 {
565 let mut s = state.lock().unwrap();
566 s.messages
567 .push(TuiMessage::new(TuiRole::User, input.clone()));
568 s.app_state = AppState::Thinking;
569 }
570
571 let h = harness.clone();
573 let handle = tokio::spawn(async move {
574 let mut h = h.lock().await;
575 let pending = h.take_pending_images();
576 if pending.is_empty() {
577 h.prompt(&input).await
578 } else {
579 let mut content = vec![opi_ai::message::InputContent::Text { text: input }];
580 content.extend(pending);
581 h.prompt_with_content(content).await
582 }
583 });
584 pending = Some(handle);
585 } else if matches_key_combo(key.code, key.modifiers, &kb.abort) {
586 if pending.is_some() {
587 cancel_token.cancel();
588 } else {
589 return Ok(());
590 }
591 } else if matches_key_combo(key.code, key.modifiers, &kb.new_line) {
592 if pending.is_none() {
593 state.lock().unwrap().input_text.push('\n');
594 }
595 } else {
596 match key.code {
597 KeyCode::Char(c) if pending.is_none() => {
598 state.lock().unwrap().input_text.push(c);
599 }
600 KeyCode::Backspace if pending.is_none() => {
601 state.lock().unwrap().input_text.pop();
602 }
603 _ => {}
604 }
605 }
606 }
607 }
608}
609
610fn resolve_interactive_theme(harness: &CodingHarness, theme_name: &str) -> Theme {
611 harness
612 .resolve_theme(theme_name)
613 .unwrap_or_else(|_| resolve_theme(theme_name))
614}
615
616fn build_shell(s: &TuiState) -> Shell {
617 let mut shell = Shell::new(s.model.clone())
618 .input_text(s.input_text.clone())
619 .state(s.app_state)
620 .theme(s.theme.clone());
621
622 if s.total_tokens > 0 {
623 shell = shell.token_count(s.total_tokens);
624 }
625
626 if let Some(cost) = s.cost_usd {
627 shell = shell.cost_usd(cost);
628 }
629
630 if !s.messages.is_empty() {
631 shell = shell.messages(s.messages.clone());
632 }
633
634 if let Some((name, args, status)) = &s.active_tool {
635 shell = shell.active_tool(name.clone(), args.clone(), status.clone());
636 }
637
638 if let Some(picker) = &s.picker {
639 shell = shell.picker(picker.title.clone(), picker.state.clone());
640 }
641
642 shell
643}
644
645fn handle_picker_key(s: &mut TuiState, code: KeyCode) -> Option<PickerAction> {
646 let picker = s.picker.as_mut()?;
647 match code {
648 KeyCode::Esc => {
649 s.picker = None;
650 Some(PickerAction::Cancel)
651 }
652 KeyCode::Enter => {
653 let item = picker.state.confirm().cloned();
654 let kind = picker.kind;
655 s.picker = None;
656 match (kind, item) {
657 (PickerKind::Model, Some(item)) => Some(PickerAction::SelectModel(item.id)),
658 (PickerKind::Session, Some(item)) => Some(PickerAction::SelectSession(item.id)),
659 (PickerKind::Branch, Some(item)) => Some(PickerAction::SelectBranch(item.id)),
660 (_, None) => Some(PickerAction::Cancel),
661 }
662 }
663 KeyCode::Down => {
664 picker.state.move_down();
665 None
666 }
667 KeyCode::Up => {
668 picker.state.move_up();
669 None
670 }
671 KeyCode::PageDown => {
672 picker.state.page_down(10);
673 None
674 }
675 KeyCode::PageUp => {
676 picker.state.page_up(10);
677 None
678 }
679 KeyCode::Backspace => {
680 let mut filter = picker.state.filter().to_string();
681 filter.pop();
682 picker.state.set_filter(filter);
683 None
684 }
685 KeyCode::Char(c) => {
686 let mut filter = picker.state.filter().to_string();
687 filter.push(c);
688 picker.state.set_filter(filter);
689 None
690 }
691 _ => None,
692 }
693}
694
695fn matches_key_combo(code: KeyCode, modifiers: KeyModifiers, combo: &KeyCombo) -> bool {
696 let key_matches = match (code, &combo.key) {
697 (KeyCode::Enter, Key::Enter) => true,
698 (KeyCode::Esc, Key::Escape) => true,
699 (KeyCode::Tab, Key::Tab) => true,
700 (KeyCode::Backspace, Key::Backspace) => true,
701 (KeyCode::Char(c), Key::Char(expected)) => c == *expected,
702 _ => false,
703 };
704 if !key_matches {
705 return false;
706 }
707 combo.modifiers.alt == modifiers.contains(KeyModifiers::ALT)
708 && combo.modifiers.ctrl == modifiers.contains(KeyModifiers::CONTROL)
709 && combo.modifiers.shift == modifiers.contains(KeyModifiers::SHIFT)
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use crate::config::OpiConfig;
716 use crate::resource::{DiscoveryLayer, ResourceDiscoveryLayers};
717 use opi_ai::test_support::MockProvider;
718 use opi_tui::SelectItem;
719
720 fn state_with_picker(kind: PickerKind) -> TuiState {
721 TuiState {
722 messages: Vec::new(),
723 input_text: String::new(),
724 app_state: AppState::Idle,
725 model: "mock:old".into(),
726 active_tool: None,
727 streaming_started: false,
728 theme: Theme::default(),
729 keybindings: Keybindings::default(),
730 total_tokens: 0,
731 cost_usd: None,
732 graphics_protocol: TerminalGraphicsProtocol::Fallback,
733 picker: Some(PickerOverlay {
734 kind,
735 title: "Pick".into(),
736 state: SelectListState::new(vec![SelectItem {
737 id: "mock:new".into(),
738 display: "New".into(),
739 metadata: "mock".into(),
740 }]),
741 }),
742 }
743 }
744
745 #[test]
746 fn model_picker_enter_returns_selected_model() {
747 let mut state = state_with_picker(PickerKind::Model);
748 let action = handle_picker_key(&mut state, KeyCode::Enter);
749 assert_eq!(action, Some(PickerAction::SelectModel("mock:new".into())));
750 assert!(state.picker.is_none());
751 }
752
753 #[test]
754 fn session_picker_enter_returns_selected_session() {
755 let mut state = state_with_picker(PickerKind::Session);
756 let action = handle_picker_key(&mut state, KeyCode::Enter);
757 assert_eq!(action, Some(PickerAction::SelectSession("mock:new".into())));
758 assert!(state.picker.is_none());
759 }
760
761 #[test]
762 fn branch_picker_enter_returns_selected_tip() {
763 let mut state = state_with_picker(PickerKind::Branch);
764 let action = handle_picker_key(&mut state, KeyCode::Enter);
765 assert_eq!(action, Some(PickerAction::SelectBranch("mock:new".into())));
766 assert!(state.picker.is_none());
767 }
768
769 #[test]
770 fn interactive_theme_resolver_uses_harness_discovered_themes() {
771 let tmp = tempfile::tempdir().unwrap();
772 let theme_dir = tmp.path().join("operator-theme");
773 std::fs::create_dir_all(&theme_dir).unwrap();
774 std::fs::write(
775 theme_dir.join("theme.toml"),
776 r##"
777name = "operator-theme"
778description = "Operator theme"
779
780[colors]
781role_user = "Red"
782status_bg = "#1a1a2e"
783"##,
784 )
785 .unwrap();
786
787 let provider = MockProvider::new("mock", vec![]);
788 let harness = CodingHarness::builder(
789 Box::new(provider),
790 "mock:mock-model".into(),
791 OpiConfig::default(),
792 tmp.path().to_path_buf(),
793 )
794 .resource_layers(ResourceDiscoveryLayers {
795 themes: vec![DiscoveryLayer {
796 root: theme_dir,
797 subdirectory: None,
798 precedence: 2,
799 }],
800 ..Default::default()
801 })
802 .build();
803
804 let theme = resolve_interactive_theme(&harness, "operator-theme");
805
806 assert_eq!(theme.name, "operator-theme");
807 }
808}