use owlen_core::Theme; use owlen_core::model::DetailedModelInfo; use ratatui::{ Frame, layout::Rect, style::{Modifier, Style}, widgets::{Block, Borders, Paragraph, Wrap}, }; /// Dedicated panel for presenting detailed model information. pub struct ModelInfoPanel { info: Option, scroll_offset: usize, total_lines: usize, } impl ModelInfoPanel { pub fn new() -> Self { Self { info: None, scroll_offset: 0, total_lines: 0, } } pub fn set_model_info(&mut self, info: DetailedModelInfo) { self.info = Some(info); self.scroll_offset = 0; self.total_lines = 0; } pub fn clear(&mut self) { self.info = None; self.scroll_offset = 0; self.total_lines = 0; } pub fn render(&mut self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) { let block = Block::default() .title("Model Information") .borders(Borders::ALL) .style( Style::default() .bg(crate::color_convert::to_ratatui_color(&theme.background)) .fg(crate::color_convert::to_ratatui_color(&theme.text)), ) .border_style(Style::default().fg(crate::color_convert::to_ratatui_color( &theme.focused_panel_border, ))); if let Some(info) = &self.info { let body = self.format_info(info); self.total_lines = body.lines().count(); let paragraph = Paragraph::new(body) .block(block) .style(Style::default().fg(crate::color_convert::to_ratatui_color(&theme.text))) .wrap(Wrap { trim: true }) .scroll((self.scroll_offset as u16, 0)); frame.render_widget(paragraph, area); } else { self.total_lines = 0; let paragraph = Paragraph::new("Select a model to inspect its details.") .block(block) .style( Style::default() .fg(crate::color_convert::to_ratatui_color(&theme.placeholder)) .add_modifier(Modifier::ITALIC), ) .wrap(Wrap { trim: true }); frame.render_widget(paragraph, area); } } pub fn scroll_up(&mut self) { if self.scroll_offset > 0 { self.scroll_offset -= 1; } } pub fn scroll_down(&mut self, viewport_height: usize) { if viewport_height == 0 { return; } let max_offset = self.total_lines.saturating_sub(viewport_height); if self.scroll_offset < max_offset { self.scroll_offset += 1; } } pub fn reset_scroll(&mut self) { self.scroll_offset = 0; } pub fn scroll_offset(&self) -> usize { self.scroll_offset } pub fn total_lines(&self) -> usize { self.total_lines } pub fn current_model_name(&self) -> Option<&str> { self.info.as_ref().map(|info| info.name.as_str()) } fn format_info(&self, info: &DetailedModelInfo) -> String { let mut lines = Vec::new(); lines.push(format!("Name: {}", info.name)); lines.push(format!( "Architecture: {}", display_option(info.architecture.as_deref()) )); lines.push(format!( "Parameters: {}", display_option(info.parameters.as_deref()) )); lines.push(format!( "Context Length: {}", display_u64(info.context_length) )); lines.push(format!( "Embedding Length: {}", display_u64(info.embedding_length) )); lines.push(format!( "Quantization: {}", display_option(info.quantization.as_deref()) )); lines.push(format!( "Family: {}", display_option(info.family.as_deref()) )); if !info.families.is_empty() { lines.push(format!("Families: {}", info.families.join(", "))); } lines.push(format!( "Parameter Size: {}", display_option(info.parameter_size.as_deref()) )); lines.push(format!("Size: {}", format_size(info.size))); lines.push(format!( "Modified: {}", display_option(info.modified_at.as_deref()) )); lines.push(format!( "License: {}", display_option(info.license.as_deref()) )); lines.push(format!( "Digest: {}", display_option(info.digest.as_deref()) )); if let Some(template) = info.template.as_deref() { lines.push(format!("Template: {}", snippet(template))); } if let Some(system) = info.system.as_deref() { lines.push(format!("System Prompt: {}", snippet(system))); } if let Some(modelfile) = info.modelfile.as_deref() { lines.push("Modelfile:".to_string()); lines.push(snippet_multiline(modelfile, 8)); } lines.join("\n") } } impl Default for ModelInfoPanel { fn default() -> Self { Self::new() } } fn display_option(value: Option<&str>) -> String { value .map(|s| s.to_string()) .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| "N/A".to_string()) } fn display_u64(value: Option) -> String { value .map(|v| v.to_string()) .unwrap_or_else(|| "N/A".to_string()) } fn format_size(value: Option) -> String { if let Some(bytes) = value { if bytes >= 1_000_000_000 { let human = bytes as f64 / 1_000_000_000_f64; format!("{human:.2} GB ({} bytes)", bytes) } else if bytes >= 1_000_000 { let human = bytes as f64 / 1_000_000_f64; format!("{human:.2} MB ({} bytes)", bytes) } else if bytes >= 1_000 { let human = bytes as f64 / 1_000_f64; format!("{human:.2} KB ({} bytes)", bytes) } else { format!("{bytes} bytes") } } else { "N/A".to_string() } } fn snippet(text: &str) -> String { const MAX_LEN: usize = 160; if text.len() > MAX_LEN { format!("{}…", text.chars().take(MAX_LEN).collect::()) } else { text.to_string() } } fn snippet_multiline(text: &str, max_lines: usize) -> String { let mut lines = Vec::new(); for (idx, line) in text.lines().enumerate() { if idx >= max_lines { lines.push("…".to_string()); break; } lines.push(snippet(line)); } if lines.is_empty() { "N/A".to_string() } else { lines.join("\n") } }