steer_tui/tui/widgets/input_panel/
edit_selection.rs

1//! Edit selection widget for browsing and selecting previous messages
2
3use ratatui::layout::Rect;
4use ratatui::prelude::{Buffer, StatefulWidget};
5use ratatui::style::Modifier;
6use ratatui::widgets::{Block, List, ListItem, ListState};
7
8use steer_core::app::conversation::Role;
9
10use crate::tui::model::{ChatItem, ChatItemData};
11use crate::tui::theme::{Component, Theme};
12
13/// State for the edit selection widget
14#[derive(Debug, Default)]
15pub struct EditSelectionState {
16    pub messages: Vec<(String, String)>,
17    pub selected_index: usize,
18    pub hovered_id: Option<String>,
19}
20
21impl EditSelectionState {
22    /// Move selection to previous message
23    pub fn select_prev(&mut self) -> Option<&(String, String)> {
24        if self.selected_index > 0 {
25            self.selected_index -= 1;
26        }
27        self.messages.get(self.selected_index)
28    }
29
30    /// Move selection to next message
31    pub fn select_next(&mut self) -> Option<&(String, String)> {
32        if self.selected_index + 1 < self.messages.len() {
33            self.selected_index += 1;
34        }
35        self.messages.get(self.selected_index)
36    }
37
38    /// Get currently selected message
39    pub fn get_selected(&self) -> Option<&(String, String)> {
40        self.messages.get(self.selected_index)
41    }
42
43    /// Populate the selection with messages from chat items
44    pub fn populate_from_chat_items<'a>(&mut self, chat_items: impl Iterator<Item = &'a ChatItem>) {
45        self.messages = chat_items
46            .filter_map(|item| match &item.data {
47                ChatItemData::Message(message) => {
48                    if message.role() == Role::User {
49                        Some((item.id().to_string(), message.content_string()))
50                    } else {
51                        None
52                    }
53                }
54                _ => None,
55            })
56            .collect();
57
58        // Select the last message by default
59        if !self.messages.is_empty() {
60            self.selected_index = self.messages.len() - 1;
61        } else {
62            self.selected_index = 0;
63            self.hovered_id = None;
64            return;
65        }
66
67        // Update hovered ID
68        self.hovered_id = self.get_selected().map(|(id, _)| id.clone());
69    }
70
71    /// Get the ID of the currently hovered message
72    pub fn get_hovered_id(&self) -> Option<&str> {
73        self.hovered_id.as_deref()
74    }
75
76    /// Clear all selection state
77    pub fn clear(&mut self) {
78        self.messages.clear();
79        self.selected_index = 0;
80        self.hovered_id = None;
81    }
82}
83
84/// Widget for displaying and selecting previous messages
85#[derive(Debug)]
86pub struct EditSelectionWidget<'a> {
87    theme: &'a Theme,
88    block: Option<Block<'a>>,
89}
90
91impl<'a> EditSelectionWidget<'a> {
92    /// Create a new edit selection widget
93    pub fn new(theme: &'a Theme) -> Self {
94        Self { theme, block: None }
95    }
96
97    /// Set the block for the widget
98    pub fn block(mut self, block: Block<'a>) -> Self {
99        self.block = Some(block);
100        self
101    }
102
103    /// Calculate the visible window for the selection list
104    fn calculate_window(
105        &self,
106        total: usize,
107        selected: usize,
108        max_visible: usize,
109    ) -> (usize, usize) {
110        if total <= max_visible {
111            (0, total)
112        } else {
113            let half_window = max_visible / 2;
114            if selected < half_window {
115                (0, max_visible)
116            } else if selected >= total - half_window {
117                (total - max_visible, total)
118            } else {
119                let start = selected - half_window;
120                (start, start + max_visible)
121            }
122        }
123    }
124
125    /// Format a message preview for display
126    fn format_message_preview(&self, content: &str, max_width: usize) -> String {
127        content
128            .lines()
129            .next()
130            .unwrap_or("")
131            .chars()
132            .take(max_width)
133            .collect()
134    }
135}
136
137impl<'a> StatefulWidget for EditSelectionWidget<'a> {
138    type State = EditSelectionState;
139
140    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
141        let mut items: Vec<ListItem> = Vec::new();
142
143        if state.messages.is_empty() {
144            items.push(
145                ListItem::new("No user messages to edit")
146                    .style(self.theme.style(Component::DimText)),
147            );
148
149            // Render empty list
150            let list = List::new(items);
151            let list = if let Some(block) = self.block {
152                list.block(block)
153            } else {
154                list
155            };
156            let mut list_state = ListState::default();
157            StatefulWidget::render(list, area, buf, &mut list_state);
158            return;
159        }
160
161        // Calculate visible window
162        let max_visible = 3;
163        let (start_idx, end_idx) =
164            self.calculate_window(state.messages.len(), state.selected_index, max_visible);
165
166        // Create list items for visible range
167        let max_width = area.width.saturating_sub(4) as usize;
168        for idx in start_idx..end_idx {
169            let (_, content) = &state.messages[idx];
170            let preview = self.format_message_preview(content, max_width);
171            items.push(ListItem::new(preview));
172        }
173
174        // Set up list state
175        let mut list_state = ListState::default();
176        list_state.select(Some(state.selected_index.saturating_sub(start_idx)));
177
178        // Create and render list
179        let highlight_style = self
180            .theme
181            .style(Component::SelectionHighlight)
182            .add_modifier(Modifier::REVERSED);
183
184        let list = List::new(items).highlight_style(highlight_style);
185        let list = if let Some(block) = self.block {
186            list.block(block)
187        } else {
188            list
189        };
190
191        StatefulWidget::render(list, area, buf, &mut list_state);
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_edit_selection_state_default() {
201        let state = EditSelectionState::default();
202        assert!(state.messages.is_empty());
203        assert_eq!(state.selected_index, 0);
204        assert!(state.hovered_id.is_none());
205    }
206
207    #[test]
208    fn test_edit_selection_navigation() {
209        let mut state = EditSelectionState {
210            messages: vec![
211                ("msg1".to_string(), "First message".to_string()),
212                ("msg2".to_string(), "Second message".to_string()),
213                ("msg3".to_string(), "Third message".to_string()),
214            ],
215            selected_index: 1,
216            hovered_id: Some("msg2".to_string()),
217        };
218
219        // Test moving previous
220        state.select_prev();
221        assert_eq!(state.selected_index, 0);
222        assert_eq!(state.get_selected().unwrap().0, "msg1");
223
224        // Test boundary - can't go before first
225        state.select_prev();
226        assert_eq!(state.selected_index, 0);
227        assert_eq!(state.get_selected().unwrap().0, "msg1");
228
229        // Test moving next
230        state.select_next();
231        assert_eq!(state.selected_index, 1);
232        assert_eq!(state.get_selected().unwrap().0, "msg2");
233
234        state.select_next();
235        assert_eq!(state.selected_index, 2);
236        assert_eq!(state.get_selected().unwrap().0, "msg3");
237
238        // Test boundary - can't go past last
239        state.select_next();
240        assert_eq!(state.selected_index, 2);
241        assert_eq!(state.get_selected().unwrap().0, "msg3");
242    }
243
244    #[test]
245    fn test_clear_selection() {
246        let mut state = EditSelectionState {
247            messages: vec![("msg1".to_string(), "First message".to_string())],
248            selected_index: 0,
249            hovered_id: Some("msg1".to_string()),
250        };
251
252        state.clear();
253        assert!(state.messages.is_empty());
254        assert_eq!(state.selected_index, 0);
255        assert!(state.hovered_id.is_none());
256    }
257
258    #[test]
259    fn test_populate_from_chat_items() {
260        use steer_core::app::conversation::{AssistantContent, Message, MessageData, UserContent};
261
262        let chat_items = vec![
263            ChatItem {
264                parent_chat_item_id: None,
265                data: ChatItemData::Message(Message {
266                    timestamp: 1000,
267                    id: "user1".to_string(),
268                    parent_message_id: None,
269                    data: MessageData::User {
270                        content: vec![UserContent::Text {
271                            text: "First user message".to_string(),
272                        }],
273                    },
274                }),
275            },
276            ChatItem {
277                parent_chat_item_id: None,
278                data: ChatItemData::Message(Message {
279                    timestamp: 2000,
280                    id: "assistant1".to_string(),
281                    parent_message_id: Some("user1".to_string()),
282                    data: MessageData::Assistant {
283                        content: vec![AssistantContent::Text {
284                            text: "Assistant response".to_string(),
285                        }],
286                    },
287                }),
288            },
289            ChatItem {
290                parent_chat_item_id: None,
291                data: ChatItemData::Message(Message {
292                    timestamp: 3000,
293                    id: "user2".to_string(),
294                    parent_message_id: Some("assistant1".to_string()),
295                    data: MessageData::User {
296                        content: vec![UserContent::Text {
297                            text: "Second user message".to_string(),
298                        }],
299                    },
300                }),
301            },
302        ];
303
304        let mut state = EditSelectionState::default();
305        state.populate_from_chat_items(chat_items.iter());
306
307        // Should only include user messages
308        assert_eq!(state.messages.len(), 2);
309        assert_eq!(state.messages[0].0, "user1");
310        assert_eq!(state.messages[0].1, "First user message");
311        assert_eq!(state.messages[1].0, "user2");
312        assert_eq!(state.messages[1].1, "Second user message");
313
314        // Should select the last message
315        assert_eq!(state.selected_index, 1);
316        assert_eq!(state.hovered_id, Some("user2".to_string()));
317    }
318}