Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/ov_cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -22,3 +22,5 @@ anyhow = "1.0"
mime_guess = "2.0"
thiserror = "1.0"
unicode-width = "0.1"
ratatui = "0.30"
crossterm = "0.28"
15 changes: 15 additions & 0 deletions crates/ov_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod commands;
mod config;
mod error;
mod output;
mod tui;

use clap::{Parser, Subcommand};
use config::Config;
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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
}
160 changes: 160 additions & 0 deletions crates/ov_cli/src/tui/app.rs
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
55 changes: 55 additions & 0 deletions crates/ov_cli/src/tui/event.rs
Original file line number Diff line number Diff line change
@@ -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();
}
_ => {}
}
}
75 changes: 75 additions & 0 deletions crates/ov_cli/src/tui/mod.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
Loading
Loading