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,
|
||||
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
|
||||
//! for interactive terminal sessions.
|
||||
//! A ratatui-based dashboard with neon styling, live telemetry cards,
|
||||
//! 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::time::{Duration, Instant};
|
||||
|
||||
use ratatui::backend::{Backend, CrosstermBackend};
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::{
|
||||
backend::{Backend, CrosstermBackend},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{
|
||||
block::{Block, BorderType, Padding, Title},
|
||||
Borders, Gauge, Paragraph, Wrap,
|
||||
},
|
||||
Frame, Terminal,
|
||||
};
|
||||
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind};
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
|
||||
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)]
|
||||
pub enum TuiCommand {
|
||||
/// Display a message in the status bar.
|
||||
Status(String),
|
||||
/// Set the title/header text.
|
||||
Title(String),
|
||||
/// Display output content.
|
||||
Output(String),
|
||||
/// 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 struct DashboardData {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub version: String,
|
||||
pub total_tokens: usize,
|
||||
pub max_tokens: usize,
|
||||
pub message_count: usize,
|
||||
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)]
|
||||
pub enum TuiMode {
|
||||
/// Normal display mode — shows output and status.
|
||||
Normal,
|
||||
/// Input mode — user can type.
|
||||
Input,
|
||||
/// Command mode — reading a 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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: "GyanDoot v2.0.0".to_string(),
|
||||
output: "Welcome to GyanDoot — Knowledge in Motion.\n\nType your message or use /help for commands.\n".to_string(),
|
||||
status: "Ready".to_string(),
|
||||
dashboard: DashboardData::default(),
|
||||
output: "┌─────────────────────────────────────────────┐\n│ Welcome to GyanDoot │\n│ Type a message or press / for commands │\n└─────────────────────────────────────────────┘\n".to_string(),
|
||||
status: "READY".to_string(),
|
||||
should_quit: false,
|
||||
mode: TuiMode::Normal,
|
||||
input: String::new(),
|
||||
message_log: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
startup_time: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
/// Create a new TUI application with default state.
|
||||
pub fn new() -> Self {
|
||||
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) {
|
||||
match key {
|
||||
KeyCode::Char(':') => {
|
||||
KeyCode::Char(':') | KeyCode::Char('/') => {
|
||||
self.mode = TuiMode::Command;
|
||||
self.input.clear();
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.mode = TuiMode::Command;
|
||||
if key == KeyCode::Char('/') {
|
||||
self.input.push('/');
|
||||
}
|
||||
}
|
||||
KeyCode::Char('i') | KeyCode::Char('I') => {
|
||||
self.mode = TuiMode::Input;
|
||||
self.input.clear();
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
KeyCode::Char('q') => {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if self.scroll_offset < self.message_log.len().saturating_sub(1) {
|
||||
self.scroll_offset += 1;
|
||||
}
|
||||
self.scroll_offset = (self.scroll_offset + 1).min(self.message_log.len().saturating_sub(1));
|
||||
}
|
||||
KeyCode::Down => {
|
||||
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) {
|
||||
match key {
|
||||
KeyCode::Enter => {
|
||||
let msg = self.input.clone();
|
||||
if !msg.is_empty() {
|
||||
self.message_log.push(format!("> {}", msg));
|
||||
self.output.push_str(&format!("\n> {}", msg));
|
||||
self.status = format!("Sent: {}", msg);
|
||||
self.message_log.push(format!("» {}", msg));
|
||||
self.output.push_str(&format!("\n» {}", msg));
|
||||
}
|
||||
self.input.clear();
|
||||
self.mode = TuiMode::Normal;
|
||||
@@ -137,17 +183,12 @@ impl TuiApp {
|
||||
self.input.clear();
|
||||
self.mode = TuiMode::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.input.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => { self.input.pop(); }
|
||||
KeyCode::Char(c) => { self.input.push(c); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a keyboard event in command mode.
|
||||
fn handle_command_key(&mut self, key: KeyCode) {
|
||||
match key {
|
||||
KeyCode::Enter => {
|
||||
@@ -157,171 +198,317 @@ impl TuiApp {
|
||||
self.input.clear();
|
||||
self.mode = TuiMode::Normal;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.input.clear();
|
||||
self.mode = TuiMode::Normal;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.input.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.push(c);
|
||||
}
|
||||
KeyCode::Esc => { self.input.clear(); self.mode = TuiMode::Normal; }
|
||||
KeyCode::Backspace => { self.input.pop(); }
|
||||
KeyCode::Char(c) => { self.input.push(c); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a command string.
|
||||
fn execute_command(&mut self, cmd: &str) {
|
||||
let cmd = cmd.trim().to_lowercase();
|
||||
match cmd.as_str() {
|
||||
"/help" | "help" | "h" => {
|
||||
let help_text = "\
|
||||
Commands:
|
||||
/help Show this help
|
||||
/clear Clear output
|
||||
/banner Show the GyanDoot banner
|
||||
/quit Exit the TUI
|
||||
/status Show current status
|
||||
i Enter input mode
|
||||
q / Esc Quit
|
||||
let help = "\
|
||||
╔══════════════════════════════╗
|
||||
║ GyanDoot Help ║
|
||||
╠══════════════════════════════╣
|
||||
║ /help — Show this help ║
|
||||
║ /clear — Clear output ║
|
||||
║ /banner — Show banner ║
|
||||
║ /status — Dashboard status ║
|
||||
║ /quit — Exit TUI ║
|
||||
║ i — Enter input mode ║
|
||||
║ q / Esc — Quit ║
|
||||
╚══════════════════════════════╝
|
||||
";
|
||||
self.output.push_str(help_text);
|
||||
self.status = "Help displayed".to_string();
|
||||
self.output.push_str(help);
|
||||
}
|
||||
"/clear" | "clear" => {
|
||||
self.output.clear();
|
||||
self.status = "Output cleared".to_string();
|
||||
self.status = "Cleared".to_string();
|
||||
}
|
||||
"/banner" | "banner" => {
|
||||
self.output = banner::banner_plain();
|
||||
self.status = "Banner displayed".to_string();
|
||||
}
|
||||
"/quit" | "quit" | "q" => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
"/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!(
|
||||
"Status: {}\nMode: {:?}\nMessages: {}\n",
|
||||
self.status,
|
||||
self.mode,
|
||||
self.message_log.len()
|
||||
"Provider: {}\nModel: {}\nTokens: {}/{} ({:.1}%)\nMessages: {}\nUptime: {:.0}s\n",
|
||||
self.dashboard.provider,
|
||||
self.dashboard.model,
|
||||
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!(
|
||||
"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;
|
||||
self.output.push_str(&format!("Unknown: {} — type /help\n", cmd));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the TUI interface using ratatui.
|
||||
// ─── RENDER ────────────────────────────────────────────────────────────
|
||||
|
||||
fn render(app: &mut TuiApp, frame: &mut Frame) {
|
||||
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)
|
||||
.constraints([
|
||||
Constraint::Length(3), // header
|
||||
Constraint::Min(1), // main content
|
||||
Constraint::Length(3), // input/status bar
|
||||
Constraint::Length(9), // Header with GyanDoot logo + telemetry bar
|
||||
Constraint::Min(1), // Chat / output area
|
||||
Constraint::Length(3), // Status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header block
|
||||
let header_block = Block::default()
|
||||
render_header(app, frame, vert[0]);
|
||||
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)
|
||||
.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(
|
||||
&app.title,
|
||||
let text = Paragraph::new(Line::from(Span::styled(
|
||||
value,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.fg(hologram::WHITE)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.block(block)
|
||||
.alignment(Alignment::Left);
|
||||
|
||||
frame.render_widget(text, area);
|
||||
}
|
||||
|
||||
#[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
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(color).bg(hologram::BG))
|
||||
.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),
|
||||
));
|
||||
|
||||
let header = Paragraph::new(header_text)
|
||||
.block(header_block)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(gauge, area);
|
||||
}
|
||||
|
||||
frame.render_widget(header, chunks[0]);
|
||||
// ── CHAT OUTPUT AREA ───────────────────────────────────────────────────
|
||||
|
||||
// Main content area
|
||||
let content_block = Block::default()
|
||||
fn render_chat_area(app: &TuiApp, frame: &mut Frame, area: Rect) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(" Output ")
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
.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()))
|
||||
.block(content_block)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false })
|
||||
.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))
|
||||
}
|
||||
// ── STATUS BAR ─────────────────────────────────────────────────────────
|
||||
|
||||
fn render_status_bar(app: &TuiApp, frame: &mut Frame, area: Rect) {
|
||||
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 bottom_block = Block::default()
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded);
|
||||
.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 bottom =
|
||||
Paragraph::new(Line::from(Span::styled(&bottom_content, bottom_style))).block(bottom_block);
|
||||
let status_line = Line::from(Span::styled(&content, style));
|
||||
let paragraph = Paragraph::new(status_line)
|
||||
.block(block)
|
||||
.alignment(Alignment::Left);
|
||||
|
||||
frame.render_widget(bottom, chunks[2]);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Run the TUI main loop.
|
||||
///
|
||||
/// Enters alternate screen, runs the event loop, and restores the terminal
|
||||
/// on exit. Returns `anyhow::Result<()>`.
|
||||
// ── TUI RUNNER ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Run the TUI, entering alternate screen and returning on quit.
|
||||
pub fn run_tui() -> anyhow::Result<()> {
|
||||
// Setup terminal
|
||||
terminal::enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
@@ -330,11 +517,10 @@ pub fn run_tui() -> anyhow::Result<()> {
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
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);
|
||||
|
||||
// Restore terminal
|
||||
terminal::disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
@@ -343,14 +529,13 @@ pub fn run_tui() -> anyhow::Result<()> {
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(e) = res {
|
||||
if let Err(ref e) = res {
|
||||
eprintln!("TUI error: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The main event loop for the TUI.
|
||||
fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
app: &mut TuiApp,
|
||||
@@ -388,93 +573,3 @@ fn run_app<B: Backend>(
|
||||
|
||||
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