steer_tui/tui/
mod.rs

1//! TUI module for the steer CLI
2//!
3//! This module implements the terminal user interface using ratatui.
4
5use std::collections::{HashSet, VecDeque};
6use std::io::{self, Stdout};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::Duration;
10
11use crate::error::{Error, Result};
12use crate::tui::commands::registry::CommandRegistry;
13use crate::tui::model::{ChatItem, NoticeLevel};
14use crate::tui::theme::Theme;
15use ratatui::backend::CrosstermBackend;
16use ratatui::crossterm::event::{
17    self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind, MouseEvent,
18    PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
19};
20use ratatui::crossterm::execute;
21use ratatui::crossterm::terminal::{
22    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
23};
24use ratatui::crossterm::{
25    event::{DisableMouseCapture, EnableMouseCapture},
26    terminal::SetTitle,
27};
28use ratatui::{Frame, Terminal};
29use steer_core::api::Model;
30use steer_core::app::conversation::{AssistantContent, Message, MessageData};
31use steer_core::app::io::{AppCommandSink, AppEventSource};
32use steer_core::app::{AppCommand, AppEvent};
33use steer_core::config::LlmConfigProvider;
34use steer_tools::schema::ToolCall;
35use tokio::sync::mpsc;
36use tokio::task::JoinHandle;
37use tracing::{debug, error, info, warn};
38
39use crate::tui::auth_controller::AuthController;
40use crate::tui::events::pipeline::EventPipeline;
41use crate::tui::events::processors::message::MessageEventProcessor;
42use crate::tui::events::processors::processing_state::ProcessingStateProcessor;
43use crate::tui::events::processors::system::SystemEventProcessor;
44use crate::tui::events::processors::tool::ToolEventProcessor;
45use crate::tui::state::SetupState;
46use crate::tui::state::{ChatStore, ToolCallRegistry};
47
48use crate::tui::chat_viewport::ChatViewport;
49use crate::tui::ui_layout::UiLayout;
50use crate::tui::widgets::InputPanel;
51
52pub mod commands;
53pub mod custom_commands;
54pub mod model;
55pub mod state;
56pub mod theme;
57pub mod widgets;
58
59mod auth_controller;
60mod chat_viewport;
61mod events;
62mod handlers;
63mod ui_layout;
64
65/// How often to update the spinner animation (when processing)
66const SPINNER_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
67
68/// Input modes for the TUI
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum InputMode {
71    /// Simple mode - default non-modal editing
72    Simple,
73    /// Vim normal mode
74    VimNormal,
75    /// Vim insert mode
76    VimInsert,
77    /// Bash command mode - executing shell commands
78    BashCommand,
79    /// Awaiting tool approval
80    AwaitingApproval,
81    /// Confirm exit dialog
82    ConfirmExit,
83    /// Edit message selection mode with fuzzy filtering
84    EditMessageSelection,
85    /// Fuzzy finder mode for file selection
86    FuzzyFinder,
87    /// Setup mode - first run experience
88    Setup,
89}
90
91/// Vim operator types
92#[derive(Debug, Clone, Copy, PartialEq)]
93enum VimOperator {
94    Delete,
95    Change,
96    Yank,
97}
98
99/// State for tracking vim key sequences
100#[derive(Debug, Default)]
101struct VimState {
102    /// Pending operator (d, c, y)
103    pending_operator: Option<VimOperator>,
104    /// Waiting for second 'g' in gg
105    pending_g: bool,
106    /// In replace mode (after 'r')
107    replace_mode: bool,
108    /// In visual mode
109    visual_mode: bool,
110}
111
112/// Main TUI application state
113pub struct Tui {
114    /// Terminal instance
115    terminal: Terminal<CrosstermBackend<Stdout>>,
116    terminal_size: (u16, u16),
117    /// Current input mode
118    input_mode: InputMode,
119    /// State for the input panel widget
120    input_panel_state: crate::tui::widgets::input_panel::InputPanelState,
121    /// The ID of the message being edited (if any)
122    editing_message_id: Option<String>,
123    /// Handle to send commands to the app
124    command_sink: Arc<dyn AppCommandSink>,
125    /// Are we currently processing a request?
126    is_processing: bool,
127    /// Progress message to show while processing
128    progress_message: Option<String>,
129    /// Animation frame for spinner
130    spinner_state: usize,
131    /// Current tool approval request
132    current_tool_approval: Option<ToolCall>,
133    /// Current model in use
134    current_model: Model,
135    /// Event processing pipeline
136    event_pipeline: EventPipeline,
137    /// Chat data store
138    chat_store: ChatStore,
139    /// Tool call registry
140    tool_registry: ToolCallRegistry,
141    /// Chat viewport for efficient rendering
142    chat_viewport: ChatViewport,
143    /// Session ID
144    session_id: String,
145    /// Current theme
146    theme: Theme,
147    /// Setup state for first-run experience
148    setup_state: Option<SetupState>,
149    /// Authentication controller (if active)
150    auth_controller: Option<AuthController>,
151    /// Track in-flight operations (operation_id -> chat_store_index)
152    in_flight_operations: HashSet<uuid::Uuid>,
153    /// Command registry for slash commands
154    command_registry: CommandRegistry,
155    /// User preferences
156    preferences: steer_core::preferences::Preferences,
157    /// Double-tap tracker for key sequences
158    double_tap_tracker: crate::tui::state::DoubleTapTracker,
159    /// Vim mode state
160    vim_state: VimState,
161    /// Stack to track previous modes (for returning after fuzzy finder, etc.)
162    mode_stack: VecDeque<InputMode>,
163    /// Last known revision of ChatStore for dirty tracking
164    last_revision: u64,
165}
166
167const MAX_MODE_DEPTH: usize = 8;
168
169impl Tui {
170    /// Push current mode onto stack before switching
171    fn push_mode(&mut self) {
172        if self.mode_stack.len() == MAX_MODE_DEPTH {
173            self.mode_stack.pop_front(); // drop oldest
174        }
175        self.mode_stack.push_back(self.input_mode);
176    }
177
178    /// Pop and restore previous mode
179    fn pop_mode(&mut self) -> Option<InputMode> {
180        self.mode_stack.pop_back()
181    }
182
183    /// Switch to a new mode, automatically managing the mode stack
184    pub fn switch_mode(&mut self, new_mode: InputMode) {
185        if self.input_mode != new_mode {
186            debug!(
187                "Switching mode from {:?} to {:?}",
188                self.input_mode, new_mode
189            );
190            self.push_mode();
191            self.input_mode = new_mode;
192        }
193    }
194
195    /// Switch mode without pushing to stack (for direct transitions like vim normal->insert)
196    pub fn set_mode(&mut self, new_mode: InputMode) {
197        debug!("Setting mode from {:?} to {:?}", self.input_mode, new_mode);
198        self.input_mode = new_mode;
199    }
200
201    /// Restore previous mode from stack (or default if empty)
202    pub fn restore_previous_mode(&mut self) {
203        self.input_mode = self.pop_mode().unwrap_or_else(|| self.default_input_mode());
204    }
205
206    /// Get the default input mode based on editing preferences
207    fn default_input_mode(&self) -> InputMode {
208        match self.preferences.ui.editing_mode {
209            steer_core::preferences::EditingMode::Simple => InputMode::Simple,
210            steer_core::preferences::EditingMode::Vim => InputMode::VimNormal,
211        }
212    }
213
214    /// Check if current mode accepts text input
215    fn is_text_input_mode(&self) -> bool {
216        matches!(
217            self.input_mode,
218            InputMode::Simple
219                | InputMode::VimInsert
220                | InputMode::BashCommand
221                | InputMode::Setup
222                | InputMode::FuzzyFinder
223        )
224    }
225    /// Create a new TUI instance
226    pub async fn new(
227        command_sink: Arc<dyn AppCommandSink>,
228        _event_source: Arc<dyn AppEventSource>,
229        current_model: Model,
230        session_id: String,
231        theme: Option<Theme>,
232    ) -> Result<Self> {
233        enable_raw_mode()?;
234        let mut stdout = io::stdout();
235        execute!(
236            stdout,
237            EnterAlternateScreen,
238            EnableBracketedPaste,
239            PushKeyboardEnhancementFlags(
240                ratatui::crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
241            ),
242            EnableMouseCapture,
243            SetTitle("Steer")
244        )?;
245
246        let backend = CrosstermBackend::new(stdout);
247        let terminal = Terminal::new(backend)?;
248        let terminal_size = terminal
249            .size()
250            .map(|s| (s.width, s.height))
251            .unwrap_or((80, 24));
252
253        // Request current conversation
254        let messages = Self::get_current_conversation(_event_source).await?;
255        info!(
256            "Received {} messages from current conversation",
257            messages.len()
258        );
259
260        // Load preferences
261        let preferences = steer_core::preferences::Preferences::load()
262            .map_err(crate::error::Error::Core)
263            .unwrap_or_default();
264
265        // Determine initial input mode based on editing mode preference
266        let input_mode = match preferences.ui.editing_mode {
267            steer_core::preferences::EditingMode::Simple => InputMode::Simple,
268            steer_core::preferences::EditingMode::Vim => InputMode::VimNormal,
269        };
270
271        // Create TUI with restored messages
272        let mut tui = Self {
273            terminal,
274            terminal_size,
275            input_mode,
276            input_panel_state: crate::tui::widgets::input_panel::InputPanelState::new(
277                session_id.clone(),
278            ),
279            editing_message_id: None,
280            command_sink,
281            is_processing: false,
282            progress_message: None,
283            spinner_state: 0,
284            current_tool_approval: None,
285            current_model,
286            event_pipeline: Self::create_event_pipeline(),
287            chat_store: ChatStore::new(),
288            tool_registry: ToolCallRegistry::new(),
289            chat_viewport: ChatViewport::new(),
290            session_id,
291            theme: theme.unwrap_or_default(),
292            setup_state: None,
293            auth_controller: None,
294            in_flight_operations: HashSet::new(),
295            command_registry: CommandRegistry::new(),
296            preferences,
297            double_tap_tracker: crate::tui::state::DoubleTapTracker::new(),
298            vim_state: VimState::default(),
299            mode_stack: VecDeque::new(),
300            last_revision: 0,
301        };
302
303        // Restore messages using the public method
304        tui.restore_messages(messages);
305
306        Ok(tui)
307    }
308
309    /// Restore messages to the TUI, properly populating the tool registry
310    fn restore_messages(&mut self, messages: Vec<Message>) {
311        let message_count = messages.len();
312        info!("Starting to restore {} messages to TUI", message_count);
313
314        // Debug: log all Tool messages to check their IDs
315        for message in &messages {
316            if let steer_core::app::MessageData::Tool { tool_use_id, .. } = &message.data {
317                debug!(
318                    target: "tui.restore",
319                    "Found Tool message with tool_use_id={}",
320                    tool_use_id
321                );
322            }
323        }
324
325        self.chat_store.ingest_messages(&messages);
326
327        // The rest of the tool registry population code remains the same
328        // Extract tool calls from assistant messages
329        for message in &messages {
330            if let steer_core::app::MessageData::Assistant { content, .. } = &message.data {
331                debug!(
332                    target: "tui.restore",
333                    "Processing Assistant message id={}",
334                    message.id()
335                );
336                for block in content {
337                    if let AssistantContent::ToolCall { tool_call } = block {
338                        debug!(
339                            target: "tui.restore",
340                            "Found ToolCall in Assistant message: id={}, name={}, params={}",
341                            tool_call.id, tool_call.name, tool_call.parameters
342                        );
343
344                        // Register the tool call
345                        self.tool_registry.register_call(tool_call.clone());
346                    }
347                }
348            }
349        }
350
351        // Map tool results to their calls
352        for message in &messages {
353            if let steer_core::app::MessageData::Tool { tool_use_id, .. } = &message.data {
354                debug!(
355                    target: "tui.restore",
356                    "Updating registry with Tool result for id={}",
357                    tool_use_id
358                );
359                // Tool results are already handled by event processors
360            }
361        }
362
363        debug!(
364            target: "tui.restore",
365            "Tool registry state after restoration: {} calls registered",
366            self.tool_registry.metrics().completed_count
367        );
368        info!("Successfully restored {} messages to TUI", message_count);
369    }
370
371    /// Helper to push a system notice to the chat store
372    fn push_notice(&mut self, level: crate::tui::model::NoticeLevel, text: String) {
373        use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
374        self.chat_store.push(ChatItem {
375            parent_chat_item_id: None,
376            data: ChatItemData::SystemNotice {
377                id: generate_row_id(),
378                level,
379                text,
380                ts: time::OffsetDateTime::now_utc(),
381            },
382        });
383    }
384
385    /// Helper to push a TUI command response to the chat store
386    fn push_tui_response(&mut self, command: String, response: String) {
387        use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
388        self.chat_store.push(ChatItem {
389            parent_chat_item_id: None,
390            data: ChatItemData::TuiCommandResponse {
391                id: generate_row_id(),
392                command,
393                response,
394                ts: time::OffsetDateTime::now_utc(),
395            },
396        });
397    }
398
399    /// Load file list into cache
400    async fn load_file_cache(&mut self) {
401        // Request workspace files from the server
402        info!(target: "tui.file_cache", "Requesting workspace files for session {}", self.session_id);
403        if let Err(e) = self
404            .command_sink
405            .send_command(AppCommand::RequestWorkspaceFiles)
406            .await
407        {
408            warn!(target: "tui.file_cache", "Failed to request workspace files: {}", e);
409        }
410    }
411
412    pub fn cleanup_terminal(&mut self) -> Result<()> {
413        execute!(
414            self.terminal.backend_mut(),
415            LeaveAlternateScreen,
416            DisableBracketedPaste,
417            PopKeyboardEnhancementFlags,
418            DisableMouseCapture
419        )?;
420        disable_raw_mode()?;
421        Ok(())
422    }
423
424    pub async fn run(&mut self, mut event_rx: mpsc::Receiver<AppEvent>) -> Result<()> {
425        // Log the current state of messages
426        info!(
427            "Starting TUI run with {} messages in view model",
428            self.chat_store.len()
429        );
430
431        // Load the initial file list
432        self.load_file_cache().await;
433
434        let (term_event_tx, mut term_event_rx) = mpsc::channel::<Result<Event>>(1);
435        let input_handle: JoinHandle<()> = tokio::spawn(async move {
436            loop {
437                // Non-blocking poll
438                if event::poll(Duration::ZERO).unwrap_or(false) {
439                    match event::read() {
440                        Ok(evt) => {
441                            if term_event_tx.send(Ok(evt)).await.is_err() {
442                                break; // Receiver dropped
443                            }
444                        }
445                        Err(e) if e.kind() == io::ErrorKind::Interrupted => {
446                            // This is a non-fatal interrupted syscall, common on some
447                            // systems. We just ignore it and continue polling.
448                            debug!(target: "tui.input", "Ignoring interrupted syscall");
449                            continue;
450                        }
451                        Err(e) => {
452                            // A real I/O error occurred. Send it to the main loop
453                            // to handle, and then stop polling.
454                            warn!(target: "tui.input", "Input error: {}", e);
455                            if term_event_tx.send(Err(Error::from(e))).await.is_err() {
456                                break; // Receiver already dropped
457                            }
458                            break;
459                        }
460                    }
461                } else {
462                    // Async sleep that CAN be interrupted by abort
463                    tokio::time::sleep(Duration::from_millis(10)).await;
464                }
465            }
466        });
467
468        let mut should_exit = false;
469        let mut needs_redraw = true; // Force initial draw
470        let mut last_spinner_char = String::new();
471
472        // Create a tick interval for spinner updates
473        let mut tick = tokio::time::interval(SPINNER_UPDATE_INTERVAL);
474        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
475
476        while !should_exit {
477            // Determine if we need to redraw
478            if needs_redraw {
479                self.draw()?;
480                needs_redraw = false;
481            }
482
483            tokio::select! {
484                Some(event_res) = term_event_rx.recv() => {
485                    match event_res {
486                        Ok(evt) => {
487                            match evt {
488                                Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
489                                    match self.handle_key_event(key_event).await {
490                                        Ok(exit) => {
491                                            if exit {
492                                                should_exit = true;
493                                            }
494                                        }
495                                        Err(e) => {
496                                            // Display error as a system notice
497                                            use crate::tui::model::{ChatItem, ChatItemData, NoticeLevel, generate_row_id};
498                                            self.chat_store.push(ChatItem {
499                                                parent_chat_item_id: None,
500                                                data: ChatItemData::SystemNotice {
501                                                    id: generate_row_id(),
502                                                    level: NoticeLevel::Error,
503                                                    text: e.to_string(),
504                                                    ts: time::OffsetDateTime::now_utc(),
505                                                },
506                                            });
507                                        }
508                                    }
509                                    needs_redraw = true;
510                                }
511                                Event::Mouse(mouse_event) => {
512                                    if self.handle_mouse_event(mouse_event)? {
513                                        needs_redraw = true;
514                                    }
515                                }
516                                Event::Resize(width, height) => {
517                                    self.terminal_size = (width, height);
518                                    // Terminal was resized, force redraw
519                                    needs_redraw = true;
520                                }
521                                Event::Paste(data) => {
522                                    // Handle paste in modes that accept text input
523                                    if self.is_text_input_mode() {
524                                        if self.input_mode == InputMode::Setup {
525                                            // Handle paste in setup mode
526                                            if let Some(setup_state) = &mut self.setup_state {
527                                                match &setup_state.current_step {
528                                                    crate::tui::state::SetupStep::Authentication(_) => {
529                                                        if setup_state.oauth_state.is_some() {
530                                                            // Pasting OAuth callback code
531                                                            setup_state.oauth_callback_input.push_str(&data);
532                                                        } else {
533                                                            // Pasting API key
534                                                            setup_state.api_key_input.push_str(&data);
535                                                        }
536                                                        debug!(target:"tui.run", "Pasted {} chars in Setup mode", data.len());
537                                                        needs_redraw = true;
538                                                    }
539                                                    _ => {
540                                                        // Other setup steps don't accept paste
541                                                    }
542                                                }
543                                            }
544                                        } else {
545                                            let normalized_data =
546                                                data.replace("\r\n", "\n").replace('\r', "\n");
547                                            self.input_panel_state.insert_str(&normalized_data);
548                                            debug!(target:"tui.run", "Pasted {} chars in {:?} mode", normalized_data.len(), self.input_mode);
549                                            needs_redraw = true;
550                                        }
551                                    }
552                                }
553                                _ => {}
554                            }
555                        }
556                        Err(e) => {
557                            error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
558                            should_exit = true;
559                        }
560                    }
561                }
562                Some(app_event) = event_rx.recv() => {
563                    self.handle_app_event(app_event).await;
564                    needs_redraw = true;
565                }
566                _ = tick.tick() => {
567                    // Check if we should animate the spinner
568                    let has_pending_tools = !self.tool_registry.pending_calls().is_empty()
569                        || !self.tool_registry.active_calls().is_empty()
570                        || self.chat_store.has_pending_tools();
571                    let has_in_flight_operations = !self.in_flight_operations.is_empty();
572
573                    if self.is_processing || has_pending_tools || has_in_flight_operations {
574                        self.spinner_state = self.spinner_state.wrapping_add(1);
575                        let ch = get_spinner_char(self.spinner_state);
576                        if ch != last_spinner_char {
577                            last_spinner_char = ch.to_string();
578                            needs_redraw = true;
579                        }
580                    }
581                }
582            }
583        }
584
585        // Cleanup terminal on exit
586        self.cleanup_terminal()?;
587        input_handle.abort();
588        Ok(())
589    }
590
591    /// Handle mouse events
592    fn handle_mouse_event(&mut self, event: MouseEvent) -> Result<bool> {
593        let needs_redraw = match event.kind {
594            event::MouseEventKind::ScrollUp => {
595                // In vim normal mode or simple mode (when not typing), allow scrolling
596                if !self.is_text_input_mode()
597                    || (self.input_mode == InputMode::Simple
598                        && self.input_panel_state.content().is_empty())
599                {
600                    self.chat_viewport.state_mut().scroll_up(3);
601                    true
602                } else {
603                    false
604                }
605            }
606            event::MouseEventKind::ScrollDown => {
607                // In vim normal mode or simple mode (when not typing), allow scrolling
608                if !self.is_text_input_mode()
609                    || (self.input_mode == InputMode::Simple
610                        && self.input_panel_state.content().is_empty())
611                {
612                    self.chat_viewport.state_mut().scroll_down(3);
613                    true
614                } else {
615                    false
616                }
617            }
618            _ => false,
619        };
620
621        Ok(needs_redraw)
622    }
623
624    /// Draw the UI
625    fn draw(&mut self) -> Result<()> {
626        self.terminal.draw(|f| {
627            // Check if we're in setup mode
628            if let Some(setup_state) = &self.setup_state {
629                use crate::tui::widgets::setup::{
630                    authentication::AuthenticationWidget, completion::CompletionWidget,
631                    provider_selection::ProviderSelectionWidget, welcome::WelcomeWidget,
632                };
633
634                match &setup_state.current_step {
635                    crate::tui::state::SetupStep::Welcome => {
636                        WelcomeWidget::render(f.area(), f.buffer_mut(), &self.theme);
637                    }
638                    crate::tui::state::SetupStep::ProviderSelection => {
639                        ProviderSelectionWidget::render(
640                            f.area(),
641                            f.buffer_mut(),
642                            setup_state,
643                            &self.theme,
644                        );
645                    }
646                    crate::tui::state::SetupStep::Authentication(provider) => {
647                        AuthenticationWidget::render(
648                            f.area(),
649                            f.buffer_mut(),
650                            setup_state,
651                            *provider,
652                            &self.theme,
653                        );
654                    }
655                    crate::tui::state::SetupStep::Completion => {
656                        CompletionWidget::render(
657                            f.area(),
658                            f.buffer_mut(),
659                            setup_state,
660                            &self.theme,
661                        );
662                    }
663                }
664                return;
665            }
666
667            let input_mode = self.input_mode;
668            let is_processing = self.is_processing;
669            let spinner_state = self.spinner_state;
670            let current_tool_approval = self.current_tool_approval.as_ref();
671            let current_model_owned = self.current_model;
672
673            // Check if ChatStore has changed and trigger rebuild if needed
674            let current_revision = self.chat_store.revision();
675            if current_revision != self.last_revision {
676                self.chat_viewport.mark_dirty();
677                self.last_revision = current_revision;
678            }
679
680            // Get chat items from the chat store
681            let chat_items: Vec<&ChatItem> = self.chat_store.as_items();
682
683            let terminal_size = f.area();
684
685            let input_area_height = self.input_panel_state.required_height(
686                current_tool_approval,
687                terminal_size.width,
688                terminal_size.height,
689            );
690
691            let layout = UiLayout::compute(terminal_size, input_area_height, &self.theme);
692            layout.prepare_background(f, &self.theme);
693
694            self.chat_viewport.rebuild(
695                &chat_items,
696                layout.chat_area.width,
697                self.chat_viewport.state().view_mode,
698                &self.theme,
699                &self.chat_store,
700            );
701
702            let hovered_id = self
703                .input_panel_state
704                .get_hovered_id()
705                .map(|s| s.to_string());
706
707            self.chat_viewport.render(
708                f,
709                layout.chat_area,
710                spinner_state,
711                hovered_id.as_deref(),
712                &self.theme,
713            );
714
715            let input_panel = InputPanel::new(
716                input_mode,
717                current_tool_approval,
718                is_processing,
719                spinner_state,
720                &self.theme,
721            );
722            f.render_stateful_widget(input_panel, layout.input_area, &mut self.input_panel_state);
723
724            // Render status bar
725            layout.render_status_bar(f, &current_model_owned, &self.theme);
726
727            // Get fuzzy finder results before the render call
728            let fuzzy_finder_data = if input_mode == InputMode::FuzzyFinder {
729                let results = self.input_panel_state.fuzzy_finder.results().to_vec();
730                let selected = self.input_panel_state.fuzzy_finder.selected_index();
731                let input_height = self.input_panel_state.required_height(
732                    current_tool_approval,
733                    terminal_size.width,
734                    10,
735                );
736                let mode = self.input_panel_state.fuzzy_finder.mode();
737                Some((results, selected, input_height, mode))
738            } else {
739                None
740            };
741
742            // Render fuzzy finder overlay when active
743            if let Some((results, selected_index, input_height, mode)) = fuzzy_finder_data {
744                Self::render_fuzzy_finder_overlay_static(
745                    f,
746                    &results,
747                    selected_index,
748                    input_height,
749                    mode,
750                    &self.theme,
751                    &self.command_registry,
752                );
753            }
754        })?;
755        Ok(())
756    }
757
758    /// Render fuzzy finder overlay above the input panel
759    fn render_fuzzy_finder_overlay_static(
760        f: &mut Frame,
761        results: &[String],
762        selected_index: usize,
763        input_panel_height: u16,
764        mode: crate::tui::widgets::fuzzy_finder::FuzzyFinderMode,
765        theme: &Theme,
766        command_registry: &CommandRegistry,
767    ) {
768        use ratatui::layout::Rect;
769        use ratatui::style::Style;
770        use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
771
772        // imports already handled above
773
774        if results.is_empty() {
775            return; // Nothing to show
776        }
777
778        // Get the terminal area and calculate input panel position
779        let total_area = f.area();
780
781        // Calculate where the input panel would be
782        let input_panel_y = total_area.height.saturating_sub(input_panel_height + 1); // +1 for status bar
783
784        // Calculate overlay height (max 10 results)
785        let overlay_height = results.len().min(10) as u16 + 2; // +2 for borders
786
787        // Position overlay just above the input panel
788        let overlay_y = input_panel_y.saturating_sub(overlay_height);
789        let overlay_area = Rect {
790            x: total_area.x,
791            y: overlay_y,
792            width: total_area.width,
793            height: overlay_height,
794        };
795
796        // Clear the area first
797        f.render_widget(Clear, overlay_area);
798
799        // Create list items with selection highlighting
800        // Reverse the order so best match (index 0) is at the bottom
801        let items: Vec<ListItem> = match mode {
802            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => results
803                .iter()
804                .enumerate()
805                .rev()
806                .map(|(i, path)| {
807                    let is_selected = selected_index == i;
808                    let style = if is_selected {
809                        theme.style(theme::Component::PopupSelection)
810                    } else {
811                        Style::default()
812                    };
813                    ListItem::new(path.as_str()).style(style)
814                })
815                .collect(),
816            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => {
817                results
818                    .iter()
819                    .enumerate()
820                    .rev()
821                    .map(|(i, cmd_name)| {
822                        let is_selected = selected_index == i;
823                        let style = if is_selected {
824                            theme.style(theme::Component::PopupSelection)
825                        } else {
826                            Style::default()
827                        };
828
829                        // Get command info to include description
830                        if let Some(cmd_info) = command_registry.get(cmd_name.as_str()) {
831                            let line = format!("/{:<12} {}", cmd_info.name, cmd_info.description);
832                            ListItem::new(line).style(style)
833                        } else {
834                            ListItem::new(format!("/{cmd_name}")).style(style)
835                        }
836                    })
837                    .collect()
838            }
839            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models
840            | crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => results
841                .iter()
842                .enumerate()
843                .rev()
844                .map(|(i, item)| {
845                    let is_selected = selected_index == i;
846                    let style = if is_selected {
847                        theme.style(theme::Component::PopupSelection)
848                    } else {
849                        Style::default()
850                    };
851                    ListItem::new(item.as_str()).style(style)
852                })
853                .collect(),
854        };
855
856        // Create the list widget
857        let list_block = Block::default()
858            .borders(Borders::ALL)
859            .border_style(theme.style(theme::Component::PopupBorder))
860            .title(match mode {
861                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => " Files ",
862                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => " Commands ",
863                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models => " Select Model ",
864                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => " Select Theme ",
865            });
866
867        let list = List::new(items)
868            .block(list_block)
869            .highlight_style(theme.style(theme::Component::PopupSelection));
870
871        // Create list state with reversed selection
872        let mut list_state = ListState::default();
873        let reversed_selection = results
874            .len()
875            .saturating_sub(1)
876            .saturating_sub(selected_index);
877        list_state.select(Some(reversed_selection));
878
879        f.render_stateful_widget(list, overlay_area, &mut list_state);
880    }
881
882    /// Create the event processing pipeline
883    fn create_event_pipeline() -> EventPipeline {
884        EventPipeline::new()
885            .add_processor(Box::new(ProcessingStateProcessor::new()))
886            .add_processor(Box::new(MessageEventProcessor::new()))
887            .add_processor(Box::new(ToolEventProcessor::new()))
888            .add_processor(Box::new(SystemEventProcessor::new()))
889    }
890
891    async fn handle_app_event(&mut self, event: AppEvent) {
892        let mut messages_updated = false;
893
894        // Handle workspace events before processing through pipeline
895        match &event {
896            AppEvent::WorkspaceChanged => {
897                self.load_file_cache().await;
898            }
899            AppEvent::WorkspaceFiles { files } => {
900                // Update file cache with the new file list
901                info!(target: "tui.handle_app_event", "Received workspace files event with {} files", files.len());
902                self.input_panel_state
903                    .file_cache
904                    .update(files.clone())
905                    .await;
906            }
907            _ => {}
908        }
909
910        // Create processing context
911        let mut ctx = crate::tui::events::processor::ProcessingContext {
912            chat_store: &mut self.chat_store,
913            chat_list_state: self.chat_viewport.state_mut(),
914            tool_registry: &mut self.tool_registry,
915            command_sink: &self.command_sink,
916            is_processing: &mut self.is_processing,
917            progress_message: &mut self.progress_message,
918            spinner_state: &mut self.spinner_state,
919            current_tool_approval: &mut self.current_tool_approval,
920            current_model: &mut self.current_model,
921            messages_updated: &mut messages_updated,
922            in_flight_operations: &mut self.in_flight_operations,
923        };
924
925        // Process the event through the pipeline
926        if let Err(e) = self.event_pipeline.process_event(event, &mut ctx).await {
927            tracing::error!(target: "tui.handle_app_event", "Event processing failed: {}", e);
928        }
929
930        // Sync doesn't need to happen anymore since we don't track threads
931
932        // Handle special input mode changes for tool approval
933        if self.current_tool_approval.is_some() && self.input_mode != InputMode::AwaitingApproval {
934            self.switch_mode(InputMode::AwaitingApproval);
935        } else if self.current_tool_approval.is_none()
936            && self.input_mode == InputMode::AwaitingApproval
937        {
938            self.restore_previous_mode();
939        }
940
941        // Auto-scroll if messages were added
942        if messages_updated {
943            // Clear cache for any updated messages
944            // Scroll to bottom if we were already at the bottom
945            if self.chat_viewport.state_mut().is_at_bottom() {
946                self.chat_viewport.state_mut().scroll_to_bottom();
947            }
948        }
949    }
950
951    async fn get_current_conversation(
952        _event_source: Arc<dyn AppEventSource>,
953    ) -> Result<Vec<Message>> {
954        // For now, just return empty - conversation will be restored by the app
955        Ok(Vec::new())
956    }
957
958    async fn send_message(&mut self, content: String) -> Result<()> {
959        // Handle slash commands
960        if content.starts_with('/') {
961            return self.handle_slash_command(content).await;
962        }
963
964        // Check if we're editing a message
965        if let Some(message_id_to_edit) = self.editing_message_id.take() {
966            // Send edit command which creates a new branch
967            if let Err(e) = self
968                .command_sink
969                .send_command(AppCommand::EditMessage {
970                    message_id: message_id_to_edit,
971                    new_content: content,
972                })
973                .await
974            {
975                self.push_notice(NoticeLevel::Error, format!("Cannot edit message: {e}"));
976            }
977        } else {
978            // Send regular message
979            if let Err(e) = self
980                .command_sink
981                .send_command(AppCommand::ProcessUserInput(content))
982                .await
983            {
984                self.push_notice(NoticeLevel::Error, format!("Cannot send message: {e}"));
985            }
986        }
987        Ok(())
988    }
989
990    async fn handle_slash_command(&mut self, command_input: String) -> Result<()> {
991        use crate::tui::commands::{AppCommand as TuiAppCommand, TuiCommand, TuiCommandType};
992        use crate::tui::model::NoticeLevel;
993
994        // First check if it's a custom command in the registry
995        let cmd_name = command_input
996            .trim()
997            .strip_prefix('/')
998            .unwrap_or(command_input.trim());
999
1000        if let Some(cmd_info) = self.command_registry.get(cmd_name) {
1001            if let crate::tui::commands::registry::CommandScope::Custom(custom_cmd) =
1002                &cmd_info.scope
1003            {
1004                // Create a TuiCommand::Custom and process it
1005                let app_cmd = TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd.clone()));
1006                // Process through the normal flow
1007                match app_cmd {
1008                    TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd)) => {
1009                        // Handle custom command based on its type
1010                        match custom_cmd {
1011                            crate::tui::custom_commands::CustomCommand::Prompt {
1012                                prompt, ..
1013                            } => {
1014                                // Forward prompt directly as user input to avoid recursive slash handling
1015                                self.command_sink
1016                                    .send_command(AppCommand::ProcessUserInput(prompt))
1017                                    .await?;
1018                            } // Future custom command types can be handled here
1019                        }
1020                    }
1021                    _ => unreachable!(),
1022                }
1023                return Ok(());
1024            }
1025        }
1026
1027        // Otherwise try to parse as built-in command
1028        let app_cmd = match TuiAppCommand::parse(&command_input) {
1029            Ok(cmd) => cmd,
1030            Err(e) => {
1031                // Add error notice to chat
1032                self.push_notice(NoticeLevel::Error, e.to_string());
1033                return Ok(());
1034            }
1035        };
1036
1037        // Handle the command based on its type
1038        match app_cmd {
1039            TuiAppCommand::Tui(tui_cmd) => {
1040                // Handle TUI-specific commands
1041                match tui_cmd {
1042                    TuiCommand::ReloadFiles => {
1043                        // Clear the file cache to force a refresh
1044                        self.input_panel_state.file_cache.clear().await;
1045                        info!(target: "tui.slash_command", "Cleared file cache, will reload on next access");
1046                        // Request workspace files again
1047                        if let Err(e) = self
1048                            .command_sink
1049                            .send_command(AppCommand::RequestWorkspaceFiles)
1050                            .await
1051                        {
1052                            self.push_notice(
1053                                NoticeLevel::Error,
1054                                format!("Cannot reload files: {e}"),
1055                            );
1056                        } else {
1057                            self.push_tui_response(
1058                                TuiCommandType::ReloadFiles.command_name(),
1059                                "File cache cleared. Files will be reloaded on next access."
1060                                    .to_string(),
1061                            );
1062                        }
1063                    }
1064                    TuiCommand::Theme(theme_name) => {
1065                        if let Some(name) = theme_name {
1066                            // Load the specified theme
1067                            let loader = theme::ThemeLoader::new();
1068                            match loader.load_theme(&name) {
1069                                Ok(new_theme) => {
1070                                    self.theme = new_theme;
1071                                    self.push_tui_response(
1072                                        TuiCommandType::Theme.command_name(),
1073                                        format!("Theme changed to '{name}'"),
1074                                    );
1075                                }
1076                                Err(e) => {
1077                                    self.push_notice(
1078                                        NoticeLevel::Error,
1079                                        format!("Failed to load theme '{name}': {e}"),
1080                                    );
1081                                }
1082                            }
1083                        } else {
1084                            // List available themes
1085                            let loader = theme::ThemeLoader::new();
1086                            let themes = loader.list_themes();
1087                            let theme_list = if themes.is_empty() {
1088                                "No themes found.".to_string()
1089                            } else {
1090                                format!("Available themes:\n{}", themes.join("\n"))
1091                            };
1092                            self.push_tui_response(
1093                                TuiCommandType::Theme.command_name(),
1094                                theme_list,
1095                            );
1096                        }
1097                    }
1098                    TuiCommand::Help(command_name) => {
1099                        // Build and show help text
1100                        let help_text = if let Some(cmd_name) = command_name {
1101                            // Show help for specific command
1102                            if let Some(cmd_info) = self.command_registry.get(&cmd_name) {
1103                                format!(
1104                                    "Command: {}\n\nDescription: {}\n\nUsage: {}",
1105                                    cmd_info.name, cmd_info.description, cmd_info.usage
1106                                )
1107                            } else {
1108                                format!("Unknown command: {cmd_name}")
1109                            }
1110                        } else {
1111                            // Show general help with all commands
1112                            let mut help_lines = vec!["Available commands:".to_string()];
1113                            for cmd_info in self.command_registry.all_commands() {
1114                                help_lines.push(format!(
1115                                    "  {:<20} - {}",
1116                                    cmd_info.usage, cmd_info.description
1117                                ));
1118                            }
1119                            help_lines.join("\n")
1120                        };
1121
1122                        self.push_tui_response(TuiCommandType::Help.command_name(), help_text);
1123                    }
1124                    TuiCommand::Auth => {
1125                        // Launch auth setup
1126                        // Initialize auth setup state
1127                        let auth_storage =
1128                            steer_core::auth::DefaultAuthStorage::new().map_err(|e| {
1129                                crate::error::Error::Generic(format!(
1130                                    "Failed to create auth storage: {e}"
1131                                ))
1132                            })?;
1133                        let auth_providers = LlmConfigProvider::new(Arc::new(auth_storage))
1134                            .available_providers()
1135                            .await?;
1136
1137                        let mut provider_status = std::collections::HashMap::new();
1138                        for provider in [
1139                            steer_core::api::ProviderKind::Anthropic,
1140                            steer_core::api::ProviderKind::OpenAI,
1141                            steer_core::api::ProviderKind::Google,
1142                            steer_core::api::ProviderKind::XAI,
1143                        ] {
1144                            let status = if auth_providers.contains(&provider) {
1145                                crate::tui::state::AuthStatus::ApiKeySet
1146                            } else {
1147                                crate::tui::state::AuthStatus::NotConfigured
1148                            };
1149                            provider_status.insert(provider, status);
1150                        }
1151
1152                        // Enter setup mode, skipping welcome page
1153                        self.setup_state = Some(
1154                            crate::tui::state::SetupState::new_for_auth_command(provider_status),
1155                        );
1156                        // Enter setup mode directly without pushing to the mode stack so that
1157                        // it can’t be accidentally popped by a later `restore_previous_mode`.
1158                        self.set_mode(InputMode::Setup);
1159                        // Clear the mode stack to avoid returning to a pre-setup mode.
1160                        self.mode_stack.clear();
1161
1162                        self.push_tui_response(
1163                            TuiCommandType::Auth.to_string(),
1164                            "Entering authentication setup mode...".to_string(),
1165                        );
1166                    }
1167                    TuiCommand::EditingMode(ref mode_name) => {
1168                        let response = match mode_name.as_deref() {
1169                            None => {
1170                                // Show current mode
1171                                let mode_str = match self.preferences.ui.editing_mode {
1172                                    steer_core::preferences::EditingMode::Simple => "simple",
1173                                    steer_core::preferences::EditingMode::Vim => "vim",
1174                                };
1175                                format!("Current editing mode: {mode_str}")
1176                            }
1177                            Some("simple") => {
1178                                self.preferences.ui.editing_mode =
1179                                    steer_core::preferences::EditingMode::Simple;
1180                                self.set_mode(InputMode::Simple);
1181                                self.preferences.save().map_err(crate::error::Error::Core)?;
1182                                "Switched to Simple mode".to_string()
1183                            }
1184                            Some("vim") => {
1185                                self.preferences.ui.editing_mode =
1186                                    steer_core::preferences::EditingMode::Vim;
1187                                self.set_mode(InputMode::VimNormal);
1188                                self.preferences.save().map_err(crate::error::Error::Core)?;
1189                                "Switched to Vim mode (Normal)".to_string()
1190                            }
1191                            Some(mode) => {
1192                                format!("Unknown mode: '{mode}'. Use 'simple' or 'vim'")
1193                            }
1194                        };
1195
1196                        self.push_tui_response(tui_cmd.as_command_str(), response);
1197                    }
1198                    TuiCommand::Custom(custom_cmd) => {
1199                        // Handle custom command based on its type
1200                        match custom_cmd {
1201                            crate::tui::custom_commands::CustomCommand::Prompt {
1202                                prompt, ..
1203                            } => {
1204                                // Forward prompt directly as user input to avoid recursive slash handling
1205                                self.command_sink
1206                                    .send_command(AppCommand::ProcessUserInput(prompt))
1207                                    .await?;
1208                            } // Future custom command types can be handled here
1209                        }
1210                    }
1211                }
1212            }
1213            TuiAppCommand::Core(core_cmd) => {
1214                // Pass core commands through to the backend
1215                if let Err(e) = self
1216                    .command_sink
1217                    .send_command(AppCommand::ExecuteCommand(core_cmd))
1218                    .await
1219                {
1220                    self.push_notice(NoticeLevel::Error, e.to_string());
1221                }
1222            }
1223        }
1224
1225        Ok(())
1226    }
1227
1228    /// Enter edit mode for a specific message
1229    fn enter_edit_mode(&mut self, message_id: &str) {
1230        // Find the message in the store
1231        if let Some(item) = self.chat_store.get_by_id(&message_id.to_string()) {
1232            if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1233                if let MessageData::User { content, .. } = &message.data {
1234                    // Extract text content from user blocks
1235                    let text = content
1236                        .iter()
1237                        .filter_map(|block| match block {
1238                            steer_core::app::conversation::UserContent::Text { text } => {
1239                                Some(text.as_str())
1240                            }
1241                            _ => None,
1242                        })
1243                        .collect::<Vec<_>>()
1244                        .join("\n");
1245
1246                    // Set up textarea with the message content
1247                    self.input_panel_state
1248                        .set_content_from_lines(text.lines().collect::<Vec<_>>());
1249                    // Switch to appropriate mode based on editing preference
1250                    self.input_mode = match self.preferences.ui.editing_mode {
1251                        steer_core::preferences::EditingMode::Simple => InputMode::Simple,
1252                        steer_core::preferences::EditingMode::Vim => InputMode::VimInsert,
1253                    };
1254
1255                    // Store the message ID we're editing
1256                    self.editing_message_id = Some(message_id.to_string());
1257                }
1258            }
1259        }
1260    }
1261
1262    /// Scroll chat list to show a specific message
1263    fn scroll_to_message_id(&mut self, message_id: &str) {
1264        // Find the index of the message in the chat store
1265        let mut target_index = None;
1266        for (idx, item) in self.chat_store.items().enumerate() {
1267            if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1268                if message.id() == message_id {
1269                    target_index = Some(idx);
1270                    break;
1271                }
1272            }
1273        }
1274
1275        if let Some(idx) = target_index {
1276            // Scroll to center the message if possible
1277            self.chat_viewport.state_mut().scroll_to_item(idx);
1278        }
1279    }
1280
1281    /// Enter edit message selection mode
1282    fn enter_edit_selection_mode(&mut self) {
1283        self.switch_mode(InputMode::EditMessageSelection);
1284
1285        // Populate the edit selection messages in the input panel state
1286        self.input_panel_state
1287            .populate_edit_selection(self.chat_store.iter_items().map(|item| &item.data));
1288
1289        // Scroll to the hovered message if there is one
1290        if let Some(id) = self.input_panel_state.get_hovered_id() {
1291            let id = id.to_string();
1292            self.scroll_to_message_id(&id);
1293        }
1294    }
1295}
1296
1297/// Helper function to get spinner character
1298fn get_spinner_char(state: usize) -> &'static str {
1299    const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1300    SPINNER_CHARS[state % SPINNER_CHARS.len()]
1301}
1302
1303/// Free function for best-effort terminal cleanup (raw mode, alt screen, mouse, etc.)
1304pub fn cleanup_terminal() {
1305    use ratatui::crossterm::{
1306        event::{DisableBracketedPaste, DisableMouseCapture, PopKeyboardEnhancementFlags},
1307        execute,
1308        terminal::{LeaveAlternateScreen, disable_raw_mode},
1309    };
1310    let _ = disable_raw_mode();
1311    let _ = execute!(
1312        std::io::stdout(),
1313        LeaveAlternateScreen,
1314        PopKeyboardEnhancementFlags,
1315        DisableBracketedPaste,
1316        DisableMouseCapture
1317    );
1318}
1319
1320/// Helper to wrap terminal cleanup in panic handler
1321pub fn setup_panic_hook() {
1322    std::panic::set_hook(Box::new(|panic_info| {
1323        cleanup_terminal();
1324        // Print panic info to stderr after restoring terminal state
1325        eprintln!("Application panicked:");
1326        eprintln!("{panic_info}");
1327    }));
1328}
1329
1330/// High-level entry point for running the TUI
1331pub async fn run_tui(
1332    client: std::sync::Arc<steer_grpc::GrpcClientAdapter>,
1333    session_id: Option<String>,
1334    model: steer_core::api::Model,
1335    directory: Option<std::path::PathBuf>,
1336    system_prompt: Option<String>,
1337    theme_name: Option<String>,
1338    force_setup: bool,
1339) -> Result<()> {
1340    use std::collections::HashMap;
1341    use steer_core::app::io::{AppCommandSink, AppEventSource};
1342    use steer_core::session::{SessionConfig, SessionToolConfig};
1343
1344    // Load theme - use catppuccin-mocha as default if none specified
1345    let loader = theme::ThemeLoader::new();
1346    let theme = if let Some(theme_name) = theme_name {
1347        // Check if theme_name is an absolute path
1348        let path = std::path::Path::new(&theme_name);
1349        let theme_result = if path.is_absolute() || path.exists() {
1350            // Load from specific path
1351            loader.load_theme_from_path(path)
1352        } else {
1353            // Load by name from search paths
1354            loader.load_theme(&theme_name)
1355        };
1356
1357        match theme_result {
1358            Ok(theme) => {
1359                info!("Loaded theme: {}", theme_name);
1360                Some(theme)
1361            }
1362            Err(e) => {
1363                warn!(
1364                    "Failed to load theme '{}': {}. Using default theme.",
1365                    theme_name, e
1366                );
1367                // Fall back to catppuccin-mocha
1368                loader.load_theme("catppuccin-mocha").ok()
1369            }
1370        }
1371    } else {
1372        // No theme specified, use catppuccin-mocha as default
1373        match loader.load_theme("catppuccin-mocha") {
1374            Ok(theme) => {
1375                info!("Loaded default theme: catppuccin-mocha");
1376                Some(theme)
1377            }
1378            Err(e) => {
1379                warn!(
1380                    "Failed to load default theme 'catppuccin-mocha': {}. Using hardcoded default.",
1381                    e
1382                );
1383                None
1384            }
1385        }
1386    };
1387
1388    // If session_id is provided, resume that session
1389    let (session_id, messages) = if let Some(session_id) = session_id {
1390        // Activate the existing session
1391        let (messages, _approved_tools) = client
1392            .activate_session(session_id.clone())
1393            .await
1394            .map_err(Box::new)?;
1395        info!(
1396            "Activated session: {} with {} messages",
1397            session_id,
1398            messages.len()
1399        );
1400        println!("Session ID: {session_id}");
1401        (session_id, messages)
1402    } else {
1403        // Create a new session
1404        let mut session_config = SessionConfig {
1405            workspace: if let Some(ref dir) = directory {
1406                steer_core::session::state::WorkspaceConfig::Local { path: dir.clone() }
1407            } else {
1408                steer_core::session::state::WorkspaceConfig::default()
1409            },
1410            tool_config: SessionToolConfig::default(),
1411            system_prompt,
1412            metadata: HashMap::new(),
1413        };
1414
1415        // Add the initial model to session metadata
1416        session_config
1417            .metadata
1418            .insert("initial_model".to_string(), model.to_string());
1419
1420        let session_id = client
1421            .create_session(session_config)
1422            .await
1423            .map_err(Box::new)?;
1424        (session_id, vec![])
1425    };
1426
1427    client.start_streaming().await.map_err(Box::new)?;
1428    let event_rx = client.subscribe().await;
1429    let mut tui = Tui::new(
1430        client.clone() as std::sync::Arc<dyn AppCommandSink>,
1431        client.clone() as std::sync::Arc<dyn AppEventSource>,
1432        model,
1433        session_id,
1434        theme.clone(),
1435    )
1436    .await?;
1437
1438    if !messages.is_empty() {
1439        tui.restore_messages(messages);
1440    }
1441
1442    let auth_storage = steer_core::auth::DefaultAuthStorage::new()
1443        .map_err(|e| Error::Generic(format!("Failed to create auth storage: {e}")))?;
1444    let auth_providers = LlmConfigProvider::new(Arc::new(auth_storage))
1445        .available_providers()
1446        .await
1447        .map_err(|e| Error::Generic(format!("Failed to check auth: {e}")))?;
1448
1449    let should_run_setup = force_setup
1450        || (!steer_core::preferences::Preferences::config_path()
1451            .map(|p| p.exists())
1452            .unwrap_or(false)
1453            && auth_providers.is_empty());
1454
1455    // Initialize setup state if first run or forced
1456    if should_run_setup {
1457        let mut provider_status = std::collections::HashMap::new();
1458        for provider in [
1459            steer_core::api::ProviderKind::Anthropic,
1460            steer_core::api::ProviderKind::OpenAI,
1461            steer_core::api::ProviderKind::Google,
1462            steer_core::api::ProviderKind::XAI,
1463        ] {
1464            let status = if auth_providers.contains(&provider) {
1465                crate::tui::state::AuthStatus::ApiKeySet
1466            } else {
1467                crate::tui::state::AuthStatus::NotConfigured
1468            };
1469            provider_status.insert(provider, status);
1470        }
1471
1472        tui.setup_state = Some(crate::tui::state::SetupState::new(provider_status));
1473        tui.input_mode = InputMode::Setup;
1474    }
1475
1476    // Run the TUI
1477    tui.run(event_rx).await?;
1478
1479    Ok(())
1480}
1481
1482/// Run TUI in authentication setup mode
1483/// This is now just a convenience function that launches regular TUI with setup mode forced
1484pub async fn run_tui_auth_setup(
1485    client: std::sync::Arc<steer_grpc::GrpcClientAdapter>,
1486    session_id: Option<String>,
1487    model: Option<Model>,
1488    session_db: Option<PathBuf>,
1489    theme_name: Option<String>,
1490) -> Result<()> {
1491    // Just delegate to regular run_tui - it will check for auth providers
1492    // and enter setup mode automatically if needed
1493    run_tui(
1494        client,
1495        session_id,
1496        model.unwrap_or_default(),
1497        session_db,
1498        None, // system_prompt
1499        theme_name,
1500        true, // force_setup = true for auth setup
1501    )
1502    .await
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508    use async_trait::async_trait;
1509    use serde_json::json;
1510    use std::sync::Arc;
1511    use steer_core::app::AppCommand;
1512    use steer_core::app::AppEvent;
1513    use steer_core::app::conversation::{AssistantContent, Message, MessageData};
1514    use steer_core::app::io::{AppCommandSink, AppEventSource};
1515    use steer_core::error::Result;
1516    use tokio::sync::mpsc;
1517
1518    /// RAII guard to ensure terminal state is restored after a test, even on panic.
1519    struct TerminalCleanupGuard;
1520
1521    impl Drop for TerminalCleanupGuard {
1522        fn drop(&mut self) {
1523            cleanup_terminal();
1524        }
1525    }
1526
1527    // Mock command sink for tests
1528    struct MockCommandSink;
1529
1530    #[async_trait]
1531    impl AppCommandSink for MockCommandSink {
1532        async fn send_command(&self, _command: AppCommand) -> Result<()> {
1533            Ok(())
1534        }
1535    }
1536
1537    struct MockEventSource;
1538
1539    #[async_trait]
1540    impl AppEventSource for MockEventSource {
1541        async fn subscribe(&self) -> mpsc::Receiver<AppEvent> {
1542            let (_, rx) = mpsc::channel(10);
1543            rx
1544        }
1545    }
1546
1547    #[tokio::test]
1548    #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1549    async fn test_restore_messages_preserves_tool_call_params() {
1550        let _guard = TerminalCleanupGuard;
1551        // Create a TUI instance for testing
1552        let command_sink = Arc::new(MockCommandSink) as Arc<dyn AppCommandSink>;
1553        let event_source = Arc::new(MockEventSource) as Arc<dyn AppEventSource>;
1554        let model = steer_core::api::Model::Claude3_5Sonnet20241022;
1555        let session_id = "test_session_id".to_string();
1556        let mut tui = Tui::new(command_sink, event_source, model, session_id, None)
1557            .await
1558            .unwrap();
1559
1560        // Build test messages: Assistant with ToolCall, then Tool result
1561        let tool_id = "test_tool_123".to_string();
1562        let tool_call = steer_tools::ToolCall {
1563            id: tool_id.clone(),
1564            name: "view".to_string(),
1565            parameters: json!({
1566                "file_path": "/test/file.rs",
1567                "offset": 10,
1568                "limit": 100
1569            }),
1570        };
1571
1572        let assistant_msg = Message {
1573            data: MessageData::Assistant {
1574                content: vec![AssistantContent::ToolCall {
1575                    tool_call: tool_call.clone(),
1576                }],
1577            },
1578            id: "msg_assistant".to_string(),
1579            timestamp: 1234567890,
1580            parent_message_id: None,
1581        };
1582
1583        let tool_msg = Message {
1584            data: MessageData::Tool {
1585                tool_use_id: tool_id.clone(),
1586                result: steer_tools::ToolResult::FileContent(
1587                    steer_tools::result::FileContentResult {
1588                        file_path: "/test/file.rs".to_string(),
1589                        content: "file content here".to_string(),
1590                        line_count: 1,
1591                        truncated: false,
1592                    },
1593                ),
1594            },
1595            id: "msg_tool".to_string(),
1596            timestamp: 1234567891,
1597            parent_message_id: Some("msg_assistant".to_string()),
1598        };
1599
1600        let messages = vec![assistant_msg, tool_msg];
1601
1602        // Restore messages
1603        tui.restore_messages(messages);
1604
1605        // Verify tool call was preserved in registry
1606        let stored_call = tui
1607            .tool_registry
1608            .get_tool_call(&tool_id)
1609            .expect("Tool call should be in registry");
1610        assert_eq!(stored_call.name, "view");
1611        assert_eq!(stored_call.parameters, tool_call.parameters);
1612    }
1613
1614    #[tokio::test]
1615    #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1616    async fn test_restore_messages_handles_tool_result_before_assistant() {
1617        let _guard = TerminalCleanupGuard;
1618        // Test edge case where Tool result arrives before Assistant message
1619        let command_sink = Arc::new(MockCommandSink) as Arc<dyn AppCommandSink>;
1620        let event_source = Arc::new(MockEventSource) as Arc<dyn AppEventSource>;
1621        let model = steer_core::api::Model::Claude3_5Sonnet20241022;
1622        let session_id = "test_session_id".to_string();
1623        let mut tui = Tui::new(command_sink, event_source, model, session_id, None)
1624            .await
1625            .unwrap();
1626
1627        let tool_id = "test_tool_456".to_string();
1628        let real_params = json!({
1629            "file_path": "/another/file.rs"
1630        });
1631
1632        let tool_call = steer_tools::ToolCall {
1633            id: tool_id.clone(),
1634            name: "view".to_string(),
1635            parameters: real_params.clone(),
1636        };
1637
1638        // Tool result comes first (unusual but possible)
1639        let tool_msg = Message {
1640            data: MessageData::Tool {
1641                tool_use_id: tool_id.clone(),
1642                result: steer_tools::ToolResult::FileContent(
1643                    steer_tools::result::FileContentResult {
1644                        file_path: "/another/file.rs".to_string(),
1645                        content: "file content".to_string(),
1646                        line_count: 1,
1647                        truncated: false,
1648                    },
1649                ),
1650            },
1651            id: "msg_tool".to_string(),
1652            timestamp: 1234567890,
1653            parent_message_id: None,
1654        };
1655
1656        let assistant_msg = Message {
1657            data: MessageData::Assistant {
1658                content: vec![AssistantContent::ToolCall {
1659                    tool_call: tool_call.clone(),
1660                }],
1661            },
1662            id: "msg_456".to_string(),
1663            timestamp: 1234567891,
1664            parent_message_id: None,
1665        };
1666
1667        let messages = vec![tool_msg, assistant_msg];
1668
1669        tui.restore_messages(messages);
1670
1671        // Should still have proper parameters
1672        let stored_call = tui
1673            .tool_registry
1674            .get_tool_call(&tool_id)
1675            .expect("Tool call should be in registry");
1676        assert_eq!(stored_call.parameters, real_params);
1677        assert_eq!(stored_call.name, "view");
1678    }
1679}