use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use super::{centered_popup, OverlayAction, OverlayComponent};
use crate::app::agent_session::{AgentSession, AgentSessionHandle};
use oxi_store::session::SessionInfo;
use oxi_store::settings::Settings;
use ratatui::{layout::Rect, style::Style, Frame};
type SharedAppState = Arc<Mutex<*mut crate::tui::app::AppState>>;
fn share_state(state: &mut crate::tui::app::AppState) -> SharedAppState {
Arc::new(Mutex::new(state as *mut _))
}
pub fn model_select(
models: Vec<String>,
session: &AgentSession,
app_state: &mut crate::tui::app::AppState,
) -> Box<dyn OverlayComponent> {
let shared = share_state(app_state);
let session = session.clone_handle();
Box::new(ModelSelectOverlay { models, filter: String::new(), selected: 0, session, app_state: shared })
}
struct ModelSelectOverlay {
models: Vec<String>,
filter: String,
selected: usize,
session: AgentSessionHandle,
app_state: SharedAppState,
}
impl std::fmt::Debug for ModelSelectOverlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ModelSelectOverlay")
.field("models", &self.models)
.field("filter", &self.filter)
.field("selected", &self.selected)
.finish()
}
}
impl OverlayComponent for ModelSelectOverlay {
fn handle_key(&mut self, key: KeyEvent) -> OverlayAction {
if key.kind != KeyEventKind::Press {
return OverlayAction::None;
}
let filtered = self.filtered();
let filtered_len = filtered.len();
match key.code {
KeyCode::Up => {
if self.selected > 0 { self.selected -= 1; }
if filtered_len > 0 && self.selected >= filtered_len {
self.selected = filtered_len - 1;
}
}
KeyCode::Down => {
if !filtered.is_empty() {
self.selected = (self.selected + 1).min(filtered.len() - 1);
}
}
KeyCode::Enter => {
let selected = self.selected;
if let Some((_idx, model_id)) = filtered.get(selected) {
let model_id = (*model_id).clone();
match self.session.set_model(&model_id) {
Ok(()) => {
if let Ok(ptr) = self.app_state.lock() {
unsafe {
if let Some(ref mut app) = (*ptr).as_mut() {
app.add_system_message(format!("Model: {}", model_id));
app.footer_state.data.model_name = model_id.clone();
}
}
}
let _ = Settings::save_last_used(&model_id);
}
Err(e) => {
if let Ok(ptr) = self.app_state.lock() {
unsafe {
if let Some(ref mut app) = (*ptr).as_mut() {
app.add_system_message(format!("Error: {}", e));
}
}
}
}
}
}
return OverlayAction::Close;
}
KeyCode::Esc => return OverlayAction::Close,
KeyCode::Backspace => {
self.filter.pop();
self.selected = 0;
}
KeyCode::Char(c) => {
self.filter.push(c);
self.selected = 0;
}
_ => {}
}
OverlayAction::None
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &oxi_tui::Theme) {
use ratatui::{
style::{Modifier, Style},
text::Span,
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
};
let styles = theme.to_styles();
let filtered = self.filtered();
let selected_in_filtered = if self.filter.is_empty() {
self.selected.min(filtered.len().saturating_sub(1))
} else {
filtered.iter().position(|(i, _)| *i == self.selected).unwrap_or(0)
};
let popup = centered_popup(area, 0.7, 0.7);
frame.render_widget(Clear, popup);
let border_block = Block::default()
.title(title_line(&self.filter))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.colors.border.to_ratatui()));
let inner = border_block.inner(popup);
frame.render_widget(border_block, popup);
frame.render_widget(
Paragraph::new(Span::styled(title_text(&self.filter), Style::default().fg(theme.colors.primary.to_ratatui()).add_modifier(Modifier::BOLD))),
Rect { x: inner.x, y: inner.y, width: inner.width, height: 1 },
);
let max_show = (inner.height as usize).saturating_sub(3).max(1);
let window_start = if selected_in_filtered >= max_show { selected_in_filtered - max_show + 1 } else { 0 };
let list_items: Vec<ListItem> = filtered.iter().skip(window_start).take(max_show).enumerate()
.map(|(i, (_, model))| {
let is_sel = window_start + i == selected_in_filtered;
let style = if is_sel {
Style::default().fg(theme.colors.background.to_ratatui()).bg(theme.colors.primary.to_ratatui()).add_modifier(Modifier::BOLD)
} else { styles.normal };
ListItem::new(Span::styled(format!("{}{}", if is_sel { "-> " } else { " " }, model), style))
})
.collect();
frame.render_widget(List::new(list_items), Rect { x: inner.x, y: inner.y + 2, width: inner.width, height: inner.height.saturating_sub(3) });
frame.render_widget(
Paragraph::new(Span::styled(format!(" {} models | Up/Down | type to filter | Enter select | Esc cancel", filtered.len()), styles.muted)),
Rect { x: inner.x, y: inner.y + inner.height.saturating_sub(1), width: inner.width, height: 1 },
);
}
fn hint(&self) -> &str { " Up/Down | type to filter | Enter select | Esc cancel" }
}
impl ModelSelectOverlay {
fn filtered(&self) -> Vec<(usize, &String)> {
if self.filter.is_empty() {
self.models.iter().enumerate().collect()
} else {
let lower = self.filter.to_lowercase();
self.models.iter().enumerate().filter(|(_, m)| m.to_lowercase().contains(&lower)).collect()
}
}
}
pub fn logout_select(
providers: Vec<String>,
app_state: &mut crate::tui::app::AppState,
) -> Box<dyn OverlayComponent> {
Box::new(LogoutSelectOverlay { providers, selected: 0, app_state: share_state(app_state) })
}
struct LogoutSelectOverlay {
providers: Vec<String>,
selected: usize,
app_state: SharedAppState,
}
impl std::fmt::Debug for LogoutSelectOverlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LogoutSelectOverlay").field("providers", &self.providers).field("selected", &self.selected).finish()
}
}
impl OverlayComponent for LogoutSelectOverlay {
fn handle_key(&mut self, key: KeyEvent) -> OverlayAction {
if key.kind != KeyEventKind::Press { return OverlayAction::None; }
match key.code {
KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
if !self.providers.is_empty() && self.selected >= self.providers.len() { self.selected = self.providers.len() - 1; }
}
KeyCode::Down => {
if !self.providers.is_empty() { self.selected = (self.selected + 1).min(self.providers.len() - 1); }
}
KeyCode::Enter => {
if let Some(provider) = self.providers.get(self.selected) {
let p = provider.clone();
oxi_store::auth_storage::shared_auth_storage().remove(&p);
if let Ok(ptr) = self.app_state.lock() {
unsafe {
if let Some(ref mut app) = (*ptr).as_mut() {
app.add_system_message(format!("Removed {}", p));
}
}
}
}
return OverlayAction::Close;
}
KeyCode::Esc => return OverlayAction::Close,
_ => {}
}
OverlayAction::None
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &oxi_tui::Theme) {
use ratatui::{style::Style, text::Span, widgets::{Block, Borders, Clear, List, ListItem, Paragraph}};
let styles = theme.to_styles();
let popup = centered_popup(area, 0.5, 0.5);
frame.render_widget(Clear, popup);
let border_block = Block::default().title(title_line_logout()).borders(Borders::ALL).border_style(Style::default().fg(theme.colors.border.to_ratatui()));
let inner = border_block.inner(popup);
frame.render_widget(border_block, popup);
let list_items: Vec<ListItem> = self.providers.iter().enumerate()
.map(|(i, provider)| {
let is_sel = i == self.selected;
let style = if is_sel { Style::default().fg(theme.colors.background.to_ratatui()).bg(theme.colors.primary.to_ratatui()) } else { styles.normal };
ListItem::new(Span::styled(format!("{}{}", if is_sel { "-> " } else { " " }, provider), style))
})
.collect();
frame.render_widget(List::new(list_items), Rect { x: inner.x, y: inner.y, width: inner.width, height: inner.height });
frame.render_widget(
Paragraph::new(Span::styled(" Up/Down select | Enter remove | Esc cancel", styles.muted)),
Rect { x: inner.x, y: inner.y + inner.height.saturating_sub(1), width: inner.width, height: 1 },
);
}
fn hint(&self) -> &str { " Up/Down | Enter remove | Esc cancel" }
}
pub fn resume_select(sessions: Vec<SessionInfo>) -> Box<dyn OverlayComponent> {
Box::new(ResumeSelectOverlay { sessions, selected: 0 })
}
struct ResumeSelectOverlay {
sessions: Vec<SessionInfo>,
selected: usize,
}
impl std::fmt::Debug for ResumeSelectOverlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResumeSelectOverlay").field("sessions", &self.sessions.len()).field("selected", &self.selected).finish()
}
}
impl OverlayComponent for ResumeSelectOverlay {
fn handle_key(&mut self, key: KeyEvent) -> OverlayAction {
if key.kind != KeyEventKind::Press { return OverlayAction::None; }
match key.code {
KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
if !self.sessions.is_empty() && self.selected >= self.sessions.len() { self.selected = self.sessions.len() - 1; }
}
KeyCode::Down => {
if !self.sessions.is_empty() { self.selected = (self.selected + 1).min(self.sessions.len() - 1); }
}
KeyCode::Enter => {
if let Some(s) = self.sessions.get(self.selected) {
return OverlayAction::SwitchSession(s.path.clone());
}
}
KeyCode::Esc => return OverlayAction::Close,
_ => {}
}
OverlayAction::None
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &oxi_tui::Theme) {
use ratatui::{style::{Modifier, Style}, text::Span, widgets::{Block, Borders, Clear, Paragraph}};
let styles = theme.to_styles();
let popup = centered_popup(area, 0.85, 0.85);
frame.render_widget(Clear, popup);
let border_block = Block::default().title(title_line_resume()).borders(Borders::ALL).border_style(Style::default().fg(theme.colors.border.to_ratatui()));
let inner = border_block.inner(popup);
frame.render_widget(border_block, popup);
let header_style = Style::default().fg(theme.colors.muted.to_ratatui()).add_modifier(Modifier::BOLD);
frame.render_widget(
Paragraph::new(Span::styled(format!("{:<20} {:>6} {:<35} {:>12} {:<20}", "NAME", "MSG", "PREVIEW", "TIME", "CWD"), header_style)),
Rect { x: inner.x + 1, y: inner.y, width: inner.width.saturating_sub(2), height: 1 },
);
let max_show = (inner.height as usize).saturating_sub(3).max(1);
let window_start = self.selected.saturating_sub(max_show - 1).min(self.selected);
for (i, session) in self.sessions.iter().skip(window_start).take(max_show).enumerate() {
let row_idx = window_start + i;
let is_sel = row_idx == self.selected;
let style = if is_sel { Style::default().fg(theme.colors.background.to_ratatui()).bg(theme.colors.primary.to_ratatui()).add_modifier(Modifier::BOLD) } else { styles.normal };
let row = format!(
"{:<20} {:>6} {:<35} {:>12} {:<20}",
truncate(session.name.as_deref().unwrap_or("new-session"), 18),
session.message_count,
truncate(session.first_message.as_str(), 33),
relative_time(session.created),
truncate(&session.cwd, 18),
);
frame.render_widget(
Paragraph::new(Span::styled(row, style)),
Rect { x: inner.x + 1, y: inner.y + 1 + i as u16, width: inner.width.saturating_sub(2), height: 1 },
);
}
frame.render_widget(
Paragraph::new(Span::styled(format!(" {} sessions | Up/Down | Enter switch | Esc cancel", self.sessions.len()), styles.muted)),
Rect { x: inner.x, y: inner.y + inner.height.saturating_sub(1), width: inner.width, height: 1 },
);
}
fn hint(&self) -> &str { " Up/Down | Enter switch | Esc cancel" }
}
fn title_text(filter: &str) -> String {
if filter.is_empty() { " Select a model ".to_string() } else { format!(" Filter: {} ", filter) }
}
fn title_line(filter: &str) -> ratatui::text::Line<'static> {
ratatui::text::Line::styled(title_text(filter), Style::default().bg(ratatui::style::Color::Rgb(0, 0, 0)))
}
fn title_line_logout() -> ratatui::text::Line<'static> {
ratatui::text::Line::styled(" Remove API Key ", Style::default().bg(ratatui::style::Color::Rgb(0, 0, 0)))
}
fn title_line_resume() -> ratatui::text::Line<'static> {
ratatui::text::Line::styled(" Resume Session ", Style::default().bg(ratatui::style::Color::Rgb(0, 0, 0)))
}
fn truncate(text: &str, max_width: usize) -> String {
let len = text.chars().count();
if len > max_width { format!("{}...", text.chars().take(max_width.saturating_sub(3)).collect::<String>()) } else { text.to_string() }
}
fn relative_time(dt: DateTime<Utc>) -> String {
let now = chrono::Utc::now();
let diff = (now - dt).num_seconds();
if diff < 60 { "< 1m ago".to_string() }
else if diff < 3600 { format!("{}m ago", diff / 60) }
else if diff < 86400 { format!("{}h ago", diff / 3600) }
else { format!("{}d ago", diff / 86400) }
}