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
21fn 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#[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 pub file_cache: FileCache,
52 pub fuzzy_finder: FuzzyFinder,
54}
55
56impl Default for InputPanelState {
57 fn default() -> Self {
58 Self::new("default".to_string())
60 }
61}
62
63impl InputPanelState {
64 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 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; } else {
89 offset += line.char_indices().nth(col).map_or(line.len(), |(i, _)| i);
91 break;
92 }
93 }
94 offset
95 }
96
97 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; }
112
113 let content = self.content();
114 let query_candidate = &content[at_pos + 1..cursor_offset];
116
117 !query_candidate.chars().any(char::is_whitespace)
119 }
120
121 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(); 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 pub fn handle_input(&mut self, input: Input) {
137 self.textarea.input(input);
138 }
139
140 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 let content = self.content();
147 let mut new_content = String::new();
148
149 new_content.push_str(&content[..=at_pos]);
151
152 new_content.push_str(selected_path);
154 new_content.push(' ');
155
156 if cursor_offset < content.len() {
158 new_content.push_str(&content[cursor_offset..]);
159 }
160
161 let lines: Vec<&str> = new_content.lines().collect();
163 self.set_content_from_lines(lines);
164
165 let new_cursor_pos_bytes = at_pos + 1 + selected_path.len() + 1;
167
168 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 let byte_offset_in_line = new_cursor_pos_bytes - bytes_traversed;
175 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; }
185 }
186 }
187
188 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 let content = self.content();
195 let mut new_content = String::new();
196
197 new_content.push_str(&content[..trigger_pos]);
199
200 new_content.push('/');
202 new_content.push_str(selected_command);
203 new_content.push(' ');
204
205 if cursor_offset < content.len() {
207 new_content.push_str(&content[cursor_offset..]);
208 }
209
210 let lines: Vec<&str> = new_content.lines().collect();
212 self.set_content_from_lines(lines);
213
214 let new_cursor_pos_bytes = trigger_pos + 1 + selected_command.len() + 1;
216
217 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 let byte_offset_in_line = new_cursor_pos_bytes - bytes_traversed;
224 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; }
234 }
235 }
236
237 pub fn insert_str(&mut self, s: &str) {
239 self.textarea.insert_str(s);
240 }
241
242 pub fn content(&self) -> String {
244 self.textarea.lines().join("\n")
245 }
246
247 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 pub fn set_content_from_lines(&mut self, lines: Vec<&str>) {
259 self.textarea = TextArea::from(lines);
260 }
261
262 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 Self::required_height_for_approval(tool_call, width, max_height)
272 } else {
273 let line_count = self.textarea.lines().len().max(1);
275 (line_count + 3).min(max_height as usize) as u16
277 }
278 }
279
280 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 + preview_lines.len() + 3).min(max_height as usize) as u16
291 }
292
293 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 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 pub fn get_selected_message(&self) -> Option<&(String, String)> {
317 self.edit_selection_messages.get(self.edit_selection_index)
318 }
319
320 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 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 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 fn update_hovered_id(&mut self) {
360 self.edit_selection_hovered_id = self.get_selected_message().map(|(id, _)| id.clone());
361 }
362
363 pub fn get_hovered_id(&self) -> Option<&str> {
365 self.edit_selection_hovered_id.as_deref()
366 }
367
368 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 pub fn activate_fuzzy(&mut self) {
377 let cursor_pos = self.get_cursor_byte_offset();
379 if cursor_pos > 0 {
380 self.fuzzy_finder
382 .activate(cursor_pos - 1, FuzzyFinderMode::Files);
383 } else {
384 self.fuzzy_finder.activate(0, FuzzyFinderMode::Files);
386 }
387 }
388
389 pub fn activate_command_fuzzy(&mut self) {
391 let cursor_pos = self.get_cursor_byte_offset();
393 if cursor_pos > 0 {
394 self.fuzzy_finder
396 .activate(cursor_pos - 1, FuzzyFinderMode::Commands);
397 } else {
398 self.fuzzy_finder.activate(0, FuzzyFinderMode::Commands);
400 }
401 }
402
403 pub fn deactivate_fuzzy(&mut self) {
405 self.fuzzy_finder.deactivate();
406 }
407
408 pub fn fuzzy_active(&self) -> bool {
410 self.fuzzy_finder.is_active()
411 }
412
413 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 let result = self.fuzzy_finder.handle_input(key);
423
424 if result.is_some() {
425 return result;
427 }
428
429 match key.code {
431 KeyCode::Up | KeyCode::Down => {
432 return None;
434 }
435 _ => {}
436 }
437
438 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 let input = Input::from(key);
455 self.textarea.input(input);
456
457 use crate::tui::widgets::fuzzy_finder::FuzzyFinderMode;
459
460 if self.fuzzy_finder.mode() == FuzzyFinderMode::Files {
461 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 } else {
467 Some(crate::tui::widgets::fuzzy_finder::FuzzyFinderResult::Close)
469 }
470 } else {
471 None
473 }
474 }
475
476 pub fn file_cache(&self) -> &FileCache {
478 &self.file_cache
479 }
480
481 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 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#[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 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 }
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 }
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 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 let mut title_spans = vec![];
736
737 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 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 let active = self.theme.style(Component::InputPanelBorderActive);
756 input_block = input_block.style(active).border_style(active);
757 }
758 InputMode::VimNormal => {
759 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 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 let list = List::new(items).block(input_block);
840 Widget::render(list, area, buf);
841 return;
842 }
843
844 state.textarea.set_block(input_block);
846 state.textarea.render(area, buf);
847
848 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 state.insert_str("Hello, world!");
893 assert_eq!(state.content(), "Hello, world!");
894
895 state.clear();
897 assert_eq!(state.content(), "");
898
899 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 assert_eq!(state.get_hovered_id(), Some("msg2"));
919
920 state.edit_selection_prev();
922 assert_eq!(state.edit_selection_index, 0);
923 assert_eq!(state.get_hovered_id(), Some("msg1"));
924
925 state.edit_selection_prev();
927 assert_eq!(state.edit_selection_index, 0);
928 assert_eq!(state.get_hovered_id(), Some("msg1"));
929
930 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 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 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 assert_eq!(state.required_height(None, 80, 10), 4); state.set_content_from_lines(vec!["Line 1", "Line 2", "Line 3"]);
971 assert_eq!(state.required_height(None, 80, 10), 6); 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); }
977
978 #[test]
979 fn test_populate_edit_selection() {
980 let mut state = InputPanelState::default();
981
982 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 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 assert_eq!(state.edit_selection_index, 1);
1023 assert_eq!(state.get_hovered_id(), Some("user2"));
1024 }
1025}