steer_tui/tui/widgets/input_panel/
mod.rs1mod 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
17use 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#[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 Self::new("default".to_string())
42 }
43}
44
45impl InputPanelState {
46 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 pub fn content(&self) -> String {
63 self.textarea.lines().join("\n")
64 }
65
66 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 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 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 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 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 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 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 self.fuzzy_finder
134 .activate(cursor_pos - 1, FuzzyFinderMode::Files);
135 } else {
136 self.fuzzy_finder.activate(0, FuzzyFinderMode::Files);
137 }
138 }
139
140 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 pub fn deactivate_fuzzy(&mut self) {
154 self.fuzzy_finder.deactivate();
155 }
156
157 pub fn fuzzy_active(&self) -> bool {
159 self.fuzzy_finder.is_active()
160 }
161
162 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 let result = self.fuzzy_finder.handle_input(key);
171 if result.is_some() {
172 return result;
173 }
174
175 use ratatui::crossterm::event::KeyCode;
177 match key.code {
178 KeyCode::Up | KeyCode::Down => {
179 return None;
181 }
182 _ => {}
183 }
184
185 self.textarea.input(Input::from(key));
187
188 if !self.is_in_fuzzy_query() {
193 return Some(crate::tui::widgets::fuzzy_finder::FuzzyFinderResult::Close);
194 }
195
196 if self.fuzzy_finder.mode() == FuzzyFinderMode::Files {
198 if let Some(query) = self.get_current_fuzzy_query() {
200 let file_results = self.file_cache.fuzzy_search(&query, Some(10)).await;
201 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 self.fuzzy_finder.update_results(Vec::new());
216 None
217 }
218 } else {
219 None
220 }
221 }
222
223 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 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 pub fn has_content(&self) -> bool {
246 !self.textarea.lines().is_empty() && !self.content().trim().is_empty()
247 }
248
249 pub fn insert_str(&mut self, text: &str) {
251 self.textarea.insert_str(text);
252 }
253
254 pub fn handle_input(&mut self, input: Input) {
256 self.textarea.input(input);
257 }
258
259 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 pub fn file_cache(&self) -> &FileCache {
266 &self.file_cache
267 }
268
269 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 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, 0);
286 base_height.saturating_add(queued_height).min(max_height)
287 } else {
288 base_height
289 }
290 }
291
292 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 + preview_lines.len() + 3).min(max_height as usize) as u16
303 }
304}
305
306#[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 queued_attachment_count: u32,
318 pub attachment_count: usize,
319 pub theme: &'a Theme,
320}
321
322#[derive(Clone, Copy, Debug)]
324pub struct InputPanel<'a> {
325 pub input_mode: InputMode,
326 pub current_approval: Option<&'a ToolCall>,
327 pub is_processing: bool,
328 pub spinner_state: usize,
329 pub is_editing: bool,
330 pub editing_preview: Option<&'a str>,
331 pub queued_count: usize,
332 pub queued_preview: Option<&'a str>,
333 pub queued_attachment_count: u32,
334 pub attachment_count: usize,
335 pub theme: &'a Theme,
336}
337
338impl<'a> InputPanel<'a> {
339 pub fn new(params: InputPanelParams<'a>) -> Self {
340 Self {
341 input_mode: params.input_mode,
342 current_approval: params.current_approval,
343 is_processing: params.is_processing,
344 spinner_state: params.spinner_state,
345 is_editing: params.is_editing,
346 editing_preview: params.editing_preview,
347 queued_count: params.queued_count,
348 queued_preview: params.queued_preview,
349 queued_attachment_count: params.queued_attachment_count,
350 attachment_count: params.attachment_count,
351 theme: params.theme,
352 }
353 }
354}
355
356impl StatefulWidget for InputPanel<'_> {
357 type State = InputPanelState;
358
359 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
360 if let Some(tool_call) = self.current_approval {
361 ApprovalWidget::new(tool_call, self.theme).render(area, buf);
362 return;
363 }
364
365 let title = ModeTitleWidget::new(ModeTitleParams {
366 mode: self.input_mode,
367 is_processing: self.is_processing,
368 spinner_state: self.spinner_state,
369 is_editing: self.is_editing,
370 editing_preview: self.editing_preview,
371 theme: self.theme,
372 has_content: state.has_content(),
373 queued_count: self.queued_count,
374 attachment_count: self.attachment_count,
375 })
376 .render();
377
378 let has_queue = self.queued_preview.is_some() || self.queued_count > 0;
379 let mut layout = vec![area];
380 if has_queue {
381 let queue_height = QueuedPreviewWidget::required_height(
382 area.width,
383 self.queued_preview,
384 self.queued_attachment_count,
385 )
386 .min(area.height);
387 let chunks = ratatui::layout::Layout::default()
388 .direction(ratatui::layout::Direction::Vertical)
389 .constraints([
390 ratatui::layout::Constraint::Length(queue_height),
391 ratatui::layout::Constraint::Min(1),
392 ])
393 .split(area);
394 layout = vec![chunks[0], chunks[1]];
395 }
396
397 if has_queue {
398 QueuedPreviewWidget::new(
399 self.queued_preview,
400 self.queued_attachment_count,
401 self.theme,
402 )
403 .render(layout[0], buf);
404 }
405
406 let input_rect = if has_queue { layout[1] } else { area };
407 let block = Block::default()
408 .borders(Borders::NONE)
409 .padding(Padding::new(1, 1, 0, 1))
410 .title(title);
411
412 TextAreaWidget::new(&mut state.textarea, self.theme)
413 .with_block(block)
414 .with_mode(self.input_mode)
415 .with_editing(self.is_editing)
416 .render(input_rect, buf);
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_input_panel_state_default() {
426 let state = InputPanelState::default();
427 assert_eq!(state.content(), "");
428 }
429
430 #[test]
431 fn test_content_manipulation() {
432 let mut state = InputPanelState::default();
433
434 state.replace_content("Hello\nWorld", None);
436 assert_eq!(state.content(), "Hello\nWorld");
437
438 state.clear();
440 assert_eq!(state.content(), "");
441 assert!(!state.has_content());
442 }
443
444 #[test]
445 fn test_fuzzy_finder_activation() {
446 let mut state = InputPanelState::default();
447
448 state.replace_content("Check @", Some((0, 7)));
450
451 state.activate_fuzzy();
453 assert!(state.fuzzy_active());
454
455 state.deactivate_fuzzy();
457 assert!(!state.fuzzy_active());
458 }
459
460 #[test]
461 fn test_cursor_byte_offset() {
462 let mut state = InputPanelState::default();
463 state.replace_content("Hello\nWorld", Some((1, 3)));
464
465 assert_eq!(state.get_cursor_byte_offset(), 9); }
468}