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
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 = " Arrow keys to navigate | Enter to switch | Esc to cancel | * = current session";
30 pub const NO_SESSIONS_MESSAGE: &str = " No sessions available";
32}
33
34#[derive(Clone)]
36pub struct SessionPickerConfig {
37 pub current_marker: String,
39 pub no_marker: String,
41 pub selection_prefix: String,
43 pub no_selection_prefix: String,
45 pub title: String,
47 pub help_text: String,
49 pub no_sessions_message: String,
51}
52
53impl Default for SessionPickerConfig {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl SessionPickerConfig {
60 pub fn new() -> Self {
62 Self {
63 current_marker: defaults::CURRENT_MARKER.to_string(),
64 no_marker: defaults::NO_MARKER.to_string(),
65 selection_prefix: defaults::SELECTION_PREFIX.to_string(),
66 no_selection_prefix: defaults::NO_SELECTION_PREFIX.to_string(),
67 title: defaults::TITLE.to_string(),
68 help_text: defaults::HELP_TEXT.to_string(),
69 no_sessions_message: defaults::NO_SESSIONS_MESSAGE.to_string(),
70 }
71 }
72
73 pub fn with_current_marker(mut self, marker: impl Into<String>) -> Self {
75 self.current_marker = marker.into();
76 self
77 }
78
79 pub fn with_selection_prefix(mut self, prefix: impl Into<String>) -> Self {
81 self.selection_prefix = prefix.into();
82 self
83 }
84
85 pub fn with_title(mut self, title: impl Into<String>) -> Self {
87 self.title = title.into();
88 self
89 }
90
91 pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
93 self.help_text = text.into();
94 self
95 }
96
97 pub fn with_no_sessions_message(mut self, message: impl Into<String>) -> Self {
99 self.no_sessions_message = message.into();
100 self
101 }
102}
103
104#[derive(Clone)]
107pub struct SessionInfo {
108 pub id: i64,
110 pub model: String,
112 pub context_used: i64,
114 pub context_limit: i32,
116 pub created_at: DateTime<Local>,
118}
119
120impl SessionInfo {
121 pub fn new(id: i64, model: String, context_limit: i32) -> Self {
123 Self {
124 id,
125 model,
126 context_used: 0,
127 context_limit,
128 created_at: Local::now(),
129 }
130 }
131}
132
133pub struct SessionPickerState {
135 pub active: bool,
137 pub selected_index: usize,
139 sessions: Vec<SessionInfo>,
141 current_session_id: i64,
143 config: SessionPickerConfig,
145}
146
147impl SessionPickerState {
148 pub fn new() -> Self {
149 Self::with_config(SessionPickerConfig::new())
150 }
151
152 pub fn with_config(config: SessionPickerConfig) -> Self {
154 Self {
155 active: false,
156 selected_index: 0,
157 sessions: Vec::new(),
158 current_session_id: 0,
159 config,
160 }
161 }
162
163 pub fn config(&self) -> &SessionPickerConfig {
165 &self.config
166 }
167
168 pub fn set_config(&mut self, config: SessionPickerConfig) {
170 self.config = config;
171 }
172
173 pub fn activate(&mut self, sessions: Vec<SessionInfo>, current_session_id: i64) {
175 self.active = true;
176 self.sessions = sessions;
177 self.current_session_id = current_session_id;
178
179 self.selected_index = self
181 .sessions
182 .iter()
183 .position(|s| s.id == current_session_id)
184 .unwrap_or(0);
185 }
186
187 pub fn cancel(&mut self) {
189 self.active = false;
190 }
191
192 pub fn confirm(&mut self) {
194 self.active = false;
195 }
196
197 pub fn select_previous(&mut self) {
199 if self.sessions.is_empty() {
200 return;
201 }
202 if self.selected_index == 0 {
203 self.selected_index = self.sessions.len() - 1;
204 } else {
205 self.selected_index -= 1;
206 }
207 }
208
209 pub fn select_next(&mut self) {
211 if self.sessions.is_empty() {
212 return;
213 }
214 self.selected_index = (self.selected_index + 1) % self.sessions.len();
215 }
216
217 pub fn selected_session_id(&self) -> Option<i64> {
219 self.sessions.get(self.selected_index).map(|s| s.id)
220 }
221
222 pub fn selected_session(&self) -> Option<&SessionInfo> {
224 self.sessions.get(self.selected_index)
225 }
226}
227
228impl Default for SessionPickerState {
229 fn default() -> Self {
230 Self::new()
231 }
232}
233
234use std::any::Any;
237use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
238use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
239
240#[derive(Debug, Clone, PartialEq)]
242pub enum SessionKeyAction {
243 None,
245 Selected(i64),
247 Cancelled,
249}
250
251impl SessionPickerState {
252 pub fn process_key(&mut self, key: KeyEvent) -> SessionKeyAction {
254 if !self.active {
255 return SessionKeyAction::None;
256 }
257
258 match key.code {
259 KeyCode::Up => {
260 self.select_previous();
261 SessionKeyAction::None
262 }
263 KeyCode::Down => {
264 self.select_next();
265 SessionKeyAction::None
266 }
267 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
268 self.select_previous();
269 SessionKeyAction::None
270 }
271 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
272 self.select_next();
273 SessionKeyAction::None
274 }
275 KeyCode::Enter => {
276 if let Some(session_id) = self.selected_session_id() {
277 self.confirm();
278 SessionKeyAction::Selected(session_id)
279 } else {
280 SessionKeyAction::None
281 }
282 }
283 KeyCode::Esc => {
284 self.cancel();
285 SessionKeyAction::Cancelled
286 }
287 _ => SessionKeyAction::None,
288 }
289 }
290}
291
292impl Widget for SessionPickerState {
293 fn id(&self) -> &'static str {
294 widget_ids::SESSION_PICKER
295 }
296
297 fn priority(&self) -> u8 {
298 250 }
300
301 fn is_active(&self) -> bool {
302 self.active
303 }
304
305 fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
306 if !self.active {
307 return WidgetKeyResult::NotHandled;
308 }
309
310 if ctx.nav.is_move_up(&key) {
312 self.select_previous();
313 return WidgetKeyResult::Handled;
314 }
315 if ctx.nav.is_move_down(&key) {
316 self.select_next();
317 return WidgetKeyResult::Handled;
318 }
319 if ctx.nav.is_select(&key) {
320 if let Some(session_id) = self.selected_session_id() {
321 self.confirm();
322 return WidgetKeyResult::Action(WidgetAction::SwitchSession { session_id });
323 }
324 return WidgetKeyResult::Handled;
325 }
326 if ctx.nav.is_cancel(&key) {
327 self.cancel();
328 return WidgetKeyResult::Action(WidgetAction::Close);
329 }
330
331 WidgetKeyResult::Handled
333 }
334
335 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
336 render_session_picker(self, frame, area, theme);
337 }
338
339 fn required_height(&self, _available: u16) -> u16 {
340 0 }
342
343 fn blocks_input(&self) -> bool {
344 self.active
345 }
346
347 fn is_overlay(&self) -> bool {
348 true
349 }
350
351 fn as_any(&self) -> &dyn Any {
352 self
353 }
354
355 fn as_any_mut(&mut self) -> &mut dyn Any {
356 self
357 }
358
359 fn into_any(self: Box<Self>) -> Box<dyn Any> {
360 self
361 }
362}
363
364pub fn render_session_picker(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
366 if !state.active {
367 return;
368 }
369
370 frame.render_widget(Clear, area);
372
373 let main_chunks =
375 Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).split(area);
376
377 render_session_list(state, frame, main_chunks[0], theme);
379
380 render_help_bar(state, frame, main_chunks[1], theme);
382}
383
384fn render_session_list(
386 state: &SessionPickerState,
387 frame: &mut Frame,
388 area: Rect,
389 theme: &Theme,
390) {
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 { &state.config.current_marker } else { &state.config.no_marker };
415 let prefix = if is_selected { &state.config.selection_prefix } else { &state.config.no_selection_prefix };
416 let context_str = format_context(session.context_used, session.context_limit);
417 let time_str = session.created_at.format("%H:%M:%S").to_string();
418
419 let text = format!(
421 "{}{} Session {} | Model: {:<width$} | Context: {} | Created: {}",
422 prefix,
423 marker,
424 session.id,
425 session.model,
426 context_str,
427 time_str,
428 width = max_model_len
429 );
430
431 let style = if is_selected {
432 theme.popup_selected_bg().patch(theme.popup_item_selected())
433 } else {
434 theme.popup_item()
435 };
436
437 let inner_width = area.width.saturating_sub(2) as usize;
439 let padded = format!("{:<width$}", text, width = inner_width);
440 lines.push(Line::from(Span::styled(padded, style)));
441 }
442 }
443
444 let block = Block::default()
445 .title(state.config.title.clone())
446 .borders(Borders::ALL)
447 .border_style(theme.popup_border());
448
449 let list = Paragraph::new(lines)
450 .block(block)
451 .style(theme.background().patch(theme.text()));
452
453 frame.render_widget(list, area);
454}
455
456fn render_help_bar(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
458 let help = Paragraph::new(state.config.help_text.clone()).style(theme.status_help());
459 frame.render_widget(help, area);
460}
461
462fn format_context(used: i64, limit: i32) -> String {
464 let used_str = format_tokens(used);
465 let limit_str = format_tokens(limit as i64);
466 format!("{} / {}", used_str, limit_str)
467}
468
469fn format_tokens(tokens: i64) -> String {
471 if tokens >= 100_000 {
472 format!("{}K", tokens / 1000)
473 } else if tokens >= 1000 {
474 format!("{:.1}K", tokens as f64 / 1000.0)
475 } else {
476 format!("{}", tokens)
477 }
478}