diff --git a/Cargo.lock b/Cargo.lock index 9dc240c3..76754db7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1510,7 +1510,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" dependencies = [ - "crossterm 0.29.0", + "crossterm", ] [[package]] @@ -1731,7 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" dependencies = [ "crokey-proc_macros", - "crossterm 0.29.0", + "crossterm", "once_cell", "serde", "strict", @@ -1743,7 +1743,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" dependencies = [ - "crossterm 0.29.0", + "crossterm", "proc-macro2", "quote", "strict", @@ -1817,22 +1817,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags 2.11.0", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.29.0" @@ -2152,7 +2136,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2339,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3426,7 +3410,7 @@ dependencies = [ "clap_complete", "criterion", "cron", - "crossterm 0.28.1", + "crossterm", "deadpool-postgres", "dirs 6.0.0", "dotenvy", @@ -4150,7 +4134,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5488,7 +5472,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6395,7 +6379,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8045,7 +8029,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 32645b9a..99992a40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,7 +88,7 @@ async-trait = "0.1" clap = { version = "4", features = ["derive", "env"] } # Terminal -crossterm = "0.28" +crossterm = "0.29" rustyline = { version = "17", features = ["custom-bindings", "derive", "with-file-history"] } termimad = "0.34" diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 90616074..7fc8e0ca 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -317,7 +317,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> { .channels .send_status( &self.message.channel, - StatusUpdate::Thinking("Calling LLM...".into()), + StatusUpdate::Thinking(format!("Thinking (step {iteration})...")), &self.message.metadata, ) .await; @@ -435,7 +435,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> { .channels .send_status( &self.message.channel, - StatusUpdate::Thinking(format!("Executing {} tool(s)...", tool_calls.len())), + StatusUpdate::Thinking(contextual_tool_message(&tool_calls)), &self.message.metadata, ) .await; @@ -969,6 +969,30 @@ pub(super) fn check_auth_required( Some((name, instructions)) } +/// Build a contextual thinking message based on tool names. +/// +/// Instead of a generic "Executing 2 tool(s)..." this returns messages like +/// "Running command..." or "Fetching page..." for single-tool calls, falling +/// back to "Executing N tool(s)..." for multi-tool calls. +fn contextual_tool_message(tool_calls: &[crate::llm::ToolCall]) -> String { + if tool_calls.len() == 1 { + match tool_calls[0].name.as_str() { + "shell" => "Running command...".into(), + "web_fetch" => "Fetching page...".into(), + "memory_search" => "Searching memory...".into(), + "memory_write" => "Writing to memory...".into(), + "memory_read" => "Reading memory...".into(), + "http_request" => "Making HTTP request...".into(), + "file_read" => "Reading file...".into(), + "file_write" => "Writing file...".into(), + "json_transform" => "Transforming data...".into(), + name => format!("Running {name}..."), + } + } else { + format!("Executing {} tool(s)...", tool_calls.len()) + } +} + /// Compact messages for retry after a context-length-exceeded error. /// /// Keeps all `System` messages (which carry the system prompt and instructions), diff --git a/src/agent/routine.rs b/src/agent/routine.rs index 1b8ca96a..26e769da 100644 --- a/src/agent/routine.rs +++ b/src/agent/routine.rs @@ -529,8 +529,8 @@ pub fn normalize_cron_expression(schedule: &str) -> String { let trimmed = schedule.trim(); let fields: Vec<&str> = trimmed.split_whitespace().collect(); match fields.len() { - 5 => format!("0 {} *", trimmed), - 6 => format!("{} *", trimmed), + 5 => format!("0 {} *", fields.join(" ")), + 6 => format!("{} *", fields.join(" ")), _ => trimmed.to_string(), } } diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 5b81dfa9..eec29099 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -556,6 +556,33 @@ impl Agent { .await; } + // Emit per-turn cost summary + { + let usage = self.cost_guard().model_usage().await; + let (total_in, total_out, total_cost) = + usage + .values() + .fold((0u64, 0u64, rust_decimal::Decimal::ZERO), |acc, m| { + ( + acc.0 + m.input_tokens, + acc.1 + m.output_tokens, + acc.2 + m.cost, + ) + }); + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::TurnCost { + input_tokens: total_in, + output_tokens: total_out, + cost_usd: format!("${:.4}", total_cost), + }, + &message.metadata, + ) + .await; + } + Ok(SubmissionResult::response(response)) } Ok(AgenticLoopResult::NeedApproval { pending }) => { diff --git a/src/app.rs b/src/app.rs index d50cefb3..b2520144 100644 --- a/src/app.rs +++ b/src/app.rs @@ -386,7 +386,7 @@ impl AppBuilder { let b = tools .register_builder_tool(llm.clone(), Some(self.config.builder.to_builder_config())) .await; - tracing::info!("Builder mode enabled"); + tracing::debug!("Builder mode enabled"); Some(b) } else { None diff --git a/src/boot_screen.rs b/src/boot_screen.rs index d9590ccc..c018abf6 100644 --- a/src/boot_screen.rs +++ b/src/boot_screen.rs @@ -1,8 +1,11 @@ //! 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. +//! Shows a compact ANSI-styled status panel with three tiers: +//! - **Tier 1 (always):** Name + version, model + backend. +//! - **Tier 2 (conditional):** Gateway URL, tunnel URL, non-default channels. +//! - **Tier 3 (removed):** Database, tool count, features → use `ironclaw status`. + +use crate::cli::fmt; /// All displayable fields for the boot screen. pub struct BootInfo { @@ -29,112 +32,76 @@ pub struct BootInfo { pub tunnel_url: Option, /// Provider name for the managed tunnel (e.g., "ngrok"). pub tunnel_provider: Option, + /// Time elapsed during startup. Shown at the bottom when present. + pub startup_elapsed: Option, } -/// 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 = "\x1b[33m"; - let yellow_underline = "\x1b[33;4m"; - let reset = "\x1b[0m"; +const KW: usize = 10; - let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58)); +/// Print the boot screen to stdout. +/// +/// **Tier 1 (always):** Name + version, model + backend. +/// **Tier 2 (conditional):** Gateway URL, tunnel URL, non-default channels. +/// **Tier 3 (removed):** Database, tool count, features — use `ironclaw status`. +pub fn print_boot_screen(info: &BootInfo) { + let border = format!(" {}", fmt::separator(58)); println!(); println!("{border}"); println!(); - println!(" {bold}{}{reset} v{}", info.agent_name, info.version); + + // ── Tier 1: always shown ────────────────────────────────────────── + + println!( + " {}{}{} v{}", + fmt::bold(), + info.agent_name, + fmt::reset(), + 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 + "{}{}{} {}cheap{} {}{}{}", + fmt::accent(), + info.llm_model, + fmt::reset(), + fmt::dim(), + fmt::reset(), + fmt::accent(), + cheap, + fmt::reset(), ) } else { - format!("{cyan}{}{reset}", info.llm_model) + format!("{}{}{}", fmt::accent(), info.llm_model, fmt::reset()) }; println!( - " {dim}model{reset} {model_display} {dim}via {}{reset}", - info.llm_backend + " {}{: { - features.push("sandbox".to_string()); - } - crate::sandbox::detect::DockerStatus::NotInstalled => { - features.push(format!("{yellow}sandbox (docker not installed){reset}")); - } - crate::sandbox::detect::DockerStatus::NotRunning => { - features.push(format!("{yellow}sandbox (docker not running){reset}")); - } - crate::sandbox::detect::DockerStatus::Disabled => { - // Don't show sandbox when disabled - } - } - if info.claude_code_enabled { - features.push("claude-code".to_string()); - } - if info.routines_enabled { - features.push("routines".to_string()); - } - if info.skills_enabled { - features.push("skills".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) + // Gateway URL if let Some(ref url) = info.gateway_url { - println!(); - println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}"); + println!( + " {}{: = info + .channels + .iter() + .filter(|c| !matches!(c.as_str(), "repl" | "gateway")) + .map(|c| c.as_str()) + .collect(); + if !non_default.is_empty() { + println!( + " {}{: = Vec::new(); + + // Database + if info.db_connected { + tags.push(format!("db:{}", info.db_backend)); + } + + // Tool count + if info.tool_count > 0 { + tags.push(format!("tools:{}", info.tool_count)); + } + + // Routines + if info.routines_enabled { + tags.push("routines".to_string()); + } + + // Heartbeat with interval + if info.heartbeat_enabled { + let interval = if info.heartbeat_interval_secs >= 3600 + && info.heartbeat_interval_secs.is_multiple_of(3600) + { + format!("{}h", info.heartbeat_interval_secs / 3600) + } else if info.heartbeat_interval_secs >= 60 + && info.heartbeat_interval_secs.is_multiple_of(60) + { + format!("{}m", info.heartbeat_interval_secs / 60) + } else { + format!("{}s", info.heartbeat_interval_secs) + }; + tags.push(format!("heartbeat:{interval}")); + } + + // Skills + if info.skills_enabled { + tags.push("skills".to_string()); + } + + // Sandbox / Docker + if info.sandbox_enabled { + let suffix = match info.docker_status { + crate::sandbox::detect::DockerStatus::Available => "", + crate::sandbox::detect::DockerStatus::NotRunning => ":stopped", + _ => ":unavail", + }; + tags.push(format!("sandbox{suffix}")); + } + + // Embeddings + if info.embeddings_enabled { + if let Some(ref provider) = info.embeddings_provider { + tags.push(format!("embeddings:{provider}")); + } else { + tags.push("embeddings".to_string()); + } + } + + // Claude Code bridge + if info.claude_code_enabled { + tags.push("claude-code".to_string()); + } + + if !tags.is_empty() { + println!( + " {}{: }, + /// Per-turn token usage and cost summary (shown as subtle metadata). + TurnCost { + input_tokens: u64, + output_tokens: u64, + cost_usd: String, + }, } impl StatusUpdate { diff --git a/src/channels/repl.rs b/src/channels/repl.rs index 36ca7c28..055dc3ad 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -20,6 +20,7 @@ use std::borrow::Cow; use std::io::{self, IsTerminal, Write}; use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use async_trait::async_trait; @@ -40,6 +41,7 @@ use tokio_stream::wrappers::ReceiverStream; use crate::agent::truncate_for_preview; use crate::bootstrap::ironclaw_base_dir; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; +use crate::cli::fmt; use crate::error::ChannelError; /// Max characters for tool result previews in the terminal. @@ -119,7 +121,7 @@ impl Hinter for ReplHelper { impl Highlighter for ReplHelper { fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { - Cow::Owned(format!("\x1b[90m{hint}\x1b[0m")) + Cow::Owned(format!("{}{hint}{}", fmt::dim(), fmt::reset())) } } @@ -143,55 +145,207 @@ impl ConditionalEventHandler for EscInterruptHandler { } } +/// Approval action chosen by the interactive selector. +#[derive(Clone, Copy)] +enum ApprovalAction { + Approve, + Always, + Deny, +} + +impl std::fmt::Display for ApprovalAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Approve => write!(f, "Approve (y)"), + Self::Always => write!(f, "Always approve (a)"), + Self::Deny => write!(f, "Deny (n)"), + } + } +} + +impl ApprovalAction { + fn as_input(self) -> &'static str { + match self { + Self::Approve => "y", + Self::Always => "a", + Self::Deny => "n", + } + } +} + +/// Interactive approval selector using crossterm raw mode. +/// Returns the approval action string ("y", "a", or "n"). +fn run_approval_selector(allow_always: bool) -> Option<&'static str> { + use crossterm::{ + cursor, + event::{self, Event as CtEvent, KeyCode as CtKeyCode, KeyEventKind}, + execute, + terminal::{self, ClearType}, + }; + + let options: Vec = if allow_always { + vec![ + ApprovalAction::Approve, + ApprovalAction::Always, + ApprovalAction::Deny, + ] + } else { + vec![ApprovalAction::Approve, ApprovalAction::Deny] + }; + + let num = options.len(); + let mut sel: usize = 0; + // Total lines: options + hint line + let total_lines = (num + 1) as u16; + + let render = |sel: usize| { + let mut w = io::stderr(); + let pipe = format!("{}│{}", fmt::accent(), fmt::reset()); + for (i, opt) in options.iter().enumerate() { + if i == sel { + let _ = write!(w, " {pipe} {}● {opt}{}\r\n", fmt::bold(), fmt::reset()); + } else { + let _ = write!(w, " {pipe} {}○ {opt}{}\r\n", fmt::dim(), fmt::reset()); + } + } + let _ = write!( + w, + " {}└{} {}↑↓ enter to select{}\r\n", + fmt::accent(), + fmt::reset(), + fmt::dim(), + fmt::reset() + ); + let _ = w.flush(); + }; + + let _ = terminal::enable_raw_mode(); + render(sel); + + let result = loop { + let Ok(evt) = event::read() else { break None }; + if let CtEvent::Key(key) = evt { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + CtKeyCode::Up | CtKeyCode::Char('k') => { + sel = if sel == 0 { num - 1 } else { sel - 1 }; + } + CtKeyCode::Down | CtKeyCode::Char('j') => { + sel = (sel + 1) % num; + } + CtKeyCode::Enter => break Some(options[sel].as_input()), + CtKeyCode::Char('y') | CtKeyCode::Char('Y') => break Some("y"), + CtKeyCode::Char('a') | CtKeyCode::Char('A') if allow_always => break Some("a"), + CtKeyCode::Char('n') | CtKeyCode::Char('N') => break Some("n"), + CtKeyCode::Esc => break None, + _ => continue, + } + // Redraw: move up, clear, render + let mut w = io::stderr(); + let _ = execute!(w, cursor::MoveUp(total_lines)); + let _ = execute!(w, terminal::Clear(ClearType::FromCursorDown)); + render(sel); + } + }; + + let _ = terminal::disable_raw_mode(); + + // Overwrite selector with the confirmed choice + let mut w = io::stderr(); + let _ = execute!(w, cursor::MoveUp(total_lines)); + let _ = execute!(w, terminal::Clear(ClearType::FromCursorDown)); + let (label, color) = if let Some(action) = result { + let l = options + .iter() + .find(|o| o.as_input() == action) + .unwrap_or(&options[0]); + let c = if action == "n" { + fmt::error() + } else { + fmt::success() + }; + (l.to_string(), c) + } else { + (ApprovalAction::Deny.to_string(), fmt::error()) + }; + let _ = writeln!( + w, + " {}└{} {color}● {label}{}", + fmt::accent(), + fmt::reset(), + fmt::reset() + ); + + result +} + /// Build a termimad skin with our color scheme. fn make_skin() -> MadSkin { let mut skin = MadSkin::default(); - skin.set_headers_fg(termimad::crossterm::style::Color::Yellow); - skin.bold.set_fg(termimad::crossterm::style::Color::White); - skin.italic - .set_fg(termimad::crossterm::style::Color::Magenta); - skin.inline_code - .set_fg(termimad::crossterm::style::Color::Green); - skin.code_block - .set_fg(termimad::crossterm::style::Color::Green); + skin.set_headers_fg(crossterm::style::Color::Yellow); + skin.bold.set_fg(crossterm::style::Color::White); + skin.italic.set_fg(crossterm::style::Color::Magenta); + skin.inline_code.set_fg(crossterm::style::Color::Green); + skin.code_block.set_fg(crossterm::style::Color::Green); skin.code_block.left_margin = 2; skin } +/// Truncate a string to `max_chars` using character boundaries. +/// +/// For strings longer than `max_chars`, shows the first half and last half +/// separated by `...` so both ends are visible. +fn smart_truncate(s: &str, max_chars: usize) -> Cow<'_, str> { + let char_count = s.chars().count(); + if char_count <= max_chars { + return Cow::Borrowed(s); + } + // Account for the 3-char "..." separator + let budget = max_chars.saturating_sub(3); + let head_len = budget / 2; + let tail_len = budget - head_len; + let head: String = s.chars().take(head_len).collect(); + let tail: String = s + .chars() + .skip(char_count.saturating_sub(tail_len)) + .collect(); + Cow::Owned(format!("{head}...{tail}")) +} + /// Format JSON params as `key: value` lines for the approval card. fn format_json_params(params: &serde_json::Value, indent: &str) -> String { + let max_val_len = fmt::term_width().saturating_sub(8); + match params { serde_json::Value::Object(map) => { let mut lines = Vec::new(); for (key, value) in map { let val_str = match value { serde_json::Value::String(s) => { - let display = if s.len() > 120 { &s[..120] } else { s }; - format!("\x1b[32m\"{display}\"\x1b[0m") + let display = smart_truncate(s, max_val_len); + format!("{}\"{display}\"{}", fmt::success(), fmt::reset()) } other => { let rendered = other.to_string(); - if rendered.len() > 120 { - format!("{}...", &rendered[..120]) - } else { - rendered - } + smart_truncate(&rendered, max_val_len).into_owned() } }; - lines.push(format!("{indent}\x1b[36m{key}\x1b[0m: {val_str}")); + lines.push(format!( + "{indent}{}{key}{}: {val_str}", + fmt::accent(), + fmt::reset() + )); } lines.join("\n") } other => { let pretty = serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()); - let truncated = if pretty.len() > 300 { - format!("{}...", &pretty[..300]) - } else { - pretty - }; + let truncated = smart_truncate(&pretty, 300); truncated .lines() - .map(|l| format!("{indent}\x1b[90m{l}\x1b[0m")) + .map(|l| format!("{indent}{}{l}{}", fmt::dim(), fmt::reset())) .collect::>() .join("\n") } @@ -210,6 +364,12 @@ pub struct ReplChannel { is_streaming: Arc, /// When true, the one-liner startup banner is suppressed (boot screen shown instead). suppress_banner: Arc, + /// Sender to inject messages into the agent loop (set after start()). + msg_tx: Arc>>>, + /// When true, the readline thread must yield stdin (approval selector or agent processing). + stdin_locked: Arc, + /// Number of transient status lines (Thinking) to erase on next output. + transient_lines: std::sync::atomic::AtomicU8, } impl ReplChannel { @@ -226,6 +386,9 @@ impl ReplChannel { debug_mode: Arc::new(AtomicBool::new(false)), is_streaming: Arc::new(AtomicBool::new(false)), suppress_banner: Arc::new(AtomicBool::new(false)), + msg_tx: Arc::new(Mutex::new(None)), + stdin_locked: Arc::new(AtomicBool::new(false)), + transient_lines: std::sync::atomic::AtomicU8::new(0), } } @@ -242,6 +405,9 @@ impl ReplChannel { debug_mode: Arc::new(AtomicBool::new(false)), is_streaming: Arc::new(AtomicBool::new(false)), suppress_banner: Arc::new(AtomicBool::new(false)), + msg_tx: Arc::new(Mutex::new(None)), + stdin_locked: Arc::new(AtomicBool::new(false)), + transient_lines: std::sync::atomic::AtomicU8::new(0), } } @@ -253,6 +419,17 @@ impl ReplChannel { fn is_debug(&self) -> bool { self.debug_mode.load(Ordering::Relaxed) } + + /// Erase transient status lines (Thinking indicators) from the terminal. + fn clear_transient(&self) { + use crossterm::{cursor, execute, terminal}; + let n = self.transient_lines.swap(0, Ordering::Relaxed); + if n > 0 { + let mut stderr = io::stderr(); + let _ = execute!(stderr, cursor::MoveUp(n as u16)); + let _ = execute!(stderr, terminal::Clear(terminal::ClearType::FromCursorDown)); + } + } } impl Default for ReplChannel { @@ -262,33 +439,30 @@ impl Default for ReplChannel { } fn print_help() { - // Bold white for section headers, bold cyan for commands, dim gray for descriptions - let h = "\x1b[1m"; // bold (section headers) - let c = "\x1b[1;36m"; // bold cyan (commands) - let d = "\x1b[90m"; // dim gray (descriptions) - let r = "\x1b[0m"; // reset + let h = fmt::bold(); + let c = fmt::bold_accent(); + let d = fmt::dim(); + let r = fmt::reset(); + let hi = fmt::hint(); println!(); println!(" {h}IronClaw REPL{r}"); println!(); - println!(" {h}Commands{r}"); - println!(" {c}/help{r} {d}show this help{r}"); - println!(" {c}/debug{r} {d}toggle verbose output{r}"); - println!(" {c}/quit{r} {c}/exit{r} {d}exit the repl{r}"); + println!(" {h}Quick start{r}"); + println!(" {c}/new{r} {hi}Start a new thread{r}"); + println!(" {c}/compact{r} {hi}Compress context window{r}"); + println!(" {c}/quit{r} {hi}Exit{r}"); println!(); - println!(" {h}Conversation{r}"); - println!(" {c}/undo{r} {d}undo the last turn{r}"); - println!(" {c}/redo{r} {d}redo an undone turn{r}"); - println!(" {c}/clear{r} {d}clear conversation{r}"); - println!(" {c}/compact{r} {d}compact context window{r}"); - println!(" {c}/new{r} {d}new conversation thread{r}"); - println!(" {c}/interrupt{r} {d}stop current operation{r}"); - println!(" {c}esc{r} {d}stop current operation{r}"); - println!(); - println!(" {h}Approval responses{r}"); - println!(" {c}yes{r} ({c}y{r}) {d}approve tool execution{r}"); - println!(" {c}no{r} ({c}n{r}) {d}deny tool execution{r}"); - println!(" {c}always{r} ({c}a{r}) {d}approve for this session{r}"); + println!(" {h}All commands{r}"); + println!( + " {d}Conversation{r} {c}/new{r} {c}/clear{r} {c}/compact{r} {c}/undo{r} {c}/redo{r} {c}/summarize{r} {c}/suggest{r}" + ); + println!(" {d}Threads{r} {c}/thread{r} {c}/resume{r} {c}/list{r}"); + println!(" {d}Execution{r} {c}/interrupt{r} {d}(esc){r} {c}/cancel{r}"); + println!( + " {d}System{r} {c}/tools{r} {c}/model{r} {c}/version{r} {c}/status{r} {c}/debug{r} {c}/heartbeat{r}" + ); + println!(" {d}Session{r} {c}/help{r} {c}/quit{r}"); println!(); } @@ -305,10 +479,15 @@ impl Channel for ReplChannel { async fn start(&self) -> Result { let (tx, rx) = mpsc::channel(32); + // Store tx so send_status can inject approval responses directly + if let Ok(mut guard) = self.msg_tx.lock() { + *guard = Some(tx.clone()); + } let single_message = self.single_message.clone(); let user_id = self.user_id.clone(); let debug_mode = Arc::clone(&self.debug_mode); let suppress_banner = Arc::clone(&self.suppress_banner); + let stdin_locked = Arc::clone(&self.stdin_locked); let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false)); std::thread::spawn(move || { @@ -357,18 +536,33 @@ impl Channel for ReplChannel { let _ = rl.load_history(&hist_path); if !suppress_banner.load(Ordering::Relaxed) { - println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit"); + println!( + "{}IronClaw{} /help for commands, /quit to exit", + fmt::bold(), + fmt::reset() + ); println!(); } loop { + // Yield stdin while approval selector or agent processing locks it + while stdin_locked.load(Ordering::Relaxed) { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + let prompt = if debug_mode.load(Ordering::Relaxed) { - "\x1b[33m[debug]\x1b[0m \x1b[1;36m\u{203A}\x1b[0m " + format!( + "{}[debug]{} {}\u{203A}{} ", + fmt::warning(), + fmt::reset(), + fmt::bold_accent(), + fmt::reset() + ) } else { - "\x1b[1;36m\u{203A}\x1b[0m " + format!("{}\u{203A}{} ", fmt::bold_accent(), fmt::reset()) }; - match rl.readline(prompt) { + match rl.readline(&prompt) { Ok(line) => { let line = line.trim(); if line.is_empty() { @@ -394,9 +588,9 @@ impl Channel for ReplChannel { let current = debug_mode.load(Ordering::Relaxed); debug_mode.store(!current, Ordering::Relaxed); if !current { - println!("\x1b[90mdebug mode on\x1b[0m"); + println!("{}debug mode on{}", fmt::dim(), fmt::reset()); } else { - println!("\x1b[90mdebug mode off\x1b[0m"); + println!("{}debug mode off{}", fmt::dim(), fmt::reset()); } continue; } @@ -405,7 +599,11 @@ impl Channel for ReplChannel { let msg = IncomingMessage::new("repl", &user_id, line).with_timezone(&sys_tz); + // Lock stdin before sending so readline doesn't restart + // while the agent is processing (approval selector needs stdin) + stdin_locked.store(true, Ordering::Relaxed); if tx.blocking_send(msg).is_err() { + stdin_locked.store(false, Ordering::Relaxed); break; } } @@ -456,21 +654,23 @@ impl Channel for ReplChannel { _msg: &IncomingMessage, response: OutgoingResponse, ) -> Result<(), ChannelError> { - let width = crossterm::terminal::size() - .map(|(w, _)| w as usize) - .unwrap_or(80); + let width = fmt::term_width(); // If we were streaming, the content was already printed via StreamChunk. // Just finish the line and reset. if self.is_streaming.swap(false, Ordering::Relaxed) { println!(); println!(); + self.stdin_locked.store(false, Ordering::Relaxed); return Ok(()); } + // Clear any leftover thinking indicators + self.clear_transient(); + // Dim separator line before the response let sep_width = width.min(80); - eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width)); + eprintln!("{}", fmt::separator(sep_width)); // Render markdown let skin = make_skin(); @@ -478,6 +678,8 @@ impl Channel for ReplChannel { print!("{text}"); println!(); + // Unlock stdin so readline can resume + self.stdin_locked.store(false, Ordering::Relaxed); Ok(()) } @@ -490,31 +692,34 @@ impl Channel for ReplChannel { match status { StatusUpdate::Thinking(msg) => { + self.clear_transient(); let display = truncate_for_preview(&msg, CLI_STATUS_MAX); - eprintln!(" \x1b[90m\u{25CB} {display}\x1b[0m"); + eprintln!(" {}\u{25CB} {display}{}", fmt::dim(), fmt::reset()); + self.transient_lines.store(1, Ordering::Relaxed); } StatusUpdate::ToolStarted { name } => { - eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m"); + self.clear_transient(); + eprintln!(" {}\u{25CB} {name}{}", fmt::dim(), fmt::reset()); + self.transient_lines.store(1, Ordering::Relaxed); } StatusUpdate::ToolCompleted { name, success, .. } => { + self.clear_transient(); if success { - eprintln!(" \x1b[32m\u{25CF} {name}\x1b[0m"); + eprintln!(" {}\u{25CF} {name}{}", fmt::success(), fmt::reset()); } else { - eprintln!(" \x1b[31m\u{2717} {name} (failed)\x1b[0m"); + eprintln!(" {}\u{2717} {name} (failed){}", fmt::error(), fmt::reset()); } } StatusUpdate::ToolResult { name: _, preview } => { let display = truncate_for_preview(&preview, CLI_TOOL_RESULT_MAX); - eprintln!(" \x1b[90m{display}\x1b[0m"); + eprintln!(" {}{display}{}", fmt::dim(), fmt::reset()); } StatusUpdate::StreamChunk(chunk) => { // Print separator on the false-to-true transition if !self.is_streaming.swap(true, Ordering::Relaxed) { - let width = crossterm::terminal::size() - .map(|(w, _)| w as usize) - .unwrap_or(80); - let sep_width = width.min(80); - eprintln!("\x1b[90m{}\x1b[0m", "\u{2500}".repeat(sep_width)); + self.clear_transient(); + let sep_width = fmt::term_width().min(80); + eprintln!("{}", fmt::separator(sep_width)); } print!("{chunk}"); let _ = io::stdout().flush(); @@ -525,73 +730,67 @@ impl Channel for ReplChannel { browse_url, } => { eprintln!( - " \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m" + " {}[job]{} {title} {}({job_id}){} {}{browse_url}{}", + fmt::accent(), + fmt::reset(), + fmt::dim(), + fmt::reset(), + fmt::link(), + fmt::reset() ); } StatusUpdate::Status(msg) => { if debug || msg.contains("approval") || msg.contains("Approval") { let display = truncate_for_preview(&msg, CLI_STATUS_MAX); - eprintln!(" \x1b[90m{display}\x1b[0m"); + eprintln!(" {}{display}{}", fmt::dim(), fmt::reset()); } } StatusUpdate::ApprovalNeeded { - request_id, + request_id: _, tool_name, - description, + description: _, parameters, allow_always, } => { - let term_width = crossterm::terminal::size() - .map(|(w, _)| w as usize) - .unwrap_or(80); - let box_width = (term_width.saturating_sub(4)).clamp(40, 60); + self.clear_transient(); + let pipe = format!("{}│{}", fmt::accent(), fmt::reset()); - // Short request ID for the bottom border - let short_id = if request_id.len() > 8 { - &request_id[..8] - } else { - &request_id - }; - - // Top border: ┌ tool_name requires approval ─── - let top_label = format!(" {tool_name} requires approval "); - let top_fill = box_width.saturating_sub(top_label.len() + 1); - let top_border = format!( - "\u{250C}\x1b[33m{top_label}\x1b[0m{}", - "\u{2500}".repeat(top_fill) + // Header: ◆ tool requires approval + eprintln!(); + eprintln!( + " {}\u{25C6} {}{tool_name}{} requires approval", + fmt::accent(), + fmt::bold(), + fmt::reset() ); - // Bottom border: └─ short_id ───── - let bot_label = format!(" {short_id} "); - let bot_fill = box_width.saturating_sub(bot_label.len() + 2); - let bot_border = format!( - "\u{2514}\u{2500}\x1b[90m{bot_label}\x1b[0m{}", - "\u{2500}".repeat(bot_fill) - ); - - eprintln!(); - eprintln!(" {top_border}"); - eprintln!(" \u{2502} \x1b[90m{description}\x1b[0m"); - eprintln!(" \u{2502}"); - - // Params - let param_lines = format_json_params(¶meters, " \u{2502} "); - // The format_json_params already includes the indent prefix - // but we need to handle the case where each line already starts with it - for line in param_lines.lines() { - eprintln!("{line}"); + // Params: │ key value + let param_lines = format_json_params(¶meters, &format!(" {pipe} ")); + if !param_lines.is_empty() { + eprintln!(" {pipe}"); + for line in param_lines.lines() { + eprintln!("{line}"); + } } - - eprintln!(" \u{2502}"); - if allow_always { - eprintln!( - " \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)" - ); - } else { - eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)"); - } - eprintln!(" {bot_border}"); - eprintln!(); + eprintln!(" {pipe}"); + // Run interactive selector directly from send_status + // stdin is already locked by Thinking/ToolStarted, so the + // readline thread is not competing for stdin. + let msg_tx = Arc::clone(&self.msg_tx); + let user_id = self.user_id.clone(); + let lock_flag = Arc::clone(&self.stdin_locked); + tokio::task::spawn_blocking(move || { + let action = run_approval_selector(allow_always).unwrap_or("n"); + // Unlock stdin so readline can resume after approval + lock_flag.store(false, Ordering::Relaxed); + let Ok(guard) = msg_tx.lock() else { + return; + }; + if let Some(tx) = guard.as_ref() { + let msg = IncomingMessage::new("repl", &user_id, action); + let _ = tx.blocking_send(msg); + } + }); } StatusUpdate::AuthRequired { extension_name, @@ -600,12 +799,16 @@ impl Channel for ReplChannel { .. } => { eprintln!(); - eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m"); + eprintln!( + "{} Authentication required for {extension_name}{}", + fmt::warning(), + fmt::reset() + ); if let Some(ref instr) = instructions { eprintln!(" {instr}"); } if let Some(ref url) = setup_url { - eprintln!(" \x1b[4m{url}\x1b[0m"); + eprintln!(" {}{url}{}", fmt::link(), fmt::reset()); } eprintln!(); } @@ -615,21 +818,32 @@ impl Channel for ReplChannel { message, } => { if success { - eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m"); + eprintln!( + "{} {extension_name}: {message}{}", + fmt::success(), + fmt::reset() + ); } else { - eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m"); + eprintln!( + "{} {extension_name}: {message}{}", + fmt::error(), + fmt::reset() + ); } } StatusUpdate::ImageGenerated { path, .. } => { if let Some(ref p) = path { - eprintln!("\x1b[36m [image] {p}\x1b[0m"); + eprintln!("{} [image] {p}{}", fmt::accent(), fmt::reset()); } else { - eprintln!("\x1b[36m [image generated]\x1b[0m"); + eprintln!("{} [image generated]{}", fmt::accent(), fmt::reset()); } } StatusUpdate::Suggestions { .. } => { // Suggestions are only rendered by the web gateway } + StatusUpdate::TurnCost { .. } => { + // Cost display is handled by the TUI channel + } } Ok(()) } @@ -640,11 +854,9 @@ impl Channel for ReplChannel { response: OutgoingResponse, ) -> Result<(), ChannelError> { let skin = make_skin(); - let width = crossterm::terminal::size() - .map(|(w, _)| w as usize) - .unwrap_or(80); + let width = fmt::term_width(); - eprintln!("\x1b[34m\u{25CF}\x1b[0m notification"); + eprintln!("{}\u{25CF}{} notification", fmt::accent(), fmt::reset()); let text = termimad::FmtText::from(&skin, &response.content, Some(width)); eprint!("{text}"); eprintln!(); diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 2b9703dc..7f0bb8fb 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -117,7 +117,7 @@ async fn register_channel( wasm_router: &Arc, ) -> (String, Box) { let channel_name = loaded.name().to_string(); - tracing::info!("Loaded WASM channel: {}", channel_name); + tracing::debug!("Loaded WASM channel: {}", channel_name); let owner_actor_id = config .channels .wasm_channel_owner_ids diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index be7768d0..65e4de88 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -3059,8 +3059,8 @@ fn status_to_wit( }, metadata_json, }, - // Suggestions are web-gateway-only; skip for WASM channels - StatusUpdate::Suggestions { .. } => return None, + // Suggestions and turn cost are web-gateway-only; skip for WASM channels + StatusUpdate::Suggestions { .. } | StatusUpdate::TurnCost { .. } => return None, }) } diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 1fdb4455..f40834cb 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -415,6 +415,16 @@ impl Channel for GatewayChannel { suggestions, thread_id, }, + StatusUpdate::TurnCost { + input_tokens, + output_tokens, + cost_usd, + } => SseEvent::TurnCost { + input_tokens, + output_tokens, + cost_usd, + thread_id, + }, }; self.state.sse.broadcast(event); diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs index 306576b9..7b952346 100644 --- a/src/channels/web/sse.rs +++ b/src/channels/web/sse.rs @@ -144,6 +144,7 @@ impl SseManager { SseEvent::Heartbeat => "heartbeat", SseEvent::ImageGenerated { .. } => "image_generated", SseEvent::Suggestions { .. } => "suggestions", + SseEvent::TurnCost { .. } => "turn_cost", SseEvent::ExtensionStatus { .. } => "extension_status", }; Ok(Event::default().event(event_type).data(data)) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 075aa7cc..ddcfc828 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -61,8 +61,16 @@ if (mql.addEventListener) { mql.addListener(onSchemeChange); } -// Bind theme toggle button (CSP-compliant — no inline onclick). +// Bind theme toggle buttons (CSP-compliant — no inline onclick). document.getElementById('theme-toggle').addEventListener('click', toggleTheme); +document.getElementById('settings-theme-toggle')?.addEventListener('click', () => { + toggleTheme(); + const btn = document.getElementById('settings-theme-toggle'); + if (btn) { + const mode = localStorage.getItem('ironclaw-theme') || 'system'; + btn.textContent = 'Theme: ' + mode.charAt(0).toUpperCase() + mode.slice(1); + } +}); let token = ''; let eventSource = null; @@ -87,6 +95,19 @@ let authFlowPending = false; let _ghostSuggestion = ''; let currentSettingsSubtab = 'inference'; +// --- Streaming Debounce State --- +let _streamBuffer = ''; +let _streamDebounceTimer = null; +const STREAM_DEBOUNCE_MS = 50; + +// --- Connection Status Banner State --- +let _connectionLostTimer = null; +let _connectionLostAt = null; +let _reconnectAttempts = 0; + +// --- Send Cooldown State --- +let _sendCooldown = false; + // --- Slash Commands --- const SLASH_COMMANDS = [ @@ -126,12 +147,36 @@ function authenticate() { return; } + // Loading state for Connect button + const connectBtn = document.getElementById('auth-connect-btn'); + if (connectBtn) { + connectBtn.disabled = true; + connectBtn.textContent = 'Connecting...'; + } + // Test the token against the health-ish endpoint (chat/threads requires auth) apiFetch('/api/chat/threads') .then(() => { sessionStorage.setItem('ironclaw_token', token); - document.getElementById('auth-screen').style.display = 'none'; - document.getElementById('app').style.display = 'flex'; + const authScreen = document.getElementById('auth-screen'); + const app = document.getElementById('app'); + // Cross-fade: fade out auth screen, then show app + if (authScreen) authScreen.style.opacity = '0'; + // Show app container (invisible — opacity:0 in CSS) so layout computes + app.style.display = 'flex'; + // Position tab indicator instantly (no transition) before fade-in + const indicator = document.getElementById('tab-indicator'); + if (indicator) indicator.style.transition = 'none'; + updateTabIndicator(); + // Force layout so the instant position is applied, then restore transition + if (indicator) { + void indicator.offsetLeft; + indicator.style.transition = ''; + } + // Now fade in + app.classList.add('visible'); + // Hide auth screen after fade-out transition completes + setTimeout(() => { if (authScreen) authScreen.style.display = 'none'; }, 300); // Strip token and log_level from URL so they're not visible in the address bar const cleaned = new URL(window.location); const urlLogLevel = cleaned.searchParams.get('log_level'); @@ -155,8 +200,14 @@ function authenticate() { .catch(() => { sessionStorage.removeItem('ironclaw_token'); document.getElementById('auth-screen').style.display = ''; + document.getElementById('auth-screen').style.opacity = ''; document.getElementById('app').style.display = 'none'; document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid'); + // Reset Connect button on error + if (connectBtn) { + connectBtn.disabled = false; + connectBtn.textContent = 'Connect'; + } }); } @@ -164,29 +215,8 @@ document.getElementById('token-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') authenticate(); }); -// --- Static element event bindings (CSP-compliant, no inline handlers) --- -document.getElementById('auth-connect-btn').addEventListener('click', () => authenticate()); -document.getElementById('restart-overlay').addEventListener('click', () => cancelRestart()); -document.getElementById('restart-close-btn').addEventListener('click', () => cancelRestart()); -document.getElementById('restart-cancel-btn').addEventListener('click', () => cancelRestart()); -document.getElementById('restart-confirm-btn').addEventListener('click', () => confirmRestart()); -document.getElementById('language-btn').addEventListener('click', () => toggleLanguageMenu()); -// Language option clicks handled by delegated data-action="switch-language" handler. -document.getElementById('restart-btn').addEventListener('click', () => triggerRestart()); -document.getElementById('thread-new-btn').addEventListener('click', () => createNewThread()); -document.getElementById('thread-toggle-btn').addEventListener('click', () => toggleThreadSidebar()); -document.getElementById('assistant-thread').addEventListener('click', () => switchToAssistant()); -document.getElementById('send-btn').addEventListener('click', () => sendMessage()); -document.getElementById('memory-edit-btn').addEventListener('click', () => startMemoryEdit()); -document.getElementById('memory-save-btn').addEventListener('click', () => saveMemoryEdit()); -document.getElementById('memory-cancel-btn').addEventListener('click', () => cancelMemoryEdit()); -document.getElementById('logs-server-level').addEventListener('change', function() { setServerLogLevel(this.value); }); -document.getElementById('logs-pause-btn').addEventListener('click', () => toggleLogsPause()); -document.getElementById('logs-clear-btn').addEventListener('click', () => clearLogs()); -document.getElementById('wasm-install-btn').addEventListener('click', () => installWasmExtension()); -document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer()); -document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub()); -document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm()); +// Note: main event listener registration is at the bottom of this file (search +// "Event Listener Registration"). Do NOT add duplicate listeners here. // Auto-authenticate from URL param or saved session (function autoAuth() { @@ -221,7 +251,9 @@ function apiFetch(path, options) { return fetch(path, opts).then((res) => { if (!res.ok) { return res.text().then(function(body) { - throw new Error(body || (res.status + ' ' + res.statusText)); + const err = new Error(body || (res.status + ' ' + res.statusText)); + err.status = res.status; + throw err; }); } if (res.status === 204) return null; @@ -327,6 +359,25 @@ function connectSSE() { eventSource.onopen = () => { document.getElementById('sse-dot').classList.remove('disconnected'); document.getElementById('sse-status').textContent = I18n.t('status.connected'); + _reconnectAttempts = 0; + + // Dismiss connection-lost banner and show reconnected flash + if (_connectionLostTimer) { + clearTimeout(_connectionLostTimer); + _connectionLostTimer = null; + } + const lostBanner = document.getElementById('connection-banner'); + if (lostBanner) { + const wasDisconnectedLong = _connectionLostAt && (Date.now() - _connectionLostAt > 10000); + lostBanner.textContent = 'Reconnected'; + lostBanner.className = 'connection-banner connection-banner-success'; + setTimeout(() => { lostBanner.remove(); }, 2000); + _connectionLostAt = null; + // If disconnected >10s, reload chat history to catch missed messages + if (wasDisconnectedLong && currentThreadId) { + loadHistory(); + } + } // If we were restarting, close the modal and reset button now that server is back if (isRestarting) { @@ -347,8 +398,28 @@ function connectSSE() { }; eventSource.onerror = () => { + _reconnectAttempts++; document.getElementById('sse-dot').classList.add('disconnected'); document.getElementById('sse-status').textContent = I18n.t('status.reconnecting'); + + // Update existing banner with attempt count + const existingBanner = document.getElementById('connection-banner'); + if (existingBanner && existingBanner.classList.contains('connection-banner-warning')) { + existingBanner.textContent = 'Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')'; + } + + // Start connection-lost banner timer (3s delay) + if (!_connectionLostTimer && !existingBanner) { + _connectionLostAt = _connectionLostAt || Date.now(); + _connectionLostTimer = setTimeout(() => { + _connectionLostTimer = null; + // Only show if still disconnected + const dot = document.getElementById('sse-dot'); + if (dot?.classList.contains('disconnected')) { + showConnectionBanner('Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')', 'warning'); + } + }, 3000); + } }; eventSource.addEventListener('response', (e) => { @@ -360,6 +431,19 @@ function connectSSE() { } return; } + // Flush any remaining streaming buffer + if (_streamDebounceTimer) { + clearInterval(_streamDebounceTimer); + _streamDebounceTimer = null; + } + if (_streamBuffer) { + appendToLastAssistant(_streamBuffer); + _streamBuffer = ''; + } + // Remove streaming attribute from active assistant message + const streamingMsg = document.querySelector('.message.assistant[data-streaming="true"]'); + if (streamingMsg) streamingMsg.removeAttribute('data-streaming'); + finalizeActivityGroup(); addMessage('assistant', data.content); enableChatInput(); @@ -417,7 +501,31 @@ function connectSSE() { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; finalizeActivityGroup(); - appendToLastAssistant(data.content); + + // Mark the active assistant message as streaming + const container = document.getElementById('chat-messages'); + let lastAssistant = container.querySelector('.message.assistant:last-of-type'); + if (!lastAssistant) { + addMessage('assistant', ''); + lastAssistant = container.querySelector('.message.assistant:last-of-type'); + } + if (lastAssistant) lastAssistant.setAttribute('data-streaming', 'true'); + + // Accumulate chunks and debounce rendering at 50ms intervals + _streamBuffer += data.content; + // Force flush when buffer exceeds 10K chars to prevent memory buildup + if (_streamBuffer.length > 10000) { + appendToLastAssistant(_streamBuffer); + _streamBuffer = ''; + } + if (!_streamDebounceTimer) { + _streamDebounceTimer = setInterval(() => { + if (_streamBuffer) { + appendToLastAssistant(_streamBuffer); + _streamBuffer = ''; + } + }, STREAM_DEBOUNCE_MS); + } }); eventSource.addEventListener('status', (e) => { @@ -487,6 +595,22 @@ function connectSSE() { } }); + eventSource.addEventListener('turn_cost', (e) => { + const event = JSON.parse(e.data); + if (!isCurrentThread(event.thread_id)) return; + // Add cost badge below last assistant message + const messages = document.querySelectorAll('.message.assistant'); + const lastMsg = messages[messages.length - 1]; + const tokens = (event.input_tokens || 0) + (event.output_tokens || 0); + if (lastMsg && tokens > 0) { + const badge = document.createElement('div'); + badge.className = 'turn-cost-badge'; + const cost = event.cost_usd ? ' \u00b7 ' + event.cost_usd : ''; + badge.textContent = tokens.toLocaleString() + ' tokens' + cost; + lastMsg.appendChild(badge); + } + }); + // Job event listeners (activity stream for all sandbox jobs) const jobEventTypes = [ 'job_message', 'job_tool_use', 'job_tool_result', @@ -578,6 +702,7 @@ function clearSuggestionChips() { function sendMessage() { clearSuggestionChips(); + removeWelcomeCard(); const input = document.getElementById('chat-input'); if (authFlowPending) { showToast('Complete the auth step before sending chat messages.', 'info'); @@ -589,10 +714,11 @@ function sendMessage() { console.warn('sendMessage: no thread selected, ignoring'); return; } + if (_sendCooldown) return; const content = input.value.trim(); if (!content && stagedImages.length === 0) return; - addMessage('user', content || '(images attached)'); + const userMsg = addMessage('user', content || '(images attached)'); input.value = ''; autoResizeTextarea(input); input.focus(); @@ -608,7 +734,33 @@ function sendMessage() { method: 'POST', body: body, }).catch((err) => { - addMessage('system', 'Failed to send: ' + err.message); + // Handle rate limiting (429) + if (err.status === 429) { + showToast('Rate limited. Please wait.', 'error'); + _sendCooldown = true; + const sendBtn = document.getElementById('send-btn'); + if (sendBtn) sendBtn.disabled = true; + setTimeout(() => { + _sendCooldown = false; + if (sendBtn) sendBtn.disabled = false; + }, 2000); + } + // Keep the user message in DOM, add a retry link + if (userMsg) { + userMsg.classList.add('send-failed'); + userMsg.style.borderStyle = 'dashed'; + const retryLink = document.createElement('a'); + retryLink.className = 'retry-link'; + retryLink.href = '#'; + retryLink.textContent = 'Retry'; + retryLink.addEventListener('click', (e) => { + e.preventDefault(); + if (userMsg.parentNode) userMsg.parentNode.removeChild(userMsg); + input.value = content; + sendMessage(); + }); + userMsg.appendChild(retryLink); + } }); } @@ -887,11 +1039,36 @@ function copyMessage(btn) { }); } +let _lastMessageDate = null; + +function maybeInsertTimeSeparator(container, timestamp) { + const date = timestamp ? new Date(timestamp) : new Date(); + const dateStr = date.toDateString(); + if (_lastMessageDate === dateStr) return; + _lastMessageDate = dateStr; + + const now = new Date(); + const today = now.toDateString(); + const yesterday = new Date(now.getTime() - 86400000).toDateString(); + + let label; + if (dateStr === today) label = 'Today'; + else if (dateStr === yesterday) label = 'Yesterday'; + else label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + + const sep = document.createElement('div'); + sep.className = 'time-separator'; + sep.textContent = label; + container.appendChild(sep); +} + function addMessage(role, content) { const container = document.getElementById('chat-messages'); + maybeInsertTimeSeparator(container); const div = createMessageElement(role, content); container.appendChild(div); container.scrollTop = container.scrollHeight; + return div; } function appendToLastAssistant(chunk) { @@ -905,6 +1082,14 @@ function appendToLastAssistant(chunk) { const content = last.querySelector('.message-content'); if (content) { content.innerHTML = renderMarkdown(raw); + // Syntax highlighting for code blocks + if (typeof hljs !== 'undefined') { + requestAnimationFrame(() => { + content.querySelectorAll('pre code').forEach(block => { + hljs.highlightElement(block); + }); + }); + } } container.scrollTop = container.scrollHeight; } else { @@ -992,16 +1177,14 @@ function addToolCard(name) { const body = document.createElement('div'); body.className = 'activity-tool-body'; - body.style.display = 'none'; const output = document.createElement('pre'); output.className = 'activity-tool-output'; body.appendChild(output); header.addEventListener('click', () => { - const isOpen = body.style.display !== 'none'; - body.style.display = isOpen ? 'none' : 'block'; - chevron.classList.toggle('expanded', !isOpen); + body.classList.toggle('expanded'); + chevron.classList.toggle('expanded', body.classList.contains('expanded')); }); card.appendChild(header); @@ -1060,7 +1243,7 @@ function completeToolCard(name, success, error, parameters) { // Auto-expand so the error is immediately visible const body = entry.card.querySelector('.activity-tool-body'); const chevron = entry.card.querySelector('.activity-tool-chevron'); - if (body) body.style.display = 'block'; + if (body) body.classList.add('expanded'); if (chevron) chevron.classList.add('expanded'); } } @@ -1547,6 +1730,13 @@ function loadHistory(before) { const isPaginating = !!before; if (isPaginating) loadingOlder = true; + // Show skeleton while loading (only for fresh loads) + if (!isPaginating) { + const chatContainer = document.getElementById('chat-messages'); + chatContainer.innerHTML = ''; + chatContainer.appendChild(renderSkeleton('message', 3)); + } + apiFetch(historyUrl).then((data) => { const container = document.getElementById('chat-messages'); @@ -1564,6 +1754,10 @@ function loadHistory(before) { addMessage('assistant', turn.response); } } + // Show welcome card when history is empty + if (data.turns.length === 0) { + showWelcomeCard(); + } // Show processing indicator if the last turn is still in-progress var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null; if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') { @@ -1610,6 +1804,30 @@ function createMessageElement(role, content) { const div = document.createElement('div'); div.className = 'message ' + role; + const ts = document.createElement('span'); + ts.className = 'message-timestamp'; + ts.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + div.appendChild(ts); + + // Message content + const contentEl = document.createElement('div'); + contentEl.className = 'message-content'; + if (role === 'user' || role === 'system') { + contentEl.textContent = content; + } else { + div.setAttribute('data-raw', content); + contentEl.innerHTML = renderMarkdown(content); + // Syntax highlighting for code blocks + if (typeof hljs !== 'undefined') { + requestAnimationFrame(() => { + contentEl.querySelectorAll('pre code').forEach(block => { + hljs.highlightElement(block); + }); + }); + } + } + div.appendChild(contentEl); + if (role === 'assistant' || role === 'user') { div.classList.add('has-copy'); div.setAttribute('data-copy-text', content); @@ -1625,15 +1843,6 @@ function createMessageElement(role, content) { div.appendChild(copyBtn); } - const body = document.createElement('div'); - body.className = 'message-content'; - if (role === 'user' || role === 'system') { - body.textContent = content; - } else { - div.setAttribute('data-raw', content); - body.innerHTML = renderMarkdown(content); - } - div.appendChild(body); return div; } @@ -1731,6 +1940,13 @@ function debouncedLoadThreads() { } function loadThreads() { + // Show skeleton while loading + const threadListEl = document.getElementById('thread-list'); + if (threadListEl && threadListEl.children.length === 0) { + threadListEl.innerHTML = ''; + threadListEl.appendChild(renderSkeleton('row', 4)); + } + apiFetch('/api/chat/threads').then((data) => { // Pinned assistant thread if (data.assistant_thread) { @@ -1828,6 +2044,11 @@ function switchToAssistant() { oldestTimestamp = null; loadHistory(); loadThreads(); + if (window.innerWidth <= 768) { + const sidebar = document.getElementById('thread-sidebar'); + sidebar.classList.remove('expanded-mobile'); + document.getElementById('thread-toggle-btn').innerHTML = '»'; + } } function switchThread(threadId) { @@ -1839,12 +2060,18 @@ function switchThread(threadId) { oldestTimestamp = null; loadHistory(); loadThreads(); + if (window.innerWidth <= 768) { + const sidebar = document.getElementById('thread-sidebar'); + sidebar.classList.remove('expanded-mobile'); + document.getElementById('thread-toggle-btn').innerHTML = '»'; + } } function createNewThread() { apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => { currentThreadId = data.id || null; document.getElementById('chat-messages').innerHTML = ''; + showWelcomeCard(); loadThreads(); }).catch((err) => { showToast('Failed to create thread: ' + err.message, 'error'); @@ -1853,9 +2080,17 @@ function createNewThread() { function toggleThreadSidebar() { const sidebar = document.getElementById('thread-sidebar'); - sidebar.classList.toggle('collapsed'); + const isMobile = window.innerWidth <= 768; + if (isMobile) { + sidebar.classList.toggle('expanded-mobile'); + } else { + sidebar.classList.toggle('collapsed'); + } const btn = document.getElementById('thread-toggle-btn'); - btn.innerHTML = sidebar.classList.contains('collapsed') ? '»' : '«'; + const isOpen = isMobile + ? sidebar.classList.contains('expanded-mobile') + : !sidebar.classList.contains('collapsed'); + btn.innerHTML = isOpen ? '«' : '»'; } // Chat input auto-resize and keyboard handling @@ -1922,6 +2157,10 @@ chatInput.addEventListener('input', () => { ghost.style.display = 'block'; wrapper.classList.add('has-ghost'); } + const sendBtn = document.getElementById('send-btn'); + if (sendBtn) { + sendBtn.classList.toggle('active', chatInput.value.trim().length > 0); + } }); chatInput.addEventListener('blur', () => { // Small delay so mousedown on autocomplete item fires first @@ -1943,8 +2182,13 @@ document.getElementById('chat-messages').addEventListener('scroll', function () }); function autoResizeTextarea(el) { + const prev = el.offsetHeight; el.style.height = 'auto'; - el.style.height = Math.min(el.scrollHeight, 120) + 'px'; + const target = Math.min(el.scrollHeight, 120); + el.style.height = prev + 'px'; + requestAnimationFrame(() => { + el.style.height = target + 'px'; + }); } // --- Tabs --- @@ -1964,6 +2208,7 @@ function switchTab(tab) { document.querySelectorAll('.tab-panel').forEach((p) => { p.classList.toggle('active', p.id === 'tab-' + tab); }); + applyAriaAttributes(); if (tab === 'memory') loadMemoryTree(); if (tab === 'jobs') loadJobs(); @@ -1974,8 +2219,26 @@ function switchTab(tab) { } else { stopPairingPoll(); } + updateTabIndicator(); } +function updateTabIndicator() { + const indicator = document.getElementById('tab-indicator'); + if (!indicator) return; + const activeBtn = document.querySelector('.tab-bar button[data-tab].active'); + if (!activeBtn) { + indicator.style.width = '0'; + return; + } + const bar = activeBtn.closest('.tab-bar'); + const barRect = bar.getBoundingClientRect(); + const btnRect = activeBtn.getBoundingClientRect(); + indicator.style.left = (btnRect.left - barRect.left) + 'px'; + indicator.style.width = btnRect.width + 'px'; +} + +window.addEventListener('resize', updateTabIndicator); + // --- Memory (filesystem tree) --- let memorySearchTimeout = null; @@ -4694,13 +4957,27 @@ document.addEventListener('keydown', (e) => { return; } - // Escape: close autocomplete, job detail, or blur input + // Mod+/: toggle shortcuts overlay + if (mod && e.key === '/') { + e.preventDefault(); + toggleShortcutsOverlay(); + return; + } + + // Escape: close modals, autocomplete, job detail, or blur input if (e.key === 'Escape') { const acEl = document.getElementById('slash-autocomplete'); if (acEl && acEl.style.display !== 'none') { hideSlashAutocomplete(); return; } + // Close shortcuts overlay if open + const shortcutsOverlay = document.getElementById('shortcuts-overlay'); + if (shortcutsOverlay?.style.display === 'flex') { + shortcutsOverlay.style.display = 'none'; + return; + } + closeModals(); if (currentJobId) { closeJobDetail(); } else if (inInput) { @@ -4732,9 +5009,17 @@ function switchSettingsSubtab(subtab) { searchInput.value = ''; searchInput.dispatchEvent(new Event('input')); } + // On mobile, drill into detail view + if (window.innerWidth <= 768) { + document.querySelector('.settings-layout').classList.add('settings-detail-active'); + } loadSettingsSubtab(subtab); } +function settingsBack() { + document.querySelector('.settings-layout').classList.remove('settings-detail-active'); +} + function loadSettingsSubtab(subtab) { if (subtab === 'inference') loadInferenceSettings(); else if (subtab === 'agent') loadAgentSettings(); @@ -4870,6 +5155,19 @@ function renderCardsSkeleton(count) { return html; } +function renderSkeleton(type, count) { + count = count || 3; + var container = document.createElement('div'); + container.className = 'skeleton-container'; + for (var i = 0; i < count; i++) { + var el = document.createElement('div'); + el.className = 'skeleton-' + type; + el.innerHTML = '
'; + container.appendChild(el); + } + return container; +} + function loadInferenceSettings() { var container = document.getElementById('settings-inference-content'); container.innerHTML = renderSettingsSkeleton(6); @@ -4888,11 +5186,13 @@ function loadInferenceSettings() { }; // Inject available model IDs as suggestions for the selected_model field var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean); - var llmGroup = INFERENCE_SETTINGS[0]; - for (var i = 0; i < llmGroup.settings.length; i++) { - if (llmGroup.settings[i].key === 'selected_model') { - llmGroup.settings[i].suggestions = modelIds; - break; + if (modelIds.length > 0) { + var llmGroup = INFERENCE_SETTINGS[0]; + for (var i = 0; i < llmGroup.settings.length; i++) { + if (llmGroup.settings[i].key === 'selected_model') { + llmGroup.settings[i].suggestions = modelIds; + break; + } } } container.innerHTML = ''; @@ -5020,34 +5320,30 @@ function renderStructuredSettingsRow(def, value, activeValue) { var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault')); if (def.type === 'boolean') { - var boolSel = document.createElement('select'); - boolSel.className = 'settings-select'; - boolSel.setAttribute('data-setting-key', def.key); - boolSel.setAttribute('aria-label', ariaLabel); - var boolDefault = document.createElement('option'); - boolDefault.value = ''; - boolDefault.textContent = activeValue !== undefined && activeValue !== null - ? '\u2014 ' + I18n.t('settings.envValue', { value: String(activeValue) }) + ' \u2014' - : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014'; - if (value === null || value === undefined) boolDefault.selected = true; - boolSel.appendChild(boolDefault); - var boolOn = document.createElement('option'); - boolOn.value = 'true'; - boolOn.textContent = I18n.t('settings.on'); - if (value === true) boolOn.selected = true; - boolSel.appendChild(boolOn); - var boolOff = document.createElement('option'); - boolOff.value = 'false'; - boolOff.textContent = I18n.t('settings.off'); - if (value === false) boolOff.selected = true; - boolSel.appendChild(boolOff); - boolSel.addEventListener('change', (function(k, el) { - return function() { - if (el.value === '') saveSetting(k, null); - else saveSetting(k, el.value === 'true'); - }; - })(def.key, boolSel)); - inputWrap.appendChild(boolSel); + var toggle = document.createElement('div'); + toggle.className = 'toggle-switch' + (value === 'true' || value === true ? ' on' : ''); + toggle.setAttribute('role', 'switch'); + toggle.setAttribute('aria-checked', value === 'true' || value === true ? 'true' : 'false'); + toggle.setAttribute('aria-label', ariaLabel); + toggle.setAttribute('tabindex', '0'); + + var savedIndicator = document.createElement('span'); + savedIndicator.className = 'settings-saved-indicator'; + savedIndicator.textContent = I18n.t('settings.saved'); + + toggle.addEventListener('click', function() { + var isOn = this.classList.toggle('on'); + this.setAttribute('aria-checked', isOn ? 'true' : 'false'); + saveSetting(def.key, isOn ? 'true' : 'false', savedIndicator); + }); + toggle.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.click(); + } + }); + inputWrap.appendChild(toggle); + inputWrap.appendChild(savedIndicator); } else if (def.type === 'select' && def.options) { var sel = document.createElement('select'); sel.className = 'settings-select'; @@ -5421,16 +5717,207 @@ function showToast(message, type) { const container = document.getElementById('toasts'); const toast = document.createElement('div'); toast.className = 'toast toast-' + (type || 'info'); - toast.textContent = message; + + // Icon prefix + const icon = document.createElement('span'); + icon.className = 'toast-icon'; + if (type === 'success') icon.textContent = '\u2713'; + else if (type === 'error') icon.textContent = '\u2717'; + else icon.textContent = '\u2139'; + toast.appendChild(icon); + + // Message text + const text = document.createElement('span'); + text.textContent = message; + toast.appendChild(text); + + // Countdown bar + const countdown = document.createElement('div'); + countdown.className = 'toast-countdown'; + toast.appendChild(countdown); + container.appendChild(toast); // Trigger slide-in requestAnimationFrame(() => toast.classList.add('visible')); setTimeout(() => { - toast.classList.remove('visible'); - toast.addEventListener('transitionend', () => toast.remove()); + toast.classList.add('dismissing'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + // Fallback removal if transitionend doesn't fire + setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500); }, 4000); } +// --- Welcome Card (Phase 4.2) --- + +function showWelcomeCard() { + const container = document.getElementById('chat-messages'); + if (!container || container.querySelector('.welcome-card')) return; + const card = document.createElement('div'); + card.className = 'welcome-card'; + + const heading = document.createElement('h2'); + heading.className = 'welcome-heading'; + heading.textContent = I18n.t('welcome.heading'); + card.appendChild(heading); + + const desc = document.createElement('p'); + desc.className = 'welcome-description'; + desc.textContent = I18n.t('welcome.description'); + card.appendChild(desc); + + const chips = document.createElement('div'); + chips.className = 'welcome-chips'; + + const suggestions = [ + { key: 'welcome.runTool', fallback: 'Run a tool' }, + { key: 'welcome.checkJobs', fallback: 'Check job status' }, + { key: 'welcome.searchMemory', fallback: 'Search memory' }, + { key: 'welcome.manageRoutines', fallback: 'Manage routines' }, + { key: 'welcome.systemStatus', fallback: 'System status' }, + { key: 'welcome.writeCode', fallback: 'Write code' }, + ]; + suggestions.forEach(({ key, fallback }) => { + const chip = document.createElement('button'); + chip.className = 'welcome-chip'; + chip.textContent = I18n.t(key) || fallback; + chip.addEventListener('click', () => sendSuggestion(chip)); + chips.appendChild(chip); + }); + + card.appendChild(chips); + container.appendChild(card); +} + +function renderEmptyState({ icon, title, hint, action }) { + const wrapper = document.createElement('div'); + wrapper.className = 'empty-state-card'; + + if (icon) { + const iconEl = document.createElement('div'); + iconEl.className = 'empty-state-icon'; + iconEl.textContent = icon; + wrapper.appendChild(iconEl); + } + + if (title) { + const titleEl = document.createElement('div'); + titleEl.className = 'empty-state-title'; + titleEl.textContent = title; + wrapper.appendChild(titleEl); + } + + if (hint) { + const hintEl = document.createElement('div'); + hintEl.className = 'empty-state-hint'; + hintEl.textContent = hint; + wrapper.appendChild(hintEl); + } + + if (action) { + const btn = document.createElement('button'); + btn.className = 'empty-state-action'; + btn.textContent = action.label || 'Go'; + if (action.onClick) btn.addEventListener('click', action.onClick); + wrapper.appendChild(btn); + } + + return wrapper; +} + +function sendSuggestion(btn) { + const textarea = document.getElementById('chat-input'); + if (textarea) { + textarea.value = btn.textContent; + sendMessage(); + } +} + +function removeWelcomeCard() { + const card = document.querySelector('.welcome-card'); + if (card) card.remove(); +} + +// --- Connection Status Banner (Phase 4.1) --- + +function showConnectionBanner(message, type) { + const existing = document.getElementById('connection-banner'); + if (existing) existing.remove(); + + const banner = document.createElement('div'); + banner.id = 'connection-banner'; + banner.className = 'connection-banner connection-banner-' + type; + banner.textContent = message; + document.body.appendChild(banner); +} + +// --- Keyboard Shortcut Helpers (Phase 7.4) --- + +function focusMemorySearch() { + const memSearch = document.getElementById('memory-search'); + if (memSearch) { + if (currentTab !== 'memory') switchTab('memory'); + memSearch.focus(); + } +} + +function toggleShortcutsOverlay() { + let overlay = document.getElementById('shortcuts-overlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'shortcuts-overlay'; + overlay.className = 'shortcuts-overlay'; + overlay.style.display = 'none'; + overlay.innerHTML = + '
' + + '

Keyboard Shortcuts

' + + '
Ctrl/Cmd + 1-5 Switch tabs
' + + '
Ctrl/Cmd + N New thread
' + + '
Ctrl/Cmd + K Focus search/input
' + + '
Ctrl/Cmd + / Toggle this overlay
' + + '
Escape Close modals
' + + '' + + '
'; + document.body.appendChild(overlay); + overlay.querySelector('.shortcuts-close').addEventListener('click', () => { + overlay.style.display = 'none'; + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.style.display = 'none'; + }); + } + overlay.style.display = overlay.style.display === 'flex' ? 'none' : 'flex'; +} + +function closeModals() { + // Close shortcuts overlay + const shortcutsOverlay = document.getElementById('shortcuts-overlay'); + if (shortcutsOverlay) shortcutsOverlay.style.display = 'none'; + + // Close restart confirmation modal + const restartModal = document.getElementById('restart-confirm-modal'); + if (restartModal) restartModal.style.display = 'none'; +} + +// --- ARIA Accessibility (Phase 5.2) --- + +function applyAriaAttributes() { + const tabBar = document.querySelector('.tab-bar'); + if (tabBar) tabBar.setAttribute('role', 'tablist'); + + document.querySelectorAll('.tab-bar button[data-tab]').forEach(btn => { + btn.setAttribute('role', 'tab'); + btn.setAttribute('aria-selected', btn.classList.contains('active') ? 'true' : 'false'); + }); + + document.querySelectorAll('.tab-panel').forEach(panel => { + panel.setAttribute('role', 'tabpanel'); + panel.setAttribute('aria-hidden', panel.classList.contains('active') ? 'false' : 'true'); + }); +} + +// Apply ARIA attributes on initial load +applyAriaAttributes(); + // --- Utilities --- function escapeHtml(str) { @@ -5469,6 +5956,17 @@ document.getElementById('skill-search-btn').addEventListener('click', () => sear document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm()); document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings()); document.getElementById('settings-import-btn').addEventListener('click', () => importSettings()); +document.getElementById('settings-back-btn')?.addEventListener('click', () => settingsBack()); + +// --- Mobile: close thread sidebar on outside click --- +document.addEventListener('click', function(e) { + const sidebar = document.getElementById('thread-sidebar'); + if (sidebar && sidebar.classList.contains('expanded-mobile') && + !sidebar.contains(e.target)) { + sidebar.classList.remove('expanded-mobile'); + document.getElementById('thread-toggle-btn').innerHTML = '»'; + } +}); // --- Delegated Event Handlers (for dynamically generated HTML) --- diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index 6029075d..761767fe 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -521,4 +521,29 @@ I18n.register('en', { 'channels.replDesc': 'Simple read-eval-print loop for testing', 'channels.configureVia': 'Configure via {env}', 'channels.runWith': 'Run with: {cmd}', + + // Welcome Card + 'welcome.heading': 'What can I help you with?', + 'welcome.description': 'IronClaw is your secure AI assistant. Choose a suggestion below or type your own message.', + 'welcome.runTool': 'Run a tool', + 'welcome.checkJobs': 'Check job status', + 'welcome.searchMemory': 'Search memory', + 'welcome.manageRoutines': 'Manage routines', + 'welcome.systemStatus': 'System status', + 'welcome.writeCode': 'Write code', + + // Connection + 'connection.disconnected': 'Disconnected — attempting to reconnect', + 'connection.reconnecting': 'Reconnecting (attempt {count})...', + 'connection.reconnected': 'Reconnected', + + // Messages + 'message.you': 'You', + 'message.assistant': 'IronClaw', + 'message.system': 'System', + 'message.copy': 'Copy', + 'message.copied': 'Copied!', + + // Approval + 'approval.pressY': 'Press Y to approve, N to deny', }); diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index 480724c9..0fb1568a 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -520,4 +520,29 @@ I18n.register('zh-CN', { 'channels.replDesc': '用于测试的简单读取-求值-打印循环', 'channels.configureVia': '通过 {env} 配置', 'channels.runWith': '运行命令: {cmd}', + + // Welcome Card + 'welcome.heading': '有什么可以帮助您的?', + 'welcome.description': 'IronClaw 是您的安全 AI 助手。选择下方的建议或输入您自己的消息。', + 'welcome.runTool': '运行工具', + 'welcome.checkJobs': '查看任务状态', + 'welcome.searchMemory': '搜索记忆', + 'welcome.manageRoutines': '管理例程', + 'welcome.systemStatus': '系统状态', + 'welcome.writeCode': '编写代码', + + // Connection + 'connection.disconnected': '已断开连接 — 正在尝试重新连接', + 'connection.reconnecting': '正在重新连接(第 {count} 次尝试)...', + 'connection.reconnected': '已重新连接', + + // Messages + 'message.you': '你', + 'message.assistant': 'IronClaw', + 'message.system': '系统', + 'message.copy': '复制', + 'message.copied': '已复制!', + + // Approval + 'approval.pressY': '按 Y 批准,N 拒绝', }); diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 113d144e..7aa2c86f 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -92,6 +92,7 @@
+
@@ -292,9 +293,11 @@ +
+ diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 31f259c9..87afea87 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -52,7 +52,6 @@ --text-on-danger: #fff; --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4); --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25); --danger-error-border: rgba(230, 76, 76, 0.2); --note-bg: rgba(255, 255, 255, 0.04); --overlay-heavy: rgba(0, 0, 0, 0.6); @@ -60,6 +59,58 @@ --hover-subtle: rgba(255, 255, 255, 0.06); --transition-fast: 150ms ease; --transition-base: 0.2s ease; + + /* Shadows (3-tier) */ + --shadow-sm: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.15); + --shadow-md: 0 4px 12px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.2); + --shadow-lg: 0 12px 40px rgba(0,0,0,0.5), 0 4px 12px rgba(0,0,0,0.3); + + /* Accent glow */ + --glow-accent: 0 0 20px rgba(52,211,153,0.1); + + /* Glass morphism */ + --glass-bg: rgba(9,9,11,0.72); + --glass-blur: blur(16px) saturate(180%); + + /* Spring easing */ + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-spring-gentle: cubic-bezier(0.22, 1.2, 0.36, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + + /* Surface highlight */ + --surface-highlight: inset 0 1px 0 rgba(255,255,255,0.05); + + /* Spacing scale */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + + /* Typography scale */ + --text-xs: 11px; + --text-sm: 13px; + --text-base: 14px; + --text-lg: 16px; + --text-xl: 20px; + --text-2xl: 24px; + --text-3xl: 36px; + + /* Timing */ + --transition-slow: 300ms ease; + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --duration-instant: 100ms; + --duration-fast: 150ms; + --duration-base: 250ms; + --duration-slow: 400ms; + + /* Legacy aliases (mapped to new theme tokens) */ + --accent-soft: var(--accent-subtle); + --accent-dim: var(--accent-subtle); + --bg-hover: var(--hover-surface); + --danger-soft: var(--danger-subtle); + --warning-soft: var(--warning-subtle); } * { @@ -68,6 +119,17 @@ box-sizing: border-box; } +*:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + animation: focusExpand 200ms ease; +} + +@keyframes focusExpand { + from { outline-offset: 0px; outline-color: transparent; } + to { outline-offset: 2px; outline-color: var(--accent); } +} + body { font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); @@ -97,7 +159,7 @@ body { max-width: 400px; display: flex; flex-direction: column; - gap: 24px; + gap: var(--space-6); box-shadow: var(--shadow-card); } @@ -107,24 +169,24 @@ body { .auth-brand h1 { font-size: 28px; - font-weight: 700; + font-weight: 800; color: var(--text); margin-bottom: 4px; } .auth-tagline { - font-size: 14px; + font-size: var(--text-base); color: var(--text-secondary); } #auth-screen .auth-form { display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } #auth-screen .auth-form label { - font-size: 13px; + font-size: var(--text-sm); font-weight: 500; color: var(--text-secondary); } @@ -135,7 +197,7 @@ body { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 14px; + font-size: var(--text-base); width: 100%; } @@ -152,10 +214,10 @@ body { border: none; border-radius: var(--radius); cursor: pointer; - font-size: 14px; + font-size: var(--text-base); font-weight: 600; margin-top: 4px; - transition: background 0.2s, transform 0.2s; + transition: background 0.2s, transform 150ms var(--ease-spring); } #auth-screen button:hover { @@ -164,12 +226,12 @@ body { } #auth-screen button:active { - transform: scale(0.98); + transform: scale(0.97); } #auth-error { color: var(--danger); - font-size: 13px; + font-size: var(--text-sm); min-height: 20px; text-align: center; } @@ -187,6 +249,12 @@ body { flex-direction: column; height: 100vh; height: 100dvh; + opacity: 0; + transition: opacity 0.3s ease 0.15s; +} + +#app.visible { + opacity: 1; } /* Tab Bar */ @@ -200,6 +268,9 @@ body { padding: 0 16px; gap: 0; flex-shrink: 0; + position: relative; + z-index: 200; + box-shadow: var(--surface-highlight); } .tab-bar button:not(.status-logs-btn):not(.restart-btn) { @@ -209,7 +280,7 @@ body { border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; - font-size: 14px; + font-size: var(--text-base); font-weight: 500; transition: color 0.2s, border-color 0.2s; } @@ -220,7 +291,9 @@ body { .tab-bar button:not(.status-logs-btn):not(.restart-btn).active { color: var(--accent); - border-bottom-color: var(--accent); + border-bottom-color: transparent; + background: var(--accent-subtle); + border-radius: var(--radius) var(--radius) 0 0; } .tab-bar .spacer { @@ -234,7 +307,7 @@ body { border-radius: var(--radius); color: var(--text-secondary); cursor: pointer; - font-size: 11px; + font-size: var(--text-xs); align-self: center; margin-right: 8px; transition: color 0.2s, border-color 0.2s, background 0.2s; @@ -254,7 +327,7 @@ body { .tab-bar .status { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); font-size: 12px; color: var(--text-secondary); position: relative; @@ -266,12 +339,25 @@ body { height: 8px; border-radius: 50%; background: var(--success); + position: relative; } .tab-bar .status .dot.disconnected { background: var(--danger); } +/* Tab sliding indicator */ +.tab-indicator { + position: absolute; + bottom: 0; + height: 2px; + background: var(--accent); + border-radius: 1px; + transition: left 300ms var(--ease-spring), width 300ms var(--ease-spring); + z-index: 1; + pointer-events: none; +} + /* TEE Shield */ .tee-shield { display: flex; @@ -591,10 +677,10 @@ body { -webkit-backdrop-filter: blur(16px); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 16px; + padding: var(--space-4); min-width: 340px; max-width: 420px; - z-index: 100; + z-index: 500; box-shadow: var(--shadow); } @@ -603,7 +689,7 @@ body { } .tee-popover-title { - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; color: var(--text); margin-bottom: 12px; @@ -625,7 +711,7 @@ body { } .tee-field-label { - font-size: 11px; + font-size: var(--text-xs); font-weight: 500; color: var(--text-secondary); text-transform: uppercase; @@ -647,7 +733,7 @@ body { .tee-popover-actions { margin-top: 12px; display: flex; - gap: 8px; + gap: var(--space-2); } .tee-btn-copy { @@ -657,7 +743,7 @@ body { border-radius: var(--radius); color: var(--text-secondary); cursor: pointer; - font-size: 11px; + font-size: var(--text-xs); transition: color 0.2s, border-color 0.2s; } @@ -677,11 +763,20 @@ body { display: none; flex: 1; overflow: hidden; + flex-direction: column; } .tab-panel.active { display: flex; - flex-direction: column; +} + +#app.visible .tab-panel.active { + animation: tabFadeIn 200ms ease forwards; +} + +@keyframes tabFadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } } /* Chat Tab */ @@ -695,20 +790,42 @@ body { .chat-messages { flex: 1; overflow-y: auto; - padding: 16px; + padding: var(--space-4); display: flex; flex-direction: column; - gap: 16px; + gap: var(--space-4); } .message { max-width: 72%; padding: 10px 14px; border-radius: var(--radius); - font-size: 14px; + font-size: var(--text-base); line-height: 1.5; word-wrap: break-word; position: relative; + animation: slideUp 350ms var(--ease-spring); +} + +@keyframes slideUp { + 0% { opacity: 0; transform: translateY(12px) scale(0.98); } + 70% { opacity: 1; transform: translateY(-2px) scale(1.005); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +.message[data-streaming="true"]::after { + content: ''; + display: inline-block; + width: 2px; + height: 16px; + background: var(--accent); + vertical-align: text-bottom; + animation: cursorPulse 1.2s ease-in-out infinite; +} + +@keyframes cursorPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } } .message.user { @@ -719,10 +836,23 @@ body { white-space: pre-wrap; } +.message.user::after { + content: ''; + position: absolute; + right: -6px; + bottom: 10px; + width: 0; + height: 0; + border: 6px solid transparent; + border-left-color: var(--accent-soft); + border-right: 0; +} + .message.assistant { align-self: flex-start; background: var(--bg-secondary); border: 1px solid var(--border); + border-left: 2px solid var(--accent); border-bottom-left-radius: 2px; padding: 14px 18px; font-size: 15px; @@ -746,7 +876,7 @@ body { background: var(--bg-primary); color: var(--text-secondary); border-radius: 8px; - font-size: 11px; + font-size: var(--text-xs); padding: 2px 8px; opacity: 0; pointer-events: none; @@ -781,6 +911,43 @@ body { } } +.message-timestamp { + position: absolute; + top: 8px; + right: 52px; + font-size: var(--text-xs); + color: var(--text-muted); + opacity: 0; + transition: opacity 150ms ease; + pointer-events: none; +} + +.message:hover .message-timestamp { + opacity: 0.7; +} + +.message.user .message-timestamp { + right: auto; + left: -80px; +} + +.time-separator { + display: flex; + align-items: center; + gap: var(--space-3); + margin: 16px 0; + color: var(--text-muted); + font-size: var(--text-xs); +} + +.time-separator::before, +.time-separator::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + .message.system { align-self: center; background: var(--bg-tertiary); @@ -793,7 +960,7 @@ body { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; - font-size: 13px; + font-size: var(--text-sm); } .message pre { @@ -833,7 +1000,7 @@ body { .message th, .message td { border: 1px solid var(--border); padding: 4px 8px; - font-size: 13px; + font-size: var(--text-sm); } .message th { background: var(--bg-tertiary); } @@ -843,7 +1010,7 @@ body { display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: var(--space-2); padding: 8px; color: var(--text-secondary); font-size: 12px; @@ -881,9 +1048,9 @@ body { .activity-thinking { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); padding: 6px 8px; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); } @@ -937,7 +1104,7 @@ body { .activity-tool-header { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); padding: 6px 10px; cursor: pointer; user-select: none; @@ -968,20 +1135,20 @@ body { .activity-icon-success { color: var(--success); - font-size: 14px; + font-size: var(--text-base); font-weight: 700; line-height: 1; } .activity-icon-fail { color: var(--danger); - font-size: 14px; + font-size: var(--text-base); font-weight: 700; line-height: 1; } .activity-tool-name { - font-size: 13px; + font-size: var(--text-sm); font-family: var(--font-mono); font-weight: 500; color: var(--text); @@ -989,7 +1156,7 @@ body { } .activity-tool-duration { - font-size: 11px; + font-size: var(--text-xs); font-family: var(--font-mono); color: var(--text-secondary); min-width: 36px; @@ -1010,6 +1177,13 @@ body { .activity-tool-body { border-top: 1px solid var(--border); + max-height: 0; + overflow: hidden; + transition: max-height 300ms var(--ease-out-expo); +} + +.activity-tool-body.expanded { + max-height: 300px; } .activity-tool-output { @@ -1035,7 +1209,7 @@ body { padding: 6px 10px; cursor: pointer; user-select: none; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); border-radius: var(--radius); transition: background 0.15s; @@ -1062,7 +1236,7 @@ body { .activity-summary-duration { font-family: var(--font-mono); - font-size: 11px; + font-size: var(--text-xs); opacity: 0.7; } @@ -1091,7 +1265,7 @@ body { padding: 14px; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); transition: border-color 0.2s; } @@ -1104,14 +1278,14 @@ body { } .approval-tool-name { - font-size: 14px; + font-size: var(--text-base); font-weight: 600; color: var(--text); font-family: var(--font-mono); } .approval-description { - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.4; } @@ -1146,7 +1320,7 @@ body { .approval-card .approval-actions { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; } @@ -1155,7 +1329,7 @@ body { border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); background: var(--bg-secondary); color: var(--text); } @@ -1273,7 +1447,7 @@ body { display: flex; align-items: center; justify-content: center; - padding: 16px; + padding: var(--space-4); } .auth-card { @@ -1286,7 +1460,7 @@ body { margin: 8px 0; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); transition: border-color 0.2s; } @@ -1303,30 +1477,30 @@ body { .auth-card .auth-header { font-weight: 600; color: var(--accent); - font-size: 13px; + font-size: var(--text-sm); } .auth-card .auth-instructions { - font-size: 13px; + font-size: var(--text-sm); color: var(--text); line-height: 1.4; } .auth-card .auth-links { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; } .auth-card .auth-links a { color: var(--accent); - font-size: 13px; + font-size: var(--text-sm); text-decoration: underline; } .auth-card .auth-token-input { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; } @@ -1337,7 +1511,7 @@ body { border-radius: var(--radius); background: var(--bg); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); font-family: var(--font-mono); } @@ -1349,7 +1523,7 @@ body { .auth-card .auth-actions { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; } @@ -1358,7 +1532,7 @@ body { border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); background: var(--bg-secondary); color: var(--text); } @@ -1396,12 +1570,15 @@ body { .chat-input { display: flex; flex-wrap: wrap; - padding: 12px 16px max(12px, env(safe-area-inset-bottom)) 16px; - gap: 8px; + margin: 0 16px 12px; + padding: 12px 16px; + gap: var(--space-2); background: var(--bg-secondary); - border-top: 1px solid var(--border); + border: 1px solid var(--border); + border-radius: var(--radius-lg); flex-shrink: 0; min-height: 56px; + box-shadow: var(--shadow-md); } .chat-input-wrapper { @@ -1417,11 +1594,12 @@ body { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 14px; + font-size: var(--text-base); font-family: inherit; resize: none; min-height: 40px; max-height: 120px; + transition: height 100ms ease; } .ghost-text { @@ -1430,7 +1608,7 @@ body { left: 0; right: 0; padding: 8px 12px; - font-size: 14px; + font-size: var(--text-base); font-family: inherit; color: var(--text-secondary); opacity: 0.5; @@ -1460,7 +1638,7 @@ body { .suggestion-chips { display: none; flex-wrap: wrap; - gap: 8px; + gap: var(--space-2); padding: 8px 16px; border-top: 1px solid var(--border); } @@ -1471,7 +1649,7 @@ body { border: 1px solid var(--border); border-radius: 16px; color: var(--text-secondary); - font-size: 13px; + font-size: var(--text-sm); font-family: inherit; cursor: pointer; transition: all 0.15s ease; @@ -1482,6 +1660,7 @@ body { background: var(--accent); color: #09090b; border-color: var(--accent); + transform: translateY(-1px); } .chat-input button { @@ -1491,10 +1670,10 @@ body { border: none; border-radius: var(--radius); cursor: pointer; - font-size: 14px; + font-size: var(--text-base); font-weight: 600; align-self: flex-end; - transition: background 0.2s, transform 0.2s; + transition: background 0.2s, transform 150ms var(--ease-spring); } .chat-input button:hover:not(:disabled) { @@ -1503,7 +1682,7 @@ body { } .chat-input button:active { - transform: scale(0.98); + transform: scale(0.97); } .chat-input button:disabled { @@ -1512,6 +1691,10 @@ body { transform: none; } +#send-btn.active { + box-shadow: var(--glow-accent); +} + /* Keyboard accessibility focus rings */ .chat-input-wrapper textarea:focus-visible, .chat-input button:focus-visible, @@ -1548,7 +1731,7 @@ body { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); } .memory-sidebar input:focus { @@ -1569,7 +1752,7 @@ body { align-items: center; padding: 3px 8px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); gap: 4px; min-height: 26px; @@ -1630,7 +1813,7 @@ body { .tree-item { padding: 4px 12px 4px 16px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); display: flex; align-items: center; @@ -1662,13 +1845,13 @@ body { .memory-breadcrumb { padding: 8px 16px; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); } .memory-breadcrumb a { @@ -1684,8 +1867,8 @@ body { .memory-viewer { flex: 1; overflow-y: auto; - padding: 16px; - font-size: 14px; + padding: var(--space-4); + font-size: var(--text-base); line-height: 1.6; white-space: pre-wrap; font-family: var(--font-mono); @@ -1717,7 +1900,7 @@ body { } .search-result .snippet { - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; @@ -1728,18 +1911,18 @@ body { .jobs-container { flex: 1; overflow-y: auto; - padding: 16px; + padding: var(--space-4); } .jobs-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 12px; + gap: var(--space-3); margin-bottom: 20px; } .summary-card { - padding: 16px; + padding: var(--space-4); background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -1749,6 +1932,8 @@ body { .summary-card:hover { border-color: var(--border-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); } .summary-card .count { @@ -1780,14 +1965,14 @@ body { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); - font-size: 13px; + font-size: var(--text-sm); } .jobs-table th { color: var(--text-secondary); font-weight: 500; text-transform: uppercase; - font-size: 11px; + font-size: var(--text-xs); letter-spacing: 0.5px; } @@ -1799,7 +1984,7 @@ body { display: inline-block; padding: 3px 10px; border-radius: 9999px; - font-size: 11px; + font-size: var(--text-xs); font-weight: 500; } @@ -1860,7 +2045,7 @@ body { .job-card { display: flex; align-items: center; - gap: 12px; + gap: var(--space-3); padding: 12px 16px; margin: 8px 0; background: var(--bg-tertiary); @@ -1875,7 +2060,7 @@ body { } .job-card-icon { - font-size: 20px; + font-size: var(--text-xl); } .job-card-info { @@ -1884,7 +2069,7 @@ body { .job-card-title { font-weight: 600; - font-size: 14px; + font-size: var(--text-base); } .job-card-id { @@ -1930,7 +2115,7 @@ body { .job-detail-header { display: flex; align-items: center; - gap: 12px; + gap: var(--space-3); margin-bottom: 16px; } @@ -1951,7 +2136,7 @@ body { border-radius: var(--radius); color: var(--text); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); flex-shrink: 0; } @@ -1973,7 +2158,7 @@ body { border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); } .job-detail-tabs button:hover { @@ -1994,7 +2179,7 @@ body { .job-meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 12px; + gap: var(--space-3); margin-bottom: 20px; } @@ -2006,7 +2191,7 @@ body { } .meta-label { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; @@ -2014,7 +2199,7 @@ body { } .meta-value { - font-size: 14px; + font-size: var(--text-base); color: var(--text); word-break: break-all; } @@ -2025,7 +2210,7 @@ body { } .job-description h3 { - font-size: 14px; + font-size: var(--text-base); font-weight: 600; margin-bottom: 8px; color: var(--text); @@ -2036,7 +2221,7 @@ body { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; - font-size: 14px; + font-size: var(--text-base); line-height: 1.6; } @@ -2046,7 +2231,7 @@ body { } .job-timeline-section h3 { - font-size: 14px; + font-size: var(--text-base); font-weight: 600; margin-bottom: 12px; color: var(--text); @@ -2079,7 +2264,7 @@ body { flex-wrap: wrap; align-items: center; gap: 6px; - font-size: 13px; + font-size: var(--text-sm); } .timeline-time { @@ -2114,7 +2299,7 @@ body { gap: 10px; padding: 10px 12px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); } .action-header:hover { @@ -2129,7 +2314,7 @@ body { .action-seq { color: var(--text-secondary); - font-size: 11px; + font-size: var(--text-xs); } .action-duration { @@ -2198,12 +2383,12 @@ body { padding: 10px 14px; border-radius: var(--radius); margin-bottom: 8px; - font-size: 14px; + font-size: var(--text-base); line-height: 1.5; } .conv-role { - font-size: 11px; + font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; @@ -2220,7 +2405,7 @@ body { } .conv-system .conv-role { color: var(--text-secondary); } -.conv-system .conv-body { color: var(--text-secondary); font-size: 13px; } +.conv-system .conv-body { color: var(--text-secondary); font-size: var(--text-sm); } .conv-user { background: var(--user-msg-bg); @@ -2240,14 +2425,14 @@ body { background: var(--bg-secondary); border: 1px solid var(--border); font-family: var(--font-mono); - font-size: 13px; + font-size: var(--text-sm); } .conv-tool .conv-role { color: var(--warning); } .conv-tool .conv-body { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; } .conv-tc-id { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 4px; font-family: var(--font-mono); @@ -2274,7 +2459,7 @@ body { background: var(--code-bg); padding: 6px 10px; border-radius: var(--radius); - font-size: 11px; + font-size: var(--text-xs); font-family: var(--font-mono); line-height: 1.4; margin: 4px 0 0; @@ -2320,7 +2505,7 @@ body { } .job-files-content { - font-size: 13px; + font-size: var(--text-sm); font-family: var(--font-mono); line-height: 1.5; white-space: pre-wrap; @@ -2329,6 +2514,63 @@ body { margin: 0; } +/* Welcome card */ +.welcome-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 32px; + text-align: center; + max-width: 600px; + margin: auto; + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + gap: var(--space-6); +} + +.welcome-heading { + font-size: var(--text-xl); + font-weight: 600; + color: var(--text); + margin: 0; +} + +.welcome-description { + font-size: var(--text-base); + color: var(--text-secondary); + line-height: 1.5; + margin: 0; +} + +.welcome-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: center; +} + +.welcome-chip { + padding: 10px 18px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: all 200ms ease, transform 150ms var(--ease-spring); + box-shadow: var(--shadow-sm); +} + +.welcome-chip:hover { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + .empty-state { text-align: center; padding: 40px; @@ -2339,13 +2581,13 @@ body { .routines-container { flex: 1; overflow-y: auto; - padding: 16px; + padding: var(--space-4); } .routines-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 12px; + gap: var(--space-3); margin-bottom: 20px; } @@ -2359,14 +2601,14 @@ body { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); - font-size: 13px; + font-size: var(--text-sm); } .routines-table th { color: var(--text-secondary); font-weight: 500; text-transform: uppercase; - font-size: 11px; + font-size: var(--text-xs); letter-spacing: 0.5px; } @@ -2425,7 +2667,7 @@ body { .logs-toolbar { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); padding: 8px 16px; background: var(--bg-secondary); border-bottom: 1px solid var(--border); @@ -2493,7 +2735,7 @@ body { .log-entry { display: flex; - gap: 8px; + gap: var(--space-2); padding: 1px 12px; white-space: nowrap; cursor: pointer; @@ -2556,7 +2798,7 @@ body { .extensions-container { flex: 1; overflow-y: auto; - padding: 16px; + padding: var(--space-4); } .extensions-section { @@ -2564,7 +2806,7 @@ body { } .extensions-section h3 { - font-size: 11px; + font-size: var(--text-xs); font-weight: 600; margin-bottom: 12px; color: var(--text-secondary); @@ -2573,7 +2815,7 @@ body { } .extensions-section h4 { - font-size: 11px; + font-size: var(--text-xs); font-weight: 600; margin: 16px 0 8px; color: var(--text-muted); @@ -2584,7 +2826,7 @@ body { .extensions-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 12px; + gap: var(--space-3); } .ext-card { @@ -2595,7 +2837,7 @@ body { padding: 14px; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); transition: border-color var(--transition-base), box-shadow var(--transition-base), transform 0.2s; } @@ -2617,17 +2859,19 @@ body { .ext-card:hover { border-color: var(--border-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); } .ext-header { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); } .ext-name { font-weight: 600; - font-size: 14px; + font-size: var(--text-base); color: var(--text); } @@ -2661,7 +2905,7 @@ body { } .ext-version { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-mono); } @@ -2682,7 +2926,7 @@ body { } .ext-desc { - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.4; } @@ -2735,13 +2979,13 @@ body { display: flex; align-items: center; justify-content: center; - font-size: 11px; + font-size: var(--text-xs); font-weight: 700; flex-shrink: 0; } .stepper-label { - font-size: 11px; + font-size: var(--text-xs); white-space: nowrap; } @@ -2807,7 +3051,7 @@ body { } .ext-error { - font-size: 11px; + font-size: var(--text-xs); color: var(--danger); background: var(--danger-error-bg); border: 1px solid var(--danger-error-border); @@ -2817,7 +3061,7 @@ body { } .ext-note { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); background: var(--note-bg); border: 1px solid var(--border); @@ -2839,7 +3083,7 @@ body { border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text); - transition: all var(--transition-fast); + transition: all var(--transition-fast), transform 150ms var(--ease-spring); } .btn-ext:hover { @@ -2888,7 +3132,7 @@ body { } .ext-keywords { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); opacity: 0.7; } @@ -2910,7 +3154,7 @@ body { } .pairing-heading { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; @@ -2920,13 +3164,13 @@ body { .pairing-row { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); margin-bottom: 4px; } .pairing-code { font-family: var(--font-mono); - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; color: var(--accent); background: var(--bg-tertiary); @@ -2968,7 +3212,7 @@ body { .configure-modal h3 { margin: 0 0 16px 0; - font-size: 16px; + font-size: var(--text-lg); color: var(--text); } @@ -2979,7 +3223,7 @@ body { background: var(--bg-secondary); border: 1px solid var(--border); color: var(--text-secondary); - font-size: 13px; + font-size: var(--text-sm); line-height: 1.5; } @@ -2995,13 +3239,13 @@ body { } .configure-verification-title { - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); } .configure-verification-instructions { - font-size: 13px; + font-size: var(--text-sm); line-height: 1.5; color: var(--text-secondary); } @@ -3014,13 +3258,13 @@ body { background: rgba(255, 255, 255, 0.06); border: 1px solid var(--border); color: var(--text-primary); - font-size: 13px; + font-size: var(--text-sm); } .configure-verification-link { width: fit-content; color: var(--accent, var(--text-link, #4ea3ff)); - font-size: 13px; + font-size: var(--text-sm); text-decoration: none; } @@ -3035,7 +3279,7 @@ body { background: rgba(220, 38, 38, 0.12); border: 1px solid rgba(220, 38, 38, 0.35); color: #fca5a5; - font-size: 13px; + font-size: var(--text-sm); line-height: 1.5; } @@ -3046,19 +3290,19 @@ body { background: var(--bg-secondary); border: 1px solid var(--border); color: var(--text-secondary); - font-size: 13px; + font-size: var(--text-sm); line-height: 1.5; } .configure-form { display: flex; flex-direction: column; - gap: 16px; + gap: var(--space-4); } .configure-field label { display: block; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); margin-bottom: 6px; } @@ -3066,7 +3310,7 @@ body { .configure-input-row { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); } .configure-input-row input { @@ -3076,7 +3320,7 @@ body { border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); - font-size: 13px; + font-size: var(--text-sm); font-family: inherit; } @@ -3091,7 +3335,7 @@ body { } .field-provided { - font-size: 11px; + font-size: var(--text-xs); padding: 2px 8px; background: rgba(63, 185, 80, 0.15); color: var(--success); @@ -3100,14 +3344,14 @@ body { } .field-autogen { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; } .configure-actions { display: flex; - gap: 8px; + gap: var(--space-2); margin-top: 20px; justify-content: flex-end; } @@ -3122,14 +3366,14 @@ body { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); - font-size: 13px; + font-size: var(--text-sm); } .tools-table th { color: var(--text-secondary); font-weight: 500; text-transform: uppercase; - font-size: 11px; + font-size: var(--text-xs); letter-spacing: 0.5px; } @@ -3145,7 +3389,7 @@ body { overflow-y: auto; padding: 12px; font-family: var(--font-mono); - font-size: 13px; + font-size: var(--text-sm); line-height: 1.6; background: var(--bg); border: 1px solid var(--border); @@ -3190,7 +3434,7 @@ body { .activity-session-id { color: var(--text-secondary); - font-size: 11px; + font-size: var(--text-xs); font-weight: 400; } @@ -3242,7 +3486,7 @@ body { .activity-input-bar { display: flex; - gap: 8px; + gap: var(--space-2); padding: 8px 0; } @@ -3253,7 +3497,7 @@ body { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); } .activity-input-bar input:focus { @@ -3269,9 +3513,9 @@ body { border: none; border-radius: var(--radius); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; - transition: background 0.2s, transform 0.2s; + transition: background 0.2s, transform 150ms var(--ease-spring); } .activity-input-bar button:hover { @@ -3280,7 +3524,7 @@ body { } .activity-input-bar button:active { - transform: scale(0.98); + transform: scale(0.97); } #activity-done-btn { @@ -3310,7 +3554,7 @@ body { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-secondary); - font-size: 11px; + font-size: var(--text-xs); cursor: pointer; opacity: 0; transition: opacity 0.15s; @@ -3334,37 +3578,103 @@ body { z-index: 10000; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); pointer-events: none; } .toast { - padding: 10px 16px; + padding: 10px 16px 10px 12px; border-radius: var(--radius); - font-size: 13px; - color: var(--text-on-danger); + font-size: var(--text-sm); + color: var(--text); pointer-events: auto; transform: translateX(120%); - transition: transform 0.25s ease; + transition: transform 400ms var(--ease-spring), opacity 200ms ease; max-width: 360px; word-break: break-word; - box-shadow: var(--shadow-toast); + box-shadow: var(--shadow-lg); + background: var(--bg-secondary); + border: 1px solid var(--border); + display: flex; + align-items: center; + gap: var(--space-2); + position: relative; + overflow: hidden; } .toast.visible { transform: translateX(0); } +.toast.dismissing { + opacity: 0; + transform: translateY(-8px); +} + .toast-info { - background: var(--accent); + border-left: 3px solid var(--accent); } .toast-success { - background: var(--success); + border-left: 3px solid var(--success); } .toast-error { - background: var(--danger); + border-left: 3px solid var(--danger); +} + +.toast-icon { + font-size: var(--text-base); + flex-shrink: 0; +} + +.toast-countdown { + position: absolute; + bottom: 0; + left: 0; + height: 2px; + background: var(--accent); + animation: toastCountdown 4s linear forwards; +} + +.toast-success .toast-countdown { background: var(--success); } +.toast-error .toast-countdown { background: var(--danger); } + +/* --- Connection status banner --- */ + +.connection-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 6px 16px; + text-align: center; + font-size: var(--text-sm); + font-weight: 500; + z-index: 9999; + animation: bannerSlideDown 250ms var(--ease-out-expo); +} + +@keyframes bannerSlideDown { + from { transform: translateY(-100%); } + to { transform: translateY(0); } +} + +.connection-banner-warning { + background: var(--warning-subtle); + color: var(--warning); + border-bottom: 1px solid var(--warning); +} + +.connection-banner-success { + background: var(--accent-subtle); + color: var(--success); + border-bottom: 1px solid var(--success); +} + +@keyframes toastCountdown { + from { width: 100%; } + to { width: 0%; } } /* --- Memory search highlighting --- */ @@ -3389,7 +3699,7 @@ mark { display: flex; flex-direction: column; flex-shrink: 0; - transition: width 0.2s ease; + transition: width 300ms var(--ease-out-expo); overflow: hidden; padding: 6px; gap: 2px; @@ -3399,11 +3709,33 @@ mark { width: 36px; } -.thread-sidebar.collapsed .thread-new-btn, +.thread-sidebar .thread-list, +.thread-sidebar .assistant-item, +.thread-sidebar .threads-section-header span { + transition: opacity 200ms ease; +} + +.thread-sidebar.collapsed .thread-list, +.thread-sidebar.collapsed .assistant-item { + display: none; +} + +.thread-sidebar.collapsed .threads-section-header { + padding: var(--space-2) 0; + flex-direction: column; + align-items: center; + gap: var(--space-2); +} + +.thread-sidebar.collapsed .threads-section-header span, +.thread-sidebar.collapsed .threads-section-header .spacer { + display: none; +} + .thread-sidebar.collapsed .thread-list, .thread-sidebar.collapsed .assistant-item, -.thread-sidebar.collapsed .threads-section-header { - display: none; +.thread-sidebar.collapsed .threads-section-header span { + opacity: 0; } .thread-new-btn { @@ -3412,7 +3744,7 @@ mark { border-radius: var(--radius); color: var(--accent); cursor: pointer; - font-size: 16px; + font-size: var(--text-lg); width: 24px; height: 24px; display: flex; @@ -3432,7 +3764,7 @@ mark { justify-content: space-between; padding: 12px 14px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; color: var(--text); background: var(--bg-tertiary); @@ -3457,7 +3789,7 @@ mark { } .assistant-meta { - font-size: 11px; + font-size: var(--text-xs); font-weight: 400; color: var(--text-secondary); } @@ -3466,7 +3798,7 @@ mark { display: flex; align-items: center; padding: 10px 10px 4px; - font-size: 11px; + font-size: var(--text-xs); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; @@ -3479,7 +3811,7 @@ mark { border: none; color: var(--text-secondary); cursor: pointer; - font-size: 14px; + font-size: var(--text-base); padding: 2px; } @@ -3498,14 +3830,16 @@ mark { justify-content: space-between; padding: 10px 14px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); border-radius: var(--radius); + transition: background var(--transition-fast), color var(--transition-fast), transform var(--transition-fast); } .thread-item:hover { background: var(--bg-tertiary); color: var(--text); + transform: translateX(2px); } .thread-item.active { @@ -3520,7 +3854,7 @@ mark { } .thread-meta { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); flex-shrink: 0; } @@ -3587,7 +3921,7 @@ mark { flex: 1; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); padding: 12px; overflow: hidden; } @@ -3600,7 +3934,7 @@ mark { border-radius: var(--radius); color: var(--text); font-family: var(--font-mono); - font-size: 13px; + font-size: var(--text-sm); line-height: 1.5; resize: none; } @@ -3613,7 +3947,7 @@ mark { .memory-editor-actions { display: flex; - gap: 8px; + gap: var(--space-2); } .btn-save { @@ -3623,9 +3957,9 @@ mark { border: none; border-radius: var(--radius); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; - transition: background 0.2s, transform 0.2s; + transition: background 0.2s, transform 150ms var(--ease-spring); } .btn-save:hover { @@ -3634,7 +3968,7 @@ mark { } .btn-save:active { - transform: scale(0.98); + transform: scale(0.97); } .btn-cancel-edit { @@ -3644,7 +3978,7 @@ mark { border-radius: var(--radius); color: var(--text); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); } .btn-cancel-edit:hover { @@ -3658,7 +3992,7 @@ mark { } .memory-rendered { - font-size: 14px; + font-size: var(--text-base); line-height: 1.6; } @@ -3674,7 +4008,7 @@ mark { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; - font-size: 13px; + font-size: var(--text-sm); } .memory-rendered pre { background: var(--code-bg); @@ -3708,7 +4042,7 @@ mark { padding: 12px; min-width: 220px; box-shadow: var(--shadow); - z-index: 100; + z-index: 500; } .gateway-popover.visible { @@ -3752,7 +4086,7 @@ mark { .gw-model-name { color: var(--text); font-weight: 500; - font-size: 11px; + font-size: var(--text-xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -3762,12 +4096,12 @@ mark { .gw-model-cost { color: var(--accent, var(--text)); font-weight: 500; - font-size: 11px; + font-size: var(--text-xs); } .gw-token-detail { display: flex; - gap: 12px; + gap: var(--space-3); font-size: 10px; color: var(--text-secondary); padding: 1px 0 4px 0; @@ -3777,7 +4111,7 @@ mark { .ext-install-form { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; flex-wrap: wrap; background: var(--bg-secondary); @@ -3792,7 +4126,7 @@ mark { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); } .ext-install-form input:focus { @@ -3808,9 +4142,9 @@ mark { border: none; border-radius: var(--radius); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; - transition: background 0.2s, transform 0.2s; + transition: background 0.2s, transform 150ms var(--ease-spring); } .ext-install-form button:hover { @@ -3819,14 +4153,14 @@ mark { } .ext-install-form button:active { - transform: scale(0.98); + transform: scale(0.97); } /* --- Skills tab --- */ .skill-search-box { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; margin-bottom: 12px; background: var(--bg-secondary); @@ -3842,7 +4176,7 @@ mark { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); } .skill-search-box input:focus { @@ -3858,7 +4192,7 @@ mark { border: none; border-radius: var(--radius); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; transition: background 0.2s, transform 0.2s; } @@ -3869,7 +4203,7 @@ mark { } .skill-trust { - font-size: 11px; + font-size: var(--text-xs); padding: 3px 8px; border-radius: 9999px; font-weight: 600; @@ -3888,7 +4222,7 @@ mark { } .skill-version { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); font-family: var(--font-mono); } @@ -3907,7 +4241,7 @@ mark { .activity-toolbar { display: flex; align-items: center; - gap: 12px; + gap: var(--space-3); padding: 8px 0; } @@ -3938,7 +4272,7 @@ mark { .tab-bar button:not(.status-logs-btn) { padding: 8px 12px; - font-size: 13px; + font-size: var(--text-sm); white-space: nowrap; } @@ -3954,11 +4288,26 @@ mark { .thread-sidebar .thread-new-btn, .thread-sidebar .thread-list, - .thread-sidebar .assistant-item, - .thread-sidebar .threads-section-header { + .thread-sidebar .assistant-item { display: none; } + .thread-sidebar .threads-section-header > span, + .thread-sidebar .threads-section-header > .spacer, + .thread-sidebar .threads-section-header > .thread-new-btn { + display: none; + } + + .thread-sidebar .threads-section-header { + padding: 0; + justify-content: center; + } + + .thread-sidebar .thread-toggle-btn { + min-width: 36px; + min-height: 44px; + } + .thread-sidebar.expanded-mobile { position: absolute; left: 0; @@ -3969,12 +4318,28 @@ mark { } .thread-sidebar.expanded-mobile .thread-new-btn, - .thread-sidebar.expanded-mobile .thread-list, - .thread-sidebar.expanded-mobile .assistant-item, - .thread-sidebar.expanded-mobile .threads-section-header { + .thread-sidebar.expanded-mobile .assistant-item { display: flex; } + .thread-sidebar.expanded-mobile .thread-list { + display: block; + } + + .thread-sidebar.expanded-mobile .threads-section-header > span, + .thread-sidebar.expanded-mobile .threads-section-header > .spacer, + .thread-sidebar.expanded-mobile .threads-section-header > .thread-new-btn { + display: initial; + } + + .thread-sidebar.expanded-mobile::before { + content: ''; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + z-index: -1; + } + /* Memory: vertical stack */ .memory-container { flex-direction: column; @@ -4014,25 +4379,55 @@ mark { border-bottom: 1px solid var(--border); } - /* Settings layout: horizontal subtabs on mobile */ + /* Settings layout: drill-down on mobile */ .settings-layout { flex-direction: column; } .settings-sidebar { width: 100%; - flex-direction: row; - overflow-x: auto; + flex-direction: column; border-right: none; - border-bottom: 1px solid var(--border); - padding: 0; + padding: 8px 0; } .settings-subtab { border-left: none; - border-bottom: 2px solid transparent; - white-space: nowrap; - padding: 8px 16px; + padding: 14px 20px; + text-align: left; + font-size: var(--text-base); + border-bottom: 1px solid var(--border); + } + .settings-subtab::after { + content: '\203A'; + float: right; + color: var(--text-secondary); + font-size: 18px; } .settings-subtab.active { border-left-color: transparent; - border-bottom-color: var(--accent); + color: var(--text); + } + .settings-layout > .settings-content { + display: none; + } + .settings-layout.settings-detail-active > .settings-sidebar { + display: none; + } + .settings-layout.settings-detail-active > .settings-content { + display: flex; + } + .settings-back-btn { + display: flex; + } + + .settings-theme-toggle { + display: block; + padding: 14px 20px; + text-align: left; + font-size: var(--text-base); + background: none; + border: none; + border-top: 1px solid var(--border); + color: var(--text-secondary); + cursor: pointer; + margin-top: auto; } /* Extension install form */ @@ -4057,7 +4452,7 @@ mark { .chat-input button { padding: 6px 16px; - font-size: 14px; + font-size: var(--text-base); } } @@ -4087,7 +4482,7 @@ mark { border-left: 2px solid transparent; color: var(--text-secondary); cursor: pointer; - font-size: 14px; + font-size: var(--text-base); font-weight: 500; text-align: left; transition: color 0.2s, background 0.2s, border-color 0.2s; @@ -4134,12 +4529,12 @@ mark { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 16px; + padding: var(--space-4); margin-bottom: 16px; } .settings-group-title { - font-size: 11px; + font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary); margin-bottom: 12px; @@ -4147,6 +4542,14 @@ mark { letter-spacing: 0.05em; padding-bottom: 8px; border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + z-index: 1; + margin: -16px -16px 12px -16px; + padding: 16px 16px 8px 16px; } .settings-row { @@ -4157,7 +4560,7 @@ mark { margin: 0 -12px; border-bottom: 1px solid rgba(255,255,255,0.04); border-radius: 6px; - gap: 16px; + gap: var(--space-4); max-height: 80px; overflow: hidden; transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease, padding 0.2s ease, background var(--transition-fast); @@ -4183,7 +4586,7 @@ mark { .settings-row:last-child { border-bottom: none; } .settings-label { - font-size: 13px; + font-size: var(--text-sm); color: var(--text); font-weight: 500; flex-shrink: 0; @@ -4196,33 +4599,67 @@ mark { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); font-family: 'IBM Plex Mono', monospace; width: 240px; max-width: 100%; } +.toggle-switch { + position: relative; + width: 44px; + height: 24px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 12px; + cursor: pointer; + transition: background 200ms ease, border-color 200ms ease; + flex-shrink: 0; +} + +.toggle-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--text-secondary); + transition: transform 200ms var(--ease-spring), background 200ms ease; +} + +.toggle-switch.on { + background: var(--accent-subtle); + border-color: var(--accent); +} + +.toggle-switch.on::after { + transform: translateX(20px); + background: var(--accent); +} + .settings-input:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); + box-shadow: 0 0 0 3px var(--accent-soft), var(--glow-accent); } .settings-saved-indicator { - font-size: 11px; + font-size: 12px; color: var(--success); opacity: 0; - transform: translateY(4px); - transition: opacity 0.3s ease, transform 0.3s ease; + transform: scale(0.5); + transition: opacity 300ms ease, transform 300ms var(--ease-spring); } .settings-saved-indicator.visible { opacity: 1; - transform: translateY(0); + transform: scale(1); } .settings-description { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); margin-top: 2px; } @@ -4252,7 +4689,7 @@ mark { border: none; border-radius: var(--radius); cursor: pointer; - font-size: 11px; + font-size: var(--text-xs); font-weight: 600; white-space: nowrap; transition: opacity var(--transition-fast); @@ -4275,7 +4712,7 @@ mark { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); font-family: 'IBM Plex Mono', monospace; width: 240px; max-width: 100%; @@ -4285,7 +4722,7 @@ mark { .settings-select:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.15); + box-shadow: 0 0 0 3px var(--accent-soft), var(--glow-accent); } input[type="checkbox"]:focus-visible { @@ -4320,7 +4757,7 @@ input[type="checkbox"]:focus-visible { .slash-ac-cmd { font-family: var(--font-mono); - font-size: 13px; + font-size: var(--text-sm); color: var(--accent); white-space: nowrap; min-width: 130px; @@ -4360,7 +4797,7 @@ input[type="checkbox"]:focus-visible { .image-preview-strip { display: flex; flex-direction: row; - gap: 8px; + gap: var(--space-2); padding: 4px; overflow-x: auto; min-height: 0; @@ -4433,7 +4870,7 @@ input[type="checkbox"]:focus-visible { color: var(--text-secondary); cursor: pointer; padding: 8px; - font-size: 16px; + font-size: var(--text-lg); border-radius: var(--radius); transition: all 0.2s; } @@ -4462,7 +4899,7 @@ input[type="checkbox"]:focus-visible { cursor: pointer; border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); transition: all 0.2s; } @@ -4486,7 +4923,7 @@ input[type="checkbox"]:focus-visible { .settings-toolbar { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); padding: 8px 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); @@ -4507,7 +4944,7 @@ input[type="checkbox"]:focus-visible { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); font-family: 'IBM Plex Mono', monospace; } @@ -4526,7 +4963,7 @@ input[type="checkbox"]:focus-visible { font-size: 12px; font-weight: 500; cursor: pointer; - transition: all var(--transition-fast); + transition: all var(--transition-fast), transform 150ms var(--ease-spring); white-space: nowrap; } @@ -4538,7 +4975,25 @@ input[type="checkbox"]:focus-visible { } .settings-toolbar-btn:active { - transform: scale(0.98); + transform: scale(0.97); +} + +.settings-back-btn { + display: none; + align-items: center; + background: none; + border: none; + color: var(--accent); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + padding: 4px 8px; + border-radius: var(--radius); + white-space: nowrap; +} + +.settings-back-btn:hover { + background: var(--bg-tertiary); } /* Confirmation modal */ @@ -4549,7 +5004,7 @@ input[type="checkbox"]:focus-visible { right: 0; bottom: 0; background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); + backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; @@ -4563,7 +5018,7 @@ input[type="checkbox"]:focus-visible { } @keyframes modalSlideIn { - from { opacity: 0; transform: translateY(10px) scale(0.98); } + from { opacity: 0; transform: translateY(10px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } @@ -4581,7 +5036,7 @@ input[type="checkbox"]:focus-visible { .modal h3 { margin: 0; padding: 16px 20px; - font-size: 16px; + font-size: var(--text-lg); color: var(--text); border-bottom: 1px solid var(--border); } @@ -4589,14 +5044,14 @@ input[type="checkbox"]:focus-visible { .modal p { margin: 0; padding: 16px 20px; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); } .modal-actions { display: flex; justify-content: flex-end; - gap: 8px; + gap: var(--space-2); padding: 12px 20px; border-top: 1px solid var(--border); } @@ -4608,7 +5063,7 @@ input[type="checkbox"]:focus-visible { border-radius: var(--radius); color: var(--text); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); } .btn-secondary:hover { @@ -4622,7 +5077,7 @@ input[type="checkbox"]:focus-visible { border-radius: var(--radius); color: white; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); } .btn-danger:hover { @@ -4661,7 +5116,7 @@ input[type="checkbox"]:focus-visible { align-items: center; justify-content: space-between; padding: 10px 12px; - gap: 16px; + gap: var(--space-4); } .skeleton-bar { @@ -4687,7 +5142,7 @@ input[type="checkbox"]:focus-visible { padding: 32px 16px; text-align: center; color: var(--text-muted); - font-size: 13px; + font-size: var(--text-sm); } /* Screen-reader only utility */ @@ -4761,6 +5216,39 @@ input[type="checkbox"]:focus-visible { --overlay-heavy: rgba(0, 0, 0, 0.4); --highlight-bg: rgba(5, 150, 105, 0.2); --hover-subtle: rgba(0, 0, 0, 0.04); + --shadow-sm: 0 1px 2px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04); + --glow-accent: 0 0 20px rgba(5,150,105,0.08); + --glass-bg: rgba(255,255,255,0.85); + --glass-blur: blur(16px) saturate(180%); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-spring-gentle: cubic-bezier(0.22, 1.2, 0.36, 1); + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --surface-highlight: inset 0 1px 0 rgba(255,255,255,0.8); + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --text-xs: 11px; + --text-sm: 13px; + --text-base: 14px; + --text-lg: 16px; + --text-xl: 20px; + --text-2xl: 24px; + --text-3xl: 36px; + --transition-slow: 300ms ease; + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --duration-instant: 100ms; + --duration-fast: 150ms; + --duration-base: 250ms; + --duration-slow: 400ms; + --accent-soft: var(--accent-subtle); + --accent-dim: var(--accent-subtle); + --bg-hover: var(--hover-surface); + --danger-soft: var(--danger-subtle); + --warning-soft: var(--warning-subtle); } /* ============================================================ @@ -4796,8 +5284,148 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) { border-color: var(--text-secondary); } +.settings-theme-toggle { + display: none; +} + /* CSS-only icon switching via data-theme-mode on */ .theme-icon { display: none; } [data-theme-mode="dark"] .icon-dark { display: block; } [data-theme-mode="light"] .icon-light { display: block; } [data-theme-mode="system"] .icon-system { display: block; } + +/* ============================================================ + Phase 6: Accessibility & Mobile Polish + ============================================================ */ + +/* Touch target audit */ +@media (pointer: coarse) { + .approval-card button, + .message-copy-btn, + .toggle-switch, + .welcome-chip, + .code-block-copy, + .copy-btn, + .tree-row { + min-height: 44px; + min-width: 44px; + } +} + +/* Mobile bottom sheet modals */ +@media (max-width: 768px) { + .modal-overlay { + align-items: flex-end; + } + + .modal { + width: 100%; + max-width: 100%; + border-radius: 12px 12px 0 0; + animation: bottomSheetSlideIn 300ms var(--ease-out-expo); + max-height: 85vh; + overflow-y: auto; + } +} + +@keyframes bottomSheetSlideIn { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +/* Mobile bottom tab bar */ +@media (max-width: 768px) { + .tab-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + top: auto; + z-index: 100; + border-bottom: none; + border-top: 1px solid var(--border); + padding-bottom: env(safe-area-inset-bottom); + overflow-x: visible; + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: 0 -2px 12px rgba(0,0,0,0.15); + } + + .tab-bar button:not(.status-logs-btn):not(.restart-btn):not(.language-btn) { + flex: 1; + text-align: center; + padding: 10px 4px; + } + + .tab-bar .spacer, + .tab-bar .language-switcher, + .tab-bar .tee-shield, + .tab-bar .restart-btn { + display: none; + } + + .tab-bar .status { + display: none; + } + + .tab-bar .status-logs-btn { + display: none; + } + + .tab-bar .theme-toggle-btn { + display: none; + } + + .tab-indicator { + top: 0; + bottom: auto; + } + + #app { + padding-bottom: 52px; + } +} + +/* Job status badge pulse */ +.badge.in_progress { + background: var(--accent-soft); + color: var(--accent); + position: relative; + padding-left: 18px; +} + +.badge.in_progress::before { + content: ''; + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + animation: statusPulse 2s ease-out infinite; +} + +@keyframes statusPulse { + 0% { transform: translateY(-50%) scale(0.8); opacity: 0.6; } + 100% { transform: translateY(-50%) scale(1.8); opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +@media (prefers-contrast: more) { + :root { + --border: rgba(255, 255, 255, 0.2); + --text-secondary: #d4d4d8; + --text-muted: #a1a1aa; + } +} diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 50c261c5..3ac4163c 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -254,6 +254,16 @@ pub enum SseEvent { thread_id: Option, }, + /// Per-turn token usage and cost summary. + #[serde(rename = "turn_cost")] + TurnCost { + input_tokens: u64, + output_tokens: u64, + cost_usd: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + /// Extension activation status change (WASM channels). #[serde(rename = "extension_status")] ExtensionStatus { @@ -797,6 +807,7 @@ impl WsServerMessage { SseEvent::JobResult { .. } => "job_result", SseEvent::ImageGenerated { .. } => "image_generated", SseEvent::Suggestions { .. } => "suggestions", + SseEvent::TurnCost { .. } => "turn_cost", SseEvent::ExtensionStatus { .. } => "extension_status", }; let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); diff --git a/src/channels/webhook_server.rs b/src/channels/webhook_server.rs index 228abf0a..7463ec3b 100644 --- a/src/channels/webhook_server.rs +++ b/src/channels/webhook_server.rs @@ -68,7 +68,7 @@ impl WebhookServer { reason: format!("Failed to bind to {}: {}", self.config.addr, e), })?; - tracing::info!("Webhook server listening on {}", self.config.addr); + tracing::debug!("Webhook server listening on {}", self.config.addr); let (shutdown_tx, shutdown_rx) = oneshot::channel(); self.shutdown_tx = Some(shutdown_tx); @@ -129,7 +129,7 @@ impl WebhookServer { }); self.handle = Some(handle); - tracing::info!("Webhook server listening on {}", new_addr); + tracing::debug!("Webhook server listening on {}", new_addr); (old_shutdown_tx, old_handle) } diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 7510635a..5d13ade6 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -7,12 +7,13 @@ use std::path::PathBuf; use crate::bootstrap::ironclaw_base_dir; +use crate::cli::fmt; use crate::settings::Settings; /// Run all diagnostic checks and print results. pub async fn run_doctor_command() -> anyhow::Result<()> { - println!("IronClaw Doctor"); - println!("===============\n"); + println!(); + println!(" {}IronClaw Doctor{}", fmt::bold(), fmt::reset()); let mut passed = 0u32; let mut failed = 0u32; @@ -21,7 +22,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> { // Load settings once for checks that need them. let settings = Settings::load(); - // ── Settings & core config ───────────────────────────────── + // ── Core ───────────────────────────────────────────────── + + section_header("Core"); check( "Settings file", @@ -63,7 +66,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> { &mut skipped, ); - // ── Subsystem configuration checks ───────────────────────── + // ── Features ───────────────────────────────────────────── + + section_header("Features"); check( "Embeddings", @@ -121,7 +126,9 @@ pub async fn run_doctor_command() -> anyhow::Result<()> { &mut skipped, ); - // ── External binary checks ──────────────────────────────── + // ── External ───────────────────────────────────────────── + + section_header("External"); check( "Docker daemon", @@ -158,7 +165,18 @@ pub async fn run_doctor_command() -> anyhow::Result<()> { // ── Summary ─────────────────────────────────────────────── println!(); - println!(" {passed} passed, {failed} failed, {skipped} skipped"); + println!( + " {}{} passed{}, {}{} failed{}, {}{} skipped{}", + fmt::success(), + passed, + fmt::reset(), + if failed > 0 { fmt::error() } else { fmt::dim() }, + failed, + fmt::reset(), + fmt::dim(), + skipped, + fmt::reset(), + ); if failed > 0 { println!("\n Some checks failed. This is normal if you don't use those features."); @@ -167,21 +185,38 @@ pub async fn run_doctor_command() -> anyhow::Result<()> { Ok(()) } +/// Print a section header with a separator and bold group name. +fn section_header(name: &str) { + println!(); + println!(" {}", fmt::separator(36)); + println!(" {}{}{}", fmt::bold(), name, fmt::reset()); + println!(); +} + // ── Individual checks ─────────────────────────────────────── fn check(name: &str, result: CheckResult, passed: &mut u32, failed: &mut u32, skipped: &mut u32) { match result { CheckResult::Pass(detail) => { *passed += 1; - println!(" [pass] {name}: {detail}"); + println!( + "{}", + fmt::check_line(fmt::StatusKind::Pass, name, &detail, 18) + ); } CheckResult::Fail(detail) => { *failed += 1; - println!(" [FAIL] {name}: {detail}"); + println!( + "{}", + fmt::check_line(fmt::StatusKind::Fail, name, &detail, 18) + ); } CheckResult::Skip(reason) => { *skipped += 1; - println!(" [skip] {name}: {reason}"); + println!( + "{}", + fmt::check_line(fmt::StatusKind::Skip, name, &reason, 18) + ); } } } diff --git a/src/cli/fmt.rs b/src/cli/fmt.rs new file mode 100644 index 00000000..79763477 --- /dev/null +++ b/src/cli/fmt.rs @@ -0,0 +1,296 @@ +//! Shared terminal design system. +//! +//! Centralizes color tokens, rendering primitives, and width detection +//! for consistent CLI output. Respects `NO_COLOR` env var and non-TTY +//! output (piping to file, CI, etc.). + +use std::io::IsTerminal; + +// ── Color detection ───────────────────────────────────────── + +/// Returns `true` when ANSI colors should be emitted. +/// +/// Disabled when: +/// - `NO_COLOR` env var is set (any value — per ) +/// - stdout is not a terminal (pipe, file redirect, CI) +fn colors_enabled() -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + std::io::stdout().is_terminal() +} + +/// Returns `true` when the terminal supports 24-bit true-color. +/// +/// Checks `$COLORTERM` for `truecolor` or `24bit`. +fn truecolor_enabled() -> bool { + std::env::var("COLORTERM") + .map(|v| v.eq_ignore_ascii_case("truecolor") || v.eq_ignore_ascii_case("24bit")) + .unwrap_or(false) +} + +// ── Color tokens ──────────────────────────────────────────── + +/// Emerald green accent — primary brand color. +/// +/// Uses true-color `#34d399` when supported, falls back to basic green. +pub fn accent() -> &'static str { + if !colors_enabled() { + return ""; + } + if truecolor_enabled() { + "\x1b[38;2;52;211;153m" + } else { + "\x1b[32m" + } +} + +/// Bold text. +pub fn bold() -> &'static str { + if colors_enabled() { "\x1b[1m" } else { "" } +} + +/// Green — success indicators. +pub fn success() -> &'static str { + if colors_enabled() { "\x1b[32m" } else { "" } +} + +/// Yellow — warning indicators. +pub fn warning() -> &'static str { + if colors_enabled() { "\x1b[33m" } else { "" } +} + +/// Red — error indicators. +pub fn error() -> &'static str { + if colors_enabled() { "\x1b[31m" } else { "" } +} + +/// Dim gray — labels, secondary text. +pub fn dim() -> &'static str { + if colors_enabled() { "\x1b[90m" } else { "" } +} + +/// Yellow underline — URLs and links. +pub fn link() -> &'static str { + if colors_enabled() { "\x1b[33;4m" } else { "" } +} + +/// Bold accent — commands and interactive elements. +/// +/// Uses bold + true-color emerald when supported, falls back to bold green. +pub fn bold_accent() -> &'static str { + if !colors_enabled() { + return ""; + } + if truecolor_enabled() { + "\x1b[1;38;2;52;211;153m" + } else { + "\x1b[1;32m" + } +} + +/// Dim italic — contextual tips and hints. +pub fn hint() -> &'static str { + if colors_enabled() { "\x1b[2;3m" } else { "" } +} + +/// Reset all attributes. +pub fn reset() -> &'static str { + if colors_enabled() { "\x1b[0m" } else { "" } +} + +// ── Width detection ───────────────────────────────────────── + +/// Detect terminal width, clamped to [40, 120]. +pub fn term_width() -> usize { + crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(80) + .clamp(40, 120) +} + +// ── Rendering primitives ──────────────────────────────────── + +/// Horizontal separator line (dim `─` characters). +pub fn separator(width: usize) -> String { + format!("{}{}{}", dim(), "\u{2500}".repeat(width), reset()) +} + +/// Key-value line with right-padded dim key and accent value. +/// +/// ```text +/// Database libsql (connected) +/// ``` +pub fn kv_line(key: &str, value: &str, key_width: usize) -> String { + format!( + " {}{: String { + match kind { + StatusKind::Pass => format!("{}\u{2713}{}", success(), reset()), + StatusKind::Fail => format!("{}\u{2717}{}", error(), reset()), + StatusKind::Skip => format!("{}\u{25CB}{}", dim(), reset()), + } +} + +/// Kind of status check result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusKind { + Pass, + Fail, + Skip, +} + +/// Top border of a box with an optional label. +/// +/// ```text +/// ┌─ label ──────────────────┐ +/// ``` +pub fn box_top(label: &str, width: usize) -> String { + if label.is_empty() { + let fill = width.saturating_sub(2); + return format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(fill)); + } + let label_part = format!(" {} ", label); + // ┌ (1) + ─ (1) + label_part + fill + ┐ (1) = width + let fill = width.saturating_sub(label_part.len() + 3); + format!( + "\u{250C}\u{2500}{}{}{}\u{2510}", + bold(), + label_part, + reset(), + ) + .replace("\u{2510}", &format!("{}\u{2510}", "\u{2500}".repeat(fill))) +} + +/// Content line inside a box. +/// +/// ```text +/// │ content │ +/// ``` +pub fn box_line(content: &str, width: usize) -> String { + let inner = width.saturating_sub(4); // │ + space + space + │ + let padded = if content.len() >= inner { + content.to_string() + } else { + format!("{}{}", content, " ".repeat(inner - content.len())) + }; + format!("\u{2502} {} \u{2502}", padded) +} + +/// Bottom border of a box. +/// +/// ```text +/// └──────────────────────────┘ +/// ``` +pub fn box_bottom(width: usize) -> String { + let fill = width.saturating_sub(2); + format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(fill)) +} + +/// Format a check result line for doctor/status commands. +/// +/// ```text +/// ✓ Database libsql (connected) +/// ✗ Docker not running — start with: open -a Docker +/// ○ Embeddings disabled +/// ``` +pub fn check_line(kind: StatusKind, name: &str, detail: &str, name_width: usize) -> String { + format!( + " {} {:= 40); + assert!(w <= 120); + } + + /// Strip ANSI escape sequences for visible-character counting. + fn strip_ansi(s: &str) -> String { + let mut result = String::new(); + let mut in_escape = false; + for c in s.chars() { + if c == '\x1b' { + in_escape = true; + continue; + } + if in_escape { + if c == 'm' { + in_escape = false; + } + continue; + } + result.push(c); + } + result + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index cc662eb9..9340e54f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -18,6 +18,7 @@ mod channels; mod completion; mod config; mod doctor; +pub mod fmt; mod hooks; #[cfg(feature = "import")] pub mod import; @@ -111,16 +112,20 @@ pub enum Command { skip_auth: bool, /// Reconfigure channels only - #[arg(long, conflicts_with_all = ["provider_only", "quick"])] + #[arg(long, conflicts_with_all = ["provider_only", "quick", "step"], help = "Deprecated: use --step channels")] channels_only: bool, /// Reconfigure LLM provider and model only - #[arg(long, conflicts_with_all = ["channels_only", "quick"])] + #[arg(long, conflicts_with_all = ["channels_only", "quick", "step"], help = "Deprecated: use --step provider")] provider_only: bool, /// Quick setup: auto-defaults everything except LLM provider and model - #[arg(long, conflicts_with_all = ["channels_only", "provider_only"])] + #[arg(long, conflicts_with_all = ["channels_only", "provider_only", "step"])] quick: bool, + + /// Run only specific setup steps (comma-separated: provider, channels, model, database, security) + #[arg(long, value_delimiter = ',', conflicts_with_all = ["channels_only", "provider_only", "quick"])] + step: Vec, }, /// Manage configuration settings diff --git a/src/cli/status.rs b/src/cli/status.rs index 6f953b5e..3ae825ee 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; use crate::bootstrap::ironclaw_base_dir; +use crate::cli::fmt; use crate::settings::Settings; /// Load settings from JSON and TOML config files, matching the runtime @@ -38,22 +39,25 @@ fn load_settings_from(json_path: &std::path::Path, toml_path: &std::path::Path) pub async fn run_status_command() -> anyhow::Result<()> { let settings = load_settings(); - println!("IronClaw Status"); - println!("===============\n"); + println!(); + println!(" {}IronClaw Status{}", fmt::bold(), fmt::reset()); + println!(); // Version println!( - " Version: {} v{}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION") + "{}", + fmt::kv_line( + "Version", + &format!("{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")), + 12, + ) ); // Database - print!(" Database: "); let db_backend = std::env::var("DATABASE_BACKEND") .ok() .unwrap_or_else(|| "postgres".to_string()); - match db_backend.as_str() { + let db_value = match db_backend.as_str() { "libsql" | "turso" | "sqlite" => { let path = std::env::var("LIBSQL_PATH") .map(std::path::PathBuf::from) @@ -64,77 +68,77 @@ pub async fn run_status_command() -> anyhow::Result<()> { } else { "" }; - println!("libSQL ({}{})", path.display(), turso); + format!("libSQL ({}{})", path.display(), turso) } else { - println!("libSQL (file missing: {})", path.display()); + format!("libSQL (file missing: {})", path.display()) } } _ => { if std::env::var("DATABASE_URL").is_ok() { match check_database().await { - Ok(()) => println!("connected (PostgreSQL)"), - Err(e) => println!("error ({})", e), + Ok(()) => "connected (PostgreSQL)".to_string(), + Err(e) => format!("error ({})", e), } } else { - println!("not configured"); + "not configured".to_string() } } - } + }; + println!("{}", fmt::kv_line("Database", &db_value, 12)); // Session / Auth - print!(" Session: "); let session_path = crate::config::llm::default_session_path(); - if session_path.exists() { - println!("found ({})", session_path.display()); + let session_value = if session_path.exists() { + format!("found ({})", session_path.display()) } else { - println!("not found (run `ironclaw onboard`)"); - } + "not found (run `ironclaw onboard`)".to_string() + }; + println!("{}", fmt::kv_line("Session", &session_value, 12)); // Secrets (auto-detect from env only; skip keychain probe to avoid // triggering macOS system password dialogs on a simple status check) - print!(" Secrets: "); - if std::env::var("SECRETS_MASTER_KEY").is_ok() { - println!("configured (env)"); + let secrets_value = if std::env::var("SECRETS_MASTER_KEY").is_ok() { + "configured (env)".to_string() } else { // We don't probe the keychain here because get_generic_password() // triggers macOS unlock+authorization dialogs, which is bad UX for // a read-only status command. If onboarding completed with keychain // storage, the key is there; we just can't cheaply verify it. - println!("env not set (keychain may be configured)"); - } + "env not set (keychain may be configured)".to_string() + }; + println!("{}", fmt::kv_line("Secrets", &secrets_value, 12)); // Embeddings - print!(" Embeddings: "); let emb_enabled = settings.embeddings.enabled || std::env::var("OPENAI_API_KEY").is_ok() || std::env::var("EMBEDDING_ENABLED") .map(|v| v == "true") .unwrap_or(false); - if emb_enabled { - println!( + let emb_value = if emb_enabled { + format!( "enabled (provider: {}, model: {})", settings.embeddings.provider, settings.embeddings.model - ); + ) } else { - println!("disabled"); - } + "disabled".to_string() + }; + println!("{}", fmt::kv_line("Embeddings", &emb_value, 12)); // WASM tools - print!(" WASM Tools: "); let tools_dir = settings .wasm .tools_dir .clone() .unwrap_or_else(default_tools_dir); - if tools_dir.exists() { + let tools_value = if tools_dir.exists() { let count = count_wasm_files(&tools_dir); - println!("{} installed ({})", count, tools_dir.display()); + format!("{} installed ({})", count, tools_dir.display()) } else { - println!("directory not found ({})", tools_dir.display()); - } + format!("directory not found ({})", tools_dir.display()) + }; + println!("{}", fmt::kv_line("WASM Tools", &tools_value, 12)); // WASM channels - print!(" Channels: "); let channels_dir = settings .channels .wasm_channels_dir @@ -153,35 +157,40 @@ pub async fn run_status_command() -> anyhow::Result<()> { channel_info.push(format!("{} wasm", wasm_count)); } } - println!("{}", channel_info.join(", ")); + println!("{}", fmt::kv_line("Channels", &channel_info.join(", "), 12)); // Heartbeat - print!(" Heartbeat: "); let hb_enabled = settings.heartbeat.enabled || std::env::var("HEARTBEAT_ENABLED") .map(|v| v == "true") .unwrap_or(false); - if hb_enabled { - println!("enabled (interval: {}s)", settings.heartbeat.interval_secs); + let hb_value = if hb_enabled { + format!("enabled (interval: {}s)", settings.heartbeat.interval_secs) } else { - println!("disabled"); - } + "disabled".to_string() + }; + println!("{}", fmt::kv_line("Heartbeat", &hb_value, 12)); // MCP servers - print!(" MCP Servers: "); - match crate::tools::mcp::config::load_mcp_servers().await { + let mcp_value = match crate::tools::mcp::config::load_mcp_servers().await { Ok(servers) => { let enabled = servers.servers.iter().filter(|s| s.enabled).count(); let total = servers.servers.len(); - println!("{} enabled / {} configured", enabled, total); + format!("{} enabled / {} configured", enabled, total) } - Err(_) => println!("none configured"), - } + Err(_) => "none configured".to_string(), + }; + println!("{}", fmt::kv_line("MCP Servers", &mcp_value, 12)); // Config path + println!(); println!( - "\n Config: {}", - crate::bootstrap::ironclaw_env_path().display() + "{}", + fmt::kv_line( + "Config", + &crate::bootstrap::ironclaw_env_path().display().to_string(), + 12, + ) ); Ok(()) diff --git a/src/db/libsql/workspace.rs b/src/db/libsql/workspace.rs index 01c47742..d43f1277 100644 --- a/src/db/libsql/workspace.rs +++ b/src/db/libsql/workspace.rs @@ -36,7 +36,7 @@ pub(crate) fn resolve_embedding_dimension() -> Option { .unwrap_or(false); if !enabled { - tracing::info!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)"); + tracing::debug!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)"); return None; } diff --git a/src/db/mod.rs b/src/db/mod.rs index d960ebaf..900d1810 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -97,7 +97,7 @@ pub async fn connect_with_handles( .map_err(|e| DatabaseError::Pool(e.to_string()))? }; backend.run_migrations().await?; - tracing::info!("libSQL database connected and migrations applied"); + tracing::debug!("libSQL database connected and migrations applied"); handles.libsql_db = Some(backend.shared_db()); diff --git a/src/main.rs b/src/main.rs index 8d80c3f5..3fbd0453 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,10 +38,49 @@ fn main() -> anyhow::Result<()> { let _ = dotenvy::dotenv(); ironclaw::bootstrap::load_ironclaw_env(); - tokio::runtime::Builder::new_multi_thread() + let result = tokio::runtime::Builder::new_multi_thread() .enable_all() .build()? - .block_on(async_main()) + .block_on(async_main()); + + if let Err(ref e) = result { + format_top_level_error(e); + } + result +} + +/// Format a top-level error with color and recovery hints. +fn format_top_level_error(err: &anyhow::Error) { + use ironclaw::cli::fmt; + let msg = format!("{err:#}"); + + eprintln!(); + eprintln!(" {}\u{2717}{} {}", fmt::error(), fmt::reset(), msg); + + // Provide recovery hints for common errors + let lower = msg.to_ascii_lowercase(); + let hint = if lower.contains("database_url") + || lower.contains("database") && lower.contains("not set") + { + Some("run `ironclaw onboard` or set DATABASE_URL in .env") + } else if lower.contains("connection refused") || lower.contains("connect error") { + Some("check that the database server is running") + } else if lower.contains("session") && lower.contains("not found") { + Some("run `ironclaw onboard` to set up authentication") + } else if lower.contains("secrets_master_key") { + Some("run `ironclaw onboard` or set SECRETS_MASTER_KEY in .env") + } else if lower.contains("already running") { + Some("stop the other instance or remove the stale PID file") + } else if lower.contains("onboard") { + Some("run `ironclaw onboard` to complete setup") + } else { + None + }; + + if let Some(hint_text) = hint { + eprintln!(" {}hint:{} {}", fmt::dim(), fmt::reset(), hint_text,); + } + eprintln!(); } async fn async_main() -> anyhow::Result<()> { @@ -190,6 +229,7 @@ async fn async_main() -> anyhow::Result<()> { channels_only, provider_only, quick, + step, }) => { #[cfg(any(feature = "postgres", feature = "libsql"))] { @@ -198,6 +238,7 @@ async fn async_main() -> anyhow::Result<()> { channels_only: *channels_only, provider_only: *provider_only, quick: *quick, + steps: step.clone(), }; let mut wizard = SetupWizard::try_with_config_and_toml(config, cli.config.as_deref())?; @@ -205,7 +246,7 @@ async fn async_main() -> anyhow::Result<()> { } #[cfg(not(any(feature = "postgres", feature = "libsql")))] { - let _ = (skip_auth, channels_only, provider_only, quick); + let _ = (skip_auth, channels_only, provider_only, quick, step); eprintln!("Onboarding wizard requires the 'postgres' or 'libsql' feature."); } return Ok(()); @@ -233,6 +274,8 @@ async fn async_main() -> anyhow::Result<()> { } }; + let startup_start = std::time::Instant::now(); + // ── Agent startup ────────────────────────────────────────────────── // Enhanced first-run detection @@ -691,6 +734,7 @@ async fn async_main() -> anyhow::Result<()> { .and_then(|t| t.public_url()) .or_else(|| config.tunnel.public_url.clone()), tunnel_provider: active_tunnel.as_ref().map(|t| t.name().to_string()), + startup_elapsed: Some(startup_start.elapsed()), }; ironclaw::boot_screen::print_boot_screen(&boot_info); } diff --git a/src/setup/prompts.rs b/src/setup/prompts.rs index ac271cf2..37f9970f 100644 --- a/src/setup/prompts.rs +++ b/src/setup/prompts.rs @@ -123,15 +123,32 @@ pub fn select_many(prompt: &str, options: &[(&str, bool)]) -> io::Result" } else { " " }; - if i == cursor_pos { + // Cursor line: cyan cursor, then colored checkbox execute!(stdout, SetForegroundColor(Color::Cyan))?; - writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?; + write!(stdout, " \u{25b8} ")?; + if selected[i] { + execute!(stdout, SetForegroundColor(Color::Green))?; + write!(stdout, "[\u{2713}]")?; + } else { + execute!(stdout, SetForegroundColor(Color::DarkGrey))?; + write!(stdout, "[\u{00b7}]")?; + } + execute!(stdout, SetForegroundColor(Color::Cyan))?; + writeln!(stdout, " {}\r", label)?; execute!(stdout, ResetColor)?; } else { - writeln!(stdout, " {} {} {}\r", prefix, checkbox, label)?; + write!(stdout, " ")?; + if selected[i] { + execute!(stdout, SetForegroundColor(Color::Green))?; + write!(stdout, "[\u{2713}]")?; + execute!(stdout, ResetColor)?; + } else { + execute!(stdout, SetForegroundColor(Color::DarkGrey))?; + write!(stdout, "[\u{00b7}]")?; + execute!(stdout, ResetColor)?; + } + writeln!(stdout, " {}\r", label)?; } } @@ -284,18 +301,12 @@ pub fn confirm(prompt: &str, default: bool) -> io::Result { }) } -/// Print the IronClaw ASCII art banner in blue. +/// Print a minimal wordmark banner. pub fn print_banner() { - let mut stdout = io::stdout(); - let _ = execute!(stdout, SetForegroundColor(Color::Cyan)); + use crate::cli::fmt; + println!(); + println!(" {}ironclaw{}", fmt::bold_accent(), fmt::reset()); println!(); - println!(r" ██╗██████╗ ██████╗ ███╗ ██╗ ██████╗██╗ █████╗ ██╗ ██╗"); - println!(r" ██║██╔══██╗██╔═══██╗████╗ ██║██╔════╝██║ ██╔══██╗██║ ██║"); - println!(r" ██║██████╔╝██║ ██║██╔██╗ ██║██║ ██║ ███████║██║ █╗ ██║"); - println!(r" ██║██╔══██╗██║ ██║██║╚██╗██║██║ ██║ ██╔══██║██║███╗██║"); - println!(r" ██║██║ ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║ ██║╚███╔███╔╝"); - println!(r" ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ "); - let _ = execute!(stdout, ResetColor); } /// Print a styled header box. @@ -310,24 +321,38 @@ pub fn print_header(text: &str) { let border = "─".repeat(width); println!(); - println!("╭{}╮", border); + println!("┌{}┐", border); println!("│ {} │", text); - println!("╰{}╯", border); + println!("└{}┘", border); println!(); } -/// Print a step indicator. +/// Print a compact dot-based step indicator. +/// +/// `●` = completed (green/success), `◉` = current (accent), `○` = remaining (dim). /// /// # Example /// /// ```ignore -/// print_step(1, 3, "NEAR AI Authentication"); -/// // Output: Step 1/3: NEAR AI Authentication -/// // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/// print_step(3, 5, "Model Selection"); +/// // Output: ● ● ◉ ○ ○ Model Selection /// ``` pub fn print_step(current: usize, total: usize, name: &str) { - println!("Step {}/{}: {}", current, total, name); - println!("{}", "━".repeat(32)); + use crate::cli::fmt; + let mut dots = String::new(); + for i in 1..=total { + if i > 1 { + dots.push(' '); + } + if i < current { + dots.push_str(&format!("{}\u{25CF}{}", fmt::success(), fmt::reset())); // ● green + } else if i == current { + dots.push_str(&format!("{}\u{25C9}{}", fmt::accent(), fmt::reset())); // ◉ accent + } else { + dots.push_str(&format!("{}\u{25CB}{}", fmt::dim(), fmt::reset())); // ○ dim + } + } + println!(" {} {}", dots, name); println!(); } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 3bdccc0b..b7669070 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -84,6 +84,8 @@ pub struct SetupConfig { pub provider_only: bool, /// Quick setup: auto-defaults everything except LLM provider and model. pub quick: bool, + /// Run only specific setup steps (e.g. "provider", "channels", "model", "database", "security"). + pub steps: Vec, } /// Interactive setup wizard for IronClaw. @@ -188,6 +190,55 @@ impl SetupWizard { print_banner(); print_header("IronClaw Setup Wizard"); + if !self.config.steps.is_empty() { + // Selective step mode: reconnect to existing DB and load settings, + // then run only the requested steps. + self.reconnect_existing_db().await?; + + let valid_steps = ["provider", "channels", "model", "database", "security"]; + for s in &self.config.steps { + if !valid_steps.contains(&s.as_str()) { + return Err(SetupError::Config(format!( + "Unknown step '{}'. Valid steps: {}", + s, + valid_steps.join(", ") + ))); + } + } + + let total = self.config.steps.len(); + for (i, step_name) in self.config.steps.clone().iter().enumerate() { + let step_num = i + 1; + match step_name.as_str() { + "database" => { + print_step(step_num, total, "Database Connection"); + self.step_database().await?; + } + "security" => { + print_step(step_num, total, "Security"); + self.step_security().await?; + } + "provider" => { + print_step(step_num, total, "Inference Provider"); + self.step_inference_provider().await?; + } + "model" => { + print_step(step_num, total, "Model Selection"); + self.step_model_selection().await?; + } + "channels" => { + print_step(step_num, total, "Channel Configuration"); + self.step_channels().await?; + } + _ => {} // already validated above + } + self.persist_after_step().await; + } + + self.save_and_summarize().await?; + return Ok(()); + } + if self.config.channels_only { // Channels-only mode: reconnect to existing DB and load settings // before running the channel step, so secrets and save work. @@ -220,23 +271,23 @@ impl SetupWizard { // Pre-populate backend from env so step_inference_provider // can offer "Keep current provider?" instead of asking from scratch. if self.settings.llm_backend.is_none() { - use crate::config::helpers::env_or_override; - if let Some(b) = env_or_override("LLM_BACKEND") - && !b.trim().is_empty() - { - self.settings.llm_backend = Some(b.trim().to_string()); - } else if env_or_override("NEARAI_API_KEY").is_some() { + if let Ok(b) = std::env::var("LLM_BACKEND") { + self.settings.llm_backend = Some(b); + } else if std::env::var("NEARAI_API_KEY").is_ok() { self.settings.llm_backend = Some("nearai".to_string()); - } else if env_or_override("ANTHROPIC_API_KEY").is_some() - || env_or_override("ANTHROPIC_OAUTH_TOKEN").is_some() + } else if std::env::var("ANTHROPIC_API_KEY").is_ok() + || std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok() { self.settings.llm_backend = Some("anthropic".to_string()); - } else if env_or_override("OPENAI_API_KEY").is_some() { + } else if std::env::var("OPENAI_API_KEY").is_ok() { self.settings.llm_backend = Some("openai".to_string()); + } else if std::env::var("OPENROUTER_API_KEY").is_ok() { + self.settings.llm_backend = Some("openrouter".to_string()); } } - if let Some(api_key) = crate::config::helpers::env_or_override("NEARAI_API_KEY") + if let Ok(api_key) = std::env::var("NEARAI_API_KEY") + && !api_key.is_empty() && self.settings.llm_backend.as_deref() == Some("nearai") { // NEARAI_API_KEY is set and backend auto-detected — skip interactive prompts @@ -254,6 +305,79 @@ impl SetupWizard { print_info(&format!("Using default model: {default}")); } self.persist_after_step().await; + } else if self.settings.llm_backend.as_deref() == Some("anthropic") + && let Some(api_key) = Self::detect_anthropic_key() + { + // Anthropic key detected — skip interactive prompts + print_info("Anthropic credentials found — using Anthropic provider"); + let secret_name = if api_key.starts_with("sk-ant-oat") { + "llm_anthropic_oauth_token" + } else { + "llm_anthropic_api_key" + }; + if let Ok(ctx) = self.init_secrets_context().await { + let key = SecretString::from(api_key.clone()); + if let Err(e) = ctx.save_secret(secret_name, &key).await { + tracing::warn!("Failed to persist Anthropic key to secrets: {}", e); + } + } + self.llm_api_key = Some(SecretString::from(api_key)); + let registry = crate::llm::ProviderRegistry::load(); + if self.settings.selected_model.is_none() { + let default = registry + .find("anthropic") + .map(|d| d.default_model.as_str()) + .unwrap_or("claude-sonnet-4-20250514"); + self.settings.selected_model = Some(default.to_string()); + print_info(&format!("Using default model: {default}")); + } + self.persist_after_step().await; + } else if let Ok(api_key) = std::env::var("OPENAI_API_KEY") + && !api_key.is_empty() + && self.settings.llm_backend.as_deref() == Some("openai") + { + // OpenAI key detected — skip interactive prompts + print_info("OPENAI_API_KEY found — using OpenAI provider"); + if let Ok(ctx) = self.init_secrets_context().await { + let key = SecretString::from(api_key.clone()); + if let Err(e) = ctx.save_secret("llm_openai_api_key", &key).await { + tracing::warn!("Failed to persist OPENAI_API_KEY to secrets: {}", e); + } + } + self.llm_api_key = Some(SecretString::from(api_key)); + let registry = crate::llm::ProviderRegistry::load(); + if self.settings.selected_model.is_none() { + let default = registry + .find("openai") + .map(|d| d.default_model.as_str()) + .unwrap_or("gpt-5-mini"); + self.settings.selected_model = Some(default.to_string()); + print_info(&format!("Using default model: {default}")); + } + self.persist_after_step().await; + } else if let Ok(api_key) = std::env::var("OPENROUTER_API_KEY") + && !api_key.is_empty() + && self.settings.llm_backend.as_deref() == Some("openrouter") + { + // OpenRouter key detected — skip interactive prompts + print_info("OPENROUTER_API_KEY found — using OpenRouter provider"); + if let Ok(ctx) = self.init_secrets_context().await { + let key = SecretString::from(api_key.clone()); + if let Err(e) = ctx.save_secret("llm_openrouter_api_key", &key).await { + tracing::warn!("Failed to persist OPENROUTER_API_KEY to secrets: {}", e); + } + } + self.llm_api_key = Some(SecretString::from(api_key)); + let registry = crate::llm::ProviderRegistry::load(); + if self.settings.selected_model.is_none() { + let default = registry + .find("openrouter") + .map(|d| d.default_model.as_str()) + .unwrap_or("openai/gpt-4o"); + self.settings.selected_model = Some(default.to_string()); + print_info(&format!("Using default model: {default}")); + } + self.persist_after_step().await; } else { print_step(1, 2, "Inference Provider"); self.step_inference_provider().await?; @@ -1132,33 +1256,98 @@ impl SetupWizard { // Build menu: NearAI first, then Gemini OAuth, then OpenAI Codex, then registry providers, then Bedrock let selectable = registry.selectable(); - let mut options: Vec = Vec::with_capacity(3 + selectable.len()); - let mut provider_ids: Vec = Vec::with_capacity(3 + selectable.len()); - options.push("NEAR AI - multi-model access via NEAR account".to_string()); - provider_ids.push("nearai".to_string()); - options.push("Gemini CLI - Official Gemini API via Gemini CLI OAuth".to_string()); - provider_ids.push("gemini_oauth".to_string()); + // Detect which providers have API keys already set in the environment. + let detected_env: HashMap<&str, bool> = [ + ("nearai", std::env::var("NEARAI_API_KEY").is_ok()), + ( + "anthropic", + std::env::var("ANTHROPIC_API_KEY").is_ok() + || std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok(), + ), + ("openai", std::env::var("OPENAI_API_KEY").is_ok()), + ("openrouter", std::env::var("OPENROUTER_API_KEY").is_ok()), + ] + .into_iter() + .collect(); - options.push("OpenAI Codex - ChatGPT subscription (Plus/Pro/Max)".to_string()); - provider_ids.push("openai_codex".to_string()); + // Helper: build a label for a provider entry, prepending a checkmark if detected. + let make_label = |id: &str, name: &str, desc: &str| -> String { + if detected_env.get(id).copied().unwrap_or(false) { + format!("\u{2713} {:<15}- {}", name, desc) + } else { + format!(" {:<15}- {}", name, desc) + } + }; + + // Collect all entries as (provider_id, label, is_detected). + struct ProviderEntry { + id: String, + label: String, + detected: bool, + } + + let mut entries: Vec = Vec::with_capacity(2 + selectable.len()); + + entries.push(ProviderEntry { + id: "nearai".to_string(), + label: make_label("nearai", "NEAR AI", "multi-model access via NEAR account"), + detected: detected_env.get("nearai").copied().unwrap_or(false), + }); + + entries.push(ProviderEntry { + id: "gemini_oauth".to_string(), + label: make_label( + "gemini_oauth", + "Gemini CLI", + "Official Gemini API via Gemini CLI OAuth", + ), + detected: false, + }); + + entries.push(ProviderEntry { + id: "openai_codex".to_string(), + label: make_label( + "openai_codex", + "OpenAI Codex", + "ChatGPT subscription (Plus/Pro/Max)", + ), + detected: false, + }); for def in &selectable { - let label = format!( - "{:<17}- {}", - def.setup - .as_ref() - .map(|s| s.display_name()) - .unwrap_or(&def.id), - def.description - ); - options.push(label); - provider_ids.push(def.id.clone()); + let display_name = def + .setup + .as_ref() + .map(|s| s.display_name()) + .unwrap_or(&def.id); + entries.push(ProviderEntry { + id: def.id.clone(), + label: make_label(&def.id, display_name, &def.description), + detected: detected_env.get(def.id.as_str()).copied().unwrap_or(false), + }); } // Bedrock is a special case (native AWS SDK, not registry-based) - options.push("AWS Bedrock - Claude & other models via AWS (IAM, SSO)".to_string()); - provider_ids.push("bedrock".to_string()); + entries.push(ProviderEntry { + id: "bedrock".to_string(), + label: make_label( + "bedrock", + "AWS Bedrock", + "Claude & other models via AWS (IAM, SSO)", + ), + detected: false, + }); + + // Sort: detected providers first, preserving relative order within each group. + entries.sort_by_key(|e| !e.detected); + + let mut options: Vec = Vec::with_capacity(entries.len()); + let mut provider_ids: Vec = Vec::with_capacity(entries.len()); + for entry in &entries { + options.push(entry.label.clone()); + provider_ids.push(entry.id.clone()); + } let option_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect(); let choice = select_one("Provider:", &option_refs).map_err(SetupError::Io)?; @@ -1262,6 +1451,24 @@ impl SetupWizard { Ok(()) } + /// Detect an Anthropic credential from the environment. + /// + /// Checks `ANTHROPIC_API_KEY` first, then `ANTHROPIC_OAUTH_TOKEN`. + /// Returns the key/token string if found, or `None`. + fn detect_anthropic_key() -> Option { + if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") + && !key.is_empty() + { + return Some(key); + } + if let Ok(token) = std::env::var("ANTHROPIC_OAUTH_TOKEN") + && !token.is_empty() + { + return Some(token); + } + None + } + /// Update the selected LLM backend while preserving the current model when /// the backend did not actually change. fn set_llm_backend_preserving_model(&mut self, backend: &str) { @@ -3079,8 +3286,11 @@ impl SetupWizard { let _ = loaded; } - /// Save settings to the database and `~/.ironclaw/.env`, then print summary. + /// Save settings to the database and `~/.ironclaw/.env`, then print + /// a warm completion card with the 3 key facts. async fn save_and_summarize(&mut self) -> Result<(), SetupError> { + use crate::cli::fmt; + self.settings.onboard_completed = true; // Final persist (idempotent — earlier incremental saves already wrote @@ -3096,117 +3306,108 @@ impl SetupWizard { // Write bootstrap env (also idempotent) self.write_bootstrap_env()?; + // ── Completion card ─────────────────────────────────── + let sep = fmt::separator(38); + println!(); - print_success("Configuration saved to database"); + println!(" {}", sep); println!(); - // Print summary - println!("Configuration Summary:"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + // Title line: checkmark + "ironclaw is ready" + println!( + " {}\u{2713}{} {}ironclaw is ready{}", + fmt::success(), + fmt::reset(), + fmt::bold_accent(), + fmt::reset(), + ); + println!(); - let backend = self - .settings - .database_backend - .as_deref() - .unwrap_or("postgres"); - match backend { - "libsql" => { - if let Some(ref path) = self.settings.libsql_path { - println!(" Database: libSQL ({})", path); - } else { - println!(" Database: libSQL (default path)"); - } - if self.settings.libsql_url.is_some() { - println!(" Turso sync: enabled"); - } - } - _ => { - if self.settings.database_url.is_some() { - println!(" Database: PostgreSQL (configured)"); - } - } - } - - match self.settings.secrets_master_key_source { - KeySource::Keychain => println!(" Security: OS keychain"), - KeySource::Env => println!(" Security: environment variable"), - KeySource::None => println!(" Security: disabled"), - } - - if let Some(ref provider) = self.settings.llm_backend { - let display = match provider.as_str() { - "nearai" => "NEAR AI", - "anthropic" => "Anthropic", - "openai" => "OpenAI", - "ollama" => "Ollama", - "openai_compatible" => "OpenAI-compatible", - "bedrock" => "AWS Bedrock", - "openai_codex" => "OpenAI Codex", - other => other, - }; - println!(" Provider: {}", display); - } - - if let Some(ref model) = self.settings.selected_model { + // Fact 1: Provider + model + let provider_display = match self.settings.llm_backend.as_deref() { + Some("nearai") => "NEAR AI".to_string(), + Some("anthropic") => "Anthropic".to_string(), + Some("openai") => "OpenAI".to_string(), + Some("ollama") => "Ollama".to_string(), + Some("openai_compatible") => "OpenAI-compatible".to_string(), + Some("bedrock") => "AWS Bedrock".to_string(), + Some("openai_codex") => "OpenAI Codex".to_string(), + Some("gemini_oauth") => "Gemini CLI".to_string(), + Some(other) => other.to_string(), + None => "unknown".to_string(), + }; + let model_suffix = if let Some(ref model) = self.settings.selected_model { // Truncate long model names (char-based to avoid UTF-8 panic) - let display = if model.chars().count() > 40 { - let truncated: String = model.chars().take(37).collect(); + let display = if model.chars().count() > 30 { + let truncated: String = model.chars().take(27).collect(); format!("{}...", truncated) } else { model.clone() }; - println!(" Model: {}", display); - } - - if self.settings.embeddings.enabled { - println!( - " Embeddings: {} ({})", - self.settings.embeddings.provider, self.settings.embeddings.model - ); + format!(" ({})", display) } else { - println!(" Embeddings: disabled"); - } + String::new() + }; + let provider_value = format!("{}{}", provider_display, model_suffix); + println!( + " {}provider{} {}{}{}", + fmt::dim(), + fmt::reset(), + fmt::accent(), + provider_value, + fmt::reset(), + ); - if let Some(ref tunnel_url) = self.settings.tunnel.public_url { - println!(" Tunnel: {} (static)", tunnel_url); - } else if let Some(ref provider) = self.settings.tunnel.provider { - println!(" Tunnel: {} (managed, starts at boot)", provider); - } + // Fact 2: Database + let db_display = match self.settings.database_backend.as_deref() { + Some("libsql") => "libSQL".to_string(), + Some("postgres") | Some("postgresql") => "PostgreSQL".to_string(), + Some(other) => other.to_string(), + None => "unknown".to_string(), + }; + println!( + " {}database{} {}{}{}", + fmt::dim(), + fmt::reset(), + fmt::accent(), + db_display, + fmt::reset(), + ); - let has_tunnel = - self.settings.tunnel.public_url.is_some() || self.settings.tunnel.provider.is_some(); - - println!(" Channels:"); - println!(" - CLI/TUI: enabled"); - - if self.settings.channels.http_enabled { - let port = self.settings.channels.http_port.unwrap_or(8080); - println!(" - HTTP: enabled (port {})", port); - } - - for channel_name in &self.settings.channels.wasm_channels { - let mode = if has_tunnel { "webhook" } else { "polling" }; - println!( - " - {}: enabled ({})", - capitalize_first(channel_name), - mode - ); - } - - if self.settings.heartbeat.enabled { - println!( - " Heartbeat: every {} minutes", - self.settings.heartbeat.interval_secs / 60 - ); - } + // Fact 3: Security + let security_display = match self.settings.secrets_master_key_source { + KeySource::Keychain => "OS keychain", + KeySource::Env => "environment variable", + KeySource::None => "disabled", + }; + println!( + " {}security{} {}{}{}", + fmt::dim(), + fmt::reset(), + fmt::accent(), + security_display, + fmt::reset(), + ); println!(); - println!("To start the agent, run:"); - println!(" ironclaw"); + println!(" {}", sep); println!(); - println!("To change settings later:"); - println!(" ironclaw config set "); - println!(" ironclaw onboard"); + + // Action hints + println!( + " {}Start chatting:{} {}ironclaw{}", + fmt::dim(), + fmt::reset(), + fmt::bold_accent(), + fmt::reset(), + ); + println!( + " {}Full setup:{} {}ironclaw onboard{}", + fmt::dim(), + fmt::reset(), + fmt::bold_accent(), + fmt::reset(), + ); println!(); if self.config.quick { @@ -3551,6 +3752,7 @@ mod tests { channels_only: false, provider_only: false, quick: false, + steps: vec![], }; let wizard = SetupWizard::with_config(config); assert!(wizard.config.skip_auth); diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 4564de7c..dff09a5c 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -604,7 +604,7 @@ impl ToolRegistry { self.register(Arc::new(BuildSoftwareTool::new(Arc::clone(&builder)))) .await; - tracing::info!("Registered software builder tool"); + tracing::debug!("Registered software builder tool"); builder } diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index a96fc9bb..3b5f7a0c 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -206,7 +206,7 @@ impl WasmToolLoader { }) .await?; - tracing::info!( + tracing::debug!( name = name, wasm_path = %wasm_path.display(), "Loaded WASM tool from file" @@ -306,7 +306,7 @@ impl WasmToolLoader { } if !results.loaded.is_empty() { - tracing::info!( + tracing::debug!( count = results.loaded.len(), tools = ?results.loaded, "Loaded WASM tools from directory" diff --git a/src/tools/wasm/runtime.rs b/src/tools/wasm/runtime.rs index 02c56f61..43593cf7 100644 --- a/src/tools/wasm/runtime.rs +++ b/src/tools/wasm/runtime.rs @@ -312,7 +312,7 @@ impl WasmToolRuntime { .insert(prepared.name.clone(), Arc::clone(&prepared)); } - tracing::info!( + tracing::debug!( name = %prepared.name, "Prepared WASM tool for execution" ); diff --git a/src/tunnel/mod.rs b/src/tunnel/mod.rs index e6245b9e..fa028834 100644 --- a/src/tunnel/mod.rs +++ b/src/tunnel/mod.rs @@ -190,7 +190,7 @@ pub async fn start_managed_tunnel( mut config: crate::config::Config, ) -> (crate::config::Config, Option>) { if config.tunnel.public_url.is_some() { - tracing::info!( + tracing::debug!( "Static tunnel URL in use: {}", config.tunnel.public_url.as_deref().unwrap_or("?") ); @@ -216,7 +216,7 @@ pub async fn start_managed_tunnel( match create_tunnel(provider_config) { Ok(Some(tunnel)) => { - tracing::info!( + tracing::debug!( "Starting {} tunnel on {}:{}...", tunnel.name(), gateway_host, @@ -224,7 +224,7 @@ pub async fn start_managed_tunnel( ); match tunnel.start(gateway_host, gateway_port).await { Ok(url) => { - tracing::info!("Tunnel started: {}", url); + tracing::debug!("Tunnel started: {}", url); config.tunnel.public_url = Some(url); (config, Some(tunnel)) } diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 79437406..5aac2500 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -558,6 +558,10 @@ impl Workspace { /// which uses `\n\n`. pub async fn append(&self, path: &str, content: &str) -> Result<(), WorkspaceError> { let path = normalize_path(path); + // Scan system-prompt-injected files for prompt injection. + if is_system_prompt_file(&path) && !content.is_empty() { + reject_if_injected(&path, content)?; + } let doc = self .storage .get_or_create_document_by_path(&self.user_id, self.agent_id, &path) diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index c8d5eff1..69982b84 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -134,7 +134,7 @@ mod tests { match &routine.trigger { Trigger::Cron { schedule, timezone } => { - assert_eq!(schedule, "0 0 9 * * *"); + assert_eq!(schedule, "0 0 9 * * * *"); assert_eq!(timezone.as_deref(), Some("America/New_York")); } other => panic!("expected cron trigger, got {other:?}"), diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index eab16ed6..be2b3bb2 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -773,7 +773,7 @@ impl TestRigBuilder { // 7. Create TestChannel and ChannelManager. // When testing bootstrap, the channel must be named "gateway" because // the bootstrap greeting targets only the gateway channel. - let test_channel = if keep_bootstrap { + let test_channel = if self.keep_bootstrap { Arc::new(TestChannel::new().with_name("gateway")) } else { Arc::new(TestChannel::new())