steer_tui/tui/widgets/input_panel/
mod.rs

1//! Input panel widget module
2//!
3//! This module contains the input panel widget and its sub-components.
4
5mod approval_prompt;
6mod edit_selection;
7mod fuzzy_state;
8mod mode_title;
9mod textarea;
10
11pub use approval_prompt::ApprovalWidget;
12pub use edit_selection::{EditSelectionState, EditSelectionWidget};
13pub use fuzzy_state::FuzzyFinderHelper;
14pub use mode_title::ModeTitleWidget;
15pub use textarea::TextAreaWidget;
16
17// Main input panel implementation
18use ratatui::layout::Rect;
19use ratatui::prelude::{Buffer, StatefulWidget, Widget};
20use ratatui::widgets::{Block, Borders};
21use tui_textarea::{Input, TextArea};
22
23use steer_tools::schema::ToolCall;
24
25use crate::tui::InputMode;
26use crate::tui::model::ChatItem;
27use crate::tui::state::file_cache::FileCache;
28use crate::tui::theme::{Component, Theme};
29use crate::tui::widgets::fuzzy_finder::{FuzzyFinder, FuzzyFinderMode};
30
31/// Stateful data for the [`InputPanel`] widget.
32#[derive(Debug)]
33pub struct InputPanelState {
34    pub textarea: TextArea<'static>,
35    pub edit_selection: EditSelectionState,
36    pub file_cache: FileCache,
37    pub fuzzy_finder: FuzzyFinder,
38}
39
40impl Default for InputPanelState {
41    fn default() -> Self {
42        // For tests and default usage, use a dummy session ID
43        Self::new("default".to_string())
44    }
45}
46
47impl InputPanelState {
48    /// Create a new InputPanelState with the given session ID
49    pub fn new(session_id: String) -> Self {
50        let mut textarea = TextArea::default();
51        textarea.set_placeholder_text("Type your message here...");
52        textarea.set_cursor_line_style(ratatui::style::Style::default());
53        textarea.set_cursor_style(
54            ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED),
55        );
56        Self {
57            textarea,
58            edit_selection: EditSelectionState::default(),
59            file_cache: FileCache::new(session_id),
60            fuzzy_finder: FuzzyFinder::new(),
61        }
62    }
63
64    /// Get the content of the textarea
65    pub fn content(&self) -> String {
66        self.textarea.lines().join("\n")
67    }
68
69    /// Get the byte offset of the cursor in the textarea content
70    pub fn get_cursor_byte_offset(&self) -> usize {
71        let (row, col) = self.textarea.cursor();
72        FuzzyFinderHelper::get_cursor_byte_offset(&self.content(), row, col)
73    }
74
75    /// Check if the fuzzy finder is active and the cursor is in a valid query position
76    pub fn is_in_fuzzy_query(&self) -> bool {
77        FuzzyFinderHelper::is_in_fuzzy_query(
78            self.fuzzy_finder.trigger_position(),
79            self.get_cursor_byte_offset(),
80            &self.content(),
81        )
82    }
83
84    /// Get the current fuzzy query if in a valid position
85    pub fn get_current_fuzzy_query(&self) -> Option<String> {
86        if self.is_in_fuzzy_query() {
87            let trigger_pos = self.fuzzy_finder.trigger_position()?;
88            FuzzyFinderHelper::get_current_fuzzy_query(
89                trigger_pos,
90                self.get_cursor_byte_offset(),
91                &self.content(),
92            )
93        } else {
94            None
95        }
96    }
97
98    /// Complete fuzzy finder by replacing the query text with the selected item's insert text
99    pub fn complete_picker_item(&mut self, item: &crate::tui::widgets::fuzzy_finder::PickerItem) {
100        if let Some(trigger_pos) = self.fuzzy_finder.trigger_position() {
101            let cursor_offset = self.get_cursor_byte_offset();
102            let content = self.content();
103
104            // Replace from the beginning of the trigger to the cursor position
105            // Preserve text up to the trigger character
106            let before_trigger = &content[..trigger_pos];
107            let after_cursor = &content[cursor_offset..];
108
109            let new_content = format!("{}{}{}", before_trigger, item.insert, after_cursor);
110
111            // Calculate new cursor position (after the inserted text)
112            let new_cursor_byte_pos = before_trigger.len() + item.insert.len();
113            let new_cursor_row = new_content[..new_cursor_byte_pos].matches('\n').count();
114            let last_newline_pos = new_content[..new_cursor_byte_pos]
115                .rfind('\n')
116                .map(|pos| pos + 1)
117                .unwrap_or(0);
118            let new_cursor_col = new_content[last_newline_pos..new_cursor_byte_pos]
119                .chars()
120                .count();
121
122            self.textarea = TextArea::from(new_content.lines().collect::<Vec<_>>());
123            self.textarea.move_cursor(tui_textarea::CursorMove::Jump(
124                new_cursor_row as u16,
125                new_cursor_col as u16,
126            ));
127            self.fuzzy_finder.deactivate();
128        }
129    }
130
131    /// Move to previous message in edit selection
132    pub fn edit_selection_prev(&mut self) -> Option<&(String, String)> {
133        self.edit_selection.select_prev()
134    }
135
136    /// Move to next message in edit selection
137    pub fn edit_selection_next(&mut self) -> Option<&(String, String)> {
138        self.edit_selection.select_next()
139    }
140
141    /// Get the currently selected message
142    pub fn get_selected_message(&self) -> Option<&(String, String)> {
143        self.edit_selection.get_selected()
144    }
145
146    /// Populate edit selection with messages from chat items
147    pub fn populate_edit_selection<'a>(&mut self, chat_items: impl Iterator<Item = &'a ChatItem>) {
148        self.edit_selection.populate_from_chat_items(chat_items);
149    }
150
151    /// Get the hovered edit selection ID
152    pub fn get_hovered_edit_id(&self) -> Option<&str> {
153        self.edit_selection.get_hovered_id()
154    }
155
156    /// Get the hovered edit selection ID (alias for compatibility)
157    pub fn get_hovered_id(&self) -> Option<&str> {
158        self.get_hovered_edit_id()
159    }
160
161    /// Clear edit selection
162    pub fn clear_edit_selection(&mut self) {
163        self.edit_selection.clear();
164    }
165
166    /// Activate fuzzy finder for files
167    pub fn activate_fuzzy(&mut self) {
168        let cursor_pos = self.get_cursor_byte_offset();
169        let content = self.content();
170        if cursor_pos > 0 && content.get(cursor_pos - 1..cursor_pos) == Some("@") {
171            // The trigger position is the '@' character just before the cursor
172            self.fuzzy_finder
173                .activate(cursor_pos - 1, FuzzyFinderMode::Files);
174        } else {
175            self.fuzzy_finder.activate(0, FuzzyFinderMode::Files);
176        }
177    }
178
179    /// Activate fuzzy finder for commands
180    pub fn activate_command_fuzzy(&mut self) {
181        let cursor_pos = self.get_cursor_byte_offset();
182        let content = self.content();
183        if content.get(cursor_pos..cursor_pos + 1) == Some("/") {
184            self.fuzzy_finder
185                .activate(cursor_pos + 1, FuzzyFinderMode::Commands);
186        } else {
187            self.fuzzy_finder.activate(0, FuzzyFinderMode::Commands);
188        }
189    }
190
191    /// Deactivate fuzzy finder
192    pub fn deactivate_fuzzy(&mut self) {
193        self.fuzzy_finder.deactivate();
194    }
195
196    /// Check if fuzzy finder is active
197    pub fn fuzzy_active(&self) -> bool {
198        self.fuzzy_finder.is_active()
199    }
200
201    /// Handle key event for fuzzy finder
202    pub async fn handle_fuzzy_key(
203        &mut self,
204        key: ratatui::crossterm::event::KeyEvent,
205    ) -> Option<crate::tui::widgets::fuzzy_finder::FuzzyFinderResult> {
206        use crate::tui::widgets::fuzzy_finder::FuzzyFinderMode;
207
208        // First handle navigation/selection in the fuzzy finder itself
209        let result = self.fuzzy_finder.handle_input(key);
210        if result.is_some() {
211            return result;
212        }
213
214        // Block up/down arrows from reaching the textarea when fuzzy finder is active
215        use ratatui::crossterm::event::KeyCode;
216        match key.code {
217            KeyCode::Up | KeyCode::Down => {
218                // These keys are for fuzzy finder navigation only
219                return None;
220            }
221            _ => {}
222        }
223
224        // Pass through to textarea for typing
225        self.textarea.input(Input::from(key));
226
227        // Ensure the cursor is still in a valid fuzzy-query position. If the
228        // user moved the cursor outside the query (e.g. with ← / → / Home /
229        // End or deleted back past the trigger) we close the fuzzy-finder so it
230        // no longer intercepts keys.
231        if !self.is_in_fuzzy_query() {
232            return Some(crate::tui::widgets::fuzzy_finder::FuzzyFinderResult::Close);
233        }
234
235        // After input, handle result updates based on the active fuzzy finder mode
236        if self.fuzzy_finder.mode() == FuzzyFinderMode::Files {
237            // Update fuzzy finder results based on current query
238            if let Some(query) = self.get_current_fuzzy_query() {
239                let file_results = self.file_cache.fuzzy_search(&query, Some(10)).await;
240                // Convert file paths to PickerItems
241                let picker_items = file_results
242                    .into_iter()
243                    .map(|path| {
244                        crate::tui::widgets::fuzzy_finder::PickerItem::new(
245                            path.clone(),
246                            format!("@{path} "),
247                        )
248                    })
249                    .collect();
250                self.fuzzy_finder.update_results(picker_items);
251                None
252            } else {
253                // Empty query – keep fuzzy finder open but clear results
254                self.fuzzy_finder.update_results(Vec::new());
255                None
256            }
257        } else {
258            None
259        }
260    }
261
262    /// Clear the input
263    pub fn clear(&mut self) {
264        self.textarea = TextArea::default();
265        self.textarea
266            .set_placeholder_text("Type your message here...");
267        self.textarea
268            .set_cursor_line_style(ratatui::style::Style::default());
269        self.textarea.set_cursor_style(
270            ratatui::style::Style::default().add_modifier(ratatui::style::Modifier::REVERSED),
271        );
272    }
273
274    /// Replace the content and optionally set cursor position
275    pub fn replace_content(&mut self, content: &str, cursor_pos: Option<(u16, u16)>) {
276        self.textarea = TextArea::from(content.lines().collect::<Vec<_>>());
277        if let Some((row, col)) = cursor_pos {
278            self.textarea
279                .move_cursor(tui_textarea::CursorMove::Jump(row, col));
280        }
281    }
282
283    /// Check if there is content in the textarea
284    pub fn has_content(&self) -> bool {
285        !self.textarea.lines().is_empty() && !self.content().trim().is_empty()
286    }
287
288    /// Insert string at current cursor position
289    pub fn insert_str(&mut self, text: &str) {
290        self.textarea.insert_str(text);
291    }
292
293    /// Handle input event (passthrough to textarea)
294    pub fn handle_input(&mut self, input: Input) {
295        self.textarea.input(input);
296    }
297
298    /// Set content from lines
299    pub fn set_content_from_lines(&mut self, lines: Vec<&str>) {
300        self.textarea = TextArea::from(lines.into_iter().map(String::from).collect::<Vec<_>>());
301    }
302
303    /// Get file cache reference (compatibility method)
304    pub fn file_cache(&self) -> &FileCache {
305        &self.file_cache
306    }
307
308    /// Calculate required height for the input panel
309    pub fn required_height(
310        &self,
311        current_approval: Option<&ToolCall>,
312        width: u16,
313        max_height: u16,
314    ) -> u16 {
315        if let Some(tool_call) = current_approval {
316            // If there's a pending approval, use the approval height calculation
317            Self::required_height_for_approval(tool_call, width, max_height)
318        } else {
319            // Otherwise use the regular calculation based on textarea lines
320            let line_count = self.textarea.lines().len().max(1);
321            // line count + 2 for borders + 1 for padding
322            (line_count + 3).min(max_height as usize) as u16
323        }
324    }
325
326    /// Calculate required height for approval mode
327    pub fn required_height_for_approval(tool_call: &ToolCall, width: u16, max_height: u16) -> u16 {
328        let theme = &Theme::default();
329        let formatter = crate::tui::widgets::formatters::get_formatter(&tool_call.name);
330        let preview_lines = formatter.approval(
331            &tool_call.parameters,
332            width.saturating_sub(4) as usize,
333            theme,
334        );
335        // 2 lines for header + preview lines + 2 for borders + 1 for padding
336        (2 + preview_lines.len() + 3).min(max_height as usize) as u16
337    }
338}
339
340/// Properties for the [`InputPanel`] widget.
341#[derive(Clone, Copy, Debug)]
342pub struct InputPanel<'a> {
343    pub input_mode: InputMode,
344    pub current_approval: Option<&'a ToolCall>,
345    pub is_processing: bool,
346    pub spinner_state: usize,
347    pub theme: &'a Theme,
348}
349
350impl<'a> InputPanel<'a> {
351    pub fn new(
352        input_mode: InputMode,
353        current_approval: Option<&'a ToolCall>,
354        is_processing: bool,
355        spinner_state: usize,
356        theme: &'a Theme,
357    ) -> Self {
358        Self {
359            input_mode,
360            current_approval,
361            is_processing,
362            spinner_state,
363            theme,
364        }
365    }
366}
367
368impl StatefulWidget for InputPanel<'_> {
369    type State = InputPanelState;
370
371    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
372        // Handle approval prompt
373        if let Some(tool_call) = self.current_approval {
374            ApprovalWidget::new(tool_call, self.theme).render(area, buf);
375            return;
376        }
377
378        // Handle edit message selection mode
379        if self.input_mode == InputMode::EditMessageSelection {
380            let title = ModeTitleWidget::new(
381                self.input_mode,
382                self.is_processing,
383                self.spinner_state,
384                self.theme,
385                state.has_content(),
386            )
387            .render();
388
389            let block = Block::default()
390                .borders(Borders::ALL)
391                .title(title)
392                .style(self.theme.style(Component::InputPanelBorderCommand))
393                .border_style(self.theme.style(Component::InputPanelBorderCommand));
394
395            EditSelectionWidget::new(self.theme).block(block).render(
396                area,
397                buf,
398                &mut state.edit_selection,
399            );
400            return;
401        }
402
403        // Handle normal text input modes
404        let title = ModeTitleWidget::new(
405            self.input_mode,
406            self.is_processing,
407            self.spinner_state,
408            self.theme,
409            state.has_content(),
410        )
411        .render();
412
413        let block = Block::default().borders(Borders::ALL).title(title);
414
415        TextAreaWidget::new(&mut state.textarea, self.theme)
416            .with_block(block)
417            .with_mode(self.input_mode)
418            .render(area, buf);
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_input_panel_state_default() {
428        let state = InputPanelState::default();
429        assert!(state.edit_selection.messages.is_empty());
430        assert_eq!(state.edit_selection.selected_index, 0);
431        assert!(state.edit_selection.hovered_id.is_none());
432        assert_eq!(state.content(), "");
433    }
434
435    #[test]
436    fn test_content_manipulation() {
437        let mut state = InputPanelState::default();
438
439        // Test setting content
440        state.replace_content("Hello\nWorld", None);
441        assert_eq!(state.content(), "Hello\nWorld");
442
443        // Test clearing
444        state.clear();
445        assert_eq!(state.content(), "");
446        assert!(!state.has_content());
447    }
448
449    #[test]
450    fn test_fuzzy_finder_activation() {
451        let mut state = InputPanelState::default();
452
453        // Set up content with @ trigger
454        state.replace_content("Check @", Some((0, 7)));
455
456        // Activate fuzzy finder
457        state.activate_fuzzy();
458        assert!(state.fuzzy_active());
459
460        // Deactivate
461        state.deactivate_fuzzy();
462        assert!(!state.fuzzy_active());
463    }
464
465    #[test]
466    fn test_cursor_byte_offset() {
467        let mut state = InputPanelState::default();
468        state.replace_content("Hello\nWorld", Some((1, 3)));
469
470        // Cursor at "Wor|ld" (row 1, col 3)
471        assert_eq!(state.get_cursor_byte_offset(), 9); // "Hello\n" (6) + "Wor" (3)
472    }
473}