BREAKING CHANGES: - owlen-core no longer depends on ratatui/crossterm - RemoteMcpClient constructors are now async - MCP path validation is stricter (security hardening) This commit resolves three critical issues identified in project analysis: ## P0-1: Extract TUI dependencies from owlen-core Create owlen-ui-common crate to hold UI-agnostic color and theme abstractions, removing architectural boundary violation. Changes: - Create new owlen-ui-common crate with abstract Color enum - Move theme.rs from owlen-core to owlen-ui-common - Define Color with Rgb and Named variants (no ratatui dependency) - Create color conversion layer in owlen-tui (color_convert.rs) - Update 35+ color usages with conversion wrappers - Remove ratatui/crossterm from owlen-core dependencies Benefits: - owlen-core usable in headless/CLI contexts - Enables future GUI frontends - Reduces binary size for core library consumers ## P0-2: Fix blocking WebSocket connections Convert RemoteMcpClient constructors to async, eliminating runtime blocking that froze TUI for 30+ seconds on slow connections. Changes: - Make new_with_runtime(), new_with_config(), new() async - Remove block_in_place wrappers for I/O operations - Add 30-second connection timeout with tokio::time::timeout - Update 15+ call sites across 10 files to await constructors - Convert 4 test functions to #[tokio::test] Benefits: - TUI remains responsive during WebSocket connections - Proper async I/O follows Rust best practices - No more indefinite hangs ## P1-1: Secure path traversal vulnerabilities Implement comprehensive path validation with 7 defense layers to prevent file access outside workspace boundaries. Changes: - Create validate_safe_path() with multi-layer security: * URL decoding (prevents %2E%2E bypasses) * Absolute path rejection * Null byte protection * Windows-specific checks (UNC/device paths) * Lexical path cleaning (removes .. components) * Symlink resolution via canonicalization * Boundary verification with starts_with check - Update 4 MCP resource functions (get/list/write/delete) - Add 11 comprehensive security tests Benefits: - Blocks URL-encoded, absolute, UNC path attacks - Prevents null byte injection - Stops symlink escape attempts - Cross-platform security (Windows/Linux/macOS) ## Test Results - owlen-core: 109/109 tests pass (100%) - owlen-tui: 52/53 tests pass (98%, 1 pre-existing failure) - owlen-providers: 2/2 tests pass (100%) - Build: cargo build --all succeeds ## Verification - ✓ cargo tree -p owlen-core shows no TUI dependencies - ✓ No block_in_place calls remain in MCP I/O code - ✓ All 11 security tests pass Fixes: #P0-1, #P0-2, #P1-1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
233 lines
6.7 KiB
Rust
233 lines
6.7 KiB
Rust
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<DetailedModelInfo>,
|
|
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<u64>) -> String {
|
|
value
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "N/A".to_string())
|
|
}
|
|
|
|
fn format_size(value: Option<u64>) -> 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::<String>())
|
|
} 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")
|
|
}
|
|
}
|