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, 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
32/// Shared state mutated by the agent callback and read by the TUI render loop.
33struct TuiState {
34    messages: Vec<TuiMessage>,
35    input_text: String,
36    app_state: AppState,
37    model: String,
38    active_tool: Option<(String, String, ToolCallStatus)>,
39    /// True when a TextDelta has been received for the current streaming cycle.
40    /// Prevents MessageEnd from pushing a duplicate text message.
41    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    // Wire agent events into shared state before wrapping harness
104    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                // Render diff for edit tool results that have before/after details.
166                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                // Extract image content from tool result.
179                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    // Setup terminal
294    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    // Main TUI loop
301    let result = tui_event_loop(&mut terminal, &harness, &state).await;
302
303    // Restore terminal
304    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        // Render current state
321        {
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        // Check if pending prompt finished (non-blocking)
328        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            // Refresh cost from the harness session (pricing lookup may yield
355            // a number; if the model isn't in the table we leave it as-is).
356            {
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            // Refresh cancel token — Agent::maybe_reset_cancel() creates a new one
366            // after cancellation, so the old token would be stale.
367            cancel_token = harness.lock().await.cancel_token();
368            pending = None;
369        }
370
371        // Poll for terminal events (non-blocking with timeout)
372        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                // Ignore submit while agent is running
435                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                    // Cancel any pending task on exit
448                    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                // Add user message to display
564                {
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                // Spawn agent prompt in background task
572                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}