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:
2026-06-12 12:35:08 +05:30
parent 8c9a32075d
commit 73bd24dcfb
2 changed files with 377 additions and 282 deletions

View File

@@ -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};

View File

@@ -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());
}
} }