diff --git a/crates/ov_cli/Cargo.toml b/crates/ov_cli/Cargo.toml index fcbd1825..5207d08c 100644 --- a/crates/ov_cli/Cargo.toml +++ b/crates/ov_cli/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ov_cli" -version = "0.1.0" -edition = "2021" +version = "0.2.0" +edition = "2024" authors = ["OpenViking Contributors"] description = "Rust CLI client for OpenViking" license = "MIT" @@ -22,3 +22,5 @@ anyhow = "1.0" mime_guess = "2.0" thiserror = "1.0" unicode-width = "0.1" +ratatui = "0.30" +crossterm = "0.28" diff --git a/crates/ov_cli/src/main.rs b/crates/ov_cli/src/main.rs index 66430907..6cec9adb 100644 --- a/crates/ov_cli/src/main.rs +++ b/crates/ov_cli/src/main.rs @@ -3,6 +3,7 @@ mod commands; mod config; mod error; mod output; +mod tui; use clap::{Parser, Subcommand}; use config::Config; @@ -284,6 +285,12 @@ enum Commands { /// or JSON array of such objects for multiple messages. content: String, }, + /// Interactive TUI file explorer + Tui { + /// Viking URI to start browsing (default: viking://) + #[arg(default_value = "viking://")] + uri: String, + }, /// Configuration management Config { #[command(subcommand)] @@ -431,6 +438,9 @@ async fn main() { Commands::AddMemory { content } => { handle_add_memory(content, ctx).await } + Commands::Tui { uri } => { + handle_tui(uri, ctx).await + } Commands::Config { action } => handle_config(action, ctx).await, Commands::Version => { println!("{}", env!("CARGO_PKG_VERSION")); @@ -716,3 +726,8 @@ async fn handle_health(ctx: CliContext) -> Result<()> { } Ok(()) } + +async fn handle_tui(uri: String, ctx: CliContext) -> Result<()> { + let client = ctx.get_client(); + tui::run_tui(client, &uri).await +} diff --git a/crates/ov_cli/src/tui/app.rs b/crates/ov_cli/src/tui/app.rs new file mode 100644 index 00000000..1039f5a0 --- /dev/null +++ b/crates/ov_cli/src/tui/app.rs @@ -0,0 +1,160 @@ +use crate::client::HttpClient; + +use super::tree::TreeState; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Panel { + Tree, + Content, +} + +pub struct App { + pub client: HttpClient, + pub tree: TreeState, + pub focus: Panel, + pub content: String, + pub content_title: String, + pub content_scroll: u16, + pub content_line_count: u16, + pub should_quit: bool, + pub status_message: String, +} + +impl App { + pub fn new(client: HttpClient) -> Self { + Self { + client, + tree: TreeState::new(), + focus: Panel::Tree, + content: String::new(), + content_title: String::new(), + content_scroll: 0, + content_line_count: 0, + should_quit: false, + status_message: String::new(), + } + } + + pub async fn init(&mut self, uri: &str) { + self.tree.load_root(&self.client, uri).await; + self.load_content_for_selected().await; + } + + pub async fn load_content_for_selected(&mut self) { + let (uri, is_dir) = match ( + self.tree.selected_uri().map(|s| s.to_string()), + self.tree.selected_is_dir(), + ) { + (Some(uri), Some(is_dir)) => (uri, is_dir), + _ => { + self.content = "(nothing selected)".to_string(); + self.content_title = String::new(); + self.content_scroll = 0; + return; + } + }; + + self.content_title = uri.clone(); + self.content_scroll = 0; + + if is_dir { + // For root-level scope URIs (e.g. viking://resources), show a + // simple placeholder instead of calling abstract/overview which + // don't work at this level. + if Self::is_root_scope_uri(&uri) { + let scope = uri.trim_start_matches("viking://").trim_end_matches('/'); + self.content = format!( + "Scope: {}\n\nPress '.' to expand/collapse.\nUse j/k to navigate.", + scope + ); + } else { + self.load_directory_content(&uri).await; + } + } else { + self.load_file_content(&uri).await; + } + + self.content_line_count = self.content.lines().count() as u16; + } + + async fn load_directory_content(&mut self, uri: &str) { + let (abstract_result, overview_result) = tokio::join!( + self.client.abstract_content(uri), + self.client.overview(uri), + ); + + let mut parts = Vec::new(); + + match abstract_result { + Ok(text) if !text.is_empty() => { + parts.push(format!("=== Abstract ===\n\n{}", text)); + } + Ok(_) => { + parts.push("=== Abstract ===\n\n(empty)".to_string()); + } + Err(_) => { + parts.push("=== Abstract ===\n\n(not available)".to_string()); + } + } + + match overview_result { + Ok(text) if !text.is_empty() => { + parts.push(format!("=== Overview ===\n\n{}", text)); + } + Ok(_) => { + parts.push("=== Overview ===\n\n(empty)".to_string()); + } + Err(_) => { + parts.push("=== Overview ===\n\n(not available)".to_string()); + } + } + + self.content = parts.join("\n\n---\n\n"); + } + + async fn load_file_content(&mut self, uri: &str) { + match self.client.read(uri).await { + Ok(text) if !text.is_empty() => { + self.content = text; + } + Ok(_) => { + self.content = "(empty file)".to_string(); + } + Err(e) => { + self.content = format!("(error reading file: {})", e); + } + } + } + + pub fn scroll_content_up(&mut self) { + self.content_scroll = self.content_scroll.saturating_sub(1); + } + + pub fn scroll_content_down(&mut self) { + if self.content_scroll < self.content_line_count.saturating_sub(1) { + self.content_scroll += 1; + } + } + + pub fn scroll_content_top(&mut self) { + self.content_scroll = 0; + } + + pub fn scroll_content_bottom(&mut self) { + self.content_scroll = self.content_line_count.saturating_sub(1); + } + + /// Returns true if the URI is a root-level scope (e.g. "viking://resources") + fn is_root_scope_uri(uri: &str) -> bool { + let stripped = uri.trim_start_matches("viking://").trim_end_matches('/'); + // Root scope = no slashes after the scheme (just the scope name) + !stripped.is_empty() && !stripped.contains('/') + } + + pub fn toggle_focus(&mut self) { + self.focus = match self.focus { + Panel::Tree => Panel::Content, + Panel::Content => Panel::Tree, + }; + } +} diff --git a/crates/ov_cli/src/tui/event.rs b/crates/ov_cli/src/tui/event.rs new file mode 100644 index 00000000..4a382abc --- /dev/null +++ b/crates/ov_cli/src/tui/event.rs @@ -0,0 +1,55 @@ +use crossterm::event::{KeyCode, KeyEvent}; + +use super::app::{App, Panel}; + +pub async fn handle_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Char('q') => { + app.should_quit = true; + } + KeyCode::Tab => { + app.toggle_focus(); + } + _ => match app.focus { + Panel::Tree => handle_tree_key(app, key).await, + Panel::Content => handle_content_key(app, key), + }, + } +} + +async fn handle_tree_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + app.tree.move_cursor_down(); + app.load_content_for_selected().await; + } + KeyCode::Char('k') | KeyCode::Up => { + app.tree.move_cursor_up(); + app.load_content_for_selected().await; + } + KeyCode::Char('.') => { + let client = app.client.clone(); + app.tree.toggle_expand(&client).await; + app.load_content_for_selected().await; + } + _ => {} + } +} + +fn handle_content_key(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + app.scroll_content_down(); + } + KeyCode::Char('k') | KeyCode::Up => { + app.scroll_content_up(); + } + KeyCode::Char('g') => { + app.scroll_content_top(); + } + KeyCode::Char('G') => { + app.scroll_content_bottom(); + } + _ => {} + } +} diff --git a/crates/ov_cli/src/tui/mod.rs b/crates/ov_cli/src/tui/mod.rs new file mode 100644 index 00000000..0079a112 --- /dev/null +++ b/crates/ov_cli/src/tui/mod.rs @@ -0,0 +1,75 @@ +mod app; +mod event; +mod tree; +mod ui; + +use std::io; + +use crossterm::{ + event::{self as ct_event, Event}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::prelude::*; + +use crate::client::HttpClient; +use crate::error::Result; +use app::App; + +pub async fn run_tui(client: HttpClient, uri: &str) -> Result<()> { + // Set up panic hook to restore terminal + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let _ = io::stdout().execute(LeaveAlternateScreen); + original_hook(panic_info); + })); + + enable_raw_mode()?; + if let Err(e) = io::stdout().execute(EnterAlternateScreen) { + let _ = disable_raw_mode(); + return Err(crate::error::Error::Io(e)); + } + + let result = run_loop(client, uri).await; + + // Always restore terminal + let _ = disable_raw_mode(); + let _ = io::stdout().execute(LeaveAlternateScreen); + + result +} + +async fn run_loop(client: HttpClient, uri: &str) -> Result<()> { + let backend = CrosstermBackend::new(io::stdout()); + let mut terminal = Terminal::new(backend)?; + + let mut app = App::new(client); + app.init(uri).await; + + loop { + // Adjust tree scroll before rendering + let tree_height = { + let area = terminal.size()?; + // main area height minus borders (2) minus status bar (1) + area.height.saturating_sub(3) as usize + }; + app.tree.adjust_scroll(tree_height); + + terminal.draw(|frame| ui::render(frame, &app))?; + + if ct_event::poll(std::time::Duration::from_millis(100))? { + if let Event::Key(key) = ct_event::read()? { + if key.kind == crossterm::event::KeyEventKind::Press { + event::handle_key(&mut app, key).await; + } + } + } + + if app.should_quit { + break; + } + } + + Ok(()) +} diff --git a/crates/ov_cli/src/tui/tree.rs b/crates/ov_cli/src/tui/tree.rs new file mode 100644 index 00000000..6a4ea1e3 --- /dev/null +++ b/crates/ov_cli/src/tui/tree.rs @@ -0,0 +1,282 @@ +use serde::Deserialize; + +use crate::client::HttpClient; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FsEntry { + pub uri: String, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub is_dir: bool, + #[serde(default)] + pub mod_time: Option, +} + +impl FsEntry { + pub fn name(&self) -> &str { + let path = self.uri.trim_end_matches('/'); + path.rsplit('/').next().unwrap_or(&self.uri) + } +} + +#[derive(Debug, Clone)] +pub struct TreeNode { + pub entry: FsEntry, + pub depth: usize, + pub expanded: bool, + pub children_loaded: bool, + pub children: Vec, +} + +#[derive(Debug, Clone)] +pub struct VisibleRow { + pub depth: usize, + pub name: String, + pub uri: String, + pub is_dir: bool, + pub expanded: bool, + /// Index path into the tree for identifying this node + pub node_index: Vec, +} + +pub struct TreeState { + pub nodes: Vec, + pub visible: Vec, + pub cursor: usize, + pub scroll_offset: usize, +} + +impl TreeState { + pub fn new() -> Self { + Self { + nodes: Vec::new(), + visible: Vec::new(), + cursor: 0, + scroll_offset: 0, + } + } + + /// Known root-level scopes in OpenViking + const ROOT_SCOPES: &'static [&'static str] = + &["resources", "memories", "skills", "temp", "agent", "queue", "user", "session"]; + + pub async fn load_root(&mut self, client: &HttpClient, uri: &str) { + let is_root = uri == "viking://" || uri == "viking:///"; + + if is_root { + // Synthesize root scope folders and eagerly load their children + let mut root_nodes = Vec::new(); + for scope in Self::ROOT_SCOPES { + let scope_uri = format!("viking://{}", scope); + let mut node = TreeNode { + entry: FsEntry { + uri: scope_uri.clone(), + size: None, + is_dir: true, + mod_time: None, + }, + depth: 0, + expanded: false, + children_loaded: false, + children: Vec::new(), + }; + + // Try to load children eagerly to show first level + if let Ok(mut children) = Self::fetch_children(client, &scope_uri).await { + for child in &mut children { + child.depth = 1; + } + node.children = children; + node.children_loaded = true; + if !node.children.is_empty() { + node.expanded = true; + } + } + // Always show all scopes + root_nodes.push(node); + } + + self.nodes = root_nodes; + self.rebuild_visible(); + } else { + match Self::fetch_children(client, uri).await { + Ok(nodes) => { + self.nodes = nodes; + self.rebuild_visible(); + } + Err(e) => { + self.nodes = vec![TreeNode { + entry: FsEntry { + uri: format!("(error: {})", e), + size: None, + is_dir: false, + mod_time: None, + }, + depth: 0, + expanded: false, + children_loaded: false, + children: Vec::new(), + }]; + self.rebuild_visible(); + } + } + } + } + + async fn fetch_children( + client: &HttpClient, + uri: &str, + ) -> Result, String> { + let result = client + .ls(uri, false, false, "original", 256, false, 1000) + .await + .map_err(|e| e.to_string())?; + + let entries: Vec = if let Some(arr) = result.as_array() { + arr.iter() + .filter_map(|v| serde_json::from_value(v.clone()).ok()) + .collect() + } else { + serde_json::from_value(result).unwrap_or_default() + }; + + let mut nodes: Vec = entries + .into_iter() + .map(|entry| TreeNode { + depth: 0, + expanded: false, + children_loaded: !entry.is_dir, + children: Vec::new(), + entry, + }) + .collect(); + + // Sort: directories first, then alphabetical + nodes.sort_by(|a, b| { + b.entry + .is_dir + .cmp(&a.entry.is_dir) + .then_with(|| a.entry.name().to_lowercase().cmp(&b.entry.name().to_lowercase())) + }); + + Ok(nodes) + } + + pub fn rebuild_visible(&mut self) { + self.visible.clear(); + let mut path = Vec::new(); + for (i, node) in self.nodes.iter().enumerate() { + path.push(i); + Self::flatten_node(node, 0, &mut self.visible, &mut path); + path.pop(); + } + } + + fn flatten_node( + node: &TreeNode, + depth: usize, + visible: &mut Vec, + path: &mut Vec, + ) { + visible.push(VisibleRow { + depth, + name: node.entry.name().to_string(), + uri: node.entry.uri.clone(), + is_dir: node.entry.is_dir, + expanded: node.expanded, + node_index: path.clone(), + }); + + if node.expanded { + for (i, child) in node.children.iter().enumerate() { + path.push(i); + Self::flatten_node(child, depth + 1, visible, path); + path.pop(); + } + } + } + + pub async fn toggle_expand(&mut self, client: &HttpClient) { + if self.visible.is_empty() { + return; + } + let row = &self.visible[self.cursor]; + if !row.is_dir { + return; + } + + let index_path = row.node_index.clone(); + let node = Self::get_node_mut(&mut self.nodes, &index_path); + + if let Some(node) = node { + if !node.children_loaded { + // Lazy load children + match Self::fetch_children(client, &node.entry.uri).await { + Ok(mut children) => { + let child_depth = node.depth + 1; + for child in &mut children { + child.depth = child_depth; + } + node.children = children; + node.children_loaded = true; + } + Err(_) => { + node.children_loaded = true; + // Leave children empty on error + } + } + } + node.expanded = !node.expanded; + } + + self.rebuild_visible(); + } + + fn get_node_mut<'a>( + nodes: &'a mut Vec, + index_path: &[usize], + ) -> Option<&'a mut TreeNode> { + if index_path.is_empty() { + return None; + } + let mut current = nodes.get_mut(index_path[0])?; + for &idx in &index_path[1..] { + current = current.children.get_mut(idx)?; + } + Some(current) + } + + pub fn move_cursor_up(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn move_cursor_down(&mut self) { + if !self.visible.is_empty() && self.cursor < self.visible.len() - 1 { + self.cursor += 1; + } + } + + pub fn selected_uri(&self) -> Option<&str> { + self.visible.get(self.cursor).map(|r| r.uri.as_str()) + } + + pub fn selected_is_dir(&self) -> Option { + self.visible.get(self.cursor).map(|r| r.is_dir) + } + + /// Adjust scroll_offset so cursor is visible in the given viewport height + pub fn adjust_scroll(&mut self, viewport_height: usize) { + if viewport_height == 0 { + return; + } + if self.cursor < self.scroll_offset { + self.scroll_offset = self.cursor; + } else if self.cursor >= self.scroll_offset + viewport_height { + self.scroll_offset = self.cursor - viewport_height + 1; + } + } +} diff --git a/crates/ov_cli/src/tui/ui.rs b/crates/ov_cli/src/tui/ui.rs new file mode 100644 index 00000000..a67591ac --- /dev/null +++ b/crates/ov_cli/src/tui/ui.rs @@ -0,0 +1,145 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, + Frame, +}; + +use super::app::{App, Panel}; + +pub fn render(frame: &mut Frame, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(frame.area()); + + let main_area = chunks[0]; + let status_area = chunks[1]; + + let panels = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(35), Constraint::Percentage(65)]) + .split(main_area); + + render_tree(frame, app, panels[0]); + render_content(frame, app, panels[1]); + render_status_bar(frame, app, status_area); +} + +fn render_tree(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let focused = app.focus == Panel::Tree; + let border_color = if focused { + Color::Cyan + } else { + Color::DarkGray + }; + + let block = Block::default() + .title(" Explorer ") + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + if app.tree.visible.is_empty() { + let empty = Paragraph::new("(empty)").style(Style::default().fg(Color::DarkGray)); + frame.render_widget(empty, inner); + return; + } + + let viewport_height = inner.height as usize; + + // Build list items with scroll offset + let items: Vec = app + .tree + .visible + .iter() + .skip(app.tree.scroll_offset) + .take(viewport_height) + .map(|row| { + let indent = " ".repeat(row.depth); + let icon = if row.is_dir { + if row.expanded { + "▾ " + } else { + "▸ " + } + } else { + " " + }; + + let style = if row.is_dir { + Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let line = Line::from(vec![ + Span::raw(indent), + Span::styled(icon, style), + Span::styled(&row.name, style), + ]); + ListItem::new(line) + }) + .collect(); + + // Adjust cursor relative to scroll offset for ListState + let adjusted_cursor = app.tree.cursor.saturating_sub(app.tree.scroll_offset); + let mut list_state = ListState::default().with_selected(Some(adjusted_cursor)); + + let list = List::new(items).highlight_style( + Style::default() + .bg(if focused { Color::DarkGray } else { Color::Reset }) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ); + + frame.render_stateful_widget(list, inner, &mut list_state); +} + +fn render_content(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let focused = app.focus == Panel::Content; + let border_color = if focused { + Color::Cyan + } else { + Color::DarkGray + }; + + let title = if app.content_title.is_empty() { + " Content ".to_string() + } else { + format!(" {} ", app.content_title) + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + + let paragraph = Paragraph::new(app.content.as_str()) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((app.content_scroll, 0)); + + frame.render_widget(paragraph, area); +} + +fn render_status_bar(frame: &mut Frame, _app: &App, area: ratatui::layout::Rect) { + let hints = Line::from(vec![ + Span::styled(" q", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(":quit "), + Span::styled("TAB", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(":switch "), + Span::styled("j/k", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(":navigate "), + Span::styled(".", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(":toggle folder "), + Span::styled("g/G", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(":top/bottom"), + ]); + + let bar = Paragraph::new(hints).style(Style::default().bg(Color::DarkGray).fg(Color::White)); + frame.render_widget(bar, area); +}