steer_tui/tui/widgets/input_panel/
edit_selection.rs1use 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#[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 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 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 pub fn get_selected(&self) -> Option<&(String, String)> {
40 self.messages.get(self.selected_index)
41 }
42
43 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 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 self.hovered_id = self.get_selected().map(|(id, _)| id.clone());
69 }
70
71 pub fn get_hovered_id(&self) -> Option<&str> {
73 self.hovered_id.as_deref()
74 }
75
76 pub fn clear(&mut self) {
78 self.messages.clear();
79 self.selected_index = 0;
80 self.hovered_id = None;
81 }
82}
83
84#[derive(Debug)]
86pub struct EditSelectionWidget<'a> {
87 theme: &'a Theme,
88 block: Option<Block<'a>>,
89}
90
91impl<'a> EditSelectionWidget<'a> {
92 pub fn new(theme: &'a Theme) -> Self {
94 Self { theme, block: None }
95 }
96
97 pub fn block(mut self, block: Block<'a>) -> Self {
99 self.block = Some(block);
100 self
101 }
102
103 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 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 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 let max_visible = 3;
163 let (start_idx, end_idx) =
164 self.calculate_window(state.messages.len(), state.selected_index, max_visible);
165
166 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 let mut list_state = ListState::default();
176 list_state.select(Some(state.selected_index.saturating_sub(start_idx)));
177
178 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 state.select_prev();
221 assert_eq!(state.selected_index, 0);
222 assert_eq!(state.get_selected().unwrap().0, "msg1");
223
224 state.select_prev();
226 assert_eq!(state.selected_index, 0);
227 assert_eq!(state.get_selected().unwrap().0, "msg1");
228
229 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 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 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 assert_eq!(state.selected_index, 1);
316 assert_eq!(state.hovered_id, Some("user2".to_string()));
317 }
318}