Skip to main content

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::notifications::{NotificationManager, NotificationManagerHandle};
14use crate::tui::commands::registry::CommandRegistry;
15use crate::tui::model::{ChatItem, NoticeLevel, TuiCommandResponse};
16use crate::tui::theme::Theme;
17use futures::{FutureExt, StreamExt};
18use ratatui::backend::CrosstermBackend;
19use ratatui::crossterm::event::{self, Event, EventStream, KeyCode, KeyEventKind, MouseEvent};
20use ratatui::{Frame, Terminal};
21use steer_grpc::AgentClient;
22use steer_grpc::client_api::{
23    AssistantContent, ClientEvent, EditingMode, LlmStatus, Message, MessageData, ModelId, OpId,
24    Preferences, ProviderId, UserContent, WorkspaceStatus, builtin, default_primary_agent_id,
25};
26
27use crate::tui::events::processor::PendingToolApproval;
28use tokio::sync::mpsc;
29use tracing::{debug, error, info, warn};
30
31fn auth_status_from_source(
32    source: Option<&steer_grpc::client_api::AuthSource>,
33) -> crate::tui::state::AuthStatus {
34    match source {
35        Some(steer_grpc::client_api::AuthSource::ApiKey { .. }) => {
36            crate::tui::state::AuthStatus::ApiKeySet
37        }
38        Some(steer_grpc::client_api::AuthSource::Plugin { .. }) => {
39            crate::tui::state::AuthStatus::OAuthConfigured
40        }
41        _ => crate::tui::state::AuthStatus::NotConfigured,
42    }
43}
44
45fn has_any_auth_source(source: Option<&steer_grpc::client_api::AuthSource>) -> bool {
46    matches!(
47        source,
48        Some(
49            steer_grpc::client_api::AuthSource::ApiKey { .. }
50                | steer_grpc::client_api::AuthSource::Plugin { .. }
51        )
52    )
53}
54
55pub(crate) fn format_agent_label(primary_agent_id: &str) -> String {
56    let agent_id = if primary_agent_id.is_empty() {
57        default_primary_agent_id()
58    } else {
59        primary_agent_id
60    };
61    agent_id.to_string()
62}
63
64use crate::tui::events::pipeline::EventPipeline;
65use crate::tui::events::processors::message::MessageEventProcessor;
66use crate::tui::events::processors::processing_state::ProcessingStateProcessor;
67use crate::tui::events::processors::system::SystemEventProcessor;
68use crate::tui::events::processors::tool::ToolEventProcessor;
69use crate::tui::state::RemoteProviderRegistry;
70use crate::tui::state::SetupState;
71use crate::tui::state::{ChatStore, ToolCallRegistry};
72
73use crate::tui::chat_viewport::ChatViewport;
74use crate::tui::terminal::{SetupGuard, cleanup};
75use crate::tui::ui_layout::UiLayout;
76use crate::tui::widgets::EditSelectionOverlayState;
77use crate::tui::widgets::InputPanel;
78use crate::tui::widgets::input_panel::InputPanelParams;
79use tracing::error as tracing_error;
80use tracing::info as tracing_info;
81
82pub mod commands;
83pub mod custom_commands;
84pub mod model;
85pub mod state;
86pub mod terminal;
87pub mod theme;
88pub mod widgets;
89
90mod chat_viewport;
91pub mod core_commands;
92mod events;
93mod handlers;
94mod ui_layout;
95mod update;
96
97#[cfg(test)]
98mod test_utils;
99
100/// How often to update the spinner animation (when processing)
101const SPINNER_UPDATE_INTERVAL: Duration = Duration::from_millis(100);
102const SCROLL_FLUSH_INTERVAL: Duration = Duration::from_millis(16);
103const MOUSE_SCROLL_STEP: usize = 1;
104
105/// Input modes for the TUI
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum InputMode {
108    /// Simple mode - default non-modal editing
109    Simple,
110    /// Vim normal mode
111    VimNormal,
112    /// Vim insert mode
113    VimInsert,
114    /// Bash command mode - executing shell commands
115    BashCommand,
116    /// Awaiting tool approval
117    AwaitingApproval,
118    /// Confirm exit dialog
119    ConfirmExit,
120    /// Edit message selection mode with fuzzy filtering
121    EditMessageSelection,
122    /// Fuzzy finder mode for file selection
123    FuzzyFinder,
124    /// Setup mode - first run experience
125    Setup,
126}
127
128/// Vim operator types
129#[derive(Debug, Clone, Copy, PartialEq)]
130enum VimOperator {
131    Delete,
132    Change,
133    Yank,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137enum ScrollDirection {
138    Up,
139    Down,
140}
141
142impl ScrollDirection {
143    fn from_mouse_event(event: &MouseEvent) -> Option<Self> {
144        match event.kind {
145            event::MouseEventKind::ScrollUp => Some(Self::Up),
146            event::MouseEventKind::ScrollDown => Some(Self::Down),
147            _ => None,
148        }
149    }
150}
151
152#[derive(Debug, Clone, Copy)]
153struct PendingScroll {
154    direction: ScrollDirection,
155    steps: usize,
156}
157
158/// State for tracking vim key sequences
159#[derive(Debug, Default)]
160struct VimState {
161    /// Pending operator (d, c, y)
162    pending_operator: Option<VimOperator>,
163    /// Waiting for second 'g' in gg
164    pending_g: bool,
165    /// In replace mode (after 'r')
166    replace_mode: bool,
167    /// In visual mode
168    visual_mode: bool,
169}
170
171/// Main TUI application state
172pub struct Tui {
173    /// Terminal instance
174    terminal: Terminal<CrosstermBackend<Stdout>>,
175    terminal_size: (u16, u16),
176    /// Current input mode
177    input_mode: InputMode,
178    /// State for the input panel widget
179    input_panel_state: crate::tui::widgets::input_panel::InputPanelState,
180    /// The ID of the message being edited (if any)
181    editing_message_id: Option<String>,
182    /// Handle to send commands to the app
183    client: AgentClient,
184    /// Are we currently processing a request?
185    is_processing: bool,
186    /// Progress message to show while processing
187    progress_message: Option<String>,
188    /// Animation frame for spinner
189    spinner_state: usize,
190    current_tool_approval: Option<PendingToolApproval>,
191    /// Current model in use
192    current_model: ModelId,
193    /// Current primary agent label for status bar
194    current_agent_label: Option<String>,
195    /// Event processing pipeline
196    event_pipeline: EventPipeline,
197    /// Chat data store
198    chat_store: ChatStore,
199    /// Tool call registry
200    tool_registry: ToolCallRegistry,
201    /// Chat viewport for efficient rendering
202    chat_viewport: ChatViewport,
203    /// Session ID
204    session_id: String,
205    /// Current theme
206    theme: Theme,
207    /// Setup state for first-run experience
208    setup_state: Option<SetupState>,
209    /// Track in-flight operations (operation_id -> chat_store_index)
210    in_flight_operations: HashSet<OpId>,
211    /// Queued head item (if any)
212    queued_head: Option<steer_grpc::client_api::QueuedWorkItem>,
213    /// Count of queued items
214    queued_count: usize,
215    /// Command registry for slash commands
216    command_registry: CommandRegistry,
217    /// User preferences
218    preferences: Preferences,
219    /// Centralized notification manager
220    notification_manager: NotificationManagerHandle,
221    /// Double-tap tracker for key sequences
222    double_tap_tracker: crate::tui::state::DoubleTapTracker,
223    /// Vim mode state
224    vim_state: VimState,
225    /// Stack to track previous modes (for returning after fuzzy finder, etc.)
226    mode_stack: VecDeque<InputMode>,
227    /// Last known revision of ChatStore for dirty tracking
228    last_revision: u64,
229    /// Update checker status
230    update_status: UpdateStatus,
231    edit_selection_state: EditSelectionOverlayState,
232}
233
234const MAX_MODE_DEPTH: usize = 8;
235
236impl Tui {
237    /// Push current mode onto stack before switching
238    fn push_mode(&mut self) {
239        if self.mode_stack.len() == MAX_MODE_DEPTH {
240            self.mode_stack.pop_front(); // drop oldest
241        }
242        self.mode_stack.push_back(self.input_mode);
243    }
244
245    /// Pop and restore previous mode
246    fn pop_mode(&mut self) -> Option<InputMode> {
247        self.mode_stack.pop_back()
248    }
249
250    /// Switch to a new mode, automatically managing the mode stack
251    pub fn switch_mode(&mut self, new_mode: InputMode) {
252        if self.input_mode != new_mode {
253            debug!(
254                "Switching mode from {:?} to {:?}",
255                self.input_mode, new_mode
256            );
257            self.push_mode();
258            self.input_mode = new_mode;
259        }
260    }
261
262    /// Switch mode without pushing to stack (for direct transitions like vim normal->insert)
263    pub fn set_mode(&mut self, new_mode: InputMode) {
264        debug!("Setting mode from {:?} to {:?}", self.input_mode, new_mode);
265        self.input_mode = new_mode;
266    }
267
268    /// Restore previous mode from stack (or default if empty)
269    pub fn restore_previous_mode(&mut self) {
270        self.input_mode = self.pop_mode().unwrap_or_else(|| self.default_input_mode());
271    }
272
273    /// Get the default input mode based on editing preferences
274    fn default_input_mode(&self) -> InputMode {
275        match self.preferences.ui.editing_mode {
276            EditingMode::Simple => InputMode::Simple,
277            EditingMode::Vim => InputMode::VimNormal,
278        }
279    }
280
281    /// Check if current mode accepts text input
282    fn is_text_input_mode(&self) -> bool {
283        matches!(
284            self.input_mode,
285            InputMode::Simple
286                | InputMode::VimInsert
287                | InputMode::BashCommand
288                | InputMode::Setup
289                | InputMode::FuzzyFinder
290        )
291    }
292    /// Create a new TUI instance
293    pub async fn new(
294        client: AgentClient,
295        current_model: ModelId,
296
297        session_id: String,
298        theme: Option<Theme>,
299    ) -> Result<Self> {
300        // Set up terminal and ensure cleanup on early error
301        let mut guard = SetupGuard::new();
302
303        let mut stdout = io::stdout();
304        terminal::setup(&mut stdout)?;
305
306        let backend = CrosstermBackend::new(stdout);
307        let terminal = Terminal::new(backend)?;
308        let terminal_size = terminal
309            .size()
310            .map(|s| (s.width, s.height))
311            .unwrap_or((80, 24));
312
313        // Load preferences
314        let preferences = Preferences::load()
315            .map_err(|e| crate::error::Error::Config(e.to_string()))
316            .unwrap_or_default();
317
318        // Determine initial input mode based on editing mode preference
319        let input_mode = match preferences.ui.editing_mode {
320            EditingMode::Simple => InputMode::Simple,
321            EditingMode::Vim => InputMode::VimNormal,
322        };
323
324        let notification_manager = std::sync::Arc::new(NotificationManager::new(&preferences));
325
326        let mut tui = Self {
327            terminal,
328            terminal_size,
329            input_mode,
330            input_panel_state: crate::tui::widgets::input_panel::InputPanelState::new(
331                session_id.clone(),
332            ),
333            editing_message_id: None,
334            client,
335            is_processing: false,
336            progress_message: None,
337            spinner_state: 0,
338            current_tool_approval: None,
339            current_model,
340            current_agent_label: None,
341            event_pipeline: Self::create_event_pipeline(notification_manager.clone()),
342            chat_store: ChatStore::new(),
343            tool_registry: ToolCallRegistry::new(),
344            chat_viewport: ChatViewport::new(),
345            session_id,
346            theme: theme.unwrap_or_default(),
347            setup_state: None,
348            in_flight_operations: HashSet::new(),
349            queued_head: None,
350            queued_count: 0,
351            command_registry: CommandRegistry::new(),
352            preferences,
353            notification_manager,
354            double_tap_tracker: crate::tui::state::DoubleTapTracker::new(),
355            vim_state: VimState::default(),
356            mode_stack: VecDeque::new(),
357            last_revision: 0,
358            update_status: UpdateStatus::Checking,
359            edit_selection_state: EditSelectionOverlayState::default(),
360        };
361
362        tui.refresh_agent_label().await;
363        tui.notification_manager.set_focus_events_enabled(true);
364
365        // Disarm guard; Tui instance will handle cleanup
366        guard.disarm();
367
368        Ok(tui)
369    }
370
371    /// Restore messages to the TUI, properly populating the tool registry
372    fn restore_messages(&mut self, messages: Vec<Message>) {
373        let message_count = messages.len();
374        info!("Starting to restore {} messages to TUI", message_count);
375
376        // Debug: log all Tool messages to check their IDs
377        for message in &messages {
378            if let MessageData::Tool { tool_use_id, .. } = &message.data {
379                debug!(
380                    target: "tui.restore",
381                    "Found Tool message with tool_use_id={}",
382                    tool_use_id
383                );
384            }
385        }
386
387        self.chat_store.ingest_messages(&messages);
388        if let Some(message) = messages.last() {
389            self.chat_store
390                .set_active_message_id(Some(message.id().to_string()));
391        }
392
393        // The rest of the tool registry population code remains the same
394        // Extract tool calls from assistant messages
395        for message in &messages {
396            if let MessageData::Assistant { content, .. } = &message.data {
397                debug!(
398                    target: "tui.restore",
399                    "Processing Assistant message id={}",
400                    message.id()
401                );
402                for block in content {
403                    if let AssistantContent::ToolCall { tool_call, .. } = block {
404                        debug!(
405                            target: "tui.restore",
406                            "Found ToolCall in Assistant message: id={}, name={}, params={}",
407                            tool_call.id, tool_call.name, tool_call.parameters
408                        );
409
410                        // Register the tool call
411                        self.tool_registry.register_call(tool_call.clone());
412                    }
413                }
414            }
415        }
416
417        // Map tool results to their calls
418        for message in &messages {
419            if let MessageData::Tool { tool_use_id, .. } = &message.data {
420                debug!(
421                    target: "tui.restore",
422                    "Updating registry with Tool result for id={}",
423                    tool_use_id
424                );
425                // Tool results are already handled by event processors
426            }
427        }
428
429        debug!(
430            target: "tui.restore",
431            "Tool registry state after restoration: {} calls registered",
432            self.tool_registry.metrics().completed_count
433        );
434        info!("Successfully restored {} messages to TUI", message_count);
435    }
436
437    /// Helper to push a system notice to the chat store
438    fn push_notice(&mut self, level: crate::tui::model::NoticeLevel, text: String) {
439        use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
440        self.chat_store.push(ChatItem {
441            parent_chat_item_id: None,
442            data: ChatItemData::SystemNotice {
443                id: generate_row_id(),
444                level,
445                text,
446                ts: time::OffsetDateTime::now_utc(),
447            },
448        });
449    }
450
451    fn push_tui_response(&mut self, command: String, response: TuiCommandResponse) {
452        use crate::tui::model::{ChatItem, ChatItemData, generate_row_id};
453        self.chat_store.push(ChatItem {
454            parent_chat_item_id: None,
455            data: ChatItemData::TuiCommandResponse {
456                id: generate_row_id(),
457                command,
458                response,
459                ts: time::OffsetDateTime::now_utc(),
460            },
461        });
462    }
463
464    fn format_grpc_error(error: &steer_grpc::GrpcError) -> String {
465        match error {
466            steer_grpc::GrpcError::CallFailed(status) => status.message().to_string(),
467            _ => error.to_string(),
468        }
469    }
470
471    fn format_workspace_status(status: &WorkspaceStatus) -> String {
472        let mut output = String::new();
473        output.push_str(&format!("Workspace: {}\n", status.workspace_id.as_uuid()));
474        output.push_str(&format!(
475            "Environment: {}\n",
476            status.environment_id.as_uuid()
477        ));
478        output.push_str(&format!("Repo: {}\n", status.repo_id.as_uuid()));
479        output.push_str(&format!("Path: {}\n", status.path.display()));
480
481        match &status.vcs {
482            Some(vcs) => {
483                output.push_str(&format!(
484                    "VCS: {} ({})\n\n",
485                    vcs.kind.as_str(),
486                    vcs.root.display()
487                ));
488                output.push_str(&vcs.status.as_llm_string());
489            }
490            None => {
491                output.push_str("VCS: <none>\n");
492            }
493        }
494
495        output
496    }
497
498    async fn refresh_agent_label(&mut self) {
499        match self.client.get_session(&self.session_id).await {
500            Ok(Some(session)) => {
501                if let Some(config) = session.config.as_ref() {
502                    let agent_id = config
503                        .primary_agent_id
504                        .clone()
505                        .unwrap_or_else(|| default_primary_agent_id().to_string());
506                    self.current_agent_label = Some(format_agent_label(&agent_id));
507                }
508            }
509            Ok(None) => {
510                warn!(
511                    target: "tui.session",
512                    "No session data available to populate agent label"
513                );
514            }
515            Err(e) => {
516                warn!(
517                    target: "tui.session",
518                    "Failed to load session config for agent label: {}",
519                    e
520                );
521            }
522        }
523    }
524
525    async fn start_new_session(&mut self) -> Result<()> {
526        use std::collections::HashMap;
527        use steer_grpc::client_api::{
528            CreateSessionParams, SessionPolicyOverrides, SessionToolConfig, WorkspaceConfig,
529        };
530
531        let session_params = CreateSessionParams {
532            workspace: WorkspaceConfig::default(),
533            tool_config: SessionToolConfig::default(),
534            primary_agent_id: None,
535            policy_overrides: SessionPolicyOverrides::empty(),
536            metadata: HashMap::new(),
537            default_model: self.current_model.clone(),
538        };
539
540        let new_session_id = self
541            .client
542            .create_session(session_params)
543            .await
544            .map_err(|e| Error::Generic(format!("Failed to create new session: {e}")))?;
545
546        self.session_id.clone_from(&new_session_id);
547        self.client.subscribe_session_events().await?;
548        self.chat_store = ChatStore::new();
549        self.tool_registry = ToolCallRegistry::new();
550        self.chat_viewport = ChatViewport::new();
551        self.in_flight_operations.clear();
552        self.input_panel_state =
553            crate::tui::widgets::input_panel::InputPanelState::new(new_session_id.clone());
554        self.is_processing = false;
555        self.progress_message = None;
556        self.current_tool_approval = None;
557        self.editing_message_id = None;
558        self.current_agent_label = None;
559        self.refresh_agent_label().await;
560
561        self.load_file_cache().await;
562
563        Ok(())
564    }
565
566    async fn load_file_cache(&mut self) {
567        info!(target: "tui.file_cache", "Requesting workspace files for session {}", self.session_id);
568        match self.client.list_workspace_files().await {
569            Ok(files) => {
570                self.input_panel_state.file_cache.update(files).await;
571            }
572            Err(e) => {
573                warn!(target: "tui.file_cache", "Failed to request workspace files: {}", e);
574            }
575        }
576    }
577
578    pub async fn run(&mut self, event_rx: mpsc::Receiver<ClientEvent>) -> Result<()> {
579        // Log the current state of messages
580        info!(
581            "Starting TUI run with {} messages in view model",
582            self.chat_store.len()
583        );
584
585        // Load the initial file list
586        self.load_file_cache().await;
587
588        // Spawn update checker
589        let (update_tx, update_rx) = mpsc::channel::<UpdateStatus>(1);
590        let current_version = env!("CARGO_PKG_VERSION").to_string();
591        tokio::spawn(async move {
592            let status = update::check_latest("BrendanGraham14", "steer", &current_version).await;
593            let _ = update_tx.send(status).await;
594        });
595
596        let mut term_event_stream = EventStream::new();
597
598        // Run the main event loop
599        self.run_event_loop(event_rx, &mut term_event_stream, update_rx)
600            .await
601    }
602
603    async fn run_event_loop(
604        &mut self,
605        mut event_rx: mpsc::Receiver<ClientEvent>,
606        term_event_stream: &mut EventStream,
607        mut update_rx: mpsc::Receiver<UpdateStatus>,
608    ) -> Result<()> {
609        let mut should_exit = false;
610        let mut needs_redraw = true; // Force initial draw
611        let mut last_spinner_char = String::new();
612        let mut update_rx_closed = false;
613        let mut pending_scroll: Option<PendingScroll> = None;
614
615        // Create a tick interval for spinner updates
616        let mut tick = tokio::time::interval(SPINNER_UPDATE_INTERVAL);
617        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
618        let mut scroll_flush = tokio::time::interval(SCROLL_FLUSH_INTERVAL);
619        scroll_flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
620
621        while !should_exit {
622            // Determine if we need to redraw
623            if needs_redraw {
624                self.draw()?;
625                needs_redraw = false;
626            }
627
628            tokio::select! {
629                status = update_rx.recv(), if !update_rx_closed => {
630                    match status {
631                        Some(status) => {
632                            self.update_status = status;
633                            needs_redraw = true;
634                        }
635                        None => {
636                            // Channel closed; stop polling this branch to avoid busy looping
637                            update_rx_closed = true;
638                        }
639                    }
640                }
641                event_res = term_event_stream.next() => {
642                    match event_res {
643                        Some(Ok(evt)) => {
644                            let (event_needs_redraw, event_should_exit) = self
645                                .handle_terminal_event(
646                                    evt,
647                                    term_event_stream,
648                                    &mut pending_scroll,
649                                    &mut scroll_flush,
650                                )
651                                .await?;
652                            if event_needs_redraw {
653                                needs_redraw = true;
654                            }
655                            if event_should_exit {
656                                should_exit = true;
657                            }
658                        }
659                        Some(Err(e)) => {
660                            if e.kind() == io::ErrorKind::Interrupted {
661                                debug!(target: "tui.input", "Ignoring interrupted syscall");
662                            } else {
663                                error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
664                                should_exit = true;
665                            }
666                        }
667                        None => {
668                            // Input stream ended, request exit
669                            should_exit = true;
670                        }
671                    }
672                }
673                client_event_opt = event_rx.recv() => {
674                    match client_event_opt {
675                        Some(client_event) => {
676                            self.handle_client_event(client_event).await;
677                            needs_redraw = true;
678                        }
679                        None => {
680                            should_exit = true;
681                        }
682                    }
683                }
684                _ = tick.tick() => {
685                    // Check if we should animate the spinner
686                    let has_pending_tools = !self.tool_registry.pending_calls().is_empty()
687                        || !self.tool_registry.active_calls().is_empty()
688                        || self.chat_store.has_pending_tools();
689                    let has_in_flight_operations = !self.in_flight_operations.is_empty();
690
691                    if self.is_processing || has_pending_tools || has_in_flight_operations {
692                        self.spinner_state = self.spinner_state.wrapping_add(1);
693                        let ch = get_spinner_char(self.spinner_state);
694                        if ch != last_spinner_char {
695                            last_spinner_char = ch.to_string();
696                            needs_redraw = true;
697                        }
698                    }
699
700                    if self.input_mode == InputMode::Setup
701                        && crate::tui::handlers::setup::SetupHandler::poll_oauth_callback(self)
702                            .await?
703                        {
704                            needs_redraw = true;
705                        }
706                }
707                _ = scroll_flush.tick(), if pending_scroll.is_some() => {
708                    if let Some(pending) = pending_scroll.take()
709                        && self.apply_scroll_steps(pending.direction, pending.steps) {
710                            needs_redraw = true;
711                        }
712                }
713            }
714        }
715
716        Ok(())
717    }
718
719    async fn handle_terminal_event(
720        &mut self,
721        event: Event,
722        term_event_stream: &mut EventStream,
723        pending_scroll: &mut Option<PendingScroll>,
724        scroll_flush: &mut tokio::time::Interval,
725    ) -> Result<(bool, bool)> {
726        let mut needs_redraw = false;
727        let mut should_exit = false;
728        let mut pending_events = VecDeque::new();
729        pending_events.push_back(event);
730
731        while let Some(event) = pending_events.pop_front() {
732            match event {
733                Event::FocusGained => {
734                    self.notification_manager.set_terminal_focused(true);
735                }
736                Event::FocusLost => {
737                    self.notification_manager.set_terminal_focused(false);
738                }
739                Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
740                    match self.handle_key_event(key_event).await {
741                        Ok(exit) => {
742                            if exit {
743                                should_exit = true;
744                            }
745                        }
746                        Err(e) => {
747                            // Display error as a system notice
748                            use crate::tui::model::{
749                                ChatItem, ChatItemData, NoticeLevel, generate_row_id,
750                            };
751                            self.chat_store.push(ChatItem {
752                                parent_chat_item_id: None,
753                                data: ChatItemData::SystemNotice {
754                                    id: generate_row_id(),
755                                    level: NoticeLevel::Error,
756                                    text: e.to_string(),
757                                    ts: time::OffsetDateTime::now_utc(),
758                                },
759                            });
760                        }
761                    }
762                    needs_redraw = true;
763                }
764                Event::Mouse(mouse_event) => {
765                    let (scroll_pending, mouse_needs_redraw, mouse_exit, deferred_event) =
766                        self.handle_mouse_event_coalesced(mouse_event, term_event_stream)?;
767                    if let Some(scroll) = scroll_pending {
768                        let pending_was_empty = pending_scroll.is_none();
769                        match pending_scroll {
770                            Some(pending) if pending.direction == scroll.direction => {
771                                pending.steps = pending.steps.saturating_add(scroll.steps);
772                            }
773                            _ => {
774                                *pending_scroll = Some(scroll);
775                            }
776                        }
777                        if pending_was_empty {
778                            scroll_flush.reset_after(SCROLL_FLUSH_INTERVAL);
779                        }
780                    }
781                    needs_redraw |= mouse_needs_redraw;
782                    should_exit |= mouse_exit;
783                    if let Some(deferred_event) = deferred_event {
784                        pending_events.push_front(deferred_event);
785                    }
786                }
787                Event::Resize(width, height) => {
788                    self.terminal_size = (width, height);
789                    // Terminal was resized, force redraw
790                    needs_redraw = true;
791                }
792                Event::Paste(data) => {
793                    // Handle paste in modes that accept text input
794                    if self.is_text_input_mode() {
795                        if self.input_mode == InputMode::Setup {
796                            // Handle paste in setup mode
797                            if let Some(setup_state) = &mut self.setup_state {
798                                if let crate::tui::state::SetupStep::Authentication(_) =
799                                    &setup_state.current_step
800                                {
801                                    setup_state.auth_input.push_str(&data);
802                                    debug!(
803                                        target:"tui.run",
804                                        "Pasted {} chars in Setup mode",
805                                        data.len()
806                                    );
807                                    needs_redraw = true;
808                                } else {
809                                    // Other setup steps don't accept paste
810                                }
811                            }
812                        } else {
813                            let normalized_data = data.replace("\r\n", "\n").replace('\r', "\n");
814                            self.input_panel_state.insert_str(&normalized_data);
815                            debug!(
816                                target:"tui.run",
817                                "Pasted {} chars in {:?} mode",
818                                normalized_data.len(),
819                                self.input_mode
820                            );
821                            needs_redraw = true;
822                        }
823                    }
824                }
825                Event::Key(_) => {}
826            }
827
828            if should_exit {
829                break;
830            }
831        }
832
833        Ok((needs_redraw, should_exit))
834    }
835
836    fn handle_mouse_event_coalesced(
837        &mut self,
838        mouse_event: MouseEvent,
839        term_event_stream: &mut EventStream,
840    ) -> Result<(Option<PendingScroll>, bool, bool, Option<Event>)> {
841        let Some(mut last_direction) = ScrollDirection::from_mouse_event(&mouse_event) else {
842            let needs_redraw = self.handle_mouse_event(mouse_event)?;
843            return Ok((None, needs_redraw, false, None));
844        };
845
846        let mut steps = 1usize;
847        let mut deferred_event = None;
848        let mut should_exit = false;
849
850        loop {
851            let next_event = term_event_stream.next().now_or_never();
852            let Some(next_event) = next_event else {
853                break;
854            };
855
856            match next_event {
857                Some(Ok(Event::Mouse(next_mouse))) => {
858                    if let Some(next_direction) = ScrollDirection::from_mouse_event(&next_mouse) {
859                        if next_direction == last_direction {
860                            steps = steps.saturating_add(1);
861                        } else {
862                            last_direction = next_direction;
863                            steps = 1;
864                        }
865                        continue;
866                    }
867                    deferred_event = Some(Event::Mouse(next_mouse));
868                    break;
869                }
870                Some(Ok(other_event)) => {
871                    deferred_event = Some(other_event);
872                    break;
873                }
874                Some(Err(e)) => {
875                    if e.kind() == io::ErrorKind::Interrupted {
876                        debug!(target: "tui.input", "Ignoring interrupted syscall");
877                    } else {
878                        error!(target: "tui.run", "Fatal input error: {}. Exiting.", e);
879                        should_exit = true;
880                    }
881                    break;
882                }
883                None => {
884                    should_exit = true;
885                    break;
886                }
887            }
888        }
889
890        Ok((
891            Some(PendingScroll {
892                direction: last_direction,
893                steps,
894            }),
895            false,
896            should_exit,
897            deferred_event,
898        ))
899    }
900
901    /// Handle mouse events
902    fn handle_mouse_event(&mut self, event: MouseEvent) -> Result<bool> {
903        let needs_redraw = match ScrollDirection::from_mouse_event(&event) {
904            Some(direction) => self.apply_scroll_steps(direction, 1),
905            None => false,
906        };
907
908        Ok(needs_redraw)
909    }
910
911    fn apply_scroll_steps(&mut self, direction: ScrollDirection, steps: usize) -> bool {
912        // In vim normal mode or simple mode (when not typing), allow scrolling
913        if !self.is_text_input_mode()
914            || (self.input_mode == InputMode::Simple && self.input_panel_state.content().is_empty())
915        {
916            let amount = steps.saturating_mul(MOUSE_SCROLL_STEP);
917            match direction {
918                ScrollDirection::Up => self.chat_viewport.state_mut().scroll_up(amount),
919                ScrollDirection::Down => self.chat_viewport.state_mut().scroll_down(amount),
920            }
921        } else {
922            false
923        }
924    }
925
926    /// Draw the UI
927    fn draw(&mut self) -> Result<()> {
928        let editing_message_id = self.editing_message_id.clone();
929        let is_editing = editing_message_id.is_some();
930        let editing_preview = if is_editing {
931            self.editing_preview()
932        } else {
933            None
934        };
935
936        self.terminal.draw(|f| {
937            // Check if we're in setup mode
938            if let Some(setup_state) = &self.setup_state {
939                use crate::tui::widgets::setup::{
940                    authentication::AuthenticationWidget, completion::CompletionWidget,
941                    provider_selection::ProviderSelectionWidget, welcome::WelcomeWidget,
942                };
943
944                match &setup_state.current_step {
945                    crate::tui::state::SetupStep::Welcome => {
946                        WelcomeWidget::render(f.area(), f.buffer_mut(), &self.theme);
947                    }
948                    crate::tui::state::SetupStep::ProviderSelection => {
949                        ProviderSelectionWidget::render(
950                            f.area(),
951                            f.buffer_mut(),
952                            setup_state,
953                            &self.theme,
954                        );
955                    }
956                    crate::tui::state::SetupStep::Authentication(provider_id) => {
957                        AuthenticationWidget::render(
958                            f.area(),
959                            f.buffer_mut(),
960                            setup_state,
961                            provider_id.clone(),
962                            &self.theme,
963                        );
964                    }
965                    crate::tui::state::SetupStep::Completion => {
966                        CompletionWidget::render(
967                            f.area(),
968                            f.buffer_mut(),
969                            setup_state,
970                            &self.theme,
971                        );
972                    }
973                }
974                return;
975            }
976
977            let input_mode = self.input_mode;
978            let is_processing = self.is_processing;
979            let spinner_state = self.spinner_state;
980            let current_tool_call = self.current_tool_approval.as_ref().map(|(_, tc)| tc);
981            let current_model_owned = self.current_model.clone();
982
983            // Check if ChatStore has changed and trigger rebuild if needed
984            let current_revision = self.chat_store.revision();
985            if current_revision != self.last_revision {
986                self.chat_viewport.mark_dirty();
987                self.last_revision = current_revision;
988            }
989
990            // Get chat items from the chat store
991            let chat_items: Vec<&ChatItem> = self.chat_store.as_items();
992
993            let terminal_size = f.area();
994
995            let queue_preview = self.queued_head.as_ref().map(|item| item.content.as_str());
996            let input_area_height = self.input_panel_state.required_height(
997                current_tool_call,
998                terminal_size.width,
999                terminal_size.height,
1000                queue_preview,
1001            );
1002
1003            let layout = UiLayout::compute(terminal_size, input_area_height, &self.theme);
1004            layout.prepare_background(f, &self.theme);
1005
1006            self.chat_viewport.rebuild(
1007                &chat_items,
1008                layout.chat.width,
1009                self.chat_viewport.state().view_mode,
1010                &self.theme,
1011                &self.chat_store,
1012                editing_message_id.as_deref(),
1013            );
1014
1015            self.chat_viewport
1016                .render(f, layout.chat, spinner_state, None, &self.theme);
1017
1018            let input_panel = InputPanel::new(InputPanelParams {
1019                input_mode,
1020                current_approval: current_tool_call,
1021                is_processing,
1022                spinner_state,
1023                is_editing,
1024                editing_preview: editing_preview.as_deref(),
1025                queued_count: self.queued_count,
1026                queued_preview: queue_preview,
1027                theme: &self.theme,
1028            });
1029            f.render_stateful_widget(input_panel, layout.input, &mut self.input_panel_state);
1030
1031            let update_badge = match &self.update_status {
1032                UpdateStatus::Available(info) => {
1033                    crate::tui::widgets::status_bar::UpdateBadge::Available {
1034                        latest: &info.latest,
1035                    }
1036                }
1037                _ => crate::tui::widgets::status_bar::UpdateBadge::None,
1038            };
1039            layout.render_status_bar(
1040                f,
1041                &current_model_owned,
1042                self.current_agent_label.as_deref(),
1043                &self.theme,
1044                update_badge,
1045            );
1046
1047            // Get fuzzy finder results before the render call
1048            let fuzzy_finder_data = if input_mode == InputMode::FuzzyFinder {
1049                let results = self.input_panel_state.fuzzy_finder.results().to_vec();
1050                let selected = self.input_panel_state.fuzzy_finder.selected_index();
1051                let input_height = self.input_panel_state.required_height(
1052                    current_tool_call,
1053                    terminal_size.width,
1054                    10,
1055                    queue_preview,
1056                );
1057                let mode = self.input_panel_state.fuzzy_finder.mode();
1058                Some((results, selected, input_height, mode))
1059            } else {
1060                None
1061            };
1062
1063            // Render fuzzy finder overlay when active
1064            if let Some((results, selected_index, input_height, mode)) = fuzzy_finder_data {
1065                Self::render_fuzzy_finder_overlay_static(
1066                    f,
1067                    &results,
1068                    selected_index,
1069                    input_height,
1070                    mode,
1071                    &self.theme,
1072                    &self.command_registry,
1073                );
1074            }
1075
1076            if input_mode == InputMode::EditMessageSelection {
1077                use crate::tui::widgets::EditSelectionOverlay;
1078                let overlay = EditSelectionOverlay::new(&self.theme);
1079                f.render_stateful_widget(overlay, terminal_size, &mut self.edit_selection_state);
1080            }
1081        })?;
1082        Ok(())
1083    }
1084
1085    /// Render fuzzy finder overlay above the input panel
1086    fn render_fuzzy_finder_overlay_static(
1087        f: &mut Frame,
1088        results: &[crate::tui::widgets::fuzzy_finder::PickerItem],
1089        selected_index: usize,
1090        input_panel_height: u16,
1091        mode: crate::tui::widgets::fuzzy_finder::FuzzyFinderMode,
1092        theme: &Theme,
1093        command_registry: &CommandRegistry,
1094    ) {
1095        use ratatui::layout::Rect;
1096        use ratatui::style::Style;
1097        use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
1098
1099        // imports already handled above
1100
1101        if results.is_empty() {
1102            return; // Nothing to show
1103        }
1104
1105        // Get the terminal area and calculate input panel position
1106        let total_area = f.area();
1107
1108        // Calculate where the input panel would be
1109        let input_panel_y = total_area.height.saturating_sub(input_panel_height + 1); // +1 for status bar
1110
1111        // Calculate overlay height (max 10 results)
1112        let overlay_height = results.len().min(10) as u16 + 2; // +2 for borders
1113
1114        // Position overlay just above the input panel
1115        let overlay_y = input_panel_y.saturating_sub(overlay_height);
1116        let overlay_area = Rect {
1117            x: total_area.x,
1118            y: overlay_y,
1119            width: total_area.width,
1120            height: overlay_height,
1121        };
1122
1123        // Clear the area first
1124        f.render_widget(Clear, overlay_area);
1125
1126        // Create list items with selection highlighting
1127        // Reverse the order so best match (index 0) is at the bottom
1128        let items: Vec<ListItem> = match mode {
1129            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => results
1130                .iter()
1131                .enumerate()
1132                .rev()
1133                .map(|(i, item)| {
1134                    let is_selected = selected_index == i;
1135                    let style = if is_selected {
1136                        theme.style(theme::Component::PopupSelection)
1137                    } else {
1138                        Style::default()
1139                    };
1140                    ListItem::new(item.label.as_str()).style(style)
1141                })
1142                .collect(),
1143            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => {
1144                results
1145                    .iter()
1146                    .enumerate()
1147                    .rev()
1148                    .map(|(i, item)| {
1149                        let is_selected = selected_index == i;
1150                        let style = if is_selected {
1151                            theme.style(theme::Component::PopupSelection)
1152                        } else {
1153                            Style::default()
1154                        };
1155
1156                        // Get command info to include description
1157                        let label = &item.label;
1158                        if let Some(cmd_info) = command_registry.get(label.as_str()) {
1159                            let line = format!("/{:<12} {}", cmd_info.name, cmd_info.description);
1160                            ListItem::new(line).style(style)
1161                        } else {
1162                            ListItem::new(format!("/{label}")).style(style)
1163                        }
1164                    })
1165                    .collect()
1166            }
1167            crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models
1168            | crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => results
1169                .iter()
1170                .enumerate()
1171                .rev()
1172                .map(|(i, item)| {
1173                    let is_selected = selected_index == i;
1174                    let style = if is_selected {
1175                        theme.style(theme::Component::PopupSelection)
1176                    } else {
1177                        Style::default()
1178                    };
1179                    ListItem::new(item.label.as_str()).style(style)
1180                })
1181                .collect(),
1182        };
1183
1184        // Create the list widget
1185        let list_block = Block::default()
1186            .borders(Borders::ALL)
1187            .border_style(theme.style(theme::Component::PopupBorder))
1188            .title(match mode {
1189                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Files => " Files ",
1190                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Commands => " Commands ",
1191                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Models => " Select Model ",
1192                crate::tui::widgets::fuzzy_finder::FuzzyFinderMode::Themes => " Select Theme ",
1193            });
1194
1195        let list = List::new(items)
1196            .block(list_block)
1197            .highlight_style(theme.style(theme::Component::PopupSelection));
1198
1199        // Create list state with reversed selection
1200        let mut list_state = ListState::default();
1201        let reversed_selection = results
1202            .len()
1203            .saturating_sub(1)
1204            .saturating_sub(selected_index);
1205        list_state.select(Some(reversed_selection));
1206
1207        f.render_stateful_widget(list, overlay_area, &mut list_state);
1208    }
1209
1210    /// Create the event processing pipeline
1211    fn create_event_pipeline(notification_manager: NotificationManagerHandle) -> EventPipeline {
1212        EventPipeline::new()
1213            .add_processor(Box::new(ProcessingStateProcessor::new(
1214                notification_manager.clone(),
1215            )))
1216            .add_processor(Box::new(MessageEventProcessor::new()))
1217            .add_processor(Box::new(
1218                crate::tui::events::processors::queue::QueueEventProcessor::new(),
1219            ))
1220            .add_processor(Box::new(ToolEventProcessor::new(
1221                notification_manager.clone(),
1222            )))
1223            .add_processor(Box::new(SystemEventProcessor::new(notification_manager)))
1224    }
1225
1226    fn preprocess_client_event_double_tap(
1227        event: &ClientEvent,
1228        double_tap_tracker: &mut crate::tui::state::DoubleTapTracker,
1229    ) {
1230        if matches!(
1231            event,
1232            ClientEvent::OperationCancelled {
1233                popped_queued_item: Some(_),
1234                ..
1235            }
1236        ) {
1237            // Cancelling with queued work restores that draft into input; clear ESC
1238            // tap state so the second keypress doesn't immediately wipe it.
1239            double_tap_tracker.clear_key(&KeyCode::Esc);
1240        }
1241    }
1242
1243    async fn handle_client_event(&mut self, event: ClientEvent) {
1244        Self::preprocess_client_event_double_tap(&event, &mut self.double_tap_tracker);
1245        let mut messages_updated = false;
1246
1247        match &event {
1248            ClientEvent::WorkspaceChanged => {
1249                self.load_file_cache().await;
1250            }
1251            ClientEvent::WorkspaceFiles { files } => {
1252                info!(target: "tui.handle_client_event", "Received workspace files event with {} files", files.len());
1253                self.input_panel_state
1254                    .file_cache
1255                    .update(files.clone())
1256                    .await;
1257            }
1258            _ => {}
1259        }
1260
1261        let mut ctx = crate::tui::events::processor::ProcessingContext {
1262            chat_store: &mut self.chat_store,
1263            chat_list_state: self.chat_viewport.state_mut(),
1264            tool_registry: &mut self.tool_registry,
1265            client: &self.client,
1266            notification_manager: &self.notification_manager,
1267            input_panel_state: &mut self.input_panel_state,
1268            is_processing: &mut self.is_processing,
1269            progress_message: &mut self.progress_message,
1270            spinner_state: &mut self.spinner_state,
1271            current_tool_approval: &mut self.current_tool_approval,
1272            current_model: &mut self.current_model,
1273            current_agent_label: &mut self.current_agent_label,
1274            messages_updated: &mut messages_updated,
1275            in_flight_operations: &mut self.in_flight_operations,
1276            queued_head: &mut self.queued_head,
1277            queued_count: &mut self.queued_count,
1278        };
1279
1280        if let Err(e) = self.event_pipeline.process_event(event, &mut ctx).await {
1281            tracing::error!(target: "tui.handle_client_event", "Event processing failed: {}", e);
1282        }
1283
1284        if self.current_tool_approval.is_some() && self.input_mode != InputMode::AwaitingApproval {
1285            self.switch_mode(InputMode::AwaitingApproval);
1286        } else if self.current_tool_approval.is_none()
1287            && self.input_mode == InputMode::AwaitingApproval
1288        {
1289            self.restore_previous_mode();
1290        }
1291
1292        if messages_updated {
1293            self.chat_viewport.mark_dirty();
1294            if self.chat_viewport.state_mut().is_at_bottom() {
1295                self.chat_viewport.state_mut().scroll_to_bottom();
1296            }
1297        }
1298    }
1299
1300    async fn send_message(&mut self, content: String) -> Result<()> {
1301        if content.starts_with('/') {
1302            return self.handle_slash_command(content).await;
1303        }
1304
1305        if let Some(message_id_to_edit) = self.editing_message_id.take() {
1306            self.chat_viewport.mark_dirty();
1307            if content.starts_with('!') && content.len() > 1 {
1308                let command = content[1..].trim().to_string();
1309                if let Err(e) = self.client.execute_bash_command(command).await {
1310                    self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1311                }
1312            } else if let Err(e) = self
1313                .client
1314                .edit_message(message_id_to_edit, content, self.current_model.clone())
1315                .await
1316            {
1317                self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1318            }
1319            return Ok(());
1320        }
1321        if let Err(e) = self
1322            .client
1323            .send_message(content, self.current_model.clone())
1324            .await
1325        {
1326            self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1327        }
1328        Ok(())
1329    }
1330
1331    async fn handle_slash_command(&mut self, command_input: String) -> Result<()> {
1332        use crate::tui::commands::{AppCommand as TuiAppCommand, TuiCommand, TuiCommandType};
1333        use crate::tui::model::NoticeLevel;
1334
1335        // First check if it's a custom command in the registry
1336        let cmd_name = command_input
1337            .trim()
1338            .strip_prefix('/')
1339            .unwrap_or(command_input.trim());
1340
1341        if let Some(cmd_info) = self.command_registry.get(cmd_name)
1342            && let crate::tui::commands::registry::CommandScope::Custom(custom_cmd) =
1343                &cmd_info.scope
1344        {
1345            // Create a TuiCommand::Custom and process it
1346            let app_cmd = TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd.clone()));
1347            // Process through the normal flow
1348            match app_cmd {
1349                TuiAppCommand::Tui(TuiCommand::Custom(custom_cmd)) => {
1350                    // Handle custom command based on its type
1351                    match custom_cmd {
1352                        crate::tui::custom_commands::CustomCommand::Prompt { prompt, .. } => {
1353                            self.client
1354                                .send_message(prompt, self.current_model.clone())
1355                                .await?;
1356                        }
1357                    }
1358                }
1359                _ => unreachable!(),
1360            }
1361            return Ok(());
1362        }
1363
1364        // Otherwise try to parse as built-in command
1365        let app_cmd = match TuiAppCommand::parse(&command_input) {
1366            Ok(cmd) => cmd,
1367            Err(e) => {
1368                // Add error notice to chat
1369                self.push_notice(NoticeLevel::Error, e.to_string());
1370                return Ok(());
1371            }
1372        };
1373
1374        // Handle the command based on its type
1375        match app_cmd {
1376            TuiAppCommand::Tui(tui_cmd) => {
1377                // Handle TUI-specific commands
1378                match tui_cmd {
1379                    TuiCommand::ReloadFiles => {
1380                        self.input_panel_state.file_cache.clear().await;
1381                        info!(target: "tui.slash_command", "Cleared file cache, will reload on next access");
1382                        self.load_file_cache().await;
1383                        self.push_tui_response(
1384                            TuiCommandType::ReloadFiles.command_name(),
1385                            TuiCommandResponse::Text(
1386                                "File cache cleared. Files will be reloaded on next access."
1387                                    .to_string(),
1388                            ),
1389                        );
1390                    }
1391                    TuiCommand::Theme(theme_name) => {
1392                        if let Some(name) = theme_name {
1393                            // Load the specified theme
1394                            let loader = theme::ThemeLoader::new();
1395                            match loader.load_theme(&name) {
1396                                Ok(new_theme) => {
1397                                    self.theme = new_theme;
1398                                    self.push_tui_response(
1399                                        TuiCommandType::Theme.command_name(),
1400                                        TuiCommandResponse::Theme { name: name.clone() },
1401                                    );
1402                                }
1403                                Err(e) => {
1404                                    self.push_notice(
1405                                        NoticeLevel::Error,
1406                                        format!("Failed to load theme '{name}': {e}"),
1407                                    );
1408                                }
1409                            }
1410                        } else {
1411                            // List available themes
1412                            let loader = theme::ThemeLoader::new();
1413                            let themes = loader.list_themes();
1414                            self.push_tui_response(
1415                                TuiCommandType::Theme.command_name(),
1416                                TuiCommandResponse::ListThemes(themes),
1417                            );
1418                        }
1419                    }
1420                    TuiCommand::Help(command_name) => {
1421                        // Build and show help text
1422                        let help_text = if let Some(cmd_name) = command_name {
1423                            // Show help for specific command
1424                            if let Some(cmd_info) = self.command_registry.get(&cmd_name) {
1425                                format!(
1426                                    "Command: {}\n\nDescription: {}\n\nUsage: {}",
1427                                    cmd_info.name, cmd_info.description, cmd_info.usage
1428                                )
1429                            } else {
1430                                format!("Unknown command: {cmd_name}")
1431                            }
1432                        } else {
1433                            // Show general help with all commands
1434                            let mut help_lines = vec!["Available commands:".to_string()];
1435                            for cmd_info in self.command_registry.all_commands() {
1436                                help_lines.push(format!(
1437                                    "  {:<20} - {}",
1438                                    cmd_info.usage, cmd_info.description
1439                                ));
1440                            }
1441                            help_lines.join("\n")
1442                        };
1443
1444                        self.push_tui_response(
1445                            TuiCommandType::Help.command_name(),
1446                            TuiCommandResponse::Text(help_text),
1447                        );
1448                    }
1449                    TuiCommand::Auth => {
1450                        // Launch auth setup
1451                        // Initialize auth setup state
1452                        // Fetch providers and their auth status from server
1453                        let providers = self.client.list_providers().await.map_err(|e| {
1454                            crate::error::Error::Generic(format!(
1455                                "Failed to list providers from server: {e}"
1456                            ))
1457                        })?;
1458                        let statuses =
1459                            self.client
1460                                .get_provider_auth_status(None)
1461                                .await
1462                                .map_err(|e| {
1463                                    crate::error::Error::Generic(format!(
1464                                        "Failed to get provider auth status: {e}"
1465                                    ))
1466                                })?;
1467
1468                        // Build provider registry view from remote providers
1469                        let mut provider_status = std::collections::HashMap::new();
1470
1471                        let mut status_map = std::collections::HashMap::new();
1472                        for s in statuses {
1473                            status_map.insert(s.provider_id.clone(), s.auth_source);
1474                        }
1475
1476                        // Convert remote providers into a minimal registry-like view for TUI
1477                        let registry =
1478                            std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
1479
1480                        for p in registry.all() {
1481                            let status = auth_status_from_source(
1482                                status_map.get(&p.id).and_then(|s| s.as_ref()),
1483                            );
1484                            provider_status.insert(ProviderId(p.id.clone()), status);
1485                        }
1486
1487                        // Enter setup mode, skipping welcome page
1488                        self.setup_state =
1489                            Some(crate::tui::state::SetupState::new_for_auth_command(
1490                                registry,
1491                                provider_status,
1492                            ));
1493                        // Enter setup mode directly without pushing to the mode stack so that
1494                        // it can’t be accidentally popped by a later `restore_previous_mode`.
1495                        self.set_mode(InputMode::Setup);
1496                        // Clear the mode stack to avoid returning to a pre-setup mode.
1497                        self.mode_stack.clear();
1498
1499                        self.push_tui_response(
1500                            TuiCommandType::Auth.to_string(),
1501                            TuiCommandResponse::Text(
1502                                "Entering authentication setup mode...".to_string(),
1503                            ),
1504                        );
1505                    }
1506                    TuiCommand::EditingMode(ref mode_name) => {
1507                        let response = match mode_name.as_deref() {
1508                            None => {
1509                                // Show current mode
1510                                let mode_str = self.preferences.ui.editing_mode.to_string();
1511                                format!("Current editing mode: {mode_str}")
1512                            }
1513                            Some("simple") => {
1514                                self.preferences.ui.editing_mode = EditingMode::Simple;
1515                                self.set_mode(InputMode::Simple);
1516                                self.preferences
1517                                    .save()
1518                                    .map_err(|e| crate::error::Error::Config(e.to_string()))?;
1519                                "Switched to Simple mode".to_string()
1520                            }
1521                            Some("vim") => {
1522                                self.preferences.ui.editing_mode = EditingMode::Vim;
1523                                self.set_mode(InputMode::VimNormal);
1524                                self.preferences
1525                                    .save()
1526                                    .map_err(|e| crate::error::Error::Config(e.to_string()))?;
1527                                "Switched to Vim mode (Normal)".to_string()
1528                            }
1529                            Some(mode) => {
1530                                format!("Unknown mode: '{mode}'. Use 'simple' or 'vim'")
1531                            }
1532                        };
1533
1534                        self.push_tui_response(
1535                            tui_cmd.as_command_str(),
1536                            TuiCommandResponse::Text(response),
1537                        );
1538                    }
1539                    TuiCommand::Mcp => {
1540                        let servers = self.client.get_mcp_servers().await?;
1541                        self.push_tui_response(
1542                            tui_cmd.as_command_str(),
1543                            TuiCommandResponse::ListMcpServers(servers),
1544                        );
1545                    }
1546                    TuiCommand::Workspace(ref workspace_id) => {
1547                        let target_id = if let Some(workspace_id) = workspace_id.clone() {
1548                            Some(workspace_id)
1549                        } else {
1550                            let session = if let Some(session) =
1551                                self.client.get_session(&self.session_id).await?
1552                            {
1553                                session
1554                            } else {
1555                                self.push_notice(
1556                                    NoticeLevel::Error,
1557                                    "Session not found for workspace status".to_string(),
1558                                );
1559                                return Ok(());
1560                            };
1561                            let config = if let Some(config) = session.config {
1562                                config
1563                            } else {
1564                                self.push_notice(
1565                                    NoticeLevel::Error,
1566                                    "Session config missing for workspace status".to_string(),
1567                                );
1568                                return Ok(());
1569                            };
1570                            config.workspace_id.or_else(|| {
1571                                config.workspace_ref.map(|reference| reference.workspace_id)
1572                            })
1573                        };
1574
1575                        let target_id = match target_id {
1576                            Some(id) if !id.is_empty() => id,
1577                            _ => {
1578                                self.push_notice(
1579                                    NoticeLevel::Error,
1580                                    "Workspace id not available for current session".to_string(),
1581                                );
1582                                return Ok(());
1583                            }
1584                        };
1585
1586                        match self.client.get_workspace_status(&target_id).await {
1587                            Ok(status) => {
1588                                let response = Self::format_workspace_status(&status);
1589                                self.push_tui_response(
1590                                    tui_cmd.as_command_str(),
1591                                    TuiCommandResponse::Text(response),
1592                                );
1593                            }
1594                            Err(e) => {
1595                                self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1596                            }
1597                        }
1598                    }
1599                    TuiCommand::Custom(custom_cmd) => match custom_cmd {
1600                        crate::tui::custom_commands::CustomCommand::Prompt { prompt, .. } => {
1601                            self.client
1602                                .send_message(prompt, self.current_model.clone())
1603                                .await?;
1604                        }
1605                    },
1606                    TuiCommand::New => {
1607                        self.start_new_session().await?;
1608                    }
1609                }
1610            }
1611            TuiAppCommand::Core(core_cmd) => match core_cmd {
1612                crate::tui::core_commands::CoreCommandType::Compact => {
1613                    if let Err(e) = self
1614                        .client
1615                        .compact_session(self.current_model.clone())
1616                        .await
1617                    {
1618                        self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1619                    }
1620                }
1621                crate::tui::core_commands::CoreCommandType::Agent { target } => {
1622                    if let Some(agent_id) = target {
1623                        if let Err(e) = self.client.switch_primary_agent(agent_id.clone()).await {
1624                            self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1625                        }
1626                    } else {
1627                        self.push_notice(NoticeLevel::Error, "Usage: /agent <mode>".to_string());
1628                    }
1629                }
1630                crate::tui::core_commands::CoreCommandType::Model { target } => {
1631                    if let Some(model_name) = target {
1632                        match self.client.resolve_model(&model_name).await {
1633                            Ok(model_id) => {
1634                                self.current_model = model_id;
1635                            }
1636                            Err(e) => {
1637                                self.push_notice(NoticeLevel::Error, Self::format_grpc_error(&e));
1638                            }
1639                        }
1640                    }
1641                }
1642            },
1643        }
1644
1645        Ok(())
1646    }
1647
1648    /// Enter edit mode for a specific message
1649    fn enter_edit_mode(&mut self, message_id: &str) {
1650        // Find the message in the store
1651        if let Some(item) = self.chat_store.get_by_id(&message_id.to_string())
1652            && let crate::tui::model::ChatItemData::Message(message) = &item.data
1653            && let MessageData::User { content, .. } = &message.data
1654        {
1655            // Extract text content from user blocks
1656            let text = content
1657                .iter()
1658                .filter_map(|block| match block {
1659                    UserContent::Text { text } => Some(text.as_str()),
1660                    UserContent::CommandExecution { .. } => None,
1661                })
1662                .collect::<Vec<_>>()
1663                .join("\n");
1664
1665            // Set up textarea with the message content
1666            self.input_panel_state
1667                .set_content_from_lines(text.lines().collect::<Vec<_>>());
1668            // Switch to appropriate mode based on editing preference
1669            self.input_mode = match self.preferences.ui.editing_mode {
1670                EditingMode::Simple => InputMode::Simple,
1671                EditingMode::Vim => InputMode::VimInsert,
1672            };
1673
1674            // Store the message ID we're editing
1675            self.editing_message_id = Some(message_id.to_string());
1676            self.chat_viewport.mark_dirty();
1677        }
1678    }
1679
1680    fn cancel_edit_mode(&mut self) {
1681        if self.editing_message_id.is_some() {
1682            self.editing_message_id = None;
1683            self.chat_viewport.mark_dirty();
1684        }
1685    }
1686
1687    fn editing_preview(&self) -> Option<String> {
1688        const EDIT_PREVIEW_MAX_LEN: usize = 40;
1689
1690        let message_id = self.editing_message_id.as_ref()?;
1691        let item = self.chat_store.get_by_id(message_id)?;
1692        let crate::tui::model::ChatItemData::Message(message) = &item.data else {
1693            return None;
1694        };
1695
1696        let content = message.content_string();
1697        let preview_line = content
1698            .lines()
1699            .find(|line| !line.trim().is_empty())
1700            .unwrap_or("")
1701            .trim();
1702        if preview_line.is_empty() {
1703            return None;
1704        }
1705
1706        let mut chars = preview_line.chars();
1707        let mut preview: String = chars.by_ref().take(EDIT_PREVIEW_MAX_LEN).collect();
1708        if chars.next().is_some() {
1709            preview.push('…');
1710        }
1711
1712        Some(preview)
1713    }
1714
1715    fn enter_edit_selection_mode(&mut self) {
1716        self.switch_mode(InputMode::EditMessageSelection);
1717        let messages = self.chat_store.user_messages_in_lineage();
1718        self.edit_selection_state.populate(messages);
1719    }
1720}
1721
1722/// Helper function to get spinner character
1723fn get_spinner_char(state: usize) -> &'static str {
1724    const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1725    SPINNER_CHARS[state % SPINNER_CHARS.len()]
1726}
1727
1728impl Drop for Tui {
1729    fn drop(&mut self) {
1730        // Use the same backend writer for reliable cleanup; idempotent via TERMINAL_STATE
1731        crate::tui::terminal::cleanup_with_writer(self.terminal.backend_mut());
1732    }
1733}
1734
1735/// Helper to wrap terminal cleanup in panic handler
1736pub fn setup_panic_hook() {
1737    std::panic::set_hook(Box::new(|panic_info| {
1738        cleanup();
1739        // Print panic info to stderr after restoring terminal state
1740        tracing_error!("Application panicked:");
1741        tracing_error!("{panic_info}");
1742    }));
1743}
1744
1745/// High-level entry point for running the TUI
1746pub async fn run_tui(
1747    client: steer_grpc::AgentClient,
1748    session_id: Option<String>,
1749    model: ModelId,
1750    directory: Option<std::path::PathBuf>,
1751    theme_name: Option<String>,
1752    force_setup: bool,
1753) -> Result<()> {
1754    use std::collections::HashMap;
1755    use steer_grpc::client_api::{
1756        CreateSessionParams, SessionPolicyOverrides, SessionToolConfig, WorkspaceConfig,
1757    };
1758
1759    // Load theme - use catppuccin-mocha as default if none specified
1760    let loader = theme::ThemeLoader::new();
1761    let theme = if let Some(theme_name) = theme_name {
1762        // Check if theme_name is an absolute path
1763        let path = std::path::Path::new(&theme_name);
1764        let theme_result = if path.is_absolute() || path.exists() {
1765            // Load from specific path
1766            loader.load_theme_from_path(path)
1767        } else {
1768            // Load by name from search paths
1769            loader.load_theme(&theme_name)
1770        };
1771
1772        match theme_result {
1773            Ok(theme) => {
1774                info!("Loaded theme: {}", theme_name);
1775                Some(theme)
1776            }
1777            Err(e) => {
1778                warn!(
1779                    "Failed to load theme '{}': {}. Using default theme.",
1780                    theme_name, e
1781                );
1782                // Fall back to catppuccin-mocha
1783                loader.load_theme("catppuccin-mocha").ok()
1784            }
1785        }
1786    } else {
1787        // No theme specified, use catppuccin-mocha as default
1788        match loader.load_theme("catppuccin-mocha") {
1789            Ok(theme) => {
1790                info!("Loaded default theme: catppuccin-mocha");
1791                Some(theme)
1792            }
1793            Err(e) => {
1794                warn!(
1795                    "Failed to load default theme 'catppuccin-mocha': {}. Using hardcoded default.",
1796                    e
1797                );
1798                None
1799            }
1800        }
1801    };
1802
1803    let (session_id, messages) = if let Some(session_id) = session_id {
1804        let (messages, _approved_tools) =
1805            client.resume_session(&session_id).await.map_err(Box::new)?;
1806        info!(
1807            "Resumed session: {} with {} messages",
1808            session_id,
1809            messages.len()
1810        );
1811        tracing_info!("Session ID: {session_id}");
1812        (session_id, messages)
1813    } else {
1814        // Create a new session
1815        let workspace = if let Some(ref dir) = directory {
1816            WorkspaceConfig::Local { path: dir.clone() }
1817        } else {
1818            WorkspaceConfig::default()
1819        };
1820        let session_params = CreateSessionParams {
1821            workspace,
1822            tool_config: SessionToolConfig::default(),
1823            primary_agent_id: None,
1824            policy_overrides: SessionPolicyOverrides::empty(),
1825            metadata: HashMap::new(),
1826            default_model: model.clone(),
1827        };
1828
1829        let session_id = client
1830            .create_session(session_params)
1831            .await
1832            .map_err(Box::new)?;
1833        (session_id, vec![])
1834    };
1835
1836    client.subscribe_session_events().await.map_err(Box::new)?;
1837    let event_rx = client.subscribe_client_events().await.map_err(Box::new)?;
1838    let mut tui = Tui::new(client, model.clone(), session_id.clone(), theme.clone()).await?;
1839
1840    // Ensure terminal cleanup even if we error before entering the event loop
1841    struct TuiCleanupGuard;
1842    impl Drop for TuiCleanupGuard {
1843        fn drop(&mut self) {
1844            cleanup();
1845        }
1846    }
1847    let _cleanup_guard = TuiCleanupGuard;
1848
1849    if !messages.is_empty() {
1850        tui.restore_messages(messages.clone());
1851        tui.chat_viewport.state_mut().scroll_to_bottom();
1852    }
1853
1854    // Query server for providers' auth status to decide if we should launch setup
1855    let statuses = tui
1856        .client
1857        .get_provider_auth_status(None)
1858        .await
1859        .map_err(|e| Error::Generic(format!("Failed to get provider auth status: {e}")))?;
1860
1861    let has_any_auth = statuses
1862        .iter()
1863        .any(|s| has_any_auth_source(s.auth_source.as_ref()));
1864
1865    let should_run_setup = force_setup
1866        || (!Preferences::config_path()
1867            .map(|p| p.exists())
1868            .unwrap_or(false)
1869            && !has_any_auth);
1870
1871    // Initialize setup state if first run or forced
1872    if should_run_setup {
1873        // Build registry for TUI sorting/labels from remote
1874        let providers =
1875            tui.client.list_providers().await.map_err(|e| {
1876                Error::Generic(format!("Failed to list providers from server: {e}"))
1877            })?;
1878        let registry = std::sync::Arc::new(RemoteProviderRegistry::from_proto(providers));
1879
1880        // Map statuses by id for quick lookup
1881        let mut status_map = std::collections::HashMap::new();
1882        for s in statuses {
1883            status_map.insert(s.provider_id.clone(), s.auth_source);
1884        }
1885
1886        let mut provider_status = std::collections::HashMap::new();
1887        for p in registry.all() {
1888            let status = auth_status_from_source(status_map.get(&p.id).and_then(|s| s.as_ref()));
1889            provider_status.insert(ProviderId(p.id.clone()), status);
1890        }
1891
1892        tui.setup_state = Some(crate::tui::state::SetupState::new(
1893            registry,
1894            provider_status,
1895        ));
1896        tui.input_mode = InputMode::Setup;
1897    }
1898
1899    // Run the TUI
1900    tui.run(event_rx).await
1901}
1902
1903/// Run TUI in authentication setup mode
1904/// This is now just a convenience function that launches regular TUI with setup mode forced
1905pub async fn run_tui_auth_setup(
1906    client: steer_grpc::AgentClient,
1907    session_id: Option<String>,
1908    model: Option<ModelId>,
1909    session_db: Option<PathBuf>,
1910    theme_name: Option<String>,
1911) -> Result<()> {
1912    // Just delegate to regular run_tui - it will check for auth providers
1913    // and enter setup mode automatically if needed
1914    run_tui(
1915        client,
1916        session_id,
1917        model.unwrap_or(builtin::claude_sonnet_4_5()),
1918        session_db,
1919        theme_name,
1920        true, // force_setup = true for auth setup
1921    )
1922    .await
1923}
1924
1925#[cfg(test)]
1926mod tests {
1927    use crate::tui::test_utils::local_client_and_server;
1928
1929    use super::*;
1930
1931    use serde_json::json;
1932
1933    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1934    use steer_grpc::client_api::{AssistantContent, Message, MessageData, OpId, QueuedWorkItem};
1935    use tempfile::tempdir;
1936
1937    /// RAII guard to ensure terminal state is restored after a test, even on panic.
1938    struct TerminalCleanupGuard;
1939
1940    impl Drop for TerminalCleanupGuard {
1941        fn drop(&mut self) {
1942            cleanup();
1943        }
1944    }
1945
1946    #[test]
1947    fn operation_cancelled_with_popped_queue_item_clears_esc_double_tap_tracker() {
1948        let mut tracker = crate::tui::state::DoubleTapTracker::new();
1949        tracker.record_key(KeyCode::Esc);
1950
1951        let popped = QueuedWorkItem {
1952            kind: steer_grpc::client_api::QueuedWorkKind::UserMessage,
1953            content: "queued draft".to_string(),
1954            model: None,
1955            queued_at: 123,
1956            op_id: OpId::new(),
1957            message_id: steer_grpc::client_api::MessageId::from_string("msg_queued"),
1958        };
1959
1960        Tui::preprocess_client_event_double_tap(
1961            &ClientEvent::OperationCancelled {
1962                op_id: OpId::new(),
1963                pending_tool_calls: 0,
1964                popped_queued_item: Some(popped),
1965            },
1966            &mut tracker,
1967        );
1968
1969        assert!(
1970            !tracker.is_double_tap(KeyCode::Esc, Duration::from_millis(300)),
1971            "Esc tracker should be cleared when cancellation restores a queued item"
1972        );
1973    }
1974
1975    #[test]
1976    fn operation_cancelled_without_popped_queue_item_keeps_esc_double_tap_tracker() {
1977        let mut tracker = crate::tui::state::DoubleTapTracker::new();
1978        tracker.record_key(KeyCode::Esc);
1979
1980        Tui::preprocess_client_event_double_tap(
1981            &ClientEvent::OperationCancelled {
1982                op_id: OpId::new(),
1983                pending_tool_calls: 0,
1984                popped_queued_item: None,
1985            },
1986            &mut tracker,
1987        );
1988
1989        assert!(
1990            tracker.is_double_tap(KeyCode::Esc, Duration::from_millis(300)),
1991            "Esc tracker should remain armed when cancellation does not restore queued input"
1992        );
1993    }
1994
1995    #[tokio::test]
1996    #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
1997    async fn test_ctrl_r_scrolls_to_bottom_in_simple_mode() {
1998        let _guard = TerminalCleanupGuard;
1999        let workspace_root = tempdir().expect("tempdir");
2000        let (client, _server_handle) =
2001            local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2002        let model = builtin::claude_sonnet_4_5();
2003        let session_id = "test_session_id".to_string();
2004        let mut tui = Tui::new(client, model, session_id, None)
2005            .await
2006            .expect("create tui");
2007
2008        tui.preferences.ui.editing_mode = EditingMode::Simple;
2009        tui.input_mode = InputMode::Simple;
2010
2011        let key = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
2012        tui.handle_simple_mode(key).await.expect("handle ctrl+r");
2013
2014        assert_eq!(
2015            tui.chat_viewport.state().view_mode,
2016            crate::tui::widgets::ViewMode::Detailed
2017        );
2018        assert_eq!(
2019            tui.chat_viewport.state_mut().take_scroll_target(),
2020            Some(crate::tui::widgets::ScrollTarget::Bottom)
2021        );
2022    }
2023
2024    #[tokio::test]
2025    #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
2026    async fn test_restore_messages_preserves_tool_call_params() {
2027        let _guard = TerminalCleanupGuard;
2028        // Create a TUI instance for testing
2029        let workspace_root = tempdir().expect("tempdir");
2030        let (client, _server_handle) =
2031            local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2032        let model = builtin::claude_sonnet_4_5();
2033        let session_id = "test_session_id".to_string();
2034        let mut tui = Tui::new(client, model, session_id, None)
2035            .await
2036            .expect("create tui");
2037
2038        // Build test messages: Assistant with ToolCall, then Tool result
2039        let tool_id = "test_tool_123".to_string();
2040        let tool_call = steer_tools::ToolCall {
2041            id: tool_id.clone(),
2042            name: "view".to_string(),
2043            parameters: json!({
2044                "file_path": "/test/file.rs",
2045                "offset": 10,
2046                "limit": 100
2047            }),
2048        };
2049
2050        let assistant_msg = Message {
2051            data: MessageData::Assistant {
2052                content: vec![AssistantContent::ToolCall {
2053                    tool_call: tool_call.clone(),
2054                    thought_signature: None,
2055                }],
2056            },
2057            id: "msg_assistant".to_string(),
2058            timestamp: 1_234_567_890,
2059            parent_message_id: None,
2060        };
2061
2062        let tool_msg = Message {
2063            data: MessageData::Tool {
2064                tool_use_id: tool_id.clone(),
2065                result: steer_tools::ToolResult::FileContent(
2066                    steer_tools::result::FileContentResult {
2067                        file_path: "/test/file.rs".to_string(),
2068                        content: "file content here".to_string(),
2069                        line_count: 1,
2070                        truncated: false,
2071                    },
2072                ),
2073            },
2074            id: "msg_tool".to_string(),
2075            timestamp: 1_234_567_891,
2076            parent_message_id: Some("msg_assistant".to_string()),
2077        };
2078
2079        let messages = vec![assistant_msg, tool_msg];
2080
2081        // Restore messages
2082        tui.restore_messages(messages);
2083
2084        // Verify tool call was preserved in registry
2085        if let Some(stored_call) = tui.tool_registry.get_tool_call(&tool_id) {
2086            assert_eq!(stored_call.name, "view");
2087            assert_eq!(stored_call.parameters, tool_call.parameters);
2088        } else {
2089            panic!("Tool call should be in registry");
2090        }
2091    }
2092
2093    #[tokio::test]
2094    #[ignore = "Requires TTY - run with `cargo test -- --ignored` in a terminal"]
2095    async fn test_restore_messages_handles_tool_result_before_assistant() {
2096        let _guard = TerminalCleanupGuard;
2097        // Test edge case where Tool result arrives before Assistant message
2098        let workspace_root = tempdir().expect("tempdir");
2099        let (client, _server_handle) =
2100            local_client_and_server(None, Some(workspace_root.path().to_path_buf())).await;
2101        let model = builtin::claude_sonnet_4_5();
2102        let session_id = "test_session_id".to_string();
2103        let mut tui = Tui::new(client, model, session_id, None)
2104            .await
2105            .expect("create tui");
2106
2107        let tool_id = "test_tool_456".to_string();
2108        let real_params = json!({
2109            "file_path": "/another/file.rs"
2110        });
2111
2112        let tool_call = steer_tools::ToolCall {
2113            id: tool_id.clone(),
2114            name: "view".to_string(),
2115            parameters: real_params.clone(),
2116        };
2117
2118        // Tool result comes first (unusual but possible)
2119        let tool_msg = Message {
2120            data: MessageData::Tool {
2121                tool_use_id: tool_id.clone(),
2122                result: steer_tools::ToolResult::FileContent(
2123                    steer_tools::result::FileContentResult {
2124                        file_path: "/another/file.rs".to_string(),
2125                        content: "file content".to_string(),
2126                        line_count: 1,
2127                        truncated: false,
2128                    },
2129                ),
2130            },
2131            id: "msg_tool".to_string(),
2132            timestamp: 1_234_567_890,
2133            parent_message_id: None,
2134        };
2135
2136        let assistant_msg = Message {
2137            data: MessageData::Assistant {
2138                content: vec![AssistantContent::ToolCall {
2139                    tool_call: tool_call.clone(),
2140                    thought_signature: None,
2141                }],
2142            },
2143            id: "msg_456".to_string(),
2144            timestamp: 1_234_567_891,
2145            parent_message_id: None,
2146        };
2147
2148        let messages = vec![tool_msg, assistant_msg];
2149
2150        tui.restore_messages(messages);
2151
2152        // Should still have proper parameters
2153        if let Some(stored_call) = tui.tool_registry.get_tool_call(&tool_id) {
2154            assert_eq!(stored_call.parameters, real_params);
2155            assert_eq!(stored_call.name, "view");
2156        } else {
2157            panic!("Tool call should be in registry");
2158        }
2159    }
2160}