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::{self, Event, KeyEventKind, MouseEvent};
18use ratatui::{Frame, Terminal};
19use steer_core::app::conversation::{AssistantContent, Message, MessageData};
20use steer_core::app::io::AppEventSource;
21use steer_core::app::{AppCommand, AppEvent};
22
23use steer_core::config::model::ModelId;
24use steer_grpc::AgentClient;
25use steer_tools::schema::ToolCall;
26use tokio::sync::mpsc;
27use tokio::task::JoinHandle;
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 (term_event_tx, term_event_rx) = mpsc::channel::<Result<Event>>(1);
417        let input_handle: JoinHandle<()> = tokio::spawn(async move {
418            loop {
419                // Non-blocking poll
420                if event::poll(Duration::ZERO).unwrap_or(false) {
421                    match event::read() {
422                        Ok(evt) => {
423                            if term_event_tx.send(Ok(evt)).await.is_err() {
424                                break; // Receiver dropped
425                            }
426                        }
427                        Err(e) if e.kind() == io::ErrorKind::Interrupted => {
428                            // This is a non-fatal interrupted syscall, common on some
429                            // systems. We just ignore it and continue polling.
430                            debug!(target: "tui.input", "Ignoring interrupted syscall");
431                            continue;
432                        }
433                        Err(e) => {
434                            // A real I/O error occurred. Send it to the main loop
435                            // to handle, and then stop polling.
436                            warn!(target: "tui.input", "Input error: {}", e);
437                            if term_event_tx.send(Err(Error::from(e))).await.is_err() {
438                                break; // Receiver already dropped
439                            }
440                            break;
441                        }
442                    }
443                } else {
444                    // Async sleep that CAN be interrupted by abort
445                    tokio::time::sleep(Duration::from_millis(10)).await;
446                }
447            }
448        });
449
450        // Run the main event loop
451        let run_result = self
452            .run_event_loop(event_rx, term_event_rx, update_rx)
453            .await;
454
455        // Always abort the input handle regardless of success or error
456        input_handle.abort();
457
458        run_result
459    }
460
461    async fn run_event_loop(
462        &mut self,
463        mut event_rx: mpsc::Receiver<AppEvent>,
464        mut term_event_rx: mpsc::Receiver<Result<Event>>,
465        mut update_rx: mpsc::Receiver<UpdateStatus>,
466    ) -> Result<()> {
467        let mut should_exit = false;
468        let mut needs_redraw = true; // Force initial draw
469        let mut last_spinner_char = String::new();
470
471        // Create a tick interval for spinner updates
472        let mut tick = tokio::time::interval(SPINNER_UPDATE_INTERVAL);
473        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
474
475        while !should_exit {
476            // Determine if we need to redraw
477            if needs_redraw {
478                self.draw()?;
479                needs_redraw = false;
480            }
481
482            tokio::select! {
483                status = update_rx.recv() => {
484                    if let Some(status) = status {
485                        self.update_status = status;
486                        needs_redraw = true;
487                    }
488                }
489                event_res = term_event_rx.recv() => {
490                    match event_res {
491                        Some(Ok(evt)) => {
492                            match evt {
493                                Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
494                                    match self.handle_key_event(key_event).await {
495                                        Ok(exit) => {
496                                            if exit {
497                                                should_exit = true;
498                                            }
499                                        }
500                                        Err(e) => {
501                                            // Display error as a system notice
502                                            use crate::tui::model::{ChatItem, ChatItemData, NoticeLevel, generate_row_id};
503                                            self.chat_store.push(ChatItem {
504                                                parent_chat_item_id: None,
505                                                data: ChatItemData::SystemNotice {
506                                                    id: generate_row_id(),
507                                                    level: NoticeLevel::Error,
508                                                    text: e.to_string(),
509                                                    ts: time::OffsetDateTime::now_utc(),
510                                                },
511                                            });
512                                        }
513                                    }
514                                    needs_redraw = true;
515                                }
516                                Event::Mouse(mouse_event) => {
517                                    if self.handle_mouse_event(mouse_event)? {
518                                        needs_redraw = true;
519                                    }
520                                }
521                                Event::Resize(width, height) => {
522                                    self.terminal_size = (width, height);
523                                    // Terminal was resized, force redraw
524                                    needs_redraw = true;
525                                }
526                                Event::Paste(data) => {
527                                    // Handle paste in modes that accept text input
528                                    if self.is_text_input_mode() {
529                                        if self.input_mode == InputMode::Setup {
530                                            // Handle paste in setup mode
531                                            if let Some(setup_state) = &mut self.setup_state {
532                                                match &setup_state.current_step {
533                                                    crate::tui::state::SetupStep::Authentication(_) => {
534                                                        if setup_state.oauth_state.is_some() {
535                                                            // Pasting OAuth callback code
536                                                            setup_state.oauth_callback_input.push_str(&data);
537                                                        } else {
538                                                            // Pasting API key
539                                                            setup_state.api_key_input.push_str(&data);
540                                                        }
541                                                        debug!(target:"tui.run", "Pasted {} chars in Setup mode", data.len());
542                                                        needs_redraw = true;
543                                                    }
544                                                    _ => {
545                                                        // Other setup steps don't accept paste
546                                                    }
547                                                }
548                                            }
549                                        } else {
550                                            let normalized_data =
551                                                data.replace("\r\n", "\n").replace('\r', "\n");
552                                            self.input_panel_state.insert_str(&normalized_data);
553                                            debug!(target:"tui.run", "Pasted {} chars in {:?} mode", normalized_data.len(), self.input_mode);
554                                            needs_redraw = true;
555                                        }
556                                    }
557                                }
558                                _ => {}
559                            }
560                        }
561                        Some(Err(e)) => {
562                            error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
563                            should_exit = true;
564                        }
565                        None => {
566                            // Input channel closed, request exit
567                            should_exit = true;
568                        }
569                    }
570                }
571                app_event_opt = event_rx.recv() => {
572                    match app_event_opt {
573                        Some(app_event) => {
574                            self.handle_app_event(app_event).await;
575                            needs_redraw = true;
576                        }
577                        None => {
578                            should_exit = true;
579                        }
580                    }
581                }
582                _ = tick.tick() => {
583                    // Check if we should animate the spinner
584                    let has_pending_tools = !self.tool_registry.pending_calls().is_empty()
585                        || !self.tool_registry.active_calls().is_empty()
586                        || self.chat_store.has_pending_tools();
587                    let has_in_flight_operations = !self.in_flight_operations.is_empty();
588
589                    if self.is_processing || has_pending_tools || has_in_flight_operations {
590                        self.spinner_state = self.spinner_state.wrapping_add(1);
591                        let ch = get_spinner_char(self.spinner_state);
592                        if ch != last_spinner_char {
593                            last_spinner_char = ch.to_string();
594                            needs_redraw = true;
595                        }
596                    }
597                }
598            }
599        }
600
601        Ok(())
602    }
603
604    /// Handle mouse events
605    fn handle_mouse_event(&mut self, event: MouseEvent) -> Result<bool> {
606        let needs_redraw = match event.kind {
607            event::MouseEventKind::ScrollUp => {
608                // In vim normal mode or simple mode (when not typing), allow scrolling
609                if !self.is_text_input_mode()
610                    || (self.input_mode == InputMode::Simple
611                        && self.input_panel_state.content().is_empty())
612                {
613                    self.chat_viewport.state_mut().scroll_up(3);
614                    true
615                } else {
616                    false
617                }
618            }
619            event::MouseEventKind::ScrollDown => {
620                // In vim normal mode or simple mode (when not typing), allow scrolling
621                if !self.is_text_input_mode()
622                    || (self.input_mode == InputMode::Simple
623                        && self.input_panel_state.content().is_empty())
624                {
625                    self.chat_viewport.state_mut().scroll_down(3);
626                    true
627                } else {
628                    false
629                }
630            }
631            _ => false,
632        };
633
634        Ok(needs_redraw)
635    }
636
637    /// Draw the UI
638    fn draw(&mut self) -> Result<()> {
639        self.terminal.draw(|f| {
640            // Check if we're in setup mode
641            if let Some(setup_state) = &self.setup_state {
642                use crate::tui::widgets::setup::{
643                    authentication::AuthenticationWidget, completion::CompletionWidget,
644                    provider_selection::ProviderSelectionWidget, welcome::WelcomeWidget,
645                };
646
647                match &setup_state.current_step {
648                    crate::tui::state::SetupStep::Welcome => {
649                        WelcomeWidget::render(f.area(), f.buffer_mut(), &self.theme);
650                    }
651                    crate::tui::state::SetupStep::ProviderSelection => {
652                        ProviderSelectionWidget::render(
653                            f.area(),
654                            f.buffer_mut(),
655                            setup_state,
656                            &self.theme,
657                        );
658                    }
659                    crate::tui::state::SetupStep::Authentication(provider_id) => {
660                        AuthenticationWidget::render(
661                            f.area(),
662                            f.buffer_mut(),
663                            setup_state,
664                            provider_id.clone(),
665                            &self.theme,
666                        );
667                    }
668                    crate::tui::state::SetupStep::Completion => {
669                        CompletionWidget::render(
670                            f.area(),
671                            f.buffer_mut(),
672                            setup_state,
673                            &self.theme,
674                        );
675                    }
676                }
677                return;
678            }
679
680            let input_mode = self.input_mode;
681            let is_processing = self.is_processing;
682            let spinner_state = self.spinner_state;
683            let current_tool_approval = self.current_tool_approval.as_ref();
684            let current_model_owned = self.current_model.clone();
685
686            // Check if ChatStore has changed and trigger rebuild if needed
687            let current_revision = self.chat_store.revision();
688            if current_revision != self.last_revision {
689                self.chat_viewport.mark_dirty();
690                self.last_revision = current_revision;
691            }
692
693            // Get chat items from the chat store
694            let chat_items: Vec<&ChatItem> = self.chat_store.as_items();
695
696            let terminal_size = f.area();
697
698            let input_area_height = self.input_panel_state.required_height(
699                current_tool_approval,
700                terminal_size.width,
701                terminal_size.height,
702            );
703
704            let layout = UiLayout::compute(terminal_size, input_area_height, &self.theme);
705            layout.prepare_background(f, &self.theme);
706
707            self.chat_viewport.rebuild(
708                &chat_items,
709                layout.chat_area.width,
710                self.chat_viewport.state().view_mode,
711                &self.theme,
712                &self.chat_store,
713            );
714
715            let hovered_id = self
716                .input_panel_state
717                .get_hovered_id()
718                .map(|s| s.to_string());
719
720            self.chat_viewport.render(
721                f,
722                layout.chat_area,
723                spinner_state,
724                hovered_id.as_deref(),
725                &self.theme,
726            );
727
728            let input_panel = InputPanel::new(
729                input_mode,
730                current_tool_approval,
731                is_processing,
732                spinner_state,
733                &self.theme,
734            );
735            f.render_stateful_widget(input_panel, layout.input_area, &mut self.input_panel_state);
736
737            let update_badge = match &self.update_status {
738                UpdateStatus::Available(info) => {
739                    crate::tui::widgets::status_bar::UpdateBadge::Available {
740                        latest: &info.latest,
741                    }
742                }
743                _ => crate::tui::widgets::status_bar::UpdateBadge::None,
744            };
745            layout.render_status_bar(f, &current_model_owned, &self.theme, update_badge);
746
747            // Get fuzzy finder results before the render call
748            let fuzzy_finder_data = if input_mode == InputMode::FuzzyFinder {
749                let results = self.input_panel_state.fuzzy_finder.results().to_vec();
750                let selected = self.input_panel_state.fuzzy_finder.selected_index();
751                let input_height = self.input_panel_state.required_height(
752                    current_tool_approval,
753                    terminal_size.width,
754                    10,
755                );
756                let mode = self.input_panel_state.fuzzy_finder.mode();
757                Some((results, selected, input_height, mode))
758            } else {
759                None
760            };
761
762            // Render fuzzy finder overlay when active
763            if let Some((results, selected_index, input_height, mode)) = fuzzy_finder_data {
764                Self::render_fuzzy_finder_overlay_static(
765                    f,
766                    &results,
767                    selected_index,
768                    input_height,
769                    mode,
770                    &self.theme,
771                    &self.command_registry,
772                );
773            }
774        })?;
775        Ok(())
776    }
777
778    /// Render fuzzy finder overlay above the input panel
779    fn render_fuzzy_finder_overlay_static(
780        f: &mut Frame,
781        results: &[crate::tui::widgets::fuzzy_finder::PickerItem],
782        selected_index: usize,
783        input_panel_height: u16,
784        mode: crate::tui::widgets::fuzzy_finder::FuzzyFinderMode,
785        theme: &Theme,
786        command_registry: &CommandRegistry,
787    ) {
788        use ratatui::layout::Rect;
789        use ratatui::style::Style;
790        use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
791
792        // imports already handled above
793
794        if results.is_empty() {
795            return; // Nothing to show
796        }
797
798        // Get the terminal area and calculate input panel position
799        let total_area = f.area();
800
801        // Calculate where the input panel would be
802        let input_panel_y = total_area.height.saturating_sub(input_panel_height + 1); // +1 for status bar
803
804        // Calculate overlay height (max 10 results)
805        let overlay_height = results.len().min(10) as u16 + 2; // +2 for borders
806
807        // Position overlay just above the input panel
808        let overlay_y = input_panel_y.saturating_sub(overlay_height);
809        let overlay_area = Rect {
810            x: total_area.x,
811            y: overlay_y,
812            width: total_area.width,
813            height: overlay_height,
814        };
815
816        // Clear the area first
817        f.render_widget(Clear, overlay_area);
818
819        // Create list items with selection highlighting
820        // Reverse the order so best match (index 0) is at the bottom
821        let items: Vec<ListItem> = match mode {
822            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => results
823                .iter()
824                .enumerate()
825                .rev()
826                .map(|(i, item)| {
827                    let is_selected = selected_index == i;
828                    let style = if is_selected {
829                        theme.style(theme::Component::PopupSelection)
830                    } else {
831                        Style::default()
832                    };
833                    ListItem::new(item.label.as_str()).style(style)
834                })
835                .collect(),
836            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => {
837                results
838                    .iter()
839                    .enumerate()
840                    .rev()
841                    .map(|(i, item)| {
842                        let is_selected = selected_index == i;
843                        let style = if is_selected {
844                            theme.style(theme::Component::PopupSelection)
845                        } else {
846                            Style::default()
847                        };
848
849                        // Get command info to include description
850                        let label = &item.label;
851                        if let Some(cmd_info) = command_registry.get(label.as_str()) {
852                            let line = format!("/{:<12} {}", cmd_info.name, cmd_info.description);
853                            ListItem::new(line).style(style)
854                        } else {
855                            ListItem::new(format!("/{label}")).style(style)
856                        }
857                    })
858                    .collect()
859            }
860            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models
861            | crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => results
862                .iter()
863                .enumerate()
864                .rev()
865                .map(|(i, item)| {
866                    let is_selected = selected_index == i;
867                    let style = if is_selected {
868                        theme.style(theme::Component::PopupSelection)
869                    } else {
870                        Style::default()
871                    };
872                    ListItem::new(item.label.as_str()).style(style)
873                })
874                .collect(),
875        };
876
877        // Create the list widget
878        let list_block = Block::default()
879            .borders(Borders::ALL)
880            .border_style(theme.style(theme::Component::PopupBorder))
881            .title(match mode {
882                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => " Files ",
883                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => " Commands ",
884                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models => " Select Model ",
885                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => " Select Theme ",
886            });
887
888        let list = List::new(items)
889            .block(list_block)
890            .highlight_style(theme.style(theme::Component::PopupSelection));
891
892        // Create list state with reversed selection
893        let mut list_state = ListState::default();
894        let reversed_selection = results
895            .len()
896            .saturating_sub(1)
897            .saturating_sub(selected_index);
898        list_state.select(Some(reversed_selection));
899
900        f.render_stateful_widget(list, overlay_area, &mut list_state);
901    }
902
903    /// Create the event processing pipeline
904    fn create_event_pipeline() -> EventPipeline {
905        EventPipeline::new()
906            .add_processor(Box::new(ProcessingStateProcessor::new()))
907            .add_processor(Box::new(MessageEventProcessor::new()))
908            .add_processor(Box::new(ToolEventProcessor::new()))
909            .add_processor(Box::new(SystemEventProcessor::new()))
910    }
911
912    async fn handle_app_event(&mut self, event: AppEvent) {
913        let mut messages_updated = false;
914
915        // Handle workspace events before processing through pipeline
916        match &event {
917            AppEvent::WorkspaceChanged => {
918                self.load_file_cache().await;
919            }
920            AppEvent::WorkspaceFiles { files } => {
921                // Update file cache with the new file list
922                info!(target: "tui.handle_app_event", "Received workspace files event with {} files", files.len());
923                self.input_panel_state
924                    .file_cache
925                    .update(files.clone())
926                    .await;
927            }
928            _ => {}
929        }
930
931        // Create processing context
932        let mut ctx = crate::tui::events::processor::ProcessingContext {
933            chat_store: &mut self.chat_store,
934            chat_list_state: self.chat_viewport.state_mut(),
935            tool_registry: &mut self.tool_registry,
936            client: &self.client,
937            is_processing: &mut self.is_processing,
938            progress_message: &mut self.progress_message,
939            spinner_state: &mut self.spinner_state,
940            current_tool_approval: &mut self.current_tool_approval,
941            current_model: &mut self.current_model,
942            messages_updated: &mut messages_updated,
943            in_flight_operations: &mut self.in_flight_operations,
944        };
945
946        // Process the event through the pipeline
947        if let Err(e) = self.event_pipeline.process_event(event, &mut ctx).await {
948            tracing::error!(target: "tui.handle_app_event", "Event processing failed: {}", e);
949        }
950
951        // Sync doesn't need to happen anymore since we don't track threads
952
953        // Handle special input mode changes for tool approval
954        if self.current_tool_approval.is_some() && self.input_mode != InputMode::AwaitingApproval {
955            self.switch_mode(InputMode::AwaitingApproval);
956        } else if self.current_tool_approval.is_none()
957            && self.input_mode == InputMode::AwaitingApproval
958        {
959            self.restore_previous_mode();
960        }
961
962        // Auto-scroll if messages were added
963        if messages_updated {
964            // Clear cache for any updated messages
965            // Scroll to bottom if we were already at the bottom
966            if self.chat_viewport.state_mut().is_at_bottom() {
967                self.chat_viewport.state_mut().scroll_to_bottom();
968            }
969        }
970    }
971
972    async fn send_message(&mut self, content: String) -> Result<()> {
973        // Handle slash commands
974        if content.starts_with('/') {
975            return self.handle_slash_command(content).await;
976        }
977
978        // Check if we're editing a message
979        if let Some(message_id_to_edit) = self.editing_message_id.take() {
980            // Send edit command which creates a new branch
981            if let Err(e) = self
982                .client
983                .send_command(AppCommand::EditMessage {
984                    message_id: message_id_to_edit,
985                    new_content: content,
986                })
987                .await
988            {
989                self.push_notice(NoticeLevel::Error, format!("Cannot edit message: {e}"));
990            }
991        } else {
992            // Send regular message
993            if let Err(e) = self
994                .client
995                .send_command(AppCommand::ProcessUserInput(content))
996                .await
997            {
998                self.push_notice(NoticeLevel::Error, format!("Cannot send message: {e}"));
999            }
1000        }
1001        Ok(())
1002    }
1003
1004    async fn handle_slash_command(&mut self, command_input: String) -> Result<()> {
1005        use crate::tui::commands::{AppCommand as TuiAppCommand, TuiCommand, TuiCommandType};
1006        use crate::tui::model::NoticeLevel;
1007
1008        // First check if it's a custom command in the registry
1009        let cmd_name = command_input
1010            .trim()
1011            .strip_prefix('/')
1012            .unwrap_or(command_input.trim());
1013
1014        if let Some(cmd_info) = self.command_registry.get(cmd_name) {
1015            if let crate::tui::commands::registry::CommandScope::Custom(custom_cmd) =
1016                &cmd_info.scope
1017            {
1018                // Create a TuiCommand::Custom and process it
1019                let app_cmd = TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd.clone()));
1020                // Process through the normal flow
1021                match app_cmd {
1022                    TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd)) => {
1023                        // Handle custom command based on its type
1024                        match custom_cmd {
1025                            crate::tui::custom_commands::CustomCommand::Prompt {
1026                                prompt, ..
1027                            } => {
1028                                // Forward prompt directly as user input to avoid recursive slash handling
1029                                self.client
1030                                    .send_command(AppCommand::ProcessUserInput(prompt))
1031                                    .await?
1032                            } // Future custom command types can be handled here
1033                        }
1034                    }
1035                    _ => unreachable!(),
1036                }
1037                return Ok(());
1038            }
1039        }
1040
1041        // Otherwise try to parse as built-in command
1042        let app_cmd = match TuiAppCommand::parse(&command_input) {
1043            Ok(cmd) => cmd,
1044            Err(e) => {
1045                // Add error notice to chat
1046                self.push_notice(NoticeLevel::Error, e.to_string());
1047                return Ok(());
1048            }
1049        };
1050
1051        // Handle the command based on its type
1052        match app_cmd {
1053            TuiAppCommand::Tui(tui_cmd) => {
1054                // Handle TUI-specific commands
1055                match tui_cmd {
1056                    TuiCommand::ReloadFiles => {
1057                        // Clear the file cache to force a refresh
1058                        self.input_panel_state.file_cache.clear().await;
1059                        info!(target: "tui.slash_command", "Cleared file cache, will reload on next access");
1060                        // Request workspace files again
1061                        if let Err(e) = self
1062                            .client
1063                            .send_command(AppCommand::RequestWorkspaceFiles)
1064                            .await
1065                        {
1066                            self.push_notice(
1067                                NoticeLevel::Error,
1068                                format!("Cannot reload files: {e}"),
1069                            );
1070                        } else {
1071                            self.push_tui_response(
1072                                TuiCommandType::ReloadFiles.command_name(),
1073                                TuiCommandResponse::Text(
1074                                    "File cache cleared. Files will be reloaded on next access."
1075                                        .to_string(),
1076                                ),
1077                            );
1078                        }
1079                    }
1080                    TuiCommand::Theme(theme_name) => {
1081                        if let Some(name) = theme_name {
1082                            // Load the specified theme
1083                            let loader = theme::ThemeLoader::new();
1084                            match loader.load_theme(&name) {
1085                                Ok(new_theme) => {
1086                                    self.theme = new_theme;
1087                                    self.push_tui_response(
1088                                        TuiCommandType::Theme.command_name(),
1089                                        TuiCommandResponse::Theme { name: name.clone() },
1090                                    );
1091                                }
1092                                Err(e) => {
1093                                    self.push_notice(
1094                                        NoticeLevel::Error,
1095                                        format!("Failed to load theme '{name}': {e}"),
1096                                    );
1097                                }
1098                            }
1099                        } else {
1100                            // List available themes
1101                            let loader = theme::ThemeLoader::new();
1102                            let themes = loader.list_themes();
1103                            self.push_tui_response(
1104                                TuiCommandType::Theme.command_name(),
1105                                TuiCommandResponse::ListThemes(themes),
1106                            );
1107                        }
1108                    }
1109                    TuiCommand::Help(command_name) => {
1110                        // Build and show help text
1111                        let help_text = if let Some(cmd_name) = command_name {
1112                            // Show help for specific command
1113                            if let Some(cmd_info) = self.command_registry.get(&cmd_name) {
1114                                format!(
1115                                    "Command: {}\n\nDescription: {}\n\nUsage: {}",
1116                                    cmd_info.name, cmd_info.description, cmd_info.usage
1117                                )
1118                            } else {
1119                                format!("Unknown command: {cmd_name}")
1120                            }
1121                        } else {
1122                            // Show general help with all commands
1123                            let mut help_lines = vec!["Available commands:".to_string()];
1124                            for cmd_info in self.command_registry.all_commands() {
1125                                help_lines.push(format!(
1126                                    "  {:<20} - {}",
1127                                    cmd_info.usage, cmd_info.description
1128                                ));
1129                            }
1130                            help_lines.join("\n")
1131                        };
1132
1133                        self.push_tui_response(
1134                            TuiCommandType::Help.command_name(),
1135                            TuiCommandResponse::Text(help_text),
1136                        );
1137                    }
1138                    TuiCommand::Auth => {
1139                        // Launch auth setup
1140                        // Initialize auth setup state
1141                        // Fetch providers and their auth status from server
1142                        let providers = self.client.list_providers().await.map_err(|e| {
1143                            crate::error::Error::Generic(format!(
1144                                "Failed to list providers from server: {e}"
1145                            ))
1146                        })?;
1147                        let statuses =
1148                            self.client
1149                                .get_provider_auth_status(None)
1150                                .await
1151                                .map_err(|e| {
1152                                    crate::error::Error::Generic(format!(
1153                                        "Failed to get provider auth status: {e}"
1154                                    ))
1155                                })?;
1156
1157                        // Build provider registry view from remote providers
1158                        let mut provider_status = std::collections::HashMap::new();
1159
1160                        use steer_grpc::proto::provider_auth_status::Status;
1161                        let mut status_map = std::collections::HashMap::new();
1162                        for s in statuses {
1163                            status_map.insert(s.provider_id.clone(), s.status);
1164                        }
1165
1166                        // Convert remote providers into a minimal registry-like view for TUI
1167                        let registry =
1168                            std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
1169
1170                        for p in registry.all() {
1171                            let status = match status_map.get(&p.id).copied() {
1172                                Some(v) if v == Status::AuthStatusOauth as i32 => {
1173                                    crate::tui::state::AuthStatus::OAuthConfigured
1174                                }
1175                                Some(v) if v == Status::AuthStatusApiKey as i32 => {
1176                                    crate::tui::state::AuthStatus::ApiKeySet
1177                                }
1178                                _ => crate::tui::state::AuthStatus::NotConfigured,
1179                            };
1180                            provider_status.insert(
1181                                steer_core::config::provider::ProviderId(p.id.clone()),
1182                                status,
1183                            );
1184                        }
1185
1186                        // Enter setup mode, skipping welcome page
1187                        self.setup_state =
1188                            Some(crate::tui::state::SetupState::new_for_auth_command(
1189                                registry,
1190                                provider_status,
1191                            ));
1192                        // Enter setup mode directly without pushing to the mode stack so that
1193                        // it can’t be accidentally popped by a later `restore_previous_mode`.
1194                        self.set_mode(InputMode::Setup);
1195                        // Clear the mode stack to avoid returning to a pre-setup mode.
1196                        self.mode_stack.clear();
1197
1198                        self.push_tui_response(
1199                            TuiCommandType::Auth.to_string(),
1200                            TuiCommandResponse::Text(
1201                                "Entering authentication setup mode...".to_string(),
1202                            ),
1203                        );
1204                    }
1205                    TuiCommand::EditingMode(ref mode_name) => {
1206                        let response = match mode_name.as_deref() {
1207                            None => {
1208                                // Show current mode
1209                                let mode_str = self.preferences.ui.editing_mode.to_string();
1210                                format!("Current editing mode: {mode_str}")
1211                            }
1212                            Some("simple") => {
1213                                self.preferences.ui.editing_mode =
1214                                    steer_core::preferences::EditingMode::Simple;
1215                                self.set_mode(InputMode::Simple);
1216                                self.preferences.save().map_err(crate::error::Error::Core)?;
1217                                "Switched to Simple mode".to_string()
1218                            }
1219                            Some("vim") => {
1220                                self.preferences.ui.editing_mode =
1221                                    steer_core::preferences::EditingMode::Vim;
1222                                self.set_mode(InputMode::VimNormal);
1223                                self.preferences.save().map_err(crate::error::Error::Core)?;
1224                                "Switched to Vim mode (Normal)".to_string()
1225                            }
1226                            Some(mode) => {
1227                                format!("Unknown mode: '{mode}'. Use 'simple' or 'vim'")
1228                            }
1229                        };
1230
1231                        self.push_tui_response(
1232                            tui_cmd.as_command_str(),
1233                            TuiCommandResponse::Text(response),
1234                        );
1235                    }
1236                    TuiCommand::Mcp => {
1237                        let servers = self.client.get_mcp_servers().await?;
1238                        self.push_tui_response(
1239                            tui_cmd.as_command_str(),
1240                            TuiCommandResponse::ListMcpServers(servers),
1241                        );
1242                    }
1243                    TuiCommand::Custom(custom_cmd) => {
1244                        // Handle custom command based on its type
1245                        match custom_cmd {
1246                            crate::tui::custom_commands::CustomCommand::Prompt {
1247                                prompt, ..
1248                            } => {
1249                                // Forward prompt directly as user input to avoid recursive slash handling
1250                                self.client
1251                                    .send_command(AppCommand::ProcessUserInput(prompt))
1252                                    .await?;
1253                            } // Future custom command types can be handled here
1254                        }
1255                    }
1256                }
1257            }
1258            TuiAppCommand::Core(core_cmd) => {
1259                // Pass core commands through to the backend
1260                if let Err(e) = self
1261                    .client
1262                    .send_command(AppCommand::ExecuteCommand(core_cmd))
1263                    .await
1264                {
1265                    self.push_notice(NoticeLevel::Error, e.to_string());
1266                }
1267            }
1268        }
1269
1270        Ok(())
1271    }
1272
1273    /// Enter edit mode for a specific message
1274    fn enter_edit_mode(&mut self, message_id: &str) {
1275        // Find the message in the store
1276        if let Some(item) = self.chat_store.get_by_id(&message_id.to_string()) {
1277            if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1278                if let MessageData::User { content, .. } = &message.data {
1279                    // Extract text content from user blocks
1280                    let text = content
1281                        .iter()
1282                        .filter_map(|block| match block {
1283                            steer_core::app::conversation::UserContent::Text { text } => {
1284                                Some(text.as_str())
1285                            }
1286                            _ => None,
1287                        })
1288                        .collect::<Vec<_>>()
1289                        .join("\n");
1290
1291                    // Set up textarea with the message content
1292                    self.input_panel_state
1293                        .set_content_from_lines(text.lines().collect::<Vec<_>>());
1294                    // Switch to appropriate mode based on editing preference
1295                    self.input_mode = match self.preferences.ui.editing_mode {
1296                        steer_core::preferences::EditingMode::Simple => InputMode::Simple,
1297                        steer_core::preferences::EditingMode::Vim => InputMode::VimInsert,
1298                    };
1299
1300                    // Store the message ID we're editing
1301                    self.editing_message_id = Some(message_id.to_string());
1302                }
1303            }
1304        }
1305    }
1306
1307    /// Scroll chat list to show a specific message
1308    fn scroll_to_message_id(&mut self, message_id: &str) {
1309        // Find the index of the message in the chat store
1310        let mut target_index = None;
1311        for (idx, item) in self.chat_store.items().enumerate() {
1312            if let crate::tui::model::ChatItemData::Message(message) = &item.data {
1313                if message.id() == message_id {
1314                    target_index = Some(idx);
1315                    break;
1316                }
1317            }
1318        }
1319
1320        if let Some(idx) = target_index {
1321            // Scroll to center the message if possible
1322            self.chat_viewport.state_mut().scroll_to_item(idx);
1323        }
1324    }
1325
1326    /// Enter edit message selection mode
1327    fn enter_edit_selection_mode(&mut self) {
1328        self.switch_mode(InputMode::EditMessageSelection);
1329
1330        // Populate the edit selection messages in the input panel state
1331        self.input_panel_state
1332            .populate_edit_selection(self.chat_store.iter_items());
1333
1334        // Scroll to the hovered message if there is one
1335        if let Some(id) = self.input_panel_state.get_hovered_id() {
1336            let id = id.to_string();
1337            self.scroll_to_message_id(&id);
1338        }
1339    }
1340}
1341
1342/// Helper function to get spinner character
1343fn get_spinner_char(state: usize) -> &'static str {
1344    const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1345    SPINNER_CHARS[state % SPINNER_CHARS.len()]
1346}
1347
1348impl Drop for Tui {
1349    fn drop(&mut self) {
1350        // Use the same backend writer for reliable cleanup; idempotent via TERMINAL_STATE
1351        crate::tui::terminal::cleanup_with_writer(self.terminal.backend_mut());
1352    }
1353}
1354
1355/// Helper to wrap terminal cleanup in panic handler
1356pub fn setup_panic_hook() {
1357    std::panic::set_hook(Box::new(|panic_info| {
1358        cleanup();
1359        // Print panic info to stderr after restoring terminal state
1360        eprintln!("Application panicked:");
1361        eprintln!("{panic_info}");
1362    }));
1363}
1364
1365/// High-level entry point for running the TUI
1366pub async fn run_tui(
1367    client: steer_grpc::AgentClient,
1368    session_id: Option<String>,
1369    model: steer_core::config::model::ModelId,
1370    directory: Option<std::path::PathBuf>,
1371    system_prompt: Option<String>,
1372    theme_name: Option<String>,
1373    force_setup: bool,
1374) -> Result<()> {
1375    use std::collections::HashMap;
1376    use steer_core::session::{SessionConfig, SessionToolConfig};
1377
1378    // Load theme - use catppuccin-mocha as default if none specified
1379    let loader = theme::ThemeLoader::new();
1380    let theme = if let Some(theme_name) = theme_name {
1381        // Check if theme_name is an absolute path
1382        let path = std::path::Path::new(&theme_name);
1383        let theme_result = if path.is_absolute() || path.exists() {
1384            // Load from specific path
1385            loader.load_theme_from_path(path)
1386        } else {
1387            // Load by name from search paths
1388            loader.load_theme(&theme_name)
1389        };
1390
1391        match theme_result {
1392            Ok(theme) => {
1393                info!("Loaded theme: {}", theme_name);
1394                Some(theme)
1395            }
1396            Err(e) => {
1397                warn!(
1398                    "Failed to load theme '{}': {}. Using default theme.",
1399                    theme_name, e
1400                );
1401                // Fall back to catppuccin-mocha
1402                loader.load_theme("catppuccin-mocha").ok()
1403            }
1404        }
1405    } else {
1406        // No theme specified, use catppuccin-mocha as default
1407        match loader.load_theme("catppuccin-mocha") {
1408            Ok(theme) => {
1409                info!("Loaded default theme: catppuccin-mocha");
1410                Some(theme)
1411            }
1412            Err(e) => {
1413                warn!(
1414                    "Failed to load default theme 'catppuccin-mocha': {}. Using hardcoded default.",
1415                    e
1416                );
1417                None
1418            }
1419        }
1420    };
1421
1422    // If session_id is provided, resume that session
1423    let (session_id, messages) = if let Some(session_id) = session_id {
1424        // Activate the existing session
1425        let (messages, _approved_tools) = client
1426            .activate_session(session_id.clone())
1427            .await
1428            .map_err(Box::new)?;
1429        info!(
1430            "Activated session: {} with {} messages",
1431            session_id,
1432            messages.len()
1433        );
1434        println!("Session ID: {session_id}");
1435        (session_id, messages)
1436    } else {
1437        // Create a new session
1438        let mut session_config = SessionConfig {
1439            workspace: if let Some(ref dir) = directory {
1440                steer_core::session::state::WorkspaceConfig::Local { path: dir.clone() }
1441            } else {
1442                steer_core::session::state::WorkspaceConfig::default()
1443            },
1444            tool_config: SessionToolConfig::default(),
1445            system_prompt,
1446            metadata: HashMap::new(),
1447        };
1448
1449        // Add the initial model to session metadata
1450        session_config.metadata.insert(
1451            "initial_model".to_string(),
1452            format!("{}/{}", model.0.storage_key(), model.1),
1453        );
1454
1455        let session_id = client
1456            .create_session(session_config)
1457            .await
1458            .map_err(Box::new)?;
1459        (session_id, vec![])
1460    };
1461
1462    client.start_streaming().await.map_err(Box::new)?;
1463    let event_rx = client.subscribe().await;
1464    let mut tui = Tui::new(client, model.clone(), session_id.clone(), theme.clone()).await?;
1465
1466    // Ensure terminal cleanup even if we error before entering the event loop
1467    struct TuiCleanupGuard;
1468    impl Drop for TuiCleanupGuard {
1469        fn drop(&mut self) {
1470            cleanup();
1471        }
1472    }
1473    let _cleanup_guard = TuiCleanupGuard;
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
1542/// Run TUI in authentication setup mode
1543/// This is now just a convenience function that launches regular TUI with setup mode forced
1544pub async fn run_tui_auth_setup(
1545    client: steer_grpc::AgentClient,
1546    session_id: Option<String>,
1547    model: Option<ModelId>,
1548    session_db: Option<PathBuf>,
1549    theme_name: Option<String>,
1550) -> Result<()> {
1551    // Just delegate to regular run_tui - it will check for auth providers
1552    // and enter setup mode automatically if needed
1553    run_tui(
1554        client,
1555        session_id,
1556        model.unwrap_or(steer_core::config::model::builtin::claude_3_7_sonnet_20250219()),
1557        session_db,
1558        None, // system_prompt
1559        theme_name,
1560        true, // force_setup = true for auth setup
1561    )
1562    .await
1563}
1564
1565#[cfg(test)]
1566mod tests {
1567    use crate::tui::test_utils::local_client_and_server;
1568
1569    use super::*;
1570
1571    use serde_json::json;
1572
1573    use steer_core::app::conversation::{AssistantContent, Message, MessageData};
1574    use tempfile::tempdir;
1575
1576    /// RAII guard to ensure terminal state is restored after a test, even on panic.
1577    struct TerminalCleanupGuard;
1578
1579    impl Drop for TerminalCleanupGuard {
1580        fn drop(&mut self) {
1581            cleanup();
1582        }
1583    }
1584
1585    #[tokio::test]
1586    #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1587    async fn test_restore_messages_preserves_tool_call_params() {
1588        let _guard = TerminalCleanupGuard;
1589        // Create a TUI instance for testing
1590        let path = tempdir().unwrap().path().to_path_buf();
1591        let (client, _server_handle) = local_client_and_server(Some(path)).await;
1592        let model = steer_core::config::model::builtin::claude_3_5_sonnet_20241022();
1593        let session_id = "test_session_id".to_string();
1594        let mut tui = Tui::new(client, model, session_id, None).await.unwrap();
1595
1596        // Build test messages: Assistant with ToolCall, then Tool result
1597        let tool_id = "test_tool_123".to_string();
1598        let tool_call = steer_tools::ToolCall {
1599            id: tool_id.clone(),
1600            name: "view".to_string(),
1601            parameters: json!({
1602                "file_path": "/test/file.rs",
1603                "offset": 10,
1604                "limit": 100
1605            }),
1606        };
1607
1608        let assistant_msg = Message {
1609            data: MessageData::Assistant {
1610                content: vec![AssistantContent::ToolCall {
1611                    tool_call: tool_call.clone(),
1612                }],
1613            },
1614            id: "msg_assistant".to_string(),
1615            timestamp: 1234567890,
1616            parent_message_id: None,
1617        };
1618
1619        let tool_msg = Message {
1620            data: MessageData::Tool {
1621                tool_use_id: tool_id.clone(),
1622                result: steer_tools::ToolResult::FileContent(
1623                    steer_tools::result::FileContentResult {
1624                        file_path: "/test/file.rs".to_string(),
1625                        content: "file content here".to_string(),
1626                        line_count: 1,
1627                        truncated: false,
1628                    },
1629                ),
1630            },
1631            id: "msg_tool".to_string(),
1632            timestamp: 1234567891,
1633            parent_message_id: Some("msg_assistant".to_string()),
1634        };
1635
1636        let messages = vec![assistant_msg, tool_msg];
1637
1638        // Restore messages
1639        tui.restore_messages(messages);
1640
1641        // Verify tool call was preserved in registry
1642        let stored_call = tui
1643            .tool_registry
1644            .get_tool_call(&tool_id)
1645            .expect("Tool call should be in registry");
1646        assert_eq!(stored_call.name, "view");
1647        assert_eq!(stored_call.parameters, tool_call.parameters);
1648    }
1649
1650    #[tokio::test]
1651    #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1652    async fn test_restore_messages_handles_tool_result_before_assistant() {
1653        let _guard = TerminalCleanupGuard;
1654        // Test edge case where Tool result arrives before Assistant message
1655        let path = tempdir().unwrap().path().to_path_buf();
1656        let (client, _server_handle) = local_client_and_server(Some(path)).await;
1657        let model = steer_core::config::model::builtin::claude_3_5_sonnet_20241022();
1658        let session_id = "test_session_id".to_string();
1659        let mut tui = Tui::new(client, model, session_id, None).await.unwrap();
1660
1661        let tool_id = "test_tool_456".to_string();
1662        let real_params = json!({
1663            "file_path": "/another/file.rs"
1664        });
1665
1666        let tool_call = steer_tools::ToolCall {
1667            id: tool_id.clone(),
1668            name: "view".to_string(),
1669            parameters: real_params.clone(),
1670        };
1671
1672        // Tool result comes first (unusual but possible)
1673        let tool_msg = Message {
1674            data: MessageData::Tool {
1675                tool_use_id: tool_id.clone(),
1676                result: steer_tools::ToolResult::FileContent(
1677                    steer_tools::result::FileContentResult {
1678                        file_path: "/another/file.rs".to_string(),
1679                        content: "file content".to_string(),
1680                        line_count: 1,
1681                        truncated: false,
1682                    },
1683                ),
1684            },
1685            id: "msg_tool".to_string(),
1686            timestamp: 1234567890,
1687            parent_message_id: None,
1688        };
1689
1690        let assistant_msg = Message {
1691            data: MessageData::Assistant {
1692                content: vec![AssistantContent::ToolCall {
1693                    tool_call: tool_call.clone(),
1694                }],
1695            },
1696            id: "msg_456".to_string(),
1697            timestamp: 1234567891,
1698            parent_message_id: None,
1699        };
1700
1701        let messages = vec![tool_msg, assistant_msg];
1702
1703        tui.restore_messages(messages);
1704
1705        // Should still have proper parameters
1706        let stored_call = tui
1707            .tool_registry
1708            .get_tool_call(&tool_id)
1709            .expect("Tool call should be in registry");
1710        assert_eq!(stored_call.parameters, real_params);
1711        assert_eq!(stored_call.name, "view");
1712    }
1713}