Skip to main content

oxi/tui/
app.rs

1//! Main TUI event loop and application state.
2
3use super::handlers;
4use super::render;
5use super::slash;
6use super::welcome;
7use crate::app::agent_session::SessionEvent;
8use crate::app::agent_session_runtime::{
9    create_agent_session_from_services, create_agent_session_services,
10    CreateAgentSessionFromServicesOptions, CreateAgentSessionServicesOptions,
11};
12use crate::context::auto_compaction::CompactionReason;
13use crate::util::slash_commands::BUILTIN_SLASH_COMMANDS;
14use anyhow::Result;
15use oxi_agent::AgentEvent;
16use oxi_store::session::SessionManager;
17use oxi_tui::theme::Theme;
18use oxi_tui::widgets::{
19    chat::{ChatMessage, ChatViewState, ContentBlock, MessageRole},
20    footer::FooterState,
21    input::InputState,
22};
23use std::io::{self, Write};
24use std::panic;
25use std::sync::{atomic::Ordering, Arc};
26use tokio::sync::mpsc;
27
28use crossterm::{
29    cursor::Hide,
30    event::{
31        self, DisableBracketedPaste, EnableBracketedPaste, KeyboardEnhancementFlags,
32        PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
33    },
34    execute,
35    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
36};
37use ratatui::{backend::CrosstermBackend, Terminal};
38
39// ── Terminal Lifecycle ───────────────────────────────────────────────────
40
41/// Terminal wrapper following ratatui best practices.
42/// Encapsulates setup/teardown, panic hook, and mouse tracking.
43struct Tui {
44    terminal: Terminal<CrosstermBackend<io::Stdout>>,
45    tty_ok: bool,
46}
47
48impl Tui {
49    fn enter() -> Result<Self> {
50        // Set panic hook first — ensures terminal is restored on panic
51        Self::set_panic_hook();
52
53        let tty_ok = enable_raw_mode().is_ok();
54        let mut stdout = io::stdout();
55
56        if tty_ok {
57            let _ = execute!(
58                stdout,
59                EnterAlternateScreen,
60                Hide,
61                EnableBracketedPaste,
62                PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
63            );
64            // Enable mouse scroll tracking without drag tracking.
65            // ?1000h = click/release/scroll, ?1006h = SGR extended coords.
66            // Intentionally skip ?1002h so terminal handles drag-to-select natively.
67            let _ = stdout.write_all(b"\x1b[?1000h\x1b[?1006h");
68            let _ = stdout.flush();
69        }
70
71        let backend = CrosstermBackend::new(stdout);
72        let mut terminal = Terminal::new(backend)?;
73        if tty_ok {
74            let _ = terminal.clear();
75        }
76
77        Ok(Self { terminal, tty_ok })
78    }
79
80    fn exit(&mut self) -> Result<()> {
81        if self.tty_ok {
82            // 1. Disable mouse tracking (before leaving alternate screen)
83            let _ = io::stdout().write_all(b"\x1b[?1000l\x1b[?1006l");
84            let _ = io::stdout().flush();
85            // 2. Pop keyboard enhancements and bracketed paste
86            execute!(
87                self.terminal.backend_mut(),
88                PopKeyboardEnhancementFlags,
89                DisableBracketedPaste
90            )?;
91            // 3. Show cursor before leaving alternate screen
92            self.terminal.show_cursor()?;
93            // 4. Leave alternate screen
94            execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
95            // 5. Disable raw mode last
96            disable_raw_mode()?;
97        }
98        Ok(())
99    }
100
101    fn set_panic_hook() {
102        let original_hook = panic::take_hook();
103        panic::set_hook(Box::new(move |panic_info| {
104            // Restore terminal state before printing panic info
105            let _ = io::stdout().write_all(b"\x1b[?1000l\x1b[?1006l");
106            let _ = io::stdout().flush();
107            let _ = execute!(io::stdout(), LeaveAlternateScreen);
108            let _ = disable_raw_mode();
109            original_hook(panic_info);
110        }));
111    }
112}
113
114impl std::ops::Deref for Tui {
115    type Target = Terminal<CrosstermBackend<io::Stdout>>;
116    fn deref(&self) -> &Self::Target {
117        &self.terminal
118    }
119}
120
121impl std::ops::DerefMut for Tui {
122    fn deref_mut(&mut self) -> &mut Self::Target {
123        &mut self.terminal
124    }
125}
126
127impl Drop for Tui {
128    fn drop(&mut self) {
129        let _ = self.exit();
130    }
131}
132
133// ── UI Events (agent → TUI) ──────────────────────────────────────────────
134
135pub(crate) enum UiEvent {
136    // ── Agent lifecycle (pi-mono: agent_start / agent_end) ──────────
137    AgentStart,
138    AgentEnd,
139
140    // ── Turn lifecycle (pi-mono: turn_start / turn_end) ────────────
141    #[allow(dead_code)]
142    TurnStart {
143        #[allow(dead_code)]
144        turn_number: u32,
145    },
146    #[allow(dead_code)]
147    TurnEnd {
148        #[allow(dead_code)]
149        turn_number: u32,
150    },
151
152    // ── Message lifecycle (pi-mono: message_start / update / end) ──
153    /// A new message is being streamed. pi-mono: message_start.
154    MessageStart {
155        message: oxi_ai::Message,
156    },
157    /// Full message snapshot with current content blocks. pi-mono: message_update.
158    /// Content blocks are already separated (text vs toolCall) by the provider.
159    MessageUpdate {
160        message: oxi_ai::Message,
161        delta: Option<String>,
162    },
163    /// Message streaming is complete. pi-mono: message_end.
164    MessageEnd {
165        message: oxi_ai::Message,
166    },
167
168    // ── Tool execution ─────────────────────────────────────────────
169    ToolExecutionStart {
170        tool_call_id: String,
171        tool_name: String,
172        args: serde_json::Value,
173    },
174    ToolExecutionEnd {
175        tool_call_id: String,
176        tool_name: String,
177        result: oxi_ai::ToolResult,
178        is_error: bool,
179    },
180
181    // ── Legacy events (kept for backward compat during transition) ──
182    Thinking,
183    ThinkingDelta(String),
184    Complete,
185    Error(String),
186
187    // ── Session events ─────────────────────────────────────────────
188    CompactionStart {
189        reason: CompactionReason,
190    },
191    CompactionEnd {
192        _reason: CompactionReason,
193        error_message: Option<String>,
194    },
195    RetryStart {
196        attempt: u32,
197        max_attempts: u32,
198        error_message: String,
199    },
200    ModelChanged {
201        model_id: String,
202    },
203    ThinkingLevelChanged {
204        level: String,
205    },
206    QueueUpdate {
207        pending: usize,
208    },
209    /// Token usage updated.
210    TokenUsage {
211        input_tokens: u32,
212        output_tokens: u32,
213        cache_read_tokens: u32,
214        cache_write_tokens: u32,
215        context_window_pct: f32,
216        total_cost: f64,
217    },
218
219    /// A queued message is being auto-processed (sent from the worker
220    /// thread when draining the steering/follow-up queue after a run).
221    /// The TUI should display the user message and enter streaming state.
222    AutoProcessStart {
223        prompt: String,
224    },
225}
226
227// ── Spinner ──────────────────────────────────────────────────────────────
228
229pub(super) const SPINNER: &[&str] = &["|", "/", "-", "\\"];
230
231// ── App State ────────────────────────────────────────────────────────────
232
233/// Setup wizard state
234#[derive(Debug, Clone)]
235pub(crate) enum SetupStep {
236    /// First step: OAuth or API Key
237    SelectAuthType {
238        auth_type: Option<String>, // "oauth" or "apikey"
239        selected: usize,
240    },
241    /// Select provider from list
242    SelectProvider {
243        providers: Vec<(String, bool)>, // (name, has_key)
244        selected: usize,
245    },
246    /// Enter API key for selected provider
247    EnterApiKey {
248        provider: String,
249        key: String,
250        #[allow(dead_code)]
251        masked_cursor: usize,
252    },
253    /// Select a model from the provider's available models
254    SelectModel {
255        provider: String,
256        models: Vec<String>,
257        selected: usize,
258    },
259    /// Done — show success
260    Done { provider: String, model: String },
261}
262
263/// Overlay types for interactive TUI dialogs.
264#[derive(Debug, Clone)]
265pub(crate) enum AppOverlay {
266    /// Initial setup wizard
267    Setup(SetupStep),
268    /// Model selector overlay
269    ModelSelect {
270        models: Vec<String>,
271        filter: String,
272        selected: usize,
273    },
274    /// Provider config wizard (reuses SetupStep)
275    ProviderConfig(SetupStep),
276    /// Logout provider selector
277    LogoutSelect {
278        providers: Vec<String>,
279        selected: usize,
280    },
281    /// Session resume selector
282    ResumeSelect {
283        sessions: Vec<oxi_store::session::SessionInfo>,
284        selected: usize,
285    },
286    /// Routing status panel (toggle with Ctrl+R)
287    #[allow(dead_code)]
288    RoutingStatus {
289        data: oxi_tui::widgets::routing::RoutingStatusData,
290        visible: bool,
291    },
292}
293
294// ── Session Switch Action ──────────────────────────────────────────────
295
296/// Action requested by a slash command or overlay to switch sessions.
297#[derive(Debug, Clone)]
298pub(crate) enum TuiNextAction {
299    /// Switch to an existing session file.
300    SwitchSession(String),
301    /// Start a fresh session.
302    NewSession,
303}
304
305pub(crate) struct AppState {
306    pub chat: ChatViewState,
307    pub input: InputState,
308    pub footer_state: FooterState,
309    pub is_agent_busy: bool,
310    pub spinner_frame: usize,
311    pub auto_scroll: bool,
312    pub input_history: Vec<String>,
313    pub history_index: usize,
314    pub saved_input: String,
315    pub slash_completions: Vec<slash::SlashCompletion>,
316    pub slash_completion_index: usize,
317    pub slash_completion_active: bool,
318    pub message_count: usize,
319    /// Active overlay (None = normal chat mode)
320    pub overlay: Option<AppOverlay>,
321    /// Component-based overlay (takes priority over AppOverlay variants for
322    /// ModelSelect, LogoutSelect, ResumeSelect). Migrated from AppOverlay.
323    pub overlay_state: Option<Box<dyn super::overlay::OverlayComponent>>,
324    /// WASM extension manager for dynamic commands
325    pub wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
326    /// Session file path for the current session
327    pub session_file_path: Option<String>,
328    /// Requested session switch action (checked by outer loop)
329    pub next_action: Option<TuiNextAction>,
330    /// Count of pending steering messages (shown in busy input)
331    pub pending_steering: usize,
332    /// Whether session needs to be persisted to disk
333    pub needs_persist: bool,
334    /// Length of text already rendered from the snapshot's Text block.
335    /// Used to compute incremental text delta from full snapshot.
336    /// Tracks bytes (not chars) to allow fast slicing of UTF-8 text.
337    snapshot_text_rendered: usize,
338    /// Per-block byte offsets for Thinking blocks already rendered.
339    /// Prevents duplicate thinking content on repeated MessageUpdates.
340    /// Uses Vec to support multiple Thinking blocks (defensive —
341    /// current providers emit at most one, but future ones may differ).
342    snapshot_thinking_rendered: Vec<usize>,
343    /// Whether the initial empty Text block has been created in the TUI.
344    /// Without this flag, the very first delta creates a Text content block
345    /// via `insert(0, ...)`, but on the *next* MessageUpdate, `first_mut()`
346    /// finds the existing block and we slice correctly.  The flag is defensive
347    /// — it ensures we always go through the `first_mut` path once the
348    /// block exists.
349    snapshot_text_block_created: bool,
350    /// Questionnaire bridge — set by run_tui_interactive_impl() from App::questionnaire_bridge().
351    questionnaire_bridge:
352        Option<std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>>,
353    /// Tool execution start times for measuring duration.
354    pub(crate) tool_start_times: std::collections::HashMap<String, std::time::Instant>,
355}
356
357impl AppState {
358    pub fn new() -> Self {
359        Self {
360            chat: ChatViewState::default(),
361            input: InputState::default(),
362            footer_state: FooterState::default(),
363            is_agent_busy: false,
364            spinner_frame: 0,
365            auto_scroll: true,
366            input_history: Vec::new(),
367            history_index: 0,
368            saved_input: String::new(),
369            slash_completions: Vec::new(),
370            slash_completion_index: 0,
371            slash_completion_active: false,
372            message_count: 0,
373            overlay: None,
374            overlay_state: None,
375            wasm_ext: None,
376            session_file_path: None,
377            next_action: None,
378            pending_steering: 0,
379            needs_persist: false,
380            snapshot_text_rendered: 0,
381            snapshot_thinking_rendered: Vec::new(),
382            snapshot_text_block_created: false,
383            questionnaire_bridge: None,
384            tool_start_times: std::collections::HashMap::new(),
385        }
386    }
387
388    // ── Input helpers ──
389
390    pub fn input_value(&self) -> String {
391        self.input.text()
392    }
393
394    pub fn input_clear(&mut self) {
395        self.input.clear();
396        self.clear_slash_completions();
397    }
398
399    pub fn input_set_text(&mut self, text: String) {
400        self.input.set_text(text);
401    }
402
403    pub fn clear_slash_completions(&mut self) {
404        self.slash_completions.clear();
405        self.slash_completion_index = 0;
406        self.slash_completion_active = false;
407    }
408
409    pub fn update_slash_completions(&mut self) {
410        let input_str = self.input_value();
411        let text = input_str.trim();
412        if !text.starts_with('/') || text.contains(' ') {
413            self.clear_slash_completions();
414            return;
415        }
416        let cmd_part = text.split_whitespace().next().unwrap_or("");
417        let query = if cmd_part.len() > 1 {
418            &cmd_part[1..]
419        } else {
420            ""
421        };
422        let mut matches: Vec<slash::SlashCompletion> = BUILTIN_SLASH_COMMANDS
423            .iter()
424            .filter(|cmd| query.is_empty() || cmd.name.starts_with(query))
425            .map(|cmd| slash::SlashCompletion {
426                name: format!("/{}", cmd.name),
427                description: cmd.description.to_string(),
428            })
429            .collect();
430        matches.sort_by(|a, b| a.name.cmp(&b.name));
431        self.slash_completions = matches;
432        self.slash_completion_index = 0;
433        self.slash_completion_active = !self.slash_completions.is_empty();
434    }
435
436    /// Get the currently selected slash command (for direct execution).
437    pub fn selected_slash_command(&self) -> Option<&slash::SlashCompletion> {
438        if !self.slash_completion_active || self.slash_completions.is_empty() {
439            return None;
440        }
441        self.slash_completions.get(self.slash_completion_index)
442    }
443
444    pub fn next_slash_completion(&mut self) {
445        if !self.slash_completions.is_empty() {
446            self.slash_completion_index =
447                (self.slash_completion_index + 1) % self.slash_completions.len();
448        }
449    }
450
451    pub fn prev_slash_completion(&mut self) {
452        if !self.slash_completions.is_empty() {
453            if self.slash_completion_index == 0 {
454                self.slash_completion_index = self.slash_completions.len() - 1;
455            } else {
456                self.slash_completion_index -= 1;
457            }
458        }
459    }
460
461    // ── Chat helpers ──
462
463    pub fn add_user_message(&mut self, content: String) {
464        self.chat.add_message(ChatMessage {
465            role: MessageRole::User,
466            content_blocks: vec![ContentBlock::Text { content }],
467            timestamp: now_millis(),
468        });
469        self.message_count += 1;
470    }
471
472    pub fn add_system_message(&mut self, content: String) {
473        self.chat.add_message(ChatMessage {
474            role: MessageRole::System,
475            content_blocks: vec![ContentBlock::Text { content }],
476            timestamp: now_millis(),
477        });
478    }
479
480    pub fn start_streaming(&mut self) {
481        self.chat.start_streaming();
482        self.is_agent_busy = true;
483        self.auto_scroll = true;
484        self.snapshot_text_rendered = 0;
485        self.snapshot_thinking_rendered.clear();
486        self.snapshot_text_block_created = false;
487    }
488
489    #[allow(dead_code)]
490    pub fn stream_text_delta(&mut self, _delta: &str) {}
491
492    /// Update the streaming message from a full MessageUpdate snapshot.
493    ///
494    /// pi-mono pattern: render from the snapshot's content blocks, NOT from
495    /// the raw delta string. The provider has already separated Text blocks
496    /// from ToolCall blocks in the snapshot. If the provider sent tool call
497    /// JSON as TextDelta, the snapshot's Text block won't contain it (it'll
498    /// be in a ToolCall block instead). This prevents JSON appearing in chat.
499    pub fn update_streaming_message(&mut self, msg: &oxi_ai::Message, _delta: Option<&str>) {
500        if let oxi_ai::Message::Assistant(assistant) = msg {
501            let mut thinking_block_idx: usize = 0;
502            for block in &assistant.content {
503                match block {
504                    oxi_ai::ContentBlock::Text(t) => {
505                        // Only render new text beyond what we've already rendered.
506                        // This is the pi-mono snapshot-based approach: use the
507                        // provider's Text block (which is properly separated
508                        // from tool calls), not the raw delta string.
509                        let text = &t.text;
510                        // Use >= so that pure-whitespace additions (a single space)
511                        // are not skipped. Previously `>` caused spaces between
512                        // words to be dropped when the provider sent them as a
513                        // separate delta that only grew the text by 1 byte.
514                        if text.len() >= self.snapshot_text_rendered {
515                            // Use char_indices to find the safe byte boundary
516                            // closest to snapshot_text_rendered (multi-byte chars
517                            // like Korean are 3 bytes each in UTF-8).
518                            let byte_off = text
519                                .char_indices()
520                                .map(|(i, _)| i)
521                                .find(|&i| i >= self.snapshot_text_rendered)
522                                .unwrap_or(text.len());
523                            let new_text = &text[byte_off..];
524                            if !new_text.is_empty() {
525                                self.chat.stream_text_delta(new_text);
526                            }
527                            self.snapshot_text_rendered = text.len();
528                            if !text.is_empty() {
529                                self.snapshot_text_block_created = true;
530                            }
531                        }
532                    }
533                    oxi_ai::ContentBlock::ToolCall(tc) => {
534                        // stream_tool_call is idempotent — it checks tool_tracker
535                        let args_str = serde_json::to_string(&tc.arguments)
536                            .unwrap_or_else(|_| tc.arguments.to_string());
537                        self.chat.stream_tool_call(
538                            tc.id.clone(),
539                            tc.name.clone(),
540                            args_str,
541                            oxi_tui::widgets::chat::ToolCallStatus::Requested,
542                        );
543                    }
544                    oxi_ai::ContentBlock::Thinking(t) => {
545                        // Thinking blocks — only append new content beyond
546                        // what we've already rendered for this specific block.
547                        // Per-block tracking prevents content loss if a future
548                        // provider emits multiple Thinking blocks.
549                        let thinking = &t.thinking;
550                        while self.snapshot_thinking_rendered.len() <= thinking_block_idx {
551                            self.snapshot_thinking_rendered.push(0);
552                        }
553                        if thinking.len() > self.snapshot_thinking_rendered[thinking_block_idx] {
554                            let prev = self.snapshot_thinking_rendered[thinking_block_idx];
555                            let byte_off = thinking
556                                .char_indices()
557                                .map(|(i, _)| i)
558                                .find(|&i| i >= prev)
559                                .unwrap_or(thinking.len());
560                            let new_thinking = &thinking[byte_off..];
561                            if !new_thinking.is_empty() {
562                                self.chat.stream_thinking(new_thinking.to_string(), false);
563                            }
564                            self.snapshot_thinking_rendered[thinking_block_idx] = thinking.len();
565                        }
566                        thinking_block_idx += 1;
567                    }
568                    oxi_ai::ContentBlock::Image(img) => {
569                        self.chat
570                            .stream_image(img.mime_type.clone(), img.data.clone());
571                    }
572                    oxi_ai::ContentBlock::Unknown(_) => {}
573                }
574            }
575        }
576    }
577
578    /// Finalize the streaming message from a MessageEnd snapshot.
579    pub fn finalize_streaming_message(&mut self, msg: &oxi_ai::Message) {
580        if let oxi_ai::Message::Assistant(assistant) = msg {
581            // Update token usage from the completed message
582            let usage = &assistant.usage;
583            tracing::info!(
584                "[TOKENS] input={} output={} cache_read={} cache_write={} total={}",
585                usage.input,
586                usage.output,
587                usage.cache_read,
588                usage.cache_write,
589                usage.total_tokens
590            );
591            let context_window_pct = if usage.total_tokens > 0 {
592                (usage.total_tokens as f32 / 200_000.0) * 100.0
593            } else if usage.input > 0 || usage.output > 0 {
594                ((usage.input + usage.output + usage.cache_read + usage.cache_write) as f32
595                    / 200_000.0)
596                    * 100.0
597            } else {
598                0.0
599            };
600            self.footer_state.data.input_tokens = usage.input as u32;
601            self.footer_state.data.output_tokens = usage.output as u32;
602            self.footer_state.data.cache_read_tokens = usage.cache_read as u32;
603            self.footer_state.data.cache_write_tokens = usage.cache_write as u32;
604            self.footer_state.data.context_window_pct = context_window_pct;
605            self.footer_state.data.total_cost = usage.cost.total();
606            self.footer_state.data.context_tokens =
607                (usage.input + usage.output + usage.cache_read + usage.cache_write) as u32;
608        }
609    }
610
611    pub fn finish_streaming(&mut self) {
612        let was_streaming = self.chat.is_streaming();
613        self.chat.finish_streaming();
614        self.is_agent_busy = false;
615        self.snapshot_text_rendered = 0;
616        self.snapshot_thinking_rendered.clear();
617        self.snapshot_text_block_created = false;
618        if was_streaming {
619            self.message_count += 1;
620            // Refresh last code block from completed message
621            self.chat.refresh_last_code_block();
622        }
623    }
624
625    pub fn cancel_streaming(&mut self) {
626        if self.chat.is_streaming() {
627            self.chat.finish_streaming();
628            self.message_count += 1;
629        }
630        self.is_agent_busy = false;
631    }
632
633    pub fn scroll_up(&mut self, n: u16) {
634        self.chat.scroll_up(n);
635        self.auto_scroll = false;
636    }
637
638    pub fn scroll_down(&mut self, n: u16) {
639        self.chat.scroll_down(n);
640    }
641
642    pub fn ensure_auto_scroll(&mut self, visible_height: u16) {
643        if self.auto_scroll {
644            self.chat.scroll_to_bottom(visible_height);
645        }
646    }
647
648    pub fn messages(&self) -> &[ChatMessage] {
649        &self.chat.messages
650    }
651}
652
653fn now_millis() -> i64 {
654    std::time::SystemTime::now()
655        .duration_since(std::time::UNIX_EPOCH)
656        .unwrap_or_default()
657        .as_millis() as i64
658}
659
660// ── Shared agent runtime ────────────────────────────────────────────────
661
662/// Returns a process-lifetime Tokio runtime used by the agent worker thread.
663/// Re-creating a multi-threaded runtime on every session switch is expensive;
664/// this `OnceLock` ensures exactly one instance lives for the entire process.
665fn get_agent_runtime() -> &'static tokio::runtime::Runtime {
666    use std::sync::OnceLock;
667    static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
668    RUNTIME.get_or_init(|| {
669        tokio::runtime::Builder::new_multi_thread()
670            .worker_threads(2)
671            .enable_all()
672            .build()
673            .expect("Failed to build agent runtime")
674    })
675}
676
677// ── Main entry point ─────────────────────────────────────────────────────
678
679/// Run the TUI interactive mode.
680pub async fn run_tui_interactive(app: crate::App) -> Result<()> {
681    run_tui_interactive_impl(app, false).await
682}
683
684/// Run TUI interactive mode, optionally resuming the most recent session.
685pub async fn run_tui_interactive_with_continue(app: crate::App, resume_last: bool) -> Result<()> {
686    run_tui_interactive_impl(app, resume_last).await
687}
688
689async fn run_tui_interactive_impl(app: crate::App, resume_last: bool) -> Result<()> {
690    // ── Extract resources from App (needed for session switching loop) ──
691    let settings = app.settings().clone();
692    let mut model_id = app.model_id();
693    let tools = app.agent().tools();
694    let wasm_ext = app.wasm_ext().cloned();
695    let questionnaire_bridge = app.questionnaire_bridge().cloned();
696    let cwd: String = std::env::current_dir()
697        .map(|p| p.to_string_lossy().into_owned())
698        .unwrap_or_else(|_| ".".to_string());
699    let cwd_path = std::env::current_dir().unwrap_or_default();
700    let git_branch = crate::util::git_utils::get_current_branch(&cwd_path);
701
702    // ── Determine initial session ──
703    let mut session_target: Option<String> = if resume_last {
704        oxi_store::session::find_recent_session_path(&cwd)
705    } else {
706        None
707    };
708
709    // ── Enter terminal ONCE ──
710    let mut tui = Tui::enter()?;
711    let theme = Theme::dark();
712
713    // ── Session switching loop ──
714    loop {
715        let is_resuming = session_target.is_some();
716
717        let session_manager = match &session_target {
718            Some(path) => SessionManager::open(path, None, Some(&cwd)),
719            None => SessionManager::create(&cwd, None),
720        };
721
722        let services = create_agent_session_services(CreateAgentSessionServicesOptions::new(
723            cwd_path.clone(),
724        ))?;
725        let services = Arc::new(services);
726
727        let create_result =
728            create_agent_session_from_services(CreateAgentSessionFromServicesOptions {
729                services: services.clone(),
730                session_manager,
731                model_id: Some(model_id.clone()),
732                thinking_level: Some(settings.thinking_level),
733                scoped_models: Vec::new(),
734                tool_registry: Some(tools.clone()),
735            })?;
736
737        let agent_session = create_result.session;
738        if let Some(msg) = create_result.model_fallback_message {
739            tracing::warn!("Model fallback: {}", msg);
740        }
741
742        let (session_event_tx, mut session_event_rx) = mpsc::unbounded_channel::<SessionEvent>();
743        agent_session.subscribe(Box::new(move |event| {
744            let _ = session_event_tx.send(event.clone());
745        }));
746
747        let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<UiEvent>();
748        let (prompt_tx, mut prompt_rx) = mpsc::channel::<String>(16);
749
750        // Agent worker thread
751        let session_handle = agent_session.clone_handle();
752        // Clone prompt_tx so the worker thread can auto-reprocess queued messages.
753        // The cloned sender feeds back into prompt_rx, triggering the outer while loop.
754        let prompt_tx_worker = prompt_tx.clone();
755        let ui_tx_for_thread = ui_tx.clone();
756        let agent_handle = std::thread::spawn(move || {
757            let rt = get_agent_runtime();
758            rt.block_on(async {
759                let local = tokio::task::LocalSet::new();
760                local
761                    .run_until(async {
762                        while let Some(prompt) = prompt_rx.recv().await {
763                            tracing::info!("[TUI] Received prompt, starting agent run");
764                            let (event_tx, event_rx) = std::sync::mpsc::channel::<AgentEvent>();
765                            let ui_fwd = ui_tx_for_thread.clone();
766                            let session_h = session_handle.clone_handle();
767                            // Forward events on a dedicated thread so it's never starved
768                            // by the agent's synchronous emit callbacks.
769                            let forwarder_handle = std::thread::spawn(move || {
770                                let mut event_count = 0u32;
771                                tracing::info!("[FORWARDER] Thread started, waiting for events");
772                                while let Ok(event) = event_rx.recv() {
773                                    event_count += 1;
774                                    tracing::info!(
775                                        "[FORWARDER] Event #{}: {:?}",
776                                        event_count,
777                                        std::mem::discriminant(&event)
778                                    );
779                                    let ui_event = match event {
780                                        // ── Agent lifecycle ─────────────────────────
781                                        AgentEvent::AgentStart { .. } => UiEvent::AgentStart,
782                                        AgentEvent::AgentEnd { .. } => UiEvent::AgentEnd,
783
784                                        // ── Turn lifecycle ───────────────────────────
785                                        AgentEvent::TurnStart { turn_number } => {
786                                            UiEvent::TurnStart { turn_number }
787                                        }
788                                        AgentEvent::TurnEnd { turn_number, .. } => {
789                                            UiEvent::TurnEnd { turn_number }
790                                        }
791
792                                        // ── Message lifecycle (pi-mono pattern) ─────
793                                        // These carry full message snapshots with properly
794                                        // separated content blocks from the provider.
795                                        AgentEvent::MessageStart { message } => {
796                                            UiEvent::MessageStart { message }
797                                        }
798                                        AgentEvent::MessageUpdate { message, delta } => {
799                                            UiEvent::MessageUpdate { message, delta }
800                                        }
801                                        AgentEvent::MessageEnd { message } => {
802                                            UiEvent::MessageEnd { message }
803                                        }
804
805                                        // ── Tool execution (structured events) ──────
806                                        AgentEvent::ToolExecutionStart {
807                                            tool_call_id,
808                                            tool_name,
809                                            args,
810                                        } => UiEvent::ToolExecutionStart {
811                                            tool_call_id,
812                                            tool_name,
813                                            args,
814                                        },
815                                        AgentEvent::ToolExecutionEnd {
816                                            tool_call_id,
817                                            tool_name,
818                                            result,
819                                            is_error,
820                                        } => UiEvent::ToolExecutionEnd {
821                                            tool_call_id,
822                                            tool_name,
823                                            result,
824                                            is_error,
825                                        },
826
827                                        // ── Legacy tool events (from Agent::run_with_channel) ──
828                                        // Map to the same structured UiEvents.
829                                        AgentEvent::ToolStart {
830                                            tool_call_id,
831                                            tool_name,
832                                            arguments,
833                                        } => UiEvent::ToolExecutionStart {
834                                            tool_call_id,
835                                            tool_name,
836                                            args: arguments,
837                                        },
838                                        AgentEvent::ToolComplete { result } => {
839                                            UiEvent::ToolExecutionEnd {
840                                                tool_call_id: result.tool_call_id.clone(),
841                                                tool_name: String::new(),
842                                                result,
843                                                is_error: false, // status checked in handler
844                                            }
845                                        }
846                                        AgentEvent::ToolError {
847                                            tool_call_id,
848                                            error,
849                                        } => UiEvent::ToolExecutionEnd {
850                                            tool_call_id,
851                                            tool_name: String::new(),
852                                            result: oxi_ai::ToolResult {
853                                                tool_call_id: String::new(),
854                                                content: error,
855                                                status: "error".to_string(),
856                                            },
857                                            is_error: true,
858                                        },
859
860                                        // ── Legacy streaming events ─────────────────
861                                        // Still emitted by agent.rs alongside MessageUpdate.
862                                        // TUI now prefers MessageUpdate, so we skip these.
863                                        AgentEvent::Start { .. } => {
864                                            // AgentStart equivalent — no action needed
865                                            // since we also get AgentStart from events.rs
866                                            continue;
867                                        }
868                                        AgentEvent::Thinking => UiEvent::Thinking,
869                                        AgentEvent::ThinkingDelta { text } => {
870                                            UiEvent::ThinkingDelta(text)
871                                        }
872                                        AgentEvent::TextChunk { .. } => {
873                                            // SKIP: TUI now renders from MessageUpdate snapshots,
874                                            // not incremental text deltas. This prevents raw
875                                            // JSON from tool calls appearing in chat.
876                                            continue;
877                                        }
878                                        AgentEvent::ToolCall { .. } => {
879                                            // SKIP: This is the LLM's request, not execution.
880                                            // ToolExecutionStart arrives when execution begins.
881                                            continue;
882                                        }
883
884                                        // ── Completion & errors ──────────────────────
885                                        AgentEvent::Complete { .. } => UiEvent::Complete,
886                                        AgentEvent::Error { message, .. } => {
887                                            UiEvent::Error(message)
888                                        }
889
890                                        // ── Usage ────────────────────────────────────
891                                        AgentEvent::Usage {
892                                            input_tokens,
893                                            output_tokens,
894                                        } => {
895                                            let _ = ui_fwd.send(UiEvent::TokenUsage {
896                                                input_tokens: input_tokens as u32,
897                                                output_tokens: output_tokens as u32,
898                                                cache_read_tokens: 0,
899                                                cache_write_tokens: 0,
900                                                context_window_pct: 0.0,
901                                                total_cost: 0.0,
902                                            });
903                                            continue;
904                                        }
905
906                                        // ── Steering / follow-up consumption ──
907                                        AgentEvent::SteeringMessage { .. } => {
908                                            // A steering message was consumed from the queue
909                                            // → emit queue update so TUI shows current count
910                                            let steering_q = session_h.steering_queue();
911                                            let follow_up_q = session_h.follow_up_queue();
912                                            let pending =
913                                                steering_q.read().len() + follow_up_q.read().len();
914                                            let _ = ui_fwd.send(UiEvent::QueueUpdate { pending });
915                                            continue;
916                                        }
917                                        AgentEvent::FollowUpMessage { .. } => {
918                                            let steering_q = session_h.steering_queue();
919                                            let follow_up_q = session_h.follow_up_queue();
920                                            let pending =
921                                                steering_q.read().len() + follow_up_q.read().len();
922                                            let _ = ui_fwd.send(UiEvent::QueueUpdate { pending });
923                                            continue;
924                                        }
925
926                                        // ── Everything else: skip ───────────────────
927                                        _ => continue,
928                                    };
929                                    tracing::info!("[FORWARDER] Sending UiEvent to ui_fwd");
930                                    if ui_fwd.send(ui_event).is_err() {
931                                        tracing::warn!("[FORWARDER] ui_fwd send failed, breaking");
932                                        break;
933                                    }
934                                    tracing::info!("[FORWARDER] UiEvent sent successfully");
935                                }
936                                tracing::info!("[FORWARDER] Event loop ended");
937                            });
938                            let sh = session_handle.clone_handle();
939                            let agent = sh.agent_ref();
940                            sh.reset_should_stop();
941                            let steering_q = sh.steering_queue();
942                            let follow_up_q = sh.follow_up_queue();
943                            let should_stop_flag = sh.should_stop_flag();
944                            let hooks = oxi_agent::AgentHooks {
945                                should_stop_after_turn: Some(Arc::new(move |_ctx| {
946                                    should_stop_flag.load(Ordering::SeqCst)
947                                })),
948                                get_steering_messages: Some(Box::new(move || {
949                                    steering_q.write().drain(..).collect::<Vec<String>>()
950                                })),
951                                get_follow_up_messages: Some(Box::new(move || {
952                                    follow_up_q.write().drain(..).collect::<Vec<String>>()
953                                })),
954                                tool_execution: oxi_agent::ToolExecutionMode::Sequential,
955                                ..Default::default()
956                            };
957                            agent.set_hooks(hooks);
958                            let agent_clone = Arc::clone(&agent);
959                            tracing::info!("[AGENT-WORKER] Spawning agent task");
960                            let agent_handle = tokio::task::spawn_local(async move {
961                                tracing::info!(
962                                    "[AGENT-WORKER] Agent task started, calling run_with_channel"
963                                );
964                                let result = agent_clone.run_with_channel(prompt, event_tx).await;
965                                if let Err(ref e) = result {
966                                    tracing::error!("Agent run_with_channel error: {:?}", e);
967                                }
968                                tracing::info!(
969                                    "[AGENT-WORKER] Agent run_with_channel completed: {:?}",
970                                    result
971                                );
972                                result
973                            });
974                            // Agent runs on LocalSet, forwarder on its own thread.
975                            // Agent drops event_tx when done → forwarder sees disconnect → exits.
976                            let _ = agent_handle.await;
977                            // Do NOT call forwarder_handle.join() here — it blocks the
978                            // tokio runtime thread, preventing prompt_rx.recv() from
979                            // resolving on the next iteration. The forwarder will exit
980                            // on its own when event_rx is disconnected.
981                            //
982                            // NOTE: The forwarder thread is detached and will clean up
983                            // when it sees the channel disconnect. If we need to wait
984                            // for it before session teardown, the outer loop handles
985                            // that via drop(prompt_tx) + agent_handle.join().
986                            let _ = forwarder_handle; // move ownership, don't block
987
988                            // ── Auto-process queued messages ──────────────────
989                            // After the agent finishes, check if new messages were
990                            // queued during the run (via steer_sync). If so, feed
991                            // the first one back through prompt_tx_worker so the outer
992                            // while loop picks it up as a new prompt. Remaining messages
993                            // stay in the queue for subsequent iterations.
994                            let first_pending: Option<String> = {
995                                let sq = session_handle.steering_queue();
996                                let fq = session_handle.follow_up_queue();
997                                let mut sq_guard = sq.write();
998                                let mut fq_guard = fq.write();
999                                // Steering takes priority over follow-up
1000                                sq_guard.pop_front().or_else(|| fq_guard.pop_front())
1001                            };
1002                            if let Some(msg) = first_pending {
1003                                let remaining = session_handle.pending_message_count();
1004                                tracing::info!(
1005                                    "[AGENT-WORKER] Auto-processing queued message ({} remaining)",
1006                                    remaining
1007                                );
1008                                let _ = ui_tx_for_thread.send(UiEvent::QueueUpdate {
1009                                    pending: remaining,
1010                                });
1011                                // Tell TUI to show user message + enter streaming state
1012                                let _ = ui_tx_for_thread.send(UiEvent::AutoProcessStart {
1013                                    prompt: msg.clone(),
1014                                });
1015                                let _ = prompt_tx_worker.send(msg).await;
1016                            }
1017                        }
1018                    })
1019                    .await;
1020            });
1021        });
1022
1023        // ── Create state ──
1024        let mut state = AppState::new();
1025        state.session_file_path = session_target.clone();
1026
1027        // Restore previous messages if resuming
1028        if is_resuming {
1029            if let Some(ref path) = session_target {
1030                let sm = oxi_store::session::SessionManager::open(path, None, Some(&cwd));
1031                let branch = sm.get_branch(None);
1032                for entry in &branch {
1033                    match &entry.message {
1034                        oxi_store::session::AgentMessage::User { content } => {
1035                            state.add_user_message(content.as_str().to_string());
1036                        }
1037                        oxi_store::session::AgentMessage::Assistant { content, .. } => {
1038                            let text: String = content
1039                                .iter()
1040                                .filter_map(|b| match b {
1041                                    oxi_store::session::AssistantContentBlock::Text { text } => {
1042                                        Some(text.as_str())
1043                                    }
1044                                    _ => None,
1045                                })
1046                                .collect::<Vec<_>>()
1047                                .join("");
1048                            if !text.is_empty() {
1049                                state.add_system_message(text);
1050                            }
1051                        }
1052                        _ => {}
1053                    }
1054                }
1055            }
1056        }
1057
1058        // Footer
1059        state.footer_state.data.pwd = Some(cwd.clone());
1060        state.footer_state.data.model_name = model_id.clone();
1061        state.footer_state.data.git_branch = git_branch.clone();
1062        state.footer_state.data.provider_name =
1063            model_id.split('/').next().unwrap_or("").to_string();
1064        state.footer_state.data.version = env!("CARGO_PKG_VERSION").to_string();
1065        state.footer_state.data.thinking_level =
1066            Some(format!("{:?}", settings.thinking_level).to_lowercase());
1067        state.wasm_ext = wasm_ext.clone();
1068        state.questionnaire_bridge = questionnaire_bridge.clone();
1069
1070        // Push welcome message (only for new sessions, not resumed)
1071        if !is_resuming {
1072            let tool_labels: Vec<(String, String)> = {
1073                let registry = tools.clone();
1074                let names = registry.names();
1075                names
1076                    .iter()
1077                    .filter_map(|name| {
1078                        registry
1079                            .get(name)
1080                            .map(|t| (name.clone(), t.label().to_string()))
1081                    })
1082                    .collect()
1083            };
1084            let tool_names: Vec<String> = tool_labels.iter().map(|(n, _)| n.clone()).collect();
1085            let skill_names: Vec<String> = {
1086                let sm = crate::skills::SkillManager::load_from_dir(
1087                    &crate::skills::SkillManager::skills_dir()
1088                        .unwrap_or_else(|_| std::path::PathBuf::from("/dev/null")),
1089                )
1090                .unwrap_or_else(|_| crate::skills::SkillManager::new());
1091                sm.all().iter().map(|s| s.name.clone()).collect()
1092            };
1093            let agents_md_path = welcome::detect_agents_md(&cwd_path);
1094            let project_name = cwd_path
1095                .file_name()
1096                .map(|n| n.to_string_lossy().into_owned())
1097                .unwrap_or_else(|| ".".to_string());
1098            let welcome_info = welcome::WelcomeInfo {
1099                model_id: model_id.clone(),
1100                thinking_level: format!("{:?}", settings.thinking_level).to_lowercase(),
1101                tool_names,
1102                tool_labels,
1103                skill_names,
1104                agents_md_path,
1105                session_type: "new",
1106                git_branch: git_branch.clone(),
1107                project_name,
1108            };
1109            state.chat.add_message(oxi_tui::widgets::chat::ChatMessage {
1110                role: oxi_tui::widgets::chat::MessageRole::System,
1111                content_blocks: vec![oxi_tui::widgets::chat::ContentBlock::Dashboard {
1112                    info: welcome::build_dashboard_info(&welcome_info),
1113                }],
1114                timestamp: std::time::SystemTime::now()
1115                    .duration_since(std::time::UNIX_EPOCH)
1116                    .unwrap_or_default()
1117                    .as_millis() as i64,
1118            });
1119        }
1120
1121        // Check if model is configured
1122        let has_model = !model_id.is_empty() && model_id.contains('/');
1123        if !has_model {
1124            let auth = oxi_store::auth_storage::shared_auth_storage();
1125            let providers: Vec<(String, bool)> = oxi_ai::register_builtins::get_builtin_providers()
1126                .iter()
1127                .map(|builtin| {
1128                    (
1129                        builtin.name.to_string(),
1130                        auth.get_api_key(builtin.name).is_some(),
1131                    )
1132                })
1133                .collect();
1134            state.overlay = Some(AppOverlay::Setup(SetupStep::SelectProvider {
1135                providers,
1136                selected: 0,
1137            }));
1138        }
1139
1140        // ── Inner TUI loop ──
1141        let mut running = true;
1142        let mut last_spinner_tick = std::time::Instant::now();
1143        let session_start = std::time::Instant::now();
1144        let poll_timeout = std::time::Duration::from_millis(50);
1145
1146        while running {
1147            let now = std::time::Instant::now();
1148            if now.duration_since(last_spinner_tick).as_millis() >= 80 {
1149                state.spinner_frame = (state.spinner_frame + 1) % SPINNER.len();
1150                state.chat.spinner_frame = state.spinner_frame;
1151                last_spinner_tick = now;
1152            }
1153
1154            // Update session duration in footer
1155            state.footer_state.data.session_duration_secs = session_start.elapsed().as_secs();
1156
1157            tui.draw(|f| render::draw(f, &mut state, &theme))?;
1158
1159            if event::poll(poll_timeout)? {
1160                if let Some(action) = handlers::handle_input(
1161                    event::read()?,
1162                    &mut state,
1163                    &agent_session,
1164                    &ui_tx,
1165                    &prompt_tx,
1166                    &mut running,
1167                )
1168                .await
1169                {
1170                    match action {
1171                        handlers::Action::SendPrompt(value) => {
1172                            tracing::info!(
1173                                "[TUI] SendPrompt action triggered: {:?}",
1174                                &value[..value
1175                                    .char_indices()
1176                                    .take(50)
1177                                    .last()
1178                                    .map(|(i, c)| i + c.len_utf8())
1179                                    .unwrap_or(0)]
1180                            );
1181                            state.add_user_message(value.clone());
1182                            state.input_history.insert(0, value.clone());
1183                            if state.input_history.len() > 100 {
1184                                state.input_history.remove(0);
1185                            }
1186                            state.history_index = 0;
1187                            state.start_streaming();
1188                            agent_session.reset_should_stop();
1189                            tracing::info!("[TUI] About to send prompt to channel");
1190                            let _ = prompt_tx.send(value).await;
1191                            tracing::info!("[TUI] Prompt sent to channel");
1192                            state.input_clear();
1193                        }
1194                        handlers::Action::ExecuteSlashCommand(cmd) => {
1195                            slash::handle_slash_command(
1196                                &cmd,
1197                                &agent_session,
1198                                &mut state,
1199                                &mut running,
1200                            );
1201                        }
1202                    }
1203                }
1204            }
1205
1206            // Check for pending questionnaire from bridge (agent thread → TUI thread)
1207            if state.overlay.is_none() && state.overlay_state.is_none() {
1208                if let Some(bridge) = &state.questionnaire_bridge {
1209                    if let Some(pending) = bridge.try_take() {
1210                        use super::overlay::questionnaire::QuestionnaireOverlay;
1211                        state.overlay_state = Some(Box::new(QuestionnaireOverlay::new(
1212                            pending.questions,
1213                            pending.responder,
1214                        )));
1215                        tracing::info!("[TUI] Questionnaire overlay opened");
1216                    }
1217                }
1218            }
1219
1220            // Check if session switch was requested by a slash command
1221            if state.next_action.is_some() {
1222                running = false;
1223            }
1224
1225            while let Ok(ui_event) = ui_rx.try_recv() {
1226                handlers::handle_ui_event(ui_event, &mut state);
1227                // Persist session after message_end events (pi-mono: persist on every message_end)
1228                if state.needs_persist {
1229                    agent_session.persist();
1230                    state.needs_persist = false;
1231                }
1232            }
1233            while let Ok(session_event) = session_event_rx.try_recv() {
1234                handlers::handle_session_event(session_event, &ui_tx).await;
1235            }
1236
1237            let chat_visible_height = {
1238                let size = tui.size()?;
1239                size.height.saturating_sub(5)
1240            };
1241            state.ensure_auto_scroll(chat_visible_height);
1242        }
1243
1244        // ── Cleanup this iteration ──
1245        let next_action = state.next_action.take();
1246        drop(prompt_tx);
1247        let _ = agent_handle.join();
1248
1249        match next_action {
1250            Some(TuiNextAction::SwitchSession(path)) => {
1251                tracing::info!("Switching to session: {}", path);
1252                session_target = Some(path);
1253                continue;
1254            }
1255            Some(TuiNextAction::NewSession) => {
1256                tracing::info!("Starting new session");
1257                // Reload settings so the new session picks up any config changes
1258                if let Ok(fresh) = oxi_store::settings::Settings::load() {
1259                    if let Some(m) = fresh.effective_model(None) {
1260                        if !m.is_empty() {
1261                            // effective_model may already include provider
1262                            model_id = if m.contains('/') {
1263                                m
1264                            } else {
1265                                let p = fresh.effective_provider(None).unwrap_or_default();
1266                                format!("{}/{}", p, m)
1267                            };
1268                        }
1269                    }
1270                }
1271                session_target = None;
1272                continue;
1273            }
1274            None => break,
1275        }
1276    }
1277
1278    tui.exit()?;
1279    Ok(())
1280}