Files
gyandoot-v2/crates/gyandoot-tui/src/setup.rs
Tara Menon 54d8ca3774 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.
2026-06-11 20:53:38 +05:30

510 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}