1use chrono::{DateTime, Local};
7use ratatui::{
8 layout::{Constraint, Layout, Rect},
9 text::{Line, Span},
10 widgets::{Block, Borders, Clear, Paragraph},
11 Frame,
12};
13
14use crate::tui::themes::Theme;
15
16#[derive(Clone)]
18pub struct SessionInfo {
19 pub id: i64,
20 pub model: String,
21 pub context_used: i64,
22 pub context_limit: i32,
23 pub created_at: DateTime<Local>,
24}
25
26impl SessionInfo {
27 pub fn new(id: i64, model: String, context_limit: i32) -> Self {
28 Self {
29 id,
30 model,
31 context_used: 0,
32 context_limit,
33 created_at: Local::now(),
34 }
35 }
36}
37
38pub struct SessionPickerState {
40 pub active: bool,
42 pub selected_index: usize,
44 sessions: Vec<SessionInfo>,
46 current_session_id: i64,
48}
49
50impl SessionPickerState {
51 pub fn new() -> Self {
52 Self {
53 active: false,
54 selected_index: 0,
55 sessions: Vec::new(),
56 current_session_id: 0,
57 }
58 }
59
60 pub fn activate(&mut self, sessions: Vec<SessionInfo>, current_session_id: i64) {
62 self.active = true;
63 self.sessions = sessions;
64 self.current_session_id = current_session_id;
65
66 self.selected_index = self
68 .sessions
69 .iter()
70 .position(|s| s.id == current_session_id)
71 .unwrap_or(0);
72 }
73
74 pub fn cancel(&mut self) {
76 self.active = false;
77 }
78
79 pub fn confirm(&mut self) {
81 self.active = false;
82 }
83
84 pub fn select_previous(&mut self) {
86 if self.sessions.is_empty() {
87 return;
88 }
89 if self.selected_index == 0 {
90 self.selected_index = self.sessions.len() - 1;
91 } else {
92 self.selected_index -= 1;
93 }
94 }
95
96 pub fn select_next(&mut self) {
98 if self.sessions.is_empty() {
99 return;
100 }
101 self.selected_index = (self.selected_index + 1) % self.sessions.len();
102 }
103
104 pub fn selected_session_id(&self) -> Option<i64> {
106 self.sessions.get(self.selected_index).map(|s| s.id)
107 }
108
109 pub fn selected_session(&self) -> Option<&SessionInfo> {
111 self.sessions.get(self.selected_index)
112 }
113}
114
115impl Default for SessionPickerState {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121use std::any::Any;
124use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
125use super::{widget_ids, Widget, WidgetAction, WidgetKeyResult};
126
127#[derive(Debug, Clone, PartialEq)]
129pub enum SessionKeyAction {
130 None,
132 Selected(i64),
134 Cancelled,
136}
137
138impl SessionPickerState {
139 pub fn process_key(&mut self, key: KeyEvent) -> SessionKeyAction {
141 if !self.active {
142 return SessionKeyAction::None;
143 }
144
145 match key.code {
146 KeyCode::Up => {
147 self.select_previous();
148 SessionKeyAction::None
149 }
150 KeyCode::Down => {
151 self.select_next();
152 SessionKeyAction::None
153 }
154 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
155 self.select_previous();
156 SessionKeyAction::None
157 }
158 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
159 self.select_next();
160 SessionKeyAction::None
161 }
162 KeyCode::Enter => {
163 if let Some(session_id) = self.selected_session_id() {
164 self.confirm();
165 SessionKeyAction::Selected(session_id)
166 } else {
167 SessionKeyAction::None
168 }
169 }
170 KeyCode::Esc => {
171 self.cancel();
172 SessionKeyAction::Cancelled
173 }
174 _ => SessionKeyAction::None,
175 }
176 }
177}
178
179impl Widget for SessionPickerState {
180 fn id(&self) -> &'static str {
181 widget_ids::SESSION_PICKER
182 }
183
184 fn priority(&self) -> u8 {
185 250 }
187
188 fn is_active(&self) -> bool {
189 self.active
190 }
191
192 fn handle_key(&mut self, key: KeyEvent, _theme: &Theme) -> WidgetKeyResult {
193 if !self.active {
194 return WidgetKeyResult::NotHandled;
195 }
196
197 match self.process_key(key) {
198 SessionKeyAction::Selected(session_id) => {
199 WidgetKeyResult::Action(WidgetAction::SwitchSession { session_id })
200 }
201 SessionKeyAction::Cancelled => WidgetKeyResult::Action(WidgetAction::Close),
202 SessionKeyAction::None => WidgetKeyResult::Handled,
203 }
204 }
205
206 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
207 render_session_picker(self, frame, area, theme);
208 }
209
210 fn required_height(&self, _available: u16) -> u16 {
211 0 }
213
214 fn blocks_input(&self) -> bool {
215 self.active
216 }
217
218 fn is_overlay(&self) -> bool {
219 true
220 }
221
222 fn as_any(&self) -> &dyn Any {
223 self
224 }
225
226 fn as_any_mut(&mut self) -> &mut dyn Any {
227 self
228 }
229
230 fn into_any(self: Box<Self>) -> Box<dyn Any> {
231 self
232 }
233}
234
235pub fn render_session_picker(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
237 if !state.active {
238 return;
239 }
240
241 frame.render_widget(Clear, area);
243
244 let main_chunks =
246 Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).split(area);
247
248 render_session_list(state, frame, main_chunks[0], theme);
250
251 render_help_bar(frame, main_chunks[1], theme);
253}
254
255fn render_session_list(
257 state: &SessionPickerState,
258 frame: &mut Frame,
259 area: Rect,
260 theme: &Theme,
261) {
262 let mut lines = Vec::new();
263
264 lines.push(Line::from(""));
266
267 if state.sessions.is_empty() {
268 lines.push(Line::from(Span::styled(
269 " No sessions available",
270 theme.text(),
271 )));
272 } else {
273 let max_model_len = state
275 .sessions
276 .iter()
277 .map(|s| s.model.len())
278 .max()
279 .unwrap_or(0);
280
281 for (idx, session) in state.sessions.iter().enumerate() {
282 let is_selected = idx == state.selected_index;
283 let is_current = session.id == state.current_session_id;
284
285 let marker = if is_current { "*" } else { " " };
286 let prefix = if is_selected { " > " } else { " " };
287 let context_str = format_context(session.context_used, session.context_limit);
288 let time_str = session.created_at.format("%H:%M:%S").to_string();
289
290 let text = format!(
292 "{}{} Session {} | Model: {:<width$} | Context: {} | Created: {}",
293 prefix,
294 marker,
295 session.id,
296 session.model,
297 context_str,
298 time_str,
299 width = max_model_len
300 );
301
302 let style = if is_selected {
303 theme.popup_selected_bg().patch(theme.popup_item_selected())
304 } else {
305 theme.popup_item()
306 };
307
308 let inner_width = area.width.saturating_sub(2) as usize;
310 let padded = format!("{:<width$}", text, width = inner_width);
311 lines.push(Line::from(Span::styled(padded, style)));
312 }
313 }
314
315 let block = Block::default()
316 .title(" Sessions ")
317 .borders(Borders::ALL)
318 .border_style(theme.popup_border());
319
320 let list = Paragraph::new(lines)
321 .block(block)
322 .style(theme.background().patch(theme.text()));
323
324 frame.render_widget(list, area);
325}
326
327fn render_help_bar(frame: &mut Frame, area: Rect, theme: &Theme) {
329 let help_text =
330 " Arrow keys to navigate | Enter to switch | Esc to cancel | * = current session";
331 let help = Paragraph::new(help_text).style(theme.status_help());
332 frame.render_widget(help, area);
333}
334
335fn format_context(used: i64, limit: i32) -> String {
337 let used_str = format_tokens(used);
338 let limit_str = format_tokens(limit as i64);
339 format!("{} / {}", used_str, limit_str)
340}
341
342fn format_tokens(tokens: i64) -> String {
344 if tokens >= 100_000 {
345 format!("{}K", tokens / 1000)
346 } else if tokens >= 1000 {
347 format!("{:.1}K", tokens as f64 / 1000.0)
348 } else {
349 format!("{}", tokens)
350 }
351}