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:
2026-06-11 20:53:38 +05:30
parent 03c103061a
commit 54d8ca3774
29 changed files with 7211 additions and 755 deletions

View 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

View 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["));
}
}

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

View 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);
}
}

View 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");
}
}

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