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