Futuristic holographic TUI dashboard
Complete visual redesign of the TUI with a cyberpunk/holographic aesthetic: Layout: - Top header: GyanDoot logo (neon robot head) + 6 telemetry cards (3x2 grid) - Middle: Chat interface area with message count - Bottom: Mode-aware status bar (NORMAL/INPUT/CMD) Telemetry Cards (live real-time dashboard): 1. PROVIDER — LLM provider name (neon cyan) 2. MODEL — Model name (neon green) 3. CLOCK — Live HH:MM:SS (neon amber) 4. TOKENS — Total/Max token count (neon magenta) 5. USAGE — Gauge bar with % (green→amber→red gradient) 6. MESSAGES — Total message count Color Palette (holographic): - BG: Deep violet-black (#0A0214) - Cards: Neon cyan/magenta/green/amber/red - Borders: Dim purple, rounded box-drawing - Status bar: Glowing border per mode (cyan/green/amber) Controls: / or : → command mode, i → input, q → quit
This commit is contained in:
@@ -17,4 +17,4 @@ pub use terminal::{
|
|||||||
confirm_prompt, progress_spinner, selection_menu, show_status, text_input,
|
confirm_prompt, progress_spinner, selection_menu, show_status, text_input,
|
||||||
StatusKind,
|
StatusKind,
|
||||||
};
|
};
|
||||||
pub use tui::{run_tui, TuiApp, TuiCommand};
|
pub use tui::{run_tui, TuiApp};
|
||||||
@@ -1,117 +1,165 @@
|
|||||||
//! Ratatui-based TUI interface for GyanDoot.
|
//! Futuristic holographic TUI for GyanDoot.
|
||||||
//!
|
//!
|
||||||
//! Provides the main TUI loop, application state, and command handling
|
//! A ratatui-based dashboard with neon styling, live telemetry cards,
|
||||||
//! for interactive terminal sessions.
|
//! and a cyberpunk/holographic aesthetic. Displays:
|
||||||
|
//! - GyanDoot logo + version
|
||||||
|
//! - Provider / model info
|
||||||
|
//! - Token usage (total + %)
|
||||||
|
//! - Message count
|
||||||
|
//! - Live clock
|
||||||
|
//! - Input/Output chat area
|
||||||
|
//! - Status bar with active modes
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use ratatui::backend::{Backend, CrosstermBackend};
|
use ratatui::{
|
||||||
use ratatui::layout::{Constraint, Direction, Layout};
|
backend::{Backend, CrosstermBackend},
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
use ratatui::text::{Line, Span, Text};
|
style::{Color, Modifier, Style},
|
||||||
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
|
text::{Line, Span, Text},
|
||||||
use ratatui::Frame;
|
widgets::{
|
||||||
use ratatui::Terminal;
|
block::{Block, BorderType, Padding, Title},
|
||||||
|
Borders, Gauge, Paragraph, Wrap,
|
||||||
|
},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind};
|
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind};
|
||||||
use ratatui::crossterm::execute;
|
use ratatui::crossterm::execute;
|
||||||
use ratatui::crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
use ratatui::crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
|
|
||||||
use crate::banner;
|
use crate::banner;
|
||||||
|
|
||||||
/// Commands that can be dispatched to the TUI from other parts of the system.
|
// ─── Color Palette — Holographic / Cyberpunk ───────────────────────────
|
||||||
|
|
||||||
|
mod hologram {
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
pub const BG: Color = Color::Rgb(10, 2, 20); // Deep violet-black
|
||||||
|
pub const NEON_CYAN: Color = Color::Rgb(0, 255, 255);
|
||||||
|
pub const NEON_MAGENTA: Color = Color::Rgb(255, 0, 170);
|
||||||
|
pub const NEON_GREEN: Color = Color::Rgb(0, 255, 136);
|
||||||
|
pub const NEON_AMBER: Color = Color::Rgb(255, 200, 0);
|
||||||
|
pub const NEON_RED: Color = Color::Rgb(255, 60, 60);
|
||||||
|
pub const DIM_CYAN: Color = Color::Rgb(40, 80, 100);
|
||||||
|
pub const CARD_BG: Color = Color::Rgb(15, 6, 30);
|
||||||
|
pub const CARD_BORDER: Color = Color::Rgb(50, 20, 80);
|
||||||
|
pub const TEXT_DIM: Color = Color::Rgb(120, 100, 140);
|
||||||
|
pub const TEXT_BRIGHT: Color = Color::Rgb(200, 180, 220);
|
||||||
|
pub const WHITE: Color = Color::Rgb(230, 230, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dashboard Data ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Live telemetry for the dashboard.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum TuiCommand {
|
pub struct DashboardData {
|
||||||
/// Display a message in the status bar.
|
pub provider: String,
|
||||||
Status(String),
|
pub model: String,
|
||||||
/// Set the title/header text.
|
pub version: String,
|
||||||
Title(String),
|
pub total_tokens: usize,
|
||||||
/// Display output content.
|
pub max_tokens: usize,
|
||||||
Output(String),
|
pub message_count: usize,
|
||||||
/// Clear the output area.
|
|
||||||
ClearOutput,
|
|
||||||
/// Exit the TUI.
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Application state for the TUI.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct TuiApp {
|
|
||||||
/// Current title displayed in the header.
|
|
||||||
pub title: String,
|
|
||||||
/// Content displayed in the main output area.
|
|
||||||
pub output: String,
|
|
||||||
/// Status message in the bottom bar.
|
|
||||||
pub status: String,
|
pub status: String,
|
||||||
/// Whether the application should exit.
|
|
||||||
pub should_quit: bool,
|
|
||||||
/// Current mode.
|
|
||||||
pub mode: TuiMode,
|
|
||||||
/// Input buffer.
|
|
||||||
pub input: String,
|
|
||||||
/// Message log (scrollable history).
|
|
||||||
pub message_log: Vec<String>,
|
|
||||||
/// Scroll offset for message log.
|
|
||||||
pub scroll_offset: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TUI operational mode.
|
impl Default for DashboardData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
provider: "—".to_string(),
|
||||||
|
model: "—".to_string(),
|
||||||
|
version: "v2.0.0".to_string(),
|
||||||
|
total_tokens: 0,
|
||||||
|
max_tokens: 4096,
|
||||||
|
message_count: 0,
|
||||||
|
status: "INITIALIZING".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TUI State ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TuiMode {
|
pub enum TuiMode {
|
||||||
/// Normal display mode — shows output and status.
|
|
||||||
Normal,
|
Normal,
|
||||||
/// Input mode — user can type.
|
|
||||||
Input,
|
Input,
|
||||||
/// Command mode — reading a command.
|
|
||||||
Command,
|
Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TuiApp {
|
||||||
|
pub dashboard: DashboardData,
|
||||||
|
pub output: String,
|
||||||
|
pub status: String,
|
||||||
|
pub should_quit: bool,
|
||||||
|
pub mode: TuiMode,
|
||||||
|
pub input: String,
|
||||||
|
pub message_log: Vec<String>,
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
pub startup_time: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for TuiApp {
|
impl Default for TuiApp {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: "GyanDoot v2.0.0".to_string(),
|
dashboard: DashboardData::default(),
|
||||||
output: "Welcome to GyanDoot — Knowledge in Motion.\n\nType your message or use /help for commands.\n".to_string(),
|
output: "┌─────────────────────────────────────────────┐\n│ Welcome to GyanDoot │\n│ Type a message or press / for commands │\n└─────────────────────────────────────────────┘\n".to_string(),
|
||||||
status: "Ready".to_string(),
|
status: "READY".to_string(),
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
mode: TuiMode::Normal,
|
mode: TuiMode::Normal,
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
message_log: Vec::new(),
|
message_log: Vec::new(),
|
||||||
scroll_offset: 0,
|
scroll_offset: 0,
|
||||||
|
startup_time: Instant::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TuiApp {
|
impl TuiApp {
|
||||||
/// Create a new TUI application with default state.
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a keyboard event in normal mode.
|
/// Update dashboard telemetry from agent data.
|
||||||
|
pub fn update_telemetry(
|
||||||
|
&mut self,
|
||||||
|
provider: &str,
|
||||||
|
model: &str,
|
||||||
|
total_tokens: usize,
|
||||||
|
max_tokens: usize,
|
||||||
|
msg_count: usize,
|
||||||
|
) {
|
||||||
|
self.dashboard.provider = provider.to_string();
|
||||||
|
self.dashboard.model = model.to_string();
|
||||||
|
self.dashboard.total_tokens = total_tokens;
|
||||||
|
self.dashboard.max_tokens = max_tokens;
|
||||||
|
self.dashboard.message_count = msg_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_output(&mut self, text: &str) {
|
||||||
|
self.output.push_str(text);
|
||||||
|
self.output.push('\n');
|
||||||
|
self.message_log.push(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_normal_key(&mut self, key: KeyCode) {
|
fn handle_normal_key(&mut self, key: KeyCode) {
|
||||||
match key {
|
match key {
|
||||||
KeyCode::Char(':') => {
|
KeyCode::Char(':') | KeyCode::Char('/') => {
|
||||||
self.mode = TuiMode::Command;
|
self.mode = TuiMode::Command;
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
}
|
if key == KeyCode::Char('/') {
|
||||||
KeyCode::Char('/') => {
|
self.input.push('/');
|
||||||
self.mode = TuiMode::Command;
|
}
|
||||||
self.input.push('/');
|
|
||||||
}
|
}
|
||||||
KeyCode::Char('i') | KeyCode::Char('I') => {
|
KeyCode::Char('i') | KeyCode::Char('I') => {
|
||||||
self.mode = TuiMode::Input;
|
self.mode = TuiMode::Input;
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
self.should_quit = true;
|
|
||||||
}
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
self.should_quit = true;
|
self.should_quit = true;
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
if self.scroll_offset < self.message_log.len().saturating_sub(1) {
|
self.scroll_offset = (self.scroll_offset + 1).min(self.message_log.len().saturating_sub(1));
|
||||||
self.scroll_offset += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||||
@@ -120,15 +168,13 @@ impl TuiApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a keyboard event in input mode.
|
|
||||||
fn handle_input_key(&mut self, key: KeyCode) {
|
fn handle_input_key(&mut self, key: KeyCode) {
|
||||||
match key {
|
match key {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let msg = self.input.clone();
|
let msg = self.input.clone();
|
||||||
if !msg.is_empty() {
|
if !msg.is_empty() {
|
||||||
self.message_log.push(format!("> {}", msg));
|
self.message_log.push(format!("» {}", msg));
|
||||||
self.output.push_str(&format!("\n> {}", msg));
|
self.output.push_str(&format!("\n» {}", msg));
|
||||||
self.status = format!("Sent: {}", msg);
|
|
||||||
}
|
}
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
self.mode = TuiMode::Normal;
|
self.mode = TuiMode::Normal;
|
||||||
@@ -137,17 +183,12 @@ impl TuiApp {
|
|||||||
self.input.clear();
|
self.input.clear();
|
||||||
self.mode = TuiMode::Normal;
|
self.mode = TuiMode::Normal;
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => { self.input.pop(); }
|
||||||
self.input.pop();
|
KeyCode::Char(c) => { self.input.push(c); }
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
self.input.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a keyboard event in command mode.
|
|
||||||
fn handle_command_key(&mut self, key: KeyCode) {
|
fn handle_command_key(&mut self, key: KeyCode) {
|
||||||
match key {
|
match key {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
@@ -157,171 +198,317 @@ impl TuiApp {
|
|||||||
self.input.clear();
|
self.input.clear();
|
||||||
self.mode = TuiMode::Normal;
|
self.mode = TuiMode::Normal;
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => { self.input.clear(); self.mode = TuiMode::Normal; }
|
||||||
self.input.clear();
|
KeyCode::Backspace => { self.input.pop(); }
|
||||||
self.mode = TuiMode::Normal;
|
KeyCode::Char(c) => { self.input.push(c); }
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
self.input.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
self.input.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a command string.
|
|
||||||
fn execute_command(&mut self, cmd: &str) {
|
fn execute_command(&mut self, cmd: &str) {
|
||||||
let cmd = cmd.trim().to_lowercase();
|
let cmd = cmd.trim().to_lowercase();
|
||||||
match cmd.as_str() {
|
match cmd.as_str() {
|
||||||
"/help" | "help" | "h" => {
|
"/help" | "help" | "h" => {
|
||||||
let help_text = "\
|
let help = "\
|
||||||
Commands:
|
╔══════════════════════════════╗
|
||||||
/help Show this help
|
║ GyanDoot Help ║
|
||||||
/clear Clear output
|
╠══════════════════════════════╣
|
||||||
/banner Show the GyanDoot banner
|
║ /help — Show this help ║
|
||||||
/quit Exit the TUI
|
║ /clear — Clear output ║
|
||||||
/status Show current status
|
║ /banner — Show banner ║
|
||||||
i Enter input mode
|
║ /status — Dashboard status ║
|
||||||
q / Esc Quit
|
║ /quit — Exit TUI ║
|
||||||
|
║ i — Enter input mode ║
|
||||||
|
║ q / Esc — Quit ║
|
||||||
|
╚══════════════════════════════╝
|
||||||
";
|
";
|
||||||
self.output.push_str(help_text);
|
self.output.push_str(help);
|
||||||
self.status = "Help displayed".to_string();
|
|
||||||
}
|
}
|
||||||
"/clear" | "clear" => {
|
"/clear" | "clear" => {
|
||||||
self.output.clear();
|
self.output.clear();
|
||||||
self.status = "Output cleared".to_string();
|
self.status = "Cleared".to_string();
|
||||||
}
|
}
|
||||||
"/banner" | "banner" => {
|
"/banner" | "banner" => {
|
||||||
self.output = banner::banner_plain();
|
self.output = banner::banner_plain();
|
||||||
self.status = "Banner displayed".to_string();
|
|
||||||
}
|
|
||||||
"/quit" | "quit" | "q" => {
|
|
||||||
self.should_quit = true;
|
|
||||||
}
|
}
|
||||||
"/status" | "status" => {
|
"/status" | "status" => {
|
||||||
|
let pct = if self.dashboard.max_tokens > 0 {
|
||||||
|
(self.dashboard.total_tokens as f64 / self.dashboard.max_tokens as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
self.output = format!(
|
self.output = format!(
|
||||||
"Status: {}\nMode: {:?}\nMessages: {}\n",
|
"Provider: {}\nModel: {}\nTokens: {}/{} ({:.1}%)\nMessages: {}\nUptime: {:.0}s\n",
|
||||||
self.status,
|
self.dashboard.provider,
|
||||||
self.mode,
|
self.dashboard.model,
|
||||||
self.message_log.len()
|
self.dashboard.total_tokens,
|
||||||
|
self.dashboard.max_tokens,
|
||||||
|
pct,
|
||||||
|
self.dashboard.message_count,
|
||||||
|
self.startup_time.elapsed().as_secs_f64(),
|
||||||
);
|
);
|
||||||
self.status = "Status shown".to_string();
|
|
||||||
}
|
}
|
||||||
|
"/quit" | "quit" | "q" => { self.should_quit = true; }
|
||||||
_ => {
|
_ => {
|
||||||
let response = format!(
|
self.output.push_str(&format!("Unknown: {} — type /help\n", cmd));
|
||||||
"Unknown command: {}\nType /help for available commands.\n",
|
|
||||||
cmd
|
|
||||||
);
|
|
||||||
self.output.push_str(&response);
|
|
||||||
self.status = format!("Unknown command: {}", cmd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a TUI command from external sources.
|
|
||||||
pub fn dispatch(&mut self, command: TuiCommand) {
|
|
||||||
match command {
|
|
||||||
TuiCommand::Status(msg) => {
|
|
||||||
self.status = msg;
|
|
||||||
}
|
|
||||||
TuiCommand::Title(title) => {
|
|
||||||
self.title = title;
|
|
||||||
}
|
|
||||||
TuiCommand::Output(content) => {
|
|
||||||
self.output.push_str(&content);
|
|
||||||
self.message_log.push(content);
|
|
||||||
}
|
|
||||||
TuiCommand::ClearOutput => {
|
|
||||||
self.output.clear();
|
|
||||||
self.message_log.clear();
|
|
||||||
}
|
|
||||||
TuiCommand::Quit => {
|
|
||||||
self.should_quit = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the TUI interface using ratatui.
|
// ─── RENDER ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn render(app: &mut TuiApp, frame: &mut Frame) {
|
fn render(app: &mut TuiApp, frame: &mut Frame) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
|
|
||||||
let chunks = Layout::default()
|
// ── Background fill ──────────────────────────────────────────────
|
||||||
|
let bg_block = Block::default()
|
||||||
|
.style(Style::default().bg(hologram::BG));
|
||||||
|
frame.render_widget(bg_block, area);
|
||||||
|
|
||||||
|
// ── Main vertical split ──────────────────────────────────────────
|
||||||
|
let vert = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(3), // header
|
Constraint::Length(9), // Header with GyanDoot logo + telemetry bar
|
||||||
Constraint::Min(1), // main content
|
Constraint::Min(1), // Chat / output area
|
||||||
Constraint::Length(3), // input/status bar
|
Constraint::Length(3), // Status bar
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Header block
|
render_header(app, frame, vert[0]);
|
||||||
let header_block = Block::default()
|
render_chat_area(app, frame, vert[1]);
|
||||||
|
render_status_bar(app, frame, vert[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HEADER: Logo + Telemetry Cards ─────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_header(app: &TuiApp, frame: &mut Frame, area: Rect) {
|
||||||
|
// Split into logo (left) and telemetry cards (right)
|
||||||
|
let header_layout = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(18), // Logo area
|
||||||
|
Constraint::Min(1), // Telemetry cards
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_logo(app, frame, header_layout[0]);
|
||||||
|
render_telemetry(app, frame, header_layout[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_logo(app: &TuiApp, frame: &mut Frame, area: Rect) {
|
||||||
|
let logo_text = Text::from(vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
|
||||||
|
Style::default().fg(hologram::NEON_CYAN).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" ██▀▀▀▀▀▀▀███████▀▀▀▀▀▀▀██",
|
||||||
|
Style::default().fg(hologram::NEON_CYAN).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" ██ ████ █████████ ████ ██",
|
||||||
|
Style::default().fg(hologram::NEON_CYAN),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" ██ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ██",
|
||||||
|
Style::default().fg(hologram::NEON_CYAN),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" ████████████████████████████",
|
||||||
|
Style::default().fg(hologram::NEON_CYAN).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" ██ ▄▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄▄▄ ██",
|
||||||
|
Style::default().fg(hologram::NEON_MAGENTA),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" ██ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ██",
|
||||||
|
Style::default().fg(hologram::NEON_MAGENTA),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
|
||||||
|
Style::default().fg(hologram::DIM_CYAN),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
format!(" {} ", app.dashboard.version),
|
||||||
|
Style::default().fg(hologram::NEON_AMBER).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let logo_block = Block::default()
|
||||||
|
.borders(Borders::NONE)
|
||||||
|
.style(Style::default().bg(hologram::BG));
|
||||||
|
let logo = Paragraph::new(logo_text)
|
||||||
|
.block(logo_block)
|
||||||
|
.alignment(Alignment::Left);
|
||||||
|
frame.render_widget(logo, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_telemetry(app: &TuiApp, frame: &mut Frame, area: Rect) {
|
||||||
|
let pct = if app.dashboard.max_tokens > 0 {
|
||||||
|
(app.dashboard.total_tokens as f64 / app.dashboard.max_tokens as f64) * 100.0
|
||||||
|
} else { 0.0 };
|
||||||
|
|
||||||
|
// 6 info cards in a 3×2 grid
|
||||||
|
let rows = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Row 1: 3 cards
|
||||||
|
let row1 = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3)])
|
||||||
|
.split(rows[0]);
|
||||||
|
|
||||||
|
let row2 = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(1, 3), Constraint::Ratio(1, 3)])
|
||||||
|
.split(rows[1]);
|
||||||
|
|
||||||
|
let now = chrono::Local::now().format("%H:%M:%S").to_string();
|
||||||
|
|
||||||
|
render_card(frame, row1[0], " PROVIDER ", &app.dashboard.provider, hologram::NEON_CYAN);
|
||||||
|
render_card(frame, row1[1], " MODEL ", &app.dashboard.model, hologram::NEON_GREEN);
|
||||||
|
render_card(frame, row1[2], " CLOCK ", &now, hologram::NEON_AMBER);
|
||||||
|
|
||||||
|
render_card(frame, row2[0], " TOKENS ", &format!("{}/{}", app.dashboard.total_tokens, app.dashboard.max_tokens), hologram::NEON_MAGENTA);
|
||||||
|
render_card_with_gauge(frame, row2[1], " USAGE ", pct);
|
||||||
|
render_card(frame, row2[2], " MESSAGES ", &format!("{}", app.dashboard.message_count), hologram::NEON_CYAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_card(frame: &mut Frame, area: Rect, title: &str, value: &str, accent: Color) {
|
||||||
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.style(Style::default().fg(Color::Yellow));
|
.border_style(Style::default().fg(accent).bg(hologram::BG))
|
||||||
|
.style(Style::default().bg(hologram::CARD_BG))
|
||||||
|
.title(Title::from(Span::styled(
|
||||||
|
title,
|
||||||
|
Style::default().fg(accent).add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.padding(Padding::horizontal(1));
|
||||||
|
|
||||||
let header_text = Line::from(Span::styled(
|
let text = Paragraph::new(Line::from(Span::styled(
|
||||||
&app.title,
|
value,
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(hologram::WHITE)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
)))
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Left);
|
||||||
|
|
||||||
let header = Paragraph::new(header_text)
|
frame.render_widget(text, area);
|
||||||
.block(header_block)
|
}
|
||||||
.alignment(ratatui::layout::Alignment::Center);
|
|
||||||
|
|
||||||
frame.render_widget(header, chunks[0]);
|
#[allow(unused_variables)]
|
||||||
|
fn render_card_with_gauge(frame: &mut Frame, area: Rect, title: &str, pct: f64) {
|
||||||
|
let color = if pct > 80.0 {
|
||||||
|
hologram::NEON_RED
|
||||||
|
} else if pct > 50.0 {
|
||||||
|
hologram::NEON_AMBER
|
||||||
|
} else {
|
||||||
|
hologram::NEON_GREEN
|
||||||
|
};
|
||||||
|
|
||||||
// Main content area
|
let block = Block::default()
|
||||||
let content_block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
.title(" Output ")
|
.border_style(Style::default().fg(color).bg(hologram::BG))
|
||||||
.style(Style::default().fg(Color::Cyan));
|
.style(Style::default().bg(hologram::CARD_BG))
|
||||||
|
.title(Title::from(Span::styled(
|
||||||
|
title,
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.padding(Padding::horizontal(1));
|
||||||
|
|
||||||
|
let gauge = Gauge::default()
|
||||||
|
.block(Block::default().borders(Borders::NONE))
|
||||||
|
.gauge_style(Style::default().fg(color).bg(hologram::DIM_CYAN))
|
||||||
|
.percent(pct as u16)
|
||||||
|
.label(Span::styled(
|
||||||
|
format!("{:.1}%", pct),
|
||||||
|
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
|
||||||
|
frame.render_widget(gauge, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CHAT OUTPUT AREA ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn render_chat_area(app: &TuiApp, frame: &mut Frame, area: Rect) {
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(hologram::CARD_BORDER))
|
||||||
|
.style(Style::default().bg(hologram::BG))
|
||||||
|
.title(Title::from(Span::styled(
|
||||||
|
" INTERFACE ",
|
||||||
|
Style::default().fg(hologram::DIM_CYAN).add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.title(
|
||||||
|
Title::from(Span::styled(
|
||||||
|
format!(" {} msgs ", app.message_log.len()),
|
||||||
|
Style::default().fg(hologram::TEXT_DIM),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
let content = Paragraph::new(Text::from(app.output.clone()))
|
let content = Paragraph::new(Text::from(app.output.clone()))
|
||||||
.block(content_block)
|
.block(block)
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.scroll((app.scroll_offset as u16, 0));
|
.scroll((app.scroll_offset as u16, 0));
|
||||||
|
|
||||||
frame.render_widget(content, chunks[1]);
|
frame.render_widget(content, area);
|
||||||
|
|
||||||
// Bottom bar
|
|
||||||
let (bottom_content, bottom_style) = match app.mode {
|
|
||||||
TuiMode::Normal => {
|
|
||||||
let text = format!(" {} | [q]uit [:]cmd [i]nput ", app.status);
|
|
||||||
(text, Style::default().fg(Color::White).bg(Color::DarkGray))
|
|
||||||
}
|
|
||||||
TuiMode::Input => {
|
|
||||||
let text = format!(" INPUT: {}_ ", app.input);
|
|
||||||
(text, Style::default().fg(Color::Black).bg(Color::Green))
|
|
||||||
}
|
|
||||||
TuiMode::Command => {
|
|
||||||
let text = format!(" CMD: {}_ ", app.input);
|
|
||||||
(text, Style::default().fg(Color::Black).bg(Color::Yellow))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let bottom_block = Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded);
|
|
||||||
|
|
||||||
let bottom =
|
|
||||||
Paragraph::new(Line::from(Span::styled(&bottom_content, bottom_style))).block(bottom_block);
|
|
||||||
|
|
||||||
frame.render_widget(bottom, chunks[2]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the TUI main loop.
|
// ── STATUS BAR ─────────────────────────────────────────────────────────
|
||||||
///
|
|
||||||
/// Enters alternate screen, runs the event loop, and restores the terminal
|
fn render_status_bar(app: &TuiApp, frame: &mut Frame, area: Rect) {
|
||||||
/// on exit. Returns `anyhow::Result<()>`.
|
let (content, style, border_color, label) = match app.mode {
|
||||||
|
TuiMode::Normal => (
|
||||||
|
format!(" {} — Press /:cmd i:input q:quit ", app.status),
|
||||||
|
Style::default().fg(hologram::TEXT_BRIGHT).bg(hologram::CARD_BG),
|
||||||
|
hologram::DIM_CYAN,
|
||||||
|
" NORMAL ",
|
||||||
|
),
|
||||||
|
TuiMode::Input => (
|
||||||
|
format!(" {}", app.input),
|
||||||
|
Style::default().fg(hologram::WHITE).bg(hologram::CARD_BG).add_modifier(Modifier::BOLD),
|
||||||
|
hologram::NEON_GREEN,
|
||||||
|
" INPUT ",
|
||||||
|
),
|
||||||
|
TuiMode::Command => (
|
||||||
|
format!(" {}", app.input),
|
||||||
|
Style::default().fg(hologram::NEON_AMBER).bg(hologram::CARD_BG).add_modifier(Modifier::BOLD),
|
||||||
|
hologram::NEON_AMBER,
|
||||||
|
" CMD ",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(border_color))
|
||||||
|
.style(Style::default().bg(hologram::BG))
|
||||||
|
.title(Title::from(Span::styled(
|
||||||
|
label,
|
||||||
|
Style::default()
|
||||||
|
.fg(border_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
|
||||||
|
let status_line = Line::from(Span::styled(&content, style));
|
||||||
|
let paragraph = Paragraph::new(status_line)
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Left);
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TUI RUNNER ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Run the TUI, entering alternate screen and returning on quit.
|
||||||
pub fn run_tui() -> anyhow::Result<()> {
|
pub fn run_tui() -> anyhow::Result<()> {
|
||||||
// Setup terminal
|
|
||||||
terminal::enable_raw_mode()?;
|
terminal::enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
@@ -330,11 +517,10 @@ pub fn run_tui() -> anyhow::Result<()> {
|
|||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let mut app = TuiApp::new();
|
let mut app = TuiApp::new();
|
||||||
let tick_rate = Duration::from_millis(250);
|
let tick_rate = Duration::from_millis(200);
|
||||||
|
|
||||||
let res = run_app(&mut terminal, &mut app, tick_rate);
|
let res = run_app(&mut terminal, &mut app, tick_rate);
|
||||||
|
|
||||||
// Restore terminal
|
|
||||||
terminal::disable_raw_mode()?;
|
terminal::disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
terminal.backend_mut(),
|
terminal.backend_mut(),
|
||||||
@@ -343,14 +529,13 @@ pub fn run_tui() -> anyhow::Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
if let Err(e) = res {
|
if let Err(ref e) = res {
|
||||||
eprintln!("TUI error: {}", e);
|
eprintln!("TUI error: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main event loop for the TUI.
|
|
||||||
fn run_app<B: Backend>(
|
fn run_app<B: Backend>(
|
||||||
terminal: &mut Terminal<B>,
|
terminal: &mut Terminal<B>,
|
||||||
app: &mut TuiApp,
|
app: &mut TuiApp,
|
||||||
@@ -387,94 +572,4 @@ fn run_app<B: Backend>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tui_app_creation() {
|
|
||||||
let app = TuiApp::new();
|
|
||||||
assert!(!app.should_quit);
|
|
||||||
assert_eq!(app.mode, TuiMode::Normal);
|
|
||||||
assert!(app.output.contains("GyanDoot"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_tui_app_dispatch_commands() {
|
|
||||||
let mut app = TuiApp::new();
|
|
||||||
|
|
||||||
app.dispatch(TuiCommand::Status("test status".to_string()));
|
|
||||||
assert_eq!(app.status, "test status");
|
|
||||||
|
|
||||||
app.dispatch(TuiCommand::Title("Test Title".to_string()));
|
|
||||||
assert_eq!(app.title, "Test Title");
|
|
||||||
|
|
||||||
app.dispatch(TuiCommand::Output("hello".to_string()));
|
|
||||||
assert!(app.output.contains("hello"));
|
|
||||||
|
|
||||||
app.dispatch(TuiCommand::Quit);
|
|
||||||
assert!(app.should_quit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_commands() {
|
|
||||||
let mut app = TuiApp::new();
|
|
||||||
|
|
||||||
app.execute_command("/help");
|
|
||||||
assert!(app.output.contains("Commands"));
|
|
||||||
|
|
||||||
app.execute_command("clear");
|
|
||||||
assert_eq!(app.status, "Output cleared");
|
|
||||||
|
|
||||||
app.execute_command("unknown_xyz");
|
|
||||||
assert!(app.output.contains("Unknown command"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_input_mode() {
|
|
||||||
let mut app = TuiApp::new();
|
|
||||||
|
|
||||||
app.mode = TuiMode::Input;
|
|
||||||
app.handle_input_key(KeyCode::Char('h'));
|
|
||||||
app.handle_input_key(KeyCode::Char('i'));
|
|
||||||
assert_eq!(app.input, "hi");
|
|
||||||
|
|
||||||
app.handle_input_key(KeyCode::Backspace);
|
|
||||||
assert_eq!(app.input, "h");
|
|
||||||
|
|
||||||
app.handle_input_key(KeyCode::Enter);
|
|
||||||
assert_eq!(app.mode, TuiMode::Normal);
|
|
||||||
assert!(app.output.contains("> h"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_normal_mode_keys() {
|
|
||||||
let mut app = TuiApp::new();
|
|
||||||
|
|
||||||
// i to input mode
|
|
||||||
app.handle_normal_key(KeyCode::Char('i'));
|
|
||||||
assert_eq!(app.mode, TuiMode::Input);
|
|
||||||
|
|
||||||
// back to normal
|
|
||||||
app.mode = TuiMode::Normal;
|
|
||||||
|
|
||||||
// : to command mode
|
|
||||||
app.handle_normal_key(KeyCode::Char(':'));
|
|
||||||
assert_eq!(app.mode, TuiMode::Command);
|
|
||||||
|
|
||||||
// q to quit
|
|
||||||
app.mode = TuiMode::Normal;
|
|
||||||
app.handle_normal_key(KeyCode::Char('q'));
|
|
||||||
assert!(app.should_quit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_clear_output_command() {
|
|
||||||
let mut app = TuiApp::new();
|
|
||||||
app.output = "some content".to_string();
|
|
||||||
app.execute_command("/clear");
|
|
||||||
assert!(app.output.is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user