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