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