diff --git a/src/boot_screen.rs b/src/boot_screen.rs new file mode 100644 index 00000000..57b082eb --- /dev/null +++ b/src/boot_screen.rs @@ -0,0 +1,208 @@ +//! Boot screen displayed after all initialization completes. +//! +//! Shows a polished ANSI-styled status panel summarizing the agent's runtime +//! state: model, database, tool count, enabled features, active channels, +//! and the gateway URL. + +/// All displayable fields for the boot screen. +pub struct BootInfo { + pub version: String, + pub agent_name: String, + pub llm_backend: String, + pub llm_model: String, + pub cheap_model: Option, + pub db_backend: String, + pub db_connected: bool, + pub tool_count: usize, + pub gateway_url: Option, + pub embeddings_enabled: bool, + pub embeddings_provider: Option, + pub heartbeat_enabled: bool, + pub heartbeat_interval_secs: u64, + pub sandbox_enabled: bool, + pub claude_code_enabled: bool, + pub routines_enabled: bool, + pub channels: Vec, +} + +/// Print the boot screen to stdout. +pub fn print_boot_screen(info: &BootInfo) { + // ANSI codes matching existing REPL palette + let bold = "\x1b[1m"; + let cyan = "\x1b[36m"; + let dim = "\x1b[90m"; + let yellow_underline = "\x1b[33;4m"; + let reset = "\x1b[0m"; + + let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58)); + + println!(); + println!("{border}"); + println!(); + println!(" {bold}{}{reset} v{}", info.agent_name, info.version); + println!(); + + // Model line + let model_display = if let Some(ref cheap) = info.cheap_model { + format!( + "{cyan}{}{reset} {dim}cheap{reset} {cyan}{}{reset}", + info.llm_model, cheap + ) + } else { + format!("{cyan}{}{reset}", info.llm_model) + }; + println!( + " {dim}model{reset} {model_display} {dim}via {}{reset}", + info.llm_backend + ); + + // Database line + let db_status = if info.db_connected { + "connected" + } else { + "none" + }; + println!( + " {dim}database{reset} {cyan}{}{reset} {dim}({db_status}){reset}", + info.db_backend + ); + + // Tools line + println!( + " {dim}tools{reset} {cyan}{}{reset} {dim}registered{reset}", + info.tool_count + ); + + // Features line + let mut features = Vec::new(); + if info.embeddings_enabled { + if let Some(ref provider) = info.embeddings_provider { + features.push(format!("embeddings ({provider})")); + } else { + features.push("embeddings".to_string()); + } + } + if info.heartbeat_enabled { + let mins = info.heartbeat_interval_secs / 60; + features.push(format!("heartbeat ({mins}m)")); + } + if info.sandbox_enabled { + features.push("sandbox".to_string()); + } + if info.claude_code_enabled { + features.push("claude-code".to_string()); + } + if info.routines_enabled { + features.push("routines".to_string()); + } + if !features.is_empty() { + println!( + " {dim}features{reset} {cyan}{}{reset}", + features.join(" ") + ); + } + + // Channels line + if !info.channels.is_empty() { + println!( + " {dim}channels{reset} {cyan}{}{reset}", + info.channels.join(" ") + ); + } + + // Gateway URL (highlighted) + if let Some(ref url) = info.gateway_url { + println!(); + println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}"); + } + + println!(); + println!("{border}"); + println!(); + println!(" /help for commands, /quit to exit"); + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_print_boot_screen_full() { + let info = BootInfo { + version: "0.2.0".to_string(), + agent_name: "ironclaw".to_string(), + llm_backend: "nearai".to_string(), + llm_model: "claude-3-5-sonnet-20241022".to_string(), + cheap_model: Some("gpt-4o-mini".to_string()), + db_backend: "libsql".to_string(), + db_connected: true, + tool_count: 24, + gateway_url: Some("http://127.0.0.1:3001/?token=abc123".to_string()), + embeddings_enabled: true, + embeddings_provider: Some("openai".to_string()), + heartbeat_enabled: true, + heartbeat_interval_secs: 1800, + sandbox_enabled: true, + claude_code_enabled: false, + routines_enabled: true, + channels: vec![ + "repl".to_string(), + "gateway".to_string(), + "telegram".to_string(), + ], + }; + // Should not panic + print_boot_screen(&info); + } + + #[test] + fn test_print_boot_screen_minimal() { + let info = BootInfo { + version: "0.2.0".to_string(), + agent_name: "ironclaw".to_string(), + llm_backend: "nearai".to_string(), + llm_model: "gpt-4o".to_string(), + cheap_model: None, + db_backend: "none".to_string(), + db_connected: false, + tool_count: 5, + gateway_url: None, + embeddings_enabled: false, + embeddings_provider: None, + heartbeat_enabled: false, + heartbeat_interval_secs: 0, + sandbox_enabled: false, + claude_code_enabled: false, + routines_enabled: false, + channels: vec![], + }; + // Should not panic + print_boot_screen(&info); + } + + #[test] + fn test_print_boot_screen_no_features() { + let info = BootInfo { + version: "0.1.0".to_string(), + agent_name: "test".to_string(), + llm_backend: "openai".to_string(), + llm_model: "gpt-4o".to_string(), + cheap_model: None, + db_backend: "postgres".to_string(), + db_connected: true, + tool_count: 10, + gateway_url: None, + embeddings_enabled: false, + embeddings_provider: None, + heartbeat_enabled: false, + heartbeat_interval_secs: 0, + sandbox_enabled: false, + claude_code_enabled: false, + routines_enabled: false, + channels: vec!["repl".to_string()], + }; + // Should not panic + print_boot_screen(&info); + } +} diff --git a/src/channels/repl.rs b/src/channels/repl.rs index 1dde2f47..a72547d0 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -184,6 +184,8 @@ pub struct ReplChannel { debug_mode: Arc, /// Whether we're currently streaming (chunks have been printed without a trailing newline). is_streaming: Arc, + /// When true, the one-liner startup banner is suppressed (boot screen shown instead). + suppress_banner: Arc, } impl ReplChannel { @@ -193,6 +195,7 @@ impl ReplChannel { single_message: None, debug_mode: Arc::new(AtomicBool::new(false)), is_streaming: Arc::new(AtomicBool::new(false)), + suppress_banner: Arc::new(AtomicBool::new(false)), } } @@ -202,9 +205,15 @@ impl ReplChannel { single_message: Some(message), debug_mode: Arc::new(AtomicBool::new(false)), is_streaming: Arc::new(AtomicBool::new(false)), + suppress_banner: Arc::new(AtomicBool::new(false)), } } + /// Suppress the one-liner startup banner (boot screen will be shown instead). + pub fn suppress_banner(&self) { + self.suppress_banner.store(true, Ordering::Relaxed); + } + fn is_debug(&self) -> bool { self.debug_mode.load(Ordering::Relaxed) } @@ -264,6 +273,7 @@ impl Channel for ReplChannel { let (tx, rx) = mpsc::channel(32); let single_message = self.single_message.clone(); let debug_mode = Arc::clone(&self.debug_mode); + let suppress_banner = Arc::clone(&self.suppress_banner); std::thread::spawn(move || { // Single message mode: send it and return @@ -298,8 +308,10 @@ impl Channel for ReplChannel { } let _ = rl.load_history(&hist_path); - println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit"); - println!(); + if !suppress_banner.load(Ordering::Relaxed) { + println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit"); + println!(); + } loop { let prompt = if debug_mode.load(Ordering::Relaxed) { diff --git a/src/config.rs b/src/config.rs index 4dd8ccce..8c2f00a0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -153,6 +153,15 @@ pub enum DatabaseBackend { LibSql, } +impl std::fmt::Display for DatabaseBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Postgres => write!(f, "postgres"), + Self::LibSql => write!(f, "libsql"), + } + } +} + impl std::str::FromStr for DatabaseBackend { type Err = String; diff --git a/src/lib.rs b/src/lib.rs index 2fca3c3f..742dadbc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ //! - **Continuous learning** - Improve estimates from historical data pub mod agent; +pub mod boot_screen; pub mod bootstrap; pub mod channels; pub mod cli; diff --git a/src/main.rs b/src/main.rs index 1b398def..71d224ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -338,7 +338,10 @@ async fn main() -> anyhow::Result<()> { let repl_channel = if let Some(ref msg) = cli.message { Some(ReplChannel::with_message(msg.clone())) } else if config.channels.cli.enabled { - Some(ReplChannel::new()) + let repl = ReplChannel::new(); + // Suppress the one-liner banner; boot screen will be shown instead. + repl.suppress_banner(); + Some(repl) } else { None }; @@ -886,12 +889,14 @@ async fn main() -> anyhow::Result<()> { // Initialize channel manager let mut channels = ChannelManager::new(); + let mut channel_names: Vec = Vec::new(); if let Some(repl) = repl_channel { channels.add(Box::new(repl)); if cli.message.is_some() { tracing::info!("Single message mode"); } else { + channel_names.push("repl".to_string()); tracing::info!("REPL mode enabled"); } } @@ -1027,6 +1032,7 @@ async fn main() -> anyhow::Result<()> { } } + channel_names.push(channel_name.clone()); channels.add(Box::new(SharedWasmChannel::new(channel_arc))); } @@ -1071,6 +1077,7 @@ async fn main() -> anyhow::Result<()> { .parse() .expect("HttpConfig host:port must be a valid SocketAddr"), ); + channel_names.push("http".to_string()); channels.add(Box::new(http_channel)); tracing::info!( "HTTP channel enabled on {}:{}", @@ -1147,6 +1154,7 @@ async fn main() -> anyhow::Result<()> { ); // Add web gateway channel if configured + let mut gateway_url: Option = None; if let Some(ref gw_config) = config.channels.gateway { let mut gw = GatewayChannel::new(gw_config.clone()); if let Some(ref ws) = workspace { @@ -1179,21 +1187,29 @@ async fn main() -> anyhow::Result<()> { } } + gateway_url = Some(format!( + "http://{}:{}/?token={}", + gw_config.host, + gw_config.port, + gw.auth_token() + )); + tracing::info!( "Web gateway enabled on {}:{}", gw_config.host, gw_config.port ); - tracing::info!( - "Web UI: http://{}:{}/?token={}", - gw_config.host, - gw_config.port, - gw.auth_token() - ); + tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port); + channel_names.push("gateway".to_string()); channels.add(Box::new(gw)); } + // Capture boot screen info before moving Arcs into AgentDeps. + let boot_tool_count = tools.count(); + let boot_llm_model = llm.model_name().to_string(); + let boot_cheap_model = cheap_llm.as_ref().map(|c| c.model_name().to_string()); + // Create and run the agent let deps = AgentDeps { store: db, @@ -1217,6 +1233,38 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Agent initialized, starting main loop..."); + // Print boot screen for interactive CLI mode (not single-message mode). + if config.channels.cli.enabled && cli.message.is_none() { + let boot_info = ironclaw::boot_screen::BootInfo { + version: env!("CARGO_PKG_VERSION").to_string(), + agent_name: config.agent.name.clone(), + llm_backend: config.llm.backend.to_string(), + llm_model: boot_llm_model, + cheap_model: boot_cheap_model, + db_backend: if cli.no_db { + "none".to_string() + } else { + config.database.backend.to_string() + }, + db_connected: !cli.no_db, + tool_count: boot_tool_count, + gateway_url, + embeddings_enabled: config.embeddings.enabled, + embeddings_provider: if config.embeddings.enabled { + Some(config.embeddings.provider.clone()) + } else { + None + }, + heartbeat_enabled: config.heartbeat.enabled, + heartbeat_interval_secs: config.heartbeat.interval_secs, + sandbox_enabled: config.sandbox.enabled, + claude_code_enabled: config.claude_code.enabled, + routines_enabled: config.routines.enabled, + channels: channel_names, + }; + ironclaw::boot_screen::print_boot_screen(&boot_info); + } + // Run the agent (blocks until shutdown) agent.run().await?;