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