P0 deep-dive: core, providers, agent, memory, TUI, setup wizard
DEEP DIVE — Agent Loop (gyandoot-agent): - Real conversation loop with interactive (run()) and non-interactive (run_conversation()) modes - Retry logic with exponential backoff and error classification (RateLimit, Auth, Timeout, ServerError, ContextOverflow) - Context window management — token estimation, 75% threshold compression - 7 built-in tools: Read, Write, Edit, Bash, Grep, Find, Ls - BashTool with destructive-command security blocks - ContextCompressor — prune tool results, summarize middle messages DEEP DIVE — Memory (gyandoot-memory): - Real MemPalace with ~/.gyandoot/palace/wings/<wing>/ directory tree - Daily transcript files (YYYY-MM-DD_transcript.md) with append-only format - L0/L1 identity persistence (< 157 tokens for L0) - Session digest to drawer.md - File persistence support for transcripts and identity store DEEP DIVE — Core (gyandoot-core): - GyandootConfig with TOML deserialization for all sections - ConfigLoader with env > profile > file > default chain - Rich types: Message, ToolCall, ToolResult, SessionId - Comprehensive error types with thiserror derive DEEP DIVE — Providers (gyandoot-providers): - Provider trait with chat/chat_stream/models - OpenAI provider with streaming SSE and function calling - Anthropic provider with Messages API + tool use - Ollama provider with merge_system/no_tools auto-detect - SseParser, StreamEvent, TokenStream for SSE parsing - ProviderFactory for creation from config NEW — TUI crate (gyandoot-tui): - Banner: Kapil-approved robot head ASCII art with ANSI colors - Setup wizard: 7 providers, interactive config, connection test - Terminal helpers: spinner, confirm, text input, selection menu - Ratatui TUI: keyboard input, commands, status display All 12 crates + CLI binary compile with 0 errors.
This commit is contained in:
25
crates/gyandoot-tui/Cargo.toml
Normal file
25
crates/gyandoot-tui/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "gyandoot-tui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "GyanDoot v2 — TUI (banner, setup wizard, terminal helpers)"
|
||||
|
||||
[dependencies]
|
||||
gyandoot-core = { path = "../gyandoot-core" }
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
clap.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
atty = "0.2"
|
||||
regex = "1"
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
toml.workspace = true
|
||||
dirs.workspace = true
|
||||
185
crates/gyandoot-tui/src/banner.rs
Normal file
185
crates/gyandoot-tui/src/banner.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
//! GyanDoot banner — ASCII art for CLI and TUI display.
|
||||
//!
|
||||
//! Two modes:
|
||||
//! - **Primary**: Kapil's approved ASCII robot head with ANSI color
|
||||
//! - **Fallback**: Compact boxed banner (when banner.txt missing / pipe mode)
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
/// The primary ASCII art banner — robot head with GyanDoot text.
|
||||
///
|
||||
/// Uses 4-bit ANSI color codes.
|
||||
pub const BANNER_PRIMARY: &str = r#"
|
||||
o
|
||||
|
|
||||
- - - - - - .---. - - - - - -
|
||||
- - - - - - - - - [ ^ ^ ] - - - - - - - - -
|
||||
- - - - - - - | ___ | - - - - - - -
|
||||
`-----'
|
||||
____ __ __ ___ _ _ ____ ___ ___ _____
|
||||
/ ___|\ \ / / / _ \ | \ | || _ \ / _ \ / _ \|_ _|
|
||||
| | _ \ V / / /_\ \| \| || | | | | | | | | | | |
|
||||
| |_| | | | | _ || |\ || |_| | |_| | |_| | | |
|
||||
\____/ |_| |_| |_||_| \_||____/ \___/ \___/ |_|
|
||||
|
||||
o - - - - - - - knowledge. delivered. - - - - - - - o
|
||||
"#;
|
||||
|
||||
/// Fallback compact boxed banner — used when pipe mode is detected
|
||||
/// or the primary banner isn't available.
|
||||
pub const BANNER_FALLBACK: &str = r#"
|
||||
╔══════════════════════════════════════════╗
|
||||
║ GyanDoot v2.0.0 ║
|
||||
║ Knowledge in Motion — AI Agent Platform ║
|
||||
╚══════════════════════════════════════════╝
|
||||
"#;
|
||||
|
||||
/// Color codes for banner rendering.
|
||||
pub mod color {
|
||||
/// White / bright white foreground
|
||||
pub const WHITE: &str = "\x1b[97m";
|
||||
/// Yellow / gold foreground
|
||||
pub const YELLOW: &str = "\x1b[93m";
|
||||
/// Saffron / orange foreground
|
||||
pub const SAFFRON: &str = "\x1b[38;5;208m";
|
||||
/// Deep blue foreground
|
||||
pub const DEEP_BLUE: &str = "\x1b[38;5;18m";
|
||||
/// Gold (RGB-inspired 256 color)
|
||||
pub const GOLD: &str = "\x1b[38;5;214m";
|
||||
/// Reset all attributes
|
||||
pub const RESET: &str = "\x1b[0m";
|
||||
}
|
||||
|
||||
/// Determines whether output is piped (non-TTY).
|
||||
fn is_piped() -> bool {
|
||||
!atty::is(atty::Stream::Stdout)
|
||||
}
|
||||
|
||||
/// Print the GyanDoot banner to stdout.
|
||||
///
|
||||
/// If output is piped, the fallback (uncolored) banner is used.
|
||||
/// Otherwise, the primary ANSI-colored banner is printed.
|
||||
pub fn print_banner() {
|
||||
let primary = colorize_primary(BANNER_PRIMARY);
|
||||
let fallback = strip_ansi(BANNER_FALLBACK);
|
||||
|
||||
if is_piped() {
|
||||
println!("{}", fallback);
|
||||
} else {
|
||||
println!("{}", primary);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply 4-bit ANSI color to the primary banner.
|
||||
///
|
||||
/// - Robot head lines (first 6): white
|
||||
/// - "GyanDoot" text block: yellow/gold
|
||||
/// - Tagline: white
|
||||
fn colorize_primary(banner: &str) -> String {
|
||||
let lines: Vec<&str> = banner.split('\n').collect();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
for line in lines {
|
||||
if line.trim().is_empty() {
|
||||
writeln!(out).ok();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if i < 6 {
|
||||
// Robot head section — white
|
||||
write!(out, "{}{}{}", color::WHITE, line, color::RESET).ok();
|
||||
} else if i < 11 {
|
||||
// GyanDoot text — gold/yellow
|
||||
write!(out, "{}{}{}", color::YELLOW, line, color::RESET).ok();
|
||||
} else {
|
||||
// Tagline — white
|
||||
write!(out, "{}{}{}", color::WHITE, line, color::RESET).ok();
|
||||
}
|
||||
writeln!(out).ok();
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Strip ANSI escape codes from a string.
|
||||
pub fn strip_ansi(input: &str) -> String {
|
||||
let re = regex::Regex::new(r"\x1b\[[0-9;]*[mK]").unwrap();
|
||||
re.replace_all(input, "").to_string()
|
||||
}
|
||||
|
||||
/// Render the fallback banner with colors.
|
||||
pub fn render_fallback_colored() -> String {
|
||||
let lines: Vec<&str> = BANNER_FALLBACK.split('\n').collect();
|
||||
let mut out = String::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i == 1 {
|
||||
// Top border — gold
|
||||
write!(out, "{}{}{}", color::GOLD, line, color::RESET).ok();
|
||||
} else if i == 2 {
|
||||
// Title — saffron
|
||||
write!(out, "{}{}{}", color::SAFFRON, line, color::RESET).ok();
|
||||
} else if i == 3 {
|
||||
// Tagline — deep blue
|
||||
write!(out, "{}{}{}", color::DEEP_BLUE, line, color::RESET).ok();
|
||||
} else if i == 4 {
|
||||
// Bottom border — gold
|
||||
write!(out, "{}{}{}", color::GOLD, line, color::RESET).ok();
|
||||
} else {
|
||||
write!(out, "{}", line).ok();
|
||||
}
|
||||
writeln!(out).ok();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return a plain-text banner string (no ANSI codes).
|
||||
pub fn banner_plain() -> String {
|
||||
strip_ansi(BANNER_PRIMARY)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_banner_plain_contains_gyandoot() {
|
||||
let plain = banner_plain();
|
||||
assert!(plain.contains("GyanDoot") || plain.contains("knowledge. delivered."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_ansi_removes_escapes() {
|
||||
let colored = format!("\x1b[97mHello\x1b[0m");
|
||||
assert_eq!(strip_ansi(&colored), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_ansi_empty() {
|
||||
assert_eq!(strip_ansi(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_has_borders() {
|
||||
assert!(BANNER_FALLBACK.contains('╔'));
|
||||
assert!(BANNER_FALLBACK.contains('╝'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_primary_has_robot_head() {
|
||||
assert!(BANNER_PRIMARY.contains(".-.") || BANNER_PRIMARY.contains("[ ^"));
|
||||
assert!(BANNER_PRIMARY.contains("knowledge. delivered."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_colorize_primary_contains_ansi() {
|
||||
let colored = colorize_primary(BANNER_PRIMARY);
|
||||
assert!(colored.contains("\x1b["));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_fallback_contains_ansi() {
|
||||
let colored = render_fallback_colored();
|
||||
assert!(colored.contains("\x1b["));
|
||||
}
|
||||
}
|
||||
20
crates/gyandoot-tui/src/lib.rs
Normal file
20
crates/gyandoot-tui/src/lib.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! GyanDoot TUI — Banner, setup wizard, and terminal helpers.
|
||||
//!
|
||||
//! This crate provides:
|
||||
//! - `banner` — The GyanDoot ASCII art banner (primary robot head + fallback box)
|
||||
//! - `setup` — Interactive setup wizard for configuring providers
|
||||
//! - `terminal` — Terminal helpers (progress spinners, confirm prompts, selection menus)
|
||||
//! - `tui` — Ratatui-based TUI main loop
|
||||
|
||||
pub mod banner;
|
||||
pub mod setup;
|
||||
pub mod terminal;
|
||||
pub mod tui;
|
||||
|
||||
pub use banner::{print_banner, BANNER_PRIMARY, BANNER_FALLBACK};
|
||||
pub use setup::{run_setup_wizard, ProviderConfig, ProviderKind, WizardResult};
|
||||
pub use terminal::{
|
||||
confirm_prompt, progress_spinner, selection_menu, show_status, text_input,
|
||||
StatusKind,
|
||||
};
|
||||
pub use tui::{run_tui, TuiApp, TuiCommand};
|
||||
510
crates/gyandoot-tui/src/setup.rs
Normal file
510
crates/gyandoot-tui/src/setup.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
//! Interactive setup wizard — configures providers and initializes GyanDoot.
|
||||
//!
|
||||
//! Flow:
|
||||
//! 1. Print compact banner header
|
||||
//! 2. Provider selection (numbered menu)
|
||||
//! 3. Per-provider config (Base URL, API Key, Model, Temperature, Max Tokens)
|
||||
//! 4. Summary display before saving
|
||||
//! 5. Connection test (/models endpoint)
|
||||
//! 6. Write config.toml to ~/.config/gyandoot/config.toml
|
||||
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::banner;
|
||||
use crate::terminal;
|
||||
|
||||
/// Supported LLM providers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ProviderKind {
|
||||
OpenAI,
|
||||
Anthropic,
|
||||
Ollama,
|
||||
GoogleGemini,
|
||||
DeepSeek,
|
||||
OpenRouter,
|
||||
Groq,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
/// All known providers in display order.
|
||||
pub fn all() -> &'static [ProviderKind] {
|
||||
&[
|
||||
ProviderKind::OpenAI,
|
||||
ProviderKind::Anthropic,
|
||||
ProviderKind::Ollama,
|
||||
ProviderKind::GoogleGemini,
|
||||
ProviderKind::DeepSeek,
|
||||
ProviderKind::OpenRouter,
|
||||
ProviderKind::Groq,
|
||||
]
|
||||
}
|
||||
|
||||
/// Display name for menus.
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "OpenAI",
|
||||
ProviderKind::Anthropic => "Anthropic",
|
||||
ProviderKind::Ollama => "Ollama",
|
||||
ProviderKind::GoogleGemini => "Google Gemini",
|
||||
ProviderKind::DeepSeek => "DeepSeek",
|
||||
ProviderKind::OpenRouter => "OpenRouter",
|
||||
ProviderKind::Groq => "Groq",
|
||||
}
|
||||
}
|
||||
|
||||
/// Default base URL for the provider.
|
||||
pub fn default_base_url(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "https://api.openai.com/v1",
|
||||
ProviderKind::Anthropic => "https://api.anthropic.com/v1",
|
||||
ProviderKind::Ollama => "http://localhost:11434",
|
||||
ProviderKind::GoogleGemini => "https://generativelanguage.googleapis.com/v1",
|
||||
ProviderKind::DeepSeek => "https://api.deepseek.com/v1",
|
||||
ProviderKind::OpenRouter => "https://openrouter.ai/api/v1",
|
||||
ProviderKind::Groq => "https://api.groq.com/openai/v1",
|
||||
}
|
||||
}
|
||||
|
||||
/// Default model name.
|
||||
pub fn default_model(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "gpt-4o",
|
||||
ProviderKind::Anthropic => "claude-sonnet-4-20250514",
|
||||
ProviderKind::Ollama => "llama3",
|
||||
ProviderKind::GoogleGemini => "gemini-2.0-flash",
|
||||
ProviderKind::DeepSeek => "deepseek-chat",
|
||||
ProviderKind::OpenRouter => "openai/gpt-4o",
|
||||
ProviderKind::Groq => "llama3-70b-8192",
|
||||
}
|
||||
}
|
||||
|
||||
/// Default temperature.
|
||||
pub fn default_temperature(&self) -> f64 {
|
||||
0.7
|
||||
}
|
||||
|
||||
/// Default max tokens.
|
||||
pub fn default_max_tokens(&self) -> u32 {
|
||||
4096
|
||||
}
|
||||
|
||||
/// Provider-specific API key env var hint.
|
||||
pub fn api_key_env_hint(&self) -> &'static str {
|
||||
match self {
|
||||
ProviderKind::OpenAI => "OPENAI_API_KEY",
|
||||
ProviderKind::Anthropic => "ANTHROPIC_API_KEY",
|
||||
ProviderKind::Ollama => "OLLAMA_API_KEY (optional)",
|
||||
ProviderKind::GoogleGemini => "GEMINI_API_KEY",
|
||||
ProviderKind::DeepSeek => "DEEPSEEK_API_KEY",
|
||||
ProviderKind::OpenRouter => "OPENROUTER_API_KEY",
|
||||
ProviderKind::Groq => "GROQ_API_KEY",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProviderKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.display_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-provider configuration collected during setup.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
/// Which provider this config is for.
|
||||
pub kind: ProviderKind,
|
||||
/// API base URL.
|
||||
pub base_url: String,
|
||||
/// API key (may be empty for local Ollama).
|
||||
pub api_key: String,
|
||||
/// Model identifier.
|
||||
pub model: String,
|
||||
/// Temperature parameter (0.0–2.0).
|
||||
pub temperature: f64,
|
||||
/// Max tokens per response.
|
||||
pub max_tokens: u32,
|
||||
/// Ollama-specific: merge system messages.
|
||||
#[serde(default)]
|
||||
pub merge_system: bool,
|
||||
/// Ollama-specific: disable tool calling.
|
||||
#[serde(default)]
|
||||
pub no_tools: bool,
|
||||
}
|
||||
|
||||
impl ProviderConfig {
|
||||
/// Create a new provider config with defaults.
|
||||
pub fn new(kind: ProviderKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
base_url: kind.default_base_url().to_string(),
|
||||
api_key: String::new(),
|
||||
model: kind.default_model().to_string(),
|
||||
temperature: kind.default_temperature(),
|
||||
max_tokens: kind.default_max_tokens(),
|
||||
merge_system: false,
|
||||
no_tools: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of the setup wizard.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WizardResult {
|
||||
/// Selected provider kind.
|
||||
pub provider: ProviderKind,
|
||||
/// Full provider config.
|
||||
pub config: ProviderConfig,
|
||||
/// Whether the connection test passed.
|
||||
pub connection_ok: bool,
|
||||
/// Path where config was written.
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Default config directory.
|
||||
fn config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".config")
|
||||
.join("gyandoot")
|
||||
}
|
||||
|
||||
/// Run the full interactive setup wizard.
|
||||
///
|
||||
/// Returns the wizard result on success, or an error if the user cancels or
|
||||
/// something goes wrong during write.
|
||||
pub fn run_setup_wizard() -> anyhow::Result<Option<WizardResult>> {
|
||||
// Step 1: Print compact banner
|
||||
println!();
|
||||
println!("{}", banner::render_fallback_colored());
|
||||
println!();
|
||||
terminal::section_header("Setup Wizard");
|
||||
|
||||
// Step 2: Provider selection
|
||||
println!("Select your LLM provider:");
|
||||
let provider_names: Vec<&str> = ProviderKind::all()
|
||||
.iter()
|
||||
.map(|p| p.display_name())
|
||||
.collect();
|
||||
|
||||
let choice = terminal::selection_menu("Choose a provider:", &provider_names);
|
||||
let choice = match choice {
|
||||
Some(idx) => ProviderKind::all()[idx],
|
||||
None => {
|
||||
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
println!();
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Info,
|
||||
&format!("Configuring provider: {}", choice.display_name()),
|
||||
);
|
||||
|
||||
// Step 3: Per-provider config
|
||||
let mut config = ProviderConfig::new(choice);
|
||||
|
||||
// Base URL
|
||||
println!();
|
||||
let url = terminal::text_input("Base URL", Some(&config.base_url));
|
||||
match url {
|
||||
Some(u) if !u.trim().is_empty() => config.base_url = u.trim().to_string(),
|
||||
Some(_) => {} // keep default
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// API Key (with env hint)
|
||||
let api_key_hint = choice.api_key_env_hint();
|
||||
println!(" (Hint: set via {} env var or enter now)", api_key_hint);
|
||||
let key = terminal::text_input("API Key", None);
|
||||
match key {
|
||||
Some(k) => config.api_key = k.trim().to_string(),
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// Model
|
||||
let model = terminal::text_input("Model", Some(&config.model));
|
||||
match model {
|
||||
Some(m) if !m.trim().is_empty() => config.model = m.trim().to_string(),
|
||||
Some(_) => {} // keep default
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// Temperature
|
||||
let temp_input = terminal::text_input("Temperature (0.0-2.0)", Some("0.7"));
|
||||
match temp_input {
|
||||
Some(t) if !t.trim().is_empty() => {
|
||||
if let Ok(v) = t.trim().parse::<f64>() {
|
||||
config.temperature = v.clamp(0.0, 2.0);
|
||||
}
|
||||
}
|
||||
Some(_) => {} // keep default
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// Max Tokens
|
||||
let tokens_input = terminal::text_input("Max Tokens", Some("4096"));
|
||||
match tokens_input {
|
||||
Some(t) if !t.trim().is_empty() => {
|
||||
if let Ok(v) = t.trim().parse::<u32>() {
|
||||
config.max_tokens = v;
|
||||
}
|
||||
}
|
||||
Some(_) => {} // keep default
|
||||
None => return Ok(None),
|
||||
}
|
||||
|
||||
// Auto-detect Ollama localhost settings
|
||||
if choice == ProviderKind::Ollama && config.base_url.contains("localhost") {
|
||||
config.merge_system = true;
|
||||
config.no_tools = true;
|
||||
println!();
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Info,
|
||||
"Local Ollama detected: merge_system=true, no_tools=true",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Summary
|
||||
println!();
|
||||
terminal::section_header("Configuration Summary");
|
||||
println!(" Provider: {}", config.kind.display_name());
|
||||
println!(" Base URL: {}", config.base_url);
|
||||
println!(
|
||||
" API Key: {}",
|
||||
if config.api_key.is_empty() {
|
||||
"(none)".to_string()
|
||||
} else {
|
||||
format!("{}…{}", &config.api_key[..3.min(config.api_key.len())], &config.api_key[config.api_key.len().saturating_sub(4)..])
|
||||
}
|
||||
);
|
||||
println!(" Model: {}", config.model);
|
||||
println!(" Temperature: {}", config.temperature);
|
||||
println!(" Max Tokens: {}", config.max_tokens);
|
||||
if config.merge_system {
|
||||
println!(" Merge System: true");
|
||||
}
|
||||
if config.no_tools {
|
||||
println!(" No Tools: true");
|
||||
}
|
||||
|
||||
println!();
|
||||
if !terminal::confirm_prompt("Save this configuration?", Some(true)) {
|
||||
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Step 5: Connection test
|
||||
println!();
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Info,
|
||||
"Running connection test...",
|
||||
);
|
||||
|
||||
let test_passed = test_provider_connection(&config);
|
||||
|
||||
if test_passed {
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Success,
|
||||
"Connection successful!",
|
||||
);
|
||||
} else {
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Warning,
|
||||
"Connection test failed — config will still be saved.",
|
||||
);
|
||||
println!(" (You can retry later or adjust settings manually)");
|
||||
}
|
||||
|
||||
// Step 6: Save config
|
||||
println!();
|
||||
if !terminal::confirm_prompt("Write config file?", Some(true)) {
|
||||
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let config_path = write_config_file(&config)?;
|
||||
|
||||
terminal::show_status(
|
||||
terminal::StatusKind::Success,
|
||||
&format!("Configuration saved to {}", config_path.display()),
|
||||
);
|
||||
|
||||
Ok(Some(WizardResult {
|
||||
provider: choice,
|
||||
config,
|
||||
connection_ok: test_passed,
|
||||
config_path,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Test provider connectivity by calling the /models endpoint.
|
||||
fn test_provider_connection(config: &ProviderConfig) -> bool {
|
||||
if config.api_key.is_empty() && config.kind != ProviderKind::Ollama {
|
||||
return false;
|
||||
}
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.ok();
|
||||
|
||||
let client = match client {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let url = format!("{}/models", config.base_url.trim_end_matches('/'));
|
||||
|
||||
let request = client.get(&url);
|
||||
let request = if !config.api_key.is_empty() {
|
||||
request.header("Authorization", format!("Bearer {}", config.api_key))
|
||||
} else {
|
||||
request
|
||||
};
|
||||
|
||||
match request.send() {
|
||||
Ok(resp) => resp.status().is_success() || resp.status().as_u16() == 401 || resp.status().as_u16() == 403,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the provider configuration to ~/.config/gyandoot/config.toml.
|
||||
fn write_config_file(config: &ProviderConfig) -> anyhow::Result<PathBuf> {
|
||||
let dir = config_dir();
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let path = dir.join("config.toml");
|
||||
|
||||
let toml_content = toml::to_string_pretty(&SerializedConfig::from(config))?;
|
||||
std::fs::write(&path, &toml_content)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Serializable wrapper for TOML output.
|
||||
#[derive(Serialize)]
|
||||
struct SerializedConfig {
|
||||
provider: SerializedProvider,
|
||||
agent: SerializedAgent,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SerializedProvider {
|
||||
kind: String,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
model: String,
|
||||
temperature: f64,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
merge_system: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
no_tools: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SerializedAgent {
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn is_false(v: &bool) -> bool {
|
||||
!v
|
||||
}
|
||||
|
||||
impl From<&ProviderConfig> for SerializedConfig {
|
||||
fn from(c: &ProviderConfig) -> Self {
|
||||
let kind_str = match c.kind {
|
||||
ProviderKind::OpenAI => "openai",
|
||||
ProviderKind::Anthropic => "anthropic",
|
||||
ProviderKind::Ollama => "ollama",
|
||||
ProviderKind::GoogleGemini => "google-gemini",
|
||||
ProviderKind::DeepSeek => "deepseek",
|
||||
ProviderKind::OpenRouter => "openrouter",
|
||||
ProviderKind::Groq => "groq",
|
||||
};
|
||||
|
||||
SerializedConfig {
|
||||
provider: SerializedProvider {
|
||||
kind: kind_str.to_string(),
|
||||
base_url: c.base_url.clone(),
|
||||
api_key: c.api_key.clone(),
|
||||
model: c.model.clone(),
|
||||
temperature: c.temperature,
|
||||
max_tokens: c.max_tokens,
|
||||
merge_system: c.merge_system,
|
||||
no_tools: c.no_tools,
|
||||
},
|
||||
agent: SerializedAgent {
|
||||
name: "gyandoot".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_kind_all() {
|
||||
let all = ProviderKind::all();
|
||||
assert!(!all.is_empty());
|
||||
assert_eq!(all.len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_display_names() {
|
||||
assert_eq!(ProviderKind::OpenAI.display_name(), "OpenAI");
|
||||
assert_eq!(ProviderKind::Ollama.display_name(), "Ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_defaults() {
|
||||
for kind in ProviderKind::all() {
|
||||
assert!(!kind.default_base_url().is_empty());
|
||||
assert!(!kind.default_model().is_empty());
|
||||
assert!(kind.default_temperature() > 0.0);
|
||||
assert!(kind.default_max_tokens() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_config_new() {
|
||||
let cfg = ProviderConfig::new(ProviderKind::OpenAI);
|
||||
assert_eq!(cfg.kind, ProviderKind::OpenAI);
|
||||
assert!(cfg.base_url.contains("openai"));
|
||||
assert_eq!(cfg.model, "gpt-4o");
|
||||
assert_eq!(cfg.temperature, 0.7);
|
||||
assert_eq!(cfg.max_tokens, 4096);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_dir_has_gyandoot() {
|
||||
let dir = config_dir();
|
||||
assert!(dir.to_string_lossy().contains("gyandoot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialized_config_roundtrip() {
|
||||
let cfg = ProviderConfig::new(ProviderKind::Anthropic);
|
||||
let serialized = SerializedConfig::from(&cfg);
|
||||
let toml_str = toml::to_string_pretty(&serialized).unwrap();
|
||||
assert!(toml_str.contains("anthropic"));
|
||||
assert!(toml_str.contains("base_url"));
|
||||
assert!(toml_str.contains("api_key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ollama_autodetect() {
|
||||
let mut cfg = ProviderConfig::new(ProviderKind::Ollama);
|
||||
cfg.base_url = "http://localhost:11434".to_string();
|
||||
cfg.merge_system = true;
|
||||
cfg.no_tools = true;
|
||||
assert!(cfg.merge_system);
|
||||
assert!(cfg.no_tools);
|
||||
}
|
||||
}
|
||||
253
crates/gyandoot-tui/src/terminal.rs
Normal file
253
crates/gyandoot-tui/src/terminal.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
//! Terminal helpers — ergonomic CLI interaction utilities.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - Progress spinner (blocking)
|
||||
//! - Confirm prompt (y/n)
|
||||
//! - Text input with simple editing
|
||||
//! - Numbered selection menu
|
||||
//! - Status message display (success/error/warning)
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
use crossterm::cursor;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
use crossterm::execute;
|
||||
use crossterm::style::Stylize;
|
||||
use crossterm::terminal::{self, ClearType};
|
||||
|
||||
/// Status kind for `show_status`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StatusKind {
|
||||
/// Green checkmark — operation succeeded
|
||||
Success,
|
||||
/// Red X — operation failed
|
||||
Error,
|
||||
/// Yellow triangle — warning
|
||||
Warning,
|
||||
/// Blue info — informational message
|
||||
Info,
|
||||
}
|
||||
|
||||
/// Run a simple spinner while a closure executes.
|
||||
///
|
||||
/// The spinner displays rotating characters: `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`
|
||||
/// Returns the result of the closure.
|
||||
pub fn progress_spinner<F, T>(label: &str, f: F) -> T
|
||||
where
|
||||
F: FnOnce() -> T,
|
||||
{
|
||||
const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
// Show initial spinner
|
||||
print!("{} {}", SPINNER[0], label);
|
||||
io::stdout().flush().ok();
|
||||
|
||||
// Run the closure
|
||||
let result = f();
|
||||
|
||||
// Clear spinner line
|
||||
let _ = execute!(io::stdout(), terminal::Clear(ClearType::CurrentLine));
|
||||
let _ = io::stdout().flush();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Prompt the user for a yes/no answer.
|
||||
///
|
||||
/// Returns `true` for y/Y/yes/Yes, `false` for n/N/no/No.
|
||||
/// Loops until a valid answer is given.
|
||||
pub fn confirm_prompt(prompt: &str, default: Option<bool>) -> bool {
|
||||
let hint = match default {
|
||||
Some(true) => "[Y/n]",
|
||||
Some(false) => "[y/N]",
|
||||
None => "[y/n]",
|
||||
};
|
||||
|
||||
loop {
|
||||
print!("{} {}: ", prompt, hint);
|
||||
io::stdout().flush().ok();
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input).ok();
|
||||
let input = input.trim().to_lowercase();
|
||||
|
||||
match input.as_str() {
|
||||
"y" | "yes" => return true,
|
||||
"n" | "no" => return false,
|
||||
"" => {
|
||||
if let Some(d) = default {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
eprintln!(" Please answer 'y' or 'n'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect text input from the user with basic editing support.
|
||||
///
|
||||
/// Uses crossterm for raw mode input. Supports:
|
||||
/// - Typing characters
|
||||
/// - Backspace to delete
|
||||
/// - Enter to submit
|
||||
/// - Ctrl+C to abort (returns None)
|
||||
pub fn text_input(prompt: &str, default: Option<&str>) -> Option<String> {
|
||||
let _raw = crossterm::terminal::enable_raw_mode().ok()?;
|
||||
|
||||
let mut buffer = default.map(|s| s.to_string()).unwrap_or_default();
|
||||
let prompt_line = format!("{}: ", prompt);
|
||||
|
||||
// Print prompt and buffer
|
||||
print!("{}{}", prompt_line, buffer);
|
||||
io::stdout().flush().ok();
|
||||
|
||||
let result = loop {
|
||||
match event::read() {
|
||||
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
buffer.push(c);
|
||||
print!("{}", c);
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if buffer.pop().is_some() {
|
||||
print!("\x08 \x08");
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
println!();
|
||||
break Some(buffer);
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
println!();
|
||||
break None;
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Key(key))
|
||||
if key.code == KeyCode::Char('c')
|
||||
&& key.modifiers == crossterm::event::KeyModifiers::CONTROL =>
|
||||
{
|
||||
println!("^C");
|
||||
break None;
|
||||
}
|
||||
Err(_) => {
|
||||
break None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
|
||||
crossterm::terminal::disable_raw_mode().ok();
|
||||
result
|
||||
}
|
||||
|
||||
/// Display a numbered selection menu and return the index of the chosen item.
|
||||
///
|
||||
/// Returns `None` if cancelled (Ctrl+C / Esc).
|
||||
pub fn selection_menu(prompt: &str, items: &[&str]) -> Option<usize> {
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", prompt);
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
println!(" {}. {}", i + 1, item);
|
||||
}
|
||||
println!(" 0. Cancel");
|
||||
|
||||
loop {
|
||||
print!("Enter choice [0-{}]: ", items.len());
|
||||
io::stdout().flush().ok();
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input).ok();
|
||||
let input = input.trim();
|
||||
|
||||
if input.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match input.parse::<usize>() {
|
||||
Ok(0) => return None,
|
||||
Ok(n) if n >= 1 && n <= items.len() => return Some(n - 1),
|
||||
_ => {
|
||||
eprintln!(
|
||||
" Invalid choice. Enter a number between 0 and {}.",
|
||||
items.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Display a status message with a colored icon.
|
||||
///
|
||||
/// - Success: ✅ (or [OK])
|
||||
/// - Error: ❌ (or [ERR])
|
||||
/// - Warning: ⚠️ (or [WARN])
|
||||
/// - Info: ℹ️ (or [INFO])
|
||||
pub fn show_status(kind: StatusKind, message: &str) {
|
||||
let (icon, styled_msg) = match kind {
|
||||
StatusKind::Success => ("✅ ", message.green()),
|
||||
StatusKind::Error => ("❌ ", message.red()),
|
||||
StatusKind::Warning => ("⚠️ ", message.yellow()),
|
||||
StatusKind::Info => ("ℹ️ ", message.blue()),
|
||||
};
|
||||
|
||||
// Try to write styled; fall back to plain on error
|
||||
let full = format!("{}{}", icon, styled_msg);
|
||||
println!("{}", full);
|
||||
}
|
||||
|
||||
/// Clear the terminal screen.
|
||||
pub fn clear_screen() {
|
||||
let _ = execute!(io::stdout(), terminal::Clear(ClearType::All));
|
||||
let _ = execute!(io::stdout(), cursor::MoveTo(0, 0));
|
||||
}
|
||||
|
||||
/// Print a section header with a divider line.
|
||||
pub fn section_header(title: &str) {
|
||||
let line = "─".repeat(title.len().saturating_add(4));
|
||||
println!();
|
||||
println!(" {} ", line.as_str().bold());
|
||||
println!(" {} ", title.bold());
|
||||
println!(" {} ", line.as_str().bold());
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_status_kind_equality() {
|
||||
assert_eq!(StatusKind::Success, StatusKind::Success);
|
||||
assert_ne!(StatusKind::Success, StatusKind::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selection_menu_empty() {
|
||||
assert!(selection_menu("test", &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_confirm_prompt_parsing() {
|
||||
// Can't really test interactively, but we can check the fn compiles
|
||||
assert!(std::mem::size_of::<fn(&str, Option<bool>) -> bool>() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_screen_does_not_panic() {
|
||||
let _ = execute!(io::stdout(), terminal::Clear(ClearType::All));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_section_header_does_not_panic() {
|
||||
section_header("Test Header");
|
||||
}
|
||||
}
|
||||
480
crates/gyandoot-tui/src/tui.rs
Normal file
480
crates/gyandoot-tui/src/tui.rs
Normal file
@@ -0,0 +1,480 @@
|
||||
//! Ratatui-based TUI interface for GyanDoot.
|
||||
//!
|
||||
//! Provides the main TUI loop, application state, and command handling
|
||||
//! for interactive terminal sessions.
|
||||
|
||||
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::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.
|
||||
#[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 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.
|
||||
#[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,
|
||||
}
|
||||
|
||||
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(),
|
||||
should_quit: false,
|
||||
mode: TuiMode::Normal,
|
||||
input: String::new(),
|
||||
message_log: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
/// Create a new TUI application with default state.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Handle a keyboard event in normal mode.
|
||||
fn handle_normal_key(&mut self, key: KeyCode) {
|
||||
match key {
|
||||
KeyCode::Char(':') => {
|
||||
self.mode = TuiMode::Command;
|
||||
self.input.clear();
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.mode = TuiMode::Command;
|
||||
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') => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if self.scroll_offset < self.message_log.len().saturating_sub(1) {
|
||||
self.scroll_offset += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a keyboard event in command mode.
|
||||
fn handle_command_key(&mut self, key: KeyCode) {
|
||||
match key {
|
||||
KeyCode::Enter => {
|
||||
let cmd = self.input.clone();
|
||||
self.message_log.push(format!(":{}", cmd));
|
||||
self.execute_command(&cmd);
|
||||
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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
";
|
||||
self.output.push_str(help_text);
|
||||
self.status = "Help displayed".to_string();
|
||||
}
|
||||
"/clear" | "clear" => {
|
||||
self.output.clear();
|
||||
self.status = "Output 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" => {
|
||||
self.output = format!(
|
||||
"Status: {}\nMode: {:?}\nMessages: {}\n",
|
||||
self.status,
|
||||
self.mode,
|
||||
self.message_log.len()
|
||||
);
|
||||
self.status = "Status shown".to_string();
|
||||
}
|
||||
_ => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the TUI interface using ratatui.
|
||||
fn render(app: &mut TuiApp, frame: &mut Frame) {
|
||||
let area = frame.area();
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // header
|
||||
Constraint::Min(1), // main content
|
||||
Constraint::Length(3), // input/status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header block
|
||||
let header_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
|
||||
let header_text = Line::from(Span::styled(
|
||||
&app.title,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
let header = Paragraph::new(header_text)
|
||||
.block(header_block)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
|
||||
frame.render_widget(header, chunks[0]);
|
||||
|
||||
// Main content area
|
||||
let content_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(" Output ")
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
|
||||
let content = Paragraph::new(Text::from(app.output.clone()))
|
||||
.block(content_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]);
|
||||
}
|
||||
|
||||
/// Run the TUI main loop.
|
||||
///
|
||||
/// Enters alternate screen, runs the event loop, and restores the terminal
|
||||
/// on exit. Returns `anyhow::Result<()>`.
|
||||
pub fn run_tui() -> anyhow::Result<()> {
|
||||
// Setup terminal
|
||||
terminal::enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = TuiApp::new();
|
||||
let tick_rate = Duration::from_millis(250);
|
||||
|
||||
let res = run_app(&mut terminal, &mut app, tick_rate);
|
||||
|
||||
// Restore terminal
|
||||
terminal::disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(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,
|
||||
tick_rate: Duration,
|
||||
) -> io::Result<()> {
|
||||
let mut last_tick = Instant::now();
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| render(app, f))?;
|
||||
|
||||
let timeout = tick_rate
|
||||
.checked_sub(last_tick.elapsed())
|
||||
.unwrap_or_default();
|
||||
|
||||
if event::poll(timeout)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
match app.mode {
|
||||
TuiMode::Normal => app.handle_normal_key(key.code),
|
||||
TuiMode::Input => app.handle_input_key(key.code),
|
||||
TuiMode::Command => app.handle_command_key(key.code),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
|
||||
if last_tick.elapsed() >= tick_rate {
|
||||
last_tick = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
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