diff --git a/crates/gyandoot-tui/src/lib.rs b/crates/gyandoot-tui/src/lib.rs index bdc629d..102399f 100644 --- a/crates/gyandoot-tui/src/lib.rs +++ b/crates/gyandoot-tui/src/lib.rs @@ -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}; \ No newline at end of file +pub use tui::{run_tui, TuiApp}; \ No newline at end of file diff --git a/crates/gyandoot-tui/src/tui.rs b/crates/gyandoot-tui/src/tui.rs index a66c14c..15bb648 100644 --- a/crates/gyandoot-tui/src/tui.rs +++ b/crates/gyandoot-tui/src/tui.rs @@ -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, - /// 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, + 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; - self.input.push('/'); + 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); - let header = Paragraph::new(header_text) - .block(header_block) - .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(text, area); +} - 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 content_block = Block::default() + let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .title(" Output ") - .style(Style::default().fg(Color::Cyan)); + .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), + )); + + 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())) - .block(content_block) + .block(block) .wrap(Wrap { trim: false }) .scroll((app.scroll_offset as u16, 0)); - frame.render_widget(content, chunks[1]); - - // 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]); + frame.render_widget(content, area); } -/// Run the TUI main loop. -/// -/// Enters alternate screen, runs the event loop, and restores the terminal -/// on exit. Returns `anyhow::Result<()>`. +// ── 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 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<()> { - // 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( terminal: &mut Terminal, app: &mut TuiApp, @@ -387,94 +572,4 @@ fn run_app( } 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()); - } } \ No newline at end of file