diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index dbc9d38b9..e89fbc4eb 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -715,6 +715,9 @@ impl Agent { None }; + // Extract engine ref for use in message loop + let _routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e)); + // Bootstrap phase 2: register the thread in session manager and // broadcast the greeting via SSE for any clients already connected. // The greeting was already persisted to DB before start_all(), so diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index fc3da61b7..ab9d28c92 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; @@ -971,6 +971,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/thread_ops.rs b/src/agent/thread_ops.rs index 0fb968f16..fe0ad9d38 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -498,6 +498,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 as u32, + output_tokens: total_out as u32, + cost_usd: format!("${:.4}", total_cost), + }, + &message.metadata, + ) + .await; + } + Ok(SubmissionResult::response(response)) } Ok(AgenticLoopResult::NeedApproval { pending }) => { diff --git a/src/boot_screen.rs b/src/boot_screen.rs index d9590ccca..c018abf63 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,128 +32,217 @@ 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, } +const KW: usize = 10; + /// 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) { - // 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"; - - let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58)); + 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 + " {}{: = info + .channels + .iter() + .filter(|c| !matches!(c.as_str(), "repl" | "gateway")) + .map(|c| c.as_str()) + .collect(); + if !non_default.is_empty() { + println!( + " {}{: { - 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 - } + + // ── Tier 3: compact feature tags ────────────────────────────────── + + let mut tags: Vec = Vec::new(); + + // Database + if info.db_connected { + tags.push(format!("db:{}", info.db_backend)); } - if info.claude_code_enabled { - features.push("claude-code".to_string()); + + // Tool count + if info.tool_count > 0 { + tags.push(format!("tools:{}", info.tool_count)); } + + // Routines if info.routines_enabled { - features.push("routines".to_string()); + 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 { - features.push("skills".to_string()); + tags.push("skills".to_string()); } - if !features.is_empty() { - println!( - " {dim}features{reset} {cyan}{}{reset}", - features.join(" ") - ); + + // 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}")); } - // Channels line - if !info.channels.is_empty() { - println!( - " {dim}channels{reset} {cyan}{}{reset}", - info.channels.join(" ") - ); + // Embeddings + if info.embeddings_enabled { + if let Some(ref provider) = info.embeddings_provider { + tags.push(format!("embeddings:{provider}")); + } else { + tags.push("embeddings".to_string()); + } } - // Gateway URL (highlighted) - if let Some(ref url) = info.gateway_url { - println!(); - println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}"); + // Claude Code bridge + if info.claude_code_enabled { + tags.push("claude-code".to_string()); } - // Tunnel URL - if let Some(ref url) = info.tunnel_url { - let provider_tag = info - .tunnel_provider - .as_deref() - .map(|p| format!(" {dim}({p}){reset}")) - .unwrap_or_default(); - println!(" {dim}tunnel{reset} {yellow_underline}{url}{reset}{provider_tag}"); + if !tags.is_empty() { + println!( + " {}{: }, + /// Per-turn token usage and cost summary (shown as subtle metadata). + TurnCost { + input_tokens: u32, + output_tokens: u32, + cost_usd: String, + }, } impl StatusUpdate { diff --git a/src/channels/repl.rs b/src/channels/repl.rs index 36ca7c28a..95a54221c 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -40,6 +40,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 +120,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())) } } @@ -158,40 +159,53 @@ fn make_skin() -> MadSkin { 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); + } + let half = max_chars / 2; + let head: String = s.chars().take(half).collect(); + let tail: String = s.chars().skip(char_count.saturating_sub(half)).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") } @@ -262,33 +276,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!(); - 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!(" {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}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!(); } @@ -357,18 +368,28 @@ 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 { 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 +415,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; } @@ -456,9 +477,7 @@ 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. @@ -470,7 +489,7 @@ impl Channel for ReplChannel { // 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(); @@ -491,30 +510,27 @@ impl Channel for ReplChannel { match status { StatusUpdate::Thinking(msg) => { 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()); } StatusUpdate::ToolStarted { name } => { - eprintln!(" \x1b[33m\u{25CB} {name}\x1b[0m"); + eprintln!(" {}\u{25CB} {name}{}", fmt::dim(), fmt::reset()); } StatusUpdate::ToolCompleted { name, success, .. } => { 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)); + let sep_width = fmt::term_width().min(80); + eprintln!("{}", fmt::separator(sep_width)); } print!("{chunk}"); let _ = io::stdout().flush(); @@ -525,59 +541,52 @@ 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, parameters, allow_always, } => { - let term_width = crossterm::terminal::size() - .map(|(w, _)| w as usize) - .unwrap_or(80); + let term_width = fmt::term_width(); let box_width = (term_width.saturating_sub(4)).clamp(40, 60); - // 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{250C}{}{top_label}{}{}", + fmt::warning(), + fmt::reset(), "\u{2500}".repeat(top_fill) ); - // 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) - ); + // Bottom border: └───── + let bot_fill = box_width.saturating_sub(1); + let bot_border = format!("\u{2514}{}", "\u{2500}".repeat(bot_fill)); eprintln!(); eprintln!(" {top_border}"); - eprintln!(" \u{2502} \x1b[90m{description}\x1b[0m"); + eprintln!(" \u{2502} {}{description}{}", fmt::bold(), fmt::reset()); 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}"); } @@ -585,10 +594,22 @@ impl Channel for ReplChannel { eprintln!(" \u{2502}"); if allow_always { eprintln!( - " \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)" + " \u{2502} {}yes{} (y) / {}always{} (a) / {}no{} (n)", + fmt::success(), + fmt::reset(), + fmt::accent(), + fmt::reset(), + fmt::error(), + fmt::reset() ); } else { - eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)"); + eprintln!( + " \u{2502} {}yes{} (y) / {}no{} (n)", + fmt::success(), + fmt::reset(), + fmt::error(), + fmt::reset() + ); } eprintln!(" {bot_border}"); eprintln!(); @@ -600,12 +621,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 +640,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 +676,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/wrapper.rs b/src/channels/wasm/wrapper.rs index 8f0c9db4b..eee7f43e3 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 bfefc5c4c..321d2fab3 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -413,6 +413,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 306576b9f..7b952346b 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 4cb5644c6..5ab0b3623 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -23,6 +23,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 = [ @@ -62,12 +75,25 @@ 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'; + app.style.display = 'flex'; + 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'); @@ -91,8 +117,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'; + } }); } @@ -100,29 +132,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() { @@ -157,7 +168,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; @@ -263,6 +276,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) { @@ -283,8 +315,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) => { @@ -296,6 +348,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(); @@ -353,7 +418,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) => { @@ -423,6 +512,20 @@ function connectSSE() { } }); + eventSource.addEventListener('turn_cost', (e) => { + const event = JSON.parse(e.data); + // Add cost badge below last assistant message + const messages = document.querySelectorAll('.message.assistant'); + const lastMsg = messages[messages.length - 1]; + if (lastMsg && event.tokens) { + const badge = document.createElement('div'); + badge.className = 'turn-cost-badge'; + const cost = event.cost ? ' \u00b7 $' + event.cost.toFixed(4) : ''; + badge.textContent = event.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', @@ -514,6 +617,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'); @@ -525,10 +629,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(); @@ -544,7 +649,35 @@ 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(); + userMsg.classList.remove('send-failed'); + userMsg.style.borderStyle = ''; + retryLink.remove(); + input.value = content; + sendMessage(); + }); + userMsg.appendChild(retryLink); + } }); } @@ -828,6 +961,7 @@ function addMessage(role, content) { const div = createMessageElement(role, content); container.appendChild(div); container.scrollTop = container.scrollHeight; + return div; } function appendToLastAssistant(chunk) { @@ -841,6 +975,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 { @@ -1483,6 +1625,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'); @@ -1500,6 +1649,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') { @@ -1546,6 +1699,25 @@ function createMessageElement(role, content) { const div = document.createElement('div'); div.className = 'message ' + role; + // 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); @@ -1561,15 +1733,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; } @@ -1667,6 +1830,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) { @@ -1781,6 +1951,7 @@ 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'); @@ -1900,6 +2071,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(); @@ -4592,13 +4764,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) { @@ -4772,6 +4958,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); @@ -4790,11 +4989,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 = ''; @@ -5333,6 +5534,181 @@ function showToast(message, type) { }, 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) { + // Remove existing banner if any + const existing = document.getElementById('connection-banner'); + if (existing) existing.remove(); + + const chatPanel = document.getElementById('tab-chat'); + if (!chatPanel) return; + + const banner = document.createElement('div'); + banner.id = 'connection-banner'; + banner.className = 'connection-banner connection-banner-' + type; + banner.textContent = message; + chatPanel.insertBefore(banner, chatPanel.firstChild); +} + +// --- 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) { diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index cd57a400a..b818e6691 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -519,4 +519,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 028ff5fc2..301f7f932 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -518,4 +518,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 45e14fa41..a387f5001 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -25,6 +25,8 @@ integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG" crossorigin="anonymous" > + + @@ -40,7 +42,7 @@

