steer_tui/tui/widgets/
input_panel.rs

1use ratatui::layout::Rect;
2use ratatui::prelude::{Buffer, StatefulWidget, Widget};
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{
6    Block, Borders, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
7    ScrollbarState,
8};
9use tui_textarea::{Input, TextArea};
10
11use steer_core::app::conversation::{MessageData, UserContent};
12use steer_tools::schema::ToolCall;
13
14use crate::tui::InputMode;
15use crate::tui::get_spinner_char;
16use crate::tui::model::ChatItemData;
17use crate::tui::state::file_cache::FileCache;
18use crate::tui::theme::{Component, Theme};
19use crate::tui::widgets::fuzzy_finder::{FuzzyFinder, FuzzyFinderMode};
20
21/// Helper function to format keybind hints with consistent styling
22fn format_keybind(key: &str, description: &str, theme: &Theme) -> Vec<Span<'static>> {
23    vec![
24        Span::styled(
25            format!("[{key}]"),
26            Style::default().add_modifier(Modifier::BOLD),
27        ),
28        Span::styled(format!(" {description}"), theme.style(Component::DimText)),
29    ]
30}
31
32fn format_keybinds(keybinds: &[(&str, &str)], theme: &Theme) -> Vec<Span<'static>> {
33    let mut spans = Vec::new();
34    for (i, (key, description)) in keybinds.iter().enumerate() {
35        spans.extend(format_keybind(key, description, theme));
36        if i < keybinds.len() - 1 {
37            spans.push(Span::styled(" │ ", theme.style(Component::DimText)));
38        }
39    }
40    spans
41}
42
43/// Stateful data for the [`InputPanel`] widget.
44#[derive(Debug)]
45pub struct InputPanelState {
46    pub textarea: TextArea<'static>,
47    pub edit_selection_messages: Vec<(String, String)>,
48    pub edit_selection_index: usize,
49    pub edit_selection_hovered_id: Option<String>,
50    /// File cache for fuzzy finder
51    pub file_cache: FileCache,
52    /// Fuzzy finder widget
53    pub fuzzy_finder: FuzzyFinder,
54}
55
56impl Default for InputPanelState {
57    fn default() -> Self {
58        // For tests and default usage, use a dummy session ID
59        Self::new("default".to_string())
60    }
61}
62
63impl InputPanelState {
64    /// Create a new InputPanelState with the given session ID
65    pub fn new(session_id: String) -> Self {
66        let mut textarea = TextArea::default();
67        textarea.set_placeholder_text("Type your message here...");
68        textarea.set_cursor_line_style(Style::default());
69        textarea.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
70        Self {
71            textarea,
72            edit_selection_messages: Vec::new(),
73            edit_selection_index: 0,
74            edit_selection_hovered_id: None,
75            file_cache: FileCache::new(session_id),
76            fuzzy_finder: FuzzyFinder::new(),
77        }
78    }
79
80    /// Get the byte offset of the cursor in the textarea content.
81    pub fn get_cursor_byte_offset(&self) -> usize {
82        let (row, col) = self.textarea.cursor();
83        let lines = self.textarea.lines();
84        let mut offset = 0;
85        for (i, line) in lines.iter().enumerate() {
86            if i < row {
87                offset += line.len() + 1; // +1 for newline
88            } else {
89                // `col` is a grapheme cluster count, find the byte offset for that.
90                offset += line.char_indices().nth(col).map_or(line.len(), |(i, _)| i);
91                break;
92            }
93        }
94        offset
95    }
96
97    /// Checks if the fuzzy finder is active and the cursor is in a valid query position.
98    /// This method does not allocate and is suitable for checks on every tick.
99    pub fn is_in_fuzzy_query(&self) -> bool {
100        if !self.fuzzy_finder.is_active() {
101            return false;
102        }
103
104        let Some(at_pos) = self.fuzzy_finder.trigger_position() else {
105            return false;
106        };
107
108        let cursor_offset = self.get_cursor_byte_offset();
109        if cursor_offset <= at_pos {
110            return false; // Cursor is before or on the trigger character
111        }
112
113        let content = self.content();
114        // The part of the string that could be the query
115        let query_candidate = &content[at_pos + 1..cursor_offset];
116
117        // If it contains whitespace, it's not a valid query anymore
118        !query_candidate.chars().any(char::is_whitespace)
119    }
120
121    /// If the fuzzy finder is active and the cursor is in a valid query position,
122    /// returns the query string. Otherwise, returns None.
123    pub fn get_current_fuzzy_query(&self) -> Option<String> {
124        if self.is_in_fuzzy_query() {
125            let at_pos = self.fuzzy_finder.trigger_position().unwrap(); // Safe due to check above
126            let cursor_offset = self.get_cursor_byte_offset();
127            let content = self.content();
128            let query_candidate = &content[at_pos + 1..cursor_offset];
129            Some(query_candidate.to_string())
130        } else {
131            None
132        }
133    }
134
135    /// Handle input in insert/bash modes
136    pub fn handle_input(&mut self, input: Input) {
137        self.textarea.input(input);
138    }
139
140    /// Complete fuzzy finder by replacing the query text with the selected path
141    pub fn complete_fuzzy_finder(&mut self, selected_path: &str) {
142        if let Some(at_pos) = self.fuzzy_finder.trigger_position() {
143            let cursor_offset = self.get_cursor_byte_offset();
144
145            // Convert content to string and replace the query portion
146            let content = self.content();
147            let mut new_content = String::new();
148
149            // Keep everything up to and including the @
150            new_content.push_str(&content[..=at_pos]);
151
152            // Add the selected path and a space
153            new_content.push_str(selected_path);
154            new_content.push(' ');
155
156            // Keep everything after the cursor
157            if cursor_offset < content.len() {
158                new_content.push_str(&content[cursor_offset..]);
159            }
160
161            // Replace the entire content
162            let lines: Vec<&str> = new_content.lines().collect();
163            self.set_content_from_lines(lines);
164
165            // Position cursor after the inserted path and space (which is a byte position)
166            let new_cursor_pos_bytes = at_pos + 1 + selected_path.len() + 1;
167
168            // Now, convert this byte position to a (row, col) grapheme position
169            let mut bytes_traversed = 0;
170            for (row_idx, line) in self.textarea.lines().iter().enumerate() {
171                let line_len_bytes = line.len();
172                if bytes_traversed + line_len_bytes >= new_cursor_pos_bytes {
173                    // The cursor should be on this line
174                    let byte_offset_in_line = new_cursor_pos_bytes - bytes_traversed;
175                    // Convert byte offset in line to character/grapheme column
176                    let char_col = line[..byte_offset_in_line].chars().count();
177                    self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
178                        row_idx as u16,
179                        char_col as u16,
180                    ));
181                    break;
182                }
183                bytes_traversed += line_len_bytes + 1; // +1 for newline
184            }
185        }
186    }
187
188    /// Complete command fuzzy finder by replacing the query text with the selected command
189    pub fn complete_command_fuzzy(&mut self, selected_command: &str) {
190        if let Some(trigger_pos) = self.fuzzy_finder.trigger_position() {
191            let cursor_offset = self.get_cursor_byte_offset();
192
193            // Convert content to string and replace the query portion
194            let content = self.content();
195            let mut new_content = String::new();
196
197            // For commands, we want to replace everything from the trigger onwards
198            new_content.push_str(&content[..trigger_pos]);
199
200            // Add the selected command with a space
201            new_content.push('/');
202            new_content.push_str(selected_command);
203            new_content.push(' ');
204
205            // Keep everything after the cursor
206            if cursor_offset < content.len() {
207                new_content.push_str(&content[cursor_offset..]);
208            }
209
210            // Replace the entire content
211            let lines: Vec<&str> = new_content.lines().collect();
212            self.set_content_from_lines(lines);
213
214            // Position cursor after the inserted command and space
215            let new_cursor_pos_bytes = trigger_pos + 1 + selected_command.len() + 1;
216
217            // Now, convert this byte position to a (row, col) grapheme position
218            let mut bytes_traversed = 0;
219            for (row_idx, line) in self.textarea.lines().iter().enumerate() {
220                let line_len_bytes = line.len();
221                if bytes_traversed + line_len_bytes >= new_cursor_pos_bytes {
222                    // The cursor should be on this line
223                    let byte_offset_in_line = new_cursor_pos_bytes - bytes_traversed;
224                    // Convert byte offset in line to character/grapheme column
225                    let char_col = line[..byte_offset_in_line].chars().count();
226                    self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
227                        row_idx as u16,
228                        char_col as u16,
229                    ));
230                    break;
231                }
232                bytes_traversed += line_len_bytes + 1; // +1 for newline
233            }
234        }
235    }
236
237    /// Insert a string (e.g., for paste operations)
238    pub fn insert_str(&mut self, s: &str) {
239        self.textarea.insert_str(s);
240    }
241
242    /// Get the current content as a single string
243    pub fn content(&self) -> String {
244        self.textarea.lines().join("\n")
245    }
246
247    /// Clear the textarea
248    pub fn clear(&mut self) {
249        self.textarea = TextArea::default();
250        self.textarea
251            .set_placeholder_text("Type your message here...");
252        self.textarea.set_cursor_line_style(Style::default());
253        self.textarea
254            .set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
255    }
256
257    /// Set content from lines (used when editing a message)
258    pub fn set_content_from_lines(&mut self, lines: Vec<&str>) {
259        self.textarea = TextArea::from(lines);
260    }
261
262    /// Calculate required height for the input panel
263    pub fn required_height(
264        &self,
265        current_approval: Option<&ToolCall>,
266        width: u16,
267        max_height: u16,
268    ) -> u16 {
269        if let Some(tool_call) = current_approval {
270            // If there's a pending approval, use the approval height calculation
271            Self::required_height_for_approval(tool_call, width, max_height)
272        } else {
273            // Otherwise use the regular calculation based on textarea lines
274            let line_count = self.textarea.lines().len().max(1);
275            // line count + 2 for borders + 1 for padding
276            (line_count + 3).min(max_height as usize) as u16
277        }
278    }
279
280    /// Calculate required height for approval mode
281    pub fn required_height_for_approval(tool_call: &ToolCall, width: u16, max_height: u16) -> u16 {
282        let theme = &Theme::default();
283        let formatter = crate::tui::widgets::formatters::get_formatter(&tool_call.name);
284        let preview_lines = formatter.approval(
285            &tool_call.parameters,
286            width.saturating_sub(4) as usize,
287            theme,
288        );
289        // 2 lines for header + preview lines + 2 for borders + 1 for padding
290        (2 + preview_lines.len() + 3).min(max_height as usize) as u16
291    }
292
293    /// Navigate up in edit selection mode
294    pub fn edit_selection_prev(&mut self) -> Option<&(String, String)> {
295        if self.edit_selection_index > 0 {
296            self.edit_selection_index -= 1;
297            self.update_hovered_id();
298            self.edit_selection_messages.get(self.edit_selection_index)
299        } else {
300            self.edit_selection_messages.get(self.edit_selection_index)
301        }
302    }
303
304    /// Navigate down in edit selection mode
305    pub fn edit_selection_next(&mut self) -> Option<&(String, String)> {
306        if self.edit_selection_index + 1 < self.edit_selection_messages.len() {
307            self.edit_selection_index += 1;
308            self.update_hovered_id();
309            self.edit_selection_messages.get(self.edit_selection_index)
310        } else {
311            self.edit_selection_messages.get(self.edit_selection_index)
312        }
313    }
314
315    /// Get currently selected message in edit selection mode
316    pub fn get_selected_message(&self) -> Option<&(String, String)> {
317        self.edit_selection_messages.get(self.edit_selection_index)
318    }
319
320    /// Populate edit selection messages from chat store
321    pub fn populate_edit_selection<'a>(
322        &mut self,
323        chat_items: impl Iterator<Item = &'a ChatItemData>,
324    ) {
325        self.edit_selection_messages = chat_items
326            .filter_map(|item| {
327                if let ChatItemData::Message(row) = item {
328                    if let MessageData::User { content, .. } = &row.data {
329                        // Extract text content from user blocks
330                        let text = content
331                            .iter()
332                            .filter_map(|block| match block {
333                                UserContent::Text { text } => Some(text.as_str()),
334                                _ => None,
335                            })
336                            .collect::<Vec<_>>()
337                            .join("\n");
338                        Some((row.id().to_string(), text))
339                    } else {
340                        None
341                    }
342                } else {
343                    None
344                }
345            })
346            .collect();
347
348        // Select the last (most recent) message if available
349        if !self.edit_selection_messages.is_empty() {
350            self.edit_selection_index = self.edit_selection_messages.len() - 1;
351            self.update_hovered_id();
352        } else {
353            self.edit_selection_index = 0;
354            self.edit_selection_hovered_id = None;
355        }
356    }
357
358    /// Update the hovered message ID based on current selection
359    fn update_hovered_id(&mut self) {
360        self.edit_selection_hovered_id = self.get_selected_message().map(|(id, _)| id.clone());
361    }
362
363    /// Get the current hovered message ID
364    pub fn get_hovered_id(&self) -> Option<&str> {
365        self.edit_selection_hovered_id.as_deref()
366    }
367
368    /// Clear edit selection state
369    pub fn clear_edit_selection(&mut self) {
370        self.edit_selection_messages.clear();
371        self.edit_selection_index = 0;
372        self.edit_selection_hovered_id = None;
373    }
374
375    /// Activate fuzzy finder for files
376    pub fn activate_fuzzy(&mut self) {
377        // The @ is one character before the cursor (since we just typed it)
378        let cursor_pos = self.get_cursor_byte_offset();
379        if cursor_pos > 0 {
380            // The trigger is the @ just before the cursor
381            self.fuzzy_finder
382                .activate(cursor_pos - 1, FuzzyFinderMode::Files);
383        } else {
384            // Shouldn't happen, but handle gracefully
385            self.fuzzy_finder.activate(0, FuzzyFinderMode::Files);
386        }
387    }
388
389    /// Activate fuzzy finder for commands
390    pub fn activate_command_fuzzy(&mut self) {
391        // The / is one character before the cursor (since we just typed it)
392        let cursor_pos = self.get_cursor_byte_offset();
393        if cursor_pos > 0 {
394            // The trigger is the / just before the cursor
395            self.fuzzy_finder
396                .activate(cursor_pos - 1, FuzzyFinderMode::Commands);
397        } else {
398            // Shouldn't happen, but handle gracefully
399            self.fuzzy_finder.activate(0, FuzzyFinderMode::Commands);
400        }
401    }
402
403    /// Deactivate fuzzy finder
404    pub fn deactivate_fuzzy(&mut self) {
405        self.fuzzy_finder.deactivate();
406    }
407
408    /// Check if fuzzy finder is active
409    pub fn fuzzy_active(&self) -> bool {
410        self.fuzzy_finder.is_active()
411    }
412
413    /// Handle key event for fuzzy finder
414    pub async fn handle_fuzzy_key(
415        &mut self,
416        key: ratatui::crossterm::event::KeyEvent,
417    ) -> Option<crate::tui::widgets::fuzzy_finder::FuzzyFinderResult> {
418        use ratatui::crossterm::event::{KeyCode, KeyModifiers};
419        use tui_textarea::{CursorMove, Input};
420
421        // First handle navigation/selection in the fuzzy finder itself
422        let result = self.fuzzy_finder.handle_input(key);
423
424        if result.is_some() {
425            // Key was handled (e.g., selection, closing), so just return the result
426            return result;
427        }
428
429        // Block up/down arrows from reaching the textarea when fuzzy finder is active
430        match key.code {
431            KeyCode::Up | KeyCode::Down => {
432                // These keys are for fuzzy finder navigation only
433                return None;
434            }
435            _ => {}
436        }
437
438        // Handle Alt+Left/Right for word navigation
439        if key.modifiers == KeyModifiers::ALT {
440            match key.code {
441                KeyCode::Left => {
442                    self.textarea.move_cursor(CursorMove::WordBack);
443                    return None;
444                }
445                KeyCode::Right => {
446                    self.textarea.move_cursor(CursorMove::WordForward);
447                    return None;
448                }
449                _ => {}
450            }
451        }
452
453        // Key was not for navigation, so treat it as text input
454        let input = Input::from(key);
455        self.textarea.input(input);
456
457        // After input, handle result updates based on the active fuzzy finder mode.
458        use crate::tui::widgets::fuzzy_finder::FuzzyFinderMode;
459
460        if self.fuzzy_finder.mode() == FuzzyFinderMode::Files {
461            // For file search, update results or close if the query is no longer valid
462            if let Some(query) = self.get_current_fuzzy_query() {
463                let results = self.file_cache.fuzzy_search(&query, Some(10)).await;
464                self.fuzzy_finder.update_results(results);
465                None // Query updated; stay active
466            } else {
467                // Query became invalid (e.g., whitespace typed) – request close
468                Some(crate::tui::widgets::fuzzy_finder::FuzzyFinderResult::Close)
469            }
470        } else {
471            // For other modes (Commands, Models, Themes) the Tui handler deals with closing logic.
472            None
473        }
474    }
475
476    /// Get file cache reference
477    pub fn file_cache(&self) -> &FileCache {
478        &self.file_cache
479    }
480
481    /// Get mutable file cache reference
482    pub fn file_cache_mut(&mut self) -> &mut FileCache {
483        &mut self.file_cache
484    }
485}
486
487fn get_formatted_mode(mode: InputMode, theme: &Theme) -> Option<Span<'static>> {
488    // Add mode name with special styling
489    let mode_name = match mode {
490        InputMode::Simple => return None,
491        InputMode::VimNormal => "NORMAL",
492        InputMode::VimInsert => "INSERT",
493        InputMode::BashCommand => "Bash",
494        InputMode::AwaitingApproval => "Awaiting Approval",
495        InputMode::ConfirmExit => "Confirm Exit",
496        InputMode::EditMessageSelection => "Edit Selection",
497        InputMode::FuzzyFinder => "Search",
498        InputMode::Setup => "Setup",
499    };
500
501    let component = match mode {
502        InputMode::ConfirmExit => Component::ErrorBold,
503        InputMode::BashCommand => Component::CommandPrompt,
504        InputMode::AwaitingApproval => Component::ErrorBold,
505        InputMode::EditMessageSelection => Component::SelectionHighlight,
506        InputMode::FuzzyFinder => Component::SelectionHighlight,
507        _ => Component::ModelInfo,
508    };
509
510    Some(Span::styled(mode_name, theme.style(component)))
511}
512
513/// Properties for the [`InputPanel`] widget.
514#[derive(Clone, Copy, Debug)]
515pub struct InputPanel<'a> {
516    pub input_mode: InputMode,
517    pub current_approval: Option<&'a ToolCall>,
518    pub is_processing: bool,
519    pub spinner_state: usize,
520    pub theme: &'a Theme,
521}
522
523impl<'a> InputPanel<'a> {
524    pub fn new(
525        input_mode: InputMode,
526        current_approval: Option<&'a ToolCall>,
527        is_processing: bool,
528        spinner_state: usize,
529        theme: &'a Theme,
530    ) -> Self {
531        Self {
532            input_mode,
533            current_approval,
534            is_processing,
535            spinner_state,
536            theme,
537        }
538    }
539
540    /// Get the title for the current mode with properly formatted keybinds
541    fn get_mode_title(&self, state: &InputPanelState) -> Line<'static> {
542        let mut spans = vec![Span::raw(" ")];
543
544        let formatted_mode = get_formatted_mode(self.input_mode, self.theme);
545        if let Some(mode) = formatted_mode {
546            spans.push(mode);
547            spans.push(Span::styled(" │ ", self.theme.style(Component::DimText)));
548        }
549
550        match self.input_mode {
551            InputMode::Simple => {
552                if state.content().is_empty() {
553                    spans.extend(format_keybinds(
554                        &[
555                            ("Enter", "send"),
556                            ("ESC ESC", "edit previous"),
557                            ("!", "bash"),
558                            ("/", "command"),
559                            ("@", "file"),
560                        ],
561                        self.theme,
562                    ));
563                } else {
564                    spans.extend(format_keybinds(
565                        &[("Enter", "send"), ("ESC ESC", "clear")],
566                        self.theme,
567                    ));
568                }
569            }
570            InputMode::VimNormal => {
571                if state.content().is_empty() {
572                    spans.extend(format_keybinds(
573                        &[
574                            ("i", "insert"),
575                            ("ESC ESC", "edit previous"),
576                            ("!", "bash"),
577                            ("/", "command"),
578                        ],
579                        self.theme,
580                    ));
581                } else {
582                    spans.extend(format_keybinds(
583                        &[("i", "insert"), ("ESC ESC", "clear"), ("hjkl", "move")],
584                        self.theme,
585                    ));
586                }
587            }
588            InputMode::VimInsert => {
589                spans.extend(format_keybinds(
590                    &[("Esc", "normal"), ("ESC ESC", "clear"), ("Enter", "send")],
591                    self.theme,
592                ));
593            }
594            InputMode::BashCommand => {
595                spans.extend(format_keybinds(
596                    &[("Enter", "execute"), ("Esc", "cancel")],
597                    self.theme,
598                ));
599            }
600            InputMode::AwaitingApproval => {
601                // No keybinds for this mode
602            }
603            InputMode::ConfirmExit => {
604                spans.extend(format_keybinds(
605                    &[("y/Y", "confirm"), ("any other key", "cancel")],
606                    self.theme,
607                ));
608            }
609            InputMode::EditMessageSelection => {
610                spans.extend(format_keybinds(
611                    &[("↑↓", "navigate"), ("Enter", "select"), ("Esc", "cancel")],
612                    self.theme,
613                ));
614            }
615            InputMode::FuzzyFinder => {
616                spans.extend(format_keybinds(
617                    &[("↑↓", "navigate"), ("Enter", "select"), ("Esc", "cancel")],
618                    self.theme,
619                ));
620            }
621            InputMode::Setup => {
622                // No keybinds shown during setup mode
623            }
624        }
625
626        spans.push(Span::raw(" "));
627        Line::from(spans)
628    }
629}
630
631impl StatefulWidget for InputPanel<'_> {
632    type State = InputPanelState;
633
634    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
635        // Render approval prompt if needed
636        if let Some(tool_call) = self.current_approval {
637            let formatter = crate::tui::widgets::formatters::get_formatter(&tool_call.name);
638            let preview_lines = formatter.approval(
639                &tool_call.parameters,
640                (area.width.saturating_sub(4)) as usize,
641                self.theme,
642            );
643
644            let is_bash_command = tool_call.name == "bash";
645
646            let mut approval_text = if is_bash_command {
647                vec![
648                    Line::from(vec![
649                        Span::styled("Tool ", Style::default()),
650                        Span::styled(&tool_call.name, self.theme.style(Component::ToolCallHeader)),
651                        Span::styled(" wants to run this shell command", Style::default()),
652                    ]),
653                    Line::from(""),
654                ]
655            } else {
656                vec![
657                    Line::from(vec![
658                        Span::styled("Tool ", Style::default()),
659                        Span::styled(&tool_call.name, self.theme.style(Component::ToolCallHeader)),
660                        Span::styled(" needs your approval", Style::default()),
661                    ]),
662                    Line::from(""),
663                ]
664            };
665            approval_text.extend(preview_lines);
666
667            let approval_keybinds = if is_bash_command {
668                vec![
669                    (
670                        Span::styled("[Y]", self.theme.style(Component::ToolSuccess)),
671                        Span::styled("Yes (once)", self.theme.style(Component::DimText)),
672                    ),
673                    (
674                        Span::styled("[A]", self.theme.style(Component::ToolSuccess)),
675                        Span::styled(
676                            "Always (this command)",
677                            self.theme.style(Component::DimText),
678                        ),
679                    ),
680                    (
681                        Span::styled("[L]", self.theme.style(Component::ToolSuccess)),
682                        Span::styled(
683                            "Always (all Bash commands)",
684                            self.theme.style(Component::DimText),
685                        ),
686                    ),
687                    (
688                        Span::styled("[N]", self.theme.style(Component::ToolError)),
689                        Span::styled("No", self.theme.style(Component::DimText)),
690                    ),
691                ]
692            } else {
693                vec![
694                    (
695                        Span::styled("[Y]", self.theme.style(Component::ToolSuccess)),
696                        Span::styled("Yes (once)", self.theme.style(Component::DimText)),
697                    ),
698                    (
699                        Span::styled("[A]", self.theme.style(Component::ToolSuccess)),
700                        Span::styled("Always", self.theme.style(Component::DimText)),
701                    ),
702                    (
703                        Span::styled("[N]", self.theme.style(Component::ToolError)),
704                        Span::styled("No", self.theme.style(Component::DimText)),
705                    ),
706                ]
707            };
708
709            let mut title_spans = vec![Span::raw(" Approval Required "), Span::raw("─ ")];
710
711            for (i, (key, desc)) in approval_keybinds.iter().enumerate() {
712                if i > 0 {
713                    title_spans.push(Span::styled(" │ ", self.theme.style(Component::DimText)));
714                }
715                title_spans.push(key.clone());
716                title_spans.push(Span::raw(" "));
717                title_spans.push(desc.clone());
718            }
719            title_spans.push(Span::raw(" "));
720
721            let title = Line::from(title_spans);
722
723            let approval_block = Paragraph::new(approval_text).block(
724                Block::default()
725                    .borders(Borders::ALL)
726                    .title(title)
727                    .style(self.theme.style(Component::InputPanelBorderApproval)),
728            );
729
730            approval_block.render(area, buf);
731            return;
732        }
733
734        // Normal input / edit selection rendering
735        let mut title_spans = vec![];
736
737        // Add spinner if processing
738        if self.is_processing {
739            title_spans.push(Span::styled(
740                format!(" {}", get_spinner_char(self.spinner_state)),
741                self.theme.style(Component::ToolCall),
742            ));
743        }
744
745        // Add mode-specific title
746        title_spans.extend(self.get_mode_title(state).spans);
747
748        let mut input_block = Block::default()
749            .borders(Borders::ALL)
750            .title(Line::from(title_spans));
751
752        match self.input_mode {
753            InputMode::Simple | InputMode::VimInsert => {
754                // Active border and text style
755                let active = self.theme.style(Component::InputPanelBorderActive);
756                input_block = input_block.style(active).border_style(active);
757            }
758            InputMode::VimNormal => {
759                // Keep text style the same as VimInsert (active) but dim the border
760                let text_style = self.theme.style(Component::InputPanelBorderActive);
761                let border_dim = self.theme.style(Component::InputPanelBorder);
762                input_block = input_block.style(text_style).border_style(border_dim);
763            }
764            InputMode::BashCommand => {
765                let style = self.theme.style(Component::InputPanelBorderCommand);
766                input_block = input_block.style(style).border_style(style);
767            }
768            InputMode::ConfirmExit => {
769                let style = self.theme.style(Component::InputPanelBorderError);
770                input_block = input_block.style(style).border_style(style);
771            }
772            InputMode::EditMessageSelection => {
773                let style = self.theme.style(Component::InputPanelBorderCommand);
774                input_block = input_block.style(style).border_style(style);
775            }
776            InputMode::FuzzyFinder => {
777                let style = self.theme.style(Component::InputPanelBorderActive);
778                input_block = input_block.style(style).border_style(style);
779            }
780            _ => {
781                let style = self.theme.style(Component::InputPanelBorder);
782                input_block = input_block.style(style).border_style(style);
783            }
784        }
785
786        if self.input_mode == InputMode::EditMessageSelection {
787            // Selection list rendering
788            let mut items: Vec<ListItem> = Vec::new();
789            if state.edit_selection_messages.is_empty() {
790                items.push(
791                    ListItem::new("No user messages to edit")
792                        .style(self.theme.style(Component::DimText)),
793                );
794            } else {
795                let max_visible = 3;
796                let total = state.edit_selection_messages.len();
797                let (start_idx, end_idx) = if total <= max_visible {
798                    (0, total)
799                } else {
800                    let half_window = max_visible / 2;
801                    if state.edit_selection_index < half_window {
802                        (0, max_visible)
803                    } else if state.edit_selection_index >= total - half_window {
804                        (total - max_visible, total)
805                    } else {
806                        let start = state.edit_selection_index - half_window;
807                        (start, start + max_visible)
808                    }
809                };
810
811                for idx in start_idx..end_idx {
812                    let (_, content) = &state.edit_selection_messages[idx];
813                    let preview = content
814                        .lines()
815                        .next()
816                        .unwrap_or("")
817                        .chars()
818                        .take(area.width.saturating_sub(4) as usize)
819                        .collect::<String>();
820                    items.push(ListItem::new(preview));
821                }
822
823                let mut list_state = ListState::default();
824                list_state.select(Some(state.edit_selection_index.saturating_sub(start_idx)));
825
826                let highlight_style = self
827                    .theme
828                    .style(Component::SelectionHighlight)
829                    .add_modifier(Modifier::REVERSED);
830
831                let list = List::new(items)
832                    .block(input_block)
833                    .highlight_style(highlight_style);
834                StatefulWidget::render(list, area, buf, &mut list_state);
835                return;
836            }
837
838            // Empty list fallback
839            let list = List::new(items).block(input_block);
840            Widget::render(list, area, buf);
841            return;
842        }
843
844        // Default: textarea
845        state.textarea.set_block(input_block);
846        state.textarea.render(area, buf);
847
848        // Scrollbar when needed
849        let textarea_height = area.height.saturating_sub(2);
850        let content_lines = state.textarea.lines().len();
851        if content_lines > textarea_height as usize {
852            let (cursor_row, _) = state.textarea.cursor();
853            let mut scrollbar_state = ScrollbarState::new(content_lines)
854                .position(cursor_row)
855                .viewport_content_length(textarea_height as usize);
856            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
857                .begin_symbol(Some("▲"))
858                .end_symbol(Some("▼"))
859                .thumb_style(self.theme.style(Component::DimText));
860            let scrollbar_area = Rect {
861                x: area.x + area.width - 1,
862                y: area.y + 1,
863                width: 1,
864                height: area.height - 2,
865            };
866            scrollbar.render(scrollbar_area, buf, &mut scrollbar_state);
867        }
868    }
869}
870
871#[cfg(test)]
872mod tests {
873    use steer_core::app::Message;
874
875    use super::*;
876    use crate::tui::model::ChatItemData;
877
878    #[test]
879    fn test_input_panel_state_default() {
880        let state = InputPanelState::default();
881        assert!(state.edit_selection_messages.is_empty());
882        assert_eq!(state.edit_selection_index, 0);
883        assert!(state.edit_selection_hovered_id.is_none());
884        assert_eq!(state.content(), "");
885    }
886
887    #[test]
888    fn test_input_panel_state_content_operations() {
889        let mut state = InputPanelState::default();
890
891        // Test inserting text
892        state.insert_str("Hello, world!");
893        assert_eq!(state.content(), "Hello, world!");
894
895        // Test clearing
896        state.clear();
897        assert_eq!(state.content(), "");
898
899        // Test setting content from lines
900        state.set_content_from_lines(vec!["Line 1", "Line 2", "Line 3"]);
901        assert_eq!(state.content(), "Line 1\nLine 2\nLine 3");
902    }
903
904    #[test]
905    fn test_edit_selection_navigation() {
906        let mut state = InputPanelState {
907            edit_selection_messages: vec![
908                ("msg1".to_string(), "First message".to_string()),
909                ("msg2".to_string(), "Second message".to_string()),
910                ("msg3".to_string(), "Third message".to_string()),
911            ],
912            ..Default::default()
913        };
914        state.edit_selection_index = 1;
915        state.update_hovered_id();
916
917        // Test initial state
918        assert_eq!(state.get_hovered_id(), Some("msg2"));
919
920        // Test navigation up
921        state.edit_selection_prev();
922        assert_eq!(state.edit_selection_index, 0);
923        assert_eq!(state.get_hovered_id(), Some("msg1"));
924
925        // Test navigation at boundary
926        state.edit_selection_prev();
927        assert_eq!(state.edit_selection_index, 0);
928        assert_eq!(state.get_hovered_id(), Some("msg1"));
929
930        // Test navigation down
931        state.edit_selection_next();
932        assert_eq!(state.edit_selection_index, 1);
933        assert_eq!(state.get_hovered_id(), Some("msg2"));
934
935        state.edit_selection_next();
936        assert_eq!(state.edit_selection_index, 2);
937        assert_eq!(state.get_hovered_id(), Some("msg3"));
938
939        // Test navigation at bottom boundary
940        state.edit_selection_next();
941        assert_eq!(state.edit_selection_index, 2);
942        assert_eq!(state.get_hovered_id(), Some("msg3"));
943    }
944
945    #[test]
946    fn test_clear_edit_selection() {
947        let mut state = InputPanelState {
948            edit_selection_messages: vec![("msg1".to_string(), "First message".to_string())],
949            edit_selection_index: 0,
950            edit_selection_hovered_id: Some("msg1".to_string()),
951            ..Default::default()
952        };
953
954        // Clear it
955        state.clear_edit_selection();
956
957        assert!(state.edit_selection_messages.is_empty());
958        assert_eq!(state.edit_selection_index, 0);
959        assert!(state.edit_selection_hovered_id.is_none());
960    }
961
962    #[test]
963    fn test_required_height_calculation() {
964        let mut state = InputPanelState::default();
965
966        // Empty textarea
967        assert_eq!(state.required_height(None, 80, 10), 4); // 1 line + 3 for borders/padding
968
969        // Multi-line content
970        state.set_content_from_lines(vec!["Line 1", "Line 2", "Line 3"]);
971        assert_eq!(state.required_height(None, 80, 10), 6); // 3 lines + 3
972
973        // Test max height constraint
974        state.set_content_from_lines(vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]);
975        assert_eq!(state.required_height(None, 80, 8), 8); // Capped at max
976    }
977
978    #[test]
979    fn test_populate_edit_selection() {
980        let mut state = InputPanelState::default();
981
982        // Create test chat items
983        let chat_items = vec![
984            ChatItemData::Message(Message {
985                data: MessageData::User {
986                    content: vec![UserContent::Text {
987                        text: "First user message".to_string(),
988                    }],
989                },
990                id: "user1".to_string(),
991                timestamp: 123,
992                parent_message_id: None,
993            }),
994            ChatItemData::Message(Message {
995                data: MessageData::Assistant { content: vec![] },
996                id: "assistant1".to_string(),
997                timestamp: 124,
998                parent_message_id: None,
999            }),
1000            ChatItemData::Message(Message {
1001                data: MessageData::User {
1002                    content: vec![UserContent::Text {
1003                        text: "Second user message".to_string(),
1004                    }],
1005                },
1006                id: "user2".to_string(),
1007                timestamp: 125,
1008                parent_message_id: None,
1009            }),
1010        ];
1011
1012        state.populate_edit_selection(chat_items.iter());
1013
1014        // Should have 2 user messages
1015        assert_eq!(state.edit_selection_messages.len(), 2);
1016        assert_eq!(state.edit_selection_messages[0].0, "user1");
1017        assert_eq!(state.edit_selection_messages[0].1, "First user message");
1018        assert_eq!(state.edit_selection_messages[1].0, "user2");
1019        assert_eq!(state.edit_selection_messages[1].1, "Second user message");
1020
1021        // Should select the last message
1022        assert_eq!(state.edit_selection_index, 1);
1023        assert_eq!(state.get_hovered_id(), Some("user2"));
1024    }
1025}