steer_tui/tui/widgets/input_panel/
mod.rs1mod 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
17use 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#[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 Self::new("default".to_string())
44 }
45}
46
47impl InputPanelState {
48 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 pub fn content(&self) -> String {
66 self.textarea.lines().join("\n")
67 }
68
69 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 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 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 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 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 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 pub fn edit_selection_prev(&mut self) -> Option<&(String, String)> {
133 self.edit_selection.select_prev()
134 }
135
136 pub fn edit_selection_next(&mut self) -> Option<&(String, String)> {
138 self.edit_selection.select_next()
139 }
140
141 pub fn get_selected_message(&self) -> Option<&(String, String)> {
143 self.edit_selection.get_selected()
144 }
145
146 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 pub fn get_hovered_edit_id(&self) -> Option<&str> {
153 self.edit_selection.get_hovered_id()
154 }
155
156 pub fn get_hovered_id(&self) -> Option<&str> {
158 self.get_hovered_edit_id()
159 }
160
161 pub fn clear_edit_selection(&mut self) {
163 self.edit_selection.clear();
164 }
165
166 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 self.fuzzy_finder
173 .activate(cursor_pos - 1, FuzzyFinderMode::Files);
174 } else {
175 self.fuzzy_finder.activate(0, FuzzyFinderMode::Files);
176 }
177 }
178
179 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 pub fn deactivate_fuzzy(&mut self) {
193 self.fuzzy_finder.deactivate();
194 }
195
196 pub fn fuzzy_active(&self) -> bool {
198 self.fuzzy_finder.is_active()
199 }
200
201 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 let result = self.fuzzy_finder.handle_input(key);
210 if result.is_some() {
211 return result;
212 }
213
214 use ratatui::crossterm::event::KeyCode;
216 match key.code {
217 KeyCode::Up | KeyCode::Down => {
218 return None;
220 }
221 _ => {}
222 }
223
224 self.textarea.input(Input::from(key));
226
227 if !self.is_in_fuzzy_query() {
232 return Some(crate::tui::widgets::fuzzy_finder::FuzzyFinderResult::Close);
233 }
234
235 if self.fuzzy_finder.mode() == FuzzyFinderMode::Files {
237 if let Some(query) = self.get_current_fuzzy_query() {
239 let file_results = self.file_cache.fuzzy_search(&query, Some(10)).await;
240 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 self.fuzzy_finder.update_results(Vec::new());
255 None
256 }
257 } else {
258 None
259 }
260 }
261
262 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 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 pub fn has_content(&self) -> bool {
285 !self.textarea.lines().is_empty() && !self.content().trim().is_empty()
286 }
287
288 pub fn insert_str(&mut self, text: &str) {
290 self.textarea.insert_str(text);
291 }
292
293 pub fn handle_input(&mut self, input: Input) {
295 self.textarea.input(input);
296 }
297
298 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 pub fn file_cache(&self) -> &FileCache {
305 &self.file_cache
306 }
307
308 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 Self::required_height_for_approval(tool_call, width, max_height)
318 } else {
319 let line_count = self.textarea.lines().len().max(1);
321 (line_count + 3).min(max_height as usize) as u16
323 }
324 }
325
326 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 + preview_lines.len() + 3).min(max_height as usize) as u16
337 }
338}
339
340#[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 if let Some(tool_call) = self.current_approval {
374 ApprovalWidget::new(tool_call, self.theme).render(area, buf);
375 return;
376 }
377
378 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 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 state.replace_content("Hello\nWorld", None);
441 assert_eq!(state.content(), "Hello\nWorld");
442
443 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 state.replace_content("Check @", Some((0, 7)));
455
456 state.activate_fuzzy();
458 assert!(state.fuzzy_active());
459
460 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 assert_eq!(state.get_cursor_byte_offset(), 9); }
473}