IronClaw

-

Enter the GATEWAY_AUTH_TOKEN from your .env configuration.

+

Paste the token shown in your terminal to connect.

@@ -90,12 +92,12 @@

Restart IronClaw Instance

-
- - - - - +
+ + + + +
@@ -122,7 +124,7 @@

Restart IronClaw Instance

-
+
Assistant @@ -143,14 +145,14 @@

Restart IronClaw Instance

Conversations
+ title="New thread (Ctrl/Cmd+N)" aria-label="New thread">+ + data-i18n-attr="title" title="Toggle sidebar" aria-label="Toggle sidebar">«
-
+
@@ -168,7 +170,7 @@

Restart IronClaw Instance

-
+
-
+
@@ -217,7 +219,7 @@

Restart IronClaw Instance

-
+
@@ -269,7 +271,7 @@

Restart IronClaw Instance

-
+
@@ -391,7 +393,19 @@

-
+ + + +
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index b2f81d890..6993d81bd 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -24,6 +24,27 @@ --warning-soft: rgba(245, 166, 35, 0.15); --transition-fast: 150ms ease; --transition-base: 0.2s ease; + --transition-slow: 300ms ease; + --accent-dim: rgba(52, 211, 153, 0.08); + --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; + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --duration-instant: 100ms; + --duration-fast: 150ms; + --duration-base: 250ms; + --duration-slow: 400ms; } * { @@ -32,6 +53,11 @@ box-sizing: border-box; } +*:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + body { font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); @@ -50,6 +76,7 @@ body { justify-content: center; height: 100vh; height: 100dvh; + transition: opacity 0.3s ease; } .auth-card-login { @@ -61,7 +88,7 @@ body { max-width: 400px; display: flex; flex-direction: column; - gap: 24px; + gap: var(--space-6); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); } @@ -70,25 +97,25 @@ body { } .auth-brand h1 { - font-size: 28px; - font-weight: 700; + font-size: var(--text-3xl); + 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); } @@ -99,14 +126,14 @@ body { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 14px; + font-size: var(--text-base); width: 100%; } #auth-screen input:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.3); + box-shadow: 0 0 0 3px var(--accent-soft); } #auth-screen button { @@ -116,7 +143,7 @@ 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; @@ -133,13 +160,13 @@ body { #auth-error { color: var(--danger); - font-size: 13px; + font-size: var(--text-sm); min-height: 20px; text-align: center; } .auth-hint { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); text-align: center; line-height: 1.4; @@ -151,6 +178,12 @@ body { flex-direction: column; height: 100vh; height: 100dvh; + opacity: 0; + transition: opacity 0.3s ease 0.15s; +} + +#app.visible { + opacity: 1; } /* Tab Bar */ @@ -173,7 +206,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; } @@ -198,7 +231,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; @@ -212,14 +245,14 @@ body { .tab-bar .status-logs-btn.active { color: var(--accent); border-color: var(--accent); - background: rgba(52, 211, 153, 0.1); + background: var(--accent-dim); } .tab-bar .status { display: flex; align-items: center; - gap: 8px; - font-size: 12px; + gap: var(--space-2); + font-size: var(--text-xs); color: var(--text-secondary); position: relative; cursor: pointer; @@ -241,12 +274,12 @@ body { display: flex; align-items: center; gap: 6px; - font-size: 12px; + font-size: var(--text-xs); color: var(--success); padding: 4px 10px; border-radius: 12px; - background: rgba(52, 211, 153, 0.1); - border: 1px solid rgba(52, 211, 153, 0.25); + background: var(--accent-dim); + border: 1px solid var(--accent-soft); cursor: pointer; position: relative; margin-right: 8px; @@ -254,7 +287,7 @@ body { } .tee-shield:hover { - background: rgba(52, 211, 153, 0.18); + background: var(--accent-soft); } .tee-shield svg { @@ -275,15 +308,15 @@ body { padding: 0.25rem 0.75rem; border-radius: 0.5rem; font-size: 0.8rem; - border: 1px solid #00d894; - color: #00d894; + border: 1px solid var(--accent); + color: var(--accent); background-color: transparent; cursor: pointer; transition: color 150ms, background-color 150ms, border-color 150ms; } .tab-bar .restart-btn:hover:not(:disabled) { - background-color: rgba(0, 216, 148, 0.1); + background-color: var(--accent-dim); } .tab-bar .restart-btn:disabled { @@ -555,7 +588,7 @@ 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; @@ -567,7 +600,7 @@ body { } .tee-popover-title { - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; color: var(--text); margin-bottom: 12px; @@ -589,7 +622,7 @@ body { } .tee-field-label { - font-size: 11px; + font-size: var(--text-xs); font-weight: 500; color: var(--text-secondary); text-transform: uppercase; @@ -598,7 +631,7 @@ body { } .tee-field-value { - font-size: 12px; + font-size: var(--text-xs); font-family: var(--font-mono); color: var(--text); word-break: break-all; @@ -611,7 +644,7 @@ body { .tee-popover-actions { margin-top: 12px; display: flex; - gap: 8px; + gap: var(--space-2); } .tee-btn-copy { @@ -621,7 +654,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; } @@ -631,7 +664,7 @@ body { } .tee-popover-loading { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); padding: 8px 0; } @@ -659,38 +692,49 @@ 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; + padding: 12px 16px; border-radius: var(--radius); - font-size: 14px; - line-height: 1.5; + font-size: var(--text-base); + line-height: 1.6; word-wrap: break-word; position: relative; + animation: slideUp 200ms var(--ease-out-expo); +} + +.message + .message { + margin-top: -4px; +} + +.message.user + .message.assistant, +.message.assistant + .message.user { + margin-top: var(--space-3); } .message.user { align-self: flex-end; - background: var(--accent-soft); - color: var(--accent); - border-bottom-right-radius: 2px; + max-width: 85%; + background: rgba(52, 211, 153, 0.08); + color: var(--text); white-space: pre-wrap; + border: 1px solid rgba(52, 211, 153, 0.15); + border-radius: var(--radius-lg) var(--radius-lg) 4px var(--radius-lg); } .message.assistant { - align-self: flex-start; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-bottom-left-radius: 2px; - padding: 14px 18px; - font-size: 15px; - line-height: 1.6; + align-self: stretch; + background: none; + border: none; + border-left: 2px solid var(--accent); + padding-left: 16px; + font-size: var(--text-base); + line-height: 1.7; } .message.has-copy { @@ -707,14 +751,15 @@ body { right: 8px; z-index: 2; border: 1px solid var(--border); - background: var(--bg-primary); + background: var(--bg-secondary); color: var(--text-secondary); - border-radius: 8px; - font-size: 11px; - padding: 2px 8px; + border-radius: 6px; + font-size: var(--text-xs); + padding: 3px 10px; + cursor: pointer; opacity: 0; pointer-events: none; - transition: opacity 0.15s ease; + transition: opacity var(--transition-fast); } .message.user:hover .message-copy-btn, @@ -733,8 +778,9 @@ body { } .message-copy-btn:hover { - background: var(--bg-secondary); - color: var(--text-primary); + background: var(--bg-tertiary); + color: var(--text); + border-color: var(--accent); } @media (hover: none) { @@ -749,15 +795,37 @@ body { align-self: center; background: var(--bg-tertiary); color: var(--text-secondary); - font-size: 12px; + font-size: var(--text-xs); padding: 6px 12px; } +/* Streaming cursor */ +.message[data-streaming="true"]::after { + content: ''; + display: inline-block; + width: 2px; + height: 16px; + background: var(--accent); + vertical-align: text-bottom; + animation: cursorBlink 1s step-end infinite; +} + +@keyframes cursorBlink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Message entry animation */ +@keyframes slideUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + .message code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; - font-size: 13px; + font-size: var(--text-sm); } .message pre { @@ -797,7 +865,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); } @@ -807,10 +875,10 @@ body { display: flex; align-items: center; justify-content: center; - gap: 8px; - padding: 8px; + gap: var(--space-2); + padding: var(--space-2); color: var(--text-secondary); - font-size: 12px; + font-size: var(--text-xs); } .scroll-load-spinner .spinner { @@ -845,9 +913,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); } @@ -887,7 +955,7 @@ body { } .activity-tool-card[data-status="running"] { - border-color: rgba(52, 211, 153, 0.3); + border-color: var(--accent-soft); } .activity-tool-card[data-status="fail"] { @@ -901,7 +969,7 @@ body { .activity-tool-header { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); padding: 6px 10px; cursor: pointer; user-select: none; @@ -932,20 +1000,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); @@ -953,7 +1021,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; @@ -980,7 +1048,7 @@ body { margin: 0; padding: 8px 10px; font-family: var(--font-mono); - font-size: 12px; + font-size: var(--text-xs); line-height: 1.4; color: var(--text-secondary); background: var(--code-bg); @@ -999,7 +1067,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; @@ -1026,7 +1094,7 @@ body { .activity-summary-duration { font-family: var(--font-mono); - font-size: 11px; + font-size: var(--text-xs); opacity: 0.7; } @@ -1055,12 +1123,13 @@ body { padding: 14px; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); transition: border-color 0.2s; + animation: slideUp 250ms ease-out; } .approval-header { - font-size: 12px; + font-size: var(--text-xs); font-weight: 600; color: var(--warning); text-transform: uppercase; @@ -1068,14 +1137,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; } @@ -1085,7 +1154,7 @@ body { border: none; color: var(--accent); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); padding: 0; text-align: left; } @@ -1098,7 +1167,7 @@ body { background: var(--code-bg); padding: 8px 12px; border-radius: var(--radius); - font-size: 12px; + font-size: var(--text-xs); font-family: var(--font-mono); line-height: 1.4; overflow-x: auto; @@ -1110,7 +1179,7 @@ body { .approval-card .approval-actions { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; } @@ -1119,7 +1188,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); } @@ -1150,7 +1219,7 @@ body { } .approval-resolved { - font-size: 12px; + font-size: var(--text-xs); font-weight: 500; color: var(--text-secondary); font-style: italic; @@ -1237,7 +1306,7 @@ body { display: flex; align-items: center; justify-content: center; - padding: 16px; + padding: var(--space-4); } .auth-card { @@ -1250,7 +1319,7 @@ body { margin: 8px 0; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); transition: border-color 0.2s; } @@ -1260,37 +1329,37 @@ body { margin: 0; align-self: auto; background: var(--bg); - border-color: rgba(52, 211, 153, 0.35); + border-color: var(--accent-soft); box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35); } .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; } @@ -1301,19 +1370,19 @@ body { border-radius: var(--radius); background: var(--bg); color: var(--text); - font-size: 13px; + font-size: var(--text-sm); font-family: var(--font-mono); } .auth-card .auth-token-input input:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); } .auth-card .auth-actions { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; } @@ -1322,7 +1391,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); } @@ -1353,7 +1422,7 @@ body { .auth-card .auth-error { color: var(--danger); - font-size: 12px; + font-size: var(--text-xs); } /* Chat input */ @@ -1361,7 +1430,7 @@ body { display: flex; flex-wrap: wrap; padding: 12px 16px max(12px, env(safe-area-inset-bottom)) 16px; - gap: 8px; + gap: var(--space-2); background: var(--bg-secondary); border-top: 1px solid var(--border); flex-shrink: 0; @@ -1381,7 +1450,7 @@ 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; @@ -1394,7 +1463,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; @@ -1413,7 +1482,7 @@ body { .chat-input-wrapper textarea:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); } .chat-input-wrapper textarea:disabled { @@ -1424,7 +1493,7 @@ body { .suggestion-chips { display: none; flex-wrap: wrap; - gap: 8px; + gap: var(--space-2); padding: 8px 16px; border-top: 1px solid var(--border); } @@ -1435,7 +1504,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; @@ -1455,7 +1524,7 @@ 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; @@ -1501,7 +1570,7 @@ body { } .memory-sidebar .search-box { - padding: 12px; + padding: var(--space-3); border-bottom: 1px solid var(--border); } @@ -1512,13 +1581,13 @@ 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 { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); } .memory-tree { @@ -1533,9 +1602,9 @@ body { align-items: center; padding: 3px 8px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); - gap: 4px; + gap: var(--space-1); min-height: 26px; } @@ -1594,7 +1663,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; @@ -1612,7 +1681,7 @@ body { } .tree-item .icon { - font-size: 12px; + font-size: var(--text-xs); width: 16px; text-align: center; } @@ -1626,13 +1695,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 { @@ -1648,8 +1717,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); @@ -1675,13 +1744,13 @@ body { } .search-result .path { - font-size: 12px; + font-size: var(--text-xs); color: var(--accent); margin-bottom: 4px; } .search-result .snippet { - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; @@ -1692,18 +1761,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); @@ -1716,13 +1785,13 @@ body { } .summary-card .count { - font-size: 28px; + font-size: var(--text-2xl); font-weight: 600; color: var(--text); } .summary-card .label { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); margin-top: 4px; text-transform: uppercase; @@ -1744,14 +1813,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; } @@ -1763,13 +1832,13 @@ body { display: inline-block; padding: 3px 10px; border-radius: 9999px; - font-size: 11px; + font-size: var(--text-xs); font-weight: 500; } .badge.pending { background: var(--bg-tertiary); color: var(--text-secondary); } -.badge.in_progress { background: rgba(52, 211, 153, 0.15); color: var(--accent); } -.badge.completed { background: rgba(52, 211, 153, 0.15); color: var(--success); } +.badge.in_progress { background: var(--accent-soft); color: var(--accent); } +.badge.completed { background: var(--accent-soft); color: var(--success); } .badge.failed { background: rgba(230, 76, 76, 0.15); color: var(--danger); } .badge.stuck { background: rgba(245, 166, 35, 0.15); color: var(--warning); } .badge.cancelled { background: var(--bg-tertiary); color: var(--text-secondary); } @@ -1784,7 +1853,7 @@ body { border-radius: var(--radius); color: var(--danger); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); } .btn-cancel:hover { @@ -1798,11 +1867,11 @@ body { border-radius: var(--radius); color: var(--accent); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); } .btn-restart:hover { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); } .btn-browse { @@ -1812,19 +1881,19 @@ body { border-radius: var(--radius); color: var(--success); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); text-decoration: none; } .btn-browse:hover { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); } /* Job started card in chat */ .job-card { display: flex; align-items: center; - gap: 12px; + gap: var(--space-3); padding: 12px 16px; margin: 8px 0; background: var(--bg-tertiary); @@ -1839,7 +1908,7 @@ body { } .job-card-icon { - font-size: 20px; + font-size: var(--text-xl); } .job-card-info { @@ -1848,11 +1917,11 @@ body { .job-card-title { font-weight: 600; - font-size: 14px; + font-size: var(--text-base); } .job-card-id { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); font-family: var(--font-mono); } @@ -1860,7 +1929,7 @@ body { .job-card-view, .job-card-browse { padding: 4px 12px; border-radius: var(--radius); - font-size: 12px; + font-size: var(--text-xs); cursor: pointer; text-decoration: none; } @@ -1872,7 +1941,7 @@ body { } .job-card-view:hover { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); } .job-card-browse { @@ -1882,7 +1951,7 @@ body { } .job-card-browse:hover { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); } /* Clickable job rows */ @@ -1894,12 +1963,12 @@ body { .job-detail-header { display: flex; align-items: center; - gap: 12px; + gap: var(--space-3); margin-bottom: 16px; } .job-detail-header h2 { - font-size: 18px; + font-size: var(--text-lg); font-weight: 600; flex: 1; min-width: 0; @@ -1915,7 +1984,7 @@ body { border-radius: var(--radius); color: var(--text); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); flex-shrink: 0; } @@ -1937,7 +2006,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 { @@ -1958,7 +2027,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; } @@ -1970,7 +2039,7 @@ body { } .meta-label { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; @@ -1978,7 +2047,7 @@ body { } .meta-value { - font-size: 14px; + font-size: var(--text-base); color: var(--text); word-break: break-all; } @@ -1989,7 +2058,7 @@ body { } .job-description h3 { - font-size: 14px; + font-size: var(--text-base); font-weight: 600; margin-bottom: 8px; color: var(--text); @@ -2000,7 +2069,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; } @@ -2010,7 +2079,7 @@ body { } .job-timeline-section h3 { - font-size: 14px; + font-size: var(--text-base); font-weight: 600; margin-bottom: 12px; color: var(--text); @@ -2043,18 +2112,18 @@ body { flex-wrap: wrap; align-items: center; gap: 6px; - font-size: 13px; + font-size: var(--text-sm); } .timeline-time { color: var(--text-secondary); - font-size: 12px; + font-size: var(--text-xs); margin-left: 8px; } .timeline-reason { width: 100%; - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); margin-top: 2px; } @@ -2078,7 +2147,7 @@ body { gap: 10px; padding: 10px 12px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); } .action-header:hover { @@ -2093,17 +2162,17 @@ body { .action-seq { color: var(--text-secondary); - font-size: 11px; + font-size: var(--text-xs); } .action-duration { color: var(--text-secondary); - font-size: 12px; + font-size: var(--text-xs); } .action-time { color: var(--text-secondary); - font-size: 12px; + font-size: var(--text-xs); margin-left: auto; } @@ -2122,7 +2191,7 @@ body { } .action-section strong { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); display: block; margin-bottom: 4px; @@ -2132,7 +2201,7 @@ body { background: var(--code-bg); padding: 8px 12px; border-radius: var(--radius); - font-size: 12px; + font-size: var(--text-xs); font-family: var(--font-mono); line-height: 1.4; overflow-x: auto; @@ -2148,7 +2217,7 @@ body { background: rgba(230, 76, 76, 0.1); padding: 8px 12px; border-radius: var(--radius); - font-size: 12px; + font-size: var(--text-xs); font-family: var(--font-mono); line-height: 1.4; color: var(--danger); @@ -2162,12 +2231,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; @@ -2184,11 +2253,11 @@ 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: rgba(52, 211, 153, 0.08); - border: 1px solid rgba(52, 211, 153, 0.2); + background: var(--accent-dim); + border: 1px solid var(--accent-soft); } .conv-user .conv-role { color: var(--accent); } @@ -2204,14 +2273,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); @@ -2228,7 +2297,7 @@ body { } .conv-tc-name { - font-size: 12px; + font-size: var(--text-xs); font-weight: 600; color: var(--accent); font-family: var(--font-mono); @@ -2238,7 +2307,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; @@ -2277,14 +2346,14 @@ body { } .job-files-path { - font-size: 12px; + font-size: var(--text-xs); color: var(--accent); margin-bottom: 8px; font-family: var(--font-mono); } .job-files-content { - font-size: 13px; + font-size: var(--text-sm); font-family: var(--font-mono); line-height: 1.5; white-space: pre-wrap; @@ -2303,13 +2372,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; } @@ -2323,14 +2392,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; } @@ -2346,7 +2415,7 @@ body { padding: 16px 0; } -.badge.enabled { background: rgba(52, 211, 153, 0.15); color: var(--success); } +.badge.enabled { background: var(--accent-soft); color: var(--success); } .badge.disabled { background: var(--bg-tertiary); color: var(--text-secondary); } .badge.failing { background: rgba(230, 76, 76, 0.15); color: var(--danger); } @@ -2357,11 +2426,11 @@ body { border-radius: var(--radius); color: var(--accent); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); } .btn-trigger:hover { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); } .btn-toggle { @@ -2371,7 +2440,7 @@ body { border-radius: var(--radius); color: var(--warning); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); } .btn-toggle:hover { @@ -2389,7 +2458,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); @@ -2403,7 +2472,7 @@ body { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 12px; + font-size: var(--text-xs); } .logs-toolbar select { @@ -2419,15 +2488,15 @@ body { .logs-toolbar input:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); } .logs-checkbox { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); display: flex; align-items: center; - gap: 4px; + gap: var(--space-1); white-space: nowrap; } @@ -2438,7 +2507,7 @@ body { border-radius: var(--radius); color: var(--text); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); } .logs-toolbar button:hover { @@ -2450,14 +2519,14 @@ body { overflow-y: auto; padding: 4px 0; font-family: var(--font-mono); - font-size: 12px; + font-size: var(--text-xs); line-height: 1.5; background: var(--bg); } .log-entry { display: flex; - gap: 8px; + gap: var(--space-2); padding: 1px 12px; white-space: nowrap; cursor: pointer; @@ -2520,7 +2589,7 @@ body { .extensions-container { flex: 1; overflow-y: auto; - padding: 16px; + padding: var(--space-4); } .extensions-section { @@ -2528,7 +2597,7 @@ body { } .extensions-section h3 { - font-size: 11px; + font-size: var(--text-xs); font-weight: 600; margin-bottom: 12px; color: var(--text-secondary); @@ -2537,7 +2606,7 @@ body { } .extensions-section h4 { - font-size: 11px; + font-size: var(--text-xs); font-weight: 600; margin: 16px 0 8px; color: var(--text-muted); @@ -2548,7 +2617,7 @@ body { .extensions-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 12px; + gap: var(--space-3); } .ext-card { @@ -2559,7 +2628,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; } @@ -2586,12 +2655,12 @@ body { .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); } @@ -2605,12 +2674,12 @@ body { } .ext-kind.kind-mcp_server { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); color: var(--accent); } .ext-kind.kind-wasm_tool { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); color: var(--success); } @@ -2625,7 +2694,7 @@ body { } .ext-version { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-mono); } @@ -2646,13 +2715,13 @@ body { } .ext-desc { - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.4; } .ext-url { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); font-family: var(--font-mono); overflow: hidden; @@ -2661,7 +2730,7 @@ body { } .ext-tools { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); } @@ -2673,7 +2742,7 @@ body { } .ext-active-label { - font-size: 12px; + font-size: var(--text-xs); color: var(--success); font-weight: 500; } @@ -2689,7 +2758,7 @@ body { .stepper-step { display: flex; align-items: center; - gap: 4px; + gap: var(--space-1); } .stepper-circle { @@ -2699,13 +2768,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; } @@ -2753,7 +2822,7 @@ body { } .ext-pairing-label { - font-size: 12px; + font-size: var(--text-xs); color: var(--warning); font-weight: 500; } @@ -2771,7 +2840,7 @@ body { } .ext-error { - font-size: 11px; + font-size: var(--text-xs); color: var(--danger); background: rgba(230, 76, 76, 0.1); border: 1px solid rgba(230, 76, 76, 0.2); @@ -2781,7 +2850,7 @@ body { } .ext-note { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border); @@ -2798,7 +2867,7 @@ body { padding: 4px 10px; border-radius: var(--radius); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); font-weight: 500; border: 1px solid var(--border); background: var(--bg-tertiary); @@ -2821,7 +2890,7 @@ body { } .btn-ext.activate:hover { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); } .btn-ext.remove { @@ -2839,7 +2908,7 @@ body { } .btn-ext.install:hover { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); } .btn-ext.install:disabled { @@ -2852,7 +2921,7 @@ body { } .ext-keywords { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); opacity: 0.7; } @@ -2874,7 +2943,7 @@ body { } .pairing-heading { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; @@ -2884,13 +2953,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); @@ -2899,7 +2968,7 @@ body { } .pairing-sender { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); flex: 1; } @@ -2923,7 +2992,7 @@ body { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; - padding: 24px; + padding: var(--space-6); width: 460px; max-width: 90vw; max-height: 80vh; @@ -2932,7 +3001,7 @@ body { .configure-modal h3 { margin: 0 0 16px 0; - font-size: 16px; + font-size: var(--text-lg); color: var(--text); } @@ -3017,12 +3086,12 @@ body { .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; } @@ -3030,7 +3099,7 @@ body { .configure-input-row { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); } .configure-input-row input { @@ -3040,7 +3109,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; } @@ -3055,7 +3124,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); @@ -3064,14 +3133,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; } @@ -3081,9 +3150,9 @@ body { .activity-terminal { flex: 1; overflow-y: auto; - padding: 12px; + padding: var(--space-3); 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); @@ -3128,7 +3197,7 @@ body { .activity-session-id { color: var(--text-secondary); - font-size: 11px; + font-size: var(--text-xs); font-weight: 400; } @@ -3143,7 +3212,7 @@ body { padding: 6px 10px; cursor: pointer; background: var(--bg-secondary); - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); } @@ -3171,7 +3240,7 @@ body { .activity-tool-output { padding: 8px 10px; margin: 0; - font-size: 12px; + font-size: var(--text-xs); overflow-x: auto; max-height: 200px; overflow-y: auto; @@ -3180,7 +3249,7 @@ body { .activity-input-bar { display: flex; - gap: 8px; + gap: var(--space-2); padding: 8px 0; } @@ -3191,13 +3260,13 @@ 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 { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); } .activity-input-bar button { @@ -3207,7 +3276,7 @@ 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; } @@ -3248,7 +3317,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; @@ -3272,14 +3341,14 @@ body { z-index: 10000; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); pointer-events: none; } .toast { padding: 10px 16px; border-radius: var(--radius); - font-size: 13px; + font-size: var(--text-sm); color: #fff; pointer-events: auto; transform: translateX(120%); @@ -3308,7 +3377,7 @@ body { /* --- Memory search highlighting --- */ mark { - background: rgba(52, 211, 153, 0.3); + background: var(--accent-soft); color: inherit; border-radius: 2px; padding: 0 1px; @@ -3335,22 +3404,31 @@ mark { .thread-sidebar.collapsed { width: 36px; + min-width: 36px; } .thread-sidebar.collapsed .thread-new-btn, .thread-sidebar.collapsed .thread-list, .thread-sidebar.collapsed .assistant-item, -.thread-sidebar.collapsed .threads-section-header { +.thread-sidebar.collapsed .threads-section-header span, +.thread-sidebar.collapsed .threads-section-header .spacer { display: none; } +.thread-sidebar.collapsed .threads-section-header { + flex-direction: column; + align-items: center; + padding: var(--space-2) 0; + gap: var(--space-2); +} + .thread-new-btn { background: none; border: 1px solid var(--border); border-radius: var(--radius); color: var(--accent); cursor: pointer; - font-size: 16px; + font-size: var(--text-lg); width: 24px; height: 24px; display: flex; @@ -3361,21 +3439,22 @@ mark { } .thread-new-btn:hover { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); } .assistant-item { display: flex; align-items: center; - justify-content: space-between; - padding: 12px 14px; + gap: var(--space-2); + padding: 10px 10px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); font-weight: 600; color: var(--text); background: var(--bg-tertiary); border-radius: var(--radius); margin-bottom: 2px; + transition: background var(--transition-fast); } .assistant-item:hover { @@ -3383,33 +3462,37 @@ mark { } .assistant-item.active { - background: rgba(52, 211, 153, 0.1); - color: var(--accent); + background: var(--accent-dim); + color: var(--text); border-left: 2px solid var(--accent); + padding-left: 8px; } .assistant-label { + flex: 1; + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .assistant-meta { - font-size: 11px; + font-size: var(--text-xs); font-weight: 400; - color: var(--text-secondary); + color: var(--text-muted); + flex-shrink: 0; } .threads-section-header { 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; color: var(--text-secondary); - gap: 4px; + gap: var(--space-1); } .thread-toggle-btn { @@ -3417,7 +3500,7 @@ mark { border: none; color: var(--text-secondary); cursor: pointer; - font-size: 14px; + font-size: var(--text-base); padding: 2px; } @@ -3433,12 +3516,14 @@ mark { .thread-item { display: flex; align-items: center; - justify-content: space-between; - padding: 10px 14px; + gap: var(--space-2); + padding: 8px 10px; cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); color: var(--text-secondary); border-radius: var(--radius); + min-height: 36px; + transition: background var(--transition-fast), color var(--transition-fast); } .thread-item:hover { @@ -3447,20 +3532,27 @@ mark { } .thread-item.active { - background: var(--bg-tertiary); - color: var(--accent); + background: var(--accent-dim); + color: var(--text); border-left: 2px solid var(--accent); + padding-left: 8px; } .thread-label { - font-family: var(--font-mono); - font-size: 12px; + flex: 1; + min-width: 0; + font-size: var(--text-sm); + font-weight: 450; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .thread-meta { - font-size: 11px; - color: var(--text-secondary); + font-size: var(--text-xs); + color: var(--text-muted); flex-shrink: 0; + white-space: nowrap; } .thread-badge { @@ -3477,7 +3569,7 @@ mark { flex-shrink: 0; } -.thread-badge-routine { background: rgba(52, 211, 153, 0.15); color: var(--accent); } +.thread-badge-routine { background: var(--accent-soft); color: var(--accent); } .thread-badge-heartbeat { background: rgba(245, 166, 35, 0.15); color: var(--warning); } .thread-badge-telegram { background: rgba(0, 136, 204, 0.15); color: #0088cc; } .thread-badge-signal { background: rgba(59, 118, 240, 0.15); color: #3b76f0; } @@ -3512,7 +3604,7 @@ mark { border-radius: var(--radius); color: var(--text-secondary); cursor: pointer; - font-size: 12px; + font-size: var(--text-xs); flex-shrink: 0; } @@ -3525,20 +3617,20 @@ mark { flex: 1; display: flex; flex-direction: column; - gap: 8px; - padding: 12px; + gap: var(--space-2); + padding: var(--space-3); overflow: hidden; } .memory-editor textarea { flex: 1; - padding: 12px; + padding: var(--space-3); background: var(--bg); border: 1px solid var(--border); 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; } @@ -3546,12 +3638,12 @@ mark { .memory-editor textarea:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); } .memory-editor-actions { display: flex; - gap: 8px; + gap: var(--space-2); } .btn-save { @@ -3561,7 +3653,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; } @@ -3582,7 +3674,7 @@ mark { border-radius: var(--radius); color: var(--text); cursor: pointer; - font-size: 13px; + font-size: var(--text-sm); } .btn-cancel-edit:hover { @@ -3596,7 +3688,7 @@ mark { } .memory-rendered { - font-size: 14px; + font-size: var(--text-base); line-height: 1.6; } @@ -3612,7 +3704,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); @@ -3643,7 +3735,7 @@ mark { -webkit-backdrop-filter: blur(16px); border: 1px solid var(--border); border-radius: var(--radius-lg); - padding: 12px; + padding: var(--space-3); min-width: 220px; box-shadow: var(--shadow); z-index: 100; @@ -3670,7 +3762,7 @@ mark { .gw-stat { display: flex; justify-content: space-between; - font-size: 12px; + font-size: var(--text-xs); padding: 3px 0; color: var(--text-secondary); } @@ -3683,14 +3775,14 @@ mark { .gw-model-row { display: flex; justify-content: space-between; - font-size: 12px; + font-size: var(--text-xs); padding: 3px 0 0 0; } .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; @@ -3700,12 +3792,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; @@ -3715,7 +3807,7 @@ mark { .ext-install-form { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; flex-wrap: wrap; background: var(--bg-secondary); @@ -3730,13 +3822,13 @@ 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 { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); } .ext-install-form button { @@ -3746,7 +3838,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; } @@ -3764,7 +3856,7 @@ mark { .skill-search-box { display: flex; - gap: 8px; + gap: var(--space-2); align-items: center; margin-bottom: 12px; background: var(--bg-secondary); @@ -3780,13 +3872,13 @@ 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 { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); } .skill-search-box button { @@ -3796,7 +3888,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; } @@ -3807,7 +3899,7 @@ mark { } .skill-trust { - font-size: 11px; + font-size: var(--text-xs); padding: 3px 8px; border-radius: 9999px; font-weight: 600; @@ -3816,7 +3908,7 @@ mark { } .skill-trust.trust-trusted { - background: rgba(52, 211, 153, 0.15); + background: var(--accent-soft); color: var(--success); } @@ -3826,7 +3918,7 @@ mark { } .skill-version { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); font-family: var(--font-mono); } @@ -3845,7 +3937,7 @@ mark { .activity-toolbar { display: flex; align-items: center; - gap: 12px; + gap: var(--space-3); padding: 8px 0; } @@ -3855,13 +3947,21 @@ mark { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); - font-size: 12px; + font-size: var(--text-xs); } .activity-toolbar select:focus { outline: none; border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); + box-shadow: 0 0 0 3px var(--accent-dim); +} + +/* --- Tablet responsive --- */ + +@media (min-width: 769px) and (max-width: 1024px) { + .thread-sidebar { display: none; } + .memory-sidebar { width: 200px; min-width: 200px; } + .tab-bar button { padding: 8px 12px; font-size: var(--text-xs); } } /* --- Mobile responsive --- */ @@ -3876,15 +3976,10 @@ mark { .tab-bar button:not(.status-logs-btn) { padding: 8px 12px; - font-size: 13px; + font-size: var(--text-sm); white-space: nowrap; } - /* Chat messages: wider */ - .message { - max-width: 95%; - } - /* Thread sidebar: hidden behind toggle */ .thread-sidebar { width: 36px; @@ -3893,7 +3988,8 @@ mark { .thread-sidebar .thread-new-btn, .thread-sidebar .thread-list, .thread-sidebar .assistant-item, - .thread-sidebar .threads-section-header { + .thread-sidebar .threads-section-header span, + .thread-sidebar .threads-section-header .spacer { display: none; } @@ -3995,7 +4091,7 @@ mark { .chat-input button { padding: 6px 16px; - font-size: 14px; + font-size: var(--text-base); } } @@ -4025,7 +4121,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; @@ -4072,12 +4168,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; @@ -4095,7 +4191,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); @@ -4121,7 +4217,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; @@ -4134,7 +4230,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%; @@ -4143,11 +4239,11 @@ mark { .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); } .settings-saved-indicator { - font-size: 11px; + font-size: var(--text-xs); color: var(--success); opacity: 0; transform: translateY(4px); @@ -4160,7 +4256,7 @@ mark { } .settings-description { - font-size: 11px; + font-size: var(--text-xs); color: var(--text-secondary); margin-top: 2px; } @@ -4174,7 +4270,7 @@ mark { border: 1px solid rgba(245, 166, 35, 0.25); border-radius: var(--radius); color: var(--text); - font-size: 12px; + font-size: var(--text-xs); margin: 8px 16px; animation: settingsFadeIn 0.25s ease forwards; } @@ -4190,7 +4286,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); @@ -4213,7 +4309,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%; @@ -4223,7 +4319,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); } input[type="checkbox"]:focus-visible { @@ -4258,14 +4354,14 @@ 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; } .slash-ac-desc { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; @@ -4278,7 +4374,7 @@ input[type="checkbox"]:focus-visible { border: none; cursor: pointer; font-size: 1.2em; - padding: 8px; + padding: var(--space-2); align-self: flex-end; color: var(--text-secondary); transition: color 0.2s; @@ -4298,8 +4394,8 @@ input[type="checkbox"]:focus-visible { .image-preview-strip { display: flex; flex-direction: row; - gap: 8px; - padding: 4px; + gap: var(--space-2); + padding: var(--space-1); overflow-x: auto; min-height: 0; width: 100%; @@ -4333,7 +4429,7 @@ input[type="checkbox"]:focus-visible { background: var(--danger); color: #fff; border: none; - font-size: 12px; + font-size: var(--text-xs); line-height: 18px; text-align: center; cursor: pointer; @@ -4370,8 +4466,8 @@ input[type="checkbox"]:focus-visible { border: none; color: var(--text-secondary); cursor: pointer; - padding: 8px; - font-size: 16px; + padding: var(--space-2); + font-size: var(--text-lg); border-radius: var(--radius); transition: all 0.2s; } @@ -4389,7 +4485,7 @@ input[type="checkbox"]:focus-visible { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); - padding: 4px; + padding: var(--space-1); min-width: 120px; z-index: 1000; box-shadow: var(--shadow); @@ -4400,7 +4496,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; } @@ -4414,7 +4510,7 @@ input[type="checkbox"]:focus-visible { } .generated-image-path { - font-size: 12px; + font-size: var(--text-xs); color: var(--text-secondary); padding: 4px 8px; background: var(--bg-secondary); @@ -4424,7 +4520,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); @@ -4445,14 +4541,14 @@ 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; } .settings-search 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); } .settings-toolbar-btn { @@ -4461,7 +4557,7 @@ input[type="checkbox"]:focus-visible { border: 1px solid var(--border); border-radius: var(--radius); color: var(--text-secondary); - font-size: 12px; + font-size: var(--text-xs); font-weight: 500; cursor: pointer; transition: all var(--transition-fast); @@ -4519,7 +4615,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); } @@ -4527,14 +4623,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); } @@ -4546,7 +4642,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 { @@ -4560,7 +4656,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 { @@ -4599,7 +4695,7 @@ input[type="checkbox"]:focus-visible { align-items: center; justify-content: space-between; padding: 10px 12px; - gap: 16px; + gap: var(--space-4); } .skeleton-bar { @@ -4625,5 +4721,490 @@ input[type="checkbox"]:focus-visible { padding: 32px 16px; text-align: center; color: var(--text-muted); - font-size: 13px; + font-size: var(--text-sm); +} + +/* Tool card shimmer for running state */ +@keyframes borderShimmer { + 0%, 100% { border-left-color: rgba(52, 211, 153, 0.3); } + 50% { border-left-color: rgba(52, 211, 153, 0.7); } +} + +.tool-card.running { + animation: borderShimmer 2s ease-in-out infinite; + border-left: 2px solid var(--accent-soft); +} + +/* =================================================================== + UX Overhaul — Component Styles (Phases 3.1–3.10, 4.4–4.6) + =================================================================== */ + +/* --- Phase 3.1: Welcome card + empty state --- */ + +/* Welcome Card */ +.welcome-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + max-width: 560px; + margin: auto; +} + +.welcome-heading { + font-size: var(--text-xl); + font-weight: 600; + margin-bottom: var(--space-2); +} + +.welcome-description { + font-size: var(--text-base); + color: var(--text-secondary); + margin-bottom: var(--space-6); + line-height: 1.5; +} + +.welcome-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: center; +} + +.welcome-chip { + padding: 8px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 20px; + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.welcome-chip:hover { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent); +} + +/* Empty state for tabs */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + color: var(--text-secondary); +} + +.empty-state-icon { + font-size: 32px; + margin-bottom: var(--space-4); + opacity: 0.5; +} + +.empty-state-title { + font-size: var(--text-lg); + font-weight: 500; + margin-bottom: var(--space-2); +} + +.empty-state-hint { + font-size: var(--text-sm); + color: var(--text-muted); +} + +/* --- Phase 3.2: Message design polish --- */ + +/* --- Phase 3.3: Code block wrapper --- */ + +/* Code block wrapper with header */ +.code-block-wrapper { + margin: var(--space-3) 0; + border-radius: var(--radius); + overflow: hidden; + border: 1px solid var(--border); +} + +.code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); + font-size: var(--text-xs); +} + +.code-block-lang { + color: var(--text-muted); + font-family: var(--font-mono); + text-transform: lowercase; +} + +.code-block-copy { + padding: 2px 8px; + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + transition: all var(--transition-fast); +} + +.code-block-copy:hover { + background: var(--accent-soft); + color: var(--accent); +} + +.code-block-copy.copied { + color: var(--success); + border-color: var(--success); +} + +/* --- Phase 3.4: Tool card refinements --- */ + +/* Tool card progress */ +.tool-progress-bar { + height: 2px; + background: var(--bg-tertiary); + border-radius: 1px; + overflow: hidden; + margin-top: var(--space-2); +} + +.tool-progress-bar .progress-fill { + height: 100%; + background: var(--accent); + animation: indeterminate 1.5s ease-in-out infinite; +} + +@keyframes indeterminate { + 0% { transform: translateX(-100%); width: 40%; } + 50% { transform: translateX(60%); width: 40%; } + 100% { transform: translateX(200%); width: 40%; } +} + +.tool-icon-complete { + animation: iconPop 0.3s var(--ease-out-expo); +} + +@keyframes iconPop { + 0% { transform: scale(0); } + 70% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + +/* --- Phase 3.5: Streaming cursor --- */ + +/* Smooth streaming cursor */ +.streaming-cursor { + display: inline-block; + width: 2px; + height: 1em; + background: var(--accent); + margin-left: 2px; + vertical-align: text-bottom; + animation: cursorPulse 1.2s ease-in-out infinite; +} + +@keyframes cursorPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* Typing indicator */ +.typing-indicator { + display: flex; + gap: 4px; + padding: 8px 12px; + align-items: center; +} + +.typing-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-muted); + animation: typingBounce 1.4s ease-in-out infinite; +} + +.typing-dot:nth-child(2) { animation-delay: 0.2s; } +.typing-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typingBounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-4px); } +} + +/* --- Phase 3.6: Inline approval polish --- */ + +/* Refined approval card */ +.approval-card { + border: 1px solid rgba(245, 166, 35, 0.25); + border-left: 3px solid var(--warning); + border-radius: var(--radius); + padding: var(--space-4); + background: var(--warning-soft); + margin: var(--space-3) 0; +} + +.approval-card .keyboard-hint { + font-size: var(--text-xs); + color: var(--text-muted); + margin-top: var(--space-2); +} + +.approval-card.resolved { + opacity: 0.6; + transform: scale(0.98); + transition: all 0.3s ease; +} + +/* --- Phase 3.7: Connection indicator --- */ + +/* Connection banner */ +.connection-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 8px 16px; + text-align: center; + font-size: var(--text-sm); + font-weight: 500; + z-index: 1000; + transition: all 0.3s ease; +} + +.connection-banner.disconnected { + background: var(--warning-soft); + color: var(--warning); + border-bottom: 1px solid var(--warning); +} + +.connection-banner.reconnecting { + background: var(--warning-soft); + color: var(--warning); + animation: reconnectPulse 2s ease-in-out infinite; +} + +.connection-banner.connected { + background: rgba(52, 211, 153, 0.1); + color: var(--success); + animation: fadeOut 1s ease 1s forwards; +} + +@keyframes reconnectPulse { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 1; } +} + +@keyframes fadeOut { + to { opacity: 0; transform: translateY(-100%); } +} + +/* --- Phase 3.8: Thread sidebar --- */ + +.thread-item .thread-preview { + font-size: var(--text-xs); + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; +} + +.thread-item .processing-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + animation: cursorPulse 1.2s ease-in-out infinite; +} + +/* --- Phase 3.9: Input area --- */ + +/* Input area upgrades */ +.input-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 12px; + font-size: var(--text-xs); + color: var(--text-muted); +} + +.char-count { + font-family: var(--font-mono); +} + +.char-count.over-limit { + color: var(--warning); +} + +.send-btn.has-content { + box-shadow: 0 0 12px var(--accent-soft); +} + +/* --- Phase 3.10: Turn cost badge --- */ + +/* Turn cost display */ +.turn-cost-badge { + font-size: var(--text-xs); + color: var(--text-muted); + font-family: var(--font-mono); + padding: 2px 0; + margin-top: var(--space-1); +} + +/* --- Phase 4.4: Mobile optimization --- */ + +/* Mobile optimization */ +@media (max-width: 768px) { + .message-copy-btn { + opacity: 1; + pointer-events: auto; + } +} + +@media (max-width: 375px) { + .tab-bar button:not(.status-logs-btn):not(.restart-btn) { + padding: 8px 12px; + font-size: var(--text-sm); + } + + .message { + padding: 8px 12px; + } + + .welcome-card { + padding: 24px 16px; + } +} + +/* iOS zoom prevention */ +@media (max-width: 768px) { + textarea, input { + font-size: 16px !important; + } +} + +/* Touch targets */ +@media (pointer: coarse) { + .approval-card button, + .message-copy-btn { + min-height: 44px; + min-width: 44px; + } +} + +/* Safe area for notched devices */ +@supports (padding: env(safe-area-inset-bottom)) { + .input-area { + padding-bottom: env(safe-area-inset-bottom); + } +} + +/* --- Phase 4.5: Accessibility --- */ + +/* Keyboard navigation */ +.thread-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.approval-card button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* --- Phase 4.6: Skeleton loading states --- */ + +/* Skeleton loading */ +.skeleton-container { + padding: var(--space-4); +} + +.skeleton-thread { + padding: 12px 16px; + margin-bottom: var(--space-2); +} + +.skeleton-thread .skeleton-bar { + height: 14px; + border-radius: 4px; + background: var(--bg-tertiary); + margin-bottom: 6px; +} + +.skeleton-thread .skeleton-bar:last-child { + width: 60%; +} + +.skeleton-message { + padding: 12px 16px; + margin-bottom: var(--space-3); + display: flex; + gap: var(--space-3); +} + +.skeleton-message .skeleton-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bg-tertiary); + flex-shrink: 0; +} + +.skeleton-message .skeleton-lines { + flex: 1; +} + +.skeleton-message .skeleton-bar { + height: 12px; + border-radius: 4px; + background: var(--bg-tertiary); + margin-bottom: 8px; +} + +.skeleton-message .skeleton-bar:nth-child(2) { + width: 80%; +} + +.skeleton-message .skeleton-bar:nth-child(3) { + width: 45%; +} + +.skeleton-table-row { + padding: 12px 16px; + margin-bottom: var(--space-1); +} + +.skeleton-table-row .skeleton-bar { + height: 16px; + border-radius: 4px; + background: var(--bg-tertiary); +} + +@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 c8601fdd7..e4268ed7c 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: u32, + output_tokens: u32, + cost_usd: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + /// Extension activation status change (WASM channels). #[serde(rename = "extension_status")] ExtensionStatus { @@ -759,6 +769,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/cli/doctor.rs b/src/cli/doctor.rs index 7510635a6..5d13ade64 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 000000000..4bde3236d --- /dev/null +++ b/src/cli/fmt.rs @@ -0,0 +1,295 @@ +//! 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); + let fill = width.saturating_sub(label_part.len() + 2); + 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 54779ae19..5b1fbaba5 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; #[cfg(feature = "import")] pub mod import; mod logs; @@ -109,16 +110,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 6f953b5ed..3ae825eef 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/main.rs b/src/main.rs index 9c482e1b2..6cf2c6103 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<()> { @@ -144,6 +183,7 @@ async fn async_main() -> anyhow::Result<()> { channels_only, provider_only, quick, + step, }) => { #[cfg(any(feature = "postgres", feature = "libsql"))] { @@ -152,6 +192,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())?; @@ -159,7 +200,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(()); @@ -187,6 +228,8 @@ async fn async_main() -> anyhow::Result<()> { } }; + let startup_start = std::time::Instant::now(); + // ── Agent startup ────────────────────────────────────────────────── // Enhanced first-run detection @@ -645,6 +688,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 ac271cf2c..37f9970f2 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))?; + 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", prefix, checkbox, label)?; + 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 6935a6192..0554b7be8 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?; @@ -1107,30 +1231,80 @@ impl SetupWizard { print_info("Select your inference provider:"); println!(); - // Build menu: NearAI first, then all registry providers with setup hints, then Bedrock + // Build menu: detected providers first (with checkmark), then remaining providers let selectable = registry.selectable(); - let mut options: Vec = Vec::with_capacity(2 + selectable.len()); - let mut provider_ids: Vec = Vec::with_capacity(2 + selectable.len()); - options.push("NEAR AI - multi-model access via NEAR account".to_string()); - provider_ids.push("nearai".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(); + + // 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), + }); 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)?; @@ -1224,6 +1398,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) { @@ -2901,8 +3093,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 @@ -2918,116 +3113,106 @@ 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!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - - 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", - other => other, - }; - println!(" Provider: {}", display); - } + // Title line: checkmark + "ironclaw is ready" + println!( + " {}\u{2713}{} {}ironclaw is ready{}", + fmt::success(), + fmt::reset(), + fmt::bold_accent(), + fmt::reset(), + ); + println!(); - 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(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"); - } - - 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); - } - - 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); - } + String::new() + }; + let provider_value = format!("{}{}", provider_display, model_suffix); + println!( + " {}provider{} {}{}{}", + fmt::dim(), + fmt::reset(), + fmt::accent(), + provider_value, + fmt::reset(), + ); - for channel_name in &self.settings.channels.wasm_channels { - let mode = if has_tunnel { "webhook" } else { "polling" }; - println!( - " - {}: enabled ({})", - capitalize_first(channel_name), - mode - ); - } + // 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(), + ); - 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 { @@ -3372,6 +3557,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/builtin/memory.rs b/src/tools/builtin/memory.rs index 327e8c7ee..8490e6ee9 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -313,27 +313,8 @@ impl Tool for MemoryWriteTool { }; // Sync derived identity documents when the profile is written. - // Normalize the path to match Workspace::normalize_path(): trim, strip - // leading/trailing slashes, collapse all consecutive slashes. - let normalized_path = { - let trimmed = path.trim().trim_matches('/'); - let mut result = String::new(); - let mut last_was_slash = false; - for c in trimmed.chars() { - if c == '/' { - if !last_was_slash { - result.push(c); - } - last_was_slash = true; - } else { - result.push(c); - last_was_slash = false; - } - } - result - }; let mut synced_docs: Vec<&str> = Vec::new(); - if normalized_path == paths::PROFILE { + if path == paths::PROFILE { match self.workspace.sync_profile_documents().await { Ok(true) => { tracing::info!("profile write: synced USER.md + assistant-directives.md"); diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 02d81418a..9e6a2b680 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -504,6 +504,10 @@ impl Workspace { /// Adds a newline separator between existing and new content. 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 03c1aefe0..62e865882 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 d23bb672d..04f7468a8 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -665,7 +665,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())