1use chrono::{DateTime, Local};
7use ratatui::{
8 Frame,
9 layout::{Constraint, Layout, Rect},
10 text::{Line, Span},
11 widgets::{Block, Borders, Clear, Paragraph},
12};
13
14use crate::themes::Theme;
15
16pub mod defaults {
18 pub const CURRENT_MARKER: &str = "*";
20 pub const NO_MARKER: &str = " ";
22 pub const SELECTION_PREFIX: &str = " > ";
24 pub const NO_SELECTION_PREFIX: &str = " ";
26 pub const TITLE: &str = " Sessions ";
28 pub const HELP_TEXT: &str =
30 " Arrow keys to navigate | Enter to switch | Esc to cancel | * = current session";
31 pub const NO_SESSIONS_MESSAGE: &str = " No sessions available";
33}
34
35#[derive(Clone)]
37pub struct SessionPickerConfig {
38 pub current_marker: String,
40 pub no_marker: String,
42 pub selection_prefix: String,
44 pub no_selection_prefix: String,
46 pub title: String,
48 pub help_text: String,
50 pub no_sessions_message: String,
52}
53
54impl Default for SessionPickerConfig {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl SessionPickerConfig {
61 pub fn new() -> Self {
63 Self {
64 current_marker: defaults::CURRENT_MARKER.to_string(),
65 no_marker: defaults::NO_MARKER.to_string(),
66 selection_prefix: defaults::SELECTION_PREFIX.to_string(),
67 no_selection_prefix: defaults::NO_SELECTION_PREFIX.to_string(),
68 title: defaults::TITLE.to_string(),
69 help_text: defaults::HELP_TEXT.to_string(),
70 no_sessions_message: defaults::NO_SESSIONS_MESSAGE.to_string(),
71 }
72 }
73
74 pub fn with_current_marker(mut self, marker: impl Into<String>) -> Self {
76 self.current_marker = marker.into();
77 self
78 }
79
80 pub fn with_selection_prefix(mut self, prefix: impl Into<String>) -> Self {
82 self.selection_prefix = prefix.into();
83 self
84 }
85
86 pub fn with_title(mut self, title: impl Into<String>) -> Self {
88 self.title = title.into();
89 self
90 }
91
92 pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
94 self.help_text = text.into();
95 self
96 }
97
98 pub fn with_no_sessions_message(mut self, message: impl Into<String>) -> Self {
100 self.no_sessions_message = message.into();
101 self
102 }
103}
104
105#[derive(Clone)]
108pub struct SessionInfo {
109 pub id: i64,
111 pub model: String,
113 pub context_used: i64,
115 pub context_limit: i32,
117 pub created_at: DateTime<Local>,
119}
120
121impl SessionInfo {
122 pub fn new(id: i64, model: String, context_limit: i32) -> Self {
124 Self {
125 id,
126 model,
127 context_used: 0,
128 context_limit,
129 created_at: Local::now(),
130 }
131 }
132}
133
134pub struct SessionPickerState {
136 pub active: bool,
138 pub selected_index: usize,
140 sessions: Vec<SessionInfo>,
142 current_session_id: i64,
144 config: SessionPickerConfig,
146}
147
148impl SessionPickerState {
149 pub fn new() -> Self {
150 Self::with_config(SessionPickerConfig::new())
151 }
152
153 pub fn with_config(config: SessionPickerConfig) -> Self {
155 Self {
156 active: false,
157 selected_index: 0,
158 sessions: Vec::new(),
159 current_session_id: 0,
160 config,
161 }
162 }
163
164 pub fn config(&self) -> &SessionPickerConfig {
166 &self.config
167 }
168
169 pub fn set_config(&mut self, config: SessionPickerConfig) {
171 self.config = config;
172 }
173
174 pub fn activate(&mut self, sessions: Vec<SessionInfo>, current_session_id: i64) {
176 self.active = true;
177 self.sessions = sessions;
178 self.current_session_id = current_session_id;
179
180 self.selected_index = self
182 .sessions
183 .iter()
184 .position(|s| s.id == current_session_id)
185 .unwrap_or(0);
186 }
187
188 pub fn cancel(&mut self) {
190 self.active = false;
191 }
192
193 pub fn confirm(&mut self) {
195 self.active = false;
196 }
197
198 pub fn select_previous(&mut self) {
200 if self.sessions.is_empty() {
201 return;
202 }
203 if self.selected_index == 0 {
204 self.selected_index = self.sessions.len() - 1;
205 } else {
206 self.selected_index -= 1;
207 }
208 }
209
210 pub fn select_next(&mut self) {
212 if self.sessions.is_empty() {
213 return;
214 }
215 self.selected_index = (self.selected_index + 1) % self.sessions.len();
216 }
217
218 pub fn selected_session_id(&self) -> Option<i64> {
220 self.sessions.get(self.selected_index).map(|s| s.id)
221 }
222
223 pub fn selected_session(&self) -> Option<&SessionInfo> {
225 self.sessions.get(self.selected_index)
226 }
227}
228
229impl Default for SessionPickerState {
230 fn default() -> Self {
231 Self::new()
232 }
233}
234
235use super::{Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult, widget_ids};
238use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
239use std::any::Any;
240
241#[derive(Debug, Clone, PartialEq)]
243pub enum SessionKeyAction {
244 None,
246 Selected(i64),
248 Cancelled,
250}
251
252impl SessionPickerState {
253 pub fn process_key(&mut self, key: KeyEvent) -> SessionKeyAction {
255 if !self.active {
256 return SessionKeyAction::None;
257 }
258
259 match key.code {
260 KeyCode::Up => {
261 self.select_previous();
262 SessionKeyAction::None
263 }
264 KeyCode::Down => {
265 self.select_next();
266 SessionKeyAction::None
267 }
268 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
269 self.select_previous();
270 SessionKeyAction::None
271 }
272 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
273 self.select_next();
274 SessionKeyAction::None
275 }
276 KeyCode::Enter => {
277 if let Some(session_id) = self.selected_session_id() {
278 self.confirm();
279 SessionKeyAction::Selected(session_id)
280 } else {
281 SessionKeyAction::None
282 }
283 }
284 KeyCode::Esc => {
285 self.cancel();
286 SessionKeyAction::Cancelled
287 }
288 _ => SessionKeyAction::None,
289 }
290 }
291}
292
293impl Widget for SessionPickerState {
294 fn id(&self) -> &'static str {
295 widget_ids::SESSION_PICKER
296 }
297
298 fn priority(&self) -> u8 {
299 250 }
301
302 fn is_active(&self) -> bool {
303 self.active
304 }
305
306 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
307 if !self.active {
308 return WidgetKeyResult::NotHandled;
309 }
310
311 if ctx.nav.is_move_up(&key) {
313 self.select_previous();
314 return WidgetKeyResult::Handled;
315 }
316 if ctx.nav.is_move_down(&key) {
317 self.select_next();
318 return WidgetKeyResult::Handled;
319 }
320 if ctx.nav.is_select(&key) {
321 if let Some(session_id) = self.selected_session_id() {
322 self.confirm();
323 return WidgetKeyResult::Action(WidgetAction::SwitchSession { session_id });
324 }
325 return WidgetKeyResult::Handled;
326 }
327 if ctx.nav.is_cancel(&key) {
328 self.cancel();
329 return WidgetKeyResult::Action(WidgetAction::Close);
330 }
331
332 WidgetKeyResult::Handled
334 }
335
336 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
337 render_session_picker(self, frame, area, theme);
338 }
339
340 fn required_height(&self, _available: u16) -> u16 {
341 0 }
343
344 fn blocks_input(&self) -> bool {
345 self.active
346 }
347
348 fn is_overlay(&self) -> bool {
349 true
350 }
351
352 fn as_any(&self) -> &dyn Any {
353 self
354 }
355
356 fn as_any_mut(&mut self) -> &mut dyn Any {
357 self
358 }
359
360 fn into_any(self: Box<Self>) -> Box<dyn Any> {
361 self
362 }
363}
364
365pub fn render_session_picker(
367 state: &SessionPickerState,
368 frame: &mut Frame,
369 area: Rect,
370 theme: &Theme,
371) {
372 if !state.active {
373 return;
374 }
375
376 frame.render_widget(Clear, area);
378
379 let main_chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).split(area);
381
382 render_session_list(state, frame, main_chunks[0], theme);
384
385 render_help_bar(state, frame, main_chunks[1], theme);
387}
388
389fn render_session_list(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
391 let mut lines = Vec::new();
392
393 lines.push(Line::from(""));
395
396 if state.sessions.is_empty() {
397 lines.push(Line::from(Span::styled(
398 state.config.no_sessions_message.clone(),
399 theme.text(),
400 )));
401 } else {
402 let max_model_len = state
404 .sessions
405 .iter()
406 .map(|s| s.model.len())
407 .max()
408 .unwrap_or(0);
409
410 for (idx, session) in state.sessions.iter().enumerate() {
411 let is_selected = idx == state.selected_index;
412 let is_current = session.id == state.current_session_id;
413
414 let marker = if is_current {
415 &state.config.current_marker
416 } else {
417 &state.config.no_marker
418 };
419 let prefix = if is_selected {
420 &state.config.selection_prefix
421 } else {
422 &state.config.no_selection_prefix
423 };
424 let context_str = format_context(session.context_used, session.context_limit);
425 let time_str = session.created_at.format("%H:%M:%S").to_string();
426
427 let text = format!(
429 "{}{} Session {} | Model: {:<width$} | Context: {} | Created: {}",
430 prefix,
431 marker,
432 session.id,
433 session.model,
434 context_str,
435 time_str,
436 width = max_model_len
437 );
438
439 let style = if is_selected {
440 theme.popup_selected_bg().patch(theme.popup_item_selected())
441 } else {
442 theme.popup_item()
443 };
444
445 let inner_width = area.width.saturating_sub(2) as usize;
447 let padded = format!("{:<width$}", text, width = inner_width);
448 lines.push(Line::from(Span::styled(padded, style)));
449 }
450 }
451
452 let block = Block::default()
453 .title(state.config.title.clone())
454 .borders(Borders::ALL)
455 .border_style(theme.popup_border());
456
457 let list = Paragraph::new(lines)
458 .block(block)
459 .style(theme.background().patch(theme.text()));
460
461 frame.render_widget(list, area);
462}
463
464fn render_help_bar(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
466 let help = Paragraph::new(state.config.help_text.clone()).style(theme.status_help());
467 frame.render_widget(help, area);
468}
469
470fn format_context(used: i64, limit: i32) -> String {
472 let used_str = format_tokens(used);
473 let limit_str = format_tokens(limit as i64);
474 format!("{} / {}", used_str, limit_str)
475}
476
477fn format_tokens(tokens: i64) -> String {
479 if tokens >= 100_000 {
480 format!("{}K", tokens / 1000)
481 } else if tokens >= 1000 {
482 format!("{:.1}K", tokens as f64 / 1000.0)
483 } else {
484 format!("{}", tokens)
485 }
486}