use anyhow::{Context, Result};
use crossterm::{
ExecutableCommand,
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Layout, Rect},
style::Modifier,
text::{Line, Span},
widgets::{Block, Paragraph},
};
use std::io::{self, IsTerminal};
use crate::config::Config;
use crate::config::schema::{
DiscordConfig, FeishuConfig, IMessageConfig, IrcConfig, LarkConfig, LarkReceiveMode,
MatrixConfig, MattermostConfig, NextcloudTalkConfig, SignalConfig, SlackConfig, StreamMode,
TelegramConfig, WhatsAppChatPolicy, WhatsAppConfig, WhatsAppWebMode,
};
use super::theme;
use super::widgets::{
Banner, ConfirmedLine, InfoPanel, InputPrompt, SelectableItem, SelectableList, StepIndicator,
StepStatus,
};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const DOCS_BASE: &str = "https://www.zeroclawlabs.ai/docs";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Screen {
Welcome,
SecurityWarning,
SetupMode,
ExistingConfig,
ConfigHandling,
QuickStartSummary,
ProviderTier,
ProviderSelect,
ApiKeyInput,
ProviderNotes,
ModelConfigured,
ModelSelect,
ChannelStatus,
HowChannelsWork,
ChannelSelect,
WebSearchInfo,
WebSearchProvider,
WebSearchApiKey,
SkillsStatus,
SkillsInstall,
HooksInfo,
HooksEnable,
GatewayService,
HealthCheck,
OptionalApps,
ControlUI,
WorkspaceBackup,
FinalSecurity,
WebSearchConfirm,
WhatNow,
Complete,
}
const PROVIDER_TIERS: &[(&str, &str)] = &[
(
"\u{2b50} Recommended",
"OpenRouter, Venice, Anthropic, OpenAI, Gemini",
),
(
"\u{26a1} Fast inference",
"Groq, Fireworks, Together AI, NVIDIA NIM",
),
(
"\u{1f310} Gateway / proxy",
"Vercel AI, Cloudflare AI, Amazon Bedrock",
),
(
"\u{1f52c} Specialized",
"Moonshot/Kimi, GLM/Zhipu, MiniMax, Qwen, Z.AI",
),
(
"\u{1f3e0} Local / private",
"Ollama, llama.cpp, vLLM — no API key",
),
("\u{1f527} Custom", "Bring your own OpenAI-compatible API"),
];
const TIER_PROVIDERS: &[&[(&str, &str, &str)]] = &[
&[
(
"OpenRouter",
"200+ models, 1 API key (recommended)",
"openrouter",
),
("Venice AI", "Privacy-first (Llama, Opus)", "venice"),
("Anthropic", "Claude Sonnet & Opus (direct)", "anthropic"),
("OpenAI", "GPT-4o, o1, GPT-5 (direct)", "openai"),
(
"OpenAI Codex",
"ChatGPT subscription OAuth, no API key",
"openai-codex",
),
("DeepSeek", "V3 & R1 (affordable)", "deepseek"),
("Mistral", "Large & Codestral", "mistral"),
("xAI", "Grok 3 & 4", "xai"),
("Perplexity", "Search-augmented AI", "perplexity"),
(
"Google Gemini",
"Gemini 2.0 Flash & Pro (supports CLI auth)",
"gemini",
),
],
&[
("Groq", "Ultra-fast LPU inference", "groq"),
("Fireworks AI", "Fast open-source inference", "fireworks"),
("Novita AI", "Affordable open-source inference", "novita"),
("Together AI", "Open-source model hosting", "together-ai"),
("NVIDIA NIM", "DeepSeek, Llama, & more", "nvidia"),
],
&[
("Vercel AI Gateway", "", "vercel"),
("Cloudflare AI Gateway", "", "cloudflare"),
("Astrai", "Compliant AI routing, PII stripping", "astrai"),
(
"Avian",
"OpenAI-compatible (DeepSeek, Kimi, GLM, MiniMax)",
"avian",
),
("Amazon Bedrock", "AWS managed models", "bedrock"),
],
&[
("Kimi Code", "Coding-optimized Kimi API", "kimi-code"),
(
"Qwen Code",
"OAuth tokens from ~/.qwen/oauth_creds.json",
"qwen-code",
),
("Moonshot", "Kimi API (China endpoint)", "moonshot"),
(
"Moonshot Intl",
"Kimi API (international endpoint)",
"moonshot-intl",
),
("GLM", "ChatGLM / Zhipu (international)", "glm"),
("GLM CN", "ChatGLM / Zhipu (China)", "glm-cn"),
("MiniMax", "International endpoint", "minimax"),
("MiniMax CN", "China endpoint", "minimax-cn"),
("Qwen", "DashScope China endpoint", "qwen"),
("Qwen Intl", "DashScope international endpoint", "qwen-intl"),
("Qwen US", "DashScope US endpoint", "qwen-us"),
("Qianfan", "Baidu AI models (China)", "qianfan"),
("Z.AI", "Global coding endpoint", "zai"),
("Z.AI CN", "China coding endpoint", "zai-cn"),
("Synthetic", "Synthetic AI models", "synthetic"),
("OpenCode Zen", "Code-focused AI", "opencode"),
("OpenCode Go", "Subsidized code-focused AI", "opencode-go"),
("Cohere", "Command R+ & embeddings", "cohere"),
],
&[
("Ollama", "Local models (Llama, Mistral, Phi)", "ollama"),
("llama.cpp", "Local OpenAI-compatible endpoint", "llamacpp"),
("SGLang", "High-performance local serving", "sglang"),
("vLLM", "High-performance local inference", "vllm"),
(
"Osaurus",
"Unified AI edge runtime (MLX + cloud + MCP)",
"osaurus",
),
],
&[(
"Custom OpenAI-compatible",
"Any OpenAI-compatible endpoint",
"custom",
)],
];
const CHANNELS: &[(&str, &str, bool)] = &[
("Telegram", "Bot API", false),
("WhatsApp", "QR link", true),
("Discord", "Bot API", false),
("IRC", "Server + Nick", false),
("Google Chat", "Chat API", true),
("Slack", "Socket Mode", false),
("Signal", "signal-cli", false),
("iMessage", "imsg", false),
("LINE", "Messaging API", false),
("Mattermost", "plugin", false),
("Nextcloud Talk", "self-hosted", false),
("Feishu/Lark", "\u{98de}\u{4e66}", false),
("BlueBubbles", "macOS app", false),
("Zalo", "Bot API", false),
("Synology Chat", "Webhook", false),
("Nostr", "NIP-04 DMs", true),
("Microsoft Teams", "Teams SDK", true),
("Matrix", "plugin", true),
("Zalo Personal", "Personal Account", true),
("Tlon", "Urbit", true),
("Twitch", "Chat", true),
("Skip for now", "configure later", false),
];
const SETUP_MODES: &[&str] = &["QuickStart", "Full Setup (9 steps)", "Skip for now"];
const MODELS: &[&str] = &[
"Auto (recommended)",
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"gpt-4o",
"gemini-2.0-flash",
"glm-5",
"Custom model ID...",
];
const SEARCH_PROVIDERS: &[(&str, &str)] = &[
("Brave Search", "API key required"),
("SearxNG", "Self-hosted, key-free"),
("Tavily", "API key required"),
("Google Custom Search", "API key required"),
("DuckDuckGo", "Key-free (limited)"),
("Skip for now", "configure later"),
];
const SKILLS: &[(&str, &str)] = &[
("Skip for now", ""),
("\u{1f510} 1password", "Password manager"),
("\u{1f43b} bear-notes", "Note taking"),
("\u{1f4f0} blogwatcher", "RSS feeds"),
("\u{1fab0} blucli", "Bluetooth CLI"),
("\u{1f4f8} camsnap", "Camera capture"),
("\u{1f9e9} clawhub", "Plugin registry"),
("\u{1f6cc} eightctl", "Sleep tracking"),
("\u{1f9f2} gifgrep", "GIF search"),
("\u{1f3ae} gog", "Game library"),
("\u{1f4cd} goplaces", "Google Places"),
("\u{1f4e7} himalaya", "Email CLI"),
("\u{1f4e6} mcporter", "MCP tools"),
("\u{1f4ca} model-usage", "LLM usage stats"),
("\u{1f4c4} nano-pdf", "PDF tools"),
("\u{1f48e} obsidian", "Knowledge base"),
("\u{1f3a4} openai-whisper", "Speech-to-text"),
("\u{1f4a1} openhue", "Smart lights"),
("\u{1f9ff} oracle", "Divination"),
("\u{1f6f5} ordercli", "Order tracking"),
("\u{1f440} peekaboo", "Screen peek"),
("\u{1f50a} sag", "Audio gen"),
("\u{1f30a} songsee", "Music ID"),
("\u{1f50a} sonoscli", "Sonos control"),
("\u{1f9fe} summarize", "Text summary"),
("\u{2705} things-mac", "Task manager"),
("\u{1f4f1} wacli", "WhatsApp CLI"),
("\u{1f426} xurl", "URL tools"),
];
struct App {
screen: Screen,
should_quit: bool,
security_accepted: bool,
setup_mode_idx: usize,
config_handling_idx: usize,
provider_tier_idx: usize,
provider_idx: usize,
provider_scroll: usize,
api_key_input: String,
model_idx: usize,
channel_idx: usize,
channel_scroll: usize,
search_provider_idx: usize,
search_api_key_input: String,
skills_idx: usize,
skills_scroll: usize,
hooks_idx: usize,
gateway_port: u16,
gateway_host: String,
pairing_code: String,
pairing_required: bool,
}
impl App {
fn new() -> Self {
let port = std::env::var("ZEROCLAW_GATEWAY_PORT")
.or_else(|_| std::env::var("PORT"))
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(42617);
let host =
std::env::var("ZEROCLAW_GATEWAY_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
Self {
screen: Screen::Welcome,
should_quit: false,
security_accepted: false,
setup_mode_idx: 0,
config_handling_idx: 0,
provider_tier_idx: 0,
provider_idx: 0,
provider_scroll: 0,
api_key_input: String::new(),
model_idx: 0,
channel_idx: 0,
channel_scroll: 0,
search_provider_idx: 0,
search_api_key_input: String::new(),
skills_idx: 0,
skills_scroll: 0,
hooks_idx: 0,
gateway_port: port,
gateway_host: host,
pairing_code: String::from("......"),
pairing_required: true,
}
}
fn gateway_base_url(&self) -> String {
format!("http://{}:{}", self.gateway_host, self.gateway_port)
}
async fn fetch_pairing_code(&mut self) {
let client = reqwest::Client::new();
let timeout = std::time::Duration::from_secs(3);
let admin_url = format!("http://127.0.0.1:{}/admin/paircode", self.gateway_port);
if let Some((code, required)) = Self::try_fetch_code(&client, &admin_url, timeout).await {
self.pairing_code = code;
self.pairing_required = required;
return;
}
let public_url = format!("http://127.0.0.1:{}/pair/code", self.gateway_port);
if let Some((code, required)) = Self::try_fetch_code(&client, &public_url, timeout).await {
self.pairing_code = code;
self.pairing_required = required;
return;
}
if self.gateway_host != "127.0.0.1" {
let remote_url = format!(
"http://{}:{}/pair/code",
self.gateway_host, self.gateway_port
);
if let Some((code, required)) =
Self::try_fetch_code(&client, &remote_url, timeout).await
{
self.pairing_code = code;
self.pairing_required = required;
return;
}
}
if let Some(code) = Self::generate_code_via_cli().await {
self.pairing_code = code;
self.pairing_required = true;
return;
}
if let Some(code) = Self::generate_code_via_docker().await {
self.pairing_code = code;
self.pairing_required = true;
return;
}
let new_url = format!("http://127.0.0.1:{}/admin/paircode/new", self.gateway_port);
if let Ok(resp) = client.post(&new_url).timeout(timeout).send().await {
if let Ok(json) = resp.json::<serde_json::Value>().await {
if let Some(code) = json.get("pairing_code").and_then(|v| v.as_str()) {
self.pairing_code = code.to_string();
return;
}
}
}
self.pairing_code = String::from("------");
self.pairing_required = true;
}
async fn generate_code_via_cli() -> Option<String> {
let output = tokio::process::Command::new("zeroclaw")
.args(["gateway", "get-paircode", "--new"])
.output()
.await
.ok()?;
Self::extract_code_from_output(&output.stdout)
}
async fn generate_code_via_docker() -> Option<String> {
let ps = tokio::process::Command::new("docker")
.args([
"ps",
"--filter",
"ancestor=ghcr.io/zeroclaw-labs/zeroclaw",
"--format",
"{{.Names}}",
])
.output()
.await
.ok()?;
let container = String::from_utf8_lossy(&ps.stdout)
.lines()
.next()?
.trim()
.to_string();
if container.is_empty() {
let ps2 = tokio::process::Command::new("docker")
.args(["ps", "--filter", "name=zeroclaw", "--format", "{{.Names}}"])
.output()
.await
.ok()?;
let container = String::from_utf8_lossy(&ps2.stdout)
.lines()
.next()?
.trim()
.to_string();
if container.is_empty() {
return None;
}
let output = tokio::process::Command::new("docker")
.args([
"exec",
&container,
"zeroclaw",
"gateway",
"get-paircode",
"--new",
])
.output()
.await
.ok()?;
return Self::extract_code_from_output(&output.stdout);
}
let output = tokio::process::Command::new("docker")
.args([
"exec",
&container,
"zeroclaw",
"gateway",
"get-paircode",
"--new",
])
.output()
.await
.ok()?;
Self::extract_code_from_output(&output.stdout)
}
fn extract_code_from_output(stdout: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(stdout);
for line in text.lines() {
let trimmed = line.trim().trim_matches('│').trim();
if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) {
return Some(trimmed.to_string());
}
}
None
}
async fn try_fetch_code(
client: &reqwest::Client,
url: &str,
timeout: std::time::Duration,
) -> Option<(String, bool)> {
let resp = client.get(url).timeout(timeout).send().await.ok()?;
let json: serde_json::Value = resp.json().await.ok()?;
let required = json
.get("pairing_required")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let code = json.get("pairing_code").and_then(|v| v.as_str())?;
Some((code.to_string(), required))
}
fn selected_provider(&self) -> &str {
TIER_PROVIDERS
.get(self.provider_tier_idx)
.and_then(|tier| tier.get(self.provider_idx))
.map_or("Unknown", |p| p.0)
}
fn selected_provider_id(&self) -> &str {
TIER_PROVIDERS
.get(self.provider_tier_idx)
.and_then(|tier| tier.get(self.provider_idx))
.map_or("openrouter", |p| p.2)
}
fn current_tier_providers(&self) -> &[(&str, &str, &str)] {
TIER_PROVIDERS
.get(self.provider_tier_idx)
.map_or(&[], |t| *t)
}
fn selected_model(&self) -> &str {
MODELS.get(self.model_idx).map_or("auto", |m| m)
}
fn selected_channel(&self) -> &str {
CHANNELS.get(self.channel_idx).map_or("Skip", |c| c.0)
}
fn selected_search_provider(&self) -> &str {
SEARCH_PROVIDERS
.get(self.search_provider_idx)
.map_or("None", |p| p.0)
}
}
fn provider_supports_keyless_local_usage(provider_id: &str) -> bool {
matches!(
provider_id,
"ollama" | "llamacpp" | "sglang" | "vllm" | "osaurus"
)
}
fn provider_uses_oauth_without_api_key(provider_id: &str) -> bool {
matches!(provider_id, "openai-codex")
}
fn provider_skips_api_key_input(provider_id: &str) -> bool {
provider_supports_keyless_local_usage(provider_id)
|| provider_uses_oauth_without_api_key(provider_id)
}
pub async fn run_tui_onboarding() -> Result<()> {
#[cfg(unix)]
if !io::stdin().is_terminal() {
use std::fs::File;
let tty = File::open("/dev/tty").context("Failed to open /dev/tty for TUI input")?;
let fd = std::os::unix::io::IntoRawFd::into_raw_fd(tty);
unsafe {
if libc::dup2(fd, 0) == -1 {
libc::close(fd);
anyhow::bail!("Failed to redirect stdin from /dev/tty");
}
libc::close(fd);
}
}
enable_raw_mode().context("Failed to enable raw mode")?;
io::stdout()
.execute(EnterAlternateScreen)
.context("Failed to enter alternate screen")?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).context("Failed to create terminal")?;
let mut app = App::new();
app.fetch_pairing_code().await;
let result = run_app(&mut terminal, &mut app);
disable_raw_mode().context("Failed to disable raw mode")?;
io::stdout()
.execute(LeaveAlternateScreen)
.context("Failed to leave alternate screen")?;
result?;
if app.screen == Screen::Complete {
#[allow(clippy::large_futures)]
match save_tui_config(&app).await {
Ok(()) => {
let skill = SKILLS
.get(app.skills_idx)
.map(|(name, _)| *name)
.unwrap_or("Skip for now");
let hooks_label = if app.hooks_idx == 0 {
"enabled"
} else {
"disabled"
};
println!();
println!(" \u{1f980} ZeroClaw {VERSION} configured successfully!");
println!(
" Provider: {} ({})",
app.selected_provider(),
app.selected_provider_id()
);
println!(" Model: {}", app.selected_model());
println!(" Channel: {}", app.selected_channel());
println!(" Web search: {}", app.selected_search_provider());
println!(" Skills: {skill}");
println!(" Hooks: {hooks_label}");
println!(" Gateway: {}:{}", app.gateway_host, app.gateway_port);
println!(
" Pairing: {}",
if app.pairing_required {
"required"
} else {
"disabled"
}
);
println!(" Dashboard: {}", app.gateway_base_url());
if app.pairing_required && app.pairing_code != "------" {
println!(" Pair code: {}", app.pairing_code);
}
println!();
let channel = app.selected_channel();
if channel != "Skip for now" {
println!(" Next: edit config.toml to add your {channel} credentials.");
println!(" zeroclaw config edit");
println!();
}
println!(" Run `zeroclaw daemon` to start your agent.");
println!();
}
Err(e) => {
eprintln!();
eprintln!(" \u{2717} Failed to save configuration: {e}");
eprintln!(" You can re-run: zeroclaw onboard --tui");
eprintln!();
}
}
}
Ok(())
}
#[allow(clippy::large_futures)]
async fn save_tui_config(app: &App) -> Result<()> {
let mut config = Config::load_or_init().await?;
apply_tui_selections_to_config(app, &mut config);
config.save().await?;
push_config_to_docker(app).await;
Ok(())
}
fn apply_tui_selections_to_config(app: &App, config: &mut Config) {
let provider_id = app.selected_provider_id();
config.default_provider = Some(provider_id.to_string());
if !provider_id.starts_with("custom") {
config.api_url = None;
}
if !app.api_key_input.is_empty() {
config.api_key = Some(app.api_key_input.clone());
}
let model = app.selected_model();
if model == "Auto (recommended)" {
config.default_model = None; } else {
config.default_model = Some(model.to_string());
}
let channel = app.selected_channel();
match channel {
"Telegram" => {
if config.channels_config.telegram.is_none() {
config.channels_config.telegram = Some(TelegramConfig {
enabled: true,
bot_token: String::from("YOUR_TELEGRAM_BOT_TOKEN"),
allowed_users: vec![],
stream_mode: StreamMode::default(),
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
}
}
"Discord" => {
if config.channels_config.discord.is_none() {
config.channels_config.discord = Some(DiscordConfig {
enabled: true,
bot_token: String::from("YOUR_DISCORD_BOT_TOKEN"),
guild_id: None,
allowed_users: vec![],
listen_to_bots: false,
interrupt_on_new_message: false,
mention_only: false,
proxy_url: None,
stream_mode: StreamMode::default(),
draft_update_interval_ms: 1000,
multi_message_delay_ms: 800,
stall_timeout_secs: 0,
});
}
}
"Slack" => {
if config.channels_config.slack.is_none() {
config.channels_config.slack = Some(SlackConfig {
enabled: true,
bot_token: String::from("xoxb-YOUR_SLACK_BOT_TOKEN"),
app_token: Some(String::from("xapp-YOUR_SLACK_APP_TOKEN")),
channel_id: None,
channel_ids: vec![],
allowed_users: vec![],
interrupt_on_new_message: false,
thread_replies: None,
mention_only: false,
use_markdown_blocks: false,
proxy_url: None,
stream_drafts: false,
draft_update_interval_ms: 1200,
cancel_reaction: None,
});
}
}
"WhatsApp" => {
if config.channels_config.whatsapp.is_none() {
config.channels_config.whatsapp = Some(WhatsAppConfig {
enabled: true,
access_token: Some(String::from("YOUR_WHATSAPP_ACCESS_TOKEN")),
phone_number_id: Some(String::from("YOUR_PHONE_NUMBER_ID")),
verify_token: Some(String::from("YOUR_VERIFY_TOKEN")),
app_secret: None,
session_path: None,
pair_phone: None,
pair_code: None,
allowed_numbers: vec![],
mention_only: false,
mode: WhatsAppWebMode::default(),
dm_policy: WhatsAppChatPolicy::default(),
group_policy: WhatsAppChatPolicy::default(),
self_chat_mode: false,
dm_mention_patterns: vec![],
group_mention_patterns: vec![],
proxy_url: None,
});
}
}
"Signal" => {
if config.channels_config.signal.is_none() {
config.channels_config.signal = Some(SignalConfig {
enabled: true,
http_url: String::from("http://127.0.0.1:8080"),
account: String::from("YOUR_SIGNAL_PHONE_NUMBER"),
group_id: None,
allowed_from: vec![],
ignore_attachments: false,
ignore_stories: true,
proxy_url: None,
});
}
}
"IRC" => {
if config.channels_config.irc.is_none() {
config.channels_config.irc = Some(IrcConfig {
enabled: true,
server: String::from("irc.libera.chat"),
port: 6697,
nickname: String::from("zeroclaw-bot"),
username: None,
channels: vec![String::from("#your-channel")],
allowed_users: vec![],
server_password: None,
nickserv_password: None,
sasl_password: None,
verify_tls: None,
});
}
}
"iMessage" => {
if config.channels_config.imessage.is_none() {
config.channels_config.imessage = Some(IMessageConfig {
enabled: true,
allowed_contacts: vec![],
});
}
}
"Matrix" => {
if config.channels_config.matrix.is_none() {
config.channels_config.matrix = Some(MatrixConfig {
enabled: true,
homeserver: String::from("https://matrix.org"),
access_token: String::from("YOUR_MATRIX_ACCESS_TOKEN"),
user_id: None,
device_id: None,
room_id: String::from("!YOUR_ROOM_ID:matrix.org"),
allowed_users: vec![],
allowed_rooms: vec![],
interrupt_on_new_message: false,
stream_mode: StreamMode::default(),
draft_update_interval_ms: 500,
multi_message_delay_ms: 800,
recovery_key: None,
});
}
}
"Mattermost" => {
if config.channels_config.mattermost.is_none() {
config.channels_config.mattermost = Some(MattermostConfig {
enabled: true,
url: String::from("https://mattermost.example.com"),
bot_token: String::from("YOUR_MATTERMOST_BOT_TOKEN"),
channel_id: None,
allowed_users: vec![],
thread_replies: None,
mention_only: None,
interrupt_on_new_message: false,
proxy_url: None,
});
}
}
"Nextcloud Talk" => {
if config.channels_config.nextcloud_talk.is_none() {
config.channels_config.nextcloud_talk = Some(NextcloudTalkConfig {
enabled: true,
base_url: String::from("https://cloud.example.com"),
app_token: String::from("YOUR_NEXTCLOUD_APP_TOKEN"),
webhook_secret: None,
allowed_users: vec![],
proxy_url: None,
bot_name: None,
});
}
}
"Feishu/Lark" => {
if config.channels_config.feishu.is_none() {
config.channels_config.feishu = Some(FeishuConfig {
enabled: true,
app_id: String::from("YOUR_FEISHU_APP_ID"),
app_secret: String::from("YOUR_FEISHU_APP_SECRET"),
encrypt_key: None,
verification_token: None,
allowed_users: vec![],
receive_mode: LarkReceiveMode::default(),
port: None,
proxy_url: None,
});
}
if config.channels_config.lark.is_none() {
config.channels_config.lark = Some(LarkConfig {
enabled: true,
app_id: String::from("YOUR_LARK_APP_ID"),
app_secret: String::from("YOUR_LARK_APP_SECRET"),
encrypt_key: None,
verification_token: None,
allowed_users: vec![],
mention_only: false,
use_feishu: false,
receive_mode: LarkReceiveMode::default(),
port: None,
proxy_url: None,
});
}
}
_ => {}
}
let search = app.selected_search_provider();
if search != "Skip for now" && search != "None" {
let search_id = match search {
"Brave Search" => "brave",
"SearxNG" => "searxng",
"Tavily" => "tavily",
"Google Custom Search" => "google",
_ => "duckduckgo",
};
config.web_search.enabled = true;
config.web_search.provider = search_id.to_string();
if !app.search_api_key_input.is_empty() {
match search_id {
"brave" => {
config.web_search.brave_api_key = Some(app.search_api_key_input.clone());
}
"searxng" => {
config.web_search.searxng_instance_url = Some(app.search_api_key_input.clone());
}
_ => {}
}
}
}
let skill = SKILLS
.get(app.skills_idx)
.map(|(name, _)| *name)
.unwrap_or("Skip for now");
if skill != "Skip for now" {
config.skills.open_skills_enabled = true;
}
config.hooks.enabled = app.hooks_idx == 0;
if app.hooks_idx == 0 {
config.hooks.builtin.command_logger = true;
}
config.gateway.port = app.gateway_port;
config.gateway.host = app.gateway_host.clone();
config.gateway.require_pairing = app.pairing_required;
}
async fn push_config_to_docker(app: &App) {
let container = find_docker_container().await;
let container = match container {
Some(c) => c,
None => return,
};
let provider_id = app.selected_provider_id();
let mut args = vec![
"exec".to_string(),
container,
"zeroclaw".to_string(),
"onboard".to_string(),
"--quick".to_string(),
"--provider".to_string(),
provider_id.to_string(),
];
if !app.api_key_input.is_empty() {
args.push("--api-key".to_string());
args.push(app.api_key_input.clone());
}
let model = app.selected_model();
if model != "Auto (recommended)" {
args.push("--model".to_string());
args.push(model.to_string());
}
let _ = tokio::process::Command::new("docker")
.args(&args)
.output()
.await;
}
async fn find_docker_container() -> Option<String> {
let ps = tokio::process::Command::new("docker")
.args([
"ps",
"--filter",
"ancestor=ghcr.io/zeroclaw-labs/zeroclaw",
"--format",
"{{.Names}}",
])
.output()
.await
.ok()?;
let name = String::from_utf8_lossy(&ps.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if !name.is_empty() {
return Some(name);
}
let ps2 = tokio::process::Command::new("docker")
.args(["ps", "--filter", "name=zeroclaw", "--format", "{{.Names}}"])
.output()
.await
.ok()?;
let name = String::from_utf8_lossy(&ps2.stdout)
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
if name.is_empty() { None } else { Some(name) }
}
fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
loop {
terminal.draw(|frame| render(frame, app))?;
if app.should_quit {
break;
}
if let Event::Key(key) = event::read().context("Failed to read event")? {
if key.kind != KeyEventKind::Press {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
continue;
}
handle_input(app, key.code);
}
}
Ok(())
}
fn nav_up(idx: &mut usize) {
if *idx > 0 {
*idx -= 1;
}
}
fn nav_down(idx: &mut usize, max: usize) {
if *idx < max {
*idx += 1;
}
}
fn scroll_into_view(scroll: &mut usize, idx: usize, visible: usize) {
if idx < *scroll {
*scroll = idx;
} else if idx >= *scroll + visible {
*scroll = idx.saturating_sub(visible - 1);
}
}
fn handle_input(app: &mut App, key: KeyCode) {
match app.screen {
Screen::Welcome => match key {
KeyCode::Enter => app.screen = Screen::SecurityWarning,
KeyCode::Char('q') => app.should_quit = true,
_ => {}
},
Screen::SecurityWarning => match key {
KeyCode::Char('y' | 'Y') | KeyCode::Enter => {
app.security_accepted = true;
app.screen = Screen::SetupMode;
}
KeyCode::Char('n' | 'N') | KeyCode::Esc => {
app.should_quit = true;
}
_ => {}
},
Screen::SetupMode => match key {
KeyCode::Up | KeyCode::Char('k') => nav_up(&mut app.setup_mode_idx),
KeyCode::Down | KeyCode::Char('j') => {
nav_down(&mut app.setup_mode_idx, SETUP_MODES.len() - 1);
}
KeyCode::Enter => app.screen = Screen::ExistingConfig,
KeyCode::Esc => app.screen = Screen::SecurityWarning,
_ => {}
},
Screen::ExistingConfig => match key {
KeyCode::Enter => app.screen = Screen::ConfigHandling,
KeyCode::Esc => app.screen = Screen::SetupMode,
_ => {}
},
Screen::ConfigHandling => match key {
KeyCode::Up | KeyCode::Char('k') => nav_up(&mut app.config_handling_idx),
KeyCode::Down | KeyCode::Char('j') => nav_down(&mut app.config_handling_idx, 1),
KeyCode::Enter => app.screen = Screen::QuickStartSummary,
KeyCode::Esc => app.screen = Screen::ExistingConfig,
_ => {}
},
Screen::QuickStartSummary => match key {
KeyCode::Enter => app.screen = Screen::ProviderTier,
KeyCode::Esc => app.screen = Screen::ConfigHandling,
_ => {}
},
Screen::ProviderTier => match key {
KeyCode::Up | KeyCode::Char('k') => nav_up(&mut app.provider_tier_idx),
KeyCode::Down | KeyCode::Char('j') => {
nav_down(&mut app.provider_tier_idx, PROVIDER_TIERS.len() - 1);
}
KeyCode::Enter => {
app.provider_idx = 0;
app.provider_scroll = 0;
app.screen = Screen::ProviderSelect;
}
KeyCode::Esc => app.screen = Screen::QuickStartSummary,
_ => {}
},
Screen::ProviderSelect => match key {
KeyCode::Up | KeyCode::Char('k') => {
nav_up(&mut app.provider_idx);
scroll_into_view(&mut app.provider_scroll, app.provider_idx, 16);
}
KeyCode::Down | KeyCode::Char('j') => {
let max = app.current_tier_providers().len().saturating_sub(1);
nav_down(&mut app.provider_idx, max);
scroll_into_view(&mut app.provider_scroll, app.provider_idx, 16);
}
KeyCode::Enter => {
if provider_skips_api_key_input(app.selected_provider_id()) {
app.api_key_input.clear();
app.screen = Screen::ProviderNotes;
} else {
app.screen = Screen::ApiKeyInput;
}
}
KeyCode::Esc => app.screen = Screen::ProviderTier,
_ => {}
},
Screen::ApiKeyInput => match key {
KeyCode::Char(c) => app.api_key_input.push(c),
KeyCode::Backspace => {
app.api_key_input.pop();
}
KeyCode::Enter => {
app.screen = Screen::ProviderNotes;
}
KeyCode::Esc => {
app.api_key_input.clear();
app.screen = Screen::ProviderSelect;
}
_ => {}
},
Screen::ProviderNotes => match key {
KeyCode::Enter => app.screen = Screen::ModelConfigured,
KeyCode::Esc => app.screen = Screen::ApiKeyInput,
_ => {}
},
Screen::ModelConfigured => match key {
KeyCode::Enter => app.screen = Screen::ModelSelect,
KeyCode::Esc => app.screen = Screen::ProviderNotes,
_ => {}
},
Screen::ModelSelect => match key {
KeyCode::Up | KeyCode::Char('k') => nav_up(&mut app.model_idx),
KeyCode::Down | KeyCode::Char('j') => {
nav_down(&mut app.model_idx, MODELS.len() - 1);
}
KeyCode::Enter => app.screen = Screen::ChannelStatus,
KeyCode::Esc => app.screen = Screen::ModelConfigured,
_ => {}
},
Screen::ChannelStatus => match key {
KeyCode::Enter => app.screen = Screen::HowChannelsWork,
KeyCode::Esc => app.screen = Screen::ModelSelect,
_ => {}
},
Screen::HowChannelsWork => match key {
KeyCode::Enter => app.screen = Screen::ChannelSelect,
KeyCode::Esc => app.screen = Screen::ChannelStatus,
_ => {}
},
Screen::ChannelSelect => match key {
KeyCode::Up | KeyCode::Char('k') => {
nav_up(&mut app.channel_idx);
if app.channel_idx < app.channel_scroll {
app.channel_scroll = app.channel_idx;
}
}
KeyCode::Down | KeyCode::Char('j') => {
nav_down(&mut app.channel_idx, CHANNELS.len() - 1);
}
KeyCode::Enter => app.screen = Screen::WebSearchInfo,
KeyCode::Esc => app.screen = Screen::HowChannelsWork,
_ => {}
},
Screen::WebSearchInfo => match key {
KeyCode::Enter => app.screen = Screen::WebSearchProvider,
KeyCode::Esc => app.screen = Screen::ChannelSelect,
_ => {}
},
Screen::WebSearchProvider => match key {
KeyCode::Up | KeyCode::Char('k') => nav_up(&mut app.search_provider_idx),
KeyCode::Down | KeyCode::Char('j') => {
nav_down(&mut app.search_provider_idx, SEARCH_PROVIDERS.len() - 1);
}
KeyCode::Enter => {
let needs_key = matches!(app.search_provider_idx, 0 | 2 | 3);
app.screen = if needs_key {
Screen::WebSearchApiKey
} else {
Screen::SkillsStatus
};
}
KeyCode::Esc => app.screen = Screen::WebSearchInfo,
_ => {}
},
Screen::WebSearchApiKey => match key {
KeyCode::Char(c) => app.search_api_key_input.push(c),
KeyCode::Backspace => {
app.search_api_key_input.pop();
}
KeyCode::Enter if !app.search_api_key_input.is_empty() => {
app.screen = Screen::SkillsStatus;
}
KeyCode::Esc => {
app.search_api_key_input.clear();
app.screen = Screen::WebSearchProvider;
}
_ => {}
},
Screen::SkillsStatus => match key {
KeyCode::Enter => app.screen = Screen::SkillsInstall,
KeyCode::Esc => app.screen = Screen::WebSearchProvider,
_ => {}
},
Screen::SkillsInstall => match key {
KeyCode::Up | KeyCode::Char('k') => {
nav_up(&mut app.skills_idx);
if app.skills_idx < app.skills_scroll {
app.skills_scroll = app.skills_idx;
}
}
KeyCode::Down | KeyCode::Char('j') => {
nav_down(&mut app.skills_idx, SKILLS.len() - 1);
}
KeyCode::Enter => app.screen = Screen::HooksInfo,
KeyCode::Esc => app.screen = Screen::SkillsStatus,
_ => {}
},
Screen::HooksInfo => match key {
KeyCode::Enter => app.screen = Screen::HooksEnable,
KeyCode::Esc => app.screen = Screen::SkillsInstall,
_ => {}
},
Screen::HooksEnable => match key {
KeyCode::Up | KeyCode::Char('k') => nav_up(&mut app.hooks_idx),
KeyCode::Down | KeyCode::Char('j') => nav_down(&mut app.hooks_idx, 1),
KeyCode::Enter => app.screen = Screen::GatewayService,
KeyCode::Esc => app.screen = Screen::HooksInfo,
_ => {}
},
Screen::GatewayService => match key {
KeyCode::Enter => app.screen = Screen::HealthCheck,
KeyCode::Esc => app.screen = Screen::HooksEnable,
_ => {}
},
Screen::HealthCheck => match key {
KeyCode::Enter => app.screen = Screen::OptionalApps,
KeyCode::Esc => app.screen = Screen::GatewayService,
_ => {}
},
Screen::OptionalApps => match key {
KeyCode::Enter => app.screen = Screen::ControlUI,
KeyCode::Esc => app.screen = Screen::HealthCheck,
_ => {}
},
Screen::ControlUI => match key {
KeyCode::Enter => app.screen = Screen::WorkspaceBackup,
KeyCode::Esc => app.screen = Screen::OptionalApps,
_ => {}
},
Screen::WorkspaceBackup => match key {
KeyCode::Enter => app.screen = Screen::FinalSecurity,
KeyCode::Esc => app.screen = Screen::ControlUI,
_ => {}
},
Screen::FinalSecurity => match key {
KeyCode::Enter => app.screen = Screen::WebSearchConfirm,
KeyCode::Esc => app.screen = Screen::WorkspaceBackup,
_ => {}
},
Screen::WebSearchConfirm => match key {
KeyCode::Enter => app.screen = Screen::WhatNow,
KeyCode::Esc => app.screen = Screen::FinalSecurity,
_ => {}
},
Screen::WhatNow => match key {
KeyCode::Enter => app.screen = Screen::Complete,
KeyCode::Esc => app.screen = Screen::WebSearchConfirm,
_ => {}
},
Screen::Complete => match key {
KeyCode::Enter | KeyCode::Char('q') | KeyCode::Esc => {
app.should_quit = true;
}
_ => {}
},
}
}
fn render(frame: &mut Frame, app: &App) {
let area = frame.area();
let bg_block = Block::default().style(ratatui::style::Style::default().bg(theme::FROST_BG));
frame.render_widget(bg_block, area);
let outer = Layout::vertical([
Constraint::Length(10),
Constraint::Length(1),
Constraint::Min(10),
Constraint::Length(1),
])
.split(area);
frame.render_widget(Banner, outer[0]);
let version_line = Line::from(vec![
Span::styled("\u{1f980} ", theme::accent_style()),
Span::styled(format!("ZeroClaw {VERSION}"), theme::heading_style()),
Span::styled(
" \u{2502} Zero overhead. Zero compromise.",
theme::dim_style(),
),
]);
frame.render_widget(
Paragraph::new(version_line).alignment(Alignment::Center),
outer[1],
);
let footer = match app.screen {
Screen::ApiKeyInput | Screen::WebSearchApiKey => Line::from(vec![
Span::styled(" Enter", theme::heading_style()),
Span::styled(" confirm ", theme::dim_style()),
Span::styled("Esc", theme::heading_style()),
Span::styled(" back ", theme::dim_style()),
Span::styled("Ctrl+C", theme::heading_style()),
Span::styled(" quit", theme::dim_style()),
]),
Screen::Complete => Line::from(vec![
Span::styled(" Enter/q", theme::heading_style()),
Span::styled(" exit", theme::dim_style()),
]),
Screen::ExistingConfig
| Screen::QuickStartSummary
| Screen::ProviderNotes
| Screen::ModelConfigured
| Screen::ChannelStatus
| Screen::HowChannelsWork
| Screen::WebSearchInfo
| Screen::SkillsStatus
| Screen::HooksInfo
| Screen::GatewayService
| Screen::HealthCheck
| Screen::OptionalApps
| Screen::ControlUI
| Screen::WorkspaceBackup
| Screen::FinalSecurity
| Screen::WebSearchConfirm
| Screen::WhatNow => Line::from(vec![
Span::styled(" Enter", theme::heading_style()),
Span::styled(" continue ", theme::dim_style()),
Span::styled("Ctrl+C", theme::heading_style()),
Span::styled(" quit", theme::dim_style()),
]),
_ => Line::from(vec![
Span::styled(" \u{2191}\u{2193}", theme::heading_style()),
Span::styled(" navigate ", theme::dim_style()),
Span::styled("Enter", theme::heading_style()),
Span::styled(" select ", theme::dim_style()),
Span::styled("Esc", theme::heading_style()),
Span::styled(" back ", theme::dim_style()),
Span::styled("Ctrl+C", theme::heading_style()),
Span::styled(" quit", theme::dim_style()),
]),
};
frame.render_widget(
Paragraph::new(footer).alignment(Alignment::Center),
outer[3],
);
let padded = Layout::horizontal([
Constraint::Length(2),
Constraint::Min(40),
Constraint::Length(2),
])
.split(outer[2]);
let content = padded[1];
match app.screen {
Screen::Welcome => render_welcome(frame, content),
Screen::SecurityWarning => render_security(frame, content),
Screen::SetupMode => render_setup_mode(frame, content, app),
Screen::ExistingConfig => render_existing_config(frame, content),
Screen::ConfigHandling => render_config_handling(frame, content, app),
Screen::QuickStartSummary => render_quickstart_summary(frame, content, app),
Screen::ProviderTier => render_provider_tier(frame, content, app),
Screen::ProviderSelect => render_provider_select(frame, content, app),
Screen::ApiKeyInput => render_api_key(frame, content, app),
Screen::ProviderNotes => render_provider_notes(frame, content, app),
Screen::ModelConfigured => render_model_configured(frame, content, app),
Screen::ModelSelect => render_model_select(frame, content, app),
Screen::ChannelStatus => render_channel_status(frame, content),
Screen::HowChannelsWork => render_how_channels_work(frame, content),
Screen::ChannelSelect => render_channel_select(frame, content, app),
Screen::WebSearchInfo => render_web_search_info(frame, content),
Screen::WebSearchProvider => render_web_search_provider(frame, content, app),
Screen::WebSearchApiKey => render_web_search_api_key(frame, content, app),
Screen::SkillsStatus => render_skills_status(frame, content),
Screen::SkillsInstall => render_skills_install(frame, content, app),
Screen::HooksInfo => render_hooks_info(frame, content),
Screen::HooksEnable => render_hooks_enable(frame, content, app),
Screen::GatewayService => render_gateway_service(frame, content, app),
Screen::HealthCheck => render_health_check(frame, content, app),
Screen::OptionalApps => render_optional_apps(frame, content),
Screen::ControlUI => render_control_ui(frame, content, app),
Screen::WorkspaceBackup => render_workspace_backup(frame, content),
Screen::FinalSecurity => render_final_security(frame, content),
Screen::WebSearchConfirm => render_web_search_confirm(frame, content, app),
Screen::WhatNow => render_what_now(frame, content),
Screen::Complete => render_complete(frame, content, app),
}
}
fn setup_title() -> Paragraph<'static> {
Paragraph::new(Line::from(vec![
Span::styled("\u{250c} ", theme::border_style()),
Span::styled("ZeroClaw setup", theme::heading_style()),
]))
}
fn continue_hint() -> Paragraph<'static> {
Paragraph::new(Line::from(Span::styled(
"Press Enter to continue...",
theme::dim_style(),
)))
}
fn render_welcome(frame: &mut Frame, area: Rect) {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
"\u{250c} ZeroClaw setup",
theme::heading_style(),
)),
Line::from(Span::styled("\u{2502}", theme::border_style())),
Line::from(vec![
Span::styled("\u{2502} ", theme::border_style()),
Span::styled(
"Welcome to ZeroClaw \u{2014} the fastest, smallest AI assistant.",
theme::body_style(),
),
]),
Line::from(vec![
Span::styled("\u{2502} ", theme::border_style()),
Span::styled(
"This wizard will configure your agent in under 60 seconds.",
theme::dim_style(),
),
]),
Line::from(Span::styled("\u{2502}", theme::border_style())),
Line::from(vec![
Span::styled("\u{2514} ", theme::border_style()),
Span::styled(
"Press Enter to begin...",
theme::heading_style().add_modifier(Modifier::SLOW_BLINK),
),
]),
];
frame.render_widget(Paragraph::new(lines), area);
}
fn render_security(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(3),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
let lines = vec![
Line::from(Span::styled(
"Security warning \u{2014} please read.",
theme::warn_style(),
)),
Line::from(""),
Line::from(Span::styled(
"ZeroClaw is optimized for single-operator deployments.",
theme::body_style(),
)),
Line::from(Span::styled(
"By default, ZeroClaw is a personal agent: one trusted operator",
theme::body_style(),
)),
Line::from(Span::styled("boundary.", theme::body_style())),
Line::from(Span::styled(
"This bot can read files and run actions if tools are enabled.",
theme::body_style(),
)),
Line::from(Span::styled(
"A bad prompt can trick it into doing unsafe things.",
theme::body_style(),
)),
Line::from(""),
Line::from(Span::styled(
"ZeroClaw is not a hostile multi-tenant boundary by default.",
theme::body_style(),
)),
Line::from(Span::styled(
"If multiple users can message one tool-enabled agent, they share",
theme::body_style(),
)),
Line::from(Span::styled(
"that delegated tool authority.",
theme::body_style(),
)),
Line::from(""),
Line::from(Span::styled(
"If you're not comfortable with security hardening and access",
theme::body_style(),
)),
Line::from(Span::styled(
"control, don't run ZeroClaw.",
theme::body_style(),
)),
Line::from(""),
Line::from(Span::styled(
"Recommended baseline:",
theme::heading_style(),
)),
Line::from(Span::styled(
" - Pairing/allowlists + mention gating.",
theme::body_style(),
)),
Line::from(Span::styled(
" - Multi-user/shared inbox: split trust boundaries (separate",
theme::body_style(),
)),
Line::from(Span::styled(
" gateway/credentials, ideally separate OS users/hosts).",
theme::body_style(),
)),
Line::from(Span::styled(
" - Sandbox + least-privilege tools.",
theme::body_style(),
)),
Line::from(Span::styled(
" - Shared inboxes: isolate DM sessions (`session.dmScope:",
theme::body_style(),
)),
Line::from(Span::styled(
" per-channel-peer`) and keep tool access minimal.",
theme::body_style(),
)),
Line::from(Span::styled(
" - Keep secrets out of the agent's reachable filesystem.",
theme::body_style(),
)),
Line::from(Span::styled(
" - Use the strongest available model for any bot with tools or",
theme::body_style(),
)),
Line::from(Span::styled(" untrusted inboxes.", theme::body_style())),
Line::from(""),
Line::from(Span::styled("Run regularly:", theme::heading_style())),
Line::from(Span::styled(
" zeroclaw security audit --deep",
theme::dim_style(),
)),
Line::from(Span::styled(
" zeroclaw security audit --fix",
theme::dim_style(),
)),
Line::from(""),
Line::from(Span::styled(
format!("Must read: {DOCS_BASE}/gateway/security"),
theme::dim_style(),
)),
];
frame.render_widget(
InfoPanel {
title: "Security",
lines,
},
layout[1],
);
let prompt = Line::from(vec![
Span::styled("\u{25c6} ", theme::accent_style()),
Span::styled(
"I understand this is personal-by-default and shared/multi-user use ",
theme::heading_style(),
),
]);
let prompt2 = Line::from(vec![
Span::raw(" "),
Span::styled("requires lock-down. Continue? ", theme::heading_style()),
Span::styled("[y/N]", theme::dim_style()),
]);
frame.render_widget(Paragraph::new(vec![prompt, prompt2]), layout[2]);
}
fn render_setup_mode(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(6),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Security accepted",
value: "Yes",
},
layout[1],
);
let items: Vec<SelectableItem> = SETUP_MODES
.iter()
.enumerate()
.map(|(i, mode)| SelectableItem {
label: mode.to_string(),
hint: match i {
0 => "recommended".to_string(),
1 => "advanced".to_string(),
_ => "skip".to_string(),
},
is_active: i == app.setup_mode_idx,
installed: false,
})
.collect();
frame.render_widget(
SelectableList {
title: "Setup mode",
items: &items,
selected: app.setup_mode_idx,
scroll_offset: 0,
},
layout[2],
);
}
fn render_existing_config(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(8),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Setup mode",
value: "QuickStart",
},
layout[1],
);
frame.render_widget(
InfoPanel {
title: "Existing config detected",
lines: vec![
Line::from(""),
Line::from(vec![
Span::styled(" gateway.bind: ", theme::dim_style()),
Span::styled("lan", theme::heading_style()),
]),
Line::from(vec![
Span::styled(" gateway.port: ", theme::dim_style()),
Span::styled("42617", theme::heading_style()),
]),
Line::from(vec![
Span::styled(" gateway.auth: ", theme::dim_style()),
Span::styled("Token (default)", theme::heading_style()),
]),
Line::from(""),
],
},
layout[2],
);
frame.render_widget(continue_hint(), layout[3]);
}
fn render_config_handling(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(6),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Setup mode",
value: "QuickStart",
},
layout[1],
);
let items = vec![
SelectableItem {
label: "Use existing values".to_string(),
hint: "keep current config".to_string(),
is_active: app.config_handling_idx == 0,
installed: false,
},
SelectableItem {
label: "Overwrite".to_string(),
hint: "start fresh".to_string(),
is_active: app.config_handling_idx == 1,
installed: false,
},
];
frame.render_widget(
SelectableList {
title: "Config handling",
items: &items,
selected: app.config_handling_idx,
scroll_offset: 0,
},
layout[2],
);
}
fn render_quickstart_summary(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(12),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Setup mode",
value: "QuickStart",
},
layout[1],
);
frame.render_widget(
ConfirmedLine {
label: "Config handling",
value: if app.config_handling_idx == 0 {
"Use existing values"
} else {
"Overwrite"
},
},
layout[2],
);
frame.render_widget(
InfoPanel {
title: "QuickStart",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" Keeping your current gateway settings:",
theme::body_style(),
)),
Line::from(vec![
Span::styled(" Gateway port: ", theme::dim_style()),
Span::styled(format!("{}", app.gateway_port), theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Gateway bind: ", theme::dim_style()),
Span::styled("LAN", theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Gateway auth: ", theme::dim_style()),
Span::styled("Token (default)", theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Tailscale exposure: ", theme::dim_style()),
Span::styled("Off", theme::heading_style()),
]),
Line::from(Span::styled(
" Direct to chat channels.",
theme::body_style(),
)),
Line::from(""),
],
},
layout[3],
);
frame.render_widget(continue_hint(), layout[4]);
}
fn render_provider_tier(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(6),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Setup mode",
value: SETUP_MODES[app.setup_mode_idx],
},
layout[1],
);
let items: Vec<SelectableItem> = PROVIDER_TIERS
.iter()
.enumerate()
.map(|(i, (name, desc))| SelectableItem {
label: name.to_string(),
hint: desc.to_string(),
is_active: i == app.provider_tier_idx,
installed: false,
})
.collect();
frame.render_widget(
SelectableList {
title: "Select provider category",
items: &items,
selected: app.provider_tier_idx,
scroll_offset: 0,
},
layout[2],
);
}
fn render_provider_select(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(6),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Setup mode",
value: SETUP_MODES[app.setup_mode_idx],
},
layout[1],
);
frame.render_widget(
ConfirmedLine {
label: "Category",
value: PROVIDER_TIERS[app.provider_tier_idx].0,
},
layout[2],
);
let providers = app.current_tier_providers();
let items: Vec<SelectableItem> = providers
.iter()
.enumerate()
.map(|(i, (name, desc, _id))| SelectableItem {
label: name.to_string(),
hint: desc.to_string(),
is_active: i == app.provider_idx,
installed: false,
})
.collect();
frame.render_widget(
SelectableList {
title: "Select your AI provider",
items: &items,
selected: app.provider_idx,
scroll_offset: app.provider_scroll,
},
layout[3],
);
}
fn render_api_key(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(3),
Constraint::Min(1),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Provider",
value: app.selected_provider(),
},
layout[1],
);
let provider_id = app.selected_provider_id();
let prompt = if provider_uses_oauth_without_api_key(provider_id) {
format!(
"{} uses OAuth (no API key). Press Enter to continue.",
app.selected_provider()
)
} else if provider_supports_keyless_local_usage(provider_id) {
format!(
"{} is local-first (no API key required). Press Enter to continue.",
app.selected_provider()
)
} else if provider_id == "bedrock" {
"Bedrock uses AWS credentials (AK/SK), not a single API key. Press Enter to continue."
.to_string()
} else {
format!(
"Enter {} API key (or press Enter to skip)",
app.selected_provider()
)
};
frame.render_widget(
InputPrompt {
label: &prompt,
input: &app.api_key_input,
masked: true,
},
layout[2],
);
}
fn render_provider_notes(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(6),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Provider",
value: app.selected_provider(),
},
layout[1],
);
let provider_id = app.selected_provider_id();
let api_key_status = if !app.api_key_input.is_empty() {
"\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022} (set)".to_string()
} else if provider_uses_oauth_without_api_key(provider_id) {
"OAuth login required (no API key)".to_string()
} else if provider_supports_keyless_local_usage(provider_id) {
"not required (local provider)".to_string()
} else if provider_id == "bedrock" {
"use AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY".to_string()
} else {
"not set (optional for now)".to_string()
};
frame.render_widget(
ConfirmedLine {
label: "API key",
value: &api_key_status,
},
layout[2],
);
frame.render_widget(
InfoPanel {
title: "Provider notes",
lines: vec![
Line::from(""),
Line::from(Span::styled(
format!(
" Verified {} on default endpoint.",
app.selected_provider()
),
theme::success_style(),
)),
Line::from(""),
],
},
layout[3],
);
frame.render_widget(continue_hint(), layout[4]);
}
fn render_model_configured(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(6),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Provider",
value: app.selected_provider(),
},
layout[1],
);
let model_name = match app.selected_provider() {
"Z.AI" => "zai/glm-5",
"Anthropic" => "anthropic/claude-sonnet-4",
"OpenAI" => "openai/gpt-4o",
"Google" => "google/gemini-2.0-flash",
"Groq" => "groq/llama-3.3-70b",
"Ollama" => "ollama/llama3",
_ => "auto",
};
frame.render_widget(
InfoPanel {
title: "Model configured",
lines: vec![
Line::from(""),
Line::from(vec![
Span::styled(" Default model set to ", theme::body_style()),
Span::styled(model_name, theme::heading_style()),
]),
Line::from(""),
],
},
layout[2],
);
frame.render_widget(continue_hint(), layout[3]);
}
fn render_model_select(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(6),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Provider",
value: app.selected_provider(),
},
layout[1],
);
let items: Vec<SelectableItem> = MODELS
.iter()
.enumerate()
.map(|(i, model)| SelectableItem {
label: model.to_string(),
hint: if i == 0 {
"default".to_string()
} else {
String::new()
},
is_active: i == app.model_idx,
installed: false,
})
.collect();
frame.render_widget(
SelectableList {
title: "Default model",
items: &items,
selected: app.model_idx,
scroll_offset: 0,
},
layout[2],
);
}
fn render_channel_status(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
let status_lines: Vec<Line> = vec![
("Telegram", "needs token", false),
("Discord", "needs token", false),
("IRC", "needs host + nick", false),
("Slack", "needs tokens", false),
("Signal", "needs setup", false),
("signal-cli", "missing (signal-cli)", false),
("iMessage", "needs setup", false),
("imsg", "found (imsg)", true),
("LINE", "needs token + secret", false),
("Mattermost", "needs token + url", false),
("Nextcloud Talk", "needs setup", false),
("Feishu", "needs app credentials", false),
("BlueBubbles", "needs setup", false),
("Zalo", "needs token", false),
("Synology Chat", "needs token + incoming webhook", false),
("WhatsApp", "not configured", false),
("Google Chat", "installed", true),
("Nostr", "installed", true),
("Microsoft Teams", "installed", true),
("Matrix", "installed", true),
("Zalo Personal", "installed", true),
("Tlon", "installed", true),
("Twitch", "installed", true),
("WhatsApp", "installed", true),
]
.into_iter()
.map(|(name, status, ok)| {
Line::from(vec![
Span::styled(format!(" {name}: "), theme::body_style()),
Span::styled(
status,
if ok {
theme::success_style()
} else {
theme::warn_style()
},
),
])
})
.collect();
frame.render_widget(
InfoPanel {
title: "Channel status",
lines: status_lines,
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_how_channels_work(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
let lines = vec![
Line::from(Span::styled(
" DM security: default is pairing; unknown DMs get a pairing code.",
theme::body_style(),
)),
Line::from(Span::styled(
" Approve with: zeroclaw pairing approve <channel> <code>",
theme::dim_style(),
)),
Line::from(Span::styled(
" Public DMs require dmPolicy=\"open\" + allowFrom=[\"*\"].",
theme::body_style(),
)),
Line::from(Span::styled(
" Multi-user DMs: run: zeroclaw config set session.dmScope",
theme::body_style(),
)),
Line::from(Span::styled(
" \"per-channel-peer\" to isolate sessions.",
theme::body_style(),
)),
Line::from(Span::styled(
format!(" Docs: {DOCS_BASE}/channels/pairing"),
theme::dim_style(),
)),
Line::from(""),
Line::from(Span::styled(
" Telegram: simplest way to get started \u{2014} register a bot with",
theme::body_style(),
)),
Line::from(Span::styled(
" @BotFather and get going.",
theme::body_style(),
)),
Line::from(Span::styled(
" WhatsApp: works with your own number; recommend a separate phone",
theme::body_style(),
)),
Line::from(Span::styled(" + eSIM.", theme::body_style())),
Line::from(Span::styled(
" Discord: very well supported right now.",
theme::body_style(),
)),
Line::from(Span::styled(
" IRC: classic IRC networks with DM/channel routing and pairing",
theme::body_style(),
)),
Line::from(Span::styled(" controls.", theme::body_style())),
Line::from(Span::styled(
" Slack: supported (Socket Mode).",
theme::body_style(),
)),
Line::from(Span::styled(
" Signal: signal-cli linked device; more setup.",
theme::body_style(),
)),
Line::from(Span::styled(
" iMessage: this is still a work in progress.",
theme::body_style(),
)),
Line::from(Span::styled(
" Matrix: open protocol; install the plugin to enable.",
theme::body_style(),
)),
Line::from(Span::styled(
" Nostr: Decentralized protocol; encrypted DMs via NIP-04.",
theme::body_style(),
)),
Line::from(Span::styled(
" Twitch: Twitch chat integration",
theme::body_style(),
)),
];
frame.render_widget(
InfoPanel {
title: "How channels work",
lines,
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_channel_select(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([Constraint::Length(2), Constraint::Min(6)]).split(area);
frame.render_widget(setup_title(), layout[0]);
let items: Vec<SelectableItem> = CHANNELS
.iter()
.enumerate()
.map(|(i, (name, hint, installed))| SelectableItem {
label: name.to_string(),
hint: if *installed {
format!("{hint} \u{2713} installed")
} else {
hint.to_string()
},
is_active: i == app.channel_idx,
installed: *installed,
})
.collect();
let visible = (layout[1].height.saturating_sub(2)) as usize;
let scroll = if app.channel_idx >= app.channel_scroll + visible {
app.channel_idx.saturating_sub(visible - 1)
} else {
app.channel_scroll
};
frame.render_widget(
SelectableList {
title: "Select channel (QuickStart)",
items: &items,
selected: app.channel_idx,
scroll_offset: scroll,
},
layout[1],
);
}
fn render_web_search_info(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(10),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
InfoPanel {
title: "Web search",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" Web search lets your agent look things up online.",
theme::body_style(),
)),
Line::from(Span::styled(
" Choose a provider. Some providers need an API key, and some work",
theme::body_style(),
)),
Line::from(Span::styled(" key-free.", theme::body_style())),
Line::from(Span::styled(
format!(" Docs: {DOCS_BASE}/tools/web"),
theme::dim_style(),
)),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_web_search_provider(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([Constraint::Length(2), Constraint::Min(6)]).split(area);
frame.render_widget(setup_title(), layout[0]);
let items: Vec<SelectableItem> = SEARCH_PROVIDERS
.iter()
.enumerate()
.map(|(i, (name, hint))| SelectableItem {
label: name.to_string(),
hint: hint.to_string(),
is_active: i == app.search_provider_idx,
installed: false,
})
.collect();
frame.render_widget(
SelectableList {
title: "Search provider",
items: &items,
selected: app.search_provider_idx,
scroll_offset: 0,
},
layout[2 - 1], );
}
fn render_web_search_api_key(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(3),
Constraint::Min(1),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
ConfirmedLine {
label: "Search provider",
value: app.selected_search_provider(),
},
layout[1],
);
frame.render_widget(
InputPrompt {
label: &format!("{} API key", app.selected_search_provider()),
input: &app.search_api_key_input,
masked: false,
},
layout[2],
);
}
fn render_skills_status(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(10),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
let skill_count = SKILLS.len() - 1; frame.render_widget(
InfoPanel {
title: "Skills status",
lines: vec![
Line::from(""),
Line::from(vec![
Span::styled(" Eligible: ", theme::dim_style()),
Span::styled(format!("{skill_count}"), theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Missing requirements: ", theme::dim_style()),
Span::styled(format!("{skill_count}"), theme::warn_style()),
]),
Line::from(vec![
Span::styled(" Unsupported on this OS: ", theme::dim_style()),
Span::styled("0", theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Blocked by allowlist: ", theme::dim_style()),
Span::styled("0", theme::heading_style()),
]),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_skills_install(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([Constraint::Length(2), Constraint::Min(6)]).split(area);
frame.render_widget(setup_title(), layout[0]);
let items: Vec<SelectableItem> = SKILLS
.iter()
.enumerate()
.map(|(i, (name, desc))| SelectableItem {
label: name.to_string(),
hint: desc.to_string(),
is_active: i == app.skills_idx,
installed: false,
})
.collect();
let visible = (layout[1].height.saturating_sub(2)) as usize;
let scroll = if app.skills_idx >= app.skills_scroll + visible {
app.skills_idx.saturating_sub(visible - 1)
} else {
app.skills_scroll
};
frame.render_widget(
SelectableList {
title: "Install missing skill dependencies",
items: &items,
selected: app.skills_idx,
scroll_offset: scroll,
},
layout[1],
);
}
fn render_hooks_info(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(10),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
InfoPanel {
title: "Hooks",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" Hooks let you automate actions when agent commands are issued.",
theme::body_style(),
)),
Line::from(Span::styled(
" Example: Save session context to memory when you issue /new or",
theme::body_style(),
)),
Line::from(Span::styled(" /reset.", theme::body_style())),
Line::from(""),
Line::from(Span::styled(
format!(" Learn more: {DOCS_BASE}/automation/hooks"),
theme::dim_style(),
)),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_hooks_enable(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([Constraint::Length(2), Constraint::Min(6)]).split(area);
frame.render_widget(setup_title(), layout[0]);
let items = vec![
SelectableItem {
label: "Enable hooks".to_string(),
hint: "recommended".to_string(),
is_active: app.hooks_idx == 0,
installed: false,
},
SelectableItem {
label: "Skip for now".to_string(),
hint: String::new(),
is_active: app.hooks_idx == 1,
installed: false,
},
];
frame.render_widget(
SelectableList {
title: "Enable hooks?",
items: &items,
selected: app.hooks_idx,
scroll_offset: 0,
},
layout[1],
);
}
fn render_gateway_service(frame: &mut Frame, area: Rect, _app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(8),
Constraint::Length(4),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
InfoPanel {
title: "Gateway service runtime",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" QuickStart uses the native Rust gateway service",
theme::body_style(),
)),
Line::from(Span::styled(
" (stable + optimized for minimal overhead).",
theme::body_style(),
)),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(
StepIndicator {
current: 1,
total: 1,
label: "Gateway service installed.",
status: StepStatus::Complete,
},
layout[2],
);
frame.render_widget(continue_hint(), layout[3]);
}
fn render_health_check(frame: &mut Frame, area: Rect, _app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(4),
Constraint::Length(8),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
StepIndicator {
current: 1,
total: 1,
label: "Health check passed",
status: StepStatus::Complete,
},
layout[1],
);
frame.render_widget(
InfoPanel {
title: "Health check help",
lines: vec![
Line::from(""),
Line::from(Span::styled(" Docs:", theme::dim_style())),
Line::from(Span::styled(
format!(" {DOCS_BASE}/gateway/health"),
theme::dim_style(),
)),
Line::from(Span::styled(
format!(" {DOCS_BASE}/gateway/troubleshooting"),
theme::dim_style(),
)),
Line::from(""),
],
},
layout[2],
);
frame.render_widget(continue_hint(), layout[3]);
}
fn render_optional_apps(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(10),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
InfoPanel {
title: "Optional apps",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" Add nodes for extra features:",
theme::body_style(),
)),
Line::from(Span::styled(
" - macOS app (system + notifications)",
theme::body_style(),
)),
Line::from(Span::styled(
" - iOS app (camera/canvas)",
theme::body_style(),
)),
Line::from(Span::styled(
" - Android app (camera/canvas)",
theme::body_style(),
)),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_control_ui(frame: &mut Frame, area: Rect, app: &App) {
let base = app.gateway_base_url();
let ws = format!("ws://{}:{}", app.gateway_host, app.gateway_port);
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Web UI: ", theme::dim_style()),
Span::styled(format!("{base}/"), theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Gateway WS: ", theme::dim_style()),
Span::styled(&ws, theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Gateway: ", theme::dim_style()),
Span::styled("detected", theme::success_style()),
]),
];
if app.pairing_required {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" \u{1f510} PAIRING CODE \u{2014} enter this in the web dashboard to connect:",
theme::warn_style(),
)));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
" \u{250c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2510}",
theme::accent_style(),
),
]));
lines.push(Line::from(vec![
Span::styled(" \u{2502} ", theme::accent_style()),
Span::styled(
&app.pairing_code,
theme::title_style().add_modifier(Modifier::BOLD),
),
Span::styled(" \u{2502}", theme::accent_style()),
]));
lines.push(Line::from(vec![
Span::styled(
" \u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2518}",
theme::accent_style(),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Also works with: Docker, macOS app, iOS/Android",
theme::dim_style(),
)));
} else {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Pairing: ", theme::dim_style()),
Span::styled("disabled (open access)", theme::warn_style()),
]));
lines.push(Line::from(Span::styled(
" Enable with: require_pairing = true in config.toml",
theme::dim_style(),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" Docs: {DOCS_BASE}/web/control-ui"),
theme::dim_style(),
)));
lines.push(Line::from(""));
let panel_height = u16::try_from(lines.len())
.unwrap_or(u16::MAX)
.saturating_add(2); let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(panel_height),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
InfoPanel {
title: "Control UI",
lines,
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_workspace_backup(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(8),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
InfoPanel {
title: "Workspace backup",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" Back up your agent workspace.",
theme::body_style(),
)),
Line::from(Span::styled(
format!(" Docs: {DOCS_BASE}/concepts/agent-workspace"),
theme::dim_style(),
)),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_final_security(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(8),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
InfoPanel {
title: "Security",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" Running agents on your computer is risky \u{2014} harden your setup:",
theme::body_style(),
)),
Line::from(Span::styled(
format!(" {DOCS_BASE}/security"),
theme::dim_style(),
)),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_web_search_confirm(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(12),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
let provider = app.selected_search_provider();
let has_key = !app.search_api_key_input.is_empty();
frame.render_widget(
InfoPanel {
title: "Web search",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" Web search is enabled, so your agent can look things up online",
theme::body_style(),
)),
Line::from(Span::styled(" when needed.", theme::body_style())),
Line::from(""),
Line::from(vec![
Span::styled(" Provider: ", theme::dim_style()),
Span::styled(provider, theme::heading_style()),
]),
Line::from(vec![
Span::styled(" API key: ", theme::dim_style()),
Span::styled(
if has_key {
"stored in config."
} else {
"not required."
},
theme::heading_style(),
),
]),
Line::from(Span::styled(
format!(" Docs: {DOCS_BASE}/tools/web"),
theme::dim_style(),
)),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_what_now(frame: &mut Frame, area: Rect) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(8),
Constraint::Min(2),
])
.split(area);
frame.render_widget(setup_title(), layout[0]);
frame.render_widget(
InfoPanel {
title: "What now",
lines: vec![
Line::from(""),
Line::from(Span::styled(
" What now: https://zeroclawlabs.ai/showcase",
theme::body_style(),
)),
Line::from(Span::styled(
" (\"What People Are Building\")",
theme::dim_style(),
)),
Line::from(""),
],
},
layout[1],
);
frame.render_widget(continue_hint(), layout[2]);
}
fn render_complete(frame: &mut Frame, area: Rect, app: &App) {
let layout = Layout::vertical([
Constraint::Length(2),
Constraint::Length(20),
Constraint::Min(2),
])
.split(area);
let title = Line::from(vec![
Span::styled("\u{2514} ", theme::border_style()),
Span::styled(
"Onboarding complete. Use the dashboard link above to control ZeroClaw.",
theme::heading_style(),
),
]);
frame.render_widget(Paragraph::new(title), layout[0]);
let url = app.gateway_base_url();
let mut summary_lines = vec![
Line::from(""),
Line::from(Span::styled(
" \u{1f980} ZeroClaw configured successfully!",
theme::success_style().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(" Provider: ", theme::dim_style()),
Span::styled(app.selected_provider(), theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Model: ", theme::dim_style()),
Span::styled(app.selected_model(), theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Channel: ", theme::dim_style()),
Span::styled(app.selected_channel(), theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Web search: ", theme::dim_style()),
Span::styled(app.selected_search_provider(), theme::heading_style()),
]),
Line::from(vec![
Span::styled(" Dashboard: ", theme::dim_style()),
Span::styled(&url, theme::heading_style()),
]),
];
if app.pairing_required {
summary_lines.push(Line::from(vec![
Span::styled(" Pairing code: ", theme::dim_style()),
Span::styled(
&app.pairing_code,
theme::title_style().add_modifier(Modifier::BOLD),
),
]));
} else {
summary_lines.push(Line::from(vec![
Span::styled(" Pairing: ", theme::dim_style()),
Span::styled("disabled (open access)", theme::warn_style()),
]));
}
summary_lines.extend([
Line::from(""),
Line::from(Span::styled(
" Run `zeroclaw daemon` to start your agent.",
theme::body_style(),
)),
Line::from(Span::styled(
" Run `zeroclaw doctor` to validate your setup.",
theme::body_style(),
)),
Line::from(""),
]);
frame.render_widget(
InfoPanel {
title: "Setup complete",
lines: summary_lines,
},
layout[1],
);
let cont = Line::from(Span::styled(
"Press Enter or q to exit.",
theme::dim_style(),
));
frame.render_widget(Paragraph::new(cont), layout[2]);
}
#[cfg(test)]
mod tests {
use super::*;
fn test_app() -> App {
App {
screen: Screen::Complete,
should_quit: false,
security_accepted: true,
setup_mode_idx: 0,
config_handling_idx: 0,
provider_tier_idx: 0,
provider_idx: 0,
provider_scroll: 0,
api_key_input: String::new(),
model_idx: 0,
channel_idx: 0,
channel_scroll: 0,
search_provider_idx: 0,
search_api_key_input: String::new(),
skills_idx: 0,
skills_scroll: 0,
hooks_idx: 0,
gateway_port: 42617,
gateway_host: "127.0.0.1".to_string(),
pairing_code: "123456".to_string(),
pairing_required: true,
}
}
#[test]
fn save_provider_openrouter() {
let app = test_app(); let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.default_provider.as_deref(), Some("openrouter"));
}
#[test]
fn save_provider_anthropic() {
let mut app = test_app();
app.provider_tier_idx = 0;
app.provider_idx = 2; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
}
#[test]
fn save_provider_ollama_local() {
let mut app = test_app();
app.provider_tier_idx = 4; app.provider_idx = 0; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.default_provider.as_deref(), Some("ollama"));
}
#[test]
fn save_provider_custom_clears_api_url() {
let mut app = test_app();
app.provider_tier_idx = 0;
app.provider_idx = 0; let mut config = Config::default();
config.api_url = Some("http://old-custom-url.com".to_string());
apply_tui_selections_to_config(&app, &mut config);
assert!(
config.api_url.is_none(),
"api_url should be cleared for non-custom providers"
);
}
#[test]
fn save_api_key_when_provided() {
let mut app = test_app();
app.api_key_input = "sk-test-key-12345".to_string();
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.api_key.as_deref(), Some("sk-test-key-12345"));
}
#[test]
fn save_no_api_key_when_empty() {
let app = test_app(); let mut config = Config::default();
config.api_key = Some("existing-key".to_string());
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.api_key.as_deref(), Some("existing-key"));
}
#[test]
fn save_model_auto_clears_default() {
let app = test_app(); let mut config = Config::default();
config.default_model = Some("old-model".to_string());
apply_tui_selections_to_config(&app, &mut config);
assert!(
config.default_model.is_none(),
"Auto should clear default_model"
);
}
#[test]
fn save_model_specific() {
let mut app = test_app();
app.model_idx = 1; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(
config.default_model.as_deref(),
Some("claude-sonnet-4-20250514")
);
}
#[test]
fn save_model_gpt4o() {
let mut app = test_app();
app.model_idx = 3; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
}
#[test]
fn save_channel_telegram() {
let mut app = test_app();
app.channel_idx = 0; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let tg = config
.channels_config
.telegram
.as_ref()
.expect("telegram should be Some");
assert_eq!(tg.bot_token, "YOUR_TELEGRAM_BOT_TOKEN");
}
#[test]
fn save_channel_discord() {
let mut app = test_app();
app.channel_idx = 2; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let dc = config
.channels_config
.discord
.as_ref()
.expect("discord should be Some");
assert_eq!(dc.bot_token, "YOUR_DISCORD_BOT_TOKEN");
assert!(dc.guild_id.is_none());
}
#[test]
fn save_channel_slack() {
let mut app = test_app();
app.channel_idx = 5; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let sl = config
.channels_config
.slack
.as_ref()
.expect("slack should be Some");
assert!(sl.bot_token.starts_with("xoxb-"));
assert!(sl.app_token.as_ref().unwrap().starts_with("xapp-"));
}
#[test]
fn save_channel_whatsapp() {
let mut app = test_app();
app.channel_idx = 1; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let wa = config
.channels_config
.whatsapp
.as_ref()
.expect("whatsapp should be Some");
assert!(wa.access_token.is_some());
assert!(wa.phone_number_id.is_some());
assert!(wa.verify_token.is_some());
}
#[test]
fn save_channel_signal() {
let mut app = test_app();
app.channel_idx = 6; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let sig = config
.channels_config
.signal
.as_ref()
.expect("signal should be Some");
assert_eq!(sig.http_url, "http://127.0.0.1:8080");
}
#[test]
fn save_channel_irc() {
let mut app = test_app();
app.channel_idx = 3; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let irc = config
.channels_config
.irc
.as_ref()
.expect("irc should be Some");
assert_eq!(irc.server, "irc.libera.chat");
assert_eq!(irc.port, 6697);
assert_eq!(irc.nickname, "zeroclaw-bot");
}
#[test]
fn save_channel_imessage() {
let mut app = test_app();
app.channel_idx = 7; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(config.channels_config.imessage.is_some());
}
#[test]
fn save_channel_matrix() {
let mut app = test_app();
let matrix_idx = CHANNELS.iter().position(|c| c.0 == "Matrix").unwrap();
app.channel_idx = matrix_idx;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let mx = config
.channels_config
.matrix
.as_ref()
.expect("matrix should be Some");
assert_eq!(mx.homeserver, "https://matrix.org");
}
#[test]
fn save_channel_mattermost() {
let mut app = test_app();
app.channel_idx = 9; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let mm = config
.channels_config
.mattermost
.as_ref()
.expect("mattermost should be Some");
assert_eq!(mm.url, "https://mattermost.example.com");
}
#[test]
fn save_channel_nextcloud_talk() {
let mut app = test_app();
let idx = CHANNELS
.iter()
.position(|c| c.0 == "Nextcloud Talk")
.unwrap();
app.channel_idx = idx;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let nc = config
.channels_config
.nextcloud_talk
.as_ref()
.expect("nextcloud should be Some");
assert_eq!(nc.base_url, "https://cloud.example.com");
}
#[test]
fn save_channel_feishu_lark() {
let mut app = test_app();
let idx = CHANNELS.iter().position(|c| c.0 == "Feishu/Lark").unwrap();
app.channel_idx = idx;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(
config.channels_config.feishu.is_some(),
"feishu should be set"
);
assert!(config.channels_config.lark.is_some(), "lark should be set");
}
#[test]
fn save_channel_skip_does_not_create_stubs() {
let mut app = test_app();
let idx = CHANNELS.iter().position(|c| c.0 == "Skip for now").unwrap();
app.channel_idx = idx;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(config.channels_config.telegram.is_none());
assert!(config.channels_config.discord.is_none());
assert!(config.channels_config.slack.is_none());
}
#[test]
fn save_channel_does_not_overwrite_existing() {
let mut app = test_app();
app.channel_idx = 0; let mut config = Config::default();
config.channels_config.telegram = Some(TelegramConfig {
enabled: true,
bot_token: "REAL_TOKEN_123".to_string(),
allowed_users: vec!["alice".to_string()],
stream_mode: StreamMode::default(),
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
proxy_url: None,
});
apply_tui_selections_to_config(&app, &mut config);
let tg = config.channels_config.telegram.as_ref().unwrap();
assert_eq!(
tg.bot_token, "REAL_TOKEN_123",
"should NOT overwrite existing config"
);
assert_eq!(tg.allowed_users, vec!["alice"]);
}
#[test]
fn save_web_search_brave() {
let mut app = test_app();
app.search_provider_idx = 0; app.search_api_key_input = "brv-key-abc".to_string();
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(config.web_search.enabled);
assert_eq!(config.web_search.provider, "brave");
assert_eq!(
config.web_search.brave_api_key.as_deref(),
Some("brv-key-abc")
);
}
#[test]
fn save_web_search_searxng() {
let mut app = test_app();
app.search_provider_idx = 1; app.search_api_key_input = "https://searx.example.com".to_string();
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(config.web_search.enabled);
assert_eq!(config.web_search.provider, "searxng");
assert_eq!(
config.web_search.searxng_instance_url.as_deref(),
Some("https://searx.example.com")
);
}
#[test]
fn save_web_search_duckduckgo() {
let mut app = test_app();
app.search_provider_idx = 4; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(config.web_search.enabled);
assert_eq!(config.web_search.provider, "duckduckgo");
}
#[test]
fn save_web_search_tavily_maps_to_tavily() {
let mut app = test_app();
app.search_provider_idx = 2; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.web_search.provider, "tavily");
}
#[test]
fn save_web_search_skip() {
let mut app = test_app();
app.search_provider_idx = 5; let mut config = Config::default();
let old_enabled = config.web_search.enabled;
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.web_search.enabled, old_enabled);
}
#[test]
fn save_skills_enabled() {
let mut app = test_app();
app.skills_idx = 1; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(config.skills.open_skills_enabled);
}
#[test]
fn save_skills_skip() {
let app = test_app(); let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(!config.skills.open_skills_enabled);
}
#[test]
fn save_hooks_enabled() {
let mut app = test_app();
app.hooks_idx = 0; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(config.hooks.enabled);
assert!(config.hooks.builtin.command_logger);
}
#[test]
fn save_hooks_disabled() {
let mut app = test_app();
app.hooks_idx = 1; let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(!config.hooks.enabled);
}
#[test]
fn save_gateway_port_and_host() {
let mut app = test_app();
app.gateway_port = 9999;
app.gateway_host = "0.0.0.0".to_string();
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.gateway.port, 9999);
assert_eq!(config.gateway.host, "0.0.0.0");
}
#[test]
fn save_gateway_default_values() {
let app = test_app();
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.gateway.port, 42617);
assert_eq!(config.gateway.host, "127.0.0.1");
}
#[test]
fn save_pairing_required() {
let mut app = test_app();
app.pairing_required = true;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(config.gateway.require_pairing);
}
#[test]
fn save_pairing_not_required() {
let mut app = test_app();
app.pairing_required = false;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert!(!config.gateway.require_pairing);
}
#[test]
fn e2e_full_setup_anthropic_telegram_brave() {
let mut app = test_app();
app.provider_tier_idx = 0;
app.provider_idx = 2;
app.api_key_input = "sk-ant-api-key".to_string();
app.model_idx = 2; app.channel_idx = 0;
app.search_provider_idx = 0;
app.search_api_key_input = "brave-key-123".to_string();
app.skills_idx = 12;
app.hooks_idx = 0;
app.gateway_port = 8080;
app.gateway_host = "192.168.1.100".to_string();
app.pairing_required = true;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
assert_eq!(config.api_key.as_deref(), Some("sk-ant-api-key"));
assert_eq!(
config.default_model.as_deref(),
Some("claude-opus-4-20250514")
);
assert!(config.channels_config.telegram.is_some());
assert!(config.web_search.enabled);
assert_eq!(config.web_search.provider, "brave");
assert_eq!(
config.web_search.brave_api_key.as_deref(),
Some("brave-key-123")
);
assert!(config.skills.open_skills_enabled);
assert!(config.hooks.enabled);
assert!(config.hooks.builtin.command_logger);
assert_eq!(config.gateway.port, 8080);
assert_eq!(config.gateway.host, "192.168.1.100");
assert!(config.gateway.require_pairing);
}
#[test]
fn e2e_minimal_setup_ollama_skip_everything() {
let mut app = test_app();
app.provider_tier_idx = 4;
app.provider_idx = 0;
app.api_key_input = String::new();
app.model_idx = 0;
let skip_idx = CHANNELS.iter().position(|c| c.0 == "Skip for now").unwrap();
app.channel_idx = skip_idx;
app.search_provider_idx = 5;
app.skills_idx = 0;
app.hooks_idx = 1;
app.pairing_required = false;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.default_provider.as_deref(), Some("ollama"));
assert!(config.api_key.is_none());
assert!(config.default_model.is_none());
assert!(config.channels_config.telegram.is_none());
assert!(config.channels_config.discord.is_none());
assert!(!config.skills.open_skills_enabled);
assert!(!config.hooks.enabled);
assert!(!config.gateway.require_pairing);
}
#[test]
fn e2e_discord_searxng_with_hooks() {
let mut app = test_app();
app.provider_tier_idx = 0;
app.provider_idx = 3;
app.api_key_input = "sk-openai-key".to_string();
app.model_idx = 3;
app.channel_idx = 2;
app.search_provider_idx = 1;
app.search_api_key_input = "https://search.local".to_string();
app.skills_idx = 0;
app.hooks_idx = 0;
app.gateway_host = "0.0.0.0".to_string();
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
assert_eq!(config.default_provider.as_deref(), Some("openai"));
assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
let dc = config.channels_config.discord.as_ref().unwrap();
assert_eq!(dc.bot_token, "YOUR_DISCORD_BOT_TOKEN");
assert_eq!(config.web_search.provider, "searxng");
assert_eq!(
config.web_search.searxng_instance_url.as_deref(),
Some("https://search.local")
);
assert!(config.hooks.enabled);
assert_eq!(config.gateway.host, "0.0.0.0");
}
#[test]
fn provider_select_skips_api_key_for_openai_codex() {
let mut app = test_app();
app.screen = Screen::ProviderSelect;
app.provider_tier_idx = 0;
app.provider_idx = 4;
handle_input(&mut app, KeyCode::Enter);
assert_eq!(app.screen, Screen::ProviderNotes);
}
#[test]
fn provider_select_skips_api_key_for_ollama_local() {
let mut app = test_app();
app.screen = Screen::ProviderSelect;
app.provider_tier_idx = 4;
app.provider_idx = 0;
handle_input(&mut app, KeyCode::Enter);
assert_eq!(app.screen, Screen::ProviderNotes);
}
#[test]
fn api_key_screen_allows_empty_enter_to_continue() {
let mut app = test_app();
app.screen = Screen::ApiKeyInput;
app.api_key_input.clear();
handle_input(&mut app, KeyCode::Enter);
assert_eq!(app.screen, Screen::ProviderNotes);
}
#[test]
fn config_serializes_to_valid_toml() {
let mut app = test_app();
app.provider_tier_idx = 0;
app.provider_idx = 0;
app.channel_idx = 0; app.hooks_idx = 0;
app.search_provider_idx = 0;
app.search_api_key_input = "brave-key".to_string();
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let toml_str = toml::to_string(&config).expect("config should serialize to TOML");
assert!(toml_str.contains("YOUR_TELEGRAM_BOT_TOKEN"));
assert!(toml_str.contains("openrouter"));
let _: Config = toml::from_str(&toml_str).expect("serialized TOML should parse back");
}
#[test]
fn config_with_all_channels_serializes() {
let channels_to_test = [
"Telegram",
"WhatsApp",
"Discord",
"IRC",
"Slack",
"Signal",
"iMessage",
"Mattermost",
"Nextcloud Talk",
"Feishu/Lark",
];
for channel_name in &channels_to_test {
let mut app = test_app();
let idx = CHANNELS
.iter()
.position(|c| c.0 == *channel_name)
.unwrap_or_else(|| panic!("channel {channel_name} not found in CHANNELS"));
app.channel_idx = idx;
let mut config = Config::default();
apply_tui_selections_to_config(&app, &mut config);
let toml_str = toml::to_string(&config)
.unwrap_or_else(|e| panic!("failed to serialize config for {channel_name}: {e}"));
let _: Config = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("failed to deserialize config for {channel_name}: {e}"));
}
}
}