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