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