Skip to main content

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