use chrono::{DateTime, Local};
use ratatui::{
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::themes::Theme;
pub mod defaults {
pub const CURRENT_MARKER: &str = "*";
pub const NO_MARKER: &str = " ";
pub const SELECTION_PREFIX: &str = " > ";
pub const NO_SELECTION_PREFIX: &str = " ";
pub const TITLE: &str = " Sessions ";
pub const HELP_TEXT: &str = " Arrow keys to navigate | Enter to switch | Esc to cancel | * = current session";
pub const NO_SESSIONS_MESSAGE: &str = " No sessions available";
}
#[derive(Clone)]
pub struct SessionPickerConfig {
pub current_marker: String,
pub no_marker: String,
pub selection_prefix: String,
pub no_selection_prefix: String,
pub title: String,
pub help_text: String,
pub no_sessions_message: String,
}
impl Default for SessionPickerConfig {
fn default() -> Self {
Self::new()
}
}
impl SessionPickerConfig {
pub fn new() -> Self {
Self {
current_marker: defaults::CURRENT_MARKER.to_string(),
no_marker: defaults::NO_MARKER.to_string(),
selection_prefix: defaults::SELECTION_PREFIX.to_string(),
no_selection_prefix: defaults::NO_SELECTION_PREFIX.to_string(),
title: defaults::TITLE.to_string(),
help_text: defaults::HELP_TEXT.to_string(),
no_sessions_message: defaults::NO_SESSIONS_MESSAGE.to_string(),
}
}
pub fn with_current_marker(mut self, marker: impl Into<String>) -> Self {
self.current_marker = marker.into();
self
}
pub fn with_selection_prefix(mut self, prefix: impl Into<String>) -> Self {
self.selection_prefix = prefix.into();
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = title.into();
self
}
pub fn with_help_text(mut self, text: impl Into<String>) -> Self {
self.help_text = text.into();
self
}
pub fn with_no_sessions_message(mut self, message: impl Into<String>) -> Self {
self.no_sessions_message = message.into();
self
}
}
#[derive(Clone)]
pub struct SessionInfo {
pub id: i64,
pub model: String,
pub context_used: i64,
pub context_limit: i32,
pub created_at: DateTime<Local>,
}
impl SessionInfo {
pub fn new(id: i64, model: String, context_limit: i32) -> Self {
Self {
id,
model,
context_used: 0,
context_limit,
created_at: Local::now(),
}
}
}
pub struct SessionPickerState {
pub active: bool,
pub selected_index: usize,
sessions: Vec<SessionInfo>,
current_session_id: i64,
config: SessionPickerConfig,
}
impl SessionPickerState {
pub fn new() -> Self {
Self::with_config(SessionPickerConfig::new())
}
pub fn with_config(config: SessionPickerConfig) -> Self {
Self {
active: false,
selected_index: 0,
sessions: Vec::new(),
current_session_id: 0,
config,
}
}
pub fn config(&self) -> &SessionPickerConfig {
&self.config
}
pub fn set_config(&mut self, config: SessionPickerConfig) {
self.config = config;
}
pub fn activate(&mut self, sessions: Vec<SessionInfo>, current_session_id: i64) {
self.active = true;
self.sessions = sessions;
self.current_session_id = current_session_id;
self.selected_index = self
.sessions
.iter()
.position(|s| s.id == current_session_id)
.unwrap_or(0);
}
pub fn cancel(&mut self) {
self.active = false;
}
pub fn confirm(&mut self) {
self.active = false;
}
pub fn select_previous(&mut self) {
if self.sessions.is_empty() {
return;
}
if self.selected_index == 0 {
self.selected_index = self.sessions.len() - 1;
} else {
self.selected_index -= 1;
}
}
pub fn select_next(&mut self) {
if self.sessions.is_empty() {
return;
}
self.selected_index = (self.selected_index + 1) % self.sessions.len();
}
pub fn selected_session_id(&self) -> Option<i64> {
self.sessions.get(self.selected_index).map(|s| s.id)
}
pub fn selected_session(&self) -> Option<&SessionInfo> {
self.sessions.get(self.selected_index)
}
}
impl Default for SessionPickerState {
fn default() -> Self {
Self::new()
}
}
use std::any::Any;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::{widget_ids, Widget, WidgetAction, WidgetKeyContext, WidgetKeyResult};
#[derive(Debug, Clone, PartialEq)]
pub enum SessionKeyAction {
None,
Selected(i64),
Cancelled,
}
impl SessionPickerState {
pub fn process_key(&mut self, key: KeyEvent) -> SessionKeyAction {
if !self.active {
return SessionKeyAction::None;
}
match key.code {
KeyCode::Up => {
self.select_previous();
SessionKeyAction::None
}
KeyCode::Down => {
self.select_next();
SessionKeyAction::None
}
KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.select_previous();
SessionKeyAction::None
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.select_next();
SessionKeyAction::None
}
KeyCode::Enter => {
if let Some(session_id) = self.selected_session_id() {
self.confirm();
SessionKeyAction::Selected(session_id)
} else {
SessionKeyAction::None
}
}
KeyCode::Esc => {
self.cancel();
SessionKeyAction::Cancelled
}
_ => SessionKeyAction::None,
}
}
}
impl Widget for SessionPickerState {
fn id(&self) -> &'static str {
widget_ids::SESSION_PICKER
}
fn priority(&self) -> u8 {
250 }
fn is_active(&self) -> bool {
self.active
}
fn handle_key(&mut self, key: KeyEvent, ctx: &WidgetKeyContext) -> WidgetKeyResult {
if !self.active {
return WidgetKeyResult::NotHandled;
}
if ctx.nav.is_move_up(&key) {
self.select_previous();
return WidgetKeyResult::Handled;
}
if ctx.nav.is_move_down(&key) {
self.select_next();
return WidgetKeyResult::Handled;
}
if ctx.nav.is_select(&key) {
if let Some(session_id) = self.selected_session_id() {
self.confirm();
return WidgetKeyResult::Action(WidgetAction::SwitchSession { session_id });
}
return WidgetKeyResult::Handled;
}
if ctx.nav.is_cancel(&key) {
self.cancel();
return WidgetKeyResult::Action(WidgetAction::Close);
}
WidgetKeyResult::Handled
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
render_session_picker(self, frame, area, theme);
}
fn required_height(&self, _available: u16) -> u16 {
0 }
fn blocks_input(&self) -> bool {
self.active
}
fn is_overlay(&self) -> bool {
true
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn into_any(self: Box<Self>) -> Box<dyn Any> {
self
}
}
pub fn render_session_picker(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
if !state.active {
return;
}
frame.render_widget(Clear, area);
let main_chunks =
Layout::vertical([Constraint::Min(0), Constraint::Length(2)]).split(area);
render_session_list(state, frame, main_chunks[0], theme);
render_help_bar(state, frame, main_chunks[1], theme);
}
fn render_session_list(
state: &SessionPickerState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
) {
let mut lines = Vec::new();
lines.push(Line::from(""));
if state.sessions.is_empty() {
lines.push(Line::from(Span::styled(
state.config.no_sessions_message.clone(),
theme.text(),
)));
} else {
let max_model_len = state
.sessions
.iter()
.map(|s| s.model.len())
.max()
.unwrap_or(0);
for (idx, session) in state.sessions.iter().enumerate() {
let is_selected = idx == state.selected_index;
let is_current = session.id == state.current_session_id;
let marker = if is_current { &state.config.current_marker } else { &state.config.no_marker };
let prefix = if is_selected { &state.config.selection_prefix } else { &state.config.no_selection_prefix };
let context_str = format_context(session.context_used, session.context_limit);
let time_str = session.created_at.format("%H:%M:%S").to_string();
let text = format!(
"{}{} Session {} | Model: {:<width$} | Context: {} | Created: {}",
prefix,
marker,
session.id,
session.model,
context_str,
time_str,
width = max_model_len
);
let style = if is_selected {
theme.popup_selected_bg().patch(theme.popup_item_selected())
} else {
theme.popup_item()
};
let inner_width = area.width.saturating_sub(2) as usize;
let padded = format!("{:<width$}", text, width = inner_width);
lines.push(Line::from(Span::styled(padded, style)));
}
}
let block = Block::default()
.title(state.config.title.clone())
.borders(Borders::ALL)
.border_style(theme.popup_border());
let list = Paragraph::new(lines)
.block(block)
.style(theme.background().patch(theme.text()));
frame.render_widget(list, area);
}
fn render_help_bar(state: &SessionPickerState, frame: &mut Frame, area: Rect, theme: &Theme) {
let help = Paragraph::new(state.config.help_text.clone()).style(theme.status_help());
frame.render_widget(help, area);
}
fn format_context(used: i64, limit: i32) -> String {
let used_str = format_tokens(used);
let limit_str = format_tokens(limit as i64);
format!("{} / {}", used_str, limit_str)
}
fn format_tokens(tokens: i64) -> String {
if tokens >= 100_000 {
format!("{}K", tokens / 1000)
} else if tokens >= 1000 {
format!("{:.1}K", tokens as f64 / 1000.0)
} else {
format!("{}", tokens)
}
}